Live progress bars above the prompt for every download Claude Code's shells run (curl, wget, Python, PowerShell, pip, git..., in WSL too), one row per download.

Two small mods for Claude Code that draw in the band above the prompt.
| Mod | What it does | Platforms |
|---|---|---|
| context-bar | A color-coded bar showing how much of the context window is used before auto-compact, split by token type (system prompt, tools, MCP, memory files, skills, messages). Hover a segment for its size; hover the percent for the full legend. | Terminal and desktop app, any OS |
| download-bar | One live progress row per download that Claude Code's shells run (curl, wget, Python, PowerShell, pip, git, npm..., WSL too): bytes, percent and ETA when the size is known, speed. Past four downloads at once it shows a summary over the three fastest. | Windows (including downloads inside WSL) |
In a Claude Code terminal session, run:
/plugin install context-bar --marketplace Ayushmaniar/claude-mods
/plugin install download-bar --marketplace Ayushmaniar/claude-mods
Answer y to add the marketplace, then pick a scope (user scope makes it apply in every session, including the desktop app's Code tab).
Rows appear on their own when a shell command starts downloading. The /dlbar command controls it:
/dlbar shows the status and help/dlbar test [count] simulates downloads so you can see the bar without touching the network/dlbar on / /dlbar off turns it on or off/dlbar lookup on|off toggles the size lookup: a HEAD request to the download's URL, sent only once the download is already running, to get a percent and ETAHow it works: a small PowerShell sampler (sampler/sampler.ps1) watches processes descended from Claude Code's shells that hold an internet connection, and reads their write counters (a download writes what it receives). Downloads inside WSL are followed by sampler/wsl-sampler.sh, run in the distro the shell's wsl.exe used, which reads the same facts from /proc.
Each mod is a folder under plugins/. To run one from source:
claude --plugin-dir plugins/context-bar
Validate and test:
claude plugin validate plugins/download-bar
claude plugin test plugins/download-bar
Claude Code writes the SDK typings into each mod's .claude-plugin/types/ when the mod loads; after that, tsc -p plugins/<mod> type-checks it.
MIT
hooks/register.tsx 623 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { DownloadRow } from '../types'
5import {
6 advance,
7 barText,
8 DETAIL_ROWS,
9 describe,
10 downloadSizes,
11 extractUrls,
12 fileLabel,
13 formatBytes,
14 formatDuration,
15 isLauncherWrapper,
16 isWslCommand,
17 layout,
18 MAX_ROWS,
19 noneFinished,
20 outputName,
21 padLabel,
22 parseReport,
23 parseWslTick,
24 restartsAfter,
25 wslCommand,
26 wslReport,
27} from './lib'
28import type { Description, Finished, Report, RowState, WslLaunch, WslTick } from './lib'
29
30const rowsAtom = atom({ plugin: 'download-bar', key: 'rows' } as const, [])
31const summaryAtom = atom({ plugin: 'download-bar', key: 'summary' } as const, null)
32const isHidden = atom({ plugin: 'download-bar', key: 'isHidden' } as const, false)
33
34// Flip on to trace the sampler's reports and each row's life to DEBUG_LOG, in the mod's own folder.
35const DEBUG = false
36const DEBUG_LOG = 'download-bar-debug.log'
37const RECENT_CALLS = 20
38// A script naming more URLs than this is a crawler or a list, not one download.
39const MAX_SCRIPT_URLS = 20
40const MAX_SCRIPT_BYTES = 512 * 1024
41
42// A WSL sampler stops once its distro has had no wsl.exe from the shells and no rows this long...
43const WSL_IDLE_MS = 15_000
44// ...and one that ends on its own is not started again for this long.
45const WSL_RETRY_MS = 10_000
46
47type ShellCall = { tool: 'Bash' | 'PowerShell'; command: string; at: number }
48
49// One distro's sampler (sampler/wsl-sampler.sh) and the rows it follows.
50type WslSource = {
51 /** The distro as `keyOf` names it: lower case, '' for the default until its name is known. */
52 key: string
53 slot: number
54 /** Its name, from the sampler's first line. */
55 distro: string
56 rows: Map<number, RowState>
57 /** Linux processes WSL started for a shell's wsl.exe; kept after that wsl.exe exits. */
58 ours: Set<number>
59 idleSince: number | null
60 isStopping: boolean
61}
62
63// Module state: a hot reload starts it over along with a fresh sampler.
64const S = {
65 rows: new Map<number, RowState>(),
66 wsl: new Map<string, WslSource>(),
67 // The commands the shells' running wsl.exe processes run.
68 launches: [] as { distro: string; command: string }[],
69 slots: new Map<string, number>(),
70 wslRetryAt: new Map<string, number>(),
71 defaultDistro: '',
72 rx: 0,
73 sims: [] as DownloadRow[],
74 calls: [] as ShellCall[],
75 // The current batch: from the first row shown until the panel empties.
76 finished: noneFinished as Finished,
77 isSummaryMode: false,
78 pinned: [] as number[],
79 published: '',
80 buffer: '',
81 restarts: 0,
82 isSimulating: false,
83 log: [] as string[],
84}
85
86async function dbg($: EngineInterface, line: string) {
87 if (!DEBUG) return
88 S.log.push(`${new Date(await $.clock.now()).toISOString().slice(11, 23)} ${line}`)
89 if (S.log.length > 120) S.log.shift()
90 await $.fs.write(`${$.plugin.root}/${DEBUG_LOG}`, `${S.log.join('\n')}\n`).catch(() => undefined)
91}
92
93async function settings($: EngineInterface) {
94 const [on, lookup] = await Promise.all([$.store.get('on'), $.store.get('lookup')])
95 return { isOn: on !== false, isLookup: lookup !== false }
96}
97
98// A shell process that is itself the downloader (PowerShell's WebClient, say)
99// has Claude Code's launcher as its command line: name it from the latest
100// command Claude gave that tool before the process connected.
101function labelFor(exe: string, cmd: string, firstSeen: number): Description {
102 if (!isLauncherWrapper(cmd)) return describe(exe, cmd)
103
104 const tool = /^(pwsh|powershell)/i.test(exe) ? 'PowerShell' : 'Bash'
105 const call = [...S.calls].reverse().find(c => c.tool === tool && c.at <= firstSeen + 1000)
106 if (call === undefined) return { label: tool, urls: [] }
107
108 const urls = extractUrls(call.command)
109 const output = outputName(call.command)
110 if (output !== null) return { label: output, urls }
111 if (urls.length > 0) return { label: fileLabel(urls[0] as string), urls }
112 return { label: tool, urls }
113}
114
115type Head = { size: number; type: string } | null
116
117// HEAD with curl, redirects followed: the final hop's size and type.
118async function lookUpHead($: EngineInterface, url: string): Promise<Head> {
119 try {
120 const run = await $.process.run(['curl.exe', '-sIL', '--max-time', '8', url], { timeoutMs: 12000 })
121 const lengths = [...run.stdout.matchAll(/^content-length:\s*(\d+)/gim)].map(m => Number(m[1]))
122 const types = [...run.stdout.matchAll(/^content-type:\s*([^\r\n;]+)/gim)].map(m => m[1] as string)
123 const size = lengths[lengths.length - 1]
124 return size !== undefined && size > 0 ? { size, type: types[types.length - 1] ?? '' } : null
125 } catch {
126 return null
127 }
128}
129
130async function setTotal($: EngineInterface, pid: number, total: number) {
131 for (const rows of [S.rows, ...[...S.wsl.values()].map(source => source.rows)]) {
132 const row = rows.get(pid)
133 if (row === undefined) continue
134 rows.set(pid, { ...row, total })
135 await publish($)
136 return
137 }
138}
139
140// URLs on the command line are the download: every one must answer a size.
141// Runs only once a process is already downloading, so never for a command
142// you denied.
143async function lookUpCommandUrls($: EngineInterface, pid: number, urls: string[]) {
144 const heads = await Promise.all(urls.map(url => lookUpHead($, url)))
145 await dbg($, `size pid=${pid} ${JSON.stringify(heads)}`)
146 if (heads.some(h => h === null)) return
147 await setTotal($, pid, heads.reduce((sum, h) => sum + (h as { size: number }).size, 0))
148}
149
150// A script's URLs are read from its source; only those answering like files
151// (big enough, not a page or an API reply) count toward its size.
152async function lookUpScriptUrls($: EngineInterface, pid: number, script: string) {
153 let source: string
154 try {
155 const stat = await $.fs.stat(script)
156 if (stat.kind !== 'file' || stat.size > MAX_SCRIPT_BYTES) return
157 source = await $.fs.read(script)
158 } catch {
159 await dbg($, `script unreadable pid=${pid} ${script}`)
160 return
161 }
162 const urls = extractUrls(source, MAX_SCRIPT_URLS + 1).filter(u => !/^https?:\/\/(localhost|127\.|\[::1\])/i.test(u))
163 await dbg($, `script pid=${pid} ${script} urls=${urls.length}`)
164 if (urls.length === 0 || urls.length > MAX_SCRIPT_URLS) return
165
166 const total = downloadSizes(await Promise.all(urls.map(url => lookUpHead($, url))))
167 await dbg($, `script size pid=${pid} ${total}`)
168 if (total !== null) await setTotal($, pid, total)
169}
170
171// A resumed download's file already held what an earlier run wrote: count
172// that too, so its percent starts where the file is, not at zero.
173async function countResumed($: EngineInterface, pid: number, file: string) {
174 let size: number
175 try {
176 const stat = await $.fs.stat(file)
177 if (stat.kind !== 'file') return
178 size = stat.size
179 } catch {
180 await dbg($, `resume file unreadable pid=${pid} ${file}`)
181 return
182 }
183 for (const rows of [S.rows, ...[...S.wsl.values()].map(source => source.rows)]) {
184 const row = rows.get(pid)
185 if (row === undefined) continue
186 const earlier = size - (row.writes - row.base)
187 await dbg($, `resume pid=${pid} file=${size} earlier=${earlier}`)
188 if (earlier <= 0) return
189 rows.set(pid, { ...row, base: row.base - earlier })
190 await publish($)
191 return
192 }
193}
194
195function toRow(r: RowState): DownloadRow {
196 return {
197 id: r.pid,
198 label: r.label,
199 bytes: r.writes - r.base,
200 total: r.total,
201 rate: r.rate,
202 status: r.status,
203 exitCode: r.exitCode,
204 }
205}
206
207async function publish($: EngineInterface) {
208 const real = [...S.rows.values(), ...[...S.wsl.values()].flatMap(source => [...source.rows.values()])]
209 .filter(r => r.isVisible)
210 .sort((a, b) => a.firstSeen - b.firstSeen)
211 .map(toRow)
212 const all = [...real, ...S.sims]
213
214 // A batch ends when the panel empties; its tallies and mode go with it.
215 if (all.length === 0) {
216 S.finished = noneFinished
217 S.isSummaryMode = false
218 S.pinned = []
219 }
220
221 const shown = layout(all, S.pinned, S.finished, S.isSummaryMode)
222 S.pinned = shown.pinned
223 S.isSummaryMode = shown.summary !== null
224
225 const text = JSON.stringify(shown)
226 if (text === S.published) return
227 S.published = text
228 await update($, rowsAtom, () => shown.rows)
229 await update($, summaryAtom, () => shown.summary)
230}
231
232// Shown rows that left the panel still count in the batch's summary.
233function retire(rows: Iterable<RowState>) {
234 for (const old of rows) {
235 if (!old.isVisible) continue
236 S.finished = {
237 done: S.finished.done + (old.status === 'failed' ? 0 : 1),
238 failed: S.finished.failed + (old.status === 'failed' ? 1 : 0),
239 bytes: S.finished.bytes + (old.writes - old.base),
240 total: S.finished.total + (old.total ?? 0),
241 hasUnknownTotal: S.finished.hasUnknownTotal || old.total === null,
242 }
243 }
244}
245
246// Moves one source's rows on by its report: the Windows sampler's, or a distro's.
247async function step($: EngineInterface, before: Map<number, RowState>, report: Report, isLookup: boolean): Promise<Map<number, RowState>> {
248 const now = await $.clock.now()
249 const { rows, appeared } = advance(before, report, now, labelFor)
250
251 for (const row of rows.values()) {
252 const old = before.get(row.pid)
253 if (old === undefined) await dbg($, `seen pid=${row.pid} ${row.exe} label=${row.label} script=${row.script} cmd=${row.cmd.slice(-100)}`)
254 else if (old.status !== row.status) await dbg($, `${row.status} pid=${row.pid} bytes=${row.writes - row.base} exit=${row.exitCode} visible=${row.isVisible}`)
255 }
256
257 retire([...before].filter(([pid]) => !rows.has(pid)).map(([, old]) => old))
258
259 if (appeared.length > 0) await update($, isHidden, () => false)
260 for (const row of appeared) {
261 await dbg($, `show pid=${row.pid} label=${row.label} urls=${row.urls.length}`)
262 if (row.resumeFile !== null) void countResumed($, row.pid, row.resumeFile)
263 if (!isLookup) continue
264 if (row.urls.length > 0) void lookUpCommandUrls($, row.pid, row.urls)
265 else if (row.script !== null) void lookUpScriptUrls($, row.pid, row.script)
266 }
267 return rows
268}
269
270async function onReport($: EngineInterface, line: string) {
271 const report = parseReport(line)
272 if (report === null) {
273 await dbg($, `sampler: ${line.slice(0, 200)}`)
274 return
275 }
276 S.rx = report.rx
277 const { isOn, isLookup } = await settings($)
278 if (!isOn) {
279 S.rows.clear()
280 for (const source of S.wsl.values()) source.rows.clear()
281 await followWsl($, [])
282 await publish($)
283 return
284 }
285
286 S.rows = await step($, S.rows, report, isLookup)
287 await followWsl($, report.wsl ?? [])
288 await publish($)
289}
290
291function keyOf(distro: string): string {
292 return (distro === '' ? S.defaultDistro : distro).toLowerCase()
293}
294
295// Starts a sampler in each distro a shell's wsl.exe runs in, and stops one
296// whose distro has gone quiet.
297async function followWsl($: EngineInterface, launches: WslLaunch[]) {
298 const now = await $.clock.now()
299 S.launches = launches.filter(l => l.x === undefined).flatMap(l => wslCommand(l.cmd) ?? [])
300 for (const { distro } of S.launches) {
301 const key = keyOf(distro)
302 if (!S.wsl.has(key) && now >= (S.wslRetryAt.get(key) ?? 0)) startWslSampler($, key, distro)
303 }
304 for (const source of S.wsl.values()) {
305 if (source.rows.size > 0 || S.launches.some(l => keyOf(l.distro) === source.key)) source.idleSince = null
306 else if (source.idleSince === null) source.idleSince = now
307 else if (now - source.idleSince >= WSL_IDLE_MS) source.isStopping = true
308 }
309}
310
311// The sampler's first line names the distro: a default one is filed under its name from then on.
312function learnDistro(source: WslSource, distro: string) {
313 source.distro = distro
314 if (source.key !== '' || distro === '') return
315 S.defaultDistro = distro
316 S.wsl.delete('')
317 source.key = distro.toLowerCase()
318 if (S.wsl.has(source.key)) source.isStopping = true
319 else S.wsl.set(source.key, source)
320}
321
322async function onWslTick($: EngineInterface, source: WslSource, tick: WslTick) {
323 const { isOn, isLookup } = await settings($)
324 if (!isOn || source.isStopping) return
325 for (const root of source.ours) if (!tick.roots.has(root)) source.ours.delete(root)
326 const report = wslReport(tick, source.slot, source.distro || source.key, S.rx, (root, cmd) => {
327 if (source.ours.has(root)) return true
328 if (!S.launches.some(l => keyOf(l.distro) === source.key && isWslCommand(cmd, l.command))) return false
329 source.ours.add(root)
330 return true
331 })
332 source.rows = await step($, source.rows, report, isLookup)
333 await publish($)
334}
335
336function startWslSampler($: EngineInterface, key: string, distro: string) {
337 const slot = S.slots.get(key) ?? S.slots.size
338 S.slots.set(key, slot)
339 const source: WslSource = { key, slot, distro, rows: new Map(), ours: new Set(), idleSince: null, isStopping: false }
340 S.wsl.set(key, source)
341 void (async () => {
342 let buffer = ''
343 let block: string[] = []
344 try {
345 const child = $.process.spawn({
346 argv: [
347 'wsl.exe',
348 ...(distro === '' ? [] : ['-d', distro]),
349 '--cd',
350 `${$.plugin.root}/sampler`.replace(/\//g, '\\'),
351 '-e',
352 'bash',
353 'wsl-sampler.sh',
354 ],
355 })
356 await dbg($, `wsl sampler start ${distro || '(default)'}`)
357 // Leaving the loop ends the sampler.
358 for await (const chunk of child) {
359 if (source.isStopping) break
360 if (chunk.stream === 'stderr') {
361 await dbg($, `wsl sampler stderr: ${chunk.text.slice(0, 300)}`)
362 continue
363 }
364 buffer += chunk.text
365 const lines = buffer.split(/\r?\n/)
366 buffer = lines.pop() ?? ''
367 for (const line of lines) {
368 if (line.startsWith('D\t')) learnDistro(source, line.slice(2))
369 else if (line === 'T') block = []
370 else if (line === 'E') await onWslTick($, source, parseWslTick(block))
371 else block.push(line)
372 }
373 }
374 } catch (error) {
375 await dbg($, `wsl sampler failed: ${String(error)}`)
376 }
377 await dbg($, `wsl sampler end ${source.distro || '(default)'} stopping=${source.isStopping}`)
378 if (S.wsl.get(source.key) === source) S.wsl.delete(source.key)
379 if (!source.isStopping) S.wslRetryAt.set(source.key, (await $.clock.now()) + WSL_RETRY_MS)
380 retire(source.rows.values())
381 await publish($)
382 })()
383}
384
385function startSampler($: EngineInterface) {
386 void (async () => {
387 // Half a line left by the sampler that ended would be glued onto the new one's first report.
388 S.buffer = ''
389 const began = await $.clock.now()
390 try {
391 const child = $.process.spawn({
392 argv: [
393 'powershell',
394 '-NoProfile',
395 '-NonInteractive',
396 '-ExecutionPolicy',
397 'Bypass',
398 '-WindowStyle',
399 'Hidden',
400 '-File',
401 `${$.plugin.root}/sampler/sampler.ps1`,
402 ],
403 })
404 for await (const chunk of child) {
405 if (chunk.stream === 'stderr') {
406 await dbg($, `sampler stderr: ${chunk.text.slice(0, 300)}`)
407 continue
408 }
409 S.buffer += chunk.text
410 const lines = S.buffer.split(/\r?\n/)
411 S.buffer = lines.pop() ?? ''
412 for (const line of lines) if (line.trim() !== '') await onReport($, line)
413 }
414 } catch (error) {
415 await dbg($, `sampler failed: ${String(error)}`)
416 }
417 const restarts = restartsAfter(S.restarts, (await $.clock.now()) - began)
418 if (restarts !== null) {
419 S.restarts = restarts
420 $.clock.after(5000, () => startSampler($))
421 }
422 })()
423}
424
425// Pretend downloads, to see the bar without touching the network: two by
426// default (one of a known size, one not), or `count` of them to see the
427// summary that takes over past MAX_ROWS.
428function simulate($: EngineInterface, startedAt: number, count: number) {
429 const MB = 1024 * 1024
430 const plans =
431 count <= 2
432 ? [
433 { id: -1, label: 'sample-model.safetensors', total: 240 * MB, rate: 18 * MB, isSized: true },
434 { id: -2, label: 'fetch_dataset.py', total: 60 * MB, rate: 7 * MB, isSized: false },
435 ]
436 : Array.from({ length: count }, (_, i) => ({
437 id: -(i + 1),
438 label: `shard-${String(i + 1).padStart(2, '0')}.parquet`,
439 total: (20 + ((i * 37) % 90)) * MB,
440 rate: (3 + ((i * 13) % 17)) * MB,
441 isSized: true,
442 }))
443 S.isSimulating = true
444 const timer = $.clock.every(250, async () => {
445 const elapsed = ((await $.clock.now()) - startedAt) / 1000
446 const before = S.sims
447 S.sims = plans.flatMap(p => {
448 const finishesAt = p.total / p.rate
449 if (elapsed >= finishesAt + 1.5) return []
450 const isDone = elapsed >= finishesAt
451 const wobble = 0.85 + 0.15 * Math.sin(elapsed * 2 + p.id)
452 return [
453 {
454 id: p.id,
455 label: p.label,
456 bytes: isDone ? p.total : p.rate * elapsed,
457 total: p.isSized ? p.total : null,
458 rate: isDone ? 0 : p.rate * wobble,
459 status: isDone ? ('done' as const) : ('active' as const),
460 exitCode: isDone ? 0 : null,
461 },
462 ]
463 })
464 for (const old of before) {
465 if (S.sims.some(r => r.id === old.id)) continue
466 S.finished = { ...S.finished, done: S.finished.done + 1, bytes: S.finished.bytes + old.bytes, total: S.finished.total + (old.total ?? 0), hasUnknownTotal: S.finished.hasUnknownTotal || old.total === null }
467 }
468 if (S.sims.length === 0) {
469 timer.cancel()
470 S.isSimulating = false
471 }
472 await publish($)
473 })
474}
475
476export const register: Register = on => {
477 on('session.start', async ($, e, next) => {
478 // The rows are the host's and outlive a reload; the processes behind them
479 // are this module's to follow, so every load starts with none.
480 await update($, rowsAtom, () => [])
481 await update($, summaryAtom, () => null)
482 await $.command.register({
483 name: 'dlbar',
484 description: 'Download progress bar: on|off, test [count], lookup on|off',
485 argumentHint: 'on|off|test [count]|lookup on|off',
486 immediate: true,
487 })
488 startSampler($)
489 return next(e)
490 })
491
492 // Remembered only to name a download made by the shell process itself.
493 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
494 S.calls = [...S.calls, { tool: 'Bash' as const, command: e.command, at: await $.clock.now() }].slice(-RECENT_CALLS)
495 return next(e)
496 })
497
498 on('tool.call', { tool: 'PowerShell' }, async ($, e, next) => {
499 S.calls = [...S.calls, { tool: 'PowerShell' as const, command: e.command, at: await $.clock.now() }].slice(-RECENT_CALLS)
500 return next(e)
501 })
502
503 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
504 const [rows, summary] = await Promise.all([read($, rowsAtom), read($, summaryAtom)])
505 if (rows.length === 0 || e.props.hasSurvey || (await read($, isHidden))) return next(e)
506
507 const { Box, Button, Text } = $.ui.resolve(e)
508 const summaryLabel = summary === null ? '' : `${summary.count} downloads`
509 const nameWidth = Math.max(8, Math.min(28, Math.max(summaryLabel.length, ...rows.map(r => r.label.length))))
510 const barWidth = Math.max(10, Math.min(24, e.props.bodyColumns - nameWidth - 52))
511 const hideButton = <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
512
513 let top = null
514 if (summary !== null) {
515 const isSized = summary.total !== null && summary.total > 0 && summary.bytes <= summary.total * 1.05
516 const fraction = isSized ? Math.min(summary.active > 0 ? 0.99 : 1, summary.bytes / (summary.total as number)) : 0
517 const sizes = isSized ? `${formatBytes(summary.bytes)} / ${formatBytes(summary.total as number)}` : formatBytes(summary.bytes)
518 const counts = [
519 summary.active > 0 ? `${formatBytes(summary.rate)}/s` : null,
520 summary.done > 0 ? `${summary.done} done` : null,
521 summary.failed > 0 ? `${summary.failed} failed` : null,
522 ].filter(part => part !== null)
523 top = (
524 <Box key="summary">
525 <Text color={summary.active > 0 ? 'cyan' : summary.failed > 0 ? 'red' : 'green'}>{summary.active > 0 ? '↓' : summary.failed > 0 ? '✗' : '✓'}</Text>
526 <Text bold>{` ${padLabel(summaryLabel, nameWidth)} `}</Text>
527 {isSized ? <Text color="cyan">{barText(barWidth, fraction)}</Text> : null}
528 {isSized ? <Text bold>{` ${String(Math.floor(fraction * 100)).padStart(3)}%`}</Text> : null}
529 <Text dimColor>{`${isSized ? ' ' : ''}${[sizes, ...counts].join(' · ')} `}</Text>
530 {hideButton}
531 </Box>
532 )
533 }
534
535 return (
536 <Box flexDirection="column">
537 {top}
538 {rows.map((row, i) => {
539 const isSized = row.total !== null && row.bytes <= row.total * 1.05
540 const total = row.total ?? 0
541 const name = ` ${padLabel(row.label, nameWidth)} `
542 const hide = i === 0 && top === null ? hideButton : null
543
544 if (row.status === 'failed') {
545 return (
546 <Box key={`row-${row.id}`}>
547 <Text color="red">{'✗'}</Text>
548 <Text>{name}</Text>
549 <Text color="red">{`failed (exit ${row.exitCode ?? '?'})`}</Text>
550 <Text dimColor>{` · ${formatBytes(row.bytes)} `}</Text>
551 {hide}
552 </Box>
553 )
554 }
555
556 if (row.status === 'done') {
557 return (
558 <Box key={`row-${row.id}`}>
559 <Text color="green">{'✓'}</Text>
560 <Text>{name}</Text>
561 {isSized ? <Text color="green">{`${barText(barWidth, 1)} 100%`}</Text> : null}
562 <Text dimColor>{` ${formatBytes(isSized ? total : row.bytes)} `}</Text>
563 {hide}
564 </Box>
565 )
566 }
567
568 const fraction = isSized ? Math.min(0.99, row.bytes / total) : 0
569 const sizes = isSized ? `${formatBytes(row.bytes)} / ${formatBytes(total)}` : formatBytes(row.bytes)
570 const eta = isSized && row.rate > 0 ? ` · ETA ${formatDuration((total - row.bytes) / row.rate)}` : ''
571
572 return (
573 <Box key={`row-${row.id}`}>
574 <Text color="cyan">{'↓'}</Text>
575 <Text>{name}</Text>
576 {isSized ? <Text color="cyan">{barText(barWidth, fraction)}</Text> : null}
577 {isSized ? <Text bold>{` ${String(Math.floor(fraction * 100)).padStart(3)}%`}</Text> : null}
578 <Text dimColor>{`${isSized ? ' ' : ''}${sizes} · ${formatBytes(row.rate)}/s${eta} `}</Text>
579 {hide}
580 </Box>
581 )
582 })}
583 </Box>
584 )
585 })
586
587 on('command.run', { command: 'dlbar' }, async ($, e) => {
588 const [word = '', arg = ''] = e.args.trim().toLowerCase().split(/\s+/)
589 const cfg = await settings($)
590
591 if (word === 'on' || word === 'off') {
592 await $.store.set('on', word === 'on')
593 return { text: `Download bar ${word}.` }
594 }
595 if (word === 'test') {
596 if (S.isSimulating) return { text: 'A simulated download is already running.' }
597 const count = Math.max(2, Math.min(50, Number.parseInt(arg, 10) || 2))
598 simulate($, await $.clock.now(), count)
599 return {
600 text:
601 count === 2
602 ? 'Simulating two downloads (one with a known size, one without). Look above the prompt.'
603 : `Simulating ${count} downloads. Look above the prompt.`,
604 }
605 }
606 if (word === 'lookup') {
607 if (arg !== 'on' && arg !== 'off') return { text: 'Usage: /dlbar lookup on|off' }
608 await $.store.set('lookup', arg === 'on')
609 return { text: `Size lookup ${arg}.` }
610 }
611
612 return {
613 text: [
614 `Download bar: ${cfg.isOn ? 'on' : 'off'} · size lookup ${cfg.isLookup ? 'on' : 'off'}`,
615 'Shows one row per download started from Claude Code\'s shells: any program that holds an internet connection and writes what it receives, in WSL too.',
616 'Percent and ETA appear when the download\'s URL reports its size (a HEAD request, sent only once the download is running).',
617 `Past ${MAX_ROWS} at once, one summary row tops the ${DETAIL_ROWS} fastest.`,
618 '/dlbar test [count] · /dlbar on|off · /dlbar lookup on|off',
619 ].join('\n'),
620 }
621 })
622}
623hooks/lib.ts 557 lines1// Pure helpers: no `$`, so the tests reach them directly. The sampler
2// (sampler/sampler.ps1) reports processes; `advance` turns reports into rows.
3
4/** One process the sampler reports: a Claude Code shell descendant that held an internet connection. */
5export type ProcReport = {
6 pid: number
7 /** Executable name, `curl.exe`. */
8 n: string
9 /** Bytes the process has written, for its whole life. */
10 w: number
11 /** Connected to a non-loopback address right now. */
12 c: boolean
13 /** Command line, on the first report only. */
14 cmd?: string
15 /** Working directory, on the first report only. */
16 cwd?: string
17 /** Exit code, on the last report only. */
18 x?: number
19}
20
21/** A `wsl.exe` a Claude Code shell started: its download runs in the Linux VM, followed by sampler/wsl-sampler.sh. */
22export type WslLaunch = {
23 pid: number
24 cmd: string
25 /** Exit code, on the last report only. */
26 x?: number
27}
28
29export type Report = { rx: number; p: ProcReport[]; wsl?: WslLaunch[] }
30
31export function parseReport(line: string): Report | null {
32 try {
33 const value = JSON.parse(line) as Partial<Report>
34 if (typeof value.rx !== 'number' || !Array.isArray(value.p)) return null
35 return value as Report
36 } catch {
37 return null
38 }
39}
40
41export type RowStatus = 'active' | 'done' | 'failed'
42
43export type RowState = {
44 pid: number
45 exe: string
46 cmd: string
47 label: string
48 urls: string[]
49 /** The script an interpreter runs, resolved against its working directory. */
50 script: string | null
51 /** The file a resumed download (curl -C, wget -c) appends to, resolved the same way. */
52 resumeFile: string | null
53 /** Write count when this download began; progress is counted from here. */
54 base: number
55 /** Machine bytes received when it began, to check data really came in. */
56 rxBase: number
57 /** Write count when the sampler first saw it, for the network check. */
58 seenWrites: number
59 writes: number
60 rate: number
61 lastAt: number
62 lastActiveAt: number
63 firstSeen: number
64 isVisible: boolean
65 status: RowStatus
66 exitCode: number | null
67 endedAt: number
68 total: number | null
69}
70
71// A row shows once a process has written this much...
72export const MIN_VISIBLE_BYTES = 256 * 1024
73// ...and at least this share of it arrived over the network meanwhile, so a
74// job writing checkpoints beside an idle connection is not a "download".
75export const MIN_RX_SHARE = 0.25
76// A process that is still running but neither connected nor writing for this
77// long has finished its download.
78export const IDLE_MS = 3000
79export const DONE_LINGER_MS = 1500
80export const FAILED_LINGER_MS = 4000
81const SMOOTHING = 0.5
82const SHELLS = /^(bash|sh|zsh|pwsh|powershell)\.exe$/i
83
84export type Advance = {
85 rows: Map<number, RowState>
86 /** Rows that became visible on this report: the time to look up a size. */
87 appeared: RowState[]
88}
89
90export function advance(
91 rows: Map<number, RowState>,
92 report: Report,
93 now: number,
94 labelFor: (exe: string, cmd: string, firstSeen: number) => Description,
95): Advance {
96 const next = new Map(rows)
97 const appeared: RowState[] = []
98
99 for (const p of report.p) {
100 let row = next.get(p.pid)
101 if (row === undefined) {
102 const cmd = p.cmd ?? ''
103 const { label, urls, script } = labelFor(p.n, cmd, now)
104 const resumed = resumedOutput(p.n, cmd)
105 const resumeFile = resumed === null ? null : resolvePath(p.cwd ?? '', resumed)
106 row = {
107 pid: p.pid,
108 exe: p.n,
109 cmd,
110 label,
111 urls,
112 script: script === undefined ? null : resolvePath(p.cwd ?? '', script),
113 // Only a path made absolute is the file the download writes.
114 resumeFile: resumeFile !== null && /^([a-z]:[\\/]|[\\/]{2})/i.test(resumeFile) ? resumeFile : null,
115 // A shell runs other work before downloading, so count from now; any
116 // other program's whole life is the download, so count from zero.
117 base: SHELLS.test(p.n) ? p.w : 0,
118 rxBase: report.rx,
119 seenWrites: p.w,
120 writes: p.w,
121 rate: 0,
122 lastAt: now,
123 lastActiveAt: now,
124 firstSeen: now,
125 isVisible: false,
126 status: 'active',
127 exitCode: null,
128 endedAt: 0,
129 total: null,
130 }
131 } else {
132 row = { ...row }
133 }
134
135 const delta = Math.max(0, p.w - row.writes)
136 const seconds = (now - row.lastAt) / 1000
137 if (seconds > 0) {
138 const instant = delta / seconds
139 row.rate = row.rate === 0 ? instant : SMOOTHING * instant + (1 - SMOOTHING) * row.rate
140 }
141 row.writes = p.w
142 row.lastAt = now
143 if (delta > 0 || p.c) row.lastActiveAt = now
144
145 // A process that finished one download and starts another (a PowerShell
146 // session fetching twice) begins a fresh row.
147 if (row.status !== 'active' && p.x === undefined && p.c && delta > 0) {
148 row = { ...row, base: p.w - delta, rxBase: report.rx, seenWrites: p.w - delta, firstSeen: now, isVisible: false, status: 'active', exitCode: null, endedAt: 0, rate: 0 }
149 }
150
151 const bytes = row.writes - row.base
152 if (!row.isVisible && row.status === 'active' && bytes >= MIN_VISIBLE_BYTES && report.rx - row.rxBase >= MIN_RX_SHARE * (row.writes - row.seenWrites)) {
153 row.isVisible = true
154 appeared.push(row)
155 }
156
157 if (p.x !== undefined) {
158 if (row.status === 'active') {
159 row.status = p.x === 0 ? 'done' : 'failed'
160 row.exitCode = p.x
161 row.endedAt = now
162 }
163 } else if (row.status === 'active' && !p.c && now - row.lastActiveAt >= IDLE_MS) {
164 row.status = 'done'
165 row.endedAt = now
166 }
167
168 next.set(p.pid, row)
169 }
170
171 // The sampler always reports an exit, so a row it stops naming has ended.
172 const named = new Set(report.p.map(p => p.pid))
173 for (const [pid, row] of next) {
174 if (!named.has(pid) && row.status === 'active') next.set(pid, { ...row, status: 'done', endedAt: now })
175 }
176
177 for (const [pid, row] of next) {
178 if (row.status === 'active') continue
179 const linger = row.status === 'failed' ? FAILED_LINGER_MS : DONE_LINGER_MS
180 if (!row.isVisible || now - row.endedAt >= linger) {
181 // Only drop a row the sampler has let go of; a live one may download again.
182 if (!named.has(pid) || row.exitCode !== null) next.delete(pid)
183 else if (row.isVisible) next.set(pid, { ...row, isVisible: false })
184 }
185 }
186
187 return { rows: next, appeared }
188}
189
190// A sampler that ends is started again, up to this many times in a row...
191export const MAX_SAMPLER_RESTARTS = 3
192// ...unless it ran this long before it ended: that was not a crash loop, so its end starts the count over.
193export const HEALTHY_MS = 60_000
194
195/** The restarts used once a sampler that ran `ranMs` has ended, or null when none is left. */
196export function restartsAfter(used: number, ranMs: number): number | null {
197 const counted = ranMs >= HEALTHY_MS ? 0 : used
198 return counted < MAX_SAMPLER_RESTARTS ? counted + 1 : null
199}
200
201export function formatBytes(n: number): string {
202 const units = ['B', 'KB', 'MB', 'GB', 'TB']
203 let value = Math.max(0, n)
204 let i = 0
205 while (value >= 1024 && i < units.length - 1) {
206 value /= 1024
207 i++
208 }
209 return `${value >= 100 || i === 0 ? Math.round(value) : value.toFixed(1)} ${units[i]}`
210}
211
212export function formatDuration(seconds: number): string {
213 const s = Math.max(0, Math.round(seconds))
214 if (s < 60) return `${s}s`
215 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
216 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
217}
218
219const URL_PATTERN = /https?:\/\/[^\s'"`<>)|;&\\]+/gi
220
221export function extractUrls(text: string, limit = 5): string[] {
222 const found = text.match(URL_PATTERN) ?? []
223 return [...new Set(found.map(u => u.replace(/[.,]+$/, '')))].slice(0, limit)
224}
225
226export function fileLabel(url: string): string {
227 const path = url.replace(/^https?:\/\//i, '').split(/[?#]/)[0] ?? ''
228 const last = path.split('/').filter(Boolean).pop() ?? path
229 try {
230 return decodeURIComponent(last)
231 } catch {
232 return last
233 }
234}
235
236// The file a command says it writes: curl -o, wget -O, aria2c -o/--out,
237// -OutFile. A bare `curl -O` has no name of its own, so the URL gives it.
238const OUTPUT_FLAG = /(?:^|\s)(?:-o|-O|--output|--output-document|--out|-OutFile)(?:\s+|=)("[^"]+"|'[^']+'|[^\s;&|]+)/i
239
240export function outputName(command: string): string | null {
241 const raw = OUTPUT_FLAG.exec(command)?.[1]
242 if (raw === undefined) return null
243 const value = raw.replace(/^["']|["']$/g, '')
244 if (/^https?:/i.test(value) || /^(-|\/dev\/null|nul)$/i.test(value)) return null
245 return value.split(/[\\/]/).filter(Boolean).pop() ?? null
246}
247
248/**
249 * The output file a download resumes into (curl -C, wget -c, aria2c -c), as
250 * its command names it, or null for one that starts the file over. Bytes
251 * already in that file are progress its own write count never sees.
252 */
253export function resumedOutput(exe: string, command: string): string | null {
254 const program = baseName(exe).replace(/\.exe$/i, '').toLowerCase()
255 const resumes =
256 program === 'curl' ? /\s(-C|--continue-at)(\s|=)/.test(command) : /^(wget|aria2c)$/.test(program) && /\s(-c|--continue)(\s|=|$)/.test(command)
257 if (!resumes) return null
258 const raw = OUTPUT_FLAG.exec(command)?.[1]
259 if (raw === undefined) return null
260 const value = raw.replace(/^["']|["']$/g, '')
261 return /^https?:/i.test(value) || /^(-|\/dev\/null|nul|~.*)$/i.test(value) ? null : value
262}
263
264/** Splits a Windows command line into arguments (quotes respected, not unescaped further). */
265export function splitArgs(cmd: string): string[] {
266 return (cmd.match(/"[^"]*"|\S+/g) ?? []).map(a => a.replace(/^"|"$/g, ''))
267}
268
269function baseName(path: string): string {
270 return path.split(/[\\/]/).filter(Boolean).pop() ?? path
271}
272
273const INTERPRETERS = /^(python\d*(\.\d+)?|pythonw|py|node|deno|bun|ruby|perl|php|uv)(\.exe)?$/i
274const PACKAGE_TOOLS = /^(pip\d*|uv|npm|pnpm|yarn|conda|cargo|winget|choco|git|huggingface-cli|hf)$/i
275
276export type Description = {
277 label: string
278 /** URLs on the command line. */
279 urls: string[]
280 /** A source file the interpreter runs (as written, maybe relative), whose URLs may say the size. */
281 script?: string
282}
283
284const SOURCE_FILE = /\.(py|pyw|js|mjs|cjs|ts|rb|pl|php)$/i
285
286/**
287 * What to call a download from its process's own command line: the output
288 * file it names, else the file in its URL, else the script an interpreter
289 * runs, else the program.
290 */
291export function describe(exe: string, cmd: string): Description {
292 const urls = extractUrls(cmd)
293 const output = outputName(cmd)
294 const program = exe.replace(/\.exe$/i, '')
295 const args = splitArgs(cmd).slice(1)
296
297 const isInterpreter = INTERPRETERS.test(exe)
298 // `-c <code>` runs inline code: no script to name it after.
299 const scriptArg = isInterpreter && !args.includes('-c') && !args.includes('-m') ? args.find(a => !a.startsWith('-')) : undefined
300 const script = scriptArg !== undefined && SOURCE_FILE.test(scriptArg) ? { script: scriptArg } : {}
301
302 if (output !== null) return { label: withMore(output, urls.length), urls, ...script }
303
304 if (isInterpreter) {
305 const moduleIndex = args.indexOf('-m')
306 if (moduleIndex !== -1 && args[moduleIndex + 1] !== undefined) {
307 return { label: tool(args[moduleIndex + 1] as string, args.slice(moduleIndex + 2)), urls }
308 }
309 if (scriptArg !== undefined) {
310 const name = baseName(scriptArg).replace(/\.exe$/i, '').replace(/^(npm|npx|pnpm|yarn)(-cli)?\.c?js$/i, '$1')
311 if (PACKAGE_TOOLS.test(name)) return { label: tool(name, args.slice(args.indexOf(scriptArg) + 1)), urls }
312 return { label: name, urls, ...script }
313 }
314 }
315
316 if (/^git-remote-https?$/i.test(program)) return { label: 'git', urls }
317 if (urls.length > 0) return { label: withMore(fileLabel(urls[0] as string), urls.length), urls }
318 if (PACKAGE_TOOLS.test(program)) return { label: tool(program, args), urls }
319 return { label: program, urls }
320}
321
322/** A path as a program got it, made absolute against that program's working directory. */
323export function resolvePath(cwd: string, path: string): string {
324 if (/^[a-z]:[\\/]/i.test(path) || /^[\\/]{2}/.test(path)) return path
325 // A Linux program's absolute path, inside the distro its cwd is in.
326 const distro = /^\\\\wsl(?:\.localhost|\$)\\[^\\]+/i.exec(cwd)
327 if (distro !== null && path.startsWith('/')) return `${distro[0]}${path.replace(/\//g, '\\')}`
328 // Git Bash hands native programs /c/... paths converted, but be safe.
329 const drive = /^\/([a-z])\/(.*)$/i.exec(path)
330 if (drive !== null) return `${(drive[1] as string).toUpperCase()}:/${drive[2]}`
331 if (cwd === '') return path
332 return `${cwd.replace(/[\\/]+$/, '')}\\${path.replace(/^\.[\\/]/, '')}`
333}
334
335/**
336 * Which looked-up URLs are files to download, by their HEAD answers: a size
337 * of at least `MIN_VISIBLE_BYTES` and not a page or an API reply. A script
338 * names pages and APIs too; their sizes are no part of the download.
339 */
340export function downloadSizes(heads: ({ size: number; type: string } | null)[]): number | null {
341 const files = heads.filter(
342 (h): h is { size: number; type: string } =>
343 h !== null && h.size >= MIN_VISIBLE_BYTES && !/^(text\/|application\/(json|xml|xhtml))/i.test(h.type),
344 )
345 return files.length === 0 ? null : files.reduce((sum, h) => sum + h.size, 0)
346}
347
348function tool(name: string, rest: string[]): string {
349 const words = rest.filter(a => !a.startsWith('-')).slice(0, 2)
350 return [name, ...words].join(' ')
351}
352
353function withMore(label: string, count: number): string {
354 return count > 1 ? `${label} +${count - 1}` : label
355}
356
357/** The command Claude gave a shell tool, when that shell process is itself the downloader. */
358export function isLauncherWrapper(cmd: string): boolean {
359 return /__claudeCodeScript|shell-snapshots[\\/]snapshot-/i.test(cmd)
360}
361
362// wsl.exe options that take a value.
363const WSL_VALUE_OPTIONS = /^(-d|--distribution|--distribution-id|-u|--user|--cd|--shell-type)$/i
364const LEADING_WORD = /^\s*("[^"]*"|\S+)/
365
366/**
367 * What a `wsl.exe` command line runs: the distro it names ('' for the
368 * default) and the command after its options, or null when it runs none
369 * (an interactive shell, `--list`, `--shutdown`).
370 */
371export function wslCommand(cmd: string): { distro: string; command: string } | null {
372 let rest = cmd.replace(LEADING_WORD, '')
373 let distro = ''
374 for (;;) {
375 const word = LEADING_WORD.exec(rest)
376 if (word === null) return null
377 const name = (word[1] as string).replace(/^"|"$/g, '')
378 const after = rest.slice(word[0].length)
379 if (name === '--' || name === '-e' || name === '--exec') {
380 const command = after.trim()
381 return command === '' ? null : { distro, command }
382 }
383 if (WSL_VALUE_OPTIONS.test(name)) {
384 const value = LEADING_WORD.exec(after)
385 if (value === null) return null
386 if (/^(-d|--distribution)$/i.test(name)) distro = (value[1] as string).replace(/^"|"$/g, '')
387 rest = after.slice(value[0].length)
388 } else if (name === '~' || name === '--system') {
389 rest = after
390 } else if (name.startsWith('-')) {
391 return null
392 } else {
393 return { distro, command: rest.trim() }
394 }
395 }
396}
397
398function words(cmd: string): string[] {
399 return cmd.replace(/["'\\]/g, '').split(/\s+/).filter(Boolean)
400}
401
402/**
403 * Whether a Linux process that WSL started (its argv joined) is the one a
404 * wsl.exe `command` ran. WSL hands the command to a shell first, so quotes
405 * are gone and `$HOME` or `$?` arrive expanded: most words must agree, not all.
406 */
407export function isWslCommand(linux: string, command: string): boolean {
408 const ours = words(command).filter(w => !/[$`]/.test(w))
409 const theirs = words(linux)
410 if (ours.length === 0 || theirs.length === 0) return false
411 const pool = new Map<string, number>()
412 for (const w of theirs) pool.set(w, (pool.get(w) ?? 0) + 1)
413 let shared = 0
414 for (const w of ours) {
415 const left = pool.get(w) ?? 0
416 if (left > 0) {
417 shared++
418 pool.set(w, left - 1)
419 }
420 }
421 return shared >= 0.75 * ours.length && shared >= 0.5 * theirs.length
422}
423
424/** One Linux process the WSL sampler reports: it held an internet connection. */
425export type WslProc = {
426 pid: number
427 /** The process WSL started that this one descends from, 0 when none. */
428 root: number
429 /** Bytes it has written (/proc/<pid>/io wchar). */
430 w: number
431 c: boolean
432 n: string
433 cwd: string
434 cmd: string
435}
436
437export type WslTick = { procs: WslProc[]; roots: Map<number, string> }
438
439/** One block of wsl-sampler.sh's lines, between its T and E. */
440export function parseWslTick(lines: string[]): WslTick {
441 const procs: WslProc[] = []
442 const roots = new Map<number, string>()
443 for (const line of lines) {
444 const f = line.split('\t')
445 if (f[0] === 'P' && f.length >= 8) {
446 const [pid, root, w] = [Number(f[1]), Number(f[2]), Number(f[3])]
447 if (Number.isInteger(pid) && Number.isInteger(root) && Number.isFinite(w)) {
448 procs.push({ pid, root, w, c: f[4] === '1', n: f[5] as string, cwd: f[6] as string, cmd: f.slice(7).join(' ') })
449 }
450 } else if (f[0] === 'R' && f.length >= 3) {
451 roots.set(Number(f[1]), f.slice(2).join(' '))
452 }
453 }
454 return { procs, roots }
455}
456
457/** A Linux process's row id: past every Windows process id, apart for each distro. */
458export function wslId(slot: number, pid: number): number {
459 return 2 ** 32 + slot * 2 ** 23 + pid
460}
461
462/** A Linux path as Windows reaches it. */
463export function wslPath(distro: string, path: string): string {
464 return path.startsWith('/') ? `\\\\wsl.localhost\\${distro}${path.replace(/\//g, '\\')}` : path
465}
466
467/** A WSL tick as a sampler report, holding only the processes under roots `isOurs` accepts. */
468export function wslReport(tick: WslTick, slot: number, distro: string, rx: number, isOurs: (root: number, cmd: string) => boolean): Report {
469 const p = tick.procs
470 .filter(proc => proc.root > 0 && isOurs(proc.root, tick.roots.get(proc.root) ?? ''))
471 .map(proc => ({ pid: wslId(slot, proc.pid), n: proc.n, w: proc.w, c: proc.c, cmd: proc.cmd, cwd: wslPath(distro, proc.cwd) }))
472 return { rx, p }
473}
474
475export function barText(width: number, fraction: number): string {
476 const filled = Math.max(0, Math.min(width, Math.round(fraction * width)))
477 return '█'.repeat(filled) + '░'.repeat(width - filled)
478}
479
480export function padLabel(label: string, width: number): string {
481 if (label.length <= width) return label.padEnd(width)
482 return `${label.slice(0, Math.max(1, width - 1))}…`
483}
484
485/** A row as the panel draws it. */
486export type PanelRow = {
487 id: number
488 label: string
489 bytes: number
490 total: number | null
491 rate: number
492 status: RowStatus
493 exitCode: number | null
494}
495
496/** Downloads that finished and left the panel during the current batch. */
497export type Finished = { done: number; failed: number; bytes: number; total: number; hasUnknownTotal: boolean }
498
499export const noneFinished: Finished = { done: 0, failed: 0, bytes: 0, total: 0, hasUnknownTotal: false }
500
501export type PanelSummary = {
502 count: number
503 active: number
504 done: number
505 failed: number
506 bytes: number
507 total: number | null
508 rate: number
509}
510
511export const MAX_ROWS = 4
512export const DETAIL_ROWS = 3
513// A shown row keeps its place while this fast relative to the DETAIL_ROWS-th fastest.
514const KEEP_RATE = 0.8
515
516export type Layout = { rows: PanelRow[]; summary: PanelSummary | null; pinned: number[] }
517
518/**
519 * Up to MAX_ROWS downloads show whole. Past that the panel turns into one
520 * summary row over the DETAIL_ROWS fastest active downloads. A shown row
521 * keeps its place until it falls clearly behind (below KEEP_RATE of the
522 * DETAIL_ROWS-th fastest), so rows do not swap every half second; `pinned`
523 * carries that choice between calls.
524 */
525export function layout(rows: PanelRow[], pinned: number[], finished: Finished, isSummaryMode: boolean): Layout {
526 if (!isSummaryMode && rows.length <= MAX_ROWS) return { rows, summary: null, pinned: [] }
527
528 const active = rows.filter(r => r.status === 'active')
529 const byRate = [...active].sort((a, b) => b.rate - a.rate)
530 const keepAbove = (byRate[DETAIL_ROWS - 1]?.rate ?? 0) * KEEP_RATE
531 const chosen = pinned.filter(id => active.some(r => r.id === id && r.rate >= keepAbove))
532 for (const r of byRate) {
533 if (chosen.length >= DETAIL_ROWS) break
534 if (!chosen.includes(r.id)) chosen.push(r.id)
535 }
536 // With few left downloading, show the latest failures, then completions.
537 for (const status of ['failed', 'done'] as const) {
538 for (const r of [...rows].reverse()) {
539 if (chosen.length >= DETAIL_ROWS) break
540 if (r.status === status && !chosen.includes(r.id)) chosen.push(r.id)
541 }
542 }
543
544 const hasUnknownTotal = finished.hasUnknownTotal || rows.some(r => r.total === null)
545 const summary: PanelSummary = {
546 count: rows.length + finished.done + finished.failed,
547 active: active.length,
548 done: rows.filter(r => r.status === 'done').length + finished.done,
549 failed: rows.filter(r => r.status === 'failed').length + finished.failed,
550 bytes: rows.reduce((sum, r) => sum + r.bytes, 0) + finished.bytes,
551 total: hasUnknownTotal ? null : rows.reduce((sum, r) => sum + (r.total ?? 0), 0) + finished.total,
552 rate: active.reduce((sum, r) => sum + r.rate, 0),
553 }
554
555 return { rows: rows.filter(r => chosen.includes(r.id)), summary, pinned: chosen.filter(id => active.some(r => r.id === id)) }
556}
557types/index.d.ts 34 lines1export type DownloadRow = {
2 /** The downloading process (or a negative id for /dlbar test rows). */
3 id: number
4 /** The file, script or program, for the row's name column. */
5 label: string
6 /** Bytes written by the process since the download began. */
7 bytes: number
8 /** Expected size, when the download's URLs reported one. */
9 total: number | null
10 /** Smoothed bytes per second. */
11 rate: number
12 status: 'active' | 'done' | 'failed'
13 exitCode: number | null
14}
15
16/** The row over the rest when more downloads run than fit. */
17export type DownloadSummary = {
18 /** Downloads in this batch, finished ones included. */
19 count: number
20 active: number
21 done: number
22 failed: number
23 bytes: number
24 /** Combined size, when every download's size is known. */
25 total: number | null
26 rate: number
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'download-bar': { rows: DownloadRow[]; summary: DownloadSummary | null; isHidden: boolean }
32 }
33}
34