Animated progress band for long-running turns, with a completion summary

A live progress band for long Claude Code tasks, and a short summary when they finish.
<img src="docs/demo.gif" alt="session-progress following a six-step plan in Claude Code, then showing the completion summary" width="900">
At the prompt of a Claude Code terminal session:
/plugin install session-progress --marketplace VAlux/claude-session-progress
Answer y to add the marketplace, then pick a scope. The user scope loads it in every session. It starts working in the current session right away.
| While Claude works | A pulsing spinner and the step Claude is on, an animated bar, elapsed time, and a row of counts: tool calls, subagents, files changed, model steps, tokens and errors. Below them, what is running right now. |
| When the steps are known | The bar shows real progress, and the headline names the step in progress. A plan file's steps are listed under the bar with ✓ done, ◐ in progress and ○ to do. A task list's are not, because Claude Code already draws its own. |
| When it finishes | The bar fills and turns green, or amber if you interrupted it, or red on an error. The summary lists duration, work done, tokens in and out with the cache share, the model, the most-used tools, and totals for the session. |
| On your next prompt | The summary goes away. |
| Source | What the bar shows |
|---|---|
A task list (TodoWrite, or TaskCreate / TaskUpdate) | Done ÷ total, with the task in progress counting as half |
A Markdown plan with - [ ] / - [x] boxes that Claude reads or edits | Ticked ÷ total, moving forward as boxes are ticked |
| Neither | A soft streak sweeps the track. There is no honest percentage to show, so it does not invent one. |
A task list wins over a plan file. A plan that is still unfinished carries over to the next turn, and a finished one is cleared. A subagent's own task list never replaces the main one, but a subagent can still mark the main session's tasks done.
The mod also adds one line to the system prompt, asking Claude to keep a task list when a task has several known steps, so the bar has real steps to follow more often.
Quick answers and short Q&A never show the band. It appears once a turn has run:
| 20 s, with at least 3 tool calls | ordinary multi-step work |
| 8 s, with a task list or plan of 2+ steps | planned work, which shows sooner |
| 45 s | anything that runs long |
The thresholds are the constants at the top of hooks/register.tsx.
Change it in /config, or with claude plugin configure session-progress.
| Setting | Values | What it does |
|---|---|---|
stepRows | auto, always, never | When the band lists the steps under its bar. auto lists a Markdown plan's checkboxes only, since Claude Code draws its own task list. always lists task lists too, and never lists nothing. |
To make the band your only task view, hide Claude Code's own list with ctrl+t and set stepRows to always.
ctrl+x ctrl+a, or with the [-] mark at its right edge.git clone https://github.com/VAlux/claude-session-progress
claude --plugin-dir ./claude-session-progress # load the working copy for one session
claude plugin validate ./claude-session-progress
claude plugin test ./claude-session-progress
| Path | What it holds |
|---|---|
hooks/register.tsx | The hooks: turn and tool tracking, plan files, the system-prompt line, the band |
hooks/frames.ts | Bar and spinner frames, the checkbox parser, formatting |
types/index.d.ts | The mod's state contract |
tests/ | Behaviour tests, run with claude plugin test |
The demo in docs/ was recorded with VHS from docs/demo.tape.
hooks/register.tsx 602 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Live, PlanFile, Summary, Task, TaskStatus, Totals } from '../types'
5import {
6 ACCENT,
7 DONE,
8 FAIL,
9 WARN,
10 activityLabel,
11 barCells,
12 MUTED,
13 pctLabel,
14 duration,
15 glyphCells,
16 isMarkdown,
17 parseCheckboxes,
18 plural,
19 shortToolName,
20 spinnerCells,
21 tokens,
22 type Palette,
23} from './frames'
24
25const live = atom({ plugin: 'session-progress', key: 'live' } as const, null)
26const summary = atom({ plugin: 'session-progress', key: 'summary' } as const, null)
27const tasks = atom({ plugin: 'session-progress', key: 'tasks' } as const, [])
28const planFile = atom({ plugin: 'session-progress', key: 'planFile' } as const, null)
29const totals = atom({ plugin: 'session-progress', key: 'totals' } as const, {
30 turns: 0,
31 longTurns: 0,
32 totalMs: 0,
33 toolCalls: 0,
34 tokensIn: 0,
35 tokensOut: 0,
36})
37const tick = atom({ plugin: 'session-progress', key: 'tick' } as const, 0)
38
39// When a turn earns the band: a quick answer or a short Q/A never does.
40const SHOW_AFTER_MS = 20_000
41const SHOW_AFTER_TOOLS = 3
42const SHOW_WITH_PLAN_AFTER_MS = 8_000
43const SHOW_ALWAYS_AFTER_MS = 45_000
44
45const FRAME_MS = 70
46const FINISH_MS = 1_400
47const SPINNER_KEY = 'spin'
48const BAR_KEY = 'bar'
49const MAX_TASK_ROWS = 5
50// Spare columns after the bar's label: a row that fills the band exactly wraps on some terminals.
51const BAR_SLACK = 2
52
53const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate'])
54const WRITE_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
55const PLAN_FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write'])
56
57/** A subagent's own TodoWrite/TaskCreate list never replaces the main one; its TaskUpdate may tick main tasks. */
58export function tracksPlanTool(tool: string, isSubagent: boolean): boolean {
59 return PLAN_TOOLS.has(tool) && (!isSubagent || tool === 'TaskUpdate')
60}
61
62function taskListLine(tools: readonly string[]): string | null {
63 const how = tools.includes('TaskCreate') ? 'TaskCreate and TaskUpdate' : tools.includes('TodoWrite') ? 'TodoWrite' : null
64 if (how === null) return null
65 return (
66 `When a task has several known steps, such as implementing a spec or a plan, keep a task list with ${how}: ` +
67 'one entry per step before you start, marked in progress when you begin it and completed as soon as it is done. ' +
68 'Skip the list for quick questions and single-step changes.'
69 )
70}
71
72type Dollar = EngineInterface
73
74// What the frame loop paints between redraws. Module state: a reload refills it on the next draw.
75const scene = {
76 requestId: null as string | null,
77 barWidth: 0,
78 // What the bar's own label shows between redraws: the live percentage, or a fixed text (the clock).
79 showPct: false,
80 label: '',
81 target: null as number | null,
82 shown: 0,
83 palette: ACCENT,
84 isFinishing: false,
85 finishFrom: 0,
86 finishFrames: 0,
87}
88let frameTimer: Timer | null = null
89let tickTimer: Timer | null = null
90
91function freshLive(turnId: string, startedAt: number): Live {
92 return {
93 turnId,
94 startedAt,
95 isLong: false,
96 steps: 0,
97 toolCalls: 0,
98 subagentToolCalls: 0,
99 subagents: 0,
100 errors: 0,
101 byTool: {},
102 files: [],
103 running: [],
104 lastActivity: null,
105 tokensIn: 0,
106 tokensCached: 0,
107 tokensOut: 0,
108 model: null,
109 }
110}
111
112function planProgress(list: readonly Task[]): number | null {
113 if (list.length === 0) return null
114 const done = list.filter(t => t.status === 'completed').length
115 const active = list.filter(t => t.status === 'in_progress').length
116 return (done + active * 0.5) / list.length
117}
118
119type Steps = { list: Task[]; progress: number | null; unit: 'tasks' | 'steps'; source: string | null }
120
121export function stepsOf(taskList: readonly Task[], plan: PlanFile | null): Steps {
122 if (taskList.length > 0) return { list: [...taskList], progress: planProgress(taskList), unit: 'tasks', source: null }
123 if (plan === null || plan.items.length < 2) return { list: [], progress: null, unit: 'tasks', source: null }
124 const firstOpen = plan.items.findIndex(item => !item.done)
125 const list = plan.items.map((item, i): Task => ({
126 id: `plan-${i}`,
127 subject: item.text,
128 status: item.done ? 'completed' : i === firstOpen ? 'in_progress' : 'pending',
129 }))
130 const done = plan.items.filter(item => item.done).length
131 return { list, progress: done / plan.items.length, unit: 'steps', source: plan.name }
132}
133
134async function readSteps($: Dollar): Promise<Steps> {
135 return stepsOf(await read($, tasks), await read($, planFile))
136}
137
138async function trackPlanFile($: Dollar, path: string, written: string | null) {
139 let text = written
140 if (text === null) {
141 try {
142 text = await $.fs.read(path)
143 } catch {
144 return
145 }
146 }
147 const items = parseCheckboxes(text)
148 const name = path.split('/').pop() || path
149 await update($, planFile, current => {
150 if (items.length < 2) return current?.path === path ? null : current
151 // Another file only takes over while it still has open steps (a finished checklist read for reference does not).
152 const isOpen = items.some(item => !item.done)
153 if (current !== null && current.path !== path && !isOpen && current.items.some(item => !item.done)) return current
154 return { path, name, items }
155 })
156}
157
158export function shouldShow(l: Live, now: number, planSize: number): boolean {
159 const elapsed = now - l.startedAt
160 if (elapsed >= SHOW_ALWAYS_AFTER_MS) return true
161 if (planSize >= 2 && elapsed >= SHOW_WITH_PLAN_AFTER_MS) return true
162 return elapsed >= SHOW_AFTER_MS && l.toolCalls + l.subagentToolCalls >= SHOW_AFTER_TOOLS
163}
164
165// A frame that did not land (the band collapsed or redrawing) is simply skipped.
166const skipFrame = () => {}
167
168function paint($: Dollar) {
169 const t = Date.now()
170 if (scene.isFinishing) {
171 // A timed ease-out that lands exactly on the target, then one redraw so the
172 // band's own tree (what a collapse and reopen shows) holds the settled bar.
173 scene.finishFrames += 1
174 const k = Math.min(1, (scene.finishFrames * FRAME_MS) / FINISH_MS)
175 const to = scene.target ?? scene.finishFrom
176 scene.shown = scene.finishFrom + (to - scene.finishFrom) * (1 - (1 - k) ** 3)
177 if (k >= 1) return settle($)
178 } else if (scene.target !== null) {
179 scene.shown += (scene.target - scene.shown) * 0.12
180 }
181 if (scene.requestId === null || scene.barWidth <= 0) return
182 const progress = scene.target === null ? null : scene.shown
183 const label = scene.showPct ? pctLabel(scene.shown) : scene.label
184 const labelColor = scene.showPct ? scene.palette.base : MUTED
185 const cells = barCells(scene.barWidth, progress, t, scene.palette, { shimmer: true, label, labelColor })
186 $.ui.blit({ requestId: scene.requestId, key: BAR_KEY, cells }).catch(skipFrame)
187 if (!scene.isFinishing) {
188 $.ui.blit({ requestId: scene.requestId, key: SPINNER_KEY, cells: spinnerCells(t, scene.palette) }).catch(skipFrame)
189 }
190}
191
192function settle($: Dollar) {
193 stopFrames()
194 scene.isFinishing = false
195 scene.shown = scene.target ?? scene.shown
196 void update($, tick, n => n + 1)
197}
198
199function startFrames($: Dollar) {
200 if (frameTimer === null) frameTimer = $.clock.every(FRAME_MS, () => paint($))
201}
202
203function stopFrames() {
204 frameTimer?.cancel()
205 frameTimer = null
206}
207
208function startTicking($: Dollar) {
209 if (tickTimer !== null) return
210 tickTimer = $.clock.every(1000, async () => {
211 const now = await $.clock.now()
212 const plan = await readSteps($)
213 let becameLong = false
214 await update($, live, l => {
215 if (l === null || l.isLong || !shouldShow(l, now, plan.list.length)) return l
216 becameLong = true
217 return { ...l, isLong: true }
218 })
219 if (becameLong) startFrames($)
220 await update($, tick, n => n + 1)
221 })
222}
223
224function stopTicking() {
225 tickTimer?.cancel()
226 tickTimer = null
227}
228
229function setTaskStatus(list: readonly Task[], id: string, status: TaskStatus | 'deleted', subject?: string, activeForm?: string): Task[] {
230 if (status === 'deleted') return list.filter(t => t.id !== id)
231 return list.map(t => (t.id === id ? { ...t, status, subject: subject ?? t.subject, activeForm: activeForm ?? t.activeForm } : t))
232}
233
234async function trackPlan($: Dollar, e: { tool: string } & Record<string, unknown>, result: unknown) {
235 if (e.tool === 'TodoWrite' && Array.isArray(e.todos)) {
236 const todos = e.todos as { content: string; status: TaskStatus; activeForm?: string }[]
237 await update($, tasks, () => todos.map((t, i) => ({ id: `todo-${i}`, subject: t.content, activeForm: t.activeForm, status: t.status })))
238 } else if (e.tool === 'TaskCreate') {
239 const id = (result as { task?: { id?: string } } | undefined)?.task?.id
240 if (typeof id !== 'string') return
241 const task: Task = {
242 id,
243 subject: String(e.subject ?? 'Task'),
244 activeForm: typeof e.activeForm === 'string' ? e.activeForm : undefined,
245 status: 'pending',
246 }
247 await update($, tasks, list => [...list.filter(t => t.id !== id), task])
248 } else if (e.tool === 'TaskUpdate' && typeof e.taskId === 'string') {
249 const status = (e.status as TaskStatus | 'deleted' | undefined) ?? undefined
250 const subject = typeof e.subject === 'string' ? e.subject : undefined
251 const activeForm = typeof e.activeForm === 'string' ? e.activeForm : undefined
252 await update($, tasks, list => {
253 const current = list.find(t => t.id === e.taskId)
254 if (current === undefined) return list
255 return setTaskStatus(list, e.taskId as string, status ?? current.status, subject, activeForm)
256 })
257 }
258}
259
260type StepRows = 'auto' | 'always' | 'never'
261
262/** Whether the band lists the steps: in `auto`, only a plan file's, since Claude Code draws its own task list. */
263export function showsStepRows(setting: StepRows, unit: 'tasks' | 'steps'): boolean {
264 return setting === 'always' || (setting === 'auto' && unit === 'steps')
265}
266
267export const register: Register = (on, options) => {
268 const stepRows: StepRows = options.stepRows === 'always' || options.stepRows === 'never' ? options.stepRows : 'auto'
269
270 on('session.start', async ($, e, next) => {
271 // A reload mid-turn: the state survived, the timers did not.
272 const current = await read($, live)
273 if (current !== null) {
274 startTicking($)
275 if (current.isLong) startFrames($)
276 }
277 return next(e)
278 })
279
280 on('prompt.compose', async ($, e, next) => {
281 const composed = await next(e)
282 const text = taskListLine(e.tools)
283 if (text === null) return composed
284 return { ...composed, sections: [...composed.sections, { id: 'session-progress:task-list', text, scope: 'session' as const }] }
285 }).catch(($, e, next) => next(e))
286
287 on('turn.start', async ($, e, next) => {
288 const now = await $.clock.now()
289 await update($, summary, () => null)
290 // A plan that finished last turn starts this one clean; an unfinished one carries on.
291 await update($, tasks, list => (list.length > 0 && list.every(t => t.status === 'completed') ? [] : list))
292 await update($, planFile, plan => (plan !== null && plan.items.every(item => item.done) ? null : plan))
293 await update($, live, () => freshLive(e.turnId, now))
294 stopFrames()
295 scene.isFinishing = false
296 scene.palette = ACCENT
297 scene.shown = 0
298 startTicking($)
299 return next(e)
300 })
301
302 on('turn.step', async function* ($, e, next) {
303 const result = yield* next(e)
304 const usage = result.usage
305 await update($, live, l => {
306 if (l === null) return l
307 return {
308 ...l,
309 steps: e.agentId === undefined ? l.steps + 1 : l.steps,
310 tokensIn: l.tokensIn + (usage ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens : 0),
311 tokensCached: l.tokensCached + (usage?.cache_read_input_tokens ?? 0),
312 tokensOut: l.tokensOut + (usage?.output_tokens ?? 0),
313 model: e.agentId === undefined ? (usage?.model ?? e.model) : l.model,
314 }
315 })
316 return result
317 })
318
319 on('tool.call', async ($, e, next) => {
320 if ((await read($, live)) === null) return next(e)
321
322 const input = e as unknown as { tool: string } & Record<string, unknown>
323 const name = shortToolName(e.tool)
324 const label = activityLabel(e.tool, input)
325 const isSub = e.agentId !== undefined
326 const isAgent = e.tool === 'Agent' || String(e.tool) === 'Task'
327
328 await update($, live, l =>
329 l === null
330 ? l
331 : {
332 ...l,
333 toolCalls: isSub ? l.toolCalls : l.toolCalls + 1,
334 subagentToolCalls: isSub ? l.subagentToolCalls + 1 : l.subagentToolCalls,
335 subagents: isAgent ? l.subagents + 1 : l.subagents,
336 byTool: isSub ? l.byTool : { ...l.byTool, [name]: (l.byTool[name] ?? 0) + 1 },
337 running: isSub ? l.running : [...l.running, { id: e.tool_use_id, label }],
338 lastActivity: isSub ? l.lastActivity : label,
339 },
340 )
341
342 const ran = await next(e)
343 const failed = ran.deny !== undefined || ran.isError === true
344 const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
345
346 await update($, live, l =>
347 l === null
348 ? l
349 : {
350 ...l,
351 errors: failed ? l.errors + 1 : l.errors,
352 running: l.running.filter(r => r.id !== e.tool_use_id),
353 files: !failed && WRITE_TOOLS.has(e.tool) && path !== null && !l.files.includes(path) ? [...l.files, path].slice(-500) : l.files,
354 },
355 )
356 if (!failed && tracksPlanTool(e.tool, isSub)) await trackPlan($, input, ran.result)
357 if (!failed && PLAN_FILE_TOOLS.has(e.tool) && path !== null && isMarkdown(path)) {
358 await trackPlanFile($, path, e.tool === 'Write' && typeof input.content === 'string' ? input.content : null)
359 }
360
361 return ran
362 }).catch(($, e, next) => next(e)) // an observer: its failure never blocks the call
363
364 on('turn.complete', async ($, e, next) => {
365 if (e.agentId !== undefined) return next(e)
366 stopTicking()
367
368 const l = await read($, live)
369 const plan = await readSteps($)
370 if (l !== null) {
371 const usage = e.usage
372 const tokensIn = Math.max(l.tokensIn, usage ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens : 0)
373 const tokensOut = Math.max(l.tokensOut, usage?.output_tokens ?? 0)
374 await update($, totals, (t): Totals => ({
375 turns: t.turns + 1,
376 longTurns: t.longTurns + (l.isLong ? 1 : 0),
377 totalMs: t.totalMs + e.durationMs,
378 toolCalls: t.toolCalls + l.toolCalls + l.subagentToolCalls,
379 tokensIn: t.tokensIn + tokensIn,
380 tokensOut: t.tokensOut + tokensOut,
381 }))
382
383 if (l.isLong) {
384 const done: Summary = {
385 reason: e.reason,
386 durationMs: e.durationMs,
387 steps: l.steps,
388 toolCalls: l.toolCalls,
389 subagentToolCalls: l.subagentToolCalls,
390 subagents: l.subagents,
391 errors: l.errors,
392 topTools: Object.entries(l.byTool)
393 .sort((a, b) => b[1] - a[1])
394 .slice(0, 6),
395 files: l.files.length,
396 tasksDone: plan.list.filter(t => t.status === 'completed').length,
397 tasksTotal: plan.list.length,
398 stepsUnit: plan.unit,
399 stepsSource: plan.source,
400 tokensIn,
401 tokensCached: Math.max(l.tokensCached, usage?.cache_read_input_tokens ?? 0),
402 tokensOut,
403 model: usage?.model ?? l.model,
404 }
405 await update($, summary, () => done)
406 scene.palette = e.reason === 'answer' ? DONE : e.reason === 'aborted' ? WARN : FAIL
407 scene.target = e.reason === 'answer' ? 1 : (plan.progress ?? scene.shown)
408 scene.finishFrom = scene.shown
409 scene.finishFrames = 0
410 scene.isFinishing = true
411 startFrames($)
412 } else {
413 stopFrames()
414 }
415 }
416 await update($, live, () => null)
417 return next(e)
418 })
419
420 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
421 // The animated Raster bar is the terminal's alone.
422 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e)
423
424 const l = await read($, live)
425 const s = await read($, summary)
426 const steps = await readSteps($)
427 const plan = steps.list
428 await read($, tick) // redraw each second for the clock
429
430 const showLive = l !== null && l.isLong
431 const showSummary = !showLive && s !== null && !e.props.isWorking
432 if (!showLive && !showSummary) return next(e)
433
434 const { Box, Text, Raster } = $.ui.resolve(e)
435 const width = Math.max(20, e.props.bodyColumns)
436 const now = await $.clock.now()
437 const t = Date.now()
438
439 scene.requestId = e.requestId
440
441 if (showLive) {
442 const progress = steps.progress
443 // A last live draw can land after the turn ended: it must not repaint the finish orange.
444 if (!scene.isFinishing) {
445 scene.target = progress
446 scene.palette = ACCENT
447 }
448 const label = progress === null ? duration(now - l.startedAt) : pctLabel(progress)
449 if (!scene.isFinishing) {
450 scene.showPct = progress !== null
451 scene.label = label
452 }
453 scene.barWidth = Math.max(12, width - BAR_SLACK)
454
455 const current = plan.find(task => task.status === 'in_progress')
456 const headline = current?.activeForm ?? current?.subject ?? l.running[l.running.length - 1]?.label ?? l.lastActivity ?? 'Thinking'
457 const done = plan.filter(task => task.status === 'completed').length
458 const stats = [
459 plural(l.toolCalls, 'tool call'),
460 l.subagents > 0 ? `${plural(l.subagents, 'subagent')} (${l.subagentToolCalls} calls)` : null,
461 l.files.length > 0 ? `${plural(l.files.length, 'file')} changed` : null,
462 plural(l.steps, 'step'),
463 l.tokensOut > 0 ? `${tokens(l.tokensIn)}↑ ${tokens(l.tokensOut)}↓` : null,
464 l.errors > 0 ? plural(l.errors, 'error') : null,
465 ].filter(Boolean)
466
467 const rows = [
468 <Box key="head-row" flexDirection="row" justifyContent="space-between" width={width}>
469 <Box flexDirection="row" flexShrink={1}>
470 <Raster key={SPINNER_KEY} columns={1} rows={1} cells={spinnerCells(t, ACCENT)} />
471 <Text bold color="claude" wrap="truncate-end">
472 {' '}
473 {headline}
474 </Text>
475 </Box>
476 <Text dimColor>
477 {steps.source !== null ? `${steps.source} · ` : ''}
478 {plan.length > 0 ? `${done}/${plan.length} ${steps.unit} · ` : ''}
479 {duration(now - l.startedAt)}
480 </Text>
481 </Box>,
482 // A fixed width, as the head row has: in a band laid out narrower than bodyColumns the bar would be squeezed and its last cells wrap.
483 <Box key="bar-row" flexDirection="row" width={width} flexShrink={0}>
484 <Raster
485 key={BAR_KEY}
486 columns={scene.barWidth}
487 rows={1}
488 cells={barCells(scene.barWidth, progress === null ? null : scene.shown, t, ACCENT, {
489 label,
490 labelColor: progress === null ? MUTED : ACCENT.base,
491 })}
492 />
493 </Box>,
494 <Text key="stats" dimColor wrap="truncate-end">
495 {stats.join(' · ')}
496 </Text>,
497 ]
498
499 if (l.running.length > 1) {
500 rows.push(
501 <Text key="running" dimColor wrap="truncate-end">
502 ↳ {plural(l.running.length, 'call')} running: {l.running.map(r => r.label).join(', ')}
503 </Text>,
504 )
505 } else if (current !== undefined && l.lastActivity !== null) {
506 rows.push(
507 <Text key="running" dimColor wrap="truncate-end">
508 ↳ {l.lastActivity}
509 </Text>,
510 )
511 }
512
513 const room = Math.max(0, Math.min(MAX_TASK_ROWS, e.props.maxRows - rows.length - 1))
514 if (plan.length > 0 && room > 0 && showsStepRows(stepRows, steps.unit)) {
515 // Keep the active task in view: start from just before the first unfinished one.
516 const firstOpen = Math.max(0, plan.findIndex(task => task.status !== 'completed'))
517 const start = Math.max(0, Math.min(firstOpen - 1, plan.length - room))
518 const visible = plan.slice(start, start + room)
519 for (const task of visible) {
520 const isDone = task.status === 'completed'
521 const isActive = task.status === 'in_progress'
522 rows.push(
523 <Text key={`task-${task.id}`} wrap="truncate-end" dimColor={!isActive} strikethrough={false}>
524 <Text color={isDone ? 'success' : isActive ? 'claude' : 'inactive'}>{isDone ? ' ✓ ' : isActive ? ' ◐ ' : ' ○ '}</Text>
525 {isActive ? <Text bold>{task.subject}</Text> : task.subject}
526 </Text>,
527 )
528 }
529 const hidden = plan.length - visible.length
530 if (hidden > 0) rows.push(<Text key="more" dimColor>{` +${hidden} more`}</Text>)
531 }
532
533 return <Box flexDirection="column">{rows.slice(0, Math.max(1, e.props.maxRows))}</Box>
534 }
535
536 const sum = s as Summary
537 const total = await read($, totals)
538 const palette: Palette = sum.reason === 'answer' ? DONE : sum.reason === 'aborted' ? WARN : FAIL
539 const color = sum.reason === 'answer' ? 'success' : sum.reason === 'aborted' ? 'warning' : 'error'
540 const mark = sum.reason === 'answer' ? '✓' : sum.reason === 'aborted' ? '■' : '✗'
541 const title = sum.reason === 'answer' ? 'Task complete' : sum.reason === 'aborted' ? 'Interrupted' : sum.reason === 'refusal' ? 'Stopped (refusal)' : 'Ended with an error'
542 const finalProgress = sum.reason === 'answer' ? 1 : sum.tasksTotal > 0 ? sum.tasksDone / sum.tasksTotal : null
543 scene.barWidth = Math.max(12, width - BAR_SLACK)
544 const barProgress = scene.isFinishing ? scene.shown : (finalProgress ?? 0)
545 scene.palette = palette
546 scene.showPct = finalProgress !== null
547 scene.label = ''
548 const label = finalProgress === null ? '' : pctLabel(barProgress)
549 const cachePct = sum.tokensIn > 0 ? Math.round((sum.tokensCached / sum.tokensIn) * 100) : 0
550
551 const work = [
552 plural(sum.toolCalls, 'tool call'),
553 sum.subagents > 0 ? `${plural(sum.subagents, 'subagent')} (${sum.subagentToolCalls} calls)` : null,
554 sum.files > 0 ? `${plural(sum.files, 'file')} changed` : null,
555 plural(sum.steps, 'model step'),
556 sum.tasksTotal > 0
557 ? `${sum.tasksDone}/${sum.tasksTotal} ${sum.stepsUnit} done${sum.stepsSource !== null ? ` (${sum.stepsSource})` : ''}`
558 : null,
559 sum.errors > 0 ? plural(sum.errors, 'failed call') : null,
560 ].filter(Boolean)
561
562 return (
563 <Box flexDirection="column">
564 <Box key="head-row" flexDirection="row" justifyContent="space-between" width={width}>
565 <Box flexDirection="row" flexShrink={1}>
566 <Raster key={SPINNER_KEY} columns={1} rows={1} cells={glyphCells(mark, palette.base)} />
567 <Text bold color={color}>
568 {' '}
569 {title}
570 </Text>
571 <Text dimColor> in {duration(sum.durationMs)}</Text>
572 </Box>
573 <Text dimColor>
574 session: {plural(total.turns, 'turn')} · {duration(total.totalMs)}
575 </Text>
576 </Box>
577 <Box key="bar-row" flexDirection="row" width={width} flexShrink={0}>
578 <Raster
579 key={BAR_KEY}
580 columns={scene.barWidth}
581 rows={1}
582 cells={barCells(scene.barWidth, barProgress, t, palette, { shimmer: scene.isFinishing, label })}
583 />
584 </Box>
585 <Text key="work" dimColor wrap="truncate-end">
586 {work.join(' · ')}
587 </Text>
588 <Text key="tokens" dimColor wrap="truncate-end">
589 {tokens(sum.tokensIn)} in ({cachePct}% cached) · {tokens(sum.tokensOut)} out
590 {sum.model ? ` · ${sum.model}` : ''}
591 {total.turns > 1 ? ` · session ${tokens(total.tokensIn)} in · ${tokens(total.tokensOut)} out · ${total.toolCalls} calls` : ''}
592 </Text>
593 {sum.topTools.length > 0 && (
594 <Text key="tools" dimColor wrap="truncate-end">
595 {sum.topTools.map(([tool, n]) => `${tool} ${n}`).join(' ')}
596 </Text>
597 )}
598 </Box>
599 )
600 })
601}
602hooks/frames.ts 213 lines1// Pure drawing helpers: Raster cells for the bar and spinner, and text formatting.
2
3export type Palette = { base: number; glow: number; dim: number }
4
5export const ACCENT: Palette = { base: 0xd97757, glow: 0xffc9a8, dim: 0x8a4a35 }
6export const DONE: Palette = { base: 0x4eba65, glow: 0xb8f0c4, dim: 0x2f7a3f }
7export const WARN: Palette = { base: 0xd8a64a, glow: 0xffe2a6, dim: 0x8a6a2c }
8export const FAIL: Palette = { base: 0xd2555a, glow: 0xffb0b2, dim: 0x8a3236 }
9
10const TRACK = 0x5c5c5c
11const DEFAULT_BG = 0x01000000
12const FILLED = 0x2501 // ━
13const EMPTY = 0x2500 // ─
14const HALF = 0x2578 // ╸
15
16const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
17
18function lerp(a: number, b: number, t: number): number {
19 const k = Math.max(0, Math.min(1, t))
20 const ch = (shift: number) => {
21 const x = (a >> shift) & 0xff
22 const y = (b >> shift) & 0xff
23 return Math.round(x + (y - x) * k) << shift
24 }
25 return ch(16) | ch(8) | ch(0)
26}
27
28function encode(words: Uint32Array): string {
29 const bytes = new Uint8Array(words.buffer)
30 const withBase64 = bytes as Uint8Array & { toBase64?: () => string }
31 if (typeof withBase64.toBase64 === 'function') return withBase64.toBase64()
32 let s = ''
33 for (const b of bytes) s += String.fromCharCode(b)
34 return btoa(s)
35}
36
37export const MUTED = 0x9a9a9a
38
39export type BarOptions = {
40 /** The moving highlight along the filled part; off for a settled bar. */
41 shimmer?: boolean
42 /** Text drawn in the bar's own last cells (a percentage, a clock), so it can never wrap away from it. */
43 label?: string
44 labelColor?: number
45}
46
47export function pctLabel(progress: number): string {
48 return `${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`.padStart(4)
49}
50
51/**
52 * One row of bar cells, `width` wide including the label. `progress` in [0, 1],
53 * or null for an indeterminate comet sweeping the track. `t` is milliseconds,
54 * driving the shimmer.
55 */
56export function barCells(width: number, progress: number | null, t: number, palette: Palette, options: BarOptions = {}): string {
57 const { shimmer = true, label = '', labelColor = palette.base } = options
58 const w = Math.max(1, Math.min(512, Math.floor(width)))
59 const words = new Uint32Array(w * 3)
60 const put = (i: number, glyph: number, color: number) => {
61 words[i * 3] = glyph
62 words[i * 3 + 1] = color
63 words[i * 3 + 2] = DEFAULT_BG
64 }
65 const text = label.length > 0 && label.length + 2 <= w ? label : ''
66 const barW = text.length > 0 ? w - text.length - 1 : w
67 if (text.length > 0) {
68 put(barW, 0x20, labelColor)
69 for (let k = 0; k < text.length; k++) put(barW + 1 + k, text.charCodeAt(k), labelColor)
70 }
71
72 if (progress === null) {
73 const tail = Math.max(6, Math.round(barW / 5))
74 const period = 2200
75 const head = ((t % period) / period) * (barW + tail)
76 for (let i = 0; i < barW; i++) {
77 const d = head - i
78 if (d >= 0 && d < tail) {
79 const k = 1 - d / tail
80 put(i, FILLED, lerp(TRACK, d < 1 ? palette.glow : palette.base, k * k))
81 } else {
82 put(i, EMPTY, TRACK)
83 }
84 }
85 return encode(words)
86 }
87
88 const exact = Math.max(0, Math.min(1, progress)) * barW
89 const full = Math.floor(exact)
90 const shimmerAt = ((t % 2600) / 2600) * (full + 16) - 8
91 for (let i = 0; i < barW; i++) {
92 if (i < full) {
93 const gradient = lerp(palette.dim, palette.base, full <= 1 ? 1 : 0.35 + (0.65 * i) / (full - 1))
94 const glow = shimmer ? Math.exp(-((i - shimmerAt) ** 2) / 10) : 0
95 put(i, FILLED, lerp(gradient, palette.glow, glow * 0.85))
96 } else if (i === full && exact - full >= 0.5) {
97 put(i, HALF, palette.base)
98 } else {
99 put(i, EMPTY, TRACK)
100 }
101 }
102 return encode(words)
103}
104
105export function spinnerCells(t: number, palette: Palette): string {
106 const glyph = SPINNER.charCodeAt(Math.floor(t / 80) % SPINNER.length)
107 const pulse = (Math.sin(t / 400) + 1) / 2
108 return encode(Uint32Array.of(glyph, lerp(palette.base, palette.glow, pulse * 0.6), DEFAULT_BG))
109}
110
111export function glyphCells(char: string, color: number): string {
112 return encode(Uint32Array.of(char.charCodeAt(0), color, DEFAULT_BG))
113}
114
115export function duration(ms: number): string {
116 const s = Math.max(0, Math.round(ms / 1000))
117 if (s < 60) return `${s}s`
118 const m = Math.floor(s / 60)
119 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
120 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
121}
122
123export function tokens(n: number): string {
124 if (n < 1000) return String(n)
125 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`
126 return `${(n / 1_000_000).toFixed(1)}M`
127}
128
129export function plural(n: number, one: string, many = `${one}s`): string {
130 return `${n} ${n === 1 ? one : many}`
131}
132
133function basename(path: string): string {
134 const parts = path.split('/')
135 return parts[parts.length - 1] || path
136}
137
138function firstLine(text: string, max = 60): string {
139 const line = (text.split('\n')[0] ?? '').trim()
140 return line.length > max ? `${line.slice(0, max - 1)}…` : line
141}
142
143/** A short "what is happening" label for a tool call. */
144export function activityLabel(tool: string, input: Record<string, unknown>): string {
145 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
146 switch (tool) {
147 case 'Bash':
148 return `$ ${firstLine(str('description') || str('command'))}`
149 case 'Read':
150 return `reading ${basename(str('file_path'))}`
151 case 'Edit':
152 case 'MultiEdit':
153 return `editing ${basename(str('file_path'))}`
154 case 'Write':
155 return `writing ${basename(str('file_path'))}`
156 case 'NotebookEdit':
157 return `editing ${basename(str('notebook_path'))}`
158 case 'Grep':
159 return `searching “${firstLine(str('pattern'), 40)}”`
160 case 'Glob':
161 return `finding ${firstLine(str('pattern'), 40)}`
162 case 'Agent':
163 case 'Task':
164 return `subagent: ${firstLine(str('description'), 50)}`
165 case 'WebFetch':
166 try {
167 return `fetching ${new URL(str('url')).host}`
168 } catch {
169 return 'fetching a page'
170 }
171 case 'WebSearch':
172 return `searching the web: ${firstLine(str('query'), 40)}`
173 case 'TodoWrite':
174 case 'TaskCreate':
175 case 'TaskUpdate':
176 return 'updating the plan'
177 default:
178 if (tool.startsWith('mcp__')) {
179 const [, server, name] = tool.split('__')
180 return `${server} · ${name}`
181 }
182 return tool
183 }
184}
185
186export function shortToolName(tool: string): string {
187 if (!tool.startsWith('mcp__')) return tool
188 const parts = tool.split('__')
189 return parts[parts.length - 1] || tool
190}
191
192const CHECKBOX = /^\s*(?:[-*+]|\d+[.)])\s+\[([ xX])\]\s+(.+?)\s*$/
193
194/** The checkbox items of a Markdown text, outside fenced code blocks. */
195export function parseCheckboxes(text: string): { text: string; done: boolean }[] {
196 const items: { text: string; done: boolean }[] = []
197 let fence: string | null = null
198 for (const line of text.split('\n')) {
199 const marker = /^\s*(```+|~~~+)/.exec(line)?.[1]
200 if (marker !== undefined) {
201 if (fence === null) fence = marker[0] ?? null
202 else if (marker[0] === fence) fence = null
203 continue
204 }
205 if (fence !== null) continue
206 const m = CHECKBOX.exec(line)
207 if (m !== null) items.push({ text: (m[2] ?? '').replace(/[*_`]/g, ''), done: m[1] !== ' ' })
208 }
209 return items.slice(0, 300)
210}
211
212export const isMarkdown = (path: string) => /\.(md|markdown|mdx)$/i.test(path)
213types/index.d.ts 63 lines1export type TaskStatus = 'pending' | 'in_progress' | 'completed'
2
3export type Task = { id: string; subject: string; activeForm?: string; status: TaskStatus }
4
5export type PlanItem = { text: string; done: boolean }
6
7/** A Markdown plan or spec whose `- [ ]` / `- [x]` boxes stand for the steps. */
8export type PlanFile = { path: string; name: string; items: PlanItem[] }
9
10export type Live = {
11 turnId: string
12 startedAt: number
13 isLong: boolean
14 steps: number
15 toolCalls: number
16 subagentToolCalls: number
17 subagents: number
18 errors: number
19 byTool: Record<string, number>
20 files: string[]
21 running: { id: string; label: string }[]
22 lastActivity: string | null
23 tokensIn: number
24 tokensCached: number
25 tokensOut: number
26 model: string | null
27}
28
29export type Summary = {
30 reason: 'answer' | 'aborted' | 'refusal' | 'error'
31 durationMs: number
32 steps: number
33 toolCalls: number
34 subagentToolCalls: number
35 subagents: number
36 errors: number
37 topTools: [string, number][]
38 files: number
39 tasksDone: number
40 tasksTotal: number
41 stepsUnit: 'tasks' | 'steps'
42 stepsSource: string | null
43 tokensIn: number
44 tokensCached: number
45 tokensOut: number
46 model: string | null
47}
48
49export type Totals = { turns: number; longTurns: number; totalMs: number; toolCalls: number; tokensIn: number; tokensOut: number }
50
51declare module 'claude-code' {
52 interface PluginState {
53 'session-progress': {
54 live: Live | null
55 summary: Summary | null
56 tasks: Task[]
57 planFile: PlanFile | null
58 totals: Totals
59 tick: number
60 }
61 }
62}
63