Live progress of every long-running job above the Claude Code prompt: bars, speed, ETA, stall and crash detection. Jobs report via a one-file JSON protocol…

<a href="https://github.com/pepperonas/taskline"><img src="docs/social.png" alt="taskline — live progress of long-running jobs above the Claude Code prompt" width="100%"></a>
A Claude Code mod that shows every long-running job — downloads, fetches, renders, imports — as a live progress line right above the prompt: bar, count, size, speed, ETA, and a loud warning when something stalls or dies.
<a href="#-install"><img alt="Install in a minute" height="56" src="https://img.shields.io/badge/%E2%AC%87%EF%B8%8F_Install-in_a_minute-2E9E5B?style=for-the-badge"></a> <a href="#-report-progress-from-your-jobs"><img alt="Report from any job" height="56" src="https://img.shields.io/badge/%F0%9F%93%A1_Report-from_any_job-7B4DFF?style=for-the-badge"></a>
<h3>👉 <code>/plugin marketplace add pepperonas/taskline</code> · <code>/plugin install taskline@pepperonas-taskline</code></h3>
[!NOTE] You start a 3 GB download in another terminal, ask Claude something else, and twenty minutes later wonder whether the download is still alive. taskline answers that without you leaving the conversation: every job that reports in shows up above the prompt, keeps its ETA honest, turns yellow when it stops moving and red when its process is gone.
In a session — a stalled job, a download with files and bytes, and an open-ended index, right above the prompt:
<img src="docs/hero.png" alt="taskline above the Claude Code prompt: a stalled job, a running download, an index with unknown total" width="100%">
Every state — a running job with files and bytes, a byte-sized download, an unknown amount of work, a stall, a crashed writer, an error, and a finished job (green for ten seconds, then gone):
<img src="docs/states.png" alt="taskline states: running, byte unit, unknown total, stalled, aborted, error, done" width="100%">
When a bar fills — it bursts into sparks out of the bar's own position, a shock ring runs out, a check mark draws itself with a pen of light, CHECK!! drops in letter by letter, a light sweeps across, and it all dissolves (≈ 3 s, 30 fps, every frame below is the mod's own output):
<img src="docs/celebrate.gif" alt="the finish: the bar bursts into sparks, a check mark draws itself, CHECK!! drops in and dissolves" width="100%">
The whole run frame by frame, from the full bar to CHECK!!:
<img src="docs/celebrate.png" alt="ten moments of the finish: the bar fills, flashes white-hot, bursts, sparks and a shock ring, the sparks cool to green, the check draws itself with a pen of light, CHECK!! drops in letter by letter, a light sweeps across, and it dissolves" width="100%">
Width-aware — the same three jobs at 140, 100, 72 and 44 columns. One line while everything fits richly; otherwise one row per job, each shrinking its bar first, then its extras, then its label. Nothing ever wraps:
<img src="docs/widths.png" alt="the same three jobs at four terminal widths" width="100%">
All images, the social card included, are rendered by tools/screenshots.ts from the mod's own layout code, not drawn by hand.
275/1,000 files · 3.1 GB · 38.2 MB/s: count in files, measure in bytes; the ETA uses whichever total is known.⏸ stalled 2m in yellow.pid and dies without saying so shows ✖ aborted, in red.✔ with the elapsed time for ten seconds, then the file is cleaned up.✔ CHECK!! line when even that does not fit, on the desktop app, or with animation off. /taskline check plays it on demand.+N more.[275/1000] in a log, files piling up in a folder.taskline CLI do it for you.NO_COLOR respected, plus a color switch in /config./plugin marketplace add pepperonas/taskline
/plugin install taskline@pepperonas-taskline
Then, for your jobs, the Python helper and the CLI:
pip install git+https://github.com/pepperonas/taskline # in a venv, or with --user
taskline --help
git clone https://github.com/pepperonas/taskline && cd taskline
./install.sh # mod + Python helper + CLI; --no-mod for just the helpers
install.sh creates ~/.claude/progress/ and ~/.claude/taskline/watchers.json, links the taskline CLI into ~/.local/bin, makes import taskline work for your python3 (a .pth file in the user site-packages), and installs the mod from this folder as a local marketplace. It never edits settings.json itself — the plugin CLI does that — and it records what it did, so ./uninstall.sh undoes exactly that (--purge also deletes your progress files and watchers). Both take --dry-run.
Projects with their own virtualenv don't see the user site-packages. Inside the venv:
pip install -e /path/to/taskline— or just copypython/taskline.py; it is one file.
claude --plugin-dir /path/to/taskline
from taskline import Progress, track
# a context manager: done on success, error (with the exception) on failure, interrupted on Ctrl+C
with Progress("bridge", total=1000, label="Bridge", unit="files", icon="⬇") as p:
for url in urls:
size = fetch(url)
p.advance(add_bytes=size) # count a file, add its bytes
# or wrap any iterable
for tile in track(tiles, "tiles", label="Tiles", unit="files"):
render(tile)
A job that reports rarely (once per batch) passes stalled_after=600 so the gaps don't read as stalls. Progress writes at most once a second (always the first and the last state), records its own pid so a crash shows as aborted, and never raises into your job — a full disk costs the display, not the download. One-shot calls are there too: report(id, done, total, ...), finish(id), fail(id, message), remove(id).
taskline set backup 0 120 --label Backup --unit files --pid $$
for f in data/*; do
rsync -a "$f" nas:/backup/ && taskline add backup
done
taskline done backup # or: taskline fail backup "nas unreachable"
Pass --pid $$ — the CLI process itself exits immediately, your script's pid is what should be watched. Sizes take suffixes (--bytes 3.1G), an unknown total is -.
| Command | What it does |
|---|---|
taskline set <id> <done> [total] | report progress (--label --unit --icon --message --bytes --bytes-total --pid --stalled-after) |
taskline add <id> [n] | add n (default 1), --bytes adds bytes |
taskline done <id> | mark done (fills a known total) |
taskline fail <id> [message] | mark failed |
taskline rm <id>... | remove tasks |
taskline clear [--all] | remove finished, failed and aborted tasks (--all: stalled too) |
taskline ls [--json] | list tasks |
taskline dir | print the progress directory |
Write ~/.claude/progress/<id>.json atomically (temp file in the same folder, then rename) — see PROTOCOL.md:
{ "v": 1, "label": "Bridge", "done": 275, "total": 1000, "unit": "files",
"status": "running", "updated_at": 1759700123.4, "pid": 4242 }
Worked examples for a real Python batch download and a Node fetch script: docs/INTEGRATIONS.md.
~/.claude/taskline/watchers.json (examples in examples/watchers.json):
{
"watchers": [
{ "type": "filesize", "label": "ISO", "path": "~/Downloads/big.iso", "total_bytes": "6.1 GB" },
{ "type": "logtail", "label": "Fetch", "path": "~/proj/fetch.log",
"pattern": "\\[(?<done>[\\d,]+)/(?<total>[\\d,]+)\\]", "unit": "files" },
{ "type": "dircount", "label": "Thumbs", "path": "~/site/thumbs", "glob": "*.{webp,png}", "total": 5000 }
]
}
| Field | Types | Meaning |
|---|---|---|
type | all | filesize, logtail or dircount |
path | all | file, log or folder; ~ works. For filesize and logtail the file name may be a glob (~/w/fetch*.log): the most recently modified match is read, so a new run's log takes over by itself |
label, icon, id, unit | all | display; id defaults to the file name |
total | all | expected count (a total group in the log wins) |
total_bytes | filesize, logtail | expected size, "6.1 GB" or a number |
pattern | logtail | regex with a (?<done>…) group, optional total, bytes, bytes_total, message; Python's (?P<name>…) works too; the last match in the last 64 KB counts |
glob | dircount | one level, * ? [ab] {png,jpg} |
active_within | all | seconds; a source not modified for longer is not shown at all (default 600) |
stalled_after | all | seconds without a change before it counts as stalled (default: the stalledAfter setting) — for logs written once per batch |
enabled | all | false keeps an entry without using it |
The file is re-read when it changes; mistakes are listed by /taskline and never break the band.
| Command | What it does | ||
|---|---|---|---|
/taskline | list every task (also hidden ones), its phase and where it comes from; watcher config errors | ||
/taskline clear [all] | remove finished, failed and aborted tasks; all also stalled ones | ||
/taskline rm <id> | remove one task's file | ||
/taskline hide · /taskline show | hide or show the band (remembered) | ||
| `/taskline layout auto\ | single\ | stacked` | override the layout (remembered) |
/taskline demo | three fake jobs for 35 s, written as real progress files | ||
/taskline check | play the finish: the bar bursts, CHECK!! |
For a longer live check with the real Python library: python3 demo.py (--fast, --fail).
In Claude Code: /plugin configure taskline@pepperonas-taskline (or /config).
| Field | Values | Default | Meaning |
|---|---|---|---|
layout | auto · single · stacked | auto | one line when everything fits, else one row per task |
maxTasks | number | 3 | tasks drawn at most; the rest fold into +N more |
stalledAfter | seconds | 60 | no update this long → stalled |
doneVisible | seconds | 10 | how long a finished task stays |
errorVisible | seconds | 3600 | how long a failed or aborted task stays |
color | true · false | true | colors (also off when NO_COLOR is set) |
animation | true · false | true | spinner and countdown at 4 fps; off = once a second |
celebrate | true · false | true | the finish when a bar fills (sparks, check, CHECK!!) |
cleanup | true · false | true | delete files of finished tasks once they are hidden |
progressDir | path | ~/.claude/progress | where jobs write |
watchersFile | path | ~/.claude/taskline/watchers.json | the watchers |
If you change progressDir, point the helpers there too: export TASKLINE_DIR=....
your job ──writes──► ~/.claude/progress/<id>.json ─┐
fetch.log / file / folder ──► watchers.json ───────┤
▼
every 1 s: list · read changed · watchers · pid check (5 s)
▼
$.state snapshot ──► phase · EMA speed · ETA ──► layout ──► AbovePrompt band
tail -c 64K above 256 KB). Processes are checked with kill -0 every five seconds.ui.render hook on AbovePrompt draws the rows; other mods' bands stay below it. While something runs, the band redraws at 4 fps for the spinner and the countdown; when nothing is visible, it draws nothing and costs nothing.statusLine script? A status line command runs as a new process on every refresh and only refreshes on conversation events (or a timer), so a download in another terminal would freeze while you wait. A mod lives in the session: no process per frame, smooth spinners, and it works in the desktop app too.aborted) and cleanup need host processes; where a surface has none, a dead job shows as stalled instead, and files stay until you remove them.dircount counts one folder level. A huge folder is listed once per change of its modification time.taskline reads only the progress directory, the watchers file and the paths you list in it, and runs only kill -0, tail and rm (the last only on its own progress files). No network, no telemetry, no data leaves your machine. In detail:
What it sends, and where. Nothing, to nowhere. The mod makes no network request of any kind. Its only output is the band it draws above the prompt; the finish repaints that band in place ($.ui.blit), which is drawing on your own screen, not sending.
Programs it runs, and why. Three, always as a fixed program name with arguments, never through a shell:
| Program | Exact call | Why |
|---|---|---|
kill | kill -0 <pid> | checks whether a job's process still lives (signal 0 sends nothing, it only asks); a dead writer shows as aborted. Only for a pid a progress file names, at most every 5 s |
tail | tail -c 65536 <log> | reads the last 64 KB of a log a logtail watcher points at, when the log is larger than 256 KB (smaller logs are read directly) |
rm | rm -f -- <files> | deletes progress files of finished tasks once they are no longer shown — only files named <id>.json directly inside the progress directory, never anything else (cleanup: false turns it off) |
Files it reads. The progress directory (progressDir, default ~/.claude/progress), the watchers file (watchersFile, default ~/.claude/taskline/watchers.json) and the files and folders you list in that watchers file. Nothing else.
Files it writes. Only /taskline demo writes: three demo progress files (demo-download.json, demo-scan.json, demo-stuck.json) into the progress directory, so the demo runs through the real reading path. The mod never edits a build, start-up, settings or instructions file; your preferences (/taskline hide, layout) live in Claude Code's own plugin store.
Values it reads from your machine. Two environment variables: HOME, to expand the ~ in the two paths above (they are /config options — set them to absolute paths and HOME is not needed for anything else), and NO_COLOR, the no-color.org convention for turning colour off (the color option does the same).
Files in this repository that the mod does not run. install.sh, uninstall.sh, demo.py, bin/taskline and python/taskline.py are readable scripts for you: the installer, a demo, and the helper your own jobs import to report progress. The mod never executes any of them.
Not related to tasklite. taskline is an independent mod by Martin Pfeffer (celox.io): progress bars for long-running jobs. It shares nothing with the tasklite task manager but four letters.
| File | Role |
|---|---|
hooks/register.tsx | the mod: polling, liveness, cleanup, /taskline, the band |
hooks/protocol.ts | reading a progress file, sanitizing strings |
hooks/state.ts | phases (running · stalled · aborted · done · error), visibility, order |
hooks/eta.ts | EMA speed and ETA |
hooks/layout.ts | detail levels, width fitting, rows |
hooks/celebrate.ts | the finish: particles, shock ring, check, CHECK!! font, dissolve — every frame a function of time |
hooks/format.ts | sizes, counts, durations, bars, cell widths |
hooks/watchers.ts | watcher config, globs, log matching |
hooks/views.ts | snapshot → what to draw, what to clean up |
python/taskline.py | Python helper + CLI (stdlib only) |
bin/taskline | CLI entry point |
PROTOCOL.md | the progress file format, v1 |
docs/DESIGN.md | design decisions |
docs/INTEGRATIONS.md | worked examples: a Python batch download, a Node fetch script, a logtail watcher |
Everything but register.tsx is pure and tested without Claude Code.
Three suites:
tests/*.spec.ts, plain node:test: formatting, protocol parsing (broken files, injection attempts), the EMA (uneven sample spacing, counter resets), phases and order, every layout at every width from 1 to 120 columns, watchers, the finish (every frame valid at every size, starts at the bar, word letter by letter, dissolves to nothing, see-through, monochrome), and drift guards that hold this README to the code (versions, test counts, config fields, commands, protocol fields).hooks/*.test.tsx, run by claude plugin test . against Claude Code's own engine with a faked file system, clock and processes: the band on terminal and desktop, stall and recovery, a dead pid, done → hidden → cleaned up, errors and /taskline clear, a file caught mid-write, a broken file next to a good one, a logtail watcher, a globbed log path switching to a new run, the finish (Raster above the band, repainted at 30 fps, gone after the show; one line on the desktop or in a tight band; queued when two jobs finish at once; never for a job that was already done), NO_COLOR, a narrow band, the survey yielding, every command.tests/test_taskline.py with pytest: atomic writes, throttling, the context manager's done/error/interrupted, the CLI.Every new test is mutated once. A test never seen red is not an assurance, so each guarded behaviour gets its bug put back and the suite must go red — see docs/MUTATIONS.md.
npm install # dev tools only; the mod has no dependencies
npm test # node suite (CI)
claude plugin test . # engine suite
python3 -m pytest tests # python suite (CI)
claude plugin validate . # what the module hooks and calls
npm run screenshots # re-render docs/*.png and the social card (uses your Chrome)
Does it slow Claude Code down? No. One directory listing per second, files re-read only when they change, and nothing at all is drawn while no job reports.
My job runs in a venv and import taskline fails. The user site-packages are not visible in a venv: pip install -e /path/to/taskline there, or copy python/taskline.py next to your script.
A job crashed but shows "stalled", not "aborted". It did not report a pid (the CLI needs --pid $$), or host processes are unavailable on this surface.
Can two Claude Code sessions show the same jobs? Yes — they read the same directory. Whichever cleans up first removes a finished file; the other simply stops seeing it.
I updated taskline, but another session still shows the old one. A running session reads the plugin once, at start. Type /reload-plugins there (or resume it with claude --continue); `/
hooks/register.tsx 576 lines1/**
2 * taskline — every long-running job's progress, live above the prompt.
3 *
4 * Polls ~/.claude/progress/*.json (PROTOCOL.md) and the watchers once a
5 * second, keeps a smoothed speed per task for the ETA, and draws the band.
6 * Everything that can fail is caught: a broken file or an unreadable log costs
7 * that one task, never the band and never the session.
8 */
9import type { EngineInterface, PluginOptions, Register, RenderNode, Timer } from 'claude-code'
10
11import type { Celebration, Layout, Prefs, Snapshot, Task } from '../types'
12import { CELEBRATE_FPS, CELEBRATE_MS, encodeCells, frame, needs, planScene, seedOf } from './celebrate'
13import type { Scene } from './celebrate'
14import { advanceAll } from './eta'
15import { PALETTE, barColumns, layoutRows } from './layout'
16import type { Row } from './layout'
17import { MAX_FILE_BYTES, idOfFile, parseTask } from './protocol'
18import { DEFAULT_TIMING, phaseOf, pidsToCheck } from './state'
19import type { Timing } from './state'
20import { buildViews, expiredFiles, finished, isAnimated } from './views'
21import { TAIL_BYTES, countMatches, expandHome, newestMatch, parseWatchers, readLog, splitGlob, watcherTask } from './watchers'
22import type { Reading, Watcher } from './watchers'
23
24const EMPTY: Snapshot = { tasks: [], alive: {}, rates: {} }
25const DEFAULT_PREFS: Prefs = { layout: 'auto', hidden: false }
26
27// --- what the band draws, in $.state (keys written out: the directory reads them) ------
28
29async function getSnapshot($: EngineInterface): Promise<Snapshot> {
30 const { value } = await $.state.get({ plugin: 'taskline', key: 'snapshot' })
31 return value ?? EMPTY
32}
33async function setSnapshot($: EngineInterface, value: Snapshot): Promise<void> {
34 await $.state.set({ plugin: 'taskline', key: 'snapshot' }, value)
35}
36async function getPrefs($: EngineInterface): Promise<Prefs> {
37 const { value } = await $.state.get({ plugin: 'taskline', key: 'prefs' })
38 return value ?? DEFAULT_PREFS
39}
40async function setPrefs($: EngineInterface, value: Prefs): Promise<void> {
41 await $.state.set({ plugin: 'taskline', key: 'prefs' }, value)
42}
43async function getCelebration($: EngineInterface): Promise<Celebration | null> {
44 const { value } = await $.state.get({ plugin: 'taskline', key: 'celebration' })
45 return value ?? null
46}
47async function setCelebration($: EngineInterface, value: Celebration | null): Promise<void> {
48 await $.state.set({ plugin: 'taskline', key: 'celebration' }, value)
49}
50/** The Raster the finish is drawn in; `$.ui.blit` repaints it by this key. */
51const RASTER_KEY = 'taskline-check'
52/** The finish's rows at most: the large show and a row of air for the sparks. */
53const CELEBRATE_ROWS = needs(2).rows + 1
54
55const LAYOUTS: readonly Layout[] = ['auto', 'single', 'stacked']
56const TICK_MS = 250
57const POLL_EVERY = 4 // ticks → 1 s
58const PID_EVERY_MS = 5000
59const CLEANUP_EVERY_MS = 5000
60/** Up to this size a log is read whole and its tail cut here; larger ones go through `tail`. */
61const READ_WHOLE_BELOW = 256 * 1024
62
63type Config = {
64 layout: Layout
65 maxTasks: number
66 timing: Timing
67 color: boolean
68 animation: boolean
69 celebrate: boolean
70 cleanup: boolean
71 progressDir: string
72 watchersFile: string
73}
74
75const pos = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : d)
76
77export function configOf(options: PluginOptions): Config {
78 const str = (v: unknown, d: string) => (typeof v === 'string' && v.trim() ? v.trim() : d)
79 return {
80 layout: LAYOUTS.includes(options.layout as Layout) ? (options.layout as Layout) : 'auto',
81 maxTasks: Math.round(pos(options.maxTasks, 3)),
82 timing: {
83 stalledAfter: pos(options.stalledAfter, DEFAULT_TIMING.stalledAfter),
84 doneVisible: pos(options.doneVisible, DEFAULT_TIMING.doneVisible),
85 errorVisible: pos(options.errorVisible, DEFAULT_TIMING.errorVisible),
86 },
87 color: options.color !== false,
88 animation: options.animation !== false,
89 celebrate: options.celebrate !== false,
90 cleanup: options.cleanup !== false,
91 progressDir: str(options.progressDir, '~/.claude/progress'),
92 watchersFile: str(options.watchersFile, '~/.claude/taskline/watchers.json'),
93 }
94}
95
96// --- module state (rebuilt on reload; what the band draws lives in $.state) ---------
97
98let timer: Timer | undefined
99let ticks = 0
100let polling = false
101let animated = false
102let home = ''
103let colorEnv = true
104let canRun = true
105/** progress file name → what it was the last time it was read */
106const files = new Map<string, { mtimeMs: number; size: number; task: Task | null }>()
107let watcherCfg: { mtimeMs: number; watchers: Watcher[]; errors: string[] } = { mtimeMs: -1, watchers: [], errors: [] }
108/** watcher id → cache of its last reading, keyed by the source's mtime/size */
109const readings = new Map<string, { key: string; reading: Reading | null }>()
110const firstSeen = new Map<string, number>()
111/** watcher id → the file a globbed path resolved to last time: a new file is a new run */
112const resolved = new Map<string, string>()
113let lastPidCheck = 0
114let lastCleanup = 0
115let demo: Timer | undefined
116
117// the finish: one at a time, the rest queued
118let showTimer: Timer | undefined
119const queue: Celebration[] = []
120/** the band's instance and the Raster as last drawn there: what a blit must match */
121let mounted: { requestId: string; cols: number; rows: number } | null = null
122let scene: { key: string; scene: Scene | null } | null = null
123
124function sceneFor(c: Celebration, cols: number, rows: number, color: boolean): Scene | null {
125 const key = `${c.id}:${c.startedAt}:${cols}:${rows}:${color}`
126 if (scene?.key !== key) scene = { key, scene: planScene({ cols, rows, seed: seedOf(key), color, accent: PALETTE.accent, bar: c.bar }) }
127 return scene.scene
128}
129
130async function celebrate($: EngineInterface, cfg: Config, c: Celebration): Promise<void> {
131 if (!cfg.celebrate) return
132 const current = await getCelebration($)
133 const now = await $.clock.now()
134 if (current && now - current.startedAt < CELEBRATE_MS) {
135 if (queue.length < 3) queue.push(c)
136 return
137 }
138 await setCelebration($, { ...c, startedAt: now })
139 $.ui.invalidate('ui.render')
140 showTimer?.cancel()
141 showTimer = $.clock.every(Math.round(1000 / CELEBRATE_FPS), () => void step($, cfg))
142}
143
144/** One frame of the finish: repaint the Raster in place; at the end, the next one or nothing. */
145async function step($: EngineInterface, cfg: Config): Promise<void> {
146 const c = await getCelebration($)
147 const now = await $.clock.now()
148 if (!c || now - c.startedAt >= CELEBRATE_MS) {
149 showTimer?.cancel()
150 showTimer = undefined
151 await setCelebration($, null)
152 $.ui.invalidate('ui.render')
153 const nextUp = queue.shift()
154 if (nextUp) await celebrate($, cfg, nextUp)
155 return
156 }
157 const sc = mounted ? sceneFor(c, mounted.cols, mounted.rows, cfg.color && colorEnv) : null
158 if (!mounted || !sc) return
159 const cells = encodeCells(frame(sc, now - c.startedAt))
160 await $.ui.blit({ requestId: mounted.requestId, key: RASTER_KEY, cells }).catch(() => undefined)
161}
162
163const dirOf = (cfg: Config) => expandHome(cfg.progressDir, home).replace(/\/+$/, '')
164
165async function readFiles($: EngineInterface, cfg: Config): Promise<Task[]> {
166 const dir = dirOf(cfg)
167 const entries = await $.fs.list(dir).catch(() => [])
168 const seen = new Set<string>()
169 const tasks: Task[] = []
170 for (const entry of entries) {
171 const id = idOfFile(entry.name)
172 if (!id || entry.kind !== 'file' || entry.size > MAX_FILE_BYTES) continue
173 seen.add(entry.name)
174 const cached = files.get(entry.name)
175 let task = cached?.task ?? null
176 if (!cached || cached.mtimeMs !== entry.mtimeMs || cached.size !== entry.size) {
177 const path = `${dir}/${entry.name}`
178 const text = await $.fs.read(path).catch(() => null)
179 const parsed = typeof text === 'string' ? parseTask(text, id, entry.mtimeMs, path) : null
180 // a file caught mid-write by a non-atomic writer keeps its last good state
181 task = parsed ?? cached?.task ?? null
182 files.set(entry.name, { mtimeMs: entry.mtimeMs, size: entry.size, task })
183 }
184 if (task) tasks.push(task)
185 }
186 for (const name of [...files.keys()]) if (!seen.has(name)) files.delete(name)
187 return tasks
188}
189
190async function loadWatchers($: EngineInterface, cfg: Config): Promise<Watcher[]> {
191 const path = expandHome(cfg.watchersFile, home)
192 const st = await $.fs.stat(path).catch(() => null)
193 if (!st) {
194 watcherCfg = { mtimeMs: -1, watchers: [], errors: [] }
195 return []
196 }
197 if (st.mtimeMs !== watcherCfg.mtimeMs) {
198 const text = await $.fs.read(path).catch(() => null)
199 const parsed = typeof text === 'string' ? parseWatchers(text) : { watchers: [], errors: [`cannot read ${path}`] }
200 watcherCfg = { mtimeMs: st.mtimeMs, ...parsed }
201 for (const err of parsed.errors) $.ui.log(`taskline: ${err}`, { to: 'debug' })
202 }
203 return watcherCfg.watchers
204}
205
206async function tail($: EngineInterface, path: string, size: number): Promise<string | null> {
207 if (size <= READ_WHOLE_BELOW) {
208 const text = await $.fs.read(path).catch(() => null)
209 return typeof text === 'string' ? text.slice(-TAIL_BYTES) : null
210 }
211 if (!canRun) return null
212 const r = await $.process.run(['tail', '-c', String(TAIL_BYTES), path], { timeoutMs: 3000 }).catch(() => null)
213 return r && r.exitCode === 0 ? r.stdout : null
214}
215
216/** The file a watcher reads: its path, or for `fetch*.log` the newest match in that folder. */
217async function resolvePath($: EngineInterface, w: Watcher): Promise<string | null> {
218 const path = expandHome(w.path, home)
219 const g = w.type === 'dircount' ? null : splitGlob(path)
220 if (!g) return path
221 const entries = await $.fs.list(g.dir).catch(() => [])
222 const name = newestMatch(entries, g.glob)
223 return name ? `${g.dir === '/' ? '' : g.dir}/${name}` : null
224}
225
226async function measure($: EngineInterface, w: Watcher, path: string): Promise<Reading | null> {
227 const st = await $.fs.stat(path).catch(() => null)
228 if (!st) return null
229 const key = `${path}:${st.mtimeMs}:${st.size}`
230 const cached = readings.get(w.id)
231 if (cached?.key === key) return cached.reading
232 let reading: Reading | null = null
233 if (w.type === 'filesize' && st.kind === 'file') reading = { done: st.size, mtimeMs: st.mtimeMs }
234 if (w.type === 'logtail' && st.kind === 'file') {
235 const text = await tail($, path, st.size)
236 const r = text === null ? null : readLog(w, text)
237 reading = r ? { ...r, mtimeMs: st.mtimeMs } : null
238 }
239 if (w.type === 'dircount' && st.kind === 'dir') {
240 const entries = await $.fs.list(path).catch(() => [])
241 const newest = entries.reduce((m, e) => Math.max(m, e.mtimeMs), st.mtimeMs)
242 reading = { done: countMatches(w, entries), mtimeMs: newest }
243 }
244 readings.set(w.id, { key, reading })
245 return reading
246}
247
248async function readWatchers($: EngineInterface, cfg: Config, now: number): Promise<Task[]> {
249 const tasks: Task[] = []
250 const live = new Set<string>()
251 for (const w of await loadWatchers($, cfg)) {
252 live.add(w.id)
253 const path = await resolvePath($, w).catch(() => null)
254 if (!path) continue
255 if (resolved.has(w.id) && resolved.get(w.id) !== path) firstSeen.delete(w.id) // another file: a new run
256 resolved.set(w.id, path)
257 const reading = await measure($, w, path).catch(() => null)
258 if (!reading) continue
259 if (now - reading.mtimeMs > w.activeWithin * 1000) {
260 firstSeen.delete(w.id) // idle: the next activity is a new run
261 continue
262 }
263 if (!firstSeen.has(w.id)) firstSeen.set(w.id, Math.min(now, reading.mtimeMs))
264 const task = watcherTask(w, reading, now, firstSeen.get(w.id)!, path)
265 if (task) tasks.push(task)
266 }
267 for (const id of [...readings.keys()]) if (!live.has(id)) readings.delete(id)
268 for (const id of [...resolved.keys()]) if (!live.has(id)) resolved.delete(id)
269 return tasks
270}
271
272/** pid → alive. `kill -0` answers EPERM for another user's live process: that is alive too. */
273async function checkPids($: EngineInterface, pids: number[], prev: Record<string, boolean>): Promise<Record<string, boolean>> {
274 const alive: Record<string, boolean> = {}
275 for (const pid of pids) {
276 if (!canRun) break
277 const r = await $.process.run(['kill', '-0', String(pid)], { timeoutMs: 2000 }).catch(() => null)
278 if (r === null) canRun = false // no host processes on this surface: liveness stays unknown
279 if (r) alive[String(pid)] = r.exitCode === 0 || /not permitted/i.test(r.stderr)
280 else if (prev[String(pid)] !== undefined) alive[String(pid)] = prev[String(pid)]!
281 }
282 return alive
283}
284
285async function removeFiles($: EngineInterface, cfg: Config, tasks: readonly Task[]): Promise<number> {
286 const dir = dirOf(cfg)
287 // only our own kind of file, only in our directory
288 const paths = tasks.map(t => t.path).filter((p): p is string => !!p && p.startsWith(`${dir}/`) && !!idOfFile(p.slice(dir.length + 1)))
289 if (!paths.length || !canRun) return 0
290 const r = await $.process.run(['rm', '-f', '--', ...paths], { timeoutMs: 3000 }).catch(() => null)
291 if (r?.exitCode !== 0) return 0
292 for (const p of paths) files.delete(p.slice(dir.length + 1))
293 return paths.length
294}
295
296async function poll($: EngineInterface, cfg: Config): Promise<void> {
297 if (polling) return
298 polling = true
299 try {
300 const now = await $.clock.now()
301 const prev = await getSnapshot($)
302 const tasks = [...(await readFiles($, cfg)), ...(await readWatchers($, cfg, now))]
303
304 let alive = prev.alive
305 if (now - lastPidCheck >= PID_EVERY_MS || pidsToCheck(tasks).some(p => prev.alive[String(p)] === undefined)) {
306 lastPidCheck = now
307 alive = await checkPids($, pidsToCheck(tasks), prev.alive)
308 }
309 const next: Snapshot = { tasks, alive, rates: advanceAll(prev.rates, tasks) }
310 for (const t of finished(prev.tasks, tasks)) await celebrate($, cfg, { id: t.id, label: t.label, startedAt: now, bar: barColumns(t) })
311
312 if (cfg.cleanup && now - lastCleanup >= CLEANUP_EVERY_MS) {
313 lastCleanup = now
314 const expired = expiredFiles(next, now, cfg.timing)
315 if (expired.length && (await removeFiles($, cfg, expired))) {
316 const gone = new Set(expired.map(t => t.id))
317 next.tasks = next.tasks.filter(t => t.source !== 'file' || !gone.has(t.id))
318 }
319 }
320
321 if (JSON.stringify(next) !== JSON.stringify(prev)) await setSnapshot($, next)
322 const views = buildViews(next, now, cfg.timing)
323 animated = isAnimated(views)
324 // ages, countdowns and the display window of done tasks move with time alone
325 if (views.length || prev.tasks.length) $.ui.invalidate('ui.render')
326 } catch (err) {
327 $.ui.log(`taskline: poll failed: ${(err as Error).message}`, { to: 'debug' })
328 } finally {
329 polling = false
330 }
331}
332
333// --- demo: three fake jobs, written as real progress files -------------------------------
334
335async function runDemo($: EngineInterface, cfg: Config): Promise<void> {
336 demo?.cancel()
337 const dir = dirOf(cfg)
338 const t0 = await $.clock.now()
339 const s = (ms: number) => ms / 1000
340 const write = (id: string, body: Record<string, unknown>) =>
341 $.fs.write(`${dir}/${id}.json`, JSON.stringify({ v: 1, id, ...body })).catch(() => undefined)
342 const GB = 3.1e9
343 await write('demo-stuck', {
344 label: 'Tiles', icon: '⬇', done: 412, total: 3400, unit: 'files',
345 started_at: s(t0 - 600_000), updated_at: s(t0 - 95_000),
346 })
347 demo = $.clock.every(500, () => {
348 void (async () => {
349 const now = await $.clock.now()
350 const el = now - t0
351 const f = Math.min(1, el / 24_000)
352 // a download that speeds up and slows down, like a real one
353 const wobble = f + 0.03 * Math.sin(el / 1500) * (1 - f) * f
354 await write('demo-download', {
355 label: 'Bridge', icon: '⬇', done: Math.round(1000 * Math.min(1, wobble)), total: 1000, unit: 'files',
356 bytes: Math.round(GB * Math.min(1, wobble)), bytes_total: GB,
357 status: f >= 1 ? 'done' : 'running', started_at: s(t0), updated_at: s(now),
358 })
359 await write('demo-scan', {
360 label: 'Index', icon: '🔍', done: Math.round(el / 7), total: null, unit: 'items',
361 status: el >= 30_000 ? 'done' : 'running', started_at: s(t0), updated_at: s(now),
362 })
363 if (el >= 34_000) {
364 demo?.cancel()
365 demo = undefined
366 await write('demo-stuck', {
367 label: 'Tiles', icon: '⬇', done: 412, total: 3400, unit: 'files', status: 'error',
368 message: 'HTTP 503 from tile server', started_at: s(t0 - 600_000), updated_at: s(now),
369 })
370 }
371 })()
372 })
373}
374
375// --- the band ---------------------------------------------------------------------------
376
377function statusText(s: Snapshot, now: number, cfg: Config, prefs: Prefs): string {
378 const lines = [`taskline — ${s.tasks.length} task(s) · layout ${prefs.layout}${prefs.hidden ? ' · hidden' : ''}`]
379 for (const t of s.tasks) {
380 const phase = phaseOf(t, now, cfg.timing, t.pid === undefined ? undefined : s.alive[String(t.pid)])
381 const of = t.total === null ? '?' : String(t.total)
382 lines.push(` ${t.id.padEnd(22)} ${phase.padEnd(8)} ${t.done}/${of} ${t.unit}${t.message ? ` ${t.message}` : ''}`)
383 if (t.path) lines.push(` ${' '.repeat(22)} ${t.source === 'watcher' ? 'watching' : 'file'} ${t.path}`)
384 }
385 lines.push(`progress files: ${dirOf(cfg)}`)
386 lines.push(`watchers: ${expandHome(cfg.watchersFile, home)} (${watcherCfg.watchers.length} active)`)
387 for (const err of watcherCfg.errors) lines.push(` ⚠ ${err}`)
388 return lines.join('\n')
389}
390
391const HELP = [
392 'Commands:',
393 ' /taskline list tasks, watchers and where they come from',
394 ' /taskline clear [all] remove finished tasks (all: stalled ones too)',
395 ' /taskline rm <id> remove one task',
396 ' /taskline hide | show hide or show the band',
397 ' /taskline layout auto|single|stacked',
398 ' /taskline demo three fake jobs for 35 s',
399 ' /taskline check play the finish (the bar bursts, CHECK!!)',
400].join('\n')
401
402export const register: Register = (on, options) => {
403 const cfg = configOf(options)
404
405 on('session.start', async ($, e, next) => {
406 home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
407 const noColor = await $.env.get('NO_COLOR').catch(() => undefined)
408 colorEnv = !noColor
409 const stored = (await $.store.get('prefs').catch(() => null)) as Partial<Prefs> | null
410 await setPrefs($, {
411 layout: LAYOUTS.includes(stored?.layout as Layout) ? (stored!.layout as Layout) : cfg.layout,
412 hidden: stored?.hidden === true,
413 })
414 await $.command.register({
415 name: 'taskline',
416 description: 'Progress of long-running jobs: list, clear, hide/show, layout, demo',
417 argumentHint: '[clear [all]|rm <id>|hide|show|layout auto|single|stacked|demo|check|help]',
418 immediate: true,
419 })
420 timer?.cancel()
421 ticks = 0
422 timer = $.clock.every(TICK_MS, () => {
423 ticks += 1
424 if (ticks % POLL_EVERY === 1) void poll($, cfg)
425 else if (animated && cfg.animation) $.ui.invalidate('ui.render')
426 })
427 void poll($, cfg)
428 return next(e)
429 })
430
431 on('command.run', { command: 'taskline' }, async ($, e) => {
432 const [cmd = '', arg = ''] = e.args.trim().split(/\s+/)
433 const prefs = await getPrefs($)
434 const save = async (p: Prefs) => {
435 await setPrefs($, p)
436 await $.store.set('prefs', p)
437 $.ui.invalidate('ui.render')
438 }
439 const now = await $.clock.now()
440 const snap = await getSnapshot($)
441
442 switch (cmd.toLowerCase()) {
443 case '':
444 case 'ls':
445 case 'list':
446 case 'status':
447 return { text: statusText(snap, now, cfg, prefs) }
448 case 'hide':
449 await save({ ...prefs, hidden: true })
450 return { text: 'taskline hidden (/taskline show brings it back)' }
451 case 'show':
452 await save({ ...prefs, hidden: false })
453 return { text: 'taskline shown' }
454 case 'layout': {
455 if (!LAYOUTS.includes(arg as Layout)) return { text: `layout is ${prefs.layout}; choose ${LAYOUTS.join(', ')}` }
456 await save({ ...prefs, layout: arg as Layout })
457 return { text: `layout ${arg}` }
458 }
459 case 'clear': {
460 const all = arg === 'all'
461 const doomed = snap.tasks.filter(t => {
462 if (t.source !== 'file') return false
463 const phase = phaseOf(t, now, cfg.timing, t.pid === undefined ? undefined : snap.alive[String(t.pid)])
464 return phase !== 'running' && (all || phase !== 'stalled')
465 })
466 const n = await removeFiles($, cfg, doomed)
467 await poll($, cfg)
468 return { text: n ? `removed ${n} task(s)` : canRun ? 'nothing to clear' : 'cannot remove files on this surface' }
469 }
470 case 'rm': {
471 const task = snap.tasks.find(t => t.id === arg && t.source === 'file')
472 if (!task) return { text: `no progress file "${arg}" (watchers are configured in ${cfg.watchersFile})` }
473 const n = await removeFiles($, cfg, [task])
474 await poll($, cfg)
475 return { text: n ? `removed ${arg}` : `could not remove ${arg}` }
476 }
477 case 'check':
478 if (!cfg.celebrate) return { text: 'the finish is off (celebrate in /config)' }
479 await celebrate($, cfg, { id: 'taskline-check', label: 'taskline', startedAt: now, bar: [2, 22] })
480 return { text: 'CHECK!!' }
481 case 'demo':
482 await runDemo($, cfg)
483 await poll($, cfg)
484 return { text: 'demo running for 35 s (above the prompt)' }
485 default:
486 return { text: HELP }
487 }
488 })
489
490 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
491 if (e.props.hasSurvey) return next(e)
492 const prefs = await getPrefs($)
493 const snap = await getSnapshot($)
494 const now = await $.clock.now()
495 const cel = await getCelebration($)
496 const showing = cel && cfg.celebrate && !prefs.hidden && now - cel.startedAt < CELEBRATE_MS ? cel : null
497 if (prefs.hidden || (snap.tasks.length === 0 && !showing)) return next(e)
498
499 const views = buildViews(snap, now, cfg.timing)
500 if (views.length === 0 && !showing) return next(e)
501 const color = cfg.color && colorEnv
502 const width = e.props.bodyColumns
503 const budget = Math.max(1, e.props.maxRows - 1)
504
505 // the finish takes the rows it can get above the tasks; one row stays for them
506 let raster: { rows: number; scene: Scene } | null = null
507 if (showing && e.surface === 'terminal' && cfg.animation) {
508 const rowsFree = Math.min(CELEBRATE_ROWS, budget - (views.length ? 1 : 0))
509 const sc = rowsFree > 0 ? sceneFor(showing, width, rowsFree, color) : null
510 if (sc) raster = { rows: rowsFree, scene: sc }
511 }
512 mounted = raster ? { requestId: e.requestId, cols: width, rows: raster.rows } : null
513 const used = raster ? raster.rows : showing ? 1 : 0
514 const rows: Row[] = views.length
515 ? layoutRows(views, {
516 width,
517 layout: prefs.layout,
518 maxTasks: cfg.maxTasks,
519 maxRows: Math.max(1, budget - used),
520 nowMs: now,
521 color,
522 })
523 : []
524 if (rows.length === 0 && !showing) return next(e)
525
526 const { Box, Text } = $.ui.resolve(e)
527 let top: RenderNode | null = null
528 if (raster && e.surface === 'terminal') {
529 const { Raster } = $.ui.resolve(e)
530 top = (
531 <Raster
532 key={RASTER_KEY}
533 columns={width}
534 rows={raster.rows}
535 cells={encodeCells(frame(raster.scene, now - showing!.startedAt))}
536 />
537 )
538 } else if (showing) {
539 // no room, no animation, or a surface without a Raster: the finish in one line
540 top = (
541 <Box key="taskline-check-line" flexDirection="row">
542 <Text color={color ? PALETTE.ok : undefined} bold wrap="truncate-end">
543 {`✔ CHECK!!`}
544 </Text>
545 <Text dimColor wrap="truncate-end">{` ${showing.label}`}</Text>
546 </Box>
547 )
548 }
549
550 const band = (
551 <Box key="taskline" flexDirection="column">
552 {top}
553 {rows.map((row, i) => (
554 <Box key={`row-${i}`} flexDirection="row">
555 {row.map((s, j) => (
556 <Text key={`s-${i}-${j}`} color={s.color} dimColor={s.dim} bold={s.bold} wrap="truncate-end">
557 {s.text}
558 </Text>
559 ))}
560 </Box>
561 ))}
562 </Box>
563 )
564 // other plugins' bands stay: ours goes first, theirs below
565 const below = await next(e)
566 return below ? (
567 <Box flexDirection="column">
568 {band}
569 {below}
570 </Box>
571 ) : (
572 band
573 )
574 })
575}
576hooks/celebrate.ts 535 lines1/**
2 * The finish: when a bar fills, it bursts into sparks, a shock ring runs out,
3 * a check mark draws itself and CHECK!! drops in letter by letter, a light
4 * sweeps across, embers rise, and the whole thing dissolves. Pure.
5 *
6 * Drawn on a pixel canvas twice as tall as the band's rows: each cell shows
7 * two pixels with a half block (top pixel as foreground, bottom as background),
8 * so motion is smooth vertically too. Light adds up (sparks crossing glow
9 * brighter), a pixel too dark to see stays the terminal's own background — the
10 * band must stay see-through on a translucent terminal.
11 *
12 * Every frame is a function of `t` alone (positions are integrated in closed
13 * form), so any moment renders exactly, in a test or a screenshot.
14 */
15
16/** How long the whole show runs, ms. */
17export const CELEBRATE_MS = 2800
18/** Frames per second the mod repaints at while it runs. */
19export const CELEBRATE_FPS = 30
20
21/** A cell's colour meaning "the terminal's own" (bit 24 alone). */
22export const DEFAULT_COLOR = 0x01000000
23const SPACE = 0x20
24const UPPER = 0x2580 // ▀
25const LOWER = 0x2584 // ▄
26const FULL = 0x2588 // █
27
28type RGB = readonly [number, number, number]
29
30const hex = (h: string): RGB => {
31 const n = parseInt(h.replace('#', ''), 16)
32 return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]
33}
34const WHITE: RGB = [1, 1, 1]
35const HOT: RGB = hex('#fff1c2')
36const GREEN: RGB = hex('#3fb950')
37const GREEN_HI: RGB = hex('#7ee787')
38const GREEN_DEEP: RGB = hex('#196c2e')
39const SHADOW: RGB = hex('#0b3d1a')
40const GOLD: RGB = hex('#ffd36b')
41
42/** Below this (brightest channel) a pixel is not drawn at all. */
43const VISIBLE = 0.07
44
45// --- the CHECK!! font: 7 rows, bold, proportional ------------------------------------------
46
47const GLYPHS: Record<string, readonly string[]> = {
48 C: ['.###.', '##.##', '##...', '##...', '##...', '##.##', '.###.'],
49 H: ['##.##', '##.##', '##.##', '#####', '##.##', '##.##', '##.##'],
50 E: ['#####', '##...', '##...', '####.', '##...', '##...', '#####'],
51 K: ['##..#', '##.##', '####.', '###..', '####.', '##.##', '##..#'],
52 '!': ['##', '##', '##', '##', '##', '..', '##'],
53}
54export const WORD = 'CHECK!!'
55const GLYPH_H = 7
56
57// --- deterministic randomness --------------------------------------------------------------
58
59/** mulberry32: a small, good, seedable PRNG. */
60export function rng(seed: number): () => number {
61 let a = seed >>> 0
62 return () => {
63 a = (a + 0x6d2b79f5) >>> 0
64 let t = a
65 t = Math.imul(t ^ (t >>> 15), t | 1)
66 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
67 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
68 }
69}
70
71/** A stable 32-bit seed from a string (FNV-1a). */
72export function seedOf(s: string): number {
73 let h = 0x811c9dc5
74 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
75 return h >>> 0
76}
77
78// --- easing --------------------------------------------------------------------------------
79
80const clamp01 = (x: number) => (x < 0 ? 0 : x > 1 ? 1 : x)
81const easeOut = (x: number) => 1 - (1 - clamp01(x)) ** 3
82const easeInOut = (x: number) => {
83 const v = clamp01(x)
84 return v < 0.5 ? 4 * v * v * v : 1 - (-2 * v + 2) ** 3 / 2
85}
86const backOut = (x: number) => {
87 const v = clamp01(x)
88 const c = 1.9
89 return 1 + (c + 1) * (v - 1) ** 3 + c * (v - 1) ** 2
90}
91const mix = (a: RGB, b: RGB, f: number): RGB => [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f]
92
93/** 4×4 Bayer thresholds, 0..1: an ordered dissolve instead of a hard cut. */
94const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(v => (v + 0.5) / 16)
95
96// --- the scene -----------------------------------------------------------------------------
97
98type Particle = {
99 x0: number
100 y0: number
101 vx: number
102 vy: number
103 born: number
104 life: number
105 size: number
106 twinkle: number
107 ember: boolean
108}
109
110export type Scene = {
111 /** cells */
112 cols: number
113 rows: number
114 /** pixels: cols × rows*2 */
115 w: number
116 h: number
117 /** font pixel size in canvas pixels: 2 (large) or 1 (compact) */
118 scale: 1 | 2
119 color: boolean
120 accent: RGB
121 /** where the bar was, in canvas pixels, on the bottom pixel row */
122 barX0: number
123 barX1: number
124 /** the check mark: three points in canvas pixels, and its stroke width */
125 check: readonly [readonly [number, number], readonly [number, number], readonly [number, number]]
126 stroke: number
127 /** text origin (top-left), canvas pixels */
128 textX: number
129 textY: number
130 particles: Particle[]
131}
132
133/** Width in font pixels of the word, letters 1 apart. */
134function wordWidth(): number {
135 let w = 0
136 for (const ch of WORD) w += GLYPHS[ch]![0]!.length + 1
137 return w - 1
138}
139
140/** The size the show needs: [columns, rows] for each scale. */
141export function needs(scale: 1 | 2): { cols: number; rows: number } {
142 const textW = wordWidth() * scale
143 const checkH = GLYPH_H * scale + 2 * scale
144 const checkW = Math.round(checkH * 1.25)
145 return { cols: checkW + 4 * scale + textW + 6, rows: Math.ceil((checkH + 2) / 2) + 2 }
146}
147
148/**
149 * Lay the show out in a band of `cols` × `rows` cells, or null when even the
150 * compact one does not fit (the caller falls back to one line of text).
151 * @param bar the bar's columns in the row beneath, [from, to)
152 */
153export function planScene(opts: {
154 cols: number
155 rows: number
156 seed: number
157 color: boolean
158 accent?: string
159 bar?: readonly [number, number]
160}): Scene | null {
161 const { cols, rows } = opts
162 const scale: 1 | 2 | null = cols >= needs(2).cols && rows >= needs(2).rows ? 2 : cols >= needs(1).cols && rows >= needs(1).rows ? 1 : null
163 if (scale === null) return null
164 const w = cols
165 const h = rows * 2
166 const textW = wordWidth() * scale
167 const checkH = GLYPH_H * scale + 2 * scale
168 const checkW = Math.round(checkH * 1.25)
169 const gap = 4 * scale
170 const total = checkW + gap + textW
171 const left = Math.floor((w - total) / 2)
172 const bottom = h - 2 // a pixel row of air above the task row beneath
173 const top = bottom - checkH
174 const stroke = scale === 2 ? 2.6 : 1.7
175 const check = [
176 [left + checkW * 0.04, top + checkH * 0.56],
177 [left + checkW * 0.36, top + checkH * 0.92],
178 [left + checkW * 0.97, top + checkH * 0.06],
179 ] as const
180 const textY = bottom - GLYPH_H * scale - Math.round(scale / 2)
181
182 const bar0 = Math.max(0, Math.min(w - 2, opts.bar?.[0] ?? left))
183 const bar1 = Math.max(bar0 + 2, Math.min(w, opts.bar?.[1] ?? left + 20))
184
185 const rand = rng(opts.seed)
186 const particles: Particle[] = []
187 // the burst: out of the bar, mostly up and out, a fountain more than a sphere
188 const n = Math.max(70, Math.min(260, (bar1 - bar0) * 9))
189 const mid = (bar0 + bar1) / 2
190 for (let i = 0; i < n; i++) {
191 const x0 = bar0 + rand() * (bar1 - bar0)
192 const out = (x0 - mid) / Math.max(1, (bar1 - bar0) / 2) // -1 … 1 along the bar
193 const ang = -Math.PI / 2 + out * 0.9 + (rand() - 0.5) * 1.9
194 const speed = (24 + rand() ** 0.6 * 72) * (scale === 2 ? 1.15 : 0.9)
195 particles.push({
196 x0,
197 y0: h - 1,
198 vx: Math.cos(ang) * speed * 1.7, // cells are tall: stretch sideways so the cloud reads round
199 vy: Math.sin(ang) * speed,
200 born: 40 + rand() * 120,
201 life: 520 + rand() * 900,
202 size: rand() < 0.14 ? 1.6 : 1,
203 twinkle: rand() < 0.3 ? rand() * 6.28 : -1,
204 ember: false,
205 })
206 }
207 // embers: slow sparks rising off the word while it holds
208 for (let i = 0; i < 34; i++) {
209 particles.push({
210 x0: left + rand() * total,
211 y0: top + rand() * checkH,
212 vx: (rand() - 0.5) * 10,
213 vy: -(6 + rand() * 14),
214 born: 950 + rand() * 1100,
215 life: 600 + rand() * 700,
216 size: 1,
217 twinkle: rand() * 6.28,
218 ember: true,
219 })
220 }
221 return {
222 cols,
223 rows,
224 w,
225 h,
226 scale,
227 color: opts.color,
228 accent: hex(opts.accent ?? '#79c0ff'),
229 barX0: bar0,
230 barX1: bar1,
231 check,
232 stroke,
233 textX: left + checkW + gap,
234 textY,
235 particles,
236 }
237}
238
239// --- drawing -------------------------------------------------------------------------------
240
241/** Light on the canvas: additive, so crossings glow. */
242class Canvas {
243 readonly px: Float32Array
244 constructor(
245 readonly w: number,
246 readonly h: number,
247 ) {
248 this.px = new Float32Array(w * h * 3)
249 }
250 add(x: number, y: number, c: RGB, k: number): void {
251 const xi = Math.round(x)
252 const yi = Math.round(y)
253 if (xi < 0 || yi < 0 || xi >= this.w || yi >= this.h || k <= 0) return
254 const i = (yi * this.w + xi) * 3
255 this.px[i] = this.px[i]! + c[0] * k
256 this.px[i + 1] = this.px[i + 1]! + c[1] * k
257 this.px[i + 2] = this.px[i + 2]! + c[2] * k
258 }
259 /** a soft dot of radius r, spread over the pixels it covers */
260 dot(x: number, y: number, r: number, c: RGB, k: number): void {
261 if (r <= 1) {
262 this.add(x, y, c, k)
263 return
264 }
265 const R = Math.ceil(r)
266 for (let dy = -R; dy <= R; dy++)
267 for (let dx = -R; dx <= R; dx++) {
268 const d = Math.hypot(dx, dy)
269 if (d <= r) this.add(x + dx, y + dy, c, k * (1 - d / (r + 0.6)))
270 }
271 }
272}
273
274/** Distance from p to segment ab. */
275function segDist(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
276 const dx = bx - ax
277 const dy = by - ay
278 const l2 = dx * dx + dy * dy
279 const t = l2 ? clamp01(((px - ax) * dx + (py - ay) * dy) / l2) : 0
280 return Math.hypot(px - (ax + t * dx), py - (ay + t * dy))
281}
282
283/** The check mark drawn up to `p` (0..1 of its length): [segments to measure against]. */
284function checkPath(s: Scene, p: number): { segs: [number, number, number, number][]; tip: [number, number] | null } {
285 const [a, b, c] = s.check
286 const l1 = Math.hypot(b[0] - a[0], b[1] - a[1])
287 const l2 = Math.hypot(c[0] - b[0], c[1] - b[1])
288 const d = clamp01(p) * (l1 + l2)
289 if (d <= 0) return { segs: [], tip: null }
290 if (d <= l1) {
291 const f = d / l1
292 const tip: [number, number] = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]
293 return { segs: [[a[0], a[1], tip[0], tip[1]]], tip }
294 }
295 const f = (d - l1) / l2
296 const tip: [number, number] = [b[0] + (c[0] - b[0]) * f, b[1] + (c[1] - b[1]) * f]
297 return { segs: [[a[0], a[1], b[0], b[1]], [b[0], b[1], tip[0], tip[1]]], tip: p >= 1 ? null : tip }
298}
299
300// the timeline, ms
301const T = {
302 flash: [0, 160],
303 ring: [70, 720],
304 check: [360, 820],
305 text: 600, // first letter; then one every `letter`
306 letter: 55,
307 pop: 200,
308 sweep: [1180, 1820],
309 fade: [2050, CELEBRATE_MS],
310} as const
311
312/** 0..1: how much of the logo is left during the fade. */
313export function fadeLeft(t: number): number {
314 return 1 - easeInOut((t - T.fade[0]) / (T.fade[1] - T.fade[0]))
315}
316
317/** Paint the scene at `t` ms into a fresh canvas. */
318function paint(s: Scene, t: number): { canvas: Canvas; logo: Uint8Array } {
319 const cv = new Canvas(s.w, s.h)
320 /** which pixels belong to the check and the word: they dissolve, sparks just dim */
321 const logo = new Uint8Array(s.w * s.h)
322 const fade = fadeLeft(t)
323
324 // 1. the bar, white-hot for a moment, then gone into its sparks
325 if (t < T.flash[1]) {
326 const f = t / T.flash[1]
327 const k = 1.5 * (1 - f) ** 1.5
328 const c = mix(s.accent, WHITE, 0.7)
329 for (let x = s.barX0; x < s.barX1; x++) {
330 cv.add(x, s.h - 1, c, k)
331 cv.add(x, s.h - 2, c, k * 0.35 * (1 - f))
332 }
333 }
334
335 // 2. the shock ring out of the bar's middle
336 if (t >= T.ring[0] && t < T.ring[1]) {
337 const f = (t - T.ring[0]) / (T.ring[1] - T.ring[0])
338 const cx = (s.barX0 + s.barX1) / 2
339 const cy = s.h - 1
340 const rx = 6 + easeOut(f) * s.w * 0.55
341 const ry = rx * 0.5
342 const k = 0.75 * (1 - f) ** 2
343 const c = mix(s.accent, WHITE, 0.5)
344 const y0 = Math.max(0, Math.floor(cy - ry - 2))
345 for (let y = y0; y < s.h; y++)
346 for (let x = 0; x < s.w; x++) {
347 const d = Math.hypot((x - cx) / rx, (y - cy) / ry)
348 const off = (d - 1) * rx
349 if (off > -3 && off < 3) cv.add(x, y, c, k * Math.exp(-(off * off) / 1.3))
350 }
351 }
352
353 // 3. the check mark draws itself, a pen of light at its tip
354 const cp = (t - T.check[0]) / (T.check[1] - T.check[0])
355 if (cp > 0 && fade > 0) {
356 const { segs, tip } = checkPath(s, easeInOut(cp))
357 const r = s.stroke / 2
358 const [minY, maxY] = [Math.min(...s.check.map(p => p[1])) - 3, Math.max(...s.check.map(p => p[1])) + 3]
359 const [minX, maxX] = [Math.min(...s.check.map(p => p[0])) - 3, Math.max(...s.check.map(p => p[0])) + 3]
360 const settle = clamp01((t - T.check[1]) / 300)
361 for (let y = Math.max(0, Math.floor(minY)); y <= Math.min(s.h - 1, Math.ceil(maxY)); y++)
362 for (let x = Math.max(0, Math.floor(minX)); x <= Math.min(s.w - 1, Math.ceil(maxX)); x++) {
363 let d = Infinity
364 let ds = Infinity
365 for (const g of segs) {
366 d = Math.min(d, segDist(x, y, g[0], g[1], g[2], g[3]))
367 ds = Math.min(ds, segDist(x - 1, y - 1, g[0], g[1], g[2], g[3]))
368 }
369 const cover = clamp01(r + 0.5 - d)
370 const shadow = clamp01(r + 0.5 - ds)
371 if (shadow > 0 && cover === 0) cv.add(x, y, SHADOW, shadow * 1.4)
372 if (cover > 0) {
373 // lit from above: lighter at the top of the stroke, freshly drawn parts glow white
374 const v = 1 - (y - minY) / (maxY - minY)
375 const base = mix(GREEN, GREEN_HI, v * 0.8)
376 cv.add(x, y, mix(WHITE, base, 0.35 + 0.65 * settle), cover * (1.05 + 0.5 * (1 - settle)))
377 logo[y * s.w + x] = 1
378 }
379 }
380 if (tip) cv.dot(tip[0], tip[1], s.stroke + 0.8, WHITE, 1.6)
381 }
382
383 // 4. CHECK!! drops in, letter by letter, white first, then green
384 let gx = s.textX
385 const shade: number[] = []
386 let i = 0
387 for (const ch of WORD) {
388 const g = GLYPHS[ch]!
389 const born = T.text + i * T.letter
390 const f = (t - born) / T.pop
391 if (f > 0 && fade > 0) {
392 const dy = Math.round((1 - backOut(f)) * -3 * s.scale)
393 const heat = 1 - easeOut(f)
394 for (let row = 0; row < g.length; row++)
395 for (let col = 0; col < g[row]!.length; col++) {
396 if (g[row]![col] !== '#') continue
397 for (let sy = 0; sy < s.scale; sy++)
398 for (let sx = 0; sx < s.scale; sx++) {
399 const x = gx + col * s.scale + sx
400 const y = s.textY + row * s.scale + sy + dy
401 const v = 1 - (row * s.scale + sy) / (GLYPH_H * s.scale)
402 const base = mix(GREEN_DEEP, GREEN_HI, 0.35 + 0.65 * v)
403 cv.add(x, y, mix(base, WHITE, heat * 0.85), 1 + heat * 0.6)
404 if (x >= 0 && y >= 0 && x < s.w && y < s.h) {
405 logo[y * s.w + x] = 1
406 shade.push(x + 1, y + 1)
407 }
408 }
409 }
410 }
411 gx += (g[0]!.length + 1) * s.scale
412 i++
413 }
414
415 // the word's drop shadow falls only where no letter is: on a letter it would light it up in stripes
416 for (let j = 0; j < shade.length; j += 2) {
417 const x = shade[j]!
418 const y = shade[j + 1]!
419 if (x < s.w && y < s.h && !logo[y * s.w + x]) cv.add(x, y, SHADOW, 1.3 / s.scale)
420 }
421
422 // 5. a sweep of light across the logo
423 if (t >= T.sweep[0] && t < T.sweep[1]) {
424 const f = easeInOut((t - T.sweep[0]) / (T.sweep[1] - T.sweep[0]))
425 const pos = -12 + f * (s.w + 24)
426 for (let y = 0; y < s.h; y++)
427 for (let x = 0; x < s.w; x++) {
428 if (!logo[y * s.w + x]) continue
429 const d = (x + 0.6 * y - pos) / 2.6
430 cv.add(x, y, WHITE, 0.95 * Math.exp(-d * d))
431 }
432 }
433
434 // 6. sparks and embers
435 const k = 2.1 // drag, 1/s
436 const g = 60 // gravity, px/s²
437 for (const p of s.particles) {
438 const age = t - p.born
439 if (age < 0 || age > p.life) continue
440 const a = age / p.life
441 const bright = (1 - a) ** 1.4
442 let c: RGB
443 if (p.ember) c = mix(GOLD, GREEN_HI, a)
444 else c = a < 0.18 ? mix(HOT, s.accent, a / 0.18) : a < 0.55 ? mix(s.accent, GREEN_HI, (a - 0.18) / 0.37) : mix(GREEN_HI, GREEN_DEEP, (a - 0.55) / 0.45)
445 const tw = p.twinkle < 0 ? 1 : 0.55 + 0.45 * Math.sin(age / 38 + p.twinkle)
446 // the trail: the same spark a moment ago, dimmer
447 for (const [lag, dim] of [
448 [0, 1],
449 [14, 0.45],
450 [28, 0.2],
451 ] as const) {
452 const tau = Math.max(0, age - lag) / 1000
453 const e = 1 - Math.exp(-k * tau)
454 const x = p.x0 + (p.vx / k) * e
455 const y = p.y0 + (p.vy / k) * e + (g / k) * (tau - e / k)
456 const kk = bright * tw * dim * (p.ember ? 0.6 : 1.25)
457 if (p.size > 1 && lag === 0) cv.dot(x, y, p.size, c, kk)
458 else cv.add(x, y, c, kk)
459 }
460 }
461
462 // the fade dims the sparks; the logo dissolves in an ordered pattern
463 if (fade < 1)
464 for (let y = 0; y < s.h; y++)
465 for (let x = 0; x < s.w; x++) {
466 const i3 = (y * s.w + x) * 3
467 const keep = logo[y * s.w + x] ? (fade > BAYER[(y & 3) * 4 + (x & 3)]! ? 0.55 + 0.45 * fade : 0) : fade
468 cv.px[i3]! *= keep
469 cv.px[i3 + 1]! *= keep
470 cv.px[i3 + 2]! *= keep
471 }
472 return { canvas: cv, logo }
473}
474
475const rgb24 = (r: number, g: number, b: number) => (Math.round(clamp01(r) * 255) << 16) | (Math.round(clamp01(g) * 255) << 8) | Math.round(clamp01(b) * 255)
476
477/**
478 * The cells at `t`: `cols * rows` triplets [codePoint, foreground, background],
479 * as a Raster takes them. Nothing to see after CELEBRATE_MS: all blank.
480 */
481export function frame(s: Scene, t: number): Uint32Array {
482 const out = new Uint32Array(s.cols * s.rows * 3)
483 const { canvas } = paint(s, t)
484 const px = canvas.px
485 const pixel = (x: number, y: number): number | null => {
486 const i = (y * s.w + x) * 3
487 const r = px[i]!
488 const g = px[i + 1]!
489 const b = px[i + 2]!
490 if (Math.max(r, g, b) < VISIBLE) return null
491 return s.color ? rgb24(r, g, b) : DEFAULT_COLOR
492 }
493 for (let row = 0; row < s.rows; row++)
494 for (let x = 0; x < s.cols; x++) {
495 const top = pixel(x, row * 2)
496 const bot = pixel(x, row * 2 + 1)
497 const o = (row * s.cols + x) * 3
498 if (top === null && bot === null) out.set([SPACE, DEFAULT_COLOR, DEFAULT_COLOR], o)
499 else if (bot === null) out.set([UPPER, top!, DEFAULT_COLOR], o)
500 else if (top === null) out.set([LOWER, bot, DEFAULT_COLOR], o)
501 else if (!s.color) out.set([FULL, DEFAULT_COLOR, DEFAULT_COLOR], o)
502 else out.set([UPPER, top, bot], o)
503 }
504 return out
505}
506
507const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
508
509/** Cells → the Raster's `cells`: padded base64 of little-endian u32s. */
510export function encodeCells(words: Uint32Array): string {
511 const bytes = new Uint8Array(words.length * 4)
512 for (let i = 0; i < words.length; i++) {
513 const v = words[i]!
514 bytes[i * 4] = v & 255
515 bytes[i * 4 + 1] = (v >>> 8) & 255
516 bytes[i * 4 + 2] = (v >>> 16) & 255
517 bytes[i * 4 + 3] = (v >>> 24) & 255
518 }
519 let out = ''
520 let i = 0
521 for (; i + 2 < bytes.length; i += 3) {
522 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
523 out += B64[n >> 18]! + B64[(n >> 12) & 63]! + B64[(n >> 6) & 63]! + B64[n & 63]!
524 }
525 const rest = bytes.length - i
526 if (rest === 1) {
527 const n = bytes[i]! << 16
528 out += `${B64[n >> 18]}${B64[(n >> 12) & 63]}==`
529 } else if (rest === 2) {
530 const n = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
531 out += `${B64[n >> 18]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`
532 }
533 return out
534}
535hooks/eta.ts 65 lines1/**
2 * Speed and ETA from an exponentially smoothed rate. Pure.
3 *
4 * Samples are taken at the task's own `updatedAt`, not at poll time: polling a
5 * file that did not change adds no sample, so an unchanged file never drags
6 * the rate towards zero (a stall is shown as a stall, not as a slow ETA).
7 */
8import type { RateState, Task } from '../types'
9
10/** Time constant of the EMA: a speed change is ~63 % in after 20 s. */
11export const TAU_MS = 20_000
12
13/** Feed one sample in. A counter that went backwards starts the measurement over. */
14export function advance(prev: RateState | undefined, value: number, t: number): RateState {
15 if (!prev || value < prev.v || t < prev.t) return { t, v: value, ema: null, t0: t, v0: value }
16 const dt = t - prev.t
17 if (dt <= 0) return prev
18 const inst = ((value - prev.v) / dt) * 1000
19 const alpha = 1 - Math.exp(-dt / TAU_MS)
20 const ema = prev.ema === null ? inst : alpha * inst + (1 - alpha) * prev.ema
21 return { ...prev, t, v: value, ema }
22}
23
24/** Units per second: the EMA, else the average since the first sample, else since `startedAt`. */
25export function speed(state: RateState | undefined, value: number, startedAt?: number, updatedAt?: number): number | null {
26 if (state?.ema !== null && state?.ema !== undefined && state.ema > 0) return state.ema
27 if (state && state.t > state.t0 && state.v > state.v0) return ((state.v - state.v0) / (state.t - state.t0)) * 1000
28 if (startedAt !== undefined && updatedAt !== undefined && updatedAt > startedAt && value > 0) return (value / (updatedAt - startedAt)) * 1000
29 return null
30}
31
32/** The quantity an ETA is computed from: bytes when the byte total is known, else `done`. */
33export function measured(task: Task): { value: number; total: number | null; unit: string } {
34 if (task.bytesTotal && task.bytes !== undefined) return { value: task.bytes, total: task.bytesTotal, unit: 'bytes' }
35 return { value: task.done, total: task.total, unit: task.unit }
36}
37
38/**
39 * Seconds left, counting down smoothly between file updates (the time since
40 * `updatedAt` is taken off). null when unknown or not meaningful.
41 */
42export function etaSeconds(remaining: number, perSecond: number | null, updatedAt: number, now: number): number | null {
43 if (perSecond === null || !(perSecond > 0) || !(remaining > 0)) return null
44 const eta = remaining / perSecond - Math.max(0, now - updatedAt) / 1000
45 return Math.max(0, eta)
46}
47
48/** Rate-state keys for a task: the measured quantity, and bytes when counted alongside. */
49export function rateKeys(task: Task): { main: string; bytes: string | null } {
50 const m = measured(task)
51 const bytesSeparate = task.bytes !== undefined && m.unit !== 'bytes' && task.unit !== 'bytes'
52 return { main: task.id, bytes: bytesSeparate ? `${task.id}#bytes` : null }
53}
54
55/** Advance every task's rate state; drops states of tasks that are gone. */
56export function advanceAll(prev: Readonly<Record<string, RateState>>, tasks: readonly Task[]): Record<string, RateState> {
57 const next: Record<string, RateState> = {}
58 for (const task of tasks) {
59 const keys = rateKeys(task)
60 next[keys.main] = advance(prev[keys.main], measured(task).value, task.updatedAt)
61 if (keys.bytes) next[keys.bytes] = advance(prev[keys.bytes], task.bytes!, task.updatedAt)
62 }
63 return next
64}
65hooks/layout.ts 257 lines1/**
2 * Turning tasks into rows of styled text that fit the band. Pure.
3 *
4 * Every task can be drawn at several detail levels, richest first. Fitting
5 * walks down the levels — shorter bar, fewer extras, shorter label — and only
6 * when even the leanest level does not fit are tasks folded into "+N more".
7 */
8import type { Layout, Task } from '../types'
9import type { Phase } from './state'
10import { age, amount, bar, bytes, cellWidth, count, duration, percent, rate, spinner, truncate } from './format'
11
12export type Seg = { text: string; color?: string; dim?: boolean; bold?: boolean }
13export type Row = Seg[]
14
15export type TaskView = {
16 task: Task
17 phase: Phase
18 /** units of the measured quantity per second, if known */
19 speed: number | null
20 /** bytes per second when bytes are counted beside another unit */
21 bytesSpeed: number | null
22 /** seconds left, if known */
23 eta: number | null
24}
25
26export const PALETTE = {
27 accent: '#79c0ff',
28 ok: '#3fb950',
29 warn: '#d29922',
30 error: '#f85149',
31} as const
32
33type Level = {
34 bar: number
35 unitWord: boolean
36 bytes: boolean
37 rate: boolean
38 eta: boolean
39 labelMax: number
40 pctOnly: boolean
41}
42
43export const LEVELS: readonly Level[] = [
44 { bar: 20, unitWord: true, bytes: true, rate: true, eta: true, labelMax: 32, pctOnly: false },
45 { bar: 12, unitWord: true, bytes: true, rate: true, eta: true, labelMax: 24, pctOnly: false },
46 { bar: 10, unitWord: true, bytes: true, rate: false, eta: true, labelMax: 20, pctOnly: false },
47 { bar: 8, unitWord: false, bytes: false, rate: false, eta: true, labelMax: 16, pctOnly: false },
48 { bar: 6, unitWord: false, bytes: false, rate: false, eta: true, labelMax: 14, pctOnly: true },
49 { bar: 4, unitWord: false, bytes: false, rate: false, eta: true, labelMax: 12, pctOnly: true },
50 { bar: 0, unitWord: false, bytes: false, rate: false, eta: true, labelMax: 10, pctOnly: true },
51 { bar: 0, unitWord: false, bytes: false, rate: false, eta: false, labelMax: 8, pctOnly: true },
52 { bar: 0, unitWord: false, bytes: false, rate: false, eta: false, labelMax: 3, pctOnly: true },
53]
54
55/** Levels up to this one still count as "fits on one line" in auto layout. */
56export const AUTO_SINGLE_MAX_LEVEL = 2
57const SEP = ' │ '
58
59export const rowWidth = (row: readonly Seg[]): number => row.reduce((w, s) => w + cellWidth(s.text), 0)
60
61type Paint = (color: string) => string | undefined
62
63function head(t: Task, lv: Level, paint: Paint, color?: string): Seg[] {
64 const segs: Seg[] = []
65 if (t.icon) segs.push({ text: `${t.icon} `, color: paint(color ?? PALETTE.accent) })
66 const label = truncate(t.label, lv.labelMax)
67 if (label) segs.push({ text: label, bold: true })
68 return segs
69}
70
71function counter(t: Task, lv: Level): string {
72 if (t.total === null) return `${amount(t.done, t.unit)}${lv.unitWord && t.unit !== 'bytes' ? ` ${t.unit}` : ''}`
73 if (lv.pctOnly) return percent(t.done / t.total)
74 const of = t.unit === 'bytes' ? `${bytes(t.done)}/${bytes(t.total)}` : `${count(t.done)}/${count(t.total)}`
75 return `${of}${lv.unitWord && t.unit !== 'bytes' ? ` ${t.unit}` : ''}`
76}
77
78/** The secondary byte count: "3.1 GB" or "3.1/8.0 GB" style, only beside a non-byte unit. */
79function byteNote(t: Task): string | null {
80 if (t.bytes === undefined || t.unit === 'bytes') return null
81 return t.bytesTotal ? `${bytes(t.bytes)}/${bytes(t.bytesTotal)}` : bytes(t.bytes)
82}
83
84function speedNote(v: TaskView): string | null {
85 if (v.bytesSpeed && v.bytesSpeed > 0) return rate(v.bytesSpeed, 'bytes')
86 if (v.speed && v.speed > 0) {
87 const unit = v.task.bytesTotal && v.task.bytes !== undefined ? 'bytes' : v.task.unit
88 return rate(v.speed, unit) || null
89 }
90 return null
91}
92
93const dot = (): Seg => ({ text: ' · ', dim: true })
94
95/** One task at one detail level. */
96export function taskSegs(v: TaskView, lv: Level, nowMs: number, color: boolean): Seg[] {
97 const paint: Paint = c => (color ? c : undefined)
98 const t = v.task
99 const segs: Seg[] = []
100 const sp = (): Seg => ({ text: ' ' })
101 const extras = (list: (string | null | false | undefined)[], style: Partial<Seg> = { dim: true }) => {
102 for (const text of list) if (text) segs.push(dot(), { text, ...style })
103 }
104 const label = head(t, lv, paint, v.phase === 'error' || v.phase === 'aborted' ? PALETTE.error : undefined)
105 segs.push(...label)
106 const gap = () => {
107 if (label.length) segs.push(sp())
108 }
109
110 if (v.phase === 'error') {
111 gap()
112 segs.push({ text: '✖', color: paint(PALETTE.error), bold: true })
113 if (lv.labelMax > 3) segs.push({ text: ` ${truncate(t.message ?? 'error', Math.max(8, lv.labelMax * 2))}`, color: paint(PALETTE.error) })
114 return segs
115 }
116 if (v.phase === 'aborted') {
117 gap()
118 segs.push({ text: '✖ aborted', color: paint(PALETTE.error), bold: true })
119 if (t.total !== null) extras([lv.labelMax > 3 && `at ${percent(t.done / t.total)}`])
120 return segs
121 }
122 if (v.phase === 'done') {
123 gap()
124 segs.push({ text: '✔', color: paint(PALETTE.ok), bold: true })
125 if (lv.labelMax > 3) {
126 const done = t.total !== null && !lv.pctOnly ? counter({ ...t, done: Math.max(t.done, t.total) }, { ...lv, pctOnly: false }) : null
127 if (done) segs.push({ text: ` ${done}`, color: paint(PALETTE.ok) })
128 const took = t.startedAt && t.updatedAt > t.startedAt ? duration((t.updatedAt - t.startedAt) / 1000) : null
129 if (took && lv.eta) segs.push({ text: ` in ${took}`, dim: true })
130 }
131 return segs
132 }
133
134 const stalled = v.phase === 'stalled'
135 const tone = stalled ? PALETTE.warn : PALETTE.accent
136 gap()
137 if (t.total === null) {
138 segs.push({ text: stalled ? '⏸' : spinner(nowMs), color: paint(tone) }, sp(), { text: counter(t, lv) })
139 } else {
140 if (lv.bar > 0) {
141 const [filled, empty] = bar(t.done / t.total, lv.bar)
142 if (filled) segs.push({ text: filled, color: paint(tone) })
143 if (empty) segs.push({ text: empty, dim: true })
144 segs.push(sp())
145 }
146 segs.push({ text: counter(t, lv) })
147 }
148 if (stalled) {
149 segs.push(dot(), { text: `⏸ stalled ${age((nowMs - t.updatedAt) / 1000)}`, color: paint(PALETTE.warn) })
150 return segs
151 }
152 extras([lv.bytes && byteNote(t), lv.rate && speedNote(v)])
153 if (lv.eta && v.eta !== null && t.total !== null) extras([`ETA ${duration(v.eta)}`], {})
154 return segs
155}
156
157/** The columns the bar of a task takes in its own row at the richest level, [from, to). */
158export function barColumns(t: Task): [number, number] {
159 const lv = LEVELS[0]!
160 const from = rowWidth(head(t, lv, () => undefined)) + 1
161 return [from, from + lv.bar]
162}
163
164/** The richest level of one task that fits `width`, or the leanest one cut to fit. */
165export function fitTask(v: TaskView, width: number, nowMs: number, color: boolean): { segs: Seg[]; level: number } {
166 for (let i = 0; i < LEVELS.length; i++) {
167 const segs = taskSegs(v, LEVELS[i]!, nowMs, color)
168 if (rowWidth(segs) <= width) return { segs, level: i }
169 }
170 return { segs: clip(taskSegs(v, LEVELS[LEVELS.length - 1]!, nowMs, color), width), level: LEVELS.length - 1 }
171}
172
173/** Cut a row to `width` cells; never wraps. */
174export function clip(row: readonly Seg[], width: number): Seg[] {
175 const out: Seg[] = []
176 let left = width
177 for (const s of row) {
178 const w = cellWidth(s.text)
179 if (w <= left) {
180 out.push(s)
181 left -= w
182 continue
183 }
184 if (left > 0) out.push({ ...s, text: truncate(s.text, left) })
185 break
186 }
187 return out
188}
189
190const more = (n: number): Seg => ({ text: `+${n} more`, dim: true })
191const countMore = (row: Row): number => Number(/^\+(\d+) more$/.exec(row[row.length - 1]?.text ?? '')?.[1] ?? 0)
192
193/** All tasks on one row at one shared level; null when they do not fit. */
194function singleAt(views: readonly TaskView[], level: number, width: number, extra: number, nowMs: number, color: boolean): Row | null {
195 const row: Row = []
196 views.forEach((v, i) => {
197 if (i > 0) row.push({ text: SEP, dim: true })
198 row.push(...taskSegs(v, LEVELS[level]!, nowMs, color))
199 })
200 if (extra > 0) row.push({ text: SEP, dim: true }, more(extra))
201 return rowWidth(row) <= width ? row : null
202}
203
204/** One row: richest shared level for as many tasks as fit, the rest as "+N more". */
205export function single(
206 views: readonly TaskView[],
207 width: number,
208 nowMs: number,
209 color: boolean,
210 maxLevel = LEVELS.length - 1,
211 alsoHidden = 0,
212): { row: Row; level: number } | null {
213 for (let shown = views.length; shown >= 1; shown--) {
214 for (let level = 0; level <= maxLevel; level++) {
215 const row = singleAt(views.slice(0, shown), level, width, views.length - shown + alsoHidden, nowMs, color)
216 if (row) return { row, level }
217 }
218 }
219 return null
220}
221
222/**
223 * The band: rows for the visible tasks (already sorted).
224 * @param maxTasks how many tasks to draw at most; the rest fold into "+N more"
225 * @param maxRows how many rows the band may take
226 */
227export function layoutRows(
228 views: readonly TaskView[],
229 opts: { width: number; layout: Layout; maxTasks: number; maxRows: number; nowMs: number; color: boolean },
230): Row[] {
231 const { width, nowMs, color } = opts
232 if (views.length === 0 || width < 4) return []
233 const cap = Math.max(1, opts.maxTasks)
234 const capped = views.slice(0, cap)
235 const hidden = views.length - capped.length
236
237 const one = (maxLevel?: number): Row[] | null => {
238 const fit = single(capped, width, nowMs, color, maxLevel, hidden)
239 return fit ? [fit.row] : null
240 }
241
242 const rows = Math.max(1, opts.maxRows)
243 if (opts.layout === 'single' || Math.min(rows, cap) === 1) return one() ?? [clip([more(views.length)], width)]
244 if (opts.layout === 'auto' && capped.length > 1) {
245 const fits = one(AUTO_SINGLE_MAX_LEVEL)
246 const folded = fits?.[0]!.some(s => /^\+\d+ more$/.test(s.text))
247 if (fits && (!folded || hidden > 0 && countMore(fits[0]!) === hidden)) return fits
248 }
249
250 // stacked: one task per row
251 const overflow = views.length > Math.min(cap, rows)
252 const n = overflow ? Math.min(cap, rows) - 1 : views.length
253 const out: Row[] = views.slice(0, Math.max(0, n)).map(v => fitTask(v, width, nowMs, color).segs)
254 if (overflow) out.push([more(views.length - n)])
255 return out
256}
257hooks/protocol.ts 85 lines1/**
2 * Reading progress files (PROTOCOL.md, v1). Pure: text in, Task or null out.
3 * Anything that is not a valid task is null — a broken file never breaks the band.
4 */
5import type { Task } from '../types'
6
7export const PROTOCOL_VERSION = 1
8export const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
9/** Files larger than this are not progress files. */
10export const MAX_FILE_BYTES = 64 * 1024
11
12const STATUSES = ['running', 'done', 'error'] as const
13
14/** The task id of a directory entry, or null if the entry is not a progress file. */
15export function idOfFile(name: string): string | null {
16 if (!name.endsWith('.json')) return null
17 const id = name.slice(0, -'.json'.length)
18 return ID_RE.test(id) ? id : null
19}
20
21/**
22 * Strip everything a terminal could interpret: C0/C1 controls, which covers
23 * ESC and therefore every ANSI/OSC sequence a file might smuggle in.
24 */
25export function sanitize(value: unknown, limit: number): string | undefined {
26 if (typeof value !== 'string' && typeof value !== 'number') return undefined
27 // eslint-disable-next-line no-control-regex
28 const text = String(value).replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()
29 if (!text) return undefined
30 const chars = [...text]
31 return chars.length > limit ? chars.slice(0, limit).join('') : text
32}
33
34const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
35const nonNeg = (v: unknown): number | undefined => (finite(v) && v >= 0 ? v : undefined)
36/** Protocol times are Unix seconds; internally everything is epoch ms. */
37const secondsToMs = (v: unknown): number | undefined => (finite(v) && v > 0 ? Math.round(v * 1000) : undefined)
38
39/**
40 * Parse one progress file.
41 * @param id the id from the file name — it wins over any `id` inside
42 * @param mtimeMs the file's mtime, used when `updated_at` is missing
43 */
44export function parseTask(text: string, id: string, mtimeMs: number, path?: string): Task | null {
45 let raw: unknown
46 try {
47 raw = JSON.parse(text)
48 } catch {
49 return null
50 }
51 if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
52 const o = raw as Record<string, unknown>
53 if (o.v !== undefined && !(finite(o.v) && o.v >= 1)) return null
54 const done = nonNeg(o.done)
55 if (done === undefined) return null
56
57 const total = finite(o.total) && o.total > 0 ? o.total : null
58 const status = STATUSES.includes(o.status as never) ? (o.status as Task['status']) : 'running'
59 const updatedAt = secondsToMs(o.updated_at) ?? mtimeMs
60 const task: Task = {
61 id,
62 label: sanitize(o.label, 40) ?? id,
63 done,
64 total,
65 unit: sanitize(o.unit, 16) ?? 'items',
66 status,
67 updatedAt,
68 source: 'file',
69 }
70 const icon = sanitize(o.icon, 2)
71 if (icon) task.icon = icon
72 const bytes = nonNeg(o.bytes)
73 if (bytes !== undefined) task.bytes = bytes
74 const bytesTotal = nonNeg(o.bytes_total)
75 if (bytesTotal) task.bytesTotal = bytesTotal
76 const message = sanitize(o.message, 200)
77 if (message) task.message = message
78 const startedAt = secondsToMs(o.started_at)
79 if (startedAt) task.startedAt = startedAt
80 if (Number.isInteger(o.pid) && (o.pid as number) > 0) task.pid = o.pid as number
81 if (finite(o.stalled_after) && o.stalled_after > 0) task.stalledAfter = o.stalled_after
82 if (path) task.path = path
83 return task
84}
85hooks/state.ts 56 lines1/** What a task is right now, whether it shows, and in which order. Pure. */
2import type { Task } from '../types'
3
4export type Phase = 'running' | 'stalled' | 'aborted' | 'done' | 'error'
5
6/** Timings, all in seconds. */
7export type Timing = {
8 /** no update for this long while running → stalled */
9 stalledAfter: number
10 /** a done task shows this long */
11 doneVisible: number
12 /** an error or aborted task shows this long */
13 errorVisible: number
14}
15
16export const DEFAULT_TIMING: Timing = { stalledAfter: 60, doneVisible: 10, errorVisible: 3600 }
17
18/**
19 * @param alive whether the task's pid is alive; undefined = not known (no pid,
20 * or the liveness check is not available on this surface)
21 */
22export function phaseOf(task: Task, now: number, timing: Timing, alive: boolean | undefined): Phase {
23 if (task.status === 'done') return 'done'
24 if (task.status === 'error') return 'error'
25 if (task.pid !== undefined && alive === false) return 'aborted'
26 if (now - task.updatedAt > (task.stalledAfter ?? timing.stalledAfter) * 1000) return 'stalled'
27 return 'running'
28}
29
30/** Whether a task in this phase still shows. Running and stalled always do. */
31export function isVisible(task: Task, phase: Phase, now: number, timing: Timing): boolean {
32 const since = (now - task.updatedAt) / 1000
33 if (phase === 'done') return since < timing.doneVisible
34 if (phase === 'error' || phase === 'aborted') return since < timing.errorVisible
35 return true
36}
37
38const RANK: Record<Phase, number> = { error: 0, aborted: 1, stalled: 2, running: 3, done: 4 }
39
40/**
41 * Problems first (they need you), then what runs, then what just finished.
42 * Within a phase, the oldest start first, so a row does not jump around.
43 */
44export function compareTasks(a: { task: Task; phase: Phase }, b: { task: Task; phase: Phase }): number {
45 const rank = RANK[a.phase] - RANK[b.phase]
46 if (rank !== 0) return rank
47 const start = (a.task.startedAt ?? a.task.updatedAt) - (b.task.startedAt ?? b.task.updatedAt)
48 if (start !== 0) return start
49 return a.task.id < b.task.id ? -1 : a.task.id > b.task.id ? 1 : 0
50}
51
52/** Pids worth a liveness check: running tasks that named one. */
53export function pidsToCheck(tasks: readonly Task[]): number[] {
54 return [...new Set(tasks.filter(t => t.status === 'running' && t.pid !== undefined).map(t => t.pid!))]
55}
56hooks/views.ts 50 lines1/** From what the poller saw to what the band draws, and what may be cleaned up. Pure. */
2import type { Snapshot, Task } from '../types'
3import { etaSeconds, measured, rateKeys, speed } from './eta'
4import type { TaskView } from './layout'
5import { compareTasks, isVisible, phaseOf } from './state'
6import type { Timing } from './state'
7
8const aliveOf = (s: Snapshot, t: Task) => (t.pid === undefined ? undefined : s.alive[String(t.pid)])
9
10/** The visible tasks, sorted, with phase, speed and ETA. */
11export function buildViews(s: Snapshot, now: number, timing: Timing): TaskView[] {
12 const views: TaskView[] = []
13 for (const task of s.tasks) {
14 const phase = phaseOf(task, now, timing, aliveOf(s, task))
15 if (!isVisible(task, phase, now, timing)) continue
16 const keys = rateKeys(task)
17 const m = measured(task)
18 const sp = speed(s.rates[keys.main], m.value, task.startedAt, task.updatedAt)
19 const bytesSpeed = keys.bytes ? speed(s.rates[keys.bytes], task.bytes ?? 0, task.startedAt, task.updatedAt) : null
20 const eta = phase === 'running' && m.total !== null ? etaSeconds(m.total - m.value, sp, task.updatedAt, now) : null
21 views.push({ task, phase, speed: sp, bytesSpeed, eta })
22 }
23 return views.sort(compareTasks)
24}
25
26/**
27 * Progress files that are finished and past their display time: done after
28 * `doneVisible`, error and aborted after `errorVisible`. Watchers own no file
29 * and are never cleaned up.
30 */
31export function expiredFiles(s: Snapshot, now: number, timing: Timing): Task[] {
32 return s.tasks.filter(task => {
33 if (task.source !== 'file' || !task.path) return false
34 const phase = phaseOf(task, now, timing, aliveOf(s, task))
35 if (phase === 'running' || phase === 'stalled') return false
36 return !isVisible(task, phase, now, timing)
37 })
38}
39
40/** Whether anything visible moves on its own (spinner, countdown, ages). */
41export function isAnimated(views: readonly TaskView[]): boolean {
42 return views.some(v => v.phase === 'running' || v.phase === 'stalled')
43}
44
45/** Tasks that were running a moment ago and are done now: each gets its finish. */
46export function finished(prev: readonly Task[], next: readonly Task[]): Task[] {
47 const was = new Map(prev.map(t => [t.id, t.status]))
48 return next.filter(t => t.status === 'done' && was.get(t.id) === 'running')
49}
50hooks/watchers.ts 256 lines1/**
2 * Watchers: progress for jobs that report nothing themselves, read from what
3 * they leave on disk. The pure half — config, matching, task building. The
4 * file system half lives in register.tsx.
5 *
6 * filesize a file growing towards a known size (a download)
7 * logtail the last match of a regex in the tail of a log (fetch.log)
8 * dircount files matching a glob in a directory, towards a known count
9 */
10import type { Task } from '../types'
11import { ID_RE, sanitize } from './protocol'
12
13export type WatcherType = 'filesize' | 'logtail' | 'dircount'
14
15export type Watcher = {
16 id: string
17 type: WatcherType
18 label: string
19 icon?: string
20 /** as written in the config, `~` not yet expanded */
21 path: string
22 unit: string
23 total: number | null
24 /** filesize: the expected size; logtail: a fallback for a missing bytes_total group */
25 bytesTotal?: number
26 /** logtail */
27 pattern?: RegExp
28 /** dircount */
29 glob?: RegExp
30 /** seconds: a source not modified for longer is not shown at all */
31 activeWithin: number
32 /** seconds without a change before the task counts as stalled */
33 stalledAfter?: number
34}
35
36/** What a watcher measured on one poll. */
37export type Reading = {
38 done: number
39 total?: number | null
40 bytes?: number
41 bytesTotal?: number
42 message?: string
43 /** epoch ms of the source's last modification */
44 mtimeMs: number
45}
46
47export const DEFAULT_ACTIVE_WITHIN = 600
48/** The tail of a log that a logtail watcher reads. */
49export const TAIL_BYTES = 64 * 1024
50
51const TYPES: readonly WatcherType[] = ['filesize', 'logtail', 'dircount']
52
53/** `~/x` → `/Users/me/x`. */
54export function expandHome(path: string, home: string): string {
55 if (path === '~') return home
56 if (path.startsWith('~/')) return `${home.replace(/\/$/, '')}/${path.slice(2)}`
57 return path
58}
59
60/** A shell glob for one directory level (`*`, `?`, `[abc]`, `{png,jpg}`) as a RegExp. */
61export function globToRegExp(glob: string): RegExp {
62 let re = ''
63 let inClass = false
64 let inBrace = false
65 for (const ch of glob) {
66 if (inClass) {
67 re += ch === ']' ? ']' : ch === '\\' ? '\\\\' : ch
68 if (ch === ']') inClass = false
69 continue
70 }
71 if (ch === '*') re += '[^/]*'
72 else if (ch === '?') re += '[^/]'
73 else if (ch === '[') {
74 re += '['
75 inClass = true
76 } else if (ch === '{') {
77 re += '(?:'
78 inBrace = true
79 } else if (ch === '}' && inBrace) {
80 re += ')'
81 inBrace = false
82 } else if (ch === ',' && inBrace) re += '|'
83 else re += ch.replace(/[.+^$()|\\/]/g, '\\$&')
84 }
85 return new RegExp(`^${re}$`)
86}
87
88const GLOB_CHARS = /[*?[{]/
89
90/**
91 * A glob in the file name of a filesize or logtail path (`~/w/fetch*.log`):
92 * the folder to list and the name pattern. null for a plain path. Only the
93 * last segment may be a glob — one level, like dircount.
94 */
95export function splitGlob(path: string): { dir: string; glob: RegExp } | null {
96 const cut = path.lastIndexOf('/')
97 if (cut < 0) return null
98 const dir = path.slice(0, cut) || '/'
99 const name = path.slice(cut + 1)
100 if (!GLOB_CHARS.test(name) || GLOB_CHARS.test(dir)) return null
101 return { dir, glob: globToRegExp(name) }
102}
103
104/** The most recently modified file matching `glob` (a tie goes to the later name). */
105export function newestMatch(entries: readonly { name: string; kind: string; mtimeMs: number }[], glob: RegExp): string | null {
106 let best: { name: string; mtimeMs: number } | null = null
107 for (const e of entries) {
108 if (e.kind === 'dir' || e.name.startsWith('.') || !glob.test(e.name)) continue
109 if (!best || e.mtimeMs > best.mtimeMs || (e.mtimeMs === best.mtimeMs && e.name > best.name)) best = e
110 }
111 return best?.name ?? null
112}
113
114/** Python's `(?P<name>…)` is the common spelling in the wild; JS wants `(?<name>…)`. */
115export function compilePattern(pattern: string): RegExp {
116 return new RegExp(pattern.replace(/\(\?P</g, '(?<'), 'gm')
117}
118
119/** "1,234" → 1234 · "3.1 GB" → 3.1e9 · "512MiB" → 536870912. NaN when not a number. */
120export function quantity(text: string | undefined): number {
121 if (text === undefined) return NaN
122 const m = /^\s*([0-9][0-9,_' ]*(?:\.[0-9]+)?|\.[0-9]+)\s*(?:([kKmMgGtTpP])(i)?)?[bB]?\s*$/.exec(text)
123 if (!m) return NaN
124 const n = Number(m[1]!.replace(/[,_' ]/g, ''))
125 if (!m[2]) return n
126 const exp = 'kmgtp'.indexOf(m[2].toLowerCase()) + 1
127 return n * (m[3] ? 1024 : 1000) ** exp
128}
129
130const num = (v: unknown): number | undefined =>
131 typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : typeof v === 'string' && quantity(v) > 0 ? quantity(v) : undefined
132
133/** Parse watchers.json. Bad entries are skipped and reported, never fatal. */
134export function parseWatchers(text: string): { watchers: Watcher[]; errors: string[] } {
135 const errors: string[] = []
136 let raw: unknown
137 try {
138 raw = JSON.parse(text)
139 } catch (err) {
140 return { watchers: [], errors: [`watchers.json is not valid JSON: ${(err as Error).message}`] }
141 }
142 const list = Array.isArray(raw) ? raw : (raw as { watchers?: unknown })?.watchers
143 if (!Array.isArray(list)) return { watchers: [], errors: ['watchers.json: expected {"watchers": [...]}'] }
144
145 const watchers: Watcher[] = []
146 const seen = new Set<string>()
147 list.forEach((entry, i) => {
148 const where = `watcher #${i + 1}`
149 if (!entry || typeof entry !== 'object') return void errors.push(`${where}: not an object`)
150 const o = entry as Record<string, unknown>
151 if (o.enabled === false) return
152 const type = o.type as WatcherType
153 if (!TYPES.includes(type)) return void errors.push(`${where}: type must be one of ${TYPES.join(', ')}`)
154 if (typeof o.path !== 'string' || !o.path) return void errors.push(`${where}: path is missing`)
155 const base = o.path.replace(/\/+$/, '').split('/').pop() ?? type
156 const id = typeof o.id === 'string' && ID_RE.test(o.id) ? o.id : base.replace(/[^A-Za-z0-9._-]/g, '-').replace(/^[._-]+/, '') || type
157 if (seen.has(id)) return void errors.push(`${where}: duplicate id "${id}"`)
158
159 const w: Watcher = {
160 id,
161 type,
162 label: sanitize(o.label, 40) ?? id,
163 path: o.path,
164 unit: sanitize(o.unit, 16) ?? (type === 'filesize' ? 'bytes' : type === 'dircount' ? 'files' : 'items'),
165 total: num(o.total) ?? null,
166 activeWithin: num(o.active_within) ?? DEFAULT_ACTIVE_WITHIN,
167 }
168 const icon = sanitize(o.icon, 2)
169 if (icon) w.icon = icon
170 const stall = num(o.stalled_after)
171 if (stall) w.stalledAfter = stall
172
173 if (type === 'filesize') {
174 const size = num(o.total_bytes) ?? num(o.total)
175 w.unit = 'bytes'
176 w.total = size ?? null
177 }
178 if (type === 'logtail') {
179 if (typeof o.pattern !== 'string') return void errors.push(`${where}: logtail needs a pattern`)
180 try {
181 w.pattern = compilePattern(o.pattern)
182 } catch (err) {
183 return void errors.push(`${where}: bad pattern: ${(err as Error).message}`)
184 }
185 if (!/\(\?P?<done>/.test(o.pattern)) return void errors.push(`${where}: the pattern needs a (?<done>…) group`)
186 const bt = num(o.total_bytes)
187 if (bt) w.bytesTotal = bt
188 }
189 if (type === 'dircount') w.glob = globToRegExp(typeof o.glob === 'string' && o.glob ? o.glob : '*')
190
191 seen.add(id)
192 watchers.push(w)
193 })
194 return { watchers, errors }
195}
196
197/** The last match of a logtail pattern in a chunk of log. null when nothing matched. */
198export function readLog(w: Watcher, text: string): Omit<Reading, 'mtimeMs'> | null {
199 if (!w.pattern) return null
200 w.pattern.lastIndex = 0
201 let last: RegExpExecArray | null = null
202 for (let m = w.pattern.exec(text); m; m = w.pattern.exec(text)) {
203 if (m[0] === '') w.pattern.lastIndex += 1
204 last = m
205 }
206 const g = last?.groups
207 if (!g) return null
208 const done = quantity(g.done)
209 if (!Number.isFinite(done)) return null
210 const reading: Omit<Reading, 'mtimeMs'> = { done }
211 const total = quantity(g.total)
212 if (total > 0) reading.total = total
213 const b = quantity(g.bytes)
214 if (b >= 0) reading.bytes = b
215 const bt = quantity(g.bytes_total)
216 if (bt > 0) reading.bytesTotal = bt
217 const msg = sanitize(g.message, 200)
218 if (msg) reading.message = msg
219 return reading
220}
221
222/** Count the names in a directory listing that match a dircount watcher. */
223export function countMatches(w: Watcher, entries: readonly { name: string; kind: string }[]): number {
224 const glob = w.glob ?? /^/
225 return entries.filter(e => e.kind !== 'dir' && !e.name.startsWith('.') && glob.test(e.name)).length
226}
227
228/**
229 * The task a reading stands for, or null when the source is idle (not
230 * modified within `activeWithin`) — an old log of a finished job stays out.
231 * @param firstSeen when the watcher first saw this source active (its "start")
232 */
233export function watcherTask(w: Watcher, r: Reading, now: number, firstSeen: number, path: string): Task | null {
234 if (now - r.mtimeMs > w.activeWithin * 1000) return null
235 const total = r.total ?? w.total
236 const task: Task = {
237 id: `watch.${w.id}`,
238 label: w.label,
239 done: r.done,
240 total: total && total > 0 ? total : null,
241 unit: w.unit,
242 status: total && total > 0 && r.done >= total ? 'done' : 'running',
243 updatedAt: r.mtimeMs,
244 startedAt: Math.min(firstSeen, r.mtimeMs),
245 source: 'watcher',
246 path,
247 }
248 if (w.icon) task.icon = w.icon
249 if (w.stalledAfter) task.stalledAfter = w.stalledAfter
250 if (r.bytes !== undefined) task.bytes = r.bytes
251 const bt = r.bytesTotal ?? w.bytesTotal
252 if (bt) task.bytesTotal = bt
253 if (r.message) task.message = r.message
254 return task
255}
256hooks/format.ts 164 lines1/** Pure formatting: sizes, counts, durations, bars, and terminal cell widths. */
2
3// --- cell widths ----------------------------------------------------------------
4
5const WIDE: readonly [number, number][] = [
6 [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
7 [0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1],
8 [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea],
9 [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa], [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b],
10 [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797],
11 [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55], [0x2e80, 0x303e],
12 [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff],
13 [0xfe30, 0xfe4f], [0xff00, 0xff60], [0xffe0, 0xffe6], [0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e],
14 [0x1f191, 0x1f19a], [0x1f200, 0x1f2ff], [0x1f300, 0x1f64f], [0x1f680, 0x1f6ff], [0x1f7e0, 0x1f7eb],
15 [0x1f900, 0x1f9ff], [0x1fa70, 0x1faff], [0x20000, 0x3fffd],
16]
17
18function isWide(c: number): boolean {
19 let lo = 0
20 let hi = WIDE.length - 1
21 while (lo <= hi) {
22 const mid = (lo + hi) >> 1
23 const [a, b] = WIDE[mid]!
24 if (c < a) hi = mid - 1
25 else if (c > b) lo = mid + 1
26 else return true
27 }
28 return false
29}
30
31const ZERO = (c: number) =>
32 c === 0x200d || c === 0xfe0e || (c >= 0x300 && c <= 0x36f) || (c >= 0x1f3fb && c <= 0x1f3ff) || (c >= 0xe0020 && c <= 0xe007f)
33
34/** Width of a string in terminal cells (emoji and CJK count 2). */
35export function cellWidth(s: string): number {
36 let w = 0
37 let prevNarrowSymbol = false
38 for (const ch of s) {
39 const c = ch.codePointAt(0)!
40 if (c === 0xfe0f) {
41 // VS16 turns a text-style symbol (⬇, ✔) into a two-cell emoji
42 if (prevNarrowSymbol) w += 1
43 prevNarrowSymbol = false
44 continue
45 }
46 if (ZERO(c)) continue
47 const wide = isWide(c)
48 w += wide ? 2 : 1
49 prevNarrowSymbol = !wide && c >= 0x2000
50 }
51 return w
52}
53
54/** Cut `s` to at most `max` cells, ending in `…` when cut. */
55export function truncate(s: string, max: number): string {
56 if (max <= 0) return ''
57 if (cellWidth(s) <= max) return s
58 let out = ''
59 let w = 0
60 for (const ch of s) {
61 const cw = cellWidth(ch)
62 if (w + cw > max - 1) break
63 out += ch
64 w += cw
65 }
66 return out + '…'
67}
68
69// --- numbers ----------------------------------------------------------------------
70
71/** 1234567 → "1,234,567"; fractions to one decimal. */
72export function count(n: number): string {
73 if (!Number.isFinite(n)) return '?'
74 const rounded = Math.abs(n) >= 100 || Number.isInteger(n) ? Math.round(n) : Math.round(n * 10) / 10
75 const [int, frac] = String(Math.abs(rounded)).split('.')
76 const grouped = int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
77 return (rounded < 0 ? '-' : '') + grouped + (frac ? `.${frac}` : '')
78}
79
80const SIZE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
81
82/** Decimal (SI) sizes like Finder and most download tools: 3100000000 → "3.1 GB". */
83export function bytes(n: number): string {
84 if (!Number.isFinite(n) || n < 0) return '?'
85 let i = 0
86 let v = n
87 while (v >= 1000 && i < SIZE_UNITS.length - 1) {
88 v /= 1000
89 i += 1
90 }
91 if (i === 0) return `${Math.round(v)} B`
92 const text = v >= 100 ? String(Math.round(v)) : v >= 10 ? v.toFixed(1).replace(/\.0$/, '') : v.toFixed(1)
93 // 999.96 MB rounds to "1000 MB": carry into the next unit
94 if (text === '1000' && i < SIZE_UNITS.length - 1) return `1.0 ${SIZE_UNITS[i + 1]}`
95 return `${text} ${SIZE_UNITS[i]}`
96}
97
98/** A quantity in its unit: bytes as sizes, everything else as a count. */
99export function amount(n: number, unit: string): string {
100 return unit === 'bytes' ? bytes(n) : count(n)
101}
102
103/** Units per second, or per minute when slow. */
104export function rate(perSecond: number, unit: string): string {
105 if (!Number.isFinite(perSecond) || perSecond <= 0) return ''
106 if (unit === 'bytes') return `${bytes(perSecond)}/s`
107 if (perSecond >= 1) return `${count(perSecond >= 10 ? Math.round(perSecond) : Math.round(perSecond * 10) / 10)}/s`
108 const perMinute = perSecond * 60
109 if (perMinute >= 1) return `${count(Math.round(perMinute * 10) / 10)}/min`
110 return `${count(Math.round(perMinute * 60 * 10) / 10)}/h`
111}
112
113const pad2 = (n: number) => String(n).padStart(2, '0')
114
115/** Seconds as a clock: 45 → "0:45", 80 → "1:20", 3723 → "1:02:03", from a day on "4d 3h". */
116export function duration(seconds: number): string {
117 if (!Number.isFinite(seconds) || seconds < 0) return '?'
118 const s = Math.round(seconds)
119 if (s >= 86400) return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`
120 const h = Math.floor(s / 3600)
121 const m = Math.floor((s % 3600) / 60)
122 const sec = s % 60
123 return h > 0 ? `${h}:${pad2(m)}:${pad2(sec)}` : `${m}:${pad2(sec)}`
124}
125
126/** An age in words for "stalled 2m": 45 → "45s", 130 → "2m", 7300 → "2h", 3 days → "3d". */
127export function age(seconds: number): string {
128 const s = Math.max(0, Math.floor(seconds))
129 if (s < 60) return `${s}s`
130 if (s < 3600) return `${Math.floor(s / 60)}m`
131 if (s < 86400) return `${Math.floor(s / 3600)}h`
132 return `${Math.floor(s / 86400)}d`
133}
134
135/** Percent 0..100, one decimal under 10 % so early progress is visible. */
136export function percent(fraction: number): string {
137 const p = Math.max(0, Math.min(1, fraction)) * 100
138 if (p > 0 && p < 10) return `${p.toFixed(1)}%`
139 // never claim 100 % before it is
140 return `${p >= 99.5 && p < 100 ? 99 : Math.round(p)}%`
141}
142
143// --- bars and spinners ------------------------------------------------------------------
144
145const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
146
147/** A bar of `width` cells with eighth-cell precision: [filled, empty]. */
148export function bar(fraction: number, width: number): [string, string] {
149 if (width <= 0) return ['', '']
150 const f = Number.isFinite(fraction) ? Math.max(0, Math.min(1, fraction)) : 0
151 const eighths = Math.round(f * width * 8)
152 const full = Math.floor(eighths / 8)
153 const part = EIGHTHS[eighths % 8]!
154 const filled = '█'.repeat(full) + part
155 return [filled, '░'.repeat(width - full - (part ? 1 : 0))]
156}
157
158export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
159
160/** The spinner frame for a moment in time (8 frames per second). */
161export function spinner(nowMs: number): string {
162 return SPINNER[Math.floor(nowMs / 125) % SPINNER.length]!
163}
164types/index.d.ts 73 lines1/** A progress source as read from disk: a progress file or a watcher. */
2export type Task = {
3 id: string
4 label: string
5 icon?: string
6 done: number
7 /** null = unknown total → spinner */
8 total: number | null
9 unit: string
10 bytes?: number
11 bytesTotal?: number
12 status: 'running' | 'done' | 'error'
13 message?: string
14 /** epoch ms */
15 startedAt?: number
16 /** epoch ms of the last real progress */
17 updatedAt: number
18 pid?: number
19 /** seconds without an update before this task counts as stalled (overrides the setting) */
20 stalledAfter?: number
21 /** where it came from: a progress file or a watcher */
22 source: 'file' | 'watcher'
23 /** the file behind it (progress file, or the watched path) */
24 path?: string
25}
26
27/** Smoothed speed of one task, for the ETA. */
28export type RateState = {
29 /** epoch ms of the last sample */
30 t: number
31 /** last value of the measured quantity */
32 v: number
33 /** EMA of units per second; null until two samples */
34 ema: number | null
35 /** epoch ms of the first sample */
36 t0: number
37 /** value at the first sample */
38 v0: number
39}
40
41export type Layout = 'auto' | 'single' | 'stacked'
42
43export type Prefs = {
44 layout: Layout
45 hidden: boolean
46}
47
48/** What the poller last saw. */
49export type Snapshot = {
50 tasks: Task[]
51 /** pid → alive, from the last liveness check */
52 alive: Record<string, boolean>
53 /** task id → rate state */
54 rates: Record<string, RateState>
55}
56
57/** The finish playing above the band: a task that just completed. */
58export type Celebration = {
59 /** the task's id, its label */
60 id: string
61 label: string
62 /** epoch ms the show started */
63 startedAt: number
64 /** where its bar was in its row, [from, to) columns */
65 bar: [number, number]
66}
67
68declare module 'claude-code' {
69 interface PluginState {
70 taskline: { snapshot: Snapshot; prefs: Prefs; celebration: Celebration | null }
71 }
72}
73