SLOPSHOPPER

turn-meter

Each turn's duration, tool calls and tokens in a band above the prompt, with a 12-turn sparkline.

newbandguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · turn-meter
› 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 › /turn-meter ⎿ turn-meter: Turn meter shown above the prompt. Last turn 0s · 9 tools · 7.9k tokens █ [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Last turn 0s · 9 tools · 7.9k tokens █ [ Hide ]
README

turn-meter

Each turn's duration, tool calls and tokens in a band above the prompt, with a 12-turn sparkline.

What it draws

After every turn, a band above the prompt:

Last turn 3m 5s · 31 tools · 88.4k tokens ▁▂▂▃▁▃▅▂▄▃▂█ [ Hide ]

When a turn runs past the threshold, a toast as well:

Slow turn: 3m 5s · 31 tools · 88.4k tokens (over 2m)
  • Duration runs from the prompt you send to the end of the turn.
  • Tools counts the main conversation's tool calls. A subagent's own calls are not counted.
  • Tokens adds up input, cache writes and output. Cache reads are left out. A turn with no usage report shows no tokens.
  • Sparkline: one bar per turn, scaled to the longest of the last 12. The terminal draws colored cells, slow turns in orange. The desktop app draws text bars ▁▂▃▄▅▆▇█, slow turns in the theme's warning color.
  • Hide hides the band for the session. With the band focused (ctrl+x tab, or a click), h does the same.
  • A turn you interrupted raises no toast.
  • The band steps aside while a survey uses that space.

Commands

CommandWhat it does
/turn-meterShows the band again after Hide. It runs at once, even while Claude is working.

Options

OptionTypeDefaultWhat it does
slowTurnSecondsnumber120A turn longer than this raises a toast. 0 turns the toast off.

Set it with /config, or in settings under pluginConfigs["turn-meter@mod-showroom"].options.

Install

  • Terminal: /plugin install turn-meter --marketplace KROU4/claude-code-mod-showroom
  • Shell: claude plugin install turn-meter --marketplace KROU4/claude-code-mod-showroom
  • Desktop app: + next to the prompt box → Plugins → Add marketplace KROU4/claude-code-mod-showroom → Add plugin

Limits

  • The band and the sparkline start empty in each new session.
  • After a reload in the middle of a turn, that turn's duration is the one Claude Code reports, and only the tool calls made after the reload are counted.

License

MIT. See LICENSE.

Source 2 files
hooks/register.tsx 167 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Turn } from '../types'
5
6const BARS = '▁▂▃▄▅▆▇█'
7const KEEP = 12
8const BAR_COLOR = 0x5f9ea0
9const SLOW_COLOR = 0xe0783f
10const TERMINAL_DEFAULT = 0x01000000
11
12const turns = atom({ plugin: 'turn-meter', key: 'turns' } as const, [])
13const isHidden = atom({ plugin: 'turn-meter', key: 'isHidden' } as const, false)
14
15export const register: Register = (on, options) => {
16  const seconds = options.slowTurnSeconds
17  const slowMs = (typeof seconds === 'number' ? seconds : 120) * 1000
18  const isSlow = (turn: Turn) => slowMs > 0 && turn.ms > slowMs
19
20  let startedAt: number | undefined
21  let tools = 0
22  let isRunning = false
23
24  on('session.start', async ($, e, next) => {
25    await $.command.register({
26      name: 'turn-meter',
27      description: 'Show the turn meter band above the prompt',
28      immediate: true,
29    })
30
31    return next(e)
32  })
33
34  on('command.run', { command: 'turn-meter' }, async $ => {
35    await update($, isHidden, () => false)
36
37    return { text: 'Turn meter shown above the prompt.' }
38  })
39
40  on('prompt.submit', async ($, e, next) => {
41    // A prompt typed over a running turn (turnId) or raised during one (another plugin's) joins
42    // that turn; one submitted while idle starts the clock.
43    if (e.turnId === undefined && !isRunning) {
44      startedAt = await $.clock.now()
45      tools = 0
46    }
47
48    return next(e)
49  })
50
51  on('turn.start', ($, e, next) => {
52    if (e.agentId === undefined) isRunning = true
53
54    return next(e)
55  })
56
57  on('tool.call', ($, e, next) => {
58    if (e.agentId === undefined) tools += 1
59
60    return next(e)
61  })
62
63  on('turn.complete', async ($, e, next) => {
64    if (e.agentId !== undefined) return next(e)
65
66    // No prompt.submit seen (a queued prompt, a reload mid-turn): the engine's own duration.
67    const ms = startedAt === undefined ? e.durationMs : (await $.clock.now()) - startedAt
68    const usage = e.usage
69    const turn: Turn = {
70      ms,
71      tools,
72      tokens: usage ? usage.input_tokens + usage.cache_creation_input_tokens + usage.output_tokens : null,
73    }
74    startedAt = undefined
75    tools = 0
76    isRunning = false
77    await update($, turns, list => [...list, turn].slice(-KEEP))
78
79    if (isSlow(turn) && !e.isAborted) {
80      $.ui.toast(`Slow turn: ${summary(turn)} (over ${duration(slowMs)})`)
81    }
82
83    return next(e)
84  })
85
86  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87    const list = await read($, turns)
88    const last = list.at(-1)
89
90    if (e.props.hasSurvey || last === undefined || (await read($, isHidden))) {
91      return next(e)
92    }
93
94    const { Box, Button, Text } = $.ui.resolve(e)
95    const levels = scale(list)
96    let spark
97    if (e.surface === 'terminal') {
98      const { Raster } = $.ui.resolve(e)
99      spark = <Raster key="spark" columns={list.length} rows={1} cells={cells(list, levels, isSlow)} />
100    } else {
101      spark = (
102        <Text>
103          {list.map((turn, i) => (
104            <Text color={isSlow(turn) ? 'warning' : 'suggestion'}>{BARS.charAt(levels[i] ?? 0)}</Text>
105          ))}
106        </Text>
107      )
108    }
109
110    return (
111      <Box flexDirection="row" gap={1}>
112        <Text dimColor wrap="truncate-end">
113          Last turn {summary(last)}
114        </Text>
115        {spark}
116        <Button key="hide" label="Hide" hotkey="h" onPress={() => update($, isHidden, () => true)} />
117      </Box>
118    )
119  })
120}
121
122function summary(turn: Turn): string {
123  const parts = [duration(turn.ms), `${turn.tools} ${turn.tools === 1 ? 'tool' : 'tools'}`]
124  if (turn.tokens !== null) parts.push(`${count(turn.tokens)} tokens`)
125
126  return parts.join(' · ')
127}
128
129function duration(ms: number): string {
130  const s = Math.round(ms / 1000)
131  if (s < 60) return `${s}s`
132  const m = Math.floor(s / 60)
133  if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`
134
135  return `${Math.floor(m / 60)}h ${m % 60}m`
136}
137
138function count(n: number): string {
139  const short = (x: number) => (x >= 100 ? String(Math.round(x)) : x.toFixed(1).replace(/\.0$/, ''))
140  if (n < 1000) return String(n)
141  // From 999,500 the k form would round up to "1000k".
142  if (n < 999_500) return `${short(n / 1000)}k`
143
144  return `${short(n / 1_000_000)}M`
145}
146
147// One bar level (0..7) per turn, relative to the longest turn shown.
148function scale(list: readonly Turn[]): number[] {
149  const top = Math.max(1, ...list.map(turn => turn.ms))
150
151  return list.map(turn => Math.max(0, Math.ceil((turn.ms / top) * BARS.length) - 1))
152}
153
154// RasterProps.cells: base64 of little-endian u32 triplets [codePoint, foreground, background].
155function cells(list: readonly Turn[], levels: readonly number[], isSlow: (turn: Turn) => boolean): string {
156  const view = new DataView(new ArrayBuffer(list.length * 12))
157  list.forEach((turn, i) => {
158    view.setUint32(i * 12, BARS.charCodeAt(levels[i] ?? 0), true)
159    view.setUint32(i * 12 + 4, isSlow(turn) ? SLOW_COLOR : BAR_COLOR, true)
160    view.setUint32(i * 12 + 8, TERMINAL_DEFAULT, true)
161  })
162  let binary = ''
163  for (const byte of new Uint8Array(view.buffer)) binary += String.fromCharCode(byte)
164
165  return btoa(binary)
166}
167
types/index.d.ts 8 lines
1export type Turn = { ms: number; tools: number; tokens: number | null }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'turn-meter': { turns: Turn[]; isHidden: boolean }
6  }
7}
8