fix(tui): preserve lifecycle and input framing

This commit is contained in:
v-openrig-build committed 2026-08-03 09:32:29 +00:00
1 parent cbb76a5a8b
commit c80abfa878
6 files changed
+58 -12

No files matched your search

+4 -1
View File
@@ -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"
-2
View File
@@ -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,
};
}
+3 -8
View File
@@ -38,7 +38,6 @@ async function run(): Promise<void> {
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<void> {
}
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);
+4
View File
@@ -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",
});
});
+9
View File
@@ -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: "[" },
]);
});
});
+38 -1
View File
@@ -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 });