SLOPSHOPPER

session-progress

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

newbandguardprompttimer
v0.1.2MITupdated 2026-10-09VAlux/claude-session-progress
A shopper browsing a rack in a slop shop
README

session-progress

A live progress band for long Claude Code tasks, and a short summary when they finish.

License: MIT Claude Code plugin Surface: terminal

<img src="docs/demo.gif" alt="session-progress following a six-step plan in Claude Code, then showing the completion summary" width="900">

Install

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.

What it shows

While Claude worksA 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 knownThe 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 finishesThe 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 promptThe summary goes away.

How progress is measured

SourceWhat 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 editsTicked ÷ total, moving forward as boxes are ticked
NeitherA 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.

When it appears

Quick answers and short Q&A never show the band. It appears once a turn has run:

20 s, with at least 3 tool callsordinary multi-step work
8 s, with a task list or plan of 2+ stepsplanned work, which shows sooner
45 sanything that runs long

The thresholds are the constants at the top of hooks/register.tsx.

Settings

Change it in /config, or with claude plugin configure session-progress.

SettingValuesWhat it does
stepRowsauto, always, neverWhen 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.

Good to know

  • It draws in the terminal only. Other surfaces show nothing extra.
  • Collapse the band at any time with ctrl+x ctrl+a, or with the [-] mark at its right edge.
  • A bug in the mod never blocks a tool call: its tool hook passes the call through if it fails.
  • It is built on Claude Code's function-hooks plugin API, which is in early access and may change between releases.

Develop

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
PathWhat it holds
hooks/register.tsxThe hooks: turn and tool tracking, plan files, the system-prompt line, the band
hooks/frames.tsBar and spinner frames, the checkbox parser, formatting
types/index.d.tsThe mod's state contract
tests/Behaviour tests, run with claude plugin test

The demo in docs/ was recorded with VHS from docs/demo.tape.

License

MIT

Source 3 files
hooks/register.tsx 602 lines
1import { 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}
602
hooks/frames.ts 213 lines
1// 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)
213
types/index.d.ts 63 lines
1export 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