SLOPSHOPPER

tqdm-claude

Claude Code mod: live tqdm-style progress bars above the prompt for background tasks and subagents, with crash and stall detection.

bandspinnerguardcommandprompttimer
★ 2v0.2.0MITupdated 2026-10-18ExtReMLapin/tqdm-claude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tqdm-claude
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /tqdm ⎿ tqdm-claude: No task is followed. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

tqdm-claude

Installation : /plugin install tqdm-claude --marketplace ExtReMLapin/tqdm-claude

Live progress bars inside Claude Code for background tasks and subagents. A Claude Code mod (a plugin built on the function-hooks API) that draws tqdm-style bars above the prompt for every background shell task. It reads the task's own output (tqdm, cargo, git, Keras, ffmpeg and more, or a one-line protocol) and shows crashes, stalls and silent failures the moment they happen.

Named after and inspired by tqdm; not affiliated with it.

Origin of the name : "T'attends quoi ? Dépèches-toi, merde." (they will deny it but it is what is it)

<img width="1675" height="526" alt="WindowsTerminal_8dZBUvjYKE" src="https://github.com/user-attachments/assets/5d5fb6c2-cfd0-49bd-a4ce-91af0346f0fd" />

…and a summary at the end of the hint line under the prompt, beside Claude Code's own task pills: esc to interrupt ⠹ Train model 40% · ✗ Index docs.

Why

Claude Code runs long jobs in the background and gets a notification when they end. Until then you see nothing: no progress, no ETA. And when a script dies mid-way, or a regex in a watcher never matches, nothing says so. tqdm-claude makes every background task visible and makes failure loud.

Features

  • Real progress, read from the task's output: tqdm (with or without a total, scaled units, postfix metrics, nested bars), cargo, ninja, CMake, git, Keras, docker buildx, ffmpeg (a real percentage from its duration), rsync, wget, curl, pnpm, apt, pip, robocopy, rclone, aws s3, git lfs, yt-dlp, HandBrake, pv, 7-Zip, dd, dask, Spark, XGBoost, LightGBM, webpack, Bazel, terraform, kubectl, and test runners with passes and failures in two colors: pytest, jest, vitest, go test, cargo test, dotnet test. Each checked against the tool's real output. OSC 9;4 and bare percentages too. See PROTOCOL.md.
  • Two levels: a main bar and a sub-bar (step 3/7, then 1840/2400 within it).
  • A one-line protocol for scripts: ##progress step=3/7 label="Train" sub=1840/2400. Claude is told about it, so the scripts it writes report progress.
  • Indeterminate tasks animate: a sweeping bar and the latest log line.
  • Failures stay: ✗, the exit code and the error line (Python exceptions, command not found...), until you dismiss them. Exit 0 short of the total is flagged too: ! (!) 45/100.
  • Stalls are flagged: ! no output for 02:10. Silence at start gets a hint: output is probably buffered (python -u).
  • ETA from tqdm's own smoothed estimate, else from the pace since progress started (start-up left out).
  • Subagents from the tasks list, with elapsed time and how they ended.
  • Notifications through your own channel when a task fails or ends early (or, if you like, when a long one succeeds), so you can look away.
  • A stuck turn is called out: no tool call for 3 minutes.
  • Outcomes in two colors: ##progress ok=30 fail=10 splits the bar.
  • The spinner follows the pace: faster when a task speeds up, slow when it stands still (after alive-progress).
  • Plays well with other mods: the band is drawn above theirs, the footer summary is added after theirs; a task ends on its notification, on TaskStop, or when the session stops listing it at a turn's end.
  • Foreground commands too: past 3 s, a foreground command gets its bar, read from the output file Claude Code writes as it runs.
  • Works everywhere Claude Code does: Windows, macOS, Linux, terminal and desktop. Glyphs follow tqdm's lead: Unicode eighth-blocks by default, ASCII where the locale is not UTF-8, and a code-page-437 set for fonts that lack the others.

Install

At the Claude Code prompt:

/plugin install tqdm-claude --marketplace ExtReMLapin/tqdm-claude

From a shell: claude plugin install tqdm-claude --marketplace ExtReMLapin/tqdm-claude. In the desktop app: + next to the prompt → Plugins → Add marketplace ExtReMLapin/tqdm-claude → Add plugin.

The mod is active at once; start a long command in the background to see it.

