diff --git a/packages/tui/src/hydrate.ts b/packages/tui/src/hydrate.ts index 33ee8c247..b28cc282d 100644 --- a/packages/tui/src/hydrate.ts +++ b/packages/tui/src/hydrate.ts @@ -37,6 +37,7 @@ interface NodeInventoryRead { startupStatus?: string | null; terminalActive?: boolean | null; agentActivity?: { state?: string } | null; + identityVerdict?: { verdict?: string } | null; canonicalSessionName: string | null; tmuxAttachCommand?: string | null; resolvedSpecName: string | null; @@ -123,6 +124,8 @@ function fmtTokens(input: number | null, output: number | null): string | null { function toAgentRow(node: NodeInventoryRead): AgentRow { const ctx = node.contextUsage; const known = ctx?.availability === "known"; + const identityDownranked = node.identityVerdict?.verdict === "mismatch" + || node.identityVerdict?.verdict === "pane_missing"; return { name: node.logicalId, runtime: node.runtime ?? "unknown", @@ -134,7 +137,7 @@ function toAgentRow(node: NodeInventoryRead): AgentRow { // while session/terminal activity drives the visible status label. status: node.startupStatus === "failed" ? "failed" - : node.startupStatus === "attention_required" + : node.lifecycleState === "attention_required" || identityDownranked || node.startupStatus === "attention_required" ? "attention_required" : node.agentActivity?.state === "needs_input" ? "needs_input" diff --git a/packages/tui/src/input.ts b/packages/tui/src/input.ts index dc85ee718..c4604fb52 100644 --- a/packages/tui/src/input.ts +++ b/packages/tui/src/input.ts @@ -72,7 +72,6 @@ function parseText(text: string, final: boolean): { events: InputEvent[]; remain export interface InputDecoder { write(bytes: string | Buffer): InputEvent[]; flush(): InputEvent[]; - hasPending(): boolean; } /** Stateful terminal-stream decoder: retains split escape sequences and uses @@ -92,7 +91,6 @@ export function createInputDecoder(): InputDecoder { pending = parsed.remainder; return parsed.events; }, - hasPending: () => pending.length > 0, }; } diff --git a/packages/tui/src/main.ts b/packages/tui/src/main.ts index 1bb82413a..316bd7f34 100644 --- a/packages/tui/src/main.ts +++ b/packages/tui/src/main.ts @@ -38,7 +38,6 @@ async function run(): Promise { let inputLine = ""; let lastScreen: Screen | null = null; const inputDecoder = createInputDecoder(); - let inputFlushTimer: NodeJS.Timeout | null = null; function draw(): void { const cols = process.stdout.columns ?? 120; @@ -149,14 +148,10 @@ async function run(): Promise { } process.stdin.on("data", (bytes: Buffer) => { - if (inputFlushTimer) clearTimeout(inputFlushTimer); handleInput(inputDecoder.write(bytes)); - if (inputDecoder.hasPending()) { - inputFlushTimer = setTimeout(() => { - inputFlushTimer = null; - handleInput(inputDecoder.flush()); - }, 25); - } + }); + process.stdin.on("end", () => { + handleInput(inputDecoder.flush()); }); process.stdout.write(ALT_SCREEN_ON + MOUSE_ENABLE); diff --git a/packages/tui/test/hydrate.test.ts b/packages/tui/test/hydrate.test.ts index 37c09df61..47f35f96c 100644 --- a/packages/tui/test/hydrate.test.ts +++ b/packages/tui/test/hydrate.test.ts @@ -251,6 +251,8 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => { { ...base, logicalId: "dev.attention", startupStatus: "attention_required", lifecycleState: "attention_required", terminalActive: false }, { ...base, logicalId: "dev.input", startupStatus: "ready", agentActivity: { state: "needs_input" }, terminalActive: true }, { ...base, logicalId: "dev.active", startupStatus: "ready", agentActivity: { state: "running" }, terminalActive: true }, + { ...base, logicalId: "dev.mismatch", startupStatus: "ready", lifecycleState: "attention_required", agentActivity: { state: "running" }, terminalActive: true, identityVerdict: { verdict: "mismatch" } }, + { ...base, logicalId: "dev.missing", startupStatus: "ready", lifecycleState: "attention_required", agentActivity: { state: "running" }, terminalActive: true, identityVerdict: { verdict: "pane_missing" } }, ]; const snap = await hydrateSnapshot(fixtureClient({}, { "/api/rigs/01JRIG/nodes": nodes })); const statuses = Object.fromEntries(snap.hosts[0]!.rigs[0]!.pods[0]!.agents.map((agent) => [agent.name, agent.status])); @@ -259,6 +261,8 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => { "dev.attention": "attention_required", "dev.input": "needs_input", "dev.active": "active", + "dev.mismatch": "attention_required", + "dev.missing": "attention_required", }); }); diff --git a/packages/tui/test/input-stream.test.ts b/packages/tui/test/input-stream.test.ts index 5bd0e2942..e90875bf4 100644 --- a/packages/tui/test/input-stream.test.ts +++ b/packages/tui/test/input-stream.test.ts @@ -26,4 +26,13 @@ describe("stateful stdin decoding", () => { expect(decoder.write(bytes.subarray(split))).toEqual([{ type: "char", ch: "界" }]); } }); + + it("defines EOF flush for an incomplete CSI prefix", () => { + const decoder = createInputDecoder(); + expect(decoder.write("\x1b[")).toEqual([]); + expect(decoder.flush()).toEqual([ + { type: "key", key: "escape" }, + { type: "char", ch: "[" }, + ]); + }); }); diff --git a/scripts/test-tui-package.mjs b/scripts/test-tui-package.mjs index 74f050da2..3daa27910 100644 --- a/scripts/test-tui-package.mjs +++ b/scripts/test-tui-package.mjs @@ -2,13 +2,49 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { execFileSync, spawnSync } from "node:child_process"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cli = path.join(root, "packages", "cli"); const temp = fs.mkdtempSync(path.join(os.tmpdir(), "openrig-tui-pack-")); +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function assertDelayedSplits(entry, pkgDir) { + const vectors = [ + ["up", "\x1b[A"], ["down", "\x1b[B"], ["right", "\x1b[C"], ["left", "\x1b[D"], + ["pageup", "\x1b[5~"], ["pagedown", "\x1b[6~"], + ["mouse", "\x1b[<0;47;12M\x1b[<0;47;12m"], + ]; + let run = 0; + for (const [label, sequence] of vectors) { + const bytes = Buffer.from(sequence); + for (let split = 1; split < bytes.length; split++) { + const socket = path.join(temp, `split-${run++}.sock`); + const child = spawn(process.execPath, [entry, "--demo", "--socket", socket], { + cwd: pkgDir, + stdio: ["pipe", "pipe", "pipe"], + env: process.env, + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { stderr += chunk; }); + await wait(40); + child.stdin.write(bytes.subarray(0, split)); + await wait(80); + child.stdin.write(bytes.subarray(split)); + await wait(20); + child.stdin.write("q"); + const code = await Promise.race([ + new Promise((resolve) => child.once("exit", resolve)), + wait(1_000).then(() => "timeout"), + ]); + if (code === "timeout") child.kill("SIGTERM"); + assert.equal(code, 0, `${label} split ${split}/${bytes.length} failed delayed packed-entry decoding: ${stderr}`); + } + } +} + try { execFileSync("npm", ["run", "build:package"], { cwd: root, stdio: "inherit" }); const packed = JSON.parse(execFileSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], { cwd: cli, encoding: "utf8" })); @@ -31,6 +67,7 @@ try { env: process.env, }); assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`); + await assertDelayedSplits(entry, pkgDir); console.log(`packed TUI launch PASS: ${packed[0].filename}`); } finally { fs.rmSync(temp, { recursive: true, force: true });