SLOPSHOPPER

spinner-stats

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

newspinnerguardtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spinner-stats
› fix the failing auth test and add an audit log call ⏺ 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 ✻ Thinking · 0s · 9 calls… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Spinner Stats

Adds live stats to Claude Code's own spinner without replacing it: Sauteing… · 12s · Bash · 4 calls.

What this shows

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.

Demo

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.

How it works

  • 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

Run it

Claude Code with mods support (2.1.289+). claude --plugin-dir ./mods/spinner-stats, or install from the marketplace.

Notes / limitations

  • Spinner is drawn on the terminal surface only; nothing shows in the Desktop Code tab, VS Code, -p or cloud.
  • Output tokens/sec is omitted: turn.step reports usage only when a step ends, so a rate would mix model time with tool wait time.
  • The 1 s timer is never cancelled; it does nothing outside a turn.
  • Does not use the AbovePrompt band, so it coexists with other mods that do.
  • Not verified in a live interactive session.

Dependencies

None.

Source 1 files
hooks/spinner-stats.mjs 81 lines
1// 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