spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/tui/composer.test.ts4 * Description: Composer tests — pure editor state machine, spans, palettes, history, paste collapsing, shell mode.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import {12 adjustSpans,13 backspace,14 deleteWordBack,15 expandSpans,16 insertText,17 killToLineStart,18 moveDown,19 moveLineEnd,20 moveLineStart,21 moveUp,22 wordLeft,23 wordRight,24 wrapWithCursor,25} from "../../src/tui/composer/editor.js";26import type { EditorState, Span } from "../../src/tui/composer/editor.js";27import { Composer } from "../../src/tui/composer/composer.js";28import type { ComposerProviders } from "../../src/tui/composer/composer.js";29import { resolveTheme } from "../../src/tui/theme.js";3031const mono = resolveTheme({ colorDepth: "mono" });3233function ed(text: string, cursor: number): EditorState {34 return { text, cursor };35}3637describe("editor state machine", () => {38 it("inserts at the cursor and advances it", () => {39 expect(insertText(ed("ac", 1), "b")).toEqual({ text: "abc", cursor: 2 });40 });4142 it("backspace deletes one code point (astral-safe)", () => {43 expect(backspace(ed("a🎉", 3), )).toEqual({ text: "a", cursor: 1 });44 expect(backspace(ed("x", 0))).toEqual({ text: "x", cursor: 0 });45 });4647 it("word navigation crosses separators", () => {48 const s = ed("foo bar-baz", 11);49 expect(wordLeft(s).cursor).toBe(8); // start of "baz"50 expect(wordRight(ed("foo bar", 0)).cursor).toBe(3);51 });5253 it("deletes the previous word including separators", () => {54 expect(deleteWordBack(ed("run npm test", 12))).toEqual({ text: "run npm ", cursor: 8 });55 });5657 it("kill-to-line-start respects logical lines", () => {58 expect(killToLineStart(ed("ab\ncdef", 5))).toEqual({ text: "ab\nef", cursor: 3 });59 });6061 it("line start/end operate on the current logical line", () => {62 expect(moveLineStart(ed("ab\ncdef", 5)).cursor).toBe(3);63 expect(moveLineEnd(ed("ab\ncdef", 4)).cursor).toBe(7);64 });6566 it("up/down move within multiline and signal edges with null", () => {67 expect(moveUp(ed("ab\ncd", 4))).toEqual({ text: "ab\ncd", cursor: 1 });68 expect(moveUp(ed("ab\ncd", 1))).toBeNull(); // first line → history boundary69 expect(moveDown(ed("ab\ncd", 0))).toEqual({ text: "ab\ncd", cursor: 3 });70 expect(moveDown(ed("ab\ncd", 4))).toBeNull(); // last line → history boundary71 });72});7374describe("spans", () => {75 const span: Span = { start: 4, end: 10, kind: "paste", payload: "PAYLOAD" };7677 it("shift after insertions before them", () => {78 expect(adjustSpans([span], 0, 0, 2)).toEqual([{ ...span, start: 6, end: 12 }]);79 });8081 it("stay put for edits after them", () => {82 expect(adjustSpans([span], 10, 0, 5)).toEqual([span]);83 });8485 it("are dropped when an edit intersects them (degrade to plain text)", () => {86 expect(adjustSpans([span], 6, 2, 0)).toEqual([]);87 });8889 it("expand paste payloads at submit, keep mention display", () => {90 const text = "see ⧉paste and @src/a.ts";91 const spans: Span[] = [92 { start: 4, end: 10, kind: "paste", payload: "RAW\nPASTE" },93 { start: 15, end: 25, kind: "mention", payload: "src/a.ts" },94 ];95 expect(expandSpans(text, spans)).toBe("see RAW\nPASTE and @src/a.ts");96 });97});9899describe("wrapWithCursor", () => {100 it("maps the cursor through hard wraps", () => {101 const w = wrapWithCursor("abcdefghij", 7, 4);102 expect(w.rows).toEqual(["abcd", "efgh", "ij"]);103 expect(w.caretRow).toBe(1);104 expect(w.caretCol).toBe(3);105 });106107 it("places the caret at the end of the last row", () => {108 const w = wrapWithCursor("abc", 3, 10);109 expect(w.caretRow).toBe(0);110 expect(w.caretCol).toBe(3);111 });112113 it("handles logical newlines", () => {114 const w = wrapWithCursor("ab\ncd", 3, 10);115 expect(w.rows).toEqual(["ab", "cd"]);116 expect(w.caretRow).toBe(1);117 expect(w.caretCol).toBe(0);118 });119});120121function makeComposer(history: string[] = []): Composer {122 const providers: ComposerProviders = {123 slashCommands: () => [124 { id: "sessions.open", label: "/sessions", detail: "browse sessions" },125 { id: "diff.show", label: "/diff", detail: "view diff" },126 { id: "cost.show", label: "/cost", detail: "session cost" },127 ],128 mentions: (q) =>129 ["src/kernel/agent.ts", "src/agents/agent-runtime.ts", "tests/agent.test.ts"]130 .filter((p) => q === "" || p.includes(q))131 .map((p) => ({ id: p, label: p })),132 };133 return new Composer(providers, history);134}135136function type(c: Composer, text: string): void {137 for (const ch of text) c.handleKey({ type: "char", ch });138}139140describe("Composer", () => {141 it("submits text and resets", () => {142 const c = makeComposer();143 type(c, "fix the bug");144 const effect = c.handleKey({ type: "enter" });145 expect(effect).toEqual({ type: "submit", text: "fix the bug", shell: false });146 expect(c.isEmpty()).toBe(true);147 });148149 it("detects shell mode via the ! prefix", () => {150 const c = makeComposer();151 type(c, "!git status");152 expect(c.isShellMode()).toBe(true);153 const effect = c.handleKey({ type: "enter" });154 expect(effect).toEqual({ type: "submit", text: "git status", shell: true });155 });156157 it("continues the line on trailing backslash + Enter", () => {158 const c = makeComposer();159 type(c, "first\\");160 expect(c.handleKey({ type: "enter" })).toBeNull();161 expect(c.text()).toBe("first\n");162 });163164 it("opens the slash palette at offset 0 and runs the selection", () => {165 const c = makeComposer();166 type(c, "/");167 expect(c.hasPalette()).toBe(true);168 type(c, "co"); // filters to /cost169 const effect = c.handleKey({ type: "enter" });170 expect(effect).toEqual({ type: "run-command", id: "cost.show" });171 expect(c.isEmpty()).toBe(true);172 });173174 it("does not open the slash palette mid-text", () => {175 const c = makeComposer();176 type(c, "a/");177 expect(c.hasPalette()).toBe(false);178 });179180 it("inserts a mention span from the @ palette", () => {181 const c = makeComposer();182 type(c, "refactor @kernel");183 expect(c.hasPalette()).toBe(true);184 c.handleKey({ type: "enter" });185 expect(c.text()).toBe("refactor @src/kernel/agent.ts ");186 expect(c.hasPalette()).toBe(false);187 });188189 it("closes the mention palette on whitespace", () => {190 const c = makeComposer();191 type(c, "@x y");192 expect(c.hasPalette()).toBe(false);193 });194195 it("collapses large pastes into a span and expands on submit", () => {196 const c = makeComposer();197 const pasted = "line1\nline2\nline3\nline4";198 c.handleKey({ type: "paste", text: pasted });199 expect(c.text()).toBe("⧉ pasted 4 lines");200 const effect = c.handleKey({ type: "enter" });201 expect(effect).toEqual({ type: "submit", text: pasted, shell: false });202 });203204 it("inserts small pastes literally", () => {205 const c = makeComposer();206 c.handleKey({ type: "paste", text: "just words" });207 expect(c.text()).toBe("just words");208 });209210 it("navigates history at buffer edges only", () => {211 const c = makeComposer(["older", "newer"]);212 c.handleKey({ type: "arrow", key: "up", alt: false, ctrl: false });213 expect(c.text()).toBe("newer");214 c.handleKey({ type: "arrow", key: "up", alt: false, ctrl: false });215 expect(c.text()).toBe("older");216 c.handleKey({ type: "arrow", key: "down", alt: false, ctrl: false });217 expect(c.text()).toBe("newer");218 c.handleKey({ type: "arrow", key: "down", alt: false, ctrl: false });219 expect(c.text()).toBe(""); // back to the draft220 });221222 it("multiline up/down never hijacks history", () => {223 const c = makeComposer(["history entry"]);224 type(c, "one");225 c.handleKey({ type: "ctrl", ch: "j" });226 type(c, "two");227 c.handleKey({ type: "arrow", key: "up", alt: false, ctrl: false });228 expect(c.text()).toBe("one\ntwo"); // moved within the buffer, not into history229 });230231 it("undo restores the previous buffer", () => {232 const c = makeComposer();233 type(c, "ab");234 c.handleKey({ type: "ctrl", ch: "_" });235 expect(c.text()).toBe("a");236 });237238 it("Ctrl+U on an empty composer requests queued-message discard", () => {239 const c = makeComposer();240 expect(c.handleKey({ type: "ctrl", ch: "u" })).toEqual({ type: "discard-queued" });241 });242243 it("renders the bordered box with the caret inside it", () => {244 const c = makeComposer();245 type(c, "hello");246 const r = c.render(40, 6, mono, { busy: false });247 expect(r.lines[0]).toBe(" ╭" + "─".repeat(36) + "╮");248 expect(r.lines[1]).toBe(" │ ❯ hello" + " ".repeat(28) + "│");249 expect(r.lines[2]).toBe(" ╰" + "─".repeat(36) + "╯");250 expect(r.caretRow).toBe(1);251 expect(r.caretCol).toBe(10); // margin+border+prompt (5) + "hello" (5)252 });253254 it("degrades to a simple prompt line on narrow terminals", () => {255 const c = makeComposer();256 type(c, "hello");257 const r = c.render(30, 6, mono, { busy: false });258 expect(r.lines).toEqual([" ❯ hello"]);259 expect(r.caretRow).toBe(0);260 expect(r.caretCol).toBe(8);261 });262});263