Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 35 additions & 29 deletions plugin/mods/agents/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,8 @@ function vocab(entries: Record<string, string>): Record<string, string> {
return Object.assign(Object.create(null) as Record<string, string>, entries);
}

const STATUS_DOT = vocab({
working: "●",
starting: "●",
needs_input: "◉",
});
const STATUS_WORD = vocab({
working: "Working now",
working: "Working",
starting: "Starting",
needs_input: "Waiting for you",
completed: "Completed",
Expand Down Expand Up @@ -111,6 +106,8 @@ function toPaneRow(row: SessionRow): PaneRow {
id: row.id,
status: row.status || EMPTY_CELL,
agent: row.agent || EMPTY_CELL,
model: text(row.model) || undefined,
effort: text(row.effort) || undefined,
name: row.name || EMPTY_CELL,
updatedAt: iso,
};
Expand All @@ -133,7 +130,11 @@ export function selectPane(rows: SessionRow[], runId: string, budget?: number):
);
const orchestratorRow = matching.find((row) => row.origin === "open");
const orchestrator = orchestratorRow
? { agent: text(orchestratorRow.agent) || EMPTY_CELL }
? {
agent: text(orchestratorRow.agent) || EMPTY_CELL,
model: text(orchestratorRow.model) || undefined,
effort: text(orchestratorRow.effort) || undefined,
}
: undefined;
const workers = matching.filter((row) => row.origin !== "open");
const ordered = [...workers].sort(compareRows);
Expand Down Expand Up @@ -179,16 +180,11 @@ function harnessLabel(agent: unknown): string {
return HARNESS_LABEL[key] ?? cell(agent);
}

function dot(status: unknown): string {
return STATUS_DOT[text(status)] ?? "○";
}

function statusWord(status: unknown): string {
return STATUS_WORD[text(status)] ?? cell(status);
}

function footerLegend(columns: number): [string, string] {
const fullStatus = " ● working ◉ waiting ○ finished";
function footerLegend(columns: number): string {
const fullHarness =
" " +
glyph("claude") +
Expand All @@ -199,23 +195,19 @@ function footerLegend(columns: number): [string, string] {
" Codex " +
glyph("omp") +
" OMP";
if (fullStatus.length <= columns - 2 && fullHarness.length <= columns - 2) {
return [fullStatus, fullHarness];
}
if (fullHarness.length <= columns - 2) return fullHarness;

const compactStatus = " ● wk ◉ wait ○ done";
const iconHarness =
" " + glyph("claude") + " " + glyph("opencode") + " " + glyph("codex") + " " + glyph("omp");
if (compactStatus.length + 4 <= columns - 2) return [compactStatus, iconHarness];
return [" ● ◉ ○", iconHarness];
return iconHarness;
}

function age(iso: unknown, now: number): string {
if (typeof iso !== "string") return "";
const then = Date.parse(iso);
if (Number.isNaN(then)) return "";
const minutes = Math.floor((now - then) / 60000);
if (minutes < 1) return "agora";
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m`;
return `${Math.floor(minutes / 60)}h`;
}
Expand All @@ -224,6 +216,16 @@ function count(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
}

function detailLine(model: unknown, effort: unknown, status: string, when: string, width: number): string {
const state = status ? `${status}${when ? ` · ${when}` : ""}` : "";
const suffix = [text(effort).trim() ? `${cell(effort)} effort` : "", state]
.filter(Boolean)
.join(" · ");
const modelWidth = width - 3 - suffix.length - (suffix ? 3 : 0);
const modelText = text(model).trim() && modelWidth > 0 ? fit(cell(model), modelWidth).trimEnd() : "";
return ` ${[modelText, suffix].filter(Boolean).join(" · ")}`;
}

// How old a row looks for eviction: an unparseable timestamp is the oldest
// possible age, matching how compareRows parks such rows at the end of the
// display. The orchestrator pins itself to +Infinity so it is the very last
Expand Down Expand Up @@ -256,7 +258,7 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
);
const orchestrator =
source.orchestrator && typeof source.orchestrator === "object"
? (source.orchestrator as Partial<{ agent: string }>)
? (source.orchestrator as Partial<{ agent: string; model: string; effort: string }>)
: undefined;
const hidden = count(source.hidden);
const total = count(source.total);
Expand Down Expand Up @@ -322,8 +324,11 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
if (orchestrator !== undefined) {
blocks.push(
cardBlock(Number.POSITIVE_INFINITY, "", [
` ${glyph(orchestrator.agent)} ● Orchestrator`,
` ${glyph(orchestrator.agent)} Orchestrator`,
` ${harnessLabel(orchestrator.agent)} · ${workerTotal} agents`,
...(text(orchestrator.model).trim() || text(orchestrator.effort).trim()
? [detailLine(orchestrator.model, orchestrator.effort, "", "", INNER)]
: []),
]),
);
}
Expand All @@ -337,9 +342,9 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
const word = statusWord(status);
blocks.push(
cardBlock(rowAge(row.updatedAt), cell(row.id), [
` ${glyph(row.agent)} ${dot(row.status)} ${cell(row.id)} ${harnessLabel(row.agent)}`,
` ${glyph(row.agent)} ${cell(row.id)} ${harnessLabel(row.agent)}`,
` ${cell(row.name)}`,
when === "" ? ` ${word}` : ` ${word} · ${when}`,
detailLine(row.model, row.effort, word, when, INNER),
]),
);
}
Expand Down Expand Up @@ -373,20 +378,17 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
const stemAbove = (list: Block[], i: number): boolean =>
i > 0 && (list[i].kind === "card" || list[i - 1].kind === "card");

// Rule 3: these ten lines are the frame. Whenever anything is drawn at all
// they are drawn, and they are never the lines that the fit cuts.
// These frame lines are always drawn when the pane has content.
const head = [
frameTop(`Run canvas ${cell(source.runId)}`),
frameRow(` ${total} sessions · ${working} working`),
frameRow(` ${waiting} waiting for you · ${finished} finished`),
frameSep(),
frameRow(""),
];
const [statusLegend, harnessLegend] = footerLegend(W);
const harnessLegend = footerLegend(W);
const tail = [
frameRow(""),
frameSep(),
frameRow(statusLegend),
frameRow(harnessLegend),
frameBot(),
];
Expand Down Expand Up @@ -434,6 +436,10 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined
if (kept.length > 0 && kept[kept.length - 1].kind === "card") out.push(stem(), hiddenRow);
else out.push(hiddenRow);
}
if (limit !== undefined) {
const spare = limit - out.length - tail.length;
for (let i = 0; i < spare; i += 1) out.push(frameRow(""));
}
out.push(...tail);
return out;
}
Expand Down
6 changes: 5 additions & 1 deletion plugin/mods/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface SessionRow {
origin?: string | null;
name?: string;
agent?: string;
model?: string;
effort?: string;
status?: string;
updatedAt?: string;
}
Expand All @@ -26,6 +28,8 @@ export interface PaneRow {
id: string;
status: string;
agent: string;
model?: string;
effort?: string;
name: string;
updatedAt?: string;
}
Expand All @@ -34,7 +38,7 @@ export interface PaneRow {
export interface PaneSnapshot {
runId: string;
/** The orchestrator's own row, the only one whose origin is "open". */
orchestrator: { agent: string } | undefined;
orchestrator: { agent: string; model?: string; effort?: string } | undefined;
rows: PaneRow[];
/** Rows that matched the run but fell outside the budget. */
hidden: number;
Expand Down
12 changes: 5 additions & 7 deletions src/open/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,17 +355,15 @@ export function writeStdoutSync(text: string): void {
/**
* Claude Code prints its own resume hint on exit
* ("Resume this session with: claude --resume ..."), with no setting to turn
* it off. It always lands right above the farewell — a leading blank line
* plus its two hint rows — so when there is an id to offer, which is the same
* condition under which Claude printed its hint, this backs the cursor over
* those two rows and clears down before the farewell goes out, leaving only
* the BYE resume line. A wrapped hint (narrow terminal, long title) leaves
* its top row behind, still strictly less noise than the duplicate.
* it off. It lands above the farewell with a blank row after the command.
* Move to the hint's heading, return to column zero, then clear down before
* printing BYE. Cursor-up preserves the current column, so clearing without
* the carriage return leaves the beginning of Claude's hint on screen.
*
* A pipe gets nothing: Claude skips its hint off-tty too, so there is nothing
* to erase and escape codes would only pollute redirected output.
*/
export const CLAUDE_RESUME_ERASE = "\x1b[2A\x1b[J";
export const CLAUDE_RESUME_ERASE = "\x1b[3A\r\x1b[J";

/** Takes the session id, writes the farewell while the SIGINT guard is live, and returns the native session id. */
export function finishOpenSession(
Expand Down
79 changes: 38 additions & 41 deletions tests/mods-agents/pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,9 @@ describe("formatPane", () => {
it("keeps a compact history preview when rows is not a usable number (rule 2)", () => {
const snap = workerFixture();
const wide = formatPane(snap, 89);
// 10 frame + the live card + its stem + the five-line history section
// 8 frame + the live card + its stem + the five-line history section
// and its stem. The 18 finished workers no longer get one line each.
expect(wide).toHaveLength(21);
expect(wide).toHaveLength(19);
expect(wide.some((l) => l.includes("hidden agents"))).toBe(false);
expect(wide.join("\n")).toContain("HISTORY · 18 finished");
expect(wide.join("\n")).toContain("a01");
Expand All @@ -188,8 +188,8 @@ describe("formatPane", () => {
expect(formatPane(snap, 89, Number.POSITIVE_INFINITY)).toEqual(wide);
expect(formatPane(snap, 89, "44" as unknown as number)).toEqual(wide);
// A shorter pane keeps the live card and drops the history first.
const short = formatPane(snap, 89, 20);
expect(short.length).toBeLessThanOrEqual(20);
const short = formatPane(snap, 89, 16);
expect(short.length).toBeLessThanOrEqual(16);
expect(short.join("\n")).toContain("w1");
expect(short.join("\n")).not.toContain("a01");
});
Expand All @@ -204,23 +204,24 @@ describe("formatPane", () => {
expect(lines[2]).toContain("finished");
expect(lines[3]).toMatch(/^├─/);
expect(lines[lines.length - 1]).toMatch(/^└─/);
expect(lines[lines.length - 3]).toContain("● working");
expect(lines[lines.length - 2]).toContain("\uEC82 Claude");
expect(lines[lines.length - 4]).toMatch(/^├─/);
expect(lines[lines.length - 3]).toMatch(/^├─/);
}
});

it("draws live rows as cards and groups finished rows in history (rule 4)", () => {
const rows: PaneRow[] = [
{ id: "k1", status: "working", agent: "opencode", name: "k-live" },
{ id: "k1", status: "working", agent: "opencode", model: "gpt-6-luna", effort: "max", name: "k-live" },
{ id: "k2", status: "needs_input", agent: "claude", name: "k-wait" },
{ id: "k3", status: "starting", agent: "codex", name: "k-boot" },
{ id: "k4", status: "completed", agent: "claude", name: "k-done" },
{ id: "k5", status: "stopped", agent: "omp", name: "k-stop" },
{ id: "k6", status: "weird", agent: "opencode", name: "k-odd" },
];
const joined = formatPane(snapshot({ rows, hidden: 0, total: 7 }), 89).join("\n");
expect(joined).toContain("Working now");
expect(joined).toContain("Working");
expect(joined).toContain("gpt-6-luna · max effort · Working");
expect(joined).not.toContain("\uE902 ● k1");
expect(joined).toContain("Waiting for you");
expect(joined).toContain("Starting");
expect(joined).not.toContain("Completed");
Expand All @@ -236,8 +237,6 @@ describe("formatPane", () => {
expect(wide[wide.length - 2]).toContain("\uEC82 Claude \uE902 OpenCode \uEC81 Codex \uE903 OMP");

const narrow = formatPane(snapshot(), 24);
expect(narrow[narrow.length - 3]).toContain("● ◉ ○");
expect(narrow[narrow.length - 3]).not.toContain("working");
expect(narrow[narrow.length - 2]).toContain("\uEC82 \uE902 \uEC81 \uE903");
expect(narrow[narrow.length - 2]).not.toContain("Claude");
});
Expand All @@ -251,20 +250,20 @@ describe("formatPane", () => {
];
const snap = snapshot({ rows, orchestrator: undefined, hidden: 0, total: 4 });
// The history section leaves as a unit, so both live cards remain visible.
const at24 = formatPane(snap, 40, 24);
expect(at24).toHaveLength(23);
expect(at24.join("\n")).toContain("wNew");
expect(at24.join("\n")).toContain("wOld");
expect(at24.join("\n")).not.toContain("cOld");
expect(at24.join("\n")).not.toContain("cNew");
expect(at24.join("\n")).toContain("+2 hidden agents");

// At 21 the live cards still win, and the hidden line gives way if needed.
const at21 = formatPane(snap, 40, 21);
expect(at21).toHaveLength(21);
expect(at21.join("\n")).toContain("wNew");
expect(at21.join("\n")).toContain("wOld");
expect(at21.join("\n")).not.toContain("hidden agents");
expect(at21.join("\n")).not.toContain("cOld");
expect(at21.join("\n")).not.toContain("cNew");
expect(at21.join("\n")).toContain("+2 hidden agents");

// At 20 the live cards still win, and the hidden line gives way.
const at20 = formatPane(snap, 40, 20);
expect(at20).toHaveLength(20);
expect(at20.join("\n")).toContain("wNew");
expect(at20.join("\n")).toContain("wOld");
expect(at20.join("\n")).not.toContain("hidden agents");
});

it("keeps history counts in the summary and reports dropped blocks when useful (rule 6)", () => {
Expand All @@ -276,8 +275,8 @@ describe("formatPane", () => {
expect(unbounded.join("\n")).toContain("+2 earlier");

// The history block represents all 18 finished workers, while the live
// card stays visible in a 20-row pane.
const fitted = formatPane(workerFixture(), 89, 20);
// card stays visible in a 16-row pane.
const fitted = formatPane(workerFixture(), 89, 16);
expect(fitted.filter((l) => l.includes("hidden agents"))).toHaveLength(1);
expect(fitted.join("\n")).toContain("+18 hidden agents");
expect(fitted.join("\n")).toContain("w1");
Expand All @@ -292,7 +291,7 @@ describe("formatPane", () => {

it("returns nothing when the height cannot hold the frame alone (rule 7)", () => {
const snap = workerFixture();
expect(formatPane(snap, 89, 9)).toEqual([]);
expect(formatPane(snap, 89, 7)).toEqual([]);
expect(formatPane(snap, 89, 0)).toEqual([]);
expect(formatPane(snap, 89, -5)).toEqual([]);
expect(formatPane(snap, 89, 10)).toHaveLength(10);
Expand Down Expand Up @@ -507,10 +506,9 @@ describe("formatPane", () => {

it("renders 19 workers at 89x44 with a compact history section (mandatory)", () => {
const lines = formatPane(workerFixture(), 89, 44);
expect(lines).toHaveLength(21);
expect(lines[20]).toBe("└" + "─".repeat(87) + "┘");
expect(lines[18]).toContain("● working");
expect(lines[19]).toContain("\uEC82 Claude");
expect(lines).toHaveLength(44);
expect(lines[43]).toBe("└" + "─".repeat(87) + "┘");
expect(lines[42]).toContain("\uEC82 Claude");
expect(lines.some((l) => l.includes("19 sessions"))).toBe(true);
expect(lines.some((l) => l.includes("1 working"))).toBe(true);
expect(lines.some((l) => l.includes("0 waiting for you"))).toBe(true);
Expand All @@ -533,10 +531,10 @@ describe("formatPane", () => {
expect(drawn).toBe(4);
});

it("keeps the live worker when the 89x20 pane drops history (mandatory)", () => {
const lines = formatPane(workerFixture(), 89, 20);
expect(lines).toHaveLength(17);
expect(lines[16]).toBe("└" + "─".repeat(87) + "┘");
it("keeps the live worker when the 89x16 pane drops history (mandatory)", () => {
const lines = formatPane(workerFixture(), 89, 16);
expect(lines).toHaveLength(16);
expect(lines[15]).toBe("└" + "─".repeat(87) + "┘");
const joined = lines.join("\n");
expect(joined).toContain("w1");
expect(joined).toContain("+18 hidden agents");
Expand Down Expand Up @@ -572,7 +570,7 @@ describe("formatPane", () => {
const lines = formatPane(snapshot({ orchestrator: undefined, rows, hidden: 0, total: 3 }), 89);
// The history block is the root, so there is nothing above it to hang a
// stem from.
expect(lines).toHaveLength(14);
expect(lines).toHaveLength(12);
expect(lines.filter((l) => l === STEM89)).toHaveLength(0);
expect(lines[5]).toContain("HISTORY · 3 finished");
});
Expand All @@ -586,21 +584,20 @@ describe("formatPane", () => {
expect(joined16).toContain("+2 hidden agents");
expect(joined16).not.toContain("a1");
expect(joined16).not.toContain("b2");
// One row tighter still keeps the root, but the hidden line gives way.
const at15 = formatPane(snapshot(), 40, 15);
expect(at15).toHaveLength(14);
const joined15 = at15.join("\n");
expect(joined15).toContain("Orchestrator");
expect(joined15).not.toContain("hidden agents");
// At 13 rows the root stays, but the hidden line gives way.
const at13 = formatPane(snapshot(), 40, 13);
expect(at13).toHaveLength(13);
const joined13 = at13.join("\n");
expect(joined13).toContain("Orchestrator");
expect(joined13).not.toContain("hidden agents");
});

it("renders the same 19 workers at 12 rows as header, footer and hidden line (mandatory)", () => {
const lines = formatPane(workerFixture(), 89, 12);
expect(lines).toHaveLength(11);
expect(lines[10]).toBe("└" + "─".repeat(87) + "┘");
expect(lines).toHaveLength(12);
expect(lines[11]).toBe("└" + "─".repeat(87) + "┘");
expect(lines.some((l) => l.includes("19 sessions"))).toBe(true);
expect(lines.some((l) => l.includes("18 finished"))).toBe(true);
expect(lines.some((l) => l.includes("● working"))).toBe(true);
expect(lines.some((l) => l.includes("+19 hidden agents"))).toBe(true);
for (const worker of ["w1", "a01", "a18"]) expect(lines.join("\n")).not.toContain(worker);
});
Expand Down
Loading
Loading