A Beadwork ticket pane inside Claude Code: /bw <id> or a search box runs bw show and draws the digest, title, description and comments; every ticket id a reply…

A Beadwork ticket viewer inside Claude Code. The agent names tickets by id (adf-c50, think-1pp) and the person reading has no idea what they refer to without opening another terminal and running bw show. This plugin puts the ticket one keypress or one click away, in a pane beside the transcript.

Nothing here writes to bw.
Needs Beadwork (bw on PATH), Claude Code 2.1.270 or newer, and function hooks turned on: export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the environment that launches claude, or the hooks module is ignored without a word. jq is optional and makes the board list cheaper.
This repo is the plugin and its own marketplace, so from any session:
/plugin marketplace add iautom8things/bw-peek
/plugin install bw-peek@bw-peek
or from a shell, claude plugin marketplace add iautom8things/bw-peek then claude plugin install bw-peek@bw-peek. Restart the session and /bw is there. To try it without installing, clone and run claude --plugin-dir ./bw-peek.
Under a reply. Every assistant text block that mentions a ticket on a board bw's registry knows (bw registry list, see The registry) gets one dim row beneath it:
⏺ The work is tracked in adf-c50 and think-1pp, and adf-zu6 blocks adf-lxh.
◈ [ adf-c50 ] [ think-1pp ] [ adf-zu6 ] [ adf-lxh ]
One button per distinct id, in order of first mention, capped (+N more). A click opens the pane on that ticket. This is a render-side decoration: the model's text is untouched, no rule asks it to format ids, and it costs no tokens. Ids with unknown prefixes (sha-256, utf-8) draw nothing.
The shape of an id is not enough. With pm a registered prefix, "the PM-internal pieces" and "after PM-1a merges" both read as pm ids, so every full id is checked against its board and only a real ticket gets a button. A board's ids come from bw list --all --json | jq -r '.[].id' (the JSON contract, one id per line, about 4 KB for 500 tickets in 0.3 s): the session repo's own at session start, another repo's (bw -C <path>, the path from the registry) the first time a reply names its prefix. Each is re-read when a reply names it and the list is older than two minutes, and after a bw create, bw delete or bw import runs through the Bash tool. Without jq the plain bw list --all text listing is read instead. Until a board has been read its ids stay plain text, then the reply redraws with their buttons. One drawing starts at most six board reads, in order of mention; the redraw each causes starts the next. A board bw cannot list (its repo moved, no bw init) gets one log line: if it was read before it keeps the ids it had, and if it never was its ids keep their buttons unchecked.
Bare ids count too, when they are tickets on the session repo's own board: c50, wxh.5, 1jf.234 (three or four letters and digits, then any .N) draw as [ adf-c50 ] and so on. A bare id never reaches another board: it has no way to say which one it means. A short stoplist of common three- and four-letter words (the, and, with, json, ...) is skipped even if a ticket happens to spell one; that ticket is still one /bw adf-the away.
The pane. Opened by a mention button, by /bw <id>, or by /bw alone with the search box focused. Docked beside the transcript from 110 columns, inline above the prompt below that. Escape closes it; ctrl+x tab focuses it.
◈ Beadwork adf-c50 [ Refresh ]
ticket id, e.g. adf-c50 or c50 ⏎ open
──────────────────────────────────────────────────────────────────────
✓ closed P1 feature ⚑ slug:live-activity-function-hooks created Sep 16 · closed Sep 16
live-activity: function-hooks plugin drawing live tasks, shells, monitors ...
blocks [ adf-lxh ]
↳ Shipped on main at 392b2a60 (v0.100.0) ...
Description 31 rows [ Show all ]
Ready-for: implement
...
… 23 more rows
Comments (1)
[ + ] Sep 16 9:44 AM Committed a7507330 on branch live-activity (workt…
recent [ think-1pp ] [ adf-zu6 ] [ forget ]
esc closes · type an id and press enter · /bw <id> from the prompt
○ open, ◐ in progress, ⊘ blocked for an open ticket with blockers, ✓ closed, ❄ deferred), priority colored by level, type, labels, assignee, ⏰ due <date> (in 4d) in yellow or red once overdue, ❄ until <date> with (now due) once the deferral has lapsed, and created / closed or updated.parent, blocked by, blocks. A press opens that ticket; recent at the bottom is the trail back.◐ P1 [ adf-utl.1 ] title, in counting order (.2 before .10). A press opens the child, whose parent button is the way back. More than the collapsed-row count fold behind [ Show all ].[ Show all ] / [ Collapse ]. Rows are counted as drawn (word-wrapped at the pane's width), so a long paragraph is cut mid-way with an ellipsis rather than hidden whole.[ + ], its time, and the first 50 characters. [ + ] opens the full text in a box; [ − ] folds it.isolated from [ adf-lxh ] after [ adf-c50 ] landed. Full ids on their board and bare ids on this board both count, the same rule as under a reply. A press opens that ticket, and recent is the way back. The plugin lays these texts out itself (a word wrap where an id is one token as wide as its button), so the row counts and the collapse cut are exact.Search. The box takes a full id in any case, with wrapping punctuation or a pasted bw show adf-c50 around it; a bare local part (c50, wxh.5) takes the prefix of the repo the session runs in (bw config get prefix). Every prefix bw's registry knows resolves from any cwd, so think-1pp opens from the adf repo. A partial id (think-1) draws bw's ambiguous list as buttons. A missing ticket says so.
Cost. A redraw never waits on bw: the board list is read in the background and the reply redraws when it lands. What the mention hook adds per redraw of an assistant block, measured on 2.1.273 (bun run tests/perf/mentions.bench.ts for the scan alone, tests/perf/render.test.ts for the hook through the engine harness), against a 500-ticket board:
| reply | scan alone | whole hook per redraw |
|---|---|---|
| 2 KB prose, no candidates | 0.23 ms | 0.43 ms |
| 5 KB, 100 bare candidates, 1 real | 0.35 ms | 0.73 ms |
| 5 KB, 100 candidates, 5 real | 0.35 ms | 0.71 ms |
| 5 KB, 100 candidates, 10 real | 0.33 ms | 0.56 ms |
| 50 KB, 1000 candidates, 100 real | 3.4 ms | 1.3 ms |
The share of real ids does not move the number; text length does (two regex passes over the block, about 70 µs per KB). A set lookup per candidate is nanoseconds. The one-time id list is about 0.3 s for 500 tickets and runs off the render path.
The board follows the shell. A cd the agent runs through the Bash tool moves the session's cwd, so the repo's prefix is read again before every board read, not once at start. In a directory without bw init the plugin stays quiet: one log line says there is no board here, bare ids stay plain text, and full ids (adf-c50) still get their buttons, checked against the board at the registry's path for their prefix, and open through bw's registry, since bw show resolves any registered prefix from any cwd. Back in a repo with a board, the bare ids light up again on the next read.
Freshness. A ticket shown again within 30 seconds is not re-fetched; [ Refresh ] always runs bw show again. The recent row is a trail of tickets, not of searches: an id joins it once bw has answered with a ticket, a miss or an unresolved partial never does, and a stored id that stops resolving leaves. Ten ids, in the plugin store across sessions.
bw keeps a host-local list of the repos it has run in, and bw show resolves any registered prefix from any cwd. That list is what gives an id from another repo (think-1pp, read in the adf repo) its button, and what lets the pane open it and list its children. The registry is off by default. Turn it on once in bw's global config, ~/.bw (YAML; BW_CONFIG names another file):
registry:
auto: true
From then on every successful bw command registers the repo it ran in, so a board joins the list the next time bw runs there (bw list in each repo is enough). bw registry list shows the entries with their prefixes, and bw registry prune drops the ones whose paths are gone. The plugin reads the list once at session start, so a repo registered mid-session gets its buttons in the next session (or after a hot reload).
Needs bw 0.13.0 or later. On an older bw, or with the registry empty, the plugin still knows the prefix of the repo the session runs in, so that board's ids keep their buttons; only ids of other repos stay plain text.
Set under pluginConfigs["bw-peek@bw-peek"].options in the user settings.json (the key is the plugin id, <plugin>@<marketplace>), or from the config menu.
| field | type | default | meaning |
|---|---|---|---|
mention_buttons | boolean | true | draw the [ id ] row under replies that mention tickets |
bare_mentions | boolean | true | also light up bare ids (c50) that are tickets on this board |
mention_cap | number | 6 | most ids drawn under one reply before +N more (1 to 20) |
collapsed_rows | number | 8 | description rows shown before [ Show all ] (3 to 40) |
digest_chars | number | 50 | characters of each comment before its [ + ] (20 to 200) |
| piece | mechanism | ||
|---|---|---|---|
| prefixes | bw registry list --json at session start (and after a hot reload), one { path, prefix } per registered repo; the session repo's own prefix from bw config get prefix, read again before every board read since a Bash cd moves the session's cwd | ||
| mentions | ui.render on AssistantMessage: a regex over e.props.text built from those prefixes, longest first, word-bounded, each match kept only when it is in its board's id set, plus bare [a-z0-9]{3,4}(\.\d+)* words looked up in the session board's; the engine's own drawing is wrapped in a column with the button row beneath | ||
| a board | `sh -c 'bw list --all --json \ | jq -r ".[].id"' for the session's own, bw -C <path> list ... for another repo's at every path the registry files its prefix under (two clones can share one; their ids are joined), ids of that prefix read off the lines, kept per prefix; the JSON itself never enters the plugin (4 MB with every description and comment inline for 500 tickets, and $.process.run cuts output at a limit). When the pipeline fails (no jq), the bw list --all text listing, whose lines carry the id near the front. Refreshed when stale or after a tool.call for Bash whose command runs bw create, bw delete or bw import` | |
| the pane | $.ui.open({ id: 'bw', focus, closeOnEscape, rows: 24 }), drawn by ui.render on Pane; /bw through $.command.register (immediate, so it works mid-turn) | ||
| a ticket | $.process.run(['bw', 'show', id, '--json']) from the session cwd, 20 s timeout; exit 1 with ambiguous ID ... matches a, b becomes the candidate list, no issue found the missing state | ||
| children | bw show --json names the parent on a child and nothing on the parent (the text view computes the list), so once the ticket lands a second call runs: `sh -c 'bw list --parent "$1" --all --json \ | jq -c "[.[] \ | {id, title, status, priority, blocked_by}]"', the whole rows without jq. bw list reads the cwd's board only, where bw show goes through the registry, so a ticket of another repo is listed with bw -C <path>` when the registry names exactly one path for its prefix. The ticket draws first; the children join it when the list answers |
| focus | after a submit the ring is put back on the search box with $.ui.focus, so the next id can be typed at once |
hooks/ticket.ts is the pure part (ids, mentions, JSON parsing, digest, time, the row layout with inline ids) under bun test; hooks/draw.tsx takes the resolved element table and a View and never sees $; hooks/register.tsx holds the hooks and every engine call.
claude --plugin-dir . # load from disk; edits hot-reload
claude plugin validate . # what the module hooks and calls
bun test tests/unit # ids, mentions, parsing, digest, wrap
claude plugin test . # pane and mention row through the engine's $, bw mocked by argv; tests/perf bounds the redraw cost
bun run tests/perf/mentions.bench.ts # µs per scan at 100 and 1000 candidates
bunx -p typescript tsc -p . && rm -f bun.lock package.json
CI runs the first four on every push (.github/workflows/test.yml); none of them needs a logged-in session.
Type checking needs the generated declarations: in a session with function hooks on, run /plugin-types .claude/types from this folder (git-ignored). The API is early access and moves between Claude Code releases; regenerate rather than edit.
Notes from the build, on 2.1.273:
Input, so the pane hook passes there and the drawings type against Elements['terminal'] | Elements['desktop'].Input's submitLabel (⏎ open) draws only while the field has the ring; it is the visible sign the pane has the keyboard. The first ctrl+x tab from the prompt lands there; a second moves on.fire() that catches its promise, else a session end mid-fetch surfaces as an unhandled rejection.claude plugin test, ui.focus is an event answered with {}, not an op answered with { value }; $.store is not on the test's $, so the recent list is seeded through mock.store(on, entries) instead.MIT.
hooks/register.tsx 528 lines1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3import { drawMentions, drawPane, type Actions, type View } from './draw.tsx'
4import { type Board, findMentions, isTicketId, type Known, mentionMatcher, mentionedPrefixes, normalizeId, parseChildren, parseListIds, parseRegistry, parseRegistryPaths, parseShow, prefixOf, type Lookup, type Ticket } from './ticket.ts'
5
6// bw-peek: a Beadwork ticket pane inside the session.
7//
8// The agent names tickets by id (adf-c50, think-1pp) and the person reading has no idea what they
9// refer to. This plugin answers that without leaving the terminal:
10// - /bw <id> opens a pane on the ticket; /bw alone opens it with the search box focused.
11// - Under every reply that mentions a ticket, one dim row of [ id ] buttons; a press opens the pane
12// on that ticket. A mention is a full id that is on its board (any board bw's registry knows), or
13// a bare local part (`c50`, `wxh.5`) that is a ticket on this repo's own. The shape alone is not
14// enough: `PM-internal` in prose has the shape of a `pm` id. This is a render-side decoration: no
15// prompt text, no CLAUDE.md rule, no tokens. A board's ids come from
16// `bw list --all --json | jq -r '.[].id'`, read the first time a drawing names its prefix and
17// again when stale or after a `bw create` / `bw delete` runs through the Bash tool.
18// - The pane runs `bw show <id> --json` through $.process.run (cross-repo, via bw's
19// registry) and draws the digest, the title, the description and the comments.
20//
21// Nothing here writes to bw. Recent lookups live in $.store across sessions.
22
23const PANE_ID = 'bw'
24// v2: only ids bw answered with a ticket; v1 kept every search, misses and all
25const RECENT_KEY = 'recent-v2'
26const RECENT_CAP = 10
27// a ticket shown again within this window is not re-fetched; Refresh always is
28const FRESH_MS = 30_000
29const SHOW_TIMEOUT_MS = 20_000
30// a board's id list is re-read when a drawing names its prefix and the list is older than this
31const KNOWN_STALE_MS = 2 * 60_000
32const LIST_TIMEOUT_MS = 20_000
33// the most board reads one drawing starts: a reply can name every prefix in the registry, and each
34// read is a `bw list` per path
35const BOARDS_PER_DRAW = 6
36// a board's ids: the JSON contract through jq (one id per line, a few KB), and when that pipeline
37// cannot run (no jq, no sh) the text listing, whose lines carry the id near the front. The JSON
38// itself is not read into the plugin: every description and comment rides along, 4 MB for 500
39// tickets, and $.process.run cuts output at a limit. `dir` is another repo's path from the
40// registry; without one bw reads the cwd's board
41function listArgv(dir: string | undefined): string[] {
42 return dir === undefined ? ['sh', '-c', 'bw list --all --json | jq -r ".[].id"'] : ['sh', '-c', 'bw -C "$1" list --all --json | jq -r ".[].id"', 'sh', dir]
43}
44function listTextArgv(dir: string | undefined): string[] {
45 return ['bw', ...(dir === undefined ? [] : ['-C', dir]), 'list', '--all']
46}
47
48let ready: Promise<void> | undefined
49let matcher: RegExp | undefined
50let prefixes: string[] = []
51let repoPaths: Record<string, string[]> = {}
52let defaultPrefix: string | undefined
53let recent: string[] = []
54let current: string | undefined
55const lookups: Record<string, Lookup> = {}
56const pending = new Set<string>()
57let paneOpen = false
58let descriptionExpanded = false
59let childrenExpanded = false
60let openComments = new Set<number>()
61let search = ''
62let commandRegistered: Promise<void> | undefined
63
64// every board read so far by prefix, when each was read, and the reads in flight
65const boards = new Map<string, Board>()
66const boardAt = new Map<string, number>()
67const boardRefresh = new Map<string, Promise<void>>()
68let homeAt = -Infinity
69let homeRefresh: Promise<void> | undefined
70// moves when a ticket is made or removed; a read that began under an older one is not fresh
71let generation = 0
72// the boards said to be unreadable ('' is the session's own, when the cwd has none)
73const warned = new Set<string>()
74
75let mentionButtons = true
76let bareMentions = true
77let mentionCap = 6
78let collapsedRows = 8
79let digestChars = 50
80
81// the engine prefixes every line with the plugin's name already
82function log($: EngineInterface, text: string): void {
83 $.ui.log(text)
84}
85
86// once per module load (session start, and again after a hot reload): the registry's prefixes,
87// this repo's prefix, the recent list
88// the prefix of the repo the session's shell is in now. A `cd` through the Bash tool moves the
89// session's cwd, and $.process.run follows it, so this is read before every board read rather
90// than once at start; a repo without `bw init` answers with an error
91async function readPrefix($: EngineInterface): Promise<{ prefix: string } | { error: string }> {
92 try {
93 const r = await $.process.run(['bw', 'config', 'get', 'prefix'], { timeoutMs: 8000 })
94 const p = r.stdout.trim().toLowerCase()
95 if (r.exitCode !== 0) return { error: r.stderr.trim() || `bw config get prefix exit ${r.exitCode}` }
96 if (!/^[a-z][a-z0-9_-]{0,23}$/.test(p)) return { error: `unusable prefix ${JSON.stringify(p)}` }
97 if (!prefixes.includes(p)) {
98 prefixes.push(p)
99 matcher = mentionMatcher(prefixes)
100 }
101 return { prefix: p }
102 } catch (err) {
103 return { error: String(err) }
104 }
105}
106
107function ensureReady($: EngineInterface): Promise<void> {
108 ready ??= (async () => {
109 // bw's host-local registry (0.13+; off until `registry.auto` is set in ~/.bw, see the README).
110 // An older bw has no `registry` command and an empty registry prints `[]`: either way the
111 // session repo's own prefix, read next, is all the plugin knows
112 try {
113 const r = await $.process.run(['bw', 'registry', 'list', '--json'], { timeoutMs: 8000 })
114 if (r.exitCode === 0) {
115 prefixes.push(...parseRegistry(r.stdout))
116 repoPaths = parseRegistryPaths(r.stdout)
117 }
118 else log($, `registry unreadable (bw registry list exit ${r.exitCode}): ${r.stderr.trim()}`)
119 } catch (err) {
120 log($, `registry read failed: ${err}`)
121 }
122 matcher = mentionMatcher(prefixes)
123 const here = await readPrefix($)
124 defaultPrefix = 'prefix' in here ? here.prefix : undefined
125 try {
126 const saved = await $.store.get(RECENT_KEY)
127 if (Array.isArray(saved)) recent = saved.filter((x): x is string => typeof x === 'string' && isTicketId(x)).slice(0, RECENT_CAP)
128 } catch (err) {
129 log($, `store read failed: ${err}`)
130 }
131 })()
132 return ready
133}
134
135// one board's ids. The session's own board is the cwd's; another repo's is read at the paths the
136// registry files its prefix under. All of them, where listChildren declines: a parent has one
137// home, but different repos do share a prefix (bw cuts it at eight characters, so `specled_ex` and
138// `specled_scenarios` are both `specled_`), and an id on either board is a ticket
139async function readBoard($: EngineInterface, prefix: string): Promise<{ ids: Set<string> } | { error: string }> {
140 const dirs: (string | undefined)[] = prefix === defaultPrefix ? [undefined] : repoPaths[prefix] ?? []
141 if (dirs.length === 0) return { error: `the registry names no repo for ${prefix}` }
142 const ids = new Set<string>()
143 let error: string | undefined
144 let read = false
145 for (const dir of dirs) {
146 // the jq pipeline first; when it fails (no jq) the text listing decides, quietly
147 let r = await $.process.run(listArgv(dir), { timeoutMs: LIST_TIMEOUT_MS })
148 if (r.exitCode !== 0 || r.stdout.trim() === '') r = await $.process.run(listTextArgv(dir), { timeoutMs: LIST_TIMEOUT_MS })
149 if (r.exitCode === 0) {
150 read = true
151 for (const id of parseListIds(r.stdout, prefix)) ids.add(id)
152 } else error ??= r.stderr.trim() || `bw list exit ${r.exitCode}`
153 }
154 return read ? { ids } : { error: error ?? 'bw list failed' }
155}
156
157function sameBoard(a: Board | undefined, b: Board): boolean {
158 if (a === undefined || a === 'unreadable' || b === 'unreadable') return a === b
159 return a.size === b.size && [...b].every(id => a.has(id))
160}
161
162// reads one board; one run at a time per prefix, the old set drawn meanwhile. A board that cannot
163// be read is said once. One never read is marked, so its full ids draw unchecked instead of never;
164// one read before keeps the ids it had, since unchecked would hand prose its buttons back
165function refreshBoard($: EngineInterface, prefix: string): Promise<void> {
166 let run = boardRefresh.get(prefix)
167 if (run !== undefined) return run
168 const started = generation
169 run = (async () => {
170 try {
171 const r = await readBoard($, prefix)
172 const before = boards.get(prefix)
173 const kept = before !== undefined && before !== 'unreadable'
174 const board: Board = 'ids' in r ? r.ids : kept ? before : 'unreadable'
175 if ('error' in r && !warned.has(prefix)) {
176 warned.add(prefix)
177 log($, `cannot list the ${prefix} board, ${kept ? 'keeping the ids read before' : 'its ids go unchecked'}: ${r.error}`)
178 } else if ('ids' in r) warned.delete(prefix)
179 const changed = !sameBoard(before, board)
180 boards.set(prefix, board)
181 if (changed) $.ui.invalidate('ui.render')
182 } catch (err) {
183 // the same rule when the read threw: a board never read is marked, one read before is kept
184 if (!boards.has(prefix)) boards.set(prefix, 'unreadable')
185 if (!warned.has(prefix)) {
186 warned.add(prefix)
187 log($, `bw list failed: ${err}`)
188 }
189 } finally {
190 boardRefresh.delete(prefix)
191 // a read that began before a ticket was made or removed does not get to call itself fresh
192 if (started === generation) boardAt.set(prefix, await $.clock.now())
193 }
194 })()
195 boardRefresh.set(prefix, run)
196 return run
197}
198
199// the session's own board: where the shell is now, then that board's ids. Nothing else reads the
200// cwd's board: only here is the prefix known to be the cwd's at the moment of the read
201function refreshHome($: EngineInterface): Promise<void> {
202 const started = generation
203 homeRefresh ??= (async () => {
204 try {
205 const before = defaultPrefix
206 const here = await readPrefix($)
207 defaultPrefix = 'prefix' in here ? here.prefix : undefined
208 // no board here (a repo without `bw init`, bw missing): said once, and full ids keep working
209 // through bw's registry
210 if ('error' in here && !warned.has('')) {
211 warned.add('')
212 log($, `no board here, bare ids stay plain: ${here.error}`)
213 } else if ('prefix' in here) warned.delete('')
214 if (before !== defaultPrefix) $.ui.invalidate('ui.render')
215 if (defaultPrefix !== undefined) await refreshBoard($, defaultPrefix)
216 } finally {
217 homeRefresh = undefined
218 if (started === generation) homeAt = await $.clock.now()
219 }
220 })()
221 return homeRefresh
222}
223
224// never blocks a drawing on bw: the session's board and the boards `text` names, where missing or
225// stale, are fetched in the background, and the drawing happens again (a refresh invalidates) once
226// they land. At most BOARDS_PER_DRAW reads start from one drawing, in order of mention; the redraw
227// each one causes starts the next
228async function knownFor($: EngineInterface, text: string): Promise<Known> {
229 const now = await $.clock.now()
230 if (now - homeAt > KNOWN_STALE_MS) fire($, 'bw list', refreshHome($))
231 let reads = 0
232 for (const prefix of mentionedPrefixes(text, matcher)) {
233 // the session's own board is refreshHome's to read
234 if (prefix === defaultPrefix || now - (boardAt.get(prefix) ?? -Infinity) <= KNOWN_STALE_MS) continue
235 if (reads++ === BOARDS_PER_DRAW) break
236 fire($, 'bw list', refreshBoard($, prefix))
237 }
238 return { prefix: bareMentions ? defaultPrefix ?? '' : '', boards }
239}
240
241function ensureCommand($: EngineInterface): Promise<void> {
242 commandRegistered ??= $.command
243 .register({ name: 'bw', description: 'Open a Beadwork ticket in a pane (adf-c50, think-1pp, or a bare id for this repo)', argumentHint: '[ticket-id]', immediate: true })
244 .then(() => undefined)
245 .catch(err => {
246 commandRegistered = undefined
247 log($, `command.register failed: ${err}`)
248 })
249 return commandRegistered
250}
251
252// recent is a trail of tickets, never of searches: an id joins once bw answered with a ticket and
253// leaves the moment a lookup says it is not one
254function remember($: EngineInterface, id: string): void {
255 recent = [id, ...recent.filter(r => r !== id)].slice(0, RECENT_CAP)
256 $.store.set(RECENT_KEY, recent).catch(err => log($, `store write failed: ${err}`))
257}
258
259function forget($: EngineInterface, id: string): void {
260 if (!recent.includes(id)) return
261 recent = recent.filter(r => r !== id)
262 $.store.set(RECENT_KEY, recent).catch(err => log($, `store write failed: ${err}`))
263}
264
265function noteLookup($: EngineInterface, id: string): void {
266 const l = lookups[id]
267 if (l === undefined) return
268 if (l.kind === 'ticket') remember($, id)
269 else forget($, id)
270}
271
272async function openPane($: EngineInterface): Promise<void> {
273 paneOpen = true
274 try {
275 await $.ui.open({ id: PANE_ID, title: 'Beadwork', focus: true, closeOnEscape: true, rows: 24 })
276 } catch (err) {
277 paneOpen = false
278 log($, `pane open failed: ${err}`)
279 $.ui.toast(`could not open the pane: ${err}`, { timeoutMs: 4000 })
280 }
281 $.ui.invalidate('ui.render')
282}
283
284function closePane($: EngineInterface): void {
285 paneOpen = false
286 $.ui.close({ id: PANE_ID }).catch(err => log($, `pane close failed: ${err}`))
287}
288
289// the tickets filed under `id`. bw show's JSON names the parent on a child and nothing on the parent
290// (its text view computes the list), so this is a second call. `bw list` reads the cwd's board only,
291// where `bw show` goes through the registry: a ticket of another repo is listed with -C <its path>,
292// when the registry names exactly one. jq keeps each child's description and comments out of the
293// output; without jq the whole rows are read.
294async function listChildren($: EngineInterface, id: string): Promise<Ticket[]> {
295 const prefix = prefixOf(id, prefixes)
296 let dir: string | undefined
297 if (prefix !== defaultPrefix) {
298 const paths = prefix === undefined ? [] : repoPaths[prefix] ?? []
299 if (paths.length !== 1) return []
300 dir = paths[0]
301 }
302 const slim = 'jq -c "[.[] | {id, title, status, priority, blocked_by}]"'
303 try {
304 let r = await $.process.run(
305 dir === undefined
306 ? ['sh', '-c', `bw list --parent "$1" --all --json | ${slim}`, 'sh', id]
307 : ['sh', '-c', `bw -C "$2" list --parent "$1" --all --json | ${slim}`, 'sh', id, dir],
308 { timeoutMs: SHOW_TIMEOUT_MS },
309 )
310 if (r.exitCode !== 0 || r.stdout.trim() === '')
311 r = await $.process.run(['bw', ...(dir === undefined ? [] : ['-C', dir]), 'list', '--parent', id, '--all', '--json'], { timeoutMs: SHOW_TIMEOUT_MS })
312 return r.exitCode === 0 ? parseChildren(r.stdout) : []
313 } catch {
314 return []
315 }
316}
317
318async function fetch($: EngineInterface, id: string): Promise<void> {
319 if (pending.has(id)) return
320 pending.add(id)
321 $.ui.invalidate('ui.render')
322 const at = await $.clock.now()
323 try {
324 const r = await $.process.run(['bw', 'show', id, '--json'], { timeoutMs: SHOW_TIMEOUT_MS })
325 const found = parseShow(id, r, at)
326 lookups[id] = found
327 if (found.kind === 'ticket') {
328 // the ticket draws now; its children join it when the list answers (bw resolved a partial id,
329 // so the list is asked by the ticket's own)
330 $.ui.invalidate('ui.render')
331 lookups[id] = { ...found, children: await listChildren($, found.ticket.id) }
332 }
333 } catch (err) {
334 lookups[id] = { kind: 'error', id, message: `bw show ${id} failed: ${err}`, at }
335 } finally {
336 pending.delete(id)
337 $.ui.invalidate('ui.render')
338 }
339}
340
341// the pane on a ticket: opened if closed, fetched unless fresh, remembered
342async function show($: EngineInterface, id: string, force = false): Promise<void> {
343 await ensureReady($)
344 if (current !== id) {
345 descriptionExpanded = false
346 childrenExpanded = false
347 openComments = new Set()
348 }
349 current = id
350 await openPane($)
351 const have = lookups[id]
352 if (force || have === undefined || (await $.clock.now()) - have.at >= FRESH_MS) await fetch($, id)
353 noteLookup($, id)
354}
355
356async function submit($: EngineInterface, value: string): Promise<void> {
357 await ensureReady($)
358 const id = normalizeId(value, defaultPrefix)
359 search = ''
360 if (id === undefined) {
361 $.ui.toast(value.trim() === '' ? 'type a ticket id first' : `“${value.trim()}” is not a ticket id`, { timeoutMs: 3000 })
362 $.ui.invalidate('ui.render')
363 return
364 }
365 await show($, id)
366 // the redraw moves the ring off the field; put it back so the next id can be typed at once
367 try {
368 await $.ui.focus({ requestId: PANE_ID, key: 'search' })
369 } catch (err) {
370 log($, `focus failed: ${err}`)
371 }
372}
373
374// a button's work runs past the press; a failure lands in the log, never as an unhandled rejection
375function fire($: EngineInterface, what: string, p: Promise<unknown>): void {
376 p.catch(err => {
377 try {
378 log($, `${what} failed: ${err}`)
379 } catch {
380 // the environment is gone (a reload or a session end); nothing left to tell
381 }
382 })
383}
384
385function actionsFor($: EngineInterface): Actions {
386 return {
387 open: id => fire($, `open ${id}`, show($, id)),
388 submit: value => fire($, 'search', submit($, value)),
389 input: value => {
390 search = value
391 },
392 refresh: () => {
393 if (current !== undefined) fire($, `refresh ${current}`, show($, current, true))
394 },
395 close: () => closePane($),
396 toggleDescription: () => {
397 descriptionExpanded = !descriptionExpanded
398 $.ui.invalidate('ui.render')
399 },
400 toggleChildren: () => {
401 childrenExpanded = !childrenExpanded
402 $.ui.invalidate('ui.render')
403 },
404 toggleComment: i => {
405 if (openComments.has(i)) openComments.delete(i)
406 else openComments.add(i)
407 $.ui.invalidate('ui.render')
408 },
409 forgetRecent: () => {
410 recent = []
411 $.store.delete(RECENT_KEY).catch(err => log($, `store delete failed: ${err}`))
412 $.ui.invalidate('ui.render')
413 },
414 }
415}
416
417// the texts of a ticket the pane draws ids inside of
418function proseOf(l: Lookup | undefined): string {
419 if (l?.kind !== 'ticket') return ''
420 const t = l.ticket
421 return [t.description, t.closeReason ?? '', ...t.comments.map(c => c.text)].join('\n')
422}
423
424async function viewOf($: EngineInterface): Promise<View> {
425 await ensureReady($)
426 return {
427 current,
428 lookup: current === undefined ? undefined : lookups[current],
429 pending: current !== undefined && pending.has(current),
430 descriptionExpanded,
431 childrenExpanded,
432 openComments,
433 recent,
434 search,
435 defaultPrefix,
436 matcher,
437 known: await knownFor($, proseOf(current === undefined ? undefined : lookups[current])),
438 now: await $.clock.now(),
439 collapsedRows,
440 digestChars,
441 }
442}
443
444function clampNumber(v: unknown, lo: number, hi: number, dflt: number): number {
445 return typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.floor(v))) : dflt
446}
447
448export const register: Register = (on, options) => {
449 mentionButtons = options.mention_buttons !== false
450 bareMentions = options.bare_mentions !== false
451 mentionCap = clampNumber(options.mention_cap, 1, 20, 6)
452 collapsedRows = clampNumber(options.collapsed_rows, 3, 40, 8)
453 digestChars = clampNumber(options.digest_chars, 20, 200, 50)
454
455 on('session.start', async ($, e, next) => {
456 const r = await next(e)
457 await ensureReady($)
458 await ensureCommand($)
459 fire($, 'bw list', refreshHome($))
460 return r
461 })
462
463 // a ticket made or removed through the Bash tool changes what a mention can mean: this board is
464 // read again now, and another repo's (`bw -C <repo> create`) the next time a drawing names it
465 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
466 const r = await next(e)
467 if (r.result !== undefined && /\bbw\s+(?:-C\s+\S+\s+)?(create|delete|import)\b/.test(e.command)) {
468 await ensureReady($)
469 generation++
470 boardAt.clear()
471 homeAt = -Infinity
472 fire($, 'bw list', refreshHome($))
473 }
474 return r
475 })
476
477 on('command.run', { command: 'bw' }, async ($, e, next) => {
478 await ensureReady($)
479 const args = e.args.trim()
480 if (args === 'close') {
481 closePane($)
482 return {}
483 }
484 if (args === '') {
485 await openPane($)
486 return {}
487 }
488 const id = normalizeId(args, defaultPrefix)
489 if (id === undefined) {
490 $.ui.toast(`“${args}” is not a ticket id`, { timeoutMs: 3000 })
491 return {}
492 }
493 await show($, id)
494 return {}
495 })
496
497 on('ui.close', async ($, e, next) => {
498 if (e.id === PANE_ID) paneOpen = false
499 return next(e)
500 })
501
502 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
503 if (e.requestId !== PANE_ID) return next(e)
504 // the mobile table has no Input yet, so the search box cannot draw there
505 if (e.surface === 'mobile') return next(e)
506 paneOpen = true
507 void ensureCommand($)
508 const v = await viewOf($)
509 const columns = Math.max(40, (e.props.bodyColumns ?? e.viewport?.columns ?? 100) - 1)
510 return drawPane($.ui.resolve(e), v, columns, actionsFor($))
511 })
512
513 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
514 if (!mentionButtons) return next(e)
515 await ensureReady($)
516 const ids = findMentions(e.props.text, matcher, await knownFor($, e.props.text))
517 if (ids.length === 0) return next(e)
518 const drawn = await next(e)
519 const { Box } = $.ui.resolve(e)
520 return (
521 <Box flexDirection="column">
522 {drawn}
523 {drawMentions($.ui.resolve(e), ids, mentionCap, actionsFor($))}
524 </Box>
525 )
526 })
527}
528hooks/draw.tsx 450 lines1/* @jsx h */
2import type { Elements, RenderElement } from 'claude-code'
3import {
4 ago,
5 firstRows,
6 type Known,
7 layoutRows,
8 type Lookup,
9 oneLine,
10 parseDate,
11 plural,
12 PRIORITY_COLOR,
13 relativeDays,
14 type Row,
15 shortDate,
16 shortDateTime,
17 statusOf,
18 type Ticket,
19} from './ticket.ts'
20
21// bw-peek: the two drawings. The pane is the ticket viewer: a search box, then the digest, the
22// title, the description (collapsed past a few rows) and the comments (a digest each, expandable).
23// The mention row sits under a reply that names tickets: one button per id. Both are pure functions
24// of a View, with the actions handed in as closures, so this file never touches the engine.
25
26// the element table of a surface that has an Input (the terminal and the desktop; mobile has none
27// yet, and the pane hook passes there)
28// every surface with an Input (mobile has none, so the pane hook passes there); vscode lacks only Client, unused here
29export type Els = Elements['terminal'] | Elements['desktop'] | Elements['vscode']
30// any surface's table: what the mention row, Box, Text and Button only, takes
31export type AnyEls = Elements[keyof Elements]
32
33export type Actions = {
34 open: (id: string) => void
35 submit: (value: string) => void
36 input: (value: string) => void
37 refresh: () => void
38 close: () => void
39 toggleDescription: () => void
40 toggleChildren: () => void
41 toggleComment: (index: number) => void
42 forgetRecent: () => void
43}
44
45export type View = {
46 // the id the pane shows, and what bw said about it (absent while the first fetch runs)
47 current?: string
48 lookup?: Lookup
49 // a fetch for `current` is in flight
50 pending: boolean
51 descriptionExpanded: boolean
52 childrenExpanded: boolean
53 openComments: ReadonlySet<number>
54 recent: readonly string[]
55 // the search field's text, as the person has typed it
56 search: string
57 defaultPrefix?: string
58 // what makes an id in a description or comment a button: the registry's prefixes, and the ids
59 // of every board read so far
60 matcher?: RegExp
61 known: Known
62 now: number
63 collapsedRows: number
64 digestChars: number
65}
66
67const MAX_CANDIDATES = 40
68const RECENT_SHOWN = 8
69
70// ---- small pieces ---------------------------------------------------------------------------------
71
72function idButton(els: AnyEls, id: string, actions: Actions, keyPrefix: string, dim = true): RenderElement {
73 const { Box, Button } = els
74 return (
75 <Box key={`${keyPrefix}-${id}`} marginRight={1}>
76 <Button key={`${keyPrefix}-${id}`} label={id} dimColor={dim} onPress={() => actions.open(id)} />
77 </Box>
78 )
79}
80
81function divider(els: Els, columns: number): RenderElement {
82 const { Text } = els
83 return <Text dimColor>{'─'.repeat(Math.max(0, columns))}</Text>
84}
85
86function sectionTitle(els: Els, title: string, note: string, control: RenderElement | null): RenderElement {
87 const { Box, Text } = els
88 return (
89 <Box flexDirection="row" marginTop={1}>
90 <Text bold>{title}</Text>
91 <Text dimColor>{note === '' ? '' : ` ${note}`}</Text>
92 {control === null ? null : <Box marginLeft={2}>{control}</Box>}
93 </Box>
94 )
95}
96
97// ---- the header -----------------------------------------------------------------------------------
98
99function header(els: Els, v: View, columns: number, actions: Actions): RenderElement {
100 const { Box, Text, Button, Input } = els
101 return (
102 <Box flexDirection="column">
103 <Box flexDirection="row" width={columns}>
104 <Box flexShrink={0} marginRight={2}>
105 <Text bold color="cyan">◈ Beadwork</Text>
106 </Box>
107 <Box flexGrow={1} flexShrink={1}>
108 <Text bold wrap="truncate-end">{v.current ?? ''}</Text>
109 </Box>
110 {v.current === undefined ? null : (
111 <Box flexShrink={0} marginLeft={2}>
112 <Button key="refresh" label={v.pending ? '…' : 'Refresh'} dimColor onPress={actions.refresh} />
113 </Box>
114 )}
115 </Box>
116 <Box width={columns}>
117 <Input
118 key="search"
119 placeholder={v.defaultPrefix === undefined ? 'ticket id, e.g. adf-c50' : `ticket id, e.g. ${v.defaultPrefix}-c50 or c50`}
120 value={v.search}
121 submitLabel="open"
122 autoFocus
123 onInput={(value: string) => actions.input(value)}
124 onSubmit={(value: string) => actions.submit(value)}
125 />
126 </Box>
127 {divider(els, columns)}
128 </Box>
129 )
130}
131
132// a laid-out text: one Box per row, ids as buttons in place. Keys carry the row and column so a text
133// that names one ticket twice draws two working buttons.
134function paragraph(els: Els, rows: Row[], keyPrefix: string, actions: Actions, color?: string): RenderElement {
135 const { Box, Text, Button } = els
136 return (
137 <Box flexDirection="column">
138 {rows.map((row, r) =>
139 row.length === 0 ? (
140 <Text key={`${keyPrefix}-r${r}`}> </Text>
141 ) : (
142 <Box key={`${keyPrefix}-r${r}`} flexDirection="row">
143 {row.map((piece, c) =>
144 piece.kind === 'id' ? (
145 <Button key={`${keyPrefix}-${r}-${c}`} label={piece.id} onPress={() => actions.open(piece.id)} />
146 ) : (
147 <Text key={`${keyPrefix}-${r}-${c}`} color={color}>{piece.text}</Text>
148 ),
149 )}
150 </Box>
151 ),
152 )}
153 </Box>
154 )
155}
156
157// ---- a ticket -------------------------------------------------------------------------------------
158
159function digest(els: Els, t: Ticket, v: View, columns: number): RenderElement {
160 const { Box, Text } = els
161 const st = statusOf(t)
162 const parts: RenderElement[] = []
163 const part = (key: string, node: RenderElement) =>
164 parts.push(
165 <Box key={`d-${key}`} flexShrink={0} marginRight={2}>
166 {node}
167 </Box>,
168 )
169
170 part('status', <Text color={st.color} dimColor={st.dim} bold>{`${st.glyph} ${st.word}`}</Text>)
171 part('prio', <Text color={PRIORITY_COLOR[t.priority] ?? 'white'} bold>{`P${t.priority}`}</Text>)
172 part('type', <Text>{t.type}</Text>)
173 if (t.labels.length > 0) part('labels', <Text color="magenta">{`⚑ ${t.labels.join(', ')}`}</Text>)
174 if (t.assignee !== '') part('assignee', <Text color="blue">{`@${t.assignee}`}</Text>)
175
176 const due = parseDate(t.due)
177 if (due !== undefined) {
178 const overdue = due < v.now && t.status !== 'closed'
179 part('due', <Text color={overdue ? 'red' : 'yellow'} bold={overdue}>{`⏰ ${overdue ? 'overdue' : 'due'} ${shortDate(due, v.now)} (${relativeDays(due, v.now)})`}</Text>)
180 }
181 const defer = parseDate(t.deferUntil)
182 if (defer !== undefined && t.status === 'deferred') {
183 const dueNow = defer <= v.now
184 part('defer', <Text color="cyan">{`❄ until ${shortDate(defer, v.now)}${dueNow ? ' (now due)' : ` (${relativeDays(defer, v.now)})`}`}</Text>)
185 }
186
187 const created = parseDate(t.created)
188 const closed = parseDate(t.closedAt)
189 const updated = parseDate(t.updatedAt)
190 const when: string[] = []
191 if (created !== undefined) when.push(`created ${shortDate(created, v.now)}`)
192 if (closed !== undefined) when.push(`closed ${shortDate(closed, v.now)}`)
193 else if (updated !== undefined && created !== undefined && updated - created > 60_000) when.push(`updated ${ago(updated, v.now)}`)
194 if (when.length > 0) part('when', <Text dimColor>{when.join(' · ')}</Text>)
195
196 return (
197 <Box flexDirection="row" flexWrap="wrap" width={columns}>
198 {parts}
199 </Box>
200 )
201}
202
203function relations(els: Els, t: Ticket, actions: Actions): RenderElement | null {
204 const { Box, Text } = els
205 const groups: RenderElement[] = []
206 const group = (key: string, word: string, ids: readonly string[]) => {
207 if (ids.length === 0) return
208 groups.push(
209 <Box key={`rel-${key}`} flexDirection="row" flexShrink={0} marginRight={2}>
210 <Box marginRight={1}>
211 <Text dimColor>{word}</Text>
212 </Box>
213 {ids.map(id => idButton(els, id, actions, `rel-${key}`))}
214 </Box>,
215 )
216 }
217 if (t.parent !== undefined) group('parent', 'parent', [t.parent])
218 group('blockedby', t.blockedBy.length === 1 ? 'blocked by' : `blocked by ${t.blockedBy.length}:`, t.blockedBy)
219 group('blocks', t.blocks.length === 1 ? 'blocks' : `blocks ${t.blocks.length}:`, t.blocks)
220 if (groups.length === 0) return null
221 return (
222 <Box flexDirection="row" flexWrap="wrap">
223 {groups}
224 </Box>
225 )
226}
227
228// the tickets filed under this one, as bw's text view lists them: status, priority, id, title
229function children(els: Els, kids: readonly Ticket[], v: View, columns: number, actions: Actions): RenderElement | null {
230 const { Box, Text, Button } = els
231 if (kids.length === 0) return null
232 const closed = kids.filter(k => k.status === 'closed').length
233 const collapsible = kids.length > v.collapsedRows
234 const shown = collapsible && !v.childrenExpanded ? kids.slice(0, v.collapsedRows) : kids
235 const control = collapsible ? (
236 <Button key="children-toggle" label={v.childrenExpanded ? 'Collapse' : 'Show all'} dimColor onPress={actions.toggleChildren} />
237 ) : null
238 return (
239 <Box flexDirection="column">
240 {sectionTitle(els, 'Children', `${kids.length} ${closed} closed`, control)}
241 <Box marginLeft={2} flexDirection="column">
242 {shown.map(k => {
243 const st = statusOf(k)
244 // glyph, priority, the button's brackets and the gaps between them
245 const room = Math.max(8, columns - 2 - (2 + 3 + k.id.length + 4 + 2))
246 const title = k.title.length > room ? `${k.title.slice(0, room - 1)}…` : k.title
247 return (
248 <Box key={`child-${k.id}`} flexDirection="row">
249 <Box marginRight={1} flexShrink={0}>
250 <Text color={st.color} dimColor={st.dim}>{st.glyph}</Text>
251 </Box>
252 <Box marginRight={1} flexShrink={0}>
253 <Text color={PRIORITY_COLOR[k.priority] ?? 'white'}>{`P${k.priority}`}</Text>
254 </Box>
255 {idButton(els, k.id, actions, 'child', false)}
256 <Text dimColor={k.status === 'closed'}>{title}</Text>
257 </Box>
258 )
259 })}
260 {shown.length < kids.length ? <Text dimColor>{`… ${plural(kids.length - shown.length, 'more child', 'more children')}`}</Text> : null}
261 </Box>
262 </Box>
263 )
264}
265
266function description(els: Els, t: Ticket, v: View, columns: number, actions: Actions): RenderElement {
267 const { Box, Text, Button } = els
268 const body = t.description.trim()
269 const width = Math.max(10, columns - 2)
270 if (body === '') return sectionTitle(els, 'Description', '(none)', null)
271 const rows = layoutRows(body, width, v.matcher, v.known)
272 const collapsible = rows.length > v.collapsedRows
273 const control = collapsible ? (
274 <Button key="desc-toggle" label={v.descriptionExpanded ? 'Collapse' : `Show all`} dimColor onPress={actions.toggleDescription} />
275 ) : null
276 const cut = collapsible && !v.descriptionExpanded ? firstRows(rows, v.collapsedRows, width) : { shown: rows, hidden: 0 }
277 return (
278 <Box flexDirection="column">
279 {sectionTitle(els, 'Description', plural(rows.length, 'row'), control)}
280 <Box marginLeft={2} width={width} flexDirection="column">
281 {paragraph(els, cut.shown, 'desc', actions)}
282 {cut.hidden > 0 ? <Text dimColor>{`… ${plural(cut.hidden, 'more row')}`}</Text> : null}
283 </Box>
284 </Box>
285 )
286}
287
288function comments(els: Els, t: Ticket, v: View, columns: number, actions: Actions): RenderElement {
289 const { Box, Text, Button } = els
290 if (t.comments.length === 0) return sectionTitle(els, 'Comments', '(none)', null)
291 const width = Math.max(10, columns - 2)
292 return (
293 <Box flexDirection="column">
294 {sectionTitle(els, `Comments (${t.comments.length})`, '', null)}
295 {t.comments.map((c, i) => {
296 const open = v.openComments.has(i)
297 const at = parseDate(c.at)
298 return (
299 <Box key={`c-${i}`} flexDirection="column" marginLeft={2}>
300 <Box flexDirection="row" width={width}>
301 <Box flexShrink={0} marginRight={1}>
302 <Button key={`c-toggle-${i}`} label={open ? '−' : '+'} dimColor onPress={() => actions.toggleComment(i)} />
303 </Box>
304 <Box flexShrink={0} marginRight={1}>
305 <Text dimColor>{at === undefined ? '' : shortDateTime(at, v.now)}</Text>
306 </Box>
307 <Box flexShrink={1} flexGrow={1}>
308 <Text wrap="truncate-end" dimColor={open}>{open ? '' : oneLine(c.text, v.digestChars)}</Text>
309 </Box>
310 </Box>
311 {open ? (
312 <Box marginLeft={6} width={Math.max(10, width - 6)} borderStyle="round" borderDimColor paddingX={1}>
313 {paragraph(els, layoutRows(c.text.trim(), Math.max(4, width - 10), v.matcher, v.known), `c-${i}`, actions)}
314 </Box>
315 ) : null}
316 </Box>
317 )
318 })}
319 </Box>
320 )
321}
322
323function ticketView(els: Els, t: Ticket, v: View, columns: number, actions: Actions): RenderElement {
324 const { Box, Text } = els
325 const rel = relations(els, t, actions)
326 return (
327 <Box flexDirection="column" marginTop={1}>
328 {digest(els, t, v, columns)}
329 <Box marginTop={1} width={columns}>
330 <Text bold wrap="wrap">{t.title}</Text>
331 </Box>
332 {rel === null ? null : <Box marginTop={1}>{rel}</Box>}
333 {t.closeReason === undefined ? null : (
334 <Box marginTop={1} width={columns}>
335 {paragraph(els, layoutRows(`↳ ${t.closeReason.trim()}`, columns, v.matcher, v.known), 'why', actions, 'green')}
336 </Box>
337 )}
338 {children(els, v.lookup?.kind === 'ticket' ? v.lookup.children ?? [] : [], v, columns, actions)}
339 {description(els, t, v, columns, actions)}
340 {comments(els, t, v, columns, actions)}
341 </Box>
342 )
343}
344
345// ---- the other outcomes ---------------------------------------------------------------------------
346
347function ambiguous(els: Els, id: string, candidates: readonly string[], actions: Actions): RenderElement {
348 const { Box, Text } = els
349 const shown = candidates.slice(0, MAX_CANDIDATES)
350 return (
351 <Box flexDirection="column" marginTop={1}>
352 <Text>
353 <Text bold color="yellow">{id}</Text>
354 <Text>{` matches ${plural(candidates.length, 'ticket')}${candidates.length > shown.length ? ` (first ${shown.length} shown)` : ''}:`}</Text>
355 </Text>
356 <Box flexDirection="row" flexWrap="wrap" marginTop={1} marginLeft={2}>
357 {shown.map(c => idButton(els, c, actions, 'cand', false))}
358 </Box>
359 </Box>
360 )
361}
362
363function empty(els: Els, v: View): RenderElement {
364 const { Box, Text } = els
365 return (
366 <Box flexDirection="column" marginTop={1} marginLeft={2}>
367 <Text dimColor>Type a ticket id in the box and press Enter, or press an id under a reply.</Text>
368 <Text dimColor>{`Full ids work across every repo bw knows (adf-c50, think-1pp)${v.defaultPrefix === undefined ? '.' : `; a bare c50 means ${v.defaultPrefix}-c50 here.`}`}</Text>
369 </Box>
370 )
371}
372
373function body(els: Els, v: View, columns: number, actions: Actions): RenderElement {
374 const { Box, Text } = els
375 const l = v.lookup
376 if (v.current === undefined) return empty(els, v)
377 if (l === undefined)
378 return (
379 <Box marginTop={1} marginLeft={2}>
380 <Text dimColor>{v.pending ? `… asking bw about ${v.current}` : `nothing known about ${v.current} yet`}</Text>
381 </Box>
382 )
383 switch (l.kind) {
384 case 'ticket':
385 return ticketView(els, l.ticket, v, columns, actions)
386 case 'ambiguous':
387 return ambiguous(els, l.id, l.candidates, actions)
388 case 'missing':
389 return (
390 <Box flexDirection="column" marginTop={1} marginLeft={2}>
391 <Text color="red">{`✗ no ticket matches ${l.id}`}</Text>
392 <Text dimColor>bw looks the prefix up in its registry (bw registry list); with registry.auto on in ~/.bw a repo joins it the next time bw runs there.</Text>
393 </Box>
394 )
395 default:
396 return (
397 <Box marginTop={1} marginLeft={2} width={columns}>
398 <Text color="red" wrap="wrap">{`✗ ${l.message}`}</Text>
399 </Box>
400 )
401 }
402}
403
404function recentRow(els: Els, v: View, actions: Actions): RenderElement | null {
405 const { Box, Text, Button } = els
406 const ids = v.recent.filter(id => id !== v.current).slice(0, RECENT_SHOWN)
407 if (ids.length === 0) return null
408 return (
409 <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
410 <Box marginRight={1}>
411 <Text dimColor>recent</Text>
412 </Box>
413 {ids.map(id => idButton(els, id, actions, 'recent'))}
414 <Button key="forget" label="forget" dimColor onPress={actions.forgetRecent} />
415 </Box>
416 )
417}
418
419export function drawPane(els: Els, v: View, columns: number, actions: Actions): RenderElement {
420 const { Box, Text } = els
421 return (
422 <Box flexDirection="column" width={columns}>
423 {header(els, v, columns, actions)}
424 {body(els, v, columns, actions)}
425 {recentRow(els, v, actions)}
426 <Box marginTop={1}>
427 <Text dimColor>{'esc closes · type an id and press enter · /bw <id> from the prompt'}</Text>
428 </Box>
429 </Box>
430 )
431}
432
433// ---- under a reply --------------------------------------------------------------------------------
434
435// one row: ◈ [ adf-c50 ] [ adf-zu6 ] +3 more
436export function drawMentions(els: AnyEls, ids: readonly string[], cap: number, actions: Actions): RenderElement {
437 const { Box, Text } = els
438 const shown = ids.slice(0, Math.max(1, cap))
439 const more = ids.length - shown.length
440 return (
441 <Box flexDirection="row" flexWrap="wrap" marginLeft={2}>
442 <Box marginRight={1}>
443 <Text color="cyan" dimColor>◈</Text>
444 </Box>
445 {shown.map(id => idButton(els, id, actions, 'mention'))}
446 {more > 0 ? <Text dimColor>{`+${more} more`}</Text> : null}
447 </Box>
448 )
449}
450hooks/ticket.ts 492 lines1// bw-peek: everything about a Beadwork ticket that needs no engine. Ids, the registry's prefixes,
2// the mentions in a reply, `bw show --json` parsed into a Ticket, its digest, and the small text
3// helpers the drawings use. No `$` here, so `bun test` covers it directly.
4
5export type Comment = { text: string; at: string }
6
7export type Ticket = {
8 id: string
9 title: string
10 description: string
11 status: string
12 type: string
13 priority: number
14 labels: string[]
15 assignee: string
16 created: string
17 updatedAt: string
18 closedAt?: string
19 closeReason?: string
20 due?: string
21 deferUntil?: string
22 parent?: string
23 blockedBy: string[]
24 blocks: string[]
25 comments: Comment[]
26}
27
28// what one `bw show` came back as
29export type Lookup =
30 // `children` lands after the ticket: bw show's JSON names a parent on the child and nothing on the
31 // parent, so they come from a second call (absent until it answers)
32 | { kind: 'ticket'; id: string; ticket: Ticket; children?: Ticket[]; at: number }
33 | { kind: 'ambiguous'; id: string; candidates: string[]; at: number }
34 | { kind: 'missing'; id: string; at: number }
35 | { kind: 'error'; id: string; message: string; at: number }
36
37// the local part of an id: `c50`, `wxh.5`, `1pp.4.1.1.6.2`. Typed, one character is enough
38// (`think-1` asks bw for the ambiguous list); found in prose, two, so `adf-c` in a sentence is not
39// a mention.
40const LOCAL_TYPED = '[a-z0-9]{1,8}(?:\\.\\d+)*'
41const LOCAL_MENTIONED = '[a-z0-9]{2,8}(?:\\.\\d+)*'
42// a prefix as bw allows one: letters, digits, `_` and `-`, starting with a letter
43const PREFIX = '[a-z][a-z0-9_-]{0,23}'
44const FULL_ID_RE = new RegExp(`^(${PREFIX})-(${LOCAL_TYPED})$`)
45const LOCAL_ONLY_RE = new RegExp(`^${LOCAL_TYPED}$`)
46
47export function isTicketId(text: string): boolean {
48 return FULL_ID_RE.test(text)
49}
50
51export function idParts(id: string): { prefix: string; local: string } | undefined {
52 const m = FULL_ID_RE.exec(id)
53 if (m === null) return undefined
54 return { prefix: m[1] as string, local: m[2] as string }
55}
56
57// what the person typed, made into an id bw will take: case folded, wrapping punctuation and a
58// `bw show` in front dropped, a bare local part given the session's prefix. undefined when nothing
59// id-shaped is left.
60export function normalizeId(raw: string, defaultPrefix?: string): string | undefined {
61 const words = raw
62 .toLowerCase()
63 .split(/\s+/)
64 .map(w => w.replace(/^[`'"([{<*_]+|[`'"\])}>*_.,;:!?]+$/g, ''))
65 .filter(w => w !== '')
66 const full = words.find(w => FULL_ID_RE.test(w))
67 if (full !== undefined) return full
68 const local = words.find(w => LOCAL_ONLY_RE.test(w) && !/^[a-z]+$/.test(w))
69 if (local !== undefined && defaultPrefix !== undefined) return `${defaultPrefix}-${local}`
70 const anyLocal = words.find(w => LOCAL_ONLY_RE.test(w))
71 if (anyLocal !== undefined && defaultPrefix !== undefined && words.length === 1) return `${defaultPrefix}-${anyLocal}`
72 return undefined
73}
74
75// bw's host-local registry as `bw registry list --json` prints it: one `{ path, prefix }` per
76// registered repo, `[]` while empty
77type RegistryEntry = { path?: unknown; prefix?: unknown }
78function registryEntries(json: string): { path: string; prefix: string }[] {
79 try {
80 const parsed = JSON.parse(json) as unknown
81 if (!Array.isArray(parsed)) return []
82 const out: { path: string; prefix: string }[] = []
83 for (const entry of parsed as RegistryEntry[]) {
84 const p = entry?.prefix
85 const path = entry?.path
86 if (typeof p === 'string' && typeof path === 'string' && path !== '' && new RegExp(`^${PREFIX}$`).test(p)) out.push({ path, prefix: p })
87 }
88 return out
89 } catch {
90 // an unreadable registry names no repos
91 return []
92 }
93}
94
95// the id prefixes bw knows, sorted, each once
96export function parseRegistry(json: string): string[] {
97 return [...new Set(registryEntries(json).map(e => e.prefix))].sort()
98}
99
100// a matcher for ids with one of these prefixes, longest prefix first so `spire-cl-x1` is not read
101// as `spire-...`; undefined with no prefixes, since a bare `[a-z]+-[a-z0-9]+` matches sha-256. The
102// prefix is group 1. A match has the shape of an id and nothing more: `PM-internal` in prose is one
103// wherever `pm` is a prefix, so a mention is checked against its board (see Known) before it draws.
104export function mentionMatcher(prefixes: readonly string[]): RegExp | undefined {
105 if (prefixes.length === 0) return undefined
106 const alts = [...prefixes]
107 .sort((a, b) => b.length - a.length)
108 .map(p => p.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&'))
109 .join('|')
110 return new RegExp(`(?<![a-z0-9_-])(${alts})-${LOCAL_MENTIONED}(?![a-z0-9_-])`, 'gi')
111}
112
113// the prefixes a text names in id-shaped words, each once: the boards a drawing of it needs read
114export function mentionedPrefixes(text: string, matcher: RegExp | undefined): string[] {
115 if (matcher === undefined) return []
116 const out = new Set<string>()
117 for (const m of text.matchAll(matcher)) out.add((m[1] as string).toLowerCase())
118 return [...out]
119}
120
121// a bare local part as the agent writes one without its prefix: `c50`, `wxh.5`, `1jf.234`. Three or
122// four letters and digits, then any number of `.N`. Not preceded by a letter, digit, `_`, `.`, `-` or
123// `/`, not followed by one of those, and not followed by `.` and a letter (`draw.tsx`).
124const BARE_RE = /(?<![a-z0-9_./-])[a-z0-9]{3,4}(?:\.\d+)*(?![a-z0-9_-])(?!\.[a-z])/gi
125
126// three- and four-letter words a base-36 id could spell; a ticket with one of these as its id is
127// still reachable by its full id, but bare it would light up every reply
128export const STOPWORDS: ReadonlySet<string> = new Set(
129 (
130 'the and for are but not you all can had her was one our out day get has him his how man new now old see two way who boy did its let put say she too use ' +
131 'also back been best both call came come does done down each even ever fail find from give goes gone good have here home into just keep kind know last left ' +
132 'life like line list live long look made make many mean more most much must name need next none once only open over part past read real said same seen ' +
133 'send show side some soon sort step such sure take tell test than that them then they this time told took true turn type upon used user very want week ' +
134 'well went were what when will with word work year your bash json main null void file path repo code diff node port host data text ' +
135 'run add fix set top end log err api cli ssh git dev prod tmp bin lib src doc pkg app web dir cwd env var const'
136 ).split(/\s+/),
137)
138
139// the ids of one board: every `<prefix>-<local>` on a page, whether the page is jq's one id per line
140// or the text listing (one line per issue, the id near the front and any blocker ids after)
141export function parseListIds(text: string, prefix: string): Set<string> {
142 const esc = prefix.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')
143 const re = new RegExp(`(?<![a-z0-9_-])${esc}-[a-z0-9]{1,8}(?:\\.\\d+)*(?![a-z0-9_-])`, 'gi')
144 const out = new Set<string>()
145 for (const m of text.matchAll(re)) out.add(m[0].toLowerCase())
146 return out
147}
148
149// a board's ids as read, or 'unreadable' when bw could not list it (no registered path, no `bw init`)
150export type Board = ReadonlySet<string> | 'unreadable'
151
152// what is known of the boards: `prefix` names the board a bare local part means, the session's
153// own ('' without one, or with bare ids turned off); `boards` holds every board read so far, by prefix
154export type Known = { prefix: string; boards: ReadonlyMap<string, Board> }
155
156type Hit = { at: number; end: number; id: string }
157
158// the ids in a text, in order. A full id counts when it is on its board; on an unreadable board
159// there is nothing to check it against and its shape decides, as it does for every full id without
160// `known`. A board not read yet holds its ids back: a button that arrives late beats one that
161// vanishes. A bare local part counts when it is a ticket on the session's own board (never another
162// board's; a bare id has no way to say which).
163function hitsIn(text: string, matcher: RegExp | undefined, known: Known | undefined): Hit[] {
164 const hits: Hit[] = []
165 if (matcher !== undefined)
166 for (const m of text.matchAll(matcher)) {
167 const id = m[0].toLowerCase()
168 const board = known?.boards.get((m[1] as string).toLowerCase())
169 if (known === undefined || board === 'unreadable' || board?.has(id)) hits.push({ at: m.index, end: m.index + m[0].length, id })
170 }
171 const home = known === undefined || known.prefix === '' ? undefined : known.boards.get(known.prefix)
172 if (known !== undefined && home !== undefined && home !== 'unreadable' && home.size > 0)
173 for (const m of text.matchAll(BARE_RE)) {
174 const word = m[0].toLowerCase()
175 if (STOPWORDS.has(word)) continue
176 const id = `${known.prefix}-${word}`
177 if (home.has(id)) hits.push({ at: m.index, end: m.index + m[0].length, id })
178 }
179 hits.sort((a, b) => a.at - b.at || b.end - a.end)
180 // overlapping hits (a bare match inside a full id) keep the earlier, longer one
181 const out: Hit[] = []
182 for (const h of hits) if (out.length === 0 || h.at >= (out[out.length - 1] as Hit).end) out.push(h)
183 return out
184}
185
186// the distinct ids a text mentions, in order of first mention
187export function findMentions(text: string, matcher: RegExp | undefined, known?: Known): string[] {
188 return [...new Set(hitsIn(text, matcher, known).map(h => h.id))]
189}
190
191function str(v: unknown): string {
192 return typeof v === 'string' ? v : ''
193}
194
195function strList(v: unknown): string[] {
196 return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
197}
198
199function optStr(v: unknown): string | undefined {
200 return typeof v === 'string' && v !== '' ? v : undefined
201}
202
203export function parseTicket(json: unknown): Ticket | undefined {
204 if (typeof json !== 'object' || json === null) return undefined
205 const j = json as Record<string, unknown>
206 if (typeof j.id !== 'string' || typeof j.title !== 'string') return undefined
207 const comments = Array.isArray(j.comments)
208 ? j.comments
209 .map(c => (typeof c === 'object' && c !== null ? (c as Record<string, unknown>) : {}))
210 .map(c => ({ text: str(c.text), at: str(c.timestamp) || str(c.created) }))
211 .filter(c => c.text !== '')
212 : []
213 return {
214 id: j.id,
215 title: j.title,
216 description: str(j.description),
217 status: str(j.status) || 'open',
218 type: str(j.type) || 'task',
219 priority: typeof j.priority === 'number' ? j.priority : 2,
220 labels: strList(j.labels),
221 assignee: str(j.assignee),
222 created: str(j.created),
223 updatedAt: str(j.updated_at),
224 closedAt: optStr(j.closed_at),
225 closeReason: optStr(j.close_reason),
226 due: optStr(j.due),
227 deferUntil: optStr(j.defer_until),
228 parent: optStr(j.parent) ?? parentOf(j.id),
229 blockedBy: strList(j.blocked_by),
230 blocks: strList(j.blocks),
231 comments,
232 }
233}
234
235// prefix → the repo paths bw's registry files it under (two clones can share one)
236export function parseRegistryPaths(json: string): Record<string, string[]> {
237 const out: Record<string, string[]> = {}
238 for (const { path, prefix } of registryEntries(json)) (out[prefix] ??= []).push(path)
239 return out
240}
241
242// the registered prefix an id starts with, longest first (`spire-cl-0aa` is `spire-cl`, not `spire`)
243export function prefixOf(id: string, prefixes: readonly string[]): string | undefined {
244 let best: string | undefined
245 for (const p of prefixes) if (id.startsWith(`${p}-`) && (best === undefined || p.length > best.length)) best = p
246 return best
247}
248
249// `bw list --parent <id> --all --json`, whole or slimmed by jq: the children in id order. bw prints
250// `null` for a ticket without any
251export function parseChildren(stdout: string): Ticket[] {
252 try {
253 const parsed: unknown = JSON.parse(stdout)
254 if (!Array.isArray(parsed)) return []
255 return parsed
256 .map(parseTicket)
257 .filter((t): t is Ticket => t !== undefined)
258 .sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true }))
259 } catch {
260 return []
261 }
262}
263
264// adf-wxh.5 → adf-wxh; adf-c50 → undefined
265export function parentOf(id: string): string | undefined {
266 const i = id.lastIndexOf('.')
267 return i > 0 ? id.slice(0, i) : undefined
268}
269
270// `bw show <id> --json` as it came back, into what the pane draws
271export function parseShow(id: string, r: { exitCode: number; stdout: string; stderr: string }, at: number): Lookup {
272 const err = `${r.stderr}\n${r.stdout}`.trim()
273 if (r.exitCode === 0) {
274 try {
275 const t = parseTicket(JSON.parse(r.stdout))
276 if (t !== undefined) return { kind: 'ticket', id, ticket: t, at }
277 } catch {
278 // fall through to the error below
279 }
280 return { kind: 'error', id, message: `bw show ${id} returned something other than a ticket: ${oneLine(err || r.stdout, 200)}`, at }
281 }
282 const amb = /ambiguous ID[^:]*:\s*matches\s+(.+)/is.exec(err)
283 if (amb !== null) {
284 const candidates = (amb[1] as string)
285 .split(/[,\s]+/)
286 .map(s => s.trim().toLowerCase())
287 .filter(isTicketId)
288 return { kind: 'ambiguous', id, candidates: sortIds([...new Set(candidates)]), at }
289 }
290 if (/no issue found/i.test(err)) return { kind: 'missing', id, at }
291 return { kind: 'error', id, message: oneLine(err || `bw show exited ${r.exitCode}`, 300), at }
292}
293
294// adf-1pp before adf-1pp.2 before adf-1pp.10; digits by value
295export function sortIds(ids: string[]): string[] {
296 return [...ids].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
297}
298
299// ---- digest ---------------------------------------------------------------------------------------
300
301export type StatusStyle = { glyph: string; word: string; color?: string; dim?: boolean }
302
303export function statusOf(t: Ticket): StatusStyle {
304 switch (t.status) {
305 case 'closed':
306 return { glyph: '✓', word: 'closed', color: 'green' }
307 case 'in_progress':
308 return { glyph: '◐', word: 'in progress', color: 'yellow' }
309 case 'deferred':
310 return { glyph: '❄', word: 'deferred', color: 'cyan' }
311 case 'open':
312 return t.blockedBy.length > 0 ? { glyph: '⊘', word: 'blocked', color: 'red' } : { glyph: '○', word: 'open', color: 'white' }
313 default:
314 return { glyph: '•', word: t.status.replace(/_/g, ' '), dim: true }
315 }
316}
317
318export const PRIORITY_COLOR: Record<number, string> = { 0: 'redBright', 1: 'red', 2: 'yellow', 3: 'blue', 4: 'gray' }
319
320// ---- time ----------------------------------------------------------------------------------------
321
322const DAY = 86_400_000
323
324// the ms of a bw date: RFC3339, or a bare YYYY-MM-DD read as local midnight
325export function parseDate(text: string | undefined): number | undefined {
326 if (text === undefined || text === '') return undefined
327 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text)
328 if (m !== null) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime()
329 const t = Date.parse(text)
330 return Number.isNaN(t) ? undefined : t
331}
332
333// Sep 16, or May 31 2025 when the year is not this one
334export function shortDate(ms: number, now: number): string {
335 const d = new Date(ms)
336 const sameYear = d.getFullYear() === new Date(now).getFullYear()
337 return d.toLocaleDateString([], sameYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' })
338}
339
340export function shortDateTime(ms: number, now: number): string {
341 return `${shortDate(ms, now)} ${new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}`
342}
343
344// "in 3d", "4d ago", "today"; whole days
345export function relativeDays(ms: number, now: number): string {
346 const days = Math.round((ms - now) / DAY)
347 if (days === 0) return 'today'
348 if (days === 1) return 'tomorrow'
349 if (days === -1) return 'yesterday'
350 return days > 0 ? `in ${days}d` : `${-days}d ago`
351}
352
353// "2h ago", "3d ago", "just now"
354export function ago(ms: number, now: number): string {
355 const s = Math.max(0, Math.floor((now - ms) / 1000))
356 if (s < 60) return 'just now'
357 const m = Math.floor(s / 60)
358 if (m < 60) return `${m}m ago`
359 const h = Math.floor(m / 60)
360 if (h < 24) return `${h}h ago`
361 const d = Math.floor(h / 24)
362 if (d < 30) return `${d}d ago`
363 return shortDate(ms, now)
364}
365
366// ---- text ----------------------------------------------------------------------------------------
367
368export function oneLine(text: string, max: number): string {
369 const flat = text.replace(/\s+/g, ' ').trim()
370 if (max <= 1) return flat.slice(0, Math.max(0, max))
371 return flat.length > max ? flat.slice(0, max - 1) + '…' : flat
372}
373
374export function plural(n: number, one: string, many = `${one}s`): string {
375 return `${n} ${n === 1 ? one : many}`
376}
377
378// a text as lines, trailing blank lines dropped
379export function lines(text: string): string[] {
380 const out = text.replace(/\r\n?/g, '\n').split('\n')
381 while (out.length > 0 && (out[out.length - 1] as string).trim() === '') out.pop()
382 return out
383}
384
385// ---- inline ids ----------------------------------------------------------------------------------
386
387// a run of a line: plain text, or a ticket id drawn as a button (a mention, as findMentions counts
388// them). `text` is the id as written.
389export type Piece = { kind: 'text'; text: string } | { kind: 'id'; id: string; text: string }
390export type Row = Piece[]
391
392// cells a Button takes on the terminal beyond its label: the engine's `[ ` and ` ]`
393export const BUTTON_CHROME = 4
394
395// one line as pieces, in order
396export function piecesOf(line: string, matcher: RegExp | undefined, known: Known | undefined): Piece[] {
397 const out: Piece[] = []
398 let at = 0
399 for (const h of hitsIn(line, matcher, known)) {
400 if (h.at > at) out.push({ kind: 'text', text: line.slice(at, h.at) })
401 out.push({ kind: 'id', id: h.id, text: line.slice(h.at, h.end) })
402 at = h.end
403 }
404 if (at < line.length) out.push({ kind: 'text', text: line.slice(at) })
405 return out
406}
407
408function widthOf(piece: Piece): number {
409 return piece.kind === 'id' ? piece.id.length + BUTTON_CHROME : piece.text.length
410}
411
412// a text laid out as rows at a width: a greedy word wrap where an id is one unbreakable token as wide
413// as its button, words longer than the width split, blank lines kept as empty rows. What is counted
414// is what is drawn, so the collapse threshold and the ellipsis land on real rows.
415export function layoutRows(text: string, width: number, matcher?: RegExp, known?: Known): Row[] {
416 const w = Math.max(1, width)
417 const rows: Row[] = []
418 for (const line of lines(text)) {
419 // tokens: words with their trailing space, ids atomic
420 const tokens: Piece[] = []
421 for (const piece of piecesOf(line, matcher, known)) {
422 if (piece.kind === 'id') {
423 tokens.push(piece)
424 continue
425 }
426 for (const m of piece.text.matchAll(/\S+\s*|\s+/g)) tokens.push({ kind: 'text', text: m[0] })
427 }
428 let row: Row = []
429 let used = 0
430 const flush = () => {
431 rows.push(merge(row))
432 row = []
433 used = 0
434 }
435 for (const t of tokens) {
436 if (t.kind === 'text' && t.text.length > w) {
437 // a word wider than the row: hard split
438 let rest = t.text
439 while (rest.length > 0) {
440 const room = w - used
441 if (room <= 0) flush()
442 const take = rest.slice(0, w - used)
443 row.push({ kind: 'text', text: take })
444 used += take.length
445 rest = rest.slice(take.length)
446 if (used >= w && rest.length > 0) flush()
447 }
448 continue
449 }
450 // a trailing space may hang past the edge
451 const need = t.kind === 'text' ? t.text.trimEnd().length : widthOf(t)
452 if (row.length > 0 && used + need > w) flush()
453 row.push(t)
454 used += widthOf(t)
455 }
456 rows.push(merge(row))
457 }
458 return rows
459}
460
461// adjacent text pieces as one; a row's trailing spaces dropped
462function merge(row: Row): Row {
463 const out: Row = []
464 for (const p of row) {
465 const last = out[out.length - 1]
466 if (p.kind === 'text' && last?.kind === 'text') last.text += p.text
467 else out.push(p.kind === 'text' ? { ...p } : p)
468 }
469 const last = out[out.length - 1]
470 if (last?.kind === 'text') {
471 last.text = last.text.trimEnd()
472 if (last.text === '') out.pop()
473 }
474 return out
475}
476
477// the first `count` rows, an ellipsis on the last when more follow
478export function firstRows(rows: Row[], count: number, width: number): { shown: Row[]; hidden: number } {
479 if (rows.length <= count) return { shown: rows, hidden: 0 }
480 const shown = rows.slice(0, Math.max(1, count)).map(r => [...r])
481 const last = shown[shown.length - 1] as Row
482 const used = last.reduce((n, p) => n + widthOf(p), 0)
483 const tail = last[last.length - 1]
484 if (used < width) last.push({ kind: 'text', text: '…' })
485 else if (tail?.kind === 'text' && tail.text.length > 0) last[last.length - 1] = { kind: 'text', text: `${tail.text.slice(0, -1)}…` }
486 return { shown, hidden: rows.length - shown.length }
487}
488
489export function rowText(row: Row): string {
490 return row.map(p => (p.kind === 'id' ? p.text : p.text)).join('')
491}
492