From 99dabb7c181ec3062b3e99f91bd97b9700230c24 Mon Sep 17 00:00:00 2001 From: Vu Anh Nguyen Date: Thu, 26 Mar 2026 16:13:53 +0700 Subject: [PATCH] fix(ask): keep multiline custom input in prompt gutter --- packages/coding-agent/CHANGELOG.md | 1 + .../src/extensibility/extensions/types.ts | 7 +- .../src/modes/components/hook-editor.ts | 66 ++- .../controllers/extension-ui-controller.ts | 5 +- .../src/modes/interactive-mode.ts | 9 +- .../coding-agent/src/modes/rpc/rpc-mode.ts | 108 +++-- .../coding-agent/src/modes/rpc/rpc-types.ts | 10 +- packages/coding-agent/src/modes/types.ts | 7 +- packages/coding-agent/src/tools/ask.ts | 90 ++-- .../coding-agent/test/hook-editor.test.ts | 327 +++++++++++++ .../test/rpc-mode-extension-ui.test.ts | 82 ++++ packages/coding-agent/test/tools/ask.test.ts | 430 ++++++++++++++++-- packages/tui/src/components/editor.ts | 250 ++++++++-- packages/tui/test/editor.test.ts | 374 +++++++++++++++ 14 files changed, 1608 insertions(+), 158 deletions(-) create mode 100644 packages/coding-agent/test/hook-editor.test.ts create mode 100644 packages/coding-agent/test/rpc-mode-extension-ui.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d52f924bc..2df0fc50f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp` - Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication - Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery +- Added multiline custom input for `ask` custom answers, using the prompt-style editor without inactivity timeout while composing ([#506](https://github.com/can1357/oh-my-pi/issues/506)) ### Changed diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index a4ad6996f..ae6bd21ee 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -152,7 +152,12 @@ export interface ExtensionUIContext { getEditorText(): string; /** Show a multi-line editor for text editing. */ - editor(title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions): Promise; + editor( + title: string, + prefill?: string, + dialogOptions?: ExtensionUIDialogOptions, + editorOptions?: { promptStyle?: boolean }, + ): Promise; /** Set a custom editor component via factory function, or undefined to restore the default editor. */ setEditorComponent( diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index 56d9fce29..e829d04ac 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -1,17 +1,27 @@ /** - * Multi-line editor component for hooks. + * Multi-line editor component for hooks and ask custom input. * Supports Ctrl+G for external editor. + * + * Two modes: + * - Default (hook): Enter inserts newline, Ctrl+Enter submits, bordered popup + * - Prompt-style (ask): Enter submits, Shift+Enter inserts newline, legacy ask chrome */ import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; import { getEditorTheme, theme } from "../../modes/theme/theme"; import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { DynamicBorder } from "./dynamic-border"; +export interface HookEditorOptions { + /** When true, use prompt-style keybindings with the legacy ask prompt chrome. */ + promptStyle?: boolean; +} + export class HookEditorComponent extends Container { #editor: Editor; #onSubmitCallback: (value: string) => void; #onCancelCallback: () => void; #tui: TUI; + #promptStyle: boolean; constructor( tui: TUI, @@ -19,23 +29,29 @@ export class HookEditorComponent extends Container { prefill: string | undefined, onSubmit: (value: string) => void, onCancel: () => void, + options?: HookEditorOptions, ) { super(); this.#tui = tui; this.#onSubmitCallback = onSubmit; this.#onCancelCallback = onCancel; + this.#promptStyle = options?.promptStyle ?? false; - // Add top border this.addChild(new DynamicBorder()); this.addChild(new Spacer(1)); - // Add title + // Title this.addChild(new Text(theme.fg("accent", title), 1, 0)); this.addChild(new Spacer(1)); - // Create editor + // Editor this.#editor = new Editor(getEditorTheme()); + if (this.#promptStyle) { + this.#editor.setBorderVisible(false); + this.#editor.setPromptGutter("> "); + this.#editor.disableSubmit = true; + } if (prefill) { this.#editor.setText(prefill); } @@ -43,17 +59,51 @@ export class HookEditorComponent extends Container { this.addChild(new Spacer(1)); - // Add hint - const hint = "ctrl+enter submit esc cancel ctrl+g external editor"; + // Hint + const hint = this.#promptStyle + ? "enter submit esc cancel" + : "ctrl+enter submit esc cancel ctrl+g external editor"; this.addChild(new Text(theme.fg("dim", hint), 1, 0)); this.addChild(new Spacer(1)); - - // Add bottom border this.addChild(new DynamicBorder()); } handleInput(keyData: string): void { + if (this.#promptStyle) { + this.#handlePromptStyleInput(keyData); + } else { + this.#handleHookStyleInput(keyData); + } + } + + /** Prompt-style: raw Enter submits; Editor owns newline-producing sequences. */ + #handlePromptStyleInput(keyData: string): void { + // Escape to cancel + if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc")) { + this.#onCancelCallback(); + return; + } + + // Ctrl+G for external editor + if (matchesKey(keyData, "ctrl+g")) { + void this.#openExternalEditor(); + return; + } + + // Submit on any plain Enter encoding the key matcher recognizes, except the raw \n path + // some terminals use for Shift+Enter in prompt-style mode. + if ((matchesKey(keyData, "enter") || matchesKey(keyData, "return")) && keyData !== "\n") { + this.#onSubmitCallback(this.#editor.getText()); + return; + } + + // Let Editor handle newline-producing Enter variants (\n, Shift+Enter, Ctrl+Enter, Alt+Enter, etc.) + this.#editor.handleInput(keyData); + } + + /** Hook-style: Enter=newline, Ctrl+Enter=submit (original behavior) */ + #handleHookStyleInput(keyData: string): void { // Ctrl+Enter to submit if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") { this.#onSubmitCallback(this.#editor.getText()); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 02645b6e7..bb5a30a19 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -43,7 +43,8 @@ export class ExtensionUiController { this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`); }, getEditorText: () => this.ctx.editor.getText(), - editor: (title, prefill, dialogOptions) => this.showHookEditor(title, prefill, dialogOptions), + editor: (title, prefill, dialogOptions, editorOptions) => + this.showHookEditor(title, prefill, dialogOptions, editorOptions), get theme() { return theme; }, @@ -714,6 +715,7 @@ export class ExtensionUiController { title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions, + editorOptions?: { promptStyle?: boolean }, ): Promise { const { promise, resolve } = Promise.withResolvers(); let settled = false; @@ -742,6 +744,7 @@ export class ExtensionUiController { this.hideHookEditor(); finish(undefined); }, + editorOptions, ); this.ctx.editorContainer.clear(); diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 4faebb1b8..3cb87194f 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1387,8 +1387,13 @@ export class InteractiveMode implements InteractiveModeContext { this.#extensionUiController.hideHookInput(); } - showHookEditor(title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions): Promise { - return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions); + showHookEditor( + title: string, + prefill?: string, + dialogOptions?: ExtensionUIDialogOptions, + editorOptions?: { promptStyle?: boolean }, + ): Promise { + return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions, editorOptions); } hideHookEditor(): void { diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 4809c1a2d..ed22b4728 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -25,6 +25,77 @@ import type { // Re-export types for consumers export type * from "./rpc-types"; +export type PendingExtensionRequest = { + resolve: (response: RpcExtensionUIResponse) => void; + reject: (error: Error) => void; +}; + +type RpcOutput = (obj: RpcResponse | RpcExtensionUIRequest | object) => void; + +export function requestRpcEditor( + pendingRequests: Map, + output: RpcOutput, + title: string, + prefill?: string, + dialogOptions?: ExtensionUIDialogOptions, + editorOptions?: { promptStyle?: boolean }, +): Promise { + if (dialogOptions?.signal?.aborted) return Promise.resolve(undefined); + + const id = Snowflake.next() as string; + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + + const cleanup = () => { + dialogOptions?.signal?.removeEventListener("abort", onAbort); + pendingRequests.delete(id); + }; + const finish = (value: string | undefined) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onAbort = () => { + output({ + type: "extension_ui_request", + id: Snowflake.next() as string, + method: "cancel", + targetId: id, + } as RpcExtensionUIRequest); + finish(undefined); + }; + + dialogOptions?.signal?.addEventListener("abort", onAbort, { once: true }); + pendingRequests.set(id, { + resolve: response => { + if ("cancelled" in response && response.cancelled) { + finish(undefined); + } else if ("value" in response) { + finish(response.value); + } else { + finish(undefined); + } + }, + reject: fail, + }); + output({ + type: "extension_ui_request", + id, + method: "editor", + title, + prefill, + promptStyle: editorOptions?.promptStyle, + } as RpcExtensionUIRequest); + return promise; +} + /** * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. @@ -51,12 +122,6 @@ export async function runRpcMode(session: AgentSession): Promise { return { id, type: "response", command, success: false, error: message }; }; - // Pending extension UI requests waiting for response - type PendingExtensionRequest = { - resolve: (response: RpcExtensionUIResponse) => void; - reject: (error: Error) => void; - }; - const pendingExtensionRequests = new Map(); // Shutdown request flag (wrapped in object to allow mutation with const) @@ -256,30 +321,13 @@ export async function runRpcMode(session: AgentSession): Promise { return ""; } - async editor(title: string, prefill?: string, _dialogOptions?: ExtensionUIDialogOptions): Promise { - const id = Snowflake.next() as string; - const { promise, resolve, reject } = Promise.withResolvers(); - this.pendingRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - this.pendingRequests.delete(id); - if ("cancelled" in response && response.cancelled) { - resolve(undefined); - } else if ("value" in response) { - resolve(response.value); - } else { - resolve(undefined); - } - }, - reject, - }); - this.output({ - type: "extension_ui_request", - id, - method: "editor", - title, - prefill, - } as RpcExtensionUIRequest); - return promise; + async editor( + title: string, + prefill?: string, + dialogOptions?: ExtensionUIDialogOptions, + editorOptions?: { promptStyle?: boolean }, + ): Promise { + return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions); } get theme(): Theme { diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index d212d70d7..a62eae7ba 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -194,7 +194,15 @@ export type RpcExtensionUIRequest = placeholder?: string; timeout?: number; } - | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } + | { + type: "extension_ui_request"; + id: string; + method: "editor"; + title: string; + prefill?: string; + promptStyle?: boolean; + } + | { type: "extension_ui_request"; id: string; method: "cancel"; targetId: string } | { type: "extension_ui_request"; id: string; diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index fd6b36370..74902aa31 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -236,7 +236,12 @@ export interface InteractiveModeContext { hideHookSelector(): void; showHookInput(title: string, placeholder?: string): Promise; hideHookInput(): void; - showHookEditor(title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions): Promise; + showHookEditor( + title: string, + prefill?: string, + dialogOptions?: ExtensionUIDialogOptions, + editorOptions?: { promptStyle?: boolean }, + ): Promise; hideHookEditor(): void; showHookNotify(message: string, type?: "info" | "warning" | "error"): void; showHookCustom( diff --git a/packages/coding-agent/src/tools/ask.ts b/packages/coding-agent/src/tools/ask.ts index 0df4b7a80..1562dd849 100644 --- a/packages/coding-agent/src/tools/ask.ts +++ b/packages/coding-agent/src/tools/ask.ts @@ -42,7 +42,6 @@ const QuestionItem = Type.Object({ question: Type.String({ description: "Question text" }), options: Type.Array(OptionItem, { description: "Available options" }), multi: Type.Optional(Type.Boolean({ description: "Allow multiple selections" })), - multiline: Type.Optional(Type.Boolean({ description: "Open multiline editor for custom input" })), recommended: Type.Optional(Type.Number({ description: "Index of recommended option (0-indexed)" })), }); @@ -132,7 +131,6 @@ interface AskSingleQuestionOptions { signal?: AbortSignal; initialSelection?: Pick; navigation?: NavigationControls; - multiline?: boolean; } interface UIContext { @@ -150,11 +148,12 @@ interface UIContext { helpText?: string; }, ): Promise; - input( - prompt: string, - options_?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void }, + editor( + title: string, + prefill?: string, + dialogOptions?: { signal?: AbortSignal }, + editorOptions?: { promptStyle?: boolean }, ): Promise; - editor(title: string, prefill?: string): Promise; } async function askSingleQuestion( @@ -164,7 +163,7 @@ async function askSingleQuestion( multi: boolean, options: AskSingleQuestionOptions = {}, ): Promise { - const { recommended, timeout, signal, initialSelection, navigation, multiline = false } = options; + const { recommended, timeout, signal, initialSelection, navigation } = options; const doneLabel = getDoneOptionLabel(); let selectedOptions = [...(initialSelection?.selectedOptions ?? [])]; let customInput = initialSelection?.customInput; @@ -211,12 +210,9 @@ async function askSingleQuestion( return { choice, timedOut: timeoutTriggered, navigation: navigationAction }; }; - const promptForCustomInput = async ( - multiline: boolean, - ): Promise<{ input: string | undefined }> => { - const showCustomInput = multiline - ? () => ui.editor("Enter your response:") - : () => ui.input("Enter your response:", { signal }); + const promptForCustomInput = async (): Promise<{ input: string | undefined }> => { + const dialogOptions = signal ? { signal } : undefined; + const showCustomInput = () => ui.editor("Enter your response:", undefined, dialogOptions, { promptStyle: true }); const input = signal ? await untilAborted(signal, showCustomInput) : await showCustomInput(); return { input }; }; @@ -267,7 +263,7 @@ async function askSingleQuestion( timedOut = true; break; } - const customResult = await promptForCustomInput(multiline); + const customResult = await promptForCustomInput(); if (customResult.input === undefined) { return { selectedOptions: Array.from(selected), customInput, timedOut, cancelled: true }; } @@ -335,7 +331,7 @@ async function askSingleQuestion( } } else if (choice === OTHER_OPTION) { if (!selectTimedOut) { - const customResult = await promptForCustomInput(multiline); + const customResult = await promptForCustomInput(); if (customResult.input === undefined) { return { selectedOptions, customInput, timedOut, cancelled: true }; } @@ -421,8 +417,8 @@ export class AskTool implements AgentTool { const extensionUi = context.ui; const ui: UIContext = { select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions), - input: (prompt, dialogOptions) => extensionUi.input(prompt, undefined, dialogOptions), - editor: (title, prefill) => extensionUi.editor(title, prefill), + editor: (title, prefill, dialogOptions, editorOptions) => + extensionUi.editor(title, prefill, dialogOptions, editorOptions), }; // Determine timeout based on settings and plan mode @@ -459,7 +455,6 @@ export class AskTool implements AgentTool { signal, initialSelection: options?.previous, navigation: options?.navigation, - multiline: q.multiline ?? false, }, ); return { optionLabels, selectedOptions, customInput, navigation, cancelled, timedOut }; @@ -487,16 +482,23 @@ export class AskTool implements AgentTool { customInput, }; - let responseText: string; - if (customInput !== undefined) { - responseText = `User provided custom input: ${customInput}`; - } else if (selectedOptions.length > 0) { - responseText = q.multi - ? `User selected: ${selectedOptions.join(", ")}` - : `User selected: ${selectedOptions[0]}`; - } else { - responseText = "User cancelled the selection"; + const responseParts: string[] = []; + if (selectedOptions.length > 0) { + responseParts.push( + q.multi ? `User selected: ${selectedOptions.join(", ")}` : `User selected: ${selectedOptions[0]}`, + ); } + if (customInput !== undefined) { + responseParts.push( + customInput.includes("\n") + ? `User provided custom input:\n${customInput + .split("\n") + .map(line => ` ${line}`) + .join("\n")}` + : `User provided custom input: ${customInput}`, + ); + } + const responseText = responseParts.length > 0 ? responseParts.join("\n") : "User cancelled the selection"; return { content: [{ type: "text" as const, text: responseText }], details }; } @@ -570,30 +572,25 @@ interface AskRenderArgs { question?: string; options?: Array<{ label: string }>; multi?: boolean; - multiline?: boolean; questions?: Array<{ id: string; question: string; options: Array<{ label: string }>; multi?: boolean; - multiline?: boolean; }>; } -function renderCustomInputLines( - uiTheme: Theme, - prefix: string, - customInput: string, - isLastEntry: boolean, -): string { +/** Render custom input as a single block with continuation lines (not one entry per line) */ +function renderCustomInput(uiTheme: Theme, prefix: string, customInput: string, isLastEntry: boolean): string { const lines = customInput.split("\n"); - return lines - .map((line, index) => { - const isLastLine = index === lines.length - 1 && isLastEntry; - const branch = isLastLine ? uiTheme.tree.last : uiTheme.tree.branch; - return `\n${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", line)}`; - }) - .join(""); + const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch; + const firstLine = lines[0] ?? ""; + let text = `\n${prefix}${uiTheme.fg("dim", branch)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", firstLine)}`; + const continuationIndent = isLastEntry ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `; + for (let i = 1; i < lines.length; i++) { + text += `\n${prefix}${continuationIndent} ${uiTheme.fg("toolOutput", lines[i])}`; + } + return text; } export const askToolRenderer = { @@ -613,7 +610,6 @@ export const askToolRenderer = { // Question line with metadata const meta: string[] = []; if (q.multi) meta.push("multi"); - if (q.multiline) meta.push("multiline"); if (q.options?.length) meta.push(`options:${q.options.length}`); const metaStr = meta.length > 0 ? uiTheme.fg("dim", ` · ${meta.join(" · ")}`) : ""; @@ -640,7 +636,6 @@ export const askToolRenderer = { let text = `${label} ${uiTheme.fg("accent", args.question)}`; const meta: string[] = []; if (args.multi) meta.push("multi"); - if (args.multiline) meta.push("multiline"); if (args.options?.length) meta.push(`options:${args.options.length}`); text += formatMeta(meta, uiTheme); @@ -704,7 +699,7 @@ export const askToolRenderer = { } } if (r.customInput !== undefined) { - text += renderCustomInputLines(uiTheme, continuation, r.customInput, true); + text += renderCustomInput(uiTheme, continuation, r.customInput, true); } else if (r.selectedOptions.length === 0) { text += `\n${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; } @@ -720,7 +715,8 @@ export const askToolRenderer = { return new Text(fallback, 0, 0); } - const hasSelection = details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0); + const hasSelection = + details.customInput !== undefined || (details.selectedOptions && details.selectedOptions.length > 0); const header = renderStatusLine( { icon: hasSelection ? "success" : "warning", title: "Ask", description: details.question }, uiTheme, @@ -736,7 +732,7 @@ export const askToolRenderer = { } } if (details.customInput !== undefined) { - text += renderCustomInputLines(uiTheme, " ", details.customInput, true); + text += renderCustomInput(uiTheme, " ", details.customInput, true); } else if (!details.selectedOptions || details.selectedOptions.length === 0) { text += `\n ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`; } diff --git a/packages/coding-agent/test/hook-editor.test.ts b/packages/coding-agent/test/hook-editor.test.ts new file mode 100644 index 000000000..53f80029b --- /dev/null +++ b/packages/coding-agent/test/hook-editor.test.ts @@ -0,0 +1,327 @@ +import { beforeAll, describe, expect, it, vi } from "bun:test"; +import { HookEditorComponent } from "@oh-my-pi/pi-coding-agent/modes/components/hook-editor"; +import { ExtensionUiController } from "@oh-my-pi/pi-coding-agent/modes/controllers/extension-ui-controller"; +import { getThemeByName, setThemeInstance } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import type { TUI } from "@oh-my-pi/pi-tui"; + +beforeAll(async () => { + const theme = await getThemeByName("dark"); + if (!theme) { + throw new Error("Failed to load dark theme for tests"); + } + setThemeInstance(theme); +}); + +function createTui(): TUI { + return { + requestRender: vi.fn(), + setFocus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + terminal: { columns: 120 }, + } as unknown as TUI; +} + +function renderText(component: HookEditorComponent, width = 120): string { + return Bun.stripANSI(component.render(width).join("\n")); +} + +function renderLines(component: HookEditorComponent, width = 120): string[] { + return Bun.stripANSI(component.render(width).join("\n")).split("\n"); +} + +type TestContext = InteractiveModeContext & { + editorContainer: { + children: unknown[]; + clear: () => void; + addChild: (child: unknown) => void; + }; +}; + +function createControllerContext() { + const editor = { id: "core-editor" }; + const editorContainer = { + children: [] as unknown[], + clear() { + this.children = []; + }, + addChild(child: unknown) { + this.children.push(child); + }, + }; + const ui = { + requestRender: vi.fn(), + setFocus: vi.fn(), + start: vi.fn(), + stop: vi.fn(), + terminal: { columns: 120 }, + } as unknown as TestContext["ui"] & { + setFocus: ReturnType; + requestRender: ReturnType; + }; + const ctx = { + editor, + editorContainer, + ui, + hookEditor: undefined, + } as unknown as TestContext; + + return { ctx, editor, editorContainer, ui }; +} + +describe("HookEditorComponent default (hook) mode", () => { + it("inserts a newline on Enter instead of submitting immediately", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel); + + component.handleInput("a"); + component.handleInput("b"); + component.handleInput("\n"); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput("c"); + component.handleInput("d"); + component.handleInput("\x1b[13;5u"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("ab\ncd"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("submits the current text on Ctrl+Enter", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", "line 1\nline 2", onSubmit, onCancel); + + component.handleInput("\x1b[13;5u"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("line 1\nline 2"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("cancels on Escape", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", "draft", onSubmit, onCancel); + + component.handleInput("\x1b"); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); + +describe("HookEditorComponent prompt-style mode", () => { + it("submits on plain Enter", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, { + promptStyle: true, + }); + + component.handleInput("a"); + component.handleInput("b"); + component.handleInput("\r"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("ab"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("submits on alternate Enter encodings recognized by the key matcher", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, { + promptStyle: true, + }); + + component.handleInput("a"); + component.handleInput("\x1bOM"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("a"); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("treats the plain newline terminal path as newline instead of submitting", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, { + promptStyle: true, + }); + + component.handleInput("a"); + component.handleInput("\n"); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput("b"); + component.handleInput("\r"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("a\nb"); + }); + + it("inserts newline on Shift+Enter instead of submitting", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, { + promptStyle: true, + }); + + component.handleInput("a"); + component.handleInput("\x1b[13;2~"); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + + component.handleInput("b"); + component.handleInput("\r"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("a\nb"); + }); + + it("treats Ctrl+Enter as newline in prompt-style mode", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, { + promptStyle: true, + }); + + component.handleInput("x"); + component.handleInput("\x1b[13;5u"); + + expect(onSubmit).not.toHaveBeenCalled(); + + component.handleInput("y"); + component.handleInput("\r"); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("x\ny"); + }); + + it("renders prompt-style editor with legacy ask chrome", () => { + const component = new HookEditorComponent(createTui(), "Prompt", undefined, vi.fn(), vi.fn(), { + promptStyle: true, + }); + + const rendered = renderText(component); + const lines = renderLines(component); + + expect(lines[0]).toMatch(/^─+$/); + expect(lines.at(-1)).toMatch(/^─+$/); + expect(lines[4]?.startsWith("> ")).toBe(true); + expect(rendered).toContain(" enter submit esc cancel"); + expect(rendered).not.toContain("shift+enter newline"); + expect(rendered).not.toContain("ctrl+g external editor"); + }); + + it("keeps the prompt gutter visible after typing in prompt-style mode", () => { + const component = new HookEditorComponent(createTui(), "Prompt", undefined, vi.fn(), vi.fn(), { + promptStyle: true, + }); + + for (const char of "hello") { + component.handleInput(char); + } + + const lines = renderLines(component); + expect(lines[4]?.startsWith("> hello")).toBe(true); + expect(lines[4]?.startsWith("hello")).toBe(false); + }); + + it("aligns wrapped prompt-style continuation rows under the text column", () => { + const component = new HookEditorComponent(createTui(), "Prompt", "abcdefghijklm", vi.fn(), vi.fn(), { + promptStyle: true, + }); + + const lines = renderLines(component, 12); + expect(lines[4]).toBe("> abcdefghij"); + expect(lines[5]?.startsWith(" klm")).toBe(true); + expect(lines[5]?.startsWith(">")).toBe(false); + }); + + it("cancels on Escape", () => { + const onSubmit = vi.fn(); + const onCancel = vi.fn(); + const component = new HookEditorComponent(createTui(), "Prompt", "draft", onSubmit, onCancel, { + promptStyle: true, + }); + + component.handleInput("\x1b"); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); + +describe("ExtensionUiController hook editor abort", () => { + it("hides the hook editor and resolves undefined when the caller aborts", async () => { + const { ctx, editor, editorContainer, ui } = createControllerContext(); + const controller = new ExtensionUiController(ctx); + const abortController = new AbortController(); + const controllerWithAbort = controller as unknown as { + showHookEditor: ( + title: string, + prefill?: string, + dialogOptions?: { signal?: AbortSignal }, + editorOptions?: { promptStyle?: boolean }, + ) => Promise; + }; + + const promise = controllerWithAbort.showHookEditor("Prompt", "draft", { signal: abortController.signal }); + + expect(editorContainer.children).toHaveLength(1); + expect(ctx.hookEditor).toBeDefined(); + + abortController.abort(); + await Bun.sleep(0); + + expect(editorContainer.children).toEqual([editor]); + expect(ctx.hookEditor).toBeUndefined(); + expect(ui.setFocus).toHaveBeenLastCalledWith(editor); + + const pending = Symbol("pending"); + const result = await Promise.race([promise, Bun.sleep(20).then(() => pending)]); + expect(result).toBeUndefined(); + }); + + it("forwards editorOptions to HookEditorComponent", async () => { + const { ctx, editorContainer } = createControllerContext(); + const controller = new ExtensionUiController(ctx); + const controllerWithOptions = controller as unknown as { + showHookEditor: ( + title: string, + prefill?: string, + dialogOptions?: { signal?: AbortSignal }, + editorOptions?: { promptStyle?: boolean }, + ) => Promise; + }; + + // Start the editor with promptStyle + const promise = controllerWithOptions.showHookEditor("Ask prompt", undefined, undefined, { + promptStyle: true, + }); + + expect(editorContainer.children).toHaveLength(1); + expect(ctx.hookEditor).toBeDefined(); + + // The component should be a HookEditorComponent in prompt-style mode. + // Verify by sending Enter — it should submit, not insert newline. + const hookEditor = ctx.hookEditor!; + hookEditor.handleInput("test-text".split("").join("")); + hookEditor.handleInput("\r"); + + // The promise should resolve since Enter submits in prompt-style mode. + const result = await promise; + // Result depends on what the editor captured. The key thing is it resolved. + expect(result).toBeDefined(); + }); +}); diff --git a/packages/coding-agent/test/rpc-mode-extension-ui.test.ts b/packages/coding-agent/test/rpc-mode-extension-ui.test.ts new file mode 100644 index 000000000..23426eaf9 --- /dev/null +++ b/packages/coding-agent/test/rpc-mode-extension-ui.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { type PendingExtensionRequest, requestRpcEditor } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-mode"; +import type { RpcExtensionUIRequest } from "@oh-my-pi/pi-coding-agent/modes/rpc/rpc-types"; + +function isExtensionUiRequest(obj: RpcExtensionUIRequest | object): obj is RpcExtensionUIRequest { + return "type" in obj && obj.type === "extension_ui_request"; +} + +describe("requestRpcEditor", () => { + it("serializes promptStyle on editor requests", async () => { + const pendingRequests = new Map(); + const requests: RpcExtensionUIRequest[] = []; + + const promise = requestRpcEditor( + pendingRequests, + obj => { + if (isExtensionUiRequest(obj)) { + requests.push(obj); + } + }, + "Enter your response:", + "draft", + undefined, + { promptStyle: true }, + ); + + expect(requests).toHaveLength(1); + const request = requests[0]; + if (!request || request.method !== "editor") { + throw new Error("Expected an editor request"); + } + expect(request.promptStyle).toBe(true); + expect(request.prefill).toBe("draft"); + + const pending = pendingRequests.get(request.id); + if (!pending) { + throw new Error("Expected a pending request"); + } + pending.resolve({ type: "extension_ui_response", id: request.id, value: "custom response" }); + + await expect(promise).resolves.toBe("custom response"); + expect(pendingRequests.size).toBe(0); + }); + + it("resolves editor requests on abort and clears pending state", async () => { + const pendingRequests = new Map(); + const requests: RpcExtensionUIRequest[] = []; + const controller = new AbortController(); + + const promise = requestRpcEditor( + pendingRequests, + obj => { + if (isExtensionUiRequest(obj)) { + requests.push(obj); + } + }, + "Enter your response:", + undefined, + { signal: controller.signal }, + { promptStyle: true }, + ); + + expect(requests).toHaveLength(1); + const request = requests[0]; + if (!request || request.method !== "editor") { + throw new Error("Expected an editor request"); + } + expect(request.promptStyle).toBe(true); + expect(pendingRequests.has(request.id)).toBe(true); + + controller.abort(); + + expect(requests).toHaveLength(2); + const cancelRequest = requests[1]; + if (!cancelRequest || cancelRequest.method !== "cancel") { + throw new Error("Expected a cancel request"); + } + expect(cancelRequest.targetId).toBe(request.id); + await expect(promise).resolves.toBeUndefined(); + expect(pendingRequests.has(request.id)).toBe(false); + }); +}); diff --git a/packages/coding-agent/test/tools/ask.test.ts b/packages/coding-agent/test/tools/ask.test.ts index 685d6ccb1..fc17a3129 100644 --- a/packages/coding-agent/test/tools/ask.test.ts +++ b/packages/coding-agent/test/tools/ask.test.ts @@ -1,9 +1,9 @@ import { beforeAll, describe, expect, it, vi } from "bun:test"; import type { AgentToolContext } from "@oh-my-pi/pi-agent-core"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; -import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { getThemeByName, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; -import { AskTool } from "@oh-my-pi/pi-coding-agent/tools/ask"; +import { AskTool, askToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/ask"; import { ToolAbortError } from "@oh-my-pi/pi-coding-agent/tools/tool-errors"; function createSession(overrides: Partial = {}): ToolSession { @@ -31,13 +31,11 @@ function createContext(args: { onRight?: () => void; }, ) => Promise; - input?: ( - prompt: string, - dialogOptions?: { - timeout?: number; - signal?: AbortSignal; - onTimeout?: () => void; - }, + editor?: ( + title: string, + prefill?: string, + dialogOptions?: { signal?: AbortSignal }, + editorOptions?: { promptStyle?: boolean }, ) => Promise; abort?: () => void; }): AgentToolContext { @@ -46,20 +44,21 @@ function createContext(args: { hasUI: true, ui: { select: args.select, - input: ( - prompt: string, - _placeholder: string | undefined, - dialogOptions?: { - timeout?: number; - signal?: AbortSignal; - onTimeout?: () => void; - }, - ) => args.input?.(prompt, dialogOptions) ?? Promise.resolve(undefined), + editor: ( + title: string, + prefill?: string, + dialogOptions?: { signal?: AbortSignal }, + editorOptions?: { promptStyle?: boolean }, + ) => args.editor?.(title, prefill, dialogOptions, editorOptions) ?? Promise.resolve(undefined), }, abort: args.abort ?? (() => {}), } as unknown as AgentToolContext; } +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*m/g, ""); +} + beforeAll(async () => { await initTheme(false); }); @@ -219,27 +218,34 @@ describe("AskTool cancellation", () => { expect(abort).not.toHaveBeenCalled(); }); - it("does not abort when custom input times out after selecting Other", async () => { + it("routes custom input through editor with promptStyle after choosing Other", async () => { const tool = new AskTool( createSession({ settings: Settings.isolated({ "ask.timeout": 0.001 }), }), ); const abort = vi.fn(); - const input = vi.fn(async (_prompt: string, dialogOptions?: { timeout?: number; onTimeout?: () => void }) => { - const timeout = dialogOptions?.timeout ?? 1; - await Bun.sleep(timeout + 5); - dialogOptions?.onTimeout?.(); - return undefined; - }); + const editor = vi.fn( + async ( + _title: string, + _prefill?: string, + _dialogOptions?: unknown, + editorOptions?: { promptStyle?: boolean }, + ) => { + // Verify promptStyle is passed + expect(editorOptions?.promptStyle).toBe(true); + return "custom response"; + }, + ); + const select = vi.fn(async () => "Other (type your own)"); const context = createContext({ - select: async () => "Other (type your own)", - input, + select, + editor, abort, }); const result = await tool.execute( - "call-timeout-input", + "call-custom-input", { questions: [ { @@ -258,19 +264,22 @@ describe("AskTool cancellation", () => { if (result.content[0]?.type !== "text") { throw new Error("Expected text result"); } - expect(result.content[0].text).toContain("User selected: yes"); - expect(result.details?.selectedOptions).toEqual(["yes"]); - expect(input).toHaveBeenCalledTimes(1); + expect(result.content[0].text).toContain("custom response"); + expect(result.details?.selectedOptions).toEqual([]); + expect(result.details?.customInput).toBe("custom response"); + expect((select.mock.calls[0] as unknown[])?.[2] as Record).toHaveProperty("timeout"); + expect(editor).toHaveBeenCalledTimes(1); expect(abort).not.toHaveBeenCalled(); }); - it("does not prompt for custom input when timeout resolves to Other in multi-select", async () => { + + it("does not enter custom input when timeout resolves to Other in multi-select", async () => { const tool = new AskTool( createSession({ settings: Settings.isolated({ "ask.timeout": 0.001 }), }), ); const abort = vi.fn(); - const input = vi.fn(async () => "should-not-be-used"); + const editor = vi.fn(async () => "should-not-be-used"); const context = createContext({ select: async (_prompt, _options, dialogOptions) => { const timeout = dialogOptions?.timeout ?? 1; @@ -278,7 +287,7 @@ describe("AskTool cancellation", () => { dialogOptions?.onTimeout?.(); return "Other (type your own)"; }, - input, + editor, abort, }); @@ -306,9 +315,10 @@ describe("AskTool cancellation", () => { expect(result.content[0].text).toContain("User selected: yes"); expect(result.details?.selectedOptions).toEqual(["yes"]); expect(result.details?.customInput).toBeUndefined(); - expect(input).not.toHaveBeenCalled(); + expect(editor).not.toHaveBeenCalled(); expect(abort).not.toHaveBeenCalled(); }); + it("aborts multi-question ask when any question is explicitly cancelled", async () => { const tool = new AskTool(createSession()); const abort = vi.fn(); @@ -320,7 +330,7 @@ describe("AskTool cancellation", () => { abort, }); - expect( + await expect( tool.execute( "call-3", { @@ -346,6 +356,309 @@ describe("AskTool cancellation", () => { }); }); +describe("AskTool custom input", () => { + it("routes custom input through editor and preserves raw multiline strings", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const multilineText = "first line\nsecond line"; + const editor = vi.fn(async () => multilineText); + const questions = [ + { + id: "details", + question: "Share details", + options: [{ label: "yes" }, { label: "no" }], + }, + ]; + const context = createContext({ + select: async () => "Other (type your own)", + editor, + abort, + }); + + const result = await tool.execute("call-editor-single", { questions }, undefined, undefined, context); + expect(result.content[0]?.type).toBe("text"); + if (result.content[0]?.type !== "text") { + throw new Error("Expected text result"); + } + expect(result.content[0].text).toBe("User provided custom input:\n first line\n second line"); + expect(result.details?.customInput).toBe(multilineText); + expect(result.details?.selectedOptions).toEqual([]); + expect(editor).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + }); + + it("aborts when editor is cancelled in single-question flow", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const editor = vi.fn(async () => undefined); + const questions = [ + { + id: "details", + question: "Share details", + options: [{ label: "yes" }, { label: "no" }], + }, + ]; + const context = createContext({ + select: async () => "Other (type your own)", + editor, + abort, + }); + + await expect( + tool.execute("call-editor-cancel", { questions }, undefined, undefined, context), + ).rejects.toBeInstanceOf(ToolAbortError); + expect(editor).toHaveBeenCalledTimes(1); + expect(abort).toHaveBeenCalledTimes(1); + }); + + it("aborts when editor is cancelled after choosing Other in multi-question flow", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const editor = vi.fn(async () => undefined); + const questions = [ + { + id: "first", + question: "First?", + options: [{ label: "one" }, { label: "two" }], + }, + { + id: "details", + question: "Details?", + options: [{ label: "short" }, { label: "long" }], + }, + ]; + const context = createContext({ + select: async prompt => { + if (prompt.includes("First?")) return "one"; + if (prompt.includes("Details?")) return "Other (type your own)"; + return undefined; + }, + editor, + abort, + }); + + await expect( + tool.execute("call-editor-multi-cancel", { questions }, undefined, undefined, context), + ).rejects.toBeInstanceOf(ToolAbortError); + expect(editor).toHaveBeenCalledTimes(1); + expect(abort).toHaveBeenCalledTimes(1); + }); + + it("surfaces external abort during editor mode as ToolAbortError", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const controller = new AbortController(); + const editor = vi.fn(async (_title: string, _prefill?: string, dialogOptions?: { signal?: AbortSignal }) => { + expect(dialogOptions?.signal).toBe(controller.signal); + return await new Promise((_resolve, reject) => { + dialogOptions?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { + once: true, + }); + queueMicrotask(() => controller.abort()); + }); + }); + const questions = [ + { + id: "details", + question: "Share details", + options: [{ label: "yes" }, { label: "no" }], + }, + ]; + const context = createContext({ + select: async () => "Other (type your own)", + editor, + abort, + }); + + await expect( + tool.execute("call-editor-abort", { questions }, controller.signal, undefined, context), + ).rejects.toBeInstanceOf(ToolAbortError); + expect(editor).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + }); + + it("treats explicit empty-string custom input as submitted input", async () => { + const tool = new AskTool(createSession()); + const abort = vi.fn(); + const editor = vi.fn(async () => ""); + const context = createContext({ + select: async () => "Other (type your own)", + editor, + abort, + }); + + const result = await tool.execute( + "call-empty-custom", + { + questions: [ + { + id: "details", + question: "Share details", + options: [{ label: "yes" }, { label: "no" }], + }, + ], + }, + undefined, + undefined, + context, + ); + + expect(result.content[0]?.type).toBe("text"); + if (result.content[0]?.type !== "text") { + throw new Error("Expected text result"); + } + expect(result.content[0].text).toContain("User provided custom input:"); + expect(result.details?.customInput).toBe(""); + expect(result.details?.selectedOptions).toEqual([]); + expect(editor).toHaveBeenCalledTimes(1); + expect(abort).not.toHaveBeenCalled(); + }); + + it("renders checked options together with custom text in multi-select answers", async () => { + const tool = new AskTool(createSession()); + let step = 0; + const editor = vi.fn(async () => "custom detail"); + const context = createContext({ + select: async (_prompt, options) => { + if (step === 0) { + step += 1; + const alphaOption = options.find(option => option.endsWith("alpha")); + if (!alphaOption) throw new Error("Missing alpha option"); + return alphaOption; + } + return "Other (type your own)"; + }, + editor, + }); + + const result = await tool.execute( + "call-multi-custom-render", + { + questions: [ + { + id: "multi", + question: "Pick answers", + options: [{ label: "alpha" }, { label: "beta" }], + multi: true, + }, + ], + }, + undefined, + undefined, + context, + ); + + expect(result.details?.selectedOptions).toEqual(["alpha"]); + expect(result.details?.customInput).toBe("custom detail"); + expect(result.content[0]?.type).toBe("text"); + if (result.content[0]?.type !== "text") { + throw new Error("Expected text result"); + } + expect(result.content[0].text).toContain("alpha"); + expect(result.content[0].text).toContain("custom detail"); + + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!); + const renderedText = stripAnsi(rendered.render(120).join("\n")); + expect(renderedText).toContain("alpha"); + expect(renderedText).toContain("custom detail"); + }); +}); + +describe("AskTool multiline custom input rendering", () => { + it("renders multiline custom answer as one block, not multiple checked items", async () => { + const tool = new AskTool(createSession()); + const multilineText = "first line\nsecond line\nthird line"; + const editor = vi.fn(async () => multilineText); + const context = createContext({ + select: async () => "Other (type your own)", + editor, + }); + + const result = await tool.execute( + "call-multiline-render", + { + questions: [ + { + id: "details", + question: "Share details", + options: [{ label: "yes" }, { label: "no" }], + }, + ], + }, + undefined, + undefined, + context, + ); + + expect(result.details?.customInput).toBe(multilineText); + + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!); + const renderedText = stripAnsi(rendered.render(120).join("\n")); + + // All three lines should appear + expect(renderedText).toContain("first line"); + expect(renderedText).toContain("second line"); + expect(renderedText).toContain("third line"); + + // Count success icons — should be exactly one for the custom input block, + // plus one for the question status icon (if present). The key contract is that + // continuation lines do NOT get their own success icon. + const successIconCount = ( + renderedText.match(new RegExp(theme!.status.success.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || [] + ).length; + // One icon on the status line header + one on the custom input first line = 2 max + expect(successIconCount).toBeLessThanOrEqual(2); + + // Ensure "second line" and "third line" are NOT preceded by a success icon on their own line + const lines = renderedText.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.includes("second line") || trimmed.includes("third line")) { + // These continuation lines must NOT start with a success icon + expect(trimmed.startsWith(theme!.status.success)).toBe(false); + } + } + }); + + it("does not fabricate placeholder text for empty first-line custom input", async () => { + const tool = new AskTool(createSession()); + const multilineText = "\nsecond line"; + const editor = vi.fn(async () => multilineText); + const context = createContext({ + select: async () => "Other (type your own)", + editor, + }); + + const result = await tool.execute( + "call-leading-empty-line-render", + { + questions: [ + { + id: "details", + question: "Share details", + options: [{ label: "yes" }, { label: "no" }], + }, + ], + }, + undefined, + undefined, + context, + ); + + const theme = await getThemeByName("dark"); + expect(theme).toBeDefined(); + const rendered = askToolRenderer.renderResult(result, { expanded: true, isPartial: false }, theme!); + const renderedText = stripAnsi(rendered.render(120).join("\n")); + + expect(renderedText).toContain("second line"); + expect(renderedText).not.toContain("(empty)"); + }); +}); + describe("AskTool multi-question navigation", () => { const questions = [ { @@ -493,4 +806,49 @@ describe("AskTool multi-question navigation", () => { expect(result.details?.results?.[1]?.selectedOptions).toEqual(["beta"]); expect(result.details?.results?.[2]?.selectedOptions).toEqual([]); }); + it("preserves custom input when navigating back and forward", async () => { + const tool = new AskTool(createSession()); + const multilineText = "line 1\nline 2"; + let detailVisits = 0; + let summaryVisits = 0; + const editor = vi.fn(async () => multilineText); + const questions = [ + { + id: "details", + question: "Details?", + options: [{ label: "short" }, { label: "long" }], + }, + { + id: "summary", + question: "Summary?", + options: [{ label: "one" }, { label: "two" }], + }, + ]; + const context = createContext({ + select: async (prompt, _options, dialogOptions) => { + if (prompt.includes("Details?")) { + detailVisits += 1; + if (detailVisits === 1) return "Other (type your own)"; + dialogOptions?.onRight?.(); + return undefined; + } + if (prompt.includes("Summary?")) { + summaryVisits += 1; + if (summaryVisits === 1) { + dialogOptions?.onLeft?.(); + return undefined; + } + return "two"; + } + return undefined; + }, + editor, + }); + + const result = await tool.execute("call-nav-multiline", { questions }, undefined, undefined, context); + + expect(result.details?.results?.[0]?.customInput).toBe(multilineText); + expect(result.details?.results?.[1]?.selectedOptions).toEqual(["two"]); + expect(editor).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 2840e1b7a..1ffb7e9cc 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -12,6 +12,7 @@ import { moveWordLeft, moveWordRight, padding, + sliceByColumn, truncateToWidth, visibleWidth, } from "../utils"; @@ -267,6 +268,7 @@ interface LayoutLine { text: string; hasCursor: boolean; cursorPos?: number; + isLogicalLineStart: boolean; } export interface EditorTheme { @@ -313,6 +315,7 @@ export class Editor implements Component, Focusable { cursorOverride: string | undefined; /** Display width of the cursorOverride glyph (needed because override may contain ANSI escapes). */ cursorOverrideWidth: number | undefined; + #promptGutter: string | undefined; // Store last layout width for cursor navigation #lastLayoutWidth: number = 80; @@ -369,6 +372,7 @@ export class Editor implements Component, Focusable { // Custom top border (for status line integration) #topBorderContent?: EditorTopBorder; + #borderVisible = true; constructor(theme: EditorTheme) { this.#theme = theme; @@ -387,13 +391,24 @@ export class Editor implements Component, Focusable { this.#topBorderContent = content; } + /** + * Show or hide the editor border chrome. + */ + setBorderVisible(borderVisible: boolean): void { + this.#borderVisible = borderVisible; + } + + setPromptGutter(promptGutter: string | undefined): void { + this.#promptGutter = promptGutter; + } + /** * Get the available width for top border content given a total terminal width. - * Accounts for the border characters and horizontal padding. + * Accounts for the border characters and horizontal padding when visible. */ getTopBorderAvailableWidth(terminalWidth: number): number { const paddingX = this.#getEditorPaddingX(); - const borderWidth = paddingX + 1; + const borderWidth = this.#getHorizontalChromeWidth(paddingX); return Math.max(0, terminalWidth - borderWidth * 2); } @@ -510,18 +525,113 @@ export class Editor implements Component, Focusable { return Math.max(0, padding); } + #getHorizontalChromeWidth(paddingX: number): number { + return this.#borderVisible ? paddingX + 1 : 0; + } + + #getPromptGutterWidth(width: number, paddingX: number): number { + if (this.#borderVisible || !this.#promptGutter) return 0; + const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX); + const availableWidth = Math.max(0, width - chromeWidth); + return Math.min(visibleWidth(this.#promptGutter), availableWidth); + } + + #getPromptGutter( + width: number, + paddingX: number, + ): { firstLine: string; continuation: string; width: number } | undefined { + if (this.#borderVisible || !this.#promptGutter) return undefined; + const gutterWidth = this.#getPromptGutterWidth(width, paddingX); + if (gutterWidth === 0) return undefined; + return { + firstLine: sliceByColumn(this.#promptGutter, 0, gutterWidth, true), + continuation: padding(gutterWidth), + width: gutterWidth, + }; + } + #getContentWidth(width: number, paddingX: number): number { - return Math.max(0, width - 2 * (paddingX + 1)); + const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX); + return Math.max(0, width - chromeWidth - this.#getPromptGutterWidth(width, paddingX)); } #getLayoutWidth(width: number, paddingX: number): number { const contentWidth = this.#getContentWidth(width, paddingX); - return Math.max(1, contentWidth - (paddingX === 0 ? 1 : 0)); + const cursorReserve = this.#borderVisible && 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; - return Math.max(1, this.#maxHeight - 2); + const verticalChrome = this.#borderVisible ? 2 : 0; + return Math.max(1, this.#maxHeight - verticalChrome); + } + + #getStyledInputCursor(): { text: string; width: number } { + const cursorChar = this.#theme.symbols.inputCursor; + return { text: `\x1b[5m${cursorChar}\x1b[0m`, width: visibleWidth(cursorChar) }; + } + + #renderEndOfLineCursorAtWidthLimit( + before: string, + marker: string, + maxWidth: number, + replacement?: { text: string; width: number }, + ): { text: string; width: number } { + const beforeGraphemes = [...segmenter.segment(before)]; + const lastGrapheme = beforeGraphemes[beforeGraphemes.length - 1]?.segment; + const lastGraphemeWidth = lastGrapheme ? visibleWidth(lastGrapheme) : 0; + const builtInCursor = this.#getStyledInputCursor(); + const fallbackReplacement = lastGrapheme + ? { text: `\x1b[7m${lastGrapheme}\x1b[0m`, width: lastGraphemeWidth } + : builtInCursor; + const clampReplacement = (candidate: { text: string; width: number }): { text: string; width: number } => { + let text = sliceByColumn(candidate.text, 0, maxWidth, true); + let width = visibleWidth(text); + if (width > maxWidth) { + text = ""; + width = 0; + } + return { text, width }; + }; + + let clampedReplacement = clampReplacement(replacement ?? fallbackReplacement); + if (replacement && clampedReplacement.width === 0) { + // A custom override that cannot fit at all should first fall back to the highlighted tail. + clampedReplacement = clampReplacement(fallbackReplacement); + } + if (lastGrapheme && clampedReplacement.width === 0) { + // If even the highlighted trailing grapheme cannot fit, show the built-in single-column cursor. + clampedReplacement = clampReplacement(builtInCursor); + } + + const replacedSpanWidth = Math.min(maxWidth, Math.max(lastGraphemeWidth, clampedReplacement.width)); + const prefixWidth = Math.max(0, maxWidth - replacedSpanWidth); + const beforePrefix = sliceByColumn(before, 0, prefixWidth, true); + const replacementPad = padding(Math.max(0, replacedSpanWidth - clampedReplacement.width)); + return { + text: `${beforePrefix}${replacementPad}${clampedReplacement.text}${marker}`, + width: visibleWidth(beforePrefix) + replacedSpanWidth, + }; + } + + #renderTerminalCursorMarker(text: string, marker: string, maxWidth: number): string { + if (!marker) return text; + if (visibleWidth(text) < maxWidth) { + return text + marker; + } + + let insertAt = text.length; + let offset = 0; + for (const seg of segmenter.segment(text)) { + if (visibleWidth(seg.segment) > 0) { + insertAt = offset; + } + offset += seg.segment.length; + } + + return `${text.slice(0, insertAt)}${marker}${text.slice(insertAt)}`; } #getPageScrollStep(totalVisualLines: number): number { @@ -550,13 +660,15 @@ export class Editor implements Component, Focusable { render(width: number): string[] { const paddingX = this.#getEditorPaddingX(); + const borderVisible = this.#borderVisible; + const promptGutter = this.#getPromptGutter(width, paddingX); const contentAreaWidth = this.#getContentWidth(width, paddingX); const layoutWidth = this.#getLayoutWidth(width, paddingX); this.#lastLayoutWidth = layoutWidth; // Box-drawing characters for rounded corners const box = this.#theme.symbols.boxRound; - const borderWidth = paddingX + 1; + const borderWidth = this.#getHorizontalChromeWidth(paddingX); const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`); const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`); const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`); @@ -570,23 +682,25 @@ export class Editor implements Component, Focusable { const result: string[] = []; - // Render top border: ╭─ [status content] ────────────────╮ - const topFillWidth = width - borderWidth * 2; - if (this.#topBorderContent) { - const { content, width: statusWidth } = this.#topBorderContent; - if (statusWidth <= topFillWidth) { - // Status fits - add fill after it - const fillWidth = topFillWidth - statusWidth; - result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); + if (borderVisible) { + // Render top border: ╭─ [status content] ────────────────╮ + const topFillWidth = Math.max(0, width - borderWidth * 2); + if (this.#topBorderContent) { + const { content, width: statusWidth } = this.#topBorderContent; + 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 { - // Status too long - truncate it - const truncated = truncateToWidth(content, topFillWidth - 1); - const truncatedWidth = visibleWidth(truncated); - const fillWidth = Math.max(0, topFillWidth - truncatedWidth); - result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight); + result.push(topLeft + horizontal.repeat(topFillWidth) + topRight); } - } else { - result.push(topLeft + horizontal.repeat(topFillWidth) + topRight); } // Render each layout line @@ -598,15 +712,63 @@ export class Editor implements Component, Focusable { const inlineHint = this.#getInlineHint(); const hintStyle = this.#theme.hintStyle ?? ((t: string) => `\x1b[2m${t}\x1b[0m`); - for (const layoutLine of visibleLayoutLines) { + for (let visibleIndex = 0; visibleIndex < visibleLayoutLines.length; visibleIndex++) { + const layoutLine = visibleLayoutLines[visibleIndex]!; let displayText = layoutLine.text; let displayWidth = visibleWidth(layoutLine.text); let cursorInPadding = false; + const showPromptGutter = promptGutter !== undefined && visibleIndex === 0; + const gutterText = + promptGutter === undefined ? "" : showPromptGutter ? promptGutter.firstLine : promptGutter.continuation; // Add cursor if this line has it const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined; const marker = emitCursorMarker ? CURSOR_MARKER : ""; + if (!borderVisible && displayWidth > lineContentWidth) { + displayText = sliceByColumn(displayText, 0, lineContentWidth, true); + displayWidth = visibleWidth(displayText); + } + + if (!borderVisible && lineContentWidth === 0) { + if (hasCursor && !this.#useTerminalCursor) { + const zeroWidthCursorBudget = visibleWidth(gutterText); + const zeroWidthCursorReplacement = this.cursorOverride + ? { text: this.cursorOverride, width: this.cursorOverrideWidth ?? 1 } + : this.#getStyledInputCursor(); + if (showPromptGutter && zeroWidthCursorBudget > 0) { + // Keep the leading prompt glyph visible when the gutter consumes the whole row. + const promptGlyph = [...segmenter.segment(gutterText)][0]?.segment ?? ""; + const promptGlyphWidth = visibleWidth(promptGlyph); + const remainingCursorWidth = Math.max(0, zeroWidthCursorBudget - promptGlyphWidth); + if (remainingCursorWidth === 0) { + result.push(`\x1b[7m${promptGlyph}\x1b[0m${marker}`); + } else { + const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit( + "", + marker, + remainingCursorWidth, + zeroWidthCursorReplacement, + ); + result.push(`${promptGlyph}${widthLimitedCursor.text}`); + } + } else { + const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit( + gutterText, + marker, + zeroWidthCursorBudget, + zeroWidthCursorReplacement, + ); + result.push(widthLimitedCursor.text); + } + } else if (hasCursor && this.#useTerminalCursor) { + result.push(this.#renderTerminalCursorMarker(gutterText, marker, visibleWidth(gutterText))); + } else { + result.push(gutterText + (hasCursor ? marker : "")); + } + continue; + } + if (hasCursor && this.#useTerminalCursor) { if (marker) { const before = displayText.slice(0, layoutLine.cursorPos); @@ -615,6 +777,8 @@ export class Editor implements Component, Focusable { const hintText = hintStyle(truncateToWidth(inlineHint, Math.max(0, lineContentWidth - displayWidth))); displayText = before + marker + hintText; displayWidth += visibleWidth(inlineHint); + } else if (after.length === 0 && !borderVisible && displayWidth >= lineContentWidth) { + displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth); } else { displayText = before + marker + after; } @@ -635,7 +799,16 @@ 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 (inlineHint) { + if (!borderVisible && 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, { + text: this.cursorOverride, + width: overrideWidth, + }); + displayText = widthLimitedCursor.text; + displayWidth = widthLimitedCursor.width; + } else if (inlineHint) { const availWidth = Math.max(0, lineContentWidth - displayWidth - overrideWidth); const hintText = hintStyle(truncateToWidth(inlineHint, availWidth)); displayText = before + marker + this.cursorOverride + hintText; @@ -646,16 +819,21 @@ export class Editor implements Component, Focusable { } } else { // Cursor is at the end - add thin cursor glyph - const cursorChar = this.#theme.symbols.inputCursor; - const cursor = `\x1b[5m${cursorChar}\x1b[0m`; - if (inlineHint) { - const availWidth = Math.max(0, lineContentWidth - displayWidth - visibleWidth(cursorChar)); + const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor(); + if (!borderVisible && 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); + displayText = widthLimitedCursor.text; + displayWidth = widthLimitedCursor.width; + } else if (inlineHint) { + const availWidth = Math.max(0, lineContentWidth - displayWidth - cursorWidth); const hintText = hintStyle(truncateToWidth(inlineHint, availWidth)); displayText = before + marker + cursor + hintText; - displayWidth += visibleWidth(cursorChar) + Math.min(visibleWidth(inlineHint), availWidth); + displayWidth += cursorWidth + Math.min(visibleWidth(inlineHint), availWidth); } else { displayText = before + marker + cursor; - displayWidth += visibleWidth(cursorChar); + displayWidth += cursorWidth; } if (displayWidth > lineContentWidth && paddingX > 0) { cursorInPadding = true; @@ -663,10 +841,15 @@ export class Editor implements Component, Focusable { } } - // All lines have consistent borders based on padding - const isLastLine = layoutLine === visibleLayoutLines[visibleLayoutLines.length - 1]; const linePad = padding(Math.max(0, lineContentWidth - displayWidth)); + if (!borderVisible) { + result.push(gutterText + displayText + linePad); + continue; + } + + // All lines have consistent borders based on padding + const isLastLine = visibleIndex === visibleLayoutLines.length - 1; const rightPaddingWidth = Math.max(0, paddingX - (cursorInPadding ? 1 : 0)); if (isLastLine) { const bottomRightPadding = Math.max(0, paddingX - 1 - (cursorInPadding ? 1 : 0)); @@ -1038,6 +1221,7 @@ export class Editor implements Component, Focusable { text: "", hasCursor: true, cursorPos: 0, + isLogicalLineStart: true, }); return layoutLines; } @@ -1055,11 +1239,13 @@ export class Editor implements Component, Focusable { text: line, hasCursor: true, cursorPos: this.#state.cursorCol, + isLogicalLineStart: true, }); } else { layoutLines.push({ text: line, hasCursor: false, + isLogicalLineStart: true, }); } } else { @@ -1103,11 +1289,13 @@ export class Editor implements Component, Focusable { text: chunk.text, hasCursor: true, cursorPos: adjustedCursorPos, + isLogicalLineStart: chunkIndex === 0, }); } else { layoutLines.push({ text: chunk.text, hasCursor: false, + isLogicalLineStart: chunkIndex === 0, }); } } diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index c271c1f04..8c04352f3 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "bun:test"; import { stripVTControlCharacters } from "node:util"; +import { CURSOR_MARKER } 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 { visibleWidth } from "@oh-my-pi/pi-tui/utils"; @@ -709,6 +710,379 @@ describe("Editor component", () => { } }); + it("keeps a persistent prompt gutter visible after typing in borderless mode", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setUseTerminalCursor(true); + + for (const char of "hello") { + editor.handleInput(char); + } + + const [line] = editor.render(20); + expect(stripVTControlCharacters(line!).startsWith("> hello")).toBeTrue(); + expect(visibleWidth(line!)).toBeLessThanOrEqual(20); + }); + + it("pads wrapped borderless lines to the prompt gutter width", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setUseTerminalCursor(true); + editor.setText("abcdefghij"); + + const lines = editor.render(10).map(line => stripVTControlCharacters(line)); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("> abcdefgh"); + expect(lines[1]).toBe(" ij "); + }); + + it("keeps the prompt gutter visible when it consumes the full borderless width", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + + let lines = editor.render(1).map(line => stripVTControlCharacters(line)); + expect(lines).toEqual([">"]); + expect(lines.every(line => visibleWidth(line) <= 1)).toBeTrue(); + + lines = editor.render(2).map(line => stripVTControlCharacters(line)); + expect(lines).toEqual([`>${defaultEditorTheme.symbols.inputCursor}`]); + expect(lines.every(line => visibleWidth(line) <= 2)).toBeTrue(); + + editor.handleInput("a"); + + lines = editor.render(2).map(line => stripVTControlCharacters(line)); + expect(lines).toEqual([`>${defaultEditorTheme.symbols.inputCursor}`]); + expect(lines.every(line => visibleWidth(line) <= 2)).toBeTrue(); + }); + + it("keeps cursor-following movement stable when the prompt gutter consumes the full borderless width", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setMaxHeight(2); + editor.focused = true; + editor.setText("a\nb\nc"); + + let lines = editor.render(2); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("> "); + expect(lines[1]).toBe(` \x1b[5m${defaultEditorTheme.symbols.inputCursor}\x1b[0m${CURSOR_MARKER}`); + + editor.handleInput("\x1b[A"); + + expect(editor.getCursor()).toEqual({ line: 1, col: 1 }); + lines = editor.render(2); + expect(lines).toEqual([`>\x1b[5m${defaultEditorTheme.symbols.inputCursor}\x1b[0m${CURSOR_MARKER}`, " "]); + }); + + it("keeps the prompt gutter visible at the borderless width limit", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.focused = true; + const width = 20; + + for (let i = 0; i < width - 2; i++) { + editor.handleInput("a"); + } + + const [line] = editor.render(width); + expect(stripVTControlCharacters(line!).startsWith("> ")).toBeTrue(); + expect(line).toContain(`\x1b[7ma\x1b[0m${CURSOR_MARKER}`); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("keeps the prompt gutter visible on the first rendered row after scrolling", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setMaxHeight(3); + editor.setText("l0\nl1\nl2\nl3"); + + const lines = editor.render(10).map(line => stripVTControlCharacters(line)); + expect(lines).toHaveLength(3); + expect(lines[0]?.startsWith("> l1")).toBeTrue(); + expect(lines.slice(1).every(line => line.startsWith(" "))).toBeTrue(); + expect(lines.every(line => visibleWidth(line) <= 10)).toBeTrue(); + }); + + it("keeps the prompt gutter visible when scrolling starts on a wrapped continuation chunk", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setUseTerminalCursor(true); + editor.setMaxHeight(2); + editor.setText("abcdefghijklmno\nz"); + + const lines = editor.render(10).map(line => stripVTControlCharacters(line)); + expect(lines).toHaveLength(2); + expect(lines[0]).toBe("> ijklmno "); + expect(lines[1]).toBe(" z "); + expect(lines.every(line => visibleWidth(line) <= 10)).toBeTrue(); + }); + + it("does not overflow width in borderless mode when the cursor reaches the line edge", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + const width = 20; + + for (let i = 0; i < width; i++) { + editor.handleInput("a"); + } + + const lines = editor.render(width); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); + }); + + it("clamps the terminal cursor marker inside a full-width borderless row", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setUseTerminalCursor(true); + editor.focused = true; + const width = 3; + editor.setText("abc"); + + const [line] = editor.render(width); + const [beforeMarker] = line!.split(CURSOR_MARKER); + + expect(line).toContain(CURSOR_MARKER); + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe("abc"); + expect(visibleWidth(beforeMarker!)).toBe(width - 1); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBe(width); + }); + + it("clamps the terminal cursor marker inside a full-width borderless prompt-gutter row", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setUseTerminalCursor(true); + editor.focused = true; + const width = 5; + editor.setText("abc"); + + const [line] = editor.render(width); + const [beforeMarker] = line!.split(CURSOR_MARKER); + + expect(line).toContain(CURSOR_MARKER); + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe("> abc"); + expect(visibleWidth(beforeMarker!)).toBe(width - 1); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBe(width); + }); + + it("does not overflow prompt-gutter wraps when a wide grapheme lands in a 1-column content area", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + const width = 3; + editor.setText("好a"); + + const lines = editor.render(width).map(line => stripVTControlCharacters(line.replaceAll(CURSOR_MARKER, ""))); + + expect(lines).toEqual(["> ", " a"]); + expect(lines.every(line => visibleWidth(line) <= width)).toBeTrue(); + }); + + it("clamps terminal-cursor rows when a wide grapheme lands in a 1-column prompt-gutter content area", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.setUseTerminalCursor(true); + editor.focused = true; + const width = 3; + editor.setText("好"); + + const [line] = editor.render(width); + + expect(line).toContain(CURSOR_MARKER); + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe("> "); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("keeps a visible cursor marker when a focused borderless line is full width", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.focused = true; + const width = 20; + + for (let i = 0; i < width; i++) { + editor.handleInput("a"); + } + + const [line] = editor.render(width); + expect(line).toContain(`\x1b[7ma\x1b[0m${CURSOR_MARKER}`); + expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("preserves cursorOverride at the borderless width limit", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.cursorOverride = "\x1b[35m~\x1b[0m"; + editor.cursorOverrideWidth = 1; + editor.focused = true; + const width = 20; + + for (let i = 0; i < width; i++) { + editor.handleInput("a"); + } + + const [line] = editor.render(width); + expect(line).toContain(`${editor.cursorOverride}${CURSOR_MARKER}`); + expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("keeps the cursor marker at the full width when cursorOverride replaces a wide trailing glyph", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.cursorOverride = "\x1b[35m~\x1b[0m"; + editor.cursorOverrideWidth = 1; + editor.focused = true; + const width = 20; + + editor.setText("aaaaaaaaaaaaaaaaaa✅"); + + const [line] = editor.render(width); + const beforeMarker = line.split(CURSOR_MARKER)[0]; + expect(line).toContain(`${editor.cursorOverride}${CURSOR_MARKER}`); + expect(visibleWidth(beforeMarker!)).toBe(width); + expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("preserves visible trailing text when a wide cursorOverride cannot fit on a narrow borderless line", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.cursorOverride = "好"; + editor.cursorOverrideWidth = 2; + editor.focused = true; + const width = 1; + editor.setText("a"); + + const [line] = editor.render(width); + + expect(line).toContain(CURSOR_MARKER); + expect(stripVTControlCharacters(line.replaceAll(CURSOR_MARKER, ""))).toBe("a"); + expect(visibleWidth(line.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("keeps a visible fake cursor when the prompt gutter consumes the full borderless width", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.focused = true; + + const [line] = editor.render(2); + + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( + `>${defaultEditorTheme.symbols.inputCursor}`, + ); + expect(line).toContain(CURSOR_MARKER); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(2); + }); + + it("renders a fitting cursorOverride after the prompt glyph in a zero-content prompt gutter row", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.cursorOverride = "\x1b[35m~\x1b[0m"; + editor.cursorOverrideWidth = 1; + editor.focused = true; + const width = 2; + + const [line] = editor.render(width); + + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe(">~"); + expect(line).toContain(`${editor.cursorOverride}${CURSOR_MARKER}`); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("highlights the only visible prompt-gutter cell when the zero-content prompt gutter truncates to one visible cell", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + const width = 1; + + const [baselineLine] = editor.render(width); + const visibleCell = stripVTControlCharacters(baselineLine!); + editor.focused = true; + + const [line] = editor.render(width); + + expect(line).toBe(`\x1b[7m${visibleCell}\x1b[0m${CURSOR_MARKER}`); + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe(visibleCell); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("preserves the prompt glyph when a wide cursorOverride hits the zero-content prompt gutter", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPromptGutter("> "); + editor.cursorOverride = "好"; + editor.cursorOverrideWidth = 2; + editor.focused = true; + const width = 2; + + const [line] = editor.render(width); + + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( + `>${defaultEditorTheme.symbols.inputCursor}`, + ); + expect(line).toContain(CURSOR_MARKER); + expect(stripVTControlCharacters(line!)).not.toContain("好"); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("falls back to a visible cursor when a wide cursorOverride cannot fit on an empty narrow borderless line", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.cursorOverride = "好"; + editor.cursorOverrideWidth = 2; + editor.focused = true; + const width = 1; + + const [line] = editor.render(width); + + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( + defaultEditorTheme.symbols.inputCursor, + ); + expect(line).toContain(CURSOR_MARKER); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("falls back to the built-in cursor when a wide trailing grapheme cannot fit on a narrow borderless line", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.focused = true; + const width = 1; + editor.setText("好"); + + const [line] = editor.render(width); + + expect(stripVTControlCharacters(line!.replaceAll(CURSOR_MARKER, ""))).toBe( + defaultEditorTheme.symbols.inputCursor, + ); + expect(line).toContain(CURSOR_MARKER); + expect(stripVTControlCharacters(line!)).not.toContain("好"); + expect(visibleWidth(line!.replaceAll(CURSOR_MARKER, ""))).toBeLessThanOrEqual(width); + }); + + it("uses the full width in borderless mode when horizontal padding is zero", () => { + const editor = new Editor(defaultEditorTheme); + editor.setBorderVisible(false); + editor.setPaddingX(0); + const width = 20; + + for (let i = 0; i < width; i++) { + editor.handleInput("a"); + } + + const lines = editor.render(width); + expect(lines).toHaveLength(1); + expect(visibleWidth(lines[0]!)).toBeLessThanOrEqual(width); + }); + it("does not exceed terminal width with emoji at wrap boundary", () => { const editor = new Editor(defaultEditorTheme); const width = 11;