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/tool-line.ts4 * Description: Tool call presentation — collapsed one-liners, live rows with timers, printed expansion blocks (TUI_DESIGN §6).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { truncateAnsi, visibleWidth } from "../renderer/ansi.js";11import type { StyleRole, Theme } from "../theme.js";1213export type ToolOutcome = "ok" | "failed" | "cancelled";1415/** UI tool kind (EVENT_MODEL `ui.kind`) — drives the `▸` glyph color. */16export type ToolLineKind = "read" | "search" | "edit" | "exec" | "process";1718/** One color per tool family; the glyph shape stays `▸` in monochrome. */19const KIND_COLOR: Record<ToolLineKind, StyleRole> = {20 read: "cyan",21 search: "violet",22 edit: "warning",23 exec: "success",24 process: "teal",25};2627function glyphFor(kind: ToolLineKind | undefined, theme: Theme): string {28 return theme.paint(kind !== undefined ? KIND_COLOR[kind] : "dim", "▸");29}3031export interface SettledToolLine {32 /** Real result summary from the tool, e.g. `Search "ContextEngine" · 14 matches`. */33 summary: string;34 outcome: ToolOutcome;35 /** Turn-local index shown for post-hoc expansion (`/tool 3`). */36 index?: number;37 /** Tool family — colors the `▸` glyph (dim when unknown). */38 kind?: ToolLineKind;39}4041/**42 * Settled one-liner: `▸` dim, summary from real tool results, failures marked43 * with color AND the word (never color alone). Printed once, immutable.44 */45export function renderToolLine(line: SettledToolLine, width: number, theme: Theme): string {46 let body: string;47 switch (line.outcome) {48 case "failed":49 body = theme.paint("error", line.summary);50 break;51 case "cancelled":52 body = `${line.summary}${theme.paint("dim", " · ")}${theme.paint("warning", "cancelled")}`;53 break;54 default:55 body = line.summary;56 }57 let text = ` ${glyphFor(line.kind, theme)} ${body}`;58 if (line.index !== undefined) {59 const tag = theme.paint("dim", `[${line.index}]`);60 const pad = Math.max(1, width - 2 - visibleWidth(text) - visibleWidth(tag));61 text += " ".repeat(pad) + tag;62 }63 return truncateAnsi(text, width);64}6566export interface RunningToolRow {67 /** Verb + argument, e.g. `Run npm test`. */68 label: string;69 startedAt: number;70 /** Rolling output tail (already ANSI-stripped by the tool layer). */71 outputTail?: string[];72 /** Tool family — colors the `▸` glyph (dim when unknown). */73 kind?: ToolLineKind;74}7576/** Live-region row for a running tool: label + real elapsed timer + optional tail. */77export function renderRunningTool(78 row: RunningToolRow,79 now: number,80 width: number,81 theme: Theme,82): string[] {83 const elapsed = Math.max(0, now - row.startedAt) / 1000;84 const secs = elapsed >= 10 ? `${Math.round(elapsed)}s` : `${elapsed.toFixed(1)}s`;85 const lines = [86 truncateAnsi(87 ` ${glyphFor(row.kind, theme)} ${row.label}${theme.paint("dim", ` · ${secs}`)}`,88 width,89 ),90 ];91 if (row.outputTail !== undefined) {92 for (const out of row.outputTail) {93 lines.push(truncateAnsi(" " + theme.paint("dim", out), width));94 }95 }96 return lines;97}9899export interface ToolDetailBlock {100 index: number;101 /** The tool subject, e.g. `npm test`. */102 title: string;103 lines: string[];104 /** Real omitted-line count when output was truncated (§6.3). */105 omitted?: number;106 /** Spill file path for the full output (~/.khaelor/tool-out/<id>.txt). */107 spillFile?: string;108}109110const DETAIL_BODY_CAP = 12;111112/**113 * Post-hoc expansion prints detail as a NEW settled block — scrollback is114 * immutable, rows are never mutated retroactively (TUI_DESIGN §6.2).115 */116export function renderToolDetailBlock(117 block: ToolDetailBlock,118 width: number,119 theme: Theme,120): string[] {121 const out: string[] = [rule(` tool ${block.index} · ${block.title} `, width, theme)];122 const body = block.lines.slice(0, DETAIL_BODY_CAP);123 for (const line of body) out.push(truncateAnsi(" " + line, width));124 const hiddenHere = block.lines.length - body.length;125 const omitted = (block.omitted ?? 0) + hiddenHere;126 if (omitted > 0) {127 const spill = block.spillFile !== undefined ? ` · full output: ${block.spillFile}` : "";128 out.push(truncateAnsi(theme.paint("dim", ` … ${omitted} lines omitted${spill}`), width));129 }130 out.push(theme.paint("dim", "─".repeat(Math.max(10, Math.min(width - 1, 60)))));131 return out;132}133134/** Section rule: `── label ────…` sized to width. */135export function rule(label: string, width: number, theme: Theme): string {136 const target = Math.max(10, Math.min(width - 1, 60));137 const head = `──${label}`;138 const tail = "─".repeat(Math.max(0, target - [...head].length));139 return theme.paint("dim", head + tail);140}141