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

Each turn's duration, tool calls and tokens in a band above the prompt, with a 12-turn sparkline.
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)
▁▂▃▄▅▆▇█, slow turns in the theme's warning color.h does the same.| Command | What it does |
|---|---|
/turn-meter | Shows the band again after Hide. It runs at once, even while Claude is working. |
| Option | Type | Default | What it does |
|---|---|---|---|
slowTurnSeconds | number | 120 | A 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.
/plugin install turn-meter --marketplace KROU4/claude-code-mod-showroomclaude plugin install turn-meter --marketplace KROU4/claude-code-mod-showroomKROU4/claude-code-mod-showroom → Add pluginMIT. See LICENSE.
hooks/register.tsx 167 lines1import { 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}
167types/index.d.ts 8 lines1export 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