SLOPSHOPPER

plan-progress

Shows the running plan's current step, elapsed times and estimates as a timeline above the prompt

newpanebandguardcommandtool
v0.1.0no licenseupdated 2026-10-07liveweird/claude-mods/plan-progress
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · plan-progress
│ ┃ Plan progress ✕ › fix the failing auth test and add an audit log call │ ┃ No plan is running. Claude reports one with │ ┃ its plan_progress tool when it starts ⏺ Read(src/auth.ts) │ ┃ executing a plan. ⎿ 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 progress pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Plan progress
No plan is running. Claude reports one with its plan_progress tool when it starts executing a plan.
README

plan-progress

A Claude Code mod that shows the plan Claude is executing as a live timeline above the prompt:

Plan · Checkup 3 · step 3/5: Fix tier A
━━━━━━━━│━━━━━━━━━━━━━│━━━━━━━──────│─────────────────│──────
elapsed 1h12m · step 8m of ~15m · ~45m left · ETA 14:32
  • the current step, and time elapsed for the whole plan and for that step
  • the estimate of every remaining step, with the expected start time of each (in the pane)
  • colours: finished steps green, the running step blue (bold for time spent, thin for the rest of its estimate), later steps dim grey, an overrun orange

Install

At the prompt of a Claude Code terminal session:

/plugin install plan-progress --marketplace liveweird/claude-mods

Answer y to add the marketplace (once), then pick the user scope. It is active at once, and in every later session.

Use

Claude reports progress through the plan_progress tool the mod registers (start with steps and minute estimates, step on each move, revise, finish, clear). To make it do so by default, add to ~/.claude/CLAUDE.md:

When executing a multi-step plan and the plan_progress tool (the plan-progress mod) is available, report it: start with every step and an estimate in minutes, step each time you move on, revise when steps or estimates change, finish at the end. Skip it for one-step tasks.

  • /plan-progress opens a pane with the timeline and one row per step.
  • /plan-progress clear drops the plan.

A finished plan stays on the band for ten minutes, then leaves it.

Develop

claude plugin validate . and claude plugin test .; after an edit, /reload-plugins in a session that installed it from this folder.

