Live turn tracker: elapsed time, current tool, step list and recent-turn history in a pane and the status line.

hooks/register.tsx 115 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Step, Turn } from '../types'
5import { summarize } from './summary'
6
7const PANE = 'savvy-progress'
8const turn = atom({ plugin: 'savvy-progress', key: 'turn' } as const, null)
9const history = atom({ plugin: 'savvy-progress', key: 'history' } as const, [])
10const now = atom({ plugin: 'savvy-progress', key: 'now' } as const, 0)
11
12const secs = (ms: number) => (ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`)
13
14function line(t: Turn, at: number): string {
15 const running = t.steps.find(s => s.state === 'run')
16 const errs = t.steps.filter(s => s.state === 'err').length
17 const parts = [`${secs((t.endedAt ?? at) - t.startedAt)}`, `${t.steps.length} tools`]
18 if (errs) parts.push(`${errs} failed`)
19 if (running) parts.push(running.tool)
20 return parts.join(' · ')
21}
22
23function setStep($: EngineInterface, id: string, patch: Partial<Step>) {
24 return update($, turn, t => (t ? { ...t, steps: t.steps.map(s => (s.id === id ? { ...s, ...patch } : s)) } : t))
25}
26
27export const register: Register = on => {
28 let tick: Timer | null = null
29
30 on('session.start', async ($, e, next) => {
31 await $.command.register({ name: 'progress', description: 'Open the Savvy Progress pane' })
32 return next(e)
33 })
34
35 on('command.run', { command: 'progress' }, async $ => {
36 await $.ui.open({ id: PANE, title: 'Savvy Progress' })
37 return { text: 'Savvy Progress opened.' }
38 })
39
40 on('turn.start', async ($, e, next) => {
41 const at = await $.clock.now()
42 await update($, turn, () => ({ prompt: e.text.replace(/\s+/g, ' ').slice(0, 80), startedAt: at, endedAt: null, steps: [] }))
43 await update($, now, () => at)
44 tick?.cancel()
45 tick = $.clock.every(1000, () => {
46 void $.clock.now().then(t => update($, now, () => t))
47 })
48 $.ui.status('⏳ working…')
49 return next(e)
50 })
51
52 on('tool.call', async ($, e, next) => {
53 const at = await $.clock.now()
54 const step: Step = { id: e.tool_use_id, tool: e.tool, what: summarize(e.tool, e), state: 'run', ms: 0 }
55 const t = await update($, turn, cur => (cur ? { ...cur, steps: [...cur.steps, step].slice(-200) } : cur))
56 if (t) $.ui.status(`⏳ ${line(t, at)}`)
57 const ran = await next(e)
58 const failed = 'deny' in ran && ran.deny !== undefined ? true : ran.isError === true
59 await setStep($, step.id, { state: failed ? 'err' : 'ok', ms: (await $.clock.now()) - at })
60 return ran
61 }).catch(($, e, next) => next(e)) // an observer: never block the call
62
63 on('turn.complete', async ($, e, next) => {
64 tick?.cancel()
65 tick = null
66 const at = await $.clock.now()
67 const done = await update($, turn, t => (t ? { ...t, endedAt: at } : t))
68 if (done) {
69 await update($, history, h => [done, ...h].slice(0, 8))
70 $.ui.status(`✓ ${line(done, at)}`)
71 }
72 return next(e)
73 })
74
75 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
76 const { Box, Text } = $.ui.resolve(e)
77 const t = await read($, turn)
78 const at = (await read($, now)) || (await $.clock.now())
79 const past = await read($, history)
80 const room = Math.max(3, (e.viewport?.rows ?? 24) - 10)
81 const width = Math.max(20, (e.props.bodyColumns ?? 60) - 4)
82
83 if (!t) return <Text dimColor>Waiting for the first turn…</Text>
84
85 const done = t.steps.filter(s => s.state !== 'run').length
86 const pct = t.endedAt ? 1 : t.steps.length ? done / t.steps.length : 0
87 const barW = Math.min(30, width - 10)
88 const fill = Math.round(pct * barW)
89
90 return (
91 <Box flexDirection="column">
92 <Text bold>{t.endedAt ? '✓ Done' : '⏳ Working'} · {line(t, at)}</Text>
93 <Text dimColor wrap="truncate-end">“{t.prompt}”</Text>
94 <Text color={t.endedAt ? 'green' : 'yellow'}>
95 {'█'.repeat(fill)}{'░'.repeat(barW - fill)} {done}/{t.steps.length}
96 </Text>
97 <Text> </Text>
98 {t.steps.slice(-room).map(s => (
99 <Text key={s.id} wrap="truncate-end" color={s.state === 'err' ? 'red' : undefined} dimColor={s.state === 'ok'}>
100 {s.state === 'run' ? '▶' : s.state === 'ok' ? '✓' : '✗'} {s.tool.padEnd(8)} {s.what}
101 {s.state !== 'run' ? ` ${secs(s.ms)}` : ''}
102 </Text>
103 ))}
104 {past.length > 1 && <Text> </Text>}
105 {past.length > 1 && <Text bold>Recent turns</Text>}
106 {past.slice(1, 5).map((p, i) => (
107 <Text key={`h${i}`} dimColor wrap="truncate-end">
108 {line(p, p.endedAt ?? at)} — {p.prompt}
109 </Text>
110 ))}
111 </Box>
112 )
113 })
114}
115hooks/summary.ts 11 lines1/** One short line describing what a tool call is about. */
2export function summarize(tool: string, input: unknown): string {
3 const x = (input ?? {}) as Record<string, unknown>
4 const pick = ['file_path', 'command', 'pattern', 'url', 'query', 'description', 'path', 'skill']
5 for (const k of pick) {
6 const v = x[k]
7 if (typeof v === 'string' && v) return v.replace(/\s+/g, ' ').slice(0, 90)
8 }
9 return tool
10}
11types/index.d.ts 14 lines1export type Step = { id: string; tool: string; what: string; state: 'run' | 'ok' | 'err'; ms: number }
2export type Turn = {
3 prompt: string
4 startedAt: number
5 endedAt: number | null
6 steps: Step[]
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 'savvy-progress': { turn: Turn | null; history: Turn[]; now: number }
12 }
13}
14