/** * KHAELOR * File: prototypes/tui-spike/candidate-b/main.tsx * Description: Candidate B — Ink 7 control (TUI_DESIGN §15.1 candidate "Ink 7, strictly bounded"): * settled content through (written once), live region as a bounded dynamic * tree (streaming tail, status line, composer, status bar). Same shared demo model * and instrumentation as candidate A. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import fs from "node:fs"; import React, { useEffect, useReducer, useState } from "react"; import { Box, Static, Text, render, useApp, useInput } from "ink"; import { DemoModel, STATUS_BAR, TAIL_CAP_LINES } from "../shared/demo"; import { SpikeMetrics, patchStdout } from "../shared/metrics"; const metrics = new SpikeMetrics("b-ink7"); // temporary diagnostic: log every stdout write (time, length, composer presence) const writeLog: string[] = []; if (process.env.WRITE_LOG) { const t0 = Date.now(); const orig = process.stdout.write.bind(process.stdout); (process.stdout as unknown as { write: unknown }).write = (chunk: unknown, ...rest: unknown[]) => { const s = typeof chunk === "string" ? chunk : Buffer.from(chunk as Uint8Array).toString("utf8"); writeLog.push( `${Date.now() - t0} len=${s.length} composer=${s.includes("❯ ") ? "Y" : "n"} ${JSON.stringify(s.slice(0, 1200))}`, ); return (orig as (c: unknown, ...r: unknown[]) => boolean)(chunk, ...rest); }; process.on("exit", () => { fs.writeFileSync(process.env.WRITE_LOG!, writeLog.join("\n") + "\n"); }); } const unpatch = patchStdout(process.stdout, metrics, () => process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80, ); const model = new DemoModel(); // Record stdin arrival time before Ink's own parser sees the byte (listener order). const pendingTs: bigint[] = []; process.stdin.on("data", () => { pendingTs.push(process.hrtime.bigint()); if (pendingTs.length > 128) pendingTs.shift(); }); interface SettledItem { id: number; text: string; } function App(): React.JSX.Element { const { exit } = useApp(); const [settled, setSettled] = useState([]); const [, force] = useReducer((x: number) => x + 1, 0); useEffect(() => { const onSettled = (lines: string[]) => setSettled((s) => [...s, { id: s.length, text: lines.join("\n") }]); const onDirty = () => force(); model.on("settled", onSettled); model.on("dirty", onDirty); const ticker = setInterval(() => { if (model.status !== null) force(); }, 100); model.start(); return () => { model.off("settled", onSettled); model.off("dirty", onDirty); clearInterval(ticker); }; }, []); useInput((input, key) => { const ts = pendingTs.shift(); if (key.escape) { exit(); return; } if (key.return) { model.handleKey({ type: "enter" }); return; } if (key.backspace || key.delete) { model.handleKey({ type: "backspace" }); return; } if (input.length > 0) { // A lagging event loop can coalesce "\r" + following chars into one chunk that Ink // delivers as string input with key.return=false — treat embedded CR/LF as Enter, // exactly like candidate A's decodeKeys. for (const ch of input) model.handleKey(ch === "\r" || ch === "\n" ? { type: "enter" } : { type: "char", ch }); // Space keystrokes are excluded: Ink/Yoga trims trailing whitespace at line ends, // which would make a space-terminated needle unmatchable (verified empirically). if (ts !== undefined && input.trim().length > 0) metrics.expectEcho(ts, ("❯ " + model.composer).trimEnd()); } }); const snap = model.snapshot(); const tailLines = snap.tail.length > 0 ? snap.tail.split("\n").slice(-TAIL_CAP_LINES) : []; const elapsed = snap.status === null ? null : ((Date.now() - snap.status.since) / 1000).toFixed(1); return ( <> {(item) => ( {item.text} )} {tailLines.length > 0 && ( <> {tailLines.join("\n")} )} {snap.status !== null && ( <> {`● ${snap.status.text} · ${elapsed}s`} )} {"❯ " + snap.composer} {STATUS_BAR} ); } process.stdout.on("resize", () => { metrics.resizes++; }); const instance = render(, { exitOnCtrlC: true, patchConsole: false }); metrics.startMem(); // headless fallback: self-terminate if the driver never sends Esc const durationMs = Number(process.env.DURATION_MS ?? 0); if (durationMs > 0) setTimeout(() => instance.unmount(), durationMs + 4000).unref(); await instance.waitUntilExit(); model.stop(); metrics.turns = model.turn; metrics.settledBlocks = model.settledCount; const file = metrics.save(process.env.METRICS_OUT); unpatch(); process.stdout.write(`metrics written: ${file}\n`); process.exit(0);