SLOPSHOPPER

tell-me-the-damage

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

newbandguardprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tell-me-the-damage
› 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 ✓ 3 files · 42s [ Details ][ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
✓ 3 files · 42s [ Details ][ Hide ]
README

tell-me-the-damage

Mod. After a turn that used tools, a one-line band above the prompt: ✓ 3 files · tests pass · 41s · $0.12.

  • Red ✗ when a test command (go test, pytest, npm test, make test, ...) failed, ⏹ when the turn was stopped.
  • Details lists the changed files; Hide dismisses it. It clears on your next prompt.
  • Options: minTools (1), showCost (true; shown only when the session reports a cost).
  • Composes with the other bands (context bar, route chip, Compact button) instead of replacing them.

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.

Source 3 files
hooks/register.tsx 121 lines
1import { 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}
121
hooks/chips.ts 42 lines
1// 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)
42
types/index.d.ts 17 lines
1export 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