83d0dc39fc
- Corrected assertion in hook-editor test to verify external editor keybinding is rendered.
347 lines
10 KiB
TypeScript
347 lines
10 KiB
TypeScript
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).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();
|
|
});
|
|
});
|