Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
13 kB · 349 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350import type { AutocompleteProvider, EditorTheme, OverlayHandle, TUI } from "@earendil-works/pi-tui";import { CURSOR_MARKER, setKeybindings, visibleWidth } from "@earendil-works/pi-tui";import { beforeEach, describe, expect, it, vi } from "vitest";import { KeybindingsManager } from "../src/core/keybindings.js";import { CustomEditor } from "../src/modes/interactive/components/custom-editor.js";import { initTheme, type ThemeColor, theme } from "../src/modes/interactive/theme/theme.js";
const passthrough = (text: string) => text;
const editorTheme: EditorTheme = { borderColor: passthrough, selectList: { selectedPrefix: passthrough, selectedText: passthrough, description: passthrough, scrollInfo: passthrough, noMatch: passthrough, },};
const fakeOverlayHandle: OverlayHandle = { hide: vi.fn(), setHidden: vi.fn(), isHidden: () => false, focus: vi.fn(), unfocus: vi.fn(), isFocused: () => false,};
const fakeTui = { requestRender: vi.fn(), showOverlay: vi.fn(() => fakeOverlayHandle), terminal: { rows: 24, columns: 80 },} as unknown as TUI;
const autocompleteProvider: AutocompleteProvider = { async getSuggestions() { return { prefix: "/", items: [ { value: "/help", label: "/help" }, { value: "/hotkeys", label: "/hotkeys" }, ], }; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { const line = lines[cursorLine] ?? ""; const before = line.slice(0, cursorCol - prefix.length); const after = line.slice(cursorCol); const nextLines = [...lines]; nextLines[cursorLine] = before + item.value + after; return { lines: nextLines, cursorLine, cursorCol: before.length + item.value.length, }; },};
describe("CustomEditor", () => { beforeEach(() => { setKeybindings(new KeybindingsManager()); vi.clearAllMocks(); });
it("cancels autocomplete before handling app.clear", async () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const handler = vi.fn();
editor.setAutocompleteProvider(autocompleteProvider); editor.onAction("app.clear", handler); editor.handleInput("/"); await vi.waitFor(() => expect(editor.isShowingAutocomplete()).toBe(true));
editor.handleInput("\x03");
expect(editor.isShowingAutocomplete()).toBe(false); expect(handler).toHaveBeenCalledTimes(1); expect(editor.getText()).toBe("/"); });
it("inserts a newline for a raw \\n byte instead of firing the ctrl+j edit-diff action", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const toggleEditDiffs = vi.fn(); editor.onAction("app.edits.expand", toggleEditDiffs);
editor.handleInput("a"); editor.handleInput("\n"); editor.handleInput("b");
expect(toggleEditDiffs).not.toHaveBeenCalled(); expect(editor.getText()).toBe("a\nb"); });
it("still fires the edit-diff action for kitty CSI-u ctrl+j", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const toggleEditDiffs = vi.fn(); editor.onAction("app.edits.expand", toggleEditDiffs);
editor.handleInput("\x1b[106;5u");
expect(toggleEditDiffs).toHaveBeenCalledOnce(); expect(editor.getText()).toBe(""); });
it("routes Escape through its handler while dismissing autocomplete", async () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const handler = vi.fn();
editor.setAutocompleteProvider(autocompleteProvider); editor.onEscape = handler; editor.handleInput("/"); await vi.waitFor(() => expect(editor.isShowingAutocomplete()).toBe(true));
editor.handleInput("\x1b");
expect(editor.isShowingAutocomplete()).toBe(false); expect(handler).toHaveBeenCalledTimes(1); });
it("handles the question-mark shortcut as an app action", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const handler = vi.fn();
editor.onAction("app.shortcuts", handler); editor.handleInput("?");
expect(handler).toHaveBeenCalledTimes(1); expect(editor.getText()).toBe(""); });
it("keeps question marks in a nonempty prompt", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const handler = vi.fn(); editor.onAction("app.shortcuts", handler); editor.setText("Can this contain");
editor.handleInput("?");
expect(handler).not.toHaveBeenCalled(); expect(editor.getText()).toBe("Can this contain?"); });
it("splits terminal-batched repeats for the configured clear-input binding", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const handler = vi.fn(); editor.onEscape = handler;
editor.handleInput("\x1b\x1b");
expect(handler).toHaveBeenCalledTimes(2); });
it("splits arbitrary terminal-batched clear-input repeats", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const handler = vi.fn(); editor.onEscape = handler;
editor.handleInput("\x1b\x1b\x1b");
expect(handler).toHaveBeenCalledTimes(3); });
it("uses custom clear-input bindings when splitting batched repeats", () => { const keybindings = new KeybindingsManager({ "app.input.clear": "ctrl+x" }); const editor = new CustomEditor(fakeTui, editorTheme, keybindings); const handler = vi.fn(); editor.onEscape = handler;
editor.handleInput("\x18\x18");
expect(handler).toHaveBeenCalledTimes(2); });
it("renders the editor caret before an empty prompt placeholder", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), { placeholder: "type to start", }); editor.focused = true;
const line = editor.render(40)[1]!;
expect(line).toContain(`${CURSOR_MARKER}\x1b[7m \x1b[0mtype to start`); expect(visibleWidth(line)).toBe(40); });
it("omits the hardware cursor marker when the placeholder editor is unfocused", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), { placeholder: "type to start", });
const line = editor.render(40)[1]!;
expect(line).not.toContain(CURSOR_MARKER); expect(line).toContain("\x1b[7m \x1b[0mtype to start"); });
it("suppresses the hardware cursor marker while placeholder autocomplete is visible", async () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), { placeholder: "type to start", }); editor.focused = true; editor.setAutocompleteProvider(autocompleteProvider);
editor.handleInput("\t"); await vi.waitFor(() => expect(editor.isShowingAutocomplete()).toBe(true)); const line = editor.render(40)[1]!;
expect(line).not.toContain(CURSOR_MARKER); expect(line).toContain("\x1b_pi:autocomplete:"); expect(line).toContain("\x1b[7m \x1b[0mtype to start"); });
it("preserves the editor background and exact width around placeholder carets", () => { const backgroundColor = (text: string) => `\x1b[48;5;234m${text}\x1b[49m`; const editor = new CustomEditor(fakeTui, { ...editorTheme, backgroundColor }, new KeybindingsManager(), { paddingX: 2, placeholder: "type to start", }); editor.focused = true;
for (const width of [1, 2, 3, 4, 8, 40]) { const line = editor.render(width)[1]!;
expect(line).toContain(`${CURSOR_MARKER}\x1b[7m \x1b[27m`); expect(visibleWidth(line)).toBe(width); } });
it("keeps the placeholder caret on the input row below a header", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), { placeholder: "reply to agent", }); editor.focused = true; editor.getHeaderLine = () => "6h last agent response";
const lines = editor.render(40);
expect(lines[1]).toContain("6h last agent response"); expect(lines[1]).not.toContain(CURSOR_MARKER); expect(lines[2]).not.toContain(CURSOR_MARKER); expect(lines[3]).toContain(`${CURSOR_MARKER}\x1b[7m \x1b[0mreply to agent`); });
it("renders a header line and blank spacer inside the top of the editor box", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const withoutHeader = editor.render(40);
editor.getHeaderLine = () => "6h last agent response"; const lines = editor.render(40);
expect(lines.length).toBe(withoutHeader.length + 2); expect(lines[0]).toBe(withoutHeader[0]); expect(lines[1]).toContain("6h last agent response"); expect(lines[2]!.trim()).toBe(""); expect(lines.slice(3)).toEqual(withoutHeader.slice(1)); });
it("truncates the header line to the editor width", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); editor.getHeaderLine = () => "x".repeat(100); const lines = editor.render(40);
expect(lines[1]).toContain("x".repeat(37)); expect(lines[1]).not.toContain("x".repeat(38)); expect(lines[1]).toContain("..."); });
it("keeps the surface background across truncation resets in the header line", () => { const backgroundColor = (text: string) => `<bg>${text}</bg>`; const editor = new CustomEditor(fakeTui, { ...editorTheme, backgroundColor }, new KeybindingsManager()); editor.getHeaderLine = () => `\x1b[31m${"x".repeat(100)}`; const lines = editor.render(40);
const segments = lines[1]!.split("\x1b[0m"); expect(segments.length).toBeGreaterThan(1); for (const segment of segments) { expect(segment.startsWith("<bg>")).toBe(true); expect(segment.endsWith("</bg>")).toBe(true); } });
const makeHighlightEditor = (text: string, options?: { isArgumentCommand?: (name: string) => boolean }) => { initTheme("dark"); const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager(), options); editor.setText(text); return editor; };
it.each<{ name: string; text: string; width: number; color: ThemeColor; has?: string[]; lacks?: string[] }>([ { name: "--flags in the input text", text: "/new --name @a.ts", width: 40, color: "mdLink", has: ["--name"] }, { name: "@paths in the input text", text: "/new --name @a.ts", width: 40, color: "success", has: ["@a.ts"] }, { name: "wrapped @path fragments across editor lines", text: "check @src/very-long-file-name.ts please", width: 16, color: "success", has: ["@src/very-lon", "g-file-name.t", "s"], }, { name: "quoted @paths across wrapped editor lines", text: 'open @"docs/some very long name.txt" now', width: 16, color: "success", has: ['@"docs/some ', "very long ", 'name.txt"'], }, { name: "no line bleed", text: "@abcde\nfoo bar", width: 11, color: "success", has: ["@abcde"], lacks: ["foo"] }, ])("highlights $name", ({ text, width, color, has, lacks }) => { const rendered = makeHighlightEditor(text).render(width).join("\n");
for (const fragment of has ?? []) expect(rendered).toContain(theme.fg(color, fragment)); for (const fragment of lacks ?? []) expect(rendered).not.toContain(theme.fg(color, fragment)); });
it("highlights a bare -- separator only for argument commands", () => { const options = { isArgumentCommand: (name: string) => name === "new" }; const separator = theme.fg("mdLink", "--");
expect(makeHighlightEditor("/new --name bla -- hello", options).render(60).join("\n")).toContain(separator); expect(makeHighlightEditor("this -- however -- is fine", options).render(60).join("\n")).not.toContain(separator); expect(makeHighlightEditor("/unknown -- hello", options).render(60).join("\n")).not.toContain(separator); });
it("does not mis-color visible text matching a scrolled-away token", () => { // 9 lines with the cursor at the end scroll @foo out of view; the visible plain "foo" must stay uncolored. const rendered = makeHighlightEditor("@foo\nhidden\nfoo\nl3\nl4\nl5\nl6\nl7\nl8").render(20).join("\n");
expect(rendered).toContain("↑ 2 more"); expect(rendered).not.toContain(theme.fg("success", "foo")); });
it("keeps the token tail colored when the cursor sits inside the token", () => { const editor = makeHighlightEditor("check @src/foo.ts"); editor.handleInput("\x1b[D"); editor.handleInput("\x1b[D");
// The cursor's full reset sits before the final "s"; the tail must be re-colored. expect(editor.render(40)[1]!).toContain(`\x1b[0m${theme.fg("success", "s")}`); });
it("renders no header when the callback returns undefined", () => { const editor = new CustomEditor(fakeTui, editorTheme, new KeybindingsManager()); const withoutCallback = editor.render(40);
editor.getHeaderLine = () => undefined;
expect(editor.render(40)).toEqual(withoutCallback); });});