SPB Git

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%
4.9 KB · 123 lines typescript
Raw Blame History
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