To get new versions without thinking about it, turn on auto-update for this marketplace: /plugin → Marketplaces → tqdm-claude → Enable auto-update (it is off by default for marketplaces other than Anthropic's). Otherwise, update by hand with claude plugin update tqdm-claude@tqdm-claude and restart Claude Code.

Settings

In /config (or pluginConfigs in settings):

OptionDefault
charsetautounicode, blocks (code page 437, if your font shows boxes), or ascii.
footermodeWhere the line under the prompt sums up tasks: mode (a label at its right, never cut), tail (after Claude Code's task pills), both, off.
agentstrueFollow subagents too.
notifyfailuresNative notification when a task fails or ends early; always adds stops and successes of a minute or more; off.
stucktrueWarn when a turn has had no tool call for 3 minutes: Claude may be stuck.

Commands

  • /tqdm: lists followed tasks with their status and output file.
  • /tqdm clear: drops finished and failed tasks from the band.

For scripts and other Claude Code instances

Print ##progress lines, unbuffered. The spec, with Python, Bash, PowerShell and Node helpers, is in PROTOCOL.md. The mod adds a short note on it to Claude's system prompt and ships the progress-protocol skill, which says the same where organization policy keeps plugins out of the system prompt.

How it works

HookWhat it does
tool.call (Bash, PowerShell)Notes each background task's id and output file; times foreground calls.
tool.call (TaskStop, every tool)Ends a stopped task; times tool calls for the stuck-turn warning.
session.appendReads <task-notification> rows: how a task ended, with its exit code.
classic.StopAt a turn's end, ends tasks the session no longer lists.
prompt.submit, turn.completeWhether a turn is running.
classic.SessionStartThe session's id and folder, to find its tasks folder.
ui.render SessionModeThe footer label, after Claude Code's own mode labels.
$.ui.notifyTells you of a failure through your notification channel.
$.clock.everyReads changed output files once a second; animates at ~4 fps while something runs.
$.agent.list()Mirrors the tasks list's subagents.
ui.render AbovePromptDraws the band.
ui.render PromptHintAdds the summary after Claude Code's own hint line.
prompt.composeTells Claude about the protocol and unbuffered output.

It never runs your tasks, never writes to their output, and never emits OSC 9;4 itself (Claude Code drives the terminal's own progress indicator).

What it reads, writes and sends

Nothing leaves your machine. The mod makes no network request (no $.http), starts no process (no $.process) and writes no file.

What it reads:

  • Task output files ($.fs.stat, $.fs.read, $.fs.list): the output files Claude Code writes in the session's tasks folder: the one it names when a background Bash or PowerShell task starts (Output is being written to: …), and, for a foreground command, the new b????????.output file that appears there when it starts. The last 64 KiB of each, to draw the bars. To find that folder before a background task names it, the mod lists the temporary folder for Claude Code's own (claude…/<project>/<session>/tasks). No other file.
  • Conversation rows (session.append): only rows holding a <task-notification>, for a task's id, status and exit code. Other rows pass through untouched and unread.
  • Prompts (prompt.submit): only the moment a turn starts, for the stuck-turn warning. The prompt's text is not read, kept or changed.
  • Tool calls (tool.call): a Bash or PowerShell call's description, command (its first line, as the title when there is no description) and run_in_background, and the task id in its result; TaskStop's task id; the start and end of every call, for the stuck-turn warning. Every call goes on unchanged.
  • The tasks list ($.agent.list, classic.Stop): subagents' description and status, and the ids of background tasks still running at a turn's end.
  • Six environment variables: LC_ALL, LC_CTYPE, LANG, to pick ASCII bars where the locale is not UTF-8; TMPDIR, TEMP, TMP, to find the tasks folder.

What it sends, and where:

  • $.ui.notify: when a task fails or ends early (or, with notify=always, when a long one ends), a native notification with the task's title, its exit code and its last error line. It goes to your own notification channel, the one Claude Code uses for its own notifications (your terminal's notification, or its bell), on this machine. Turn it off with notify=off.

What it changes:

  • The system prompt (prompt.compose): adds one section, tqdm-claude:protocol, telling Claude to run long commands in the background, to keep their output unbuffered and to print ##progress lines. Nothing else in the prompt is read or changed. It is not added under --bare.
  • The screen (ui.render): draws the band above the prompt, above any other plugin's band, and adds a summary after the hint line under the prompt, after any other plugin's.
  • Commands (command.register, command.run): adds /tqdm and answers only it; other commands pass through untouched.

The settings of the mod (its charset, footer, agents, notify, stuck options) are Claude Code's own; the tasks it follows live in Claude Code's session state and are gone when the session ends.

Limits

  • Only the last 64 KiB of an output is parsed; past 4 MiB the band shows a sweeping bar and says the log is too large.
  • Progress hidden by a tool when its output is not a terminal can't be read: see the "to get progress in a log" column in PROTOCOL.md.
  • Built on Claude Code's early-access function-hooks API, which may change between releases. Needs Claude Code 2.1.302 or later (tested on 2.1.302).
  • Where an organization manages Claude Code, hooks on prompt.compose and classic.Stop run only for plugins it lists: there, Claude learns the protocol from the bundled skill instead, and a task ends on its notification or TaskStop only.

Development

claude plugin validate .
claude plugin test .          # 209 tests: parser (on real output of 30+ tools), rendering, charsets, signals, the band end to end

claude --plugin-dir . runs a session with the mod loaded from this folder. scenarios/ holds scripts that exercise every case in a real session (tqdm single and nested, crash, stall, plain logs, sequential bars, huge output, other tools...). tests/fixtures/real-tqdm.ts is genuine tqdm output, regenerated with python scenarios/capture_real_tqdm.py.

Source 5 files
hooks/register.tsx 940 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, Register } from 'claude-code'
3
4import type { Activity, Track } from '../types'
5import { CHARSETS, type CharsetEnv, chooseCharset } from './charset'
6import { backgroundOf, notificationsOf, parse } from './parse'
7import {
8  barParts,
9  cells,
10  clip,
11  duration,
12  endNotice,
13  failureOf,
14  fractionOf,
15  glyph,
16  hasDetailRow,
17  hasMainRow,
18  isShortOfTotal,
19  isStalled,
20  type NotifyMode,
21  ordered,
22  receiptOf,
23  rowsOf,
24  silentStartHint,
25  modeLabelOf,
26  summaryOf,
27  timing,
28} from './render'
29
30const tracks = atom({ plugin: 'tqdm-claude', key: 'tracks' } as const, [])
31const clock = atom({ plugin: 'tqdm-claude', key: 'now' } as const, 0)
32/** The clock to the second: the footer redraws once a second, not at the band's frame rate. */
33const second = atom({ plugin: 'tqdm-claude', key: 'second' } as const, 0)
34const activity = atom({ plugin: 'tqdm-claude', key: 'activity' } as const, { isTurnRunning: false, lastAt: 0, runningTools: 0 })
35
36const POLL_MS = 1000
37/** A redraw of the band costs real time (#91870 measured 150–190 ms): four frames a second is enough. */
38const FRAME_MS = 250
39/** An output this large is read every few seconds only: each read copies all of it. */
40const LARGE_OUTPUT_BYTES = 256 * 1024
41const LARGE_OUTPUT_EVERY_MS = 5000
42/** Endings this close together are told of in one notification. */
43const NOTIFY_BATCH_MS = 1500
44/** A foreground call shows once it has run this long: quick commands never flicker the band. */
45const FOREGROUND_AFTER_MS = 3000
46/** A task that ended well leaves the band after this long; a failed one stays until dismissed. */
47const DONE_LINGER_MS = 8000
48/** A turn with no tool call starting or ending for this long may be stuck. A guess, said as one. */
49const STUCK_MS = 3 * 60_000
50/** `$.fs.read` refuses past 4 MiB. */
51const MAX_READ_BYTES = 4 * 1024 * 1024 - 1024
52const MAX_TRACKS = 20
53
54const PROTOCOL_SECTION = {
55  id: 'tqdm-claude:protocol',
56  scope: 'session',
57  text: [
58    '# Long-running commands (tqdm-claude)',
59    'The person sees a live progress band above the prompt for every background Bash/PowerShell task: it reads the task\'s output file, parses tqdm bars, and shows crashes and stalls.',
60    '- Run anything expected to take more than ~30 seconds with run_in_background, and give it a short `description`: it is the band\'s title.',
61    '- Make output unbuffered so progress reaches the file: `python -u` or PYTHONUNBUFFERED=1, `print(..., flush=True)`, `stdbuf -oL` for other tools.',
62    '- In scripts you write, report progress with one line per update: `##progress step=3/7 label="Training" sub=1840/2400 sublabel="Epoch 3"` (`sub=1840` with no total when unknown; `pct=45` for a bare percentage; `ok=12 fail=2` to count outcomes; `msg="..."` for a status; `done=ok` or `done=fail` when a long-lived process finished its work). tqdm output needs nothing extra.',
63    '- Do not poll a background task\'s output file or sleep to wait for it: the band shows its progress to the person, and you are notified when it ends.',
64    '- When a background task ends, report its exit code; never take silence for success.',
65  ].join('\n'),
66} as const
67
68type ShellInput = {
69  tool_use_id: string
70  command: string
71  description?: string
72  run_in_background?: boolean
73}
74
75type ShellResult = { deny?: string; isError?: true; text?: string; result?: unknown }
76
77type Patch = Partial<Track>
78
79let timers: { cancel(): void }[] | undefined
80/** A tick that finds the last one still running skips: two polls must not end a task twice. */
81let isPolling = false
82let isAnimating = false
83let pending: { title: string; text: string }[] = []
84let env: CharsetEnv | undefined
85
86/**
87 * The session's tasks folder, where Claude Code writes every shell call's
88 * output as it runs, foreground calls included (checked live: a foreground
89 * command's `b????????.output` grows line by line). Learned from a background
90 * call's or a subagent's output path, else found from the session's id.
91 */
92let tasksDir: string | undefined
93let sessionInfo: { id: string; cwd: string } | undefined
94/** Foreground calls waiting for their output file: the folder's files before each started. */
95const pendingForeground = new Map<string, { dir: string; before: Set<string> }>()
96/** A shell call's output file: `b` and eight characters. A subagent's transcript is named otherwise. */
97const SHELL_OUTPUT = /^b[a-z0-9]{8}\.output$/
98
99function folderOf(path: string): string {
100  return path.replace(/[\\/][^\\/]+$/, '')
101}
102
103function learnTasksDir(outputFile: string | undefined): void {
104  if (outputFile && !tasksDir) {
105    tasksDir = folderOf(outputFile)
106  }
107}
108
109/** The tasks folder: learned, or found under the temporary folder, as `<tmp>/claude…/<cwd slug>/<session>/tasks`. */
110async function findTasksDir($: EngineInterface): Promise<string | undefined> {
111  if (tasksDir || !sessionInfo) {
112    return tasksDir
113  }
114
115  const slug = sessionInfo.cwd.replace(/[^A-Za-z0-9]/g, '-')
116  const roots = [await $.env.get('TMPDIR'), await $.env.get('TEMP'), await $.env.get('TMP'), '/tmp'].filter(
117    (root): root is string => Boolean(root),
118  )
119
120  for (const root of roots) {
121    let entries: Awaited<ReturnType<typeof $.fs.list>>
122
123    try {
124      entries = await $.fs.list(root)
125    } catch {
126      continue
127    }
128
129    for (const entry of entries.filter(one => one.kind === 'dir' && one.name.startsWith('claude'))) {
130      const candidate = `${root}/${entry.name}/${slug}/${sessionInfo.id}/tasks`
131
132      try {
133        if ((await $.fs.stat(candidate)).kind === 'dir') {
134          tasksDir = candidate
135
136          return tasksDir
137        }
138      } catch {
139        // Not this one.
140      }
141    }
142  }
143
144  return undefined
145}
146
147/** Shell output files in the tasks folder now. */
148async function shellOutputs($: EngineInterface, dir: string): Promise<Set<string>> {
149  try {
150    return new Set((await $.fs.list(dir)).filter(one => SHELL_OUTPUT.test(one.name)).map(one => one.name))
151  } catch {
152    return new Set()
153  }
154}
155
156/**
157 * Gives each running foreground call the output file that appeared after it
158 * started and that no other task holds, oldest first: two calls started
159 * together may swap files, never share one.
160 */
161async function claimForegroundOutputs($: EngineInterface, list: readonly Track[]): Promise<Map<string, Patch>> {
162  const patches = new Map<string, Patch>()
163  // By file name: the engine spells the folder with `\` on Windows where a path was joined with `/`.
164  const held = new Set(list.map(one => one.outputFile?.split(/[\\/]/).pop()).filter((name): name is string => Boolean(name)))
165  const waiting = list
166    .filter(one => one.kind === 'fg' && one.status === 'running' && !one.outputFile && pendingForeground.has(one.id))
167    .sort((a, b) => a.startedAt - b.startedAt)
168
169  for (const track of waiting) {
170    const pending = pendingForeground.get(track.id)
171
172    if (!pending) {
173      continue
174    }
175
176    const now = await shellOutputs($, pending.dir)
177    const fresh = [...now].find(name => !pending.before.has(name) && !held.has(name))
178
179    if (fresh) {
180      held.add(fresh)
181      patches.set(track.id, { outputFile: `${pending.dir}/${fresh}`, size: -1 })
182      pendingForeground.delete(track.id)
183    }
184  }
185
186  return patches
187}
188/** The person's options, set as the module registers. */
189let notifyMode: NotifyMode = 'failures'
190let isFollowingAgents = true
191
192/** The variables the charset is chosen from, read once per load (names must be literals). */
193async function loadEnv($: EngineInterface): Promise<CharsetEnv> {
194  env ??= {
195    LC_ALL: await $.env.get('LC_ALL'),
196    LC_CTYPE: await $.env.get('LC_CTYPE'),
197    LANG: await $.env.get('LANG'),
198  }
199
200  return env
201}
202
203function labelOf(e: ShellInput): string {
204  const text = e.description?.trim() || (e.command.trim().split(/\r?\n/)[0] ?? '')
205
206  return clip(text, 80)
207}
208
209function upsert(list: readonly Track[], track: Track): Track[] {
210  return [...list.filter(one => one.id !== track.id), track].slice(-MAX_TRACKS)
211}
212
213function patchAll(list: readonly Track[], patches: Map<string, Patch>, removed: Set<string>): Track[] {
214  return list
215    .filter(one => !removed.has(one.id))
216    .map(one => (patches.has(one.id) ? { ...one, ...patches.get(one.id) } : one))
217}
218
219/**
220 * Tells the person of a task's end by their own notification channel, where it
221 * is worth it. Endings that come together (a batch of jobs failing at once)
222 * are told of in one notification: nothing else limits how often one is sent.
223 */
224async function tell($: EngineInterface, track: Track | undefined): Promise<void> {
225  const notice = track ? endNotice(track, notifyMode) : undefined
226
227  if (!notice) {
228    return
229  }
230
231  pending.push(notice)
232
233  if (pending.length === 1) {
234    $.clock.after(NOTIFY_BATCH_MS, () => void flush($))
235  }
236}
237
238async function flush($: EngineInterface): Promise<void> {
239  const batch = pending
240
241  pending = []
242
243  if (batch.length === 1 && batch[0]) {
244    await $.ui.notify(batch[0].text, { title: batch[0].title }).catch(() => undefined)
245  } else if (batch.length > 1) {
246    const failed = batch.filter(one => one.title === 'Task failed').length
247    const title = failed === batch.length ? `${failed} tasks failed` : `${batch.length} tasks ended`
248
249    await $.ui.notify(batch.map(one => one.text.split(' · ')[0]).join(', '), { title }).catch(() => undefined)
250  }
251}
252
253/** Reads a task's output again when it grew; undefined when nothing changed. */
254async function refresh($: EngineInterface, track: Track, now: number): Promise<Patch | undefined> {
255  if (!track.outputFile) {
256    return undefined
257  }
258
259  let size: number
260
261  try {
262    size = (await $.fs.stat(track.outputFile)).size
263  } catch {
264    return undefined
265  }
266
267  if (size === track.size) {
268    return undefined
269  }
270
271  // A large output grows while it is read in whole: read it less often (it still counts as output).
272  if (size > LARGE_OUTPUT_BYTES && track.size > 0 && now - track.lastChangeAt < LARGE_OUTPUT_EVERY_MS) {
273    return undefined
274  }
275
276  if (size > MAX_READ_BYTES) {
277    return { size, lastChangeAt: now, isTooBig: true }
278  }
279
280  let text: string
281
282  try {
283    text = await $.fs.read(track.outputFile)
284  } catch {
285    return undefined
286  }
287
288  const parsed = parse(text)
289  const patch: Patch = {
290    size,
291    lastChangeAt: now,
292    main: parsed.main,
293    sub: parsed.sub,
294    lastLine: parsed.lastLine,
295    error: parsed.error,
296    isExact: parsed.isExact,
297  }
298
299  const fraction = parsed.main ? fractionOf(parsed.main) : undefined
300  const isNewBar = parsed.main?.label !== track.main?.label
301
302  if (fraction !== undefined && (!track.progressFrom || isNewBar)) {
303    patch.progressFrom = { at: now, fraction }
304    patch.lastSample = { at: now, fraction }
305    patch.paceRatio = 1
306  } else if (fraction !== undefined && track.lastSample && track.progressFrom && fraction > track.lastSample.fraction) {
307    // alive-progress: the recent pace against the average one turns the spinner faster or slower.
308    const recent = (fraction - track.lastSample.fraction) / Math.max(1, now - track.lastSample.at)
309    const average = (fraction - track.progressFrom.fraction) / Math.max(1, now - track.progressFrom.at)
310
311    patch.paceRatio = average > 0 ? Math.min(4, Math.max(0.25, recent / average)) : 1
312    patch.lastSample = { at: now, fraction }
313  }
314
315  if (parsed.declared && track.status === 'running') {
316    patch.status = parsed.declared === 'ok' ? 'done' : 'failed'
317    patch.endedAt = now
318  }
319
320  return patch
321}
322
323const AGENT_ENDED: Partial<Record<AgentInfo['status'], Track['status']>> = {
324  completed: 'done',
325  failed: 'failed',
326  killed: 'killed',
327}
328
329/**
330 * Mirrors the tasks list's subagents: one seen running is followed until it
331 * ends; one already ended when first seen is history, never shown. A
332 * teammate idles between turns and is left to the team's own view.
333 */
334async function syncAgents($: EngineInterface): Promise<void> {
335  let agents: AgentInfo[]
336
337  try {
338    agents = await $.agent.list()
339  } catch {
340    return
341  }
342
343  const now = await $.clock.now()
344  const ended: string[] = []
345
346  await update($, tracks, list => {
347    let next = [...list]
348
349    ended.length = 0
350
351    for (const agent of agents) {
352      if (agent.teammateId !== undefined) {
353        continue
354      }
355
356      const id = `agent:${agent.id}`
357      const known = next.find(one => one.id === id)
358      const end = AGENT_ENDED[agent.status]
359
360      if (!known && !end) {
361        next = upsert(next, {
362          id,
363          label: clip(agent.description || agent.type, 80),
364          kind: 'agent',
365          startedAt: now,
366          status: 'running',
367          lastChangeAt: now,
368          size: 0,
369          lastLine: agent.type,
370        })
371      } else if (known && known.status === 'running' && end) {
372        next = next.map(one => (one.id === id ? { ...one, status: end, endedAt: now } : one))
373        ended.push(id)
374      }
375    }
376
377    // One the list no longer holds has ended: its timer must not run on (claude-code #99613).
378    const listed = new Set(agents.map(agent => `agent:${agent.id}`))
379
380    next = next.map(one => {
381      if (one.kind === 'agent' && one.status === 'running' && !listed.has(one.id)) {
382        ended.push(one.id)
383
384        return { ...one, status: 'done', endedAt: now }
385      }
386
387      return one
388    })
389
390    return next
391  })
392
393  if (ended.length > 0) {
394    const list = await read($, tracks)
395
396    for (const id of ended) {
397      await tell($, list.find(one => one.id === id))
398    }
399  }
400}
401
402async function poll($: EngineInterface): Promise<void> {
403  if (isFollowingAgents) {
404    await syncAgents($)
405  }
406
407  const list = await read($, tracks)
408
409  if (list.length === 0) {
410    return
411  }
412
413  const now = await $.clock.now()
414  const patches = await claimForegroundOutputs($, list)
415  const removed = new Set<string>()
416  const declared: string[] = []
417
418  for (const track of list.map(one => (patches.has(one.id) ? { ...one, ...patches.get(one.id) } : one))) {
419    if (track.status === 'done' && !isShortOfTotal(track) && now - (track.endedAt ?? now) > DONE_LINGER_MS) {
420      removed.add(track.id)
421    } else if (track.status === 'running' && track.kind !== 'agent' && track.outputFile) {
422      const patch = await refresh($, track, now)
423
424      if (patch) {
425        patches.set(track.id, { ...patches.get(track.id), ...patch })
426
427        if (patch.status) {
428          declared.push(track.id)
429        }
430      }
431    }
432  }
433
434  if (patches.size > 0 || removed.size > 0) {
435    await update($, tracks, current => patchAll(current, patches, removed))
436  }
437
438  if (declared.length > 0) {
439    const current = await read($, tracks)
440
441    for (const id of declared) {
442      await tell($, current.find(one => one.id === id))
443    }
444  }
445}
446
447function isShown(track: Track, now: number): boolean {
448  return track.kind !== 'fg' || now - track.startedAt >= FOREGROUND_AFTER_MS
449}
450
451function isStuck(state: Activity, now: number): boolean {
452  return state.isTurnRunning && state.runningTools === 0 && state.lastAt > 0 && now - state.lastAt > STUCK_MS
453}
454
455async function animate($: EngineInterface): Promise<void> {
456  const list = await read($, tracks)
457  const now = await $.clock.now()
458  const last = await read($, clock)
459  const isMoving = list.some(track => track.status === 'running' && isShown(track, now))
460
461  if (isMoving) {
462    await update($, clock, () => now)
463  } else if ((list.length > 0 || (await read($, activity)).isTurnRunning) && last < now - POLL_MS) {
464    // Nothing moves, but finished tracks age out and a quiet turn's time goes on.
465    await update($, clock, () => now)
466  }
467
468  const whole = Math.floor(now / 1000) * 1000
469
470  if ((isMoving || list.length > 0) && (await read($, second)) !== whole) {
471    await update($, second, () => whole)
472  }
473}
474
475/** Runs `work` unless the last run of it is still going. */
476function once(isRunning: () => boolean, setRunning: (value: boolean) => void, work: () => Promise<void>): void {
477  if (isRunning()) {
478    return
479  }
480
481  setRunning(true)
482  void work()
483    .catch(() => undefined)
484    .finally(() => setRunning(false))
485}
486
487function startTimers($: EngineInterface): void {
488  if (timers) {
489    return
490  }
491
492  timers = [
493    $.clock.every(POLL_MS, () => once(() => isPolling, value => (isPolling = value), () => poll($))),
494    $.clock.every(FRAME_MS, () => once(() => isAnimating, value => (isAnimating = value), () => animate($))),
495  ]
496}
497
498/**
499 * Ends a task. The notification is the word that counts: it ends a running
500 * task and corrects one a turn's end had ended unconfirmed. A later word on
501 * a task already ended is ignored, so it is told of once.
502 */
503async function settle($: EngineInterface, taskId: string, status: string, exitCode: number | undefined, isUnconfirmed = false) {
504  const list = await read($, tracks)
505  const track = list.find(one => one.id === taskId)
506
507  if (!track || (track.status !== 'running' && !track.isUnconfirmed) || (isUnconfirmed && track.status !== 'running')) {
508    return
509  }
510
511  const now = await $.clock.now()
512  // One last read: the final bars and, on a failure, the error line.
513  const last = (await refresh($, { ...track, size: -1 }, now)) ?? {}
514  const ended: Track['status'] =
515    status === 'killed' || status === 'stopped'
516      ? 'killed'
517      : status === 'failed' || (exitCode !== undefined && exitCode !== 0)
518        ? 'failed'
519        : 'done'
520  const patch: Patch = {
521    ...last,
522    lastChangeAt: now,
523    status: ended,
524    exitCode,
525    endedAt: track.isUnconfirmed ? track.endedAt : now,
526    isUnconfirmed,
527  }
528
529  await update($, tracks, current => patchAll(current, new Map([[taskId, patch]]), new Set()))
530  await tell($, { ...track, ...patch })
531}
532
533async function followShell<R extends ShellResult>(
534  $: EngineInterface,
535  e: ShellInput,
536  run: () => Promise<R>,
537): Promise<R> {
538  const startedAt = await $.clock.now()
539  const label = labelOf(e)
540  const isBackground = e.run_in_background === true
541
542  if (!isBackground) {
543    const dir = await findTasksDir($)
544
545    // The files there before this call started: its own is the one that appears next.
546    if (dir) {
547      pendingForeground.set(e.tool_use_id, { dir, before: await shellOutputs($, dir) })
548    }
549
550    await update($, tracks, list =>
551      upsert(list, { id: e.tool_use_id, label, kind: 'fg', startedAt, status: 'running', lastChangeAt: startedAt, size: 0 }),
552    )
553  }
554
555  let ran: R
556
557  try {
558    ran = await run()
559  } finally {
560    pendingForeground.delete(e.tool_use_id)
561  }
562  const isAnswered = ran.deny === undefined && ran.isError !== true
563  const record = isAnswered ? (ran.result as { backgroundTaskId?: string; backgroundOutputPath?: string } | undefined) : undefined
564  const background = isAnswered ? backgroundOf(ran.text, record ?? undefined) : undefined
565  const now = await $.clock.now()
566
567  learnTasksDir(background?.outputFile)
568
569  await update($, tracks, list => {
570    const rest = list.filter(one => one.id !== e.tool_use_id)
571
572    return background
573      ? upsert(rest, {
574          id: background.taskId,
575          label,
576          kind: 'bg',
577          outputFile: background.outputFile,
578          startedAt,
579          status: 'running',
580          lastChangeAt: now,
581          size: -1,
582        })
583      : rest
584  })
585
586  return ran
587}
588
589/**
590 * Counts running tool calls. The state is written only when the count leaves
591 * or reaches zero, the moments the stuck-turn guess turns on: a write per call
592 * would redraw the band on every tool call of every kind.
593 */
594let runningTools = 0
595
596async function touch($: EngineInterface, change: number): Promise<void> {
597  const before = runningTools
598
599  runningTools = Math.max(0, runningTools + change)
600
601  if ((before === 0) !== (runningTools === 0)) {
602    const now = await $.clock.now()
603    const count = runningTools
604
605    await update($, activity, state => ({ ...state, lastAt: now, runningTools: count }))
606  }
607}
608
609export const register: Register = (on, options) => {
610  const charset = typeof options.charset === 'string' ? options.charset : 'auto'
611  // Where the footer summary goes: the mode labels right of the hint line (never cut), the hint line's
612  // tail (cut where the line runs out), both, or nowhere. A boolean is the option's earlier form.
613  const footer = options.footer === false || options.footer === 'off' ? 'off' : options.footer === 'tail' || options.footer === 'both' ? options.footer : 'mode'
614  const isWarningStuck = options.stuck !== false
615
616  notifyMode = options.notify === 'always' || options.notify === 'off' ? options.notify : 'failures'
617  isFollowingAgents = options.agents !== false
618
619  on('session.start', async ($, e, next) => {
620    startTimers($)
621    await loadEnv($)
622
623    try {
624      // Last, as registering a name another plugin holds throws.
625      await $.command.register({
626        name: 'tqdm',
627        description: 'Task progress band: `/tqdm` lists tasks, `/tqdm clear` drops finished ones',
628      })
629    } catch {
630      // The band works without its command.
631    }
632
633    return next(e)
634  })
635
636  on('command.run', { command: 'tqdm' }, async ($, e) => {
637    if (e.args.trim() === 'clear') {
638      await update($, tracks, list => list.filter(one => one.status === 'running'))
639
640      return { text: 'Cleared finished tasks from the progress band.' }
641    }
642
643    const list = await read($, tracks)
644    const now = await $.clock.now()
645
646    if (list.length === 0) {
647      return { text: 'No task is followed.' }
648    }
649
650    return {
651      text: ordered(list)
652        .map(one => `${glyph(one, now)} ${one.label} (${one.status}, ${timing(one, now)})${one.outputFile ? ` ${one.outputFile}` : ''}`)
653        .join('\n'),
654    }
655  })
656
657  on('prompt.compose', async ($, e, next) => {
658    const composed = await next(e)
659
660    // `--bare` asks for the bare prompt: no section of a plugin's in it.
661    if (e.traits.includes('bare')) {
662      return composed
663    }
664
665    return { sections: [...composed.sections.filter(one => one.id !== PROTOCOL_SECTION.id), PROTOCOL_SECTION] }
666  })
667
668  on('prompt.submit', async ($, e, next) => {
669    const now = await $.clock.now()
670
671    runningTools = 0
672    await update($, activity, () => ({ isTurnRunning: true, lastAt: now, runningTools: 0 }))
673
674    return next(e)
675  }).catch(($, e, next) => next(e))
676
677  on('turn.complete', async ($, e, next) => {
678    runningTools = 0
679    await update($, activity, state => ({ ...state, isTurnRunning: false, runningTools: 0 }))
680
681    return next(e)
682  }).catch(($, e, next) => next(e))
683
684  // Every tool call, for the stuck-turn guess: while one runs, the turn is waiting, not stuck.
685  on('tool.call', async ($, e, next) => {
686    if (!isWarningStuck) {
687      return next(e)
688    }
689
690    await touch($, 1)
691
692    try {
693      return await next(e)
694    } finally {
695      await touch($, -1)
696    }
697  }).catch(($, e, next) => next(e))
698
699  // A subagent's own commands are its business: the subagent is followed as one task, with its time and end.
700  on('tool.call', { tool: 'Bash' }, ($, e, next) => {
701    startTimers($)
702
703    return e.agentId === undefined ? followShell($, e, () => next(e)) : next(e)
704  }).catch(($, e, next) => next(e))
705
706  on('tool.call', { tool: 'PowerShell' }, ($, e, next) => {
707    startTimers($)
708
709    return e.agentId === undefined ? followShell($, e, () => next(e)) : next(e)
710  }).catch(($, e, next) => next(e))
711
712  // A subagent starting is the moment to begin polling the tasks list.
713  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
714    startTimers($)
715
716    const ran = await next(e)
717    // A subagent's output file sits in the session's tasks folder, the one foreground calls write to.
718    const launched = ran.isError !== true && ran.deny === undefined ? (ran.result as { outputFile?: unknown } | undefined) : undefined
719
720    learnTasksDir(typeof launched?.outputFile === 'string' ? launched.outputFile : undefined)
721
722    return ran
723  }).catch(($, e, next) => next(e))
724
725  // A task Claude stopped has ended now, whatever its notification says later.
726  on('tool.call', { tool: 'TaskStop' }, async ($, e, next) => {
727    const ran = await next(e)
728    const id = e.task_id ?? e.shell_id
729
730    if (id && ran.deny === undefined && ran.isError !== true) {
731      await settle($, id, 'killed', undefined)
732      await settle($, `agent:${id}`, 'killed', undefined)
733    }
734
735    return ran
736  }).catch(($, e, next) => next(e))
737
738  // At a turn's end the session lists the background work still in flight: a task it no
739  // longer lists has ended, though its notification has not come yet (the desktop app).
740  // The session's id and folder, to find its tasks folder before any background call names it.
741  on('classic.SessionStart', async ($, e, next) => {
742    sessionInfo = { id: e.session_id, cwd: e.cwd }
743
744    return next(e)
745  }).catch(($, e, next) => next(e))
746
747  on('classic.Stop', async ($, e, next) => {
748    sessionInfo ??= { id: e.session_id, cwd: e.cwd }
749
750    const result = await next(e)
751    const inFlight = e.background_tasks?.map(task => task.id)
752
753    if (inFlight) {
754      const open = new Set(inFlight)
755
756      // After the hook has answered: the reads and the notification hold up no one.
757      $.clock.after(0, () => {
758        void (async () => {
759          const now = await $.clock.now()
760
761          for (const track of await read($, tracks)) {
762            if (track.kind === 'bg' && track.status === 'running' && !open.has(track.id) && now - track.startedAt > 5000) {
763              await settle($, track.id, 'completed', undefined, true)
764            }
765          }
766        })().catch(() => undefined)
767      })
768    }
769
770    return result
771  }).catch(($, e, next) => next(e))
772
773  on('session.append', async ($, e, next) => {
774    const stored = await next(e)
775    const content = e.message.content as unknown
776    const text =
777      typeof content === 'string'
778        ? content
779        : Array.isArray(content)
780          ? content.map(block => (block && typeof block.text === 'string' ? block.text : '')).join('\n')
781          : ''
782
783    if (text.includes('<task-notification>')) {
784      const ended = notificationsOf(text)
785
786      // Rows are stored in order: the last read of the output waits until this one is stored.
787      $.clock.after(0, () => {
788        void (async () => {
789          for (const one of ended) {
790            await settle($, one.taskId, one.status, one.exitCode)
791          }
792        })().catch(() => undefined)
793      })
794    }
795
796    return stored
797  }).catch(($, e, next) => next(e))
798
799  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
800    await read($, second)
801
802    if (footer !== 'mode' && footer !== 'both') {
803      return next(e)
804    }
805
806    const now = await $.clock.now()
807    const list = (await read($, tracks)).filter(one => isShown(one, now))
808    const label = modeLabelOf(list, now, CHARSETS[chooseCharset(charset, await loadEnv($), e.surface)])
809
810    // Added after the engine's own labels and any other plugin's, never in their place.
811    return label ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, label] } }) : next(e)
812  })
813  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
814    await read($, second)
815
816    if (footer !== 'tail' && footer !== 'both') {
817      return next(e)
818    }
819
820    const now = await $.clock.now()
821    const list = (await read($, tracks)).filter(one => isShown(one, now))
822    const glyphs = CHARSETS[chooseCharset(charset, await loadEnv($), e.surface)]
823    const summary = summaryOf(list, now, glyphs)
824
825    // Added to what another plugin's hook above already put there, never in its place.
826    return summary ? next({ ...e, props: { ...e.props, tail: [e.props.tail, `  ${summary}`].filter(Boolean).join('') } }) : next(e)
827  })
828
829  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
830    await read($, clock)
831
832    const now = await $.clock.now()
833    const list = ordered((await read($, tracks)).filter(one => isShown(one, now)))
834    const state = await read($, activity)
835    const stuck = isWarningStuck && isStuck(state, now)
836
837    // What the other plugins and the engine draw here stays: the band is drawn above it.
838    const below = await next(e)
839
840    if (e.props.hasSurvey || (list.length === 0 && !stuck)) {
841      return below
842    }
843
844    const { Box, Button, Text } = $.ui.resolve(e)
845    const glyphs = CHARSETS[chooseCharset(charset, await loadEnv($), e.surface)]
846    const width = Math.max(20, e.props.bodyColumns)
847    const room = Math.max(2, e.props.maxRows - 1 - (stuck ? 1 : 0))
848    const shown: Track[] = []
849    let rows = 0
850
851    for (const track of list) {
852      if (rows + rowsOf(track) > room && shown.length > 0) {
853        break
854      }
855
856      shown.push(track)
857      rows += rowsOf(track)
858    }
859
860    const hidden = list.length - shown.length
861
862    const bar = (track: Track, which: 'main' | 'sub', prefix: string, stalled: boolean) => {
863      const parts = barParts(track[which] ?? { n: 0 }, prefix, width, now, glyphs)
864      const tone = stalled ? 'warning' : undefined
865
866      return (
867        <Text dimColor={which === 'sub' && track.status !== 'running'}>
868          {parts.head}
869          <Text color={tone}>{parts.done}</Text>
870          {parts.failed && <Text color="error">{parts.failed}</Text>}
871          {parts.left && <Text color={tone}>{parts.left}</Text>}
872          {parts.tail}
873        </Text>
874      )
875    }
876
877    return (
878      <Box flexDirection="column">
879        {stuck && (
880          <Text color="warning">
881            {clip(`${glyphs.warning} no tool call for ${duration(now - state.lastAt)} · Claude may be stuck (esc to interrupt)`, width, glyphs.ellipsis)}
882          </Text>
883        )}
884        {shown.map(track => {
885          const receipt = track.status === 'done' ? receiptOf(track) : ''
886          const time = receipt ? `${timing(track, now)}  ${receipt}` : timing(track, now)
887          const stalled = isStalled(track, now)
888          const silent = silentStartHint(track, now)
889          const warning = stalled ? `  ${glyphs.warning} no output for ${duration(now - track.lastChangeAt)}` : ''
890          const tooBig = track.isTooBig && track.status === 'running' ? '  (log too large to parse)' : ''
891          const titleRoom = width - time.length - warning.length - tooBig.length - 6
892          const color =
893            track.status === 'failed' || track.status === 'killed'
894              ? 'error'
895              : isShortOfTotal(track)
896                ? 'warning'
897                : track.status === 'done'
898                  ? 'success'
899                  : 'claude'
900
901          return (
902            <Box key={`track-${track.id}`} flexDirection="column">
903              <Box flexDirection="row">
904                <Text>
905                  <Text color={color}>{glyph(track, now, glyphs)}</Text> <Text bold>{clip(track.label, titleRoom, glyphs.ellipsis)}</Text>
906                  <Text dimColor>  {time}</Text>
907                  {warning && <Text color="warning">{warning}</Text>}
908                  {tooBig && <Text dimColor>{tooBig}</Text>}
909                </Text>
910                {hasDetailRow(track) && (
911                  <Button key={`dismiss-${track.id}`} plain label=" [x]" onPress={() => update($, tracks, l => l.filter(one => one.id !== track.id))} />
912                )}
913              </Box>
914              {hasMainRow(track) &&
915                (track.main ? (
916                  bar(track, 'main', '  ', stalled)
917                ) : (
918                  <Text>
919                    <Text color={stalled ? 'warning' : 'claude'}>{`  ${cells({ n: 0 }, 20, now, glyphs)}`}</Text>
920                    {silent ? (
921                      <Text color="warning">{`  ${clip(silent, width - 24, glyphs.ellipsis)}`}</Text>
922                    ) : (
923                      <Text dimColor>{`  ${clip(track.lastLine ?? '', width - 24, glyphs.ellipsis)}`}</Text>
924                    )}
925                  </Text>
926                ))}
927              {track.sub && bar(track, 'sub', `  ${glyphs.branch} `, false)}
928              {hasDetailRow(track) && (
929                <Text color={isShortOfTotal(track) ? 'warning' : 'error'}>{clip(`  ${failureOf(track)}`, width, glyphs.ellipsis)}</Text>
930              )}
931            </Box>
932          )
933        })}
934        {hidden > 0 && <Text dimColor>  +{hidden} more · /tqdm</Text>}
935        {below}
936      </Box>
937    )
938  })
939}
940
hooks/charset.ts 140 lines
1/**
2 * The glyphs the band draws with, in three sets after tqdm's own: tqdm draws
3 * `" ▏▎▍▌▋▊▉█"` where its stream can encode it and `" 123456789#"` where it
4 * cannot. The band is drawn by Claude Code, whose output is always UTF-8, so
5 * the question is the font, not the encoding: `blocks` keeps to what code
6 * page 437 has, which the legacy Windows console draws in any font.
7 */
8export type Glyphs = {
9  full: string
10  empty: string
11  /** The partial cells between empty and full, in order: eighths, tenths, or none. */
12  partials: readonly string[]
13  spinner: readonly string[]
14  done: string
15  failed: string
16  killed: string
17  warning: string
18  branch: string
19  ellipsis: string
20}
21
22export const CHARSETS = {
23  unicode: {
24    full: '█',
25    empty: '░',
26    partials: ['▏', '▎', '▍', '▌', '▋', '▊', '▉'],
27    spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
28    done: '✓',
29    failed: '✗',
30    killed: '■',
31    warning: '!',
32    branch: '└',
33    ellipsis: '…',
34  },
35  blocks: {
36    full: '█',
37    empty: '░',
38    partials: [],
39    spinner: ['|', '/', '-', '\\'],
40    done: '√',
41    failed: 'x',
42    killed: '■',
43    warning: '!',
44    branch: '└',
45    ellipsis: '~',
46  },
47  ascii: {
48    full: '#',
49    empty: '-',
50    partials: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
51    spinner: ['|', '/', '-', '\\'],
52    done: '+',
53    failed: 'x',
54    killed: '#',
55    warning: '!',
56    branch: '`-',
57    ellipsis: '~',
58  },
59} as const satisfies Record<string, Glyphs>
60
61export type CharsetName = keyof typeof CHARSETS
62
63/** The environment variables the choice reads. */
64export type CharsetEnv = {
65  LC_ALL?: string
66  LC_CTYPE?: string
67  LANG?: string
68}
69
70/**
71 * `auto`: unicode, except where the locale names a charset that is not UTF-8
72 * (ascii). Claude Code draws its own interface with symbols (`✻`, `●`, `⎿`)
73 * wherever it runs, so a terminal that shows Claude Code shows the band; and
74 * the terminal variables say little (Windows Terminal's `WT_SESSION` is often
75 * not passed on to the process). `blocks` and `ascii` are for the person to
76 * pick when their font lacks a glyph. Surfaces other than the terminal draw
77 * unicode.
78 */
79export function chooseCharset(setting: string, env: CharsetEnv, surface: string): CharsetName {
80  if (setting === 'unicode' || setting === 'blocks' || setting === 'ascii') {
81    return setting
82  }
83
84  if (surface !== 'terminal') {
85    return 'unicode'
86  }
87
88  const locale = env.LC_ALL || env.LC_CTYPE || env.LANG
89
90  // `en_US.ISO-8859-1` names a charset; `C` and `POSIX` name none and say nothing of the terminal.
91  if (locale && locale.includes('.') && !/utf-?8/i.test(locale)) {
92    return 'ascii'
93  }
94
95  return 'unicode'
96}
97
98/**
99 * Cells a character takes in a terminal: 2 for East Asian wide and
100 * fullwidth characters and emoji, 0 for combining marks and joiners, else 1
101 * (tqdm's `_text_width`, which counts `east_asian_width` F and W as 2).
102 */
103export function cellsOf(char: string): number {
104  const code = char.codePointAt(0) ?? 0
105
106  if ((code >= 0x0300 && code <= 0x036f) || code === 0x200d || (code >= 0xfe00 && code <= 0xfe0f)) {
107    return 0
108  }
109
110  if (
111    (code >= 0x1100 && code <= 0x115f) ||
112    (code >= 0x2e80 && code <= 0xa4cf) ||
113    (code >= 0xac00 && code <= 0xd7a3) ||
114    (code >= 0xf900 && code <= 0xfaff) ||
115    (code >= 0xfe30 && code <= 0xfe4f) ||
116    (code >= 0xff00 && code <= 0xff60) ||
117    (code >= 0xffe0 && code <= 0xffe6) ||
118    (code >= 0x1f300 && code <= 0x1f64f) ||
119    (code >= 0x1f680 && code <= 0x1f6ff) ||
120    (code >= 0x1f7e0 && code <= 0x1f7eb) ||
121    (code >= 0x1f900 && code <= 0x1faff) ||
122    (code >= 0x20000 && code <= 0x3fffd)
123  ) {
124    return 2
125  }
126
127  return 1
128}
129
130/** The width of a text in terminal cells. */
131export function widthOf(text: string): number {
132  let width = 0
133
134  for (const char of text) {
135    width += cellsOf(char)
136  }
137
138  return width
139}
140
hooks/parse.ts 1029 lines
1import type { Bar, Parsed } from '../types'
2
3/** Only the end of an output is read: progress lives in its last updates. */
4const TAIL_CHARS = 64 * 1024
5
6const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g
7
8const MULTIPLIER: Record<string, number> = { '': 1, k: 1e3, K: 1e3, M: 1e6, G: 1e9, T: 1e12 }
9
10// `Epoch 3:  77%|███████▋  | 1840/2400 [00:05<00:01, 312.00it/s]`
11const TQDM =
12  /^(?:(.*?):\s*)?(\d{1,3})%\|[^|]*\|\s*(\d+(?:\.\d+)?[kKMGT]?)\/(\d+(?:\.\d+)?[kKMGT]?)\s*\[([\d:]+)<([\d:?]+)(?:,\s*([^\]]*))?\]/
13
14// `Scanning: 1840it [00:05, 312.00it/s]` (tqdm without a total)
15const TQDM_OPEN =
16  /^(?:(.*?):\s*)?(\d+(?:\.\d+)?[kKMGT]?)([A-Za-z]+)\s*\[(\d+:\d\d(?::\d\d)?)(?:,\s*([^\]]*))?\]/
17
18// `##progress step=3/7 label="Train" sub=1840/2400 sublabel="Epoch 3"`
19const PROTOCOL = /^##progress\b(.*)$/
20const PAIR = /(\w+)=(?:"([^"]*)"|(\S+))/g
21
22const ERROR =
23  /Traceback \(most recent call last\)|\b(?:[A-Z]\w*)?(?:Error|Exception)\b|\bFATAL\b|\b[Ff]atal error\b|^(?:error|ERROR)(?:\[[\w-]+\])?:|\bKilled\b|^panic:|Segmentation fault|command not found|No such file or directory|Permission denied|is not recognized as/
24
25// The line Claude Code appends to a background task's output when it ends: no output of the task's own.
26const EXIT_TRAILER = /^\[exited with code -?\d+\]$/
27
28const PERCENT = /(\d{1,3}(?:\.\d+)?)\s?%/g
29
30/** `1.84k` → 1840; NaN when it is no number. */
31export function toNumber(text: string): number {
32  const match = /^(\d+(?:\.\d+)?)([kKMGT]?)$/.exec(text.trim())
33
34  return match ? parseFloat(match[1] ?? '') * (MULTIPLIER[match[2] ?? ''] ?? 1) : NaN
35}
36
37/** `3/7` or `3` → `{ n, total }`; undefined when it is neither. */
38function fraction(text: string): { n: number; total?: number } | undefined {
39  const [left, right] = text.split('/')
40  const n = toNumber(left ?? '')
41
42  if (Number.isNaN(n)) {
43    return undefined
44  }
45
46  const total = right === undefined || right === '?' ? undefined : toNumber(right)
47
48  return { n, total: total === undefined || Number.isNaN(total) || total <= 0 ? undefined : total }
49}
50
51function firstPart(text: string | undefined): string | undefined {
52  const part = text?.split(',')[0]?.trim()
53
54  return part ? part : undefined
55}
56
57/** What follows the first comma-separated part: tqdm's postfix. */
58function restParts(text: string | undefined): string | undefined {
59  const rest = text?.split(',').slice(1).join(',').trim()
60
61  return rest ? rest : undefined
62}
63
64/** One tqdm line as a bar, or undefined when the line is no tqdm bar. */
65export function tqdmBar(line: string): Bar | undefined {
66  const known = TQDM.exec(line)
67
68  if (known) {
69    const n = toNumber(known[3] ?? '')
70    const total = toNumber(known[4] ?? '')
71
72    if (!Number.isNaN(n) && !Number.isNaN(total) && total > 0) {
73      return {
74        label: known[1]?.trim() || undefined,
75        n,
76        total,
77        eta: known[6] === '?' ? undefined : known[6],
78        rate: firstPart(known[7]),
79        postfix: restParts(known[7]),
80      }
81    }
82  }
83
84  const open = TQDM_OPEN.exec(line)
85
86  if (open) {
87    const n = toNumber(open[2] ?? '')
88
89    if (!Number.isNaN(n)) {
90      return { label: open[1]?.trim() || undefined, n, unit: open[3], rate: firstPart(open[5]), postfix: restParts(open[5]) }
91    }
92  }
93
94  return undefined
95}
96
97// cargo: `    Building [=============>     ] 120/300: serde, syn`
98const CARGO = /^Building \[[=> -]*\]\s*(\d+)\/(\d+)(?::\s*(.*))?$/
99// ninja: `[12/345] Building CXX object src/a.o`; CMake makefiles: `[ 45%] Building CXX object ...`
100const NINJA = /^\[(\d+)\/(\d+)\]\s+(.*)$/
101const CMAKE = /^\[\s*(\d{1,3})%\]\s+(.*)$/
102// git: `Receiving objects:  45% (450/1000), 1.20 MiB | 2.40 MiB/s`
103const GIT =
104  /^(?:remote:\s+)?((?:Enumerating|Counting|Compressing|Receiving|Writing|Unpacking) objects|Resolving deltas|Updating files|Checking out files|(?:Downloading|Uploading) LFS objects|Filtering content):\s+\d{1,3}% \((\d+)\/(\d+)\)(?:,\s*(.*))?$/
105// Keras: `Epoch 3/10`, then `32/100 [========>.....] - ETA: 2s - loss: 0.3` or `32/100 ━━━━━━━ 1s 15ms/step - loss: 0.3`
106const KERAS_EPOCH = /^Epoch (\d+)\/(\d+)$/
107const KERAS_STEP = /^(\d+)\/(\d+)\s+(?:\[[=>.]*\]|[─-╿]+)\s*(.*)$/
108// docker buildx (--progress=plain): `#7 [builder 3/5] RUN pip install -r requirements.txt`
109const DOCKER = /^#\d+ \[(?:[\w.-]+ )?(\d+)\/(\d+)\] (.*)$/
110// ffmpeg: `Duration: 00:05:00.00, ...` once, then `frame= 1234 ... time=00:00:41.13 ... speed=2.0x`
111const FFMPEG_DURATION = /Duration: (\d+):(\d\d):(\d\d(?:\.\d+)?)/
112const FFMPEG_TIME = /\btime=\s*(\d+):(\d\d):(\d\d(?:\.\d+)?)(?:.*?speed=\s*([\d.]+x))?/
113// OSC 9;4, the terminal progress sequence (ConEmu, Windows Terminal) cargo and others emit: ESC ] 9 ; 4 ; state ; pct BEL
114const OSC_PROGRESS = /\x1b\]9;4;(\d)(?:;(\d{1,3}))?(?:\x07|\x1b\\)/g
115
116// rsync --info=progress2: `  1,234,567  45%   12.34MB/s    0:00:05 (xfr#12, to-chk=88/100)`
117const RSYNC = /^\s*[\d,]+\s+(\d{1,3})%\s+(\S+\/s)\s+(\d+:\d\d:\d\d)(?:\s+\((.*)\))?/
118// wget --progress=bar:force: `ubuntu.iso      45%[=======>        ]  1.08G  2.31MB/s    eta 20s`
119const WGET_BAR = /^(.+?)\s+(\d{1,3})%\[[=> ]*\]\s+(\S+)\s+(\S+\/s)(?:\s+eta\s+(.+))?$/
120// wget dot style (its default in a log): `  4096K .......... .......... ..........  9% 2.31M 20s`
121const WGET_DOTS = /^\s*\d+[KMG]\s+[. ]+\s(\d{1,3})%\s+(\S+)\s+(\S+)$/
122// curl's meter: ` 45 10.0M   45 4608k    0     0  1234k      0  0:00:08  0:00:03  0:00:05 1234k`
123const CURL_METER =
124  /^\s*(\d{1,3})\s+\S+\s+\d{1,3}\s+\S+\s+\d{1,3}\s+\S+\s+(\S+)\s+\S+\s+([\d:-]+)\s+([\d:-]+)\s+([\d:-]+)\s+(\S+)$/
125// curl -#: `######################                        48.2%`
126const CURL_HASHES = /^#+\s+(\d{1,3}(?:\.\d+)?)%$/
127// pnpm --reporter=append-only: `Progress: resolved 123, reused 100, downloaded 20, added 0`
128const PNPM = /^Progress: resolved (\d+), reused (\d+), downloaded (\d+), added (\d+)(?:, (done))?$/
129// apt: `Progress: [ 45%] [#####.....]`; with -o APT::Status-Fd=1: `pmstatus:dpkg-exec:50.0:Running dpkg`
130const APT = /^Progress: \[\s*(\d{1,3})%\]/
131const APT_STATUS = /^(?:pm|dl)status:[^:]*:(\d+(?:\.\d+)?):(.*)$/
132
133/** `20s`, `1m5s`, `2h3m` (wget's estimates) → `00:20`, `01:05`, `2:03:00`. */
134function clockOf(text: string | undefined): string | undefined {
135  const match = text ? /^(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+)s)?$/.exec(text.trim()) : null
136
137  if (!match || (!match[1] && !match[2] && !match[3])) {
138    return undefined
139  }
140
141  const hours = Number(match[1] ?? 0)
142  const mm = String(Number(match[2] ?? 0)).padStart(2, '0')
143  const ss = String(Number(match[3] ?? 0)).padStart(2, '0')
144
145  return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
146}
147
148/** rsync, wget, curl, pnpm and apt: transfers and installs, one bar each. */
149function transferBar(line: string): Bar | undefined {
150  let match = RSYNC.exec(line)
151
152  if (match) {
153    return { label: 'rsync', n: Number(match[1]), total: 100, unit: '%', rate: match[2], eta: match[3], postfix: match[4] }
154  }
155
156  if ((match = WGET_BAR.exec(line))) {
157    return { label: match[1]?.trim(), n: Number(match[2]), total: 100, unit: '%', rate: match[4], eta: clockOf(match[5]) }
158  }
159
160  if ((match = WGET_DOTS.exec(line))) {
161    return { label: 'wget', n: Number(match[1]), total: 100, unit: '%', rate: `${match[2]}B/s`, eta: clockOf(match[3]) }
162  }
163
164  if ((match = CURL_METER.exec(line))) {
165    const left = match[5]
166
167    return {
168      label: 'curl',
169      n: Number(match[1]),
170      total: 100,
171      unit: '%',
172      rate: `${match[6]}B/s`,
173      eta: left && /^\d+:\d\d:\d\d$/.test(left) ? left : undefined,
174    }
175  }
176
177  if ((match = CURL_HASHES.exec(line))) {
178    return { label: 'curl', n: Math.min(100, Number(match[1])), total: 100, unit: '%' }
179  }
180
181  if ((match = PNPM.exec(line))) {
182    return {
183      label: 'pnpm',
184      n: Number(match[1]),
185      unit: ' resolved',
186      postfix: `reused ${match[2]}, downloaded ${match[3]}, added ${match[4]}${match[5] ? ', done' : ''}`,
187    }
188  }
189
190  if ((match = APT.exec(line))) {
191    return { label: 'apt', n: Number(match[1]), total: 100, unit: '%' }
192  }
193
194  if ((match = APT_STATUS.exec(line))) {
195    return { label: 'apt', n: Math.min(100, Number(match[1])), total: 100, unit: '%', postfix: match[2]?.trim() }
196  }
197
198  return undefined
199}
200
201/**
202 * Tool bars of one family nest: Keras's steps under its epochs. A bar of
203 * another family replaces them all: tools run one after another.
204 */
205function familyOf(label: string): string {
206  return label === 'Epoch' || label === 'Steps' ? 'keras' : label
207}
208
209function seconds(h: string | undefined, m: string | undefined, s: string | undefined): number {
210  return Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0)
211}
212
213/** Keras's tail, `1s 15ms/step - loss: 0.3 - acc: 0.9`: its rate, estimate and metrics. */
214function kerasTail(tail: string): Pick<Bar, 'rate' | 'eta' | 'postfix'> {
215  const parts = tail
216    .replace(/^-\s*/, '')
217    .split(/\s+-\s+/)
218    .map(part => part.trim())
219    .filter(Boolean)
220  const eta = parts.find(part => part.startsWith('ETA:'))?.slice(4).trim()
221  const rate = /(\d+(?:\.\d+)?m?s\/step)/.exec(parts[0] ?? '')?.[1]
222  const metrics = parts.filter(part => /^\w+: /.test(part) && !part.startsWith('ETA:'))
223
224  return {
225    rate,
226    eta: eta && /^\d+:\d\d/.test(eta) ? eta : undefined,
227    postfix: metrics.length > 0 ? metrics.join(' ') : undefined,
228  }
229}
230
231/**
232 * The progress lines of common tools besides tqdm, each by its own exact
233 * shape: a bare `n/m` anywhere in a line is never read, it is too often no
234 * progress at all. `context` carries ffmpeg's duration across lines.
235 */
236/** What a tool's progress spreads over several lines: ffmpeg's duration, a test run's outcomes, the file at hand. */
237export type ToolContext = {
238  duration?: number
239  tests?: { ok: number; fail: number }
240  pipFile?: string
241  copyFile?: string
242  /** go test: whether -v lines (one per test) were seen; the packages done. */
243  goTests?: boolean
244  packages?: { ok: number; fail: number }
245  /** cargo test: the tests its `running N tests` lines announced so far. */
246  announced?: number
247  /** The line a tool prints once its work is done, which completes its bar. */
248  lastDone?: { label: string; marker: string }
249  /** yt-dlp: the file its`Destination:` line names. */
250  download?: string
251  /** terraform: the plan's count of changes, and how many are done. */
252  plan?: { total: number; done: number }
253  /** kubectl rollout status: the deployment and its replicas. */
254  rollout?: { name: string; total: number }
255}
256
257// terraform (captured, v1.9.8): `Plan: 4 to add, 0 to change, 0 to destroy.`, then per resource
258// `time_sleep.a: Creating...`, `... Still creating... [10s elapsed]`, `... Creation complete after 35s [id=...]`
259const TF_PLAN = /^Plan: (\d+) to add, (\d+) to change, (\d+) to destroy\./
260const TF_DONE = /^(\S+): (?:Creation|Modifications|Destruction) complete after (\S+)/
261const TF_WORKING = /^(\S+): (?:Creating|Modifying|Destroying)\.\.\.|^(\S+): Still (?:creating|modifying|destroying)\.\.\. \[(\S+) elapsed\]/
262const TF_APPLIED = /^(?:Apply|Destroy) complete! Resources:/
263// kubectl rollout status (captured, v1.36): `Waiting for deployment "x" rollout to finish: 1 out of 3 new
264// replicas have been updated...`, `... 2 of 3 updated replicas are available...`,
265// `... 1 old replicas are pending termination...`, then `deployment "x" successfully rolled out`
266const KUBE_PROGRESS = /^Waiting for (?:deployment|daemon set|statefulset|rollout) "(.+?)" rollout to finish: (\d+) (?:out )?of (\d+) (new replicas have been updated|updated replicas are available|new pods have been updated|updated pods are available)/
267const KUBE_PENDING = /^Waiting for (?:deployment|daemon set|statefulset) "(.+?)" rollout to finish: (\d+) old replicas are pending termination/
268const KUBE_DONE = /^(?:deployment|daemon set|statefulset)(?:\.apps)? "(.+?)" successfully rolled out/
269
270// yt-dlp (captured, 2026.08.19): `[download] Destination: v.mp4`, then
271// `[download]  41.5% of   25.74MiB at    2.91MiB/s ETA 00:05`, last `[download] 100% of 25.74MiB in 00:00:09 at 2.79MiB/s`
272const YTDLP_FILE = /^\[download\] Destination: (.+)$/
273// (the last line has `in <time>` before `at <rate>`)
274const YTDLP = /^\[download\]\s+(\d{1,3}(?:\.\d+)?)% of\s+~?\s*([\d.]+\s*\w+)(?:\s+in\s+\S+)?(?:\s+at\s+(\S+\/s))?(?:\s+ETA\s+(\S+))?/
275// HandBrakeCLI (captured, 1.7.2): `Encoding: task 1 of 1, 36.33 % (159.40 fps, avg 159.40 fps, ETA 00h00m08s)`,
276// a log line sometimes glued after it
277const HANDBRAKE = /^Encoding: task (\d+) of (\d+), ([\d.]+) %(?: \(([\d.]+) fps, avg [\d.]+ fps, ETA (\d+)h(\d+)m(\d+)s\))?/
278// pv -f (captured, 1.8.5): ` 160MiB 0:00:04 [40.0MiB/s] [=========>    ]  53% ETA 0:00:03`, or with no
279// size known `80.0MiB 0:00:02 [40.0MiB/s] [ <=>     ]`
280const PV = /^([\d.]+\s*[KMGT]?i?B)\s+(\d+:\d\d:\d\d)\s+\[\s*([\d.]+\s*[KMGT]?i?B\/s)\]\s+\[[=> ]*\]\s+(\d{1,3})%(?:\s+ETA\s+(\d+:\d\d:\d\d))?/
281const PV_OPEN = /^([\d.]+\s*[KMGT]?i?B)\s+(\d+:\d\d:\d\d)\s+\[\s*([\d.]+\s*[KMGT]?i?B\/s)\]\s+\[\s*<=>\s*\]/
282// 7-Zip -bsp1 (captured, 23.01): `  51% 1 + data.bin`, updated in place with backspaces; `- name` when extracting
283const SEVEN_ZIP = /^(\d{1,3})%(?:\s+\d+)?\s+[+\-U=]\s+(.+)$/
284// GNU dd status=progress (captured, coreutils 9.4): `537919488 bytes (538 MB, 513 MiB) copied, 4 s, 133 MB/s`
285const DD = /^(\d+) bytes \(([\d.]+ \w+)(?:, [\d.]+ \w+)?\) copied, ([\d.]+) s, ([\d.]+ \w+\/s)/
286
287// Test runners, all captured with no terminal (and, for jest and vitest, as Claude Code runs them: with
288// CLAUDECODE set they switch to their terse agent reporters, which still print these lines).
289// jest 30: `PASS jt/a.test.js`, `FAIL jt/d.test.js`, then `Tests:       2 failed, 32 passed, 34 total`
290const JEST_SUITE = /^(PASS|FAIL)\s+(\S+)/
291const JEST_SUMMARY = /^Tests:\s+(?:(\d+) failed,\s*)?(?:(\d+) skipped,\s*)?(?:(\d+) todo,\s*)?(?:(\d+) passed,\s*)?(\d+) total/
292// vitest 5: ` ✓ vt/a.test.mjs (8 tests) 1264ms`, ` ❯ vt/d.test.mjs (9 tests | 1 failed) 1282ms`; with the
293// verbose reporter ` ✓ vt/a.test.mjs > a case 1 156ms`; then `      Tests  2 failed | 32 passed (34)`
294const VITEST_FILE = /^([✓❯×↓])\s+(\S+?\.[cm]?[jt]sx?)\s+\((\d+) tests?(?:\s*\|\s*(\d+) failed)?(?:\s*\|\s*(\d+) skipped)?\)/
295const VITEST_TEST = /^([✓×↓])\s+\S+\.[cm]?[jt]sx? > .+?(?:\s+\d+ms)?$/
296const VITEST_SUMMARY = /^Tests\s+(?:(\d+) failed\s*\|\s*)?(?:(\d+) passed)?(?:\s*\|\s*(\d+) skipped)?[^(]*\((\d+)\)$/
297// go test 1.25: with -v `--- PASS: TestCase1 (0.10s)` per test; always `ok  \tpkg\t1.108s` or `FAIL\tpkg\t1.159s`
298const GO_TEST = /^--- (PASS|FAIL|SKIP): (\S+)/
299const GO_PACKAGE = /^(ok|FAIL)\s+(\S+)\s+(?:[\d.]+s|\(cached\))/
300// cargo test 1.99: `running 11 tests`, `test case_10 ... ok` / `... FAILED` / `... ignored`
301const CARGO_RUNNING = /^running (\d+) tests?$/
302const CARGO_TEST = /^test (\S+) \.\.\. (ok|FAILED|ignored)/
303// dotnet test (SDK 10, VSTest), in the system's language: `Passed Caps.A.Case7 [115 ms]` or `Réussi ...`;
304// the summary `Failed!  - Failed:     2, Passed:    33, Skipped:     0, Total:    35, ...`
305// (`Échoué!  - échec :     2, réussite :    33, ignorée(s) :     0, total :    35, ...` in French)
306// (a test under a millisecond reads `[< 1 ms]`); with normal verbosity the run ends `Total tests: 35`
307const DOTNET_TEST = /^(\S+)\s+\S+\.\S+\s+\[<?\s*\d+(?:[.,]\d+)?\s*m?s\]$/
308const DOTNET_TOTAL = /^(?:Total tests|Nombre total de tests)\s*:\s*(\d+)$/
309const DOTNET_PASS = /^(Passed|Réussi|Bestanden|Superado|Riuscito)$/
310const DOTNET_FAIL = /^(Failed|Échoué|Fehlgeschlagen|Error)$/
311const DOTNET_SUMMARY = /^\S+!\s+-\s+[^:,]+:\s*(\d+),\s*[^:,]+:\s*(\d+),\s*[^:,]+:\s*(\d+),\s*[^:,]+:\s*(\d+),/
312
313function counted(context: ToolContext, ok: number, fail: number): { ok: number; fail: number } {
314  context.tests = { ok: (context.tests?.ok ?? 0) + ok, fail: (context.tests?.fail ?? 0) + fail }
315
316  return context.tests
317}
318
319/** Test runners: jest, vitest, go test, cargo test, dotnet test. Passes and failures go to the two-color bar. */
320function testBar(line: string, context: ToolContext): Bar | undefined {
321  let match = JEST_SUMMARY.exec(line)
322
323  if (match) {
324    const total = Number(match[5])
325
326    return { label: 'jest', n: total, total, ok: Number(match[4] ?? 0), fail: Number(match[1] ?? 0), unit: ' tests' }
327  }
328
329  if ((match = JEST_SUITE.exec(line)) && /\.(test|spec)\.[cm]?[jt]sx?$|__tests__/.test(match[2] ?? '')) {
330    const tests = counted(context, match[1] === 'PASS' ? 1 : 0, match[1] === 'FAIL' ? 1 : 0)
331
332    return { label: 'jest', n: tests.ok + tests.fail, ok: tests.ok, fail: tests.fail, unit: ' suites', postfix: match[2] }
333  }
334
335  if ((match = VITEST_SUMMARY.exec(line))) {
336    const total = Number(match[4])
337
338    return { label: 'vitest', n: total, total, ok: Number(match[2] ?? 0), fail: Number(match[1] ?? 0), unit: ' tests' }
339  }
340
341  if ((match = VITEST_FILE.exec(line))) {
342    const failed = Number(match[4] ?? 0)
343    const tests = counted(context, Number(match[3]) - failed - Number(match[5] ?? 0), failed)
344
345    return { label: 'vitest', n: tests.ok + tests.fail, ok: tests.ok, fail: tests.fail, unit: ' tests', postfix: match[2] }
346  }
347
348  if ((match = VITEST_TEST.exec(line))) {
349    const tests = counted(context, match[1] === '✓' ? 1 : 0, match[1] === '×' ? 1 : 0)
350
351    return { label: 'vitest', n: tests.ok + tests.fail, ok: tests.ok, fail: tests.fail, unit: ' tests' }
352  }
353
354  if ((match = GO_TEST.exec(line))) {
355    // Without -v go still prints failing tests: only a passing test's line says every test is listed.
356    if (match[1] !== 'FAIL') {
357      context.goTests = true
358    }
359
360    const tests = counted(context, match[1] === 'PASS' ? 1 : 0, match[1] === 'FAIL' ? 1 : 0)
361
362    return context.goTests
363      ? { label: 'go test', n: tests.ok + tests.fail, ok: tests.ok, fail: tests.fail, unit: ' tests', postfix: match[2] }
364      : undefined
365  }
366
367  if ((match = GO_PACKAGE.exec(line))) {
368    context.packages = {
369      ok: (context.packages?.ok ?? 0) + (match[1] === 'ok' ? 1 : 0),
370      fail: (context.packages?.fail ?? 0) + (match[1] === 'FAIL' ? 1 : 0),
371    }
372
373    // With -v the tests are counted; without, the packages are all there is.
374    const counts = context.goTests && context.tests ? context.tests : context.packages
375
376    return {
377      label: 'go test',
378      n: counts.ok + counts.fail,
379      ok: counts.ok,
380      fail: counts.fail,
381      unit: context.goTests ? ' tests' : ' packages',
382      postfix: match[2],
383    }
384  }
385
386  if ((match = CARGO_RUNNING.exec(line))) {
387    context.announced = (context.announced ?? 0) + Number(match[1])
388
389    return undefined
390  }
391
392  if ((match = CARGO_TEST.exec(line))) {
393    const tests = counted(context, match[2] === 'FAILED' ? 0 : 1, match[2] === 'FAILED' ? 1 : 0)
394
395    return { label: 'cargo test', n: tests.ok + tests.fail, total: context.announced, ok: tests.ok, fail: tests.fail, postfix: match[1] }
396  }
397
398  if ((match = DOTNET_SUMMARY.exec(line))) {
399    const total = Number(match[4])
400
401    return { label: 'dotnet test', n: total, total, ok: Number(match[2]), fail: Number(match[1]), unit: ' tests' }
402  }
403
404  if ((match = DOTNET_TOTAL.exec(line)) && context.tests) {
405    const total = Number(match[1])
406
407    return { label: 'dotnet test', n: total, total, ok: context.tests.ok, fail: context.tests.fail, unit: ' tests' }
408  }
409
410  if ((match = DOTNET_TEST.exec(line))) {
411    const word = match[1] ?? ''
412
413    if (DOTNET_PASS.test(word) || DOTNET_FAIL.test(word)) {
414      const tests = counted(context, DOTNET_PASS.test(word) ? 1 : 0, DOTNET_FAIL.test(word) ? 1 : 0)
415
416      return { label: 'dotnet test', n: tests.ok + tests.fail, ok: tests.ok, fail: tests.fail, unit: ' tests' }
417    }
418  }
419
420  return undefined
421}
422
423// Compute and builds, all captured with no terminal.
424// dask 2026.8 ProgressBar: `[####################                    ] | 52% Completed | 5.62 s`
425const DASK = /^\[[# ]+\] \| (\d{1,3})% Completed \| ([\d.]+ \w+)/
426// PySpark 3.5 (on by default): `[Stage 1:=================>         (59 + 2) / 100]`
427const SPARK = /^\[Stage (\d+):[=> ]*\((\d+) \+ (\d+)\) \/ (\d+)\]/
428// XGBoost 3.2 (verbose_eval): `[45]\ttrain-logloss:0.13592\ttrain-auc:0.99302\tvalid-logloss:0.26546\tvalid-auc:0.95584`
429// LightGBM 4.7 (log_evaluation): `[25]\ttrain's binary_logloss: 0.250063\ttrain's auc: 0.9664\tvalid's ...`
430const BOOSTING = /^\[(\d+)\]\t(\S+(?:'s \S+)?:\s?[-\d.e]+(?:\t.+)?)$/
431// webpack 5 --progress (or ProgressPlugin): `<s> [webpack.Progress] 65% building 1/1 entries 41/41 dependencies`
432const WEBPACK = /^<s> \[webpack\.Progress\] (\d{1,3})%\s*(.*)$/
433// Bazel 7: `[10 / 21] Executing genrule //:g12; 2s local ... (2 actions, 1 running)`, with --curses=yes
434// `[7 / 21] 2 actions running`; at the end `INFO: Build completed successfully, 21 total actions`
435const BAZEL = /^\[([\d,]+) \/ ([\d,]+)\]\s+(?:\[\w+\]\s+)?([^;]+?)(?:;.*|\s+\.\.\..*)?$/
436const BAZEL_DONE = /^INFO: Build completed successfully, ([\d,]+) total actions?/
437
438/** dask, Spark, XGBoost, LightGBM, webpack, Bazel. */
439function computeBar(line: string): Bar | undefined {
440  let match = DASK.exec(line)
441
442  if (match) {
443    return { label: 'dask', n: Number(match[1]), total: 100, unit: '%', postfix: match[2] }
444  }
445
446  if ((match = SPARK.exec(line))) {
447    return { label: `Spark stage ${match[1]}`, n: Number(match[2]), total: Number(match[4]), postfix: `${match[3]} running` }
448  }
449
450  if ((match = BOOSTING.exec(line))) {
451    const fields = (match[2] ?? '').split('\t')
452    const isLightGbm = fields.some(field => field.includes("'s "))
453    // The validation metrics say how training goes; the training ones only that it fits.
454    const shown = fields.filter(field => /^valid/.test(field))
455
456    return {
457      label: isLightGbm ? 'LightGBM' : 'XGBoost',
458      // XGBoost counts rounds from 0, LightGBM from 1.
459      n: Number(match[1]) + (isLightGbm ? 0 : 1),
460      unit: ' rounds',
461      postfix: (shown.length > 0 ? shown : fields).join(' ').replace(/'s /g, ' '),
462    }
463  }
464
465  if ((match = WEBPACK.exec(line))) {
466    return { label: 'webpack', n: Number(match[1]), total: 100, unit: '%', postfix: match[2]?.trim() || undefined }
467  }
468
469  if ((match = BAZEL.exec(line))) {
470    return {
471      label: 'Bazel',
472      n: Number(match[1]?.replace(/,/g, '')),
473      total: Number(match[2]?.replace(/,/g, '')),
474      postfix: match[3]?.trim(),
475    }
476  }
477
478  if ((match = BAZEL_DONE.exec(line))) {
479    const total = Number(match[1]?.replace(/,/g, ''))
480
481    return { label: 'Bazel', n: total, total }
482  }
483
484  return undefined
485}
486
487/** Media and files: yt-dlp, HandBrakeCLI, pv, 7-Zip, dd. */
488function mediaBar(line: string, context: ToolContext): Bar | undefined {
489  let match = YTDLP_FILE.exec(line)
490
491  if (match) {
492    context.download = match[1]?.trim()
493
494    return undefined
495  }
496
497  if ((match = YTDLP.exec(line))) {
498    const eta = match[4] && /^\d+:\d\d(:\d\d)?$/.test(match[4]) ? match[4] : undefined
499    const rate = match[3] && !match[3].startsWith('Unknown') ? match[3] : undefined
500
501    return {
502      label: context.download ?? 'yt-dlp',
503      n: Math.min(100, Number(match[1])),
504      total: 100,
505      unit: '%',
506      rate,
507      eta,
508      postfix: match[2]?.replace(/\s+/g, ''),
509    }
510  }
511
512  if ((match = HANDBRAKE.exec(line))) {
513    context.lastDone = { label: 'HandBrake', marker: 'Encode done!' }
514
515    const eta = match[5] ? `${Number(match[5])}:${match[6]}:${match[7]}` : undefined
516
517    return {
518      label: 'HandBrake',
519      n: Math.min(100, Number(match[3])),
520      total: 100,
521      unit: '%',
522      rate: match[4] ? `${match[4]} fps` : undefined,
523      eta,
524      postfix: match[2] !== '1' ? `task ${match[1]}/${match[2]}` : undefined,
525    }
526  }
527
528  if ((match = PV.exec(line))) {
529    return { label: 'pv', n: Number(match[4]), total: 100, unit: '%', rate: match[3], eta: match[5], postfix: match[1]?.replace(/\s+/g, '') }
530  }
531
532  if ((match = PV_OPEN.exec(line))) {
533    const done = bytesOf(match[1] ?? '')
534
535    return Number.isNaN(done) ? undefined : { label: 'pv', n: done, unit: 'B', rate: match[3] }
536  }
537
538  if ((match = SEVEN_ZIP.exec(line))) {
539    context.lastDone = { label: '7-Zip', marker: 'Everything is Ok' }
540
541    return { label: '7-Zip', n: Number(match[1]), total: 100, unit: '%', postfix: match[2]?.trim() }
542  }
543
544  // 7-Zip stops at 99 %, HandBrake at 99.94 %: their own word that the work is done completes the bar.
545  if (context.lastDone && line === context.lastDone.marker) {
546    return { label: context.lastDone.label, n: 100, total: 100, unit: '%' }
547  }
548
549  if ((match = DD.exec(line))) {
550    return { label: 'dd', n: Number(match[1]), unit: 'B', rate: match[4], postfix: `${match[3]} s` }
551  }
552
553  return undefined
554}
555
556/** terraform applies and kubectl rollouts: steps counted over lines. */
557function infraBar(line: string, context: ToolContext): Bar | undefined {
558  let match = TF_PLAN.exec(line)
559
560  if (match) {
561    context.plan = { total: Number(match[1]) + Number(match[2]) + Number(match[3]), done: 0 }
562
563    return { label: 'terraform', n: 0, total: context.plan.total || undefined, unit: ' changes' }
564  }
565
566  if ((match = TF_DONE.exec(line))) {
567    context.plan = { total: context.plan?.total ?? 0, done: (context.plan?.done ?? 0) + 1 }
568
569    return { label: 'terraform', n: context.plan.done, total: context.plan.total || undefined, unit: ' changes', postfix: `${match[1]} done` }
570  }
571
572  if ((match = TF_WORKING.exec(line))) {
573    const resource = match[1] ?? match[2]
574    const elapsed = match[3] ? ` ${match[3]}` : ''
575
576    return { label: 'terraform', n: context.plan?.done ?? 0, total: context.plan?.total || undefined, unit: ' changes', postfix: `${resource}${elapsed}` }
577  }
578
579  if (TF_APPLIED.test(line) && context.plan) {
580    return { label: 'terraform', n: context.plan.total, total: context.plan.total || undefined, unit: ' changes' }
581  }
582
583  if ((match = KUBE_PROGRESS.exec(line))) {
584    context.rollout = { name: match[1] ?? '', total: Number(match[3]) }
585
586    return {
587      label: `rollout ${match[1]}`,
588      n: Number(match[2]),
589      total: Number(match[3]),
590      postfix: match[4]?.startsWith('new') ? 'updated' : 'available',
591    }
592  }
593
594  if ((match = KUBE_PENDING.exec(line))) {
595    const total = context.rollout?.total
596
597    return { label: `rollout ${match[1]}`, n: total ?? 0, total, postfix: `${match[2]} old pending` }
598  }
599
600  if ((match = KUBE_DONE.exec(line))) {
601    const total = context.rollout?.total ?? 1
602
603    return { label: `rollout ${match[1]}`, n: total, total }
604  }
605
606  return undefined
607}
608
609// pytest: `tests/test_api.py ....F.  [ 45%]`, or with -v `tests/test_api.py::test_a PASSED  [ 45%]`
610const PYTEST = /^(\S+?\.py)\s+([.FEsxX]+)\s+\[\s*(\d{1,3})%\]$/
611const PYTEST_VERBOSE = /^(\S+?\.py)::\S+\s+(PASSED|FAILED|ERROR|SKIPPED|XFAIL|XPASS)\s+\[\s*(\d{1,3})%\]$/
612// pip --progress-bar on: `   ---------------------------------------- 12.9/12.9 MB 10.9 MB/s eta 0:00:00`
613const PIP = /^\s*[-━╸╺]+\s+([\d.]+)\/([\d.]+)\s+([kMG]?B)\s+([\d.]+\s*[kMG]?B\/s)\s+eta\s+(\d+:\d\d:\d\d)$/
614const PIP_FILE = /^\s*Downloading (\S+)(?: \([\d.]+ [kMG]?B\))?$/
615// robocopy: a file's line, `\t  New File\t\t 400.0 m\tbig.bin` (its words translated, its
616// leading tab trimmed by the time it is read), then `  12.5%`
617const ROBOCOPY_FILE = /^[^\t]+\t+\s*[\d.]+\s*[kmgt]?\t(.+)$/i
618const ROBOCOPY_PERCENT = /^\s*(\d{1,3}(?:\.\d+)?)%$/
619// rclone: `Transferred:   	    1.234 GiB / 3.000 GiB, 41%, 12.345 MiB/s, ETA 2m23s (xfr#3/10)`
620const RCLONE = /^(?:Transferred:\s+)?(?:\d{4}\/\d\d\/\d\d [\d:]+\s+)?\s*([\d.]+ ?\w*B) \/ ([\d.]+ ?\w*B), (\d{1,3})%, ([\d.]+ ?\w*B\/s), ETA (\S+)/
621// aws s3 cp/sync: `Completed 256.0 MiB/~1.0 GiB (12.3 MiB/s) with ~3 file(s) remaining`
622const AWS_BYTES = /^Completed ([\d.]+ \w+)\/~?([\d.]+ \w+) \(([\d.]+ \w+\/s)\) with ~?(\d+) file\(s\) remaining/
623const AWS_FILES = /^Completed (\d+) file\(s\) with ~?(\d+) file\(s\) remaining/
624// Gradle's rich console: `<=====--------> 37% EXECUTING [12s]` (unverified; its plain console draws none)
625const GRADLE = /<[=\-]+>\s*(\d{1,3})%\s+(EXECUTING|CONFIGURING|INITIALIZING|WAITING)(?:\s*\[([^\]]+)\])?/
626
627const SIZE_UNITS: Record<string, number> = {
628  B: 1,
629  KB: 1e3,
630  MB: 1e6,
631  GB: 1e9,
632  TB: 1e12,
633  KIB: 1024,
634  MIB: 1024 ** 2,
635  GIB: 1024 ** 3,
636  TIB: 1024 ** 4,
637}
638
639/** `256.0 MiB` → bytes; NaN when it is no size. */
640function bytesOf(text: string): number {
641  const match = /^([\d.]+)\s*([KMGT]?i?B)(?:ytes)?$/i.exec(text.trim())
642  const unit = match?.[2]?.toUpperCase() ?? ''
643
644  return match && SIZE_UNITS[unit] !== undefined ? parseFloat(match[1] ?? '') * (SIZE_UNITS[unit] ?? 1) : NaN
645}
646
647/** Test runs and copies: pytest, pip downloads, robocopy, rclone, aws s3, Gradle. */
648function runBar(line: string, context: ToolContext): Bar | undefined {
649  let match = PYTEST.exec(line)
650
651  if (match) {
652    const marks = match[2] ?? ''
653    const fail = (marks.match(/[FE]/g) ?? []).length
654
655    context.tests = { ok: (context.tests?.ok ?? 0) + marks.length - fail, fail: (context.tests?.fail ?? 0) + fail }
656
657    return { label: 'pytest', n: Number(match[3]), total: 100, unit: '%', ok: context.tests.ok, fail: context.tests.fail, postfix: match[1] }
658  }
659
660  if ((match = PYTEST_VERBOSE.exec(line))) {
661    const isFail = match[2] === 'FAILED' || match[2] === 'ERROR'
662
663    context.tests = { ok: (context.tests?.ok ?? 0) + (isFail ? 0 : 1), fail: (context.tests?.fail ?? 0) + (isFail ? 1 : 0) }
664
665    return { label: 'pytest', n: Number(match[3]), total: 100, unit: '%', ok: context.tests.ok, fail: context.tests.fail, postfix: match[1] }
666  }
667
668  if ((match = PIP_FILE.exec(line))) {
669    context.pipFile = match[1]
670
671    return undefined
672  }
673
674  if ((match = PIP.exec(line))) {
675    return {
676      label: context.pipFile ?? 'pip',
677      n: parseFloat(match[1] ?? ''),
678      total: parseFloat(match[2] ?? '') || undefined,
679      rate: match[4],
680      eta: match[5],
681      postfix: match[3],
682    }
683  }
684
685  if ((match = ROBOCOPY_FILE.exec(line))) {
686    context.copyFile = match[1]?.trim()
687
688    return undefined
689  }
690
691  if (context.copyFile && (match = ROBOCOPY_PERCENT.exec(line))) {
692    return { label: 'robocopy', n: Math.min(100, Number(match[1])), total: 100, unit: '%', postfix: context.copyFile }
693  }
694
695  if ((match = RCLONE.exec(line))) {
696    return {
697      label: 'rclone',
698      n: Number(match[3]),
699      total: 100,
700      unit: '%',
701      rate: match[4],
702      eta: clockOf(match[5]),
703      postfix: `${match[1]} / ${match[2]}`,
704    }
705  }
706
707  if ((match = AWS_BYTES.exec(line))) {
708    const done = bytesOf(match[1] ?? '')
709    const total = bytesOf(match[2] ?? '')
710
711    if (!Number.isNaN(done) && !Number.isNaN(total) && total > 0) {
712      return { label: 'aws s3', n: done, total, rate: match[3], postfix: `${match[4]} file(s) left` }
713    }
714  }
715
716  if ((match = AWS_FILES.exec(line))) {
717    const done = Number(match[1])
718
719    return { label: 'aws s3', n: done, total: done + Number(match[2]), unit: ' files' }
720  }
721
722  if ((match = GRADLE.exec(line))) {
723    return { label: 'Gradle', n: Number(match[1]), total: 100, unit: '%', postfix: [match[2], match[3]].filter(Boolean).join(' ') }
724  }
725
726  return undefined
727}
728
729export function toolBar(line: string, context: ToolContext): Bar | undefined {
730  const run =
731    runBar(line, context) ?? testBar(line, context) ?? infraBar(line, context) ?? mediaBar(line, context) ?? computeBar(line)
732
733  if (run) {
734    return run
735  }
736
737  let match = CARGO.exec(line)
738
739  if (match) {
740    return { label: 'Building', n: Number(match[1]), total: Number(match[2]), postfix: match[3]?.trim() || undefined }
741  }
742
743  if ((match = GIT.exec(line))) {
744    const speed = match[4]?.split('|').pop()?.trim()
745
746    return {
747      label: match[1],
748      n: Number(match[2]),
749      total: Number(match[3]),
750      rate: speed && /\/s$/.test(speed) ? speed : undefined,
751    }
752  }
753
754  if ((match = NINJA.exec(line))) {
755    return { label: 'Build', n: Number(match[1]), total: Number(match[2]), postfix: match[3]?.trim() }
756  }
757
758  if ((match = CMAKE.exec(line))) {
759    return { label: 'Build', n: Math.min(100, Number(match[1])), total: 100, unit: '%', postfix: match[2]?.trim() }
760  }
761
762  if ((match = KERAS_EPOCH.exec(line))) {
763    return { label: 'Epoch', n: Number(match[1]) - 1, total: Number(match[2]) }
764  }
765
766  if ((match = KERAS_STEP.exec(line))) {
767    return { label: 'Steps', n: Number(match[1]), total: Number(match[2]), ...kerasTail(match[3] ?? '') }
768  }
769
770  if ((match = DOCKER.exec(line))) {
771    return { label: 'Docker build', n: Number(match[1]), total: Number(match[2]), postfix: match[3]?.trim() }
772  }
773
774  const transfer = transferBar(line)
775
776  if (transfer) {
777    return transfer
778  }
779
780  const duration = FFMPEG_DURATION.exec(line)
781
782  if (duration) {
783    context.duration = seconds(duration[1], duration[2], duration[3])
784  }
785
786  if (context.duration && (match = FFMPEG_TIME.exec(line))) {
787    return {
788      label: 'ffmpeg',
789      n: Math.round(Math.min(context.duration, seconds(match[1], match[2], match[3]))),
790      total: Math.round(context.duration),
791      unit: 's',
792      rate: match[4],
793    }
794  }
795
796  return undefined
797}
798
799type Protocol = { main?: Bar; sub?: Bar; declared?: 'ok' | 'fail'; message?: string }
800
801function applyProtocol(state: Protocol, rest: string): void {
802  const pairs: Record<string, string> = {}
803
804  for (const match of rest.matchAll(PAIR)) {
805    pairs[match[1] ?? ''] = match[2] ?? match[3] ?? ''
806  }
807
808  const hasSub = pairs.sub !== undefined || pairs.sublabel !== undefined
809
810  if (pairs.step !== undefined) {
811    const step = fraction(pairs.step)
812
813    if (step) {
814      if (!hasSub && state.main?.n !== step.n) {
815        state.sub = undefined
816      }
817
818      state.main = { ...state.main, ...step, total: step.total }
819    }
820  }
821
822  if (pairs.pct !== undefined && !Number.isNaN(parseFloat(pairs.pct))) {
823    state.main = { ...state.main, n: Math.min(100, parseFloat(pairs.pct)), total: 100, unit: '%' }
824  }
825
826  if (pairs.label !== undefined) {
827    state.main = { n: 0, ...state.main, label: pairs.label }
828  }
829
830  if (pairs.sub !== undefined) {
831    const sub = fraction(pairs.sub)
832
833    if (sub) {
834      state.sub = { ...state.sub, ...sub, total: sub.total }
835    }
836  }
837
838  if (pairs.sublabel !== undefined) {
839    state.sub = { n: 0, ...state.sub, label: pairs.sublabel }
840  }
841
842  const target = hasSub ? state.sub : state.main
843
844  if (target) {
845    if (pairs.rate !== undefined) target.rate = pairs.rate
846    if (pairs.eta !== undefined) target.eta = pairs.eta
847    if (pairs.unit !== undefined) target.unit = pairs.unit
848    if (pairs.ok !== undefined && !Number.isNaN(Number(pairs.ok))) target.ok = Number(pairs.ok)
849    if (pairs.fail !== undefined && !Number.isNaN(Number(pairs.fail))) target.fail = Number(pairs.fail)
850  }
851
852  if (pairs.done === 'ok' || pairs.done === 'fail') {
853    state.declared = pairs.done
854  }
855
856  if (pairs.msg !== undefined) {
857    state.message = pairs.msg
858  }
859}
860
861function isComplete(bar: Bar): boolean {
862  return bar.total !== undefined && bar.n >= bar.total
863}
864
865/**
866 * Reads a task's output: the progress protocol first, then tqdm bars, then a
867 * bare percentage; the last plain line; the last line that looks like an error.
868 */
869export function parse(text: string): Parsed {
870  const tail = text.length > TAIL_CHARS ? text.slice(-TAIL_CHARS) : text
871  let terminal: Bar | undefined
872
873  for (const osc of tail.matchAll(OSC_PROGRESS)) {
874    const state = osc[1]
875
876    terminal =
877      state === '1' || state === '2' || state === '4'
878        ? { n: Math.min(100, Number(osc[2] ?? 0)), total: 100, unit: '%' }
879        : state === '3'
880          ? { n: 0 }
881          : undefined
882  }
883
884  // 7-Zip rewrites its line with backspaces, not carriage returns: a run of them ends an update too.
885  const segments = tail.replace(OSC_PROGRESS, '\n').replace(ANSI, '').replace(/\x08+/g, '\r').split(/\r\n|\n|\r/)
886  const context: ToolContext = {}
887  // ffmpeg prints its duration once, at the start: look for it before the tail too.
888  const duration = FFMPEG_DURATION.exec(text.slice(0, 64 * 1024))
889
890  if (duration) {
891    context.duration = seconds(duration[1], duration[2], duration[3])
892  }
893  const parsed: Parsed = {}
894  const protocol: Protocol = {}
895  let hasProtocol = false
896  // tqdm bars by label, in the order first seen, with the segment of their last update.
897  const bars = new Map<string, { bar: Bar; at: number; isTqdm: boolean }>()
898  const toolKeys = new Set<string>()
899  let percent: Bar | undefined
900
901  segments.forEach((raw, at) => {
902    const line = raw.trim()
903
904    if (line === '' || EXIT_TRAILER.test(line)) {
905      return
906    }
907
908    const directive = PROTOCOL.exec(line)
909
910    if (directive) {
911      hasProtocol = true
912      applyProtocol(protocol, directive[1] ?? '')
913
914      return
915    }
916
917    const tqdm = tqdmBar(line)
918    const bar = tqdm ?? toolBar(line, context)
919
920    if (bar) {
921      const key = bar.label ?? ''
922
923      // Tools run one after another (a build, then a push): a new tool's bar replaces
924      // the last one's. Only Keras nests its own, steps under epochs.
925      if (!tqdm) {
926        const family = familyOf(key)
927
928        for (const tool of toolKeys) {
929          if (familyOf(tool) !== family) {
930            bars.delete(tool)
931            toolKeys.delete(tool)
932          }
933        }
934
935        toolKeys.add(key)
936      }
937
938      bars.set(key, { bar, at, isTqdm: tqdm !== undefined })
939
940      return
941    }
942
943    if (ERROR.test(line)) {
944      parsed.error = line.slice(0, 300)
945    }
946
947    let last: RegExpExecArray | null = null
948
949    for (const match of line.matchAll(PERCENT)) {
950      last = match as RegExpExecArray
951    }
952
953    if (last) {
954      percent = { n: Math.min(100, parseFloat(last[1] ?? "0")), total: 100, unit: '%' }
955    }
956
957    parsed.lastLine = line.slice(0, 300)
958  })
959
960  if (hasProtocol) {
961    parsed.main = protocol.main
962    parsed.isExact = protocol.main !== undefined
963    parsed.sub = protocol.sub
964    parsed.declared = protocol.declared
965    parsed.lastLine = protocol.message ?? parsed.lastLine
966  } else if (bars.size > 0) {
967    const entries = [...bars.values()]
968    const latest = entries.reduce((a, b) => (b.at > a.at ? b : a))
969    // A finished bar that is not the latest is a step already behind (pip, sequential loops).
970    const active = entries.filter(one => one === latest || !isComplete(one.bar))
971    const main = active[0] ?? latest
972    const others = active.filter(one => one !== main)
973
974    parsed.main = main.bar
975    parsed.isExact = main.isTqdm
976    parsed.sub = others.length > 0 ? others.reduce((a, b) => (b.at > a.at ? b : a)).bar : undefined
977
978    // Keras's `Epoch 2/2` opens the epoch: once its steps are all done, so is the epoch.
979    if (parsed.main.label === 'Epoch' && parsed.sub?.label === 'Steps' && isComplete(parsed.sub)) {
980      parsed.main = { ...parsed.main, n: Math.min(parsed.main.n + 1, parsed.main.total ?? Infinity) }
981    }
982  } else if (terminal) {
983    parsed.main = terminal
984  } else if (percent) {
985    parsed.main = percent
986  }
987
988  return parsed
989}
990
991const BACKGROUND = /with ID: (\S+?)\.?\s+Output is being written to: (.+?\.output)\b/
992
993/** The task id and output file a backgrounded shell call reports, if it does. */
994export function backgroundOf(
995  text: string | undefined,
996  record?: { backgroundTaskId?: string; backgroundOutputPath?: string },
997): { taskId: string; outputFile?: string } | undefined {
998  const match = text ? BACKGROUND.exec(text) : null
999  const taskId = record?.backgroundTaskId ?? match?.[1]
1000
1001  if (!taskId) {
1002    return undefined
1003  }
1004
1005  return { taskId, outputFile: record?.backgroundOutputPath ?? match?.[2] }
1006}
1007
1008const NOTIFICATION = /<task-notification>([\s\S]*?)<\/task-notification>/g
1009
1010export type Notification = { taskId: string; status: string; exitCode?: number }
1011
1012/** The background-task notifications a conversation row carries. */
1013export function notificationsOf(text: string): Notification[] {
1014  const found: Notification[] = []
1015
1016  for (const block of text.matchAll(NOTIFICATION)) {
1017    const body = block[1] ?? ''
1018    const taskId = /<task-id>([^<]+)<\/task-id>/.exec(body)?.[1]?.trim()
1019    const status = /<status>([^<]+)<\/status>/.exec(body)?.[1]?.trim() ?? 'completed'
1020    const code = /exit code (-?\d+)/.exec(body)?.[1]
1021
1022    if (taskId) {
1023      found.push({ taskId, status, exitCode: code === undefined ? undefined : Number(code) })
1024    }
1025  }
1026
1027  return found
1028}
1029
hooks/render.ts 459 lines
1import type { Bar, Track } from '../types'
2import { CHARSETS, cellsOf, type Glyphs, widthOf } from './charset'
3
4const UNICODE: Glyphs = CHARSETS.unicode
5/** Width of the block that sweeps an indeterminate bar. */
6const SWEEP = 4
7
8/** A spinner frame; `interval` is the time per frame, shorter while the task goes faster. */
9export function spinnerAt(now: number, glyphs: Glyphs = UNICODE, interval = 100): string {
10  return glyphs.spinner[Math.floor(now / interval) % glyphs.spinner.length] ?? '*'
11}
12
13/** No move of the main bar for this long: the spinner slows to say so. */
14const STILL_MS = 5000
15
16/**
17 * The spinner's time per frame, after alive-progress: faster while the task
18 * goes faster than its average, slower while it lags or stands still.
19 */
20export function spinInterval(track: Track, now: number): number {
21  if (track.lastSample && now - track.lastSample.at > STILL_MS) {
22    return 300
23  }
24
25  return Math.min(300, Math.max(60, 100 / (track.paceRatio ?? 1)))
26}
27
28/** `75000` → `01:15`, `3_725_000` → `1:02:05`. */
29export function duration(ms: number): string {
30  const total = Math.max(0, Math.floor(ms / 1000))
31  const hours = Math.floor(total / 3600)
32  const minutes = Math.floor((total % 3600) / 60)
33  const seconds = total % 60
34  const mm = String(minutes).padStart(2, '0')
35  const ss = String(seconds).padStart(2, '0')
36
37  return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
38}
39
40/** `01:15` or `1:02:05` → milliseconds; undefined when it is neither. */
41export function durationMs(text: string | undefined): number | undefined {
42  if (!text || !/^\d+(:\d\d){1,2}$/.test(text)) {
43    return undefined
44  }
45
46  return text.split(':').reduce((sum, part) => sum * 60 + Number(part), 0) * 1000
47}
48
49/** Counts as tqdm prints them: plain up to 99,999, then `1.2M`. */
50export function count(n: number): string {
51  if (n < 100_000) {
52    return Number.isInteger(n) ? String(n) : n.toFixed(1)
53  }
54
55  for (const [size, suffix] of [[1e12, 'T'], [1e9, 'G'], [1e6, 'M'], [1e3, 'k']] as const) {
56    if (n >= size) {
57      return `${(n / size).toFixed(n / size < 10 ? 2 : 1)}${suffix}`
58    }
59  }
60
61  return String(n)
62}
63
64export function fractionOf(bar: Bar): number | undefined {
65  return bar.total === undefined ? undefined : Math.min(1, Math.max(0, bar.n / bar.total))
66}
67
68/**
69 * The bar's cells: filled to its fraction, a partial cell at its edge as
70 * fine as the charset allows (tqdm's `Bar`), or, with no total, a block
71 * sweeping back and forth so a running task visibly moves.
72 */
73export function cells(bar: Bar, width: number, now: number, glyphs: Glyphs = UNICODE): string {
74  const fraction = fractionOf(bar)
75
76  if (fraction === undefined) {
77    const sweep = Math.min(SWEEP, width)
78    const travel = Math.max(1, width - sweep)
79    const step = Math.floor(now / 80) % (travel * 2)
80    const at = step < travel ? step : travel * 2 - step
81
82    return glyphs.empty.repeat(at) + glyphs.full.repeat(sweep) + glyphs.empty.repeat(Math.max(0, width - at - sweep))
83  }
84
85  const steps = glyphs.partials.length + 1
86  const filled = Math.round(fraction * width * steps)
87  const full = Math.min(width, Math.floor(filled / steps))
88  const partial = full < width ? (glyphs.partials[(filled % steps) - 1] ?? '') : ''
89
90  return glyphs.full.repeat(full) + partial + glyphs.empty.repeat(Math.max(0, width - full - (partial ? 1 : 0)))
91}
92
93/** The figures after a bar: `1840/2400  77%  312it/s`. */
94export function figures(bar: Bar): string {
95  const fraction = fractionOf(bar)
96  const parts: string[] = []
97
98  if (bar.unit === '%' && bar.total === 100) {
99    parts.push(`${Math.floor(bar.n)}%`)
100  } else if (fraction === undefined) {
101    parts.push(`${count(bar.n)}${bar.unit ?? ''}`)
102  } else {
103    parts.push(`${count(bar.n)}/${count(bar.total as number)}`, `${Math.floor(fraction * 100)}%`)
104  }
105
106  if (bar.rate) {
107    parts.push(bar.rate)
108  }
109
110  if (bar.ok !== undefined || bar.fail !== undefined) {
111    parts.push([bar.ok !== undefined ? `ok ${count(bar.ok)}` : '', bar.fail ? `fail ${count(bar.fail)}` : ''].filter(Boolean).join(' '))
112  }
113
114  if (bar.postfix) {
115    parts.push(clip(bar.postfix, 32))
116  }
117
118  return parts.join('  ')
119}
120
121/**
122 * Ended well by its exit code, yet its main bar stopped short of its total:
123 * alive-progress's `(!)`. It waits for the person, as a failure does.
124 */
125export function isShortOfTotal(track: Track): boolean {
126  // Only a source that always prints its final state (tqdm, ##progress) can say so: other tools stop
127  // short of their total on success (Spark's last line is 48/50, webpack's 99 %). And within 1 % of
128  // the total is done (ffmpeg's last time= a frame before the duration).
129  return (
130    track.status === 'done' &&
131    track.isExact === true &&
132    track.main?.total !== undefined &&
133    track.main.n < track.main.total * 0.99
134  )
135}
136
137/** The line a finished task keeps: `200/200 in 00:20 (9.9it/s)`, alive-progress's receipt. */
138export function receiptOf(track: Track): string {
139  if (!track.main || track.endedAt === undefined) {
140    return ''
141  }
142
143  const bar = track.main
144  const amount = bar.total === undefined ? `${count(bar.n)}${bar.unit ?? ''}` : bar.unit === '%' ? `${Math.floor(bar.n)}%` : `${count(bar.n)}/${count(bar.total)}`
145
146  return `${isShortOfTotal(track) ? '(!) ' : ''}${amount}${bar.rate ? ` (${bar.rate})` : ''}`
147}
148
149export type NotifyMode = 'failures' | 'always' | 'off'
150
151/** A task this long is worth a notification when it ends well, in `always` mode. */
152export const NOTIFY_SUCCESS_AFTER_MS = 60_000
153
154/**
155 * The native notification a task's end is worth (tqdm.contrib's
156 * Telegram/Slack/Discord idea, through the person's own channel), if any:
157 * `failures` tells of a failure or an early end; `always` also of a stop and
158 * of a success that took a minute or more.
159 */
160export function endNotice(track: Track, mode: NotifyMode): { title: string; text: string } | undefined {
161  if (mode === 'off' || track.status === 'running' || track.isUnconfirmed) {
162    return undefined
163  }
164
165  const took = duration((track.endedAt ?? track.startedAt) - track.startedAt)
166
167  if (track.status === 'failed') {
168    return { title: 'Task failed', text: `${track.label} · ${failureOf(track)}` }
169  }
170
171  if (isShortOfTotal(track)) {
172    return { title: 'Task ended early', text: `${track.label} · ${failureOf(track)}` }
173  }
174
175  if (mode !== 'always') {
176    return undefined
177  }
178
179  if (track.status === 'killed') {
180    return { title: 'Task stopped', text: `${track.label} · after ${took}` }
181  }
182
183  if ((track.endedAt ?? 0) - track.startedAt >= NOTIFY_SUCCESS_AFTER_MS) {
184    const receipt = receiptOf(track)
185
186    return { title: 'Task finished', text: `${track.label} · ${receipt ? `${receipt} ` : ''}in ${took}` }
187  }
188
189  return undefined
190}
191
192/** No output after this long: most often a buffered stdout, which looks exactly like a frozen task. */
193export const SILENT_START_MS = 15_000
194
195export function silentStartHint(track: Track, now: number): string | undefined {
196  if (track.kind !== 'bg' || track.status !== 'running' || track.size > 0 || now - track.startedAt < SILENT_START_MS) {
197    return undefined
198  }
199
200  return 'no output yet · buffered? python -u / PYTHONUNBUFFERED=1, stdbuf -oL, git --progress, docker --progress=plain'
201}
202
203/** Cuts a text to `width` terminal cells, wide characters counted as two (tqdm's `disp_trim`). */
204export function clip(text: string, width: number, ellipsis = UNICODE.ellipsis): string {
205  if (width <= 0) {
206    return ''
207  }
208
209  if (widthOf(text) <= width) {
210    return text
211  }
212
213  const room = width - widthOf(ellipsis)
214  let out = ''
215  let used = 0
216
217  for (const char of text) {
218    const size = cellsOf(char)
219
220    if (used + size > room) {
221      break
222    }
223
224    out += char
225    used += size
226  }
227
228  return out + ellipsis
229}
230
231/**
232 * A bar line in parts, each drawn in its own color: the label, the cells done
233 * (ok), the cells failed, the cells left, then the figures. Without failures
234 * the whole bar is in `done`, its partial cell included.
235 */
236export type BarParts = { head: string; done: string; failed: string; left: string; tail: string }
237
238export function barParts(bar: Bar, prefix: string, width: number, now: number, glyphs: Glyphs = UNICODE): BarParts {
239  const label = clip(bar.label ?? '', 24, glyphs.ellipsis)
240  const figuresText = figures(bar)
241  const head = `${prefix}${label ? `${label} ` : ''}`
242  const room = width - widthOf(head) - widthOf(figuresText) - 2
243  const barWidth = Math.max(6, Math.min(40, room))
244  const tail = clip(`  ${figuresText}`, Math.max(0, width - widthOf(head) - barWidth), glyphs.ellipsis)
245  const fraction = fractionOf(bar)
246
247  if (!bar.fail || fraction === undefined || bar.n <= 0) {
248    return { head, done: cells(bar, barWidth, now, glyphs), failed: '', left: '', tail }
249  }
250
251  // enlighten's subcounters: the filled part split by outcome, failures always at least a cell.
252  const filled = Math.round(fraction * barWidth)
253  // Out of the outcomes counted, when both are (a test run's percentage is no count of them).
254  const outcomes = bar.ok !== undefined ? bar.ok + bar.fail : bar.n
255  const failedCells = Math.min(filled, Math.max(1, Math.round((filled * bar.fail) / Math.max(1, outcomes))))
256
257  return {
258    head,
259    done: glyphs.full.repeat(filled - failedCells),
260    failed: glyphs.full.repeat(failedCells),
261    left: glyphs.empty.repeat(barWidth - filled),
262    tail,
263  }
264}
265
266/** One bar line: `  Epoch 3 ██████▋░░░  1840/2400  77%  312it/s`. */
267export function barLine(bar: Bar, prefix: string, width: number, now: number, glyphs: Glyphs = UNICODE): string {
268  const parts = barParts(bar, prefix, width, now, glyphs)
269
270  return clip(`${parts.head}${parts.done}${parts.failed}${parts.left}${parts.tail}`, width, glyphs.ellipsis)
271}
272
273export const STALL_MS = 60_000
274
275/** A last line saying the work goes on quietly (a link, a compile, a download) earns a longer silence. */
276export const BUSY_STALL_MS = 180_000
277const BUSY = /\b(compil|link|build|bundl|transpil|generat|optimi[sz]|download|fetch|install|resolv|pull|push|upload|index|migrat|train|wait)/i
278
279/**
280 * Silent for longer than a task of its kind would be: a minute, or three after
281 * a line saying the work goes on quietly (Cline's "hot" grace, after the
282 * same words). Silence is measured from the output's last change.
283 */
284export function isStalled(track: Track, now: number): boolean {
285  const limit = track.lastLine && BUSY.test(track.lastLine) ? BUSY_STALL_MS : STALL_MS
286
287  const isRead = track.kind === 'bg' || (track.kind === 'fg' && track.outputFile !== undefined)
288
289  return track.status === 'running' && isRead && now - track.lastChangeAt > limit
290}
291
292/** What an exit code says beyond failure: a timeout, or the signal that ended the process (128 + n). */
293export function exitMeaning(code: number | undefined): string | undefined {
294  switch (code) {
295    case 124:
296      return 'timed out'
297    case 130:
298      return 'interrupted (Ctrl+C)'
299    case 137:
300      return 'killed (SIGKILL, often out of memory)'
301    case 139:
302      return 'crashed (segmentation fault)'
303    case 143:
304      return 'terminated (SIGTERM)'
305    default:
306      return undefined
307  }
308}
309
310/**
311 * The time on a header: elapsed while running, with an estimate of the total
312 * once the main bar says (tqdm's own remaining time, smoothed by its moving
313 * average, where it printed one; else from the fraction done); the run's
314 * length once it ended.
315 */
316export function timing(track: Track, now: number): string {
317  const end = track.endedAt ?? now
318  const elapsed = end - track.startedAt
319
320  if (track.status !== 'running') {
321    return duration(elapsed)
322  }
323
324  const remaining = durationMs(track.main?.eta)
325
326  if (remaining !== undefined && remaining > 0) {
327    return `${duration(elapsed)} / ~${duration(elapsed + remaining)}`
328  }
329
330  const fraction = track.main ? fractionOf(track.main) : undefined
331  const from = track.progressFrom
332
333  // The pace since progress was first seen, leaving out the start-up before it (tqdm #967).
334  if (fraction !== undefined && from && fraction < 1 && fraction - from.fraction >= 0.02 && now - from.at >= 2000) {
335    const left = ((now - from.at) / (fraction - from.fraction)) * (1 - fraction)
336
337    return `${duration(elapsed)} / ~${duration(elapsed + left)}`
338  }
339
340  if (fraction !== undefined && fraction >= 0.02 && fraction < 1) {
341    return `${duration(elapsed)} / ~${duration(elapsed / fraction)}`
342  }
343
344  return duration(elapsed)
345}
346
347export function glyph(track: Track, now: number, glyphs: Glyphs = UNICODE): string {
348  switch (track.status) {
349    case 'done':
350      return isShortOfTotal(track) ? glyphs.warning : glyphs.done
351    case 'failed':
352      return glyphs.failed
353    case 'killed':
354      return glyphs.killed
355    default:
356      return spinnerAt(now, glyphs, spinInterval(track, now))
357  }
358}
359
360/** Failed and killed first (they wait for the person), then running, newest first, then done. */
361export function ordered(tracks: readonly Track[]): Track[] {
362  const rank = (track: Track) => (hasDetailRow(track) ? 0 : track.status === 'running' ? 1 : 2)
363
364  return [...tracks].sort((a, b) => rank(a) - rank(b) || b.startedAt - a.startedAt)
365}
366
367/** The footer's summary: `⠙ Train 43% · ✗ Index docs`, most urgent first. */
368export function summaryOf(tracks: readonly Track[], now: number, glyphs: Glyphs = UNICODE, most = 3): string {
369  const urgent = ordered(tracks).filter(one => one.status === 'running' || hasDetailRow(one))
370  const parts = urgent.slice(0, most).map(one => {
371    const fraction = one.status === 'running' && one.main ? fractionOf(one.main) : undefined
372    const percent = fraction === undefined ? '' : ` ${Math.floor(fraction * 100)}%`
373
374    return `${glyph(one, now, glyphs)} ${clip(one.label, 18, glyphs.ellipsis)}${percent}`
375  })
376
377  if (urgent.length > most) {
378    parts.push(`+${urgent.length - most}`)
379  }
380
381  return parts.join(' · ')
382}
383
384/**
385 * The footer's mode label, right of the hint line, never cut: one task by its
386 * name (`⠋ Train 43%`), several by count (`⠋ 3 tasks · 43% · ✗ 1`), the
387 * percentage of the most urgent running one, and the failures waiting.
388 */
389export function modeLabelOf(tracks: readonly Track[], now: number, glyphs: Glyphs = UNICODE): string {
390  const running = ordered(tracks).filter(one => one.status === 'running')
391  const waiting = tracks.filter(hasDetailRow).length
392
393  if (running.length === 0 && waiting === 0) {
394    return ''
395  }
396
397  const lead = running[0]
398  const fraction = lead?.main ? fractionOf(lead.main) : undefined
399  const percent = fraction === undefined ? '' : `${Math.floor(fraction * 100)}%`
400  const parts: string[] = []
401
402  if (lead) {
403    const icon = glyph(lead, now, glyphs)
404
405    parts.push(running.length === 1 ? `${icon} ${clip(lead.label, 20, glyphs.ellipsis)}${percent ? ` ${percent}` : ''}` : `${icon} ${running.length} tasks`)
406
407    if (running.length > 1 && percent) {
408      parts.push(percent)
409    }
410  }
411
412  if (waiting > 0) {
413    parts.push(`${glyphs.failed} ${waiting}`)
414  }
415
416  return parts.join(' · ')
417}
418
419/**
420 * A running track with no main bar draws a sweeping one in its place; one
421 * that ended well says it all in its header's receipt; one that ended badly
422 * keeps its bar where it stopped.
423 */
424export function hasMainRow(track: Track): boolean {
425  if (track.status === 'running') {
426    return true
427  }
428
429  return track.main !== undefined && (track.status !== 'done' || isShortOfTotal(track))
430}
431
432/** Whether the track waits for the person to dismiss it. */
433export function hasDetailRow(track: Track): boolean {
434  return track.status === 'failed' || track.status === 'killed' || isShortOfTotal(track)
435}
436
437/** How many rows a track draws: header, main, sub, then the failure's line. */
438export function rowsOf(track: Track): number {
439  return 1 + (hasMainRow(track) ? 1 : 0) + (track.sub ? 1 : 0) + (hasDetailRow(track) ? 1 : 0)
440}
441
442/** What a failed or killed track says under its bars. */
443export function failureOf(track: Track): string {
444  const meaning = exitMeaning(track.exitCode)
445  const code = track.exitCode === undefined ? '' : `exit code ${track.exitCode}${meaning ? ` (${meaning})` : ''}`
446  const reason = track.error ?? track.lastLine
447
448  if (track.status === 'killed') {
449    return reason ? `stopped · ${reason}` : 'stopped'
450  }
451
452  // A clean exit short of the total: only an error line says more, not just any last line.
453  if (isShortOfTotal(track) && track.main) {
454    return `exited ${track.exitCode ?? 0} at ${count(track.main.n)}/${count(track.main.total ?? 0)}${track.error ? ` · ${track.error}` : ''}`
455  }
456
457  return [code, reason].filter(Boolean).join(' · ') || 'failed'
458}
459
types/index.d.ts 85 lines
1/** One progress bar: `n` of `total` (total absent when unknown). */
2export type Bar = {
3  label?: string
4  n: number
5  total?: number
6  unit?: string
7  rate?: string
8  eta?: string
9  /** What follows the rate: tqdm's postfix (`loss=0.231`), Keras metrics, cargo's crates. */
10  postfix?: string
11  /** Of `n`, how many succeeded and failed (`##progress ok=12 fail=2`): the bar is drawn in two colors. */
12  ok?: number
13  fail?: number
14}
15
16/** What the main loop is doing, to tell a long think from a stuck one. */
17export type Activity = {
18  isTurnRunning: boolean
19  /** The last time a tool call started or ended, or a turn started. */
20  lastAt: number
21  /** Tool calls running now: while one runs, the turn is waiting on it, not stuck. */
22  runningTools: number
23}
24
25/** What the parser reads out of a task's output. */
26export type Parsed = {
27  main?: Bar
28  sub?: Bar
29  lastLine?: string
30  error?: string
31  /** Set by a `##progress done=ok|fail` line. */
32  declared?: 'ok' | 'fail'
33  /**
34   * The main bar comes from a source that always prints its final state (tqdm, `##progress`):
35   * only then does a clean exit short of the total say the work stopped early.
36   */
37  isExact?: boolean
38}
39
40export type TrackStatus = 'running' | 'done' | 'failed' | 'killed'
41
42/** One task followed by the band. */
43export type Track = {
44  id: string
45  label: string
46  /**
47   * `bg`: a background shell whose output file is polled; `fg`: a foreground
48   * call; `agent`: a subagent from `$.agent.list()`, the tasks list's own rows.
49   */
50  kind: 'bg' | 'fg' | 'agent'
51  outputFile?: string
52  startedAt: number
53  endedAt?: number
54  status: TrackStatus
55  exitCode?: number
56  main?: Bar
57  sub?: Bar
58  lastLine?: string
59  error?: string
60  /** Last time the output changed (file mtime), for stall detection. */
61  lastChangeAt: number
62  size: number
63  /** The output file is past what one read may copy: progress is no longer parsed. */
64  isTooBig?: boolean
65  /** The main bar's source always prints its final state (see Parsed.isExact). */
66  isExact?: boolean
67  /** When the main bar was first seen, and how far along: the estimate leaves out the start-up before it. */
68  progressFrom?: { at: number; fraction: number }
69  /** The main bar at the last poll that moved it, for the recent pace. */
70  lastSample?: { at: number; fraction: number }
71  /** Recent pace over average pace, 0.25 to 4: the spinner turns faster or slower with it (alive-progress). */
72  paceRatio?: number
73  /**
74   * Ended because the session stopped listing it at a turn's end, before its
75   * notification said how: shown as done until the notification corrects it.
76   */
77  isUnconfirmed?: boolean
78}
79
80declare module 'claude-code' {
81  interface PluginState {
82    'tqdm-claude': { tracks: Track[]; now: number; second: number; activity: Activity }
83  }
84}
85