SLOPSHOPPER

taskline

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…

newbandcommandprocesstimer
★ 1v0.2.1MITupdated 2026-10-06pepperonas/taskline
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taskline
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /taskline ⎿ taskline: taskline — 0 task(s) · layout auto ⎿ taskline: progress files: /Users/dev/.claude/progress ⎿ taskline: watchers: /Users/dev/.claude/taskline/watchers.json (0 active) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

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

⏳ taskline

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> &nbsp; <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>

version node tests engine tests python tests

CI Claude Code mod tested with TypeScript Python runtime deps protocol surfaces network calls mutation tested Keep a Changelog SemVer PRs welcome License: MIT

Donate with PayPal Rate celox.io on Google

[!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.

📸 Screenshots

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.

✨ Features

  • One line for everything that runs — any number of jobs from any project, one row above the prompt, problems first.
  • Real ETA — an exponentially smoothed speed (time constant 20 s) sampled at each job's own update time, so polling an unchanged file never drags the estimate down, and the countdown keeps ticking between updates.
  • Bytes beside files — 275/1,000 files · 3.1 GB · 38.2 MB/s: count in files, measure in bytes; the ETA uses whichever total is known.
  • Unknown totals — a spinner, the count and a rate instead of a bar.
  • Stall detection — no update for 60 s (configurable) → ⏸ stalled 2m in yellow.
  • Crash detection — a job that names its pid and dies without saying so shows ✖ aborted, in red.
  • Errors stick — a failed job stays red with its message until you clear it (or an hour passes).
  • Done fades — green ✔ with the elapsed time for ten seconds, then the file is cleaned up.
  • A finish worth watching — when a running bar fills, it explodes into sparks and a big green check with CHECK!! plays above the band: a truecolor pixel canvas (two pixels per cell), repainted in place at 30 fps, see-through where nothing glows. Compact below 107 columns, one ✔ CHECK!! line when even that does not fit, on the desktop app, or with animation off. /taskline check plays it on demand.
  • Width-aware, never wraps — shorter bar → fewer extras → shorter label → +N more.
  • Watchers for jobs that report nothing — a file growing to a known size, the last [275/1000] in a log, files piling up in a folder.
  • A one-file protocol — any language can report: write JSON, rename. A Python helper and a taskline CLI do it for you.
  • Safe by construction — strings are stripped of control characters (no ANSI injection from a log), ids are path-safe, a broken file costs only itself, nothing reaches the network.
  • NO_COLOR respected, plus a color switch in /config.

📥 Install

Requirements

  • Claude Code 2.1.289 or newer with mods (function-hook plugins). Tested on 2.1.289, terminal and desktop.
  • For the reporting helpers: Python 3.8+ (standard library only). The protocol itself needs nothing.

Option 1 — the mod from the marketplace

/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

Option 2 — clone and run the installer

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 copy python/taskline.py; it is one file.

Option 3 — try it for one session

claude --plugin-dir /path/to/taskline

📡 Report progress from your jobs

Python

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

Shell

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

CommandWhat 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 dirprint the progress directory

Any other language

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.

Jobs that report nothing: watchers

~/.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 }
  ]
}
FieldTypesMeaning
typeallfilesize, logtail or dircount
pathallfile, 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, unitalldisplay; id defaults to the file name
totalallexpected count (a total group in the log wins)
total_bytesfilesize, logtailexpected size, "6.1 GB" or a number
patternlogtailregex 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
globdircountone level, * ? [ab] {png,jpg}
active_withinallseconds; a source not modified for longer is not shown at all (default 600)
stalled_afterallseconds without a change before it counts as stalled (default: the stalledAfter setting) — for logs written once per batch
enabledallfalse keeps an entry without using it

The file is re-read when it changes; mistakes are listed by /taskline and never break the band.

🕹️ Usage

CommandWhat it does
/tasklinelist 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 showhide or show the band (remembered)
`/taskline layout auto\single\stacked`override the layout (remembered)
/taskline demothree fake jobs for 35 s, written as real progress files
/taskline checkplay the finish: the bar bursts, CHECK!!

For a longer live check with the real Python library: python3 demo.py (--fast, --fail).

⚙️ Configuration

In Claude Code: /plugin configure taskline@pepperonas-taskline (or /config).

FieldValuesDefaultMeaning
layoutauto · single · stackedautoone line when everything fits, else one row per task
maxTasksnumber3tasks drawn at most; the rest fold into +N more
stalledAfterseconds60no update this long → stalled
doneVisibleseconds10how long a finished task stays
errorVisibleseconds3600how long a failed or aborted task stays
colortrue · falsetruecolors (also off when NO_COLOR is set)
animationtrue · falsetruespinner and countdown at 4 fps; off = once a second
celebratetrue · falsetruethe finish when a bar fills (sparks, check, CHECK!!)
cleanuptrue · falsetruedelete files of finished tasks once they are hidden
progressDirpath~/.claude/progresswhere jobs write
watchersFilepath~/.claude/taskline/watchers.jsonthe watchers

If you change progressDir, point the helpers there too: export TASKLINE_DIR=....

🧠 How it works

 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
  • Polling, cheaply. Once a second the mod lists the directory and re-reads only files whose mtime or size changed. Logs are read from the tail (tail -c 64K above 256 KB). Processes are checked with kill -0 every five seconds.
  • Drawing. A 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.
  • Why a mod and not a 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.

⚠️ Limits

  • The band appears above the prompt; while a survey is shown there, taskline steps aside.
  • Liveness (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.
  • Two writers must not share one id.

🔒 Privacy

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:

ProgramExact callWhy
killkill -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
tailtail -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)
rmrm -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.

🏛️ Architecture

FileRole
hooks/register.tsxthe mod: polling, liveness, cleanup, /taskline, the band
hooks/protocol.tsreading a progress file, sanitizing strings
hooks/state.tsphases (running · stalled · aborted · done · error), visibility, order
hooks/eta.tsEMA speed and ETA
hooks/layout.tsdetail levels, width fitting, rows
hooks/celebrate.tsthe finish: particles, shock ring, check, CHECK!! font, dissolve — every frame a function of time
hooks/format.tssizes, counts, durations, bars, cell widths
hooks/watchers.tswatcher config, globs, log matching
hooks/views.tssnapshot → what to draw, what to clean up
python/taskline.pyPython helper + CLI (stdlib only)
bin/tasklineCLI entry point
PROTOCOL.mdthe progress file format, v1
docs/DESIGN.mddesign decisions
docs/INTEGRATIONS.mdworked examples: a Python batch download, a Node fetch script, a logtail watcher

Everything but register.tsx is pure and tested without Claude Code.

🧪 Testing

Three suites:

  • Node suite — 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).
  • Engine suite — 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.
  • Python suite — 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)

❓ FAQ

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); `/

Source 10 files
hooks/register.tsx 576 lines
1/**
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}
576
hooks/celebrate.ts 535 lines
1/**
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}
535
hooks/eta.ts 65 lines
1/**
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}
65
hooks/layout.ts 257 lines
1/**
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}
257
hooks/protocol.ts 85 lines
1/**
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}
85
hooks/state.ts 56 lines
1/** 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}
56
hooks/views.ts 50 lines
1/** 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}
50
hooks/watchers.ts 256 lines
1/**
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}
256
hooks/format.ts 164 lines
1/** 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}
164
types/index.d.ts 73 lines
1/** 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