SLOPSHOPPER

turn-footer

Turns the line under each answer into a turn summary (tools, requests, tokens, cache hit) and shows the running tool in the spinner

newspinnerguard
★ 5v0.1.0MITupdated 2026-10-02arasovic/claude-code-mods/turn-footer
A shopper browsing a rack in a slop shop

Draws

Turn line
✻ Worked… 9 tools · 1 failed
README

turn-footer

Adds a summary line under each answer and names the running tool in the spinner.

<img src="../docs/turn-footer.png" alt="A turn summary line under the Worked for line" width="420">

What it shows

  • Under each answer, below Claude Code's own "Worked for …" line: tool calls and failures, subagents, model requests, input and output tokens, and the cache hit rate. A low cache rate turns yellow under 50% and red under 20%.
  • In the spinner, while a tool runs: its name and how long it has been running.

Subagent work counts toward the turn that started it.

Install

claude plugin marketplace add arasovic/claude-code-mods
claude plugin install turn-footer@claude-code-mods

Restart Claude Code. The summary shows from the next answer on.

Develop

claude plugin validate .
claude plugin test .
../typecheck.sh turn-footer
Source 2 files
hooks/register.tsx 122 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { FooterRunning, FooterStats } from '../types'
5
6const EMPTY: FooterStats = { tools: 0, failed: 0, requests: 0, agents: [], input: 0, cached: 0, output: 0 }
7
8const live = atom({ plugin: 'turn-footer', key: 'live' } as const, EMPTY)
9const done = atom({ plugin: 'turn-footer', key: 'done' } as const, [] as { durationMs: number; stats: FooterStats }[])
10const running = atom({ plugin: 'turn-footer', key: 'running' } as const, [] as FooterRunning[])
11
12export const clock = (ms: number) => {
13  const s = Math.max(0, Math.round(ms / 1000))
14  if (s < 60) return `${s}s`
15  if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
16  return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
17}
18
19export const tokens = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n))
20
21const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
22
23// The summary's parts, each with a tone: failures and a cold cache stand out, the rest stays dim like the engine's line.
24export const summary = (s: FooterStats) => {
25  const parts: { t: string; c?: string }[] = []
26  if (s.tools) parts.push({ t: plural(s.tools, 'tool') })
27  if (s.failed) parts.push({ t: `${s.failed} failed`, c: 'error' })
28  if (s.agents.length) parts.push({ t: plural(s.agents.length, 'agent') })
29  if (s.requests) parts.push({ t: plural(s.requests, 'request') })
30  if (s.input || s.output) parts.push({ t: `${tokens(s.input)} in  ${tokens(s.output)} out` })
31  if (s.input) {
32    const hit = Math.round((s.cached / s.input) * 100)
33    parts.push({ t: `cache ${hit}%`, c: hit < 20 ? 'error' : hit < 50 ? 'warning' : undefined })
34  }
35  return parts
36}
37
38export const register: Register = on => {
39  on('turn.start', async ($, e, next) => {
40    await update($, live, () => EMPTY)
41    return next(e)
42  })
43
44  on('tool.call', async ($, e, next) => {
45    const at = await $.clock.now()
46    const id = e.tool_use_id ?? `${e.tool}:${at}`
47    const main = e.agentId === undefined
48    if (main) await update($, running, list => [...list, { id, name: e.tool.startsWith('mcp__') ? (e.tool.split('__').at(-1) ?? e.tool) : e.tool, at }])
49    let ok = false
50    try {
51      const result = await next(e)
52      ok = result.deny === undefined && !result.isError
53      return result
54    } finally {
55      if (main) await update($, running, list => list.filter(t => t.id !== id))
56      await update($, live, s => ({ ...s, tools: s.tools + 1, failed: s.failed + (ok ? 0 : 1) }))
57    }
58  })
59
60  on('turn.step', async function* ($, e, next) {
61    const result = yield* next(e)
62    const usage = result.usage
63    if (usage) {
64      try {
65        const agent = e.agentId
66        await update($, live, s => ({
67          ...s,
68          requests: s.requests + 1,
69          agents: agent && !s.agents.includes(agent) ? [...s.agents, agent] : s.agents,
70          input: s.input + usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
71          cached: s.cached + usage.cache_read_input_tokens,
72          output: s.output + usage.output_tokens,
73        }))
74      } catch {
75        // The summary is a convenience: a failed write must never break the model's response.
76      }
77    }
78    return result
79  })
80
81  on('turn.complete', async ($, e, next) => {
82    // A subagent's run is a turn of its own; its steps already counted toward the main turn.
83    if (e.agentId === undefined) {
84      const stats = await read($, live)
85      // The footer line carries no turn id, only the duration this event reports, so that is the join key.
86      await update($, done, list => [...list, { durationMs: e.durationMs, stats }].slice(-100))
87    }
88    return next(e)
89  })
90
91  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
92    const drawn = await next(e)
93    const found = (await read($, done)).findLast(d => d.durationMs === e.props.durationMs)
94    if (!found) return drawn
95    const { Box, Text } = $.ui.resolve(e)
96    // The engine's line fills the row, so the summary goes on its own line under it, indented past the glyph.
97    return (
98      <Box flexDirection="column">
99        {drawn}
100        <Text>
101          <Text>{'  '}</Text>
102          {summary(found.stats).map((p, i) => (
103            <Text key={String(i)}>
104              {i ? <Text dimColor>{'  ·  '}</Text> : null}
105              <Text color={p.c} dimColor={p.c === undefined}>
106                {p.t}
107              </Text>
108            </Text>
109          ))}
110        </Text>
111      </Box>
112    )
113  })
114
115  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
116    const tool = (await read($, running)).at(-1)
117    if (!tool || e.props.mode !== 'tool-use') return next(e)
118    const now = await $.clock.now()
119    return next({ ...e, props: { ...e.props, suffix: `… ${tool.name} ${clock(now - tool.at)}` } })
120  })
121}
122
types/index.d.ts 13 lines
1export type FooterStats = { tools: number; failed: number; requests: number; agents: string[]; input: number; cached: number; output: number }
2export type FooterRunning = { id: string; name: string; at: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'turn-footer': {
7      live: FooterStats
8      done: { durationMs: number; stats: FooterStats }[]
9      running: FooterRunning[]
10    }
11  }
12}
13