See what your agent's shell commands are doing instead of a spinner: live steps, progress bars, elapsed time and ETAs in Claude Code's spinner, above the…

See what your coding agent's shell commands are doing, instead of a spinner.
<img src="docs/demo.gif" width="860" alt="Claude Code running unspin demo. The spinner names the step that is running, such as 'building macos 31/42, eta 1s', and a block above the prompt lists the steps with live progress bars, a red cross on the failed test run, and the summary when it ends.">
When Claude Code runs a long shell command, you get the command and a spinner until it ends. Is it installing? On the fourth of fifty downloads? Stuck? You can't tell. unspin teaches the agent to mark the steps of its batched commands as it goes, and shows them live. Claude Code's own spinner names the step that is running, and a block above the prompt lists every step with a progress bar, its time and an ETA.
✻ Baking · building image 37/72, eta 33s… (1m 12s · ↓ 2.1k tokens)
▶ Ship the release step 4/6 · 1m14s
✔ linting 1.2s
✘ running tests 120/120 — 1 of 120 failed 23s
· test_88.py is a known flake, carrying on
⠴ building image 35s ━━━━━───── 37/72 · eta 33s · layer sha256:ab12cd34ef56
In Claude Code, install the plugin. There is nothing to configure.
/plugin marketplace add nkyriazis/unspin
/plugin install unspin@unspin
Restart Claude Code, then ask Claude to run the unspin demo command.
Prefer the flow under your status line, or still on Claude Code before 2.1.287? Use the installer instead. It backs up settings.json before changing it.
curl -fsSL https://raw.githubusercontent.com/nkyriazis/unspin/main/install.sh | bash
More ways to install covers both at once, other agents and installing from a clone.
Tell Claude once, at the start of a session.
use unspin from now on
From then on, when it batches several steps into one command or starts something long, such as a build, a test run, a loop over files or a wait for a server, it marks the steps and you watch them as they go. A hook reminds it when it forgets. A long job can also run in the background with its progress in view.
To have this in every session without asking, add a line like this to ~/.claude/CLAUDE.md.
Use the unspin skill for batched and long-running shell commands, so I can see their steps and progress live.
The skill gives Claude a few shell functions. A typical command looks like this.
source "${CLAUDE_SKILL_DIR}/unspin.sh"
flow "Ship the release" 6
run -q "installing dependencies" npm ci
flow_each -j 4 "optimizing images" assets/*.png -- optipng -quiet {}
run -p "building" ninja -C build
run "unit tests" npm test & run "lint" npm run lint & wait
flow_wait -t 60 "waiting for the preview" -- curl -sf localhost:3000/health
| Call | What it does | |
|---|---|---|
flow "Title" [N] | Starts a flow of N steps | |
step "msg", ok, fail, note "msg" | Open a step, close it, or add a note | |
run "msg" cmd… | A step around one command, closed by its exit status. -l shows its latest output line, -p reads counts like [3/10] from it, -q keeps the output in a log and prints only the tail if it fails | |
progress N [TOTAL] ["label"] | Moves the bar. Counts can carry units (1.2G/4G) or be percentages | |
flow_each [-j N] "msg" items… -- cmd {} | A loop with one bar, the current item and a failure tally, N at a time | |
flow_wait [-t SECS] "msg" -- cmd… | Retries until cmd succeeds, with a bar for the time | |
| `cmd \ | flow_lines [-p]` | Counts output lines as progress, or reads counts from them |
Jobs started with & each get their own row. The skill has the details the agent reads.
| Where | How |
|---|---|
| Claude Code's spinner, and the band above the prompt | the plugin |
| Under your Claude Code status line | unspin setup, which the installer runs |
| Your own status line script | unspin block "$session_id" prints the block, or nothing |
| ccstatusline and other status line builders | a custom command running unspin statusline --line |
| A second terminal or a tmux pane | unspin watch |
| The tmux status bar | set -g status-right '#(unspin tmux)' and set -g status-interval 1 |
| Codex, Gemini CLI, opencode, Goose, Cline, Cursor, Amp | each step is also printed to the command's stderr, which these agents show |
The helper appends one line per event to a file for the agent session, in $XDG_RUNTIME_DIR/unspin or /tmp/unspin-<uid>. The plugin reads that file twice a second and the status line once a second, and both draw it. No daemon runs and nothing goes over the network. The helper writes to that file and not to stdout, so the agent still reads the command's own output.
Each event is a plain line of text, so anything that can append to a file can report progress. A Python loop, a Makefile and a hand-typed echo all work. The format is in docs/protocol.md.
A step remembers the process that opened it. If that process dies before closing the step (you pressed Esc, the shell was killed), the step shows as interrupted instead of spinning forever. Steps that are running or have failed stay in view when later steps would push them out. A short reminder reaches Claude when it writes a batched command without the helper, at most once every five minutes. When you send your next prompt the finished flow is put away.
Set these in the environment, or under env in Claude Code's settings.json.
| Variable | Default | Effect |
|---|---|---|
UNSPIN_ROWS | 4 | Steps shown under the header |
UNSPIN_SPINNER | 1 | 0 keeps the plugin out of the spinner |
UNSPIN_BAND | 1 | 0 keeps the plugin out of the band above the prompt |
UNSPIN_NUDGE | 1 | 0 stops the reminder to instrument batched commands |
UNSPIN_ECHO | auto | 1 also writes each step to stderr, 0 never does. Auto turns it on in agents without a live status line |
UNSPIN_ASCII | 0 | 1 draws with plain ASCII |
NO_COLOR | Turns color off in the status line | |
UNSPIN_DIR | Where flow files go |
bash 3.2 (the one macOS ships) to 5.3, and zsh. gawk, mawk, busybox awk and BSD awk. Linux and macOS. The test suite runs the helper under each of these shells and the status line renderer under each of these awks, and the plugin's mod under Claude Code's own test kit.
unspin doctor checks the install and says what to fix.
If the plugin is installed but nothing shows in the spinner or above the prompt, mods may be off for your account or organization. claude --debug logs why a mod did not load. The status line works either way, so until that changes, add it next to the plugin as in More ways to install.
Both places. With the plugin installed, run the installer with --statusline. It then adds only the status line.
curl -fsSL https://raw.githubusercontent.com/nkyriazis/unspin/main/install.sh | bash -s -- --statusline
Other agents. npx skills add nkyriazis/unspin installs the skill for Codex, Gemini CLI, opencode, Cursor, Goose and the other agents skills knows about. Where it shows says what you see in each.
From a clone. git clone https://github.com/nkyriazis/unspin && unspin/install.sh installs that working copy.
The plugin goes with /plugin uninstall unspin@unspin. The installer's setup goes with unspin uninstall, which restores your status line and hooks, and then rm -rf ~/.local/share/unspin removes the files.
MIT
hooks/register.ts 141 lines1// unspin as a Claude Code mod. While a batched command runs, the step it is
2// on shows in Claude Code's own spinner, and the whole flow in the band above
3// the prompt, both redrawn from this session's flow file twice a second.
4// Nothing to set up: the status line stays yours.
5//
6// UNSPIN_BAND=0 or UNSPIN_SPINNER=0 turns either off, UNSPIN_ROWS sets how
7// many steps the band shows (4).
8import { body, brief, glyph, header, parse, pick, settle, type Flow, type Item, type Seg } from './flow.ts'
9
10let sid = ''
11let file = ''
12let seen = -1
13let busy = false
14let flow: Flow | undefined
15let now = 0
16let showBand = true
17let showSpinner = true
18let rows = 4
19let hasProc = false
20let ownNs = ''
21let checked = 0
22
23// The session's flow file, resolved the way unspin.sh resolves it
24async function locate($) {
25 showBand = (await $.env.get('UNSPIN_BAND')) !== '0'
26 showSpinner = (await $.env.get('UNSPIN_SPINNER')) !== '0'
27 rows = Math.max(1, Number((await $.env.get('UNSPIN_ROWS')) ?? '4') || 4)
28 hasProc = await $.fs.exists('/proc/self/stat')
29 let dir = (await $.env.get('UNSPIN_DIR')) ?? ''
30 if (dir === '') {
31 const xdg = (await $.env.get('XDG_RUNTIME_DIR')) ?? ''
32 if (xdg !== '' && (await $.fs.exists(xdg))) {
33 dir = xdg + '/unspin'
34 } else {
35 const id = await $.process.run(['id', '-u'])
36 dir = '/tmp/unspin-' + id.stdout.trim()
37 }
38 }
39 if (hasProc) {
40 const link = await $.process.run(['readlink', '/proc/self/ns/pid'])
41 ownNs = link.stdout.replace(/[^0-9]/g, '')
42 }
43 file = dir + '/' + sid.replace(/[^A-Za-z0-9._-]/g, '_') + '.log'
44 seen = -1
45 flow = undefined
46}
47
48// A step whose shell is gone without closing it was interrupted. Across pid
49// namespaces (a sandbox) the pid means nothing here, so it counts as alive.
50async function alive($, item: Item): Promise<boolean> {
51 if (!item.pid) return true
52 if (item.ns && ownNs && item.ns !== ownNs) return true
53 if (hasProc) return $.fs.exists('/proc/' + item.pid)
54 const r = await $.process.run(['kill', '-0', item.pid])
55 return r.exitCode === 0
56}
57
58// One tick at a time, even when a slow one overlaps the next
59async function refresh($) {
60 if (busy) return
61 busy = true
62 try {
63 await tick($)
64 } finally {
65 busy = false
66 }
67}
68
69// Read the flow again when the file changed, and redraw while it moves. A new
70// session id (after /clear, /resume or /branch) means another file.
71async function tick($) {
72 now = (await $.clock.now()) / 1000
73 const id = String(await $.session.id())
74 if (id !== sid) {
75 sid = id
76 await locate($)
77 }
78 const stat = await $.fs.stat(file).catch(() => undefined)
79 if (!stat) {
80 if (flow) {
81 flow = undefined
82 $.ui.invalidate('ui.render')
83 }
84 return
85 }
86 let changed = false
87 if (stat.mtimeMs !== seen) {
88 seen = stat.mtimeMs
89 const text = await $.fs.read(file).catch(() => '')
90 flow = text ? parse(text) : undefined
91 changed = true
92 }
93 if (!flow) return
94 const running = flow.items.filter((i) => i.state === 'run')
95 if (running.length > 0 && (changed || now - checked >= 2)) {
96 checked = now
97 const live = new Set<Item>()
98 for (const item of running) if (await alive($, item)) live.add(item)
99 settle(flow, (item) => live.has(item))
100 }
101 if (changed || flow.items.some((i) => i.state === 'run')) $.ui.invalidate('ui.render')
102}
103
104export function register(on) {
105 // The first tick finds the file; the session does not wait for it.
106 on('session.start', async ($, e, next) => {
107 $.clock.every(500, () => refresh($))
108 return next(e)
109 })
110
111 // Claude Code's spinner, with the running step after its word:
112 // "Baking · building image 37/72, eta 33s… (1m 12s)"
113 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
114 if (!showSpinner || !flow) return next(e)
115 const what = brief(flow, now)
116 if (!what) return next(e)
117 return next({ ...e, props: { ...e.props, suffix: ' · ' + what + '…' } })
118 })
119
120 // The band above the prompt: a header and the steps that matter most,
121 // with anything other mods draw there kept below.
122 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
123 if (!showBand || !flow || e.props.hasSurvey) return next(e)
124 const { Box, Text } = $.ui.resolve(e)
125 const width = e.props.bodyColumns
126 const room = Math.max(1, Math.min(rows, e.props.maxRows - 2))
127 const draw = (seg: Seg) =>
128 seg.color || seg.dim || seg.bold
129 ? Text({ color: seg.color, dimColor: seg.dim, bold: seg.bold, children: [seg.text] })
130 : seg.text
131 const line = (segs: Seg[]) => Text({ wrap: 'truncate-end', children: segs.map(draw) })
132 const lines = [line(header(flow, now))]
133 for (const item of pick(flow, room)) {
134 const cap = item.state === 'run' && item.count ? Math.floor(width * 0.4) : 0
135 lines.push(line([{ text: ' ' }, glyph(item, now), { text: ' ' }, ...body(item, now, cap)]))
136 }
137 const theirs = await next(e)
138 return Box({ flexDirection: 'column', children: theirs ? [...lines, theirs] : lines })
139 })
140}
141hooks/flow.ts 320 lines1// The flow file (docs/protocol.md) as data, and what unspin draws from it.
2// The same rules as skills/unspin/lib/render.awk, which the status line uses.
3
4export type State = 'run' | 'ok' | 'fail' | 'stale' | 'note'
5
6export interface Item {
7 state: State
8 text: string
9 t0?: number
10 t1?: number
11 pid?: string
12 ns?: string
13 // Both times carry fractions, so a short duration can show tenths
14 precise?: boolean
15 detail?: string
16 count?: string
17 label?: string
18 // First and latest numeric readings of the count, for the rate behind the ETA
19 n0?: number
20 at0?: number
21 nl?: number
22}
23
24export interface Flow {
25 title: string
26 total?: string
27 started?: number
28 last?: number
29 items: Item[]
30 steps: number
31}
32
33// A piece of a drawn line, with its look
34export interface Seg {
35 text: string
36 color?: 'green' | 'red' | 'yellow'
37 dim?: boolean
38 bold?: boolean
39}
40
41const GLYPHS = ['▶', '✔', '✘', '·', '%']
42const KEYS: Record<string, string[]> = { '▶': ['id', 'pid', 'ns'], '✔': ['id'], '✘': ['id'], '%': ['id'], '·': [] }
43
44// Strip "@ts" and the key=value tokens a glyph may carry (each key once)
45function meta(s: string, keys: string[]): { rest: string; ts?: number; fine: boolean; m: Record<string, string> } {
46 const m: Record<string, string> = {}
47 const first = /^@(\d+(?:\.\d+)?)(?: |$)/.exec(s)
48 if (!first) return { rest: s, fine: false, m }
49 let rest = s.slice(first[0].length)
50 for (;;) {
51 const tok = /^([a-z]+)=(\S*)(?: |$)/.exec(rest)
52 if (!tok || !keys.includes(tok[1]) || tok[1] in m) break
53 m[tok[1]] = tok[2]
54 rest = rest.slice(tok[0].length)
55 }
56 return { rest, ts: Number(first[1]), fine: first[1].includes('.'), m }
57}
58
59function clean(s: string): string {
60 return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x1f\x7f]/g, ' ')
61}
62
63export function parse(text: string): Flow {
64 const flow: Flow = { title: '', items: [], steps: 0 }
65 const byId = new Map<string, Item>()
66 const lines = text.split('\n')
67 const lastRunning = () => {
68 for (let i = flow.items.length - 1; i >= 0; i--) if (flow.items[i].state === 'run') return flow.items[i]
69 return undefined
70 }
71 const stamp = (ts?: number) => {
72 if (ts !== undefined && (flow.last === undefined || ts > flow.last)) flow.last = ts
73 }
74 lines.forEach((raw, i) => {
75 if (raw === '') return
76 const line = clean(raw)
77 const glyph = GLYPHS.find((g) => line.startsWith(g + ' '))
78 if (i === 0 && !glyph) {
79 let t = line
80 const at = /^@(\d+(?:\.\d+)?) /.exec(t)
81 if (at) {
82 flow.started = Number(at[1])
83 t = t.slice(at[0].length)
84 }
85 const total = / \[(\d+)\]$/.exec(t)
86 if (total) {
87 flow.total = total[1]
88 t = t.slice(0, total.index)
89 }
90 flow.title = t
91 return
92 }
93 if (!glyph) {
94 if (/\S/.test(line)) flow.items.push({ state: 'note', text: line })
95 return
96 }
97 const { rest, ts, fine, m } = meta(line.slice(glyph.length + 1), KEYS[glyph])
98 stamp(ts)
99 const target = m.id !== undefined ? byId.get(m.id) : lastRunning()
100 switch (glyph) {
101 case '▶': {
102 const item: Item = { state: 'run', text: rest, t0: ts, precise: fine, pid: /^\d+$/.test(m.pid ?? '') ? m.pid : undefined, ns: m.ns }
103 flow.items.push(item)
104 if (m.id !== undefined) byId.set(m.id, item)
105 flow.steps++
106 break
107 }
108 case '✔':
109 case '✘': {
110 const state = glyph === '✔' ? 'ok' : 'fail'
111 if (target && target.state === 'run') {
112 target.state = state
113 target.t1 = ts
114 target.precise = target.precise && fine
115 target.detail = rest
116 } else if (!target) {
117 flow.items.push({ state, text: rest, t0: ts })
118 flow.steps++
119 }
120 break
121 }
122 case '%': {
123 if (!target || target.state !== 'run') break
124 const sp = rest.indexOf(' ')
125 const count = sp < 0 ? rest : rest.slice(0, sp)
126 target.label = sp < 0 ? '' : rest.slice(sp + 1)
127 if (count === '-' || count === '') break
128 target.count = count
129 const f = frac(count)
130 if (f) {
131 if (target.n0 === undefined) {
132 target.n0 = f.n
133 target.at0 = ts
134 }
135 target.nl = f.n
136 }
137 break
138 }
139 case '·':
140 flow.items.push({ state: 'note', text: rest, t0: ts })
141 break
142 }
143 })
144 return flow
145}
146
147// Steps whose shell is gone without closing them were interrupted
148export function settle(flow: Flow, isAlive: (item: Item) => boolean): Flow {
149 for (const item of flow.items) if (item.state === 'run' && !isAlive(item)) item.state = 'stale'
150 return flow
151}
152
153const MULT: Record<string, number> = { K: 1e3, M: 1e6, G: 1e9, T: 1e12, P: 1e15 }
154const MULTI: Record<string, number> = { K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }
155
156function mult(unit: string): number {
157 const c = unit.charAt(0).toUpperCase()
158 if (/^[KMGT]i/.test(unit)) return MULTI[c] ?? 1
159 return MULT[c] ?? 1
160}
161
162// "N/T", "P%" with optional units ("12s/60s", "1.5G/4G")
163export function frac(count: string): { n: number; t: number; unit: string } | undefined {
164 const pct = /^(\d+(?:\.\d+)?)%$/.exec(count)
165 if (pct) return { n: Number(pct[1]), t: 100, unit: '%' }
166 const m = /^(\d+(?:\.\d+)?)([A-Za-z]*)\/(\d+(?:\.\d+)?)([A-Za-z]*)$/.exec(count)
167 if (!m) return undefined
168 const t = Number(m[3]) * mult(m[4])
169 if (!(t > 0)) return undefined
170 return { n: Number(m[1]) * mult(m[2]), t, unit: m[4] }
171}
172
173export function dur(d: number, precise = false): string {
174 if (!(d > 0)) d = 0
175 if (d < 10 && precise) return d.toFixed(1) + 's'
176 if (d < 60) return Math.floor(d) + 's'
177 if (d < 3600) return Math.floor(d / 60) + 'm' + String(Math.floor(d % 60)).padStart(2, '0') + 's'
178 return Math.floor(d / 3600) + 'h' + String(Math.floor((d % 3600) / 60)).padStart(2, '0') + 'm'
179}
180
181// Ten cells: a heavy line for the part done and a light one for the rest,
182// meeting in a half cell
183export function bar(n: number, t: number, cells = 10): [done: string, rest: string] {
184 const f = Math.min(1, Math.max(0, t > 0 ? n / t : 0))
185 const h = Math.floor(f * cells * 2)
186 const done = '━'.repeat(Math.floor(h / 2)) + (h % 2 ? '╾' : '')
187 return [done, '─'.repeat(cells - Math.ceil(h / 2))]
188}
189
190function eta(item: Item, now: number): number | undefined {
191 const f = item.count ? frac(item.count) : undefined
192 if (!f || f.unit === 's' || f.unit === '%' || f.n >= f.t) return undefined
193 if (item.at0 === undefined || item.n0 === undefined || item.nl === undefined) return undefined
194 if (now - item.at0 < 2 || item.nl <= item.n0) return undefined
195 const rate = (item.nl - item.n0) / (now - item.at0)
196 const left = (f.t - f.n) / rate
197 return left >= 1 ? left : undefined
198}
199
200const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
201
202export function spinner(now: number, perSecond = 2): string {
203 return SPIN[Math.floor(now * perSecond) % SPIN.length]
204}
205
206export function counts(flow: Flow) {
207 let running = 0
208 let failed = 0
209 let stale = 0
210 for (const item of flow.items) {
211 if (item.state === 'run') running++
212 else if (item.state === 'fail') failed++
213 else if (item.state === 'stale') stale++
214 }
215 return { running, failed, stale }
216}
217
218// The header: what the flow is and how far along
219export function header(flow: Flow, now: number): Seg[] {
220 const { running, failed, stale } = counts(flow)
221 const start = flow.started ?? flow.items.find((i) => i.t0 !== undefined)?.t0
222 const title = flow.title || 'flow'
223 if (running) {
224 let meta = 'step ' + flow.steps + (flow.total ? '/' + flow.total : '')
225 if (running > 1) meta += ' · ' + running + ' running'
226 if (start !== undefined) meta += ' · ' + dur(now - start)
227 return [{ text: '▶ ', color: 'yellow' }, { text: title, bold: true }, { text: ' ' + meta, dim: true }]
228 }
229 let meta = flow.steps + (flow.steps === 1 ? ' step' : ' steps')
230 if (failed) meta += ', ' + failed + ' failed'
231 if (stale) meta += ', ' + stale + ' interrupted'
232 if (start !== undefined && flow.last !== undefined) meta += ' · ' + dur(flow.last - start)
233 const bad = failed + stale > 0
234 return [{ text: bad ? '✘ ' : '✔ ', color: bad ? 'red' : 'green' }, { text: title, bold: true }, { text: ' ' + meta, dim: true }]
235}
236
237// One step or note, without its glyph
238export function body(item: Item, now: number, textCap = 0): Seg[] {
239 const cap = (s: string) => (textCap > 0 && s.length > textCap ? s.slice(0, textCap - 1) + '…' : s)
240 if (item.state === 'note') return [{ text: item.text, dim: true }]
241 if (item.state === 'run') {
242 const segs: Seg[] = [{ text: cap(item.text), color: 'yellow' }]
243 const el = item.t0 !== undefined ? now - item.t0 : -1
244 const f = item.count ? frac(item.count) : undefined
245 if (f) {
246 if (f.unit !== 's' && el >= 1) segs.push({ text: ' ' + dur(el), dim: true })
247 const [done, rest] = bar(f.n, f.t)
248 segs.push({ text: ' ' }, { text: done, color: 'yellow' }, { text: rest, dim: true }, { text: ' ' })
249 segs.push({ text: item.count! })
250 const left = eta(item, now)
251 if (left !== undefined) segs.push({ text: ' · eta ' + dur(left), dim: true })
252 } else {
253 if (el >= 1) segs.push({ text: ' ' + dur(el), dim: true })
254 if (item.count) segs.push({ text: ' · ' + item.count })
255 }
256 if (item.label) segs.push({ text: ' · ' + item.label, dim: true })
257 return segs
258 }
259 const segs: Seg[] = [{ text: item.text }]
260 const f = item.count ? frac(item.count) : undefined
261 if (item.count && !(f && f.unit === 's')) segs.push({ text: ' ' + item.count })
262 if (item.state === 'stale') {
263 segs.push({ text: ' — interrupted', color: 'red' })
264 return segs
265 }
266 const d = item.detail ?? ''
267 if (item.t0 === undefined && /^[0-9.]+m?[0-9.]*s$/.test(d)) {
268 segs.push({ text: ' ' + d, dim: true })
269 return segs
270 }
271 if (d) segs.push({ text: ' — ' + d })
272 if (item.t0 !== undefined && item.t1 !== undefined) segs.push({ text: ' ' + dur(item.t1 - item.t0, item.precise), dim: true })
273 return segs
274}
275
276export function glyph(item: Item, now: number): Seg {
277 switch (item.state) {
278 case 'run':
279 return { text: spinner(now), color: 'yellow' }
280 case 'ok':
281 return { text: '✔', color: 'green' }
282 case 'fail':
283 case 'stale':
284 return { text: '✘', color: 'red' }
285 default:
286 return { text: '·', dim: true }
287 }
288}
289
290// Which items get a row: running steps first, then failures, then the most
291// recent, shown in their original order
292export function pick(flow: Flow, rows: number): Item[] {
293 const chosen = new Set<number>()
294 const want = (test: (i: Item) => boolean) => {
295 for (let i = flow.items.length - 1; i >= 0 && chosen.size < rows; i--) if (test(flow.items[i])) chosen.add(i)
296 }
297 want((i) => i.state === 'run')
298 want((i) => i.state === 'fail' || i.state === 'stale')
299 want(() => true)
300 return [...chosen].sort((a, b) => a - b).map((i) => flow.items[i])
301}
302
303// For Claude Code's spinner: the step that is running now, in a few words
304export function brief(flow: Flow, now: number): string {
305 const running = flow.items.filter((i) => i.state === 'run')
306 if (running.length === 0) return ''
307 const cur = running[running.length - 1]
308 let s = cur.text.length > 40 ? cur.text.slice(0, 39) + '…' : cur.text
309 const f = cur.count ? frac(cur.count) : undefined
310 if (f && f.unit !== 's') {
311 s += ' ' + cur.count
312 const left = eta(cur, now)
313 if (left !== undefined) s += ', eta ' + dur(left)
314 } else if (cur.label) {
315 s += ': ' + (cur.label.length > 30 ? cur.label.slice(0, 29) + '…' : cur.label)
316 }
317 if (running.length > 1) s += ' (+' + (running.length - 1) + ' more)'
318 return s
319}
320