SLOPSHOPPER

bw-peek

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…

newpanerowsguardcommandtoast
v0.1.5MITupdated 2026-09-17iautom8things/bw-peek
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bw-peek
│ ┃ Beadwork ✕ › fix the failing auth test and add an audit log call │ ┃ ◈ Beadwork │ ┃ ticket id, e.g. adf-c50 ⏎ open ● bw-peek: no board here, bare ids stay plain: unusable prefix "" │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ Type a ticket id in the box and press Ente ⏺ Update(src/auth.ts) │ ┃ an id under a reply. ⎿ Added 2 lines, removed 1 line │ ┃ Full ids work across every repo bw knows ( ⏺ Bash(bun test) │ ┃ think-1pp). ⎿ 3 pass, 1 fail │ ┃ │ ┃ esc closes · type an id and press enter · /b ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ the prompt │ ✻ Worked for 42s · done 4:20 PM │ │ › /bw │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Beadwork
◈ Beadwork ticket id, e.g. adf-c50 ⏎ open ─────────────────────────────────────────────────────── Type a ticket id in the box and press Enter, or press an id under a reply. Full ids work across every repo bw knows (adf-c50, think-1pp). esc closes · type an id and press enter · /bw <id> from the prompt
README

bw-peek

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.

bw-peek: ticket buttons under a reply, and the pane they open

Nothing here writes to bw.

Install

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.

What it draws

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
  • The digest row: status (○ 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.
  • The title in bold, wrapped.
  • Relations as buttons: parent, blocked by, blocks. A press opens that ticket; recent at the bottom is the trail back.
  • Children, for a ticket that has any: a count with how many are closed, then one row each as bw's text view lists them, ◐ 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 ].
  • The close reason, when the ticket has one.
  • The description, collapsed to its first rows with [ 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.
  • Comments, each as [ + ], its time, and the first 50 characters. [ + ] opens the full text in a box; [ − ] folds it.
  • Every ticket id inside the description, the close reason or an open comment is drawn in place as a button: 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:

replyscan alonewhole hook per redraw
2 KB prose, no candidates0.23 ms0.43 ms
5 KB, 100 bare candidates, 1 real0.35 ms0.73 ms
5 KB, 100 candidates, 5 real0.35 ms0.71 ms
5 KB, 100 candidates, 10 real0.33 ms0.56 ms
50 KB, 1000 candidates, 100 real3.4 ms1.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.

The registry

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.

Config

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.

fieldtypedefaultmeaning
mention_buttonsbooleantruedraw the [ id ] row under replies that mention tickets
bare_mentionsbooleantruealso light up bare ids (c50) that are tickets on this board
mention_capnumber6most ids drawn under one reply before +N more (1 to 20)
collapsed_rowsnumber8description rows shown before [ Show all ] (3 to 40)
digest_charsnumber50characters of each comment before its [ + ] (20 to 200)

How it works

piecemechanism
prefixesbw 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
mentionsui.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
childrenbw 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
focusafter 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.

Develop

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:

  • The mobile element table has no 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.
  • A pressed button's work outlives the press. Every action goes through a fire() that catches its promise, else a session end mid-fetch surfaces as an unhandled rejection.
  • In 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.

License

MIT.

Source 3 files
hooks/register.tsx 528 lines
1/* @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}
528
hooks/draw.tsx 450 lines
1/* @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}
450
hooks/ticket.ts 492 lines
1// 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