SLOPSHOPPER

download-bar

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.

newbandguardcommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · download-bar
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /dlbar ⎿ download-bar: Download bar: on · size lookup on ⎿ download-bar: Shows one row per download started from Claude Code's shells: any program that holds an internet connection an ⎿ download-bar: Percent and ETA appear when the download's URL reports its size (a HEAD request, sent only once the download i ⎿ download-bar: Past 4 at once, one summary row tops the 3 fastest. ⎿ download-bar: /dlbar test [count] · /dlbar on|off · /dlbar lookup on|off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

Two small mods for Claude Code that draw in the band above the prompt.

ModWhat it doesPlatforms
context-barA 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-barOne 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)

Install

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).

Using download-bar

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 ETA

How 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.

Developing

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.

License

MIT

Source 3 files
hooks/register.tsx 623 lines
1import { 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}
623
hooks/lib.ts 557 lines
1// 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}
557
types/index.d.ts 34 lines
1export 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