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

hooks/register.tsx 236 lines1import { 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};
236hooks/agents.ts 17 lines1import 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};
17hooks/band.desktop.tsx 147 lines1import 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}
147hooks/band.terminal.tsx 112 lines1import 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}
112hooks/config.ts 38 lines1export 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 " };
38hooks/pane.tsx 110 lines1import 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}
110hooks/prices.ts 54 lines1// 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};
54hooks/format.ts 147 lines1import 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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};
147types/index.d.ts 41 lines1export 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