Counts this turn's tool calls in a band above the prompt.

Counts this turn's tool calls in a band above the prompt.
<!-- One screen: what the person sees and why it helps. Replace this comment with a short paragraph and, if it draws, an ASCII capture of its main state from SPEC.md. -->
/turn-counter demo
<!-- List every subcommand the mod answers, one line each. -->
From a GitHub marketplace that lists it:
/plugin install turn-counter --marketplace <owner>/<repo>
From a local folder marketplace (claude plugin marketplace add <folder> once):
/plugin install turn-counter@<marketplace-name>
While developing, from its folder:
claude --plugin-dir <path-to>/turn-counter
Changes load with /reload-plugins or a new session.
<!-- One row per userConfig key from plugin.json, or "None." -->
| Option | Default | What it does |
|---|
Mods are not sandboxed. They run with your access and skip Claude's permission prompts, so this table lists everything this one can touch. It is generated from claude plugin validate.
<!-- Fill from the validate output's hooks / calls / gating lines: -->
| Reach | Detail |
|---|---|
| Hooks | |
| Processes | |
| Files | |
| Network | |
| Model calls | |
| Gating hooks |
claude plugin validate .
claude plugin test .
Built on Claude Code <version>. Written spec-first with /mod-builder:build; see SPEC.md.
hooks/register.tsx 77 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { bandTextOf, countCall } from './turn-counter-logic'
5
6/**
7 * Turn Counter: a band above the prompt counting this turn's tool calls.
8 * Archetype: band. It composes with the bands below, steps aside for a
9 * survey, and hides when idle.
10 */
11
12const turn = atom({ plugin: 'turn-counter', key: 'turn' } as const, null)
13const isHidden = atom({ plugin: 'turn-counter', key: 'isHidden' } as const, false)
14
15async function showDemo($: EngineInterface): Promise<void> {
16 await update($, isHidden, () => false)
17 await update($, turn, () => ({ tools: 7, failures: 1 }))
18}
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 await $.command
23 .register({ name: 'turn-counter', description: "Counts this turn's tool calls in a band above the prompt.", argumentHint: '[demo | hide | show]' })
24 .catch(() => undefined)
25 return next(e)
26 })
27
28 on('command.run', { command: 'turn-counter' }, async ($, e) => {
29 const word = e.args.trim().toLowerCase()
30 if (word === 'demo') {
31 void showDemo($).catch(() => undefined)
32 return { text: 'Turn Counter: a demo band is above the prompt. It clears when the next turn ends.' }
33 }
34 if (word === 'hide' || word === 'show') {
35 void update($, isHidden, () => word === 'hide').catch(() => undefined)
36 return { text: `Turn Counter: band ${word === 'hide' ? 'hidden' : 'shown'}.` }
37 }
38 return { text: 'Usage: /turn-counter [demo | hide | show]' }
39 })
40
41 on('prompt.submit', async ($, e, next) => {
42 await update($, turn, () => null)
43 return next(e)
44 }).catch(($, e, next) => next(e))
45
46 on('tool.call', async ($, e, next) => {
47 const ran = await next(e)
48 if (!e.agentId) await update($, turn, current => countCall(current, ran.deny !== undefined || ran.isError === true))
49 return ran
50 }).catch(($, e, next) => next(e))
51
52 on('turn.complete', async ($, e, next) => {
53 if (!e.agentId) await update($, turn, () => null)
54 return next(e)
55 })
56
57 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
58 const rest = await next(e)
59 try {
60 if (e.props.hasSurvey || e.props.maxRows < 1 || (await read($, isHidden))) return rest
61 const text = bandTextOf(await read($, turn), e.props.bodyColumns - 2)
62 if (!text) return rest
63 const { Box, Text } = $.ui.resolve(e)
64 return (
65 <Box flexDirection="column">
66 <Box paddingX={1}>
67 <Text color="#D97757">{text}</Text>
68 </Box>
69 {rest}
70 </Box>
71 )
72 } catch {
73 return rest
74 }
75 })
76}
77hooks/turn-counter-logic.ts 22 lines1/**
2 * Turn Counter's rules, free of `$` so tests can call them.
3 */
4
5import type { Turn } from '../types'
6
7export type { Turn }
8
9/** The band's one line, cut to `columns`; empty when there is nothing to say. */
10export function bandTextOf(turn: Turn | null, columns: number): string {
11 if (!turn || turn.tools === 0) return ''
12 const calls = `${turn.tools} tool call${turn.tools === 1 ? '' : 's'}`
13 const failed = turn.failures ? ` · ${turn.failures} failed` : ''
14 return `◆ turn-counter ${calls} this turn${failed}`.slice(0, Math.max(0, columns))
15}
16
17/** One more call, failed or not. */
18export function countCall(turn: Turn | null, isError: boolean): Turn {
19 const base = turn ?? { tools: 0, failures: 0 }
20 return { tools: base.tools + 1, failures: base.failures + (isError ? 1 : 0) }
21}
22types/index.d.ts 10 lines1// Turn Counter's state contract. Self-contained: no imports.
2
3export type Turn = { tools: number; failures: number }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'turn-counter': { turn: Turn | null; isHidden: boolean }
8 }
9}
10