Skip to content

Commit 3148e3e

Browse files
committed
feat(tui): surface run and gate state as terminal program status
1 parent 4958d68 commit 3148e3e

6 files changed

Lines changed: 213 additions & 8 deletions

File tree

‎src/tui/chrome-state.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,11 @@ describe("chromeFromSession", () => {
651651
expect(zones.agents?.[0]?.label).toContain("explorer");
652652
});
653653

654+
test("copies a turn-level stall flag onto the live snapshot", () => {
655+
expect(chromeFromSession({ stalled: true }).stalled).toBe(true);
656+
expect(chromeFromSession({}).stalled).toBeUndefined();
657+
});
658+
654659
test("falls back agent id; empty bags hide", () => {
655660
const state = chromeFromSession({
656661
tasks: [],

‎src/tui/chrome-state.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ export interface ChromeLiveState {
8282
readonly agentId: string;
8383
readonly description: string;
8484
} | null;
85+
/** Turn-level stall (`isStalledForDisplay`). Absent means the host reads
86+
* the live watchdog instead of this snapshot. */
87+
readonly stalled?: boolean;
8588
}
8689

8790
/** One rendered agents-panel row. `stalled` comes precomputed so the
@@ -513,6 +516,7 @@ export interface ChromeSessionInput {
513516
readonly tasks?: readonly ChromeSessionTask[] | null;
514517
readonly agents?: readonly ChromeSessionAgent[] | null;
515518
readonly observe?: ChromeLiveState["observe"];
519+
readonly stalled?: boolean;
516520
}
517521

518522
/** Map real session shapes (tasks / subagent store) into ChromeLiveState.
@@ -521,11 +525,13 @@ export function chromeFromSession(input: ChromeSessionInput): ChromeLiveState {
521525
const task = mapSessionTasks(input.tasks);
522526
const agents = mapSessionAgents(input.agents);
523527
const observe = input.observe ?? null;
528+
const stalled = input.stalled;
524529

525530
return {
526531
...(task !== undefined ? { task } : {}),
527532
...(agents !== undefined ? { agents } : {}),
528533
...(observe !== null && observe !== undefined ? { observe } : {}),
534+
...(stalled !== undefined ? { stalled } : {}),
529535
};
530536
}
531537

‎src/tui/product-host.ts‎

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
*/
55

66
import { EventEmitter } from "node:events";
7-
import { createCliRenderer, type CliRenderer } from "@opentui/core";
7+
import {
8+
createCliRenderer,
9+
type CliRenderer,
10+
type ProgramStatus,
11+
} from "@opentui/core";
812

913
import { createLiveSessionPort } from "./live-session-port.js";
1014
import { checkWidthContract, widthContractNotice } from "./width-contract.js";
@@ -18,6 +22,7 @@ import type { ShellOutputFeed } from "../session/shell-output-feed.js";
1822
import { openAddProviderOverlay, openModelPickerOverlay } from "./overlays.js";
1923
import { wireGates } from "./gate-wire.js";
2024
import { createSystemClipboard } from "./system-clipboard.js";
25+
import { programStatusForChrome } from "./program-status.js";
2126
import {
2227
AGENTS_PANEL_LINGER_MS,
2328
agentsChromeNeedsSticky,
@@ -260,6 +265,13 @@ export interface ProductHostConfig {
260265
* resends wheel input as arrow keys; with it, drag-select is OpenTUI-owned
261266
* and auto-copies on mouse-up (Alt+M returns to native selection). */
262267
readonly useMouse?: boolean;
268+
/** OSC 7501 sink. Default calls `renderer.setProgramStatus` and ignores a
269+
* false return (terminal inactive, output full, or destroyed). */
270+
readonly programStatus?: ProgramStatusPort;
271+
}
272+
273+
export interface ProgramStatusPort {
274+
set(status: ProgramStatus): boolean;
263275
}
264276

265277
export interface ProductHost {
@@ -384,17 +396,41 @@ export async function mountProductHost(
384396
const agentsPanelLingerMs =
385397
config.agentsPanelLingerMs ?? AGENTS_PANEL_LINGER_MS;
386398
let chromeState: ChromeLiveState | null = config.chrome ?? null;
399+
const programStatus: ProgramStatusPort = config.programStatus ?? {
400+
set: (status) => {
401+
try {
402+
return renderer.setProgramStatus(status);
403+
} catch {
404+
return false;
405+
}
406+
},
407+
};
408+
let openGates = 0;
409+
let lastProgramStatus = "";
410+
const publishProgramStatus = (): void => {
411+
const status = programStatusForChrome({
412+
tasks: chromeState?.task,
413+
agents: chromeState?.agents,
414+
stalled: chromeState?.stalled ?? bridge.isTurnStalled(),
415+
gateOpen: openGates > 0,
416+
});
417+
const key = JSON.stringify(status);
418+
if (key === lastProgramStatus) return;
419+
if (programStatus.set(status)) lastProgramStatus = key;
420+
};
387421
const paintChromeZones = (): void => {
388422
if (chromeState === null) {
389423
setChromeZones(shell, { task: null, agents: null });
390-
return;
424+
} else {
425+
setChromeZones(
426+
shell,
427+
formatChromeZones(chromeState, Date.now(), agentsPanelLingerMs),
428+
);
391429
}
392-
setChromeZones(
393-
shell,
394-
formatChromeZones(chromeState, Date.now(), agentsPanelLingerMs),
395-
);
430+
publishProgramStatus();
396431
};
397432
if (chromeState !== null) paintChromeZones();
433+
else publishProgramStatus();
398434

399435
let disposed = false;
400436
let resolveExit: (() => void) | undefined;
@@ -443,6 +479,8 @@ export async function mountProductHost(
443479
// formatAgentsPanel returns null after linger.
444480
if (stickyNeeded || stickyWasNeeded) {
445481
paintChromeZones();
482+
} else {
483+
publishProgramStatus();
446484
}
447485
stickyWasNeeded = stickyNeeded;
448486
} catch {
@@ -467,6 +505,11 @@ export async function mountProductHost(
467505
config.eventEmitter.off("compaction", onCompaction);
468506
config.eventEmitter.off("workflow", onWorkflow);
469507
bridge.dispose();
508+
try {
509+
programStatus.set({ state: "clear" });
510+
} catch {
511+
// renderer already gone
512+
}
470513
// Cancels any flash still counting down: its expiry repaints into a
471514
// destroyed text buffer after teardown.
472515
setStatusFlash(shell, null);
@@ -559,8 +602,16 @@ export async function mountProductHost(
559602
let disposeGates: () => void;
560603
try {
561604
disposeGates = wireGates(config.eventEmitter, shell, {
562-
onGateOpened: () => bridge.gateOpened(),
563-
onGateClosed: () => bridge.gateClosed(),
605+
onGateOpened: () => {
606+
openGates += 1;
607+
bridge.gateOpened();
608+
publishProgramStatus();
609+
},
610+
onGateClosed: () => {
611+
openGates = Math.max(0, openGates - 1);
612+
bridge.gateClosed();
613+
publishProgramStatus();
614+
},
564615
onPrimaryOperatorAdmitted: (event) =>
565616
setOperatorInputRequiredGate(shell, event),
566617
onPrimaryOperatorSettled: (event) =>

‎src/tui/program-status.test.ts‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { programStatusForChrome } from "./program-status.js";
4+
5+
const doing = { title: "ship the mapper", status: "doing" as const };
6+
const todo = { title: "later", status: "todo" as const };
7+
const liveAgent = {
8+
agentId: "a",
9+
description: "read the tree",
10+
status: "running" as const,
11+
currentToolStartedAt: null,
12+
};
13+
14+
describe("programStatusForChrome", () => {
15+
test("an open gate blocks even while work is in flight", () => {
16+
expect(
17+
programStatusForChrome({
18+
tasks: [doing],
19+
agents: [liveAgent],
20+
stalled: true,
21+
gateOpen: true,
22+
}),
23+
).toEqual({
24+
state: "blocked",
25+
app: "corbits",
26+
title: "corbits",
27+
message: "waiting on the operator",
28+
});
29+
});
30+
31+
test("a stalled turn is an error when no gate is open", () => {
32+
expect(
33+
programStatusForChrome({
34+
tasks: [doing],
35+
stalled: true,
36+
gateOpen: false,
37+
}).state,
38+
).toBe("error");
39+
});
40+
41+
test("a doing task names the working record", () => {
42+
expect(
43+
programStatusForChrome({
44+
tasks: [todo, doing],
45+
stalled: false,
46+
gateOpen: false,
47+
}),
48+
).toMatchObject({ state: "working", message: "ship the mapper" });
49+
});
50+
51+
test("a live agent is working when no task is doing", () => {
52+
expect(
53+
programStatusForChrome({
54+
agents: [liveAgent],
55+
stalled: false,
56+
gateOpen: false,
57+
}),
58+
).toMatchObject({ state: "working", message: "read the tree" });
59+
});
60+
61+
test("an idle session reports idle, not clear", () => {
62+
expect(
63+
programStatusForChrome({ stalled: false, gateOpen: false }),
64+
).toMatchObject({ state: "idle", message: "idle" });
65+
});
66+
67+
test("the report addresses the root record", () => {
68+
const status = programStatusForChrome({
69+
stalled: false,
70+
gateOpen: false,
71+
});
72+
expect("id" in status).toBe(false);
73+
});
74+
});

‎src/tui/program-status.ts‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Pure OSC 7501 roll-up for one session. One root record (no lane id): a
3+
* terminal that speaks the protocol replaces that record on every report.
4+
* Lane-keyed records stay out until a real OSC 7511 terminal confirms them.
5+
*/
6+
7+
import type { ProgramStatus } from "@opentui/core";
8+
9+
import { agentLaneIsLive } from "../subagent/agent-progress.js";
10+
import type { ChromeAgentSession, ChromeTaskRow } from "./chrome-state.js";
11+
12+
const MESSAGE_MAX = 80;
13+
14+
export interface ProgramStatusInput {
15+
readonly tasks?: readonly ChromeTaskRow[] | null | undefined;
16+
readonly agents?: readonly ChromeAgentSession[] | null | undefined;
17+
/** Turn-level stall from `isStalledForDisplay`, not a per-lane clock. */
18+
readonly stalled: boolean;
19+
/** A permission or operator gate is outstanding, queued or on screen. */
20+
readonly gateOpen: boolean;
21+
}
22+
23+
export function programStatusForChrome(
24+
input: ProgramStatusInput,
25+
): ProgramStatus {
26+
if (input.gateOpen) {
27+
return {
28+
state: "blocked",
29+
app: "corbits",
30+
title: "corbits",
31+
message: "waiting on the operator",
32+
};
33+
}
34+
if (input.stalled) {
35+
return {
36+
state: "error",
37+
app: "corbits",
38+
title: "corbits",
39+
message: "stalled",
40+
};
41+
}
42+
const doing = input.tasks?.find((task) => task.status === "doing");
43+
const live = input.agents?.find((agent) => agentLaneIsLive(agent));
44+
if (doing !== undefined || live !== undefined) {
45+
const raw = doing?.title ?? live?.description ?? "working";
46+
return {
47+
state: "working",
48+
app: "corbits",
49+
title: "corbits",
50+
message: clip(raw.length > 0 ? raw : "working"),
51+
};
52+
}
53+
return {
54+
state: "idle",
55+
app: "corbits",
56+
title: "corbits",
57+
message: "idle",
58+
};
59+
}
60+
61+
function clip(text: string): string {
62+
const flat = text.replace(/\s+/g, " ").trim();
63+
if (flat.length <= MESSAGE_MAX) return flat;
64+
return `${flat.slice(0, MESSAGE_MAX - 3)}...`;
65+
}

‎src/tui/runtime-bridge.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ export interface SessionBridge {
237237
dispose: () => void;
238238
/** Current derived turn phase (progress label, stall clock, quota window). */
239239
readonly turn: TurnState;
240+
/** Turn-level stall the program-status roll-up paints. Same predicate as
241+
* the lockup (`isStalledForDisplay`). */
242+
readonly isTurnStalled: () => boolean;
240243
readonly shell: AppShell;
241244
/** Refresh `spawn_agent` rows with live progress for the worker lifetime. */
242245
syncAgentProgress: (sessions: readonly TaskProgressSession[]) => void;
@@ -2250,6 +2253,7 @@ export function attachSessionBridge(
22502253
get turn() {
22512254
return bag.turn;
22522255
},
2256+
isTurnStalled: () => isStalledForDisplay(stallArgsFor(now())),
22532257
syncAgentProgress: (sessions) => {
22542258
if (bag.disposed) return;
22552259
bag.agentSessions = sessions;

0 commit comments

Comments
 (0)