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
7 changes: 5 additions & 2 deletions docs/mods.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions plugin/hooks/pane-toggle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export type PaneToggleUi = {
open: () => Promise<unknown>;
close: () => Promise<unknown>;
};

export async function togglePane(isOpen: boolean, ui: PaneToggleUi): Promise<boolean> {
if (isOpen) {
await ui.close();
return false;
}
await ui.open();
return true;
}
31 changes: 13 additions & 18 deletions plugin/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,6 +43,12 @@ type Engine$ = {
};
};

const toggleAgentsPane = async ($: Engine$, isOpen: boolean): Promise<boolean> =>
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
Expand Down Expand Up @@ -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" };
});
Expand All @@ -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);
});
Expand All @@ -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");
Expand Down
39 changes: 33 additions & 6 deletions plugin/mods/agents/pane.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions tests/hooks/pane-toggle.test.ts
Original file line number Diff line number Diff line change
@@ -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"]);
});
});
21 changes: 16 additions & 5 deletions tests/mods-agents/pane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^├─/);
}
});
Expand All @@ -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)", () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading