From 973c7b5f53de85e57578151ea61f6e4c21f9792a Mon Sep 17 00:00:00 2001 From: 4ndreello <4ndreello@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:03:08 -0300 Subject: [PATCH] fix(open): Compact agent cards and clear native resume hint Keep the agents pane footer at the bottom and show model, effort, and status in compact cards. Clear the full Claude resume hint before printing the CodeDeck farewell. --- plugin/mods/agents/pane.ts | 64 ++++++++++++++------------- plugin/mods/agents/types.ts | 6 ++- src/open/runtime.ts | 12 +++--- tests/mods-agents/pane.test.ts | 79 ++++++++++++++++------------------ tests/open-args.test.ts | 6 +-- 5 files changed, 86 insertions(+), 81 deletions(-) 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 }); }