/** * KHAELOR * File: src/tui/components/composer-box.ts * Description: The bordered composer box — rounded frame, vertical growth with internal scroll, caret mapping, narrow fallback. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { wrapWithCursor } from "../composer/editor.js"; import { padEndAnsi, truncateAnsi } from "../renderer/ansi.js"; import type { Theme } from "../theme.js"; /** Below this terminal width the box degrades to a simple prompt line. */ export const COMPOSER_BOX_MIN_WIDTH = 40; /** Visible columns of the in-box prompt prefix ` ❯ `. */ const PROMPT_WIDTH = 3; export interface ComposerBoxContent { text: string; /** Cursor as an offset into `text`. */ cursor: number; /** Shell mode (`!` prefix): warning glyph, the `!` itself is not displayed. */ shell: boolean; } export interface ComposerBoxOptions { /** Agent working: border and glyph dim; typed input queues (never locks). */ busy: boolean; /** Dim hint shown inside the box while empty (first run only). */ placeholder?: string; /** Queued steering messages — surfaces as a bottom-border indicator. */ queuedCount?: number; } export interface ComposerBoxRender { lines: string[]; /** Row index into `lines` where the hardware cursor parks. */ caretRow: number; /** Visible column (0-based) where the hardware cursor parks. */ caretCol: number; } interface VisibleContent { rows: string[]; /** Index of the visible row that carries the caret. */ caretRow: number; caretCol: number; /** First visible row is the first logical row (shell `!` stripping). */ startsAtTop: boolean; } /** Wrap + scroll the editor content into at most `cap` visible rows. */ function visibleRows(content: ComposerBoxContent, textWidth: number, cap: number): VisibleContent { const wrapped = wrapWithCursor(content.text, content.cursor, Math.max(8, textWidth)); const max = Math.max(1, cap); let start = 0; if (wrapped.rows.length > max) { start = Math.min(Math.max(0, wrapped.caretRow - max + 1), wrapped.rows.length - max); } const rows = wrapped.rows.slice(start, start + max); const caretRow = Math.min(Math.max(wrapped.caretRow - start, 0), rows.length - 1); let caretCol = wrapped.caretCol; if (content.shell && wrapped.caretRow === 0) caretCol = Math.max(0, caretCol - 1); if (content.shell && start === 0 && rows.length > 0) { rows[0] = (rows[0] as string).slice(1); } return { rows, caretRow, caretCol, startsAtTop: start === 0 }; } function promptGlyph(content: ComposerBoxContent, opts: ComposerBoxOptions, theme: Theme): string { if (content.shell) return theme.paint("warning", "!"); return theme.paint(opts.busy ? "dim" : "accent", "❯"); } /** Simple prompt-line fallback for narrow terminals (< COMPOSER_BOX_MIN_WIDTH). */ function renderNarrow( content: ComposerBoxContent, width: number, maxContentRows: number, theme: Theme, opts: ComposerBoxOptions, ): ComposerBoxRender { const inner = Math.max(8, width - PROMPT_WIDTH - 1); const v = visibleRows(content, inner, maxContentRows); const glyph = promptGlyph(content, opts, theme); const lines = v.rows.map((row, idx) => { const prefix = v.startsAtTop && idx === 0 ? ` ${glyph} ` : " "; return truncateAnsi(prefix + row, width); }); return { lines, caretRow: v.caretRow, caretCol: v.caretCol + PROMPT_WIDTH }; } /** * Render the composer as a rounded bordered box (TUI_DESIGN §3.1): full-width * minus a one-column margin, growing with content up to `maxContentRows` then * scrolling internally. The border carries state: brand gradient when the * agent is idle (input focus), dim while it works; a `⋯ n queued` indicator * rides the bottom border. The caret maps to the real text position inside * the box for hardware-cursor parking. */ export function renderComposerBox( content: ComposerBoxContent, width: number, maxContentRows: number, theme: Theme, opts: ComposerBoxOptions, ): ComposerBoxRender { if (width < COMPOSER_BOX_MIN_WIDTH) { return renderNarrow(content, width, maxContentRows, theme, opts); } const boxWidth = width - 2; // 1-column left margin; last column stays free const inner = boxWidth - 2; // between the │ borders const textWidth = inner - PROMPT_WIDTH - 1; // prompt prefix + right padding const v = visibleRows(content, textWidth, maxContentRows); const borderPaint = (s: string): string => opts.busy ? theme.paint("dim", s) : theme.paintGradient("brand", s); const side = (edge: "left" | "right"): string => opts.busy ? theme.paint("dim", "│") : theme.paint(edge === "left" ? "violet" : "cyan", "│"); const glyph = promptGlyph(content, opts, theme); const lines: string[] = []; lines.push(" " + borderPaint("╭" + "─".repeat(inner) + "╮")); const empty = content.text === ""; if (empty && opts.placeholder !== undefined) { const body = ` ${glyph} ${theme.paint("dim", truncateAnsi(opts.placeholder, textWidth))}`; lines.push(" " + side("left") + padEndAnsi(body, inner) + side("right")); } else { v.rows.forEach((row, idx) => { const prefix = v.startsAtTop && idx === 0 ? ` ${glyph} ` : " "; const body = padEndAnsi(truncateAnsi(prefix + row, inner), inner); lines.push(" " + side("left") + body + side("right")); }); } const queued = opts.queuedCount ?? 0; if (queued > 0) { const label = ` ⋯ ${queued} queued `; const fill = Math.max(0, inner - label.length - 1); lines.push( " " + borderPaint("╰─") + theme.paint("warning", label) + borderPaint("─".repeat(fill) + "╯"), ); } else { lines.push(" " + borderPaint("╰" + "─".repeat(inner) + "╯")); } // margin (1) + border (1) + prompt (3) = 5 columns before the text. const caretRow = 1 + (empty ? 0 : v.caretRow); const caretCol = 5 + (empty ? 0 : v.caretCol); return { lines, caretRow, caretCol }; }