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

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">
Subagent work counts toward the turn that started it.
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.
claude plugin validate .
claude plugin test .
../typecheck.sh turn-footerhooks/register.tsx 122 lines1import { 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}
122types/index.d.ts 13 lines1export 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