SLOPSHOPPER

usage-band

Two-line band above the prompt: model, git, context, limits with pace markers, and subagent cost

newpanebandcommandprocesstimer
★ 1v0.2.0no licenseupdated 2026-10-13petrubear/dotfiles/claude/mods/usage-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ ≈$0.00 · 0 tokens · 0:00 │ ┃ No subagents yet. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /agents-usage │ ⎿ usage-band: Subagents panel opened. │ │ Opus 5.5 app on feat/auth-refresh ctx ━━━━━━────── 49% │ 5h ━━━╾─────┃── 31% ↻ 59m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Opus 5.5 app on feat/auth-refresh ctx ━━━━━━────── 49% │ 5h ━━━╾─────┃── 31% ↻ 59m
Pane · Subagents
≈$0.00 · 0 tokens · 0:00 No subagents yet.
Source 9 files
hooks/register.tsx 236 lines
1import { atom, read, update } from "claude-code";
2import type { ElementTable, EngineInterface, Register, SessionRateLimit, Timer } from "claude-code";
3
4import type { AgentRun, Snapshot } from "../types";
5import { chipOf } from "./agents";
6import { desktopBand } from "./band.desktop";
7import { terminalBand } from "./band.terminal";
8import { readConfig } from "./config";
9import { desktopPane, terminalPane } from "./pane";
10import { costOf, windowOf } from "./prices";
11
12const snapshot = atom({ plugin: "usage-band", key: "snapshot" } as const, null);
13const agents = atom({ plugin: "usage-band", key: "agents" } as const, []);
14const now = atom({ plugin: "usage-band", key: "now" } as const, 0);
15
16const PANE = "usage-agents";
17// The engine refuses a built-in's name, and /usage and /agents are built-ins.
18const COMMAND = "agents-usage";
19
20// Re-registered on every session.start (a /clear or resume), the old one cancelled first.
21let refreshTimer: Timer | undefined;
22let agentTimer: Timer | undefined;
23
24const pickLimit = (limits: SessionRateLimit[], kind: string): SessionRateLimit | undefined => limits.find(l => l.kind === kind);
25
26async function refresh($: EngineInterface) {
27    const [usage, model, cwd, at] = await Promise.all([$.session.usage(), $.session.model(), $.session.cwd(), $.clock.now()]);
28
29    let branch: string | null = null;
30    let isDirty = false;
31    const head = await $.process.run(["git", "branch", "--show-current"], { cwd, timeoutMs: 5000 }).catch(() => null);
32    if (head && head.exitCode === 0 && head.stdout.trim() !== "") {
33        branch = head.stdout.trim();
34        const status = await $.process
35            .run(["git", "--no-optional-locks", "status", "--porcelain", "--untracked-files=no"], { cwd, timeoutMs: 5000 })
36            .catch(() => null);
37        isDirty = status !== null && status.stdout.trim() !== "";
38    }
39
40    const five = pickLimit(usage.rateLimits, "five_hour");
41    const seven = pickLimit(usage.rateLimits, "seven_day");
42    const next: Snapshot = {
43        at,
44        model,
45        dir: cwd.split("/").filter(Boolean).pop() ?? cwd,
46        branch,
47        isDirty,
48        contextPct: usage.context.percent ?? null,
49        fiveHourPct: five?.percentUsed ?? null,
50        sevenDayPct: seven?.percentUsed ?? null,
51        fiveHourResetsAt: five?.resetsAt ?? null,
52        sevenDayResetsAt: seven?.resetsAt ?? null,
53        costUsd: usage.cost?.usd ?? null
54    };
55    await update($, snapshot, () => next);
56}
57
58// Opens the agents pane, or closes it when it is up; true when it ends up open.
59async function togglePane($: EngineInterface): Promise<boolean> {
60    const isOpen = (await $.ui.panes()).some(p => p.id === PANE);
61    if (isOpen) {
62        await $.ui.close({ id: PANE });
63        return false;
64    }
65    await $.ui.open({ id: PANE, title: "Subagents" });
66    return true;
67}
68
69export const register: Register = (on, options) => {
70    on("session.start", async ($, e, next) => {
71        const result = await next(e);
72        await $.command
73            .register({
74                name: COMMAND,
75                description: "Show or hide the subagents panel: running and finished, with model, context, cost and time"
76            })
77            .catch(() => undefined);
78        await refresh($).catch(() => undefined);
79        // Keeps "resets in 2h 14m" right while the session is idle.
80        refreshTimer?.cancel();
81        refreshTimer = $.clock.every(60_000, () => {
82            void refresh($).catch(() => undefined);
83        });
84        // Ticks the running agents' clocks; quiet when nothing runs.
85        agentTimer?.cancel();
86        agentTimer = $.clock.every(1000, () => {
87            void (async () => {
88                const list = await read($, agents);
89                if (!list.some(a => a.status === "running")) return;
90                const at = await $.clock.now();
91                await update($, now, () => at);
92            })().catch(() => undefined);
93        });
94        return result;
95    });
96
97    on("agent.spawn", async ($, e, next) => {
98        const started = await next(e);
99        if (started.deny !== undefined) return started;
100        // Tracking must never block an agent from launching.
101        const at = await $.clock.now().catch(() => 0);
102        await update($, agents, list => {
103            const run: AgentRun = {
104                id: started.agentId ?? e.tool_use_id,
105                agentId: started.agentId,
106                type: e.subagentType,
107                description: e.description,
108                model: started.model,
109                status: "running",
110                startedAt: at,
111                contextTokens: 0,
112                contextMax: windowOf(started.model),
113                tokens: 0,
114                costUsd: 0,
115                steps: 0
116            };
117            return [...list.filter(a => a.id !== run.id), run].slice(-200);
118        }).catch(() => undefined);
119        await update($, now, () => at).catch(() => undefined);
120        return started;
121    }).catch(($, e, next) => next(e));
122
123    // Each model request of a subagent: live context, tokens and cost.
124    on("turn.step", async function* ($, e, next) {
125        const result = yield* next(e);
126        const agentId = e.agentId;
127        const usage = result.usage;
128        if (!agentId || !usage) return result;
129
130        const model = usage.model || e.model;
131        // Tracking must never break the agent's own turn.
132        await update($, agents, list =>
133            list.map(
134                (a): AgentRun =>
135                    a.agentId !== agentId
136                        ? a
137                        : {
138                              ...a,
139                              model,
140                              effort: typeof e.effort === "string" ? e.effort : a.effort,
141                              status: "running",
142                              endedAt: undefined,
143                              contextTokens: (usage.input_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0) + (usage.output_tokens || 0),
144                              contextMax: windowOf(model),
145                              tokens: a.tokens + (usage.input_tokens || 0) + (usage.output_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0),
146                              costUsd: a.costUsd + costOf(model, usage, true),
147                              steps: a.steps + 1
148                          }
149            )
150        ).catch(() => undefined);
151        return result;
152    });
153
154    on("turn.complete", async ($, e, next) => {
155        const agentId = e.agentId;
156        if (agentId) {
157            try {
158                const at = await $.clock.now();
159                await update($, agents, list =>
160                    list.map((a): AgentRun => {
161                        if (a.agentId !== agentId) return a;
162                        // A run whose steps went unseen still gets the turn's own sum.
163                        const fallback = a.steps === 0 && e.usage;
164                        return {
165                            ...a,
166                            status: e.reason === "answer" ? "done" : "failed",
167                            endedAt: at,
168                            ...(fallback && e.usage
169                                ? {
170                                      model: e.usage.model || a.model,
171                                      tokens: e.usage.input_tokens + e.usage.output_tokens + e.usage.cache_read_input_tokens + e.usage.cache_creation_input_tokens,
172                                      costUsd: costOf(e.usage.model || a.model, e.usage, false)
173                                  }
174                                : {})
175                        };
176                    })
177                );
178                await update($, now, () => at);
179            } catch {
180                // Tracking must never break the agent's own turn.
181            }
182        }
183        const result = await next(e);
184        await refresh($).catch(() => undefined);
185        return result;
186    });
187
188    on("session.measure", async ($, e, next) => {
189        const result = await next(e);
190        await refresh($).catch(() => undefined);
191        return result;
192    });
193
194    on("command.run", { command: COMMAND }, async $ => {
195        const isOpen = await togglePane($);
196        return { text: isOpen ? "Subagents panel opened." : "Subagents panel closed." };
197    }).catch(() => ({ text: "Subagents panel unavailable." }));
198
199    on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
200        const ui = $.ui.resolve(e);
201        const list = await read($, agents);
202        const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0);
203        if (e.surface !== "terminal" && "Svg" in ui) {
204            const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8));
205            return desktopPane(ui as ElementTable<"desktop">, list, at, W);
206        }
207        return terminalPane(ui as ElementTable<"terminal">, list, at);
208    });
209
210    on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
211        const snap = await read($, snapshot);
212        if (e.props.hasSurvey || snap === null) return next(e);
213
214        // Draw other mods' bands beneath ours. Claude Code's own placeholder
215        // ({ type: "engine" }) is never nested: the desktop drops a band that nests it.
216        const drawn = await Promise.resolve(next(e)).catch(() => null);
217        const below = drawn && typeof drawn === "object" && (drawn as { type?: string }).type !== "engine" ? drawn : null;
218
219        const chip = chipOf(await read($, agents));
220        const onChip = () => void togglePane($);
221        const cfg = readConfig(options);
222        const ui = $.ui.resolve(e);
223        const { Box } = ui;
224        const band =
225            e.surface !== "terminal" && "Svg" in ui
226                ? desktopBand(ui as ElementTable<"desktop">, snap, cfg, Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 8)), chip, onChip)
227                : terminalBand(ui as ElementTable<"terminal">, snap, cfg, e.props.bodyColumns || 80, chip, onChip);
228        return (
229            <Box flexDirection="column">
230                {band}
231                {below}
232            </Box>
233        );
234    });
235};
236
hooks/agents.ts 17 lines
1import type { AgentRun, Chip } from "../types";
2
3export const chipOf = (list: AgentRun[]): Chip | null =>
4    list.length === 0 ? null : { running: list.filter(a => a.status === "running").length, costUsd: list.reduce((s, a) => s + a.costUsd, 0) };
5
6export const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0);
7
8export const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt;
9
10export const totals = (list: AgentRun[], at: number) => {
11    const cost = list.reduce((s, a) => s + a.costUsd, 0);
12    const tokens = list.reduce((s, a) => s + a.tokens, 0);
13    const start = Math.min(...list.map(a => a.startedAt));
14    const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)));
15    return { cost, tokens, time: list.length ? end - start : 0 };
16};
17
hooks/band.desktop.tsx 147 lines
1import type { ElementTable } from "claude-code";
2
3import type { Chip, Snapshot } from "../types";
4import { glyphs } from "./config";
5import type { Config } from "./config";
6import { blend, chipText, clampPct, fitSegs, fitText, formatReset, paceOf, textWidth, WINDOW_MS, xml } from "./format";
7import type { Seg } from "./format";
8import { modelName } from "./prices";
9
10type Ui = ElementTable<"desktop">;
11type Part = (x: number) => string;
12
13export const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif";
14
15// Shared by the band and the /usage pane. Dark mode follows the page.
16export const STYLE = `<style>
17.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.pm{fill:#1f1f1f}.ln{stroke:#e4e4e1}.b1{fill:#0e7490}.b2{fill:#a21caf}.cost{fill:#a16207}
18@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.pm{fill:#ececec}.ln{stroke:#333331}.b1{fill:#8be9fd}.b2{fill:#ff79c6}.cost{fill:#f1fa8c}}
19.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
20@media (prefers-reduced-motion: reduce){.live{animation:none}}
21</style>`;
22
23const SIZE = 12.5;
24const H1 = 20;
25const H2 = 22;
26const BAR_W = 72;
27const BAR_H = 8;
28const GAP = 14;
29
30const rowSvg = (W: number, H: number, segs: Seg<Part>[]): string => {
31    const kept = fitSegs(segs, W, GAP);
32    let x = 0;
33    const body = kept
34        .map(s => {
35            const out = s.node(x);
36            x += s.w + GAP;
37            return out;
38        })
39        .join("");
40    return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${STYLE}${body}</svg>`;
41};
42
43const txt = (x: number, y: number, cls: string, s: string, extra = ""): string =>
44    `<text class="${cls}" x="${x}" y="${y}" font-family="${FONT}" font-size="${SIZE}" ${extra}>${xml(s)}</text>`;
45
46export function desktopBand(ui: Ui, snap: Snapshot, cfg: Config, W: number, chip: Chip | null, onChip?: () => void) {
47    const { Box, Text, Button, Svg } = ui;
48    // The SVG text uses the system UI font, which has no Nerd Font glyphs: always plain here.
49    const g = glyphs({ ...cfg, nerd: false });
50    const show = (k: string) => cfg.segments.has(k);
51    const chipLabel = show("agents") && chip !== null ? chipText(chip) : null;
52    const reserve = chipLabel !== null ? Math.round(chipLabel.length * 7 + 28) : 0;
53    const w1 = Math.max(120, W - reserve);
54    const y1 = H1 / 2 + 4.5;
55    const y2 = H2 / 2 + 4.5;
56
57    // Line 1: running dot, model, dir, branch.
58    const top: Seg<Part>[] = [];
59    if (chip !== null && chip.running > 0 && show("agents")) {
60        top.push({ key: "dot", w: 8, drop: 3, node: x => `<circle class="live" cx="${x + 4}" cy="${H1 / 2}" r="3" fill="#8f8cf4"/>` });
61    }
62    if (show("model")) {
63        const name = modelName(snap.model);
64        top.push({ key: "model", w: textWidth(name, 13) * 1.08, drop: 0, node: x => txt(x, y1, "t", name, 'font-weight="600"') });
65    }
66    if (show("dir")) {
67        const label = g.dir + snap.dir;
68        top.push({ key: "dir", w: textWidth(label, SIZE), drop: 0, node: x => txt(x, y1, "t", label) });
69    }
70    if (show("git") && snap.branch !== null) {
71        const name = `${snap.branch}${snap.isDirty ? "*" : ""}`;
72        top.push({
73            key: "git",
74            w: textWidth(g.branch + name, SIZE),
75            drop: 1,
76            node: x =>
77                `<text x="${x}" y="${y1}" font-family="${FONT}" font-size="${SIZE}" font-style="italic"><tspan class="b1">${xml(g.branch)}</tspan><tspan class="b2">${xml(name)}</tspan></text>`
78        });
79    }
80
81    // Line 2: limit bars and, off subscription, the session cost.
82    const y0 = (H2 - BAR_H) / 2;
83    const limit = (key: string, label: string, pct: number | null, resetsAt: string | null, kind: string | null, drop: number): Seg<Part> | null => {
84        if (!show(key) || pct === null) return null;
85        const value = clampPct(pct);
86        const color = blend(value, cfg.warnAt, cfg.critAt);
87        const pace = cfg.pace && kind !== null ? paceOf(resetsAt, WINDOW_MS[kind] ?? 0, snap.at) : null;
88        const reset = formatReset(resetsAt, snap.at);
89        const lw = textWidth(label, SIZE);
90        const pw = textWidth(`${value}%`, SIZE);
91        const base = lw + 6 + BAR_W + 6 + pw;
92        const fillW = value === 0 ? 0 : Math.max(BAR_H, Math.round((BAR_W * value) / 100));
93        const draw =
94            (withReset: boolean): Part =>
95            x => {
96                const bx = x + lw + 6;
97                const mark = pace === null ? "" : `<rect class="pm" x="${bx + Math.round((BAR_W * pace) / 100) - 0.75}" y="${y0 - 2}" width="1.5" height="${BAR_H + 4}" rx="0.75"/>`;
98                const fill = fillW > 0 ? `<rect x="${bx}" y="${y0}" width="${fillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>` : "";
99                const after = bx + BAR_W + 6;
100                return (
101                    txt(x, y2, "m", label) +
102                    `<rect class="k" x="${bx}" y="${y0}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>` +
103                    fill +
104                    mark +
105                    txt(after, y2, "t", `${value}%`, 'font-variant-numeric="tabular-nums"') +
106                    (withReset && reset !== null ? txt(after + pw + 5, y2, "m", `(${reset})`) : "")
107                );
108            };
109        const rw = reset !== null ? textWidth(`(${reset})`, SIZE) + 5 : 0;
110        return { key, w: base + rw, drop, node: draw(true), short: reset !== null ? { w: base, node: draw(false) } : undefined };
111    };
112
113    const bottom: Seg<Part>[] = [];
114    for (const s of [
115        limit("ctx", "ctx", snap.contextPct, null, null, 2),
116        limit("5h", "5h", snap.fiveHourPct, snap.fiveHourResetsAt, "five_hour", 0),
117        limit("7d", "7d", snap.sevenDayPct, snap.sevenDayResetsAt, "seven_day", 3)
118    ]) {
119        if (s !== null) bottom.push(s);
120    }
121    if (show("cost") && snap.fiveHourPct === null && snap.costUsd !== null && snap.costUsd > 0) {
122        const label = `$${snap.costUsd.toFixed(2)}`;
123        bottom.push({ key: "cost", w: textWidth(label, SIZE), drop: 4, node: x => txt(x, y2, "cost", label) });
124    }
125
126    const altTop = fitText([snap.model, snap.dir, snap.branch ?? ""].filter(Boolean).join(" "), SIZE, 400);
127    const altBottom = [
128        snap.contextPct !== null ? `ctx ${clampPct(snap.contextPct)}%` : "",
129        snap.fiveHourPct !== null ? `5h ${clampPct(snap.fiveHourPct)}%` : "",
130        snap.sevenDayPct !== null ? `7d ${clampPct(snap.sevenDayPct)}%` : ""
131    ]
132        .filter(Boolean)
133        .join(", ");
134
135    return (
136        <Box flexDirection="column">
137            {top.length > 0 && (
138                <Box flexDirection="row" alignItems="center" gap={1}>
139                    <Svg source={rowSvg(w1, H1, top)} alt={altTop} width={w1} height={H1} />
140                    {chipLabel !== null && (onChip ? <Button label={chipLabel} plain onPress={onChip} /> : <Text color="yellow">{chipLabel}</Text>)}
141                </Box>
142            )}
143            {bottom.length > 0 && <Svg source={rowSvg(W, H2, bottom)} alt={altBottom} width={W} height={H2} />}
144        </Box>
145    );
146}
147
hooks/band.terminal.tsx 112 lines
1import type { ElementTable } from "claude-code";
2
3import type { Chip, Snapshot } from "../types";
4import { glyphs } from "./config";
5import type { Config } from "./config";
6import { barCells, blend, chipText, clampPct, compactReset, fitSegs, formatReset, paceOf, WINDOW_MS } from "./format";
7import type { Seg } from "./format";
8import { modelName } from "./prices";
9
10type Ui = ElementTable<"terminal">;
11type Node = ReturnType<Ui["Text"]>;
12
13const BAR = 12;
14
15export function terminalBand(ui: Ui, snap: Snapshot, cfg: Config, cols: number, chip: Chip | null, onChip?: () => void) {
16    const { Box, Text, Button } = ui;
17    const g = glyphs(cfg);
18    const show = (k: string) => cfg.segments.has(k);
19
20    // Line 1: model, dir, branch, agents chip.
21    const top: Seg<Node>[] = [];
22    if (show("model")) {
23        const name = modelName(snap.model);
24        top.push({ key: "model", w: name.length, drop: 0, node: <Text bold>{name}</Text> });
25    }
26    if (show("dir")) top.push({ key: "dir", w: g.dir.length + snap.dir.length, drop: 0, node: <Text>{g.dir}{snap.dir}</Text> });
27    if (show("git") && snap.branch !== null) {
28        const name = `${snap.branch}${snap.isDirty ? "*" : ""}`;
29        top.push({
30            key: "git",
31            w: g.branch.length + name.length,
32            drop: 1,
33            node: (
34                <Text>
35                    <Text italic color="cyan">{g.branch}</Text>
36                    <Text italic color="magenta">{name}</Text>
37                </Text>
38            )
39        });
40    }
41    if (show("agents") && chip !== null) {
42        const label = chipText(chip);
43        top.push({
44            key: "agents",
45            w: label.length + 2,
46            drop: 2,
47            node: onChip ? <Button label={label} plain onPress={onChip} /> : <Text color="yellow">{label}</Text>
48        });
49    }
50
51    // Line 2: ctx, 5h, 7d bars and, off subscription, the session cost.
52    const limit = (key: string, label: string, pct: number | null, resetsAt: string | null, kind: string | null, drop: number): Seg<Node> | null => {
53        if (!show(key) || pct === null) return null;
54        const value = clampPct(pct);
55        const pace = cfg.pace && kind !== null ? paceOf(resetsAt, WINDOW_MS[kind] ?? 0, snap.at) : null;
56        const reset = compactReset(formatReset(resetsAt, snap.at));
57        const bar = barCells(value, BAR, pace).map((c, i) => (
58            <Text
59                key={String(i)}
60                color={c.kind === "fill" ? blend(c.at, cfg.warnAt, cfg.critAt) : undefined}
61                dimColor={c.kind === "track"}
62                bold={c.kind === "mark"}
63            >
64                {c.ch}
65            </Text>
66        ));
67        const pctText = `${value}%`.padStart(4);
68        const body = (withReset: boolean) => (
69            <Text>
70                <Text dimColor>{label} </Text>
71                {bar}
72                <Text bold>{pctText}</Text>
73                {withReset && reset !== null && <Text dimColor> ↻ {reset}</Text>}
74            </Text>
75        );
76        const w = label.length + 1 + BAR + 4;
77        return { key, w: w + (reset !== null ? reset.length + 3 : 0), drop, node: body(true), short: reset !== null ? { w, node: body(false) } : undefined };
78    };
79
80    const bottom: Seg<Node>[] = [];
81    const ctx = limit("ctx", "ctx", snap.contextPct, null, null, 2);
82    const five = limit("5h", "5h", snap.fiveHourPct, snap.fiveHourResetsAt, "five_hour", 0);
83    const seven = limit("7d", "7d", snap.sevenDayPct, snap.sevenDayResetsAt, "seven_day", 3);
84    for (const s of [ctx, five, seven]) if (s !== null) bottom.push(s);
85    if (show("cost") && snap.fiveHourPct === null && snap.costUsd !== null && snap.costUsd > 0) {
86        const label = `$${snap.costUsd.toFixed(2)}`;
87        bottom.push({ key: "cost", w: label.length, drop: 4, node: <Text color="yellow">{label}</Text> });
88    }
89
90    const row = (segs: Seg<Node>[], sep: string) => {
91        if (segs.length === 0) return null;
92        const kept = fitSegs(segs, cols, sep.length);
93        return (
94            <Box flexDirection="row">
95                {kept.map((s, i) => (
96                    <Box key={s.key}>
97                        {i > 0 && <Text dimColor>{sep}</Text>}
98                        {s.node}
99                    </Box>
100                ))}
101            </Box>
102        );
103    };
104
105    return (
106        <Box flexDirection="column">
107            {row(top, "  ")}
108            {row(bottom, " │ ")}
109        </Box>
110    );
111}
112
hooks/config.ts 38 lines
1export const SEGMENTS = ["model", "dir", "git", "ctx", "5h", "7d", "cost", "agents"] as const;
2
3export type Config = {
4    segments: ReadonlySet<string>;
5    warnAt: number;
6    critAt: number;
7    pace: boolean;
8    nerd: boolean;
9};
10
11export const DEFAULT_CONFIG: Config = {
12    segments: new Set(SEGMENTS),
13    warnAt: 50,
14    critAt: 80,
15    pace: true,
16    nerd: false
17};
18
19// `options` are the plugin's userConfig fields as strings, numbers or booleans.
20export function readConfig(o: Readonly<Record<string, unknown>>): Config {
21    const known: readonly string[] = SEGMENTS;
22    const list = typeof o.segments === "string" ? o.segments.split(",").map(s => s.trim()).filter(s => known.includes(s)) : [];
23    const warn = Number(o.warnAt);
24    const crit = Number(o.critAt);
25    const warnAt = Number.isFinite(warn) && warn > 0 && warn < 100 ? warn : 50;
26    const critAt = Number.isFinite(crit) && crit > warnAt && crit <= 100 ? crit : Math.min(100, Math.max(80, warnAt + 1));
27    return {
28        segments: new Set(list.length > 0 ? list : SEGMENTS),
29        warnAt,
30        critAt,
31        pace: o.paceMarker !== "off",
32        nerd: o.glyphs === "nerd"
33    };
34}
35
36export const glyphs = (cfg: Config): { dir: string; branch: string } =>
37    cfg.nerd ? { dir: " ", branch: " " } : { dir: "", branch: "on " };
38
hooks/pane.tsx 110 lines
1import type { ElementTable } from "claude-code";
2
3import type { AgentRun } from "../types";
4import { ctxOf, elapsed, totals } from "./agents";
5import { FONT, STYLE } from "./band.desktop";
6import { barCells, blend, fitText, fmtCost, fmtTime, fmtTokens, xml } from "./format";
7import { modelName } from "./prices";
8
9const ROW_H = 52;
10
11const mark = (status: AgentRun["status"]): string => {
12    if (status === "running") return `<circle class="live" cx="8" cy="14" r="3.5" fill="#8f8cf4"/>`;
13    if (status === "done") return `<path d="M3 14l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`;
14    return `<path d="M4 10l8 8M12 10l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`;
15};
16
17const rowSvg = (W: number, a: AgentRun, at: number): string => {
18    const ctx = ctxOf(a);
19    const color = a.status === "failed" ? "#D0453F" : blend(ctx, 50, 80);
20    const textW = W - 24;
21    const meta = [a.type, modelName(a.model), a.effort ?? ""].filter(Boolean).join(" · ");
22    const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`;
23    const fillW = Math.round((W - 20) * (ctx / 100));
24    return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${ROW_H}" viewBox="0 0 ${W} ${ROW_H}">${STYLE}
25${mark(a.status)}
26<text class="t" x="20" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
27<text class="m" x="20" y="34" font-family="${FONT}" font-size="11">${xml(fitText(meta, 11, textW / 2))}</text>
28<text class="m" x="${W}" y="34" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>
29<rect class="k" x="20" y="42" width="${W - 20}" height="4" rx="2"/><rect x="20" y="42" width="${fillW}" height="4" rx="2" fill="${color}"/>
30<line class="ln" x1="0" y1="${ROW_H - 0.5}" x2="${W}" y2="${ROW_H - 0.5}"/>
31</svg>`;
32};
33
34const split = (list: AgentRun[]) => ({
35    running: list.filter(a => a.status === "running").reverse(),
36    finished: list.filter(a => a.status !== "running").reverse()
37});
38
39const summaryOf = (list: AgentRun[], at: number): string => {
40    const t = totals(list, at);
41    return `≈${fmtCost(t.cost)}  ·  ${fmtTokens(t.tokens)} tokens  ·  ${fmtTime(t.time)}`;
42};
43
44export function desktopPane(ui: ElementTable<"desktop">, list: AgentRun[], at: number, W: number) {
45    const { Box, Text, Svg } = ui;
46    const { running, finished } = split(list);
47    const rows = (items: AgentRun[], head: string) => (
48        <Box flexDirection="column">
49            {items.length > 0 && (
50                <Text dimColor>
51                    {head} · {items.length}
52                </Text>
53            )}
54            {items.map(a => (
55                <Svg key={a.id} source={rowSvg(W, a, at)} alt={`${a.description || a.type}: ${a.status}`} width={W} height={ROW_H} />
56            ))}
57        </Box>
58    );
59    return (
60        <Box flexDirection="column">
61            <Text bold>{summaryOf(list, at)}</Text>
62            {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
63            {rows(running, "Running")}
64            {rows(finished, "Finished")}
65        </Box>
66    );
67}
68
69const GLYPH: Record<AgentRun["status"], string> = { running: "●", done: "✓", failed: "✗" };
70
71export function terminalPane(ui: ElementTable<"terminal">, list: AgentRun[], at: number) {
72    const { Box, Text } = ui;
73    const { running, finished } = split(list);
74    const row = (a: AgentRun) => {
75        const ctx = ctxOf(a);
76        return (
77            <Box key={a.id} flexDirection="column" marginBottom={1}>
78                <Text bold wrap="truncate-end">
79                    {GLYPH[a.status]} {a.description || a.type}
80                </Text>
81                <Text dimColor wrap="truncate-end">
82                    {"  "}
83                    {[a.type, modelName(a.model), a.effort ?? ""].filter(Boolean).join(" · ")}
84                </Text>
85                <Text wrap="truncate-end">
86                    {"  "}
87                    {barCells(ctx, 12, null).map((c, i) => (
88                        <Text key={String(i)} color={c.kind === "fill" ? blend(c.at, 50, 80) : undefined} dimColor={c.kind === "track"}>
89                            {c.ch}
90                        </Text>
91                    ))}
92                    <Text dimColor>
93                        {" "}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
94                    </Text>
95                </Text>
96            </Box>
97        );
98    };
99    return (
100        <Box flexDirection="column">
101            <Text bold>{summaryOf(list, at)}</Text>
102            {list.length === 0 && <Text dimColor>No subagents yet.</Text>}
103            {running.length > 0 && <Text dimColor>Running · {running.length}</Text>}
104            {running.map(row)}
105            {finished.length > 0 && <Text dimColor>Finished · {finished.length}</Text>}
106            {finished.map(row)}
107        </Box>
108    );
109}
110
hooks/prices.ts 54 lines
1// USD per million tokens: input, output, cache read, cache write. First match
2// wins, so a release sits above its family. The engine reports tokens, not
3// money, so every figure derived from this table is an ESTIMATE. Cache writes
4// are priced at the 1-hour rate, which is what a subscription writes.
5// Values copied from savvy-progress (list prices as of 2026-10-10); update here.
6type Price = [input: number, output: number, cacheRead: number, cacheWrite: number];
7
8const PRICES: [RegExp, Price][] = [
9    [/(fable|mythos)-5-1/, [10, 50, 0.25, 20]],
10    [/fable|mythos/, [10, 50, 1, 20]],
11    [/opus-5-5/, [4, 20, 0.2, 8]],
12    [/opus/, [5, 25, 0.5, 10]],
13    [/sonnet-5-5/, [2, 10, 0.1, 4]],
14    [/sonnet-5/, [2, 10, 0.2, 4]],
15    [/sonnet/, [3, 15, 0.3, 6]],
16    [/haiku-5-5/, [0.1, 0.5, 0.01, 0.2]],
17    [/haiku/, [1, 5, 0.1, 2]]
18];
19
20// Haiku 5.5 charges these when one request's prompt is over 100,000 tokens.
21const HAIKU_LONG_PROMPT = 100_000;
22const HAIKU_LONG: Price = [0.5, 2.5, 0.05, 1];
23
24export type Usage = {
25    input_tokens: number;
26    output_tokens: number;
27    cache_read_input_tokens: number;
28    cache_creation_input_tokens: number;
29};
30
31const priceOf = (model: string, prompt: number): Price => {
32    const id = model.toLowerCase();
33    if (prompt > HAIKU_LONG_PROMPT && /haiku-5-5/.test(id)) return HAIKU_LONG;
34    return PRICES.find(([re]) => re.test(id))?.[1] ?? [4, 20, 0.2, 8];
35};
36
37// `isRequest`: the usage is one model request (which sets a prompt-length price);
38// a turn's sum is priced at the ordinary prices.
39export const costOf = (model: string, u: Usage, isRequest: boolean): number => {
40    const prompt = isRequest ? (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0) : 0;
41    const [i, o, r, w] = priceOf(model, prompt);
42    return ((u.input_tokens || 0) * i + (u.output_tokens || 0) * o + (u.cache_read_input_tokens || 0) * r + (u.cache_creation_input_tokens || 0) * w) / 1e6;
43};
44
45// Haiku 4.5 and the Claude 3 models have a 200K window; the rest, 1M.
46export const windowOf = (model: string): number => (/haiku-4|claude-3/i.test(model) ? 200_000 : 1_000_000);
47
48export const modelName = (id: string): string => {
49    const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id);
50    const [, family = "", major = "", minor] = m ?? [];
51    if (!family) return id.replace(/^claude-/, "").replace(/\[.*\]$/, "") || "-";
52    return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? "." + minor : ""}`;
53};
54
hooks/format.ts 147 lines
1import type { Chip } from "../types";
2
3export const clampPct = (n: number): number => Math.max(0, Math.min(100, Math.round(n)));
4
5const DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
6
7export function formatReset(iso: string | null, at: number): string | null {
8    if (iso === null) return null;
9    const when = new Date(iso);
10    const diff = Math.floor((when.getTime() - at) / 1000);
11    if (Number.isNaN(diff) || diff <= 0) return null;
12    const hours = Math.floor(diff / 3600);
13    const mins = Math.floor((diff % 3600) / 60);
14    if (hours >= 24) {
15        const h = when.getHours();
16        return `resets ${DAYS[when.getDay()]} ${h % 12 === 0 ? 12 : h % 12}${h < 12 ? "am" : "pm"}`;
17    }
18    return hours > 0 ? `resets in ${hours}h ${mins}m` : `resets in ${mins}m`;
19}
20
21export const WINDOW_MS: Record<string, number> = {
22    five_hour: 5 * 3_600_000,
23    seven_day: 7 * 86_400_000
24};
25
26// Share of the window already elapsed, 0..100. Null when the reset time is
27// missing, past, or farther away than the window (so the window is unknown).
28export function paceOf(resetsAt: string | null, windowMs: number, at: number): number | null {
29    if (resetsAt === null || windowMs <= 0) return null;
30    const left = new Date(resetsAt).getTime() - at;
31    if (Number.isNaN(left) || left <= 0 || left > windowMs) return null;
32    return ((windowMs - left) / windowMs) * 100;
33}
34
35type RGB = [number, number, number];
36const GREEN: RGB = [80, 250, 123];
37const YELLOW: RGB = [241, 250, 140];
38const RED: RGB = [255, 85, 85];
39
40const mix = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
41const hex = (c: RGB): string => "#" + c.map(v => Math.round(v).toString(16).padStart(2, "0")).join("");
42
43// Green at 0, yellow at warnAt, red from critAt, blended in between.
44export function blend(pct: number, warnAt: number, critAt: number): string {
45    if (pct <= 0) return hex(GREEN);
46    if (pct < warnAt) return hex(mix(GREEN, YELLOW, pct / warnAt));
47    if (pct < critAt) return hex(mix(YELLOW, RED, (pct - warnAt) / (critAt - warnAt)));
48    return hex(RED);
49}
50
51export type Cell = { ch: string; kind: "fill" | "track" | "mark"; at: number };
52
53// One bar as cells: heavy line for the fill with a half-cell head, a thin line
54// for the rest, and a "┃" at the pace position. `at` is the cell centre as a
55// percent of the bar, so the fill can be coloured along its length.
56export function barCells(pct: number, width: number, pace: number | null): Cell[] {
57    const cells: Cell[] = [];
58    const mark = pace === null ? -1 : Math.min(width - 1, Math.floor((pace / 100) * width));
59    for (let i = 0; i < width; i++) {
60        const f = Math.max(0, Math.min(1, (pct / 100) * width - i));
61        const at = ((i + 0.5) / width) * 100;
62        if (i === mark) cells.push({ ch: "┃", kind: "mark", at });
63        else if (f >= 0.75) cells.push({ ch: "━", kind: "fill", at });
64        else if (f > 0) cells.push({ ch: "╾", kind: "fill", at });
65        else cells.push({ ch: "─", kind: "track", at });
66    }
67    return cells;
68}
69
70// "resets in 3h 36m" -> "3h 36m", "resets Monday 9am" -> "Mon 9am".
71export const compactReset = (s: string | null): string | null =>
72    s === null ? null : s.replace(/^resets (in )?/, "").replace(/^(\w{3})\w*day\b/, "$1");
73
74// A band segment. `w` is its width in the renderer's unit (cells or px);
75// `short` is a narrower variant tried before the segment is dropped.
76export type Seg<T> = { key: string; w: number; node: T; drop: number; short?: { w: number; node: T } };
77
78// Makes the row fit: first every segment with a short variant takes it, then
79// segments are dropped, highest `drop` first. `drop` 0 is never dropped.
80export function fitSegs<T>(segs: Seg<T>[], cols: number, gap: number): Seg<T>[] {
81    const out = segs.map(s => ({ ...s }));
82    const width = () => out.reduce((n, s) => n + s.w, 0) + gap * Math.max(0, out.length - 1);
83    while (width() > cols) {
84        const shrinkable = out.find(s => s.short);
85        const sh = shrinkable?.short;
86        if (shrinkable && sh) {
87            shrinkable.w = sh.w;
88            shrinkable.node = sh.node;
89            shrinkable.short = undefined;
90            continue;
91        }
92        let idx = -1;
93        let best = 0;
94        out.forEach((s, i) => {
95            if (s.drop > best) {
96                best = s.drop;
97                idx = i;
98            }
99        });
100        if (idx < 0) break;
101        out.splice(idx, 1);
102    }
103    return out;
104}
105
106// Under a cent a cheap run would read $0.00, so it gets a third decimal.
107export const fmtCost = (usd: number): string => `$${usd > 0 && usd < 0.01 ? usd.toFixed(3) : usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`;
108
109export function chipText(chip: Chip): string {
110    const cost = `≈${fmtCost(chip.costUsd)}`;
111    return chip.running > 0 ? `${chip.running} running · ${cost}` : cost;
112}
113
114export const xml = (s: string): string =>
115    s.replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] ?? c);
116
117// CJK, fullwidth forms and emoji advance about 1em.
118const WIDE =
119    /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꥠ-꥿가-힣豈-﫿︰-﹏!-⦆¢-₩\u{1F300}-\u{1FAFF}\u{20000}-\u{3FFFD}]/u;
120
121// Rough advance of system UI text in em; SVG cannot measure, so slots are sized from this.
122const charEm = (ch: string): number =>
123    WIDE.test(ch) ? 1 : /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-Z@%mw]/.test(ch) ? 0.72 : 0.56;
124
125export const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0);
126
127export function fitText(s: string, size: number, maxW: number): string {
128    if (textWidth(s, size) <= maxW) return s;
129    let out = "";
130    for (const ch of s) {
131        if (textWidth(out + ch + "…", size) > maxW) break;
132        out += ch;
133    }
134    return out + "…";
135}
136
137export const fmtTokens = (n: number): string =>
138    n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`;
139
140export const fmtTime = (ms: number): string => {
141    const s = Math.max(0, Math.round(ms / 1000));
142    const h = Math.floor(s / 3600);
143    const m = Math.floor((s % 3600) / 60);
144    const ss = String(s % 60).padStart(2, "0");
145    return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
146};
147
types/index.d.ts 41 lines
1export type Snapshot = {
2    at: number;
3    model: string;
4    dir: string;
5    branch: string | null;
6    isDirty: boolean;
7    contextPct: number | null;
8    fiveHourPct: number | null;
9    sevenDayPct: number | null;
10    fiveHourResetsAt: string | null;
11    sevenDayResetsAt: string | null;
12    costUsd: number | null;
13};
14
15export type AgentStatus = 'running' | 'done' | 'failed';
16
17export type AgentRun = {
18    id: string;
19    agentId?: string;
20    type: string;
21    description: string;
22    model: string;
23    effort?: string;
24    status: AgentStatus;
25    startedAt: number;
26    endedAt?: number;
27    contextTokens: number;
28    contextMax: number;
29    tokens: number;
30    costUsd: number;
31    steps: number;
32};
33
34export type Chip = { running: number; costUsd: number };
35
36declare module 'claude-code' {
37    interface PluginState {
38        'usage-band': { snapshot: Snapshot | null; agents: AgentRun[]; now: number };
39    }
40}
41