SLOPSHOPPER

turn-timer

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

newrowstimer
v1.0.1MITupdated 2026-10-13Barncore/turn-timer
A shopper browsing a rack in a slop shop
README

turn-timer

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

A rounded chip with a grey stopwatch icon and "1m 39s"

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.

What it shows

  • A finished turn: a grey stopwatch and the time, such as 1m 39s. Under a minute it's seconds (42s), over an hour it's hours and minutes (1h 04m).
  • A turn you interrupted: a small orange square and stopped · 1m 05s, like this:

A rounded chip with a small orange square and "stopped · 20s"

Orange is saved for this one case, because an interrupted turn is the one worth noticing.

  • Subagents' turns: nothing. Only the main conversation's turns get a chip.
  • Surfaces that can't draw images, such as a terminal: an italic line at the end of the reply instead, took 3m 12s.

The chip only changes what's drawn. The stored reply, and what Claude reads back, stay exactly as they were.

Install

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.

What it needs

  • Claude Code 2.1.287 or later, the release mods arrived in. They're on by default, but local settings, safe mode or an organisation policy can turn them off. See Anthropic's mod docs.

Good to know

  • The chip appears on replies that finish after turn-timer loads. After you install it or restart the app, your earlier replies won't have one, because there's no record of how long they took. The next reply will.
  • A restart starts fresh. The times live in the session's own state, which doesn't survive the app restarting.
  • It keeps the last 200 turns. Scroll far enough back in a very long session and the oldest replies lose their chips.
  • One narrow case can still put a chip on the wrong reply. During a turn, you scroll an older reply into view that the Code tab hadn't drawn since turn-timer loaded, and it ends with exactly the same words as this turn's reply. Then the older reply can take the chip.

Tested on

  • Windows 11, the Claude desktop app's Code tab, Claude Code 2.1.295. The chip has been seen drawing there under real replies, on 1.0.1 after a restart.
  • macOS: untested. It should work. Mods are JavaScript run by Claude Code itself, and turn-timer touches no files, processes or OS features. But the desktop app draws mods itself, and a few drawing bugs have only been reported on Mac. If you try it on a Mac, an issue saying how it went would help.
  • Terminal: the italic line is covered by the tests, but nobody has watched it in a real terminal yet.

How it works

  1. When Claude's reply arrives, turn-timer notes the id and the closing words of the reply's last block.
  2. While the turn runs, it notes each block the Code tab draws for the first time.
  3. When the turn ends, Claude Code reports how long it took. turn-timer stores that time against the block, once per turn.
  4. When the Code tab draws that block, turn-timer draws the chip under the app's own drawing of the reply, so the reply keeps its formatting, text selection and copy button.

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.

What it can reach

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.

Develop

claude plugin validate .
claude plugin test .

Licence

MIT, see LICENSE.

Source 2 files
hooks/register.ts 187 lines
1// 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}
187
types/index.d.ts 9 lines
1/** 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