/** * KHAELOR * File: src/tui/components/tool-line.ts * Description: Tool call presentation — collapsed one-liners, live rows with timers, printed expansion blocks (TUI_DESIGN §6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { truncateAnsi, visibleWidth } from "../renderer/ansi.js"; import type { StyleRole, Theme } from "../theme.js"; export type ToolOutcome = "ok" | "failed" | "cancelled"; /** UI tool kind (EVENT_MODEL `ui.kind`) — drives the `▸` glyph color. */ export type ToolLineKind = "read" | "search" | "edit" | "exec" | "process"; /** One color per tool family; the glyph shape stays `▸` in monochrome. */ const KIND_COLOR: Record = { read: "cyan", search: "violet", edit: "warning", exec: "success", process: "teal", }; function glyphFor(kind: ToolLineKind | undefined, theme: Theme): string { return theme.paint(kind !== undefined ? KIND_COLOR[kind] : "dim", "▸"); } export interface SettledToolLine { /** Real result summary from the tool, e.g. `Search "ContextEngine" · 14 matches`. */ summary: string; outcome: ToolOutcome; /** Turn-local index shown for post-hoc expansion (`/tool 3`). */ index?: number; /** Tool family — colors the `▸` glyph (dim when unknown). */ kind?: ToolLineKind; } /** * Settled one-liner: `▸` dim, summary from real tool results, failures marked * with color AND the word (never color alone). Printed once, immutable. */ export function renderToolLine(line: SettledToolLine, width: number, theme: Theme): string { let body: string; switch (line.outcome) { case "failed": body = theme.paint("error", line.summary); break; case "cancelled": body = `${line.summary}${theme.paint("dim", " · ")}${theme.paint("warning", "cancelled")}`; break; default: body = line.summary; } let text = ` ${glyphFor(line.kind, theme)} ${body}`; if (line.index !== undefined) { const tag = theme.paint("dim", `[${line.index}]`); const pad = Math.max(1, width - 2 - visibleWidth(text) - visibleWidth(tag)); text += " ".repeat(pad) + tag; } return truncateAnsi(text, width); } export interface RunningToolRow { /** Verb + argument, e.g. `Run npm test`. */ label: string; startedAt: number; /** Rolling output tail (already ANSI-stripped by the tool layer). */ outputTail?: string[]; /** Tool family — colors the `▸` glyph (dim when unknown). */ kind?: ToolLineKind; } /** Live-region row for a running tool: label + real elapsed timer + optional tail. */ export function renderRunningTool( row: RunningToolRow, now: number, width: number, theme: Theme, ): string[] { const elapsed = Math.max(0, now - row.startedAt) / 1000; const secs = elapsed >= 10 ? `${Math.round(elapsed)}s` : `${elapsed.toFixed(1)}s`; const lines = [ truncateAnsi( ` ${glyphFor(row.kind, theme)} ${row.label}${theme.paint("dim", ` · ${secs}`)}`, width, ), ]; if (row.outputTail !== undefined) { for (const out of row.outputTail) { lines.push(truncateAnsi(" " + theme.paint("dim", out), width)); } } return lines; } export interface ToolDetailBlock { index: number; /** The tool subject, e.g. `npm test`. */ title: string; lines: string[]; /** Real omitted-line count when output was truncated (§6.3). */ omitted?: number; /** Spill file path for the full output (~/.khaelor/tool-out/.txt). */ spillFile?: string; } const DETAIL_BODY_CAP = 12; /** * Post-hoc expansion prints detail as a NEW settled block — scrollback is * immutable, rows are never mutated retroactively (TUI_DESIGN §6.2). */ export function renderToolDetailBlock( block: ToolDetailBlock, width: number, theme: Theme, ): string[] { const out: string[] = [rule(` tool ${block.index} · ${block.title} `, width, theme)]; const body = block.lines.slice(0, DETAIL_BODY_CAP); for (const line of body) out.push(truncateAnsi(" " + line, width)); const hiddenHere = block.lines.length - body.length; const omitted = (block.omitted ?? 0) + hiddenHere; if (omitted > 0) { const spill = block.spillFile !== undefined ? ` · full output: ${block.spillFile}` : ""; out.push(truncateAnsi(theme.paint("dim", ` … ${omitted} lines omitted${spill}`), width)); } out.push(theme.paint("dim", "─".repeat(Math.max(10, Math.min(width - 1, 60))))); return out; } /** Section rule: `── label ────…` sized to width. */ export function rule(label: string, width: number, theme: Theme): string { const target = Math.max(10, Math.min(width - 1, 60)); const head = `──${label}`; const tail = "─".repeat(Math.max(0, target - [...head].length)); return theme.paint("dim", head + tail); }