SLOPSHOPPER

turn-meter

A band above the prompt: the tool running now during a turn, and a one-line summary of the last turn after it

newbandguardcommandtoastprompt
v0.2.0MITupdated 2026-10-02barmoshe/bar-mods/turn-meter
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 hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

turn-meter

A band above the prompt that tells you what Claude is doing and what the last turn cost in time and tools.

  • While Claude works: ● working 5 tool calls, now: Bash
  • After the turn: Last turn: 34s, 7 tool calls (Bash 4, Read 2, Edit 1), with a Hide button
  • A turn over 2 minutes: a toast with its length
  • /turn-meter: shows or hides the band

A subagent finishing never replaces the main turn's summary.

claude plugin install turn-meter@bar-mods

Footprint

From claude plugin validate:

  • hooks: session.start, command.run{command=turn-meter}, prompt.submit, tool.call (observes only, never changes a call), turn.complete, ui.render{component=AbovePrompt}
  • calls: $.clock.now, $.command.register, $.state.get, $.state.set, $.ui.resolve, $.ui.toast

No files, processes, network, environment or model calls. It costs no tokens.

Surfaces

Draws in the terminal and the desktop app's Code tab. In claude -p, the SDK and the VS Code chat panel its hooks run and nothing draws.

Tested on Claude Code 2.1.288.

Source 2 files
hooks/register.tsx 121 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Live, Turn } from '../types'
5
6const last = atom({ plugin: 'turn-meter', key: 'last' } as const, null)
7const live = atom({ plugin: 'turn-meter', key: 'live' } as const, null)
8const isHidden = atom({ plugin: 'turn-meter', key: 'isHidden' } as const, false)
9
10const LONG_TURN_SECONDS = 120
11
12const total = (byTool: Record<string, number>) =>
13  Object.values(byTool).reduce((sum, n) => sum + n, 0)
14
15const breakdown = (byTool: Record<string, number>) =>
16  Object.entries(byTool)
17    .sort((a, b) => b[1] - a[1])
18    .slice(0, 4)
19    .map(([tool, n]) => `${tool} ${n}`)
20    .join(', ')
21
22const summary = (turn: Turn) => {
23  const count = total(turn.byTool)
24  const kinds = count > 0 ? ` (${breakdown(turn.byTool)})` : ''
25  return `Last turn: ${turn.seconds}s, ${count} tool calls${kinds}`
26}
27
28export const register: Register = on => {
29  let startedAt = 0
30  let byTool: Record<string, number> = {}
31
32  on('session.start', async ($, e, next) => {
33    await $.command.register({
34      name: 'turn-meter',
35      description: 'Show or hide the turn meter band',
36    })
37
38    return next(e)
39  })
40
41  on('command.run', { command: 'turn-meter' }, async $ => {
42    const hidden = await update($, isHidden, was => !was)
43
44    return { text: hidden ? 'Turn meter hidden.' : 'Turn meter shown.' }
45  })
46
47  on('prompt.submit', async ($, e, next) => {
48    startedAt = await $.clock.now()
49    byTool = {}
50    await update($, live, (): Live => ({ tools: 0, current: null }))
51
52    return next(e)
53  })
54
55  on('tool.call', async ($, e, next) => {
56    byTool[e.tool] = (byTool[e.tool] ?? 0) + 1
57    await update($, live, (): Live => ({ tools: total(byTool), current: e.tool }))
58
59    try {
60      return await next(e)
61    } finally {
62      await update($, live, was => (was ? { ...was, current: null } : was))
63    }
64  })
65
66  on('turn.complete', async ($, e, next) => {
67    // A subagent's turn ends too; only the main loop's end is "the turn".
68    if (e.agentId !== undefined) {
69      return next(e)
70    }
71
72    const seconds = Math.round(((await $.clock.now()) - startedAt) / 1000)
73    const turn: Turn = { seconds, byTool: { ...byTool } }
74    await update($, last, () => turn)
75    await update($, live, () => null)
76
77    if (seconds > LONG_TURN_SECONDS) {
78      $.ui.toast(`Long turn: ${seconds}s, ${total(turn.byTool)} tool calls`)
79    }
80
81    return next(e)
82  })
83
84  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
85    if (e.props.hasSurvey || (await read($, isHidden))) {
86      return next(e)
87    }
88
89    const now = await read($, live)
90    const turn = await read($, last)
91    const { Box, Button, Text } = $.ui.resolve(e)
92
93    if (e.props.isWorking && now) {
94      const doing = now.current ? `, now: ${now.current}` : ''
95      return (
96        <Box>
97          <Text color="cyan">● working </Text>
98          <Text dimColor>{`${now.tools} tool calls${doing}`}</Text>
99        </Box>
100      )
101    }
102
103    if (turn === null) {
104      return next(e)
105    }
106
107    return (
108      <Box gap={1}>
109        <Text dimColor wrap="truncate-end">
110          {summary(turn)}
111        </Text>
112        <Button
113          key="hide"
114          label="Hide"
115          onPress={() => update($, isHidden, () => true)}
116        />
117      </Box>
118    )
119  })
120}
121
types/index.d.ts 9 lines
1export type Turn = { seconds: number; byTool: Record<string, number> }
2export type Live = { tools: number; current: string | null }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'turn-meter': { last: Turn | null; live: Live | null; isHidden: boolean }
7  }
8}
9