Adds elapsed time, the running tool and the tool-call count to Claude Code's own spinner.

Adds live stats to Claude Code's own spinner without replacing it: Sauteing… · 12s · Bash · 4 calls.
ui.render on the Spinner site, rewriting props.suffix so it composes with other mods' suffixes; a $.clock.every redraw; tool.call wrapped around await next(e) to know when a tool is running.
Not captured live: the spinner only draws in an interactive terminal session, and claude -p can't show it. Covered by $.ui.mount tests (claude plugin test), not by a screenshot.
session.start starts a 1 s timer that refreshes the elapsed seconds and invalidates ui.render, only while a turn runs.turn.start records the start time ($.clock.now()) and resets the call count and running tools.tool.call adds the tool to a running list, bumps the count, runs await next(e), then removes it (a list, so concurrent calls work; the newest running tool is shown).turn.complete stops the stats.ui.render {component: "Spinner"} appends · <elapsed>s · <tool> · <n> calls to the existing suffix. Any throw falls back to next(e), so the spinner is never broken.Main loop only: tool.calls with an agentId (subagents) are not counted, so the numbers describe what you are waiting on in this turn.
What claude plugin validate reports:
hooks: session.start, turn.start, tool.call, turn.complete, ui.render{component=Spinner}
calls: $.clock.every, $.clock.now, $.ui.invalidate
Claude Code with mods support (2.1.289+). claude --plugin-dir ./mods/spinner-stats, or install from the marketplace.
-p or cloud.turn.step reports usage only when a step ends, so a rate would mix model time with tool wait time.AbovePrompt band, so it coexists with other mods that do.None.
hooks/spinner-stats.mjs 81 lines1// Spinner Stats: appends " · 12s · Bash · 4 calls" to Claude Code's own spinner.
2//
3// turn.start resets, tool.call tracks the running tool(s) and the call count,
4// a 1 s timer refreshes the elapsed time, and ui.render {Spinner} adds to the
5// suffix (it composes with other mods' suffixes).
6//
7// Main loop only: tool calls with `agentId` (subagents) are not counted, so the
8// numbers describe what the person is waiting on in this turn, not fan-out work.
9//
10// The host reads `on(...)` and `$.noun.method(...)` from source: spelled literally.
11
12let startedAt = null; // ms, set while a turn runs
13let elapsedS = 0;
14let calls = 0;
15let running = []; // names of in-flight main-loop tools (concurrent calls)
16
17export function register(on) {
18 on("session.start", async ($, e, next) => {
19 // ponytail: the timer is never cancelled; it only invalidates while a turn runs
20 $.clock.every(1000, async () => {
21 try {
22 if (startedAt === null) return;
23 elapsedS = Math.floor(((await $.clock.now()) - startedAt) / 1000);
24 $.ui.invalidate("ui.render");
25 } catch {
26 // a missed tick just means a stale second
27 }
28 });
29 return next(e);
30 });
31
32 on("turn.start", async ($, e, next) => {
33 try {
34 startedAt = await $.clock.now();
35 elapsedS = 0;
36 calls = 0;
37 running = [];
38 $.ui.invalidate("ui.render");
39 } catch {
40 startedAt = null;
41 }
42 return next(e);
43 });
44
45 on("tool.call", async ($, e, next) => {
46 if (e.agentId) return next(e);
47 running.push(e.tool);
48 calls++;
49 try {
50 $.ui.invalidate("ui.render");
51 } catch {
52 // cosmetic
53 }
54 try {
55 return await next(e);
56 } finally {
57 const i = running.indexOf(e.tool);
58 if (i >= 0) running.splice(i, 1);
59 }
60 });
61
62 on("turn.complete", async ($, e, next) => {
63 if (!e.agentId) startedAt = null; // the timer goes quiet; the spinner is gone anyway
64 return next(e);
65 });
66
67 on("ui.render", { component: "Spinner" }, async ($, e, next) => {
68 try {
69 if (startedAt === null) return next(e);
70 const parts = [`${elapsedS}s`];
71 if (running.length) parts.push(running[running.length - 1]);
72 if (calls) parts.push(`${calls} ${calls === 1 ? "call" : "calls"}`);
73 return next({ ...e, props: { ...e.props, suffix: (e.props.suffix ?? "") + " · " + parts.join(" · ") } });
74 } catch {
75 return next(e);
76 }
77 });
78}
79// ponytail: output tokens/sec omitted. turn.step only reports usage when a step ends, so a rate would be
80// a per-step average mixed with tool wait time; add it if the host streams usage mid-step.
81