SLOPSHOPPER

savvy-progress

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

newpaneguardcommandstatustimer
v0.1.0no licenseupdated 2026-10-22ranslu/king-pedro/.claude/skills/savvy-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · savvy-progress
│ ┃ Savvy Progress ✕ › fix the failing auth test and add an audit log call │ ┃ Waiting for the first turn… │ ⏺ 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 │ │ › /progress │ ⎿ savvy-progress: Savvy Progress opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Savvy Progress
Waiting for the first turn…
Source 3 files
hooks/register.tsx 115 lines
1import { 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}
115
hooks/summary.ts 11 lines
1/** 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}
11
types/index.d.ts 14 lines
1export 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