Right-side pane: main model vitals plus task progress and the current step

Claude Code mod: a pane docked to the right of the transcript with three sections:
done/total · % right next to it (in-progress tasks in yellow), and the current step on the line below, with its task number when it has one (▸ #2 Wiring). Tasks come from todos (TodoWrite / TaskCreate); with none (or all done while the plan has open tasks), from the active plan: a superpowers plan (docs/superpowers/plans/*.md, ### Task N: headings, completed by its SDD ledger or ticked steps) or a plan-mode plan (~/.claude/plans/*.md, its - [ ] checkboxes, else its numbered steps ticked as 1. [x]). Once every task is done it says when (✓ 모두 완료 · 오후 2:05, with the date from the next day on). While Claude works, a spinner turns and a bright cell sweeps the in-progress part of the bar. With no todos or plan, or once every task is done, a working turn shows the tool Claude is running (Bash · Run tests). A superpowers plan's completed tasks stay completed after finishing-a-development-branch drops the SDD ledger, and ⎇ finishing-a-development-branch shows under them until another plan starts1: Explore auth --- Haiku 5.5 01:23: its number, task, model and running time, the latest on top. A finished agent leaves the list; one waiting on its own background work stays

The pane opens on session start. /task-progress reopens it, /task-progress close closes it. In a non-fullscreen terminal it sits inline above the prompt instead.
/plugin install task-progress --marketplace bob-park/claude-task-progress
Answer y to add the marketplace, then pick a scope.
hooks/register.tsx 409 lines1import { atom, read, update } from 'claude-code'
2import type { BuiltinToolResults, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRun, Main, Plan, Task } from '../types'
5import { isPlanPath, keepCompleted, ledgerOwns, ledgerPath, parsePlan } from './plan'
6
7const tasks = atom({ plugin: 'task-progress', key: 'tasks' } as const, {} as Record<string, Task>)
8const plan = atom({ plugin: 'task-progress', key: 'plan' } as const, { path: '', tasks: [] } as Plan)
9const agents = atom({ plugin: 'task-progress', key: 'agents' } as const, {} as Record<string, AgentRun>)
10
11// a failed read keeps what the pane already shows
12const refreshPlan = async ($: EngineInterface, path: string) => {
13 const text = await $.fs.read(path).catch(() => null)
14 if (typeof text !== 'string') return
15 const at = ledgerPath(path)
16 const ledger = at ? await $.fs.read(at).catch(() => null) : null
17 const owned = typeof ledger === 'string' && ledgerOwns(path, ledger) ? ledger : undefined
18 const base = (p: string) => p.slice(p.lastIndexOf('/') + 1)
19 await update($, plan, prev => {
20 const tasks = parsePlan(text, owned)
21 // same plan (maybe now in the main checkout): done stays done
22 const same = !!prev?.path && base(prev.path) === base(path)
23 return { path, tasks: same ? keepCompleted(prev.tasks, tasks) : tasks, ...(same && prev.finishing ? { finishing: true } : {}) }
24 })
25}
26
27const DEFAULT_MAIN: Main = {
28 model: '',
29 effort: '',
30 mode: '',
31 steps: 0,
32 isRunning: false,
33 activity: '',
34 pct: null,
35 tokens: null,
36 window: 0,
37 compactions: 0,
38 costUsd: null,
39 limits: [],
40 doneAt: null,
41}
42const main = atom({ plugin: 'task-progress', key: 'main' } as const, DEFAULT_MAIN)
43
44const PANE = 'task-progress'
45const PANE_COLUMNS = 48
46
47// Formatting below is adapted from Flightdeck (MIT, github.com/scasella/claude-flightdeck)
48const prettyModel = (id: string) => {
49 if (!id) return '—'
50 const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d])/i.exec(id)
51 return m?.[1] ? `${m[1].charAt(0).toUpperCase()}${m[1].slice(1).toLowerCase()} ${m[2]}${m[3] ? `.${m[3]}` : ''}` : id.slice(0, 22)
52}
53const kTokens = (n: number) => (n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n))
54const fmtUsd = (n: number) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`)
55const gauge = (pct: number, width: number) => {
56 const full = Math.max(0, Math.min(width, Math.round((pct / 100) * width)))
57 return { on: '▰'.repeat(full), off: '▱'.repeat(width - full) }
58}
59const limitLabel = (kind: string) =>
60 kind
61 .replace(/five[_ -]?hours?/i, '5h')
62 .replace(/seven[_ -]?days?/i, '7d')
63 .replace(/[_-]+/g, ' ')
64 .trim()
65
66const setMain = ($: EngineInterface, patch: (m: Main) => Partial<Main>) => update($, main, m => ({ ...DEFAULT_MAIN, ...m, ...patch({ ...DEFAULT_MAIN, ...m }) }))
67
68type Usage = Awaited<ReturnType<EngineInterface['session']['usage']>>
69const usageOf = (u: Pick<Usage, 'context' | 'cost' | 'rateLimits'>): Partial<Main> => ({
70 pct: u.context.percent ?? null,
71 tokens: u.context.tokens ?? null,
72 window: u.context.window,
73 costUsd: u.cost?.usd ?? null,
74 limits: u.rateLimits.map(r => ({ kind: r.kind, pct: r.percentUsed })),
75})
76
77// while a turn runs the pane redraws on a clock so the spinner and shimmer move
78const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
79const FRAME_MS = 100
80const frame = () => Math.floor(Date.now() / FRAME_MS)
81let ticker: Timer | undefined
82// the clock runs while the main turn or any agent is working
83const animate = async ($: EngineInterface) => {
84 const [m, a] = await Promise.all([read($, main), read($, agents)])
85 const on = !!m?.isRunning || Object.values(a ?? {}).some(r => r.endedAt === undefined)
86 if (on) ticker ??= $.clock.every(FRAME_MS, () => $.ui.invalidate('ui.render'))
87 else ticker = void ticker?.cancel()
88}
89
90const mmss = (ms: number) => {
91 const s = Math.max(0, Math.floor(ms / 1000))
92 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
93}
94// `오후 2:05`, and `10월 9일 오후 2:05` once it is another day
95export const doneTime = (at: number, now: number) => {
96 const d = new Date(at)
97 const h = d.getHours()
98 const day = d.toDateString() === new Date(now).toDateString() ? '' : `${d.getMonth() + 1}월 ${d.getDate()}일 `
99 return `${day}${h < 12 ? '오전' : '오후'} ${h % 12 || 12}:${String(d.getMinutes()).padStart(2, '0')}`
100}
101
102// todos win, unless all are done and a plan still has open work
103const shownTasks = (todos: Task[], p: Plan | undefined) => {
104 const planOpen = (p?.tasks ?? []).some(t => t.status !== 'completed')
105 return todos.some(t => t.status !== 'completed') || (todos.length > 0 && !planOpen) ? todos : (p?.tasks ?? [])
106}
107
108// the moment the shown list became all done; cleared once something is open again
109const stampDone = async ($: EngineInterface) => {
110 const [todos, p, m] = await Promise.all([read($, tasks).then(t => Object.values(t ?? {})), read($, plan), read($, main)])
111 const list = shownTasks(todos, p)
112 const allDone = list.length > 0 && list.every(t => t.status === 'completed')
113 const stamped = typeof m?.doneAt === 'number'
114 if (allDone && !stamped) {
115 const at = await $.clock.now()
116 await setMain($, () => ({ doneAt: at }))
117 } else if (!allDone && stamped) await setMain($, () => ({ doneAt: null }))
118}
119
120const numbered = (t: Task) => (t.n ? `#${t.n} ${t.label}` : t.label)
121
122// `Bash · Run tests`, `Read · plan.ts`: the tool and the most telling input it has
123const activityOf = (e: { tool: string; description?: unknown; file_path?: unknown; pattern?: unknown; command?: unknown }) => {
124 const what = [e.description, e.file_path, e.pattern, e.command].find(x => typeof x === 'string' && x) as string | undefined
125 return what ? `${e.tool} · ${what.slice(what.lastIndexOf('/') + 1)}` : e.tool
126}
127
128const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Task Progress', columns: PANE_COLUMNS, rows: 8 })
129
130export const register: Register = on => {
131 on('session.start', async ($, e, next) => {
132 await $.command.register({ name: 'task-progress', description: 'Open or close the task progress pane', argumentHint: '[close]' })
133 // a host without usage (headless, SDK) just starts without it
134 const u = await $.session.usage().catch(() => null)
135 if (u) await setMain($, () => usageOf(u))
136 void openPane($).catch(() => undefined)
137 return next(e)
138 })
139
140 on('command.run', { command: 'task-progress' }, async ($, e) => {
141 if (e.args.trim() === 'close') {
142 await $.ui.close({ id: PANE })
143 return { text: 'Task progress closed.' }
144 }
145 const opened = await openPane($)
146 return { text: opened.isPlaced ? 'Task progress opened.' : `Task progress is not shown yet: ${opened.reason}` }
147 })
148
149 on('classic.UserPromptSubmit', async ($, e, next) => {
150 if (e.permission_mode) await setMain($, () => ({ mode: e.permission_mode }))
151 return next(e)
152 })
153
154 on('turn.start', async ($, e, next) => {
155 await setMain($, () => ({ isRunning: true }))
156 // a new prompt clears agents that finished before it
157 await update($, agents, all => Object.fromEntries(Object.entries(all ?? {}).filter(([, r]) => r.endedAt === undefined)))
158 await animate($)
159 return next(e)
160 })
161
162 on('turn.complete', async ($, e, next) => {
163 const done = await next(e)
164 if (e.agentId) {
165 const { agentId } = e
166 // an agent waiting on its own background work ends its turn but isn't done
167 const status = (await $.agent.list().catch(() => [])).find(a => a.id === agentId)?.status
168 if (status === 'waiting') return done
169 const at = await $.clock.now()
170 await update($, agents, all => {
171 const r = all?.[agentId]
172 return r ? { ...all, [agentId]: { ...r, endedAt: at } } : all
173 })
174 } else await setMain($, () => ({ isRunning: false, activity: '' }))
175 await animate($)
176 return done
177 })
178
179 on('turn.step', async function* ($, e, next) {
180 const { agentId } = e
181 if (!agentId) await setMain($, m => ({ model: e.model, effort: String(e.effort ?? m.effort), steps: m.steps + 1 }))
182 else {
183 // a step after its end: the agent resumed once its background work came back
184 const r = (await read($, agents))?.[agentId]
185 if (r) {
186 const { endedAt, ...run } = r
187 await update($, agents, all => ({ ...all, [agentId]: { ...run, model: e.model } }))
188 if (endedAt !== undefined) await animate($)
189 }
190 }
191 return yield* next(e)
192 })
193
194 on('agent.spawn', async ($, e, next) => {
195 const spawned = await next(e)
196 const { agentId, model = '' } = spawned as { agentId?: string; model?: string }
197 if (agentId) {
198 const startedAt = await $.clock.now()
199 // next after the highest still listed: one left running keeps its number
200 await update($, agents, all => ({
201 ...all,
202 [agentId]: { n: Math.max(0, ...Object.values(all ?? {}).map(r => r.n)) + 1, label: e.description || e.subagentType, model, startedAt },
203 }))
204 await animate($)
205 }
206 return spawned
207 })
208
209 on('skill.prompt', async ($, e, next) => {
210 if (/(^|:)finishing-a-development-branch$/.test(e.skill)) await update($, plan, p => ({ ...(p ?? { path: '', tasks: [] }), finishing: true }))
211 return next(e)
212 })
213
214 on('session.measure', async ($, e, next) => {
215 await setMain($, () => usageOf(e))
216 return next(e)
217 })
218
219 on('session.compact', async ($, e, next) => {
220 const done = await next(e)
221 if (!e.agentId && e.trigger !== 'precompute') await setMain($, m => ({ compactions: m.compactions + 1 }))
222 return done
223 })
224
225 on('tool.call', async ($, e, next) => {
226 if (!e.agentId) await setMain($, () => ({ activity: activityOf(e as Parameters<typeof activityOf>[0]) }))
227 const ran = await next(e)
228 if (ran.deny !== undefined || ran.isError) return ran
229
230 if (e.tool === 'TodoWrite') {
231 // TodoWrite replaces the whole list
232 const list = Object.fromEntries(e.todos.map((t, i) => [`todo:${i}`, { status: t.status, label: t.activeForm || t.content }]))
233 await update($, tasks, () => list)
234 } else if (e.tool === 'TaskCreate') {
235 // next(e) ran before e.tool was narrowed, so result isn't typed per tool
236 const id = (ran.result as BuiltinToolResults['TaskCreate'] | undefined)?.task?.id
237 const label = e.activeForm || e.subject
238 if (id) await update($, tasks, all => ({ ...all, [id]: { status: 'pending' as const, label, n: id } }))
239 } else if (e.tool === 'TaskUpdate') {
240 const { taskId, status, activeForm, subject } = e
241 await update($, tasks, all => {
242 const { [taskId]: prev, ...rest } = all
243 if (status === 'deleted' || !prev) return rest
244 return { ...rest, [taskId]: { ...prev, status: status ?? prev.status, label: activeForm || subject || prev.label } }
245 })
246 }
247
248 if (e.agentId) {
249 await stampDone($)
250 return ran
251 }
252 const planned = e.tool === 'ExitPlanMode' ? (ran.result as BuiltinToolResults['ExitPlanMode'] | undefined)?.filePath : undefined
253 // Read too: a plan written in an earlier session is executed by reading it
254 const written = (e.tool === 'Write' || e.tool === 'Edit' || e.tool === 'Read') && isPlanPath(e.file_path) ? e.file_path : undefined
255 const path = planned ?? written ?? (await read($, plan))?.path
256 if (path) await refreshPlan($, path)
257 await stampDone($)
258 if (!planned) return ran
259 return { ...ran, context: [...(ran.context ?? []), `Mark each step done by ticking it (- [x], or 1. [x] for numbered steps) in ${planned} as you finish it.`] }
260 })
261
262 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
263 const [m, todos, p, runs, now] = await Promise.all([
264 read($, main).then(x => ({ ...DEFAULT_MAIN, ...x })),
265 read($, tasks).then(Object.values),
266 read($, plan),
267 // running ones only, the latest on top
268 read($, agents).then(a => Object.values(a ?? {}).filter(r => r.endedAt === undefined).sort((x, y) => y.startedAt - x.startedAt)),
269 $.clock.now(),
270 ])
271 const list = shownTasks(todos, p)
272 const { Box, Text } = $.ui.resolve(e)
273 const W = Math.max(30, e.props.bodyColumns)
274 const f = frame()
275 const spin = SPINNER[f % SPINNER.length]
276 // a background agent keeps the session working after the main turn ends
277 const busy = m.isRunning || runs.length > 0
278
279 // ---- main (layout from Flightdeck's main panel)
280 const effortN = { low: 1, medium: 2, high: 3, xhigh: 4, max: 4 }[m.effort] ?? 0
281 const ctx = m.pct !== null ? gauge(m.pct, 10) : null
282 const mainPanel = (
283 <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1} width={W}>
284 <Box justifyContent="space-between">
285 <Text color="cyan" bold>{`${prettyModel(m.model)} · main`}</Text>
286 <Text color={busy ? 'cyan' : undefined} dimColor={!busy}>{busy ? `${spin} working` : '○ idle'}</Text>
287 </Box>
288 <Text wrap="truncate">
289 <Text dimColor>effort </Text>
290 <Text color="cyan">{'▮'.repeat(effortN) + '▯'.repeat(4 - effortN)} </Text>
291 <Text color="cyan" bold>{m.effort || '—'}</Text>
292 {m.mode ? <Text dimColor>{` mode ${m.mode}`}</Text> : null}
293 <Text dimColor>{` ${m.steps} req`}</Text>
294 </Text>
295 {ctx ? (
296 <Text wrap="truncate">
297 <Text dimColor>ctx </Text>
298 <Text color={m.pct !== null && m.pct >= 80 ? 'red' : 'cyan'}>{ctx.on}</Text>
299 <Text dimColor>{ctx.off}</Text>
300 <Text bold>{` ${Math.round(m.pct ?? 0)}%`}</Text>
301 {m.tokens !== null ? <Text dimColor>{` ${kTokens(m.tokens)}/${kTokens(m.window)}`}</Text> : null}
302 {m.compactions > 0 ? <Text color="yellow">{` ⟲${m.compactions}`}</Text> : null}
303 </Text>
304 ) : null}
305 {m.costUsd !== null || m.limits.length > 0 ? (
306 <Text wrap="truncate">
307 {m.costUsd !== null ? <Text>{`${fmtUsd(m.costUsd)} `}</Text> : null}
308 {m.limits.slice(0, 2).map(l => {
309 const g = gauge(l.pct, 5)
310 return (
311 <Text key={l.kind}>
312 <Text dimColor>{`${limitLabel(l.kind)} `}</Text>
313 <Text color={l.pct >= 80 ? 'red' : 'cyan'}>{g.on}</Text>
314 <Text dimColor>{`${g.off} ${Math.round(l.pct)}% `}</Text>
315 </Text>
316 )
317 })}
318 </Text>
319 ) : null}
320 </Box>
321 )
322
323 // ---- agents: `1: label --- Model mm:ss`, the right column lined up
324 const agentsPanel =
325 runs.length > 0 ? (
326 <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} width={W}>
327 <Text color="magenta" bold>AGENTS</Text>
328 {runs.map(r => {
329 const right = ` ${prettyModel(r.model)} ${mmss(now - r.startedAt)}`
330 const room = W - 4 - 2 - right.length - 4 // icon, then ` ---` at the least
331 let left = `${r.n}: ${r.label}`
332 if (left.length > room) left = `${left.slice(0, Math.max(0, room - 1))}…`
333 return (
334 <Text key={String(r.n)} color="yellow" wrap="truncate">
335 {`${spin} ${left} ${'-'.repeat(Math.max(3, room - left.length + 3))}${right}`}
336 </Text>
337 )
338 })}
339 </Box>
340 ) : null
341
342 // what Claude is doing right now, when no task says it
343 const activityPanel =
344 m.isRunning && m.activity ? (
345 <Box borderStyle="round" borderColor="yellow" paddingX={1} width={W}>
346 <Text color="yellow" wrap="truncate">{`${spin} ${m.activity}`}</Text>
347 </Box>
348 ) : null
349
350 if (list.length === 0) {
351 return (
352 <Box flexDirection="column">
353 {mainPanel}
354 {activityPanel}
355 {agentsPanel}
356 </Box>
357 )
358 }
359
360 // ---- tasks
361 const total = list.length
362 const done = list.filter(t => t.status === 'completed').length
363 const active = list.filter(t => t.status === 'in_progress')
364 const barW = Math.max(10, W - 4 - ` ${total}/${total} · 100%`.length - 1)
365 const filled = Math.round((done / total) * barW)
366 const activeCells = Math.round(((done + active.length) / total) * barW) - filled
367 const percent = Math.round((done / total) * 100)
368 const allDone = done === total
369 const next = list.find(t => t.status === 'pending')
370 // a bright cell sweeps across the in-progress part of the bar while working
371 const sweep = busy && activeCells > 0 ? f % activeCells : -1
372
373 return (
374 <Box flexDirection="column">
375 {mainPanel}
376 <Box flexDirection="column" borderStyle="round" borderColor="green" paddingX={1} width={W}>
377 <Text color="green" bold>{allDone ? '✓ TASKS' : 'TASKS'}</Text>
378 {/* count sits right after the bar so the eye doesn't travel */}
379 <Text wrap="truncate">
380 <Text color="green">{'█'.repeat(filled)}</Text>
381 {sweep >= 0 ? (
382 <Text color="yellow">
383 {'▓'.repeat(sweep)}
384 <Text bold>█</Text>
385 {'▓'.repeat(activeCells - sweep - 1)}
386 </Text>
387 ) : (
388 <Text color="yellow">{'▓'.repeat(activeCells)}</Text>
389 )}
390 <Text dimColor>{'░'.repeat(barW - filled - activeCells)}</Text>
391 <Text bold color="blue">{` ${done}/${total}`}</Text>
392 <Text dimColor>{` · ${percent}%`}</Text>
393 </Text>
394 {allDone ? (
395 <Text color="green">{m.doneAt !== null ? `✓ 모두 완료 · ${doneTime(m.doneAt, now)}` : '✓ 모두 완료'}</Text>
396 ) : active.length > 0 ? (
397 <Text color="yellow" wrap="truncate">{`${busy ? spin : '▸'} ${active.map(numbered).join(', ')}`}</Text>
398 ) : (
399 <Text dimColor wrap="truncate">{`○ 다음: ${next ? numbered(next) : '—'}`}</Text>
400 )}
401 {p?.finishing ? <Text color="cyan" wrap="truncate">{`${busy ? spin : '⎇'} finishing-a-development-branch`}</Text> : null}
402 </Box>
403 {allDone ? activityPanel : null}
404 {agentsPanel}
405 </Box>
406 )
407 })
408}
409hooks/plan.ts 78 lines1import type { Task } from '../types'
2
3const TASK_HEADING = /^###\s+Task\s+(\d+):\s*(.+)$/
4const CHECKBOX = /^\s*[-*]\s+\[([ xX])\]\s+(.+)$/
5const NUMBERED = /^(\d+)\.\s+(?:\[([ xX])\]\s+)?(.+)$/
6
7const clean = (s: string) => s.replace(/\*\*/g, '').trim()
8
9/** Plan lines outside fenced code blocks: plans quote templates inside fences */
10const planLines = (text: string) => {
11 let fenced = false
12 return text.split(/\r?\n/).filter(l => {
13 if (l.trimStart().startsWith('```')) fenced = !fenced
14 else return !fenced
15 return false
16 })
17}
18
19export const isPlanPath = (p: string) => /\/(docs\/superpowers\/plans|\.claude\/plans)\/[^/]+\.md$/.test(p)
20
21/** The SDD ledger beside a superpowers plan; null for any other plan */
22export const ledgerPath = (planPath: string) => {
23 const m = /^(.*)\/docs\/superpowers\/plans\/([^/]+)\.md$/.exec(planPath)
24 return m ? `${m[1]}/.superpowers/sdd/${m[2]}/progress.md` : null
25}
26
27/** A ledger's first line names the plan it tracks */
28export const ledgerOwns = (planPath: string, ledger: string) =>
29 (ledger.split(/\r?\n/, 1)[0] ?? '').includes(planPath.slice(planPath.lastIndexOf('/') + 1))
30
31/** Done items first get their status; the first open one is current once anything has started */
32const withCurrent = (items: Array<{ label: string; done: boolean; n?: string }>, started: boolean): Task[] => {
33 const current = items.findIndex(i => !i.done)
34 return items.map((i, k) => ({
35 status: i.done ? 'completed' : started && k === current ? 'in_progress' : 'pending',
36 label: i.label,
37 ...(i.n ? { n: i.n } : {}),
38 }))
39}
40
41/** Re-read plan keeps what was done: finishing a branch drops the SDD ledger */
42export const keepCompleted = (prev: Task[], next: Task[]): Task[] => {
43 const done = new Set(prev.filter(t => t.status === 'completed').map(t => t.label))
44 return withCurrent(
45 next.map(t => ({ label: t.label, n: t.n, done: t.status === 'completed' || done.has(t.label) })),
46 next.some(t => t.status !== 'pending') || next.some(t => done.has(t.label)),
47 )
48}
49
50export const parsePlan = (text: string, ledger?: string): Task[] => {
51 const lines = planLines(text)
52
53 // superpowers: `### Task N:` headings, steps beneath, ledger lines `Task N: complete`
54 const tasks: Array<{ n: string; label: string; steps: boolean[] }> = []
55 for (const line of lines) {
56 const h = TASK_HEADING.exec(line)
57 if (h) tasks.push({ n: h[1]!, label: clean(h[2]!), steps: [] })
58 else {
59 const c = CHECKBOX.exec(line)
60 if (c) tasks.at(-1)?.steps.push(c[1] !== ' ')
61 }
62 }
63 if (tasks.length > 0) {
64 const logged = new Set([...(ledger ?? '').matchAll(/^Task (\d+): complete/gm)].map(m => m[1]))
65 const started = logged.size > 0 || tasks.some(t => t.steps.some(Boolean))
66 return withCurrent(
67 tasks.map(t => ({ label: t.label, n: t.n, done: logged.has(t.n) || (t.steps.length > 0 && t.steps.every(Boolean)) })),
68 started,
69 )
70 }
71
72 // plan mode: checkboxes, else top-level numbered items (ticked as `1. [x]`)
73 const items = lines.map(l => CHECKBOX.exec(l)).filter(m => m !== null).map(m => ({ label: clean(m[2]!), done: m[1] !== ' ' }))
74 if (items.length > 0) return withCurrent(items, items.some(i => i.done))
75 const numbered = lines.map(l => NUMBERED.exec(l)).filter(m => m !== null).map(m => ({ label: clean(m[3]!), n: m[1]!, done: !!m[2] && m[2] !== ' ' }))
76 return withCurrent(numbered, numbered.some(i => i.done))
77}
78types/index.d.ts 36 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3/** `n`: the task's number when its source has one (plan `Task N`, TaskCreate id, `1.` step) */
4export type Task = { status: TaskStatus; label: string; n?: string }
5
6/** A subagent the model spawned, listed under the tasks */
7export type AgentRun = { n: number; label: string; model: string; startedAt: number; endedAt?: number }
8
9/** The plan file progress is read from when there are no todos; `finishing` once finishing-a-development-branch ran on it */
10export type Plan = { path: string; tasks: Task[]; finishing?: boolean }
11
12/** Main-loop vitals, drawn like Flightdeck's main panel */
13export type Main = {
14 model: string
15 effort: string
16 mode: string
17 steps: number
18 isRunning: boolean
19 /** The main loop's latest tool call, shown when there are no tasks */
20 activity: string
21 pct: number | null
22 tokens: number | null
23 window: number
24 compactions: number
25 costUsd: number | null
26 limits: Array<{ kind: string; pct: number }>
27 /** When the shown tasks all became completed */
28 doneAt: number | null
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'task-progress': { tasks: Record<string, Task>; main: Main; plan: Plan; agents: Record<string, AgentRun> }
34 }
35}
36