Adds what a turn did to its 'Worked for' line: tool calls by tool, failures, files changed and cost

Adds what a turn did under the line Claude Code draws when the turn ends, so it is where you already look:
✻ Baked for 2m 3s
15 tools (Bash 6, Edit 5, Read 3, …) · 2 failed · 4 files · $0.42
/plugin install turn-stats@claude-mods
Edit, Write and NotebookEdit changedA turn that did nothing worth saying keeps the plain line. Summaries are kept across sessions (the last 200), so a resumed transcript shows them too; turns from before the mod was installed have none.
The line is the transcript's turn_duration row, and its render instance is that row's uuid. A session.append hook catches the row as it is stored and binds the turn that just ran to it; a ui.render hook on TurnDuration draws the engine's own line and the summary on a row beneath it.
| Option | Default | |
|---|---|---|
cost | true | Show the turn's cost |
topTools | 3 | How many tools to name in the breakdown (0: the count alone) |
hooks/register.tsx 112 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TurnStats } from '../types'
5import { addCall, emptyStats, keepNewest, summarize } from './stats'
6
7const lines = atom({ plugin: 'turn-stats', key: 'lines' } as const, {})
8
9/** Summaries kept across sessions, so a resumed transcript keeps them. */
10const STORE_KEY = 'lines'
11const MAX_LINES = 200
12
13/** The main turn running now. */
14let current: TurnStats | undefined
15let costAtStart: number | undefined
16/** The turn that just completed, waiting for its turn_duration row. */
17let pending: TurnStats | undefined
18
19async function sessionCost($: EngineInterface) {
20 return (await $.session.usage()).cost?.usd
21}
22
23/** The running turn as it stands, with what it cost so far. */
24async function settle($: EngineInterface, stats: TurnStats): Promise<TurnStats> {
25 const costAtEnd = await sessionCost($)
26 const costUsd = costAtEnd !== undefined && costAtStart !== undefined ? costAtEnd - costAtStart : undefined
27
28 return { ...stats, ...(costUsd === undefined ? {} : { costUsd }) }
29}
30
31/** Subagents run turns of their own inside the main one; only the main turn is summarized. */
32const isSubagent = (e: object) => 'agentId' in e && e.agentId !== undefined
33
34export const register: Register = (on, options) => {
35 const topTools = Math.max(0, Number(options.topTools ?? 3))
36 const isCostShown = options.cost !== false
37
38 on('session.start', async ($, e, next) => {
39 const stored = await $.store.get(STORE_KEY)
40 if (stored !== null && typeof stored === 'object') {
41 await update($, lines, record => ({ ...(stored as Record<string, TurnStats>), ...record }))
42 }
43
44 return next(e)
45 })
46
47 on('turn.start', async ($, e, next) => {
48 if (!isSubagent(e)) {
49 current = emptyStats()
50 costAtStart = await sessionCost($)
51 }
52
53 return next(e)
54 })
55
56 on('tool.call', async ($, e, next) => {
57 const ran = await next(e)
58 if (current) {
59 const isFailed = ran.deny !== undefined || ran.isError === true
60 current = addCall(current, String(e.tool), e as Readonly<Record<string, unknown>>, isFailed)
61 }
62
63 return ran
64 })
65
66 // The summary is settled before the rest of the chain runs, in case the
67 // turn_duration row is appended while the turn completes.
68 on('turn.complete', async ($, e, next) => {
69 if (current && !isSubagent(e)) {
70 pending = await settle($, current)
71 current = undefined
72 }
73
74 return next(e)
75 })
76
77 // The TurnDuration line's requestId is the uuid of this row. It may come
78 // before turn.complete, so a turn still running is settled here too.
79 on('session.append', async ($, e, next) => {
80 const isDurationRow = e.message.type === 'system' && e.message.name === 'turn_duration' && e.agentId === undefined
81 if (isDurationRow && (pending || current)) {
82 const stats = pending ?? (await settle($, current!))
83 pending = undefined
84 current = undefined
85 await update($, lines, record => keepNewest({ ...record, [e.uuid]: stats }, MAX_LINES))
86 await $.store.set(STORE_KEY, await read($, lines))
87 }
88
89 return next(e)
90 })
91
92 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
93 const stats = (await read($, lines))[e.requestId]
94 const line = await next(e)
95 const text = stats ? summarize(stats, { topTools, isCostShown }) : undefined
96 if (text === undefined) return line
97
98 const { Box, Text } = $.ui.resolve(e)
99
100 // The engine line takes the full width, so the summary goes on its own row
101 // beneath it, under the text after the glyph.
102 return (
103 <Box flexDirection="column">
104 {line}
105 <Box paddingLeft={2}>
106 <Text dimColor>{text}</Text>
107 </Box>
108 </Box>
109 )
110 })
111}
112hooks/stats.ts 68 lines1// Collecting and formatting a turn's stats as pure functions: no `$`, so
2// tests call them directly.
3
4import type { TurnStats } from '../types'
5
6/** The tools whose calls change a file, and the input field naming it. */
7export const FILE_TOOLS: Record<string, string> = {
8 Edit: 'file_path',
9 Write: 'file_path',
10 NotebookEdit: 'notebook_path',
11}
12
13export const emptyStats = (): TurnStats => ({ tools: {}, failed: 0, files: [] })
14
15/** Adds one finished tool call to the stats. */
16export function addCall(stats: TurnStats, tool: string, input: Readonly<Record<string, unknown>>, isError: boolean): TurnStats {
17 const field = FILE_TOOLS[tool]
18 const path = field === undefined ? undefined : input[field]
19 const files = typeof path === 'string' && !isError && !stats.files.includes(path) ? [...stats.files, path] : stats.files
20
21 return {
22 ...stats,
23 tools: { ...stats.tools, [tool]: (stats.tools[tool] ?? 0) + 1 },
24 failed: stats.failed + (isError ? 1 : 0),
25 files,
26 }
27}
28
29/** `mcp__linear__create_issue` reads as `linear.create_issue`. */
30export const toolLabel = (tool: string) => tool.replace(/^mcp__(.+?)__/, '$1.')
31
32/** `$0.42`, `$0.003`, `<$0.001`. */
33export function formatCost(usd: number): string {
34 if (usd < 0.001) return '<$0.001'
35 if (usd < 0.1) return `$${usd.toFixed(3)}`
36 return `$${usd.toFixed(2)}`
37}
38
39/**
40 * `14 tools (Bash 6, Edit 5, Read 3) · 2 failed · 4 files · $0.42`, or
41 * undefined when the turn did nothing worth a summary.
42 */
43export function summarize(stats: TurnStats, options: { topTools: number; isCostShown: boolean }): string | undefined {
44 const calls = Object.values(stats.tools).reduce((sum, n) => sum + n, 0)
45 const parts: string[] = []
46
47 if (calls > 0) {
48 const top = Object.entries(stats.tools)
49 .sort(([a, x], [b, y]) => y - x || a.localeCompare(b))
50 .slice(0, Math.max(0, options.topTools))
51 .map(([tool, n]) => `${toolLabel(tool)} ${n}`)
52 const rest = Object.keys(stats.tools).length - top.length
53 const breakdown = top.length > 0 ? ` (${top.join(', ')}${rest > 0 ? ', …' : ''})` : ''
54 parts.push(`${calls} ${calls === 1 ? 'tool' : 'tools'}${breakdown}`)
55 }
56 if (stats.failed > 0) parts.push(`${stats.failed} failed`)
57 if (stats.files.length > 0) parts.push(`${stats.files.length} ${stats.files.length === 1 ? 'file' : 'files'}`)
58 if (options.isCostShown && stats.costUsd !== undefined && stats.costUsd > 0) parts.push(formatCost(stats.costUsd))
59
60 return parts.length > 0 ? parts.join(' · ') : undefined
61}
62
63/** Keeps the newest `max` entries of an insertion-ordered record. */
64export function keepNewest<T>(record: Record<string, T>, max: number): Record<string, T> {
65 const entries = Object.entries(record)
66 return Object.fromEntries(entries.slice(Math.max(0, entries.length - max)))
67}
68types/index.d.ts 20 lines1export type TurnStats = {
2 /** Tool calls by tool name, subagents' included. */
3 tools: Record<string, number>
4 /** Calls whose result was an error. */
5 failed: number
6 /** Files Edit, Write or NotebookEdit changed. */
7 files: string[]
8 /** What the turn cost, in US dollars, when the session reports a cost. */
9 costUsd?: number
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'turn-stats': {
15 /** Summaries by the uuid of their turn_duration row (the line's requestId). */
16 lines: Record<string, TurnStats>
17 }
18 }
19}
20