Shows how long each turn took, how many tools it used in the main thread and in subagents, and the tokens it spent, in a band above the prompt, and alerts you…

Why use it • What's inside • Install • Usage • Compatibility • FAQ
💻 Claude Code only — it is a mod: it draws in Claude Code's own interface, which the Claude app's chat and Cowork don't load.
[!TIP] TL;DR — After every turn, one line above the prompt tells you how long it took, how many tools Claude and its subagents ran and how many tokens it spent, and a long turn alerts you when it ends: a toast by default, or a native notification.
/cost or the transcript./turn-meter hides it.| Component | Name | What it does | How it runs |
|---|---|---|---|
| 🧩 Mod | turn-meter | Counts each turn's tools and tokens, draws the last-turn band above the prompt, alerts on long turns, and adds the /turn-meter command | Automatically in every Claude Code session once installed |
| Requirement | Details |
|---|---|
| Claude Code | v2.1.295 or later — check with claude --version. Tested with v2.1.308. |
| Surfaces | See Compatibility |
| External tools | None |
| Accounts / API keys | None |
| Native notifications (optional) | A terminal Claude Code can notify through: iTerm2, kitty or Ghostty, or the terminal bell. Claude Code picks it with its preferredNotifChannel setting. Where none is available, the alert falls back to a toast. |
[!NOTE] Turn Meter makes no network calls, runs no commands, and reads or writes no files. It keeps the last turn's summary in the session's memory only, so nothing carries over between sessions.
# One step: adds the marketplace (if needed) and installs the plugin — Claude Code v2.1.292+
claude plugin install turn-meter --marketplace nerymurillohnd/the-claude-factory
# Or, with the marketplace already added
claude plugin install turn-meter@the-claude-factory
Add --scope project to enable it for everyone working in the current repository; each collaborator still installs it once on their own machine.
/plugin install turn-meter --marketplace nerymurillohnd/the-claude-factory
Requires Claude Code v2.1.275+. With the marketplace already added, /plugin install turn-meter@the-claude-factory opens the plugin's details so you can pick a scope. You can also browse it in /plugin → Discover.
With the marketplace added, click + next to the prompt box → Add plugins (or Plugins → Browse plugins), or open Settings → Plugins → Discover; then select Turn Meter and choose a scope.
[!NOTE] New to this marketplace? The installation guide covers every surface step by step.
A mod runs with your permissions and no sandbox. Before you install it, you can see everything it hooks and calls with claude plugin validate <path to the plugin folder>.
Install it and work as usual. The band appears after your first turn ends.
Last turn: 3m 12s · 24 tools (9 in 2 subagents) · 41.2k in / 6.3k out [ Hide ]
Result: the turn took 3 minutes 12 seconds. Claude ran 15 tools itself and two subagents ran 9 more. Input tokens count uncached input, cache reads and cache writes, for the main thread and its subagents together.
| How the turn ended | The band reads |
|---|---|
| You pressed Esc | Last turn: interrupted after 1m 05s · 7 tools |
| An API error ended it | Last turn: ended by API error after 40s · 3 tools |
| The model refused | Last turn: refused after 12s |
| A narrow band (under 60 columns of room) | 3m12s · 24 tools · 48k tok |
/turn-meter
Result: the band hides if it is showing and comes back if it is hidden. The Hide button on the band hides it too.
claude plugin install turn-meter@the-claude-factory --config alert=notify --config alertAfterSeconds=90
Result: a turn that runs 90 seconds or more sends a native notification ("That turn took 3m 12s") when it ends. A turn you interrupt never alerts: you were there.
| Option | Type | Default | Description |
|---|---|---|---|
alert | toast, notify or off | toast | How a long turn alerts you: a message inside Claude Code, a native notification (a toast when none can be sent), or nothing |
alertAfterSeconds | number, at least 1 | 120 | A main turn that runs at least this long alerts you |
showTokens | boolean | true | Show the turn's tokens in the band; turn it off to see only time and tools |
Change them in /config, where alert is a picker (v2.1.271+), or in /plugin → Installed → Turn Meter → Configure options. From the shell, pass --config <option>=<value> to claude plugin install.
claude plugin update turn-meter@the-claude-factory
Or in a session: /plugin → Installed → Turn Meter → Update now. Then run /reload-plugins.
[!TIP] Turn on auto-update once for the whole marketplace:
/plugin→ Marketplaces → the-claude-factory → Enable auto-update.
claude plugin uninstall turn-meter@the-claude-factory
In a session: /plugin → Installed → Turn Meter → Uninstall.
Works in: 💻 Claude Code only
| Component | Claude Code | Claude app |
|---|---|---|
| 🧩 Mods | ✅ every Claude Code session except a Desktop session on WSL | ⚠️ the plugin directory lists a mod for Claude Code only |
<sub>✅ works · ⚠️ partial · 🔗 manual step · ➖ ignored · ❌ the plugin can't be installed there. The Claude app (web, desktop and mobile) runs a conversation as a Cowork session when it can, and as a chat conversation otherwise, for example on a device that can't run Cowork or in the separate Chat mode that Team and Enterprise plans keep for now.</sub>
The band draws in the terminal and the desktop app's Code tab. The VS Code extension does not draw it yet: there the mod still counts and alerts, and /turn-meter still works.
disableAllHooks, allowManagedModsOnly and allowManagedHooksOnly in managed settings, and starting Claude Code with --safe-mode or --bare.The band appears only after the first turn of a session ends, and it steps aside while Claude Code shows a survey above the prompt. If you hid it, type /turn-meter. Then run /plugin: it lists active mods as N mod active · <names>. If turn-meter is missing there, start Claude Code with claude --debug and look in the debug log for turn-meter: a line saying the module was not loaded, or a hook was skipped, names the reason.
Claude Code could not send a native notification: your terminal has no notification channel it supports, notifications are turned off, or the session has no terminal, as in the desktop app. Turn Meter then shows a toast so you still see the alert. Check the preferredNotifChannel setting in /config.
It hooks the start of each session (to add /turn-meter), the start and end of each turn, each tool call, the band above the prompt, and the /turn-meter command. It calls only Claude Code's interface: registering the command, the session's own memory, drawing, toasts and notifications. It never changes a prompt, a tool call or a reply, and it never touches files, processes, the network or the model. claude plugin validate lists the same hooks and calls.
Report a bug • Request a feature • Ask a question
See CHANGELOG.md for every release.
MIT © Nery Murillo
hooks/register.tsx 233 lines1import type { On, PluginOptions, Register, TurnUsage } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { TurnSummary } from '../types'
5
6type Alert = 'toast' | 'notify' | 'off'
7
8type Settings = {
9 alert: Alert
10 alertAfterMs: number
11 showTokens: boolean
12}
13
14const COMMAND = 'turn-meter'
15const NARROW_COLUMNS = 60
16
17const last = atom({ plugin: 'turn-meter', key: 'last' } as const, null)
18const isHidden = atom({ plugin: 'turn-meter', key: 'isHidden' } as const, false)
19
20// The engine fills in the manifest's defaults; these only cover a value of the wrong type.
21const readSettings = (options: PluginOptions): Settings => {
22 const { alert, alertAfterSeconds, showTokens } = options
23 const seconds =
24 typeof alertAfterSeconds === 'number' && alertAfterSeconds > 0 ? alertAfterSeconds : 120
25
26 return {
27 alert: alert === 'notify' || alert === 'off' ? alert : 'toast',
28 alertAfterMs: seconds * 1000,
29 showTokens: typeof showTokens === 'boolean' ? showTokens : true,
30 }
31}
32
33const tokensIn = (usage: TurnUsage | undefined): number =>
34 usage === undefined
35 ? 0
36 : usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
37
38// "3m 12s", "1m 05s" and "40s"; the short form drops the space: "3m12s".
39export const formatDuration = (ms: number, isShort = false): string => {
40 const total = Math.round(ms / 1000)
41 const minutes = Math.floor(total / 60)
42 const seconds = total % 60
43
44 if (minutes === 0) {
45 return `${seconds}s`
46 }
47
48 return `${minutes}m${isShort ? '' : ' '}${String(seconds).padStart(2, '0')}s`
49}
50
51// "6.3k" and "41.2k"; the short form rounds to whole thousands: "47k". The unit is picked after
52// rounding, so 999,999 reads "1M", never "1000k".
53export const formatTokens = (count: number, isShort = false): string => {
54 const thousands = isShort ? String(Math.round(count / 1000)) : (count / 1000).toFixed(1)
55
56 if (count >= 1000 && Number(thousands) < 1000) {
57 return `${thousands.replace(/\.0$/, '')}k`
58 }
59
60 if (count >= 1000) {
61 return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
62 }
63
64 return String(count)
65}
66
67const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
68
69const LEAD: Record<TurnSummary['reason'], string> = {
70 answer: '',
71 aborted: 'interrupted after ',
72 error: 'ended by API error after ',
73 refusal: 'refused after ',
74}
75
76const SHORT_LEAD: Record<TurnSummary['reason'], string> = {
77 answer: '',
78 aborted: 'interrupted ',
79 error: 'API error ',
80 refusal: 'refused ',
81}
82
83export const bandText = (turn: TurnSummary, showTokens: boolean, columns: number): string => {
84 const isShort = columns < NARROW_COLUMNS
85 const parts = [
86 `${(isShort ? SHORT_LEAD : LEAD)[turn.reason]}${formatDuration(turn.durationMs, isShort)}`,
87 ]
88
89 if (turn.reason !== 'refusal') {
90 const tools = plural(turn.tools + turn.subagentTools, 'tool')
91 const split =
92 isShort || turn.subagents === 0
93 ? ''
94 : ` (${turn.subagentTools} in ${plural(turn.subagents, 'subagent')})`
95 parts.push(`${tools}${split}`)
96 }
97
98 if (showTokens && turn.tokensIn + turn.tokensOut > 0) {
99 parts.push(
100 isShort
101 ? `${formatTokens(turn.tokensIn + turn.tokensOut, true)} tok`
102 : `${formatTokens(turn.tokensIn)} in / ${formatTokens(turn.tokensOut)} out`,
103 )
104 }
105
106 return `${isShort ? '' : 'Last turn: '}${parts.join(' · ')}`
107}
108
109export const register: Register = (on: On, options: PluginOptions): void => {
110 const settings = readSettings(options)
111
112 // The turn in progress. A reload in the middle of a turn starts these over,
113 // so that one turn comes out short; the last finished turn lives in $.state.
114 let tools = 0
115 let subagentTools = 0
116 let subagentIds = new Set<string>()
117 let subagentTokensIn = 0
118 let subagentTokensOut = 0
119
120 on('session.start', async ($, e, next) => {
121 await $.command.register({
122 name: COMMAND,
123 description: 'Show or hide the last-turn band above the prompt',
124 })
125
126 return next(e)
127 })
128
129 on('turn.start', (_$, e, next) => {
130 if (e.agentId === undefined) {
131 tools = 0
132 subagentTools = 0
133 subagentIds = new Set()
134 subagentTokensIn = 0
135 subagentTokensOut = 0
136 }
137
138 return next(e)
139 })
140
141 on('tool.call', (_$, e, next) => {
142 if (e.agentId === undefined) {
143 tools += 1
144 } else {
145 subagentTools += 1
146 subagentIds.add(e.agentId)
147 }
148
149 return next(e)
150 })
151
152 on('turn.complete', async ($, e, next) => {
153 if (e.agentId !== undefined) {
154 subagentIds.add(e.agentId)
155 subagentTokensIn += tokensIn(e.usage)
156 subagentTokensOut += e.usage?.output_tokens ?? 0
157
158 return next(e)
159 }
160
161 const turn: TurnSummary = {
162 durationMs: e.durationMs,
163 reason: e.reason,
164 tools,
165 subagentTools,
166 subagents: subagentIds.size,
167 tokensIn: tokensIn(e.usage) + subagentTokensIn,
168 tokensOut: (e.usage?.output_tokens ?? 0) + subagentTokensOut,
169 }
170 await update($, last, () => turn)
171
172 // An interrupted turn needs no alert: the person pressed Esc and is watching.
173 const isLong = turn.durationMs >= settings.alertAfterMs && turn.reason !== 'aborted'
174 if (isLong && settings.alert !== 'off') {
175 const text = `That turn took ${formatDuration(turn.durationMs)}`
176 // A notification nobody can receive (no terminal channel, the desktop app)
177 // falls back to a toast, so the alert is never lost.
178 const isSent =
179 settings.alert === 'notify' &&
180 (await $.ui.notify(text).then(
181 (sent) => sent.isSent,
182 () => false,
183 ))
184 if (!isSent) {
185 $.ui.toast(text)
186 }
187 }
188
189 return next(e)
190 })
191
192 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
193 const turn = await read($, last)
194
195 if (e.props.hasSurvey || turn === null || (await read($, isHidden))) {
196 return next(e)
197 }
198
199 const { Box, Button, Text } = $.ui.resolve(e)
200 // The band is shared: what the mods after this one draw stays beneath the row.
201 const below = await next(e)
202
203 return (
204 <Box flexDirection="column">
205 <Box flexDirection="row" gap={1}>
206 <Text dimColor={true}>{bandText(turn, settings.showTokens, e.props.bodyColumns)}</Text>
207 <Button
208 key="hide"
209 label="Hide"
210 onPress={(): Promise<boolean> => update($, isHidden, () => true)}
211 />
212 </Box>
213 {below}
214 </Box>
215 )
216 })
217
218 on('command.run', { command: COMMAND }, async ($) => {
219 const hidden = await update($, isHidden, (value) => !value)
220 const hasTurn = (await read($, last)) !== null
221
222 if (hidden) {
223 return { text: 'Turn Meter band hidden. Run /turn-meter to show it again.' }
224 }
225
226 return {
227 text: hasTurn
228 ? 'Turn Meter band shown.'
229 : 'Turn Meter band shown. It appears after the first turn ends.',
230 }
231 })
232}
233types/index.d.ts 25 lines1/** Why a turn ended, as `turn.complete` reports it. */
2export type TurnReason = 'answer' | 'aborted' | 'error' | 'refusal'
3
4/** One finished main-loop turn, as the band shows it. */
5export type TurnSummary = {
6 durationMs: number
7 reason: TurnReason
8 /** Tool calls made by the main loop. */
9 tools: number
10 /** Tool calls made by subagents during the turn. */
11 subagentTools: number
12 /** Distinct subagents that ran during the turn. */
13 subagents: number
14 /** Input tokens: uncached, cache reads and cache writes, main loop and subagents. */
15 tokensIn: number
16 /** Output tokens, main loop and subagents. */
17 tokensOut: number
18}
19
20declare module 'claude-code' {
21 interface PluginState {
22 'turn-meter': { last: TurnSummary | null; isHidden: boolean }
23 }
24}
25