/** * KHAELOR * File: tests/tui/composer.test.ts * Description: Composer tests — pure editor state machine, spans, palettes, history, paste collapsing, shell mode. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { adjustSpans, backspace, deleteWordBack, expandSpans, insertText, killToLineStart, moveDown, moveLineEnd, moveLineStart, moveUp, wordLeft, wordRight, wrapWithCursor, } from "../../src/tui/composer/editor.js"; import type { EditorState, Span } from "../../src/tui/composer/editor.js"; import { Composer } from "../../src/tui/composer/composer.js"; import type { ComposerProviders } from "../../src/tui/composer/composer.js"; import { resolveTheme } from "../../src/tui/theme.js"; const mono = resolveTheme({ colorDepth: "mono" }); function ed(text: string, cursor: number): EditorState { return { text, cursor }; } describe("editor state machine", () => { it("inserts at the cursor and advances it", () => { expect(insertText(ed("ac", 1), "b")).toEqual({ text: "abc", cursor: 2 }); }); it("backspace deletes one code point (astral-safe)", () => { expect(backspace(ed("a🎉", 3), )).toEqual({ text: "a", cursor: 1 }); expect(backspace(ed("x", 0))).toEqual({ text: "x", cursor: 0 }); }); it("word navigation crosses separators", () => { const s = ed("foo bar-baz", 11); expect(wordLeft(s).cursor).toBe(8); // start of "baz" expect(wordRight(ed("foo bar", 0)).cursor).toBe(3); }); it("deletes the previous word including separators", () => { expect(deleteWordBack(ed("run npm test", 12))).toEqual({ text: "run npm ", cursor: 8 }); }); it("kill-to-line-start respects logical lines", () => { expect(killToLineStart(ed("ab\ncdef", 5))).toEqual({ text: "ab\nef", cursor: 3 }); }); it("line start/end operate on the current logical line", () => { expect(moveLineStart(ed("ab\ncdef", 5)).cursor).toBe(3); expect(moveLineEnd(ed("ab\ncdef", 4)).cursor).toBe(7); }); it("up/down move within multiline and signal edges with null", () => { expect(moveUp(ed("ab\ncd", 4))).toEqual({ text: "ab\ncd", cursor: 1 }); expect(moveUp(ed("ab\ncd", 1))).toBeNull(); // first line → history boundary expect(moveDown(ed("ab\ncd", 0))).toEqual({ text: "ab\ncd", cursor: 3 }); expect(moveDown(ed("ab\ncd", 4))).toBeNull(); // last line → history boundary }); }); describe("spans", () => { const span: Span = { start: 4, end: 10, kind: "paste", payload: "PAYLOAD" }; it("shift after insertions before them", () => { expect(adjustSpans([span], 0, 0, 2)).toEqual([{ ...span, start: 6, end: 12 }]); }); it("stay put for edits after them", () => { expect(adjustSpans([span], 10, 0, 5)).toEqual([span]); }); it("are dropped when an edit intersects them (degrade to plain text)", () => { expect(adjustSpans([span], 6, 2, 0)).toEqual([]); }); it("expand paste payloads at submit, keep mention display", () => { const text = "see ⧉paste and @src/a.ts"; const spans: Span[] = [ { start: 4, end: 10, kind: "paste", payload: "RAW\nPASTE" }, { start: 15, end: 25, kind: "mention", payload: "src/a.ts" }, ]; expect(expandSpans(text, spans)).toBe("see RAW\nPASTE and @src/a.ts"); }); }); describe("wrapWithCursor", () => { it("maps the cursor through hard wraps", () => { const w = wrapWithCursor("abcdefghij", 7, 4); expect(w.rows).toEqual(["abcd", "efgh", "ij"]); expect(w.caretRow).toBe(1); expect(w.caretCol).toBe(3); }); it("places the caret at the end of the last row", () => { const w = wrapWithCursor("abc", 3, 10); expect(w.caretRow).toBe(0); expect(w.caretCol).toBe(3); }); it("handles logical newlines", () => { const w = wrapWithCursor("ab\ncd", 3, 10); expect(w.rows).toEqual(["ab", "cd"]); expect(w.caretRow).toBe(1); expect(w.caretCol).toBe(0); }); }); function makeComposer(history: string[] = []): Composer { const providers: ComposerProviders = { slashCommands: () => [ { id: "sessions.open", label: "/sessions", detail: "browse sessions" }, { id: "diff.show", label: "/diff", detail: "view diff" }, { id: "cost.show", label: "/cost", detail: "session cost" }, ], mentions: (q) => ["src/kernel/agent.ts", "src/agents/agent-runtime.ts", "tests/agent.test.ts"] .filter((p) => q === "" || p.includes(q)) .map((p) => ({ id: p, label: p })), }; return new Composer(providers, history); } function type(c: Composer, text: string): void { for (const ch of text) c.handleKey({ type: "char", ch }); } describe("Composer", () => { it("submits text and resets", () => { const c = makeComposer(); type(c, "fix the bug"); const effect = c.handleKey({ type: "enter" }); expect(effect).toEqual({ type: "submit", text: "fix the bug", shell: false }); expect(c.isEmpty()).toBe(true); }); it("detects shell mode via the ! prefix", () => { const c = makeComposer(); type(c, "!git status"); expect(c.isShellMode()).toBe(true); const effect = c.handleKey({ type: "enter" }); expect(effect).toEqual({ type: "submit", text: "git status", shell: true }); }); it("continues the line on trailing backslash + Enter", () => { const c = makeComposer(); type(c, "first\\"); expect(c.handleKey({ type: "enter" })).toBeNull(); expect(c.text()).toBe("first\n"); }); it("opens the slash palette at offset 0 and runs the selection", () => { const c = makeComposer(); type(c, "/"); expect(c.hasPalette()).toBe(true); type(c, "co"); // filters to /cost const effect = c.handleKey({ type: "enter" }); expect(effect).toEqual({ type: "run-command", id: "cost.show" }); expect(c.isEmpty()).toBe(true); }); it("does not open the slash palette mid-text", () => { const c = makeComposer(); type(c, "a/"); expect(c.hasPalette()).toBe(false); }); it("inserts a mention span from the @ palette", () => { const c = makeComposer(); type(c, "refactor @kernel"); expect(c.hasPalette()).toBe(true); c.handleKey({ type: "enter" }); expect(c.text()).toBe("refactor @src/kernel/agent.ts "); expect(c.hasPalette()).toBe(false); }); it("closes the mention palette on whitespace", () => { const c = makeComposer(); type(c, "@x y"); expect(c.hasPalette()).toBe(false); }); it("collapses large pastes into a span and expands on submit", () => { const c = makeComposer(); const pasted = "line1\nline2\nline3\nline4"; c.handleKey({ type: "paste", text: pasted }); expect(c.text()).toBe("⧉ pasted 4 lines"); const effect = c.handleKey({ type: "enter" }); expect(effect).toEqual({ type: "submit", text: pasted, shell: false }); }); it("inserts small pastes literally", () => { const c = makeComposer(); c.handleKey({ type: "paste", text: "just words" }); expect(c.text()).toBe("just words"); }); it("navigates history at buffer edges only", () => { const c = makeComposer(["older", "newer"]); c.handleKey({ type: "arrow", key: "up", alt: false, ctrl: false }); expect(c.text()).toBe("newer"); c.handleKey({ type: "arrow", key: "up", alt: false, ctrl: false }); expect(c.text()).toBe("older"); c.handleKey({ type: "arrow", key: "down", alt: false, ctrl: false }); expect(c.text()).toBe("newer"); c.handleKey({ type: "arrow", key: "down", alt: false, ctrl: false }); expect(c.text()).toBe(""); // back to the draft }); it("multiline up/down never hijacks history", () => { const c = makeComposer(["history entry"]); type(c, "one"); c.handleKey({ type: "ctrl", ch: "j" }); type(c, "two"); c.handleKey({ type: "arrow", key: "up", alt: false, ctrl: false }); expect(c.text()).toBe("one\ntwo"); // moved within the buffer, not into history }); it("undo restores the previous buffer", () => { const c = makeComposer(); type(c, "ab"); c.handleKey({ type: "ctrl", ch: "_" }); expect(c.text()).toBe("a"); }); it("Ctrl+U on an empty composer requests queued-message discard", () => { const c = makeComposer(); expect(c.handleKey({ type: "ctrl", ch: "u" })).toEqual({ type: "discard-queued" }); }); it("renders the bordered box with the caret inside it", () => { const c = makeComposer(); type(c, "hello"); const r = c.render(40, 6, mono, { busy: false }); expect(r.lines[0]).toBe(" ╭" + "─".repeat(36) + "╮"); expect(r.lines[1]).toBe(" │ ❯ hello" + " ".repeat(28) + "│"); expect(r.lines[2]).toBe(" ╰" + "─".repeat(36) + "╯"); expect(r.caretRow).toBe(1); expect(r.caretCol).toBe(10); // margin+border+prompt (5) + "hello" (5) }); it("degrades to a simple prompt line on narrow terminals", () => { const c = makeComposer(); type(c, "hello"); const r = c.render(30, 6, mono, { busy: false }); expect(r.lines).toEqual([" ❯ hello"]); expect(r.caretRow).toBe(0); expect(r.caretCol).toBe(8); }); });