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

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.
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.
##progress step=3/7 label="Train" sub=1840/2400. Claude is told about it, so the scripts it writes report progress.✗, 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.! no output for 02:10. Silence at start gets a hint: output is probably buffered (python -u).##progress ok=30 fail=10 splits the bar.TaskStop, or when the session stops listing it at a turn's end.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.
In /config (or pluginConfigs in settings):
| Option | Default | |
|---|---|---|
charset | auto | unicode, blocks (code page 437, if your font shows boxes), or ascii. |
footer | mode | Where 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. |
agents | true | Follow subagents too. |
notify | failures | Native notification when a task fails or ends early; always adds stops and successes of a minute or more; off. |
stuck | true | Warn when a turn has had no tool call for 3 minutes: Claude may be stuck. |
/tqdm: lists followed tasks with their status and output file./tqdm clear: drops finished and failed tasks from the band.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.
| Hook | What 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.append | Reads <task-notification> rows: how a task ended, with its exit code. |
classic.Stop | At a turn's end, ends tasks the session no longer lists. |
prompt.submit, turn.complete | Whether a turn is running. |
classic.SessionStart | The session's id and folder, to find its tasks folder. |
ui.render SessionMode | The footer label, after Claude Code's own mode labels. |
$.ui.notify | Tells you of a failure through your notification channel. |
$.clock.every | Reads changed output files once a second; animates at ~4 fps while something runs. |
$.agent.list() | Mirrors the tasks list's subagents. |
ui.render AbovePrompt | Draws the band. |
ui.render PromptHint | Adds the summary after Claude Code's own hint line. |
prompt.compose | Tells 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).
Nothing leaves your machine. The mod makes no network request (no $.http), starts no process (no $.process) and writes no file.
What it reads:
$.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.session.append): only rows holding a <task-notification>, for a task's id, status and exit code. Other rows pass through untouched and unread.prompt.submit): only the moment a turn starts, for the stuck-turn warning. The prompt's text is not read, kept or changed.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.$.agent.list, classic.Stop): subagents' description and status, and the ids of background tasks still running at a turn's end.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:
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.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.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.
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.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.
hooks/register.tsx 940 lines1import { 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}
940hooks/charset.ts 140 lines1/**
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}
140hooks/parse.ts 1029 lines1import 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}
1029hooks/render.ts 459 lines1import 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}
459types/index.d.ts 85 lines1/** 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