SLOPSHOPPER

session-status

Shows the agent's busy time, turn count, tool calls, tokens, context fill and cost above the prompt.

newbandguardstatustimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-status
› 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 Busy 00:00 · 1 turn · 9 tool calls Context 49% · 97.4k in (93% cached) 1.5k out Bash 4 · Write 2 · Read 1 · other 2 $0.42 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Busy 00:00 · 1 turn · 9 tool calls Context 49% · 97.4k in (93% cached) 1.5k out Bash 4 · Write 2 · Read 1 · other 2 $0.42
README

session-status

A Claude Code mod that draws a small stats band above the prompt:

Busy 02:06 · 9 turns · 15 tool calls          Context 13% · 15.0k in (100% cached) 72 out
Bash 4 · Read 3                                                                      $0.99
  • Busy time counts only the time the main agent is working, not idle time between turns.
  • Turns and tool calls are counted for the session, with the top three tools by name.
  • Subagents show their summed run time and run count once one has run.
  • Tokens show total input (with the share served from the prompt cache) and output.
  • Context and cost come from the session's own measurements and update after each turn.

The busy time, turns and tool calls also appear in the status line while the agent works.

Install it from the marketplace in this repository; see the repository README.

Source 2 files
hooks/register.tsx 187 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Stats } from '../types'
5
6const EMPTY: Stats = {
7  total: 0,
8  since: null,
9  now: 0,
10  turns: 0,
11  tools: 0,
12  byTool: {},
13  subMs: 0,
14  subTurns: 0,
15  inputTokens: 0,
16  outputTokens: 0,
17  cacheReadTokens: 0,
18  cacheWriteTokens: 0,
19  context: null,
20  usd: null,
21}
22
23const stats = atom({ plugin: 'session-status', key: 'stats' } as const, EMPTY)
24
25// State kept across a reload may predate a field, so fill gaps from EMPTY.
26const patch = (s: Stats, change: Partial<Stats>): Stats => ({ ...EMPTY, ...s, ...change })
27
28const SHOWN = 3
29
30const format = (ms: number) => {
31  const seconds = Math.floor(ms / 1000)
32  const h = Math.floor(seconds / 3600)
33  const m = String(Math.floor((seconds % 3600) / 60)).padStart(2, '0')
34  const s = String(seconds % 60).padStart(2, '0')
35
36  return h > 0 ? `${h}:${m}:${s}` : `${m}:${s}`
37}
38
39const compact = (n: number) =>
40  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(n)
41
42const label = (tool: string) => (tool.startsWith('mcp__') ? tool.split('__').pop() ?? tool : tool)
43
44const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
45
46const busy = (s: Stats) => s.total + (s.since === null ? 0 : s.now - s.since)
47
48const join = (parts: (string | null)[]) => parts.filter(Boolean).join(' · ')
49
50const summary = (s: Stats) =>
51  join([
52    `Busy ${format(busy(s))}`,
53    plural(s.turns, 'turn'),
54    plural(s.tools, 'tool call'),
55    s.subTurns > 0 ? `Subagents ${format(s.subMs)} (${s.subTurns})` : null,
56  ])
57
58const byTool = (s: Stats) => {
59  const ranked = Object.entries(s.byTool).sort((a, b) => b[1] - a[1])
60  const shown = ranked.slice(0, SHOWN).map(([tool, n]) => `${label(tool)} ${n}`)
61  const rest = ranked.slice(SHOWN).reduce((sum, [, n]) => sum + n, 0)
62
63  return rest > 0 ? [...shown, `other ${rest}`] : shown
64}
65
66const tokens = (s: Stats) => {
67  const input = s.inputTokens + s.cacheReadTokens + s.cacheWriteTokens
68
69  if (input + s.outputTokens === 0) {
70    return null
71  }
72
73  const cached = input === 0 ? 0 : Math.round((s.cacheReadTokens / input) * 100)
74
75  return `${compact(input)} in (${cached}% cached) ${compact(s.outputTokens)} out`
76}
77
78// Row one: time and counts on the left, context and tokens on the right. Row two: tools on the left, cost on the right.
79const rows = (s: Stats) => ({
80  left: summary(s),
81  right: join([s.context === null ? null : `Context ${s.context}%`, tokens(s)]),
82  tools: byTool(s).join(' · '),
83  usd: s.usd === null ? '' : `$${s.usd.toFixed(2)}`,
84})
85
86export const register: Register = on => {
87  on('session.start', async ($, e, next) => {
88    $.clock.every(1000, async () => {
89      const current = patch(await read($, stats), {})
90
91      if (current.since === null) {
92        return
93      }
94
95      const now = await $.clock.now()
96      await update($, stats, s => patch(s, { now }))
97      $.ui.status(summary({ ...current, now }))
98    })
99
100    return next(e)
101  })
102
103  on('turn.start', async ($, e, next) => {
104    if (e.agentId === undefined) {
105      const now = await $.clock.now()
106      await update($, stats, s => patch(s, { since: s.since ?? now, now, turns: s.turns + 1 }))
107    }
108
109    return next(e)
110  })
111
112  on('tool.call', async ($, e, next) => {
113    await update($, stats, s => {
114      const counts = patch(s, {}).byTool
115
116      return patch(s, { tools: s.tools + 1, byTool: { ...counts, [e.tool]: (counts[e.tool] ?? 0) + 1 } })
117    })
118
119    return next(e)
120  })
121
122  on('turn.complete', async ($, e, next) => {
123    const now = await $.clock.now()
124    const usage = e.usage
125
126    if (usage !== undefined) {
127      await update($, stats, s => {
128        const current = patch(s, {})
129
130        return patch(s, {
131          inputTokens: current.inputTokens + usage.input_tokens,
132          outputTokens: current.outputTokens + usage.output_tokens,
133          cacheReadTokens: current.cacheReadTokens + usage.cache_read_input_tokens,
134          cacheWriteTokens: current.cacheWriteTokens + usage.cache_creation_input_tokens,
135        })
136      })
137    }
138
139    if (e.agentId === undefined) {
140      await update($, stats, s =>
141        patch(s, {
142          total: s.since === null ? s.total : s.total + now - s.since,
143          since: null,
144          now,
145        }),
146      )
147      $.ui.status(undefined)
148    } else {
149      await update($, stats, s =>
150        patch(s, { subMs: (s.subMs ?? 0) + e.durationMs, subTurns: (s.subTurns ?? 0) + 1 }),
151      )
152    }
153
154    return next(e)
155  })
156
157  on('session.measure', async ($, e, next) => {
158    await update($, stats, s =>
159      patch(s, { context: e.context.percent ?? s.context, usd: e.cost?.usd ?? s.usd }),
160    )
161
162    return next(e)
163  })
164
165  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
166    if (e.props.hasSurvey) {
167      return next(e)
168    }
169
170    const { Box, Text } = $.ui.resolve(e)
171    const { left, right, tools, usd } = rows(patch(await read($, stats), {}))
172
173    return (
174      <Box flexDirection="column">
175        <Box justifyContent="space-between">
176          <Text dimColor>{left}</Text>
177          <Text dimColor>{right}</Text>
178        </Box>
179        <Box justifyContent="space-between">
180          <Text dimColor>{tools}</Text>
181          <Text dimColor>{usd}</Text>
182        </Box>
183      </Box>
184    )
185  })
186}
187
types/index.d.ts 37 lines
1export type Stats = {
2  /** Milliseconds of finished busy turns. */
3  total: number
4  /** When the running main turn started, or null while idle. */
5  since: number | null
6  /** The latest tick while a turn runs. */
7  now: number
8  /** Main-agent turns started. */
9  turns: number
10  /** Tool calls made, subagents' included. */
11  tools: number
12  /** Tool calls by tool name. */
13  byTool: Record<string, number>
14  /** Milliseconds of finished subagent turns, summed; parallel runs overlap. */
15  subMs: number
16  /** Subagent turns finished. */
17  subTurns: number
18  /** Uncached input tokens, every turn and subagent turn summed. */
19  inputTokens: number
20  /** Output tokens, summed. */
21  outputTokens: number
22  /** Input tokens the prompt cache served, summed. */
23  cacheReadTokens: number
24  /** Input tokens written to the prompt cache, summed. */
25  cacheWriteTokens: number
26  /** Context window fill as a whole percentage, or null before the first response. */
27  context: number | null
28  /** Cost so far in US dollars, or null where the host keeps no ledger. */
29  usd: number | null
30}
31
32declare module 'claude-code' {
33  interface PluginState {
34    'session-status': { stats: Stats }
35  }
36}
37