Merge review/pr-538-fix
This commit is contained in:
@@ -74,6 +74,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
|
||||
|
||||
|
||||
@@ -161,7 +161,12 @@ export interface ExtensionUIContext {
|
||||
getEditorText(): string;
|
||||
|
||||
/** Show a multi-line editor for text editing. */
|
||||
editor(title: string, prefill?: string): 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(
|
||||
|
||||
@@ -120,7 +120,7 @@ export interface HookUIContext {
|
||||
* @param prefill - Optional initial text
|
||||
* @returns Edited text, or undefined if cancelled (Escape)
|
||||
*/
|
||||
editor(title: string, prefill?: string): Promise<string | undefined>;
|
||||
editor(title: string, prefill?: string, options?: { signal?: AbortSignal }): Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Get the current theme for styling text with ANSI codes.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -8,11 +12,17 @@ import { matchesAppInterrupt } from "../../modes/utils/keybinding-matchers";
|
||||
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,
|
||||
@@ -20,23 +30,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);
|
||||
}
|
||||
@@ -44,17 +60,50 @@ 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 {
|
||||
// Prompt-style keeps Escape as an explicit cancel key and also honors app.interrupt remaps.
|
||||
if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesAppInterrupt(keyData)) {
|
||||
this.#onCancelCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+G for external editor
|
||||
if (matchesKey(keyData, "ctrl+g")) {
|
||||
void this.#openExternalEditor();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on any plain Enter encoding, including terminals that report unmodified Enter as LF.
|
||||
if (matchesKey(keyData, "enter") || matchesKey(keyData, "return")) {
|
||||
this.#onSubmitCallback(this.#editor.getText());
|
||||
return;
|
||||
}
|
||||
|
||||
// Let Editor handle modified newline-producing variants (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());
|
||||
|
||||
@@ -50,7 +50,8 @@ export class ExtensionUiController {
|
||||
this.ctx.editor.handleInput(`\x1b[200~${text}\x1b[201~`);
|
||||
},
|
||||
getEditorText: () => this.ctx.editor.getText(),
|
||||
editor: (title, prefill) => this.showHookEditor(title, prefill),
|
||||
editor: (title, prefill, dialogOptions, editorOptions) =>
|
||||
this.showHookEditor(title, prefill, dialogOptions, editorOptions),
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
@@ -783,26 +784,53 @@ export class ExtensionUiController {
|
||||
/**
|
||||
* Show a multi-line editor for hooks (with Ctrl+G support).
|
||||
*/
|
||||
showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
showHookEditor(
|
||||
title: string,
|
||||
prefill?: string,
|
||||
dialogOptions?: ExtensionUIDialogOptions,
|
||||
editorOptions?: { promptStyle?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
||||
let settled = false;
|
||||
const onAbort = () => {
|
||||
this.hideHookEditor();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
const finish = (value: string | undefined) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
dialogOptions?.signal?.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
};
|
||||
this.ctx.hookEditor = new HookEditorComponent(
|
||||
this.ctx.ui,
|
||||
title,
|
||||
prefill,
|
||||
value => {
|
||||
this.hideHookEditor();
|
||||
resolve(value);
|
||||
finish(value);
|
||||
},
|
||||
() => {
|
||||
this.hideHookEditor();
|
||||
resolve(undefined);
|
||||
finish(undefined);
|
||||
},
|
||||
editorOptions,
|
||||
);
|
||||
|
||||
this.ctx.editorContainer.clear();
|
||||
this.ctx.editorContainer.addChild(this.ctx.hookEditor);
|
||||
this.ctx.ui.setFocus(this.ctx.hookEditor);
|
||||
this.ctx.ui.requestRender();
|
||||
if (dialogOptions?.signal) {
|
||||
if (dialogOptions.signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
||||
@@ -1399,8 +1399,13 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.#extensionUiController.hideHookInput();
|
||||
}
|
||||
|
||||
showHookEditor(title: string, prefill?: string): Promise<string | undefined> {
|
||||
return this.#extensionUiController.showHookEditor(title, prefill);
|
||||
showHookEditor(
|
||||
title: string,
|
||||
prefill?: string,
|
||||
dialogOptions?: ExtensionUIDialogOptions,
|
||||
editorOptions?: { promptStyle?: boolean },
|
||||
): Promise<string | undefined> {
|
||||
return this.#extensionUiController.showHookEditor(title, prefill, dialogOptions, editorOptions);
|
||||
}
|
||||
|
||||
hideHookEditor(): void {
|
||||
|
||||
@@ -29,6 +29,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.
|
||||
@@ -55,12 +126,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)
|
||||
@@ -261,30 +326,13 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||
return "";
|
||||
}
|
||||
|
||||
async editor(title: string, prefill?: string): 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;
|
||||
|
||||
@@ -243,7 +243,12 @@ export interface InteractiveModeContext {
|
||||
hideHookSelector(): void;
|
||||
showHookInput(title: string, placeholder?: string): Promise<string | undefined>;
|
||||
hideHookInput(): void;
|
||||
showHookEditor(title: string, prefill?: string): 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>(
|
||||
|
||||
@@ -8,6 +8,7 @@ Asks user when you need clarification or input during task execution.
|
||||
- Use `recommended: <index>` to mark default (0-indexed); " (Recommended)" added automatically
|
||||
- Use `questions` for multiple related questions instead of asking one at a time
|
||||
- Set `multi: true` on question to allow multiple selections
|
||||
- `ask.timeout` only applies while choosing options; once the user selects "Other (type your own)", there is no timeout
|
||||
</instruction>
|
||||
|
||||
<caution>
|
||||
|
||||
@@ -147,9 +147,11 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -207,15 +209,11 @@ async function askSingleQuestion(
|
||||
return { choice, timedOut: timeoutTriggered, navigation: navigationAction };
|
||||
};
|
||||
|
||||
const promptForInput = async (): Promise<{ input: string | undefined; timedOut: boolean }> => {
|
||||
let inputTimedOut = false;
|
||||
const onTimeout = () => {
|
||||
inputTimedOut = true;
|
||||
};
|
||||
const input = signal
|
||||
? await untilAborted(signal, () => ui.input("Enter your response:", { signal, timeout, onTimeout }))
|
||||
: await ui.input("Enter your response:", { signal, timeout, onTimeout });
|
||||
return { input, timedOut: inputTimedOut };
|
||||
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 };
|
||||
};
|
||||
|
||||
const promptWithProgress = navigation?.progressText ? `${question} (${navigation.progressText})` : question;
|
||||
@@ -264,9 +262,11 @@ async function askSingleQuestion(
|
||||
timedOut = true;
|
||||
break;
|
||||
}
|
||||
const inputResult = await promptForInput();
|
||||
if (inputResult.input) customInput = inputResult.input;
|
||||
if (inputResult.timedOut) timedOut = true;
|
||||
const customResult = await promptForCustomInput();
|
||||
if (customResult.input === undefined) {
|
||||
break;
|
||||
}
|
||||
customInput = customResult.input;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ async function askSingleQuestion(
|
||||
if (previouslySelected) {
|
||||
const selectedIndex = optionLabels.indexOf(previouslySelected);
|
||||
if (selectedIndex >= 0) initialIndex = selectedIndex;
|
||||
} else if (customInput) {
|
||||
} else if (customInput !== undefined) {
|
||||
initialIndex = displayLabels.length;
|
||||
}
|
||||
if (initialIndex !== undefined) {
|
||||
@@ -330,11 +330,13 @@ async function askSingleQuestion(
|
||||
}
|
||||
} else if (choice === OTHER_OPTION) {
|
||||
if (!selectTimedOut) {
|
||||
const inputResult = await promptForInput();
|
||||
if (inputResult.input) customInput = inputResult.input;
|
||||
if (inputResult.timedOut) timedOut = true;
|
||||
const customResult = await promptForCustomInput();
|
||||
if (customResult.input !== undefined) {
|
||||
customInput = customResult.input;
|
||||
selectedOptions = [];
|
||||
}
|
||||
// If editor was dismissed (undefined), keep prior selectedOptions/customInput intact
|
||||
}
|
||||
selectedOptions = [];
|
||||
} else {
|
||||
selectedOptions = [stripRecommendedSuffix(choice)];
|
||||
customInput = undefined;
|
||||
@@ -344,7 +346,7 @@ async function askSingleQuestion(
|
||||
}
|
||||
}
|
||||
|
||||
if (timedOut && selectedOptions.length === 0 && !customInput) {
|
||||
if (timedOut && selectedOptions.length === 0 && customInput === undefined) {
|
||||
selectedOptions = getAutoSelectionOnTimeout(optionLabels, recommended);
|
||||
}
|
||||
|
||||
@@ -352,15 +354,14 @@ async function askSingleQuestion(
|
||||
}
|
||||
|
||||
function formatQuestionResult(result: QuestionResult): string {
|
||||
if (result.customInput) {
|
||||
return `${result.id}: "${result.customInput}"`;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (result.selectedOptions.length > 0) {
|
||||
return result.multi
|
||||
? `${result.id}: [${result.selectedOptions.join(", ")}]`
|
||||
: `${result.id}: ${result.selectedOptions[0]}`;
|
||||
parts.push(result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0]!);
|
||||
}
|
||||
return `${result.id}: (cancelled)`;
|
||||
if (result.customInput !== undefined) {
|
||||
parts.push(`"${result.customInput}"`);
|
||||
}
|
||||
return parts.length > 0 ? `${result.id}: ${parts.join(", ")}` : `${result.id}: (cancelled)`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -415,7 +416,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, dialogOptions, editorOptions) =>
|
||||
extensionUi.editor(title, prefill, dialogOptions, editorOptions),
|
||||
};
|
||||
|
||||
// Determine timeout based on settings and plan mode
|
||||
@@ -467,7 +469,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
||||
const [q] = params.questions;
|
||||
const { optionLabels, selectedOptions, customInput, cancelled, timedOut } = await askQuestion(q);
|
||||
|
||||
if (!timedOut && (cancelled || (selectedOptions.length === 0 && !customInput))) {
|
||||
if (!timedOut && (cancelled || (selectedOptions.length === 0 && customInput === undefined))) {
|
||||
context.abort();
|
||||
throw new ToolAbortError("Ask tool was cancelled by the user");
|
||||
}
|
||||
@@ -479,16 +481,23 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
||||
customInput,
|
||||
};
|
||||
|
||||
let responseText: string;
|
||||
if (customInput) {
|
||||
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,6 +579,25 @@ interface AskRenderArgs {
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
includeLeadingNewline = true,
|
||||
): string {
|
||||
const lines = customInput.split("\n");
|
||||
const branch = isLastEntry ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const firstLine = lines[0] ?? "";
|
||||
let text = `${includeLeadingNewline ? "\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 = {
|
||||
renderCall(args: AskRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
const label = formatTitle("Ask", uiTheme);
|
||||
@@ -658,7 +686,7 @@ export const askToolRenderer = {
|
||||
// Multi-part results
|
||||
if (details.results && details.results.length > 0) {
|
||||
const hasAnySelection = details.results.some(
|
||||
r => r.customInput || (r.selectedOptions && r.selectedOptions.length > 0),
|
||||
r => r.customInput !== undefined || (r.selectedOptions && r.selectedOptions.length > 0),
|
||||
);
|
||||
const header = renderStatusLine(
|
||||
{
|
||||
@@ -676,7 +704,7 @@ export const askToolRenderer = {
|
||||
const isLastQuestion = i === details.results.length - 1;
|
||||
const branch = isLastQuestion ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const continuation = isLastQuestion ? " " : `${uiTheme.fg("dim", uiTheme.tree.vertical)} `;
|
||||
const hasSelection = r.customInput || r.selectedOptions.length > 0;
|
||||
const hasSelection = r.customInput !== undefined || r.selectedOptions.length > 0;
|
||||
const statusIcon = hasSelection
|
||||
? uiTheme.styledSymbol("status.success", "success")
|
||||
: uiTheme.styledSymbol("status.warning", "warning");
|
||||
@@ -686,23 +714,30 @@ export const askToolRenderer = {
|
||||
);
|
||||
container.addChild(new Markdown(r.question, 3, 0, mdTheme, accentStyle));
|
||||
|
||||
let answerText = "";
|
||||
if (r.customInput) {
|
||||
answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", r.customInput)}`;
|
||||
} else if (r.selectedOptions.length > 0) {
|
||||
for (let j = 0; j < r.selectedOptions.length; j++) {
|
||||
const isLast = j === r.selectedOptions.length - 1;
|
||||
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
|
||||
uiTheme.fg("toolOutput", t),
|
||||
);
|
||||
answerText += `\n${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
|
||||
}
|
||||
} else {
|
||||
answerText = `${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
|
||||
const answerLines: string[] = [];
|
||||
for (let j = 0; j < r.selectedOptions.length; j++) {
|
||||
const isLast = j === r.selectedOptions.length - 1 && r.customInput === undefined;
|
||||
const optBranch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const selectedLabel = renderInlineMarkdown(r.selectedOptions[j], mdTheme, t =>
|
||||
uiTheme.fg("toolOutput", t),
|
||||
);
|
||||
answerLines.push(
|
||||
`${continuation}${uiTheme.fg("dim", optBranch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
|
||||
);
|
||||
}
|
||||
if (answerText) {
|
||||
container.addChild(new Text(answerText, 0, 0));
|
||||
if (answerLines.length > 0) {
|
||||
container.addChild(new Text(answerLines.join("\n"), 0, 0));
|
||||
}
|
||||
if (r.customInput !== undefined) {
|
||||
container.addChild(new Text(renderCustomInput(uiTheme, continuation, r.customInput, true, false), 0, 0));
|
||||
} else if (r.selectedOptions.length === 0) {
|
||||
container.addChild(
|
||||
new Text(
|
||||
`${continuation}${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return container;
|
||||
@@ -715,28 +750,40 @@ export const askToolRenderer = {
|
||||
return new Text(fallback, 0, 0);
|
||||
}
|
||||
|
||||
const hasSelection = details.customInput || (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" }, uiTheme);
|
||||
const container = new Container();
|
||||
container.addChild(new Text(header, 0, 0));
|
||||
container.addChild(new Markdown(details.question, 1, 0, mdTheme, accentStyle));
|
||||
|
||||
let answerText = "";
|
||||
if (details.customInput) {
|
||||
answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", details.customInput)}`;
|
||||
} else if (details.selectedOptions && details.selectedOptions.length > 0) {
|
||||
const answerLines: string[] = [];
|
||||
if (details.selectedOptions && details.selectedOptions.length > 0) {
|
||||
for (let i = 0; i < details.selectedOptions.length; i++) {
|
||||
const isLast = i === details.selectedOptions.length - 1;
|
||||
const isLast = i === details.selectedOptions.length - 1 && details.customInput === undefined;
|
||||
const branch = isLast ? uiTheme.tree.last : uiTheme.tree.branch;
|
||||
const selectedLabel = renderInlineMarkdown(details.selectedOptions[i], mdTheme, t =>
|
||||
uiTheme.fg("toolOutput", t),
|
||||
);
|
||||
answerText += `\n ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`;
|
||||
answerLines.push(
|
||||
` ${uiTheme.fg("dim", branch)} ${uiTheme.fg("success", uiTheme.checkbox.checked)} ${selectedLabel}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
answerText = ` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`;
|
||||
}
|
||||
container.addChild(new Text(answerText, 0, 0));
|
||||
if (answerLines.length > 0) {
|
||||
container.addChild(new Text(answerLines.join("\n"), 0, 0));
|
||||
}
|
||||
if (details.customInput !== undefined) {
|
||||
container.addChild(new Text(renderCustomInput(uiTheme, " ", details.customInput, true, false), 0, 0));
|
||||
} else if (!details.selectedOptions || details.selectedOptions.length === 0) {
|
||||
container.addChild(
|
||||
new Text(
|
||||
` ${uiTheme.fg("dim", uiTheme.tree.last)} ${uiTheme.styledSymbol("status.warning", "warning")} ${uiTheme.fg("warning", "Cancelled")}`,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return container;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { KeybindingsManager } from "@oh-my-pi/pi-coding-agent/config/keybindings";
|
||||
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 { setKeybindings, 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);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setKeybindings(KeybindingsManager.inMemory());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
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("submits when a terminal reports plain Enter as LF", () => {
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).toHaveBeenCalledWith("a");
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it("cancels on app.interrupt in prompt-style mode even when remapped", () => {
|
||||
setKeybindings(
|
||||
KeybindingsManager.inMemory({
|
||||
"app.interrupt": "ctrl+c",
|
||||
}),
|
||||
);
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const component = new HookEditorComponent(createTui(), "Prompt", "draft", onSubmit, onCancel, {
|
||||
promptStyle: true,
|
||||
});
|
||||
|
||||
component.handleInput("\x03");
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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,356 @@ 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("continues multi-question flow when editor is dismissed on a fresh question", 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,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-editor-multi-dismiss", { questions }, undefined, undefined, context);
|
||||
|
||||
// Editor dismissed on "Details?" — flow continues with empty answer, not abort
|
||||
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["one"]);
|
||||
expect(result.details?.results?.[1]?.selectedOptions).toEqual([]);
|
||||
expect(result.details?.results?.[1]?.customInput).toBeUndefined();
|
||||
expect(editor).toHaveBeenCalledTimes(1);
|
||||
expect(abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("preserves prior multi-select answers when custom editor is dismissed", async () => {
|
||||
const tool = new AskTool(createSession());
|
||||
let step = 0;
|
||||
const editor = vi.fn(async () => undefined);
|
||||
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-dismiss",
|
||||
{
|
||||
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).toBeUndefined();
|
||||
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 selected: alpha");
|
||||
expect(editor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
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 +853,98 @@ 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);
|
||||
});
|
||||
|
||||
it("preserves prior single-select answer when custom editor is dismissed during navigation", async () => {
|
||||
const tool = new AskTool(createSession());
|
||||
let detailVisits = 0;
|
||||
const editor = vi.fn(async () => undefined);
|
||||
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 "short";
|
||||
// Second visit: try Other then dismiss editor, then forward
|
||||
if (detailVisits === 2) return "Other (type your own)";
|
||||
dialogOptions?.onRight?.();
|
||||
return undefined;
|
||||
}
|
||||
if (prompt.includes("Summary?")) {
|
||||
const summaryVisit = detailVisits;
|
||||
if (summaryVisit <= 2) {
|
||||
// Navigate back to re-visit details
|
||||
dialogOptions?.onLeft?.();
|
||||
return undefined;
|
||||
}
|
||||
return "two";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
editor,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-nav-single-dismiss", { questions }, undefined, undefined, context);
|
||||
|
||||
// The prior selection "short" should survive the editor dismiss
|
||||
expect(result.details?.results?.[0]?.selectedOptions).toEqual(["short"]);
|
||||
expect(result.details?.results?.[0]?.customInput).toBeUndefined();
|
||||
expect(result.details?.results?.[1]?.selectedOptions).toEqual(["two"]);
|
||||
expect(editor).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
moveWordLeft,
|
||||
moveWordRight,
|
||||
padding,
|
||||
sliceByColumn,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "../utils";
|
||||
@@ -22,6 +23,16 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
||||
maxPrimaryColumnWidth: 32,
|
||||
};
|
||||
|
||||
function sanitizeLoadedText(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.replace(/\t/g, " ")
|
||||
.split("")
|
||||
.filter(char => char === "\n" || char.charCodeAt(0) >= 32)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const segmenter = getSegmenter();
|
||||
|
||||
/**
|
||||
@@ -272,6 +283,7 @@ interface LayoutLine {
|
||||
text: string;
|
||||
hasCursor: boolean;
|
||||
cursorPos?: number;
|
||||
isLogicalLineStart: boolean;
|
||||
}
|
||||
|
||||
export interface EditorTheme {
|
||||
@@ -318,6 +330,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;
|
||||
@@ -374,6 +387,7 @@ export class Editor implements Component, Focusable {
|
||||
|
||||
// Custom top border (for status line integration)
|
||||
#topBorderContent?: EditorTopBorder;
|
||||
#borderVisible = true;
|
||||
|
||||
constructor(theme: EditorTheme) {
|
||||
this.#theme = theme;
|
||||
@@ -392,13 +406,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);
|
||||
}
|
||||
|
||||
@@ -492,7 +517,7 @@ export class Editor implements Component, Focusable {
|
||||
/** Internal setText that doesn't reset history state - used by navigateHistory */
|
||||
#setTextInternal(text: string, cursorAnchor: HistoryCursorAnchor = "end"): void {
|
||||
this.#undoStack.length = 0;
|
||||
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||
const lines = sanitizeLoadedText(text).split("\n");
|
||||
this.#state.lines = lines.length === 0 ? [""] : lines;
|
||||
if (cursorAnchor === "start") {
|
||||
this.#state.cursorLine = 0;
|
||||
@@ -515,18 +540,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 {
|
||||
@@ -555,13 +675,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))}`);
|
||||
@@ -575,23 +697,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
|
||||
@@ -603,15 +727,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);
|
||||
@@ -620,6 +792,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;
|
||||
}
|
||||
@@ -640,7 +814,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;
|
||||
@@ -651,16 +834,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;
|
||||
@@ -668,10 +856,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));
|
||||
@@ -1041,6 +1234,7 @@ export class Editor implements Component, Focusable {
|
||||
text: "",
|
||||
hasCursor: true,
|
||||
cursorPos: 0,
|
||||
isLogicalLineStart: true,
|
||||
});
|
||||
return layoutLines;
|
||||
}
|
||||
@@ -1058,11 +1252,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 {
|
||||
@@ -1106,11 +1302,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -502,6 +503,16 @@ describe("Editor component", () => {
|
||||
expect(text).toBe("Hällö Wörld! 😀 äöüÄÖÜß");
|
||||
});
|
||||
|
||||
it("strips control characters from programmatically loaded text before render", () => {
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
editor.setText("start\x1b[31mred\x1b[0m\u0007end");
|
||||
|
||||
expect(editor.getText()).toBe("start[31mred[0mend");
|
||||
expect(editor.getText()).not.toContain("\x1b");
|
||||
expect(editor.getText()).not.toContain("\u0007");
|
||||
expect(editor.render(80).join("\n")).not.toContain("\x1b[31m");
|
||||
});
|
||||
|
||||
it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => {
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
@@ -709,6 +720,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;
|
||||
|
||||
Reference in New Issue
Block a user