diff --git a/docs/mods.md b/docs/mods.md index 5b24f35..a146755 100644 --- a/docs/mods.md +++ b/docs/mods.md @@ -169,10 +169,13 @@ prompt. The dock draws an `✕` in its top-right corner. Clicking it closes the pane and fires no hook at all: no `ui.press`, no render, nothing. A module that tracks -open state in a flag will desync there, and the next toggle goes the wrong way. +open state in a flag will desync there. `$.ui.open` on an already open pane is a no-op, no error and no second dock, so -the safe shape for a reopen affordance is a button that only ever opens. +the agents pane keeps a small internal flag for the transitions it owns. The +`/band` command and the button above the prompt both toggle the pane. Closing +with the native `✕` remains an engine limitation: the next toggle can consume +the stale close state before the pane opens again. ### Clicking a button from a test diff --git a/plugin/hooks/pane-toggle.ts b/plugin/hooks/pane-toggle.ts new file mode 100644 index 0000000..ea64f89 --- /dev/null +++ b/plugin/hooks/pane-toggle.ts @@ -0,0 +1,13 @@ +export type PaneToggleUi = { + open: () => Promise; + close: () => Promise; +}; + +export async function togglePane(isOpen: boolean, ui: PaneToggleUi): Promise { + if (isOpen) { + await ui.close(); + return false; + } + await ui.open(); + return true; +} diff --git a/plugin/hooks/register.tsx b/plugin/hooks/register.tsx index 885c07a..7f176d9 100644 --- a/plugin/hooks/register.tsx +++ b/plugin/hooks/register.tsx @@ -7,6 +7,7 @@ import type { Register } from "claude-code"; import { formatPane, paneButtonLabel, selectPane } from "../mods/agents/pane.js"; import { parseRows } from "../mods/agents/parse.js"; import type { PaneSnapshot } from "../mods/agents/types.js"; +import { togglePane } from "./pane-toggle.js"; // Stable pane id: 1 to 64 letters, digits, "_" or "-". open carries it into // e.requestId on the render event, which is how this module tells its own pane @@ -42,6 +43,12 @@ type Engine$ = { }; }; +const toggleAgentsPane = async ($: Engine$, isOpen: boolean): Promise => + togglePane(isOpen, { + open: () => $.ui.open({ id: PANE_ID, side: "right" }), + close: () => $.ui.close({ id: PANE_ID }), + }); + // Passing $ into a helper is allowed, verified. What the engine refuses is // pulling a namespace off it: `const P = $.process` fails to load the module. // So refresh takes $ as a parameter and the state register owns travels beside @@ -143,13 +150,7 @@ export const register: Register = (on) => { // is the one call here not yet verified in a PTY session, paneOpen stays // true and the next /band retries the close, instead of the flag and the // pane desyncing for the rest of the session. - if (paneOpen) { - await $.ui.close({ id: PANE_ID }); - paneOpen = false; - } else { - await $.ui.open({ id: PANE_ID, side: "right" }); - paneOpen = true; - } + paneOpen = await toggleAgentsPane($, paneOpen); await $.ui.invalidate("ui.render"); return { text: paneOpen ? "agents pane open" : "agents pane closed" }; }); @@ -175,14 +176,9 @@ export const register: Register = (on) => { // A $ captured from a past render does work, verified, but it outlives the // event it came from and nothing promises how long. if ((e as { element?: string }).element === BUTTON_KEY) { - await $.ui.open({ id: PANE_ID, side: "right" }); - // Opening an already open pane is a no-op, verified live, so the button - // never has to know the truth. Re-syncing the flag is the point: the - // pane's own close box closes it without firing a single hook, and after - // that one /band would toggle the wrong way. - paneOpen = true; + paneOpen = await toggleAgentsPane($, paneOpen); await $.ui.invalidate("ui.render"); - void refresh($, state); + if (paneOpen) void refresh($, state); } return await next(e); }); @@ -195,10 +191,9 @@ export const register: Register = (on) => { // wrong shape". on("ui.render", async ($, e, next) => { if (e.surface !== "terminal") return await next(e); - // The way back in. The pane can be closed from its own close box, which - // fires no hook, so without a visible affordance the only recovery is - // knowing that /band exists. One button above the prompt, only when there - // is a run behind it. + // The pane can be closed from its own close box, which fires no hook. + // Keep one button above the prompt as the visible toggle for the state + // transitions this module owns. if (e.component === "AbovePrompt") { if (!state.hasRun) return await next(e); const { Box, Button } = await $.ui.resolve(e, "Box", "Button"); diff --git a/plugin/mods/agents/pane.ts b/plugin/mods/agents/pane.ts index dfe6b92..4fd827d 100644 --- a/plugin/mods/agents/pane.ts +++ b/plugin/mods/agents/pane.ts @@ -60,11 +60,14 @@ const STATUS_WORD = vocab({ stopped: "Paused", failed: "Failed", }); +// Brand marks from the JetBrains Nerd Font and Omarchy icon font. The pane is +// a terminal surface, so these keep the identity of each harness without +// spending the width of its full name. const HARNESS_GLYPH = vocab({ - claude: "▲", - opencode: "■", - codex: "◆", - omp: "⬟", + claude: "\uEC82", + opencode: "\uE902", + codex: "\uEC81", + omp: "\uE903", }); const HARNESS_LABEL = vocab({ claude: "Claude", @@ -184,6 +187,29 @@ function statusWord(status: unknown): string { return STATUS_WORD[text(status)] ?? cell(status); } +function footerLegend(columns: number): [string, string] { + const fullStatus = " ● working ◉ waiting ○ finished"; + const fullHarness = + " " + + glyph("claude") + + " Claude " + + glyph("opencode") + + " OpenCode " + + glyph("codex") + + " Codex " + + glyph("omp") + + " OMP"; + if (fullStatus.length <= columns - 2 && fullHarness.length <= columns - 2) { + return [fullStatus, 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]; +} + function age(iso: unknown, now: number): string { if (typeof iso !== "string") return ""; const then = Date.parse(iso); @@ -356,11 +382,12 @@ function draw(snapshot: PaneSnapshot, columns: number, limit: number | undefined frameSep(), frameRow(""), ]; + const [statusLegend, harnessLegend] = footerLegend(W); const tail = [ frameRow(""), frameSep(), - frameRow(" ● working ◉ waiting ○ finished"), - frameRow(" ▲ Claude ■ OpenCode ◆ Codex ⬟ OMP"), + frameRow(statusLegend), + frameRow(harnessLegend), frameBot(), ]; const fixed = head.length + tail.length; diff --git a/tests/hooks/pane-toggle.test.ts b/tests/hooks/pane-toggle.test.ts new file mode 100644 index 0000000..20cd63e --- /dev/null +++ b/tests/hooks/pane-toggle.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { togglePane } from "../../plugin/hooks/pane-toggle.js"; + +describe("togglePane", () => { + it("opens a closed pane", async () => { + const calls: string[] = []; + + const open = await togglePane(false, { + open: async () => calls.push("open"), + close: async () => calls.push("close"), + }); + + expect(open).toBe(true); + expect(calls).toEqual(["open"]); + }); + + it("closes an open pane", async () => { + const calls: string[] = []; + + const open = await togglePane(true, { + open: async () => calls.push("open"), + close: async () => calls.push("close"), + }); + + expect(open).toBe(false); + expect(calls).toEqual(["close"]); + }); +}); diff --git a/tests/mods-agents/pane.test.ts b/tests/mods-agents/pane.test.ts index 56708c2..9285f21 100644 --- a/tests/mods-agents/pane.test.ts +++ b/tests/mods-agents/pane.test.ts @@ -205,7 +205,7 @@ describe("formatPane", () => { expect(lines[3]).toMatch(/^├─/); expect(lines[lines.length - 1]).toMatch(/^└─/); expect(lines[lines.length - 3]).toContain("● working"); - expect(lines[lines.length - 2]).toContain("▲ Claude"); + expect(lines[lines.length - 2]).toContain("\uEC82 Claude"); expect(lines[lines.length - 4]).toMatch(/^├─/); } }); @@ -226,9 +226,20 @@ describe("formatPane", () => { expect(joined).not.toContain("Completed"); expect(joined).not.toContain("Paused"); expect(joined).toContain("HISTORY · 3 finished"); - expect(joined).toContain("▲ k4 k-done"); - expect(joined).toContain("⬟ k5 k-stop"); - expect(joined).toContain("■ k6 k-odd"); + expect(joined).toContain("\uEC82 k4 k-done"); + expect(joined).toContain("\uE903 k5 k-stop"); + expect(joined).toContain("\uE902 k6 k-odd"); + }); + + it("uses brand marks when the legend has room and hides labels when it does not", () => { + const wide = formatPane(snapshot(), 40); + 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"); }); it("drops history before live cards when the pane is short (rule 5)", () => { @@ -499,7 +510,7 @@ describe("formatPane", () => { expect(lines).toHaveLength(21); expect(lines[20]).toBe("└" + "─".repeat(87) + "┘"); expect(lines[18]).toContain("● working"); - expect(lines[19]).toContain("▲ Claude"); + expect(lines[19]).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);