Mod: one-line band after each working turn: files changed, tests, time, cost, with expandable details

Mod. After a turn that used tools, a one-line band above the prompt: ✓ 3 files · tests pass · 41s · $0.12.
✗ when a test command (go test, pytest, npm test, make test, ...) failed, ⏹ when the turn was stopped.minTools (1), showCost (true; shown only when the session reports a cost).It is a chip: it joins the one shared chip row with the route and Compact chips instead of taking its own line; the file list opens below the bands.
hooks/register.tsx 121 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Summary, TestState } from '../types'
5
6import { RANK, chipKey, injectChips, isEmpty } from './chips'
7
8const summary = atom({ plugin: 'tell-me-the-damage', key: 'summary' } as const, null)
9
10const TEST = /\b(go test|pytest|jest|vitest|cargo test|npm (run )?test|pnpm test|yarn test|make test)\b/
11
12export const isTestCommand = (cmd: string): boolean => TEST.test(cmd)
13
14export const fmtDuration = (ms: number): string => {
15 const s = Math.round(ms / 1000)
16 return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s`
17}
18
19export const fmtUsd = (usd: number): string => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
20
21const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`
22
23/** The one-line band text. */
24export const formatSummary = (s: Summary, showCost = true): string => {
25 const parts: string[] = []
26 parts.push(s.files.length > 0 ? plural(s.files.length, 'file') : plural(s.tools, 'tool call'))
27 if (s.tests !== 'none') parts.push(`tests ${s.tests}`)
28 parts.push(fmtDuration(s.ms))
29 if (showCost && s.usd !== null && s.usd > 0) parts.push(fmtUsd(s.usd))
30 let icon = '✓'
31 if (s.reason !== 'answer') icon = '⏹'
32 else if (s.tests === 'fail') icon = '✗'
33 return `${icon} ${parts.join(' · ')}`
34}
35
36export const register: Register = (on, options) => {
37 const minTools = typeof options.minTools === 'number' ? options.minTools : 1
38 let files = new Set<string>()
39 let tests: TestState = 'none'
40 let tools = 0
41 let costBefore: number | null = null
42
43 on('prompt.submit', async ($, e, next) => {
44 files = new Set()
45 tests = 'none'
46 tools = 0
47 const u = await $.session.usage().catch(() => undefined)
48 costBefore = u?.cost?.usd ?? null
49 await update($, summary, () => null)
50 return next(e)
51 })
52
53 for (const tool of ['Edit', 'Write'] as const) {
54 on('tool.call', { tool }, ($, e, next) => {
55 tools += 1
56 files.add(e.file_path)
57 return next(e)
58 })
59 }
60
61 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
62 tools += 1
63 const ran = await next(e)
64 if (isTestCommand(e.command) && ran.deny === undefined) tests = ran.isError ? 'fail' : 'pass'
65 return ran
66 })
67
68 on('turn.complete', async ($, e, next) => {
69 if (tools >= minTools || files.size > 0) {
70 const u = await $.session.usage().catch(() => undefined)
71 const now = u?.cost?.usd ?? null
72 const usd = now !== null && costBefore !== null ? Math.max(0, now - costBefore) : null
73 await update($, summary, () => ({ files: [...files], tests, tools, ms: e.durationMs, usd, reason: e.reason, isOpen: false }))
74 }
75 return next(e)
76 })
77
78 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
79 const s = await read($, summary)
80
81 if (!s || e.props.hasSurvey || e.props.isWorking) return next(e)
82
83 const { Box, Text, Button } = $.ui.resolve(e)
84 const rest = await next(e)
85 const color = s.reason !== 'answer' || s.tests === 'fail' ? 'red' : 'green'
86 const names = s.files.slice(0, 8).map(f => f.split('/').slice(-2).join('/'))
87 const chip = (
88 <Box key={chipKey(RANK.result, 'result')}>
89 <Text key="hdtg" color={color}>{formatSummary(s, options.showCost !== false)} </Text>
90 {s.files.length > 0 && (
91 <Button key="details" label={s.isOpen ? 'Less' : 'Details'} onPress={() => update($, summary, x => (x ? { ...x, isOpen: !x.isOpen } : x))} />
92 )}
93 <Button key="hide" label="Hide" onPress={() => update($, summary, () => null)} />
94 </Box>
95 )
96 const joined = injectChips(rest, chip)
97 const base = joined ?? (isEmpty(rest) ? (
98 <Box key="chips" flexWrap="wrap">{chip}</Box>
99 ) : (
100 <Box flexDirection="column">
101 <Box key="chips" flexWrap="wrap">{chip}</Box>
102 {rest}
103 </Box>
104 ))
105
106 if (!s.isOpen) return base as never
107
108 return (
109 <Box flexDirection="column">
110 {base}
111 {names.map(n => (
112 <Text key={`f-${n}`} dimColor>
113 {' '}{n}
114 </Text>
115 ))}
116 {s.files.length > names.length && <Text dimColor>{' '}+{s.files.length - names.length} more</Text>}
117 </Box>
118 )
119 })
120}
121hooks/chips.ts 42 lines1// Chip row: small status chips from different plugins share one row above the prompt.
2// - The row is a Box with props.key === 'chips' (wrapping, so a narrow terminal gets a second line
3// instead of cut-off buttons).
4// - Each chip is a Box keyed `chip:<rank>:<name>`; chips sort by rank so the order is the same
5// whichever plugin happens to draw first: gate 5, context 10, route 20, result 30.
6// Plain data in, plain data out, so it works on whatever `next(e)` returns.
7export type Node = { type?: string; props?: Record<string, unknown>; children?: unknown[] }
8
9export const CHIPS_KEY = 'chips'
10export const GAP = ' '
11export const RANK = { gate: 5, context: 10, route: 20, result: 30 } as const
12
13export const chipKey = (rank: number, name: string): string => `chip:${String(rank).padStart(3, '0')}:${name}`
14
15const isNode = (n: unknown): n is Node => typeof n === 'object' && n !== null
16
17const keyOf = (n: unknown): string => (isNode(n) && typeof n.props?.key === 'string' ? n.props.key : '')
18
19/** Chips in rank order with a gap between each. */
20export const arrange = (chips: unknown[]): unknown[] =>
21 [...chips]
22 .sort((a, b) => keyOf(a).localeCompare(keyOf(b)))
23 .flatMap((c, i) => (i === 0 ? [c] : [GAP, c]))
24
25/** Add `chip` to the chips row inside `node`; undefined when there is none. */
26export const injectChips = (node: unknown, chip: unknown): Node | undefined => {
27 if (!isNode(node)) return undefined
28 if (node.props?.key === CHIPS_KEY) {
29 const existing = (node.children ?? []).filter(c => c !== GAP)
30 return { ...node, children: arrange([...existing, chip]) }
31 }
32 const kids = node.children ?? []
33 for (let i = kids.length - 1; i >= 0; i--) {
34 const hit = injectChips(kids[i], chip)
35 if (hit) return { ...node, children: kids.map((c, j) => (j === i ? hit : c)) }
36 }
37 return undefined
38}
39
40/** True when the band beneath drew nothing. */
41export const isEmpty = (node: unknown): boolean => node == null || (isNode(node) && (node.children ?? []).length === 0)
42types/index.d.ts 17 lines1export type TestState = 'none' | 'pass' | 'fail'
2export type Summary = {
3 files: string[]
4 tests: TestState
5 tools: number
6 ms: number
7 usd: number | null
8 reason: string
9 isOpen: boolean
10}
11
12declare module 'claude-code' {
13 interface PluginState {
14 'tell-me-the-damage': { summary: Summary | null }
15 }
16}
17