Source 3 files
hooks/register.tsx 257 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Plan } from '../types'
5import {
6  clockTime,
7  describe,
8  fmt,
9  goTo,
10  revise,
11  type Segment,
12  startPlan,
13  stepDurationMs,
14  type StepInput,
15  summarize,
16  timeline,
17} from './plan'
18
19const PANE = 'plan-progress'
20const TOOL = 'mcp__plan-progress__plan_progress'
21const TICK_MS = 10_000
22/** A finished plan stays on the band this long, then leaves it. */
23const LINGER_MS = 10 * 60_000
24
25const plan = atom({ plugin: 'plan-progress', key: 'plan' } as const, null)
26
27const COLOR: Record<Segment['kind'], string> = {
28  done: 'success',
29  now: 'suggestion',
30  over: 'warning',
31  left: 'suggestion',
32  todo: 'inactive',
33  gap: 'subtle',
34}
35
36/** Done is green, the running step blue (solid for time spent, thin for its rest), later steps grey. */
37const DIM: ReadonlySet<Segment['kind']> = new Set(['todo', 'gap'])
38
39const STEPS_SCHEMA = {
40  type: 'array',
41  items: {
42    type: 'object',
43    properties: {
44      title: { type: 'string', description: 'Short step name (a few words).' },
45      estimateMin: { type: 'number', description: 'Estimated duration in minutes.' },
46    },
47    required: ['title', 'estimateMin'],
48  },
49}
50
51export const register: Register = on => {
52  on('session.start', async ($, e, next) => {
53    await $.tool.register({
54      name: 'plan_progress',
55      description: [
56        'Reports progress through the plan being executed, shown to the user as a timeline above the prompt.',
57        'Call action "start" when you begin executing a multi-step plan (title + steps, each with an estimate in minutes).',
58        'Call action "step" with the 1-based step number each time you move on to a step.',
59        'Call action "revise" when steps or estimates change (steps replace the list from step `from`, default the running one).',
60        'Call action "finish" when the plan is done, "clear" to drop it, "show" to read it back.',
61      ].join(' '),
62      inputSchema: {
63        type: 'object',
64        properties: {
65          action: { type: 'string', enum: ['start', 'step', 'revise', 'finish', 'clear', 'show'] },
66          title: { type: 'string', description: 'start: the plan name.' },
67          steps: { ...STEPS_SCHEMA, description: 'start/revise: the steps in order.' },
68          step: { type: 'number', description: 'step: the 1-based step now starting.' },
69          from: { type: 'number', description: 'revise: the 1-based step the new list starts at.' },
70        },
71        required: ['action'],
72      },
73    })
74    await $.command.register({
75      name: 'plan-progress',
76      description: 'Show the running plan as a timeline (args: clear)',
77      argumentHint: '[clear]',
78    })
79    $.clock.every(TICK_MS, async () => {
80      const p = await read($, plan)
81      if (p !== null && p.finishedAt === undefined) $.ui.invalidate('ui.render')
82    })
83
84    return next(e)
85  })
86
87  on('tool.call', { tool: TOOL }, async ($, e) => {
88    const now = await $.clock.now()
89    const current = await read($, plan)
90    const steps = Array.isArray(e.steps) ? (e.steps as StepInput[]) : undefined
91    const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : undefined)
92    let nextPlan: Plan | null = current
93
94    switch (e.action) {
95      case 'start':
96        if (!steps?.length) return { result: 'start needs a non-empty steps list.', isError: true }
97        nextPlan = startPlan(typeof e.title === 'string' && e.title.trim() ? e.title.trim() : 'Plan', steps, now)
98        break
99      case 'step': {
100        const n = num(e.step)
101        if (current === null) return { result: 'No plan is running; call start first.', isError: true }
102        if (n === undefined || n < 1 || n > current.steps.length) {
103          return { result: `step must be 1..${current.steps.length}.`, isError: true }
104        }
105        nextPlan = goTo(current, n - 1, now)
106        break
107      }
108      case 'revise': {
109        if (current === null) return { result: 'No plan is running; call start first.', isError: true }
110        if (!steps) return { result: 'revise needs steps.', isError: true }
111        const from = num(e.from)
112        nextPlan = revise(current, steps, from === undefined ? undefined : from - 1, now)
113        if (typeof e.title === 'string' && e.title.trim()) nextPlan = { ...nextPlan, title: e.title.trim() }
114        break
115      }
116      case 'finish':
117        if (current === null) return { result: 'No plan is running.', isError: true }
118        nextPlan = goTo(current, current.steps.length, now)
119        break
120      case 'clear':
121        nextPlan = null
122        break
123      case 'show':
124        break
125      default:
126        return { result: 'action must be one of start, step, revise, finish, clear, show.', isError: true }
127    }
128
129    if (nextPlan !== current) await update($, plan, () => nextPlan)
130    return { result: nextPlan === null ? 'No plan.' : describe(nextPlan, now) }
131  })
132
133  on('command.run', { command: 'plan-progress' }, async ($, e) => {
134    if (e.args.trim() === 'clear') {
135      await update($, plan, () => null)
136      return { text: 'Plan progress cleared.' }
137    }
138    await $.ui.open({ id: PANE, title: 'Plan progress' })
139    return { text: 'Plan progress pane opened.' }
140  })
141
142  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
143    const p = await read($, plan)
144    if (e.props.hasSurvey || p === null) return next(e)
145    const now = await $.clock.now()
146    if (p.finishedAt !== undefined && now - p.finishedAt > LINGER_MS) return next(e)
147
148    const { Box, Text } = $.ui.resolve(e)
149    const width = Math.max(10, e.props.bodyColumns)
150    const sum = summarize(p, now)
151    const step = p.steps[p.current]
152
153    return (
154      <Box flexDirection="column">
155        <Text wrap="truncate-end">
156          <Text dimColor>Plan · {p.title} · </Text>
157          {sum.isFinished ? (
158            <Text color="success" bold>
159              ✓ done, {p.steps.length} steps
160            </Text>
161          ) : (
162            <Text bold color={sum.isOver ? 'warning' : 'suggestion'}>
163              step {p.current + 1}/{p.steps.length}: {step?.title}
164            </Text>
165          )}
166        </Text>
167        {bar($, e, p, now, width)}
168        <Text wrap="truncate-end" dimColor>
169          {statusLine(p, now)}
170        </Text>
171      </Box>
172    )
173  })
174
175  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
176    const { Box, Text } = $.ui.resolve(e)
177    const p = await read($, plan)
178    if (p === null) {
179      return (
180        <Text dimColor>
181          No plan is running. Claude reports one with its plan_progress tool when it starts executing a plan.
182        </Text>
183      )
184    }
185    const now = await $.clock.now()
186    const width = Math.max(10, e.props.bodyColumns)
187    const sum = summarize(p, now)
188    let cursor = now + Math.max(0, sum.stepEstimateMs - sum.stepElapsedMs)
189
190    return (
191      <Box flexDirection="column">
192        <Text bold wrap="truncate-end">
193          {p.title}
194        </Text>
195        <Text dimColor wrap="truncate-end">
196          {statusLine(p, now)}
197        </Text>
198        {bar($, e, p, now, width)}
199        <Text> </Text>
200        {p.steps.map((s, i) => {
201          const isDone = sum.isFinished || i < p.current
202          const isNow = !sum.isFinished && i === p.current
203          const took = stepDurationMs(p, i, now)
204          let right: string
205          let color: string | undefined
206          if (isDone) {
207            right = `${fmt(took ?? 0)} / est ${s.estimateMin}m`
208            color = 'success'
209          } else if (isNow) {
210            right = `${fmt(took ?? 0)} / est ${s.estimateMin}m`
211            color = sum.isOver ? 'warning' : 'suggestion'
212          } else {
213            right = `est ${s.estimateMin}m · ≈${clockTime(cursor)}`
214            cursor += s.estimateMin * 60_000
215          }
216          return (
217            <Box flexDirection="row" key={`step-${i}`}>
218              <Box flexGrow={1} flexShrink={1}>
219                <Text wrap="truncate-end" color={color} bold={isNow} dimColor={!isDone && !isNow}>
220                  {isDone ? '✓' : isNow ? '▶' : '·'} {i + 1}. {s.title}
221                </Text>
222              </Box>
223              <Text color={isNow ? color : undefined} dimColor={!isNow}> {right}</Text>
224            </Box>
225          )
226        })}
227      </Box>
228    )
229  })
230}
231
232function statusLine(p: Plan, now: number): string {
233  const sum = summarize(p, now)
234  if (sum.isFinished) {
235    const est = p.steps.reduce((t, s) => t + s.estimateMin, 0)
236    return `took ${fmt(sum.elapsedMs)} (estimated ${fmt(est * 60_000)}) · finished ${clockTime(p.finishedAt ?? now)}`
237  }
238  const stepPart = sum.isOver
239    ? `step ${fmt(sum.stepElapsedMs)}, ${fmt(sum.stepElapsedMs - sum.stepEstimateMs)} over its ${fmt(sum.stepEstimateMs)}`
240    : `step ${fmt(sum.stepElapsedMs)} of ~${fmt(sum.stepEstimateMs)}`
241  return `elapsed ${fmt(sum.elapsedMs)} · ${stepPart} · ~${fmt(sum.remainingMs)} left · ETA ${clockTime(now + sum.remainingMs)}`
242}
243
244function bar($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], p: Plan, now: number, width: number) {
245  const { Box, Text } = $.ui.resolve(e)
246  const segments = timeline(p, now, width)
247  return (
248    <Box flexDirection="row">
249      {segments.map(s => (
250        <Text color={COLOR[s.kind]} bold={s.kind === 'now'} dimColor={DIM.has(s.kind)}>
251          {s.text}
252        </Text>
253      ))}
254    </Box>
255  )
256}
257
hooks/plan.ts 170 lines
1import type { Plan, PlanStep } from '../types'
2
3export type StepInput = { title: string; estimateMin: number }
4
5const MINUTE = 60_000
6
7export function startPlan(title: string, steps: StepInput[], now: number): Plan {
8  return {
9    title,
10    startedAt: now,
11    current: 0,
12    steps: steps.map((s, i) => ({ ...clean(s), ...(i === 0 ? { startedAt: now } : {}) })),
13  }
14}
15
16/** Ends the running step and starts `index` (0-based); steps jumped over count as skipped. */
17export function goTo(plan: Plan, index: number, now: number): Plan {
18  const target = Math.max(0, Math.min(index, plan.steps.length))
19  const steps = plan.steps.map((s, i): PlanStep => {
20    if (i < target) {
21      return s.endedAt !== undefined ? s : { ...s, startedAt: s.startedAt ?? now, endedAt: now }
22    }
23    if (i === target) {
24      return { title: s.title, estimateMin: s.estimateMin, startedAt: s.startedAt !== undefined && s.endedAt === undefined ? s.startedAt : now }
25    }
26    return { title: s.title, estimateMin: s.estimateMin }
27  })
28  const isDone = target >= steps.length
29  return { ...plan, steps, current: target, ...(isDone ? { finishedAt: now } : { finishedAt: undefined }) }
30}
31
32/** Replaces the steps from `from` (default: the running one) on, keeping what already ran. */
33export function revise(plan: Plan, steps: StepInput[], from: number | undefined, now: number): Plan {
34  const at = Math.max(0, Math.min(from ?? plan.current, plan.steps.length))
35  const kept = plan.steps.slice(0, at)
36  const fresh = steps.map((s, i): PlanStep => {
37    const old = plan.steps[at + i]
38    const isRunning = at + i === plan.current
39    return { ...clean(s), ...(isRunning ? { startedAt: old?.startedAt ?? now } : {}) }
40  })
41  const merged = [...kept, ...fresh]
42  const current = Math.min(plan.current, merged.length)
43  return { ...plan, steps: merged, current, ...(current >= merged.length && merged.length > 0 ? { finishedAt: plan.finishedAt ?? now } : { finishedAt: undefined }) }
44}
45
46function clean(s: StepInput): PlanStep {
47  return { title: String(s.title).trim() || 'Untitled step', estimateMin: Math.max(0, Number(s.estimateMin) || 0) }
48}
49
50export type Summary = {
51  elapsedMs: number
52  stepElapsedMs: number
53  stepEstimateMs: number
54  /** Estimated time still to go: the running step's unspent estimate plus every later step's. */
55  remainingMs: number
56  isOver: boolean
57  isFinished: boolean
58}
59
60export function summarize(plan: Plan, now: number): Summary {
61  const end = plan.finishedAt ?? now
62  const running = plan.steps[plan.current]
63  const isFinished = plan.finishedAt !== undefined || running === undefined
64  const stepElapsedMs = !isFinished && running?.startedAt !== undefined ? now - running.startedAt : 0
65  const stepEstimateMs = isFinished ? 0 : (running?.estimateMin ?? 0) * MINUTE
66  const later = plan.steps.slice(plan.current + 1).reduce((sum, s) => sum + s.estimateMin * MINUTE, 0)
67  return {
68    elapsedMs: end - plan.startedAt,
69    stepElapsedMs,
70    stepEstimateMs,
71    remainingMs: isFinished ? 0 : Math.max(0, stepEstimateMs - stepElapsedMs) + later,
72    isOver: !isFinished && stepEstimateMs > 0 && stepElapsedMs > stepEstimateMs,
73    isFinished,
74  }
75}
76
77export function stepDurationMs(plan: Plan, i: number, now: number): number | undefined {
78  const s = plan.steps[i]
79  if (s?.startedAt === undefined) return undefined
80  return (s.endedAt ?? now) - s.startedAt
81}
82
83export type Segment = { text: string; kind: 'done' | 'now' | 'left' | 'over' | 'todo' | 'gap' }
84
85/**
86 * The timeline as runs of cells, one block per step sized by its actual time (done),
87 * its larger of elapsed and estimate (running) or its estimate (to do).
88 */
89export function timeline(plan: Plan, now: number, width: number): Segment[] {
90  const n = plan.steps.length
91  if (n === 0 || width < n) return []
92  /** Separators only where every step still gets a cell of its own. */
93  const hasGaps = width - (n - 1) >= n
94  const room = hasGaps ? width - (n - 1) : width
95  const weights = plan.steps.map((s, i) => {
96    const est = s.estimateMin * MINUTE
97    if (i < plan.current || plan.finishedAt !== undefined) return Math.max(stepDurationMs(plan, i, now) ?? 0, 1)
98    if (i === plan.current) return Math.max(est, stepDurationMs(plan, i, now) ?? 0, 1)
99    return Math.max(est, 1)
100  })
101  const cells = allocate(weights, room)
102  const out: Segment[] = []
103  plan.steps.forEach((s, i) => {
104    if (i > 0 && hasGaps) out.push({ text: '│', kind: 'gap' })
105    const w = cells[i] ?? 1
106    if (i < plan.current || plan.finishedAt !== undefined) {
107      out.push({ text: '━'.repeat(w), kind: 'done' })
108    } else if (i === plan.current) {
109      const spent = stepDurationMs(plan, i, now) ?? 0
110      const est = s.estimateMin * MINUTE
111      if (est > 0 && spent > est) {
112        const ok = Math.max(1, Math.round((w * est) / spent))
113        out.push({ text: '━'.repeat(Math.min(ok, w)), kind: 'now' })
114        if (w - ok > 0) out.push({ text: '━'.repeat(w - ok), kind: 'over' })
115      } else {
116        const fill = est > 0 ? Math.min(w, Math.max(1, Math.round((w * spent) / est))) : w
117        out.push({ text: '━'.repeat(fill), kind: 'now' })
118        if (w - fill > 0) out.push({ text: '─'.repeat(w - fill), kind: 'left' })
119      }
120    } else {
121      out.push({ text: '─'.repeat(w), kind: 'todo' })
122    }
123  })
124  return out
125}
126
127/** Every weight gets at least one cell; the rest go by largest remainder. */
128function allocate(weights: number[], room: number): number[] {
129  const spare = room - weights.length
130  const total = weights.reduce((a, b) => a + b, 0)
131  const exact = weights.map(w => (spare * w) / total)
132  const cells = exact.map(x => 1 + Math.floor(x))
133  let left = room - cells.reduce((a, b) => a + b, 0)
134  const order = exact.map((x, i) => [x - Math.floor(x), i] as const).sort((a, b) => b[0] - a[0])
135  for (const [, i] of order) {
136    if (left <= 0) break
137    cells[i] = (cells[i] ?? 0) + 1
138    left -= 1
139  }
140  return cells
141}
142
143export function fmt(ms: number): string {
144  const s = Math.max(0, Math.round(ms / 1000))
145  if (s < 60) return `${s}s`
146  const m = Math.floor(s / 60)
147  if (m < 60) return `${m}m`
148  const h = Math.floor(m / 60)
149  return `${h}h${String(m % 60).padStart(2, '0')}m`
150}
151
152export function clockTime(ms: number): string {
153  const d = new Date(ms)
154  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
155}
156
157/** The plan as text: the tool's answer and the pane's fallback. */
158export function describe(plan: Plan, now: number): string {
159  const sum = summarize(plan, now)
160  const head = sum.isFinished
161    ? `Plan "${plan.title}" finished in ${fmt(sum.elapsedMs)}.`
162    : `Plan "${plan.title}": step ${plan.current + 1}/${plan.steps.length}, ${fmt(sum.elapsedMs)} elapsed, ~${fmt(sum.remainingMs)} left.`
163  const rows = plan.steps.map((s, i) => {
164    const took = stepDurationMs(plan, i, now)
165    const mark = i < plan.current || sum.isFinished ? 'done' : i === plan.current ? 'now ' : 'todo'
166    return `  ${mark} ${i + 1}. ${s.title} — est ${s.estimateMin}m${took !== undefined ? `, ${fmt(took)}` : ''}`
167  })
168  return [head, ...rows].join('\n')
169}
170
types/index.d.ts 23 lines
1export type PlanStep = {
2  title: string
3  /** The estimate in minutes, as last given. */
4  estimateMin: number
5  startedAt?: number
6  endedAt?: number
7}
8
9export type Plan = {
10  title: string
11  startedAt: number
12  finishedAt?: number
13  /** Index of the running step; equals steps.length once finished. */
14  current: number
15  steps: PlanStep[]
16}
17
18declare module 'claude-code' {
19  interface PluginState {
20    'plan-progress': { plan: Plan | null }
21  }
22}
23