From bb3ac37fb7d6d4b59040b84d3c1222d7b406ecf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20M=C3=BCller?= Date: Fri, 21 Aug 2026 12:12:54 +0200 Subject: [PATCH] feat(coding-agent): render the subagents summary as a bordered tile (#1586) --- .../.changes/feat-subagent-summary-tile.md | 1 + .../components/subagent-summary-line.ts | 39 ++++++++++-- .../test/subagent-summary-line.test.ts | 62 +++++++++++++++---- 3 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 packages/coding-agent/.changes/feat-subagent-summary-tile.md diff --git a/packages/coding-agent/.changes/feat-subagent-summary-tile.md b/packages/coding-agent/.changes/feat-subagent-summary-tile.md new file mode 100644 index 000000000..2adb0a31c --- /dev/null +++ b/packages/coding-agent/.changes/feat-subagent-summary-tile.md @@ -0,0 +1 @@ +- Changed the subagents summary under the prompt into a bordered `agents` tile with color-coded running/idle/inactive counts and a right-aligned open hint. diff --git a/packages/coding-agent/src/modes/interactive/components/subagent-summary-line.ts b/packages/coding-agent/src/modes/interactive/components/subagent-summary-line.ts index 0ae22af74..69af41895 100644 --- a/packages/coding-agent/src/modes/interactive/components/subagent-summary-line.ts +++ b/packages/coding-agent/src/modes/interactive/components/subagent-summary-line.ts @@ -83,13 +83,42 @@ export class SubagentSummaryLine implements Component, Focusable { render(width: number): string[] { const lines = this.renderInfoLine(width); if (this.counts.total === 0) return lines; - const summary = `${this.counts.total} subagent${this.counts.total === 1 ? "" : "s"}: ${this.counts.running} running · ${this.counts.idle} idle · ${this.counts.inactive} inactive`; + if (width < 2) return lines; + const safeWidth = width; + const inner = safeWidth - 2; + const label = theme.fg("accent", "agents"); + const top = truncateToWidth( + `${theme.fg("border", "╭─ ")}${label}${theme.fg("border", ` ${"─".repeat(Math.max(0, inner - 9))}╮`)}`, + safeWidth, + "…", + ); + const counts = + theme.fg("success", `● ${this.counts.running} running`) + + " " + + theme.fg("warning", `◐ ${this.counts.idle} idle`) + + " " + + theme.fg("dim", `○ ${this.counts.inactive} inactive`); const openHint = this.openable - ? ` ${keyText("tui.select.confirm")} or ${keyText("app.agents.open")} to open` + ? this.focused + ? `${keyText("tui.select.confirm")}/${keyText("app.agents.open")} open` + : `${keyText("tui.editor.cursorDown", { primaryOnly: true })} select` : ""; - const text = `${this.focused ? "▸" : " "} ${summary}${openHint}`; - const line = truncateToWidth(text, width, "…"); - lines.push(this.focused ? theme.bg("selectedBg", line.padEnd(width)) : theme.fg("dim", line)); + const gap = Math.max(1, inner - 2 - visibleWidth(counts) - visibleWidth(openHint)); + const body = truncateToWidth(` ${counts}${" ".repeat(gap)}${theme.fg("dim", openHint)} `, inner, "…"); + const pad = " ".repeat(Math.max(0, inner - visibleWidth(body))); + // Truncation may inject full ANSI resets; wrap each segment so the + // selection background survives past them (custom-editor precedent). + const content = this.focused + ? `${body}${pad}` + .split("\x1b[0m") + .map((segment) => theme.bg("selectedBg", segment)) + .join("\x1b[0m") + : `${body}${pad}`; + lines.push( + top, + `${theme.fg("border", "│")}${content}${theme.fg("border", "│")}`, + theme.fg("border", `╰${"─".repeat(inner)}╯`), + ); return lines; } diff --git a/packages/coding-agent/test/subagent-summary-line.test.ts b/packages/coding-agent/test/subagent-summary-line.test.ts index 03839caa0..38959d6ee 100644 --- a/packages/coding-agent/test/subagent-summary-line.test.ts +++ b/packages/coding-agent/test/subagent-summary-line.test.ts @@ -24,18 +24,56 @@ describe("SubagentSummaryLine", () => { setKeybindings(new KeybindingsManager()); }); - it("renders no subagent line without children and uses singular and plural labels", () => { + it("renders nothing without children and a bordered agents tile with counts otherwise", () => { const line = new SubagentSummaryLine(); expect(line.render(120)).toEqual([]); line.setSubagentCounts({ total: 1, running: 1, idle: 0, inactive: 0 }); let rendered = line.render(120).map(stripAnsi); - expect(rendered).toHaveLength(1); - expect(rendered[0]).toContain("1 subagent: 1 running · 0 idle · 0 inactive"); + expect(rendered).toHaveLength(3); + expect(rendered[0]).toContain("╭─ agents ─"); + expect(rendered[1]).toContain("● 1 running ◐ 0 idle ○ 0 inactive"); line.setSubagentCounts({ total: 2, running: 1, idle: 1, inactive: 0 }); rendered = line.render(120).map(stripAnsi); - expect(rendered[0]).toContain("2 subagents: 1 running · 1 idle · 0 inactive"); + expect(rendered[1]).toContain("● 1 running ◐ 1 idle ○ 0 inactive"); + }); + + it("hints ↓ select when unfocused and Enter/→ open when focused", () => { + const line = new SubagentSummaryLine(); + line.setSubagentCounts({ total: 1, running: 1, idle: 0, inactive: 0 }); + line.setOpenable(true); + + expect(stripAnsi(line.render(120)[1])).toContain("↓ select"); + + line.focused = true; + const focused = stripAnsi(line.render(120)[1]); + expect(focused).toContain("open"); + expect(focused).not.toContain("↓ select"); + }); + + it("keeps the selection background across truncation resets when focused", () => { + const line = new SubagentSummaryLine(); + line.setSubagentCounts({ total: 3, running: 1, idle: 1, inactive: 1 }); + line.setOpenable(true); + line.focused = true; + // Narrow enough that the colored counts truncate and inject a full reset. + const content = line.render(20)[1]; + expect(content).toContain("\x1b[0m"); + for (const segment of content.split("\x1b[0m").slice(1, -1)) { + expect(segment.startsWith("\x1b[4") || segment.startsWith("\x1b[10")).toBe(true); + } + }); + + it("never emits lines wider than the allocated width", () => { + const line = new SubagentSummaryLine(); + line.setSubagentCounts({ total: 3, running: 1, idle: 1, inactive: 1 }); + line.setOpenable(true); + for (const width of [120, 40, 24, 12, 6, 3, 2, 1]) { + for (const rendered of line.render(width).map(stripAnsi)) { + expect(rendered.length).toBeLessThanOrEqual(width); + } + } }); it("counts only direct children using running, idle, and inactive status projections", () => { @@ -80,7 +118,7 @@ describe("SubagentSummaryLine", () => { line.setSubagentCounts({ total: 1, running: 0, idle: 0, inactive: 1 }); line.setOpenable(false); - expect(stripAnsi(line.render(100).join("\n"))).toContain("1 subagent: 0 running · 0 idle · 1 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 0 running ◐ 0 idle ○ 1 inactive"); expect(line.isSelectable()).toBe(false); line.handleInput("\r"); expect(onOpen).not.toHaveBeenCalled(); @@ -107,10 +145,10 @@ describe("SubagentSummaryLine", () => { ) => void; update.call(mode, child("worker", "running")); - expect(stripAnsi(line.render(100).join("\n"))).toContain("1 running · 0 idle · 0 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 1 running ◐ 0 idle ○ 0 inactive"); update.call(mode, child("worker", "done", { activeSessionId: "active-worker" })); - expect(stripAnsi(line.render(100).join("\n"))).toContain("0 running · 1 idle · 0 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 0 running ◐ 1 idle ○ 0 inactive"); }); it("counts a retained completed child as running while a follow-up turn is active", () => { @@ -133,13 +171,13 @@ describe("SubagentSummaryLine", () => { ) => void; update.call(mode, child("worker", "done", { activeSessionId: "resident-worker" })); - expect(stripAnsi(line.render(100).join("\n"))).toContain("0 running · 1 idle · 0 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 0 running ◐ 1 idle ○ 0 inactive"); update.call(mode, child("worker", "done", { activeSessionId: "resident-worker", activity: { kind: "waiting" } })); - expect(stripAnsi(line.render(100).join("\n"))).toContain("1 running · 0 idle · 0 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 1 running ◐ 0 idle ○ 0 inactive"); update.call(mode, child("worker", "done", { activeSessionId: "resident-worker" })); - expect(stripAnsi(line.render(100).join("\n"))).toContain("0 running · 1 idle · 0 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 0 running ◐ 1 idle ○ 0 inactive"); }); it("refreshes counts when startup seeding follows an early live child update", () => { @@ -171,7 +209,7 @@ describe("SubagentSummaryLine", () => { Reflect.set(mode, "rlmNodeId", "me"); seed.call(mode, [worker]); - expect(stripAnsi(line.render(100).join("\n"))).toContain("1 subagent:"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("╭─ agents ─"); }); it("clears a resident session id when a terminal update reports an evicted child", () => { @@ -198,7 +236,7 @@ describe("SubagentSummaryLine", () => { update.call(mode, child("worker", "running")); update.call(mode, child("worker", "done")); - expect(stripAnsi(line.render(100).join("\n"))).toContain("0 running · 0 idle · 1 inactive"); + expect(stripAnsi(line.render(100).join("\n"))).toContain("● 0 running ◐ 0 idle ○ 1 inactive"); }); it("turns a selection into the scoped agents-view run result", async () => { -- 2.54.0