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: src/tui/components/composer-box.ts4 * Description: The bordered composer box — rounded frame, vertical growth with internal scroll, caret mapping, narrow fallback.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { wrapWithCursor } from "../composer/editor.js";11import { padEndAnsi, truncateAnsi } from "../renderer/ansi.js";12import type { Theme } from "../theme.js";1314/** Below this terminal width the box degrades to a simple prompt line. */15export const COMPOSER_BOX_MIN_WIDTH = 40;1617/** Visible columns of the in-box prompt prefix ` ❯ `. */18const PROMPT_WIDTH = 3;1920export interface ComposerBoxContent {21 text: string;22 /** Cursor as an offset into `text`. */23 cursor: number;24 /** Shell mode (`!` prefix): warning glyph, the `!` itself is not displayed. */25 shell: boolean;26}2728export interface ComposerBoxOptions {29 /** Agent working: border and glyph dim; typed input queues (never locks). */30 busy: boolean;31 /** Dim hint shown inside the box while empty (first run only). */32 placeholder?: string;33 /** Queued steering messages — surfaces as a bottom-border indicator. */34 queuedCount?: number;35}3637export interface ComposerBoxRender {38 lines: string[];39 /** Row index into `lines` where the hardware cursor parks. */40 caretRow: number;41 /** Visible column (0-based) where the hardware cursor parks. */42 caretCol: number;43}4445interface VisibleContent {46 rows: string[];47 /** Index of the visible row that carries the caret. */48 caretRow: number;49 caretCol: number;50 /** First visible row is the first logical row (shell `!` stripping). */51 startsAtTop: boolean;52}5354/** Wrap + scroll the editor content into at most `cap` visible rows. */55function visibleRows(content: ComposerBoxContent, textWidth: number, cap: number): VisibleContent {56 const wrapped = wrapWithCursor(content.text, content.cursor, Math.max(8, textWidth));57 const max = Math.max(1, cap);58 let start = 0;59 if (wrapped.rows.length > max) {60 start = Math.min(Math.max(0, wrapped.caretRow - max + 1), wrapped.rows.length - max);61 }62 const rows = wrapped.rows.slice(start, start + max);63 const caretRow = Math.min(Math.max(wrapped.caretRow - start, 0), rows.length - 1);64 let caretCol = wrapped.caretCol;65 if (content.shell && wrapped.caretRow === 0) caretCol = Math.max(0, caretCol - 1);66 if (content.shell && start === 0 && rows.length > 0) {67 rows[0] = (rows[0] as string).slice(1);68 }69 return { rows, caretRow, caretCol, startsAtTop: start === 0 };70}7172function promptGlyph(content: ComposerBoxContent, opts: ComposerBoxOptions, theme: Theme): string {73 if (content.shell) return theme.paint("warning", "!");74 return theme.paint(opts.busy ? "dim" : "accent", "❯");75}7677/** Simple prompt-line fallback for narrow terminals (< COMPOSER_BOX_MIN_WIDTH). */78function renderNarrow(79 content: ComposerBoxContent,80 width: number,81 maxContentRows: number,82 theme: Theme,83 opts: ComposerBoxOptions,84): ComposerBoxRender {85 const inner = Math.max(8, width - PROMPT_WIDTH - 1);86 const v = visibleRows(content, inner, maxContentRows);87 const glyph = promptGlyph(content, opts, theme);88 const lines = v.rows.map((row, idx) => {89 const prefix = v.startsAtTop && idx === 0 ? ` ${glyph} ` : " ";90 return truncateAnsi(prefix + row, width);91 });92 return { lines, caretRow: v.caretRow, caretCol: v.caretCol + PROMPT_WIDTH };93}9495/**96 * Render the composer as a rounded bordered box (TUI_DESIGN §3.1): full-width97 * minus a one-column margin, growing with content up to `maxContentRows` then98 * scrolling internally. The border carries state: brand gradient when the99 * agent is idle (input focus), dim while it works; a `⋯ n queued` indicator100 * rides the bottom border. The caret maps to the real text position inside101 * the box for hardware-cursor parking.102 */103export function renderComposerBox(104 content: ComposerBoxContent,105 width: number,106 maxContentRows: number,107 theme: Theme,108 opts: ComposerBoxOptions,109): ComposerBoxRender {110 if (width < COMPOSER_BOX_MIN_WIDTH) {111 return renderNarrow(content, width, maxContentRows, theme, opts);112 }113114 const boxWidth = width - 2; // 1-column left margin; last column stays free115 const inner = boxWidth - 2; // between the │ borders116 const textWidth = inner - PROMPT_WIDTH - 1; // prompt prefix + right padding117 const v = visibleRows(content, textWidth, maxContentRows);118119 const borderPaint = (s: string): string =>120 opts.busy ? theme.paint("dim", s) : theme.paintGradient("brand", s);121 const side = (edge: "left" | "right"): string =>122 opts.busy123 ? theme.paint("dim", "│")124 : theme.paint(edge === "left" ? "violet" : "cyan", "│");125126 const glyph = promptGlyph(content, opts, theme);127 const lines: string[] = [];128129 lines.push(" " + borderPaint("╭" + "─".repeat(inner) + "╮"));130131 const empty = content.text === "";132 if (empty && opts.placeholder !== undefined) {133 const body = ` ${glyph} ${theme.paint("dim", truncateAnsi(opts.placeholder, textWidth))}`;134 lines.push(" " + side("left") + padEndAnsi(body, inner) + side("right"));135 } else {136 v.rows.forEach((row, idx) => {137 const prefix = v.startsAtTop && idx === 0 ? ` ${glyph} ` : " ";138 const body = padEndAnsi(truncateAnsi(prefix + row, inner), inner);139 lines.push(" " + side("left") + body + side("right"));140 });141 }142143 const queued = opts.queuedCount ?? 0;144 if (queued > 0) {145 const label = ` ⋯ ${queued} queued `;146 const fill = Math.max(0, inner - label.length - 1);147 lines.push(148 " " +149 borderPaint("╰─") +150 theme.paint("warning", label) +151 borderPaint("─".repeat(fill) + "╯"),152 );153 } else {154 lines.push(" " + borderPaint("╰" + "─".repeat(inner) + "╯"));155 }156157 // margin (1) + border (1) + prompt (3) = 5 columns before the text.158 const caretRow = 1 + (empty ? 0 : v.caretRow);159 const caretCol = 5 + (empty ? 0 : v.caretCol);160 return { lines, caretRow, caretCol };161}162