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