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