fix(tui): close terminal review gaps

This commit is contained in:
v-openrig-build committed 2026-08-03 09:32:29 +00:00
1 parent caae077c7a
commit 4f78f931d0
22 files changed
+516 -129

No files matched your search

+1
View File
@@ -17,6 +17,7 @@
"test:workspaces": "npm run test --workspaces",
"lint": "tsc --noEmit --project packages/daemon/tsconfig.json && tsc --noEmit --project packages/ui/tsconfig.json && tsc --noEmit --project packages/cli/tsconfig.json && tsc --noEmit --project packages/tui/tsconfig.json",
"build:package": "bash scripts/build-package.sh",
"test:tui-package": "node scripts/test-tui-package.mjs",
"mirror-skills": "node scripts/mirror-skills.mjs",
"mirror-skills:check": "node scripts/mirror-skills.mjs --check"
},
+3 -1
View File
@@ -26,12 +26,14 @@
"url": "https://esoteric.run"
},
"bin": {
"rig": "dist/bin-wrapper.js"
"rig": "dist/bin-wrapper.js",
"openrig-tui": "tui/dist/main.js"
},
"files": [
"dist",
"daemon",
"ui",
"tui",
"scripts"
],
"scripts": {
+4 -4
View File
@@ -16,15 +16,15 @@ export function demoSnapshot(): FleetSnapshot {
{
name: "dev50",
agents: [
{ name: "dev50.driver", runtime: "claude-code", spec: "driver-agent", context: 62, tokens: "118k", status: "running", session: "dev50-driver@openrig-build", attach: "tmux attach -t dev50-driver@openrig-build" },
{ name: "dev50.guard", runtime: "codex", spec: "guard-agent", context: 31, tokens: "54k", status: "idle", session: "dev50-guard@openrig-build" },
{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "unknown", session: "dev50-qa@openrig-build" },
{ name: "dev50.driver", runtime: "claude-code", spec: "driver-agent", context: 62, tokens: "118k", status: "active", live: true, canRun: false, session: "dev50-driver@openrig-build", attach: "tmux attach -t dev50-driver@openrig-build" },
{ name: "dev50.guard", runtime: "codex", spec: "guard-agent", context: 31, tokens: "54k", status: "idle", live: true, canRun: false, session: "dev50-guard@openrig-build" },
{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "unknown", live: false, canRun: true, session: "dev50-qa@openrig-build" },
],
},
{
name: "orch",
agents: [
{ name: "orch.lead", runtime: "codex", spec: "lead-agent", context: 88, tokens: "203k", status: "needs-attention" },
{ name: "orch.lead", runtime: "codex", spec: "lead-agent", context: 88, tokens: "203k", status: "needs-attention", live: true, canRun: false },
],
},
],
+6 -5
View File
@@ -4,21 +4,22 @@
// dispatch, so every input adapter shares one failure surface.
// Richer grammar (compound commands, prefixes, history/completion) was
// RETIRED by the Phase-0 kill-criterion — see the spike verdict.
import type { Action, ResourceKind } from "./types.js";
import { SECTION_REGISTRY } from "./sections.js";
import type { Action, ResourceKind, SectionDef } from "./types.js";
const SECTIONS = ["topology", "specs", "needs"] as const;
const RESOURCES: ResourceKind[] = ["host", "rig", "pod", "agent", "spec"];
export function parseCommand(raw: string): Action {
export function parseCommand(raw: string, sections: readonly SectionDef[] = SECTION_REGISTRY): Action {
const input = raw.trim();
if (input === "") return { type: "noop" };
if (input.startsWith(":")) {
const section = input.slice(1).trim();
if ((SECTIONS as readonly string[]).includes(section)) return { type: "jump", section };
const names = sections.map((entry) => entry.name);
if (names.includes(section)) return { type: "jump", section };
return {
type: "error",
message: `unknown section ":${section}" — known: ${SECTIONS.map((s) => ":" + s).join(" ")}`,
message: `unknown section ":${section}" — known: ${names.map((s) => ":" + s).join(" ")}`,
};
}
+16 -5
View File
@@ -33,6 +33,10 @@ interface NodeInventoryRead {
nodeKind: "agent" | "infrastructure";
runtime: string | null;
lifecycleState: string;
sessionStatus?: string | null;
startupStatus?: string | null;
terminalActive?: boolean | null;
agentActivity?: { state?: string } | null;
canonicalSessionName: string | null;
tmuxAttachCommand?: string | null;
resolvedSpecName: string | null;
@@ -95,8 +99,9 @@ interface NeedsYouItemRead {
destinationSession: string | null;
derived: { kind: string; evidence: string; threshold: string } | null;
}
interface ReviewRigRead {
interface ReviewFleetRead {
needsYou?: { items?: NeedsYouItemRead[] };
hosts?: Array<{ hostId: string; status: { status: string } }>;
}
interface AttentionAggregateRead {
hosts?: Array<{ hostId: string; status: string; error?: string }>;
@@ -123,8 +128,13 @@ function toAgentRow(node: NodeInventoryRead): AgentRow {
// honest-unknown: no value in the projection → null → renders "—"
context: known && ctx.usedPercentage != null ? Math.round(ctx.usedPercentage) : null,
tokens: known ? fmtTokens(ctx.totalInputTokens, ctx.totalOutputTokens) : null,
// PIN 2: the maintained projection's lifecycleState VERBATIM
status: node.lifecycleState,
// Mirror the maintained web projection: lifecycle truth drives actions,
// while session/terminal activity drives the visible status label.
status: node.sessionStatus === "running" || node.sessionStatus === "ready"
? (node.terminalActive === true || (node.terminalActive == null && node.agentActivity?.state === "running") ? "active" : "idle")
: (node.sessionStatus ?? "unknown"),
live: node.lifecycleState === "running",
canRun: node.lifecycleState !== "running",
session: node.canonicalSessionName,
attach: node.tmuxAttachCommand ?? null,
};
@@ -207,7 +217,7 @@ export async function hydrateSnapshot(client: DaemonClient, reviewCache?: SpecRe
safe<AttentionAggregateRead>("attention-aggregate", () => client.attentionAggregate()),
safe<RigSummaryRead[]>("rigs-summary", () => client.rigsSummary()),
safe<SpecLibraryRead[]>("specs-library", () => client.specsLibrary()),
safe<ReviewRigRead>("review-rig", () => client.reviewRig()),
safe<ReviewFleetRead>("review-fleet", () => client.reviewFleet()),
safe<StreamItemRead[]>("stream-list", () => client.streamList()),
]);
@@ -372,7 +382,8 @@ export async function hydrateSnapshot(client: DaemonClient, reviewCache?: SpecRe
hosts: [localHost, ...remoteHosts],
specs,
needs,
humanQueueProbed: review != null,
humanQueueProbed: review != null && Array.isArray(review.hosts) && review.hosts.length > 0
&& review.hosts.every((host) => host.status.status === "ok"),
humanQueue,
hostsDown,
stream: (streamItems ?? []).map((s) => ({ tsEmitted: s.tsEmitted, sourceSession: s.sourceSession, body: s.body })),
+2 -2
View File
@@ -1,7 +1,7 @@
export * from "./types.js";
export { parseCommand } from "./grammar.js";
export { createViewState, defaultSections, emptySnapshot, computeExplorerRows, findAgent, findSpec, findRig, agentsRunningSpec } from "./state.js";
export { decodeInput, sgrClick, MOUSE_ENABLE, MOUSE_DISABLE, ALT_SCREEN_ON, ALT_SCREEN_OFF } from "./input.js";
export { createViewState, defaultSections, emptySnapshot, computeExplorerRows, findAgent, findSpec, findRig, agentsRunningSpec, agentsRunningSpecTargets } from "./state.js";
export { decodeInput, resolveKeyAction, sgrClick, MOUSE_ENABLE, MOUSE_DISABLE, ALT_SCREEN_ON, ALT_SCREEN_OFF } from "./input.js";
export { renderScreen } from "./render.js";
export { createControlSocket, defaultSocketPath, describeState, MAX_SOCKET_PATH_BYTES } from "./socket-server.js";
export { DaemonClient } from "./daemon-client.js";
+24 -1
View File
@@ -2,7 +2,7 @@
// ESC [ < b ; x ; y M/m — the standard tmux/iTerm/Terminal.app mouse encoding.
// Mouse events are resolved against the renderer's hit-map by the caller and
// then dispatched through the SAME dispatch as commands and keys (PIN 1).
import type { InputEvent } from "./types.js";
import type { Action, InputEvent, Screen, ViewState } from "./types.js";
const SGR_MOUSE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
@@ -73,6 +73,29 @@ export function sgrClick(x: number, y: number): string {
return `\x1b[<0;${x};${y}M\x1b[<0;${x};${y}m`;
}
/** Resolve directional/Enter keys against the currently rendered pane. */
export function resolveKeyAction(
event: Extract<InputEvent, { type: "key" }>,
state: ViewState,
screen: Screen,
explorerCount: number,
): Action | null {
if (event.key === "left") return { type: "focus", pane: "explorer" };
if (event.key === "right") return screen.contentTargets.length > 0 ? { type: "focus", pane: "content" } : null;
if (event.key === "up" || event.key === "down") {
const delta = event.key === "down" ? 1 : -1;
return state.focusedPane === "content"
? { type: "content-select", delta }
: { type: "select", delta, rowCount: explorerCount };
}
if (event.key === "enter") {
return state.focusedPane === "content"
? (screen.contentTargets[state.contentSelection]?.action ?? { type: "error", message: "nothing selected in content" })
: { type: "activate" };
}
return "action" in event ? event.action : null;
}
export const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
export const MOUSE_DISABLE = "\x1b[?1006l\x1b[?1000l";
export const ALT_SCREEN_ON = "\x1b[?1049h\x1b[?25l";
+14 -7
View File
@@ -7,7 +7,7 @@
// openrig-tui [--instance <id>] [--socket <path>] [--url <daemon>] [--demo]
import { createViewState, computeExplorerRows, emptySnapshot } from "./state.js";
import { parseCommand } from "./grammar.js";
import { decodeInput, MOUSE_ENABLE, MOUSE_DISABLE, ALT_SCREEN_ON, ALT_SCREEN_OFF } from "./input.js";
import { decodeInput, resolveKeyAction, MOUSE_ENABLE, MOUSE_DISABLE, ALT_SCREEN_ON, ALT_SCREEN_OFF } from "./input.js";
import { renderScreen } from "./render.js";
import { createControlSocket, defaultSocketPath } from "./socket-server.js";
import { demoSnapshot } from "./demo-data.js";
@@ -42,6 +42,10 @@ async function run(): Promise<void> {
const cols = process.stdout.columns ?? 120;
const rows = process.stdout.rows ?? 32;
lastScreen = renderScreen(view.get(), snapshot, { cols, rows }, inputLine);
if (view.get().contentMaxOffset !== lastScreen.contentMaxOffset || view.get().contentTargetCount !== lastScreen.contentTargets.length) {
view.dispatch({ type: "layout", contentMaxOffset: lastScreen.contentMaxOffset, contentTargetCount: lastScreen.contentTargets.length });
lastScreen = renderScreen(view.get(), snapshot, { cols, rows }, inputLine);
}
process.stdout.write("\x1b[H" + lastScreen.lines.map((l) => "\x1b[2K" + l).join("\r\n"));
}
@@ -121,16 +125,19 @@ async function run(): Promise<void> {
inputLine = "";
} else if (ev.type === "key" && ev.key === "enter") {
if (inputLine !== "") {
view.dispatch(parseCommand(inputLine));
perform(parseCommand(inputLine, view.get().sections));
inputLine = "";
} else {
view.dispatch({ type: "activate" });
if (lastScreen) {
const action = resolveKeyAction(ev, view.get(), lastScreen, computeExplorerRows(view.get(), snapshot).length);
if (action) perform(action);
}
}
} else if (ev.type === "key" && "action" in ev) {
const action = ev.action;
if (action.type === "select")
view.dispatch({ ...action, rowCount: computeExplorerRows(view.get(), snapshot).length });
else view.dispatch(action);
if (lastScreen) {
const action = resolveKeyAction(ev, view.get(), lastScreen, computeExplorerRows(view.get(), snapshot).length);
if (action) perform(action);
}
} else if (ev.type === "mouse" && lastScreen) {
const hit = lastScreen.hitMap.find((h) => h.y === ev.y && ev.x >= h.x1 && ev.x <= h.x2);
if (hit) perform(hit.action);
+40 -20
View File
@@ -4,7 +4,7 @@
// agent-refs, Needs-You items) — so a mouse click anywhere resolves to the
// SAME semantic actions commands produce (PIN 1). Isolated seam: a substrate
// swap touches only this module (spike verdict revisit trigger).
import { computeExplorerRows, findAgent, findSpec, findAgentBySession, agentsRunningSpec } from "./state.js";
import { computeExplorerRows, findAgent, findSpec, findAgentBySession, agentsRunningSpec, agentsRunningSpecTargets } from "./state.js";
import type { Action, FleetSnapshot, NeedsItem, Screen, ViewState } from "./types.js";
const EXPL_W = 30;
@@ -68,10 +68,10 @@ function specTabsLine(state: ViewState, name: string): ContentLine {
}
function needsLine(prefix: string, item: NeedsItem, snap: FleetSnapshot): ContentLine {
const agent = findAgentBySession(snap, item.target);
const found = findAgentBySession(snap, item.target);
return {
text: `${prefix}${item.kind} ${item.target} — ${item.detail}${agent ? " (open ▸)" : ""}`,
...(agent ? { action: { type: "drill", resource: "agent", name: agent.name } as const } : {}),
text: `${prefix}${item.kind} ${item.target} — ${item.detail}${found ? " (open ▸)" : ""}`,
...(found ? { action: { type: "drill", resource: "agent", name: found.agent.name, target: { host: found.host.name, rig: found.rig.name, pod: found.pod.name } } as const } : {}),
};
}
@@ -116,28 +116,37 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
if (state.section === "topology") {
if (state.runningOf) {
lines.push({ text: `seats running spec "${state.runningOf}":` });
for (const seat of agentsRunningSpec(snap, state.runningOf))
lines.push({ text: ` ● ${seat} (open: agent ${seat})`, action: { type: "drill", resource: "agent", name: seat } });
for (const seat of agentsRunningSpecTargets(snap, state.runningOf))
lines.push({
text: ` ● ${seat.agent.name} (open: agent ${seat.agent.name})`,
action: { type: "drill", resource: "agent", name: seat.agent.name, target: { host: seat.host.name, rig: seat.rig.name, pod: seat.pod.name } },
});
if (lines.length === 1) lines.push({ text: " (no seats currently run it)" });
return lines;
}
const leaf = state.drill.at(-1);
if (leaf?.kind === "agent") {
const found = findAgent(snap, leaf.name);
const hostName = state.drill.find((part) => part.kind === "host")?.name;
const rigName = state.drill.find((part) => part.kind === "rig")?.name;
const podName = state.drill.find((part) => part.kind === "pod")?.name;
const found = hostName ? findAgent(snap, leaf.name, { host: hostName, rig: rigName, pod: podName }) : findAgent(snap, leaf.name);
if (!found) return [{ text: `agent "${leaf.name}" not in the current snapshot` }];
const { agent, rig, pod } = found;
lines.push({ text: `agent ${agent.name}` });
lines.push({ text: ` rig ${rig.name} · pod ${pod.name} · runtime ${agent.runtime}` });
if (agent.spec)
lines.push({ text: ` spec ${agent.spec} (open: spec-of ${agent.name})`, action: { type: "cross", kind: "spec-of", name: agent.name } });
if (agent.spec && findSpec(snap, agent.spec))
lines.push({ text: ` spec ${agent.spec} (open: spec-of ${agent.name})`, action: { type: "cross", kind: "spec-of", name: agent.name, target: { host: found.host.name, rig: rig.name, pod: pod.name } } });
else if (agent.spec) lines.push({ text: ` spec ${agent.spec} (not in library)` });
lines.push({ text: ` state ${agent.status}` });
if (agent.attach) lines.push({ text: ` attach: ${agent.attach}` });
lines.push({ text: ` term ▸ (open pod terminal)`, action: { type: "act", act: "open-terminal", view: `pod:${rig.name}/${pod.name}` } });
return lines;
}
const rigName = state.drill.find((d) => d.kind === "rig")?.name ?? snap.hosts[0]?.rigs[0]?.name;
const rig = snap.hosts.flatMap((h) => h.rigs).find((r) => r.name === rigName);
if (!rig) return [{ text: "no rig in view — waiting on the daemon read (honest-empty, not fabricated)" }];
const hostName = state.drill.find((d) => d.kind === "host")?.name;
const host = (hostName ? snap.hosts.find((candidate) => candidate.name === hostName) : snap.hosts[0]);
const rig = host?.rigs.find((candidate) => candidate.name === rigName);
if (!rig || !host) return [{ text: "no rig in view — waiting on the daemon read (honest-empty, not fabricated)" }];
const podFilter = leaf?.kind === "pod" ? leaf.name : null;
const all = rig.pods.flatMap((p) => p.agents.map((a) => ({ pod: p.name, ...a })));
const rows = all
@@ -151,7 +160,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
for (const pod of rig.pods)
lines.push({
text: ` ▾ ${pod.name} — ${pod.agents.length} agents`,
action: { type: "drill", resource: "pod", name: pod.name },
action: { type: "drill", resource: "pod", name: pod.name, target: { host: host.name, rig: rig.name } },
});
return lines;
}
@@ -163,7 +172,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
// write contract: `run ▸` = the rig-restore write (rendered only where
// it applies — the seat is not running); `term ▸` = the terminal-open
// view contract (pod-scoped, the web's granularity). No false affordance.
const canRun = a.status !== "running";
const canRun = a.canRun ?? !a.live;
const actionsCell = canRun ? "run ▸ · term ▸" : "term ▸";
const zones: ContentLine["zones"] = [];
const termOffset = actionsColStart + actionsCell.indexOf("term ▸");
@@ -187,7 +196,7 @@ function contentLines(state: ViewState, snap: FleetSnapshot): ContentLine[] {
a.status,
actionsCell,
]),
action: { type: "drill", resource: "agent", name: a.name },
action: { type: "drill", resource: "agent", name: a.name, target: { host: host.name, rig: rig.name, pod: a.pod } },
zones,
});
}
@@ -327,7 +336,7 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
const content = contentLines(state, snap);
const footer = state.footerOn ? snap.stream.at(-1) : undefined;
const chromeRows = footer ? 3 : 2; // bottom rule + status line (+ footer)
const bodyRows = Math.min(Math.max(explorer.length, content.length), Math.max(rows - 2 - chromeRows, 1));
const bodyRows = Math.max(rows - 2 - chromeRows, 1);
const explorerStart = Math.min(
Math.max(state.selection - bodyRows + 1, 0),
Math.max(explorer.length - bodyRows, 0),
@@ -349,6 +358,7 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
});
}
const explorerRows: Screen["explorerRows"] = [];
const contentTargets: Screen["contentTargets"] = [];
for (let i = 0; i < bodyRows; i++) {
const y = lines.length + 1; // 1-based terminal row this line will occupy
const explorerIndex = explorerStart + i;
@@ -356,15 +366,25 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
const marker = explorerIndex === state.selection && row ? "›" : " ";
const left = pad(row ? `${marker}${row.label}` : "", EXPL_W);
const item = visibleContent[i];
lines.push(pad(`${left}│ ${item?.text ?? ""}`, cols));
const hasContentTarget = !!item?.action || (item?.zones?.length ?? 0) > 0;
const targetIndex = contentTargets.length;
const contentMarker = state.focusedPane === "content" && hasContentTarget && targetIndex === state.contentSelection ? "›" : " ";
lines.push(pad(`${left}│${contentMarker}${item?.text ?? ""}`, cols));
if (row) {
hitMap.push({ y, x1: 1, x2: EXPL_W, action: row.action });
explorerRows.push({ ...row, y });
}
// zones first: hit lookup takes the first match, so a zone wins over the row-wide action
for (const z of item?.zones ?? [])
hitMap.push({ y, x1: EXPL_W + 3 + z.start, x2: EXPL_W + 2 + z.end, action: z.action });
if (item?.action) hitMap.push({ y, x1: EXPL_W + 3, x2: cols, action: item.action });
for (const z of item?.zones ?? []) {
const target = { y, x1: EXPL_W + 3 + z.start, x2: EXPL_W + 2 + z.end, action: z.action };
hitMap.push(target);
contentTargets.push(target);
}
if (item?.action) {
const target = { y, x1: EXPL_W + 3, x2: cols, action: item.action };
hitMap.push(target);
contentTargets.push(target);
}
}
if (footer) lines.push(pad(`≋ ${footer.tsEmitted.slice(11, 16)} ${footer.sourceSession}: ${footer.body}`, cols));
@@ -378,5 +398,5 @@ export function renderScreen(state: ViewState, snap: FleetSnapshot, options: Ren
),
);
while (lines.length < rows) lines.push("");
return { lines: lines.slice(0, rows), hitMap, explorerRows };
return { lines: lines.slice(0, rows), hitMap, contentTargets, contentMaxOffset: maxContentOffset, explorerRows };
}
+20
View File
@@ -0,0 +1,20 @@
import type { SectionDef } from "./types.js";
/** The sole section registry consumed by state and command parsing. */
export const SECTION_REGISTRY: readonly SectionDef[] = [
{
name: "topology",
sourceRead: "GET /api/rigs/:id/graph + /api/ps + /api/rigs/summary (existing)",
drillShape: "host>rig>pod>agent",
},
{
name: "specs",
sourceRead: "GET /api/specs/library + /api/rigs/:rigId/spec (existing)",
drillShape: "kind>spec",
},
{
name: "needs",
sourceRead: "GET /api/review/fleet + /api/queue/list?attention=1 (existing)",
drillShape: "flat",
},
];
+2 -1
View File
@@ -26,6 +26,7 @@ export function describeState(state: ViewState) {
screen: state.section,
drill: state.drill.map((d) => `${d.kind}:${d.name}`),
filter: state.filter || undefined,
viewTab: state.viewTab,
error: state.lastError ?? undefined,
};
}
@@ -75,7 +76,7 @@ export async function createControlSocket(options: {
continue;
}
// The one mutation path: grammar → dispatch. Nothing else.
const next = view.dispatch(parseCommand(line));
const next = view.dispatch(parseCommand(line, view.get().sections));
conn.write(JSON.stringify(describeState(next)) + "\n");
onMutation?.();
}
+123 -60
View File
@@ -13,25 +13,10 @@ import type {
ViewState,
ViewStateStore,
} from "./types.js";
import { SECTION_REGISTRY } from "./sections.js";
export function defaultSections(): SectionDef[] {
return [
{
name: "topology",
sourceRead: "GET /api/rigs/:id/graph + /api/ps + /api/rigs/summary (existing)",
drillShape: "host>rig>pod>agent",
},
{
name: "specs",
sourceRead: "GET /api/specs/library + /api/rigs/:rigId/spec (existing)",
drillShape: "kind>spec",
},
{
name: "needs",
sourceRead: "GET /api/review/rig|fleet + /api/queue/list?attention=1 (existing)",
drillShape: "flat",
},
];
return SECTION_REGISTRY.map((section) => ({ ...section }));
}
export function emptySnapshot(): FleetSnapshot {
@@ -58,6 +43,10 @@ export function createViewState(options: CreateViewStateOptions): ViewStateStore
runningOf: null,
viewTab: "table",
contentOffset: 0,
contentMaxOffset: 0,
contentTargetCount: 0,
contentSelection: 0,
focusedPane: "explorer",
footerOn: true,
notice: null,
lastError: null,
@@ -91,12 +80,31 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
case "jump": {
if (!state.sections.some((s) => s.name === action.section))
return { ...next, lastError: `unknown section "${action.section}"` };
return { ...next, section: action.section, drill: [], filter: "", selection: 0, runningOf: null, viewTab: "table", contentOffset: 0 };
return resetContent({ ...next, section: action.section, drill: [], filter: "", selection: 0, runningOf: null, viewTab: "table" });
}
case "tab": {
const rigSpec = state.section === "specs" && state.drill.at(-1)?.kind === "spec" && findSpec(snap, state.drill.at(-1)!.name)?.kind === "rig";
const allowed = rigSpec ? ["topology", "configuration", "yaml"] : state.section === "topology" ? ["table", "overview"] : [];
if (!allowed.includes(action.tab)) return { ...next, lastError: `tab ${action.tab} is not available in this content context` };
return resetContent({ ...next, viewTab: action.tab });
}
case "tab":
return { ...next, viewTab: action.tab, contentOffset: 0 };
case "content-scroll":
return { ...next, contentOffset: Math.max(0, state.contentOffset + action.delta) };
return { ...next, contentOffset: Math.min(Math.max(0, state.contentOffset + action.delta), state.contentMaxOffset) };
case "focus":
return { ...next, focusedPane: action.pane };
case "content-select": {
const count = Math.max(state.contentTargetCount, 1);
const target = action.index ?? state.contentSelection + (action.delta ?? 0);
return { ...next, contentSelection: Math.min(Math.max(target, 0), count - 1) };
}
case "layout":
return {
...next,
contentMaxOffset: Math.max(action.contentMaxOffset, 0),
contentTargetCount: Math.max(action.contentTargetCount, 0),
contentOffset: Math.min(state.contentOffset, Math.max(action.contentMaxOffset, 0)),
contentSelection: Math.min(state.contentSelection, Math.max(action.contentTargetCount - 1, 0)),
};
case "footer":
return { ...next, footerOn: action.on ?? !state.footerOn };
case "act":
@@ -106,7 +114,7 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
case "notice":
return { ...next, notice: action.message };
case "filter":
return { ...next, filter: action.text, selection: 0, contentOffset: 0 };
return resetContent({ ...next, filter: action.text, selection: 0 });
case "select": {
const count = Math.max(action.rowCount ?? Number.MAX_SAFE_INTEGER, 1);
const target = action.index ?? state.selection + (action.delta ?? 0);
@@ -120,26 +128,38 @@ function reduce(state: ViewState, action: Action, snap: FleetSnapshot): ViewStat
return reduce(next, row.action, snap);
}
case "drill": {
const drilled = drillTo(next, action.resource, action.name, snap);
const drilled = drillTo(next, action.resource, action.name, snap, action.target);
if (drilled.lastError) return drilled;
const spec = action.resource === "spec" ? findSpec(snap, action.name) : null;
return { ...drilled, viewTab: spec?.kind === "rig" ? "configuration" : "table", contentOffset: 0 };
return resetContent({ ...drilled, viewTab: spec?.kind === "rig" ? "configuration" : "table" });
}
case "cross":
return crossNav(next, action.kind, action.name, snap);
return crossNav(next, action.kind, action.name, snap, action.target);
default:
return { ...next, lastError: "unknown action" };
}
}
function resetContent(state: ViewState): ViewState {
return { ...state, contentOffset: 0, contentMaxOffset: 0, contentTargetCount: 0, contentSelection: 0, focusedPane: "explorer" };
}
// --- snapshot lookups (pure; no daemon calls here) ---
export function findAgent(snap: FleetSnapshot, name: string) {
function agentMatches(snap: FleetSnapshot, name: string, target?: { host: string; rig?: string; pod?: string }) {
const matches = [];
for (const host of snap.hosts)
for (const rig of host.rigs)
for (const pod of rig.pods)
for (const agent of pod.agents) if (agent.name === name) return { host, rig, pod, agent };
return null;
for (const agent of pod.agents)
if (agent.name === name && (!target || (host.name === target.host && (!target.rig || rig.name === target.rig) && (!target.pod || pod.name === target.pod))))
matches.push({ host, rig, pod, agent });
return matches;
}
export function findAgent(snap: FleetSnapshot, name: string, target?: { host: string; rig?: string; pod?: string }) {
const matches = agentMatches(snap, name, target);
return matches.length === 1 ? matches[0]! : null;
}
export function findSpec(snap: FleetSnapshot, name: string) {
@@ -151,69 +171,97 @@ export function findAgentBySession(snap: FleetSnapshot, session: string) {
for (const host of snap.hosts)
for (const rig of host.rigs)
for (const pod of rig.pods)
for (const agent of pod.agents) if (agent.session === session) return agent;
for (const agent of pod.agents) if (agent.session === session) return { host, rig, pod, agent };
return null;
}
export function findRig(snap: FleetSnapshot, name: string) {
for (const host of snap.hosts) for (const rig of host.rigs) if (rig.name === name) return { host, rig };
return null;
function rigMatches(snap: FleetSnapshot, name: string, hostName?: string) {
return snap.hosts.flatMap((host) => host.rigs
.filter((rig) => rig.name === name && (!hostName || host.name === hostName))
.map((rig) => ({ host, rig })));
}
export function findRig(snap: FleetSnapshot, name: string, hostName?: string) {
const matches = rigMatches(snap, name, hostName);
return matches.length === 1 ? matches[0]! : null;
}
export function agentsRunningSpec(snap: FleetSnapshot, specName: string): string[] {
const out: string[] = [];
return agentsRunningSpecTargets(snap, specName).map(({ agent }) => agent.name);
}
export function agentsRunningSpecTargets(snap: FleetSnapshot, specName: string) {
const out = [];
for (const host of snap.hosts)
for (const rig of host.rigs)
for (const pod of rig.pods)
for (const agent of pod.agents) if (agent.spec === specName) out.push(agent.name);
for (const agent of pod.agents) if (agent.live && agent.spec === specName) out.push({ host, rig, pod, agent });
return out;
}
function drillTo(state: ViewState, resource: string, name: string, snap: FleetSnapshot): ViewState {
function drillTo(state: ViewState, resource: string, name: string, snap: FleetSnapshot, target?: { host: string; rig?: string; pod?: string }): ViewState {
switch (resource) {
case "host": {
if (!snap.hosts.some((h) => h.name === name)) return { ...state, lastError: `no such host "${name}"` };
return { ...state, section: "topology", drill: [{ kind: "host", name }], selection: 0, runningOf: null };
}
case "rig": {
const found = findRig(snap, name);
const qualified = !target ? parseQualified(name, 2) : null;
const rigName = qualified?.at(-1) ?? name;
const hostName = qualified?.[0] ?? target?.host;
const matches = rigMatches(snap, rigName, hostName);
if (matches.length > 1) return { ...state, lastError: `ambiguous rig "${name}" — use rig <host>/<rig>` };
const found = matches[0];
if (!found) return { ...state, lastError: `no such rig "${name}"` };
return {
...state,
section: "topology",
drill: [
{ kind: "host", name: found.host.name },
{ kind: "rig", name },
{ kind: "rig", name: rigName },
],
selection: 0,
runningOf: null,
};
}
case "pod": {
const qualified = !target ? parseQualified(name, 3) : null;
const podName = qualified?.at(-1) ?? name;
const hostName = qualified?.[0] ?? target?.host;
const rigName = qualified?.[1] ?? target?.rig;
const matches = [];
for (const host of snap.hosts)
for (const rig of host.rigs)
if (rig.pods.some((p) => p.name === name))
return {
...state,
section: "topology",
drill: [
{ kind: "host", name: host.name },
{ kind: "rig", name: rig.name },
{ kind: "pod", name },
],
selection: 0,
runningOf: null,
};
for (const pod of rig.pods)
if (pod.name === podName && (!hostName || host.name === hostName) && (!rigName || rig.name === rigName)) matches.push({ host, rig, pod });
if (matches.length > 1) return { ...state, lastError: `ambiguous pod "${name}" — use pod <host>/<rig>/<pod>` };
const found = matches[0];
if (found) return {
...state,
section: "topology",
drill: [
{ kind: "host", name: found.host.name },
{ kind: "rig", name: found.rig.name },
{ kind: "pod", name: podName },
],
selection: 0,
runningOf: null,
};
return { ...state, lastError: `no such pod "${name}"` };
}
case "agent": {
const found = findAgent(snap, name);
const qualified = !target ? parseQualifiedAgent(name) : null;
const agentName = qualified?.name ?? name;
const exactTarget = qualified?.target ?? target;
const matches = agentMatches(snap, agentName, exactTarget);
if (matches.length > 1) return { ...state, lastError: `ambiguous agent "${name}" — use agent <host>/<rig>/<pod>/<agent>` };
const found = matches[0];
if (!found) return { ...state, lastError: `no such agent "${name}"` };
const drill: DrillSegment[] = [
{ kind: "host", name: found.host.name },
{ kind: "rig", name: found.rig.name },
{ kind: "pod", name: found.pod.name },
{ kind: "agent", name },
{ kind: "agent", name: agentName },
];
return { ...state, section: "topology", drill, selection: 0, runningOf: null };
}
@@ -226,22 +274,37 @@ function drillTo(state: ViewState, resource: string, name: string, snap: FleetSn
}
}
function crossNav(state: ViewState, kind: "spec-of" | "running", name: string, snap: FleetSnapshot): ViewState {
function parseQualifiedAgent(value: string): { name: string; target: { host: string; rig: string; pod: string } } | null {
const [host, rig, pod, ...agentParts] = value.split("/");
if (!host || !rig || !pod || agentParts.length === 0) return null;
return { name: agentParts.join("/"), target: { host, rig, pod } };
}
function parseQualified(value: string, count: number): string[] | null {
const parts = value.split("/");
return parts.length === count && parts.every(Boolean) ? parts : null;
}
function crossNav(state: ViewState, kind: "spec-of" | "running", name: string, snap: FleetSnapshot, target?: { host: string; rig?: string; pod?: string }): ViewState {
if (kind === "spec-of") {
const found = findAgent(snap, name);
const qualified = !target ? parseQualifiedAgent(name) : null;
const agentName = qualified?.name ?? name;
const matches = agentMatches(snap, agentName, qualified?.target ?? target);
if (matches.length > 1) return { ...state, lastError: `ambiguous agent "${name}" — use spec-of <host>/<rig>/<pod>/<agent>` };
const found = matches[0];
if (!found) return { ...state, lastError: `no such agent "${name}"` };
return {
if (!findSpec(snap, found.agent.spec)) return { ...state, lastError: `spec "${found.agent.spec}" not in the library` };
return resetContent({
...state,
section: "specs",
drill: [{ kind: "spec", name: found.agent.spec }],
selection: 0,
runningOf: null,
viewTab: "table",
contentOffset: 0,
};
});
}
if (!findSpec(snap, name)) return { ...state, lastError: `no such spec "${name}"` };
return { ...state, section: "topology", drill: [], runningOf: name, filter: "", selection: 0, viewTab: "table", contentOffset: 0 };
return resetContent({ ...state, section: "topology", drill: [], runningOf: name, filter: "", selection: 0, viewTab: "table" });
}
// The explorer row model — pure function of (state, snapshot), shared by the
@@ -268,16 +331,16 @@ export function computeExplorerRows(state: ViewState, snap: FleetSnapshot): Expl
});
for (const rig of host.rigs) {
const stateSuffix = rig.lifecycleState && rig.lifecycleState !== "running" ? ` (${rig.lifecycleState})` : "";
rows.push({ label: ` ▾ ${rig.name}${stateSuffix}`, action: { type: "drill", resource: "rig", name: rig.name } });
rows.push({ label: ` ▾ ${rig.name}${stateSuffix}`, action: { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } } });
for (const pod of rig.pods) {
rows.push({
label: ` ▾ ${pod.name} (${pod.agents.length})`,
action: { type: "drill", resource: "pod", name: pod.name },
action: { type: "drill", resource: "pod", name: pod.name, target: { host: host.name, rig: rig.name } },
});
for (const agent of pod.agents)
rows.push({
label: ` ● ${agent.name}`,
action: { type: "drill", resource: "agent", name: agent.name },
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod: pod.name } },
});
}
}
+21 -2
View File
@@ -11,6 +11,10 @@ export interface AgentRow {
context: number | null;
tokens: string | null;
status: string;
/** lifecycle truth, separate from the displayed activity status */
live: boolean;
/** whether the lifecycle restore action is valid */
canRun?: boolean;
/** canonicalSessionName where served — joins Needs-You targets to topology */
session?: string | null;
/** served tmuxAttachCommand, displayed verbatim in the detail view (web parity) */
@@ -134,6 +138,12 @@ export type GetSnapshot = () => FleetSnapshot;
export type ResourceKind = "host" | "rig" | "pod" | "agent" | "spec";
export interface ResourceTarget {
host: string;
rig?: string;
pod?: string;
}
export interface DrillSegment {
kind: ResourceKind;
name: string;
@@ -148,10 +158,13 @@ export type Action =
| { type: "filter"; text: string }
| { type: "select"; delta?: number; index?: number; rowCount?: number }
| { type: "activate" }
| { type: "drill"; resource: ResourceKind; name: string }
| { type: "cross"; kind: "spec-of" | "running"; name: string }
| { type: "drill"; resource: ResourceKind; name: string; target?: ResourceTarget }
| { type: "cross"; kind: "spec-of" | "running"; name: string; target?: ResourceTarget }
| { type: "tab"; tab: ViewTab }
| { type: "content-scroll"; delta: number }
| { type: "focus"; pane: "explorer" | "content" }
| { type: "content-select"; delta?: number; index?: number }
| { type: "layout"; contentMaxOffset: number; contentTargetCount: number }
| { type: "footer"; on?: boolean }
/** drive-structure daemon writes (BR-8/BR-9): executed by the driver loop
* against EXISTING write contracts; never a view-state mutation */
@@ -178,6 +191,10 @@ export interface ViewState {
runningOf: string | null;
viewTab: ViewTab;
contentOffset: number;
contentMaxOffset: number;
contentTargetCount: number;
contentSelection: number;
focusedPane: "explorer" | "content";
/** the rig-stream footer is ambient: toggleable, never a navigable view (FR-10) */
footerOn: boolean;
/** transient result line from an executed act (daemon reply, verbatim) */
@@ -207,6 +224,8 @@ export interface HitTarget {
export interface Screen {
lines: string[];
hitMap: HitTarget[];
contentTargets: HitTarget[];
contentMaxOffset: number;
explorerRows: Array<ExplorerRow & { y: number }>;
}
+6 -1
View File
@@ -45,7 +45,12 @@ describe("ACTIONS column = real drive-structure acts (BR-9)", () => {
const termHit = hitAt(screen, row.indexOf("term ▸") + 1, rowIdx + 1);
expect(termHit?.action).toEqual({ type: "act", act: "open-terminal", view: "pod:openrig-build/dev50" });
const cellHit = hitAt(screen, row.indexOf("codex") + 1, rowIdx + 1);
expect(cellHit?.action).toEqual({ type: "drill", resource: "agent", name: "dev50.guard" });
expect(cellHit?.action).toEqual({
type: "drill",
resource: "agent",
name: "dev50.guard",
target: { host: "vm-host", rig: "openrig-build", pod: "dev50" },
});
});
it("acts NEVER mutate the view-state; the outcome arrives as a notice (PIN 1 intact)", () => {
+6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseCommand } from "../src/grammar.js";
import { defaultSections } from "../src/state.js";
describe("safe-core grammar (FR-1, §4.B)", () => {
it("parses :section jump for the three launch sections", () => {
@@ -14,6 +15,11 @@ describe("safe-core grammar (FR-1, §4.B)", () => {
if (r.type === "error") expect(r.message).toMatch(/unknown section ":bogus"/);
});
it("derives section commands from the same supplied registry", () => {
const sections = [...defaultSections(), { name: "extra", sourceRead: "GET /extra", drillShape: "flat" }];
expect(parseCommand(":extra", sections)).toEqual({ type: "jump", section: "extra" });
});
it("parses /text filter; bare / clears", () => {
expect(parseCommand("/driver")).toEqual({ type: "filter", text: "driver" });
expect(parseCommand("/")).toEqual({ type: "filter", text: "" });
+29 -7
View File
@@ -53,6 +53,7 @@ const FIXTURES: Record<string, unknown> = {
rigId: "01JRIG", rigName: "myrig", logicalId: "dev.impl", podId: "01JPOD", podNamespace: "dev",
role: "implementer", canonicalSessionName: "dev-impl@myrig", nodeKind: "agent", runtime: "claude-code",
sessionStatus: "running", startupStatus: "ready", restoreOutcome: "resumed", oriented: "verified",
terminalActive: false,
lifecycleState: "running", occupantLifecycle: "active", continuityOutcome: null, handoverResult: null,
previousOccupant: null, handoverAt: null, tmuxAttachCommand: null, resumeCommand: null,
recoveryGuidance: null, latestError: null, model: null, agentRef: "implementer", profile: "default",
@@ -65,6 +66,7 @@ const FIXTURES: Record<string, unknown> = {
rigId: "01JRIG", rigName: "myrig", logicalId: "dev.qa", podId: "01JPOD", podNamespace: "dev",
role: "qa", canonicalSessionName: "dev-qa@myrig", nodeKind: "agent", runtime: "codex",
sessionStatus: null, startupStatus: null, restoreOutcome: "n-a", oriented: "n-a",
terminalActive: null,
lifecycleState: "detached", occupantLifecycle: "unknown", continuityOutcome: null, handoverResult: null,
previousOccupant: null, handoverAt: null, tmuxAttachCommand: null, resumeCommand: null,
recoveryGuidance: null, latestError: null, model: null, agentRef: "qa", profile: "default",
@@ -85,11 +87,12 @@ const FIXTURES: Record<string, unknown> = {
{ id: "a2", kind: "agent", name: "implementer", version: "0.1.0", sourceType: "builtin", sourcePath: "/s/implementer.yaml", relativePath: "agents/implementer.yaml", updatedAt: "2026-08-01T00:00:00.000Z" },
{ id: "a3", kind: "workflow", name: "conveyor", version: "0.3.0", sourceType: "user_file", sourcePath: "/s/conveyor.yaml", relativePath: "workflows/conveyor.yaml", updatedAt: "2026-08-01T00:00:00.000Z", isBuiltIn: false, rolesCount: 4, stepsCount: 9, status: "valid", errorMessage: null },
],
"/api/review/rig": {
"/api/review/fleet": {
scope: "rig",
needsYou: {
items: [
{ source: "agent", identity: "qitem-2026080201", summary: "approve slice 11 proof", leg: "human-routed", where: "human@kernel", ageIso: "2026-08-02T08:40:00.000Z", priority: "high", tier: "human-gate", evidenceRef: "proof/qa.md", unblocks: null, qitemId: "qitem-2026080201", destinationSession: "human@kernel", derived: null },
{ source: "agent", identity: "qitem-remote", summary: "remote founder gate", leg: "human-routed", where: "human@kernel", ageIso: "2026-08-02T08:41:00.000Z", priority: "high", tier: "human-gate", evidenceRef: null, unblocks: null, qitemId: "qitem-remote", destinationSession: "human@kernel", derived: null, hostId: "mm2-host" },
{ source: "derived", identity: "dev-impl@myrig|stuck|2026-08-02T08:43:00.000Z", summary: "impl looks stuck", leg: "stuck", where: "rig", ageIso: null, priority: null, tier: null, evidenceRef: null, unblocks: null, qitemId: null, destinationSession: null, derived: { kind: "stuck", evidence: "idle 47m >= 30m default · holds 2", threshold: "idle-with-work >= 30m" } },
{ source: "derived", identity: "dev-qa@myrig|too-long-in-state|2026-08-02T05:00:00.000Z", summary: "qa has not transitioned in 180m", leg: "stuck", where: "rig", ageIso: null, priority: null, tier: null, evidenceRef: null, unblocks: null, qitemId: null, destinationSession: null, derived: { kind: "stuck", evidence: "no transition for 180m >= 120m default · holds 2", threshold: "too-long-in-state >= 120m" } },
],
@@ -97,6 +100,7 @@ const FIXTURES: Record<string, unknown> = {
},
agents: { scope: "rig", rows: [], provenance: "computed", coordinationHealth: null },
settled: [], settledProvenance: "computed", composedAt: "2026-08-02T09:30:00.000Z",
hosts: [{ hostId: "local", status: { hostId: "local", status: "ok" } }, { hostId: "mm2-host", status: { hostId: "mm2-host", status: "ok" } }],
},
"/api/stream/list?limit=5": [
{ streamItemId: "si-1", tsEmitted: "2026-08-02T10:00:00.000Z", streamSortKey: "k1", sourceSession: "dev-guard@myrig", body: "gate cleared: slice-11", format: "text", hintType: null, hintUrgency: null, hintDestination: null, hintTags: null, interrupt: false, archivedAt: null },
@@ -110,11 +114,12 @@ const FIXTURES: Record<string, unknown> = {
},
};
function fixtureClient(overrides: Record<string, { status: number } | undefined> = {}): DaemonClient {
function fixtureClient(overrides: Record<string, { status: number } | undefined> = {}, responses: Record<string, unknown> = {}): DaemonClient {
const fetchImpl = (async (url: unknown) => {
const route = String(url).replace("http://x", "");
const failure = overrides[route];
if (failure) return { ok: false, status: failure.status, json: async () => ({}) } as Response;
if (route in responses) return { ok: true, json: async () => responses[route] } as Response;
if (!(route in FIXTURES)) throw new Error(`unexpected route in test: ${route}`);
return { ok: true, json: async () => FIXTURES[route] } as Response;
}) as typeof fetch;
@@ -129,10 +134,10 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
const dev = local?.rigs[0]?.pods.find((p) => p.name === "dev");
expect(dev?.agents.map((a) => a.name)).toEqual(["dev.impl", "dev.qa"]);
const impl = dev?.agents[0];
expect(impl).toMatchObject({ runtime: "claude-code", spec: "implementer", context: 43, tokens: "129k", status: "running" });
expect(impl).toMatchObject({ runtime: "claude-code", spec: "implementer", context: 43, tokens: "129k", status: "idle", canRun: false });
// honest-unknown: availability "unknown" → null cells; lifecycleState verbatim
const qa = dev?.agents[1];
expect(qa).toMatchObject({ context: null, tokens: null, status: "detached" });
expect(qa).toMatchObject({ context: null, tokens: null, status: "unknown", canRun: true });
// infrastructure nodes are not agent rows
expect(local?.rigs[0]?.pods.flatMap((p) => p.agents.map((a) => a.name))).not.toContain("svc.db");
});
@@ -207,7 +212,24 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
it("maps the human-queue leg and marks it PROBED (proven-empty vs not-yet-known)", async () => {
const snap = await hydrateSnapshot(fixtureClient());
expect(snap.humanQueueProbed).toBe(true);
expect(snap.humanQueue).toEqual([{ kind: "human-routed", target: "human@kernel", detail: "approve slice 11 proof" }]);
expect(snap.humanQueue).toEqual([
{ kind: "human-routed", target: "human@kernel", detail: "approve slice 11 proof" },
{ kind: "human-routed", target: "human@kernel", detail: "remote founder gate" },
]);
});
it("marks fleet attention incomplete when any remote host is absent, never proven-empty", async () => {
const snap = await hydrateSnapshot(fixtureClient({}, {
"/api/review/fleet": {
needsYou: { items: [] },
hosts: [
{ hostId: "local", status: { hostId: "local", status: "ok" } },
{ hostId: "mm2-host", status: { hostId: "mm2-host", status: "unreachable" } },
],
},
}));
expect(snap.humanQueueProbed).toBe(false);
expect(snap.humanQueue).toEqual([]);
});
it("joins Specs↔Topology over existing reads: rig agentRefs + agent usedByRigs", async () => {
@@ -218,10 +240,10 @@ describe("snapshot hydration over the §4.A reads (Phase 2)", () => {
});
it("leaves a failed read honest-empty with a NAMED error; other sections still hydrate", async () => {
const snap = await hydrateSnapshot(fixtureClient({ "/api/review/rig": { status: 503 } }));
const snap = await hydrateSnapshot(fixtureClient({ "/api/review/fleet": { status: 503 } }));
expect(snap.humanQueueProbed).toBe(false);
expect(snap.needs).toEqual([]);
expect(snap.readErrors).toEqual([expect.stringMatching(/review-rig: .*503/)]);
expect(snap.readErrors).toEqual([expect.stringMatching(/review-fleet: .*503/)]);
expect(snap.hosts.find((h) => h.name === "local")?.rigs[0]?.name).toBe("myrig");
});
});
+69 -4
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { createViewState } from "../src/state.js";
import { computeExplorerRows, createViewState } from "../src/state.js";
import { parseCommand } from "../src/grammar.js";
import { decodeInput, sgrClick } from "../src/input.js";
import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
import { renderScreen } from "../src/render.js";
import { demoSnapshot } from "../src/demo-data.js";
import type { ViewState, ViewStateStore } from "../src/types.js";
import type { FleetSnapshot, Screen, ViewState, ViewStateStore } from "../src/types.js";
// Phase-3 parity hardening: CONTENT-pane surfaces are hit targets too.
// Hit-target realism: these tests click coordinates on the rendered SURFACE
@@ -38,6 +38,25 @@ function findContentLine(store: ViewStateStore, match: RegExp): { y: number; tex
return { y: idx + 1, text: screen.lines[idx]! };
}
function syncedScreen(store: ViewStateStore, snapshot: FleetSnapshot = snap): Screen {
let screen = renderScreen(store.get(), snapshot, { cols: 120, rows: 32 });
store.dispatch({
type: "layout",
contentMaxOffset: screen.contentMaxOffset,
contentTargetCount: screen.contentTargets.length,
});
screen = renderScreen(store.get(), snapshot, { cols: 120, rows: 32 });
return screen;
}
function press(store: ViewStateStore, bytes: string, snapshot: FleetSnapshot = snap): void {
const screen = syncedScreen(store, snapshot);
const event = decodeInput(bytes)[0];
if (!event || event.type !== "key") throw new Error("expected key event");
const action = resolveKeyAction(event, store.get(), screen, computeExplorerRows(store.get(), snapshot).length);
if (action) store.dispatch(action);
}
describe("content-pane parity (Phase 3): click the surface, not a control", () => {
it("clicking a table row's STATUS cell opens the agent — identical to the command", () => {
const byCommand = fresh("cmd");
@@ -108,12 +127,58 @@ describe("content-pane parity (Phase 3): click the surface, not a control", () =
it("PageDown scrolls content without moving the Explorer selection", () => {
const s = fresh("t");
const selected = s.get().selection;
const screen = renderScreen(s.get(), snap, { cols: 120, rows: 8 });
s.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
const pageDown = decodeInput("\x1b[6~")[0];
if (!pageDown || pageDown.type !== "key" || !("action" in pageDown)) throw new Error("PageDown was not decoded");
s.dispatch(pageDown.action);
expect(s.get().contentOffset).toBe(10);
expect(s.get().contentOffset).toBe(Math.min(10, screen.contentMaxOffset));
expect(s.get().selection).toBe(selected);
});
it("raw right/down/Enter reaches content tabs, table rows, spec refs, and Needs links", () => {
const structured: FleetSnapshot = {
...snap,
specs: [{
name: "openrig-build-rig",
kind: "rig",
format: "pod_aware",
pods: [{ id: "dev50", members: [{ id: "guard", agentRef: "guard-agent", runtime: "codex" }], edges: [] }],
graph: { nodes: [], edges: [] },
raw: "name: openrig-build-rig",
}, ...snap.specs.filter((spec) => spec.name !== "openrig-build-rig")],
};
const tab = createViewState({ instanceId: "tab", getSnapshot: () => structured });
tab.dispatch(parseCommand("spec openrig-build-rig"));
press(tab, "\x1b[C", structured);
press(tab, "\r", structured);
expect(tab.get().viewTab).toBe("topology");
const row = fresh("row");
row.dispatch(parseCommand("rig openrig-build"));
press(row, "\x1b[C");
let screen = syncedScreen(row);
const rowTarget = screen.contentTargets.findIndex((target) => target.action.type === "drill" && target.action.resource === "agent" && target.action.name === "dev50.guard");
for (let i = 0; i < rowTarget; i++) press(row, "\x1b[B");
press(row, "\r");
expect(row.get().drill.at(-1)).toEqual({ kind: "agent", name: "dev50.guard" });
const ref = createViewState({ instanceId: "ref", getSnapshot: () => structured });
ref.dispatch(parseCommand("spec openrig-build-rig"));
press(ref, "\x1b[C", structured);
screen = syncedScreen(ref, structured);
const refTarget = screen.contentTargets.findIndex((target) => target.action.type === "drill" && target.action.resource === "spec" && target.action.name === "guard-agent");
for (let i = 0; i < refTarget; i++) press(ref, "\x1b[B", structured);
press(ref, "\r", structured);
expect(ref.get().drill.at(-1)).toEqual({ kind: "spec", name: "guard-agent" });
const needs = fresh("needs");
needs.dispatch(parseCommand(":needs"));
press(needs, "\x1b[C");
press(needs, "\r");
expect(needs.get().drill.at(-1)).toEqual({ kind: "agent", name: "dev50.guard" });
});
});
describe("rig-stream footer (FR-10): ambient, toggleable, never a view", () => {
@@ -27,6 +27,19 @@ describe("live visual regressions", () => {
expect(screen.lines.every((line) => line.length <= 80)).toBe(true);
});
it("anchors ticker, rule, and status to the bottom of an exact short 140x34 view", () => {
const base = demoSnapshot();
const snap: FleetSnapshot = { ...base, needs: [], hostsDown: [], humanQueue: [] };
const view = createViewState({ instanceId: "t", getSnapshot: () => snap });
view.dispatch({ type: "jump", section: "needs" });
const screen = renderScreen(view.get(), snap, { cols: 140, rows: 34 });
expect(screen.lines).toHaveLength(34);
expect(screen.lines[31]).toContain("≋");
expect(screen.lines[32]).toMatch(/^─+$/);
expect(screen.lines[33]).toContain("[t] needs");
});
it("scrolls the Explorer viewport to keep the keyboard selection visible", () => {
const base = demoSnapshot();
const snap: FleetSnapshot = {
@@ -139,6 +152,7 @@ describe("live visual regressions", () => {
const selected = view.get().selection;
const initial = renderScreen(view.get(), snap, { cols: 100, rows: 12 });
view.dispatch({ type: "layout", contentMaxOffset: initial.contentMaxOffset, contentTargetCount: initial.contentTargets.length });
expect(initial.lines.join("\n")).not.toContain("pod-17 (pod)");
const scrollY = initial.lines.findIndex((line) => line.includes("content ↑/↓")) + 1;
expect(initial.hitMap).toContainEqual(expect.objectContaining({
+9
View File
@@ -68,6 +68,15 @@ describe("control-socket adapter (spike-adopted; arch boundary constraint)", ()
expect(view.get().drill).toEqual([]);
});
it("rejects a tab outside its content context and exposes the active view tab", async () => {
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
open = await createControlSocket({ socketPath: shortSockPath(), view });
const [bad, good, state] = await ask(open.path, ["tab yaml", "spec openrig-build-rig", "state"]);
expect(JSON.parse(bad!).error).toMatch(/not available/);
expect(JSON.parse(good!).viewTab).toBe("configuration");
expect(JSON.parse(state!).state.viewTab).toBe("configuration");
});
it("refuses socket paths beyond the sun_path limit with a named error", async () => {
const view = createViewState({ instanceId: "tui-sock", getSnapshot: () => snap });
const tooLong = path.join(os.tmpdir(), "x".repeat(MAX_SOCKET_PATH_BYTES + 1) + ".sock");
+52 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { createViewState, defaultSections } from "../src/state.js";
import { agentsRunningSpec, createViewState, defaultSections } from "../src/state.js";
import { parseCommand } from "../src/grammar.js";
import { demoSnapshot } from "../src/demo-data.js";
import type { NeedsItem, SectionDef } from "../src/types.js";
@@ -42,6 +43,24 @@ describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
expect(s.get().lastError).toMatch(/no such agent "nobody\.here"/);
});
it("rejects ambiguous fleet shorthand and accepts an exact scoped agent target", () => {
const duplicate = demoSnapshot();
duplicate.hosts[0]!.rigs.push({
name: "other-rig",
pods: [{ name: "dev50", agents: [{ name: "dev50.qa", runtime: "codex", spec: "qa-agent", context: null, tokens: null, status: "idle", live: true }] }],
});
const s = createViewState({ instanceId: "t", getSnapshot: () => duplicate });
expect(s.dispatch({ type: "drill", resource: "agent", name: "dev50.qa" }).lastError).toMatch(/ambiguous agent/);
expect(s.dispatch({
type: "drill",
resource: "agent",
name: "dev50.qa",
target: { host: "vm-host", rig: "other-rig", pod: "dev50" },
}).drill.map((part) => part.name)).toEqual(["vm-host", "other-rig", "dev50", "dev50.qa"]);
expect(s.dispatch(parseCommand("agent vm-host/openrig-build/dev50/dev50.qa")).drill.map((part) => part.name))
.toEqual(["vm-host", "openrig-build", "dev50", "dev50.qa"]);
});
it("cross-navs spec-of: running agent → its agent spec", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "cross", kind: "spec-of", name: "dev50.driver" });
@@ -56,6 +75,29 @@ describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
expect(s.get().runningOf).toBe("driver-agent");
});
it("filters spec reverse navigation to genuinely live seats", () => {
const copy = demoSnapshot();
copy.hosts[0]!.rigs[0]!.pods[0]!.agents[0]!.live = false;
expect(agentsRunningSpec(copy, "driver-agent")).toEqual([]);
});
it("keeps a missing spec-of target in place with a named error", () => {
const copy = demoSnapshot();
copy.hosts[0]!.rigs[0]!.pods[0]!.agents[0]!.spec = "materialized-only";
const s = createViewState({ instanceId: "t", getSnapshot: () => copy });
const before = s.get().section;
const next = s.dispatch({ type: "cross", kind: "spec-of", name: "dev50.driver" });
expect(next.section).toBe(before);
expect(next.lastError).toMatch(/spec .*not in the library/);
});
it("rejects tabs that do not exist in the current content context", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
expect(s.dispatch({ type: "tab", tab: "yaml" }).lastError).toMatch(/not available/);
s.dispatch({ type: "drill", resource: "spec", name: "openrig-build-rig" });
expect(s.dispatch({ type: "tab", tab: "yaml" }).lastError).toBeNull();
});
it("filters the current view and clears with empty text", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "filter", text: "dev50" });
@@ -64,6 +106,15 @@ describe("one instance-scoped view-state (PIN 1, FR-12/13)", () => {
expect(s.get().filter).toBe("");
});
it("bounds content scrolling in state so PageUp moves immediately from the bottom", () => {
const s = createViewState({ instanceId: "t", ...withSnap });
s.dispatch({ type: "layout", contentMaxOffset: 15, contentTargetCount: 0 });
for (let i = 0; i < 20; i++) s.dispatch({ type: "content-scroll", delta: 10 });
expect(s.get().contentOffset).toBe(15);
s.dispatch({ type: "content-scroll", delta: -10 });
expect(s.get().contentOffset).toBe(5);
});
it("renders honest-empty against an empty snapshot: errors name the miss, nothing is fabricated", () => {
const s = createViewState({ instanceId: "t" }); // default emptySnapshot
s.dispatch({ type: "drill", resource: "agent", name: "dev50.driver" });
+18 -8
View File
@@ -5,29 +5,34 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CLI_DIR="$REPO_ROOT/packages/cli"
DAEMON_DIR="$REPO_ROOT/packages/daemon"
UI_DIR="$REPO_ROOT/packages/ui"
TUI_DIR="$REPO_ROOT/packages/tui"
echo "=== OpenRig Package Build ==="
echo "Repo root: $REPO_ROOT"
echo ""
# 1. Clean previous build artifacts
echo "[1/5] Cleaning previous artifacts..."
rm -rf "$CLI_DIR/daemon" "$CLI_DIR/ui"
echo "[1/6] Cleaning previous artifacts..."
rm -rf "$CLI_DIR/daemon" "$CLI_DIR/ui" "$CLI_DIR/tui"
# 2. Build daemon
echo "[2/5] Building daemon..."
echo "[2/6] Building daemon..."
(cd "$DAEMON_DIR" && npm run build)
# 3. Build UI
echo "[3/5] Building UI..."
echo "[3/6] Building UI..."
(cd "$UI_DIR" && npm run build)
# 4. Build CLI
echo "[4/5] Building CLI..."
# 4. Build TUI
echo "[4/6] Building TUI..."
(cd "$TUI_DIR" && npm run build)
# 5. Build CLI
echo "[5/6] Building CLI..."
(cd "$CLI_DIR" && npm run build)
# 5. Assemble: copy daemon + UI into CLI package
echo "[5/5] Assembling package..."
# 6. Assemble: copy daemon + UI + TUI into CLI package
echo "[6/6] Assembling package..."
# OPR.0.4.4.11 FR-6 — deploy-identity stamp. Computed ONCE here (the runtime
# worktree is at the SHA — available at build), then written as the compiled
@@ -80,6 +85,10 @@ fi
mkdir -p "$CLI_DIR/ui/dist"
cp -r "$UI_DIR/dist/"* "$CLI_DIR/ui/dist/"
# TUI: dist (public openrig-tui bin declared by the CLI package)
mkdir -p "$CLI_DIR/tui/dist"
cp -r "$TUI_DIR/dist/"* "$CLI_DIR/tui/dist/"
# Report
echo ""
echo "=== Package Assembled ==="
@@ -89,5 +98,6 @@ echo "Daemon assets: $(find "$CLI_DIR/daemon/assets" -type f 2>/dev/null | wc
echo "Daemon specs: $(find "$CLI_DIR/daemon/specs" -type f 2>/dev/null | wc -l | tr -d ' ') files"
echo "Daemon docs: $(find "$CLI_DIR/daemon/docs" -type f 2>/dev/null | wc -l | tr -d ' ') files"
echo "UI dist: $(find "$CLI_DIR/ui/dist" -type f | wc -l | tr -d ' ') files"
echo "TUI dist: $(find "$CLI_DIR/tui/dist" -type f | wc -l | tr -d ' ') files"
echo ""
echo "Ready to publish: cd packages/cli && npm publish --access public"
+37
View File
@@ -0,0 +1,37 @@
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 { 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-"));
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" }));
const tarball = path.join(temp, packed[0].filename);
execFileSync("tar", ["-xzf", tarball, "-C", temp]);
const pkgDir = path.join(temp, "package");
const pkg = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
assert.equal(pkg.bin["openrig-tui"], "tui/dist/main.js");
const entry = path.join(pkgDir, pkg.bin["openrig-tui"]);
assert.ok(fs.existsSync(entry), "packed TUI entrypoint is missing");
assert.notEqual(fs.statSync(entry).mode & 0o111, 0, "packed TUI entrypoint is not executable");
fs.symlinkSync(path.join(root, "node_modules"), path.join(pkgDir, "node_modules"), "dir");
const launched = spawnSync(process.execPath, [entry, "--demo", "--socket", path.join(temp, "tui.sock")], {
cwd: pkgDir,
input: "q",
encoding: "utf8",
timeout: 10_000,
env: process.env,
});
assert.equal(launched.status, 0, launched.stderr || `packed TUI exited ${launched.status}`);
console.log(`packed TUI launch PASS: ${packed[0].filename}`);
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}