Shows how long each turn took, in a small stopwatch chip under the last block of each reply.

A Claude Code mod that shows how long each turn took, in a small chip under Claude's reply:

The desktop app's Code tab tells you when a turn finished, but not how long Claude worked on it. turn-timer adds that, quietly, under the last block of each reply.
1m 39s. Under a minute it's seconds (42s), over an hour it's hours and minutes (1h 04m).stopped · 1m 05s, like this:
Orange is saved for this one case, because an interrupted turn is the one worth noticing.
took 3m 12s.The chip only changes what's drawn. The stored reply, and what Claude reads back, stay exactly as they were.
Mods install from a terminal. The desktop app's Code tab doesn't offer the /plugin command, so run these once in PowerShell, Terminal, or any shell where claude works:
claude plugin marketplace add Barncore/turn-timer
claude plugin install turn-timer@turn-timer --scope user
Then restart the desktop app, or start a new claude session.
To update later:
claude plugin marketplace update turn-timer
claude plugin update turn-timer@turn-timer --scope user
Then restart again. A running session keeps the copy it started with.
The Code tab draws a block under an id of its own (msg_…-t0), not the id the reply arrived with, and Claude Code doesn't hand that id to mods. So turn-timer links the two: the block drawn for the first time during this turn whose closing words match the reply's. A reply drawn before the turn started can't match, so an older reply ending with the same words, say Done., never borrows a newer reply's time. If the Code tab only draws the block after the turn ends, turn-timer links the first new block drawn with those closing words. The terminal draws a block under the id the reply arrived with, so there it matches by id.
The stopwatch is one mid-grey (#8a8883) that reads on both the light and the dark theme, so turn-timer doesn't need to know which theme you use.
From claude plugin validate on Claude Code 2.1.296:
hooks: turn.start, session.append, turn.complete, ui.render{component=AssistantMessage}
calls: $.clock.after, $.state.get, $.state.set, $.ui.resolve
In plain words: it sees turns start and end and replies arrive, and it draws under replies. It saves one small value per turn in the session's state. The clock call saves a link made while drawing once the drawing is done. It has no file, network or process access.
claude plugin validate .
claude plugin test .
MIT, see LICENSE.
hooks/register.ts 187 lines1// turn-timer: a quiet "took 3m 12s" line under the last block of each reply.
2// The desktop's own footer says when a turn finished, not how long it took.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5import type { Finished } from '../types'
6
7const finished = atom({ plugin: 'turn-timer', key: 'finished' } as const, {})
8/** Ids kept for scrolling back through older replies; the oldest go first. A turn stores at most two, so this holds at least 200 turns. */
9const KEEP = 400
10/** Render ids remembered in the order they were first drawn; the oldest go first. */
11const SEEN = 1000
12
13type Drawn = {
14 /** When this module first saw the id drawn, counted in render ids. */
15 seq: number
16 /** The end of the block's text as it was last drawn. */
17 tail: string
18}
19
20type State = {
21 /** The transcript id of the turn's last assistant row that holds text. */
22 lastRowId: string | null
23 /** The end of that row's text, to recognise its block on a surface that draws it under another id. */
24 lastTail: string | null
25 /** How many render ids this module has seen drawn. */
26 seq: number
27 /** Every render id seen drawn, in the order first seen. */
28 seen: Map<string, Drawn>
29 /** `seq` when the current turn started: ids first drawn after it belong to this turn. */
30 turnStart: number
31 /** A finished turn whose block the desktop hadn't drawn yet when the turn ended. */
32 pending: { line: string; tail: string; since: number } | null
33}
34
35export function fmtTook(ms: number): string {
36 const total = Math.max(0, Math.round(ms / 1000))
37 if (total < 60) return `${total}s`
38 const m = Math.floor(total / 60)
39 if (m < 60) return `${m}m ${String(total % 60).padStart(2, '0')}s`
40 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
41}
42
43/** The end of a block's text, control characters and spacing evened out: its last 160 characters. */
44export function tailOf(text: string): string {
45 return text.replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim().slice(-160)
46}
47
48/** The newest render id first drawn after `since` whose text ends as `tail` does. */
49export function newestDrawn(seen: ReadonlyMap<string, Drawn>, tail: string, since: number): string | undefined {
50 let found: string | undefined
51 for (const [id, drawn] of seen) if (drawn.seq > since && drawn.tail === tail) found = id
52 return found
53}
54
55/** Notes a render id as drawn with this text, and says when it was first drawn. */
56function noteDrawn(s: State, id: string, tail: string): Drawn {
57 let drawn = s.seen.get(id)
58 if (drawn === undefined) {
59 drawn = { seq: ++s.seq, tail }
60 s.seen.set(id, drawn)
61 if (s.seen.size > SEEN) s.seen.delete(s.seen.keys().next().value as string)
62 }
63 drawn.tail = tail
64 return drawn
65}
66
67/** The block's markdown with the line appended: the fallback for surfaces that draw no Svg. */
68export function withLine(text: string, line: string): string {
69 return `${text}\n\n*${line}*`
70}
71
72/** The chip's words: `3m 12s` for a finished turn, `stopped · 1m 05s` for an interrupted one. */
73export function chipText(line: string): string {
74 return line.startsWith('stopped after ') ? `stopped · ${line.slice('stopped after '.length)}` : line.replace(/^took /, '')
75}
76
77// One mid-grey reads on both the light and the dark theme, so the icon needs no theme of its own.
78// The stop square keeps cache-watch's orange: an interrupted turn is the one worth noticing.
79const ICON_GREY = '#8a8883'
80const STOP_ORANGE = '#d97757'
81
82export const CLOCK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="${ICON_GREY}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">` +
83 '<circle cx="8" cy="9" r="5.75"/><path d="M8 6.25V9l1.75 1.25M6.5 1.75h3M8 1.75v1.5M12.6 4.4l.9-.9"/></svg>'
84
85export const STOP_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16">` +
86 `<rect x="3.5" y="3.5" width="9" height="9" rx="1.75" fill="${STOP_ORANGE}"/></svg>`
87
88async function store($: EngineInterface, ids: string[], line: string) {
89 await update($, finished, prev => {
90 const next: Finished = { ...(prev ?? {}) }
91 for (const id of ids) {
92 delete next[id]
93 next[id] = line
94 }
95 const all = Object.keys(next)
96 for (const old of all.slice(0, Math.max(0, all.length - KEEP))) delete next[old]
97 return next
98 })
99}
100
101async function remember($: EngineInterface, s: State, line: string) {
102 const rowId = s.lastRowId
103 const tail = s.lastTail
104 const since = s.turnStart
105 s.lastRowId = null
106 s.lastTail = null
107 s.turnStart = s.seq
108 s.pending = null
109 if (rowId === null || tail === null) return
110 // The terminal draws a block under its row id. The desktop draws it under an id of its own
111 // (`msg_…-t0`) that no event hands a mod, so link the two: the block drawn this turn whose
112 // text ends as the row's does. A reply drawn before the turn started can't match, whatever its words.
113 const drawn = newestDrawn(s.seen, tail, since)
114 if (drawn === undefined) s.pending = { line, tail, since }
115 // One state write per turn, at its end: rare enough not to disturb other mods' buttons.
116 await store($, drawn === undefined || drawn === rowId ? [rowId] : [rowId, drawn], line)
117}
118
119export const register: Register = on => {
120 const s: State = { lastRowId: null, lastTail: null, seq: 0, seen: new Map(), turnStart: 0, pending: null }
121
122 on('turn.start', async ($, e, next) => {
123 s.turnStart = s.seq
124 // A block still unlinked stays bare, rather than lending its time to this turn's blocks.
125 s.pending = null
126 return next(e)
127 })
128 .catch(($, e, next) => next(e))
129
130 on('session.append', async ($, e, next) => {
131 const r = await next(e)
132 if (e.agentId === undefined && e.door === 'response' && e.message.role === 'assistant') {
133 const text = e.message.content
134 .map(block => {
135 const body = (block as { text?: unknown }).text
136 return block.type === 'text' && typeof body === 'string' ? body : ''
137 })
138 .join('\n')
139 if (text.trim() !== '') {
140 s.lastRowId = e.uuid
141 s.lastTail = tailOf(text)
142 }
143 }
144 return r
145 })
146 // The timer must never hold up a reply: on a failure, pass the row through as stored.
147 .catch(($, e, next) => next(e))
148
149 on('turn.complete', async ($, e, next) => {
150 const r = await next(e)
151 if (e.agentId !== undefined) return r
152 await remember($, s, e.isAborted ? `stopped after ${fmtTook(e.durationMs)}` : `took ${fmtTook(e.durationMs)}`)
153 return r
154 })
155 .catch(($, e, next) => next(e))
156
157 // A rewrite changes the drawing only; the stored reply, and what Claude reads, stay as they were.
158 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
159 if (e.props.isSummary) return next(e)
160 const tail = tailOf(e.props.text)
161 const drawn = noteDrawn(s, e.requestId, tail)
162 let line = ((await read($, finished)) ?? {})[e.requestId]
163 const late = s.pending
164 if (line === undefined && late !== null && drawn.seq > late.since && tail === late.tail) {
165 // The desktop drew this turn's block only after the turn ended: link it now, and save the
166 // link once this drawing is done, since a state write redraws every site.
167 s.pending = null
168 line = late.line
169 const id = e.requestId
170 $.clock.after(0, () => { void store($, [id], late.line).catch(() => undefined) })
171 }
172 if (line === undefined) return next(e)
173 if (e.surface === 'desktop') {
174 // A chip under the app's own drawing of the block, which keeps its markdown, selection and copy.
175 const rest = await next(e)
176 const { Box, Text, Svg } = $.ui.resolve(e)
177 const stopped = line.startsWith('stopped')
178 const chip = Box({ key: 'turn-timer', flexDirection: 'row', alignItems: 'center', gap: 1, alignSelf: 'flex-start', borderStyle: 'round', borderColor: 'subtle', paddingX: 1, children: [
179 Box({ flexShrink: 0, children: [Svg({ source: stopped ? STOP_SVG : CLOCK_SVG, width: 14, height: 14, alt: stopped ? 'Turn stopped' : 'Turn time' })] }),
180 Text({ dimColor: true, wrap: 'truncate-end', children: [chipText(line)] }),
181 ] })
182 return Box({ flexDirection: 'column', gap: 1, children: [rest, chip] })
183 }
184 return next({ ...e, props: { ...e.props, text: withLine(e.props.text, line) } })
185 })
186}
187types/index.d.ts 9 lines1/** Finished turns: the last reply block's ids (its row's, and the desktop's own) mapped to the line drawn under it. */
2export type Finished = Record<string, string>
3
4declare module 'claude-code' {
5 interface PluginState {
6 'turn-timer': { finished: Finished }
7 }
8}
9