diff --git a/plugin/mods/agents/pane.ts b/plugin/mods/agents/pane.ts index 1fb455a..b6fdc47 100644 --- a/plugin/mods/agents/pane.ts +++ b/plugin/mods/agents/pane.ts @@ -47,13 +47,8 @@ function vocab(entries: Record): Record { return Object.assign(Object.create(null) as Record, 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", @@ -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, }; @@ -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); @@ -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") + @@ -199,15 +195,11 @@ 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 { @@ -215,7 +207,7 @@ function age(iso: unknown, now: number): string { 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`; } @@ -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 @@ -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); @@ -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)] + : []), ]), ); } @@ -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), ]), ); } @@ -373,8 +378,7 @@ 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`), @@ -382,11 +386,9 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined frameSep(), frameRow(""), ]; - const [statusLegend, harnessLegend] = footerLegend(W); + const harnessLegend = footerLegend(W); const tail = [ - frameRow(""), frameSep(), - frameRow(statusLegend), frameRow(harnessLegend), frameBot(), ]; @@ -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; } diff --git a/plugin/mods/agents/types.ts b/plugin/mods/agents/types.ts index 762e3a0..0423528 100644 --- a/plugin/mods/agents/types.ts +++ b/plugin/mods/agents/types.ts @@ -13,6 +13,8 @@ export interface SessionRow { origin?: string | null; name?: string; agent?: string; + model?: string; + effort?: string; status?: string; updatedAt?: string; } @@ -26,6 +28,8 @@ export interface PaneRow { id: string; status: string; agent: string; + model?: string; + effort?: string; name: string; updatedAt?: string; } @@ -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; diff --git a/src/open/runtime.ts b/src/open/runtime.ts index 3e9e40b..f9cd4b6 100644 --- a/src/open/runtime.ts +++ b/src/open/runtime.ts @@ -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( diff --git a/tests/mods-agents/pane.test.ts b/tests/mods-agents/pane.test.ts index b36644a..eca7d78 100644 --- a/tests/mods-agents/pane.test.ts +++ b/tests/mods-agents/pane.test.ts @@ -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"); @@ -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"); }); @@ -204,15 +204,14 @@ 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" }, @@ -220,7 +219,9 @@ describe("formatPane", () => { { 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"); @@ -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"); }); @@ -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)", () => { @@ -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"); @@ -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); @@ -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); @@ -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"); @@ -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"); }); @@ -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); }); diff --git a/tests/open-args.test.ts b/tests/open-args.test.ts index fe318d6..fd01a24 100644 --- a/tests/open-args.test.ts +++ b/tests/open-args.test.ts @@ -548,7 +548,7 @@ describe("open command pure helpers", () => { }); // Claude Code prints its own resume hint on exit with no way to turn it - // off, so the farewell erases those two rows first on a tty, leaving only + // off, so the farewell clears the hint from column zero on a tty, leaving only // the BYE resume line. it("erases Claude's own resume hint before signing off on a tty", () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codedeck-open-test-")); @@ -582,14 +582,14 @@ describe("open command pure helpers", () => { finishOpenSession("reviewer", sessionFile, (text) => { piped.push(text); }, false); - expect(piped.join("")).not.toContain("\x1b[2A"); + expect(piped.join("")).not.toContain(CLAUDE_RESUME_ERASE); fs.writeFileSync(sessionFile, sessionId); const noId: string[] = []; finishOpenSession("reviewer", path.join(tempDir, "missing"), (text) => { noId.push(text); }, true); - expect(noId.join("")).not.toContain("\x1b[2A"); + expect(noId.join("")).not.toContain(CLAUDE_RESUME_ERASE); } finally { fs.rmSync(tempDir, { recursive: true, force: true }); }