Shows long-running and background jobs with a percentage and an estimated finish time, read live from their output.

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">
<h3>Install in Claude Code</h3>
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.
Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.
Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions
<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">
Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.
The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.
<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">
One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.
These are the statusline, eta and smart-compact mods.
<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">
Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.
That's the cells mod.
<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">
When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.
That's the delegate mod.
A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.
That's the route mod.
When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.
That's the lsp-plus mod.
Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.
That's the seek mod.
<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">
/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).
That's the modpack plugin, which installs the other eight.
In Claude Code, type:
/plugin install modpack --marketplace HarzerHeribert/claude-modpack
Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:
/modpack:setup
It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.
By hand instead: SETUP.md has every step.
Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.
We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.
| Cost per job | Without | With the pack | |
|---|---|---|---|
| In a long session (Claude has already read the whole project) | $0.44 | $0.29 | −33% |
| Bigger jobs in a long session (a feature, tests, a rename) | $0.91 | $0.57 | −37% |
| In a fresh session | $0.37 | $0.35 | −4% |
| Bigger jobs in a fresh session | $0.55 | $0.51 | −8%, and 15% faster |
| Quick questions in a fresh session | $0.23 | $0.24 | +2%, about even |
<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">
<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">
<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">
How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.
Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.
Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).
Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.
I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.
Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.
How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.
Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.
What does it cost? Nothing. It's free and open source.
One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:
⏵⏵ auto mode on Opus 5.5 xhigh 412k ━━━━━━────── 5h 34% ↺2h14 7d 12% ↺3d ● ready · cold in 44m
$.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off./rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
placement | line | tail: appended dim to Claude Code's hint. band: above the prompt. off. |
limits | always | half: a plan window only once it is half used. off: never. |
animate | true | The highlight along the gauge. |
An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:
| Option | Cost |
|---|---|
| Let it expire, re-cache on return | $3.20 |
| One keep-alive (re-read the cached prefix; a read resets the TTL) | $0.09 |
| Compact before it expires | ~$0.87, plus detail lost to the summary |
A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:
compactPenaltyUsd. It stops after maxIdleHours.minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.
On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.
| Option | Default | |
|---|---|---|
enabled | true | false turns the mod off; /mods does it for all of the pack. |
strategy | smart | compact: compact at the first expiry. keepalive: never compact. off: status line only. |
cacheTtl | auto | 1h or 5m to override detection. |
minSavingsUsd | 0.25 | Below this re-cache cost, the cache is left to expire. |
compactPenaltyUsd | 0.5 | Higher keeps the cache warm longer before compacting. |
minCompactTokens | 100000 | Never compacts a smaller context. |
maxIdleHours | 6 | Stops keep-alives after this long idle. |
compactInstructions | empty | Passed to idle compactions, like /compact <text>. |
Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.
Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.
read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.
A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d
hooks/register.tsx 379 lines1import { atom, read, update } from 'claude-code'
2import type { Color, EngineInterface, Register, Timer } from 'claude-code'
3
4import { compile, describe, feed } from './estimate'
5import { BEAT_MS, table } from './table'
6import type { Tone } from './table'
7import type { Job } from '../types'
8
9const jobs = atom({ plugin: 'eta', key: 'jobs' } as const, [] as Job[])
10const tzOffset = atom({ plugin: 'eta', key: 'tzOffsetMin' } as const, null as number | null)
11const seen = atom({ plugin: 'eta', key: 'seen' } as const, [] as string[])
12
13/** smart-compact's palette: in Claude Code's themes "inactive" is the readable grey, "subtle" the dim one. */
14const COLORS: Record<Tone, Color> = {
15 accent: 'claude',
16 good: 'success',
17 bad: 'error',
18 warn: 'warning',
19 text: 'text',
20 label: 'inactive',
21 faint: 'subtle',
22 glint: 'text',
23}
24
25const TOOL = 'track_progress'
26const POLL_MS = 4000
27/** A failure the person has not seen stays this long at most. */
28const KEEP_FAILED_MS = 10 * 60_000
29/** A job that writes its state and whose file has not grown this long has stopped. */
30const STALE_MS = 20 * 60_000
31const CHUNK = 512 * 1024
32const CHUNKS_PER_TICK = 32
33const REMOTE = new Set(['mobile', 'vscode'])
34
35const TOOL_DESCRIPTION = `Shows the person a job's progress as a percentage and an estimated finish time, read live from the output it writes, so they can see when a long or background job will likely be done. The estimate keeps updating by itself while the job runs; you don't need to check on it.
36
37Call it in the same response as your other tool calls, right after starting a job that runs for minutes and prints countable progress: a background command (pass its task id), or anything else that writes a log (pass a short name and the file). Say how progress shows:
38- done_pattern and total: every output line the pattern matches is one unit of total (for example "^ \\S+ (RESOLVED|no) " with total 90).
39- or progress_pattern: a line holding the progress, with groups (done)(total) or one group with a percentage.
40Common progress bars (tqdm, pytest, cargo, "[3/10]", "step 3 of 10") are read without a pattern. Add expected_minutes when you can estimate the whole job's length; for a job already running, started_minutes_ago.`
41
42let ticker: Timer | undefined
43let busy = false
44let statusShown = false
45let frames: Timer | undefined
46let frameMs = 0
47
48/**
49 * Redraws for the band while it moves: the spinner turns four times a second while a job runs,
50 * and a finished job's glint runs at 80 ms for its one second. Nothing runs when nothing moves.
51 */
52function syncFrames($: EngineInterface, list: readonly Job[], now: number) {
53 const isBeat = list.some(j => j.status === 'completed' && j.endedAt !== undefined && now - j.endedAt < BEAT_MS)
54 const want = isBeat ? 80 : list.some(j => j.status === 'running') ? 250 : 0
55 if (want === frameMs) return
56 frames?.cancel()
57 frames = undefined
58 frameMs = want
59 if (want > 0) frames = $.clock.every(want, () => void frame($).catch(() => undefined))
60}
61
62async function frame($: EngineInterface) {
63 $.ui.invalidate('ui.render')
64 syncFrames($, await read($, jobs), await $.clock.now())
65}
66
67/** What stays: running jobs, a finished or stopped one through its beat, a failure until seen or ten minutes old. */
68function remaining(list: readonly Job[], now: number, seenIds: readonly string[]) {
69 return list.filter(j => {
70 if (j.status === 'running') return true
71 const age = now - (j.endedAt ?? now)
72 if (j.status === 'failed') return !seenIds.includes(j.id) && age < KEEP_FAILED_MS
73 return age < BEAT_MS + 1_000
74 })
75}
76
77/** Reads what the job's file gained since the last look, in chunks, and feeds it to the estimate. */
78async function poll($: EngineInterface, job: Job, now: number): Promise<Job> {
79 if (!job.file) return job
80 const st = await $.fs.stat(job.file).catch(() => undefined)
81 if (!st || st.kind !== 'file') return job
82 let j = st.size < job.offset ? { ...job, offset: 0, carry: '' } : job
83 for (let n = 0; n < CHUNKS_PER_TICK && j.offset < st.size; n++) {
84 const want = Math.min(CHUNK, st.size - j.offset)
85 const r = await $.process.run(['/bin/sh', '-c', 'tail -c +"$1" "$2" | head -c "$3"', 'sh', String(j.offset + 1), job.file, String(want)])
86 if (r.exitCode !== 0) break
87 j = { ...feed(j, r.stdout, now), offset: j.offset + want, grewAt: now }
88 if (j.status !== 'running') break
89 }
90 const measured = j.statusLine || j.frac !== undefined || j.done > 0
91 if (j.status === 'running' && measured && now - (j.grewAt ?? j.startedAt) >= STALE_MS) {
92 j = { ...j, status: 'failed', lastLine: 'no update for 20 min', endedAt: now }
93 }
94 return j
95}
96
97async function tick($: EngineInterface) {
98 if (busy) return
99 busy = true
100 try {
101 const now = await $.clock.now()
102 const polled = new Map<string, Job>()
103 for (const j of await read($, jobs)) {
104 if (j.status === 'running') polled.set(j.id, await poll($, j, now))
105 }
106 const seenIds = await read($, seen)
107 const list = await update($, jobs, cur => remaining(cur.map(c => (c.status === 'running' && polled.get(c.id)) || c), now, seenIds))
108 await showStatus($, list, now)
109 syncFrames($, list, now)
110 $.ui.invalidate('ui.render')
111 if (list.length === 0) {
112 ticker?.cancel()
113 ticker = undefined
114 }
115 } finally {
116 busy = false
117 }
118}
119
120function ensureTicking($: EngineInterface) {
121 if (ticker === undefined) ticker = $.clock.every(POLL_MS, () => void tick($).catch(() => undefined))
122}
123
124/**
125 * The band above the prompt is drawn on the terminal and the desktop app only; mobile and VS Code get
126 * the same news as this plugin's status line, while one of them is attached. A terminal session seen
127 * over Remote Control has neither: there only the tool's reply reaches the viewer.
128 */
129async function showStatus($: EngineInterface, list: readonly Job[], now: number) {
130 const remote = (await $.session.surfaces().catch(() => [] as string[])).some(s => REMOTE.has(s))
131 const running = list.filter(j => j.status === 'running')
132 if (!remote || running.length === 0) {
133 if (statusShown) $.ui.status(undefined)
134 statusShown = false
135 return
136 }
137 const tz = await read($, tzOffset)
138 const parts = running.slice(0, 2).map(j => {
139 const d = describe(j, now, tz)
140 return `${d.label} ${d.progress.replace(/^[▰▱]+ /, '')} ${d.eta}`.replace(/\s+/g, ' ').trim()
141 })
142 const more = running.length > 2 ? ` · +${running.length - 2} more` : ''
143 $.ui.status(`⏳ ${parts.join(' · ')}${more}`)
144 statusShown = true
145}
146
147async function addJob($: EngineInterface, job: Job) {
148 await update($, jobs, cur => [...cur.filter(c => c.id !== job.id), job])
149 ensureTicking($)
150}
151
152async function finish($: EngineInterface, id: string, status: Job['status']) {
153 const now = await $.clock.now()
154 const list = await read($, jobs)
155 const job = list.find(j => j.id === id && j.status === 'running')
156 if (!job) return
157 const last = await poll($, job, now)
158 await update($, jobs, cur => cur.map(c => (c.id === id ? { ...last, status: last.status === 'running' ? status : last.status, endedAt: now } : c)))
159 void tick($).catch(() => undefined)
160}
161
162type TrackInput = {
163 task?: unknown
164 file?: unknown
165 label?: unknown
166 done_pattern?: unknown
167 progress_pattern?: unknown
168 finished_pattern?: unknown
169 total?: unknown
170 expected_minutes?: unknown
171 started_minutes_ago?: unknown
172 stop?: unknown
173 status_line?: unknown
174}
175
176const str = (v: unknown) => (typeof v === 'string' && v.trim() !== '' ? v : undefined)
177
178/** The running background task that writes `file`: its own output file, or a file its command names. */
179function writerOf(list: readonly Job[], file: string): Job | undefined {
180 const name = file.split('/').pop() ?? ''
181 const named = name.length > 3 && new RegExp(`(^|[\\s>=/'"])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[\\s'";|&)])`)
182 return list.find(j => j.status === 'running' && j.source === 'background'
183 && (j.file === file || (j.command !== undefined && (j.command.includes(file) || (named !== false && named.test(j.command))))))
184}
185const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined)
186
187async function track($: EngineInterface, i: TrackInput): Promise<string> {
188 const id = str(i.task)
189 if (!id) return 'track_progress needs `task`: a background task id, or a short name for the job.'
190 const now = await $.clock.now()
191 const list = await read($, jobs)
192 const named = list.find(j => j.id === id)
193 if (i.stop === true) {
194 await update($, jobs, cur => cur.filter(c => c.id !== id))
195 void tick($).catch(() => undefined)
196 return `Stopped showing ${id}.`
197 }
198 for (const key of ['done_pattern', 'progress_pattern', 'finished_pattern'] as const) {
199 if (str(i[key]) && !compile(str(i[key]))) return `${key} is not a valid regular expression.`
200 }
201 const file = str(i.file) ?? named?.file
202 if (!file) return `No file to read for ${id}: pass the log or output file the job writes, or the id of a background task Claude Code started.`
203 const st = await $.fs.stat(file).catch(() => undefined)
204 if (!st || st.kind !== 'file') return `${file} is not a file I can read.`
205 // A log a running background command writes, tracked under a name of its own: the same job, so it
206 // stays one row, under the task's id (its notification ends it).
207 const existing = named ?? writerOf(list, file)
208 const ago = num(i.started_minutes_ago)
209 const startedAt = existing?.startedAt ?? (ago ? now - ago * 60_000 : now)
210 const fromStart = existing?.source === 'background' || ago !== undefined
211 let job: Job = {
212 id: existing?.id ?? id,
213 label: str(i.label) ?? existing?.label ?? id,
214 source: existing?.source ?? 'claude',
215 file,
216 command: existing?.command,
217 donePattern: str(i.done_pattern) ?? existing?.donePattern,
218 progressPattern: str(i.progress_pattern) ?? existing?.progressPattern,
219 finishedPattern: str(i.finished_pattern) ?? existing?.finishedPattern,
220 statusLine: typeof i.status_line === 'boolean' ? i.status_line : existing?.statusLine,
221 lastLine: existing?.lastLine,
222 total: num(i.total) ?? existing?.total,
223 expectedMs: num(i.expected_minutes) ? (i.expected_minutes as number) * 60_000 : existing?.expectedMs,
224 startedAt,
225 offset: 0,
226 grewAt: now,
227 carry: '',
228 done: 0,
229 samples: fromStart ? [[startedAt, 0]] : [],
230 status: 'running',
231 }
232 job = await poll($, job, now)
233 await addJob($, job)
234 const tz = await read($, tzOffset)
235 const d = describe(job, now, tz)
236 const read_ = d.progress ? `${d.progress.replace(/^[▰▱]+ /, '')}, ${d.eta}` : d.eta
237 const as = job.id === id ? '' : ` (as background task ${job.id}, which writes it)`
238 return `Showing "${job.label}" to the person${as}: ${read_}. It updates by itself from ${file}.`
239}
240
241export const register: Register = (on, options) => {
242 if (options.enabled === false) {
243 // Off: a tool registered earlier may outlive the reload, so only its call is answered.
244 on('tool.call', { tool: 'mcp__eta__track_progress' }, () => ({ result: 'eta is turned off (/mods turns it on).' }))
245 return
246 }
247 on('session.start', async ($, e, next) => {
248 await $.tool.register({
249 name: TOOL,
250 description: TOOL_DESCRIPTION,
251 isDeferred: false,
252 inputSchema: {
253 type: 'object',
254 properties: {
255 task: { type: 'string', description: 'The background task id, or a short name for a job started another way.' },
256 file: { type: 'string', description: 'The log or output file the job writes; not needed for a background task Claude Code started.' },
257 label: { type: 'string', description: 'A few words the person will recognise the job by.' },
258 done_pattern: { type: 'string', description: 'Regular expression: each output line it matches is one unit done.' },
259 total: { type: 'number', description: 'How many units make the whole job (with done_pattern).' },
260 progress_pattern: { type: 'string', description: 'Regular expression for a progress line: groups (done)(total), or one group with a percentage.' },
261 finished_pattern: { type: 'string', description: 'Regular expression for a line that means the job finished.' },
262 expected_minutes: { type: 'number', description: 'Your estimate of the whole job’s duration, shown until progress is measurable.' },
263 started_minutes_ago: { type: 'number', description: 'For a job already running: how long ago it started.' },
264 status_line: { type: 'boolean', description: "Show the file's last line beside the job: for jobs that write their state rather than countable progress." },
265 stop: { type: 'boolean', description: 'Stop showing this job.' },
266 },
267 required: ['task'],
268 },
269 })
270 const tz = await $.process.run(['/bin/date', '+%z']).catch(() => undefined)
271 const m = tz?.stdout.trim().match(/^([+-])(\d{2})(\d{2})$/)
272 if (m) await update($, tzOffset, () => (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])))
273 if ((await read($, jobs)).length > 0) ensureTicking($)
274 return next(e)
275 })
276
277 on('tool.call', async ($, e, next) => {
278 // A subagent's tool call carries its agentId; the main loop's does not.
279 const fromSubagent = (e as { agentId?: unknown }).agentId !== undefined
280 if (e.tool.endsWith(`__${TOOL}`)) {
281 if (fromSubagent) return { result: 'track_progress shows jobs to the person from the main conversation only; nothing is shown for a subagent\'s job.' }
282 return { result: await track($, e as TrackInput) }
283 }
284 const r = await next(e)
285 if (r.deny !== undefined) return r
286 if (e.tool === 'TaskStop' && !r.isError) {
287 const input = e as { task_id?: unknown; shell_id?: unknown }
288 const id = str(input.task_id) ?? str(input.shell_id)
289 if (id) await finish($, id, 'killed')
290 return r
291 }
292 if (e.tool !== 'Bash' || r.isError) return r
293 const id = (r.result as { backgroundTaskId?: unknown } | undefined)?.backgroundTaskId
294 if (typeof id !== 'string' || fromSubagent) return r
295 const input = e as { command?: unknown; description?: unknown }
296 const now = await $.clock.now()
297 const file = (r.text ?? '').match(/written to:?\s+(\S+?\.output)\b/)?.[1]
298 const label = str(input.description) ?? String(input.command ?? id).split('\n')[0] ?? id
299 await addJob($, {
300 id, label, source: 'background', file, command: str(input.command), startedAt: now, offset: 0, carry: '', done: 0,
301 samples: [[now, 0]], status: 'running',
302 })
303 const hint = `Background task ${id} now shows the person its elapsed time. If its output will show countable progress, call ${TOOL} for it in your next response, alongside your other calls, so they also see a percentage and an ETA.`
304 return { ...r, context: [...(r.context ?? []), hint] }
305 }).catch(($, e, next) => next(e))
306
307 on('prompt.submit', async ($, e, next) => {
308 // The person writes again: the failures on the band have been seen, and leave it.
309 if (e.origin.kind === 'composer' || e.origin.kind === 'bridge') {
310 const failed = (await read($, jobs)).filter(j => j.status === 'failed').map(j => j.id)
311 if (failed.length > 0) await update($, seen, s => [...new Set([...s, ...failed])])
312 }
313 if (e.origin.kind === 'task-notification') {
314 for (const j of await read($, jobs)) {
315 if (j.status !== 'running' || j.source !== 'background' || !e.text.includes(j.id)) continue
316 const word = e.text.match(/<status>\s*(\w+)/)?.[1] ?? 'completed'
317 await finish($, j.id, word === 'failed' ? 'failed' : word === 'killed' || word === 'stopped' ? 'killed' : 'completed')
318 }
319 }
320 return next(e)
321 }).catch(($, e, next) => next(e))
322
323 // Its own tool call reads as one line: what is shown, from which file. Where the band carries the
324 // progress (terminal, desktop), the tool's reply draws nothing under it; elsewhere it is the only news.
325 on('ui.render', { component: 'ToolUse', props: { tool: `mcp__eta__${TOOL}` } }, async ($, e, next) => {
326 const i = (e.props.input ?? {}) as TrackInput
327 const task = str(i.task)
328 if (!task) return next(e)
329 const { Box, Text } = $.ui.resolve(e)
330 const file = str(i.file)?.split('/').pop()
331 const total = num(i.total)
332 const what = i.stop === true ? `stopped showing ${task}` : `showing ${str(i.label) ?? task} above the prompt`
333 const detail = i.stop === true ? '' : [total ? `${total} steps` : '', file ? `from ${file}` : ''].filter(Boolean).join(' · ')
334 return (
335 <Box flexDirection="row" gap={1}>
336 <Text color={COLORS.accent}>⏳</Text>
337 <Text>{what}</Text>
338 {detail ? <Text color={COLORS.faint}>{`· ${detail}`}</Text> : null}
339 </Box>
340 )
341 }).catch(($, e, next) => next(e))
342
343 on('ui.render', { component: 'ToolResult', props: { tool: `mcp__eta__${TOOL}` } }, async ($, e, next) => {
344 if (e.props.isErrored || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
345 const text = typeof e.props.output === 'string' ? e.props.output : ''
346 // A reply that is not the usual "Showing … to the person" (a bad pattern, no file) stays readable.
347 if (text !== '' && !/^(Showing|Stopped showing)/.test(text)) return next(e)
348 const { Box } = $.ui.resolve(e)
349 return <Box />
350 }).catch(($, e, next) => next(e))
351
352 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
353 const list = await read($, jobs)
354 if (e.props.hasSurvey || list.length === 0) return next(e)
355 const now = await $.clock.now()
356 const view = { seen: await read($, seen) }
357 const rows = table(list, now, await read($, tzOffset), e.props.bodyColumns, view)
358 if (rows.length === 0) return next(e)
359 syncFrames($, list, now)
360 const { Box, Text } = $.ui.resolve(e)
361 const drawn = rows.map(r => (
362 <Box key={r.key} flexDirection="row">
363 {r.parts.map(p => (
364 <Text color={COLORS[p.tone]} bold={p.tone === 'glint'}>
365 {p.text}
366 </Text>
367 ))}
368 </Box>
369 ))
370 const below = await next(e)
371 return (
372 <Box flexDirection="column">
373 {drawn}
374 {below ?? null}
375 </Box>
376 )
377 }).catch(($, e, next) => next(e))
378}
379hooks/estimate.ts 181 lines1import type { Job } from '../types'
2
3export type { Job }
4
5/** Progress lines common tools print, tried when Claude named no pattern. */
6const AUTO: { re: RegExp; read: (m: RegExpMatchArray) => number | undefined }[] = [
7 { re: /(\d{1,3}(?:\.\d+)?)%\|/, read: m => pct(m[1]) }, // tqdm
8 { re: /\[\s*(\d{1,3})%\]/, read: m => pct(m[1]) }, // pytest
9 { re: /Building \[[^\]]*\]\s*(\d+)\/(\d+)/, read: m => ratio(m[1], m[2]) }, // cargo
10 { re: /\[(\d+)\/(\d+)\]/, read: m => ratio(m[1], m[2]) }, // ninja, [3/10]
11 { re: /\b(?:step|task|test|file|item|batch|epoch|attempt|block|run|job|shard|chunk|page)s?\s*:?\s*(\d+)\s*(?:\/|of)\s*(\d+)/i, read: m => ratio(m[1], m[2]) },
12 { re: /\b(?:progress|done|completed?)\b\D{0,12}(\d{1,3}(?:\.\d+)?)\s*%/i, read: m => pct(m[1]) },
13]
14
15function pct(s: string | undefined) {
16 const v = Number(s)
17 return Number.isFinite(v) && v >= 0 && v <= 100 ? v / 100 : undefined
18}
19
20function ratio(a: string | undefined, b: string | undefined) {
21 const x = Number(a)
22 const y = Number(b)
23 return y > 0 && x >= 0 && x <= y ? x / y : undefined
24}
25
26/** Reads one progress line with a pattern Claude gave: named or positional groups. */
27function readPattern(m: RegExpMatchArray) {
28 const g = m.groups ?? {}
29 if (g.done !== undefined && g.total !== undefined) return ratio(g.done, g.total)
30 if (g.pct !== undefined) return pct(g.pct)
31 if (m[2] !== undefined) return ratio(m[1], m[2])
32 return pct(m[1])
33}
34
35export function compile(source: string | undefined) {
36 if (!source) return undefined
37 try {
38 return new RegExp(source)
39 } catch {
40 return undefined
41 }
42}
43
44/**
45 * Feeds new output to a job: counts done lines, reads the latest progress line, notices a finish
46 * line. A partial last line waits in `carry` for the rest. Returns the updated job.
47 */
48export function feed(job: Job, text: string, now: number): Job {
49 const all = job.carry + text
50 const parts = all.split(/\r\n|\r|\n/)
51 const carry = /[\r\n]$/.test(all) ? '' : (parts.pop() ?? '')
52 if (carry === '' && parts.at(-1) === '') parts.pop()
53 const done = compile(job.donePattern)
54 const progress = compile(job.progressPattern)
55 const finished = compile(job.finishedPattern)
56 let count = job.done
57 let frac = job.frac
58 let ended = false
59 let lastLine = job.lastLine
60 // A bar redrawn with \r is still mid-line: read it too, without keeping it.
61 for (const line of [...parts, carry]) {
62 if (line.trim() !== '') lastLine = line.trim()
63 if (line !== carry && done?.test(line)) count++
64 if (progress) {
65 const m = line.match(progress)
66 const v = m ? readPattern(m) : undefined
67 if (v !== undefined) frac = v
68 } else if (!done) {
69 for (const { re, read } of AUTO) {
70 const m = line.match(re)
71 const v = m ? read(m) : undefined
72 if (v !== undefined) {
73 frac = v
74 break
75 }
76 }
77 }
78 if (line !== carry && finished?.test(line)) ended = true
79 }
80 if (done && job.total) frac = Math.min(1, count / job.total)
81 const moved = frac !== undefined && frac !== job.frac
82 const samples = moved ? thin([...job.samples, [now, frac as number]]) : job.samples
83 let next: Job = { ...job, carry: carry.slice(-4000), lastLine, done: count, frac, samples, lastProgressAt: moved ? now : job.lastProgressAt }
84 if (ended || (frac !== undefined && frac >= 1)) next = { ...next, status: 'completed', endedAt: now }
85 return next
86}
87
88/** Keeps the first sample and at most 120 after it. */
89function thin(samples: [number, number][]) {
90 return samples.length <= 121 ? samples : [samples[0] as [number, number], ...samples.slice(-120)]
91}
92
93export type Estimate = {
94 frac?: number
95 /** When it will likely finish, and a range from the overall and the recent rate. */
96 at?: number
97 lo?: number
98 hi?: number
99 basis: 'measured' | 'guess' | 'none'
100 /** How long since progress last moved, once that is unusually long. */
101 stalledMs?: number
102}
103
104export function estimate(job: Job, now: number): Estimate {
105 const frac = job.frac
106 const stalledMs = stalled(job, now)
107 const first = job.samples[0]
108 if (frac !== undefined && first && frac > first[1] && now > first[0]) {
109 const elapsed = now - first[0]
110 const overall = (frac - first[1]) / elapsed
111 const window = Math.max(elapsed / 4, 60_000)
112 const back = [...job.samples].reverse().find(([t]) => t <= now - window) ?? first
113 const recent = (frac - back[1]) / (now - back[0])
114 const remaining = 1 - frac
115 let mid = remaining / overall
116 if (job.expectedMs && frac < 0.1) {
117 const w = frac / 0.1
118 mid = w * mid + (1 - w) * Math.max(0, job.startedAt + job.expectedMs - now)
119 }
120 const fast = Math.max(overall, recent)
121 const slow = Math.min(overall, recent)
122 return {
123 frac, at: now + mid, lo: now + Math.min(mid, remaining / fast),
124 hi: slow > 0 ? now + Math.max(mid, remaining / slow) : undefined, basis: 'measured', stalledMs,
125 }
126 }
127 if (job.expectedMs) return { frac, at: job.startedAt + job.expectedMs, basis: 'guess', stalledMs }
128 return { frac, basis: 'none', stalledMs }
129}
130
131/** Progress that has not moved for three times its usual gap, and at least two minutes. */
132function stalled(job: Job, now: number) {
133 if (job.status !== 'running' || job.lastProgressAt === undefined || job.samples.length < 3) return undefined
134 const first = job.samples[0] as [number, number]
135 const gap = (job.lastProgressAt - first[0]) / (job.samples.length - 1)
136 const quiet = now - job.lastProgressAt
137 return quiet > Math.max(3 * gap, 120_000) ? quiet : undefined
138}
139
140export function duration(ms: number) {
141 const m = Math.max(1, Math.round(ms / 60_000))
142 if (m < 60) return `${m}m`
143 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
144}
145
146/** hh:mm in the person's time zone, given its offset from UTC in minutes. */
147export function clock(ms: number, tzOffsetMin: number) {
148 const d = new Date(ms + tzOffsetMin * 60_000)
149 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
150}
151
152export function bar(frac: number, cells = 10) {
153 const full = Math.max(0, Math.min(cells, Math.round(frac * cells)))
154 return '▰'.repeat(full) + '▱'.repeat(cells - full)
155}
156
157/** One line for a job: what a status line or a band row says. */
158export function describe(job: Job, now: number, tz: number | null) {
159 const label = job.label.length > 32 ? job.label.slice(0, 31) + '…' : job.label
160 if (job.status !== 'running') {
161 const took = duration((job.endedAt ?? now) - job.startedAt)
162 const word = job.status === 'completed' ? `done in ${took}` : job.status === 'failed' ? `failed after ${took}` : `stopped after ${took}`
163 return { label, progress: '', eta: word }
164 }
165 const e = estimate(job, now)
166 const count = job.donePattern && job.total ? ` ${job.done}/${job.total}` : ''
167 const progress = e.frac === undefined ? '' : `${bar(e.frac)} ${Math.floor(e.frac * 100)}%${count}`
168 const at = (ms: number) => (tz === null ? `in ${duration(ms - now)}` : clock(ms, tz))
169 let eta: string
170 if (e.basis === 'measured' && e.at !== undefined) {
171 const range = e.lo !== undefined && e.hi !== undefined && e.hi - e.lo >= 4 * 60_000 ? ` (${at(e.lo)}–${at(e.hi)})` : ''
172 eta = `ETA ${at(e.at)}${tz === null ? '' : ` · in ${duration(e.at - now)}`}${range}`
173 } else if (e.basis === 'guess' && e.at !== undefined) {
174 eta = e.at > now ? `ETA ~${at(e.at)} (Claude's guess)` : `past Claude's guess of ${duration((job.expectedMs ?? 0))}`
175 } else {
176 eta = `running ${duration(now - job.startedAt)}${e.frac === undefined ? ' · no progress readable yet' : ' · measuring'}`
177 }
178 if (e.stalledMs !== undefined) eta += ` · no progress for ${duration(e.stalledMs)}`
179 return { label, progress, eta }
180}
181hooks/table.ts 265 lines1// The band's table of jobs as data: rows of toned parts, in columns sized to the band's width.
2// Drawing them is register.tsx's job.
3//
4// Columns, left to right: state, name, gauge, percent, done/total, ETA clock, "in …", range, note.
5// Running jobs come first, soonest ETA first. What has run is done with: a finished job shows its
6// full gauge for a beat and leaves; a failed one stays in red below a hairline until the person
7// has seen it. Columns keep their widths while the values in them change; when the band narrows,
8// columns go in a fixed order.
9//
10// One palette with smart-compact's line: the Claude accent for what runs, green for done, red for
11// failed, the readable grey for labels and the dim one for what sits back.
12
13import type { Job } from '../types'
14import { clock, duration, estimate } from './estimate'
15
16export type Tone = 'accent' | 'good' | 'bad' | 'warn' | 'text' | 'label' | 'faint' | 'glint'
17
18export type Part = { text: string; tone: Tone }
19
20/** A job row, the hairline, or the line that folds the ended jobs (a button). */
21export type Row = { key: string; kind: 'job' | 'rule' | 'fold'; parts: Part[] }
22
23/** How long a finished job's gauge shows full, with one glint along it, before it becomes a done row. */
24export const BEAT_MS = 1200
25const SPIN = ['◐', '◓', '◑', '◒'] as const
26const GAP = ' '
27
28function part(text: string, tone: Tone): Part {
29 return { text, tone }
30}
31
32export function width(text: string): number {
33 return [...text].length
34}
35
36/** Cut to `n` cells with an ellipsis, or padded to `n`. */
37export function cell(text: string, n: number): string {
38 const chars = [...text]
39 if (chars.length <= n) return text + ' '.repeat(n - chars.length)
40 return chars.slice(0, Math.max(0, n - 1)).join('') + '…'
41}
42
43function right(text: string, n: number): string {
44 return ' '.repeat(Math.max(0, n - width(text))) + text
45}
46
47/** smart-compact's gauge: half-cell steps (━ full, ╸ half, ─ empty), with an optional glint cell. */
48export function gauge(frac: number, cells: number, tone: Tone, glintAt?: number): Part[] {
49 const halves = Math.round(Math.min(1, Math.max(0, frac)) * cells * 2)
50 const out: Part[] = []
51 for (let i = 0; i < cells; i += 1) {
52 const filled = Math.min(2, Math.max(0, halves - i * 2))
53 const glyph = filled === 2 ? '━' : filled === 1 ? '╸' : '─'
54 const isGlint = glintAt !== undefined && Math.abs(i - glintAt) < 1
55 const t: Tone = isGlint ? (filled > 0 ? 'glint' : 'label') : filled > 0 ? tone : 'faint'
56 const last = out.at(-1)
57 if (last && last.tone === t) last.text += glyph
58 else out.push(part(glyph, t))
59 }
60 return out
61}
62
63type State = 'running' | 'waiting' | 'beat' | 'completed' | 'failed' | 'killed'
64
65/** What one job's row says, column by column. */
66export type Info = {
67 job: Job
68 state: State
69 frac?: number
70 pct?: string
71 count?: string
72 at?: string
73 inn?: string
74 innTone?: Tone
75 range?: string
76 note?: string
77 noteTone?: Tone
78 /** The note is the job's own last line: cut to the room there is. */
79 liveLine?: boolean
80 /** When it will likely finish, for the order. */
81 eta?: number
82}
83
84export function info(job: Job, now: number, tz: number | null): Info {
85 const count = job.donePattern && job.total ? `${job.done}/${job.total}` : undefined
86 if (job.status !== 'running') {
87 const end = job.endedAt ?? now
88 if (job.status === 'completed' && now - end < BEAT_MS) return { job, state: 'beat', frac: 1, pct: '100%', count }
89 const word = job.status === 'completed' ? 'done' : job.status === 'failed' ? 'failed' : 'stopped'
90 const took = duration(end - job.startedAt)
91 return {
92 job,
93 state: job.status,
94 at: tz === null ? word : `${word} ${clock(end, tz)}`,
95 inn: job.status === 'completed' ? `took ${took}` : `after ${took}`,
96 }
97 }
98 const e = estimate(job, now)
99 const pct = e.frac === undefined ? undefined : `${Math.floor(e.frac * 100)}%`
100 const stalledNote = e.stalledMs === undefined ? {} : { note: `no progress for ${duration(e.stalledMs)}`, noteTone: 'warn' as const }
101 if (e.basis === 'measured' && e.at !== undefined) {
102 const wide = e.lo !== undefined && e.hi !== undefined && e.hi - e.lo >= 4 * 60_000 && tz !== null
103 return {
104 job, state: 'running', frac: e.frac, pct, count, eta: e.at,
105 at: tz === null ? undefined : clock(e.at, tz),
106 inn: `in ${duration(e.at - now)}`,
107 range: wide ? `${clock(e.lo as number, tz as number)}–${clock(e.hi as number, tz as number)}` : undefined,
108 ...stalledNote,
109 }
110 }
111 if (e.basis === 'guess' && e.at !== undefined) {
112 const isOver = e.at <= now
113 return {
114 job, state: 'running', frac: e.frac, pct, count, eta: e.at,
115 at: tz === null || isOver ? undefined : `~${clock(e.at, tz)}`,
116 inn: isOver ? `over by ${duration(now - e.at)}` : `in ~${duration(e.at - now)}`,
117 innTone: isOver ? 'warn' : undefined,
118 note: "Claude's guess",
119 noteTone: 'faint',
120 ...stalledNote,
121 }
122 }
123 const line = job.statusLine && e.frac === undefined && job.lastLine ? job.lastLine.trim() : undefined
124 return {
125 job, state: e.frac === undefined ? 'waiting' : 'running', frac: e.frac, pct, count,
126 inn: `${duration(now - job.startedAt)} so far`,
127 note: line ?? (e.frac === undefined ? 'no progress readable yet' : 'measuring'),
128 noteTone: line ? 'text' : 'faint',
129 liveLine: line !== undefined,
130 ...stalledNote,
131 }
132}
133
134/** The columns a table keeps at a width. */
135export type Layout = { name: number; cells: number; count: boolean; inn: boolean; range: boolean; note: boolean }
136
137type Widths = { count: number; at: number; inn: number; range: number; note: number }
138
139function rowWidth(l: Layout, w: Widths): number {
140 return (
141 3 + l.name + 2 + l.cells + 2 + 4 +
142 (l.count && w.count ? 2 + w.count : 0) +
143 (w.at ? 2 + w.at : 0) +
144 (l.inn && w.inn ? 2 + w.inn : 0) +
145 (l.range && w.range ? 2 + w.range : 0) +
146 (l.note && w.note ? 2 + w.note : 0)
147 )
148}
149
150/**
151 * The widest layout that fits: the note goes first, then the range, the gauge shrinks to 12,
152 * done/total goes, then "in …", the gauge shrinks to 6, and last the name narrows.
153 */
154export function layout(longestName: number, w: Widths, columns: number): Layout {
155 let l: Layout = { name: Math.min(28, Math.max(8, longestName)), cells: 20, count: true, inn: true, range: true, note: true }
156 const steps: ((l: Layout) => Layout)[] = [
157 l => ({ ...l, note: false }),
158 l => ({ ...l, range: false }),
159 l => ({ ...l, cells: 12 }),
160 l => ({ ...l, count: false }),
161 l => ({ ...l, inn: false }),
162 l => ({ ...l, cells: 6 }),
163 l => ({ ...l, name: Math.min(l.name, 16) }),
164 l => ({ ...l, name: Math.min(l.name, 10) }),
165 ]
166 for (const step of steps) {
167 if (rowWidth(l, w) <= columns) break
168 l = step(l)
169 }
170 return l
171}
172
173/** The failures the person has seen (written again since): those leave the band too. */
174export type View = { seen: readonly string[] }
175
176function iconOf(i: Info, now: number): Part {
177 if (i.state === 'running' || i.state === 'waiting') return part(SPIN[Math.floor(now / 250) % SPIN.length] ?? '◐', 'accent')
178 if (i.state === 'beat' || i.state === 'completed') return part('✓', 'good')
179 if (i.state === 'failed') return part('✕', 'bad')
180 return part('⊘', 'label')
181}
182
183/**
184 * The table, row by row: running jobs (soonest first), then a hairline and the failures the person
185 * has not seen yet. A job that finished or was stopped is gone after its beat.
186 */
187export function table(list: readonly Job[], now: number, tz: number | null, columns: number, view: View): Row[] {
188 const infos = list.map(j => info(j, now, tz))
189 const running = infos
190 .filter(i => i.state === 'running' || i.state === 'waiting' || i.state === 'beat')
191 .sort((a, b) => (a.state === 'beat' ? -1 : b.state === 'beat' ? 1 : (a.eta ?? Infinity) - (b.eta ?? Infinity)))
192 const shown = infos
193 .filter(i => i.state === 'failed' && !view.seen.includes(i.job.id))
194 .sort((a, b) => (b.job.endedAt ?? 0) - (a.job.endedAt ?? 0))
195
196 const max = (f: (i: Info) => string | undefined) => Math.max(0, ...running.map(i => width(f(i) ?? '')))
197 // Widths that hold still while the values change: done/total as wide as its total twice, the
198 // clock with room for its "~", "in …" at least ten cells, the range its full length.
199 const totals = running.map(i => (i.job.donePattern && i.job.total ? width(String(i.job.total)) * 2 + 1 : 0))
200 const w: Widths = {
201 count: Math.max(0, ...totals),
202 at: running.some(i => i.at) ? Math.max(6, max(i => i.at)) : 0,
203 inn: running.some(i => i.inn) ? Math.max(10, max(i => i.inn)) : 0,
204 range: running.some(i => i.range) ? 11 : 0,
205 note: Math.max(0, ...running.map(i => Math.min(width(i.note ?? ''), i.liveLine ? 40 : Infinity))),
206 }
207 const longest = Math.max(0, ...[...running, ...shown].map(i => width(i.job.label)))
208 const l = layout(longest, w, columns)
209 const rows: Row[] = []
210
211 for (const i of running) {
212 const isBeat = i.state === 'beat'
213 const glintAt = isBeat ? ((now - (i.job.endedAt ?? now)) / BEAT_MS) * (l.cells + 4) - 2 : undefined
214 const parts: Part[] = [iconOf(i, now), part(GAP, 'faint'), part(cell(i.job.label, l.name), 'text'), part(GAP, 'faint')]
215 parts.push(...gauge(i.frac ?? 0, l.cells, isBeat ? 'good' : 'accent', glintAt))
216 parts.push(part(GAP + right(i.pct ?? '—', 4), i.pct ? 'text' : 'faint'))
217 if (l.count && w.count) parts.push(part(GAP + right(i.count ?? '', w.count), 'label'))
218 if (w.at) parts.push(part(GAP + right(i.at ?? '', w.at), 'text'))
219 if (l.inn && w.inn) parts.push(part(GAP + right(i.inn ?? '', w.inn), i.innTone ?? 'label'))
220 if (l.range && w.range) parts.push(part(GAP + cell(i.range ?? '', w.range), 'faint'))
221 if (l.note && w.note && i.note) {
222 const room = Math.max(8, columns - rowWidth(l, { ...w, note: 0 }) - GAP.length)
223 parts.push(part(GAP + (i.liveLine ? cell(i.note, Math.min(room, width(i.note))).trimEnd() : i.note), i.noteTone ?? 'faint'))
224 }
225 rows.push({ key: i.job.id, kind: 'job', parts: trimEnd(parts) })
226 }
227
228 if (running.length > 0 && shown.length > 0) {
229 rows.push({ key: 'rule', kind: 'rule', parts: [part('─'.repeat(Math.min(columns, rowWidth(l, w))), 'faint')] })
230 }
231
232 for (const i of shown) {
233 const failed = i.state === 'failed'
234 rows.push({
235 key: i.job.id,
236 kind: 'job',
237 parts: [
238 iconOf(i, now),
239 part(GAP, 'faint'),
240 part(cell(i.job.label, l.name), failed ? 'bad' : 'label'),
241 part(GAP, 'faint'),
242 part(i.at ?? '', failed ? 'bad' : i.state === 'completed' ? 'good' : 'label'),
243 part(` · ${i.inn ?? ''}`, 'faint'),
244 ],
245 })
246 }
247
248 return rows
249}
250
251/** No trailing blanks: a padded empty column at the end of a row is just room. */
252function trimEnd(parts: Part[]): Part[] {
253 const out = [...parts]
254 while (out.length > 0) {
255 const last = out.at(-1) as Part
256 const trimmed = last.text.replace(/\s+$/, '')
257 if (trimmed) {
258 out[out.length - 1] = { ...last, text: trimmed }
259 break
260 }
261 out.pop()
262 }
263 return out
264}
265types/index.d.ts 47 lines1/** A job whose progress is read from a file it writes: a background task's output or a log. */
2export type Job = {
3 id: string
4 label: string
5 source: 'background' | 'claude'
6 file?: string
7 /** A background task's command line: a log it writes is that task's progress, not another job. */
8 command?: string
9 /** Each output line this matches counts one unit done. */
10 donePattern?: string
11 /** A progress line: groups (done)(total), or one group holding a percentage. */
12 progressPattern?: string
13 finishedPattern?: string
14 /** Show the file's last line beside the job, for jobs that write their state. */
15 statusLine?: boolean
16 /** The last non-empty line read from the file. */
17 lastLine?: string
18 total?: number
19 /** Claude's own guess at the whole job's duration, used until progress is measurable. */
20 expectedMs?: number
21 startedAt: number
22 offset: number
23 /** When the file last grew (or the job began); a status-line job silent for too long is ended. */
24 grewAt?: number
25 carry: string
26 done: number
27 frac?: number
28 /** [time, fraction done], oldest first; the first may be the job's start at 0. */
29 samples: [number, number][]
30 lastProgressAt?: number
31 status: 'running' | 'completed' | 'failed' | 'killed'
32 endedAt?: number
33}
34
35declare module 'claude-code' {
36 interface PluginState {
37 eta: {
38 /** The jobs shown: running, finishing (their beat), or failed and not yet seen. */
39 jobs: Job[]
40 /** Failed jobs the person has seen, by writing again since; until then a failure keeps its red row. */
41 seen: string[]
42 /** The person's offset from UTC in minutes, for clock times; null until read. */
43 tzOffsetMin: number | null
44 }
45 }
46}
47