Merge remote-tracking branch 'origin/main' into sostenuto-bassoon

This commit is contained in:
Miguel Palhas
2026-08-31 15:04:28 +01:00
13 changed files with 675 additions and 131 deletions
+60 -11
View File
@@ -47,15 +47,33 @@ function rowFor(el: HTMLElement, id: string): HTMLElement | null {
}
describe("blocked-work indicator (#83)", () => {
it("marks the row of a blocked session with the shared phrasing", () => {
it("marks the row of a blocked session in diff notation", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-000000000001",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toContain("⚠");
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
expect(badge?.textContent).toBe("+124-18↑2");
// The spelled-out breakdown survives in the tooltip.
expect(badge?.getAttribute("title")).toContain(
"3 uncommitted files, 2 unpushed commits",
);
});
it("falls back to the file count when tracked changes carry no lines", () => {
const blocked = session({
id: "aaaaaaaa-0000-0000-0000-00000000001a",
blocked_work: { uncommitted_files: 2, untracked_files: 1 },
});
const el = renderSidebar(storeWith([blocked])).el;
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
expect(badge?.textContent).toBe("~2?1");
});
it("does not mark clear or unprobed sessions", () => {
@@ -161,7 +179,7 @@ describe("session cards (#107)", () => {
expect(line2).not.toBeNull();
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
expect(line2?.querySelector(".activity-duration")).toBeNull();
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
expect(line2?.querySelector(".leaf-cmd")).not.toBeNull();
});
it("shows dot + floored duration once the state has been held past 30s", () => {
@@ -177,14 +195,40 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
});
it("renders the command when the session name equals it", () => {
it("renders the command as its tool icon when the tool is known", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001b",
command: "claude",
metadata: { name: "claude" },
});
const el = renderSidebar(storeWith([s])).el;
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.getAttribute("title")).toBe("claude");
// The icon said it all: no text left over.
expect(cmd?.querySelector(".leaf-cmd-rest")).toBeNull();
});
it("keeps the command's arguments as text beside the icon", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001c",
command: "claude --resume",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")?.getAttribute("data-tool")).toBe("claude");
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("--resume");
});
it("keeps an unknown command as plain text", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-00000000001d",
command: "hx src/main.rs",
});
const el = renderSidebar(storeWith([s])).el;
const cmd = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2 .leaf-cmd");
expect(cmd?.querySelector(".tool-icon")).toBeNull();
expect(cmd?.querySelector(".leaf-cmd-rest")?.textContent).toBe("hx src/main.rs");
});
it("maps each activity state to its contract slug", () => {
@@ -222,15 +266,20 @@ describe("session cards (#107)", () => {
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
});
it("moves blocked work onto line 2 in the shared phrasing", () => {
it("moves blocked work onto line 2 as diff notation", () => {
const s = session({
id: "aaaaaaaa-0000-0000-0000-000000000017",
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
blocked_work: {
uncommitted_files: 3,
unpushed_commits: 2,
insertions: 124,
deletions: 18,
},
});
const el = renderSidebar(storeWith([s])).el;
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
expect(line2?.textContent).toContain("⚠");
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
expect(line2?.textContent).toContain("+124");
expect(line2?.textContent).toContain("-18");
// Line 1 carries no badges.
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
});
+28 -14
View File
@@ -3,9 +3,10 @@ import { getDaemonInfo, killSession, renameSession } from "./api.js";
import { openContextMenu } from "./contextMenu.js";
import { isTypingTarget, toggleHelp } from "./help.js";
import { CatalogStore } from "./events.js";
import type { Session } from "./types.js";
import type { InTheWay, Session } from "./types.js";
import {
abbreviateFolderName,
blockerStats,
customSessionName,
formatInTheWay,
MAX_SESSION_NAME_LEN,
@@ -13,6 +14,7 @@ import {
sessionBlockers,
sessionName,
} from "./types.js";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
import {
activityColorSlug,
activityDurationLabel,
@@ -73,15 +75,33 @@ function metaLine(s: Session, now: number): string {
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
blockers
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
: "",
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
/** docs/SIDEBAR.md: blocked work renders as diff notation — `+124 -18 ?3 ↑2`
* — with the spelled-out breakdown kept in the tooltip. */
function blockedFragment(blockers: InTheWay): string {
const stats = blockerStats(blockers)
.map((st) => `<span class="blocked-stat" data-stat="${st.kind}">${escapeHtml(st.text)}</span>`)
.join("");
const title = `Kill will be refused unless forced — ${formatInTheWay(blockers)}`;
return `<span class="card-flag leaf-blocked" title="${escapeHtml(title)}">${stats}</span>`;
}
/** docs/SIDEBAR.md: the command's leading program becomes its tool icon when
* we have a mark for it, and only the remaining words stay as text. The full
* command line stays in the tooltip either way. */
function commandFragment(command: string): string {
const { tool, rest } = splitCommand(command);
const icon = tool ? toolIconSvg(tool) : null;
const text = icon ? rest : command;
return `<span class="card-flag leaf-cmd" title="${escapeHtml(command)}">${icon ?? ""}${text ? `<span class="leaf-cmd-rest">${escapeHtml(text)}</span>` : ""}</span>`;
}
function renderNode(node: TreeNode, depth: number): HTMLElement {
if (node.kind === "folder") {
const wrap = document.createElement("div");
@@ -341,12 +361,10 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
}
/** Two-tier sidebar highlight state (issue #33): which sessions are open in some
* pane (quiet), which one is focused (loud), and a stable per-session colour so
* a row maps to its pane. */
* pane (quiet), and which one is focused (loud). */
export interface SidebarHighlights {
open: Set<string>;
focused: string | null;
colors: Map<string, string>;
}
/** Live handle to the persistent sidebar (built once, survives navigation). */
@@ -356,7 +374,7 @@ export interface SidebarHandle {
/** Highlight the row for `id` (or clear it with `null`) as the focused session.
* Back-compat shim over {@link setHighlights}. */
setActive(id: string | null): void;
/** Apply the full open/focused/colour highlight state. */
/** Apply the full open/focused highlight state. */
setHighlights(h: SidebarHighlights): void;
/** Hand the keyboard to the session list, highlighting a row to walk from. */
focusList(): void;
@@ -469,7 +487,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
refresh();
});
let highlights: SidebarHighlights = { open: new Set(), focused: null, colors: new Map() };
let highlights: SidebarHighlights = { open: new Set(), focused: null };
const applyHighlights = () => {
for (const n of treeHost.querySelectorAll<HTMLElement>(".tree-leaf")) {
const id = n.dataset.sessionId;
@@ -478,9 +496,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
const open = highlights.open.has(id);
n.classList.toggle("is-active", focused);
n.classList.toggle("is-open", open && !focused);
const color = highlights.colors.get(id);
if (color && (open || focused)) n.style.setProperty("--mv-color", color);
else n.style.removeProperty("--mv-color");
}
};
@@ -605,7 +620,6 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
highlights = {
open: id ? new Set([id]) : new Set(),
focused: id,
colors: new Map(),
};
applyHighlights();
},
+15 -3
View File
@@ -14,6 +14,7 @@ function sess(
status: "Running" | "Dead",
cwd: string,
name?: string,
blocked_work?: Session["blocked_work"],
): Session {
const seed = parseInt(id.slice(-1), 16) || 0;
return {
@@ -30,6 +31,7 @@ function sess(
last_activity_at: new Date(Date.now() - seed * 11_000).toISOString(),
// The session tree nests by current_cwd; default it to the spawn dir.
current_cwd: cwd,
blocked_work,
};
}
@@ -109,16 +111,26 @@ function stubSpawnApi(): void {
export function seedMock(store: CatalogStore): void {
const sessions: Session[] = [
// ~/git/maestro/web/* — deep dir chain; "~/git/maestro" collapses to one node.
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign"),
sess("a1b2c3d4-0000-0000-0000-000000000001", "claude", "Running", "/home/dev/git/maestro/web", "redesign", {
uncommitted_files: 4,
untracked_files: 2,
unpushed_commits: 1,
insertions: 312,
deletions: 47,
}),
sess("a1b2c3d4-0000-0000-0000-000000000002", "vite dev", "Running", "/home/dev/git/maestro/web", "dev-server"),
sess("a1b2c3d4-0000-0000-0000-000000000003", "bash", "Dead", "/home/dev/git/maestro/web", "scratch"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "claude", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes"),
sess("a1b2c3d4-0000-0000-0000-000000000004", "codex --resume", "Running", "/home/dev/git/maestro/web/tests", "e2e-fixes", {
uncommitted_files: 1,
}),
// ~/git/maestro/crates/* — sibling subtree under the same repo.
sess("a1b2c3d4-0000-0000-0000-000000000005", "claude", "Running", "/home/dev/git/maestro/crates/daemon", "daemon-work"),
sess("a1b2c3d4-0000-0000-0000-000000000006", "cargo watch -x test", "Running", "/home/dev/git/maestro/crates/daemon", "test-loop"),
sess("a1b2c3d4-0000-0000-0000-000000000007", "cargo run -- daemon", "Running", "/home/dev/git/maestro/crates/daemon", "daemon"),
// ~/work/acme/* — a separate top-level dir on the same host.
sess("a1b2c3d4-0000-0000-0000-000000000008", "claude", "Running", "/home/dev/work/acme/api", "billing-api"),
sess("a1b2c3d4-0000-0000-0000-000000000008", "gemini", "Running", "/home/dev/work/acme/api", "billing-api", {
untracked_files: 3,
}),
sess("a1b2c3d4-0000-0000-0000-000000000009", "pnpm dev", "Dead", "/home/dev/work/acme/web", "dashboard"),
// ~/git/dotfiles — single session, fully collapsed chain.
sess("a1b2c3d4-0000-0000-0000-000000000010", "opencode", "Running", "/home/dev/git/dotfiles", "dotfiles"),
+3 -15
View File
@@ -22,7 +22,6 @@ import {
removeLeaf,
splitLeaf,
} from "./layout.js";
import { sessionColor } from "./sessioncolor.js";
import type { AttachInfo } from "./attach.js";
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
@@ -97,12 +96,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
// ---- highlight helpers ----
function colorMap(): Map<string, string> {
const m = new Map<string, string>();
for (const id of openSessions(layout)) m.set(id, sessionColor(id));
return m;
}
function focusedLeafSession(): string | null {
const tab = activeTab(layout);
const fl = tab.focusedLeafId ? findLeaf(tab.root, tab.focusedLeafId) : null;
@@ -120,7 +113,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
sidebar.setHighlights({
open: openSessions(layout),
focused: focusedLeafSession(),
colors: colorMap(),
});
}
@@ -142,10 +134,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
const pane = document.createElement("div");
pane.className = "mv-pane";
pane.dataset.leafId = leaf.id;
if (leaf.sessionId) {
pane.dataset.sessionId = leaf.sessionId;
pane.style.setProperty("--mv-color", sessionColor(leaf.sessionId));
}
if (leaf.sessionId) pane.dataset.sessionId = leaf.sessionId;
const host = document.createElement("div");
host.className = "mv-pane-host";
@@ -482,7 +471,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pool.setVisible([]);
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: null });
persist();
return;
}
@@ -523,7 +512,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pill.type = "button";
pill.className = "mv-pill" + (l.id === focused.id ? " is-active" : "");
pill.textContent = l.sessionId ? (infoFor(l.sessionId)?.name ?? l.sessionId.slice(0, 8)) : "empty";
if (l.sessionId) pill.style.setProperty("--mv-color", sessionColor(l.sessionId));
pill.addEventListener("click", () => {
tab.focusedLeafId = l.id;
render();
@@ -543,7 +531,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
);
pool.setFocus(focused.sessionId ?? null);
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null, colors: colorMap() });
sidebar.setHighlights({ open: openSessions(layout), focused: focused.sessionId ?? null });
}
function closeSession(sessionId: string): void {
-23
View File
@@ -1,23 +0,0 @@
// Stable per-session accent colour, used to map a sidebar row to the pane
// showing that session (issue #33). Hash the session id into a fixed OKLCH
// palette tuned to the maestro tokens; collisions are tolerated when there are
// more open sessions than hues — the colour is a hint, not an identity.
import type { Uuid } from "./types.js";
const PALETTE = [
"oklch(0.80 0.13 152)", // green (the house accent)
"oklch(0.80 0.12 90)", // amber
"oklch(0.76 0.13 30)", // coral
"oklch(0.78 0.11 210)", // teal
"oklch(0.74 0.13 265)", // blue
"oklch(0.76 0.14 320)", // magenta
"oklch(0.80 0.11 60)", // gold
"oklch(0.78 0.12 185)", // cyan
];
export function sessionColor(id: Uuid): string {
let h = 0;
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
return PALETTE[h % PALETTE.length];
}
+44 -41
View File
@@ -1059,6 +1059,46 @@ input.leaf-rename:focus {
truncation target when even the complete trailing set cannot fit. */
.leaf-cmd {
flex: 0 1 auto;
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* The tool's mark stands in for the program the command line starts with
(docs/SIDEBAR.md). It keeps its own colours where the tool has brand
colours, and inherits the muted fragment colour where it does not. */
.tool-icon {
flex: 0 0 auto;
opacity: 0.9;
}
.leaf-cmd-rest {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Blocked work as diff notation: `+124 -18 ?3 ↑2` (docs/SIDEBAR.md). The
spelled-out breakdown is the tooltip; only the additions and deletions take
colour, so the ⚠-less fragment still reads as a diff and not as an alarm. */
.leaf-blocked {
display: inline-flex;
align-items: center;
gap: 4px;
font-variant-numeric: tabular-nums;
}
.blocked-stat[data-stat="add"] {
color: var(--live);
}
.blocked-stat[data-stat="del"] {
color: var(--danger);
}
.blocked-stat[data-stat="files"],
.blocked-stat[data-stat="untracked"],
.blocked-stat[data-stat="unpushed"] {
color: var(--faint);
}
/* Actions live in line 1's flow at its right edge — never overlaid on the
@@ -2313,20 +2353,10 @@ input.leaf-rename:focus {
min-width: 0;
min-height: 0;
}
/* per-session colour strip — maps a pane to its sidebar row */
.mv-pane::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 2px;
background: var(--mv-color, transparent);
z-index: 3;
pointer-events: none;
}
/* focused pane: accent ring + glow, full strength */
/* focused pane: accent ring, full strength */
.mv-pane.is-focused {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent), 0 0 22px -8px var(--accent-ring);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* unfocused panes recede so the eye finds the live one */
.mv-pane:not(.is-focused) {
@@ -2429,31 +2459,12 @@ input.leaf-rename:focus {
border-radius: var(--r-1);
}
/* sidebar two-tier highlight + colour dot (issue #33).
/* sidebar two-tier highlight (issue #33).
.is-active = focused pane (loud, from existing rule); .is-open = open
elsewhere (quiet). The dot uses the session's colour so a row maps to a pane. */
elsewhere (quiet). */
.tree-leaf.is-open {
background: color-mix(in oklab, var(--accent) 6%, transparent);
}
.tree-leaf.is-open::after,
.tree-leaf.is-active::after {
content: "";
position: absolute;
right: 9px;
top: 50%;
width: 7px;
height: 7px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--mv-color, var(--accent));
pointer-events: none;
}
.tree-leaf.is-open::after {
opacity: 0.7;
}
.tree-leaf.is-active::after {
box-shadow: 0 0 8px var(--mv-color, var(--accent-ring));
}
/* =========================================================================
multi-view mobile (#35): below 860px the split tree projects to a pill strip
@@ -2498,14 +2509,6 @@ input.leaf-rename:focus {
cursor: pointer;
border: 1px solid transparent;
}
.mv-pill::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mv-color, var(--faint));
flex: none;
}
.mv-pill.is-active {
background: var(--accent-tint);
color: var(--accent-strong);
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { splitCommand, toolIconSvg } from "./toolIcon.js";
describe("tool icons on the command fragment (docs/SIDEBAR.md)", () => {
it("recognises the agents and shells sessions are spawned with", () => {
const cases: Array<[string, string]> = [
["claude", "claude"],
["codex", "codex"],
["opencode", "opencode"],
["gemini", "gemini"],
["aider", "aider"],
["cursor-agent", "cursor"],
["pi", "pi"],
["zsh", "shell"],
["bash", "shell"],
["fish", "shell"],
];
for (const [command, tool] of cases) {
expect(splitCommand(command).tool, command).toBe(tool);
}
});
it("keeps everything after the program as text", () => {
expect(splitCommand("claude --resume -p hi")).toEqual({
tool: "claude",
rest: "--resume -p hi",
});
expect(splitCommand(" zsh -l ")).toEqual({ tool: "shell", rest: "-l" });
});
it("looks through paths, env assignments and wrappers", () => {
expect(splitCommand("/usr/local/bin/claude").tool).toBe("claude");
expect(splitCommand("FOO=1 BAR=2 claude").tool).toBe("claude");
expect(splitCommand("npx opencode").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").tool).toBe("opencode");
expect(splitCommand("pnpm dlx opencode run").rest).toBe("run");
});
it("leaves an unrecognised program as text, mark-less", () => {
expect(splitCommand("hx src/main.rs")).toEqual({
tool: null,
rest: "hx src/main.rs",
});
// A wrapper with nothing after it is not a tool either.
expect(splitCommand("sudo").tool).toBeNull();
expect(splitCommand("").tool).toBeNull();
});
it("gives every recognised tool a labelled mark", () => {
for (const tool of ["claude", "codex", "opencode", "gemini", "aider", "cursor", "pi", "shell"]) {
const svg = toolIconSvg(tool);
expect(svg, tool).toContain("<svg");
expect(svg, tool).toContain(`data-tool="${tool}"`);
expect(svg, tool).toContain("aria-label=");
}
expect(toolIconSvg("nonesuch")).toBeNull();
});
});
+141
View File
@@ -0,0 +1,141 @@
/** Tool icons for the sidebar's command fragment (docs/SIDEBAR.md).
*
* A session's command line starts with the program that runs it, and that
* program is nearly always one of a handful of agents or a shell. Rendering it
* as its own mark buys back most of the fragment's width, which the sidebar
* spends on the rest of the command line instead.
*
* Recognition is deliberately narrow: an unrecognised program keeps its text,
* so the fragment never renders a mark that stands for the wrong tool.
*/
/** Command wrappers that run another program: the tool is what follows. */
const WRAPPERS = new Set(["sudo", "env", "npx", "bunx", "uvx", "pnpm", "yarn", "command"]);
/** Program name (already basenamed and lowercased) → icon id. */
const BY_PROGRAM: Record<string, string> = {
claude: "claude",
"claude-code": "claude",
codex: "codex",
opencode: "opencode",
gemini: "gemini",
aider: "aider",
cursor: "cursor",
"cursor-agent": "cursor",
pi: "pi",
bash: "shell",
zsh: "shell",
sh: "shell",
fish: "shell",
dash: "shell",
ksh: "shell",
nu: "shell",
xonsh: "shell",
};
/** Human name behind an icon id, for the tooltip and the aria label. */
export const TOOL_LABELS: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "opencode",
gemini: "Gemini CLI",
aider: "Aider",
cursor: "Cursor",
pi: "pi",
shell: "Shell",
};
export interface CommandParts {
/** Icon id, or `null` when the program is not one we have a mark for. */
tool: string | null;
/** What is left of the command line once the icon stands for the program.
* Empty when the icon says everything the command line said. */
rest: string;
}
/** Split a command line into the tool its icon stands for and the remainder.
*
* `claude --resume` → `{tool: "claude", rest: "--resume"}`; an unrecognised
* `hx src/` keeps the whole line as `rest` with no tool. */
export function splitCommand(command: string): CommandParts {
const words = command.trim().split(/\s+/).filter(Boolean);
let i = 0;
// Leading `FOO=bar` assignments and wrappers stand in front of the real
// program; `pnpm dlx x` / `yarn dlx x` need one more word skipped.
while (i < words.length) {
const word = words[i];
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
i += 1;
continue;
}
const program = basename(word);
if (WRAPPERS.has(program)) {
i += 1;
if ((program === "pnpm" || program === "yarn") && words[i] === "dlx") i += 1;
continue;
}
break;
}
if (i >= words.length) return { tool: null, rest: command.trim() };
const tool = BY_PROGRAM[basename(words[i])] ?? null;
if (!tool) return { tool: null, rest: command.trim() };
return { tool, rest: words.slice(i + 1).join(" ") };
}
function basename(word: string): string {
const cut = word.lastIndexOf("/");
return (cut >= 0 ? word.slice(cut + 1) : word).toLowerCase();
}
/** Anthropic's burst: tapered rays around a centre, long ones on the axes. */
function burst(): string {
const rays = Array.from({ length: 8 }, (_, k) => {
const long = k % 2 === 0;
const tip = long ? 1.1 : 3.4;
const waist = long ? 6.3 : 6.6;
const half = long ? 1.05 : 0.85;
return `<path d="M8 ${tip} L${8 + half} ${waist} L8 8 L${8 - half} ${waist} Z" transform="rotate(${k * 45} 8 8)"/>`;
}).join("");
return `<g fill="#d97757">${rays}</g>`;
}
const SVGS: Record<string, string> = {
claude: burst(),
// A hexafoil knot in the shape of OpenAI's mark. The petals stay thin and
// wide apart: three fatter loops merge into a blob at 13px.
codex: `<g fill="none" stroke="currentColor" stroke-width="1.05">
<ellipse cx="8" cy="8" rx="2.5" ry="6.7"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(60 8 8)"/>
<ellipse cx="8" cy="8" rx="2.5" ry="6.7" transform="rotate(120 8 8)"/>
</g>`,
gemini: `<path fill="#4285f4" d="M8 0.8C8.6 4.6 11.4 7.4 15.2 8C11.4 8.6 8.6 11.4 8 15.2C7.4 11.4 4.6 8.6 0.8 8C4.6 7.4 7.4 4.6 8 0.8Z"/>`,
// An isometric cube, the shape Cursor's mark is built from.
cursor: `<g fill="currentColor">
<path d="M8 1 L14.5 4.75 L8 8.5 L1.5 4.75 Z" opacity="0.95"/>
<path d="M14.5 4.75 L14.5 12.25 L8 16 L8 8.5 Z" opacity="0.5"/>
<path d="M1.5 4.75 L8 8.5 L8 16 L1.5 12.25 Z" opacity="0.75"/>
</g>`,
shell: `<g fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M2.6 4 L6.4 8 L2.6 12"/>
<path d="M8.4 12.6 H13.4"/>
</g>`,
opencode: monogram("oc"),
aider: monogram("ai"),
pi: monogram("π"),
};
/** Tools whose own mark is a wordmark get their initials instead — inventing
* a logo for them would be worse than saying which letters they start with.
* No chip around the letters: a border this small eats the height the letters
* need to stay apart. */
function monogram(text: string): string {
return `<text x="8" y="8.6" fill="currentColor" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="11" font-weight="700" letter-spacing="-0.6" text-anchor="middle" dominant-baseline="central">${text}</text>`;
}
/** The `<svg>` for an icon id, or `null` when the id has no mark. */
export function toolIconSvg(tool: string): string | null {
const body = SVGS[tool];
if (!body) return null;
const label = TOOL_LABELS[tool] ?? tool;
return `<svg class="tool-icon" data-tool="${tool}" viewBox="0 0 16 16" width="13" height="13" role="img" aria-label="${label}"><title>${label}</title>${body}</svg>`;
}
+36
View File
@@ -164,6 +164,42 @@ export interface InTheWay {
uncommitted_files?: number;
untracked_files?: number;
unpushed_commits?: number;
/** Lines added across the tracked changes, staged and unstaged together. */
insertions?: number;
/** Lines removed across the same tracked changes. */
deletions?: number;
}
/** One fragment of the sidebar's compact blocked-work stat (docs/SIDEBAR.md).
* `kind` picks the colour; `text` is what the client prints. */
export interface BlockerStat {
kind: "add" | "del" | "files" | "untracked" | "unpushed";
text: string;
}
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`.
*
* The line counts cover tracked changes only, so a tree whose only tracked
* change carries no line delta — a mode change, a rename — falls back to the
* file count (`~2`) rather than claiming `+0 -0`. Both clients render exactly
* these fragments; the spelled-out {@link formatInTheWay} survives in the
* tooltip and in the kill refusal. */
export function blockerStats(w: InTheWay): BlockerStat[] {
const stats: BlockerStat[] = [];
const added = w.insertions ?? 0;
const removed = w.deletions ?? 0;
const changed = w.uncommitted_files ?? 0;
if (added > 0 || removed > 0) {
if (added > 0) stats.push({ kind: "add", text: `+${added}` });
if (removed > 0) stats.push({ kind: "del", text: `-${removed}` });
} else if (changed > 0) {
stats.push({ kind: "files", text: `~${changed}` });
}
if ((w.untracked_files ?? 0) > 0)
stats.push({ kind: "untracked", text: `?${w.untracked_files}` });
if ((w.unpushed_commits ?? 0) > 0)
stats.push({ kind: "unpushed", text: `\u2191${w.unpushed_commits}` });
return stats;
}
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by