SLOPSHOPPER

timings

How long each prompt took, with its tokens and API cost, on the prompt's row and in a history pane (T in the footer, /timings) with totals.

newpanespinnerrowscommandtoast
v0.1.0no licenseupdated 2026-10-25eshin087/claude-code-mods/timings
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · timings
│ ┃ Timings ✕ › fix the failing auth test and add an audit log call │ ┃ Timings [ This chat ] [ Past chats ] [ Clos │ ┃ ⏺ Read(src/auth.ts) │ ┃ # Prompt Time Tokens Cost ⎿ Read 6 lines │ ┃ No finished prompts yet in this chat. ⏺ Update(src/auth.ts) │ ┃ ───────────────────────────────────────────… ⎿ Added 2 lines, removed 1 line │ ┃ Total · 0 prompts 0s 0 — ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Chat open 30m 00s │ ┃ Tokens 0 fresh · 0 cached · 0 out ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Cost — at API prices (what /cost show │ ✻ Worked for 42s · done 4:20 PM │ │ › /timings │ ⎿ timings: Timings opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⟨Claude Code's own drawing⟩ 🔸

Draws

Pane · Timings
Timings [ This chat ] [ Past chats ] [ Close ] # Prompt Time Tokens Cost No finished prompts yet in this chat. ───────────────────────────────────────────────────────────… Total · 0 prompts 0s 0 — Chat open 30m 00s Tokens 0 fresh · 0 cached · 0 out Cost — at API prices (what /cost shows)
README

Timings

What it does: shows how long each prompt took, how many tokens it used and what it would have cost at API prices, and the totals for the whole chat.

Where you see it:

  • On each prompt in the chat: once its reply finishes, the prompt's row gets a line under it: `` fix the footer colors ⏱ 3m 12s · 1.2M tokens · $0.84 `` Only how the row is drawn changes; the stored message stays as you typed it.
  • 🔸 in the footer, desktop and terminal: a small orange button that opens the history pane on a click and closes it on another (/timings does the same). It shows no time itself; the pane has every figure, and the pace panel shows the chat's working time (Worked 1h42m over 12 prompts).
  • The pane, This chat: one row per prompt (number, its first line, time, tokens, cost), then a rule and a bold Total row with the prompt count and each total under its own column. Below it: how long the chat has been open, the tokens split into fresh, cached and out, and the cost at API prices. ``` # Prompt Time Tokens Cost 1 fix the footer colors 3m 12s 1.2M $0.84 ───────────────────────────────────────────────────────────── Total · 1 prompt 3m 12s 1.2M $0.84

Chat open 3h 00m Tokens 50k fresh · 1.1M cached · 20k out Cost $0.84 at API prices (what /cost shows)

- **The pane, Past chats:** every chat of this project with a history, newest first: when it started, its prompts, its first prompt, working time, tokens and cost, with the project's totals in a Total row the same way. This chat is marked ▸.

**How it counts:**
- **Time:** from your prompt to the end of Claude's reply, as the engine measures it.
- **Tokens:** every reply in the prompt, its subagents' too: input read fresh (cache writes included), input read from the cache, and output.
- **Cost:** the engine's own cost ledger (what `/cost` shows) before and after the prompt, so it's API list prices, subagents included. On a subscription it's what the same work would have cost on the API.

**Data saved:** each chat in `~/.claude/mods-data/timings/<project>/<session>.json`, so a restarted chat keeps its history.

**Cost:** none. No model calls.
Source 2 files
hooks/register.tsx 366 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren, TurnUsage } from 'claude-code'
3
4import type { ChatSummary, ChatTimings, PromptRecord, PromptTokens } from '../types'
5
6// How long each prompt took, with its tokens and what it would have cost at
7// API prices: on the prompt's own row in the chat once its reply finishes
8// (⏱ 3m 12s · 1.2M tokens · $0.84), and in a pane, /timings or T in the
9// footer (T 1h42m: the chat's working time), with totals at the bottom and the
10// project's past chats a click away. Each chat is kept in
11// ~/.claude/mods-data/timings/<project>/<session>.json, so a restarted chat
12// keeps its history.
13
14const PANE = 'timings'
15const ACCENT = '#22d3ee'
16const GREY = '#9ca3af'
17const REDRAW_MS = 30_000
18
19const chat = atom({ plugin: 'timings', key: 'chat' } as const, null)
20const isOpen = atom({ plugin: 'timings', key: 'isOpen' } as const, false)
21const view = atom({ plugin: 'timings', key: 'view' } as const, 'chat')
22const past = atom({ plugin: 'timings', key: 'past' } as const, [])
23
24const ZERO: PromptTokens = { tokensIn: 0, tokensCached: 0, tokensOut: 0 }
25
26// The prompt under way: when it started, the cost ledger then, and its subagents' tokens so far.
27let running: { turnId: string; at: number; text: string; usdAtStart: number | null; extra: PromptTokens } | null = null
28
29const tokensOf = (u: TurnUsage | undefined): PromptTokens =>
30  u ? { tokensIn: u.input_tokens + u.cache_creation_input_tokens, tokensCached: u.cache_read_input_tokens, tokensOut: u.output_tokens } : ZERO
31const plus = (a: PromptTokens, b: PromptTokens): PromptTokens => ({
32  tokensIn: a.tokensIn + b.tokensIn,
33  tokensCached: a.tokensCached + b.tokensCached,
34  tokensOut: a.tokensOut + b.tokensOut,
35})
36const totalOf = (t: PromptTokens) => t.tokensIn + t.tokensCached + t.tokensOut
37
38// A prompt's row is found again by its text: its length and its start.
39const matchOf = (text: string) => `${text.length}:${text.slice(0, 200)}`
40const firstLine = (text: string) => {
41  const line = text.trim().split(/\r?\n/)[0] ?? ''
42  return line.length > 120 ? `${line.slice(0, 119)}…` : line || '(continued)'
43}
44
45// 45s, 3m 12s, 1h 42m; `isShort` drops the space (1h42m) for the footer.
46const duration = (ms: number, isShort = false) => {
47  const s = Math.max(0, Math.round(ms / 1000))
48  const sep = isShort ? '' : ' '
49  if (s < 60) return `${s}s`
50  const m = Math.floor(s / 60)
51  if (m < 60) return isShort ? `${m}m` : `${m}m${sep}${String(s % 60).padStart(2, '0')}s`
52  return `${Math.floor(m / 60)}h${sep}${String(m % 60).padStart(2, '0')}m`
53}
54const tokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
55const dollars = (n: number | null) => (n === null ? '—' : `$${n.toFixed(2)}`)
56const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
57const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
58const dateOf = (t: number) => {
59  const d = new Date(t)
60  const h = d.getHours()
61  const m = d.getMinutes()
62  return `${DAYS[d.getDay()]} ${MONTHS[d.getMonth()]} ${d.getDate()} ${h % 12 || 12}:${String(m).padStart(2, '0')}${h < 12 ? 'a' : 'p'}`
63}
64
65const projectOf = (dir: string) => {
66  const main = dir.replace(/[\\/]\.claude[\\/]worktrees[\\/].*$/i, '')
67  return main.split(/[\\/]/).filter(Boolean).pop() ?? main
68}
69
70const folderOf = async ($: EngineInterface, project: string) => {
71  // CLAUDE_CONFIG_DIR (set when Claude's folder lives on another drive) wins over ~/.claude
72  const dir = ((await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '.'}/.claude`).replace(/\\/g, '/')
73  return `${dir}/mods-data/timings/${project}`
74}
75
76const save = async ($: EngineInterface, c: ChatTimings) => $.fs.write(`${await folderOf($, c.project)}/${c.sessionId}.json`, JSON.stringify(c))
77
78const costNow = async ($: EngineInterface) => (await $.session.usage()).cost?.usd ?? null
79
80const workedOf = (c: ChatTimings | null) => (c?.prompts ?? []).reduce((n, p) => n + p.durationMs, 0)
81
82// Every chat of this project with a history file, newest first.
83const loadPast = async ($: EngineInterface) => {
84  const c = await read($, chat)
85  if (!c) return
86  const folder = await folderOf($, c.project)
87  const list: ChatSummary[] = []
88  for (const entry of await $.fs.list(folder).catch(() => [])) {
89    if (!entry.name.endsWith('.json')) continue
90    try {
91      const other = JSON.parse(await $.fs.read(`${folder}/${entry.name}`)) as ChatTimings
92      const costs = other.prompts.map(p => p.usd)
93      list.push({
94        sessionId: other.sessionId,
95        startedAt: other.startedAt,
96        title: other.prompts[0]?.prompt ?? '(no prompts)',
97        prompts: other.prompts.length,
98        workedMs: workedOf(other),
99        tokens: other.prompts.reduce((n, p) => n + totalOf(p), 0),
100        usd: costs.every(u => u === null) ? null : costs.reduce<number>((n, u) => n + (u ?? 0), 0),
101      })
102    } catch {
103      // A file this mod didn't write, or half written: left out.
104    }
105  }
106  list.sort((a, b) => b.startedAt - a.startedAt)
107  await update($, past, () => list)
108}
109
110const togglePane = async ($: EngineInterface) => {
111  if ((await $.ui.panes()).some(p => p.id === PANE)) {
112    await $.ui.close({ id: PANE })
113    // A plugin's own close does not reach its own ui.close hook: reset the flag here.
114    await update($, isOpen, () => false)
115    return 'Timings closed.'
116  }
117  await loadPast($)
118  const opened = await $.ui.open({ id: PANE, title: 'Timings' })
119  if (opened.isPlaced) await update($, isOpen, () => true)
120  else $.ui.toast(`The timings pane could not open (${opened.reason}).`)
121  return opened.isPlaced ? 'Timings opened.' : `Could not open the pane (${opened.reason}).`
122}
123
124export const register: Register = on => {
125  on('session.start', async ($, e, next) => {
126    await $.command.register({ name: 'timings', description: "Each prompt's time, tokens and cost in this chat, with totals; past chats too" })
127    const sessionId = await $.session.id()
128    const project = projectOf(await $.session.cwd())
129    const file = `${await folderOf($, project)}/${sessionId}.json`
130    let saved: ChatTimings | null = null
131    try {
132      saved = JSON.parse(await $.fs.read(file)) as ChatTimings
133    } catch {
134      saved = null
135    }
136    const startedAt = (await $.session.usage()).startedAt
137    await update($, chat, () => saved ?? { sessionId, project, startedAt, prompts: [] })
138    // The footer's total and the chat's open time move on their own.
139    $.clock.every(REDRAW_MS, () => $.ui.invalidate('ui.render'))
140    return next(e)
141  })
142
143  on('command.run', { command: 'timings' }, async $ => ({ text: await togglePane($) }))
144
145  on('ui.close', { id: PANE }, async ($, e, next) => {
146    const done = await next(e)
147    await update($, isOpen, () => false)
148    return done
149  })
150
151  on('turn.start', async ($, e, next) => {
152    running = { turnId: e.turnId, at: await $.clock.now(), text: e.text, usdAtStart: await costNow($), extra: ZERO }
153    return next(e)
154  })
155
156  on('turn.complete', async ($, e, next) => {
157    const done = await next(e)
158    const used = tokensOf(done.usage ?? e.usage)
159    // A subagent's turn: its tokens go to the prompt that ran it.
160    if (e.agentId !== undefined) {
161      if (running) running = { ...running, extra: plus(running.extra, used) }
162      return done
163    }
164    if (!running || running.turnId !== e.turnId) return done
165    const now = await costNow($)
166    const r = running
167    running = null
168    const record: PromptRecord = {
169      ...plus(used, r.extra),
170      turnId: r.turnId,
171      at: r.at,
172      prompt: firstLine(r.text),
173      match: matchOf(r.text),
174      durationMs: e.durationMs,
175      // To the hundredth of a cent: the ledger's difference carries float noise.
176      usd: now === null || r.usdAtStart === null ? null : Math.max(0, Math.round((now - r.usdAtStart) * 10_000) / 10_000),
177    }
178    await update($, chat, c => (c ? { ...c, prompts: [...c.prompts, record] } : c))
179    const c = await read($, chat)
180    if (c) await save($, c)
181    $.ui.invalidate('ui.render')
182    return done
183  })
184
185  // Each finished prompt's row in the chat gets a line: its time, tokens and cost.
186  // The row's text is what's rewritten (drawn on every surface); the stored message stays.
187  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
188    const c = await read($, chat)
189    const key = matchOf(e.props.text)
190    const r = c?.prompts.findLast(p => p.match === key)
191    if (!r) return next(e)
192    const usd = r.usd === null ? '' : ` · ${dollars(r.usd)}`
193    return next({ ...e, props: { ...e.props, text: `${e.props.text}\n⏱ ${duration(r.durationMs)} · ${tokens(totalOf(r))} tokens${usd}` } })
194  })
195
196  // T in the footer: the chat's working time so far, a press opens or closes the pane.
197  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
198    const below = await next(e)
199    const { Box, Button } = $.ui.resolve(e)
200    // Just an orange diamond: it toggles the pane, and the pane has the times.
201    return (
202      <Box flexDirection="row" gap={1}>
203        {below}
204        <Button key="timings:t" label="🔸" plain dimColor onPress={() => togglePane($)} />
205      </Box>
206    )
207  })
208
209  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
210    const { Box, Text, Button } = $.ui.resolve(e)
211    const c = await read($, chat)
212    const v = await read($, view)
213    const now = await $.clock.now()
214    const prompts = c?.prompts ?? []
215
216    // One row of a table: fixed columns, the text column takes the rest and is
217    // cut with "…". The head is grey; the total is bold, its numbers in their columns.
218    type Cells = { n: string; text: string; time: string; tokens: string; cost: string }
219    const row = (key: string, cells: Cells, kind: 'head' | 'body' | 'total' = 'body') => {
220      const isHead = kind === 'head'
221      const isTotal = kind === 'total'
222      return (
223        <Box key={key} flexDirection="row" gap={1}>
224          <Box width={4} flexShrink={0}>
225            <Text color={GREY}>{cells.n}</Text>
226          </Box>
227          <Box flexGrow={1} flexShrink={1} minWidth={0}>
228            <Text bold={isTotal} color={isHead ? GREY : undefined} wrap="truncate-end">
229              {cells.text}
230            </Text>
231          </Box>
232          <Box width={9} flexShrink={0}>
233            <Text bold={isTotal} color={isHead ? GREY : ACCENT}>
234              {cells.time}
235            </Text>
236          </Box>
237          <Box width={8} flexShrink={0}>
238            <Text bold={isTotal} color={isTotal ? undefined : GREY}>
239              {cells.tokens}
240            </Text>
241          </Box>
242          <Box width={8} flexShrink={0}>
243            <Text bold={isTotal} color={isTotal ? undefined : GREY}>
244              {cells.cost}
245            </Text>
246          </Box>
247        </Box>
248      )
249    }
250    // A thin rule above the total, cut to the pane's width.
251    const rule = (
252      <Text color={GREY} wrap="truncate-end">
253        {'─'.repeat(240)}
254      </Text>
255    )
256    // The details under the table: a grey label column, the value beside it.
257    const detail = (key: string, label: string, value: RenderChildren) => (
258      <Box key={key} flexDirection="row" gap={1}>
259        <Box width={11} flexShrink={0}>
260          <Text color={GREY}>{label}</Text>
261        </Box>
262        <Box flexGrow={1} flexShrink={1} minWidth={0}>
263          {value}
264        </Box>
265      </Box>
266    )
267    // 50k fresh · 1.1M cached · 20k out: the numbers full strength, the words grey.
268    const breakdown = (t: PromptTokens) => (
269      <Text wrap="truncate-end">
270        {tokens(t.tokensIn)}
271        <Text color={GREY}> fresh · </Text>
272        {tokens(t.tokensCached)}
273        <Text color={GREY}> cached · </Text>
274        {tokens(t.tokensOut)}
275        <Text color={GREY}> out</Text>
276      </Text>
277    )
278
279    const tabs = (
280      <Box flexDirection="row" gap={1}>
281        <Text bold color={ACCENT}>
282          Timings
283        </Text>
284        <Button key="view:chat" label="This chat" variant={v === 'chat' ? 'primary' : 'secondary'} onPress={() => update($, view, () => 'chat' as const)} />
285        <Button key="view:past" label="Past chats" variant={v === 'past' ? 'primary' : 'secondary'} onPress={async () => (await loadPast($), update($, view, () => 'past' as const))} />
286        <Box flexGrow={1} />
287        <Button key="close" label="Close" role="dismiss" onPress={() => togglePane($)} />
288      </Box>
289    )
290
291    if (v === 'past') {
292      const list = await read($, past)
293      const usd = list.every(p => p.usd === null) ? null : list.reduce((n, p) => n + (p.usd ?? 0), 0)
294      return (
295        <Box flexDirection="column" gap={1}>
296          {tabs}
297          <Box flexDirection="column">
298            {row('head', { n: '', text: `Chats in ${c?.project ?? 'this project'}`, time: 'Worked', tokens: 'Tokens', cost: 'Cost' }, 'head')}
299            {list.map(p =>
300              row(`chat:${p.sessionId}`, {
301                n: p.sessionId === c?.sessionId ? '▸' : '',
302                text: `${dateOf(p.startedAt)} · ${p.prompts} ${p.prompts === 1 ? 'prompt' : 'prompts'} · ${p.title}`,
303                time: duration(p.workedMs),
304                tokens: tokens(p.tokens),
305                cost: dollars(p.usd),
306              }),
307            )}
308            {rule}
309            {row(
310              'total',
311              {
312                n: '',
313                text: `Total · ${list.length} ${list.length === 1 ? 'chat' : 'chats'}`,
314                time: duration(list.reduce((n, p) => n + p.workedMs, 0)),
315                tokens: tokens(list.reduce((n, p) => n + p.tokens, 0)),
316                cost: dollars(usd),
317              },
318              'total',
319            )}
320          </Box>
321        </Box>
322      )
323    }
324
325    const sum = prompts.reduce((t, p) => plus(t, p), ZERO)
326    const costs = prompts.map(p => p.usd)
327    const usd = costs.length === 0 || costs.every(u => u === null) ? null : costs.reduce<number>((n, u) => n + (u ?? 0), 0)
328    return (
329      <Box flexDirection="column" gap={1}>
330        {tabs}
331        <Box flexDirection="column">
332          {row('head', { n: '#', text: 'Prompt', time: 'Time', tokens: 'Tokens', cost: 'Cost' }, 'head')}
333          {prompts.length === 0 && <Text color={GREY}>No finished prompts yet in this chat.</Text>}
334          {prompts.map((p, i) =>
335            row(`prompt:${p.turnId}`, { n: String(i + 1), text: p.prompt, time: duration(p.durationMs), tokens: tokens(totalOf(p)), cost: dollars(p.usd) }),
336          )}
337          {rule}
338          {row(
339            'total',
340            {
341              n: '',
342              text: `Total · ${prompts.length} ${prompts.length === 1 ? 'prompt' : 'prompts'}`,
343              time: duration(workedOf(c)),
344              tokens: tokens(totalOf(sum)),
345              cost: dollars(usd),
346            },
347            'total',
348          )}
349        </Box>
350        <Box flexDirection="column">
351          {detail('open', 'Chat open', <Text>{duration(c ? now - c.startedAt : 0)}</Text>)}
352          {detail('tokens', 'Tokens', breakdown(sum))}
353          {detail(
354            'cost',
355            'Cost',
356            <Text wrap="truncate-end">
357              {dollars(usd)}
358              <Text color={GREY}> at API prices (what /cost shows)</Text>
359            </Text>,
360          )}
361        </Box>
362      </Box>
363    )
364  })
365}
366
types/index.d.ts 34 lines
1/** Tokens over a prompt's replies, subagents' included: read fresh (cache writes too), read from the cache, written out. */
2export type PromptTokens = { tokensIn: number; tokensCached: number; tokensOut: number }
3
4/** One finished prompt. */
5export type PromptRecord = PromptTokens & {
6  turnId: string
7  /** When it was sent (ms). */
8  at: number
9  /** Its first line, for the pane. */
10  prompt: string
11  /** What its row in the chat shows, to find the row again: length and start of the text. */
12  match: string
13  durationMs: number
14  /** What it cost at API prices, as /cost counts it; null where the host keeps no cost ledger. */
15  usd: number | null
16}
17
18/** One chat's history, as ~/.claude/mods-data/timings/<project>/<session>.json keeps it. */
19export type ChatTimings = { sessionId: string; project: string; startedAt: number; prompts: PromptRecord[] }
20
21/** A chat in the Past chats list. */
22export type ChatSummary = { sessionId: string; startedAt: number; title: string; prompts: number; workedMs: number; tokens: number; usd: number | null }
23
24declare module 'claude-code' {
25  interface PluginState {
26    timings: {
27      chat: ChatTimings | null
28      isOpen: boolean
29      view: 'chat' | 'past'
30      past: ChatSummary[]
31    }
32  }
33}
34