fix(ask): keep multiline custom input in prompt gutter

This commit is contained in:
Vu Anh Nguyen
2026-03-26 16:13:53 +07:00
parent 0675ecd7f9
commit 99dabb7c18
14 changed files with 1608 additions and 158 deletions
+1
View File
@@ -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
@@ -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<string | undefined>;
editor(
title: string,
prefill?: string,
dialogOptions?: ExtensionUIDialogOptions,
editorOptions?: { promptStyle?: boolean },
): Promise<string | undefined>;
/** Set a custom editor component via factory function, or undefined to restore the default editor. */
setEditorComponent(
@@ -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());
@@ -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<string | undefined> {
const { promise, resolve } = Promise.withResolvers<string | undefined>();
let settled = false;
@@ -742,6 +744,7 @@ export class ExtensionUiController {
this.hideHookEditor();
finish(undefined);
},
editorOptions,
);
this.ctx.editorContainer.clear();
@@ -1387,8 +1387,13 @@ export class InteractiveMode implements InteractiveModeContext {
this.#extensionUiController.hideHookInput();
}
showHookEditor(title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions);
showHookEditor(
title: string,
prefill?: string,
dialogOptions?: ExtensionUIDialogOptions,
editorOptions?: { promptStyle?: boolean },
): Promise<string | undefined> {
return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions, editorOptions);
}
hideHookEditor(): void {
+78 -30
View File
@@ -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<string, PendingExtensionRequest>,
output: RpcOutput,
title: string,
prefill?: string,
dialogOptions?: ExtensionUIDialogOptions,
editorOptions?: { promptStyle?: boolean },
): Promise<string | undefined> {
if (dialogOptions?.signal?.aborted) return Promise.resolve(undefined);
const id = Snowflake.next() as string;
const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
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<never> {
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<string, PendingExtensionRequest>();
// Shutdown request flag (wrapped in object to allow mutation with const)
@@ -256,30 +321,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
return "";
}
async editor(title: string, prefill?: string, _dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined> {
const id = Snowflake.next() as string;
const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
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<string | undefined> {
return requestRpcEditor(this.pendingRequests, this.output, title, prefill, dialogOptions, editorOptions);
}
get theme(): Theme {
@@ -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;
+6 -1
View File
@@ -236,7 +236,12 @@ export interface InteractiveModeContext {
hideHookSelector(): void;
showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
hideHookInput(): void;
showHookEditor(title: string, prefill?: string, dialogOptions?: ExtensionUIDialogOptions): Promise<string | undefined>;
showHookEditor(
title: string,
prefill?: string,
dialogOptions?: ExtensionUIDialogOptions,
editorOptions?: { promptStyle?: boolean },
): Promise<string | undefined>;
hideHookEditor(): void;
showHookNotify(message: string, type?: "info" | "warning" | "error"): void;
showHookCustom<T>(
+43 -47
View File
@@ -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<SelectionResult, "selectedOptions" | "customInput">;
navigation?: NavigationControls;
multiline?: boolean;
}
interface UIContext {
@@ -150,11 +148,12 @@ interface UIContext {
helpText?: string;
},
): Promise<string | undefined>;
input(
prompt: string,
options_?: { signal?: AbortSignal; timeout?: number; onTimeout?: () => void },
editor(
title: string,
prefill?: string,
dialogOptions?: { signal?: AbortSignal },
editorOptions?: { promptStyle?: boolean },
): Promise<string | undefined>;
editor(title: string, prefill?: string): Promise<string | undefined>;
}
async function askSingleQuestion(
@@ -164,7 +163,7 @@ async function askSingleQuestion(
multi: boolean,
options: AskSingleQuestionOptions = {},
): Promise<SelectionResult> {
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<typeof askSchema, AskToolDetails> {
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<typeof askSchema, AskToolDetails> {
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<typeof askSchema, AskToolDetails> {
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")}`;
}
@@ -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<typeof vi.fn>;
requestRender: ReturnType<typeof vi.fn>;
};
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<string | undefined>;
};
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<string | undefined>;
};
// 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();
});
});
@@ -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<string, PendingExtensionRequest>();
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<string, PendingExtensionRequest>();
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);
});
});
+394 -36
View File
@@ -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> = {}): ToolSession {
@@ -31,13 +31,11 @@ function createContext(args: {
onRight?: () => void;
},
) => Promise<string | undefined>;
input?: (
prompt: string,
dialogOptions?: {
timeout?: number;
signal?: AbortSignal;
onTimeout?: () => void;
},
editor?: (
title: string,
prefill?: string,
dialogOptions?: { signal?: AbortSignal },
editorOptions?: { promptStyle?: boolean },
) => Promise<string | undefined>;
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<string, unknown>).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<string | undefined>((_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);
});
});
+219 -31
View File
@@ -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,
});
}
}
+374
View File
@@ -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;