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.2 KB · 158 lines tsx
Raw Blame History
1/**2 * KHAELOR3 * File: prototypes/tui-spike/candidate-b/main.tsx4 * Description: Candidate B — Ink 7 control (TUI_DESIGN §15.1 candidate "Ink 7, strictly bounded"):5 *              settled content through <Static> (written once), live region as a bounded dynamic6 *              tree (streaming tail, status line, composer, status bar). Same shared demo model7 *              and instrumentation as candidate A.8 *9 * Author: Simon-Pierre Boucher10 * Contact: contact@spboucher.ai11 */1213import fs from "node:fs";14import React, { useEffect, useReducer, useState } from "react";15import { Box, Static, Text, render, useApp, useInput } from "ink";16import { DemoModel, STATUS_BAR, TAIL_CAP_LINES } from "../shared/demo";17import { SpikeMetrics, patchStdout } from "../shared/metrics";1819const metrics = new SpikeMetrics("b-ink7");2021// temporary diagnostic: log every stdout write (time, length, composer presence)22const writeLog: string[] = [];23if (process.env.WRITE_LOG) {24  const t0 = Date.now();25  const orig = process.stdout.write.bind(process.stdout);26  (process.stdout as unknown as { write: unknown }).write = (chunk: unknown, ...rest: unknown[]) => {27    const s = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8");28    writeLog.push(29      `${Date.now() - t0} len=${s.length} composer=${s.includes("❯ ") ? "Y" : "n"} ${JSON.stringify(s.slice(0, 1200))}`,30    );31    return (orig as (c: unknown, ...r: unknown[]) => boolean)(chunk, ...rest);32  };33  process.on("exit", () => {34    fs.writeFileSync(process.env.WRITE_LOG!, writeLog.join("\n") + "\n");35  });36}37const unpatch = patchStdout(process.stdout, metrics, () =>38  process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80,39);40const model = new DemoModel();4142// Record stdin arrival time before Ink's own parser sees the byte (listener order).43const pendingTs: bigint[] = [];44process.stdin.on("data", () => {45  pendingTs.push(process.hrtime.bigint());46  if (pendingTs.length > 128) pendingTs.shift();47});4849interface SettledItem {50  id: number;51  text: string;52}5354function App(): React.JSX.Element {55  const { exit } = useApp();56  const [settled, setSettled] = useState<SettledItem[]>([]);57  const [, force] = useReducer((x: number) => x + 1, 0);5859  useEffect(() => {60    const onSettled = (lines: string[]) =>61      setSettled((s) => [...s, { id: s.length, text: lines.join("\n") }]);62    const onDirty = () => force();63    model.on("settled", onSettled);64    model.on("dirty", onDirty);65    const ticker = setInterval(() => {66      if (model.status !== null) force();67    }, 100);68    model.start();69    return () => {70      model.off("settled", onSettled);71      model.off("dirty", onDirty);72      clearInterval(ticker);73    };74  }, []);7576  useInput((input, key) => {77    const ts = pendingTs.shift();78    if (key.escape) {79      exit();80      return;81    }82    if (key.return) {83      model.handleKey({ type: "enter" });84      return;85    }86    if (key.backspace || key.delete) {87      model.handleKey({ type: "backspace" });88      return;89    }90    if (input.length > 0) {91      // A lagging event loop can coalesce "\r" + following chars into one chunk that Ink92      // delivers as string input with key.return=false — treat embedded CR/LF as Enter,93      // exactly like candidate A's decodeKeys.94      for (const ch of input)95        model.handleKey(ch === "\r" || ch === "\n" ? { type: "enter" } : { type: "char", ch });96      // Space keystrokes are excluded: Ink/Yoga trims trailing whitespace at line ends,97      // which would make a space-terminated needle unmatchable (verified empirically).98      if (ts !== undefined && input.trim().length > 0)99        metrics.expectEcho(ts, ("❯ " + model.composer).trimEnd());100    }101  });102103  const snap = model.snapshot();104  const tailLines = snap.tail.length > 0 ? snap.tail.split("\n").slice(-TAIL_CAP_LINES) : [];105  const elapsed =106    snap.status === null ? null : ((Date.now() - snap.status.since) / 1000).toFixed(1);107108  return (109    <>110      <Static items={settled}>111        {(item) => (112          <Text key={item.id} wrap="wrap">113            {item.text}114          </Text>115        )}116      </Static>117      <Box flexDirection="column">118        {tailLines.length > 0 && (119          <>120            <Text wrap="wrap">{tailLines.join("\n")}</Text>121            <Text> </Text>122          </>123        )}124        {snap.status !== null && (125          <>126            <Text>{`● ${snap.status.text} · ${elapsed}s`}</Text>127            <Text> </Text>128          </>129        )}130        <Text wrap="truncate">{"❯ " + snap.composer}</Text>131        <Text dimColor wrap="truncate">132          {STATUS_BAR}133        </Text>134      </Box>135    </>136  );137}138139process.stdout.on("resize", () => {140  metrics.resizes++;141});142143const instance = render(<App />, { exitOnCtrlC: true, patchConsole: false });144metrics.startMem();145146// headless fallback: self-terminate if the driver never sends Esc147const durationMs = Number(process.env.DURATION_MS ?? 0);148if (durationMs > 0) setTimeout(() => instance.unmount(), durationMs + 4000).unref();149150await instance.waitUntilExit();151model.stop();152metrics.turns = model.turn;153metrics.settledBlocks = model.settledCount;154const file = metrics.save(process.env.METRICS_OUT);155unpatch();156process.stdout.write(`metrics written: ${file}\n`);157process.exit(0);158