SLOPSHOPPER

unspin

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…

bandspinnerprocesstimer
v1.0.0MITupdated 2026-10-15nkyriazis/unspin
A shopper browsing a rack in a slop shop
README

unspin

CI License: MIT

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

Install

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.

Everyday use

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.

What the agent writes

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
CallWhat 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 it shows

WhereHow
Claude Code's spinner, and the band above the promptthe plugin
Under your Claude Code status lineunspin setup, which the installer runs
Your own status line scriptunspin block "$session_id" prints the block, or nothing
ccstatusline and other status line buildersa custom command running unspin statusline --line
A second terminal or a tmux paneunspin watch
The tmux status barset -g status-right '#(unspin tmux)' and set -g status-interval 1
Codex, Gemini CLI, opencode, Goose, Cline, Cursor, Ampeach step is also printed to the command's stderr, which these agents show

How it works

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.

Configuration

Set these in the environment, or under env in Claude Code's settings.json.

VariableDefaultEffect
UNSPIN_ROWS4Steps shown under the header
UNSPIN_SPINNER10 keeps the plugin out of the spinner
UNSPIN_BAND10 keeps the plugin out of the band above the prompt
UNSPIN_NUDGE10 stops the reminder to instrument batched commands
UNSPIN_ECHOauto1 also writes each step to stderr, 0 never does. Auto turns it on in agents without a live status line
UNSPIN_ASCII01 draws with plain ASCII
NO_COLORTurns color off in the status line
UNSPIN_DIRWhere flow files go

Works with

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.

Troubleshooting

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.

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.

Uninstall

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.

License

MIT

Source 2 files
hooks/register.ts 141 lines
1// 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}
141
hooks/flow.ts 320 lines
1// 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