diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2bfe11504..1fe285432 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `composer.shape` setting (`/settings` → Appearance → Composer) to customize the editor's visual layout, including live layout previews in settings and the setup wizard - Added `omp ps` for inspecting and controlling daemon-broker supervised processes from outside the harness: an interactive alt-screen monitor on TTYs (live table, info/logs views, stop/kill/restart, all-scopes toggle) plus static `--plain`/`--json` listings and `info`/`logs`/`stop`/`kill`/`restart` subactions with `--all`, `--dir`, and `--global` scope selectors. Brokers now record their project directory in `scope.json` so runtime scopes can be mapped back to projects offline. - Added `qwenTemplateReasoningEffort` to the `models.yml` `compat` schema, so the auto-enabled Qwen 3.8+ template effort dialect (`chat_template_kwargs.reasoning_effort`) can be switched off per provider/model for strict local servers that reject unknown `chat_template_kwargs`. - Added `tokenizer` to custom model and `modelOverrides` configuration. It overrides the catalog-resolved local tokenizer family for a model when a proxy serves a known model id with a different tokenizer. diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index cbb39d5a7..8f18d972a 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -81,6 +81,9 @@ import { export type ModelRoleStorage = "global" | "project"; +export type ComposerShape = "box" | "claude" | "pi" | "borderless"; +export const COMPOSER_SHAPE_VALUES = ["box", "claude", "pi", "borderless"] as const; + export type SettingTab = | "appearance" | "model" @@ -130,7 +133,7 @@ export const TAB_METADATA: Record = { - appearance: ["Theme", "Status Line", "Display", "Images"], + appearance: ["Theme", "Composer", "Status Line", "Display", "Images"], model: ["Thinking", "Sampling", "Prompt", "Retry & Fallback", "Advisor", "Prewalk", "Vision"], interaction: [ "Input", @@ -633,6 +636,40 @@ export const SETTINGS_SCHEMA = { description: "Use blue instead of green for diff additions", }, }, + // Composer + "composer.shape": { + type: "enum", + values: COMPOSER_SHAPE_VALUES, + default: "box", + ui: { + tab: "appearance", + group: "Composer", + label: "Composer Shape", + description: "Visual layout of the input editor and status line", + options: [ + { + value: "box", + label: "Rounded Box (Default)", + description: "Status line embedded in top border, compact 2-line prompt", + }, + { + value: "claude", + label: "Claude Code", + description: "Full-width horizontal rules above and below, status line at bottom", + }, + { + value: "pi", + label: "Pi", + description: "Framed rounded box with prompt glyph, status line at bottom", + }, + { + value: "borderless", + label: "Borderless", + description: "Clean prompt glyph with status line at bottom, no box borders", + }, + ], + }, + }, // Status line "statusLine.preset": { diff --git a/packages/coding-agent/src/modes/components/composer-shape-preview.ts b/packages/coding-agent/src/modes/components/composer-shape-preview.ts new file mode 100644 index 000000000..d9c27d29d --- /dev/null +++ b/packages/coding-agent/src/modes/components/composer-shape-preview.ts @@ -0,0 +1,97 @@ +import { type Component, padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import type { ComposerShape } from "../../config/settings-schema"; +import { theme } from "../theme/theme"; + +export interface ComposerShapePreviewOptions { + requestRender?: () => void; +} + +function fitLine(line: string, width: number): string { + const truncated = truncateToWidth(line, width); + return truncated + padding(Math.max(0, width - visibleWidth(truncated))); +} + +export function renderMockStatusLine(width: number): string { + const sep = theme.fg("statusLineSep", ` ${theme.sep.powerlineThin} `); + const leftContent = [ + theme.fg("statusLineModel", `${theme.icon.model} sonnet`), + theme.fg("statusLinePath", "~/project"), + theme.fg("statusLineGitDirty", `${theme.icon.git} main +2`), + ].join(sep); + const rightContent = [ + theme.fg("statusLineContext", `${theme.icon.context} 42%`), + theme.fg("statusLineCost", `${theme.icon.cost} 0.18`), + ].join(sep); + const bgAnsi = theme.getBgAnsi("statusLineBg"); + const isTransparent = bgAnsi === "\x1b[49m" || !bgAnsi; + const capAnsi = isTransparent ? "" : bgAnsi.replace("\x1b[48;", "\x1b[38;"); + const leftCap = isTransparent ? "" : `${capAnsi}${theme.sep.powerline}\x1b[39m`; + const rightCap = isTransparent ? "" : `${capAnsi}${theme.sep.powerlineLeft}\x1b[39m`; + const leftGroup = `${theme.bg("statusLineBg", ` ${leftContent} `)}${leftCap}`; + const rightGroup = `${rightCap}${theme.bg("statusLineBg", ` ${rightContent} `)}`; + const leftWidth = visibleWidth(leftGroup); + const rightWidth = visibleWidth(rightGroup); + const gapWidth = Math.max(1, width - leftWidth - rightWidth); + const usedCount = Math.round(0.42 * gapWidth); + const unusedCount = gapWidth - usedCount; + const usedFill = usedCount > 0 ? theme.fg("borderAccent", theme.boxRound.horizontal.repeat(usedCount)) : ""; + const unusedFill = unusedCount > 0 ? theme.fg("border", theme.boxRound.horizontal.repeat(unusedCount)) : ""; + const gap = `\x1b[49m${usedFill}${unusedFill}\x1b[39m`; + return `${leftGroup}${gap}${rightGroup}`; +} + +export function renderComposerShapePreview(shape: ComposerShape, width: number): readonly string[] { + const previewWidth = Math.max(24, Math.min(width, 76)); + const box = theme.boxRound; + const innerWidth = Math.max(1, previewWidth - 2); + const promptText = "Ask anything, edit files, run tools"; + + switch (shape) { + case "box": { + const statusContent = renderMockStatusLine(innerWidth); + const top = `${theme.fg("borderAccent", `${box.topLeft}${box.horizontal}`)} ${statusContent} ${theme.fg("borderAccent", `${box.horizontal}${box.topRight}`)}`; + const bottomInner = `${promptText} `; + const bottomFill = box.horizontal.repeat(Math.max(0, innerWidth - visibleWidth(bottomInner) - 2)); + const bottom = `${theme.fg("borderAccent", `${box.bottomLeft}${box.horizontal} `)}${theme.fg("text", promptText)}${theme.inverse(" ")}${theme.fg("borderAccent", ` ${bottomFill}${box.bottomRight}`)}`; + return [top, bottom]; + } + case "claude": { + const rule = theme.fg("borderAccent", box.horizontal.repeat(previewWidth)); + const prompt = `${theme.fg("accent", "❯")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`; + return [rule, prompt, rule, renderMockStatusLine(previewWidth)]; + } + case "pi": { + const horizontal = box.horizontal.repeat(innerWidth); + const top = theme.fg("borderAccent", `${box.topLeft}${horizontal}${box.topRight}`); + const prompt = `${theme.fg("accent", ">")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`; + const content = `${theme.fg("borderAccent", box.vertical)} ${fitLine(prompt, innerWidth - 2)} ${theme.fg("borderAccent", box.vertical)}`; + const bottom = theme.fg("borderAccent", `${box.bottomLeft}${horizontal}${box.bottomRight}`); + return [top, content, bottom, renderMockStatusLine(previewWidth)]; + } + case "borderless": { + const prompt = `${theme.fg("accent", "❯")} ${theme.fg("text", promptText)}${theme.inverse(" ")}`; + return [prompt, renderMockStatusLine(previewWidth)]; + } + } +} + +export class ComposerShapePreview implements Component { + #shape: ComposerShape; + #options: ComposerShapePreviewOptions; + + constructor(initialValue: ComposerShape = "box", options: ComposerShapePreviewOptions = {}) { + this.#shape = initialValue; + this.#options = options; + } + + setValue(shape: ComposerShape): void { + if (this.#shape === shape) return; + this.#shape = shape; + this.#options.requestRender?.(); + } + + render(width: number): readonly string[] { + const lines = renderComposerShapePreview(this.#shape, width); + return ["", theme.fg("muted", "Preview:"), ...lines]; + } +} diff --git a/packages/coding-agent/src/modes/components/settings-selector.ts b/packages/coding-agent/src/modes/components/settings-selector.ts index 8b6cdb046..b52212d95 100644 --- a/packages/coding-agent/src/modes/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/components/settings-selector.ts @@ -35,6 +35,7 @@ import { validateProviderMaxInFlightRequests, } from "../../config/settings"; import type { + ComposerShape, SettingTab, StatusLinePreset, StatusLineSegmentId, @@ -44,6 +45,7 @@ import { SETTING_TABS, TAB_METADATA } from "../../config/settings-schema"; import { getCurrentThemeName, getSelectListTheme, getSettingsListTheme, theme } from "../../modes/theme/theme"; import { AUTO_THINKING, type ConfiguredThinkingLevel } from "../../thinking"; import { getTabBarTheme } from "../shared"; +import { ComposerShapePreview } from "./composer-shape-preview"; import { bottomBorder, divider, row, topBorder } from "./overlay-box"; import { handleInputOrEscape, PluginSettingsComponent } from "./plugin-settings"; import { getSettingDef, getSettingsForTab, type SettingDef } from "./settings-defs"; @@ -1124,8 +1126,13 @@ export class SettingsSelectorComponent implements Component { }); onPreview = value => shapePreview.setValue(value); footer = shapePreview; + } else if (def.path === "composer.shape") { + const shapePreview = new ComposerShapePreview(currentValue as ComposerShape, { + requestRender: this.context.requestRender, + }); + onPreview = value => shapePreview.setValue(value as ComposerShape); + footer = shapePreview; } - // Provide status line preview for theme selection const isThemeSetting = def.path === "theme.dark" || def.path === "theme.light"; const getPreview = isThemeSetting ? this.callbacks.getStatusLinePreview : undefined; diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index dbf61a565..cd494e08c 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -291,6 +291,7 @@ function hasGitBackedSegment(segments: readonly StatusLineSegmentId[]): boolean // ═══════════════════════════════════════════════════════════════════════════ export class StatusLineComponent implements Component { + #standalone = false; #widthEpochRevision = 0; #settings: StatusLineSettings = {}; #effectiveSettings: EffectiveStatusLineSettings | undefined; @@ -1537,7 +1538,7 @@ export class StatusLineComponent implements Component { return { usedTokens: cache.usedTokens, contextWindow: cache.contextWindow }; } - const usage = this.session.getContextUsage(); + const usage = typeof this.session.getContextUsage === "function" ? this.session.getContextUsage() : undefined; const usedTokens = usage?.tokens ?? 0; const contextWindow = usage?.contextWindow ?? modelContextWindow; this.#contextUsageCache = { @@ -1559,7 +1560,6 @@ export class StatusLineComponent implements Component { width: number, segmentOptions: StatusLineSettings["segmentOptions"], includePath: boolean, - includeContext: boolean, includeGit: boolean, includePr: boolean, ): SegmentContext { @@ -1587,15 +1587,10 @@ export class StatusLineComponent implements Component { }; let contextWindow = state.model?.contextWindow ?? this.session.model?.contextWindow ?? 0; - let contextPercent: number | null = 0; - let contextTokens = 0; - if (includeContext) { - const breakdown = this.getCachedContextBreakdown(); - contextTokens = breakdown.usedTokens; - contextWindow = breakdown.contextWindow || contextWindow; - contextPercent = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null; - } - + const breakdown = this.getCachedContextBreakdown(); + let contextTokens = breakdown.usedTokens; + contextWindow = breakdown.contextWindow || contextWindow; + let contextPercent: number | null = contextWindow > 0 ? (breakdown.usedTokens / contextWindow) * 100 : null; // Collab guest: context comes from the host's state frames — the local // replica does no accounting of its own. const collabState = this.#collabStatus?.stateOverride; @@ -1717,8 +1712,6 @@ export class StatusLineComponent implements Component { const effectiveSettings = this.#resolveSettings(); const includePath = hasPathSegment(effectiveSettings.leftSegments) || hasPathSegment(effectiveSettings.rightSegments); - const includeContext = - hasContextSegment(effectiveSettings.leftSegments) || hasContextSegment(effectiveSettings.rightSegments); const gitEnabled = this.#gitEnabled(); const includeGit = gitEnabled && @@ -1729,7 +1722,6 @@ export class StatusLineComponent implements Component { width, effectiveSettings.segmentOptions, includePath, - includeContext, includeGit, includePr, ); @@ -1891,8 +1883,17 @@ export class StatusLineComponent implements Component { const accentHex = sessionName ? getSessionAccentHex(sessionName, theme.getMajorThemeColorHexes(), theme.accentSurfaceLuminance) : undefined; - const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("border"); - const gapFill = `${gapColor}${theme.boxRound.horizontal.repeat(gapWidth)}\x1b[39m`; + const gapColor = getSessionAccentAnsi(accentHex) ?? theme.getFgAnsi("borderAccent"); + const unusedColor = theme.getFgAnsi("border"); + let usedCount = gapWidth; + if (ctx.contextPercent !== null && ctx.contextPercent !== undefined) { + const clampedPct = Math.min(100, Math.max(0, ctx.contextPercent)); + usedCount = Math.min(gapWidth, Math.max(0, Math.round((clampedPct / 100) * gapWidth))); + } + const unusedCount = gapWidth - usedCount; + const usedFill = usedCount > 0 ? `${gapColor}${theme.boxRound.horizontal.repeat(usedCount)}` : ""; + const unusedFill = unusedCount > 0 ? `${unusedColor}${theme.boxRound.horizontal.repeat(unusedCount)}` : ""; + const gapFill = `\x1b[49m${usedFill}${unusedFill}\x1b[39m`; return leftGroup + gapFill + rightGroup; } @@ -1909,16 +1910,28 @@ export class StatusLineComponent implements Component { revision: this.#widthEpochRevision, }; } + setStandalone(standalone: boolean): void { + this.#standalone = standalone; + } render(width: number): readonly string[] { - // Only render hook statuses - main status is in editor's top border - const showHooks = this.#settings.showHookStatus ?? true; - if (!showHooks || this.#hookStatuses.size === 0) { - return []; + const lines: string[] = []; + if (this.#standalone) { + let content = this.#buildStatusLine(width); + if (content) { + if (this.#focusedAgentId) { + content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`; + } + lines.push(content); + } } - - return Array.from(this.#hookStatuses.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, text]) => truncateToWidth(sanitizeStatusText(text), width)); + const showHooks = this.#settings.showHookStatus ?? true; + if (showHooks && this.#hookStatuses.size > 0) { + const hookLines = Array.from(this.#hookStatuses.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => truncateToWidth(sanitizeStatusText(text), width)); + lines.push(...hookLines); + } + return lines; } } diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index a01117113..c35ca5f03 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -436,7 +436,7 @@ const costSegment: StatusLineSegment = { const advisorCost = ctx.session.getAdvisorCost?.() ?? 0; const normalizedPremiumRequests = normalizePremiumRequests(premiumRequests); const state = ctx.session.state; - const usingSubscription = state.model ? ctx.session.modelRegistry.isUsingOAuth(state.model) : false; + const usingSubscription = state.model ? (ctx.session.modelRegistry?.isUsingOAuth(state.model) ?? false) : false; if (!cost && !advisorCost && !usingSubscription && !normalizedPremiumRequests) { return { content: "", visible: false }; diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 61f00c31c..21c1f77c7 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -443,6 +443,9 @@ export class SelectorController { this.ctx.session.setAutoCompactionEnabled(value as boolean); this.ctx.statusLine.setAutoCompactEnabled(value as boolean); break; + case "composer.shape": + this.ctx.syncComposerShape(); + break; case "advisor.enabled": this.ctx.session.setAdvisorEnabled(value as boolean); this.ctx.statusLine.invalidate(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 37ff1ce89..63c86d2b4 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -67,6 +67,7 @@ import { Settings, settings, } from "../config/settings"; +import type { ComposerShape } from "../config/settings-schema"; import { clearClaudePluginRootsCache } from "../discovery/helpers"; import type { AutocompleteProviderFactory, @@ -1105,11 +1106,12 @@ export class InteractiveMode implements InteractiveModeContext { // HUDs, just above the editor's hook-widget top margin — so it reads next to // the prompt while keeping the one-line gap above the editor. this.ui.addChild(this.statusContainer); - this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border) this.ui.addChild(this.hookWidgetContainerAbove); this.ui.addChild(this.editorContainer); this.ui.addChild(this.hookWidgetContainerBelow); + this.ui.addChild(this.statusLine); this.ui.setFocus(this.editor); + this.syncComposerShape(); this.#inputController.setupKeyHandlers(); this.#inputController.setupEditorSubmitHandler(); @@ -1920,6 +1922,20 @@ export class InteractiveMode implements InteractiveModeContext { compactThinkingLevel: settings.get("statusLine.compactThinkingLevel"), }); } + syncComposerShape(): void { + const shape = (settings.get("composer.shape") as ComposerShape) ?? "box"; + this.editor.setBorderStyle(shape); + if (shape === "box") { + this.editor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth)); + this.statusLine.setStandalone(false); + } else { + this.editor.setTopBorderProvider(undefined); + this.editor.setTopBorder(undefined); + this.statusLine.setStandalone(true); + } + this.updateEditorBorderColor(); + this.ui.requestRender(); + } #handleSessionAccentInputsChanged(): void { this.#clearWorkingMessageAccentCache(); @@ -4470,7 +4486,8 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); }; nextEditor.setShimmerRepaintHandler(() => this.ui.requestDirectWrite(nextEditor)); - nextEditor.setTopBorderProvider(availableWidth => this.statusLine.getTopBorder(availableWidth)); + this.editor = nextEditor; + this.syncComposerShape(); nextEditor.setMaxHeight(this.#computeEditorMaxHeight()); if (this.historyStorage) { nextEditor.setHistoryStorage(this.historyStorage); diff --git a/packages/coding-agent/src/modes/setup-version.ts b/packages/coding-agent/src/modes/setup-version.ts index 33bed5051..7ffd454fa 100644 --- a/packages/coding-agent/src/modes/setup-version.ts +++ b/packages/coding-agent/src/modes/setup-version.ts @@ -8,4 +8,4 @@ * the overlay component and their TUI deps. MUST equal `max(scene.minVersion)` * across `ALL_SCENES`; the `setup-wizard` barrel and test suite guard it. */ -export const CURRENT_SETUP_VERSION = 1; +export const CURRENT_SETUP_VERSION = 2; diff --git a/packages/coding-agent/src/modes/setup-wizard/index.ts b/packages/coding-agent/src/modes/setup-wizard/index.ts index 0499bb8d4..a00d95443 100644 --- a/packages/coding-agent/src/modes/setup-wizard/index.ts +++ b/packages/coding-agent/src/modes/setup-wizard/index.ts @@ -1,6 +1,7 @@ import type { Settings } from "../../config/settings"; import { CURRENT_SETUP_VERSION } from "../setup-version"; import type { InteractiveModeContext } from "../types"; +import { composerSetupScene } from "./scenes/composer"; import { glyphSetupScene } from "./scenes/glyph"; import { modelSetupScene } from "./scenes/model"; import { providersSetupScene } from "./scenes/providers"; @@ -17,6 +18,7 @@ export const ALL_SCENES = [ providersSetupScene, modelSetupScene, glyphSetupScene, + composerSetupScene, themeSetupScene, ] as const satisfies readonly SetupScene[]; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts new file mode 100644 index 000000000..94ab37aef --- /dev/null +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/composer.ts @@ -0,0 +1,114 @@ +import { routeSelectListMouse, type SelectItem, SelectList, type SgrMouseEvent } from "@oh-my-pi/pi-tui"; +import type { ComposerShape } from "../../../config/settings-schema"; +import { renderComposerShapePreview } from "../../components/composer-shape-preview"; +import { getSelectListTheme, theme } from "../../theme/theme"; +import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types"; + +const COMPOSER_SHAPES: readonly ComposerShape[] = ["box", "claude", "pi", "borderless"]; + +const COMPOSER_LABELS: Readonly> = { + box: "Rounded Box (Default)", + claude: "Claude Code", + pi: "Pi", + borderless: "Borderless", +}; + +const COMPOSER_DESCRIPTIONS: Readonly> = { + box: "Status line integrated in top border, compact 2-line prompt", + claude: "Full-width horizontal rules above and below, status line at bottom", + pi: "Framed rounded box with prompt glyph, status line at bottom", + borderless: "Clean prompt glyph with status line at bottom, no box borders", +}; + +const COMPOSER_ITEMS: readonly SelectItem[] = COMPOSER_SHAPES.map((shape, index) => ({ + value: shape, + label: `${index + 1} ${COMPOSER_LABELS[shape]}`, + description: COMPOSER_DESCRIPTIONS[shape], +})); + +class ComposerSceneController implements SetupSceneController { + title = "Choose composer shape"; + subtitle = "Pick the prompt and status line layout for your workflow."; + #selectList: SelectList; + #currentShape: ComposerShape = "box"; + #committing = false; + #listRowStart = 0; + + constructor(private readonly host: SetupSceneHost) { + const configuredShape = host.ctx.settings.get("composer.shape") as ComposerShape; + const initialShape = COMPOSER_SHAPES.includes(configuredShape) ? configuredShape : "box"; + this.#currentShape = initialShape; + const initialIndex = Math.max(0, COMPOSER_SHAPES.indexOf(initialShape)); + + const selectListTheme = getSelectListTheme(); + this.#selectList = new SelectList(COMPOSER_ITEMS, COMPOSER_ITEMS.length, selectListTheme); + this.#selectList.setSelectedIndex(initialIndex); + this.#selectList.onSelectionChange = item => { + this.#preview(item.value as ComposerShape); + }; + this.#selectList.onSelect = item => { + void this.#commit(item.value as ComposerShape); + }; + this.#selectList.onCancel = () => { + // Esc skips the scene without saving; the configured shape stays untouched. + this.host.finish("skipped"); + }; + } + + invalidate(): void { + this.#selectList.invalidate(); + } + + handleInput(data: string): void { + if (this.#committing) return; + const quickIndex = data >= "1" && data <= "4" ? Number(data) - 1 : -1; + if (quickIndex >= 0 && quickIndex < COMPOSER_ITEMS.length) { + this.#selectList.setSelectedIndex(quickIndex); + this.#preview(COMPOSER_SHAPES[quickIndex] ?? "box"); + return; + } + this.#selectList.handleInput(data); + } + + routeMouse(event: SgrMouseEvent, line: number, _col: number): void { + const listLine = line - this.#listRowStart; + routeSelectListMouse(this.#selectList, event, listLine); + } + + render(width: number, maxLines?: number): readonly string[] { + const budget = maxLines ?? Number.POSITIVE_INFINITY; + const lines = [theme.fg("muted", "Select a layout; live preview updates below. Press Enter to confirm."), ""]; + + const previewLines = renderComposerShapePreview(this.#currentShape, width); + if (budget - lines.length - previewLines.length - 2 >= COMPOSER_ITEMS.length) { + lines.push(theme.fg("muted", "Preview:"), ...previewLines, ""); + } + + this.#listRowStart = lines.length; + lines.push(...this.#selectList.render(width)); + return lines; + } + + async #commit(shape: ComposerShape): Promise { + if (this.#committing) return; + this.#committing = true; + try { + this.host.ctx.settings.set("composer.shape", shape); + await this.host.ctx.settings.flush(); + } finally { + this.host.finish("done"); + } + } + + #preview(shape: ComposerShape): void { + this.#currentShape = shape; + this.host.requestRender(); + } +} + +export const composerSetupScene: SetupScene = { + id: "composer-shape", + title: "Choose composer shape", + minVersion: 2, + mount: host => new ComposerSceneController(host), +}; diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 904549d14..04d4322d8 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -121,6 +121,7 @@ export interface InteractiveModeContext { hookWidgetContainerAbove: Container; hookWidgetContainerBelow: Container; statusLine: StatusLineComponent; + syncComposerShape(): void; // Session access session: AgentSession; diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index 753fc946b..3c1969723 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -105,6 +105,8 @@ function createContext(): { const hasActiveBtw = vi.fn(() => false); const handleOmfgEscape = vi.fn(() => true); const hasActiveOmfg = vi.fn(() => false); + const handleCleanseEscape = vi.fn(() => true); + const hasActiveCleanse = vi.fn(() => false); const updatePendingMessagesDisplay = vi.fn(); const prompt = vi.fn(); const startPendingSubmission = vi.fn( @@ -214,6 +216,8 @@ function createContext(): { hasActiveBtw, handleOmfgEscape, hasActiveOmfg, + handleCleanseEscape, + hasActiveCleanse, showTreeSelector: vi.fn(), showUserMessageSelector: vi.fn(), showSessionSelector: vi.fn(), diff --git a/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts b/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts new file mode 100644 index 000000000..57df8ae22 --- /dev/null +++ b/packages/coding-agent/test/modes/components/composer-shape-preview.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { COMPOSER_SHAPE_VALUES, type ComposerShape } from "@oh-my-pi/pi-coding-agent/config/settings-schema"; +import { + ComposerShapePreview, + renderComposerShapePreview, + renderMockStatusLine, +} from "@oh-my-pi/pi-coding-agent/modes/components/composer-shape-preview"; +import { SettingsSelectorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/settings-selector"; +import { initTheme, setTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; + +beforeAll(async () => { + await initTheme(); +}); + +describe("composer shape preview", () => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + }); + + afterEach(() => { + resetSettingsForTest(); + }); + + const shapes: ComposerShape[] = [...COMPOSER_SHAPE_VALUES]; + + it.each(shapes)("renders %s shape preview without throwing in dark theme", async (shape: ComposerShape) => { + await setTheme("dark"); + const lines = renderComposerShapePreview(shape, 80); + expect(lines.length).toBeGreaterThan(0); + const joined = lines.join("\n"); + expect(joined).toContain("Ask anything"); + }); + + it.each(shapes)("renders %s shape preview without throwing in light theme", async (shape: ComposerShape) => { + await setTheme("light"); + const lines = renderComposerShapePreview(shape, 80); + expect(lines.length).toBeGreaterThan(0); + const joined = lines.join("\n"); + expect(joined).toContain("Ask anything"); + }); + + it("updates preview when setValue is called on ComposerShapePreview component", async () => { + await setTheme("dark"); + let renderRequested = false; + const preview = new ComposerShapePreview("box", { + requestRender: () => { + renderRequested = true; + }, + }); + const initialLines = preview.render(80); + expect(initialLines.some(l => l.includes("Preview:"))).toBe(true); + + preview.setValue("claude"); + expect(renderRequested).toBe(true); + const nextLines = preview.render(80); + expect(nextLines.some(l => l.includes("Preview:"))).toBe(true); + }); + + it("renders mock status line with powerline separator and status elements", async () => { + await setTheme("dark"); + const rendered = renderMockStatusLine(80); + expect(rendered).toContain("sonnet"); + expect(rendered).toContain("~/project"); + expect(rendered).toContain("42%"); + }); + + it("renders preview inside SettingsSelectorComponent submenu without crashing", async () => { + await setTheme("dark"); + const selector = new SettingsSelectorComponent( + { + availableThinkingLevels: [], + thinkingLevel: undefined, + availableThemes: ["dark", "light"], + providers: [], + cwd: process.cwd(), + }, + { + onChange: () => {}, + onCancel: () => {}, + }, + ); + + for (const ch of "composer shape") selector.handleInput(ch); + // Open the composer.shape submenu + selector.handleInput("\n"); + + const rendered = selector.render(80).join("\n"); + expect(rendered).toContain("Composer Shape"); + expect(rendered).toContain("Preview:"); + expect(rendered).toContain("Ask anything"); + + // Cycle down to claude + selector.handleInput("\x1b[B"); + const nextRendered = selector.render(80).join("\n"); + expect(nextRendered).toContain("Claude Code"); + expect(nextRendered).toContain("Preview:"); + }); +}); diff --git a/packages/coding-agent/test/modes/controllers/omfg-controller.test.ts b/packages/coding-agent/test/modes/controllers/omfg-controller.test.ts index 6f42f16df..a77eada22 100644 --- a/packages/coding-agent/test/modes/controllers/omfg-controller.test.ts +++ b/packages/coding-agent/test/modes/controllers/omfg-controller.test.ts @@ -194,7 +194,7 @@ describe("OmfgController", () => { [PROJECT_OPTION, GLOBAL_OPTION, AMEND_OPTION], ]); expect(harness.ttsrAddRule.mock.calls[0]?.[0].path).toBe(savedPath); - const rendered = Bun.stripANSI(harness.container.render(120).join("\n")); + const rendered = Bun.stripANSI(harness.container.render(200).join("\n")); expect(rendered).toContain("Registered live"); expect(rendered).toContain(path.join(".omp", "rules", "ts-no-any.md")); expect(rendered).toContain("Esc dismiss"); diff --git a/packages/coding-agent/test/setup-wizard.test.ts b/packages/coding-agent/test/setup-wizard.test.ts index e1d7e81b6..b3b177b13 100644 --- a/packages/coding-agent/test/setup-wizard.test.ts +++ b/packages/coding-agent/test/setup-wizard.test.ts @@ -390,7 +390,7 @@ describe("setup wizard short terminals", () => { component.handleInput(key); const frame = component.render(80).map(line => Bun.stripANSI(line)); expect(frame.length).toBe(24); - expect(frame.some(line => line.trimStart().startsWith(theme.nav.cursor))).toBe(true); + expect(frame.some(line => line.includes(`${theme.nav.cursor} `))).toBe(true); } } finally { nowSpy.mockRestore(); diff --git a/packages/coding-agent/test/status-line-context-cache.test.ts b/packages/coding-agent/test/status-line-context-cache.test.ts index c465653c4..a4e6b9f28 100644 --- a/packages/coding-agent/test/status-line-context-cache.test.ts +++ b/packages/coding-agent/test/status-line-context-cache.test.ts @@ -51,6 +51,7 @@ function makeSession(opts: { messages: unknown[]; contextWindow?: number; usage? agent: { state: { tools: [] } }, skills: [], model: { id: "test-model", contextWindow }, + modelRegistry: { isUsingOAuth: () => false }, state: { messages: opts.messages, model: { contextWindow } }, sessionManager: { getUsageStatistics: () => ({ @@ -68,6 +69,7 @@ function makeSession(opts: { messages: unknown[]; contextWindow?: number; usage? getSessionName: () => "test", }, getAsyncJobSnapshot: () => ({ running: [] }), + isFastModeActive: () => false, getContextUsage: () => { calls++; return usage; @@ -203,7 +205,7 @@ describe("StatusLineComponent context breakdown", () => { expect(breakdown.contextWindow).toBe(128_000); }); - it("does not query usage when no context segment is rendered", () => { + it("memoizes usage queries so repeated renders query only once", () => { const { session, usageCalls } = makeSession({ messages: [userMessage("hi")] }); const comp = new StatusLineComponent(session); comp.updateSettings({ @@ -213,9 +215,11 @@ describe("StatusLineComponent context breakdown", () => { separator: "powerline-thin", }); - const border = comp.getTopBorder(80); - expect(border.content.length).toBeGreaterThan(0); - expect(usageCalls()).toBe(0); + const border1 = comp.getTopBorder(80); + const border2 = comp.getTopBorder(80); + expect(border1.content.length).toBeGreaterThan(0); + expect(border2.content.length).toBeGreaterThan(0); + expect(usageCalls()).toBe(1); }); it("renders the anchored percent against the (sub-)budget window in the context segment", () => { @@ -271,4 +275,38 @@ describe("StatusLineComponent context breakdown", () => { expect(plain).toContain("5K/?"); expect(plain).not.toContain("0.0%/0"); }); + + it("dims the unused portion of the gap fill between left and right segments based on context usage", () => { + const { session } = makeSession({ + messages: [userMessage("hi"), assistantMessage("done")], + usage: { tokens: 50_000, contextWindow: 100_000, percent: 50 }, + }); + const comp = new StatusLineComponent(session); + comp.updateSettings({ + preset: "custom", + leftSegments: ["pi"], + rightSegments: ["session_name"], + separator: "none", + sessionAccent: false, + }); + + const border = comp.getTopBorder(80).content; + // With 50% context usage, the gap fill contains a faint sequence \x1b[2m for the right half + expect(border).toContain("\x1b[2m"); + expect(border).toContain("\x1b[22m"); + }); + + it("renders standalone status line when setStandalone is true", () => { + const { session } = makeSession({ + messages: [userMessage("hi")], + usage: { tokens: 1000, contextWindow: 100_000, percent: 1 }, + }); + const comp = new StatusLineComponent(session); + expect(comp.render(80)).toHaveLength(0); // Not standalone -> no main status in render() + + comp.setStandalone(true); + const lines = comp.render(80); + expect(lines).toHaveLength(1); // Standalone -> renders main status line + expect(lines[0]).toContain("pi"); + }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index e48cce402..371c04d1e 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added support for customizable editor border styles including box, claude, pi, and borderless - Added support for warning risk notes and row markers in settings lists ## [17.3.8] - 2026-08-19 diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 069d6fbf1..9fe84cad1 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -385,6 +385,7 @@ export interface EditorTheme { /** Style function for inline hint/ghost text (dim text after cursor) */ hintStyle?: (text: string) => string; } +export type EditorBorderStyle = "box" | "claude" | "pi" | "borderless"; export interface EditorTopBorder { /** The status content (already styled) */ @@ -523,7 +524,7 @@ export class Editor implements Component, Focusable { #topBorderProviderSignature: string | undefined; #topBorderProviderRevision: number | undefined; #borderVisible = true; - + #borderStyle: EditorBorderStyle = "box"; constructor(theme: EditorTheme) { this.#theme = theme; this.borderColor = theme.borderColor; @@ -581,6 +582,15 @@ export class Editor implements Component, Focusable { setPromptGutter(promptGutter: string | undefined): void { this.#promptGutter = promptGutter; } + getBorderStyle(): EditorBorderStyle { + return this.#borderStyle; + } + + setBorderStyle(style: EditorBorderStyle): void { + if (this.#borderStyle === style) return; + this.#borderStyle = style; + this.#widthEpochRevision++; + } /** * Get the available width for top border content given a total terminal width. @@ -724,31 +734,48 @@ export class Editor implements Component, Focusable { // No cached state to invalidate currently } + #isBorderVisible(): boolean { + return this.#borderVisible && this.#borderStyle !== "borderless"; + } + + #getEffectivePromptGutter(): string | undefined { + if (this.#promptGutter !== undefined) return this.#promptGutter; + if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return "❯ "; + if (this.#borderStyle === "pi") return "> "; + return undefined; + } + #getEditorPaddingX(): number { - const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2; + if (this.#paddingXOverride !== undefined) return Math.max(0, this.#paddingXOverride); + if (this.#borderStyle === "claude" || this.#borderStyle === "borderless") return 0; + if (this.#borderStyle === "pi") return 1; + const padding = this.#theme.editorPaddingX ?? 2; return Math.max(0, padding); } #getHorizontalChromeWidth(paddingX: number): number { - return this.#borderVisible ? paddingX + 1 : 0; + if (!this.#isBorderVisible() || this.#borderStyle === "claude") return 0; + return paddingX + 1; } #getPromptGutterWidth(width: number, paddingX: number): number { - if (this.#borderVisible || !this.#promptGutter) return 0; + const gutter = this.#getEffectivePromptGutter(); + if (!gutter) return 0; const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX); const availableWidth = Math.max(0, width - chromeWidth); - return Math.min(visibleWidth(this.#promptGutter), availableWidth); + return Math.min(visibleWidth(gutter), availableWidth); } #getPromptGutter( width: number, paddingX: number, ): { firstLine: string; continuation: string; width: number } | undefined { - if (this.#borderVisible || !this.#promptGutter) return undefined; + const gutter = this.#getEffectivePromptGutter(); + if (!gutter) return undefined; const gutterWidth = this.#getPromptGutterWidth(width, paddingX); if (gutterWidth === 0) return undefined; return { - firstLine: sliceByColumn(this.#promptGutter, 0, gutterWidth, true), + firstLine: sliceByColumn(gutter, 0, gutterWidth, true), continuation: padding(gutterWidth), width: gutterWidth, }; @@ -761,17 +788,17 @@ export class Editor implements Component, Focusable { #getLayoutWidth(width: number, paddingX: number): number { const contentWidth = this.#getContentWidth(width, paddingX); - const cursorReserve = this.#borderVisible && paddingX === 0 ? 1 : 0; + const isBox = this.#isBorderVisible() && this.#borderStyle !== "claude"; + const cursorReserve = isBox && paddingX === 0 ? 1 : 0; // Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column. return Math.max(1, contentWidth - cursorReserve); } #getVisibleContentHeight(contentLines: number): number { if (this.#maxHeight === undefined) return contentLines; - const verticalChrome = this.#borderVisible ? 2 : 0; + const verticalChrome = !this.#isBorderVisible() ? 0 : 2; return Math.max(1, this.#maxHeight - verticalChrome); } - /** Apply the optional input decorator to a plain (ANSI-free) text segment. * Decoration only adds zero-width SGR codes, so visible width is unchanged. * Splits around CURSOR_MARKER so each user-text segment is decorated in @@ -883,7 +910,9 @@ export class Editor implements Component, Focusable { render(width: number): readonly string[] { const paddingX = this.#getEditorPaddingX(); - const borderVisible = this.#borderVisible; + const borderVisible = this.#isBorderVisible(); + const borderStyle = this.#borderStyle; + const isSideBordered = borderVisible && borderStyle !== "claude"; const promptGutter = this.#getPromptGutter(width, paddingX); const contentAreaWidth = this.#getContentWidth(width, paddingX); const layoutWidth = this.#getLayoutWidth(width, paddingX); @@ -922,47 +951,48 @@ export class Editor implements Component, Focusable { } if (borderVisible) { - // Render top border: ╭─ [status content] ────────────────╮ - const topFillWidth = Math.max(0, width - borderWidth * 2); - // Provider (lazy) wins over eager content — a host that installs both - // wants the coalesced path; falling back to eager keeps existing - // setTopBorder callers working unchanged. - let topBorder: EditorTopBorder | undefined; - if (this.#topBorderProvider) { - const previousWidth = this.#topBorderProviderWidth; - topBorder = this.#topBorderProvider(topFillWidth); - const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : ""; - const revision = topBorder?.revision; - if ( - (previousWidth !== undefined && - revision !== undefined && - this.#topBorderProviderRevision !== undefined && - revision !== this.#topBorderProviderRevision) || - (previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature) - ) { - this.#widthEpochRevision++; - } - this.#topBorderProviderWidth = topFillWidth; - this.#topBorderProviderSignature = signature; - this.#topBorderProviderRevision = revision; - } else { - topBorder = this.#topBorderContent; - } - if (topBorder) { - const { content, width: statusWidth } = topBorder; - if (statusWidth <= topFillWidth) { - // Status fits - add fill after it - const fillWidth = topFillWidth - statusWidth; - result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); - } else { - // Status too long - truncate it - const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1)); - const truncatedWidth = visibleWidth(truncated); - const fillWidth = Math.max(0, topFillWidth - truncatedWidth); - result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); - } - } else { + if (borderStyle === "claude") { + result.push(this.borderColor(box.horizontal.repeat(width))); + } else if (borderStyle === "pi") { + const topFillWidth = Math.max(0, width - borderWidth * 2); result.push(topLeft + horizontal.repeat(topFillWidth) + topRight); + } else { + const topFillWidth = Math.max(0, width - borderWidth * 2); + let topBorder: EditorTopBorder | undefined; + if (this.#topBorderProvider) { + const previousWidth = this.#topBorderProviderWidth; + topBorder = this.#topBorderProvider(topFillWidth); + const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : ""; + const revision = topBorder?.revision; + if ( + (previousWidth !== undefined && + revision !== undefined && + this.#topBorderProviderRevision !== undefined && + revision !== this.#topBorderProviderRevision) || + (previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature) + ) { + this.#widthEpochRevision++; + } + this.#topBorderProviderWidth = topFillWidth; + this.#topBorderProviderSignature = signature; + this.#topBorderProviderRevision = revision; + } else { + topBorder = this.#topBorderContent; + } + if (topBorder) { + const { content, width: statusWidth } = topBorder; + if (statusWidth <= topFillWidth) { + const fillWidth = topFillWidth - statusWidth; + result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); + } else { + const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1)); + const truncatedWidth = visibleWidth(truncated); + const fillWidth = Math.max(0, topFillWidth - truncatedWidth); + result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); + } + } else { + result.push(topLeft + horizontal.repeat(topFillWidth) + topRight); + } } } @@ -991,12 +1021,12 @@ export class Editor implements Component, Focusable { const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined; const marker = emitCursorMarker ? CURSOR_MARKER : ""; - if (!borderVisible && displayWidth > lineContentWidth) { + if (!isSideBordered && displayWidth > lineContentWidth) { displayText = sliceByColumn(displayText, 0, lineContentWidth, true); displayWidth = visibleWidth(displayText); } - if (!borderVisible && lineContentWidth === 0) { + if (!isSideBordered && lineContentWidth === 0) { if (hasCursor && !this.#useTerminalCursor) { const zeroWidthCursorBudget = visibleWidth(gutterText); const zeroWidthCursorReplacement = this.cursorOverride @@ -1039,7 +1069,7 @@ export class Editor implements Component, Focusable { if (marker) { const before = displayText.slice(0, layoutLine.cursorPos); const after = displayText.slice(layoutLine.cursorPos); - if (this.#imeSafeCursorLayout && after.length === 0 && borderVisible) { + if (this.#imeSafeCursorLayout && after.length === 0 && isSideBordered) { // Terminal frontends render IME marked text locally before committed bytes // reach the application. Keep the end-of-input cursor row empty to its // right so that insertion cannot shift box chrome onto the next row. @@ -1050,7 +1080,7 @@ export class Editor implements Component, Focusable { const hintText = hintStyle(truncateToWidth(inlineHint, availWidth)); displayText = before + marker + hintText; displayWidth += Math.min(visibleWidth(inlineHint), availWidth); - } else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) { + } else if (after.length === 0 && !isSideBordered && displayWidth >= lineContentWidth) { displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth); } else { displayText = before + marker + after; @@ -1076,7 +1106,7 @@ export class Editor implements Component, Focusable { } else if (this.cursorOverride) { // Cursor override replaces the normal end-of-text cursor glyph const overrideWidth = this.cursorOverrideWidth ?? 1; - if (!borderVisible && displayWidth + overrideWidth > lineContentWidth) { + if (!isSideBordered && displayWidth + overrideWidth > lineContentWidth) { // Borderless editors have no spare padding cell for an end-of-line cursor glyph. // Preserve cursorOverride by replacing the tail of the line with it. const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth, { @@ -1097,7 +1127,7 @@ export class Editor implements Component, Focusable { } else { // Cursor is at the end - add thin cursor glyph const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor(); - if (!borderVisible && displayWidth + cursorWidth > lineContentWidth) { + if (!isSideBordered && displayWidth + cursorWidth > lineContentWidth) { // Borderless editors have no spare padding cell for an end-of-line cursor glyph. // Highlight the last grapheme so the cursor stays visible without consuming width. const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth); @@ -1136,17 +1166,26 @@ export class Editor implements Component, Focusable { const linePad = padding(Math.max(0, lineContentWidth - displayWidth)); - if (!borderVisible) { + if (!isSideBordered) { result.push(gutterText + displayText + linePad); continue; } + const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow); + if (borderStyle === "pi") { + const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`); + const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end; + const rightGlyph = inThumb ? "█" : box.vertical; + const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`); + result.push(leftBorder + gutterText + displayText + linePad + rightBorder); + continue; + } + // All lines have consistent borders based on padding. When the end-of-line cursor // glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the // right chrome by the exact overflow count: drop padding spaces first, then the // trailing `─`, but never the corner/vertical bar itself. const isLastLine = visibleIndex === visibleLayoutLines.length - 1; - const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow); if (isLastLine && imeSafeCursorTail) { const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`); const bottomBorder = this.borderColor( @@ -1174,6 +1213,16 @@ export class Editor implements Component, Focusable { } } + if (borderVisible) { + if (borderStyle === "claude") { + result.push(this.borderColor(box.horizontal.repeat(width))); + } else if (borderStyle === "pi") { + result.push( + this.borderColor(`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`), + ); + } + } + // Add autocomplete list if active if (this.#autocompleteState && this.#autocompleteList) { const autocompleteResult = this.#autocompleteList.render(width); diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 437981b81..eacd45fd7 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -3,9 +3,8 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { stripVTControlCharacters } from "node:util"; -import { CURSOR_MARKER, TUI } from "@oh-my-pi/pi-tui"; +import { CURSOR_MARKER, Editor, type EditorTheme, TUI } from "@oh-my-pi/pi-tui"; import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete"; -import { Editor } from "@oh-my-pi/pi-tui/components/editor"; import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "@oh-my-pi/pi-tui/keybindings"; import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys"; import { visibleWidth } from "@oh-my-pi/pi-tui/utils"; @@ -2902,4 +2901,92 @@ describe("Editor component", () => { expect(editor.getText()).toBe("single line"); }); }); + + describe("composer border styles", () => { + const unicodeTheme: EditorTheme = { + ...defaultEditorTheme, + borderColor: (t: string) => t, + symbols: { + cursor: "❯", + inputCursor: "│", + boxRound: { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + }, + boxSharp: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + teeDown: "┬", + teeUp: "┴", + teeLeft: "├", + teeRight: "┤", + cross: "┼", + }, + table: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + teeDown: "┬", + teeUp: "┴", + teeLeft: "├", + teeRight: "┤", + cross: "┼", + }, + quoteBorder: "│", + hrChar: "─", + spinnerFrames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], + }, + }; + + it("renders claude style with top and bottom horizontal rules and prompt gutter", () => { + const editor = new Editor(unicodeTheme); + editor.setBorderStyle("claude"); + editor.setText("hello"); + const lines = editor.render(20); + expect(lines.length).toBe(3); // top rule, content, bottom rule + expect(lines[0]).toBe("─".repeat(20)); + expect(lines[1]).toContain("❯ hello"); + expect(lines[2]).toBe("─".repeat(20)); + }); + + it("renders pi style with framed box, prompt gutter, and separate bottom border", () => { + const editor = new Editor(unicodeTheme); + editor.setBorderStyle("pi"); + editor.setText("hello"); + const lines = editor.render(20); + expect(lines.length).toBe(3); // top border, content, bottom border + expect(lines[0]).toBe(`╭${"─".repeat(18)}╮`); + expect(lines[1]).toContain("> hello"); + expect(lines[2]).toBe(`╰${"─".repeat(18)}╯`); + }); + + it("renders borderless style without box borders", () => { + const editor = new Editor(unicodeTheme); + editor.setBorderStyle("borderless"); + editor.setText("hello"); + const lines = editor.render(20); + expect(lines.length).toBe(1); // content only + expect(lines[0]).toContain("❯ hello"); + }); + + it("renders default box style with compact bottom border", () => { + const editor = new Editor(unicodeTheme); + editor.setText("hello"); + const lines = editor.render(20); + expect(lines.length).toBe(2); // top border, bottom border with content + expect(lines[0]).toContain("╭"); + expect(lines[1]).toContain("╰─ hello"); + }); + }); });