SLOPSHOPPER

plan-progress

Band above the prompt showing the current plan step and a progress bar

newbandguardcommandtool
v0.1.0MITupdated 2026-10-07DamianoCode/claude-plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
› 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 › /plan-progress ⎿ plan-progress: Plan band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

plan-progress

Mod do Claude Code, który nad promptem pokazuje etap zaplanowanej pracy i pasek postępu.

 ◆ plan etap 4/6 · Dodaję testy                              50% [ Lista ] [ Ukryj ]
 ██████████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░

 ✓ 1. Przeanalizuj kod
 ✓ 2. Zaprojektuj API
 ✓ 3. Napisz endpointy
 ▶ 4. Dodaję testy
 ○ 5. Zaktualizuj dokumentację
 ○ 6. Zrób code review
  • bieżący etap, liczba etapów i procent ukończenia; po 100% pasek robi się zielony
  • [ Lista ] rozwija pełną listę etapów, [ Ukryj ] chowa pasek (nowy plan pokaże go znowu)
  • /plan-progress przełącza widoczność

Skąd bierze plan

  • z wbudowanych narzędzi Claude Code: TodoWrite albo TaskCreate / TaskUpdate / TaskList
  • z własnego narzędzia mcp__plan-progress__plan (na sesje bez wbudowanych narzędzi do planowania); jest odroczone, więc dopóki model go nie użyje, w kontekście jest tylko jego nazwa

Instalacja

/plugin install plan-progress --marketplace DamianoCode/claude-plan-progress

Potwierdź dodanie marketplace (y) i wybierz zakres (domyślnie user).

Bez instalacji

git clone https://github.com/DamianoCode/claude-plan-progress
claude --plugin-dir ./claude-plan-progress

Mod działa tylko w sesji uruchomionej z tą flagą.

Rozwój

claude plugin validate .
claude plugin test .

Mod to moduł function hooks (hooks/register.tsx); przy --plugin-dir przeładowuje się po każdej zmianie pliku.

