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-box.test.ts4 * Description: Composer box render tests — borders, growth, cap+scroll, narrow fallback, caret mapping, state styling.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import { renderComposerBox } from "../../src/tui/components/composer-box.js";12import { stripAnsi, visibleWidth } from "../../src/tui/renderer/ansi.js";13import { resolveTheme } from "../../src/tui/theme.js";1415const mono = resolveTheme({ colorDepth: "mono" });16const dark = resolveTheme({ colorDepth: "truecolor" });1718const idle = { busy: false };1920describe("renderComposerBox", () => {21 it("draws rounded borders and one content row for a single line", () => {22 const r = renderComposerBox({ text: "hello", cursor: 5, shell: false }, 60, 8, mono, idle);23 expect(r.lines).toHaveLength(3);24 expect(r.lines[0]).toBe(" ╭" + "─".repeat(56) + "╮");25 expect(r.lines[1]).toBe(" │ ❯ hello" + " ".repeat(48) + "│");26 expect(r.lines[2]).toBe(" ╰" + "─".repeat(56) + "╯");27 });2829 it("every box row shares one visible width (width − 1, last column free)", () => {30 const r = renderComposerBox(31 { text: "one\ntwo\nthree", cursor: 13, shell: false },32 72,33 8,34 mono,35 idle,36 );37 const widths = new Set(r.lines.map((l) => visibleWidth(l)));38 expect(widths).toEqual(new Set([71]));39 });4041 it("grows vertically with multiline content", () => {42 const r = renderComposerBox({ text: "one\ntwo", cursor: 7, shell: false }, 60, 8, mono, idle);43 expect(r.lines).toHaveLength(4); // 2 borders + 2 content rows44 expect(r.lines[1]).toContain("❯ one");45 expect(r.lines[2]).toContain(" two");46 expect(r.caretRow).toBe(2);47 expect(r.caretCol).toBe(5 + 3);48 });4950 it("caps content rows and scrolls internally, keeping the caret visible", () => {51 const text = ["l1", "l2", "l3", "l4", "l5", "l6"].join("\n");52 const r = renderComposerBox({ text, cursor: text.length, shell: false }, 60, 3, mono, idle);53 expect(r.lines).toHaveLength(5); // 2 borders + 3 content rows54 expect(stripAnsi(r.lines[1] as string)).toContain("l4");55 expect(stripAnsi(r.lines[3] as string)).toContain("l6");56 expect(r.caretRow).toBe(3);57 });5859 it("maps the caret through hard wraps inside the box", () => {60 // width 40 → textWidth 32; 40 chars wrap to two rows, caret at offset 35.61 const text = "a".repeat(40);62 const r = renderComposerBox({ text, cursor: 35, shell: false }, 40, 8, mono, idle);63 expect(r.lines).toHaveLength(4);64 expect(r.caretRow).toBe(2);65 expect(r.caretCol).toBe(5 + 3); // 35 − 32 into the second row66 });6768 it("shows the placeholder dim when empty and parks the caret at the prompt", () => {69 const r = renderComposerBox(70 { text: "", cursor: 0, shell: false },71 60,72 8,73 mono,74 { busy: false, placeholder: "What do you want to build?" },75 );76 expect(stripAnsi(r.lines[1] as string)).toContain("What do you want to build?");77 expect(r.caretRow).toBe(1);78 expect(r.caretCol).toBe(5);79 });8081 it("renders without a placeholder as just the prompt glyph", () => {82 const r = renderComposerBox({ text: "", cursor: 0, shell: false }, 60, 8, mono, idle);83 expect(r.lines[1]).toBe(" │ ❯ " + " ".repeat(53) + "│");84 expect(r.caretRow).toBe(1);85 expect(r.caretCol).toBe(5);86 });8788 it("shell mode swaps the glyph and hides the leading !", () => {89 const r = renderComposerBox({ text: "!git", cursor: 4, shell: true }, 60, 8, mono, idle);90 expect(r.lines[1]).toContain("! git");91 expect(r.lines[1]).not.toContain("!git");92 expect(r.caretCol).toBe(5 + 3); // "git" is 3 visible chars93 });9495 it("surfaces queued steering on the bottom border", () => {96 const r = renderComposerBox(97 { text: "", cursor: 0, shell: false },98 60,99 8,100 mono,101 { busy: true, queuedCount: 2 },102 );103 expect(stripAnsi(r.lines[r.lines.length - 1] as string)).toContain("⋯ 2 queued");104 });105106 it("degrades to a simple prompt line below 40 columns", () => {107 const r = renderComposerBox({ text: "hi", cursor: 2, shell: false }, 30, 8, mono, idle);108 expect(r.lines).toEqual([" ❯ hi"]);109 expect(r.caretRow).toBe(0);110 expect(r.caretCol).toBe(5);111 });112113 it("idle border is gradient-painted, busy border dim — same glyphs either way", () => {114 const content = { text: "x", cursor: 1, shell: false };115 const idleBox = renderComposerBox(content, 60, 8, dark, { busy: false });116 const busyBox = renderComposerBox(content, 60, 8, dark, { busy: true });117 // Gradient = many distinct 24-bit foregrounds on the idle top border.118 const gradientCodes = (idleBox.lines[0] as string).match(/38;2;/g) ?? [];119 expect(gradientCodes.length).toBeGreaterThan(4);120 expect(idleBox.lines.map((l) => stripAnsi(l))).toEqual(busyBox.lines.map((l) => stripAnsi(l)));121 });122});123