- Replaced usage of `ReturnType<typeof setTimeout>` and `ReturnType<typeof setInterval>` with the explicit `Timer` type across the codebase. - Updated several type definitions and function signatures to use concrete types instead of inferred return types for improved clarity and maintainability.
510 lines
16 KiB
TypeScript
510 lines
16 KiB
TypeScript
import { afterEach, beforeAll, describe, expect, it, type Mock, 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");
|
|
}
|
|
|
|
function largePasteText(): string {
|
|
return Array.from({ length: 11 }, (_, index) => `pasted line ${index + 1}`).join("\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: Mock;
|
|
requestRender: Mock;
|
|
};
|
|
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("submits Ctrl+Enter variants with NumLock or keypad Enter metadata", () => {
|
|
const variants = ["\x1b[13;133u", "\x1b[57414;5u", "\x1b[57414;133u"];
|
|
|
|
for (const variant of variants) {
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
const component = new HookEditorComponent(createTui(), "Prompt", "draft", onSubmit, onCancel);
|
|
|
|
component.handleInput(variant);
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith("draft");
|
|
expect(onCancel).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("submits LF-prefixed modified Enter sequences", () => {
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
const component = new HookEditorComponent(createTui(), "Prompt", "draft", onSubmit, onCancel);
|
|
|
|
component.handleInput("\n\x1b[13;5u");
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith("draft");
|
|
expect(onCancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("expands large paste markers when submitting on Ctrl+Enter", () => {
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel);
|
|
const pasted = largePasteText();
|
|
|
|
component.handleInput(`\x1b[200~${pasted}\x1b[201~`);
|
|
|
|
expect(renderText(component)).toContain("[Paste #1, +11 lines]");
|
|
|
|
component.handleInput("\x1b[13;5u");
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith(pasted);
|
|
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("absorbs enhanced-paste payloads delivered via pasteText (kitty OSC 5522 routing)", () => {
|
|
// Regression: pasting into the ask tool's "Other" editor on OSC 5522
|
|
// terminals routed the payload to the hidden main prompt, because the
|
|
// enhanced-paste focus routing only targets components exposing a
|
|
// `pasteText` hook and the dialog wrapper had none (#2127 contract).
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, {
|
|
promptStyle: true,
|
|
});
|
|
const pasted = largePasteText();
|
|
|
|
component.pasteText(pasted);
|
|
|
|
expect(renderText(component)).toContain("[Paste #1, +11 lines]");
|
|
|
|
component.handleInput("\r");
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith(pasted);
|
|
expect(onCancel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("expands large paste markers when submitting on Enter", () => {
|
|
const onSubmit = vi.fn();
|
|
const onCancel = vi.fn();
|
|
const component = new HookEditorComponent(createTui(), "Prompt", undefined, onSubmit, onCancel, {
|
|
promptStyle: true,
|
|
});
|
|
const pasted = largePasteText();
|
|
|
|
component.handleInput(`\x1b[200~${pasted}\x1b[201~`);
|
|
|
|
expect(renderText(component)).toContain("[Paste #1, +11 lines]");
|
|
|
|
component.handleInput("\r");
|
|
|
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
|
expect(onSubmit).toHaveBeenCalledWith(pasted);
|
|
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).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();
|
|
});
|
|
});
|
|
|
|
describe("ExtensionUiController dialog serialization", () => {
|
|
type SelectorController = {
|
|
showHookSelector: (
|
|
title: string,
|
|
options: string[],
|
|
dialogOptions?: { signal?: AbortSignal },
|
|
) => Promise<string | undefined>;
|
|
};
|
|
|
|
it("queues a second selector instead of clobbering the open one", async () => {
|
|
const { ctx, editor, editorContainer } = createControllerContext();
|
|
const controller = new ExtensionUiController(ctx) as unknown as SelectorController;
|
|
|
|
const abortA = new AbortController();
|
|
const abortB = new AbortController();
|
|
|
|
const promiseA = controller.showHookSelector("A", ["a1", "a2"], { signal: abortA.signal });
|
|
// First dialog is presented synchronously on the shared surface.
|
|
const componentA = ctx.hookSelector;
|
|
expect(componentA).toBeDefined();
|
|
expect(editorContainer.children).toEqual([componentA]);
|
|
|
|
const promiseB = controller.showHookSelector("B", ["b1", "b2"], { signal: abortB.signal });
|
|
// The second request must NOT swap itself into the surface while A is open —
|
|
// that orphaning is exactly the hang this serialization fixes.
|
|
expect(ctx.hookSelector).toBe(componentA);
|
|
expect(editorContainer.children).toEqual([componentA]);
|
|
|
|
// Resolving A hands the surface to the queued B.
|
|
abortA.abort();
|
|
await Bun.sleep(0);
|
|
expect(await promiseA).toBeUndefined();
|
|
const componentB = ctx.hookSelector;
|
|
expect(componentB).toBeDefined();
|
|
expect(componentB).not.toBe(componentA);
|
|
expect(editorContainer.children).toEqual([componentB]);
|
|
|
|
// Resolving B restores the core editor.
|
|
abortB.abort();
|
|
await Bun.sleep(0);
|
|
expect(await promiseB).toBeUndefined();
|
|
expect(ctx.hookSelector).toBeUndefined();
|
|
expect(editorContainer.children).toEqual([editor]);
|
|
});
|
|
|
|
it("never presents a queued selector whose signal aborts before its turn", async () => {
|
|
const { ctx, editor, editorContainer } = createControllerContext();
|
|
const controller = new ExtensionUiController(ctx) as unknown as SelectorController;
|
|
|
|
const abortA = new AbortController();
|
|
const abortB = new AbortController();
|
|
|
|
const promiseA = controller.showHookSelector("A", ["a1"], { signal: abortA.signal });
|
|
const componentA = ctx.hookSelector;
|
|
const promiseB = controller.showHookSelector("B", ["b1"], { signal: abortB.signal });
|
|
|
|
// Abort the queued B before A releases the surface.
|
|
abortB.abort();
|
|
expect(await promiseB).toBeUndefined();
|
|
// A is untouched and still owns the surface.
|
|
expect(ctx.hookSelector).toBe(componentA);
|
|
expect(editorContainer.children).toEqual([componentA]);
|
|
|
|
// When A resolves, the skipped B must not be shown — surface returns to editor.
|
|
abortA.abort();
|
|
await Bun.sleep(0);
|
|
expect(await promiseA).toBeUndefined();
|
|
expect(ctx.hookSelector).toBeUndefined();
|
|
expect(editorContainer.children).toEqual([editor]);
|
|
});
|
|
});
|