/** * KHAELOR * File: tests/tui/composer-box.test.ts * Description: Composer box render tests — borders, growth, cap+scroll, narrow fallback, caret mapping, state styling. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import { renderComposerBox } from "../../src/tui/components/composer-box.js"; import { stripAnsi, visibleWidth } from "../../src/tui/renderer/ansi.js"; import { resolveTheme } from "../../src/tui/theme.js"; const mono = resolveTheme({ colorDepth: "mono" }); const dark = resolveTheme({ colorDepth: "truecolor" }); const idle = { busy: false }; describe("renderComposerBox", () => { it("draws rounded borders and one content row for a single line", () => { const r = renderComposerBox({ text: "hello", cursor: 5, shell: false }, 60, 8, mono, idle); expect(r.lines).toHaveLength(3); expect(r.lines[0]).toBe(" ╭" + "─".repeat(56) + "╮"); expect(r.lines[1]).toBe(" │ ❯ hello" + " ".repeat(48) + "│"); expect(r.lines[2]).toBe(" ╰" + "─".repeat(56) + "╯"); }); it("every box row shares one visible width (width − 1, last column free)", () => { const r = renderComposerBox( { text: "one\ntwo\nthree", cursor: 13, shell: false }, 72, 8, mono, idle, ); const widths = new Set(r.lines.map((l) => visibleWidth(l))); expect(widths).toEqual(new Set([71])); }); it("grows vertically with multiline content", () => { const r = renderComposerBox({ text: "one\ntwo", cursor: 7, shell: false }, 60, 8, mono, idle); expect(r.lines).toHaveLength(4); // 2 borders + 2 content rows expect(r.lines[1]).toContain("❯ one"); expect(r.lines[2]).toContain(" two"); expect(r.caretRow).toBe(2); expect(r.caretCol).toBe(5 + 3); }); it("caps content rows and scrolls internally, keeping the caret visible", () => { const text = ["l1", "l2", "l3", "l4", "l5", "l6"].join("\n"); const r = renderComposerBox({ text, cursor: text.length, shell: false }, 60, 3, mono, idle); expect(r.lines).toHaveLength(5); // 2 borders + 3 content rows expect(stripAnsi(r.lines[1] as string)).toContain("l4"); expect(stripAnsi(r.lines[3] as string)).toContain("l6"); expect(r.caretRow).toBe(3); }); it("maps the caret through hard wraps inside the box", () => { // width 40 → textWidth 32; 40 chars wrap to two rows, caret at offset 35. const text = "a".repeat(40); const r = renderComposerBox({ text, cursor: 35, shell: false }, 40, 8, mono, idle); expect(r.lines).toHaveLength(4); expect(r.caretRow).toBe(2); expect(r.caretCol).toBe(5 + 3); // 35 − 32 into the second row }); it("shows the placeholder dim when empty and parks the caret at the prompt", () => { const r = renderComposerBox( { text: "", cursor: 0, shell: false }, 60, 8, mono, { busy: false, placeholder: "What do you want to build?" }, ); expect(stripAnsi(r.lines[1] as string)).toContain("What do you want to build?"); expect(r.caretRow).toBe(1); expect(r.caretCol).toBe(5); }); it("renders without a placeholder as just the prompt glyph", () => { const r = renderComposerBox({ text: "", cursor: 0, shell: false }, 60, 8, mono, idle); expect(r.lines[1]).toBe(" │ ❯ " + " ".repeat(53) + "│"); expect(r.caretRow).toBe(1); expect(r.caretCol).toBe(5); }); it("shell mode swaps the glyph and hides the leading !", () => { const r = renderComposerBox({ text: "!git", cursor: 4, shell: true }, 60, 8, mono, idle); expect(r.lines[1]).toContain("! git"); expect(r.lines[1]).not.toContain("!git"); expect(r.caretCol).toBe(5 + 3); // "git" is 3 visible chars }); it("surfaces queued steering on the bottom border", () => { const r = renderComposerBox( { text: "", cursor: 0, shell: false }, 60, 8, mono, { busy: true, queuedCount: 2 }, ); expect(stripAnsi(r.lines[r.lines.length - 1] as string)).toContain("⋯ 2 queued"); }); it("degrades to a simple prompt line below 40 columns", () => { const r = renderComposerBox({ text: "hi", cursor: 2, shell: false }, 30, 8, mono, idle); expect(r.lines).toEqual([" ❯ hi"]); expect(r.caretRow).toBe(0); expect(r.caretCol).toBe(5); }); it("idle border is gradient-painted, busy border dim — same glyphs either way", () => { const content = { text: "x", cursor: 1, shell: false }; const idleBox = renderComposerBox(content, 60, 8, dark, { busy: false }); const busyBox = renderComposerBox(content, 60, 8, dark, { busy: true }); // Gradient = many distinct 24-bit foregrounds on the idle top border. const gradientCodes = (idleBox.lines[0] as string).match(/38;2;/g) ?? []; expect(gradientCodes.length).toBeGreaterThan(4); expect(idleBox.lines.map((l) => stripAnsi(l))).toEqual(busyBox.lines.map((l) => stripAnsi(l))); }); });