Compare commits

..
Author SHA1 Message Date
core-builder-b 32ffc41937 fix(tui): Feed times follow ui.timezone, as the header's do
The Feed printed raw ISO timestamps on each row, in a request's detail
("Observed:"), on a delivered update's "Delivered:" or "Accepted by
bridge:" line, and in "Read at". The header already draws times with
displayTime in the configured zone. The Feed now uses the same function.

The times are formatted when the page is drawn, not when it is read. The
zone setting arrives after start-up, so a time formatted at read time
could stay in the default zone until the next refresh. A delivered
update's detail used to print its time twice, as "Observed:" and as
"Delivered:". It now prints it once, under the delivery label. A missing
time reads "time unknown", as elsewhere in the TUI. The default zone is
unchanged.
2026-11-12 01:04:55 +00:00
12 changed files with 112 additions and 362 deletions

No files matched your search

+3 -4
View File
@@ -1493,10 +1493,9 @@ Examples:
.addHelpText("after", `
Siblings are untouched. A dead seat is refused (use rig seat clean). An adopted
session is refused: to end only it, run rig unclaim <session> (it releases the
session and leaves its process running), then end that process and the
underlying tmux session, including any remaining shell. Closing only its
viewing window leaves the tmux session running. rig down <rig> leaves a
session OpenRig didn't start running.
session and leaves its process running), then end that process in its own
terminal. rig down <rig> ends every seat in its rig, so it is the route only
for a one-seat rig.
A seat handed over (fresh, fork or rebuild) before OpenRig 0.7.9 still reads
adopted and is refused too, until its next launched handover or managed launch;
the refusal says so. Stopping ends that agent's session and any work in progress:
@@ -21,17 +21,12 @@
// per founder authorization 2026-05-10 (LICENSE only); 404 is the
// expected normal-state response until a separate publish authorization.
import fs from "node:fs";
import nodePath from "node:path";
import { createHash, randomUUID } from "node:crypto";
export interface PluginVendorFs {
readFile(path: string): string;
writeFile(path: string, content: string): void;
/** A file's raw bytes. With `writeBytes`, a plugin's files are copied and compared byte for byte, so a binary file
* (an icon) arrives intact. Optional: without them files are read and written as text. */
readBytes?(path: string): Uint8Array;
writeBytes?(path: string, content: Uint8Array): void;
/** Copy raw bytes, failing if the recovery path already exists. */
copyFileExclusive(source: string, target: string): void;
exists(path: string): boolean;
@@ -165,7 +160,7 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
if (
this.fs.exists(destPath) &&
hashContent(this.read(destPath)) === hashContent(this.read(srcPath))
hashContent(this.fs.readFile(destPath)) === hashContent(this.fs.readFile(srcPath))
) {
this.preserveMode(srcPath, destPath);
}
@@ -175,7 +170,7 @@ export class PluginVendorService {
}
}
const updates: Array<[string, FileContent]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, updates)) return;
this.fs.mkdirp(targetDir);
@@ -184,34 +179,23 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
// Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on
// skip — a byte-identical copy staged earlier may still carry the wrong (default) mode.
if (this.fs.exists(destPath) && hashContent(this.read(destPath)) === hashContent(content)) {
if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) {
this.preserveMode(srcPath, destPath);
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.write(destPath, content);
this.fs.writeFile(destPath, content);
this.preserveMode(srcPath, destPath);
}
}
/** A plugin file as the fs reads it exactly: bytes when it can, else text. */
private read(path: string): FileContent {
return this.fs.readBytes ? this.fs.readBytes(path) : this.fs.readFile(path);
}
private write(path: string, content: FileContent): void {
if (typeof content === "string") this.fs.writeFile(path, content);
else if (this.fs.writeBytes) this.fs.writeBytes(path, content);
else this.fs.writeFile(path, Buffer.from(content).toString("utf-8"));
}
/** Preserve every replaced file before touching a target, including its version authority. */
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, FileContent]>): boolean {
private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean {
const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID());
try {
for (const [rel, content] of new Map(updates)) {
const dest = nodePath.join(targetDir, rel);
if (!this.fs.exists(dest) || hashContent(this.read(dest)) === hashContent(content)) continue;
if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue;
const backup = nodePath.join(backupDir, rel);
this.fs.mkdirp(nodePath.dirname(backup));
this.fs.copyFileExclusive(dest, backup);
@@ -292,7 +276,7 @@ export class PluginVendorService {
continue;
}
}
const updates: Array<[string, FileContent]> = files.map(rel => [rel, this.read(nodePath.join(sourceDir, rel))]);
const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]);
if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue;
this.fs.mkdirp(targetDir);
for (const [relPath, content] of updates) {
@@ -300,13 +284,13 @@ export class PluginVendorService {
const destPath = nodePath.join(targetDir, relPath);
if (
this.fs.exists(destPath) &&
hashContent(this.read(destPath)) === hashContent(content)
hashContent(this.fs.readFile(destPath)) === hashContent(content)
) {
this.preserveMode(srcPath, destPath);
continue;
}
this.fs.mkdirp(nodePath.dirname(destPath));
this.write(destPath, content);
this.fs.writeFile(destPath, content);
this.preserveMode(srcPath, destPath);
}
this.fs.writeFile(versionMarker, `${sourceVersion}\n`);
@@ -355,37 +339,6 @@ export class PluginVendorService {
}
}
/** A plugin file's content: its bytes, or its text from an fs that only reads text. */
type FileContent = string | Uint8Array;
/** The same digest for a text and for its UTF-8 bytes, so a text compare and a byte compare agree. */
function hashContent(content: FileContent): string {
function hashContent(content: string): string {
return createHash("sha256").update(content).digest("hex");
}
/** The daemon's own filesystem for vendoring: files are copied and compared as bytes. */
export function nodePluginVendorFs(): PluginVendorFs {
return {
readFile: (p) => fs.readFileSync(p, "utf-8"),
writeFile: (p, c) => fs.writeFileSync(p, c, "utf-8"),
readBytes: (p) => fs.readFileSync(p),
writeBytes: (p, c) => fs.writeFileSync(p, c),
copyFileExclusive: (src, dest) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p) => fs.existsSync(p),
mkdirp: (p) => { fs.mkdirSync(p, { recursive: true }); },
listFiles: (dir) => {
const r: string[] = [];
/** Collect relative file paths recursively. */
const w = (d: string, pre: string) => {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
}
};
w(dir, "");
return r;
},
statMode: (p) => fs.statSync(p).mode,
chmod: (p, m) => fs.chmodSync(p, m),
};
}
@@ -73,8 +73,7 @@ const SEAT_LOOKUP_GUIDANCE = "List seats with: rig ps --nodes";
/**
* The supported route for a session registered as adopted (origin=claimed): `rig unclaim` releases
* it and leaves its process running, so ending that process and its tmux session ends only this seat. Closing only
* a viewing window detaches from the tmux session and leaves it running.
* it and leaves its process running, so ending that process in its own terminal ends only this seat.
* `rig down` stops only a session carrying the seat's OpenRig marker, which OpenRig sets on the sessions
* it starts and an adopted, bound or self-attached session lacks, so it isn't a route here and the
* guidance says why. Only the guidance names the route; the refusal stands. A seat handed over before
@@ -86,13 +85,9 @@ export function claimedSessionRoutes(rigName: string, sessionName: string, exter
const release = `To release it from OpenRig and leave its process running, run rig unclaim ${session}.`;
if (externalCli) return `This seat was joined with rig attach as an external CLI, so rig down leaves it running. ${release} End the process in its own terminal.`;
return `To end this session, run rig unclaim ${session} (it releases the session from OpenRig and leaves its process running), `
+ `then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. `
+ `${VIEWER_LEAVES_IT} ${rigDownLeavesIt(rigName)}`;
+ `then end that process in its own terminal. ${rigDownLeavesIt(rigName)}`;
}
/** Closing a viewer detaches; the tmux session, its shell and the seat's name stay. */
const VIEWER_LEAVES_IT = "Closing only its viewing window leaves the tmux session running.";
/** Why `rig down <rig>` isn't the route for an adopted session. */
function rigDownLeavesIt(rigName: string): string {
return `rig down ${shellQuote(rigName)} leaves a session OpenRig didn't start running.`;
@@ -1386,8 +1381,6 @@ export class SeatLifecycleService {
ok: false,
code: "unmanaged_session_collision",
message: `Canonical tmux session "${canonicalSessionName}" exists but is not owned by this seat's current managed rows; refusing to overwrite it.`,
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
+ "then launch again. Closing only its viewing window doesn't end the session.",
};
}
if (!stop) {
@@ -1431,10 +1424,8 @@ export class SeatLifecycleService {
return {
ok: false, code: "claimed_session",
message: `Seat "${seatRef}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), `
+ (externalCli
? "end that process and close its terminal, then launch fresh."
: `end that process and the underlying tmux session, including any remaining shell, then launch fresh. ${VIEWER_LEAVES_IT} ${rigDownLeavesIt(seat.rigName)}`),
guidance: `Release it with rig unclaim ${shellQuote(claimed.session_name)} (its process keeps running), end that process yourself, then launch fresh.`
+ (externalCli ? "" : ` ${rigDownLeavesIt(seat.rigName)}`),
};
}
const binding = this.sessionRegistry.getBindingForNode(nodeId);
+23 -2
View File
@@ -1049,9 +1049,30 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
// (repo currently empty as of 2026-05-10; vendored
// copy is the source of truth at v0).
try {
const { PluginVendorService, nodePluginVendorFs } = await import("./domain/plugin-vendor-service.js");
const { PluginVendorService } = await import("./domain/plugin-vendor-service.js");
const vendoredAssetsDir = nodePath.resolve(import.meta.dirname, "../assets/plugins");
const userPluginsDir = getDefaultOpenRigPath("plugins");
const realFs = {
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"),
copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL),
exists: (p: string) => fs.existsSync(p),
mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }),
listFiles: (dir: string) => {
const r: string[] = [];
/** Collect relative file paths recursively. */
function w(d: string, pre: string) {
for (const e of fs.readdirSync(d, { withFileTypes: true })) {
if (e.isDirectory()) w(nodePath.join(d, e.name), nodePath.join(pre, e.name));
else r.push(pre ? nodePath.join(pre, e.name) : e.name);
}
}
w(dir, "");
return r;
},
statMode: (p: string) => fs.statSync(p).mode,
chmod: (p: string, m: number) => fs.chmodSync(p, m),
};
const httpClient = async (url: string, opts?: { timeoutMs?: number }) => {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), opts?.timeoutMs ?? 5000);
@@ -1065,7 +1086,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
const vendorService = new PluginVendorService({
vendoredAssetsDir,
userPluginsDir,
fs: nodePluginVendorFs(),
fs: realFs,
httpClient,
logger: (...args) => console.log("[openrig]", ...args),
});
@@ -1,74 +0,0 @@
// Vendoring copies a bundled plugin into ~/.openrig/plugins byte for byte, binary files included: a plugin's icon.png
// must arrive exactly as it ships, and a text file must arrive as it always did.
import fs from "node:fs";
import os from "node:os";
import nodePath from "node:path";
import { createHash } from "node:crypto";
import { afterEach, describe, expect, it } from "vitest";
import { nodePluginVendorFs, PluginVendorService } from "../src/domain/plugin-vendor-service.js";
const dirs: string[] = [];
afterEach(() => { for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
/** A PNG header and bytes that aren't valid UTF-8, so a text round trip would change them. */
const ICON = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.from([0xff, 0xfe, 0x80, 0x00, 0xc3, 0x28, 0xa0, 0xa1])]);
const sha = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex");
const httpClient = async () => ({ ok: false, status: 404 });
function scratch(): { assets: string; plugins: string } {
const root = fs.mkdtempSync(nodePath.join(os.tmpdir(), "openrig-vendor-binary-"));
dirs.push(root);
return { assets: nodePath.join(root, "assets"), plugins: nodePath.join(root, "home", "plugins") };
}
function shipPlugin(assets: string, version: string, icon = ICON): void {
const plugin = nodePath.join(assets, "openrig");
fs.mkdirSync(nodePath.join(plugin, ".claude-plugin"), { recursive: true });
fs.mkdirSync(nodePath.join(plugin, "skills", "demo"), { recursive: true });
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "openrig", version, description: "Team — work" }));
fs.writeFileSync(nodePath.join(plugin, ".claude-plugin", "icon.png"), icon);
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "SKILL.md"), "# Demo — a skill\n");
fs.writeFileSync(nodePath.join(plugin, "skills", "demo", "badge.png"), icon);
}
const service = (assets: string, plugins: string) =>
new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: plugins, fs: nodePluginVendorFs(), httpClient });
describe("vendoring a plugin with a binary file", () => {
it("installs the icon byte for byte, and its text files as before", async () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
await service(assets, plugins).ensureVendored("openrig");
const installed = fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png"));
expect(sha(installed)).toBe(sha(ICON));
expect(fs.readFileSync(nodePath.join(plugins, "openrig", "skills", "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
});
it("leaves an identical install alone at the same version, and refreshes a newer one exactly, keeping the old bytes", async () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
await service(assets, plugins).ensureVendored("openrig");
await service(assets, plugins).ensureVendored("openrig");
const backups = nodePath.join(nodePath.dirname(plugins), "backups");
expect(fs.existsSync(backups)).toBe(false);
const newer = Buffer.concat([ICON, Buffer.from([0xfd, 0xbe])]);
fs.rmSync(assets, { recursive: true });
shipPlugin(assets, "0.2.0", newer);
await service(assets, plugins).ensureVendored("openrig");
expect(sha(fs.readFileSync(nodePath.join(plugins, "openrig", ".claude-plugin", "icon.png")))).toBe(sha(newer));
const kept = fs.readdirSync(nodePath.join(backups, "managed-refresh"))
.map((id) => nodePath.join(backups, "managed-refresh", id, ".claude-plugin", "icon.png"))
.filter((path) => fs.existsSync(path));
expect(kept.map((path) => sha(fs.readFileSync(path)))).toEqual([sha(ICON)]);
});
it("projects a skill's binary file into a global skill root byte for byte", () => {
const { assets, plugins } = scratch();
shipPlugin(assets, "0.1.0");
const root = nodePath.join(nodePath.dirname(plugins), "global-skills");
service(assets, plugins).ensureSkillDirGlobally(nodePath.join(assets, "openrig", "skills", "demo"), "demo", "0.1.0", [root]);
expect(sha(fs.readFileSync(nodePath.join(root, "demo", "badge.png")))).toBe(sha(ICON));
expect(fs.readFileSync(nodePath.join(root, "demo", "SKILL.md"), "utf-8")).toBe("# Demo — a skill\n");
});
});
@@ -499,8 +499,7 @@ describe("SeatLifecycleService.launchFresh", () => {
code: "claimed_session",
message: `Seat "${seat.sessionName}" has an adopted/operator-owned occupant; fresh launch refuses even with --stop.`,
// B5: rig unclaim releases it (the process keeps running); rig down leaves a session OpenRig didn't start.
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process and the underlying tmux session, "
+ "including any remaining shell, then launch fresh. Closing only its viewing window leaves the tmux session running. "
guidance: "Release it with rig unclaim 'dev-impl@fresh-rig' (its process keeps running), end that process yourself, then launch fresh. "
+ "rig down 'fresh-rig' leaves a session OpenRig didn't start running.",
};
const before = db.serialize();
@@ -687,11 +686,6 @@ describe("SeatLifecycleService.launchFresh", () => {
alive.add(seat.sessionName);
const result = await service.launchFresh({ seatRef: "dev.impl", fresh: true, stop: true, reason: "x" });
expect(result).toMatchObject({ ok: false, code: "unmanaged_session_collision" });
// A session someone released with rig unclaim and left open is the usual cause: say how to clear it.
expect(result).toMatchObject({
guidance: "If you released this session with rig unclaim, end that underlying tmux session, including any remaining shell, "
+ "then launch again. Closing only its viewing window doesn't end the session.",
});
expect(killed).toEqual([]);
expect(alive.has(seat.sessionName)).toBe(true);
});
@@ -447,7 +447,7 @@ describe("SeatLifecycleService", () => {
// the refusal names the supported route, rig unclaim, and says rig down doesn't stop a session OpenRig didn't start,
// even in a one-seat rig.
expect(claimedResult.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@s5-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 's5-rig' leaves a session OpenRig didn't start running.");
+ "and leaves its process running), then end that process in its own terminal. rig down 's5-rig' leaves a session OpenRig didn't start running.");
const probed = seatFixture("s5-rig", "dev.flaky");
tmux.failProbeFor(probed.sessionName);
@@ -471,7 +471,7 @@ describe("SeatLifecycleService", () => {
const shared = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
if (shared.ok) throw new Error("expected refusal");
expect(shared.guidance).toBe("To end this session, run rig unclaim 'dev-adopted@adopted-rig' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
+ "and leaves its process running), then end that process in its own terminal. rig down 'adopted-rig' leaves a session OpenRig didn't start running.");
sessionRegistry.updateBinding(claimed.node.id, { attachmentType: "external_cli", externalSessionName: claimed.sessionName });
const external = await service.stopSeat({ seatRef: claimed.sessionName, reason: "x" });
@@ -485,7 +485,7 @@ describe("SeatLifecycleService", () => {
it("claimedSessionRoutes routes a tmux session through rig unclaim, never rig down", () => {
expect(claimedSessionRoutes("r", "a@r")).toBe("To end this session, run rig unclaim 'a@r' (it releases the session from OpenRig "
+ "and leaves its process running), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name. Closing only its viewing window leaves the tmux session running. rig down 'r' leaves a session OpenRig didn't start running.");
+ "and leaves its process running), then end that process in its own terminal. rig down 'r' leaves a session OpenRig didn't start running.");
});
// ---- P5 / P6 — clean ----
@@ -636,10 +636,10 @@ describe("rig seat restart --keep-conversation: gates before any signal", () =>
w.db.prepare("UPDATE sessions SET origin = 'claimed' WHERE id = ?").run(w.session.id);
const claimed = await w.service.restartInPlace({ seatRef: SEAT, plan: true });
expect(claimed).toMatchObject({ ok: false, code: "claimed_session" });
// B5: the refusal's route is rig unclaim, then ending the process and its tmux session; rig down leaves a session
// B5: the refusal's route is rig unclaim, then ending the process in its own terminal; rig down leaves a session
// OpenRig didn't start running, so it isn't offered as the way to end this one.
const guidance = (claimed as { guidance?: string }).guidance ?? "";
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process and the underlying tmux session, including any remaining shell, so no session keeps the seat's name\. Closing only its viewing window leaves the tmux session running\. /);
expect(guidance).toMatch(/^To end this session, run rig unclaim '[^']+' \(it releases the session from OpenRig and leaves its process running\), then end that process in its own terminal\. /);
expect(guidance).toMatch(/rig down '[^']+' leaves a session OpenRig didn't start running\.$/);
expect(guidance).not.toMatch(/run rig down/);
});
+11 -5
View File
@@ -1,6 +1,7 @@
import type { AttentionRead, AttentionItem } from "@openrig/daemon/attention";
import type { Action, FleetSnapshot, ViewState } from "../types.js";
import { listItem, wrapDetailLines, type ContentLine } from "../detail.js";
import { displayTime } from "../time.js";
/** The bounded, confirmed-delivery or bridge-acceptance projection served by S02; no client classification. */
export interface DeliveredHumanUpdates {
@@ -11,6 +12,8 @@ export interface DeliveredHumanUpdates {
truncated: boolean;
}
const BRIDGE_ACCEPTED_SCOPE = " · accepted by bridge";
function isBridgeAcceptedUpdate(q: DeliveredHumanUpdates["items"][number]): boolean {
return q.deliveryOutcome === "accepted" || q.deliveryReceipt.startsWith("relay-owner-notification-accepted");
}
@@ -38,13 +41,13 @@ export function composeHumanUpdates(attention: AttentionRead | null, updates: De
const baseScope = projects.length === 1 ? `project ${projects[0]} (queue tag)` : "instance · project unknown";
const item: AttentionItem = { id: `human-update:${q.qitemId}`, kind: "update", summary: q.summary || q.body.trim().split(/\r?\n/).find(Boolean) || (accepted ? "Accepted by bridge" : "Delivered update"),
recipient: q.destinationSession, urgency: "update", unblocks: null, at: q.deliveredAt,
scope: accepted ? `${baseScope} · accepted by bridge` : baseScope, project: null,
scope: accepted ? `${baseScope}${BRIDGE_ACCEPTED_SCOPE}` : baseScope, project: null,
source: `/api/queue/${encodeURIComponent(q.qitemId)}` };
if (!read.items.some(current => current.id === `queue-update:${q.qitemId}`)) read.items.push(item);
if (wanted === item.id && read.detail?.item.id !== item.id) {
read.detailError = null;
read.detail = { item, lines: [q.body, ...(q.humanDetail ? ["Supplemental detail:", q.humanDetail] : []),
`From: ${q.sourceSession}`, accepted ? `Accepted by bridge: ${q.deliveredAt}` : `Delivered: ${q.deliveredAt}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
`From: ${q.sourceSession}`, `Delivery receipt: ${q.deliveryReceipt}`, `Queue: ${q.qitemId}`, `Evidence: ${q.evidenceRef ?? "none recorded"}`],
files: q.evidenceRef?.startsWith("/") ? [{ label: "Update evidence", path: q.evidenceRef }] : [] };
}
}
@@ -71,7 +74,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
if (d.item.recipient) lines.push({ text: `To: ${d.item.recipient}` });
if (d.item.id.startsWith("human-update:") || d.item.id.startsWith("queue-update:")) lines.push({ text: "No action needed" });
if (d.item.unblocks) lines.push({ text: `Unblocks: ${d.item.unblocks}` });
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `Observed: ${d.item.at ?? "unknown"}` });
// Times are drawn in the person's zone (ui.timezone), as the header's are; a delivered update's time is when it was
// delivered or accepted by the bridge.
const when = !d.item.id.startsWith("human-update:") ? "Observed" : d.item.scope.endsWith(BRIDGE_ACCEPTED_SCOPE) ? "Accepted by bridge" : "Delivered";
lines.push({ text: `Scope: ${d.item.scope}` }, { text: `${when}: ${displayTime(d.item.at, state.timeZone)}` });
lines.push(...questionLines(d.item).map(text => ({ text })));
lines.push(...d.lines.map(text => ({ text })));
for (const f of d.files) lines.push(listItem(f.label, { type: "attention-source", path: f.path }));
@@ -124,10 +130,10 @@ export function attentionLines(state: ViewState, snap: FleetSnapshot, width: num
if (i.humanQuestions?.length) lines.push({ text: ` Questions: ${i.humanQuestions.length}, ${unansweredCount(i)} unanswered` });
if (i.id.startsWith("human-update:") || i.id.startsWith("queue-update:")) lines.push({ text: " No action needed" });
if (i.unblocks) lines.push({ text: ` Unblocks: ${i.unblocks}` });
lines.push({ text: ` ${i.scope} · ${i.at ?? "time unknown"}` });
lines.push({ text: ` ${i.scope} · ${displayTime(i.at, state.timeZone)}` });
}
}
lines.push({ text: "" }, { text: `Read at ${read.readAt}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
lines.push({ text: "" }, { text: `Read at ${displayTime(read.readAt, state.timeZone)}` }, ...read.sources.map(s => ({ text: `${s.source}: ${s.state} · ${s.detail}` })));
}
lines.push({ text: "" }, { text: "Viewing is not approval. Each request's detail says whether it went to Slack and where to answer it." });
return wrapDetailLines(lines, width);
+30 -49
View File
@@ -48,8 +48,6 @@ interface ContentLine {
zones?: Array<{ start: number; end: number; action: Action }>;
/** slice-17: token segments for canvas-rendered rows (graph view) */
segs?: Array<{ text: string; token?: import("./theme.js").Token; bold?: boolean; bg?: import("./theme.js").Token }>;
/** Row of a labelled group. The first visible row of each group shows `text` when set, so a scrolled group keeps its label. */
sticky?: { group: string; text?: string };
}
function pad(text: string | number | null | undefined, width: number): string {
@@ -96,24 +94,17 @@ function agentColumns(contentWidth: number): AgentColumn[] {
];
}
/** RIG fits the longest rig name shown, between the tier's old width and its cap; NOW or WORK gives up the difference. */
function instanceAgentColumns(contentWidth: number, longestRigName = 0): AgentColumn[] {
if (contentWidth >= 110) {
const rig = Math.min(Math.max(longestRigName, 13), 24);
return [
["rig", "RIG", rig, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78 - (rig - 13)), "left"],
];
}
if (contentWidth >= 78) {
const rig = Math.min(Math.max(longestRigName, 10), 16);
return [
["rig", "RIG", rig, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54 - (rig - 10)), "left"],
];
}
function instanceAgentColumns(contentWidth: number): AgentColumn[] {
if (contentWidth >= 110) return [
["rig", "RIG", 13, "left"], ["pod", "POD", 8, "left"], ["seat", "SEAT", 14, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 7, "right"], ["status", "STATE", 10, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", 12, "left"], ["now", "NOW", Math.max(18, contentWidth - 78), "left"],
];
if (contentWidth >= 78) return [
["rig", "RIG", 10, "left"], ["pod", "POD", 6, "left"], ["seat", "SEAT", 12, "left"],
["runtime", "RT", 3, "left"], ["context", "CTX", 5, "right"], ["status", "STATE", 9, "left"],
["queue", "Q", 2, "right"], ["work", "WORK", Math.max(8, contentWidth - 54), "left"],
];
const fixed = 8 + 5 + 11 + 2 + 5 + 8 + 2 + 7;
return [
["rig", "RIG", 8, "left"], ["pod", "POD", 5, "left"], ["seat", "SEAT", 11, "left"],
@@ -718,7 +709,7 @@ function noRigsText(snap: FleetSnapshot, host: FleetSnapshot["hosts"][number], l
: ` (no rigs served by ${host.name})`;
}
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG shows on a rig's first visible row so scrolled rows keep it. */
/** Rig boundaries take a solid rule and pod boundaries a dotted one; RIG repeats on every row so scrolled rows keep it. */
function instanceContentLines(
state: ViewState,
snap: FleetSnapshot,
@@ -810,12 +801,7 @@ function instanceContentLines(
lines.push(healthSummaryLine(snap, scope, contentWidth));
lines.push(...queueSampleLines(snap, contentWidth));
lines.push({ text: state.filter ? `/ filter instance rows: ${state.filter} · / replace · esc clear` : "/ filter instance rows…" });
const shownRigs = host.rigs.map((rig) => ({
rig,
agents: rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter)),
})).filter(({ rig, agents }) => !state.filter || agents.length > 0 || rig.name.includes(state.filter));
const columns = instanceAgentColumns(contentWidth, Math.max(0, ...shownRigs.map(({ rig }) => rig.name.length)));
const columns = instanceAgentColumns(contentWidth);
lines.push({ text: tableRow(columns, { rig: "RIG", pod: "POD", seat: "SEAT", runtime: "RT", context: "CTX", status: "STATE", queue: "Q", work: "WORK", now: "NOW" }) });
lines.push({ text: "━".repeat(columnsWidth(columns)) });
let seatCount = 0;
@@ -824,7 +810,10 @@ function instanceContentLines(
let attentionUnknown = 0;
let openCount = 0;
let firstRig = true;
for (const { rig, agents } of shownRigs) {
for (const rig of host.rigs) {
const agents = rig.pods.flatMap((pod) => pod.agents.map((agent) => ({ pod: pod.name, agent })))
.filter(({ pod, agent }) => !state.filter || rig.name.includes(state.filter) || pod.includes(state.filter) || agent.name.includes(state.filter));
if (state.filter && agents.length === 0 && !rig.name.includes(state.filter)) continue;
if (!firstRig) lines.push({ text: "─".repeat(columnsWidth(columns)) });
firstRig = false;
const rigAction: Action = { type: "drill", resource: "rig", name: rig.name, target: { host: host.name } };
@@ -836,7 +825,7 @@ function instanceContentLines(
continue;
}
let previousPod: string | null = null;
for (const [index, { pod, agent }] of agents.entries()) {
for (const { pod, agent } of agents) {
const firstInPod = pod !== previousPod;
if (firstInPod && previousPod != null) lines.push({ text: "┈".repeat(columnsWidth(columns)) });
previousPod = pod;
@@ -847,21 +836,18 @@ function instanceContentLines(
if (agent.needsAttention === true) attentionCount += 1;
else if (agent.needsAttention == null) attentionUnknown += 1;
openCount += queue.count;
const cells = {
rig: rig.name,
pod: firstInPod ? pod : "",
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
runtime: runtimeShort(agent.runtime),
context: contextCompact(agent.context, contentWidth < 110),
status: stateCell.word,
queue: queueCount(snap, queue.count),
work: queue.work,
now: queue.now,
};
const named = tableRow(columns, cells);
lines.push({
text: index === 0 ? named : tableRow(columns, { ...cells, rig: "" }),
sticky: { group: `rig:${rig.name}`, ...(index === 0 ? {} : { text: named }) },
text: tableRow(columns, {
rig: rig.name,
pod: firstInPod ? pod : "",
seat: `${stateCell.mark} ${seatName(pod, agent.name)}`,
runtime: runtimeShort(agent.runtime),
context: contextCompact(agent.context, contentWidth < 110),
status: stateCell.word,
queue: queueCount(snap, queue.count),
work: queue.work,
now: queue.now,
}),
action: { type: "drill", resource: "agent", name: agent.name, target: { host: host.name, rig: rig.name, pod } },
zones: [{ start: 0, end: columns[0]![2], action: rigAction }],
});
@@ -1958,12 +1944,7 @@ function renderBody(state: ViewState, snap: FleetSnapshot, options: RenderOption
const contentRows = content.length > bodyRows ? Math.max(bodyRows - 1, 0) : bodyRows;
const maxContentOffset = Math.max(content.length - contentRows, 0);
const contentStart = Math.min(state.contentOffset, maxContentOffset);
const labelledGroups = new Set<string>();
const visibleContent = content.slice(contentStart, contentStart + contentRows).map((line) => {
if (!line.sticky || labelledGroups.has(line.sticky.group)) return line;
labelledGroups.add(line.sticky.group);
return line.sticky.text == null ? line : { ...line, text: line.sticky.text };
});
const visibleContent = content.slice(contentStart, contentStart + contentRows);
if (content.length > bodyRows) {
const scrollText = `scroll ↑/↓ · ${contentStart + 1}-${contentStart + visibleContent.length} of ${content.length}`;
const up = scrollText.indexOf("↑");
@@ -50,6 +50,24 @@ it("renders an unsent local FYI without inventing an approval or a delivery rece
expect(text).not.toContain("Delivery receipt:");
});
it("draws Feed times in the person's zone, as the header does, and redraws them when the zone changes", () => {
const item = current(), attention = read([item]);
attention.detail = { item, lines: ["Current queue body"], files: [] };
const merged = composeHumanUpdates(attention, delivered("other"), item.id)!;
const draw = (timeZone: string, attentionOpen: string | null) => attentionLines(
{ ...createViewState({ instanceId: "owned-fixture", timeZone }).get(), section: "needs" as const, attentionOpen },
{ ...emptySnapshot(), attentionRead: merged }, 140).map(l => l.text).join("\n");
const london = draw("Europe/London", null);
expect(london).toContain("instance · 2026-10-01 13:00:00 GMT+1");
expect(london).toContain("instance · project unknown · 2026-10-08 13:00:00 GMT+1");
expect(london).toContain("Read at 2026-10-09 13:00:00 GMT+1");
expect(london).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:/);
// The same read, drawn after the zone setting arrives: nothing is re-read, and nothing keeps the earlier zone.
const angeles = draw("America/Los_Angeles", item.id);
expect(angeles).toContain("Observed: 2026-10-01 05:00:00 PDT");
expect(angeles).not.toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:|GMT\+1/);
});
it("does not retain delivered history beyond a successful empty receipt window when only current updates fail", () => {
const prior = composeHumanUpdates(read([]), delivered())!;
const failed = read([]);
@@ -155,8 +173,9 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
],
};
const openedBridge = composeHumanUpdates(read([]), bridgeUpdates, "human-update:bridge-fyi")!;
const bridgeDetailLines = openedBridge.detail?.lines.join("\n") ?? "";
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08T14:00:00Z");
const detailState = (id: string) => ({ ...createViewState({ instanceId: "owned-fixture", timeZone: "UTC" }).get(), section: "needs" as const, attentionOpen: id });
const bridgeDetailLines = attentionLines(detailState("human-update:bridge-fyi"), { ...emptySnapshot(), attentionRead: openedBridge }, 140).map(l => l.text).join("\n");
expect(bridgeDetailLines).toContain("Accepted by bridge: 2026-10-08 14:00:00 UTC");
expect(bridgeDetailLines).not.toContain("Delivered:");
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).toContain("accepted by the bridge");
expect(openedBridge.sources.find(s => s.source === "delivered updates")?.detail).not.toContain("confirmed delivered updates");
@@ -168,8 +187,8 @@ it("labels relay bridge acceptance as accepted by the bridge rather than Deliver
// Slack delivered wording remains unchanged
const openedSlack = composeHumanUpdates(read([]), delivered("slack-fyi"), "human-update:slack-fyi")!;
const slackDetailLines = openedSlack.detail?.lines.join("\n") ?? "";
expect(slackDetailLines).toContain("Delivered: 2026-10-08T12:00:00Z");
const slackDetailLines = attentionLines(detailState("human-update:slack-fyi"), { ...emptySnapshot(), attentionRead: openedSlack }, 140).map(l => l.text).join("\n");
expect(slackDetailLines).toContain("Delivered: 2026-10-08 12:00:00 UTC");
expect(slackDetailLines).not.toContain("Accepted by bridge:");
expect(openedSlack.sources.find(s => s.source === "delivered updates")?.detail).toBe(
"Latest 20 confirmed delivered updates; retained receipt window.",
@@ -6,7 +6,6 @@ import { decodeInput, resolveKeyAction, sgrClick } from "../src/input.js";
import { renderScreen } from "../src/render.js";
import { computeExplorerRows, createViewState } from "../src/state.js";
import { createStyle, stripAnsi } from "../src/theme.js";
import { explorerWidth } from "../src/visual-layout.js";
import { stylizeLines } from "../src/stylize.js";
import type { AgentRow, FleetSnapshot } from "../src/types.js";
@@ -119,20 +118,16 @@ describe("S12 instance mission control", () => {
let scrolledSeatRows = 0;
for (let offset = 0; offset <= screen.contentMaxOffset; offset += 1) {
screen = renderScreen(view.get(), snap, { cols, rows, nowMs: 0, colorMode: "none" });
const namedRigs = new Set<string>();
for (const target of screen.contentTargets) {
if (target.action.type !== "drill" || target.action.resource !== "agent") continue;
reached.add(target.action.name);
const rig = target.action.target?.rig;
const content = stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2);
const firstVisible = !!rig && !namedRigs.has(rig);
if (rig) namedRigs.add(rig);
if (!rig || !/\b(working|needs you|idle|detached|failed|unknown|blocked)\b/.test(content)) continue;
const headingVisible = screen.lines.some((line) => stripAnsi(line).slice(screen.explorerWidth + 2).includes(`RIG ${rig} ·`));
if (headingVisible) continue;
scrolledSeatRows += 1;
if (firstVisible) expect(content.startsWith(rig), `${cols}: first visible seat row of a rig retains rig identity`).toBe(true);
else expect(content.startsWith(" ".repeat(rig.length)), `${cols}: later seat rows of a rig leave RIG blank`).toBe(true);
expect(content.trimStart().startsWith(rig), `${cols}: scrolled seat row retains rig identity`).toBe(true);
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
@@ -145,7 +140,7 @@ describe("S12 instance mission control", () => {
expect(whole).toMatch(/empty\s+—\s+\(no seats\).*stopped/);
expect(whole).toMatch(/dev\s+[\s\S]*┈{8,}[\s\S]*qa\s+/);
expect((whole.match(/┈{8,}/g) ?? [])).toHaveLength(1);
expect(whole).toMatch(/\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/build\s+qa\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*docs\s+write/);
expect(whole).toMatch(/docs\s+write\s+[^\n]*\n[^\n]*─{8,}[^\n]*\n[^\n]*empty\s+—/);
expect(whole).not.toMatch(/\+\d+|more hidden/i);
expect(reached).toEqual(expectedAgents);
@@ -324,138 +319,3 @@ describe("S12 view-aware hydration", () => {
expect(snap.hosts[0]).toMatchObject({ id: "local", name: "local" });
});
});
describe("instance table RIG column (#2848)", () => {
const LONG = "RIG-session-review";
const OVER_CAP = "rig-name-thirty-characters-xx";
function colsFor(contentWidth: number): number {
for (let cols = contentWidth; cols < contentWidth + 80; cols += 1) {
if (cols - explorerWidth(cols) - 2 === contentWidth) return cols;
}
throw new Error(`no terminal width gives content width ${contentWidth}`);
}
function snapshotWithRigs(names: string[]): FleetSnapshot {
const snap = multiRigSnapshot();
snap.hosts[0]!.rigs.push(...names.map((name) => ({
id: `r-${name}`,
name,
lifecycleState: "running",
pods: [{ name: "ops", agents: [agent("ops.worker", `ops-worker@${name}`, "idle", 10)] }],
})));
return snap;
}
function table(snap: FleetSnapshot, contentWidth: number, filter = "") {
const view = openInstance(snap);
if (filter) view.dispatch({ type: "filter", text: filter });
const cols = colsFor(contentWidth);
const screen = renderScreen(view.get(), snap, { cols, rows: 160, nowMs: 0, colorMode: "none" });
const content = screen.lines.map((line) => stripAnsi(line).slice(screen.explorerWidth + 2));
const header = content.find((line) => /^RIG\s+POD\s+SEAT/.test(line));
expect(header, `${contentWidth}: table header`).toBeDefined();
const heavyRule = content.find((line) => /^━{20,}/.test(line))!.match(/^━+/)![0].length;
const rigWidth = header!.indexOf("POD") - 1;
return { screen, content, header: header!, rigWidth, rowWidth: heavyRule };
}
it.each([
// [content width, expected RIG width, flexible column, expected flexible width]
[77, 8, "WORK", 77 - (8 + 5 + 11 + 2 + 5 + 8 + 2 + 7)],
[78, 16, "WORK", 78 - 54 - (16 - 10)],
[109, 16, "WORK", 109 - 54 - (16 - 10)],
[110, LONG.length, "NOW", 110 - 78 - (LONG.length - 13)],
[190, LONG.length, "NOW", 190 - 78 - (LONG.length - 13)],
] as const)("fits RIG to the longest shown name at content width %i", (contentWidth, rigWidth, flexible, flexibleWidth) => {
const { header, rigWidth: actual, rowWidth } = table(snapshotWithRigs([LONG]), contentWidth);
expect(actual).toBe(rigWidth);
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
expect(header.trimEnd().endsWith(flexible)).toBe(true);
expect(rowWidth - header.lastIndexOf(flexible)).toBe(flexibleWidth);
});
it("keeps the old RIG width as the minimum when every name is short", () => {
expect(table(multiRigSnapshot(), 160).rigWidth).toBe(13);
expect(table(multiRigSnapshot(), 90).rigWidth).toBe(10);
});
it.each([[110, 24], [160, 24], [78, 16], [100, 16]])("caps RIG and truncates a longer name at content width %i", (contentWidth, cap) => {
const { content, rigWidth, rowWidth } = table(snapshotWithRigs([OVER_CAP]), contentWidth);
expect(rigWidth).toBe(cap);
expect(rowWidth).toBeLessThanOrEqual(contentWidth);
expect(content.some((line) => line.startsWith(`${OVER_CAP.slice(0, cap - 1)}…`))).toBe(true);
for (const line of content) expect(line.trimEnd().length).toBeLessThanOrEqual(contentWidth);
});
function seatRows(screen: ReturnType<typeof renderScreen>) {
return screen.contentTargets
.filter((target) => target.action.type === "drill" && target.action.resource === "agent")
.map((target) => ({
y: target.y,
rig: target.action.type === "drill" ? target.action.target?.rig ?? "" : "",
name: target.action.type === "drill" ? target.action.name : "",
text: stripAnsi(screen.lines[target.y - 1] ?? "").slice(screen.explorerWidth + 2),
}));
}
it("names every rig on its single row when each rig has one seat", () => {
const names = Array.from({ length: 8 }, (_, index) => `solo-${index}`);
const { screen } = table(snapshotWithRigs(names), 160);
const rows = seatRows(screen).filter((row) => names.includes(row.rig));
expect(rows.map((row) => row.rig)).toEqual(names);
for (const row of rows) expect(row.text.startsWith(`${row.rig} `)).toBe(true);
});
it("names a rig on its first row only, across pod dividers, while POD still leads each pod", () => {
const { screen, content, rigWidth } = table(multiRigSnapshot(), 160);
const build = seatRows(screen).filter((row) => row.rig === "build");
expect(build.map((row) => row.name)).toEqual(["dev.driver", "dev.guard", "qa.checker"]);
expect(build[0]!.text).toMatch(/^build\s+dev\s/);
expect(build[1]!.text.slice(0, rigWidth + 4).trim()).toBe("");
expect(build[2]!.text).toMatch(new RegExp(`^ {${rigWidth}} qa\\s`));
expect(content[build[2]!.y - 2]).toMatch(/^┈{8,}/);
expect(content.filter((line) => line.startsWith("build "))).toHaveLength(1);
});
it("drills into the row's rig from a blank RIG cell", () => {
const { screen, rigWidth } = table(multiRigSnapshot(), 160);
const guard = seatRows(screen).find((row) => row.name === "dev.guard")!;
expect(guard.text.slice(0, rigWidth).trim()).toBe("");
for (const x of [screen.explorerWidth + 3, screen.explorerWidth + 2 + rigWidth]) {
const hit = screen.hitMap.find((target) => target.y === guard.y && x >= target.x1 && x <= target.x2);
expect(hit?.action).toEqual({ type: "drill", resource: "rig", name: "build", target: { host: "mm2-openrig1" } });
}
const pastRig = screen.explorerWidth + 3 + rigWidth;
const seatHit = screen.hitMap.find((target) => target.y === guard.y && pastRig >= target.x1 && pastRig <= target.x2);
expect(seatHit?.action).toMatchObject({ type: "drill", resource: "agent", name: "dev.guard" });
});
it("names a scrolled rig on its top visible row and leaves the rows under it blank", () => {
const snap = multiRigSnapshot();
snap.hosts[0]!.rigs[0]!.pods[0]!.agents.push(...Array.from({ length: 36 }, (_, index) =>
agent(`dev.extra-${index}`, `dev-extra-${index}@build`, "active", 20 + index),
));
const view = openInstance(snap);
let screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
view.dispatch({ type: "layout", contentMaxOffset: screen.contentMaxOffset, contentTargetCount: screen.contentTargets.length });
let checked = false;
for (let offset = 0; offset <= screen.contentMaxOffset && !checked; offset += 1) {
screen = renderScreen(view.get(), snap, { cols: 160, rows: 30, nowMs: 0, colorMode: "none" });
const build = seatRows(screen).filter((row) => row.rig === "build");
if (build.length >= 3 && build[0]!.name !== "dev.driver") {
expect(build[0]!.text.startsWith("build ")).toBe(true);
for (const row of build.slice(1)) expect(row.text.startsWith(" ")).toBe(true);
checked = true;
}
view.dispatch({ type: "content-scroll", delta: 1 });
}
expect(checked).toBe(true);
});
it("sizes RIG from the rigs the filter leaves on screen", () => {
const snap = snapshotWithRigs([LONG, "short-rig"]);
expect(table(snap, 160).rigWidth).toBe(LONG.length);
expect(table(snap, 160, "short").rigWidth).toBe(13);
});
});