/** * KHAELOR * File: prototypes/tui-spike/shared/demo.ts * Description: Shared demo model — mocked agent-turn event script (markdown stream at ~30 deltas/s, * tool rows, status line) plus composer state. Both candidates render this same model. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { EventEmitter } from "node:events"; export interface StatusState { text: string; since: number; } export interface Snapshot { tail: string; // unsettled markdown tail (raw) status: StatusState | null; composer: string; turn: number; } export type KeyEvent = | { type: "char"; ch: string } | { type: "backspace" } | { type: "enter" } | { type: "esc" } | { type: "ctrlc" } | { type: "other" }; /** Inner text of the fenced code block — used by the driver as the settled-content byte-identity probe. */ export const FENCE_TEXT = [ "async append(event: SessionEvent): Promise {", " for (let attempt = 0; attempt < 3; attempt++) {", " try {", " await this.journal.write(encode(event));", " return;", " } catch (err) {", " if (!isTransient(err)) throw err;", " await delay(2 ** (2 * attempt + 1));", " }", " }", " this.bus.emit({ type: 'WriteFailed', event });", "}", ].join("\n"); /** ~2 KB of markdown-ish response text streamed each turn. */ export const MARKDOWN: string = [ "## Session store retry logic", "", "The failure point is in `SessionStore.append` — journal writes are", "not retried on transient `EAGAIN`, so a busy filesystem drops the", "event and the session log diverges from what the user saw on", "screen. The fix wraps the journal write in a bounded retry loop", "with exponential backoff, and keeps the event log append-only.", "", "Key changes:", "", "- `append` now retries up to 3 times on `EAGAIN` / `EBUSY`", "- backoff is 2 ms, 8 ms, 32 ms — bounded, never user-visible", "- a `WriteFailed` event is emitted only after the final attempt", "- no partial frames are ever kept in the journal", "", "```ts", FENCE_TEXT, "```", "", "The retry loop is deliberately synchronous with the event bus:", "observations settle in order, and the live region never shows a", "frame that the journal has not accepted. Interruption is safe", "because a cancelled write is indistinguishable from a write that", "never started — the journal either has the full event or nothing.", "", "Two details worth calling out for review:", "", "1. `isTransient` treats `EAGAIN`, `EBUSY` and `EINTR` as retryable", "2. the backoff delays are cumulative worst-case 42 ms, well under", " the 100 ms budget for a settled-event flush", "", "With this in place the flaky `store.test.ts` failures stop", "reproducing under load, and the append path stays inside the hot", "loop budget. The remaining work is to surface `WriteFailed` in", "the status bar so a dying disk is visible before data is lost.", "", ].join("\n"); const DELTA_CHARS = 8; // ~8 chars per delta const DELTA_INTERVAL_MS = 33; // ~30 deltas/second const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * Drives the mocked agent turn and owns composer state. * Emits: 'settled' (lines: string[]) — block to print once to scrollback; * 'dirty' () — live region must repaint. */ export class DemoModel extends EventEmitter { tail = ""; status: StatusState | null = null; composer = ""; turn = 0; settledCount = 0; private stopped = false; snapshot(): Snapshot { return { tail: this.tail, status: this.status, composer: this.composer, turn: this.turn }; } start(): void { void this.loop(); } stop(): void { this.stopped = true; } handleKey(k: KeyEvent): void { if (k.type === "char") this.composer += k.ch; else if (k.type === "backspace") this.composer = this.composer.slice(0, -1); else if (k.type === "enter") { if (this.composer.length > 0) { this.settle(["", "❯ " + this.composer, ""]); this.composer = ""; } } this.emit("dirty"); } private settle(lines: string[]): void { this.settledCount++; this.emit("settled", lines); } private setStatus(text: string | null): void { this.status = text === null ? null : { text, since: Date.now() }; this.emit("dirty"); } private async loop(): Promise { while (!this.stopped) { await this.runTurn(); } } private async runTurn(): Promise { this.turn++; this.setStatus("Reading src/session/store.ts"); await sleep(500); if (this.stopped) return; this.settle([" ▸ Read src/session/store.ts · 212 lines"]); await sleep(250); if (this.stopped) return; this.settle([' ▸ Search "retry" · 6 matches']); this.setStatus("Thinking"); await sleep(400); if (this.stopped) return; this.setStatus("Writing"); for (let i = 0; i < MARKDOWN.length; i += DELTA_CHARS) { if (this.stopped) return; this.tail += MARKDOWN.slice(i, i + DELTA_CHARS); this.scanSettle(); this.emit("dirty"); if (i / DELTA_CHARS === 120) { // a tool call completes while text is still streaming this.settle([" ▸ Edit src/context/engine.ts · +31 −12"]); this.setStatus("Editing src/context/engine.ts"); } await sleep(DELTA_INTERVAL_MS); } // flush the remaining tail as settled if (this.tail.trim().length > 0) this.settle(this.tail.split("\n")); this.tail = ""; this.settle([" ▸ Run npm test · passed · 4.2s", ""]); this.setStatus(null); await sleep(700); } /** * Settled-block scanner (TUI_DESIGN §8): a top-level block settles at a blank line * outside a code fence. Only complete (newline-terminated) lines are considered. */ private scanSettle(): void { const lines = this.tail.split("\n"); let fence = false; let lastBoundary = -1; for (let i = 0; i < lines.length - 1; i++) { if (/^```/.test(lines[i].trim())) fence = !fence; if (!fence && lines[i].trim() === "") lastBoundary = i; } if (lastBoundary >= 0) { const block = lines.slice(0, lastBoundary + 1); if (block.some((l) => l.trim() !== "")) this.settle(block); this.tail = lines.slice(lastBoundary + 1).join("\n"); } } } /** Minimal raw-mode key decoder for the spike (arrows and other CSI input are ignored). */ export function decodeKeys(buf: Buffer): KeyEvent[] { const s = buf.toString("utf8"); if (s === "\x1b") return [{ type: "esc" }]; if (s.startsWith("\x1b")) return [{ type: "other" }]; const out: KeyEvent[] = []; for (const ch of s) { if (ch === "\x03") out.push({ type: "ctrlc" }); else if (ch === "\r" || ch === "\n") out.push({ type: "enter" }); else if (ch === "\x7f" || ch === "\b") out.push({ type: "backspace" }); else if (ch >= " ") out.push({ type: "char", ch }); } return out; } export const STATUS_BAR = " main +2 −0 │ claude-sonnet │ context 31% │ $0.42"; export const TAIL_CAP_LINES = 12;