mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
fix(tui): preserve lifecycle and input framing
This commit is contained in:
1 parent
cbb76a5a8b
commit
c80abfa878
6 files changed
+58
-12
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: "[" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user