Source 2 files
hooks/register.tsx 220 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Plan, Step } from '../types'
5
6const plan = atom({ plugin: 'plan-progress', key: 'plan' } as const, null)
7const isExpanded = atom({ plugin: 'plan-progress', key: 'isExpanded' } as const, false)
8const isHidden = atom({ plugin: 'plan-progress', key: 'isHidden' } as const, false)
9
10const PAD = 1
11const PLAN_TOOL = 'mcp__plan-progress__plan'
12
13const isDone = (p: Plan | null) => p !== null && p.steps.every(s => s.status === 'completed')
14
15const bar = (ratio: number, width: number) => {
16  const filled = Math.round(ratio * width)
17  return ['█'.repeat(filled), '░'.repeat(width - filled)] as const
18}
19
20export const register: Register = on => {
21  // TodoWrite sends the whole list each time.
22  on('tool.call', { tool: 'TodoWrite' }, async ($, e, next) => {
23    const ran = await next(e)
24    if (ran.deny === undefined && !ran.isError) {
25      const steps: Step[] = e.todos.map((t, i) => ({
26        id: String(i),
27        title: t.content,
28        active: t.activeForm,
29        status: t.status,
30      }))
31      await update($, plan, () => (steps.length ? { steps } : null))
32      await update($, isHidden, () => false)
33    }
34    return ran
35  }).catch(($, e, next) => next(e))
36
37  // TaskCreate/TaskUpdate build the list one task at a time.
38  on('tool.call', { tool: 'TaskCreate' }, async ($, e, next) => {
39    const ran = await next(e)
40    if (ran.deny === undefined && !ran.isError) {
41      const step: Step = {
42        id: ran.result.task.id,
43        title: e.subject,
44        active: e.activeForm ?? e.subject,
45        status: 'pending',
46      }
47      // A new task after a finished plan starts a new plan.
48      await update($, plan, p => ({ steps: [...(p === null || isDone(p) ? [] : p.steps), step] }))
49      await update($, isHidden, () => false)
50    }
51    return ran
52  }).catch(($, e, next) => next(e))
53
54  on('tool.call', { tool: 'TaskUpdate' }, async ($, e, next) => {
55    const ran = await next(e)
56    if (ran.deny === undefined && !ran.isError && ran.result.success) {
57      await update($, plan, p => {
58        if (p === null) return p
59        const { status } = e
60        const steps = status === 'deleted'
61          ? p.steps.filter(s => s.id !== e.taskId)
62          : p.steps.map(s =>
63              s.id !== e.taskId
64                ? s
65                : {
66                    ...s,
67                    title: e.subject ?? s.title,
68                    active: e.activeForm ?? s.active,
69                    status: status ?? s.status,
70                  },
71            )
72        return steps.length ? { steps } : null
73      })
74    }
75    return ran
76  }).catch(($, e, next) => next(e))
77
78  // TaskList is the source of truth: reconcile with it.
79  on('tool.call', { tool: 'TaskList' }, async ($, e, next) => {
80    const ran = await next(e)
81    if (ran.deny === undefined && !ran.isError) {
82      await update($, plan, p => {
83        const known = new Map((p?.steps ?? []).map(s => [s.id, s]))
84        const steps: Step[] = ran.result.tasks.map(t => ({
85          id: t.id,
86          title: t.subject,
87          active: known.get(t.id)?.active ?? t.subject,
88          status: t.status,
89        }))
90        return steps.length ? { steps } : null
91      })
92    }
93    return ran
94  }).catch(($, e, next) => next(e))
95
96  on('command.run', { command: 'plan-progress' }, async $ => {
97    const hidden = await update($, isHidden, h => !h)
98    return { text: hidden ? 'Plan band hidden.' : 'Plan band shown.' }
99  })
100
101  // The mod's own planning tool, for sessions without TodoWrite / Task tools.
102  on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
103    const input = e as unknown as { steps?: { title?: unknown; active?: unknown; status?: unknown }[] }
104    const steps: Step[] = (input.steps ?? []).map((s, i) => ({
105      id: String(i),
106      title: String(s.title ?? ''),
107      active: String(s.active ?? s.title ?? ''),
108      status: s.status === 'completed' || s.status === 'in_progress' ? s.status : 'pending',
109    }))
110    await update($, plan, () => (steps.length ? { steps } : null))
111    await update($, isHidden, () => false)
112    const done = steps.filter(s => s.status === 'completed').length
113    return { result: `Plan updated: ${done}/${steps.length} steps completed.` }
114  })
115
116  on('session.start', async ($, e, next) => {
117    await $.tool.register({
118      name: 'plan',
119      description:
120        'Show the user the plan for multi-step work as a progress band above the prompt. ' +
121        'Send the whole list every time: set a step in_progress when you start it and completed as soon as it is done. ' +
122        'Send an empty list to clear the band.',
123      inputSchema: {
124        type: 'object',
125        properties: {
126          steps: {
127            type: 'array',
128            items: {
129              type: 'object',
130              properties: {
131                title: { type: 'string', description: 'Imperative step name, e.g. "Run tests"' },
132                active: { type: 'string', description: 'Present continuous form shown while in progress, e.g. "Running tests"' },
133                status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] },
134              },
135              required: ['title', 'status'],
136            },
137          },
138        },
139        required: ['steps'],
140      },
141    })
142    await $.command.register({ name: 'plan-progress', description: 'Show or hide the plan progress band' })
143    return next(e)
144  })
145
146  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
147    const p = await read($, plan)
148    if (e.props.hasSurvey || p === null || (await read($, isHidden))) {
149      return next(e)
150    }
151
152    const { Box, Text, Button } = $.ui.resolve(e)
153    const total = p.steps.length
154    const done = p.steps.filter(s => s.status === 'completed').length
155    const currentIdx = p.steps.findIndex(s => s.status === 'in_progress')
156    const nextIdx = p.steps.findIndex(s => s.status !== 'completed')
157    const idx = currentIdx >= 0 ? currentIdx : nextIdx
158    const ratio = total ? done / total : 0
159    const pct = Math.round(ratio * 100)
160    const finished = done === total
161
162    const label = finished
163      ? 'Wszystkie etapy ukończone'
164      : idx >= 0
165        ? (currentIdx >= 0 ? p.steps[idx]!.active : `Następny: ${p.steps[idx]!.title}`)
166        : ''
167    const stage = finished ? `${total}/${total}` : `${idx + 1}/${total}`
168
169    // One cell of padding each side, so the bar lines up with the header row.
170    const width = Math.max(10, e.props.bodyColumns - 2 * PAD)
171    const [full, empty] = bar(ratio, width)
172    const tone = finished ? 'success' : 'claude'
173    const expanded = await read($, isExpanded)
174
175    return (
176      <Box flexDirection="column" paddingX={PAD} paddingTop={1}>
177        <Box>
178          <Text color="claude" bold>◆ plan </Text>
179          <Text bold>etap {stage} </Text>
180          <Text dimColor>· </Text>
181          <Text wrap="truncate-end">{label}</Text>
182          <Box flexGrow={1} />
183          <Text color={tone} bold> {pct}% </Text>
184          <Button
185            key="toggle"
186            label={expanded ? 'Zwiń' : 'Lista'}
187            onPress={() => update($, isExpanded, x => !x)}
188          />
189          <Text> </Text>
190          <Button key="hide" label="Ukryj" onPress={() => update($, isHidden, () => true)} />
191        </Box>
192        <Box>
193          <Text color={tone}>{full}</Text>
194          <Text color="subtle">{empty}</Text>
195        </Box>
196        {expanded ? (
197          <Box flexDirection="column" paddingTop={1}>
198            {p.steps.map((step, i) => (
199              <Box key={`step-${step.id}`}>
200                <Text color={step.status === 'completed' ? 'success' : step.status === 'in_progress' ? 'claude' : 'inactive'}>
201                  {step.status === 'completed' ? ' ✓ ' : step.status === 'in_progress' ? ' ▶ ' : ' ○ '}
202                </Text>
203                <Text dimColor>{String(i + 1).padStart(String(total).length)}. </Text>
204                <Text
205                  wrap="truncate-end"
206                  bold={step.status === 'in_progress'}
207                  dimColor={step.status === 'completed'}
208                  color={step.status === 'pending' ? 'inactive' : undefined}
209                >
210                  {step.status === 'in_progress' ? step.active : step.title}
211                </Text>
212              </Box>
213            ))}
214          </Box>
215        ) : null}
216      </Box>
217    )
218  })
219}
220
types/index.d.ts 9 lines
1export type Step = { id: string; title: string; active: string; status: 'pending' | 'in_progress' | 'completed' }
2export type Plan = { steps: Step[] }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'plan-progress': { plan: Plan | null; isHidden: boolean; isExpanded: boolean }
7  }
8}
9