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.

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:
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./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).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.
hooks/register.tsx 366 lines1import { 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}
366types/index.d.ts 34 lines1/** 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