A quiet band above the prompt: what Claude is doing, how long, task progress and failures; hides when idle.

Two small mods for Claude Code that make it easier to see what Claude is doing and to decide what to do next.
| Mod | What you get |
|---|---|
| recap | After Claude finishes a real piece of work, a side pane lists 14–20 possible next moves, color-coded by direction and ranked 80/20. Pick a few and it writes your next prompt for you. |
| turn-status | A quiet one-line band above the prompt while Claude works: what it is doing right now, how long it has been going, task progress and failures. It hides itself when Claude is idle. |
In a terminal, start claude and paste the line for the mod you want:
/plugin install recap --marketplace lokki007/claude-code-mods
/plugin install turn-status --marketplace lokki007/claude-code-mods
Claude Code asks whether to add the marketplace: answer y, then press Enter to install it for your user. The mod works straight away in that terminal session, and in every new chat after that, including the desktop app's Code tab. Restart chats that were already open.
The desktop app's Code tab can't run
/plugin installitself; install once from a terminal and the desktop app picks it up.
Needs a recent Claude Code (one that supports hooks-module plugins, 2.1.29x or later).

The ✨ Next pane in the Claude desktop app, beside the chat: 17 options, filterable by direction, ranked 80/20, each with + Pick and ☆ Save.
When a turn edits files or digs through research, the ✨ Next pane opens beside the chat:
| Direction | Direction | ||
|---|---|---|---|
| 🐞 | Fix | 💰 | Money |
| 🧹 | Tech debt | 📣 | Growth & leads |
| ✨ | Features | 🚀 | Ship & ops |
| 🎨 | UI & design | 🧪 | Wild ideas |
Each option has a colored bar per direction it serves, a rough size (quick, medium, big), and whether it needs your ideas (💬) or Claude can decide it alone (🤖).
/recap opens it).It also keeps track for you:
/recap makes one anyway./recap in any chat of the same project shows it straight away; ↻ New options brews fresh ones for that chat./recap diary shows where it is.~/.claude/recap/log.jsonl (/recap log).Commands: /recap opens the pane, /recap again brews fresh options, /recap diary and /recap log show those files. Newer Claude versions have a built-in /recap; there the same commands start with /next instead (/next, /next again, and so on), and the mod tells you once.
What's next for it: a daily digest across all your projects (a sketch).
How it works: the options come from a short follow-up question to Claude about the conversation that just happened, so they can point at real code. It costs a little extra per qualifying turn (most of the conversation is served from the prompt cache); if that fails it falls back to the small, fast model.
While Claude works, one row above the prompt shows:
⟳ Building the status band Running npm test 1:46 ▬▬▬▬▬ 1/5 ● 1 failed
When the turn ends it shows "✓ Done in 1:46" for a few seconds and then gets out of the way. In the desktop app it is drawn as a small animated graphic; in a terminal, as text.
Each mod is a Claude Code hooks-module plugin under plugins/<mod>: hooks/register.tsx is the entry point, types/index.d.ts declares the state it keeps, and hooks/*.test.ts are its tests. Run them with:
claude plugin validate plugins/recap
claude plugin test plugins/recap
To work on a mod locally, add this folder as a marketplace once (claude plugin marketplace add <path to this repo>), install from it, then release changes with:
node scripts/release.mjs recap [patch|minor|major] [--publish]
It bumps the version, validates and tests, commits the release, refreshes the installed copy, prunes old cached copies, and with --publish pushes here.
MIT
hooks/register.tsx 117 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Snap } from '../types'
5import { IDLE, LINGER_MS, applyTaskCall, describeTool, plainLine, taskCallOf } from './model'
6import { bandSvg } from './svg'
7import type { Instance } from './copies'
8import { duplicateWarning, otherCopies, registryPath, withInstance } from './copies'
9
10export const snap = atom({ plugin: 'turn-status', key: 'snap' } as const, IDLE)
11
12export const register: Register = on => {
13 // A second copy of this mod in the same chat: each copy announces itself, then looks for the other.
14 on('session.start', async ($, e, next) => {
15 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
16 if (home) {
17 const me: Instance = { session: await $.session.id(), token: crypto.randomUUID(), root: $.plugin.root, at: await $.clock.now() }
18 const registry = registryPath(home)
19 const announce = async () => {
20 const raw = (await $.fs.exists(registry)) ? String(await $.fs.read(registry)) : '[]'
21 await $.fs.write(registry, JSON.stringify(withInstance(raw, me)))
22 }
23 await announce()
24 // Two copies writing at the same instant can drop each other: write again twice, a little apart.
25 $.clock.after(300 + Math.floor(Math.random() * 300), () => void announce())
26 $.clock.after(900 + Math.floor(Math.random() * 400), () => void announce())
27 $.clock.after(2000, async () => {
28 const raw = (await $.fs.exists(registry)) ? String(await $.fs.read(registry)) : '[]'
29 const others = otherCopies(withInstance(raw, me), me)
30 // Every copy sees the others; the one that started last says so (its check runs last), so the warning shows once.
31 if (others.length > 0 && [me, ...others].sort((a, b) => b.at - a.at || a.token.localeCompare(b.token))[0]!.token === me.token) {
32 $.ui.toast(duplicateWarning(me, others), { timeoutMs: 12000 })
33 }
34 })
35 }
36 return next(e)
37 })
38
39 on('turn.start', async ($, e, next) => {
40 const now = await $.clock.now()
41 await update($, snap, s => ({
42 ...IDLE,
43 tasks: s.tasks ?? [],
44 phase: 'working' as const,
45 turnId: e.turnId,
46 startedAt: now,
47 actionAt: now,
48 }))
49 return next(e)
50 })
51
52 on('tool.call', async ($, e, next) => {
53 // A subagent's tools are its own business; the line follows the main loop.
54 const isMain = e.agentId === undefined
55 if (isMain) {
56 const now = await $.clock.now()
57 await update($, snap, s => ({ ...s, action: describeTool(e as any), actionAt: now }))
58 }
59 const ran = await next(e)
60 const now = await $.clock.now()
61 const failed = ran.deny === undefined && ran.isError === true
62 const call = ran.deny === undefined && !failed ? taskCallOf(e as any, ran.result) : null
63 await update($, snap, s => {
64 let out: Snap = { ...IDLE, ...s }
65 if (isMain) out = { ...out, action: null, actionAt: now }
66 if (failed) out = { ...out, failed: out.failed + 1, lastFailAt: now }
67 if (call) out = applyTaskCall(out, call, now)
68 return out
69 })
70 return ran
71 }).catch(($, e, next) => next(e))
72
73 on('turn.complete', async ($, e, next) => {
74 if (e.agentId === undefined) {
75 const now = await $.clock.now()
76 await update($, snap, s => ({
77 ...IDLE,
78 ...s,
79 phase: 'done' as const,
80 action: null,
81 durationMs: e.durationMs,
82 aborted: e.isAborted,
83 doneAt: now,
84 }))
85 // Then get out of the way, unless another turn has started meanwhile.
86 $.clock.after(LINGER_MS, () => {
87 void update($, snap, s => (s.phase === 'done' && s.turnId === e.turnId ? { ...s, phase: 'idle' as const } : s))
88 })
89 }
90 return next(e)
91 })
92
93 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
94 const s: Snap = { ...IDLE, ...(await read($, snap)) } // older saved state may lack newer fields
95 if (e.props.hasSurvey || s.phase === 'idle') return next(e)
96 const now = await $.clock.now()
97
98 if (e.surface === 'desktop') {
99 const { Svg } = $.ui.resolve({ ...e, surface: e.surface })
100 const band = bandSvg(s, now)
101 return <Svg source={band.source} alt={band.alt} width={band.width} height={band.height} />
102 }
103
104 if (e.surface === 'terminal') {
105 const { Box, Client } = $.ui.resolve({ ...e, surface: e.surface })
106 return (
107 <Box>
108 <Client key="band" module="./motion.tsx" props={s} flexGrow={1} />
109 </Box>
110 )
111 }
112
113 const { Text } = $.ui.resolve(e)
114 return <Text dimColor>{plainLine(s, now)}</Text>
115 })
116}
117hooks/model.ts 168 lines1import type { Snap, Task } from '../types'
2
3// Pure state and wording, shared by the hooks module, both drawings and tests.
4
5export const IDLE: Snap = {
6 phase: 'idle',
7 turnId: '',
8 action: null,
9 failed: 0,
10 durationMs: 0,
11 aborted: false,
12 startedAt: 0,
13 doneAt: 0,
14 actionAt: 0,
15 lastFailAt: 0,
16 tasks: [],
17 taskDoneAt: 0,
18 taskDoneId: '',
19}
20
21/** How long the finished line stays before the band gets out of the way. */
22export const LINGER_MS = 8000
23
24const clip = (t: string, max: number) => {
25 const one = t.replace(/\s+/g, ' ').trim()
26 return one.length > max ? `${one.slice(0, max - 1)}…` : one
27}
28
29const base = (p: unknown) =>
30 typeof p === 'string' ? (p.split(/[\\/]/).pop() ?? p) : ''
31
32const host = (u: unknown) => {
33 try {
34 return new URL(String(u)).host
35 } catch {
36 return ''
37 }
38}
39
40/** A tool call in words: verb and target ("Editing svg.ts", "Running npm test"). */
41export const describeTool = (e: { tool: string } & Record<string, any>): string => {
42 switch (e.tool) {
43 case 'Read':
44 return `Reading ${base(e.file_path)}`
45 case 'Edit':
46 return `Editing ${base(e.file_path)}`
47 case 'Write':
48 return `Writing ${base(e.file_path)}`
49 case 'NotebookEdit':
50 return `Editing ${base(e.notebook_path)}`
51 case 'Bash':
52 case 'PowerShell':
53 return `Running ${clip(String(e.command ?? '').split('\n')[0] ?? '', 40)}`
54 case 'Grep':
55 return `Searching for “${clip(String(e.pattern ?? ''), 28)}”`
56 case 'Glob':
57 return `Finding ${clip(String(e.pattern ?? ''), 32)}`
58 case 'WebFetch':
59 return `Reading ${host(e.url) || 'a web page'}`
60 case 'WebSearch':
61 return `Searching the web for “${clip(String(e.query ?? ''), 28)}”`
62 case 'Agent':
63 case 'Task':
64 return `Delegating: ${clip(String(e.description ?? 'a subtask'), 36)}`
65 case 'Skill':
66 return `Using ${String(e.skill ?? 'a skill')}`
67 case 'TodoWrite':
68 case 'TaskCreate':
69 case 'TaskUpdate':
70 case 'TaskList':
71 case 'TaskGet':
72 return 'Updating the plan'
73 default: {
74 const mcp = /^mcp__(.+?)__(.+)$/.exec(e.tool)
75 if (mcp) return `Using ${(mcp[2] ?? '').replace(/_/g, ' ')}`
76 return `Using ${e.tool}`
77 }
78 }
79}
80
81export type TaskCall =
82 | { tool: 'TodoWrite'; todos: { content: string; status: Task['status']; activeForm: string }[] }
83 | { tool: 'TaskCreate'; subject: string; activeForm?: string; createdId: string }
84 | { tool: 'TaskUpdate'; taskId: string; subject?: string; activeForm?: string; status?: Task['status'] | 'deleted' }
85
86export const taskCallOf = (e: { tool: string } & Record<string, any>, result: any): TaskCall | null => {
87 if (e.tool === 'TodoWrite' && Array.isArray(e.todos)) return { tool: 'TodoWrite', todos: e.todos }
88 if (e.tool === 'TaskCreate' && typeof result?.task?.id === 'string') {
89 return { tool: 'TaskCreate', subject: e.subject, activeForm: e.activeForm, createdId: result.task.id }
90 }
91 if (e.tool === 'TaskUpdate' && typeof e.taskId === 'string') {
92 return { tool: 'TaskUpdate', taskId: e.taskId, subject: e.subject, activeForm: e.activeForm, status: e.status }
93 }
94 return null
95}
96
97/** The task list after one task tool call. */
98export const applyTaskCall = (prev: Snap, call: TaskCall, now: number): Snap => {
99 const s: Snap = { ...IDLE, ...prev }
100 let tasks: Task[]
101 if (call.tool === 'TodoWrite') {
102 tasks = call.todos.map((t, i) => ({ id: `todo-${i}`, title: t.content, active: t.activeForm || null, status: t.status }))
103 } else if (call.tool === 'TaskCreate') {
104 const isFinished = s.tasks.length > 0 && s.tasks.every(t => t.status === 'completed')
105 tasks = [
106 ...(isFinished ? [] : s.tasks),
107 { id: call.createdId, title: call.subject, active: call.activeForm ?? null, status: 'pending' },
108 ]
109 } else {
110 tasks = s.tasks.flatMap(t => {
111 if (t.id !== call.taskId) return [t]
112 if (call.status === 'deleted') return []
113 return [{ ...t, title: call.subject ?? t.title, active: call.activeForm ?? t.active, status: call.status ?? t.status }]
114 })
115 }
116 const wasDone = new Set(s.tasks.filter(t => t.status === 'completed').map(t => t.id))
117 const justDone = tasks.find(t => t.status === 'completed' && !wasDone.has(t.id))
118 return {
119 ...s,
120 tasks,
121 taskDoneAt: justDone ? now : s.taskDoneAt,
122 taskDoneId: justDone ? justDone.id : s.taskDoneId,
123 }
124}
125
126export const fmtClock = (ms: number): string => {
127 const s = Math.floor(Math.max(0, ms) / 1000)
128 return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
129}
130
131/** What the line says, in priority order: the task, then the tool doing it. */
132export type Words = {
133 /** the main words: the running task, else the tool action, else "Thinking" */
134 headline: string
135 /** the tool action under a running task, when there is one */
136 detail: string | null
137 /** "2/5" when there is a task list */
138 progress: string | null
139 done: number
140 total: number
141}
142
143export const words = (s: Snap): Words => {
144 const current = s.tasks.find(t => t.status === 'in_progress')
145 const done = s.tasks.filter(t => t.status === 'completed').length
146 const total = s.tasks.length
147 const task = current ? (current.active ?? current.title) : null
148 const action = s.action ?? 'Thinking'
149 return {
150 headline: task ?? action,
151 detail: task ? action : null,
152 progress: total > 0 ? `${done}/${total}` : null,
153 done,
154 total,
155 }
156}
157
158/** The whole line as plain text: the fallback drawing and the image's alt. */
159export const plainLine = (s: Snap, now: number): string => {
160 const w = words(s)
161 const failed = s.failed > 0 ? ` · ${s.failed} failed` : ''
162 const tasks = w.progress ? ` · ${w.progress} tasks` : ''
163 if (s.phase === 'working') {
164 return `${w.headline}${w.detail ? ` · ${w.detail}` : ''} · ${fmtClock(now - s.startedAt)}${tasks}${failed}`
165 }
166 return `${s.aborted ? 'Interrupted' : 'Done'} in ${fmtClock(s.durationMs)}${tasks}${failed}`
167}
168hooks/svg.ts 197 lines1import type { Snap } from '../types'
2import { LINGER_MS, fmtClock, plainLine, words } from './model'
3
4// Desktop band: one quiet row drawn as an SVG image whose motion is SMIL, so
5// it runs at the browser's frame rate. Each animation's `begin` is offset by
6// how long ago its moment was, so a redraw resumes motion instead of replaying it.
7
8const H = 34
9const MID = H / 2
10const BASE = MID + 4.5
11const ROLL_S = 0.28
12const ORANGE = '#D97757'
13const GREEN = '#3FB950'
14const RED = '#F2777B'
15
16const esc = (t: string) =>
17 t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
18const n = (x: number) => Number(x.toFixed(2))
19const ago = (now: number, at: number) => n(-(Math.max(0, now - at) / 1000))
20const clip = (t: string, max: number) => (t.length > max ? `${t.slice(0, max - 1)}…` : t)
21
22/** Rough advance widths, enough to lay a row of system-ui text out. */
23const textW = (t: string, px: number) => t.length * px * 0.5
24const SANS = `system-ui,-apple-system,'Segoe UI',sans-serif`
25const MONO = `ui-monospace,'SF Mono','Cascadia Mono',Consolas,monospace`
26
27const STYLE = `
28 svg{--fg:#ECEAE6;--dim:#8F8C86;--track:rgba(255,255,255,.1)}
29 @media (prefers-color-scheme: light){svg{--fg:#1F1E1C;--dim:#6E6B65;--track:rgba(0,0,0,.1)}}
30 .h{font:500 13px ${SANS};fill:var(--fg)}
31 .d{font:400 12.5px ${SANS};fill:var(--dim)}
32 .m{font:500 12px ${MONO};fill:var(--dim)}`
33
34/** One odometer column: digits 0..count-1 then 0 again, rolling every `stepS` seconds. */
35const odoColumn = (x: number, count: number, stepS: number, elapsedS: number, id: string) => {
36 const lh = 14
37 const r = Math.min(ROLL_S / stepS, 0.4)
38 const times: number[] = [0]
39 const values: number[] = [0]
40 const splines: string[] = []
41 for (let i = 0; i < count; i++) {
42 times.push((i + 1 - r) / count, (i + 1) / count)
43 values.push(-i * lh, -(i + 1) * lh)
44 splines.push('0 0 1 1', '0.5 0 0.2 1')
45 }
46 const digits = Array.from({ length: count + 1 }, (_, i) =>
47 `<text class="m" x="${x}" y="${BASE - 0.5 + i * lh}">${i % count}</text>`).join('')
48 return `<clipPath id="${id}"><rect x="${x - 1}" y="${MID - 7}" width="9" height="14"/></clipPath>
49 <g clip-path="url(#${id})"><g>
50 <animateTransform attributeName="transform" type="translate" calcMode="spline"
51 values="${values.map(v => `0 ${n(v)}`).join(';')}" keyTimes="${times.map(n).join(';')}"
52 keySplines="${splines.join(';')}" dur="${count * stepS}s" begin="${n(-elapsedS)}s" repeatCount="indefinite"/>
53 ${digits}</g></g>`
54}
55
56/** m:ss that rolls by itself; returns its width. */
57const timer = (x: number, elapsedS: number) => {
58 const cw = 7.3
59 let svg = ''
60 let cx = x
61 if (elapsedS >= 600) {
62 svg += odoColumn(cx, 6, 600, elapsedS, 'o0')
63 cx += cw
64 }
65 svg += odoColumn(cx, 10, 60, elapsedS, 'o1')
66 svg += `<text class="m" x="${n(cx + cw)}" y="${BASE - 0.5}">:</text>`
67 svg += odoColumn(cx + cw * 2, 6, 10, elapsedS, 'o2') + odoColumn(cx + cw * 3, 10, 1, elapsedS, 'o3')
68 return { svg, w: cx + cw * 4 - x }
69}
70
71const spinner = (x: number) => `
72 <g transform="translate(${x} ${MID})">
73 <circle r="2.6" fill="${ORANGE}">
74 <animate attributeName="opacity" values="1;.45;1" dur="1.6s" repeatCount="indefinite"/>
75 </circle>
76 <circle r="6.5" fill="none" stroke="${ORANGE}" stroke-width="1.6" stroke-linecap="round" stroke-dasharray="10 31" opacity=".9">
77 <animateTransform attributeName="transform" type="rotate" values="0;360" dur="1s" repeatCount="indefinite"/>
78 </circle>
79 </g>`
80
81const check = (x: number, s: Snap, now: number) => {
82 if (s.aborted) return `<rect x="${x - 4.5}" y="${MID - 4.5}" width="9" height="9" rx="2" fill="#E5A84D"/>`
83 const tick = `<path d="M-3.6 0.2 L-1.1 2.7 L3.8 -2.5" fill="none" stroke="#0D1117" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"`
84 if (now - s.doneAt > 900) return `<g transform="translate(${x} ${MID})"><circle r="8" fill="${GREEN}"/>${tick}/></g>`
85 const b = ago(now, s.doneAt)
86 return `<g transform="translate(${x} ${MID})">
87 <circle r="0" fill="${GREEN}">
88 <animate attributeName="r" values="0;9.5;8" keyTimes="0;.6;1" dur=".42s" begin="${b}s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1;0.4 0 0.6 1"/>
89 </circle>
90 ${tick} stroke-dasharray="13" stroke-dashoffset="13">
91 <animate attributeName="stroke-dashoffset" values="13;0" dur=".28s" begin="${n(b + 0.28)}s" fill="freeze" calcMode="spline" keySplines="0.4 0 0.2 1"/>
92 </path>
93 </g>`
94}
95
96/** A small segmented bar with the count after it; returns its width. */
97const progress = (x: number, s: Snap, now: number) => {
98 const { done, total } = words(s)
99 const barW = Math.min(84, Math.max(36, total * 14))
100 const gap = total > 12 ? 0 : 2.5
101 const segW = (barW - (total - 1) * gap) / total
102 let svg = ''
103 if (gap === 0) {
104 svg += `<rect x="${x}" y="${MID - 2}" width="${barW}" height="4" rx="2" style="fill:var(--track)"/>
105 <rect x="${x}" y="${MID - 2}" width="${n((barW * done) / total)}" height="4" rx="2" fill="${GREEN}"/>`
106 } else {
107 s.tasks.forEach((t, i) => {
108 const sx = n(x + i * (segW + gap))
109 const w = n(segW)
110 svg += `<rect x="${sx}" y="${MID - 2}" width="${w}" height="4" rx="2" style="fill:var(--track)"/>`
111 if (t.status === 'completed') {
112 const grow = t.id === s.taskDoneId && now - s.taskDoneAt < 700
113 ? `<animate attributeName="width" values="0;${w}" dur=".45s" begin="${ago(now, s.taskDoneAt)}s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>`
114 : ''
115 svg += `<rect x="${sx}" y="${MID - 2}" width="${w}" height="4" rx="2" fill="${GREEN}">${grow}</rect>`
116 } else if (t.status === 'in_progress') {
117 svg += `<rect x="${sx}" y="${MID - 2}" width="${w}" height="4" rx="2" fill="${ORANGE}">
118 <animate attributeName="opacity" values=".35;1;.35" dur="1.6s" repeatCount="indefinite"/></rect>`
119 }
120 })
121 }
122 const count = `${done}/${total}`
123 svg += `<text class="m" x="${n(x + barW + 7)}" y="${BASE - 0.5}">${count}</text>`
124 return { svg, w: barW + 7 + count.length * 7.3 }
125}
126
127const failures = (x: number, s: Snap, now: number) => {
128 const label = `${s.failed} failed`
129 const fresh = now - s.lastFailAt < 1200
130 const ring = fresh
131 ? `<circle cx="${x + 3}" cy="${MID}" r="3" fill="none" stroke="${RED}" stroke-width="1.5">
132 <animate attributeName="r" values="3;10" dur=".9s" begin="${ago(now, s.lastFailAt)}s" fill="freeze" calcMode="spline" keySplines="0.2 0.8 0.2 1"/>
133 <animate attributeName="opacity" values="1;0" dur=".9s" begin="${ago(now, s.lastFailAt)}s" fill="freeze"/>
134 </circle>`
135 : ''
136 return {
137 svg: `${ring}<circle cx="${x + 3}" cy="${MID}" r="3" fill="${RED}"/>
138 <text x="${x + 11}" y="${BASE}" style="font:500 12.5px ${SANS};fill:${RED}">${label}</text>`,
139 w: 11 + textW(label, 12.5),
140 }
141}
142
143const SEP = 16
144
145const wrap = (w: number, body: string, alt: string) =>
146 `<svg xmlns="http://www.w3.org/2000/svg" width="${Math.ceil(w)}" height="${H}" viewBox="0 0 ${Math.ceil(w)} ${H}" role="img" aria-label="${esc(alt)}"><style>${STYLE}</style>${body}</svg>`
147
148export const bandSvg = (s: Snap, now: number): { source: string; alt: string; width: number; height: number } => {
149 const alt = plainLine(s, now)
150 const w = words(s)
151 let x = 14
152 let body = ''
153
154 if (s.phase === 'working') {
155 body += spinner(x)
156 x += 16
157 const headline = clip(w.headline, 56)
158 const detail = w.detail ? clip(w.detail, 40) : ''
159 // New words ease in, so a change of task or tool reads as motion, not a flicker.
160 const enter = now - s.actionAt < 400
161 ? `<animate attributeName="opacity" values="0;1" dur=".25s" begin="${ago(now, s.actionAt)}s" fill="freeze"/>`
162 : ''
163 body += `<text x="${x}" y="${BASE}" xml:space="preserve">${enter}<tspan class="h">${esc(headline)}</tspan>${detail ? `<tspan class="d"> ${esc(detail)}</tspan>` : ''}</text>`
164 x += textW(headline, 13) + (detail ? textW(` ${detail}`, 12.5) * 0.92 : 0) + SEP
165 const t = timer(x, (now - s.startedAt) / 1000)
166 body += t.svg
167 x += t.w + SEP
168 } else {
169 body += check(x, s, now)
170 x += 16
171 const label = s.aborted ? 'Interrupted' : 'Done'
172 const time = ` in ${fmtClock(s.durationMs)}`
173 body += `<text x="${x}" y="${BASE}" xml:space="preserve"><tspan class="h">${label}</tspan><tspan class="d">${time}</tspan></text>`
174 x += textW(label, 13) + textW(time, 12.5) + SEP
175 }
176
177 if (w.total > 0) {
178 const p = progress(x, s, now)
179 body += p.svg
180 x += p.w + SEP
181 }
182 if (s.failed > 0) {
183 const f = failures(x, s, now)
184 body += f.svg
185 x += f.w + SEP
186 }
187
188 if (s.phase === 'done') {
189 // Fade out just before the band is taken down.
190 const out = n(ago(now, s.doneAt) + LINGER_MS / 1000 - 0.6)
191 body = `<g><animate attributeName="opacity" values="1;0" dur=".6s" begin="${out}s" fill="freeze"/>${body}</g>`
192 }
193
194 const width = Math.ceil(x - SEP + 14)
195 return { source: wrap(width, body, alt), alt, width, height: H }
196}
197hooks/copies.ts 30 lines1// Spotting a second copy of this mod loaded in the same chat (the same check recap makes).
2
3/** A copy of the mod announcing itself in a chat. */
4export type Instance = { session: string; token: string; root: string; at: number }
5
6const DAY = 86_400_000
7
8/** Where the copy registry lives. */
9export const registryPath = (home: string) => `${home.replace(/\\/g, '/').replace(/\/+$/, '')}/.claude/turn-status/instances.json`
10
11/** The copy registry with this copy added and day-old entries dropped. */
12export const withInstance = (raw: string, me: Instance): Instance[] => {
13 let list: Instance[] = []
14 try {
15 const parsed = JSON.parse(raw)
16 if (Array.isArray(parsed)) list = parsed.filter(i => i && typeof i.token === 'string')
17 } catch {
18 list = []
19 }
20 return [...list.filter(i => i.token !== me.token && me.at - i.at < DAY), me]
21}
22
23/** Other copies that started in the same chat at the same moment: a second copy of the mod. */
24export const otherCopies = (list: readonly Instance[], me: Instance, windowMs = 5000): Instance[] =>
25 list.filter(i => i.session === me.session && i.token !== me.token && Math.abs(i.at - me.at) < windowMs)
26
27export const duplicateWarning = (me: Instance, others: readonly Instance[]) =>
28 `⚠ turn-status is loaded twice in this chat (${[me, ...others].map(i => i.root).join(' and ')}). `
29 + 'Two copies can clash; remove one from your plugins and restart the chat.'
30hooks/motion.tsx 88 lines1import type { ClientModule } from 'claude-code'
2
3import type { Snap } from '../types'
4import { fmtClock, words } from './model'
5
6// Terminal band: the same one quiet row, drawn in cells. The frame clock runs
7// only while a turn does (spinner and timer); a finished line is static.
8
9const TICK_MS = 100
10const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
11
12export const clip = (t: string, max: number) => (t.length > max ? `${t.slice(0, max - 1)}…` : t)
13
14/** One block per task (at most 16), for the progress bar. */
15export const blocks = (s: Snap, frame: number): ('done' | 'on' | 'off' | 'todo')[] =>
16 s.tasks.slice(0, 16).map(t =>
17 t.status === 'completed' ? 'done' : t.status === 'in_progress' ? (frame % 10 < 6 ? 'on' : 'off') : 'todo')
18
19type Local = { frame: number; startedAt: number; turnId: string; stop?: () => void }
20
21const TurnBand: ClientModule<Snap, Local> = (props, surface) => {
22 const { Box, Text } = surface.elements
23 let L = surface.state
24 if (L === undefined) {
25 L = { frame: 0, startedAt: Date.now(), turnId: props.turnId }
26 surface.setState(L)
27 }
28 const s = L
29 if (props.turnId !== s.turnId) {
30 s.turnId = props.turnId
31 s.startedAt = Date.now()
32 }
33
34 const isWorking = props.phase === 'working'
35 if (isWorking && s.stop === undefined) {
36 s.stop = surface.every(TICK_MS, () => {
37 s.frame++
38 surface.setState(s)
39 })
40 } else if (!isWorking && s.stop !== undefined) {
41 s.stop()
42 s.stop = undefined
43 }
44
45 const w = words(props)
46 const cols = surface.columns || 100
47 const bar = w.total > 0 ? (
48 <>
49 <Text> </Text>
50 {blocks(props, s.frame).map((b, i) => (
51 <Text key={`b${i}`} color={b === 'done' ? 'success' : b === 'todo' ? 'subtle' : 'claude'} dimColor={b === 'off'}>
52 {b === 'todo' ? '▱' : '▰'}
53 </Text>
54 ))}
55 <Text dimColor> {w.progress}</Text>
56 </>
57 ) : null
58 const fail = props.failed > 0 ? <Text color="error"> ● {props.failed} failed</Text> : null
59
60 if (isWorking) {
61 const room = Math.max(16, cols - 30)
62 const headline = clip(w.headline, Math.min(56, room))
63 const detail = w.detail && cols >= 90 ? clip(w.detail, 40) : null
64 return (
65 <Box>
66 <Text color="claude">{SPIN[s.frame % SPIN.length]} </Text>
67 <Text bold>{headline}</Text>
68 {detail ? <Text dimColor> {detail}</Text> : null}
69 <Text dimColor> {fmtClock(Date.now() - s.startedAt)}</Text>
70 {bar}
71 {fail}
72 </Box>
73 )
74 }
75
76 return (
77 <Box>
78 <Text color={props.aborted ? 'warning' : 'success'}>{props.aborted ? '■' : '✓'} </Text>
79 <Text bold>{props.aborted ? 'Interrupted' : 'Done'}</Text>
80 <Text dimColor> in {fmtClock(props.durationMs)}</Text>
81 {bar}
82 {fail}
83 </Box>
84 )
85}
86
87export default TurnBand
88types/index.d.ts 33 lines1export type Task = {
2 id: string
3 title: string
4 /** present-continuous form shown while it runs ("Running tests") */
5 active: string | null
6 status: 'pending' | 'in_progress' | 'completed'
7}
8
9export type Snap = {
10 phase: 'idle' | 'working' | 'done'
11 turnId: string
12 /** what the running tool is doing ("Editing svg.ts"); null between tools */
13 action: string | null
14 failed: number
15 durationMs: number
16 aborted: boolean
17 /** epoch ms: turn start, turn end, last action change, last failure */
18 startedAt: number
19 doneAt: number
20 actionAt: number
21 lastFailAt: number
22 /** Claude's task list (TodoWrite / TaskCreate / TaskUpdate), kept across turns */
23 tasks: Task[]
24 taskDoneAt: number
25 taskDoneId: string
26}
27
28declare module 'claude-code' {
29 interface PluginState {
30 'turn-status': { snap: Snap }
31 }
32}
33