SLOPSHOPPER

thimble-term

thimble's terminal-mode renderer: it draws thimble's cards, citations, labels, agents, side threads, documents and files in Claude Code. It only draws; the…

newpanebandspinnerrowsguard
★ 18v0.6.1Apache-2.0updated 2026-10-09safety-research/thimble/mods/thimble-term
A shopper browsing a rack in a slop shop
README

thimble-term

thimble's terminal-mode renderer: a Claude Code plugin of function hooks that draws thimble's work in the terminal. thimble mode terminal once in a folder, then thimble, starts Claude Code with thimble's plugin and this one (--plugin-dir <tree>/mods/thimble-term), with no server, no port and no browser. Browser mode does not load it.

It only draws. It registers no model tools, agents, guidance or commands, and it keeps no data except what is on screen; the one call it changes is main's Agent call for a side thread's fork, which runs with the thread's question as its description, so that Claude Code's agent tray and exit dialog name the thread by it (its prompt keeps thread:<name>, by which thimble knows the fork): what it draws comes from thimble state, and every change it makes goes through thimble act. The one file it writes is the workspace's terminal/chat.json (hooks/kept.ts): what main's chat drew under each of its rows (the turn's cards, the answer's footer, the ↳ rows), by the row's uuid, which Claude Code keeps across --continue and --resume, so a resumed session draws it again. It writes to main only when the analyst asks it to: a code label's run and a card's run again (code runs only in main's Bash) and a new label described in the labels list go to main as the analyst's prompt, and a document's as slides or as a story runs /thimble:write. It is idle in any session whose THIMBLE_WS does not name a workspace with mode: "terminal" in its trusted/launch.json.

What it draws

Its look is SPEC.md ("The visual system"): one left edge, links blue and underlined, new in green, Claude Code's panel chrome, no right-click menu.

  • Main's replies. The model's Markdown as Claude Code draws it (bold bold, headings bold, inline code coloured). The prose and the cards share one left edge, column 2, Claude Code's own (the ⏺ row's text), and one width: the terminal's, less 2 and the margin, with no measure; the cards follow the text and each other border to border. Each citation, [[value|ref]] or the Markdown link main writes for the terminal, is a link, blue and underlined; red when its place does not exist or does not hold its value (thimble state resolve); in a card's takeaway, ◌ after it while thimble's links check runs, ✓ once it found the value, a red × when it found another. The citation under the pointer shows its status in plain words on a quiet box (found in README.md line 3, found on the card; its value is not checked, not found: … does not exist, and why). The blue ? beside a passage, in the ⏺'s column (over the ⏺ on the reply's first row), asks a side thread about it (a heading's about its whole section, a card's about the card); once one was asked, a blue ↳ stays there and opens it. While main streams, citations show as links and a card's line as ◌ <its question>, never [[…]]; a citation typed into the prompt is blue and underlined too. A chip, a citation that names only its place ([[card:<id>]], ↗), reads as its place's short name in brackets in the link color, [ card ] or [ events.jsonl line 12 ], alike in replies, thread answers, documents, takeaways and previews; its tip names the place in full. Every cut is at a word (hooks/lib.ts cut). Main's end token, (shown in the dashboard), is not shown. Off the terminal a cited block is Markdown, each citation a link to its file and a problem marked ×.
  • The footer under a turn's answer (its last part that cites or embeds a card), one blank row below it: N citations · N cards dim, · N problems in red, ask about this answer ›. None for the prompts thimble-term gives main itself. There is no open as report: thimble keeps one document per type, so an answer would replace the report; main's /thimble:write writes one.
  • Cards. Each card a turn of main added or changed (add_card, edit_card, apply_label, thimble-run card) is drawn once, under the turn's last reply, in its last state. Every card, wherever it is drawn (under a reply, the card pane, the citation panel, a document), has a full round border; inside it, the title in bold (in inverse under the pointer, a press asks a side thread), one blank row, then the plot or body directly; below the plot the readout (the value under the pointer, or the card's state), the label rows (the label's name a link with ↗, which opens its panel; its values in their colours, changed since once the label changed after the card ran), the params and the takeaway. A card that read a label colours its marks by the label's values. A label card is a bar card of the label's counts: its records are in the label panel. A card that cannot be read is one red line (× card 2 cannot be drawn: …). Claude Code's tool rows stay its own, folded, and no hex id is drawn, in the row or in ctrl+o's detailed view: a thimble tool's row names a card by its question (cut at a word, without straight quotation marks Claude Code would escape) and a citation by its words, a label tool's result its name and counts, and a side thread's fork row and the notice that it finished the thread by its first question, and so does the fork's prompt in ctrl+o; a tool's words draw their straight quotation marks curly, which Claude Code does not escape; thimble's tool results keep their card ids, which main cites. A bar card keeps the order its chart's label axis sorts, and a bar chart with its values written on its bars is a bar card too; a bar chart with a color field has one row per label, its series stacked in their hues and its key below, and timestamps as labels read as the browser's axis writes them (24 May). A table, a timeline, a diagram, an example and a label draw directly; a simple bar or line chart draws as text; any other chart as a table of its rows; a note, a custom card, a code card and a card type's card as their words.
  • Rows under main's replies. ↳ thread · "<the turn's question>" · answered when a side thread's turn ends while the panel shows something else (failed in red, new in green until it is read; a stop, the analyst's or the end of the Claude Code session, is not news; main's own ↳ thread line about that thread is then not drawn; main's ↳ The writer … line is drawn once per writer run, the first time), and ↳ view · <name> · building|built|proposed under the answer that proposed a view (failed in red, new once built until opened).
  • One row above the prompt, a toast: what is new in the workspace since home was last opened (2 new cards, new in green), open › opening home, gone once it is opened. Side threads have their ↳ rows and thimble's agents Claude Code's agent tray, so no row repeats them.
  • One panel, on Claude Code's panel chrome: one title row, the path from home, its earlier steps dim and a click away and the current step last in the accent colour and bold (home › Threads; on home, show all threads and N new at the right; no ‹ back, b goes back), a dim subtitle, a rule, the actions at the bottom after a second rule, and a dim italic row of key hints (b to go back only where there is a way back), which goes on to a second row where it does not fit, never cut. Claude Code's pane title says what it shows (Citation, Threads, Label: …, a card's question, a document's title). Its views: home (one column: views, documents, threads, cards by group with the newest open, labels, files by folder and the orientation's coverage line); a card with how its last run ended, run again (main runs thimble-run card), and its code with what it printed; a citation (its value as a link with ◌ or a red ×, its status in plain words, the sentence it stands in, its lines with the value marked (the whole line where it shows none), a file's as a window that ↑↓ and the wheel scroll through the whole file, or the card it names with the cited mark lit, a follow-up field when opened from a side thread); the threads (a tree with a root per place, main or report "…", the selected thread under it with what it is about above its chat: a card in its frame, a file's cited lines, a passage's words, cut to 6 rows and … N more; a new thread shows it above its field too; stop while it answers, hand back to main once it answered, the ask field); a label; the documents and one document (a report with its contents, a deck or a story one slide or beat at a time, the retell controls; its comments, a check's, Claude's or the analyst's, under the passages they are on, ↑↓ to choose one, r to resolve it, v to show the resolved ones; e to edit a report as Markdown, its cards as their lines, saved as the browser's editor saves it); the file browser, after the browser's Files (folders that nest and fold, each folder's count and size, a ● in a label's hue after each file a label that is on labeled; f to find by name and by words, thimble state find and grep, what it found in place of the tree with each matching line and its match lit; the chosen file's preview in the mode it opens in) and a file (the modes that fit it, as the browser offers them: Table for records and CSV rows, Transcript, Text, JSON one record at a time, Raw; the chosen record's place and ?; f to find in the file, thimble state findin; the labels that are on marking the records they labeled in their values' hues, thimble state marks; a database's tables and a table's rows, thimble state tables and rows); an agent; and the views (a view built in terminal mode drawn by its program, view.term.js on the terminal view kit, which thimble's view host runs sandboxed while the view shows: docs/terminal-views.md; a view built in browser mode as one line that says so).
  • The label panel, as Matt laid it out: name: its name in the accent and bold after a ● in its colour, type: (prompt regex code, the one in use on the selection background, the others a click away), scope: (its files, a field, and how many records), for a label over files in files: (on off, o, thimble act label-show), then a rule; the prompt (or pattern, or code) whole in a field to edit, on the same column (hooks/field.tsx: a click gives it the keyboard, Enter saves it, thimble act label); run on a sample and run on all N, which save what was typed first and run it (thimble act label-run), and stop (s) while it runs (thimble act label-stop), rename (n) and delete (k), whose undo the labels list then offers (u, thimble act label-undelete); then ▸ counts (with the values to edit, and each value's color, the eighteen label colors around the color wheel by the names show_label takes, and filter, thimble act label-show and label-filter), ▸ examples (the held-out agreement; each record under its value, agree or another value, thimble act verdict; … N more per value, thimble state label --rows; a JSON record's other fields on one dim row cut at whole pairs) and ▸ cards (the cards that use it, each a click away), folded. A label's values take their classes' colors wherever they show (hooks/labels.ts, paint.ts LABEL_HUES and PICKED_HUES). Nothing else shows until it is opened. A code label's code runs only in main's Bash: its run asks main to run the thimble-run label command it gives.
  • Side threads. The ? beside a passage or a card, a press on a card's title or mark, a selection's "ask", or ask about this answer › opens the ask field, which posts the thread (thimble act thread, with the thread it was asked from as its parent, and a document's passage as its anchor); main forks thread:<name>, as for the browser's.
  • /thimble opens the home panel, with no model turn; /thimble threads, /thimble cite [n] (the n-th citation of the last reply), /thimble card [n|id], /thimble files [path[:line]] and /thimble documents open those.

What it reads and changes

hooks/data.ts runs the thimble command beside the plugin in thimble's tree (<tree>/plugin/bin/thimble; THIMBLE_TERM_CLI names a stand-in for tests and the live check). Each surface prints the JSON of the server's GET route for it:

CallRead as
thimble state home --cwd <dir>the workspace's counts: cards, labels, docs, threads, views, files (a number or a list each); views, each with its status (built, building, proposed, failed), ts and claimed files; and the orientation's coverage line
thimble state cards --cwd <dir> --since <iso>the canvas route's {groups, cells}, the cells changed since
thimble state card --cwd <dir> <id>the cell route's cell
thimble state labels --cwd <dir> / label --cwd <dir> <id> [--rows <json>]the concepts route's list / one concept, with a page of its rows per value (rows, as /rows?text=1 answers; --rows {"<value>": n} n of that value) after the records the analyst gave that value, how many records have each value (totals) and the value its scope's filter keeps (filter)
thimble state docs --cwd <dir> / doc --cwd <dir> <slug>the document types route's {slug: {exists, title, …}} / one document
thimble state threads --cwd <dir> / thread --cwd <dir> <id> [--after n]the chats route's metas (each with answers and seen, or unread) / {meta, events} past n
thimble state agents --cwd <dir>the agents route's {rows}
thimble state files --cwd <dir> [path] [--start n]the sources route's list / a page of a file's records
thimble state turns --cwd <dir> <path> [--start n] [--line n]a page of a whole-file JSON transcript's turns, parsed from the whole file (GET /source/turns), or none with none saying why, for its Transcript tab
thimble state opens --cwd <dir> <paths json>{path: "transcript"} for each listed file of plain text that opens as a transcript, which the type column shows
thimble state resolve --cwd <dir> <refs json>{ref: resolution} (the ref route's answer, or {error}) for a list of refs
thimble state ui --cwd <dir> --after <n>the ui.jsonl records past n
thimble act thread --cwd <dir> {anchor, anchor_text?, message}a new side thread: {ok, thread}
thimble act thread-message --cwd <dir> {thread, message}a question in a thread
thimble act verdict --cwd <dir> {label, ref, value}the analyst's value for a record
thimble act label --cwd <dir> {label, name?, kind?, body?, glob?, values?}the label panel's edit, saved as the browser's label editor saves it
thimble act label-show --cwd <dir> {label, on?, values?, colours?}a label over files on or off in Files and the views, or its values given colors by name, as the Labels pane and show_label do
thimble act label-filter --cwd <dir> {label, value?}the label's scope's filter set to value, or cleared when it names the label and no value is given
thimble act label-delete --cwd <dir> {label} / label-undelete --cwd <dir> {label}a label deleted with its marks, card and filters / its delete undone while it is the last change
thimble act label-run --cwd <dir> {label, limit?}a run on a sample (limit) or on every record; it answers once the run ends, so the renderer starts it beside the session ($.process.spawn), which it ends with; a code label's answer is the thimble-run label command (deferred)
thimble act label-stop --cwd <dir> {label}stop a run label-run started, after its current record (the run's process watches for the stop file this writes)
thimble act seen --cwd <dir> {thread}the thread's answers read
thimble act hand-back --cwd <dir> {thread}a finished thread's answer sent to main as the analyst's message, From thread "<question>": <answer> (the thread's meta then says hand_back: handed)
thimble act stop --cwd <dir> {agent}stop one of thimble's agents, or a side thread's fork
thimble state checks --cwd <dir>the report checks route's list: each check's name, colour, shown and runs, which name a document's comments
thimble act comment-resolve --cwd <dir> {doc, comment} / comment-reopena document's comment resolved, as the browser's margin's ✓ does, or opened again
thimble act doc-save --cwd <dir> {doc, title?, blocks}a report edited as Markdown, saved as the browser's editor saves it (PUT …/blocks): each block with the id of the unit it was built from, so a kept passage keeps its id and its comments
thimble view host --cwd <dir>thimble's view host, started beside the session the first time a view opens ($.process.spawn): it prints {t: ready, socket, token}, then the frames a view's program draws on its own; the panel posts /open, /event and /close to the socket ($.http.fetch, hooks/viewhost.ts)

It sees a change without starting Python: once a second it lists the workspace's folders (notebooks, concepts, labels, investigations/main, checks, chats, trusted/subagents.json, orient/run.json, extension/views, views/proposals.json, ui.jsonl) and reads again only the surfaces a drawing shows.

Files

hooks/register.tsx holds the hooks and the one place $ is used (Claude Code follows $ into no import); hooks/ctx.ts is what it shares with the rest (the answer footer, the ↳ rows, the stream and /thimble's words are in register.tsx too). hooks/term.ts keeps what it read and the panel's moves, hooks/reply.tsx draws main's chat, hooks/panel.tsx the panel, hooks/filesview.tsx its file browser and a file, hooks/lines.tsx the parts drawn from styled lines (homeview.tsx), hooks/cell.ts turns a thimble cell into the drawing's card form, hooks/model.ts reads the other surfaces, hooks/data.ts runs the command. The drawing itself: draw.ts lays out cards and charts as styled lines, card.tsx draws a card (its border the drawing's, its title a hot spot, everything but the title below the plot), cite.ts and para.tsx citations and their tips, home.ts and homeview.tsx home, chrome.tsx the panel's chrome, paint.ts the colors, gestures.tsx what a press does, anim.ts the mark a citation lights on a card, nav.ts the path from home and the threads tree, signal.ts the ↳ rows' rules, report.ts a document's comments and its edit as Markdown, docedit.tsx the document's editor, files.ts the file browser's pure parts (the file a ref cites, the folder tree, a file's modes, the labels that are on and the values they gave its records, what a find found, the turns a whole-file JSON transcript's view reads), turns.ts one drawing of a pane at a time, lib.ts the pure helpers, field.tsx the text field that shows all of its text (a label's prompt), kept.ts what main's chat drew under its rows, kept for a resume, viewhost.ts thimble's view host and the open view's frame, and viewclient.tsx a view's frame drawn with its hot regions and tips.

Tests

From mods/thimble-term:

claude plugin validate . claude plugin test . npx -p typescript tsc -p . --noEmit

tsc needs the types Claude Code writes into .claude-plugin/types/ when it loads the plugin, for example in a session started with claude --plugin-dir .. The tests answer thimble state and thimble act from fixture states (tests/fixtures.ts).

Source 33 files
hooks/register.tsx 1141 lines
1// thimble-term: thimble's terminal-mode renderer. The `thimble` command loads this plugin in terminal mode only
2// (`thimble mode terminal`), so browser mode loads nothing new. It registers no model tools, agents, guidance or
3// commands, and keeps no data except what is on screen: what it draws comes from `thimble state`, and every change it
4// makes goes through `thimble act` (hooks/data.ts). What main's chat drew under its rows is kept in the workspace's
5// terminal/chat.json for a resumed session (hooks/kept.ts). Its scope check: THIMBLE_WS names a workspace whose
6// trusted/launch.json has `mode: "terminal"`; anywhere else every hook passes through.
7//
8// What it draws (its look is SPEC.md's "The visual system"):
9//   - main's replies on the mod's grid, each citation a link, red when its place does not hold its value (reply.tsx)
10//   - each card a turn of main added or changed, once, under the turn's last reply, in its last state, its takeaway
11//     under it; no hex id, and Claude Code's tool groups left folded
12//   - one row above the prompt, a toast: what is new in the workspace since home was last opened (`open ›` opens home
13//     and the row goes); Claude Code's agent tray shows thimble's agents, and side threads have `↳` rows
14//   - one panel (panel.tsx): home, a card, a citation's place, the threads, a label, a document, the files, an agent
15//   - side threads: the blue "?" beside a passage or a card, or a selection's "ask"; a `↳ thread` row under main's
16//     latest row when an answer comes in while the panel shows something else, and a blue ↳ beside the passage asked
17//     about. A right-click does what a click does: there is no menu
18//   - `/thimble` opens the home panel, with no model turn
19// It hides main's end token, `(shown in the dashboard)`, as the browser does.
20//
21// `$` stays in this file (Claude Code follows it into no import): each hook builds the context the other files take
22// (cxOf, hooks/ctx.ts).
23import { atom, read, update } from 'claude-code'
24import type { EngineInterface, Register, RenderElement, ResolveInput } from 'claude-code'
25
26import type { Ctx } from './ctx'
27import { act, scopeOf } from './data'
28import type { Sent } from './gestures'
29import { chipState, claimsIn, streamLink, streamStep, streaming } from './cite'
30import type { StreamLook, Streaming } from './cite'
31import type { CardData } from './draw'
32import { bareCard, cardWords, cid, citeSpans, citations, clip, cut, embeddedCards, labelRef, needsDrawing, quoted } from './lib'
33import { cardsOfCall, docsOf, forkDescription, labelsOf, namedForks, namedThreads, runIds, runShown, saysWriter, threadOf, withoutEnd, withoutNotes, withoutToldThreads, withoutWriterLines } from './model'
34import { HOME_UI_EMPTY } from './home'
35import { keepLast, keepRow, loadKept, resetKept } from './kept'
36import { linesMessage, onListClick } from './lines'
37import { ANSWER_ELEMENT, RELAY, docEditMessage, drawPanel, fieldMessage, focusedField, hasList, homeViews, onGesture, openAsk, openCard, openCite, openFile, openHomeNew, openLabel, openThread, openView, relayKey, relayMove, scrollPending, viewWheelAt, wheelWindow } from './panel'
38import type { PaneEvent } from './panel'
39// the file browser and a file, drawn by a module of their own (panel.tsx drawsView, term.ts loadsView)
40import './filesview'
41import { MARGIN, chipOf, drawCards, drawReply, placeUrl, toolWords } from './reply'
42import { COLORS } from './paint'
43import { isAnchor, signalEnd, signalQuestion } from './signal'
44import type { AppendedRow } from './signal'
45import { NAV_EMPTY } from './nav'
46import { FILES_UI_EMPTY, LABEL_UI_EMPTY, PANEL, checkQueued, closePanel, loadCards, loadCardsBatch, navOrigin, openHome, openPanel, openPending, paneTitle, panelColumns, placedLater, readSurface, readThread, retryPending, rt, surfaceValue, takeKeys, threadsNow, tick } from './term'
47import type { UiApply } from './term'
48import { turns } from './turns'
49import { PUMP_MS, closeView, openViewState, sendEvent, viewFault, viewMessage, viewPump } from './viewhost'
50
51type Dollar = EngineInterface
52
53/** /thimble's description and argument hint in terminal mode (the command.describe hook). */
54export const THIMBLE_DESCRIPTION =
55  "Opens thimble's home panel in this terminal: documents, side threads, cards, labels and files. `/thimble threads`, `cite <n>`, `card <n>`, `files [path[:line]]` and `documents` open those."
56export const THIMBLE_ARGS = '[threads | cite <n> | card <n> | files [path[:line]] | documents]'
57/** What `/thimble` says in terminal mode: the home panel is open, and how to reach the browser instead. */
58export const HOME_LINE = 'thimble: terminal mode. The home panel is open. For the browser workspace, quit, run `thimble mode browser`, and start `thimble` again.'
59
60// the text Claude Code stores as the reply of a turn that ended with none (its `<synthetic>` model's)
61const SYNTHETIC_RE = /^\s*No response requested\.\s*$/
62const THIMBLE_TOOL = /^mcp__plugin_thimble_thimble__/
63const CARD_TOOL = /^mcp__plugin_thimble_thimble__(add_card|edit_card|apply_label)$/
64const VIEW_TOOL = /^mcp__plugin_thimble_thimble__propose_view$/
65
66/** The text of a tool's result as the transcript holds it: a string, or content blocks. */
67function resultText(output: unknown): string {
68  if (typeof output === 'string') return output
69  const blocks = Array.isArray(output) ? output : (output as { content?: unknown } | undefined)?.content
70  return Array.isArray(blocks) ? blocks.map(b => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : '')).join('\n') : ''
71}
72const ABOVE_LABEL = 12
73const paneTurns = turns()
74
75// the state the drawings read (types/index.d.ts)
76const CARDS = { plugin: 'thimble-term', key: 'cards' } as const
77const TURN_CARDS = { plugin: 'thimble-term', key: 'turnCards' } as const
78const VERDICTS = { plugin: 'thimble-term', key: 'verdicts' } as const
79const THREAD = { plugin: 'thimble-term', key: 'thread' } as const
80const THREAD_ROWS = { plugin: 'thimble-term', key: 'threadRows' } as const
81const ANSWERS = { plugin: 'thimble-term', key: 'answers' } as const
82const VIEW_ROWS = { plugin: 'thimble-term', key: 'viewRows' } as const
83const SURFACE = { plugin: 'thimble-term', key: 'surface' } as const
84const panelA = { plugin: 'thimble-term', key: 'panel' } as const
85const navRef = { plugin: 'thimble-term', key: 'nav' } as const
86const pendingRef = { plugin: 'thimble-term', key: 'pending' } as const
87const homeRef = { plugin: 'thimble-term', key: 'home' } as const
88const homeSeenRef = { plugin: 'thimble-term', key: 'homeSeen' } as const
89const agentsRef = { plugin: 'thimble-term', key: 'agents' } as const
90const threadsRef = { plugin: 'thimble-term', key: 'threads' } as const
91const newsRef = { plugin: 'thimble-term', key: 'threadNews' } as const
92const homeUiRef = { plugin: 'thimble-term', key: 'homeUi' } as const
93const labelUiRef = { plugin: 'thimble-term', key: 'labelUi' } as const
94const filesUiRef = { plugin: 'thimble-term', key: 'filesUi' } as const
95const panelTickRef = { plugin: 'thimble-term', key: 'panelTick' } as const
96const panelTickA = atom(panelTickRef, 0)
97
98/** The context the other files take, bound to this hook's `$`. */
99function cxOf($: Dollar): Ctx {
100  // what main's chat draws under a row is kept in the workspace too, so a resumed session draws it again (kept.ts)
101  const io = { read: (path: string) => $.fs.read(path), write: (path: string, text: string) => $.fs.write(path, text) }
102  const keep = async (row: string, part: Parameters<typeof keepRow>[3]) => (rt.sc ? keepRow(io, rt.sc.ws, row, part) : undefined)
103  return {
104    now: () => $.clock.now().catch(() => Date.now()),
105    theme: async () => {
106      try {
107        const v = (await $.config.list()).find(r => r.key === 'theme')?.value
108        return /light/i.test(String(v ?? '')) ? 'light' : 'dark'
109      } catch {
110        return 'dark'
111      }
112    },
113    run: (argv, init) => $.process.run(argv, init),
114    runLong: async (argv, init) => {
115      const child = $.process.spawn({ argv, ...(init?.cwd ? { cwd: init.cwd } : {}), ...(init?.env ? { env: init.env } : {}) })
116      let stdout = ''
117      let stderr = ''
118      for await (const piece of child) {
119        if (piece.stream === 'stdout') stdout += piece.text
120        else stderr += piece.text
121      }
122      const end = await child.result
123      return { exitCode: end.code ?? 1, stdout, stderr }
124    },
125    spawnLines: (argv, init, onLine, onErr) => {
126      const child = $.process.spawn({ argv, ...(init.cwd ? { cwd: init.cwd } : {}), ...(init.env ? { env: init.env } : {}) })
127      const done = (async () => {
128        let buf = ''
129        try {
130          for await (const piece of child) {
131            if (piece.stream !== 'stdout') {
132              onErr?.(piece.text.slice(-500))
133              continue
134            }
135            buf += piece.text
136            let i = buf.indexOf('\n')
137            while (i >= 0) {
138              onLine(buf.slice(0, i))
139              buf = buf.slice(i + 1)
140              i = buf.indexOf('\n')
141            }
142          }
143        } catch (err) {
144          onErr?.(String(err).slice(0, 300))
145        }
146      })()
147      return { stop: () => void child.return(undefined as never).catch(() => undefined), done }
148    },
149    fetch: (url, init) => $.http.fetch(url, init),
150    read: path => $.fs.read(path),
151    write: (path, text) => $.fs.write(path, text),
152    stat: path => $.fs.stat(path),
153    list: path => $.fs.list(path),
154    // each variable by its literal name, so `claude plugin validate` lists what the module reads
155    env: async name => {
156      switch (name) {
157        case 'THIMBLE_WS':
158          return $.env.get('THIMBLE_WS')
159        case 'THIMBLE_TERM_CLI':
160          return $.env.get('THIMBLE_TERM_CLI')
161        case 'THIMBLE_HOME':
162          return $.env.get('THIMBLE_HOME')
163        case 'THIMBLE_PORT':
164          return $.env.get('THIMBLE_PORT')
165        case 'THIMBLE_UI_PORT':
166          return $.env.get('THIMBLE_UI_PORT')
167        default:
168          return undefined
169      }
170    },
171    root: () => $.session.root().catch(() => $.session.cwd()),
172    pluginRoot: $.plugin.root,
173    open: args => $.ui.open(args),
174    close: id => $.ui.close({ id }),
175    panes: () => $.ui.panes().catch(() => []),
176    later: (ms, fn) => void $.clock.after(ms, fn),
177    log: text => $.ui.log(text),
178    toast: text => $.ui.toast(text),
179    submit: async text => {
180      rt.own.add(text)
181      await $.prompt.submit({ text, asUser: true })
182    },
183    command: async (name, args) => void (await $.command.run({ command: name, args })),
184    promptText: async () => (await $.prompt.read().catch(() => ({ text: '' }))).text,
185    fill: async text => void (await $.prompt.fill({ text, mode: 'insert' }).catch(() => undefined)),
186    focus: async key => {
187      const r = await $.ui.focus({ requestId: PANEL, key }).catch(() => ({ deny: 'failed' }))
188      return !r.deny
189    },
190    scroll: async (key, block) => void (await $.ui.scroll({ to: { key }, in: PANEL, block: block ?? 'nearest' }).catch(() => undefined)),
191    els: e => $.ui.resolve(e as ResolveInput<'Pane', 'terminal'>),
192    card: async id => (await $.state.get({ ...CARDS, id })).value,
193    setCard: async (id, v) => void (await $.state.set({ ...CARDS, id }, v)),
194    turnCards: async row => (await $.state.get({ ...TURN_CARDS, id: row })).value ?? [],
195    setTurnCards: async (row, ids) => {
196      await $.state.set({ ...TURN_CARDS, id: row }, ids)
197      await keep(row, { cards: ids })
198    },
199    verdict: async id => (await $.state.get({ ...VERDICTS, id })).value,
200    setVerdict: async (id, v) => void (await $.state.set({ ...VERDICTS, id }, v)),
201    thread: async id => (await $.state.get({ ...THREAD, id })).value,
202    setThread: async (id, v) => void (await $.state.set({ ...THREAD, id }, v)),
203    threadRows: async row => (await $.state.get({ ...THREAD_ROWS, id: row })).value ?? [],
204    setThreadRows: async (row, rows) => {
205      await $.state.set({ ...THREAD_ROWS, id: row }, rows)
206      await keep(row, { threads: rows })
207    },
208    answer: async row => (await $.state.get({ ...ANSWERS, id: row })).value,
209    setAnswer: async (row, a) => {
210      await $.state.set({ ...ANSWERS, id: row }, a)
211      await keep(row, { answer: a })
212    },
213    viewRows: async row => (await $.state.get({ ...VIEW_ROWS, id: row })).value ?? [],
214    setViewRows: async (row, slugs) => {
215      await $.state.set({ ...VIEW_ROWS, id: row }, slugs)
216      await keep(row, { views: slugs })
217    },
218    surface: async key => (await $.state.get({ ...SURFACE, id: key })).value as never,
219    setSurface: async (key, v) => void (await $.state.set({ ...SURFACE, id: key }, v)),
220    panel: async () => (await $.state.get(panelA)).value ?? null,
221    setPanel: async p => void (await $.state.set(panelA, p)),
222    nav: async () => (await $.state.get(navRef)).value ?? NAV_EMPTY,
223    setNav: async n => void (await $.state.set(navRef, n)),
224    pending: async () => (await $.state.get(pendingRef)).value ?? null,
225    setPending: async p => void (await $.state.set(pendingRef, p)),
226    home: async () => (await $.state.get(homeRef)).value ?? null,
227    setHome: async h => void (await $.state.set(homeRef, h)),
228    homeSeen: async () => (await $.state.get(homeSeenRef)).value ?? null,
229    setHomeSeen: async h => void (await $.state.set(homeSeenRef, h)),
230    agents: async () => (await $.state.get(agentsRef)).value ?? [],
231    setAgents: async a => void (await $.state.set(agentsRef, a)),
232    threads: async () => (await $.state.get(threadsRef)).value ?? [],
233    setThreads: async t => void (await $.state.set(threadsRef, t)),
234    news: async () => (await $.state.get(newsRef)).value ?? { n: 0, one: '' },
235    setNews: async n => void (await $.state.set(newsRef, n)),
236    homeUi: async () => ({ ...HOME_UI_EMPTY, ...((await $.state.get(homeUiRef)).value ?? {}) }),
237    setHomeUi: async u => void (await $.state.set(homeUiRef, u)),
238    labelUi: async () => ({ ...LABEL_UI_EMPTY, ...((await $.state.get(labelUiRef)).value ?? {}) }),
239    setLabelUi: async u => void (await $.state.set(labelUiRef, u)),
240    filesUi: async () => ({ ...FILES_UI_EMPTY, ...((await $.state.get(filesUiRef)).value ?? {}) }),
241    setFilesUi: async u => void (await $.state.set(filesUiRef, u)),
242    panelTick: async () => (await $.state.get(panelTickRef)).value ?? 0,
243    bumpPanel: async () => void (await update($, panelTickA, n => (n ?? 0) + 1)),
244  }
245}
246
247/** A ui.jsonl record a tool wrote for the renderer (set_layout, open_view, set_filter, show_label): the panel shows the
248 *  surface it names. */
249const applyUi: UiApply = async (cx, kind, args) => {
250  const surfaces = Array.isArray(args.surfaces) ? args.surfaces.map(String) : typeof args.surface === 'string' ? [args.surface] : []
251  const view = String(args.view ?? args.slug ?? '')
252  if (kind === 'open_view' || (kind === 'layout' && surfaces.some(s => s.startsWith('view:')))) {
253    const slug = view || (surfaces.find(s => s.startsWith('view:')) ?? '').slice(5)
254    if (slug) await openPanel(cx, { view: 'view', title: String(args.name ?? slug), slug })
255    return
256  }
257  if (kind === 'layout') {
258    const first = surfaces.find(s => s !== 'canvas') ?? surfaces[0] ?? ''
259    if (first === 'files') await openPanel(cx, { view: 'files', title: 'Files' })
260    else if (first === 'report') await openPanel(cx, { view: 'docs', title: 'Documents' })
261    else if (first) await openHome(cx)
262    return
263  }
264  const p = await cx.panel()
265  if ((kind === 'filter' || kind === 'label') && p?.view.startsWith('file')) await openPanel(cx, p)
266}
267
268/** `/thimble <what>` in terminal mode, a keyboard way to what the chat and the panel draw: `threads`, `cite [n]` (the
269 *  n-th citation of the last reply), `card [n|id]` (the n-th card of the last turn, or a card by its id), `files
270 *  [path[:line]]`, `documents`. What it says, or null for plain `/thimble` (home). */
271async function thimbleCommand(cx: Ctx, args: string): Promise<string | null> {
272  const [what = '', ...rest] = args.trim().split(/\s+/)
273  const arg = rest.join(' ')
274  const plural = (n: number, w: string) => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`
275  switch (what.toLowerCase()) {
276    case '':
277      return null
278    case 'threads': {
279      await openPanel(cx, { view: 'threads', title: 'Threads' })
280      return `thimble: ${plural((await cx.threads()).length, 'side thread')}`
281    }
282    case 'documents':
283    case 'docs':
284    case 'reports': {
285      await readSurface(cx, 'docs', 'docs')
286      const got = await surfaceValue(cx, 'docs')
287      await openPanel(cx, { view: 'docs', title: 'Documents' })
288      return `thimble: ${plural(got?.ok ? docsOf(got.value).length : 0, 'document')}`
289    }
290    case 'cite': {
291      const cs = citations(rt.lastReply)
292      const n = Number(arg)
293      if (!cs.length) return 'thimble: the last reply cites nothing'
294      if (!Number.isInteger(n) || n < 1 || n > cs.length) return `thimble: the last reply has ${plural(cs.length, 'citation')}: \`/thimble cite <1-${cs.length}>\` opens one`
295      const c = cs[n - 1]!
296      await openCite(cx, c.ref, c.display)
297      return `thimble: citation ${n} of ${cs.length}`
298    }
299    case 'card': {
300      const ids = rt.lastCards
301      const n = Number(arg)
302      if (arg && !Number.isInteger(n)) {
303        const id = arg.replace(/^(?:card|cell):/, '')
304        await loadCards(cx, [id])
305        if (!(await cx.card(id))?.data) return 'thimble: no such card in this workspace'
306        await openCard(cx, id)
307        return 'thimble: the card is open'
308      }
309      if (!ids.length) return 'thimble: the last turn made no card'
310      if (!Number.isInteger(n) || n < 1 || n > ids.length) return `thimble: the last turn has ${plural(ids.length, 'card')}: \`/thimble card <1-${ids.length}>\` opens one`
311      await openCard(cx, ids[n - 1]!)
312      return `thimble: card ${n} of ${ids.length}`
313    }
314    case 'files': {
315      if (!arg) {
316        await openPanel(cx, { view: 'files', title: 'Files' })
317        return 'thimble: the file browser is open'
318      }
319      const m = /^(.*?)(?::(\d+))?$/.exec(arg)
320      const path = (m?.[1] ?? arg).replace(/^\.\//, '')
321      const line = m?.[2] ? Number(m[2]) : undefined
322      await openFile(cx, path, line ? Math.max(1, line - 5) : 1, line)
323      return `thimble: ${path}${line ? ` line ${line}` : ''} is open`
324    }
325    default:
326      return `thimble: \`/thimble\` opens home; \`/thimble threads\`, \`cite [n]\`, \`card [n]\`, \`files [path[:line]]\` and \`documents\` open those`
327  }
328}
329
330// a text block as Claude Code was handed it while it streamed -> as the model wrote it
331const asWritten = new Map<string, string>()
332
333/** The part of a turn that is its answer: the last that cites or embeds a card, else the last with text. Earlier parts
334 *  are what main wrote while it worked ("Reading the files…"). */
335function answerPart(parts: { uuid: string; text: string }[][]): { uuid: string; text: string }[] | undefined {
336  const full = parts.filter(p => p.some(r => r.text.trim()))
337  return [...full].reverse().find(p => needsDrawing(p.map(r => r.text).join('\n\n'))) ?? full.at(-1)
338}
339
340function hasText(content: unknown): boolean {
341  return Array.isArray(content) && content.some(b => b && typeof b === 'object' && (b as { type?: unknown }).type === 'text' && String((b as { text?: unknown }).text ?? '').trim() !== '')
342}
343
344/** The rows a side thread's answer leaves under a row of main's chat (signal.ts, SPEC.md "Main's chat"), one
345 *  blank row above the first: `↳` at column 0 and its words at 2, dim (`thread · "<the turn's question>" · answered`,
346 *  `failed` in red), `new` in green until it is read; a press on the question opens the thread. Each turn once. */
347async function signalRows(cx: Ctx, e: ResolveInput & { requestId: string; viewport?: { columns: number } }): Promise<RenderElement | null> {
348  const rows = await cx.threadRows(e.requestId)
349  if (!rows.length) return null
350  const { Box, Text, Button } = cx.els(e)
351  const threads = await cx.threads()
352  const seen = new Set<string>()
353  const out: RenderElement[] = []
354  const n = Math.max(16, Math.min(60, (e.viewport?.columns ?? 100) - 30))
355  for (const s of rows) {
356    const k = `${s.thread}:${s.turn}`
357    if (seen.has(k)) continue
358    seen.add(k)
359    const row = threads.find(x => x.id === s.thread)
360    const tt = await cx.thread(s.thread)
361    const th = tt?.events.length ? threadOf(tt.meta, tt.events) : null
362    const label = row?.anchorText || (row?.anchor ? await anchorName(cx, row.anchor) : '') || row?.title || 'the side thread'
363    const q = th ? signalQuestion({ turns: th.turns, label }, s.turn, n) : quoted(clip(row?.question || row?.title || label, n))
364    const end = th ? signalEnd(th, s.turn) ?? 'answered' : 'answered'
365    // `new` while the thread holds an answer the analyst has not opened (thimble's `unread`), on its latest answer's row
366    const latest = th ? th.turns.map((x, i) => (x.state === 'done' ? i + 1 : 0)).reduce((a, b) => Math.max(a, b), 0) : s.turn
367    const fresh = Boolean(row?.unread) && s.turn >= latest
368    out.push(
369      <Box key={`signal-${k}`} flexDirection="row" {...(out.length ? {} : { marginTop: 1 })}>
370        <Text dimColor>{'↳ '}</Text>
371        <Text dimColor>{'thread · '}</Text>
372        <Button key={`signal-open-${s.thread}`} label={q} plain dimColor onPress={() => openThread(cx, s.thread)} />
373        {end === 'failed' ? <Text color={COLORS.problem}>{' · failed'}</Text> : <Text dimColor>{' · answered'}</Text>}
374        {fresh && end !== 'failed' ? <Text color={COLORS.fresh}>{' · new'}</Text> : null}
375      </Box>,
376    )
377  }
378  return <Box flexDirection="column">{out}</Box>
379}
380
381/** What a thread's anchor names, in words: a card by its question, a citation's place in words. */
382async function anchorName(cx: Ctx, anchor: string): Promise<string> {
383  const id = /^(?:card|cell):([A-Za-z0-9_-]+)/.exec(anchor)?.[1]
384  if (id) {
385    const q = ((await cx.card(id))?.data as CardData | null | undefined)?.question
386    return q ? cardWords(q) : 'a card'
387  }
388  return anchor
389}
390
391/** An Agent call's stored result with a thread's fork named by the thread's first question where its `prompt` or
392 *  `description` names the fork's slug (`thread:<slug>`, what ctrl+o shows under `Prompt:`); the result itself when it
393 *  names none. */
394function forkOutput(output: unknown, threads: Parameters<typeof namedForks>[1]): unknown {
395  if (!output || typeof output !== 'object' || Array.isArray(output)) return output
396  const o = output as Record<string, unknown>
397  const named = (v: unknown) => (typeof v === 'string' && /\bthread:/.test(v) ? namedForks(v, threads) : v)
398  const prompt = named(o.prompt)
399  const description = named(o.description)
400  return prompt === o.prompt && description === o.description ? output : { ...o, prompt, description }
401}
402
403/** A Bash command that runs `thimble-run`, as its row shows it (model.ts runShown), the cards it names by their
404 *  questions as read. */
405async function runWords(cx: Ctx, command: string): Promise<string> {
406  const questions = new Map<string, string>()
407  for (const id of runIds(command)) {
408    const q = ((await cx.card(id))?.data as CardData | null | undefined)?.question
409    if (q) questions.set(id, q)
410  }
411  return runShown(command, id => questions.get(id))
412}
413
414/** The `↳ view` rows under a row of main's chat (SPEC.md, "Main's chat"): a view main proposed, `↳` dim at 0,
415 *  `view · <name> · building|built|proposed` dim, `failed` in red, then `new` in green once built and not yet opened;
416 *  a press on its name opens its line in the panel. */
417async function viewRowsEl(cx: Ctx, e: ResolveInput & { requestId: string }): Promise<RenderElement | null> {
418  const slugs = await cx.viewRows(e.requestId)
419  if (!slugs.length) return null
420  const { Box, Text, Button } = cx.els(e)
421  const views = await homeViews(cx)
422  const out: RenderElement[] = []
423  for (const slug of [...new Set(slugs)]) {
424    const v = views.find(x => x.slug === slug)
425    const state = v?.state ?? 'proposed'
426    out.push(
427      <Box key={`view-row-${slug}`} flexDirection="row" {...(out.length ? {} : { marginTop: 1 })}>
428        <Text dimColor>{'↳ view · '}</Text>
429        <Button key={`view-open-${slug}`} label={v?.name ?? slug} plain dimColor onPress={() => openView(cx, slug, v?.name ?? slug)} />
430        {state === 'failed' ? <Text color={COLORS.problem}>{' · failed'}</Text> : <Text dimColor>{` · ${state === 'built' ? 'built' : state === 'building' ? 'building' : 'proposed'}`}</Text>}
431        {v?.fresh ? <Text color={COLORS.fresh}>{' · new'}</Text> : null}
432      </Box>,
433    )
434  }
435  return <Box flexDirection="column">{out}</Box>
436}
437
438/** The footer under a turn's answer (SPEC.md, "Main's chat"), one blank row under it at column 2: `N citations ·
439 *  N cards` dim, ` · N problems` in red (counted from the checks as they stand now), then `ask about this answer ›`. The
440 *  facts are cut first; the problems stay whole. */
441async function footerEl(cx: Ctx, e: ResolveInput & { requestId: string }): Promise<RenderElement | null> {
442  const ans = await cx.answer(e.requestId)
443  if (!ans) return null
444  const { Box, Text, Button } = cx.els(e)
445  // a card cited whole (`[[card:<id>]]`) is no cited value, and a label's link (`[33](concept:<id>/yes)`) names no place
446  // a check reads: the footer counts the values cited, and only their problems (live check term-fix6, new quirk 1)
447  const cls = claimsIn(ans.text, e.requestId).filter(cl => !bareCard(cl.c) && !labelRef(cl.c.ref))
448  let red = 0
449  for (const cl of cls) {
450    const v = await cx.verdict(cid(cl.c.raw))
451    const st = chipState(v?.status, undefined)
452    if (st === 'problem' || st === 'failed') red++
453  }
454  const plural = (n: number, w: string) => `${n.toLocaleString('en-US')} ${w}${n === 1 ? '' : 's'}`
455  const facts = [...(cls.length ? [plural(cls.length, 'citation')] : []), ...(ans.cards.length ? [plural(ans.cards.length, 'card')] : [])].join(' · ')
456  return (
457    <Box key={`footer-${e.requestId}`} marginTop={1} marginLeft={MARGIN} flexDirection="row" columnGap={2}>
458      <Box flexShrink={1} flexDirection="row">
459        <Box flexShrink={1}>
460          <Text dimColor wrap="truncate-end">{facts}</Text>
461        </Box>
462        {red ? (
463          <Box flexShrink={0}>
464            <Text color={COLORS.problem}>{` · ${plural(red, 'problem')}`}</Text>
465          </Box>
466        ) : null}
467      </Box>
468      <Box flexShrink={0}>
469        <Button key={`ask-answer-${e.requestId}`} label="ask about this answer ›" plain onPress={() => openAsk(cx, { kind: 'sentence', text: withoutNotes(ans.text).slice(0, 6000), label: 'this answer' }, { element: ANSWER_ELEMENT })} />
470      </Box>
471    </Box>
472  )
473}
474
475/** A row of main's chat without its `↳ The writer …` line when an earlier row said that writer run's end (the run: the
476 *  writer whose chat began last when the row was first drawn, kept with the row so a resumed session decides the same). */
477async function withoutSaidWriter(cx: Ctx, row: string, text: string): Promise<string> {
478  if (!row || !saysWriter(text)) return text
479  let chat = rt.writerOf.get(row)
480  if (!chat) {
481    // the writer that began last: its chat's start, the newest
482    const latest = (await cx.agents()).filter(a => a.role === 'writer' && a.chat).sort((a, b) => a.started.localeCompare(b.started)).at(-1)
483    if (!latest) return text
484    chat = latest.chat
485    rt.writerOf.set(row, chat)
486    if (!rt.writerSaid.has(chat)) rt.writerSaid.set(chat, row)
487    if (rt.sc) await keepRow(cx, rt.sc.ws, row, { writer: { chat, first: rt.writerSaid.get(chat) === row } })
488  }
489  return rt.writerSaid.get(chat) === row || !rt.writerSaid.has(chat) ? text : withoutWriterLines(text)
490}
491
492/** A row of main's chat with what thimble-term draws under it: the turn's cards (when no reply row carries them) and
493 *  the side threads' rows. */
494async function underRow(cx: Ctx, e: ResolveInput & { requestId: string; viewport?: { columns: number } }, next: () => Promise<RenderElement>): Promise<RenderElement> {
495  const ids = await cx.turnCards(e.requestId)
496  const told = await signalRows(cx, e)
497  const views = await viewRowsEl(cx, e)
498  if (!ids.length && !told && !views) return next()
499  const { Box } = cx.els(e)
500  const cards = await drawCards(cx, e, ids, (e.viewport?.columns ?? 100) - 2, t => openAsk(cx, t), id => openThread(cx, id))
501  return (
502    <Box flexDirection="column">
503      {await next()}
504      {cards}
505      {told}
506      {views}
507    </Box>
508  )
509}
510
511/** What main's chat drew under its rows in earlier processes of this conversation (kept.ts), put in the state where
512 *  it holds none (a file read, awaited at the session's start); then, beside the session, the cards those rows draw,
513 *  read in one call, and the threads their `↳ thread` rows name. */
514async function restoreKept($: Dollar, cx: Ctx): Promise<void> {
515  if (!rt.sc) return
516  resetKept()
517  const k = await loadKept(cx, rt.sc.ws)
518  const ids = new Set<string>()
519  for (const [row, r] of Object.entries(k.rows)) {
520    if (r.cards?.length && !(await $.state.get({ ...TURN_CARDS, id: row })).value?.length) await $.state.set({ ...TURN_CARDS, id: row }, r.cards)
521    if (r.answer && !(await $.state.get({ ...ANSWERS, id: row })).value) await $.state.set({ ...ANSWERS, id: row }, r.answer)
522    if (r.threads?.length && !(await $.state.get({ ...THREAD_ROWS, id: row })).value?.length) await $.state.set({ ...THREAD_ROWS, id: row }, r.threads)
523    if (r.views?.length && !(await $.state.get({ ...VIEW_ROWS, id: row })).value?.length) await $.state.set({ ...VIEW_ROWS, id: row }, r.views)
524    for (const id of [...(r.cards ?? []), ...(r.answer?.cards ?? []), ...embeddedCards(r.answer?.text ?? '')]) ids.add(id)
525    for (const v of r.views ?? []) rt.viewsTold.add(v)
526    for (const t of r.threads ?? []) rt.told.add(t.thread)
527    if (r.writer) {
528      if (!rt.writerOf.has(row)) rt.writerOf.set(row, r.writer.chat)
529      if (r.writer.first && !rt.writerSaid.has(r.writer.chat)) rt.writerSaid.set(r.writer.chat, row)
530    }
531  }
532  if (!rt.lastReply && k.last.reply) rt.lastReply = k.last.reply
533  if (!rt.lastCards.length && k.last.cards.length) rt.lastCards = k.last.cards
534  const threads = new Set(Object.values(k.rows).flatMap(r => (r.threads ?? []).map(t => t.thread)))
535  void (async () => {
536    await loadCardsBatch(cx, [...ids])
537    // each `↳ thread` row names its turn's question, from the thread's chat
538    for (const id of threads) await readThread(cx, id)
539  })().catch(err => $.ui.log(`thimble-term: the cards main's chat drew before could not be read: ${String(err).slice(0, 200)}`))
540}
541
542export const register: Register = on => {
543  // a click on an empty part of a list hands the keys back to the pane (term.ts takeKeys)
544  onListClick(takeKeys)
545  on('session.start', async ($, e, next) => {
546    const started = await next(e)
547    const cx = cxOf($)
548    rt.sc = await scopeOf(cx)
549    if (!rt.sc) return started
550    rt.sig = null
551    rt.uiN = -1
552    // a pane that waited undrawn before the module loaded again (its state is the session's)
553    rt.waiting = (await cx.pending()) !== null
554    // when this conversation began: its first launch, so a thread asked before a `--continue` is not an earlier one's
555    rt.startedAt = await $.session
556      .usage()
557      .then(u => u.startedAt)
558      .catch(() => $.clock.now())
559      .catch(() => Date.now())
560    // what main's chat drew under its rows before a resume, and the cards it drew
561    await restoreKept($, cx).catch(err => $.ui.log(`thimble-term: what main's chat drew before could not be read: ${String(err).slice(0, 200)}`))
562    $.clock.every(1000, () => void tick(cx, applyUi))
563    $.clock.every(250, () => void checkQueued(cx))
564    // the open view matched to what the panel draws, and thimble's view host started the first time a view opens, from
565    // a timer of the session's, which lives as long as it (viewhost.ts)
566    $.clock.every(PUMP_MS, () => void viewPump(cx))
567    void tick(cx, applyUi)
568    // what drew before the scope was known (the band above the prompt) draws again, now reading thimble-term's state;
569    // the typeahead lists /thimble again with its terminal description
570    $.ui.invalidate('ui.render')
571    $.ui.invalidate('command.describe')
572    return started
573  })
574
575  // /thimble in terminal mode: the home panel, with no model turn
576  on('command.run', async ($, e, next) => {
577    if (!rt.sc) return next(e)
578    const cx = cxOf($)
579    if (e.presentation?.columns > 0) rt.termColumns = e.presentation.columns
580    await navOrigin(cx, false)
581    if (e.command === 'thimble:thimble' || e.command === 'thimble') {
582      const said = await thimbleCommand(cx, String(e.args ?? ''))
583      if (said !== null) return { text: said }
584      await openHome(cx)
585      return { text: HOME_LINE }
586    }
587    return next(e)
588  })
589  // /thimble as the typeahead and /help describe it in terminal mode: what it does here, never the browser's server and
590  // URL (live check term-fix7, quirk 8: the skill's description, written for both modes, named those first)
591  on('command.describe', async ($, e, next) => {
592    if (!rt.sc || (e.command !== 'thimble:thimble' && e.command !== 'thimble')) return next(e)
593    return next({ ...e, description: THIMBLE_DESCRIPTION, argumentHint: THIMBLE_ARGS })
594  })
595  // where Claude Code routes the skill past command.run, its prompt opens the panel and main says the skill's line
596  on('skill.prompt', async ($, e, next) => {
597    if (rt.sc && (e.skill === 'thimble:thimble' || e.skill === 'thimble')) await openHome(cxOf($))
598    return next(e)
599  })
600
601  // a citation typed or pasted into the prompt is painted there as the reply's are, blue and underlined
602  on('prompt.edit', async ($, e, next) => {
603    const r = await next(e)
604    if (!rt.sc) return r
605    const d = citeSpans(r.text).map(sp => ({ start: sp.at, end: sp.end, underline: true, color: COLORS.link }))
606    return d.length ? { ...r, decorations: [...(r.decorations ?? []), ...d] } : r
607  })
608
609  // ---------------------------------------------------------------------------------------------- main's turn
610
611  on('turn.start', async ($, e, next) => {
612    if (rt.sc) {
613      const own = rt.own.has(e.text)
614      rt.own.delete(e.text)
615      rt.turn = { id: e.turnId, at: new Date(await $.clock.now()).toISOString(), cards: [], row: '', parts: [[]], views: [], own }
616    }
617    return next(e)
618  })
619
620  // main's fork of a side thread runs with the thread's question as its description, which Claude Code's agent tray and
621  // its exit dialog show beside the fork's name (never `thread:<slug>`); the prompt keeps `thread:<name>`, by which
622  // thimble knows the fork (backend threads.fork_ref)
623  on('tool.call', async ($, e, next) => {
624    let call = e
625    try {
626      if (rt.sc && e.agentId === undefined && e.tool === 'Agent') {
627        const input = e as unknown as Record<string, unknown>
628        const rows = await cxOf($).threads()
629        const description = forkDescription(input, rows) ?? (input.subagent_type === 'fork' ? forkDescription(input, await threadsNow(cxOf($))) : null)
630        if (description) call = { ...e, description } as typeof e
631      }
632    } catch {
633      // the call runs as main wrote it
634    }
635    const ran = await next(call)
636    try {
637      if (!rt.sc || e.agentId !== undefined || ran.deny !== undefined) return ran
638      const ids = cardsOfCall(String(e.tool), e, String(ran.text ?? ''))
639      if (ids.length) {
640        if (rt.turn) for (const id of ids) if (!rt.turn.cards.includes(id)) rt.turn.cards.push(id)
641        void loadCards(cxOf($), ids)
642      }
643      // a view main proposed: its `↳ view` row under the turn's answer
644      if (VIEW_TOOL.test(String(e.tool)) && rt.turn) {
645        const name = String((e as { name?: unknown }).name ?? '')
646        const slug = /\bview:([a-z0-9][a-z0-9-]*)/.exec(String(ran.text ?? ''))?.[1] ?? name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
647        if (slug && !rt.turn.views.includes(slug)) rt.turn.views.push(slug)
648      }
649    } catch {
650      // the call's answer stands whatever thimble-term makes of it
651    }
652    return ran
653  })
654
655  // main's reply as it streams: each citation handed to Claude Code as a Markdown link (never its raw spelling) and a
656  // card's line as a placeholder naming the card; a citation, code span or card line not yet closed waits. The row is
657  // stored as the model wrote it (session.append), and drawn by AssistantMessage once it is whole.
658  on('turn.step', async function* ($, e, next) {
659    if (!rt.sc || e.agentId !== undefined) return yield* next(e)
660    const cx = cxOf($)
661    const blocks = new Map<number, Streaming>()
662    const urls = new Map<string, string>()
663    const questions = new Map<string, string>()
664    const look: StreamLook = {
665      link: c => streamLink(c, urls.get(c.ref) ?? ''),
666      card: id => `◌ ${(questions.get(id) || 'drawing the card').replace(/[\\[\]*_`<>]/g, m => `\\${m}`)}`,
667    }
668    const end = (i: number, st: Streaming) => {
669      const out = streamStep(st, '', true, look)
670      if (st.shown !== st.raw) {
671        asWritten.set(st.shown, st.raw)
672        if (asWritten.size > 50) asWritten.delete(asWritten.keys().next().value!)
673      }
674      return out ? [{ kind: 'text' as const, index: i, text: out }] : []
675    }
676    for await (const ch of next(e)) {
677      if (ch.kind === 'text') {
678        const st = blocks.get(ch.index) ?? streaming()
679        blocks.set(ch.index, st)
680        const ahead = `${st.raw.slice(st.done)}${ch.text}`
681        try {
682          for (const c of citations(ahead)) if (!urls.has(c.ref)) urls.set(c.ref, await placeUrl(cx, c.ref))
683          for (const m of ahead.matchAll(/\[\[card:([A-Za-z0-9_-]+)\]\]/g)) if (!questions.has(m[1]!)) questions.set(m[1]!, ((await cx.card(m[1]!))?.data as CardData | null | undefined)?.question ?? '')
684        } catch {
685          // a link without its file still hides the raw spelling
686        }
687        const out = streamStep(st, ch.text, false, look)
688        if (out === ch.text) yield ch
689        else if (out) yield { ...ch, text: out }
690        continue
691      }
692      // a block ends before anything else of the response passes: what it held back is handed over
693      for (const [i, st] of blocks) yield* end(i, st)
694      blocks.clear()
695      yield ch
696    }
697    for (const [i, st] of blocks) yield* end(i, st)
698  })
699
700  // the rows of main's chat a drawing stands under, by the uuid they are stored under (their `requestId`): the
701  // latest row a line can stand under, and the turn's latest text row; each text row of the turn in its part (a tool
702  // call starts the next part), for the turn's answer. A block of main's reply is stored as the model wrote it, not as
703  // it showed while it streamed.
704  on('session.append', async ($, e, next) => {
705    let msg = e.message
706    try {
707      if (rt.sc && e.agentId === undefined) {
708        if (e.door === 'response' && Array.isArray(msg.content)) {
709          const shown = msg.content as { type?: string; text?: string }[]
710          const blocks = shown.map(b => (b.type === 'text' && typeof b.text === 'string' && asWritten.has(b.text) ? { ...b, text: asWritten.get(b.text)! } : b))
711          if (blocks.some((b, i) => b !== shown[i])) msg = { ...msg, content: blocks as typeof msg.content }
712          const texts = blocks.flatMap(b => (b.type === 'text' && typeof b.text === 'string' && b.text.trim() ? [b.text] : []))
713          if (rt.turn) {
714            if (texts.length) rt.turn.parts.at(-1)!.push({ uuid: e.uuid, text: texts.join('\n\n') })
715            if (blocks.some(b => b.type === 'tool_use')) rt.turn.parts.push([])
716          }
717          // the cards a reply embeds, read so they draw
718          const embeds = texts.flatMap(t => embeddedCards(t))
719          if (embeds.length) void loadCards(cxOf($), embeds)
720        }
721        if (isAnchor({ ...e, message: msg } as unknown as AppendedRow)) rt.anchor = e.uuid
722        if (e.door === 'response' && rt.turn && hasText(msg.content)) rt.turn.row = e.uuid
723      }
724    } catch {
725      // the row is stored whatever thimble-term makes of it
726    }
727    return next(msg === e.message ? e : { ...e, message: msg })
728  }).catch(($, e, next) => next(e))
729
730  on('turn.complete', async ($, e, next) => {
731    const done = await next(e)
732    if (rt.sc && e.agentId === undefined && rt.turn) {
733      const t = rt.turn
734      rt.turn = null
735      const row = t.row || rt.anchor
736      const cx = cxOf($)
737      if (t.cards.length && row) {
738        const cur = await cx.turnCards(row)
739        await cx.setTurnCards(row, [...cur, ...t.cards.filter(id => !cur.includes(id))])
740      }
741      // what `/thimble cite` and `/thimble card` open: the turn's citations and cards
742      const all = t.parts.flat().map(r => r.text).join('\n\n')
743      if (all.trim()) rt.lastReply = all
744      if (all.trim() || t.cards.length) rt.lastCards = [...new Set([...embeddedCards(all), ...t.cards])]
745      if ((all.trim() || t.cards.length) && rt.sc) await keepLast(cx, rt.sc.ws, rt.lastReply, rt.lastCards)
746      // the answer: its last part that cites or embeds a card, else its last; the footer stands under its last row
747      const part = answerPart(t.parts)
748      const last = part?.at(-1)?.uuid ?? ''
749      if (part && last && !t.own) {
750        const text = part.map(r => r.text).join('\n\n')
751        const shows = [...new Set([...embeddedCards(text), ...(last === row ? t.cards : [])])]
752        if (citations(text).length || shows.length) await cx.setAnswer(last, { rows: part.map(r => r.uuid), text, cards: shows })
753      }
754      // the views this turn proposed: their rows under the answer
755      const vrow = last || row
756      if (t.views.length && vrow) {
757        const cur = await cx.viewRows(vrow)
758        await cx.setViewRows(vrow, [...cur, ...t.views.filter(v => !cur.includes(v))])
759        for (const v of t.views) rt.viewsTold.add(v)
760      }
761    }
762    return done
763  })
764
765  // ---------------------------------------------------------------------------------------------- main's chat
766
767  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
768    if (!rt.sc) return next(e)
769    const cx = cxOf($)
770    const ids = await cx.turnCards(e.requestId)
771    const told = await signalRows(cx, e)
772    const views = await viewRowsEl(cx, e)
773    const footer = await footerEl(cx, e)
774    // without main's end token, and a `↳ thread` line naming its thread by its first question, not its fork's slug; no
775    // such line for a thread whose `↳ thread` row thimble-term drew, which says the same
776    const rows = await cx.threads()
777    // Claude Code's own stand-in for a reply that never came (its `<synthetic>` text, as after a quit stopped a thread),
778    // which main never wrote: not drawn (live check term-fix9, quirk 13)
779    const said = SYNTHETIC_RE.test(e.props.text) ? '' : e.props.text
780    const text = await withoutSaidWriter(cx, e.requestId, namedThreads(withoutToldThreads(withoutEnd(said), rows, rt.told), rows))
781    const live = e.surface === 'terminal' || e.surface === 'desktop'
782    if (!ids.length && !told && !views && !footer && text === e.props.text && !live && !needsDrawing(text)) return next(e)
783    const { Box } = $.ui.resolve(e)
784    // the reply fills the terminal's width, less 2, its cards as wide as its prose
785    const cols = (e.viewport?.columns ?? 100) - 2
786    // a sentence that cites a card whole keeps its chip (`[ card ]`) though the card is drawn under the reply
787    const body = text.trim() ? await drawReply(cx, e, text, cols - MARGIN, { first: Boolean(e.props.isFirstOfReply), skipCards: new Set(ids), ask: t => openAsk(cx, t), open: id => openThread(cx, id) }) : []
788    const cards = await drawCards(cx, e, ids, cols, t => openAsk(cx, t), id => openThread(cx, id))
789    // a block that held only a line thimble-term hides: nothing (ctrl+o's view still draws the reply's time and model
790    // above it, which no hook reaches; the engine's own block with no text draws the same)
791    if (!body.length && !cards && !told && !views && !footer) return <Box />
792    return (
793      <Box flexDirection="column">
794        {body}
795        {cards}
796        {footer}
797        {told}
798        {views}
799      </Box>
800    )
801  })
802
803  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'composer' } } }, async ($, e, next) => (rt.sc ? underRow(cxOf($), e, () => next(e)) : next(e)))
804  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => (rt.sc ? underRow(cxOf($), e, () => next(e)) : next(e)))
805  // /thimble's line as thimble says it, not under the plugin's name, which Claude Code puts before a hook's answer
806  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
807    if (!rt.sc) return next(e)
808    // `/thimble …` answers as thimble, not under the plugin's name, which Claude Code puts before a hook's answer
809    const own = (e.props.command === 'thimble:thimble' || e.props.command === 'thimble') && /^\s*(?:thimble-term:\s*)?thimble:/.test(e.props.text)
810    const shown = own ? { ...e, props: { ...e.props, text: e.props.text.replace(/^\s*thimble-term:\s*/, '') } } : e
811    return underRow(cxOf($), e, () => next(shown))
812  })
813
814  // a thimble tool's row, and a card's run in Bash, name the card by its question, never by its id, and a citation by
815  // its words (in the row and in ctrl+o's detailed view); a side thread's fork is named by the thread's first question,
816  // never its slug
817  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
818    if (!rt.sc) return next(e)
819    const cx = cxOf($)
820    const tool = String(e.props.tool)
821    const input = e.props.input as Record<string, unknown> | undefined
822    if (!input || typeof input !== 'object') return next(e)
823    if (THIMBLE_TOOL.test(tool)) {
824      let changed = false
825      const out: Record<string, unknown> = {}
826      for (const [k, v] of Object.entries(input)) {
827        const s = typeof v === 'string' ? await toolWords(cx, k, v) : v
828        if (s !== v) changed = true
829        out[k] = s
830      }
831      return changed ? next({ ...e, props: { ...e.props, input: out } }) : next(e)
832    }
833    if (tool === 'Bash' && typeof input.command === 'string' && /thimble-run\b/.test(input.command)) {
834      return next({ ...e, props: { ...e.props, input: { ...input, command: await runWords(cx, input.command) } } })
835    }
836    if (tool === 'Agent' || tool === 'Task') {
837      // a fork's description and its prompt (ctrl+o's `Prompt:`) name the thread by its first question, never its slug
838      const threads = await cx.threads()
839      const named = (v: unknown) => (typeof v === 'string' && /\bthread:/.test(v) ? namedForks(v, threads) : v)
840      const description = named(input.description)
841      const prompt = named(input.prompt)
842      const output = forkOutput(e.props.output, threads)
843      if (description !== input.description || prompt !== input.prompt || output !== e.props.output) return next({ ...e, props: { ...e.props, input: { ...input, description, prompt }, ...(output !== undefined ? { output } : {}) } })
844    }
845    return next(e)
846  })
847
848  // the row saying a side thread's fork finished names the thread by its first question, never the fork's slug
849  on('ui.render', { component: 'UserMessage', props: { origin: { kind: 'task-notification' } } }, async ($, e, next) => {
850    if (!rt.sc || !/\bthread:/.test(e.props.text)) return next(e)
851    const text = namedForks(e.props.text, await cxOf($).threads())
852    return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } })
853  })
854
855  // a card tool's result row: the card by its question, since the card itself is drawn under the turn's last reply; a
856  // label's, its name and each value's count. An error stays Claude Code's row.
857  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
858    if (!rt.sc || e.props.isErrored) return next(e)
859    // a fork's result (`Backgrounded agent`, and in ctrl+o its `Prompt:`) names the thread by its first question
860    if (e.props.tool === 'Agent' || e.props.tool === 'Task') {
861      const output = forkOutput(e.props.output, await cxOf($).threads())
862      return output === e.props.output ? next(e) : next({ ...e, props: { ...e.props, output } })
863    }
864    if (!CARD_TOOL.test(String(e.props.tool))) return next(e)
865    const ids = cardsOfCall(String(e.props.tool), {}, resultText(e.props.output))
866    const tc = ids[0] ? await cxOf($).card(ids[0]) : undefined
867    const data = tc?.data as CardData | null | undefined
868    const q = data?.question
869    if (!q) return next(e)
870    const { Text } = $.ui.resolve(e)
871    // the row cut at a word, as every cut, to the terminal's width
872    const room = Math.max(20, (e.viewport?.columns ?? 100) - 2)
873    if (data.kind === 'label' && data.label) {
874      const counts = ((data.rows ?? []) as { label: string; value: number }[]).map(r => `${r.label} ${r.value.toLocaleString('en-US')}`).join(' · ')
875      return <Text dimColor wrap="truncate-end">{cut(`  ⎿  label ${quoted(data.label.name)}${counts ? ` · ${counts}` : ''}${tc?.busy ? ` · ${tc.busy}` : ''}`, room)}</Text>
876    }
877    return <Text dimColor wrap="truncate-end">{cut(`  ⎿  card ${quoted(q)}${tc?.busy ? ` · ${tc.busy}` : ''}`, room)}</Text>
878  })
879
880  // ---------------------------------------------------------------------------------------------- above the prompt
881
882  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
883    if (!rt.sc || e.props.hasSurvey || e.props.view.agentId) return next(e)
884    const cx = cxOf($)
885    const panes = await cx.panes()
886    if (e.surface === 'terminal' && e.viewport?.columns && !panes.some(p => p.isPlaced)) rt.termColumns = e.viewport.columns
887    const { Box, Text, Button } = $.ui.resolve(e)
888    const rows: RenderElement[] = []
889    const label = (s: string) => (
890      <Box width={ABOVE_LABEL} flexShrink={0}>
891        <Text dimColor>{`  ${s}`}</Text>
892      </Box>
893    )
894    // a panel Claude Code left undrawn (an open it was not asked for, on a terminal narrower than it places those at),
895    // and why; `open panel` opens it as `/thimble` does, from the press, which Claude Code places at any width. Gone
896    // once the pane is drawn (Claude Code places it when the terminal is widened to that width)
897    const pending = await cx.pending()
898    const placed = panes.some(p => p.id === PANEL && p.isPlaced)
899    if (pending && placed) $.clock.after(0, () => void placedLater(cx))
900    if (pending && !placed) {
901      const columns = (e.surface === 'terminal' ? e.viewport?.columns : 0) || pending.columns || 0
902      const more = pending.floor && columns ? pending.floor - columns : 0
903      if (pending.floor && columns && more <= 0) $.clock.after(0, () => void retryPending(cx, columns))
904      rows.push(
905        <Box key="above-panel" flexDirection="row">
906          {label('panel')}
907          <Box flexDirection="row" columnGap={2} flexShrink={1}>
908            <Box flexShrink={1}>
909              <Text wrap="truncate-end">{`${pending.title} is ready`}</Text>
910            </Box>
911            <Box flexDirection="row" columnGap={2} flexShrink={0}>
912              <Button key="above-panel-open" label="open panel" plain onPress={() => openPending(cx)} />
913              <Button key="above-panel-dismiss" label="dismiss" plain onPress={() => closePanel(cx)} />
914            </Box>
915          </Box>
916        </Box>,
917      )
918    }
919    // what is new in the workspace since home was last opened, like a toast: open › opens home on the first new item
920    // and the row goes; none while home shows (live check term-fix8, quirk 6: it stayed beside home, which showed them)
921    const home = await cx.home()
922    const homeShown = (await cx.panel())?.view === 'home' && panes.some(p => p.id === PANEL && p.isPlaced)
923    if (home && !homeShown) {
924      // the first count of a session (refreshHome keeps it) is what was there already, so nothing is new yet
925      const seen = (await cx.homeSeen()) ?? home
926      const fresh = (k: 'cards' | 'labels' | 'docs' | 'views') => Math.max(0, home[k] - (seen as typeof home)[k])
927      const plural = (n: number, w: string) => `${n.toLocaleString('en-US')} new ${w}${n === 1 ? '' : 's'}`
928      const words = [fresh('cards') ? plural(fresh('cards'), 'card') : '', fresh('labels') ? plural(fresh('labels'), 'label') : '', fresh('docs') ? plural(fresh('docs'), 'document') : '', fresh('views') ? plural(fresh('views'), 'view') : ''].filter(Boolean).join(' · ')
929      if (words) {
930        rows.push(
931          <Box key="above-home" flexDirection="row">
932            {label('thimble')}
933            <Box flexDirection="row" columnGap={2} flexShrink={1}>
934              {/* the word `new` in green, as wherever it shows (SPEC.md, rule 8) */}
935              <Text wrap="truncate-end">{words.split(/( new )/).map((w, i) => (w === ' new ' ? <Text key={`new-${i}`}>{' '}<Text color={COLORS.fresh}>new</Text>{' '}</Text> : w))}</Text>
936              <Button key="above-home-open" label="open ›" plain onPress={() => openHomeNew(cx)} />
937            </Box>
938          </Box>,
939        )
940      }
941    }
942    // side threads show as `↳ thread` rows under main's latest row and as `N new` on the panel's title row (SPEC.md,
943    // "Main's chat"): no row of their own here
944    if (!rows.length) return next(e)
945    return <Box flexDirection="column">{rows}</Box>
946  })
947
948  // ---------------------------------------------------------------------------------------------- the panel
949
950  // the panel's drawings run one at a time (turns.ts, two seconds at most each); one that settles after a later one
951  // began, or that Claude Code abandoned, draws once more 50 ms after, so the shown panel's buttons and fields work
952  const paneDraws = { draws: 0, settled: 0, redrawing: false }
953  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e, next) => {
954    if (!rt.sc) return next(e)
955    const pe = e as PaneEvent
956    const cx = cxOf($)
957    // a pane that waited undrawn is drawn now (Claude Code placed it once the terminal was wide enough): the row above
958    // the prompt that offered it goes
959    if (rt.waiting) {
960      rt.waiting = false
961      $.clock.after(0, () => void placedLater(cx))
962    }
963    if (pe.surface === 'terminal' && pe.viewport?.columns && (pe.props.placement === 'dock' || pe.props.placement === 'inline')) {
964      rt.termColumns = pe.props.placement === 'dock' ? pe.viewport.columns + pe.props.bodyColumns + 1 : pe.viewport.columns
965      // a resize keeps the dock at the width it was opened with: it opens again at the panel's width for the terminal
966      // now (a width the person dragged still wins)
967      if (pe.props.placement === 'dock' && rt.termColumns !== rt.fittedFor && rt.termColumns >= 110) {
968        rt.fittedFor = rt.termColumns
969        if (panelColumns() !== pe.props.bodyColumns) {
970          $.clock.after(0, () => {
971            void (async () => {
972              const placed = (await cx.panes()).some(p => p.id === PANEL && p.isPlaced)
973              const p = await cx.panel()
974              if (placed && p) await cx.open({ id: PANEL, title: paneTitle(p), columns: panelColumns() }).catch(() => undefined)
975            })()
976          })
977        }
978      }
979    }
980    return paneTurns.run(async () => {
981      const n = ++paneDraws.draws
982      await read($, panelTickA)
983      const tree = await drawPanel(cx, pe)
984      const late = paneDraws.settled > n
985      paneDraws.settled = Math.max(paneDraws.settled, n)
986      if ((late || next.signal.aborted) && !paneDraws.redrawing) {
987        paneDraws.redrawing = true
988        $.clock.after(50, () => {
989          paneDraws.redrawing = false
990          // an aborted drawing draws again only when no drawing began since; drawing again regardless would abort a
991          // drawing slower than 50 ms each time, and the panel would never show
992          if (late || paneDraws.draws === n) void update($, panelTickA, k => (k ?? 0) + 1)
993        })
994      }
995      return tree
996    }, fire => $.clock.after(2000, fire))
997  })
998
999  on('ui.close', async ($, e, next) => {
1000    const closed = await next(e)
1001    if (e.id === PANEL) {
1002      rt.panelFocus = ''
1003      rt.waiting = false
1004      await $.state.set(pendingRef, null).catch(() => undefined)
1005      await $.state.set(panelA, null).catch(() => undefined)
1006      // a terminal view's program ends with its pane
1007      closeView()
1008    }
1009    return closed
1010  })
1011
1012  // the panel's focus ring: while a text field holds it the hint row names Enter and Esc alone (panel.tsx endHints),
1013  // since a letter goes into the field; off every element, NO_FOCUS (not '', which is a view just opened)
1014  const NO_FOCUS = '-'
1015  // A list's keys reach it through the relay's three Buttons (panel.tsx RELAY): a move of the ring from the middle one
1016  // onto a neighbour (↑, ↓, Tab) is that key for the list, and the ring stays; a move onto a neighbour from elsewhere
1017  // lands on the middle one. Every move draws the panel again, so its hint row names the keys where the ring is.
1018  on('ui.focus', { requestId: PANEL }, async ($, e, next) => {
1019    // the person moving the ring while the panel's typing went to the prompt (panel.tsx typeThrough): the panel has its
1020    // keys again
1021    if (rt.sc && rt.typeThrough && e.origin.kind === 'person') rt.typeThrough = false
1022    const how = rt.sc ? relayMove(rt.panelFocus, e.element) : ''
1023    if (how === 'up' || how === 'down') {
1024      await relayKey(how).catch(() => undefined)
1025      void cxOf($).bumpPanel()
1026      // a document's comment the key chose, scrolled into view once the panel is drawn again
1027      scrollPending(cxOf($))
1028      return {}
1029    }
1030    // a ring put back on a text field the panel no longer draws (the new thread's form left by back, live check
1031    // term-fix9, quirk 1) is on none of its elements: onto the list's keys when it draws a list
1032    const stale = Boolean(rt.sc && e.element && focusedField(e.element) && !rt.fields.has(e.element))
1033    const park = how === 'park' || (stale && hasList())
1034    const moved = await next(park ? { ...e, element: RELAY.pick } : e)
1035    try {
1036      if (rt.sc && !moved.deny) {
1037        rt.panelFocus = park ? RELAY.pick : stale ? NO_FOCUS : (e.element ?? NO_FOCUS)
1038        void cxOf($).bumpPanel()
1039      }
1040    } catch {
1041      // the ring moves whatever thimble-term makes of it
1042    }
1043    return moved
1044  })
1045
1046  // the wheel over a list the panel cut to its rows (panel.tsx windowList) moves the list's rows, the choice where it is;
1047  // over any other panel the pane scrolls
1048  on('ui.scroll', { requestId: PANEL }, async ($, e, next) => {
1049    if (!rt.sc || !e.pointer || e.origin.kind !== 'person') return next(e)
1050    // over a terminal view the wheel is the view's: the list under the pointer moves its rows, so it goes with the
1051    // frame's cell there
1052    if (openViewState()?.id && (await cxOf($).panel())?.view === 'view') {
1053      void sendEvent(cxOf($), { t: 'wheel', by: e.by, ...viewWheelAt(e.pointer) })
1054      return {}
1055    }
1056    if (!wheelWindow(e.by)) return next(e)
1057    void cxOf($).bumpPanel()
1058    return {}
1059  })
1060
1061  // ---------------------------------------------------------------------------------------------- clicks
1062
1063  // "ask" beside a selection (para.tsx): its press is the person's own (a press on a card's title is a gesture,
1064  // card.tsx, which asks a side thread about the card)
1065  on('ui.press', async ($, e, next) => {
1066    if (!rt.sc) return next(e)
1067    const cx = cxOf($)
1068    await navOrigin(cx, e.component === 'Pane' && e.requestId === PANEL)
1069    if (e.element === 'sel-ask' && rt.selection) await openAsk(cx, { kind: 'sentence', text: rt.selection.slice(0, 1200) })
1070    return next(e)
1071  })
1072
1073  on('ui.message', async ($, e, next) => {
1074    if (!rt.sc) return next(e)
1075    const cx = cxOf($)
1076    const d = (e.data ?? {}) as Record<string, unknown>
1077    const inPanel = e.component === 'Pane' && e.requestId === PANEL
1078    // every post of a Client that uses hooks/gestures.tsx carries its recent gestures; each is handled once
1079    if (Array.isArray(d.gestures) && typeof d.origin === 'string') {
1080      const last = rt.seenGestures.get(d.origin) ?? 0
1081      const fresh = (d.gestures as Sent[]).filter(g => typeof g?.seq === 'number' && g.seq > last)
1082      if (fresh.length) rt.seenGestures.set(d.origin, Math.max(...fresh.map(g => g.seq)))
1083      for (const g of fresh) {
1084        if (!g.gesture || !g.target) continue
1085        await navOrigin(cx, inPanel)
1086        try {
1087          await onGesture(cx, g.gesture, g.target)
1088        } catch (err) {
1089          $.ui.log(`thimble-term: the ${g.gesture} gesture failed: ${String(err).slice(0, 200)}`)
1090        }
1091      }
1092    }
1093    if (d.type === 'home') {
1094      await navOrigin(cx, inPanel)
1095      await linesMessage(cx, d.horigin, d.hacts)
1096    } else if (d.type === 'view') {
1097      // a click or a drag in a terminal view (viewclient.tsx); like a click on a list, it gives the pane its keys back
1098      await navOrigin(cx, inPanel)
1099      if (await viewMessage(cx, d)) await takeKeys(cx)
1100    } else if (d.type === 'copy' && typeof d.text === 'string') {
1101      const text = d.text.slice(0, 100000)
1102      rt.selection = text
1103      const r = await $.ui.copy({ text, surface: e.surface })
1104      $.ui.toast(r.isCopied ? `copied ${text.length} characters` : `could not copy: ${r.reason}`)
1105    } else if (d.type === 'label-open' && typeof d.slug === 'string') {
1106      // a press on a card's label row (its name or its ↗): the label's panel, under its name
1107      await navOrigin(cx, inPanel)
1108      const find = async () => {
1109        const got = await surfaceValue(cx, 'labels')
1110        return got?.ok ? labelsOf(got.value).find(l => l.id === d.slug || l.name === d.slug) : undefined
1111      }
1112      // the labels as last read may be older than the label (home read them before main made it): read them again
1113      let hit = await find()
1114      if (!hit) {
1115        await readSurface(cx, 'labels', 'labels')
1116        hit = await find()
1117      }
1118      if (!hit) cx.toast('thimble: that label is no longer in this workspace')
1119      else await openLabel(cx, hit.id, hit.name ?? hit.id)
1120    } else if (d.type === 'field' && typeof d.name === 'string' && typeof d.text === 'string') {
1121      // a field's words (field.tsx): a draft, or a save
1122      await fieldMessage(cx, d.name, d.text.slice(0, 20000), d.save === true)
1123    } else if (d.type === 'doc-edit' && typeof d.slug === 'string' && typeof d.text === 'string') {
1124      // a document's editor (docedit.tsx): what was typed, or a save
1125      await docEditMessage(cx, d.slug, d.text, d.save === true)
1126    } else if (d.type === 'files-open' && typeof d.path === 'string') {
1127      await openFile(cx, d.path)
1128    }
1129    return next(e)
1130  })
1131
1132  // a Client of thimble-term that could not draw (its tree did not validate, its module threw): why goes to the debug
1133  // log, never to main's chat; the open view's own says so in the panel, dim, in its place (panel.tsx drawView), as
1134  // the engine draws the panel again once this answers
1135  on('ui.fault', async ($, e, next) => {
1136    $.ui.log(`thimble-term: ${e.module} could not draw in ${e.component} (${e.phase}): ${e.reason}`, { to: 'debug' })
1137    if (e.component === 'Pane' && e.requestId === PANEL && /(^|\/)viewclient\.tsx$/.test(e.module)) viewFault(e.reason)
1138    return next(e)
1139  })
1140}
1141
hooks/ctx.ts 104 lines
1// What register.tsx shares of the engine with thimble-term's other files (`$` itself never crosses an import): the host
2// calls they make and the state values they read and write, each a function bound to the `$` of the hook that made it.
3// A drawing builds its own (its reads subscribe it); a timer or a handler uses the one its hook built.
4import type { Elements, FsEntry, FsStat, HttpInit, HttpResponse, PaneOpenArgs, ProcessRunInit, ProcessRunResult, RenderElement, ResolveInput, UiOpenResult } from 'claude-code'
5
6import type { ChatHomeUi, ChatNav, ChatNews, ChatSignal, TermAgent, TermAnswer, TermCard, TermFilesUi, TermHome, TermLabelUi, TermPanel, TermPending, TermThread, TermThreadRow, TermVerdict } from '../types'
7
8/** What `thimble state` printed for a surface the panel shows, or why it failed. */
9export type SurfaceGot = { ok: true; value: unknown } | { ok: false; error: string }
10
11export type Ctx = {
12  // ---- the host
13  now: () => Promise<number>
14  /** Claude Code's theme as a view's program hears it: `light` for a light theme, else `dark` */
15  theme: () => Promise<'dark' | 'light'>
16  run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
17  /** a command that may run longer than `run` allows (a label's run on every record): started beside the session, which
18   *  it ends with, its whole output read once it exits */
19  runLong: (argv: readonly string[], init?: { cwd?: string; env?: Record<string, string> }) => Promise<{ exitCode: number; stdout: string; stderr: string }>
20  /** a command beside the session that runs on (thimble's view host): each line it prints, then `done` as it ends;
21   *  `stop` ends it */
22  spawnLines: (argv: readonly string[], init: { cwd?: string; env?: Record<string, string> }, onLine: (line: string) => void, onErr?: (text: string) => void) => { stop: () => void; done: Promise<void> }
23  /** an HTTP request through the host, over a Unix socket with `socketPath` (thimble's view host) */
24  fetch: (url: string, init?: HttpInit) => Promise<HttpResponse>
25  read: (path: string) => Promise<string>
26  /** a file written whole, its folders made (the workspace's terminal/chat.json, kept.ts) */
27  write: (path: string, text: string) => Promise<void>
28  stat: (path: string) => Promise<FsStat>
29  list: (path: string) => Promise<FsEntry[]>
30  env: (name: string) => Promise<string | undefined>
31  /** the session's folder: the corpus */
32  root: () => Promise<string>
33  /** this plugin's folder */
34  pluginRoot: string
35  open: (args: PaneOpenArgs) => Promise<UiOpenResult>
36  close: (id: string) => Promise<void>
37  /** this plugin's open panes as the engine records them: placed, and holding the keyboard */
38  panes: () => Promise<readonly { id: string; isPlaced: boolean; isFocused?: boolean }[]>
39  /** `fn` once, `ms` from now, beside the hook that asked */
40  later: (ms: number, fn: () => void) => void
41  log: (text: string) => void
42  toast: (text: string) => void
43  /** a prompt to main, as the analyst's (a code label's run, which only main's Bash runs; a new label described) */
44  submit: (text: string) => Promise<void>
45  /** a slash command run as if the analyst typed it (a plugin's skill, such as `thimble:write`) */
46  command: (name: string, args: string) => Promise<void>
47  /** the prompt box's draft */
48  promptText: () => Promise<string>
49  /** text typed into the prompt box at its cursor, as the person's typing (a key the panel does not bind) */
50  fill: (text: string) => Promise<void>
51  /** the panel's focus ring onto one of its elements, by its key, while the pane holds the keys: true once the ring is
52   *  there (moved, or there already) */
53  focus: (key: string) => Promise<boolean>
54  /** the panel's element drawn with `key` scrolled into view, `block` saying where it lands (a document's chosen
55   *  comment) */
56  scroll: (key: string, block?: 'start' | 'center' | 'end' | 'nearest') => Promise<void>
57  /** the element table of the surface a drawing is for */
58  els: (e: ResolveInput) => Elements['terminal']
59  // ---- thimble-term's state (types/index.d.ts)
60  card: (id: string) => Promise<TermCard | undefined>
61  setCard: (id: string, v: TermCard) => Promise<void>
62  turnCards: (row: string) => Promise<string[]>
63  setTurnCards: (row: string, ids: string[]) => Promise<void>
64  verdict: (id: string) => Promise<TermVerdict | undefined>
65  setVerdict: (id: string, v: TermVerdict) => Promise<void>
66  thread: (id: string) => Promise<TermThread | undefined>
67  setThread: (id: string, v: TermThread) => Promise<void>
68  threadRows: (row: string) => Promise<ChatSignal[]>
69  setThreadRows: (row: string, rows: ChatSignal[]) => Promise<void>
70  answer: (row: string) => Promise<TermAnswer | undefined>
71  setAnswer: (row: string, a: TermAnswer) => Promise<void>
72  viewRows: (row: string) => Promise<string[]>
73  setViewRows: (row: string, slugs: string[]) => Promise<void>
74  surface: (key: string) => Promise<SurfaceGot | undefined>
75  setSurface: (key: string, v: SurfaceGot) => Promise<void>
76  panel: () => Promise<TermPanel | null>
77  setPanel: (p: TermPanel | null) => Promise<void>
78  nav: () => Promise<ChatNav>
79  setNav: (n: ChatNav) => Promise<void>
80  pending: () => Promise<TermPending | null>
81  setPending: (p: TermPending | null) => Promise<void>
82  home: () => Promise<TermHome | null>
83  setHome: (h: TermHome | null) => Promise<void>
84  homeSeen: () => Promise<TermHome | null>
85  setHomeSeen: (h: TermHome | null) => Promise<void>
86  agents: () => Promise<TermAgent[]>
87  setAgents: (a: TermAgent[]) => Promise<void>
88  threads: () => Promise<TermThreadRow[]>
89  setThreads: (t: TermThreadRow[]) => Promise<void>
90  news: () => Promise<ChatNews>
91  setNews: (n: ChatNews) => Promise<void>
92  homeUi: () => Promise<ChatHomeUi>
93  setHomeUi: (u: ChatHomeUi) => Promise<void>
94  labelUi: () => Promise<TermLabelUi>
95  setLabelUi: (u: TermLabelUi) => Promise<void>
96  filesUi: () => Promise<TermFilesUi>
97  setFilesUi: (u: TermFilesUi) => Promise<void>
98  /** a count a drawing of the panel reads, so a bump draws it again */
99  panelTick: () => Promise<number>
100  bumpPanel: () => Promise<void>
101}
102
103export type { RenderElement }
104
hooks/data.ts 161 lines
1// thimble-term's one way to thimble's data: the `thimble` command, with no server and no port.
2//
3//   readState(surface, args)   `thimble state <surface> --cwd <dir> [args]`, which prints the JSON the server's GET route
4//                              for that surface gives the browser; `{error}` and exit 1 when it fails
5//   act(kind, payload)         `thimble act <kind> --cwd <dir> <json>`, which calls what the browser's POST route calls
6//                              and prints `{ok, …}`; actLong the same for an act that runs until a label's run ends
7//
8// The renderer keeps no data of its own: what these print is drawn, and read again when the workspace changes. To see
9// that it changed without starting Python, `signature` lists the workspace's folders (names, sizes and times only).
10//
11// The scope: thimble-term draws only in a session the `thimble` command started in terminal mode, which puts the
12// workspace folder in THIMBLE_WS and writes `mode: "terminal"` in its trusted/launch.json. Anywhere else it is idle.
13import type { Ctx } from './ctx'
14
15/** Where this session's thimble lives: its workspace folder, the corpus folder, the command and the environment each
16 *  call carries. */
17export type Scope = { ws: string; cwd: string; bin: string; env: Record<string, string> }
18
19export type Got<T = unknown> = { ok: true; value: T } | { ok: false; error: string }
20
21const STATE_TIMEOUT_MS = 60_000
22const ACT_TIMEOUT_MS = 60_000
23
24/** The `thimble` command beside this plugin in thimble's tree (`<tree>/mods/thimble-term` → `<tree>/plugin/bin/thimble`),
25 *  or the one THIMBLE_TERM_CLI names (the tests' and the live check's stand-in). */
26export function cliOf(root: string, override: string | undefined): string {
27  if (override) return override
28  const dir = root.replace(/\/+$/, '').replace(/\/\.claude-plugin$/, '')
29  return `${dir.replace(/\/mods\/[^/]+$/, '')}/plugin/bin/thimble`
30}
31
32/** launch.json's mode, or '' when it names none or cannot be read. */
33export function launchMode(raw: string): string {
34  try {
35    const v = JSON.parse(raw) as { mode?: unknown }
36    return typeof v?.mode === 'string' ? v.mode : ''
37  } catch {
38    return ''
39  }
40}
41
42/** This session's scope when it runs in terminal mode, else null (thimble-term stays idle). */
43export async function scopeOf(cx: Ctx): Promise<Scope | null> {
44  const ws = ((await cx.env('THIMBLE_WS').catch(() => undefined)) ?? '').replace(/\/+$/, '')
45  if (!ws) return null
46  const raw = await cx.read(`${ws}/trusted/launch.json`).catch(() => '')
47  if (launchMode(raw) !== 'terminal') return null
48  const cwd = await cx.root()
49  const bin = cliOf(cx.pluginRoot, (await cx.env('THIMBLE_TERM_CLI').catch(() => undefined)) || undefined)
50  const env: Record<string, string> = { THIMBLE_WS: ws, THIMBLE_MODE: 'terminal' }
51  for (const k of ['THIMBLE_HOME', 'THIMBLE_PORT', 'THIMBLE_UI_PORT']) {
52    const v = await cx.env(k).catch(() => undefined)
53    if (v) env[k] = v
54  }
55  return { ws, cwd, bin, env }
56}
57
58/** What a run printed, as JSON: its value when it exited 0 and printed JSON without an `error` key, else why not. */
59export function parsePrinted(r: { exitCode: number; stdout: string; stderr: string }): Got {
60  const out = r.stdout.trim()
61  let v: unknown
62  try {
63    v = out ? JSON.parse(out) : undefined
64  } catch {
65    v = undefined
66  }
67  const err = v && typeof v === 'object' && !Array.isArray(v) && typeof (v as { error?: unknown }).error === 'string' ? (v as { error: string }).error : ''
68  if (r.exitCode !== 0 || err || v === undefined) {
69    const why = err || r.stderr.trim().split('\n').filter(Boolean).at(-1) || (out ? 'it printed no JSON' : 'it printed nothing')
70    return { ok: false, error: why.slice(0, 300) }
71  }
72  return { ok: true, value: v }
73}
74
75export async function readState<T = unknown>(cx: Ctx, sc: Scope, surface: string, args: readonly string[] = []): Promise<Got<T>> {
76  try {
77    const r = await cx.run([sc.bin, 'state', surface, '--cwd', sc.cwd, ...args], { cwd: sc.cwd, env: sc.env, timeoutMs: STATE_TIMEOUT_MS })
78    return parsePrinted(r) as Got<T>
79  } catch (err) {
80    return { ok: false, error: String(err).slice(0, 300) }
81  }
82}
83
84export async function act<T = Record<string, unknown>>(cx: Ctx, sc: Scope, kind: string, payload: Record<string, unknown>): Promise<Got<T>> {
85  try {
86    const r = await cx.run([sc.bin, 'act', kind, '--cwd', sc.cwd, JSON.stringify(payload)], { cwd: sc.cwd, env: sc.env, timeoutMs: ACT_TIMEOUT_MS })
87    const got = parsePrinted(r)
88    if (got.ok && (got.value as { ok?: unknown })?.ok === false) return { ok: false, error: String((got.value as { error?: unknown }).error ?? 'refused') }
89    return got as Got<T>
90  } catch (err) {
91    return { ok: false, error: String(err).slice(0, 300) }
92  }
93}
94
95/** An act that may run longer than a run allows (`label-run` on every record): started beside the session, which it ends
96 *  with, its answer read once it exits. */
97export async function actLong<T = Record<string, unknown>>(cx: Ctx, sc: Scope, kind: string, payload: Record<string, unknown>): Promise<Got<T>> {
98  try {
99    const r = await cx.runLong([sc.bin, 'act', kind, '--cwd', sc.cwd, JSON.stringify(payload)], { cwd: sc.cwd, env: sc.env })
100    const got = parsePrinted(r)
101    if (got.ok && (got.value as { ok?: unknown })?.ok === false) return { ok: false, error: String((got.value as { error?: unknown }).error ?? 'refused') }
102    return got as Got<T>
103  } catch (err) {
104    return { ok: false, error: String(err).slice(0, 300) }
105  }
106}
107
108// ------------------------------------------------------------------------------------------------ what changed
109
110/** The parts of a workspace a surface reads, each with the folders whose listing says it changed: a folder whole, or
111 *  the entries of it that `names` lists. Only listings: a file that is not there yet costs no failed call. */
112export const AREAS = {
113  cards: [{ dir: 'notebooks' }],
114  labels: [{ dir: 'concepts' }, { dir: 'labels' }],
115  // a document, and a report check made, changed or run on one (report.ts reads its name and its runs)
116  docs: [{ dir: 'investigations/main' }, { dir: 'checks' }],
117  chats: [{ dir: 'chats' }],
118  agents: [{ dir: 'trusted', names: ['subagents.json', 'module.json'] }, { dir: 'orient', names: ['run.json'] }],
119  // a view built (its folder in the local extension) or proposed (proposals.json)
120  views: [{ dir: 'extension/views' }, { dir: 'views', names: ['proposals.json'] }],
121  ui: [{ dir: '', names: ['ui.jsonl'] }],
122} as const satisfies Record<string, readonly { dir: string; names?: readonly string[] }[]>
123
124export type Area = keyof typeof AREAS
125export type Signature = Record<Area, string>
126
127async function stamp(cx: Ctx, path: string, names?: readonly string[]): Promise<string> {
128  try {
129    const entries = await cx.list(path)
130    // a file's size and time, a folder's name: what an edit, an add or a remove changes
131    return entries
132      .filter(e => !names || names.includes(e.name))
133      .map(e => `${e.name}:${e.kind === 'file' ? `${e.size}:${e.mtimeMs}` : e.kind}`)
134      .sort()
135      .join('|')
136  } catch {
137    return '-'
138  }
139}
140
141/** One stamp per area: a change of any file it reads changes it. Each folder is listed once a pass. */
142export async function signature(cx: Ctx, sc: Scope): Promise<Signature> {
143  const out = {} as Signature
144  const listed = new Map<string, Promise<string>>()
145  for (const area of Object.keys(AREAS) as Area[]) {
146    const parts: string[] = []
147    for (const spec of AREAS[area] as readonly { dir: string; names?: readonly string[] }[]) {
148      const key = `${spec.dir}|${(spec.names ?? []).join(',')}`
149      if (!listed.has(key)) listed.set(key, stamp(cx, spec.dir ? `${sc.ws}/${spec.dir}` : sc.ws, spec.names))
150      parts.push(await listed.get(key)!)
151    }
152    out[area] = parts.join('#')
153  }
154  return out
155}
156
157/** The areas whose stamp differs between two signatures (every area when there was none before). */
158export function changed(before: Signature | null, now: Signature): Area[] {
159  return (Object.keys(now) as Area[]).filter(a => !before || before[a] !== now[a])
160}
161
hooks/gestures.tsx 159 lines
1// One set of gestures for every target a Client of the mod draws (a card, a mark, a row, a record, a node):
2//
3//   click            the target's one action: open the place it cites, or a side thread about a card
4//   double-click     the same as a click
5//   right-click      the same as a click: there is no menu
6//
7// Modifier clicks and the middle button are left alone: terminals keep them for their own selection, and a gesture
8// that works in one terminal and not the next is worse than none. A reply's paragraphs are the engine's Markdown, each
9// citation a link a plain click presses (register.tsx), so their text selects as usual.
10//
11// A Client calls onPointer from its pointer listener with the target under the pointer; the gesture is posted to the
12// hooks module (register.tsx), which acts on it.
13import type { ClientModule, ClientPointerEvent, JsonValue } from 'claude-code'
14
15import type { ChatTarget } from '../types'
16import { chipName, citeLabel, plainCites } from './cite'
17import { citations, cut, isChip, quoted } from './lib'
18import type { Citation } from './lib'
19
20export type Target = ChatTarget
21
22export type PointerEv = { button: 'left' | 'middle' | 'right'; shift: boolean; ctrl: boolean; alt: boolean; type: 'press' | 'release' | 'double' }
23
24/** A click's (a right-click does what a click does). */
25export type Gesture = 'primary'
26
27/** One pointer event as posted: what it was, which gesture it made (null: none), and on what. */
28export type Sent = { seq: number; gesture: Gesture | null; target: Target; ev: PointerEv }
29
30type Port = { post: (data: JsonValue) => void; every?: (ms: number, fn: () => void) => () => void }
31
32const origin = Math.random().toString(36).slice(2, 10)
33let seq = 0
34let outbox: Sent[] = []
35let rightDown = false // a right press reached this module and its release has not
36
37function portOf(ctx: unknown): Port | null {
38  const c = ctx as { post?: unknown; surface?: unknown } | null
39  if (c && typeof c.post === 'function') return c as Port
40  if (c && c.surface) return portOf(c.surface)
41  return null
42}
43
44/** A Client's raw event (down/up) or an event already in this module's terms; null for moves and edges. */
45export function normalize(ev: PointerEv | ClientPointerEvent): PointerEv | null {
46  const t = ev.type === 'down' ? 'press' : ev.type === 'up' ? 'release' : ev.type
47  if (t !== 'press' && t !== 'release' && t !== 'double') return null
48  return { type: t, button: ev.button ?? 'left', shift: Boolean(ev.shift), ctrl: Boolean(ev.ctrl), alt: Boolean(ev.alt) }
49}
50
51/** The gesture a press makes: a left or right press (once or twice) its one action; the rest none (a modified click
52 *  and the middle button are the terminal's). */
53export function classify(ev: PointerEv): Gesture | null {
54  if (ev.type === 'double') return 'primary'
55  if (ev.type !== 'press') return null
56  return (ev.button === 'left' || ev.button === 'right') && !ev.shift && !ev.ctrl && !ev.alt ? 'primary' : null
57}
58
59export function targetKey(t: Target): string {
60  return [t.kind, t.ref ?? '', t.cardId ?? '', (t.text ?? '').slice(0, 80)].join('|')
61}
62
63/** Every post carries what this module sent since the last frame, so a post that replaces another in the same frame
64 *  (the engine delivers one per frame) loses no gesture; register.tsx drops the ones it has seen. */
65function emit(port: Port, entry: Omit<Sent, 'seq'>): void {
66  const mine = ++seq
67  outbox = [...outbox, { ...entry, seq: mine }].slice(-8)
68  port.post({ type: 'gesture', origin, gestures: outbox } as unknown as JsonValue)
69  if (port.every) {
70    const stop = port.every(120, () => {
71      stop()
72      outbox = outbox.filter(x => x.seq > mine)
73    })
74  }
75}
76
77/** A Client's other posts (a hover, a param) go through here, so they carry the gestures not yet delivered. */
78export function send(ctx: unknown, data: Record<string, JsonValue>): void {
79  const port = portOf(ctx)
80  if (port) port.post({ ...data, origin, gestures: outbox } as unknown as JsonValue)
81}
82
83/** Called by a Client's pointer listener with the target under the pointer, on a press and on a release. `ctx` is the
84 *  Client's surface (its `post`, and its `every` for the double-click window). */
85export function onPointer(target: Target, ev: PointerEv | ClientPointerEvent, ctx: unknown): void {
86  const port = portOf(ctx)
87  const e = normalize(ev)
88  if (!port || !e) return
89  if (e.type === 'release') {
90    // The press picks the target. The panel it opens can reflow the transcript before the release, which then lands
91    // on another target; only a right release whose press never reached this module acts, as a click.
92    const lost = e.button === 'right' && !rightDown && !e.shift && !e.ctrl && !e.alt
93    if (e.button === 'right') rightDown = false
94    emit(port, { gesture: lost ? 'primary' : null, target, ev: e })
95    return
96  }
97  if (e.type === 'press' && e.button === 'right') rightDown = true
98  emit(port, { gesture: classify(e), target, ev: e })
99}
100
101// ------------------------------------------------------------------------------------------------ what a target means
102
103const CARD_REF = /^card:([A-Za-z0-9_-]+)/
104
105/** The target's citation: a full `[[value|ref]]` in `ref`, `[[text|ref]]` for a mark or a row (its text is the value
106 *  shown), or `[[ref]]` (a record's or a node's text is words about it, not a value). */
107export function citationOf(t: Target): Citation | null {
108  if (t.kind === 'card') return t.cardId ? { raw: `[[card:${t.cardId}]]`, ref: `card:${t.cardId}`, display: null } : null
109  const ref = (t.ref ?? '').trim()
110  if (!ref) return null
111  if (ref.startsWith('[[')) return citations(ref)[0] ?? null
112  const value = t.kind === 'mark' || t.kind === 'row' ? (t.text ?? '').trim() : ''
113  return value && !/[|\[\]\n]/.test(value) ? { raw: `[[${value}|${ref}]]`, ref, display: value } : { raw: `[[${ref}]]`, ref, display: null }
114}
115
116export function cardOf(t: Target): string {
117  return t.cardId || CARD_REF.exec(citationOf(t)?.ref ?? '')?.[1] || ''
118}
119
120/** What a double-click or "cite" puts into the prompt: the citation, or a sentence quoted. */
121export function citeText(t: Target): string {
122  const c = citationOf(t)
123  if (c) return c.raw
124  const s = (t.text ?? '').replace(/\s+/g, ' ').trim()
125  return s ? quoted(cut(s, 300)) : ''
126}
127
128/** The place a click opens: a citation's; a mark's, row's, record's or node's when it lies outside the cards. Plain
129 *  words (a sentence, a reply's table row) open nothing: only what is drawn as a link opens a panel. */
130export function placeOf(t: Target): Citation | null {
131  if (t.kind === 'card' || t.kind === 'sentence' || (t.kind === 'row' && !t.ref)) return null
132  const c = citationOf(t)
133  if (!c) return null
134  return t.kind === 'citation' || !CARD_REF.test(c.ref) ? c : null
135}
136
137export type Act = 'open' | 'thread' | 'verify' | 'script' | 'rerun' | 'files'
138/** A short name for the target in at most `max` characters, as the mouse log and a thread's title show it: each citation by
139 *  its label, never its ref; quoted words keep their closing quote when cut. */
140export function targetLabel(t: Target, max = 48): string {
141  const s = plainCites(t.text ?? '')
142    .replace(/[`*_]+|^\s*(#+|[-*+]|\d+[.)])\s+/g, '')
143    .replace(/\s+/g, ' ')
144    .trim()
145  const n = Math.max(8, max)
146  const c = citationOf(t)
147  switch (t.kind) {
148    case 'card':
149      return s ? `card ${quoted(cut(s, n - 7))}` : 'card'
150    case 'sentence':
151      return s ? quoted(cut(s, n - 2)) : 'sentence'
152    case 'citation':
153      // a chip by its place in full words (`card "How many…"`, `events.jsonl line 12`): no tip names it here
154      return cut(c ? (isChip(c) ? chipName(c) : citeLabel(c)) : 'citation', n)
155    default:
156      return cut(t.label || s || (c ? citeLabel(c) : t.kind), n)
157  }
158}
159
hooks/cite.ts 842 lines
1// Citations as the analyst sees them (no `$`), shared by the hooks module, para.tsx and the tests.
2//
3// - Display: every citation is a link, blue and underlined; red when the value is not at the cited place or the place
4//   does not exist. A spinner follows a citation while a fix round or its verification works on it; then ✓ when its
5//   verification recomputed the value, or × (and red) when the verification failed (another value, a crash, no script
6//   written) or the fix round could not correct it. The model's Markdown is drawn as Claude Code draws it: bold bold,
7//   italic italic, inline code in the code colour, a heading bold.
8// - Layout: a reply's paragraph or table wrapped to its width, a citation's shown value whole and wrapping like the
9//   words around it, with where each citation, word and table row lands, so a pointer finds what it is over.
10// - Fix rounds: the sentences a forked subagent is asked to rewrite, its answer read, and each corrected sentence put
11//   in place of the old one, unmarked.
12// - Marks: each answer's footer rows and each citation's fix and verification, as the file that keeps them across a
13//   resume.
14// - Streaming: a reply's text as the engine shows it while it streams, citations as links and card lines as
15//   placeholders, before the mod draws the finished block.
16import type { ChatCorrection, ChatEnd, ChatFix, ChatFixItem, ChatVerify } from '../types'
17import { lineWidth, placeWords, width } from './draw'
18import type { Line, Seg } from './draw'
19import { EMBED_RE, bareCard, cardWords, chipLabel, chipPlace, chipText, chipWords, cid, citations, citeEnd, citeSpans, dayMonth, isChip, labelRef, linksAsSpans, outputLine, parseReply, plainLinks, prefix, questionOf, reportRef, shownMatches, valueIn, windowAt, withoutOwnParens } from './lib'
20import type { Citation, Run, TableRuns } from './lib'
21import { COLORS } from './paint'
22
23/** `link` for every citation without a problem (checked, unchecked or not checked yet); `problem` when the value is
24 *  not at the place or the place does not exist; `fixing` while a fix round runs on it; `failed` when the fix round
25 *  could not correct it or its verification failed (another value, a crash, no script). */
26export type ChipState = 'link' | 'problem' | 'fixing' | 'failed'
27/** `mark` is ✓ (a script recomputed the value), × (failed) or nothing; `spin` while a fix round or a verification works
28 *  on the citation, drawn as ◌. `chip`: a citation with no words of its own, drawn as `[ card ]` (its label holds the
29 *  brackets), whole on one row and not underlined. */
30export type ChipView = { label: string; state: ChipState; mark: string; spin: boolean; tip: string; chip?: boolean }
31
32/** What a citation's link says: its shown value whole, or a short name of the place for one without a value. */
33export function citeLabel(c: Citation): string {
34  return c.display ?? chipLabel(c)
35}
36
37/** A chip's place in full words, where a link with a tip is not drawn (a thread's subject): a card by its question
38 *  (`card "How many pages…"`), a line a card printed (`card "…" output line 1`), a file's line (`events.jsonl line 12`),
39 *  a command's output, a label or a document by its name; the chip's words while that name is not known. */
40export function chipName(c: Citation): string {
41  const named = chipPlace(c)
42  if (named) return named
43  const out = outputLine(c.ref)
44  if (out) return out.words
45  const card = bareCard(c) || /^(?:card|cell):([A-Za-z0-9_-]+)/.exec(c.ref)?.[1] || ''
46  if (card) return questionOf(card) ? cardWords(questionOf(card)) : chipWords(c)
47  return labelRef(c.ref) || reportRef(c.ref) ? chipWords(c) : placeWords(c.ref)
48}
49
50/** The glyph of a citation being worked on: running (SPEC.md, "The visual system", section 5). */
51export const SPIN = '◌'
52
53/** A verification that failed: its script recomputed another value, crashed, printed no result, or was never written;
54 *  or thimble's links check found the value typed in the card's code. */
55export function verifyFailed(verify: string | undefined): boolean {
56  return verify === 'refuted' || verify === 'error' || verify === 'missing' || verify === 'typed'
57}
58
59/** How a citation is drawn, from the resolver's status, its fix round's state and its verification's state. */
60export function chipState(status: string | undefined, fix: string | undefined, verify?: string): ChipState {
61  if (fix === 'fixing') return 'fixing'
62  if (verifyFailed(verify)) return 'failed'
63  if (status !== 'missing' && status !== 'differs') return 'link'
64  return fix === 'failed' ? 'failed' : 'problem'
65}
66
67/** A verification is working while its subagent writes the script or the mod runs it. */
68export function verifying(verify: string | undefined): boolean {
69  return verify === 'asked' || verify === 'running'
70}
71
72/** The state, mark and spinner of a citation. */
73export function chipLook(status: string | undefined, fix: string | undefined, verify: string | undefined): Pick<ChipView, 'state' | 'mark' | 'spin'> {
74  const state = chipState(status, fix, verify)
75  const spin = state === 'fixing' || verifying(verify)
76  const mark = spin ? '' : state === 'failed' ? '×' : state === 'link' && verify === 'verified' ? '✓' : ''
77  return { state, mark, spin }
78}
79
80/** A citation as styled segments: its label blue and underlined (red with a problem), in inverse under the pointer, so
81 *  its blue becomes the background; a chip (`[ card ]`) blue with no underline, its brackets marking it; then ◌ while it
82 *  is worked on or its mark: ✓ in the text colour, × in red. */
83export function chipSegs(c: ChipView, hover: boolean, _frame = 0): Seg[] {
84  const segs: Seg[] = [{ s: c.label, fg: c.state === 'link' ? COLORS.link : COLORS.problem, ...(c.chip ? {} : { u: true }), ...(hover ? { inv: true } : {}) }]
85  // the mark a cell apart from the value, as the spinner is: `19,931 ✓`, not `19,931✓`
86  if (c.spin) segs.push({ s: ` ${SPIN}`, ...(c.state === 'link' ? {} : { fg: COLORS.problem }) })
87  else if (c.mark) segs.push({ s: ` ${c.mark}`, ...(c.mark === '✓' ? {} : { fg: COLORS.problem }) })
88  return segs
89}
90
91// ---------------------------------------------------------------------------------------- what a verification compares
92
93const NUMBER_RE = /^[-−]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?$/
94
95/** Whether a citation's words are a value its place can show, a number, a quote or a date in words (`23 June`, `4 June
96 *  2026 at 10:53 UTC`, which dateIn checks), as the resolver (refs.py) reads them; live check term-fix7, new quirk 3: a
97 *  wrong `[24 June](card:…#day/06-23)` was blue, "its value is not checked". Other words, as in
98 *  [[its revisions|revisions.jsonl#L5603-L5625]], name the place and show no value. */
99export function showsValue(display: string | null): boolean {
100  return display !== null && (NUMBER_RE.test(display.trim()) || quotedWords(display) !== '' || dayMonth(display) !== null)
101}
102
103/** The place a file citation names after its "#" (L5603-L5625, row=12, a JSON pointer, table/key), or '' for a whole
104 *  file, a card or a call: what a verification of words that show no value recomputes. */
105export function citedPlace(ref: string | undefined): string {
106  if (!ref || /^(?:card|call):/.test(ref)) return ''
107  const hash = ref.indexOf('#')
108  return hash < 0 ? '' : ref.slice(hash + 1).trim()
109}
110
111const PLACE_LINES = /^L(\d+)(?:-L?(\d+))?$/
112
113/** Whether a script's RESULT names the place `ref` cites: the same lines (L5603-L5625, lines 5603-5625), the same
114 *  row, or the place as the citation writes it. */
115export function placeIn(ref: string | undefined, result: string): boolean {
116  const place = citedPlace(ref)
117  if (!place) return false
118  const lines = PLACE_LINES.exec(place)
119  if (lines) {
120    const [a, b] = [Number(lines[1]), Number(lines[2] ?? lines[1])]
121    return [...result.matchAll(/(?:\bL|\blines?\s+)(\d+)(?:\s*(?:-|–|to)\s*L?(\d+))?/gi)].some(m => Number(m[1]) === a && Number(m[2] ?? m[1]) === b)
122  }
123  const row = /^row=(\d+)$/.exec(place)
124  if (row) return [...result.matchAll(/\brow\s*=?\s*(\d+)/gi)].some(m => m[1] === row[1])
125  return result.includes(place)
126}
127
128/** Whether a verification's RESULT agrees with its citation: the value the words show, or for words that show no
129 *  value, those words or the place the citation names. */
130export function verifyMatches(expected: string | null, ref: string | undefined, result: string): boolean {
131  if (expected === null) return true
132  if (shownMatches(expected, result) || valueIn(expected, result)) return true
133  return !showsValue(expected) && placeIn(ref, result)
134}
135
136/** What a verification compared its RESULT with, in words: the value the citation shows, or the place its words name. */
137export function citedAs(expected: string | null, ref: string | undefined): string {
138  if (expected === null) return ''
139  return showsValue(expected) || !citedPlace(ref) ? expected : citedPlace(ref)
140}
141
142/** What a verification script is asked to do for a citation: recompute the value its words show, or, for words that
143 *  name a place, find the records that show the sentence's claim and print their place, which the mod compares. */
144export function scriptAim(display: string, ref: string): string {
145  const place = citedPlace(ref)
146  if (showsValue(display) || !place) return `recomputes ${display} from the raw files`
147  const form = PLACE_LINES.test(place) ? 'L<first>-L<last>' : /^row=\d+$/.test(place) ? 'row=<n>' : 'a citation writes it after "#"'
148  return `finds in the raw files the records that show what the sentence claims and ends with \`RESULT: <their place>\`, written as ${form}`
149}
150
151// ---------------------------------------------------------------------------------------- layout
152
153export type ChipSpan = { line: number; x0: number; x1: number; chip: number }
154/** A word as laid out, and where it starts in the block's source (its text with each citation as written). */
155export type WordSpan = { line: number; x0: number; x1: number; at: number }
156/** `rows`: for a table, each line's row as source text ('' for the rule). */
157export type ParaLayout = { lines: Line[]; spans: ChipSpan[]; words: WordSpan[]; source: string; rows?: string[] }
158
159const segsWidth = (segs: Seg[]) => segs.reduce((n, s) => n + width(s.s), 0)
160
161/** A word or a space of a flowing text. `chip` is the citation it belongs to (-1 for none); a citation's words wrap like
162 *  any others, its mark or spinner stays with its last word. `at` is where a plain word starts in the source. */
163type Tok = { segs: Seg[]; space: boolean; chip: number; at: number }
164
165/** Runs as words and spaces, their citations numbered from `k0`; `bold` for a table's column names and a heading's
166 *  words. The model's Markdown as Claude Code draws it: its bold bold, its italic italic, its inline code in the code
167 *  colour, a link's words blue and underlined. */
168function tokens(runs: Run[], chips: ChipView[], k0: number, hover: number, frame: number, bold: boolean): { toks: Tok[]; source: string; next: number } {
169  const toks: Tok[] = []
170  let source = ''
171  let k = k0
172  for (const r of runs) {
173    if (r.cite) {
174      const c: ChipView = chips[k] ?? { label: citeLabel(r.cite), state: 'link', mark: '', spin: false, tip: '', ...(isChip(r.cite) ? { chip: true } : {}) }
175      const [label, ...after] = chipSegs(c, k === hover, frame)
176      // a chip is one word, kept whole on its row; a value's words wrap like the words around it
177      const parts = c.chip ? [label!.s] : label!.s.split(/(\s+)/).filter(Boolean)
178      parts.forEach((part, i) => {
179        const space = /^\s+$/.test(part)
180        const segs: Seg[] = [{ ...label!, s: space ? ' ' : part }]
181        if (i === parts.length - 1) segs.push(...after)
182        toks.push({ segs, space, chip: k, at: source.length })
183      })
184      source += r.cite.raw
185      k++
186      continue
187    }
188    let at = source.length
189    for (const part of r.text.split(/(\s+)/)) {
190      if (!part) continue
191      const space = /^\s+$/.test(part)
192      const style: Seg = { s: space ? ' ' : part }
193      if (bold || r.b) style.b = true
194      if (r.i) style.i = true
195      if (r.code) style.fg = COLORS.code
196      if (r.u) {
197        style.fg = COLORS.link
198        style.u = true
199      }
200      toks.push({ segs: [style], space, chip: -1, at })
201      at += part.length
202    }
203    source += r.text
204  }
205  return { toks, source, next: k }
206}
207
208/** Words and spaces wrapped to `room` columns, x from 0: each citation's cells as spans, each plain word's place. A word
209 *  longer than the line is cut into pieces. */
210function flow(toks: Tok[], room: number): { lines: Line[]; spans: ChipSpan[]; words: WordSpan[] } {
211  const lines: Line[] = []
212  const spans: ChipSpan[] = []
213  const words: WordSpan[] = []
214  let cur: Line = []
215  let used = 0
216  const span = (x0: number, x1: number, chip: number) => {
217    const last = spans.at(-1)
218    if (last && last.chip === chip && last.line === lines.length && last.x1 === x0) last.x1 = x1
219    else spans.push({ line: lines.length, x0, x1, chip })
220  }
221  const newLine = () => {
222    while (cur.length && cur.at(-1)!.s === ' ') cur.pop()
223    const last = spans.at(-1)
224    if (last && last.line === lines.length) last.x1 = Math.min(last.x1, lineWidth(cur))
225    lines.push(cur)
226    cur = []
227    used = 0
228  }
229  let glued = false // the token before was a word: no break before this one, unless its group is wider than a line
230  let groupAt = 0
231  toks.forEach((t, n) => {
232    const w = segsWidth(t.segs)
233    if (t.space) {
234      glued = false
235      if (used > 0 && used + 1 <= room) {
236        cur.push(t.segs[0]!)
237        if (t.chip >= 0) span(used, used + 1, t.chip)
238        used += 1
239      }
240      return
241    }
242    if (!glued) {
243      // a group of words with no space between ("[[value|ref]]," or "(value)") wraps whole
244      let gw = 0
245      for (let j = n; j < toks.length && !toks[j]!.space; j++) gw += segsWidth(toks[j]!.segs)
246      if (used > 0 && used + gw > room) newLine()
247      groupAt = used
248    } else if (groupAt === 0 && used > 0 && used + w > room) newLine()
249    glued = true
250    const [seg, ...after] = t.segs
251    let s = seg!.s
252    let at = t.at
253    while (width(s) > room) {
254      const head = prefix(s, room) || [...s][0]!
255      if (t.chip >= 0) span(used, used + width(head), t.chip)
256      else words.push({ line: lines.length, x0: used, x1: used + width(head), at })
257      cur.push({ ...seg!, s: head })
258      newLine()
259      s = s.slice(head.length)
260      at += head.length
261    }
262    const piece: Seg[] = [{ ...seg!, s }, ...after]
263    const pw = segsWidth(piece)
264    if (t.chip >= 0) span(used, used + pw, t.chip)
265    else words.push({ line: lines.length, x0: used, x1: used + width(s), at })
266    cur.push(...piece)
267    used += pw
268  })
269  if (cur.length || lines.length === 0) newLine()
270  return { lines, spans, words }
271}
272
273/** A rich block wrapped to `cols`: words flow, a citation's words with them, each citation one link. */
274export function paraLayout(
275  block: { prefix: string; heading: number; quote: boolean; runs: Run[] },
276  chips: ChipView[],
277  cols: number,
278  hover: number,
279  frame = 0,
280): ParaLayout {
281  // a heading bold, as Claude Code draws every Markdown level; a quote 2 cells in, in italic
282  const lead = block.quote ? '  ' : block.prefix
283  const indent = block.quote ? '  ' : ' '.repeat(width(block.prefix))
284  const room = Math.max(10, cols - width(lead))
285  const { toks, source } = tokens(block.runs, chips, 0, hover, frame, block.heading > 0)
286  if (block.quote) for (const t of toks) if (t.chip < 0) t.segs = t.segs.map(g => ({ ...g, i: true }))
287  const f = flow(toks, room)
288  const x0 = width(lead)
289  return {
290    lines: f.lines.map((l, i) => [{ s: i === 0 ? lead : indent }, ...l]),
291    spans: f.spans.map(s => ({ ...s, x0: s.x0 + x0, x1: s.x1 + x0 })),
292    words: f.words.map(w => ({ ...w, x0: w.x0 + x0, x1: w.x1 + x0 })),
293    source,
294  }
295}
296
297/** A table block in aligned columns, each citation one link: the column names bold, a rule in the rule grey under each as
298 *  wide as its column, the rows right under it, as a card's table draws its header. Columns wider than `cols` allows are
299 *  narrowed from the widest, and a cell's words (a citation's too) wrap within its column. */
300export function mdTableLayout(table: TableRuns, chips: ChipView[], cols: number, hover: number, frame = 0): ParaLayout {
301  const GAP = 2
302  let k = 0
303  const grid = table.rows.map((row, r) =>
304    row.map(cell => {
305      const t = tokens(cell, chips, k, hover, frame, r === 0)
306      k = t.next
307      return t.toks
308    }),
309  )
310  const rowSource = table.rows.map(row => row.map(cell => cell.map(run => (run.cite ? run.cite.raw : run.text)).join('')).join(' | '))
311  const ncol = Math.max(...grid.map(r => r.length))
312  const natural = (toks: Tok[] | undefined) => lineWidth(flow(toks ?? [], 1e9).lines[0] ?? [])
313  const w = Array.from({ length: ncol }, (_, c) => Math.max(1, ...grid.map(r => natural(r[c]))))
314  const room = Math.max(ncol, cols - GAP * (ncol - 1))
315  while (w.reduce((a, b) => a + b, 0) > room) {
316    const widest = w.indexOf(Math.max(...w))
317    if (w[widest]! <= 4) break
318    w[widest]!--
319  }
320  const lines: Line[] = []
321  const spans: ChipSpan[] = []
322  const rows: string[] = []
323  grid.forEach((row, r) => {
324    const cells = Array.from({ length: ncol }, (_, c) => flow(row[c] ?? [], w[c]!))
325    const height = Math.max(...cells.map(f => f.lines.length))
326    for (let i = 0; i < height; i++) {
327      const line: Line = []
328      let x = 0
329      for (let c = 0; c < ncol; c++) {
330        const f = cells[c]!
331        const part = f.lines[i] ?? []
332        const fill = Math.max(0, w[c]! - lineWidth(part))
333        const align = table.align[c] ?? 'left'
334        const before = align === 'right' ? fill : align === 'center' ? Math.floor(fill / 2) : 0
335        if (c > 0) {
336          line.push({ s: ' '.repeat(GAP) })
337          x += GAP
338        }
339        if (before) line.push({ s: ' '.repeat(before) })
340        for (const s of f.spans) if (s.line === i) spans.push({ line: lines.length, x0: x + before + s.x0, x1: x + before + s.x1, chip: s.chip })
341        line.push(...part)
342        if (fill - before) line.push({ s: ' '.repeat(fill - before) })
343        x += w[c]!
344      }
345      lines.push(line)
346      rows.push(rowSource[r] ?? '')
347    }
348    // under the column names, a rule under each as wide as its column
349    if (r === 0) {
350      lines.push(w.flatMap((cw, c): Seg[] => [...(c > 0 ? [{ s: ' '.repeat(GAP) }] : []), { s: '─'.repeat(cw), fg: COLORS.rule }]))
351      rows.push('')
352    }
353  })
354  return { lines, spans, words: [], source: rowSource.join('\n'), rows }
355}
356
357/** A rich block's layout: a table's columns, or a paragraph's flowing words. */
358export function blockLayout(
359  block: { prefix: string; heading: number; quote: boolean; runs: Run[]; table?: TableRuns },
360  chips: ChipView[],
361  cols: number,
362  hover: number,
363  frame = 0,
364): ParaLayout {
365  return block.table ? mdTableLayout(block.table, chips, cols, hover, frame) : paraLayout(block, chips, cols, hover, frame)
366}
367
368/** The sentence of a source text around an offset, its citations as written. */
369export function sentenceAt(source: string, at: number): string {
370  const ends = /[.!?](?=\s|$)/g
371  const spans = citeSpans(source)
372  let start = 0
373  let end = source.length
374  for (const m of source.matchAll(ends)) {
375    const i = (m.index ?? 0) + 1
376    // a full stop inside a citation ([[3.5|x]]) is no sentence end
377    if (spans.some(sp => sp.at < i && i < sp.end)) continue
378    if (i <= at) start = i
379    else {
380      end = i
381      break
382    }
383  }
384  return source.slice(start, end).trim()
385}
386
387// ---------------------------------------------------------------------------------------- claims
388
389/** A citation where it stands: its sentence (a table's row) in one answer. `key` names the state of what is checked
390 *  about it (its fix, its verification), so the same citation in another sentence or answer is checked on its own. */
391export type Claim = { key: string; c: Citation; sentence: string }
392
393export function claimKey(answer: string, sentence: string, raw: string): string {
394  return cid(`${answer}\n${sentence}\n${raw}`)
395}
396
397/** A rich block's claims, one per citation in the order its chips are numbered. */
398export function blockClaims(block: { runs: Run[]; table?: TableRuns }, answer: string): Claim[] {
399  const out: Claim[] = []
400  const add = (c: Citation, sentence: string) => out.push({ key: claimKey(answer, sentence, c.raw), c, sentence })
401  if (block.table) {
402    for (const row of block.table.rows) {
403      const source = row.map(cell => cell.map(r => (r.cite ? r.cite.raw : r.text)).join('')).join(' | ')
404      for (const cell of row) for (const r of cell) if (r.cite) add(r.cite, source)
405    }
406    return out
407  }
408  let source = ''
409  const at: { c: Citation; at: number }[] = []
410  for (const r of block.runs) {
411    if (r.cite) at.push({ c: r.cite, at: source.length })
412    source += r.cite ? r.cite.raw : r.text
413  }
414  for (const x of at) add(x.c, sentenceAt(source, x.at))
415  return out
416}
417
418/** A reply text's claims in reading order, each once. */
419export function claimsIn(text: string, answer: string): Claim[] {
420  const seen = new Set<string>()
421  const out: Claim[] = []
422  for (const b of parseReply(text)) {
423    if (b.type !== 'rich') continue
424    for (const cl of blockClaims(b, answer)) if (!seen.has(cl.key) && seen.add(cl.key)) out.push(cl)
425  }
426  return out
427}
428
429/** A text with each citation as its shown words, for a line the analyst reads where no link is drawn (a preview: the
430 *  threads tree's answer row, the New thread view's passage, a caption, a `source` row): a chip (a citation with no
431 *  words of its own) as `[ card ]`, as the reply draws it (Matt, 2026-10-07), without the brackets main put around it;
432 *  `chips` false for a tool's own words in Claude Code's rows, where a chip reads as its words (`events.jsonl line 12`). */
433export function plainCites(text: string, chips = true): string {
434  // a citation written as a Markdown link reads as its `[[…]]` spelling first
435  const spans = withoutOwnParens(linksAsSpans(text))
436  return plainLinks(citations(spans).reduce((t, c) => t.replaceAll(c.raw, c.display ?? (chips ? chipText(c) : chipWords(c))), spans))
437}
438
439// ---------------------------------------------------------------------------------------- a cited record
440
441const QUOTE_MARKS = '"\'“”‘’'
442
443/** The words of a citation that quotes ([["a phrase"|ref]]), without their quote marks; '' for any other. */
444export function quotedWords(display: string | null): string {
445  const d = (display ?? '').trim()
446  return d.length > 2 && QUOTE_MARKS.includes(d[0]!) && QUOTE_MARKS.includes(d.at(-1)!) ? d.slice(1, -1) : ''
447}
448
449/** A line of a cited place cut to `cap` characters for keeping, around its first span (the shown value or the quoted
450 *  passage), the spans moved with it. */
451export function capLine<T extends { text: string; spans?: number[][] }>(w: T, cap = 1200): T {
452  if (w.text.length <= cap) return w
453  // each end cut at a word, as every cut (lib.ts windowAt)
454  const { text, shift } = windowAt(w.text, w.spans?.[0]?.[0] ?? 0, cap)
455  if (!w.spans) return { ...w, text }
456  return { ...w, text, spans: w.spans.map(([a, b]) => [a! - shift, b! - shift]).filter(([a, b]) => a! >= 0 && b! <= text.length) }
457}
458
459/** Where a quoted passage stands in a record's line as written: as is, JSON-escaped, or its words apart by any
460 *  whitespace or escaped line break; a quote whose inner quote marks are escaped also as unescaped. */
461export function quoteSpan(line: string, quote: string): [number, number] | null {
462  const escaped = (s: string) => JSON.stringify(s).slice(1, -1)
463  const ascii = (s: string) => escaped(s).replace(/[\u0080-￿]/g, ch => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`)
464  const lit = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
465  const q = quote.trim()
466  for (const quoted of new Set([q, q.replace(/\\(["'\\])/g, '$1')])) {
467    if (!quoted) return null
468    for (const form of [quoted, escaped(quoted), ascii(quoted)]) {
469      const at = line.indexOf(form)
470      if (at >= 0) return [at, at + form.length]
471    }
472    const words = quoted.split(/\s+/).map(w => `(?:${[...new Set([w, escaped(w), ascii(w)])].map(lit).join('|')})`)
473    const m = new RegExp(words.join('(?:\\s|\\\\[nrt])+')).exec(line)
474    if (m) return [m.index, m.index + m[0].length]
475  }
476  return null
477}
478
479/** A long line wrapped to `room` columns in at most `rows` rows, the rows around `span` when it does not fit; each row
480 *  with the part of `span` it holds. A row breaks at its last space (a word wider than the row breaks where the row
481 *  ends), and the spaces at a break are left out; each row keeps where it starts in `text`, so `span` stays in place. */
482export function wrapAround(text: string, span: [number, number] | null, room: number, rows: number): { text: string; hi: [number, number] | null }[] {
483  const n = Math.max(1, room)
484  const all: { at: number; text: string }[] = []
485  let at = 0
486  while (at < text.length || all.length === 0) {
487    if (text.length - at <= n) {
488      all.push({ at, text: text.slice(at) })
489      break
490    }
491    // the last space at or before the row's end; none, and the row ends mid-word
492    const sp = text.lastIndexOf(' ', at + n)
493    const end = sp > at ? sp : at + n
494    all.push({ at, text: text.slice(at, end).trimEnd() })
495    at = end
496    while (text[at] === ' ') at++
497  }
498  let first = 0
499  const spanRow = span ? Math.max(0, all.findLastIndex(r => r.at <= span[0])) : 0
500  if (all.length > rows && span) first = Math.max(0, Math.min(all.length - rows, spanRow - 1))
501  return all.slice(first, first + rows).map((r, i, shown) => {
502    const lo = span ? Math.max(span[0], r.at) - r.at : 0
503    const hi = span ? Math.min(span[1], r.at + r.text.length) - r.at : 0
504    const more = (i === 0 && first > 0 ? '…' : '') + r.text + (i === shown.length - 1 && first + rows < all.length ? '…' : '')
505    const shift = i === 0 && first > 0 ? 1 : 0
506    return { text: more, hi: span && hi > lo ? [lo + shift, hi + shift] : null }
507  })
508}
509
510/** What a pointer at (x, y) of a layout is over, other than a citation: a table's row, or the sentence of the word
511 *  there (the nearest word of the line, between words). */
512export function passageAt(lay: ParaLayout, x: number, y: number): { kind: 'row' | 'sentence'; text: string } | null {
513  if (lay.rows) {
514    const text = lay.rows[y] ?? ''
515    return text ? { kind: 'row', text } : null
516  }
517  const line = lay.words.filter(w => w.line === y)
518  if (!line.length) return null
519  const word = line.find(w => x >= w.x0 && x < w.x1) ?? line.reduce((a, b) => (Math.abs(b.x0 - x) < Math.abs(a.x0 - x) ? b : a))
520  const text = sentenceAt(lay.source, word.at)
521  return text ? { kind: 'sentence', text } : null
522}
523
524// ---------------------------------------------------------------------------------------- fix rounds
525
526export type Problem = { cite: Citation; why: string } | { card: string; why: string }
527
528/** The sentence of a text that holds a citation: within its line, without the line's Markdown lead (a list marker, a
529 *  heading's #, a quote's >), so the sentence put in its place keeps the lead; a table's row whole. */
530export function sentenceIn(text: string, raw: string): string {
531  const at = text.indexOf(raw)
532  if (at < 0) return ''
533  const start = text.lastIndexOf('\n', at - 1) + 1
534  const nl = text.indexOf('\n', at)
535  const line = text.slice(start, nl < 0 ? undefined : nl)
536  if (/^\s*\|/.test(line)) return line.trim()
537  const lead = /^\s*(?:(?:[-*+]|\d+[.)]|#{1,6})\s+|>\s*)*/.exec(line)?.[0].length ?? 0
538  return sentenceAt(line.slice(lead), at - start - lead)
539}
540
541/** The passages of a reply to correct: each problem citation's sentence (one item per sentence, however many of its
542 *  citations fail), and each card that cannot be drawn by its embed line. */
543export function fixItems(text: string, problems: Problem[]): ChatFixItem[] {
544  const items: ChatFixItem[] = []
545  for (const p of problems) {
546    const old = 'card' in p ? `[[card:${p.card}]]` : sentenceIn(text, p.cite.raw) || p.cite.raw
547    let it = items.find(x => x.old === old)
548    if (!it) {
549      it = { old, problems: [], cites: [] }
550      items.push(it)
551    }
552    if ('card' in p) {
553      it.card = p.card
554      it.problems.push({ raw: old, why: p.why })
555    } else {
556      it.cites.push(p.cite.raw)
557      it.problems.push({ raw: p.cite.raw, why: p.why })
558    }
559  }
560  return items
561}
562
563export type FixAnswer = { ok: true; text: string } | { ok: false; why: string }
564
565/** The fix round's answer for each of `n` items: its corrected text, or why it could not be corrected. */
566export function parseFix(answer: string, n: number): FixAnswer[] {
567  const got = new Map<number, string>()
568  for (const line of answer.split('\n')) {
569    const m = /^\s*(?:[-*]\s+)?(`?)(\d+)[:.)]\s*(.*)$/.exec(line)
570    if (!m) continue
571    let text = m[3]!.trim()
572    if (m[1] && text.endsWith('`')) text = text.slice(0, -1).trim()
573    const k = Number(m[2])
574    if (!got.has(k)) got.set(k, text)
575  }
576  return Array.from({ length: n }, (_, i): FixAnswer => {
577    const t = got.get(i + 1)
578    if (!t) return { ok: false, why: 'the fix gave no corrected text' }
579    const no = /^CANNOT\b[\s:,-]*(.*)$/i.exec(t)
580    if (no) return { ok: false, why: no[1]?.trim() || 'the fix could not correct it' }
581    return { ok: true, text: t.length > 2 && t.startsWith('"') && t.endsWith('"') ? t.slice(1, -1) : t }
582  })
583}
584
585/** A reply row's text with each passage corrected for that row (`row`, its uuid) in place of the old one, unmarked; a
586 *  correction made for another answer never applies, though the same sentence stands there. */
587export function applyCorrections(text: string, corrections: readonly ChatCorrection[], row: string): string {
588  let out = text
589  for (const c of corrections) {
590    if (c.row !== row || !c.old || !out.includes(c.old)) continue
591    out = out.replace(c.old, () => c.new)
592  }
593  return out
594}
595
596/** An answer's file: its heading and its rows, each with the corrections made for it. */
597export function answerFile(end: { rows: { id: string; text: string }[]; head: string }, corrections: readonly ChatCorrection[]): string {
598  return `# ${end.head}\n\n${end.rows.map(r => applyCorrections(r.text, corrections, r.id)).join('\n\n')}\n`
599}
600
601/** A side thread's answer with each corrected passage in place of the old one. */
602export function correctText(text: string, corrections: readonly { old: string; new: string }[]): string {
603  return corrections.reduce((out, c) => (c.old && out.includes(c.old) ? out.replace(c.old, () => c.new) : out), text)
604}
605
606// ---------------------------------------------------------------------------------------- marks kept across sessions
607
608/** What the chat draws on answers beyond their text, kept in a file so a resumed session draws the same: each answer's
609 *  end (its footer and rows) by its last row, each claim's verification and fix by its key, and the last answer's row. */
610export type Marks = { ends: Record<string, ChatEnd>; verify: Record<string, ChatVerify>; fixes: Record<string, ChatFix>; last: string }
611
612export const MARKS_CAP = 300
613
614export function emptyMarks(): Marks {
615  return { ends: {}, verify: {}, fixes: {}, last: '' }
616}
617
618const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
619
620/** Marks read back from their file; a verification or fix its session left running is marked ended. */
621export function parseMarks(raw: string): Marks {
622  let v: unknown
623  try {
624    v = JSON.parse(raw)
625  } catch {
626    return emptyMarks()
627  }
628  const out = emptyMarks()
629  if (!isObj(v)) return out
630  const ended = 'the session ended before it finished'
631  if (isObj(v.ends)) {
632    for (const [id, e] of Object.entries(v.ends)) {
633      if (isObj(e) && Array.isArray(e.rows) && e.rows.every(r => isObj(r) && typeof r.id === 'string' && typeof r.text === 'string') && typeof e.file === 'string') {
634        out.ends[id] = { rows: e.rows as ChatEnd['rows'], cards: Array.isArray(e.cards) ? e.cards.filter((c): c is string => typeof c === 'string') : [], file: e.file, head: typeof e.head === 'string' ? e.head : '', ...(typeof e.check === 'string' && e.check ? { check: e.check } : {}) }
635      }
636    }
637  }
638  if (isObj(v.verify)) {
639    for (const [id, r] of Object.entries(v.verify)) {
640      if (!isObj(r) || typeof r.state !== 'string' || typeof r.script !== 'string') continue
641      const run = { ...r, id } as ChatVerify
642      out.verify[id] = verifying(run.state) ? { ...run, state: 'error', stderr: run.stderr || ended } : run
643    }
644  }
645  if (isObj(v.fixes)) {
646    for (const [id, f] of Object.entries(v.fixes)) {
647      if (!isObj(f) || typeof f.state !== 'string') continue
648      out.fixes[id] = f.state === 'fixing' ? { state: 'failed', why: ended } : { state: f.state, ...(typeof f.why === 'string' ? { why: f.why } : {}) }
649    }
650  }
651  if (typeof v.last === 'string') out.last = v.last
652  return out
653}
654
655/** Marks as their file holds them: the newest `cap` of each kind. */
656export function marksJson(m: Marks, cap = MARKS_CAP): string {
657  const newest = <T>(r: Record<string, T>) => Object.fromEntries(Object.entries(r).slice(-cap))
658  return JSON.stringify({ ends: newest(m.ends), verify: newest(m.verify), fixes: newest(m.fixes), last: m.last })
659}
660
661/** One mark set, made the newest of its kind (so a cap drops the oldest). */
662export function setMark<K extends 'ends' | 'verify' | 'fixes'>(m: Marks, kind: K, id: string, value: Marks[K][string]): void {
663  const r = m[kind] as Record<string, unknown>
664  delete r[id]
665  r[id] = value
666}
667
668/** A text's words and numbers outside its citations. */
669function prose(s: string): string {
670  return citations(s)
671    .reduce((t, c) => t.replace(c.raw, ' '), s)
672    .replace(/[^\p{L}\p{N}]+/gu, '')
673}
674
675/** The card a card item names after its fix: the one its corrected embed line names, else its own. */
676export function fixedCard(it: ChatFixItem, g: FixAnswer): string {
677  return (g.ok ? EMBED_RE.exec(g.text.trim())?.slice(1).find(Boolean) : undefined) ?? it.card ?? ''
678}
679
680export type FixOutcome = { corrections: { old: string; new: string }[]; states: { state: 'fixed' | 'failed'; why?: string }[]; notes: string[] }
681
682/** What a fix round's answer comes to, item by item: a correction put in place when it is a whole sentence (not a bare
683 *  value) and everything it cites checks (or, for a card, when the card now draws), else the item stays red with why.
684 *  `verdict` is the check of a citation of a corrected text, `cardError` why a card cannot be drawn ('' when it can);
685 *  a passage not in `reply` cannot be corrected in place. */
686export function settleFix(
687  items: ChatFixItem[],
688  got: FixAnswer[],
689  verdict: (raw: string) => { status: string; why: string } | undefined,
690  cardError: (id: string) => string,
691  reply?: string,
692): FixOutcome {
693  const out: FixOutcome = { corrections: [], states: [], notes: [] }
694  items.forEach((it, i) => {
695    const g = got[i] ?? { ok: false, why: 'the fix gave no corrected text' }
696    const fail = (why: string, note: string) => {
697      out.states.push({ state: 'failed', why })
698      out.notes.push(`could not fix ${note}`)
699    }
700    if (it.card) {
701      const id = fixedCard(it, g)
702      const err = cardError(id)
703      if (err) return fail(g.ok ? `still: ${err}` : g.why, `${it.old}: ${g.ok ? err : g.why}`)
704      if (id !== it.card) out.corrections.push({ old: it.old, new: `[[card:${id}]]` })
705      out.states.push({ state: 'fixed' })
706      out.notes.push(id !== it.card ? `${it.old} is now [[card:${id}]]` : `${it.old} now draws`)
707      return
708    }
709    if (!g.ok) return fail(g.why, `"${it.old}": ${g.why}`)
710    if (reply !== undefined && !reply.includes(it.old)) return fail('the cited passage is not in the reply as written', `"${it.old}": it is not in the reply as written`)
711    if (prose(it.old) && !prose(g.text)) return fail('the fix gave a value, not the whole sentence', `"${it.old}": the fix gave a value, not the whole sentence`)
712    for (const c of citations(g.text)) {
713      const v = verdict(c.raw)
714      if (v?.status === 'missing' || v?.status === 'differs') return fail(`the correction still does not check (${c.raw}: ${v.why})`, `"${it.old}": the correction still does not check`)
715    }
716    out.corrections.push({ old: it.old, new: g.text })
717    out.states.push({ state: 'fixed' })
718    out.notes.push(`"${it.old}" now reads "${g.text}"`)
719  })
720  return out
721}
722
723// ---------------------------------------------------------------------------------------- while a reply streams
724
725/** One text block of a reply as it streams: what the model wrote (`raw`), what the engine was handed to show
726 *  (`shown`), and how far the finished lines go (`done`: their length in `raw`, `doneShown` as shown). */
727export type Streaming = { raw: string; shown: string; done: number; doneShown: string; fence: boolean }
728
729export function streaming(): Streaming {
730  return { raw: '', shown: '', done: 0, doneShown: '', fence: false }
731}
732
733/** How a streaming reply shows a citation and a card's embed line: `link` gives a citation's Markdown, `card` the
734 *  placeholder line of a card by its id. */
735export type StreamLook = { link: (c: Citation) => string; card: (id: string) => string }
736
737// the start of a line that may still turn out to be a card's embed line
738const EMBED_PREFIX = /^\s*(?:\[(?:\[(?:c(?:a(?:r(?:d(?::[A-Za-z0-9_-]*(?:\](?:\]\s*)?)?)?)?)?)?)?)?|!(?:\[.*)?)$/
739
740/** A citation as a Markdown link the engine underlines: its label escaped, to the file of its place. */
741export function streamLink(c: Citation, url: string): string {
742  return `[${citeLabel(c).replace(/[\\[\]*_`<>]/g, m => `\\${m}`)}](${url})`
743}
744
745/** A line outside a fence, each whole citation outside code drawn by `link`. On an unfinished line (`partial`), the text
746 *  from a citation or a code span that has not closed yet (it may still) is held back: `text` is what shows now. */
747function streamLine(line: string, partial: boolean, look: StreamLook): string {
748  let out = ''
749  let i = 0
750  while (i < line.length) {
751    const ch = line[i]!
752    if (ch === '`') {
753      const j = line.indexOf('`', i + 1)
754      if (j >= 0) {
755        out += line.slice(i, j + 1)
756        i = j + 1
757        continue
758      }
759      if (partial) return out
760    } else if (ch === '[' && line[i + 1] === '[') {
761      const end = citeEnd(line, i)
762      const c = end >= 0 ? citations(line.slice(i, end))[0] : undefined
763      if (c) {
764        out += look.link(c)
765        i = end
766        continue
767      }
768      // a citation still being written, whose value may hold brackets
769      if (partial && end < 0) return out
770    } else if (ch === '[' && partial && i === line.length - 1) return out
771    out += ch
772    i++
773  }
774  return out
775}
776
777/** A finished line as it shows: a fence's lines as written, a card's embed line as its placeholder, else its citations
778 *  as links. */
779function streamDone(st: Streaming, line: string, look: StreamLook): string {
780  if (/^\s*```/.test(line)) {
781    st.fence = !st.fence
782    return line
783  }
784  if (st.fence) return line
785  const embed = EMBED_RE.exec(line)
786  if (embed) return look.card((embed[1] ?? embed[2])!)
787  return streamLine(line, false, look)
788}
789
790/** More of a block's text arrived (or, with `end`, the block is whole): the text to hand the engine now, so that what
791 *  it shows never holds a citation's raw spelling. Finished lines are final; of the line still being written, a
792 *  citation, code span or embed line not yet closed waits. */
793export function streamStep(st: Streaming, more: string, end: boolean, look: StreamLook): string {
794  st.raw += more
795  let nl = st.raw.indexOf('\n', st.done)
796  while (nl >= 0) {
797    st.doneShown += `${streamDone(st, st.raw.slice(st.done, nl), look)}\n`
798    st.done = nl + 1
799    nl = st.raw.indexOf('\n', st.done)
800  }
801  const rest = st.raw.slice(st.done)
802  let now = st.doneShown
803  if (end) now += rest ? streamDone(st, rest, look) : ''
804  else if (st.fence) now += rest
805  else if (!EMBED_PREFIX.test(rest)) now += streamLine(rest, true, look)
806  // what was handed over stands (the record is put back as written at its append)
807  if (!now.startsWith(st.shown)) return ''
808  const out = now.slice(st.shown.length)
809  st.shown = now
810  return out
811}
812
813// ---------------------------------------------------------------------------------------- a rich block as Markdown
814
815/** A run as Markdown: code, bold and italic kept; a citation drawn by `link` with its `n` (its place among the
816 *  block's citations). */
817function runMarkdown(r: Run, n: number, link: (c: Citation, n: number) => string, cell: boolean): string {
818  if (r.cite) return link(r.cite, n)
819  let s = r.code ? `\`${r.text}\`` : cell ? r.text.replace(/\|/g, '\\|') : r.text
820  if (r.i) s = `*${s}*`
821  if (r.b) s = `**${s}**`
822  return s
823}
824
825/** A block that holds citations (a paragraph, heading, list item, quote or table) as
826 *  Markdown the engine draws, each citation a link, so its text selects like any reply's and a plain click on a
827 *  citation is a press. */
828export function richMarkdown(block: { prefix: string; heading: number; quote: boolean; runs: Run[]; table?: TableRuns }, link: (c: Citation, n: number) => string): string {
829  let n = 0
830  const md = (runs: Run[], cell = false) => runs.map(r => runMarkdown(r, r.cite ? n++ : -1, link, cell)).join('')
831  if (block.table) {
832    const [head = [], ...body] = block.table.rows
833    const cols = Math.max(1, ...block.table.rows.map(r => r.length))
834    const row = (cells: Run[][]) => `| ${Array.from({ length: cols }, (_, i) => md(cells[i] ?? [], true).trim() || ' ').join(' | ')} |`
835    const rule = `| ${Array.from({ length: cols }, (_, i) => ({ left: '---', right: '--:', center: ':-:' })[block.table!.align[i] ?? 'left']).join(' | ')} |`
836    return [row(head), rule, ...body.map(row)].join('\n')
837  }
838  const text = md(block.runs)
839  const lead = block.heading ? `${'#'.repeat(block.heading)} ` : block.prefix
840  return block.quote ? text.split('\n').map(l => `> ${l}`).join('\n') : `${lead}${text}`
841}
842
hooks/draw.ts 2069 lines
1// Layouts as styled lines, shared by the surface modules (the interactive chips and cards) and the hooks module (the
2// static drawing where no Client runs). Each layout takes the width it may use and the item under the pointer, and
3// returns its lines and a hit test from a cell to an item. The `note` and `text` kinds (noteLayout, textLayout) are the
4// cards drawn as their words.
5import { cut, cw, fmt, formatted, quoted, width } from './lib'
6import type { Run, TableRuns } from './lib'
7import { COLORS } from './paint'
8import { hueOf } from './labels'
9
10export { COLORS }
11
12export type Seg = { s: string; fg?: string; bg?: string; b?: boolean; d?: boolean; i?: boolean; u?: boolean; inv?: boolean }
13export type Line = Seg[]
14
15export type BarRow = { label: string; value: number; group: string }
16export type Cell = string | number | boolean | null
17export type CardParam = { name: string; value: string | number; default: string | number; choices: (string | number)[] }
18export type DiagramNode = { id: string; label: string; ref?: string; detail?: string }
19export type DiagramEdge = { source: string; target: string; label?: string }
20/** A label a card's script read (tcard.label): its values, and the value of each record the card names (`marks`, by
21 *  ref). `stale`: the label changed after the card was made (register.tsx sets it). */
22export type CardLabel = { slug: string; name: string; values: string[]; marks?: Record<string, string>; stale?: boolean; colors?: Record<string, number> }
23/** A label card's own label (cell.ts labelCard, from `thimble state label`): how its records were labeled and how many. */
24export type LabelInfo = { slug: string; name: string; kind: string; values: string[]; labeled: number; total: number; trial: boolean; paths?: string[]; colors?: Record<string, number> }
25/** An example: its record, its words, and on a label card its value now, why, and whether the analyst set or agreed
26 *  with it (`set`; `was` the value the label gave). */
27export type CardExample = { ref: string; quote: string; note: string; value?: string; why?: string; set?: boolean; was?: string }
28export type CardData = {
29  id: string
30  kind: string
31  question: string
32  x: string
33  y: string
34  note: string
35  source: { script?: string; sha1?: string; index?: number }
36  params?: CardParam[]
37  rows?: (BarRow | Cell[])[]
38  total?: number
39  columns?: string[]
40  series?: { name: string; points: [string | number, number][] }[]
41  events?: { time: string; label: string; ref: string; shown?: string }[]
42  examples?: CardExample[]
43  nodes?: DiagramNode[]
44  edges?: DiagramEdge[]
45  labels?: CardLabel[]
46  label?: LabelInfo
47  /** a table's number formats by column (the frame's `view.formats`, backend frames.py) */
48  formats?: Record<string, string>
49}
50
51/** What the mod is doing to a card now: running its script, or why the last run failed. */
52export type CardMeta = { busy?: string; error?: string }
53
54/** What a pointer can pick on a card: what the readout says, the citation a click puts in the prompt, the place it
55 *  opens, and how it reaches the gestures (`kind` and `text`, the shown value or words, of its Target). */
56export type Item = { label: string; value: string; cite: string; open: string; kind: 'mark' | 'row' | 'record' | 'node'; text: string }
57
58export type Layout = { lines: Line[]; items: Item[]; hit: (x: number, y: number) => number }
59
60/** The first palette hue: the marks of a chart with no colour field, which is one series (SPEC.md, "The visual
61 *  system", rule 12). */
62export const ONE = COLORS.series[0]!
63
64// ---------------------------------------------------------------------------------------- text width
65
66// the cells a character takes, a string's width and the one cut of thimble-term (lib.ts)
67export { cut, cw, width }
68
69// a byte of a UTF-8 sequence after its first, as Windows-1252 shows it
70const CP1252: Record<number, number> = { 0x20ac: 0x80, 0x201a: 0x82, 0x192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x2c6: 0x88, 0x2030: 0x89, 0x160: 0x8a, 0x2039: 0x8b, 0x152: 0x8c, 0x17d: 0x8e, 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x2dc: 0x98, 0x2122: 0x99, 0x161: 0x9a, 0x203a: 0x9b, 0x153: 0x9c, 0x17e: 0x9e, 0x178: 0x9f }
71const CONT = '[\\u0080-\\u00bf\\u0152\\u0153\\u0160\\u0161\\u0178\\u017d\\u017e\\u0192\\u02c6\\u02dc\\u2013\\u2014\\u2018-\\u201a\\u201c-\\u201e\\u2020-\\u2022\\u2026\\u2030\\u2039\\u203a\\u20ac\\u2122]'
72const MOJIBAKE = new RegExp(`[\\u00c2-\\u00df]${CONT}|[\\u00e0-\\u00ef]${CONT}{2}|[\\u00f0-\\u00f4]${CONT}{3}`, 'g')
73
74/**
75 * Text whose UTF-8 bytes were once read as Windows-1252 or Latin-1 and saved again ("möchten"), each such character
76 * back as written ("möchten"); a run that is not one UTF-8 character is kept. For display only.
77 */
78export function demojibake(s: string): string {
79  if (!/[\u00c2-\u00f4]/.test(s)) return s
80  return s.replace(MOJIBAKE, run => {
81    const bytes = [...run].map(ch => CP1252[ch.codePointAt(0)!] ?? ch.codePointAt(0)!)
82    const lead = bytes[0]!
83    const n = bytes.length
84    let cp = lead & (n === 2 ? 0x1f : n === 3 ? 0x0f : 0x07)
85    for (const b of bytes.slice(1)) {
86      if ((b & 0xc0) !== 0x80) return run
87      cp = (cp << 6) | (b & 0x3f)
88    }
89    // an overlong form, a surrogate or past Unicode is not a character UTF-8 writes
90    const least = n === 2 ? 0x80 : n === 3 ? 0x800 : 0x10000
91    if (cp < least || (cp >= 0xd800 && cp <= 0xdfff) || cp > 0x10ffff) return run
92    return String.fromCodePoint(cp)
93  })
94}
95
96/** `s` in at most two lines of `n` columns: broken at the last space that fits, else as wrapLabel breaks a word; the
97 *  second line cut. */
98export function fold(s: string, n: number): string[] {
99  const t = s.replace(/\s+/g, ' ').trim()
100  if (width(t) <= n) return [t]
101  let w = 0
102  let sp = -1
103  let i = 0
104  for (const ch of t) {
105    if (w + cw(ch) > n) break
106    if (ch === ' ' && w > n / 3) sp = i
107    w += cw(ch)
108    i += ch.length
109  }
110  if (t[i] === ' ') sp = i
111  if (sp < 0) return wrapLabel(t, n)
112  return [t.slice(0, sp), cut(t.slice(sp + 1), n)]
113}
114
115/** `s` in at most `max` lines of `n` columns, each broken at the last space that fits (a word wider than a line broken
116 *  where the line ends); the last line cut with `…` when words are left. */
117export function wrapRows(s: string, n: number, max: number): string[] {
118  if (max <= 1) return [cut(s.replace(/\s+/g, ' ').trim(), n)]
119  const words = s.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
120  const lines: string[] = []
121  let cur = ''
122  for (let i = 0; i < words.length; i++) {
123    let word = words[i]!
124    const next = cur ? `${cur} ${word}` : word
125    if (width(next) <= n) {
126      cur = next
127      continue
128    }
129    if (cur) {
130      lines.push(cur)
131      cur = ''
132    }
133    // a word wider than a line: its cells up to the line's end, the rest on the next
134    while (width(word) > n && lines.length < max - 1) {
135      let k = 0
136      let w = 0
137      for (const ch of word) {
138        if (w + cw(ch) > n) break
139        w += cw(ch)
140        k += ch.length
141      }
142      lines.push(word.slice(0, k))
143      word = word.slice(k)
144    }
145    cur = word
146    if (lines.length >= max - 1) {
147      // the last line takes the rest
148      cur = [cur, ...words.slice(i + 1)].join(' ')
149      break
150    }
151  }
152  if (cur) lines.push(lines.length >= max - 1 ? cut(cur, n) : cur)
153  return lines.slice(0, max)
154}
155
156export function pad(s: string, n: number, right = false): string {
157  const c = cut(s, n)
158  const fill = ' '.repeat(Math.max(0, n - width(c)))
159  return right ? fill + c : c + fill
160}
161
162export function lineWidth(l: Line): number {
163  return l.reduce((n, s) => n + width(s.s), 0)
164}
165
166/** A legend's entries in as many lines of at most `cols` columns as they need, two spaces between entries on a line;
167 *  an entry wider than a line alone has its last segment cut. */
168function flow(entries: readonly Line[], cols: number): Line[] {
169  const lines: Line[] = []
170  let cur: Line = []
171  let w = 0
172  for (const entry of entries) {
173    let e = entry
174    let ew = lineWidth(e)
175    if (ew > cols && e.length) {
176      const last = e.at(-1)!
177      e = [...e.slice(0, -1), { ...last, s: cut(last.s, Math.max(1, cols - (ew - width(last.s)))) }]
178      ew = lineWidth(e)
179    }
180    if (cur.length && w + 2 + ew > cols) {
181      lines.push(cur)
182      cur = []
183      w = 0
184    }
185    if (cur.length) {
186      cur.push({ s: '  ' })
187      w += 2
188    }
189    cur.push(...e)
190    w += ew
191  }
192  if (cur.length) lines.push(cur)
193  return lines
194}
195
196/** `lines` with a background on the cells [x0, x1) of each span's line, segments split where a span starts or ends. */
197export function shade(lines: readonly Line[], spans: readonly { line: number; x0: number; x1: number }[], bg: string): Line[] {
198  return lines.map((l, y) => {
199    const mine = spans.filter(s => s.line === y && s.x1 > s.x0)
200    if (!mine.length) return l
201    const on = (x: number) => mine.some(s => x >= s.x0 && x < s.x1)
202    const out: Line = []
203    let x = 0
204    for (const seg of l) {
205      let run = ''
206      let runOn = false
207      const flush = () => {
208        if (run) out.push(runOn ? { ...seg, s: run, bg } : { ...seg, s: run })
209        run = ''
210      }
211      for (const ch of seg.s) {
212        const o = on(x)
213        if (run && o !== runOn) flush()
214        runOn = o
215        run += ch
216        x += cw(ch)
217      }
218      flush()
219    }
220    return out
221  })
222}
223
224// ---------------------------------------------------------------------------------------- cards
225
226const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
227
228export function bar(v: number, max: number, w: number): string {
229  if (max <= 0 || w <= 0) return ''
230  const n8 = Math.max(v !== 0 ? 1 : 0, Math.round((Math.abs(v) / max) * w * 8))
231  return '█'.repeat(Math.floor(n8 / 8)) + EIGHTHS[n8 % 8]!
232}
233
234function compact(v: number): string {
235  const a = Math.abs(v)
236  if (a >= 1e9) return `${fmt(+(v / 1e9).toFixed(1))}B`
237  if (a >= 1e6) return `${fmt(+(v / 1e6).toFixed(1))}M`
238  if (a >= 1e5) return `${fmt(+(v / 1e3).toFixed(0))}k`
239  if (a >= 1e4) return `${fmt(+(v / 1e3).toFixed(1))}k`
240  return amount(+v.toPrecision(4))
241}
242
243/** A number as the mod draws a count: thousands separators from 1,000 on a whole number, else as written. */
244export function amount(v: number): string {
245  return Number.isInteger(v) && Math.abs(v) >= 1000 ? v.toLocaleString('en-US') : fmt(v)
246}
247
248function cite(display: string, ref: string): string {
249  return `[[${display}|${ref}]]`
250}
251
252export const MAX_BARS = 30
253export const MAX_TABLE_ROWS = 15
254
255// ---------------------------------------------------------------------------------------- labels on cards
256
257/** A count with thousands separators, as the label panel writes it. */
258export function count(n: number): string {
259  return n.toLocaleString('en-US')
260}
261
262/** A share in whole percent, so shares side by side read alike (`7%` beside `93%`): one decimal under 1%, `<0.1%` below
263 *  that, and `>99%` for a part that rounds to the whole. */
264export function share(a: number, b: number): string {
265  if (!b || !a) return '0%'
266  const p = (100 * a) / b
267  if (p < 0.1) return '<0.1%'
268  if (p < 1) return `${p.toFixed(1)}%`
269  const r = Math.round(p)
270  return r >= 100 && a < b ? '>99%' : `${r}%`
271}
272
273/** The shares of parts of a whole, each in whole percent as `share` writes it, those of 1% or more rounded so they add
274 *  up to 100% when the parts make the whole (largest remainders; live check term-fix9, low quirk: a label card showed
275 *  38% and 63%). */
276export function shares(parts: readonly number[], total: number): string[] {
277  const out = parts.map(a => share(a, total))
278  if (!total || parts.reduce((a, b) => a + b, 0) !== total) return out
279  // only parts all written in whole percent: a share under 1% or over 99% keeps its own words (`0.4%`, `>99%`)
280  const ps = parts.map(a => (100 * a) / total)
281  if (ps.some(p => p > 0 && (p < 1 || p >= 99.5))) return out
282  const r = ps.map(p => Math.floor(p))
283  const order = ps.map((p, i) => ({ i, rest: p - Math.floor(p) })).filter(x => x.rest > 0).sort((a, b) => b.rest - a.rest)
284  for (let k = 0, left = 100 - r.reduce((a, b) => a + b, 0); k < order.length && left > 0; k++, left--) r[order[k]!.i]!++
285  return ps.map((p, i) => (p > 0 ? `${r[i]}%` : out[i]!))
286}
287
288/** The color of a label's value, as the label panel draws it: the color its class has (`colors`, labels.ts hueOf: the
289 *  browser's label colors, the analyst's choice among them, 0 dim), else the categorical palette in the label's order,
290 *  and the last value of two or more, the one that is not the category, dim. */
291export function valueColour(values: readonly string[], value: string, colors?: Record<string, number>): string | undefined {
292  const i = values.indexOf(value)
293  if (i < 0) return undefined
294  if (colors && typeof colors[value] === 'number') return hueOf(colors[value]!)
295  return values.length > 1 && i === values.length - 1 ? COLORS.dim : COLORS.series[i % COLORS.series.length]
296}
297
298/** The labels a card shows: a label card's own, else those its script read. */
299export function cardLabels(card: CardData): CardLabel[] {
300  if (card.kind === 'label' && card.label) return [{ slug: card.label.slug, name: card.label.name, values: card.label.values, ...(card.label.colors ? { colors: card.label.colors } : {}) }]
301  return card.labels ?? []
302}
303
304/** The colour a mark takes from the labels the card read: its record's value (`ref`), else the value its name is. */
305function classColour(card: CardData, name?: string, ref?: string): string | undefined {
306  for (const l of cardLabels(card)) {
307    const v = (ref ? l.marks?.[ref] : undefined) ?? (name !== undefined && l.values.includes(name) ? name : undefined)
308    if (v !== undefined) return valueColour(l.values, v, l.colors)
309  }
310  return undefined
311}
312
313/** A place as the analyst reads it: `revisions.jsonl line 10566`, `lines 3-8`, `row 12`, `results.json item 4`, `the
314 *  command's output line 3`; never `#L` or a command's id. A card's place is named by its question by the caller. */
315export function placeWords(ref: string): string {
316  const call = /^call:[A-Za-z0-9_-]+(?:#L(\d+)(?:-L?(\d+))?)?$/.exec(ref)
317  if (call) return `the command's output${call[1] ? ` ${call[2] && call[2] !== call[1] ? `lines ${call[1]}-${call[2]}` : `line ${call[1]}`}` : ''}`
318  const at = ref.indexOf('#')
319  if (at < 0) return ref
320  const path = ref.slice(0, at)
321  const frag = ref.slice(at + 1)
322  // a line, a range of lines, or a passage of a line (`L2.b0:c0-120`, a block of a JSON line and its characters), which
323  // reads as its line
324  const lines = /^L(\d+)(?:-L?(\d+)|\.b\d+(?::c\d+-\d+)?)?$/.exec(frag)
325  if (lines) return `${path} ${lines[2] && lines[2] !== lines[1] ? `lines ${lines[1]}-${lines[2]}` : `line ${lines[1]}`}`
326  const row = /^row=(\d+)$/.exec(frag)
327  if (row) return `${path} row ${row[1]}`
328  // a JSON list's item, counted from 1 as the file's view counts them
329  const item = /^\/(?:[^/]+\/)?(\d+)$/.exec(frag)
330  if (item && !path.startsWith('card:')) return `${path} item ${Number(item[1]) + 1}`
331  return ref
332}
333
334/** A place in words in `n` columns: its file's path cut, its line or row kept ("revisi… line 10904"). */
335export function cutRef(ref: string, n: number): string {
336  const words = placeWords(ref)
337  if (width(words) <= n) return words
338  const frag = / (?:line|lines|row) [\d-]+$/.exec(words)?.[0] ?? ''
339  return frag && n - width(frag) >= 4 ? `${cut(words.slice(0, words.length - frag.length), n - width(frag))}${frag}` : cut(words, n)
340}
341
342/**
343 * The card's label rows, under its title, one per label: "label" dim, the label's name in blue and underlined, then a
344 * blue ↗ (a press on either opens the label in the panel), then its values each after a ● in its hue. A label card's
345 * row stops after the ↗, since its bars name the values. `hover`: the slug of the label under the pointer, its name and
346 * ↗ in inverse. `hots`: where each row's name and ↗ stand, the cells a press opens the label from.
347 */
348export function labelHead(card: CardData, cols: number, hover = ''): { lines: Line[]; slugs: string[]; hots: { x0: number; x1: number }[] } {
349  const lines: Line[] = []
350  const slugs: string[] = []
351  const hots: { x0: number; x1: number }[] = []
352  for (const l of cardLabels(card)) {
353    const on = l.slug === hover
354    const head: Line = [{ s: 'label  ', fg: COLORS.dim }]
355    const nameW = Math.max(8, Math.min(width(l.name), Math.floor(cols * 0.55)))
356    const x0 = lineWidth(head)
357    head.push({ s: cut(l.name, nameW), fg: COLORS.link, u: true, ...(on ? { inv: true } : {}) }, { s: ' ' }, { s: '↗', fg: COLORS.link, ...(on ? { inv: true } : {}) })
358    hots.push({ x0, x1: lineWidth(head) })
359    const tail: Line = []
360    if (!(card.kind === 'label' && card.label)) {
361      const room = cols - lineWidth(head) - (l.stale ? 16 : 0)
362      let w = 0
363      l.values.forEach((v, i) => {
364        const entry = width(v) + 4
365        if (w < 0) return
366        const left = l.values.length - i - 1
367        if (w + entry + (left ? 4 : 0) > room) {
368          tail.push({ s: `  +${l.values.length - i}`, fg: COLORS.dim })
369          w = -1
370          return
371        }
372        tail.push({ s: '  ' }, { s: '●', fg: valueColour(l.values, v, l.colors) }, { s: ` ${v}` })
373        w += entry
374      })
375    }
376    if (l.stale) tail.push({ s: '  changed since', fg: COLORS.dim })
377    const line = [...head, ...tail]
378    lines.push(lineWidth(line) > cols && tail.length ? [...head, { s: cut(tail.map(x => x.s).join(''), Math.max(1, cols - lineWidth(head))), fg: COLORS.dim }] : line)
379    slugs.push(l.slug)
380  }
381  return { lines, slugs, hots }
382}
383
384/** The bars a bar card draws: every row of its first MAX_BARS labels, in the order the rows come (cell.ts sortedBars),
385 *  and how many labels are left out. A label's rows of several groups (the chart's colour field) stand on the label's
386 *  one row, stacked, as the browser stacks them. The layout's items are these rows, in this order. */
387export function barRows(card: CardData): { rows: BarRow[]; more: number } {
388  const all = (card.rows ?? []) as BarRow[]
389  const labels = [...new Set(all.map(r => r.label))]
390  const keep = new Set(labels.slice(0, MAX_BARS))
391  return { rows: all.filter(r => keep.has(r.label)), more: labels.length - keep.size }
392}
393
394/** A bar's label as the browser's axis draws it: timestamps all in one form (`24 May`, the time only when one is not
395 *  midnight, shortTimes); any other label as written. */
396function barNames(labels: readonly string[]): string[] {
397  return labels.length && labels.every(l => STAMP.test(l.trim())) ? shortTimes(labels) : [...labels]
398}
399
400function barLayout(card: CardData, cols: number, hover: number): Layout {
401  const { rows, more } = barRows(card)
402  const col = card.y || 'value'
403  const groups = [...new Set(rows.map(r => r.group).filter(Boolean))]
404  // one row per label, the label's rows (one per group) stacked on it in the data's order
405  const labels = [...new Set(rows.map(r => r.label))]
406  const ofLabel = new Map<string, number[]>()
407  rows.forEach((r, i) => ofLabel.set(r.label, [...(ofLabel.get(r.label) ?? []), i]))
408  const names = barNames(labels)
409  const nameOf = new Map(labels.map((l, j) => [l, names[j]!]))
410  const totals = labels.map(l => ofLabel.get(l)!.reduce((a, i) => a + rows[i]!.value, 0))
411  // a label card's counts as its panel writes them, each with its share of all
412  const own = card.kind === 'label'
413  const sum = own ? (card.total ?? rows.reduce((a, r) => a + r.value, 0)) : 0
414  // a count reads with thousands separators from 1,000, as everywhere the mod draws one
415  const shown = (v: number) => (own || (Number.isInteger(v) && Math.abs(v) >= 1000) ? count(v) : fmt(v))
416  const parts = own ? shares(totals, sum) : []
417  const shareW = own ? Math.max(...parts.map(x => x.length)) + 2 : 0
418  const valueW = Math.max(...totals.map(t => shown(t).length), 1)
419  // 2-cell gutters after the names and before the numbers (rule 3)
420  const room = cols - valueW - 4 - shareW
421  // the bars keep two fifths of the room; a label longer than the rest takes two lines
422  const labelW = Math.min(Math.max(4, ...names.map(n => width(n))), Math.max(8, room - Math.max(12, Math.ceil(room * 0.4))))
423  const barW = Math.max(4, room - labelW)
424  const max = Math.max(...totals.map(t => Math.abs(t)), 0)
425  const lines: Line[] = []
426  const owner: number[] = []
427  // where each label's bars stand on its row: [x0, x1) per row of the data, from the content's edge
428  const spans: { x0: number; x1: number; i: number }[][] = []
429  const items: Item[] = rows.map(r => {
430    // a stacked bar's readout names its group too (`24 May · page saved`)
431    const name = nameOf.get(r.label) ?? r.label
432    const group = groups.length > 1 && r.group && r.group !== r.label ? ` · ${r.group}` : ''
433    return {
434      label: `${name}${group}`,
435      value: `${shown(r.value)} ${col}`,
436      cite: cite(fmt(r.value), `card:${card.id}#${col}/${r.label}`),
437      open: `card:${card.id}#${col}/${r.label}`,
438      kind: 'mark',
439      text: shown(r.value),
440    }
441  })
442  labels.forEach((l, j) => {
443    const ids = ofLabel.get(l)!
444    const on = ids.includes(hover)
445    // a hue for a value of the card's colour field (its groups, or a label it read); with no colour field the bars are
446    // one series, in the first hue (rule 20); the bar under the pointer turns the text colour
447    const hueOf = (r: BarRow) => classColour(card, r.group || r.label) ?? (groups.length ? COLORS.series[Math.max(0, groups.indexOf(r.group)) % COLORS.series.length]! : ONE)
448    const segs: Seg[] = []
449    const mine: { x0: number; x1: number; i: number }[] = []
450    let x = labelW + 2
451    if (ids.length === 1) {
452      const b = bar(rows[ids[0]!]!.value, max, barW)
453      segs.push({ s: b, fg: on ? COLORS.text : hueOf(rows[ids[0]!]!) })
454      mine.push({ x0: x, x1: x + width(b), i: ids[0]! })
455    } else {
456      // stacked: each group's part in whole cells, the last in eighths, so the bar is as long as its total's
457      let cells = 0
458      ids.forEach((i, k) => {
459        const v = Math.abs(rows[i]!.value)
460        let s: string
461        if (k < ids.length - 1) {
462          const n = max > 0 ? Math.max(v ? 1 : 0, Math.round((v / max) * barW)) : 0
463          s = '█'.repeat(n)
464          cells += n
465        } else {
466          const n8 = max > 0 ? Math.max(cells * 8 + (v ? 1 : 0), Math.round((Math.abs(totals[j]!) / max) * barW * 8)) - cells * 8 : 0
467          s = '█'.repeat(Math.floor(n8 / 8)) + EIGHTHS[n8 % 8]!
468        }
469        if (!s) return
470        segs.push({ s, fg: i === hover ? COLORS.text : hueOf(rows[i]!) })
471        mine.push({ x0: x, x1: x + width(s), i })
472        x += width(s)
473      })
474    }
475    const b = segs.map(g => g.s).join('')
476    const [first, second] = fold(names[j]!, labelW)
477    // the label of the mark under the pointer in inverse; a label card's bars are parts of a whole, on a track to it
478    const track = own ? '─'.repeat(Math.max(0, barW - width(b))) : ''
479    lines.push([
480      { s: first!, inv: on },
481      { s: ' '.repeat(Math.max(0, labelW - width(first!)) + 2) },
482      ...segs,
483      ...(track ? [{ s: track, fg: COLORS.rule }] : []),
484      { s: ' '.repeat(Math.max(2, barW - width(b) - width(track) + 2)) },
485      { s: pad(shown(totals[j]!), valueW, true) },
486      ...(own ? [{ s: pad(parts[j]!, shareW, true), fg: COLORS.dim }] : []),
487    ])
488    owner.push(j)
489    spans.push(mine)
490    if (second) {
491      lines.push([{ s: second, inv: on }])
492      owner.push(j)
493    }
494  })
495  if (more > 0) lines.push([{ s: `… ${more} more`, fg: COLORS.dim }])
496  if (card.total !== undefined) lines.push([{ s: 'all  ', fg: COLORS.dim }, { s: shown(card.total) }])
497  // a label card's state while no run of it ended: `◌ labeling 3,000 of 4,579`, `stopped at 3,150 of 4,579`
498  if (own && card.note) lines.push(card.note.startsWith('◌') ? [{ s: card.note }] : [{ s: card.note, fg: COLORS.dim }])
499  // the legend on its own row under the chart; none when a label it read names the groups on its label row
500  const named = new Set(cardLabels(card).flatMap(l => l.values))
501  if (groups.length && !groups.every(g => named.has(g))) lines.push(...flow(groups.map((g, j) => [{ s: '● ', fg: classColour(card, g) ?? COLORS.series[j % COLORS.series.length] }, { s: g }]), cols))
502  // a row's one bar is the row's mark; on a stacked row the part under the pointer, the nearest part off the bar
503  const hit = (x: number, y: number): number => {
504    const j = owner[y]
505    if (j === undefined) return -1
506    const mine = spans[j]!
507    if (mine.length <= 1) return mine[0]?.i ?? ofLabel.get(labels[j]!)![0]!
508    const inside = mine.find(m => x >= m.x0 && x < m.x1)
509    if (inside) return inside.i
510    return x < mine[0]!.x0 ? mine[0]!.i : mine.at(-1)!.i
511  }
512  return { lines, items, hit }
513}
514
515const DOT = [
516  [0x01, 0x08],
517  [0x02, 0x10],
518  [0x04, 0x20],
519  [0x40, 0x80],
520]
521
522function xNumber(v: string | number, kind: 'num' | 'time' | 'cat', i: number): number {
523  if (kind === 'num') return Number(v)
524  if (kind === 'time') return Date.parse(String(v).replace(' ', 'T'))
525  return i
526}
527
528function lineLayout(card: CardData, cols: number, hover: number, plotRows = 10): Layout {
529  const series = (card.series ?? []).filter(s => s.points.length > 0)
530  const all = series.flatMap(s => s.points)
531  const xs = all.map(p => p[0])
532  const kind: 'num' | 'time' | 'cat' = xs.every(x => typeof x === 'number' || (typeof x === 'string' && x.trim() !== '' && !Number.isNaN(Number(x))))
533    ? 'num'
534    : xs.every(x => typeof x === 'string' && /^\d{4}-\d{2}(-\d{2})?([ T]\d{2}:\d{2}(:\d{2})?)?/.test(x) && !Number.isNaN(Date.parse(x.replace(' ', 'T'))))
535      ? 'time'
536      : 'cat'
537  // categorical x: one position per distinct value, in first-seen order
538  const cats = kind === 'cat' ? [...new Set(xs.map(String))] : []
539  const xOf = (p: [string | number, number], i: number) => (kind === 'cat' ? cats.indexOf(String(p[0])) : xNumber(p[0], kind, i))
540  const xv = series.flatMap(s => s.points.map((p, i) => xOf(p, i)))
541  const yv = all.map(p => p[1])
542  const x0 = Math.min(...xv)
543  const x1 = Math.max(...xv)
544  let y0 = Math.min(...yv, 0 < Math.min(...yv) && Math.min(...yv) < Math.max(...yv) * 0.3 ? 0 : Math.min(...yv))
545  let y1 = Math.max(...yv)
546  if (y0 === y1) {
547    y0 -= 1
548    y1 += 1
549  }
550  const labels = [compact(y1), compact((y0 + y1) / 2), compact(y0)]
551  const yW = Math.max(...labels.map(l => l.length)) + 1
552  const pw = Math.max(10, cols - yW - 1)
553  const W = pw * 2
554  const H = plotRows * 4
555  const bits: number[][] = Array.from({ length: plotRows }, () => new Array<number>(pw).fill(0))
556  const owner: number[][] = Array.from({ length: plotRows }, () => new Array<number>(pw).fill(-1))
557  const px = (x: number) => (x1 === x0 ? Math.floor(W / 2) : Math.round(((x - x0) / (x1 - x0)) * (W - 1)))
558  const py = (y: number) => Math.round(((y1 - y) / (y1 - y0)) * (H - 1))
559  const plot = (dx: number, dy: number, s: number) => {
560    if (dx < 0 || dy < 0 || dx >= W || dy >= H) return
561    const cx = dx >> 1
562    const cy = dy >> 2
563    bits[cy]![cx]! |= DOT[dy & 3]![dx & 1]!
564    owner[cy]![cx] = s
565  }
566  series.forEach((s, si) => {
567    const pts = s.points.map((p, i) => [px(xOf(p, i)), py(p[1])] as const).sort((a, b) => a[0] - b[0])
568    for (let i = 0; i < pts.length; i++) {
569      const [ax, ay] = pts[i]!
570      if (i === 0 || pts.length === 1) plot(ax, ay, si)
571      if (i === 0) continue
572      const [bx0, by0] = pts[i - 1]!
573      const steps = Math.max(Math.abs(ax - bx0), Math.abs(ay - by0), 1)
574      for (let t = 0; t <= steps; t++) plot(Math.round(bx0 + ((ax - bx0) * t) / steps), Math.round(by0 + ((ay - by0) * t) / steps), si)
575    }
576  })
577  // the point nearest the pointer's column, per series; items are every point of every series, in order
578  const items: Item[] = []
579  const at: { s: number; i: number; cx: number; cy: number }[] = []
580  series.forEach((s, si) =>
581    s.points.forEach((p, i) => {
582      const xLabel = String(p[0])
583      items.push({ label: xLabel, value: `${s.name} ${amount(p[1])}`, cite: cite(fmt(p[1]), `card:${card.id}#${s.name}/${xLabel}`), open: `card:${card.id}#${s.name}/${xLabel}`, kind: 'mark', text: amount(p[1]) })
584      at.push({ s: si, i, cx: px(xOf(p, i)) >> 1, cy: py(p[1]) >> 2 })
585    }),
586  )
587  const nearest = (x: number, y: number): number => {
588    const cx = x - yW - 1
589    if (cx < 0 || cx >= pw || y < 0 || y >= plotRows) return -1
590    let best = -1
591    let score = Infinity
592    at.forEach((a, j) => {
593      const d = Math.abs(a.cx - cx) * 4 + Math.abs(a.cy - y)
594      if (d < score) {
595        score = d
596        best = j
597      }
598    })
599    return best
600  }
601  const hot = hover >= 0 ? at[hover] : undefined
602  // a chart with no colour field is one series, in the first hue (rule 12); with several, each its hue
603  const seriesColour = (si: number) => classColour(card, series[si]?.name) ?? (series.length > 1 ? COLORS.series[si % COLORS.series.length]! : ONE)
604  const lines: Line[] = []
605  for (let r = 0; r < plotRows; r++) {
606    const yl = r === 0 ? labels[0]! : r === plotRows - 1 ? labels[2]! : r === Math.floor((plotRows - 1) / 2) ? labels[1]! : ''
607    const row: Line = [{ s: yl.padStart(yW - 1) + ' ', fg: COLORS.dim }, { s: '│', fg: COLORS.rule }]
608    for (let c = 0; c < pw; c++) {
609      const b = bits[r]![c]!
610      const o = owner[r]![c]!
611      const isHot = hot && c === hot.cx
612      const isPoint = hot && c === hot.cx && r === hot.cy
613      // the pointer's column a ┊ in the rule grey, the point under it in inverse
614      row.push({
615        s: b ? String.fromCodePoint(0x2800 + b) : isHot ? '┊' : ' ',
616        fg: b ? seriesColour(Math.max(0, o)) : COLORS.rule,
617        ...(isPoint ? { inv: true } : {}),
618      })
619    }
620    lines.push(row)
621  }
622  // the x labels, dim: the first and the last x, and the one nearest the middle where it fits between them with a
623  // gutter at each side
624  const xsAt = series.flatMap(s => s.points.map((p, i) => ({ label: String(p[0]), x: px(xOf(p, i)) >> 1 })))
625  const ends = xsAt.reduce((m, q) => ({ lo: q.x < m.lo.x ? q : m.lo, hi: q.x > m.hi.x ? q : m.hi }), { lo: xsAt[0] ?? { label: '', x: 0 }, hi: xsAt[0] ?? { label: '', x: 0 } })
626  const first = kind === 'cat' ? cats[0] ?? '' : ends.lo.label
627  const last = kind === 'cat' ? cats.at(-1) ?? '' : ends.hi.label
628  const mid = xsAt.reduce<{ label: string; x: number } | null>((m, q) => (!m || Math.abs(q.x - pw / 2) < Math.abs(m.x - pw / 2) ? q : m), null)
629  const axis = ' '.repeat(yW) + '└' + '─'.repeat(pw)
630  lines.push([{ s: axis, fg: COLORS.rule }])
631  let xl = first.length + last.length + 2 <= pw ? first + ' '.repeat(pw - first.length - last.length) + last : first
632  if (mid && mid.label !== first && mid.label !== last && xl.length === pw) {
633    const m0 = Math.round(pw / 2 - width(mid.label) / 2)
634    if (m0 >= width(first) + 2 && m0 + width(mid.label) + 2 <= pw - width(last)) xl = xl.slice(0, m0) + mid.label + xl.slice(m0 + mid.label.length)
635  }
636  lines.push([{ s: ' '.repeat(yW + 1) + xl, fg: COLORS.dim }])
637  if (series.length > 1) lines.push(...flow(series.map((s, si) => [{ s: '● ', fg: seriesColour(si) }, { s: s.name }]), cols))
638  return { lines, items, hit: (x, y) => nearest(x, y) }
639}
640
641const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
642const STAMP = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?\s*(?:Z|[+-]\d{2}(?::?\d{2})?)?$/
643
644/**
645 * Timestamps as a timeline shows them, all in one form: "18 Jun 21:26", the year only when the events span more than
646 * one, the time only when one is not midnight, seconds only when two events share a minute. The clock reads as written
647 * (no time zone conversion). Times that are not all ISO dates are kept as they are.
648 */
649export function shortTimes(times: readonly string[]): string[] {
650  const parts = times.map(t => STAMP.exec(t.trim()))
651  if (!parts.length || parts.some(p => !p)) return [...times]
652  const ps = parts as RegExpExecArray[]
653  const years = new Set(ps.map(p => p[1]))
654  const clock = ps.some(p => (p[4] ?? '00') !== '00' || (p[5] ?? '00') !== '00' || (p[6] ?? '00') !== '00')
655  const minute = (p: RegExpExecArray) => `${p[1]}-${p[2]}-${p[3]} ${p[4] ?? '00'}:${p[5] ?? '00'}`
656  const secs = ps.some(p => ps.some(q => minute(p) === minute(q) && (p[6] ?? '00') !== (q[6] ?? '00')))
657  return ps.map(p => {
658    const day = `${Number(p[3])} ${MONTHS[Number(p[2]) - 1] ?? p[2]}${years.size > 1 ? ` ${p[1]}` : ''}`
659    if (!clock) return day
660    return `${day} ${p[4] ?? '00'}:${p[5] ?? '00'}${secs ? `:${p[6] ?? '00'}` : ''}`
661  })
662}
663
664/**
665 * Times of a transcript's turns as its time column shows them: the clock alone (`07:40:01`; seconds only when a time
666 * has them), and the day (`18 Jun 2026`) on each turn where it changes, which the column shows on a row of its own. Times
667 * that are not all ISO stamps stay as written, with no day. An empty time stays empty.
668 */
669export function turnTimes(times: readonly string[]): { clock: string; day: string }[] {
670  const parts = times.map(t => (t.trim() ? STAMP.exec(t.trim()) : null))
671  if (times.some((t, i) => t.trim() && !parts[i]) || !parts.some(Boolean)) return times.map(t => ({ clock: t, day: '' }))
672  const secs = parts.some(p => p && (p[6] ?? '00') !== '00')
673  let last = ''
674  return parts.map(p => {
675    if (!p) return { clock: '', day: '' }
676    const key = `${p[1]}-${p[2]}-${p[3]}`
677    const day = key === last ? '' : `${Number(p[3])} ${MONTHS[Number(p[2]) - 1] ?? p[2]} ${p[1]}`
678    last = key
679    const clock = p[4] === undefined ? '' : `${p[4]}:${p[5] ?? '00'}${secs ? `:${p[6] ?? '00'}` : ''}`
680    return { clock, day }
681  })
682}
683
684function timelineLayout(card: CardData, cols: number, hover: number): Layout {
685  const evs = (card.events ?? []).slice(0, 30)
686  const times = evs.map(e => Date.parse(e.time.replace(' ', 'T')))
687  const isTime = times.every(t => !Number.isNaN(t))
688  // the form the card helper wrote beside each time, else the same rule applied here
689  const fallback = shortTimes(evs.map(e => e.time))
690  const shown = evs.map((e, i) => e.shown || fallback[i]!)
691  const lines: Line[] = []
692  const items: Item[] = evs.map((e, i) => ({
693    label: shown[i]!,
694    value: e.label,
695    cite: e.ref ? `[[${e.ref}]]` : cite(shown[i]!, `card:${card.id}#time/${i + 1}`),
696    open: e.ref || `card:${card.id}#time/${i + 1}`,
697    kind: e.ref ? 'record' : 'mark',
698    text: e.ref ? e.label : shown[i]!,
699  }))
700  let axisRows = 0
701  // with a label read, each event's value as a dot in its colour, and an event the label does not mark dim; with none,
702  // every event's dot in the first hue
703  const valued = cardLabels(card).length > 0
704  const mark = (e: { label: string; ref: string }) => classColour(card, e.label, e.ref) ?? (valued ? COLORS.dim : ONE)
705  if (isTime && evs.length > 1) {
706    const t0 = Math.min(...times)
707    const t1 = Math.max(...times)
708    // the axis takes the card's width from its A0, its end times under its ends
709    const aw = Math.max(10, cols)
710    const cells = new Array<number>(aw).fill(-1)
711    times.forEach((t, i) => {
712      const x = t1 === t0 ? 0 : Math.round(((t - t0) / (t1 - t0)) * (aw - 1))
713      cells[x] = cells[x] === -1 || i === hover ? i : cells[x]!
714    })
715    // each event a ● on the axis, in the first hue, or in its value's hue when the card read a label (dim for an event
716    // the label does not mark); the one under the pointer in inverse
717    lines.push(cells.map((c): Seg => (c >= 0 ? { s: '●', fg: mark(evs[c]!), ...(c === hover ? { inv: true } : {}) } : { s: '─', fg: COLORS.rule })))
718    const a = shown[times.indexOf(t0)]!
719    const b = shown[times.indexOf(t1)]!
720    // the axis's two ends named under them, both or neither: the list right under the axis already names them when its
721    // first row is the start and its last the end
722    if (!(times[0] === t0 && times[times.length - 1] === t1)) {
723      lines.push([{ s: `${a}${' '.repeat(Math.max(2, aw - width(a) - width(b)))}${b}`, fg: COLORS.dim }])
724      axisRows = 2
725    } else axisRows = 1
726  }
727  // one row per event: its time dim at the content's edge, under the axis's start time (in inverse under the
728  // pointer), its ● in hue, its words, and a blue ↗ when it has a record a click opens
729  const tW = Math.min(Math.max(...shown.map(t => width(t))), 22)
730  const owner: number[] = lines.map(() => -1)
731  evs.forEach((e, i) => {
732    const on = i === hover
733    const label = fold(e.label, Math.max(8, cols - tW - 6))
734    const arrow: Seg[] = e.ref ? [{ s: ' ' }, { s: '↗', fg: COLORS.link }] : []
735    const time = pad(shown[i]!, tW)
736    lines.push([on ? { s: time, inv: true } : { s: time, fg: COLORS.dim }, { s: '  ' }, { s: '●', fg: mark(e) }, { s: ' ' }, { s: label[0]! }, ...(label.length === 1 ? arrow : [])])
737    owner.push(i)
738    if (label[1]) {
739      lines.push([{ s: ' '.repeat(tW + 4) }, { s: label[1] }, ...arrow])
740      owner.push(i)
741    }
742  })
743  return { lines, items, hit: (_x, y) => owner[y] ?? -1 }
744}
745
746// ---------------------------------------------------------------------------------------- tables
747
748// columns between a table's columns: two, or one where that saves a cut, a broken word or a block
749const GAPS = [2, 1]
750
751/** A piece of a cell, where a table's line may end before it: `sp`, a space comes before it; `r`, how much a break
752 *  before it costs the reader: 0 at a space, 1 at a space after a word of symbols alone (so `A + B` breaks as `A` /
753 *  `+ B`) or after a hyphen or slash that follows a letter, 2 inside a word, between the parts of a name. */
754type Piece = { t: string; sp: boolean; r: number }
755
756const SYMBOLS = /^[^\p{L}\p{N}]+$/u
757
758/** `s` as pieces: its words, and inside a word the parts before a capital that follows a lower-case letter or a digit
759 *  that follows two letters, after a hyphen or slash that follows a letter, after an underscore, and after a dot
760 *  between letters. A date, a time, a number or a line's ref (#L1234) stays one piece. */
761const pieced = new Map<string, Piece[]>()
762
763function pieces(s: string): Piece[] {
764  const known = pieced.get(s)
765  if (known) return known
766  if (pieced.size > 4000) pieced.clear()
767  const out: Piece[] = []
768  const words = s.replace(/\s+/g, ' ').trim().split(' ').filter(Boolean)
769  words.forEach((word, wi) => {
770    const ch = [...word]
771    let cur = ''
772    let r = wi > 0 && SYMBOLS.test(words[wi - 1]!) && !SYMBOLS.test(word) ? 1 : 0
773    let sp = wi > 0
774    ch.forEach((c, i) => {
775      const a = ch[i - 1] ?? ''
776      const b = ch[i - 2] ?? ''
777      const soft = /\p{L}/u.test(b) && /[-/]/.test(a)
778      const hard =
779        (/\p{Ll}/u.test(a) && /\p{Lu}/u.test(c)) ||
780        (/\p{L}/u.test(b) && /\p{L}/u.test(a) && /\d/.test(c)) ||
781        (/\p{L}/u.test(b) && a === '_') ||
782        (/\p{L}/u.test(b) && a === '.' && /\p{L}/u.test(c))
783      if ((soft || hard) && cur) {
784        out.push({ t: cur, sp, r })
785        sp = false
786        r = soft ? 1 : 2
787        cur = ''
788      }
789      cur += c
790    })
791    out.push({ t: cur, sp, r })
792  })
793  pieced.set(s, out)
794  return out
795}
796
797function joinPieces(ps: readonly Piece[]): string {
798  return ps.reduce((a, p, i) => `${a}${i && p.sp ? ' ' : ''}${p.t}`, '')
799}
800
801/** Pieces joined where a break would cost more than `most`: the units a line may end between. */
802function units(ps: readonly Piece[], most: number): Piece[] {
803  const out: Piece[] = []
804  for (const p of ps) {
805    const last = out.at(-1)
806    if (last && p.r > most) out[out.length - 1] = { ...last, t: `${last.t}${p.sp ? ' ' : ''}${p.t}` }
807    else out.push({ ...p })
808  }
809  return out
810}
811
812/** Units in lines of `w` columns, as many to a line as fit; from line `max` on, the rest on the last line. The lines and
813 *  the most a break among them costs. */
814function fill(us: readonly Piece[], w: number, max: number): { lines: string[]; r: number } {
815  const lines: string[] = []
816  let r = 0
817  let cur = us[0]?.t ?? ''
818  for (const u of us.slice(1)) {
819    const next = `${cur}${u.sp ? ' ' : ''}${u.t}`
820    if (width(next) <= w || lines.length >= max - 1) cur = next
821    else {
822      lines.push(cur)
823      cur = u.t
824      r = Math.max(r, u.r)
825    }
826  }
827  lines.push(cur)
828  return { lines, r }
829}
830
831type Wrapped = { lines: string[]; cut: boolean; r: number }
832
833/** `s` in at most `max` lines of `w` columns, broken between words where they fit, else after a hyphen or slash, and
834 *  inside a word only where a word alone is wider than `w`; what does not fit is cut on the last line. Two lines that
835 *  hold it all are balanced, at the cheapest break. */
836export function wrapCell(s: string, w: number, max: number): string[] {
837  return wrapped(s, w, max).lines
838}
839
840const wraps = new Map<string, Wrapped>()
841
842/** wrapCell's lines, whether any of `s` is cut from them, and the most a break among them costs (`r` of a Piece). */
843function wrapped(s: string, w: number, max: number): Wrapped {
844  const key = `${w} ${max} ${s}`
845  const known = wraps.get(key)
846  if (known) return known
847  if (wraps.size > 8000) wraps.clear()
848  const ps = pieces(s)
849  let out: Wrapped | null = null
850  for (const most of [0, 1, 2]) {
851    const f = fill(units(ps, most), w, max)
852    if (f.lines.every(l => width(l) <= w)) {
853      out = { lines: f.lines, cut: false, r: f.r }
854      break
855    }
856  }
857  if (!out) {
858    const f = fill(ps, w, max)
859    out = { lines: f.lines.map(l => cut(l, w)), cut: true, r: f.r }
860  }
861  if (out.lines.length === 2 && !out.cut) {
862    let score = Number.POSITIVE_INFINITY
863    for (let i = 1; i < ps.length; i++) {
864      const a = joinPieces(ps.slice(0, i))
865      const b = joinPieces(ps.slice(i))
866      const sc = ps[i]!.r * 1000 + Math.max(width(a), width(b))
867      if (ps[i]!.r <= out.r && width(a) <= w && width(b) <= w && sc < score) {
868        score = sc
869        out = { lines: [a, b], cut: false, r: ps[i]!.r }
870      }
871    }
872  }
873  wraps.set(key, out)
874  return out
875}
876
877/** The fewest columns that hold `s` in at most `max` lines with nothing cut and no break costing more than `most`. */
878function fitWidth(s: string, max: number, most = 1): number {
879  const us = units(pieces(s), most)
880  if (!us.length) return 0
881  let lo = Math.max(...us.map(u => width(u.t)))
882  let hi = Math.max(lo, width(joinPieces(us)))
883  while (lo < hi) {
884    const mid = (lo + hi) >> 1
885    if (fill(us, mid, max + 1).lines.length <= max) hi = mid
886    else lo = mid + 1
887  }
888  return lo
889}
890
891type TableGeometry = { k: number; ws: number[]; packed: number[][]; gap: number }
892
893const geometries = new Map<string, TableGeometry>()
894
895/** Where a table's columns go in `cols` columns (see tableLayout): the first column's width `k`, each column's width,
896 *  and the other columns in blocks. Kept by the table's text and width, as a pointer's moves draw it again and again. */
897function tableGeometry(heads: readonly string[], cells: readonly string[][], numeric: readonly boolean[], cols: number): TableGeometry {
898  const key = JSON.stringify([cols, heads, cells, numeric])
899  const known = geometries.get(key)
900  if (known) return known
901  if (geometries.size > 64) geometries.clear()
902  const n = heads.length
903  if (!n) return { k: 0, ws: [], packed: [], gap: GAPS[0]! }
904  const whole = heads.map((_, c) => Math.max(0, ...cells.map(r => width(r[c]!))))
905  const nat = heads.map((h, c) => Math.max(width(h), whole[c]!))
906  // the narrowest a column can be with no word broken: its header in three lines, its other cells in two, a number whole
907  const floor = heads.map((h, c) => Math.max(fitWidth(h, 3), ...cells.map(r => (numeric[c] ? width(r[c]!) : fitWidth(r[c]!, 2)))))
908  const others = heads.map((_, c) => c).slice(1)
909  /** Beside a first column k wide, `gap` between columns: the blocks the other columns fill at their narrowest, then
910   *  each column's width, widened by what its block has left: text columns towards their whole cells, the smallest
911   *  gaps first, then the headers to as few lines as it allows. */
912  const geometry = (k: number, gap: number): TableGeometry => {
913    const room = cols - k - gap
914    const ws = heads.map((_, c) => (c === 0 ? k : Math.max(1, Math.min(floor[c]!, room))))
915    const packed: number[][] = []
916    let cur: number[] = []
917    let used = 0
918    for (const c of others) {
919      if (cur.length && used + gap + ws[c]! > room) {
920        packed.push(cur)
921        cur = []
922        used = 0
923      }
924      used += (cur.length ? gap : 0) + ws[c]!
925      cur.push(c)
926    }
927    if (cur.length) packed.push(cur)
928    for (const b of packed) {
929      let left = room - b.reduce((a, c) => a + ws[c]!, 0) - gap * (b.length - 1)
930      for (const c of b.filter(c => !numeric[c]).sort((p, q) => whole[p]! - ws[p]! - (whole[q]! - ws[q]!))) {
931        const add = Math.max(0, Math.min(left, whole[c]! - ws[c]!))
932        ws[c] = ws[c]! + add
933        left -= add
934      }
935      for (let l = Math.max(1, wrapCell(heads[0]!, k, 3).length); l <= 3; l++) {
936        const want = b.map(c => Math.max(0, Math.min(fitWidth(heads[c]!, l), room) - ws[c]!))
937        if (want.reduce((a, x) => a + x, 0) > left) continue
938        b.forEach((c, i) => (ws[c] = ws[c]! + want[i]!))
939        break
940      }
941      // a header still broken after a hyphen takes what is left to break between words only
942      for (const c of b) {
943        const need = fitWidth(heads[c]!, 3, 0) - ws[c]!
944        if (need > 0 && need <= left && wrapped(heads[c]!, ws[c]!, 3).r > 0) {
945          ws[c] = ws[c]! + need
946          left -= need
947        }
948      }
949    }
950    return { k, ws, packed, gap }
951  }
952  // what a geometry costs, in this order: the characters its cut cells and headers hide, the words it breaks, its
953  // blocks, a narrow gap, the words it breaks after a hyphen or slash, and the lines it takes; null once it hides or
954  // breaks more than `bound`
955  const cost = (g: TableGeometry, bound: number[]): number[] | null => {
956    let hidden = 0
957    let broken = 0
958    let soft = 0
959    let tall = 0
960    const blocks = g.packed.length ? g.packed : [[]]
961    const see = (s: string, c: number, max: number, count: boolean): number => {
962      if (numeric[c] && max === 2) {
963        hidden += Math.max(0, width(s) - g.ws[c]!)
964        return 1
965      }
966      const w = wrapped(s, g.ws[c]!, max)
967      if (count) {
968        if (w.cut) hidden += Math.max(1, width(s) - w.lines.reduce((a, l) => a + width(l) - 1, 0))
969        if (w.r > 1) broken += 1
970        else if (w.r > 0 && /[-/]/.test(s)) soft += 1
971      }
972      return w.lines.length
973    }
974    const over = () => bound.length > 0 && (hidden > bound[0]! || (hidden === bound[0] && broken > bound[1]!))
975    for (const [bi, b] of blocks.entries()) {
976      const cs = [0, ...b]
977      // the first column is the same in every block: what it hides or breaks counts once
978      tall += 1 + Math.max(...cs.map(c => see(heads[c]!, c, 3, c > 0 || bi === 0)))
979      for (const r of cells) {
980        tall += Math.max(...cs.map(c => see(r[c]!, c, 2, c > 0 || bi === 0)))
981        if (over()) return null
982      }
983    }
984    return [hidden, broken, blocks.length, g.gap < GAPS[0]! ? 1 : 0, soft, tall]
985  }
986  const less = (a: number[], b: number[]) => {
987    const i = a.findIndex((x, j) => x !== b[j])
988    return i >= 0 && a[i]! < b[i]!
989  }
990  // the first column at each width from the narrowest that cuts nothing to its whole width, with each gap: the geometry
991  // that costs least, the widest of those
992  const narrowest = Math.max(fitWidth(heads[0]!, 3, 2), ...cells.map(r => (numeric[0] ? width(r[0]!) : fitWidth(r[0]!, 2, 2))))
993  const kLo = n > 1 ? Math.max(1, Math.min(narrowest, cols - 2)) : Math.min(nat[0]!, cols)
994  const kHi = n > 1 ? Math.max(kLo, Math.min(nat[0]!, cols - 2)) : kLo
995  let best: TableGeometry | null = null
996  let least: number[] = []
997  for (const gap of n > 1 ? GAPS : GAPS.slice(0, 1)) {
998    const most = n > 1 ? cols - gap - 1 : cols
999    for (let k = Math.min(kHi, most); k >= Math.min(kLo, most); k--) {
1000      const g = geometry(k, gap)
1001      const c = cost(g, least)
1002      if (c && (!best || less(c, least))) {
1003        best = g
1004        least = c
1005      }
1006    }
1007  }
1008  const out = best ?? geometry(kLo, GAPS[0]!)
1009  geometries.set(key, out)
1010  return out
1011}
1012
1013/** A padded cell as a table's header draws it: its words bold, the spaces around them plain. */
1014export function boldCell(cell: string): Seg[] {
1015  const body = cell.trim()
1016  if (!body) return [{ s: cell }]
1017  const at = cell.indexOf(body)
1018  return [{ s: cell.slice(0, at) }, { s: body, b: true }, { s: cell.slice(at + body.length) }].filter(x => x.s)
1019}
1020
1021/**
1022 * A table in `cols` columns. A word is broken only where it alone is wider than its column: a column is never
1023 * narrower than its widest number, its header in three lines or its other cells in two, broken between words; the first
1024 * column, which names the rows, then takes all it can, and the others widen with what is left. Columns that do not fit
1025 * beside each other go into blocks one under another, each led by the first column, as R prints a wide data frame.
1026 */
1027function tableLayout(card: CardData, cols: number, hover: number): Layout {
1028  const heads = card.columns ?? []
1029  const n = heads.length
1030  const all = (card.rows ?? []) as Cell[][]
1031  const rows = all.slice(0, MAX_TABLE_ROWS)
1032  // each number in its column's format, as the browser's table writes it (FrameTable's cellText): `1,446`
1033  const text = (v: Cell, h: string): string => (typeof v === 'number' ? formatted(v, card.formats?.[h]) ?? fmt(v) : fmt(v))
1034  const cells = rows.map(r => heads.map((h, c) => text(r[c] ?? null, h)))
1035  // a blank cell does not make a column of numbers text
1036  const numeric = heads.map((_, c) => rows.every(r => typeof r[c] === 'number' || r[c] === null || (typeof r[c] === 'string' && !r[c].trim())))
1037  const { ws, packed, gap } = tableGeometry(heads, cells, numeric, cols)
1038  const blocks = n ? (packed.length ? packed : [[]]).map(b => [0, ...b]) : []
1039  const items: Item[] = []
1040  rows.forEach(r =>
1041    heads.forEach((h, c) => {
1042      // the value as the card shows it; the row named by its label unformatted, as the backend names it in a ref
1043      const v = text(r[c] ?? null, h)
1044      const ref = `card:${card.id}#${h}/${fmt(r[0])}`
1045      items.push({ label: `${text(r[0] ?? null, heads[0] ?? '')} · ${h}`, value: v, cite: c === 0 ? v : cite(v, ref), open: ref, kind: c === 0 ? 'row' : 'mark', text: v })
1046    }),
1047  )
1048  const hr = hover >= 0 ? Math.floor(hover / Math.max(1, n)) : -1
1049  const hc = hover >= 0 ? hover % Math.max(1, n) : -1
1050  const lines: Line[] = []
1051  const owner: number[] = [] // the row a line draws, -1 for none
1052  const within: number[] = [] // the block a line is in
1053  const push = (l: Line, ri: number, bi: number) => {
1054    lines.push(l)
1055    owner.push(ri)
1056    within.push(bi)
1057  }
1058  const join = (cs: number[], segs: (c: number, i: number) => Seg[]): Line => cs.flatMap((c, i): Seg[] => [...segs(c, i), ...(i < cs.length - 1 ? [{ s: ' '.repeat(gap) }] : [])])
1059  blocks.forEach((cs, bi) => {
1060    if (bi) push([], -1, bi)
1061    // the headers sit on the rule: a shorter one starts lower
1062    const head = cs.map(c => wrapCell(heads[c]!, ws[c]!, 3))
1063    const hl = Math.max(...head.map(h => h.length))
1064    // the column names bold, a rule in the rule grey under each as wide as its column, the rows right under it; the
1065    // words of the cell under the pointer in inverse
1066    for (let j = 0; j < hl; j++) push(join(cs, (c, i) => boldCell(pad(head[i]![j - hl + head[i]!.length] ?? '', ws[c]!, numeric[c]))), -1, bi)
1067    push(join(cs, c => [{ s: '─'.repeat(ws[c]!), fg: COLORS.rule }]), -1, bi)
1068    cells.forEach((r, ri) => {
1069      const p = cs.map(c => (numeric[c] ? [r[c]!] : wrapCell(r[c]!, ws[c]!, 2)))
1070      for (let j = 0; j < Math.max(...p.map(q => q.length)); j++) {
1071        push(
1072          join(cs, (c, i) => {
1073            const cell = pad(p[i]![j] ?? '', ws[c]!, numeric[c])
1074            const body = cell.trim()
1075            if (!(ri === hr && c === hc && body)) return [{ s: cell }]
1076            const before = cell.indexOf(body)
1077            return [{ s: cell.slice(0, before) }, { s: body, inv: true }, { s: cell.slice(before + body.length) }].filter(x => x.s)
1078          }),
1079          ri,
1080          bi,
1081        )
1082      }
1083    })
1084  })
1085  if (all.length > rows.length) push([{ s: `… ${all.length - rows.length} more`, fg: COLORS.dim }], -1, -1)
1086  const lefts = blocks.map(cs => {
1087    const xs: number[] = []
1088    cs.reduce((x, c) => (xs.push(x), x + ws[c]! + gap), 0)
1089    return xs
1090  })
1091  return {
1092    lines,
1093    items,
1094    hit: (x, y) => {
1095      const ri = owner[y] ?? -1
1096      if (ri < 0) return -1
1097      const cs = blocks[within[y]!]!
1098      const i = lefts[within[y]!]!.findIndex((x0, j) => x >= x0 && x < x0 + ws[cs[j]!]! + (j < cs.length - 1 ? gap : 0))
1099      return ri * n + cs[Math.max(0, i)]!
1100    },
1101  }
1102}
1103
1104/** A record's place as a link: a blue ↗, then its place in words, blue and underlined, in inverse while the record is
1105 *  under the pointer. */
1106function placeLink(ref: string, n: number, on: boolean): Seg[] {
1107  return [{ s: '↗', fg: COLORS.link }, { s: ' ' }, { s: cutRef(ref, Math.max(4, n - 2)), fg: COLORS.link, u: true, ...(on ? { inv: true } : {}) }]
1108}
1109
1110/** A record's own words in quotation marks, in at most `max` rows of `room` columns; words that do not fit end in
1111 *  `…"`. */
1112function quoteLines(words: string, room: number, max: number): string[] {
1113  // in quotation marks of a kind the words do not hold (lib.ts quoted)
1114  const q = quoted(words)
1115  const close = q === words ? '' : q.at(-1)!
1116  const lines = wrapCell(q, room, max).filter(Boolean)
1117  const last = lines.at(-1)
1118  if (last && last.endsWith('…')) {
1119    // cut at a word, as every cut, with room for the `…` and the closing mark after it
1120    const t = last.slice(0, -1).trimEnd()
1121    const kept = width(t) + 1 + close.length <= room ? t : cut(t, room - close.length).slice(0, -1)
1122    lines[lines.length - 1] = `${kept.replace(/[\s,;:.!?]+$/, '')}…${close}`
1123  }
1124  return lines
1125}
1126
1127/**
1128 * Example records: per record, a ● at the content's edge (in its value's hue when a label the card read marks it, dim
1129 * when it marks it not, else the first hue) and thimble's note after it, regular; under the note, 2 cells in, the
1130 * record's words in quotation marks and italic, up to three rows, then ↗ and its place in blue and underlined, on the
1131 * quote's last row where it fits, else on the row under it. A blank row between records.
1132 */
1133function exampleLayout(card: CardData, cols: number, hover: number): Layout {
1134  const exs = (card.examples ?? []).slice(0, 8)
1135  const lines: Line[] = []
1136  const owner: number[] = []
1137  const items: Item[] = exs.map(e => ({ label: placeWords(e.ref), value: e.note || e.quote.slice(0, 60), cite: `[[${e.ref}]]`, open: e.ref, kind: 'record', text: e.note || e.quote }))
1138  const valued = cardLabels(card).length > 0
1139  const room = Math.max(10, cols - 2)
1140  exs.forEach((e, i) => {
1141    const on = i === hover
1142    if (i) {
1143      lines.push([])
1144      owner.push(-1)
1145    }
1146    const glyph: Seg = { s: '● ', fg: classColour(card, e.value, e.ref) ?? (valued ? COLORS.dim : ONE) }
1147    const rows: Line[] = []
1148    const lead = () => (rows.length ? { s: '  ' } : glyph)
1149    const note = e.note ? wrapCell(e.note.replace(/\s+/g, ' ').trim(), room, 3).filter(Boolean) : []
1150    for (const t of note) rows.push([lead(), { s: t }])
1151    const words = demojibake(e.quote).replace(/\s+/g, ' ').trim()
1152    const quote = words ? quoteLines(words, room, 3) : []
1153    for (const t of quote) rows.push([lead(), { s: t, i: true }])
1154    // the place after the quote's last row (or the note's, with no quote) when it fits there with a gutter
1155    const last = rows.at(-1)
1156    const want = 2 + width(placeWords(e.ref))
1157    if (last && lineWidth(last) + 2 + want <= cols) last.push({ s: '  ' }, ...placeLink(e.ref, cols - lineWidth(last) - 2, on))
1158    else rows.push([lead(), ...placeLink(e.ref, room, on)])
1159    for (const r of rows) {
1160      lines.push(r)
1161      owner.push(i)
1162    }
1163  })
1164  return { lines, items, hit: (_x, y) => owner[y] ?? -1 }
1165}
1166
1167// ---------------------------------------------------------------------------------------- diagrams
1168
1169export const MAX_NODES = 40
1170export const MAX_EDGES = 80
1171const NODE_LABEL = 24 // columns of a label line; a longer label takes two lines, and the readout has the whole label
1172const NODE_LABEL_MIN = 12
1173const NODE_LABEL_MAX = 40
1174const EDGE_INLINE = 24 // a longer edge label, or one with no place of its own beside its edge, is a numbered note
1175
1176/**
1177 * Nodes in layers by dependency, as thimble's canvas lays out a diagram: a node with no incoming edge sits in layer 0,
1178 * every other one layer past its furthest predecessor. A cycle is broken at the node with the fewest unplaced
1179 * predecessors.
1180 */
1181export function layerGraph(ids: readonly string[], edges: readonly DiagramEdge[]): Map<string, number> {
1182  const known = new Set(ids)
1183  const preds = new Map<string, Set<string>>(ids.map(id => [id, new Set<string>()]))
1184  for (const e of edges) if (known.has(e.source) && known.has(e.target) && e.source !== e.target) preds.get(e.target)!.add(e.source)
1185  const layer = new Map<string, number>()
1186  const left = new Set(ids)
1187  while (left.size) {
1188    let placed = 0
1189    for (const id of [...left]) {
1190      const ps = [...preds.get(id)!]
1191      if (ps.every(p => layer.has(p))) {
1192        layer.set(id, ps.reduce((m, p) => Math.max(m, layer.get(p)! + 1), 0))
1193        left.delete(id)
1194        placed++
1195      }
1196    }
1197    if (placed) continue
1198    const unplaced = (id: string) => [...preds.get(id)!].filter(p => !layer.has(p)).length
1199    const pick = [...left].sort((a, b) => unplaced(a) - unplaced(b))[0]!
1200    layer.set(pick, [...preds.get(pick)!].reduce((m, p) => (layer.has(p) ? Math.max(m, layer.get(p)! + 1) : m), 0))
hooks/lib.ts 1250 lines
1// Pure helpers of thimble-term (no `$`), shared by the hooks module, the surface modules and the tests.
2//
3// - citations(text): the [[display|ref]] and [[ref]] citations of a reply, and the link form [display](ref).
4// - parseReply(text): a reply block cut into Markdown chunks, card embeds (a line holding only [[card:<id>]]) and rich
5//   blocks (a paragraph, list item, heading, quote or table that holds a citation), each rich block as inline runs.
6// - shownMatches / valueIn: thimble's number comparison (backend/app/cite.py), for the verification script's result.
7// - cut / clip / windowAt: the one cut of thimble-term, at a word; quoted: words in quotation marks of a kind they do
8//   not hold; formatted: a table column's number format, as the browser's table writes it.
9
10export type Citation = { raw: string; ref: string; display: string | null }
11
12export type Run = { text: string; b?: boolean; i?: boolean; code?: boolean; u?: boolean; cite?: Citation }
13
14/** `gap`: a blank line stood before the block in the reply, so it is drawn one row below the one before it. */
15export type Block =
16  | { type: 'md'; text: string; gap: boolean }
17  | { type: 'card'; id: string; gap: boolean; caption?: string }
18  | { type: 'rich'; prefix: string; heading: number; quote: boolean; runs: Run[]; gap: boolean; table?: TableRuns }
19
20/** A Markdown table that holds citations, drawn by thimble-term (the `|` inside `[[value|ref]]` breaks a GFM table):
21 *  its rows of cells of runs, the first row the header, and each column's alignment. A table block's `runs` are its
22 *  cells' runs in reading order, so its chips are numbered as a paragraph's are. */
23export type TableRuns = { rows: Run[][][]; align: ('left' | 'right' | 'center')[] }
24
25const FENCE_RE = /```[\s\S]*?```|`[^`\n]*`/g
26const LINK_RE = /(?<![\[!])\[([^\[\]\n]*)\]\(\s*(?:<([^<>\n]+)>|((?:[^()\s<>]|\([^()\s]*\))+))\s*\)/g
27const WEB_RE = /^(?:[a-z][a-z0-9+.-]*:\/\/|mailto:|tel:)/i
28const REF_SHAPE = /^(?:call:[A-Za-z0-9_-]+(?:#L\d+(?:-L?\d+)?)?|card:[A-Za-z0-9_-]+(?:@[A-Za-z0-9_]+)?(?:#.*)?|concept:[A-Za-z0-9_-]+(?:\/[^\s()]+)?|report:[A-Za-z0-9_-]+(?:#p?[A-Za-z0-9_-]+)?|[^\s:#]+#\S+|[^\s:#()]+\.[A-Za-z][A-Za-z0-9]{0,7})$/
29const LABEL_REF = /^concept:([A-Za-z0-9_-]+)(?:\/(.+))?$/
30const REPORT_REF = /^report:([A-Za-z0-9_-]+)(?:#(p?[A-Za-z0-9_-]+))?$/
31
32/** A link to a document, or to one of its paragraphs (`p<id>`), sentences or headings (`report:<slug>#<unit>`): the
33 *  document's slug and the unit; null for any other ref. */
34export function reportRef(ref: string): { slug: string; unit: string } | null {
35  const m = REPORT_REF.exec(ref.trim())
36  return m ? { slug: m[1]!, unit: m[2] ?? '' } : null
37}
38
39// what names a label and a document's passage a reply links to without words, as their resolution (term.ts
40// resolveCitations) and the lists read named them: a label's name by its id; a document's title by its slug and a
41// passage's words by its ref
42const labelNames = new Map<string, string>()
43const docTitles = new Map<string, string>()
44const passages = new Map<string, string>()
45
46/** Whether thimble's agents list a running chat that follows a run of the label named `name` (a `labels` agent titled
47 *  `label <name>`): a run the session's own process holds, which `thimble state` does not show as the label's run. */
48export function labelRunning(agents: readonly { label: string; state: string; role: string }[], name: string): boolean {
49  return Boolean(name) && agents.some(a => a.role === 'labels' && a.state === 'running' && a.label === `label ${name}`)
50}
51
52/** How a label's runs stand, as every place that names it says it (home, the labels list, its panel, its card; live
53 *  check term-fix9, quirk 4: the list and the panel said `not run yet` while home said `◌ labeling`): `running` while a
54 *  run goes on (thimble's, or main's that a chat follows: labelRunning), `ran` once a run ended, `stopped` for records
55 *  labeled with no run that ended (a quit stopped its first run part way), `labeled` the records labeled so far, and
56 *  `total` its run's size (the run's own, else the scope's size `thimble state` counted). */
57export type LabelState = { running: boolean; ran: boolean; stopped: boolean; labeled: number; total: number | null }
58
59type LabelRunLike = { status?: string; total?: number; matched_total?: number | null; stopped?: boolean; labeled?: number } | null | undefined
60
61export function labelState(
62  l: { name?: string; last_run?: LabelRunLike; applications?: LabelRunLike[]; label_stats?: { counts?: Record<string, number>; n_labeled?: number } | null; scope_total?: number },
63  agents: readonly { label: string; state: string; role: string }[],
64): LabelState {
65  const last = l.last_run ?? l.applications?.at(-1) ?? null
66  const running = (l.last_run?.status ?? '') === 'running' || labelRunning(agents, l.name ?? '')
67  const labeled = l.label_stats?.n_labeled ?? Object.values(l.label_stats?.counts ?? {}).reduce((a, b) => a + b, 0)
68  const total = typeof last?.matched_total === 'number' ? last.matched_total : typeof last?.total === 'number' ? last.total : typeof l.scope_total === 'number' ? l.scope_total : null
69  return { running, ran: Boolean(last) && last?.status !== 'running', stopped: !running && !last && labeled > 0, labeled, total }
70}
71
72/** A label's state in words while it has no run that ended: `◌ labeling`, with the records labeled so far and of how
73 *  many when known (`◌ labeling 3,000 of 4,579`); `stopped at 3,150 of 4,579`; `not run yet`. '' once a run ended. */
74export function labelStateWords(st: LabelState): string {
75  const n = (x: number) => x.toLocaleString('en-US')
76  const of = st.total !== null && st.total >= st.labeled ? ` of ${n(st.total)}` : ''
77  if (st.running) return st.labeled ? `◌ labeling ${n(st.labeled)}${of}` : '◌ labeling'
78  if (st.ran) return ''
79  return st.stopped ? `stopped at ${n(st.labeled)}${of}` : 'not run yet'
80}
81
82/** A label's name, noted when the labels are read or a link to it resolves, so a link to it names it in words. */
83export function noteLabelName(id: string, name: string | undefined): void {
84  if (id && name) labelNames.set(id, name)
85}
86
87/** A document's title (by its slug) and a passage's words (by its ref), noted when a link to it resolves or the documents
88 *  are read, so a link to it names it in words. */
89export function noteDocPlace(ref: string, title: string | undefined, words = ''): void {
90  const r = reportRef(ref)
91  if (!r) return
92  if (title) docTitles.set(r.slug, title)
93  if (r.unit && words.trim()) passages.set(`report:${r.slug}#${r.unit}`, words.replace(/\s+/g, ' ').trim())
94}
95
96/** The kinds of a thread's `error` record that end its run as a stop, not a failure: the analyst's stop, and the Claude
97 *  Code session that ended under it (backend threads.SESSION_ENDED, as when the analyst quits). */
98export const STOP_KINDS: readonly string[] = ['stopped', 'session-ended']
99
100/** Whether a thread's turn ended as a stop, not a failure: its record said so (`stopped`), or its words do (live check
101 *  term-fix6, new quirk 2: a thread stopped by quitting showed a red failure). */
102export function stoppedTurn(t: { state: string; a: string; stopped?: boolean }): boolean {
103  return t.state === 'error' && (Boolean(t.stopped) || /^\s*stopped\b/i.test(t.a))
104}
105
106/** A citation of a label, or of one of its values (`[[33|concept:<id>/yes]]`, or `[33](concept:<id>/yes)` as main writes
107 *  it for the terminal): the label's id and the value; null for any other ref. It is a link that opens the label at the
108 *  value, never a place a check reads. */
109export function labelRef(ref: string): { id: string; value: string } | null {
110  const m = LABEL_REF.exec(ref.trim())
111  return m ? { id: m[1]!, value: m[2] ? decodeURIComponent(m[2]) : '' } : null
112}
113export const EMBED_RE = /^\s*(?:\[\[card:([A-Za-z0-9_-]+)\]\]|!\[[^\]\n]*\]\(card:([A-Za-z0-9_-]+)\))\s*$/
114
115function make(display: string | null, ref: string): Citation {
116  return { raw: display === null ? `[[${ref}]]` : `[[${display}|${ref}]]`, ref, display }
117}
118
119function spanCitation(inner: string): Citation | null {
120  const t = inner.trim()
121  const bar = t.lastIndexOf('|')
122  const display = bar >= 0 ? t.slice(0, bar).trim() : null
123  const ref = (bar >= 0 ? t.slice(bar + 1) : t).trim()
124  return ref ? make(display, ref) : null
125}
126
127const CITE_MAX = 2000 // characters a citation may span, so a stray [[ costs little
128
129/** Where the citation that opens with the `[[` at `at` ends (just past its `]]`), or -1 when none opens there. A shown
130 *  value may hold brackets, as a quoted line of code or JSON does (`[["counts[\"dse\"] += 1"|call:x#L2]]`); the place
131 *  after its last bar holds no bracket. The citation ends at the first `]]` that closes such a place, provided the value
132 *  before the bar holds `[[` only inside balanced brackets (a quoted JSON list). One that holds a bracket lies on one
133 *  line, and none runs past a blank line, so code in prose is not taken for a citation. */
134export function citeEnd(text: string, at: number): number {
135  if (!text.startsWith('[[', at)) return -1
136  const stop = Math.min(text.length, at + CITE_MAX)
137  // the place since the last bar (all of it, with no bar): whether it holds a bracket, and whether any words
138  let placeBracket = false
139  let placeWords = false
140  // the value so far, and as it stood at the last bar
141  let depth = 0
142  let unbalanced = false
143  let double = false
144  let valueOk = true
145  let bracket = false
146  let lined = false
147  for (let k = at + 2; k < stop; k++) {
148    const ch = text[k]!
149    if (ch === ']' && text[k + 1] === ']' && !placeBracket && placeWords && valueOk) return k + 2
150    if (ch === '|') {
151      valueOk = !double || (depth === 0 && !unbalanced)
152      placeBracket = false
153      placeWords = false
154    } else if (ch === '[' || ch === ']') {
155      if (lined) return -1
156      bracket = placeBracket = true
157      if (ch === '[' && k > at + 2 && text[k - 1] === '[') double = true
158      depth += ch === '[' ? 1 : -1
159      if (depth < 0) unbalanced = true
160    } else if (ch === '\n') {
161      if (bracket) return -1
162      lined = true
163      let j = k + 1
164      while (text[j] === ' ' || text[j] === '\t') j++
165      if (text[j] === '\n') return -1
166    } else if (ch !== ' ' && ch !== '\t') placeWords = true
167  }
168  return -1
169}
170
171/** Each `[[...]]` citation of a text as written, where it starts and where it ends, in order. */
172export function citeSpans(text: string): { at: number; end: number }[] {
173  const out: { at: number; end: number }[] = []
174  for (let i = text.indexOf('[['); i >= 0; ) {
175    const end = citeEnd(text, i)
176    if (end >= 0) out.push({ at: i, end })
177    i = text.indexOf('[[', end >= 0 ? end : i + 1)
178  }
179  return out
180}
181
182function linkCitation(shown: string, target: string): Citation | null {
183  // a file's place with its spaces encoded (`my%20file.md#L3`) as the file names it; a label's value keeps its encoding,
184  // which labelRef decodes (`concept:<id>/mentions%20June`: live check term-fix9, it drew as its raw words)
185  const raw = target.trim()
186  const ref = raw.startsWith('concept:') ? raw : raw.replaceAll('%20', ' ')
187  if (!ref || WEB_RE.test(ref) || shown.includes('|') || !REF_SHAPE.test(ref)) return null
188  const s = shown.trim()
189  return make(s === '' || s === '↗' ? null : s, ref)
190}
191
192/** Every citation of a text in order, deduplicated by its `[[...]]` spelling; code spans and fences left out. */
193export function citations(text: string): Citation[] {
194  const found: { at: number; c: Citation }[] = []
195  const clean = text.replace(FENCE_RE, m => ' '.repeat(m.length))
196  for (const sp of citeSpans(clean)) {
197    const c = spanCitation(text.slice(sp.at + 2, sp.end - 2))
198    if (c) found.push({ at: sp.at, c })
199  }
200  for (const m of clean.matchAll(LINK_RE)) {
201    const c = linkCitation(m[1]!, m[2] ?? m[3] ?? '')
202    if (c) found.push({ at: m.index ?? 0, c })
203  }
204  const seen = new Set<string>()
205  return found
206    .sort((a, b) => a.at - b.at)
207    .map(f => f.c)
208    .filter(c => !seen.has(c.raw) && Boolean(seen.add(c.raw)))
209}
210
211/** A text with each Markdown link that is a citation (`[4,579](README.md#L3)`, the form main writes for the terminal)
212 *  in its `[[…]]` spelling (`[[4,579|README.md#L3]]`); a web link stays as written. */
213export function linksAsSpans(text: string): string {
214  return text.replace(LINK_RE, (m: string, shown: string, a?: string, b?: string) => linkCitation(shown, a ?? b ?? '')?.raw ?? m)
215}
216
217/** A text with each Markdown link that is a citation (`[4,579](README.md#L3)`, the form main writes for the terminal)
218 *  as its shown words; a web link stays as written. */
219export function plainLinks(text: string): string {
220  return text.replace(LINK_RE, (m: string, shown: string, a?: string, b?: string) => {
221    const c = linkCitation(shown, a ?? b ?? '')
222    return c ? (c.display ?? chipLabel(c)) : m
223  })
224}
225
226/** A short stable id for a citation (FNV-1a of its raw spelling), the key of its state. */
227export function cid(raw: string): string {
228  let h = 0x811c9dc5
229  for (let i = 0; i < raw.length; i++) {
230    h ^= raw.charCodeAt(i)
231    h = Math.imul(h, 0x01000193) >>> 0
232  }
233  return h.toString(36)
234}
235
236// the cards' questions read so far, by id (term.ts loadCards notes each): what names a card a reply cites without words
237const questions = new Map<string, string>()
238
239/** A card's question, noted when the card is read, so a citation of the card without words names it by its question. */
240export function noteQuestion(id: string, question: string | undefined): void {
241  if (!question) return
242  questions.delete(id)
243  questions.set(id, question)
244  if (questions.size > 2000) questions.delete(questions.keys().next().value!)
245}
246
247/** A card's question as noted (noteQuestion); '' for a card not read yet. */
248export function questionOf(id: string): string {
249  return questions.get(id) ?? ''
250}
251
252/** The card a citation names whole, with no words and no place on it (`[[card:<id>]]`, as a sentence ends with it), or
253 *  ''. */
254export function bareCard(c: Citation): string {
255  return c.display === null ? (/^(?:card|cell):([A-Za-z0-9_-]+)$/.exec(c.ref)?.[1] ?? '') : ''
256}
257
258/** A card named by its question in words: `card “<question>”`, the question cut at a word; `a card` while it is not
259 *  read. */
260export function cardWords(question: string, n = 40): string {
261  return question.trim() ? `card ${quoted(clip(question, n))}` : 'a card'
262}
263
264/** A citation of lines a card printed (`card:<id>@out0#L1`): the card, the lines, and the place in words, `card "…"
265 *  output line 1` (`a card's output line 1` while the card is not read); null for any other ref. */
266export function outputLine(ref: string): { card: string; first: number; last: number; words: string } | null {
267  const m = /^(?:card|cell):([A-Za-z0-9_-]+)@out\d+#L(\d+)(?:-L?(\d+))?$/.exec(ref.trim())
268  if (!m) return null
269  const [first, last] = [Number(m[2]), Number(m[3] ?? m[2])]
270  const lines = last !== first ? `lines ${first}-${last}` : `line ${first}`
271  const q = questionOf(m[1]!)
272  return { card: m[1]!, first, last, words: q ? `${cardWords(q)} output ${lines}` : `a card's output ${lines}` }
273}
274
275/** A chip: a citation that names only its place, with no words of its own (`[[card:<id>]]`, `[↗](<ref>)`), which the
276 *  browser draws as a chip; its own kind of citation (Matt, 2026-10-07). Every surface draws it as `[ card ]`
277 *  (chipText), never as words of the sentence. */
278export function isChip(c: Citation): boolean {
279  return c.display === null
280}
281
282/** The most cells a chip's words take, its brackets aside. */
283export const CHIP_MAX = 30
284
285/** What a chip says inside its brackets, short (Matt, 2026-10-07): `card` for a card or one of its cells, `card output
286 *  line 1` for a line it printed, `events.jsonl line 12` for a file's line (its name cut in its middle, the line kept),
287 *  a label by its name (`edit purpose`, `edit purpose · yes` for a value), `report`, `slides` or `story` for a document
288 *  or a passage of one, `output line 1` for a command's output. Never an id. */
289export function chipWords(c: Citation): string {
290  // a label's link and a document's by their names, never their ids (live check term-fix8, quirk 3: `concept:eb534ca4`
291  // and `↗ (report:report#4255ef27)` showed in a reply)
292  const lr = labelRef(c.ref)
293  if (lr) {
294    const name = labelNames.get(lr.id)
295    const value = lr.value ? ` · ${lr.value}` : ''
296    return name ? `${cut(name, Math.max(8, CHIP_MAX - width(value)))}${value}` : `label${value}`
297  }
298  const rr = reportRef(c.ref)
299  if (rr) return rr.slug === 'slides' || rr.slug === 'story' ? rr.slug : 'report'
300  const [base = '', frag = ''] = c.ref.split('#', 2)
301  const out = outputLine(c.ref)
302  if (out) return out.last !== out.first ? `card output lines ${out.first}-${out.last}` : `card output line ${out.first}`
303  if (/^(?:card|cell):/.test(base)) return 'card'
304  if (base.startsWith('call:')) {
305    const call = /^L(\d+)(?:-L?(\d+))?$/.exec(frag)
306    return call ? (call[2] && call[2] !== call[1] ? `output lines ${call[1]}-${call[2]}` : `output line ${call[1]}`) : 'output'
307  }
308  // a file's place in words, its name as written (`events.jsonl line 12`, `agent-chat.jsonl lines 1-2`), never
309  // `events:1063`, which reads as an id (live check term-fix9, low quirk); a long name cut in its middle, its line kept
310  const name = base.split('/').at(-1) ?? base
311  const lines = /^L(\d+)(?:-L?(\d+)|\.b\d+(?::c\d+-\d+)?)?$/.exec(frag)
312  const row = /^row=(\d+)$/.exec(frag)
313  const page = /^p(\d+)$/.exec(frag)
314  // a JSON list's item, counted from 1 as the file's view counts them (draw.ts placeWords)
315  const item = /^\/(?:[^/]+\/)?(\d+)$/.exec(frag)
316  const where = lines ? (lines[2] && lines[2] !== lines[1] ? ` lines ${lines[1]}-${lines[2]}` : ` line ${lines[1]}`) : row ? ` row ${row[1]}` : page ? ` page ${page[1]}` : item ? ` item ${Number(item[1]) + 1}` : frag ? ` ${frag}` : ''
317  if (width(`${name}${where}`) <= CHIP_MAX) return `${name}${where}`
318  const room = CHIP_MAX - width(where)
319  return room >= 8 ? `${middleCut(name, room)}${where}` : cut(`${name}${where}`, CHIP_MAX)
320}
321
322/** A short name cut in its middle to `n` cells, its extension kept (`revis…ns.jsonl`); a longer title at its end. */
323export function middleCut(name: string, n: number): string {
324  if (width(name) <= n) return name
325  if (name.split(/\s+/).length > 3 || n < 8) return cut(name, n)
326  const dot = name.lastIndexOf('.')
327  const ext = dot > 0 && name.length - dot <= 8 ? name.slice(dot) : ''
328  const stem = ext ? name.slice(0, dot) : name
329  const room = n - width(ext) - 1
330  if (room < 4) return cut(name, n)
331  const head = Math.ceil(room / 2)
332  return `${stem.slice(0, head)}…${stem.slice(stem.length - (room - head))}${ext}`
333}
334
335/** A chip as every surface draws it: its words in brackets, a space inside each (`[ card ]`). */
336export function chipText(c: Citation): string {
337  return `[ ${chipWords(c)} ]`
338}
339
340/** What a citation's link says: the shown value, cut at a word, or a chip (chipText) for a citation without one. */
341export function chipLabel(c: Citation): string {
342  return c.display !== null ? clip(c.display, 40) : chipText(c)
343}
344
345/** A chip's place in full words, for its tip: a label by its name, a document by its title or a passage by its words,
346 *  as they were noted (noteLabelName, noteDocPlace); '' for any other place, which the caller names. */
347export function chipPlace(c: Citation): string {
348  const lr = labelRef(c.ref)
349  if (lr) {
350    const name = labelNames.get(lr.id)
351    return name ? `label ${quoted(clip(name, 40))}${lr.value ? ` at its value ${quoted(lr.value)}` : ''}` : ''
352  }
353  const rr = reportRef(c.ref)
354  if (rr) {
355    const kind = rr.slug === 'slides' || rr.slug === 'story' ? rr.slug : 'report'
356    const words = rr.unit ? passages.get(`report:${rr.slug}#${rr.unit}`) : ''
357    if (words) return `${kind} ${quoted(clip(words, 60))}`
358    const title = docTitles.get(rr.slug)
359    return title ? `${kind} ${quoted(clip(title, 60))}` : `the ${kind}`
360  }
361  return ''
362}
363
364/** A text's chips with the brackets main put around one of its own taken away (`([[card:<id>]])` reads `[ card ]`, never
365 *  `([ card ])`): a chip carries its own brackets. */
366export function withoutOwnParens(text: string): string {
367  return text.replace(/\(\s*(\[\[[^\[\]\n|]+\]\]|\[\s*↗?\s*\]\([^()\s]+\))\s*\)/g, (m, inner: string) => {
368    const c = citations(inner)[0]
369    return c && isChip(c) ? inner : m
370  })
371}
372
373// ---------------------------------------------------------------------------------------- text width and cuts
374
375/** The cells a character takes on the grid: 2 for a wide one (CJK, emoji), 1 else. */
376export function cw(ch: string): number {
377  const c = ch.codePointAt(0) ?? 0
378  if (c === 0) return 0
379  if (
380    (c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) ||
381    (c >= 0xf900 && c <= 0xfaff) || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) ||
382    (c >= 0xffe0 && c <= 0xffe6) || (c >= 0x1f300 && c <= 0x1faff)
383  ) return 2
384  return 1
385}
386
387export function width(s: string): number {
388  let n = 0
389  for (const ch of s) n += cw(ch)
390  return n
391}
392
393/** The longest start of `s` that fits in `n` cells, with no `…`: a word wider than its row, broken where the row ends. */
394export function prefix(s: string, n: number): string {
395  let out = ''
396  let w = 0
397  for (const ch of s) {
398    if (w + cw(ch) > n) break
399    out += ch
400    w += cw(ch)
401  }
402  return out
403}
404
405// what a cut leaves out before its `…`: the space and the punctuation that ended the last word kept, and the `·` of a
406// list of facts whose next item it leaves out
407const CUT_TAIL = /[\s,;:.!?\-–—·]+$/
408
409/** Where the words in quotation marks that end `s` open (`"…"`, or `“…”`), when they open after its first cell; -1
410 *  when `s` does not end in quoted words. */
411function quoteOpens(s: string): number {
412  const close = s.at(-1)
413  if (close !== '"' && close !== '”') return -1
414  const at = close === '”' ? s.lastIndexOf('“') : s.lastIndexOf('"', s.length - 2)
415  return at >= 0 && at < s.length - 2 ? at : -1
416}
417
418/** `s` in at most `n` cells: whole when it fits, else cut at the last word that fits, mid-word only when that keeps
419 *  less than half of the room, with no space or punctuation before the `…` (SPEC.md, section 5, "Words that recur").
420 *  Words in quotation marks that end `s` are cut inside the marks, which stay: `thread "Which line of…"`.
421 *  The one cut of thimble-term: every row, title, preview and path step that shortens prose shortens it here. */
422export function cut(s: string, n: number): string {
423  if (width(s) <= n) return s
424  if (n <= 1) return n === 1 ? '…' : ''
425  // quoted words with a short tail after them (`card "How many…" · code`): the words inside the marks cut, the marks and
426  // the tail kept (live check term-fix9, low quirk: the code view's step read `card "How many deletes does…"…`)
427  const tailed = /^(.*?["“])([^"“”]+)(["”] · [^"“”]{1,16})$/.exec(s)
428  if (tailed && n - width(tailed[1]!) - width(tailed[3]!) >= 5) return `${tailed[1]}${cut(tailed[2]!, n - width(tailed[1]!) - width(tailed[3]!))}${tailed[3]}`
429  const open = quoteOpens(s)
430  if (open >= 0) {
431    // the words before the quotation and its opening mark whole, the quoted words cut, then the closing mark; when the
432    // room keeps fewer than 4 cells of the quoted words, the whole is cut as any words are
433    const head = s.slice(0, open + 1)
434    const room = n - width(head) - 1
435    if (room >= 4) return `${head}${cut(s.slice(open + 1, -1), room)}${s.at(-1)}`
436  }
437  // words in quotation marks that open inside the words kept and close after the cut (`citation card "How many…" output
438  // line 1`) keep their closing mark too: `citation card "How…"`
439  const plain = cutWords(s, n)
440  const close = unclosed(plain)
441  if (!close || n < 3) return plain
442  const shorter = cutWords(s, n - 1)
443  return unclosed(shorter) ? `${shorter}${unclosed(shorter)}` : shorter
444}
445
446/** `s` cut at a word in `n` cells (cut's last step). */
447function cutWords(s: string, n: number): string {
448  let head = ''
449  let w = 0
450  for (const ch of s) {
451    if (w + cw(ch) > n - 1) break
452    head += ch
453    w += cw(ch)
454  }
455  // a word that ends right where the room does is whole
456  const at = /\s/.test(s[head.length] ?? '') ? head.length : head.search(/\s\S*$/)
457  const keep = at > 0 && width(head.slice(0, at)) * 2 > n ? head.slice(0, at) : head
458  return `${keep.replace(CUT_TAIL, '') || keep.trimEnd()}…`
459}
460
461/** The closing quotation mark words in quotation marks that open in `s` and do not close there want, else ''. */
462function unclosed(s: string): string {
463  if (s.lastIndexOf('“') > s.lastIndexOf('”')) return '”'
464  return (s.match(/"/g)?.length ?? 0) % 2 === 1 ? '"' : ''
465}
466
467// a terminal's escape sequence (CSI, OSC, or one character after ESC), then any other control character
468const ESCAPES = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|[@-_])?/g
469const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
470
471/** Text as a drawing may hold it: a terminal's escape sequences (a tool's colored output kept in a transcript) and
472 *  other control characters left out, a tab or a newline as a space, since a drawing whose text holds one does not
473 *  validate. Pure. */
474export function noControls(s: string): string {
475  return /[\u0000-\u001f\u007f-\u009f]/.test(s) ? s.replace(ESCAPES, '').replace(/[\t\n]/g, ' ').replace(CONTROLS, '') : s
476}
477
478/** A file's line in at most `n` cells: a line of code or data (JSON, a tag) cut at the cell edge, so the rows of a file
479 *  end together; prose cut at a word, as `cut` cuts. */
480export function cutLine(s: string, n: number): string {
481  if (width(s) <= n || !/^\s*[{[<]/.test(s)) return cut(s, n)
482  if (n <= 1) return n === 1 ? '…' : ''
483  // no space or sentence punctuation right before the `…` (live check term-fix9, low quirk: `Some …`, `alone.…`); a
484  // JSON key's colon stays, so a cut value reads as one (`"page_key":…`)
485  const head = prefix(s, n - 1)
486  return `${head.replace(/[\s.,;!?]+$/, '') || head}…`
487}
488
489/** Items of an inline list of facts parted by ` · ` in at most `n` cells: as many whole items as fit, then ` · +N` for
490 *  the N left out (never a `…` against a whole item, which would read as a cut value: `wiki dse…`); the first item cut
491 *  as `cut` cuts when not even it fits whole. */
492export function itemsRow(items: readonly string[], n: number): string {
493  let k = items.length
494  const more = (m: number) => (m < items.length ? ` · +${items.length - m}` : '')
495  const row = (m: number) => `${items.slice(0, m).join(' · ')}${more(m)}`
496  while (k > 0 && width(row(k)) > n) k--
497  if (k > 0) return row(k)
498  const tail = more(1)
499  return n - width(tail) >= 8 ? `${cut(items[0] ?? '', n - width(tail))}${tail}` : cut(items[0] ?? '', n)
500}
501
502/** `s` in at most `n` cells, cut in its middle at words so its end shows: `How many deletes… of 27 June?`, for rows
503 *  that share their first words and differ at their ends (live check term-fix9, quirk 8: fifteen card rows read `How
504 *  many deletes does…`). As `cut` when the room keeps fewer than 6 cells of the end. */
505export function cutMiddle(s: string, n: number): string {
506  if (width(s) <= n) return s
507  const tailRoom = Math.floor((n - 2) / 2)
508  if (tailRoom < 6) return cut(s, n)
509  // the end: the last words that fit its half, whole
510  const words = s.split(' ')
511  let tail = ''
512  for (let i = words.length - 1; i > 0; i--) {
513    const next = tail ? `${words[i]} ${tail}` : words[i]!
514    if (width(next) > tailRoom) break
515    tail = next
516  }
517  if (!tail) return cut(s, n)
518  const head = cut(s.slice(0, s.length - tail.length).trimEnd(), n - width(tail) - 1)
519  return `${head.endsWith('…') ? head : `${head}…`} ${tail}`
520}
521
522/** `s` on one line in `n` cells, cut as `cut` cuts. */
523export function clip(s: string, n: number): string {
524  return cut(s.replace(/\s+/g, ' ').trim(), n)
525}
526
527/** About `room` characters of a long line around position `at` (a third of the room before it), each end cut at a word
528 *  with `…` right against the words; `shift` is how far a position of `text` moved to the left. A line of data (JSON, a
529 *  tag) is cut at the cell edge instead, `room` characters exactly, so the lines of a file end together (live check
530 *  term-fix9, low quirk: a citation's context lines were cut to uneven widths). */
531export function windowAt(text: string, at: number, room: number): { text: string; shift: number } {
532  if (text.length <= room) return { text, shift: 0 }
533  if (/^\s*[{[<]/.test(text) && room >= 4) {
534    const lo = Math.max(0, Math.min(at - Math.floor(room / 3), text.length - room + 1))
535    const late = lo > 0
536    const hi = Math.min(text.length, lo + room - (late ? 1 : 0) - 1)
537    const body = text.slice(lo, hi)
538    return { text: `${late ? '…' : ''}${body}${hi < text.length ? '…' : ''}`, shift: lo - (late ? 1 : 0) }
539  }
540  let lo = Math.max(0, Math.min(at - Math.floor(room / 3), text.length - room))
541  const late = lo > 0
542  let hi = Math.min(text.length, lo + room - (late ? 2 : 1))
543  if (late) {
544    // the first word whole: start after the space that ends the cut one, while that loses less than a third of the
545    // room; no space after the `…`
546    if (!/\s/.test(text[lo - 1]!)) {
547      const sp = text.slice(lo, Math.min(at, lo + Math.floor(room / 3))).search(/\s/)
548      if (sp >= 0) lo += sp + 1
549    }
550    while (lo < hi && /\s/.test(text[lo]!)) lo++
551  }
552  if (hi < text.length && !/\s/.test(text[hi]!)) {
553    const sp = text.slice(lo, hi).search(/\s\S*$/)
554    if (sp > (hi - lo) / 2) hi = lo + sp
555  }
556  const body = text.slice(lo, hi)
557  const end = hi < text.length ? `${body.replace(CUT_TAIL, '') || body.trimEnd()}…` : body
558  return { text: `${late ? '…' : ''}${end}`, shift: lo - (late ? 1 : 0) }
559}
560
561/** Words in quotation marks for a row: straight ones, curly when the words hold straight ones of their own, and none
562 *  when they hold both kinds, so no quotation marks stand inside the same kind. */
563export function quoted(s: string): string {
564  const straight = s.includes('"')
565  const curly = /[“”]/.test(s)
566  if (straight && curly) return s
567  return straight ? `“${s}”` : `"${s}"`
568}
569
570/** Straight double quotation marks as curly ones, opening after a space or a bracket and closing elsewhere: words in a
571 *  tool's row, where Claude Code escapes straight ones (`\"`). */
572export function curlyQuotes(s: string): string {
573  return s.replace(/"/g, (_m, at: number) => (at === 0 || /[\s([{—–-]/.test(s[at - 1]!) ? '“' : '”'))
574}
575
576// ---------------------------------------------------------------------------------------- inline runs
577
578/** A line of Markdown as styled runs: bold, italic, code, links (their text), citations (one run each). */
579export function inlineRuns(text: string): Run[] {
580  const out: Run[] = []
581  // each citation outside code is masked to one token of its length, so the brackets, stars and underscores of a
582  // quoted value neither end it nor start emphasis
583  let masked = ''
584  let from = 0
585  for (const sp of citeSpans(text.replace(FENCE_RE, m => ' '.repeat(m.length)))) {
586    masked += `${text.slice(from, sp.at)}\uE000${'\uE001'.repeat(sp.end - sp.at - 1)}`
587    from = sp.end
588  }
589  masked += text.slice(from)
590  // tokens: code span, citation, link (citation form or web), bold, italic
591  const TOKEN = /(`[^`\n]+`)|(\uE000\uE001*)|((?<![\[!])\[[^\[\]\n]*\]\([^()\s]*(?:\([^()\s]*\)[^()\s]*)*\))|(\*\*[^*\n]+\*\*|__[^_\n]+__)|((?<![\w*])\*[^*\n]+\*(?!\w)|(?<![\w_])_[^_\n]+_(?![\w]))/g
592  let last = 0
593  const push = (r: Run) => {
594    if (r.text) out.push(r)
595  }
596  for (const m of masked.matchAll(TOKEN)) {
597    const at = m.index ?? 0
598    push({ text: text.slice(last, at) })
599    const tok = text.slice(at, at + m[0].length)
600    if (m[1]) push({ text: tok.slice(1, -1), code: true })
601    else if (m[2]) {
602      const c = spanCitation(tok.slice(2, -2))
603      if (c) out.push({ text: chipLabel(c), cite: c })
604      else push({ text: tok })
605    } else if (m[3]) {
606      const lm = /^\[([^\[\]\n]*)\]\((.*)\)$/.exec(tok)
607      const c = lm ? linkCitation(lm[1]!, lm[2]!) : null
608      if (c) out.push({ text: chipLabel(c), cite: c })
609      else push({ text: lm ? lm[1]! : tok, u: true })
610    } else if (m[4]) {
611      for (const r of inlineRuns(tok.slice(2, -2))) push({ ...r, b: true })
612    } else if (m[5]) {
613      for (const r of inlineRuns(tok.slice(1, -1))) push({ ...r, i: true })
614    }
615    last = at + tok.length
616  }
617  push({ text: text.slice(last) })
618  return out
619}
620
621// ---------------------------------------------------------------------------------------- blocks
622
623/** A table row's cells: split at each bar outside citations and code spans; an escaped bar stays in its cell. */
624export function tableCells(line: string): string[] {
625  let t = line.trim()
626  if (t.startsWith('|')) t = t.slice(1)
627  if (t.endsWith('|') && !t.endsWith('\\|')) t = t.slice(0, -1)
628  const cells: string[] = []
629  let cur = ''
630  let code = false
631  for (let i = 0; i < t.length; i++) {
632    const ch = t[i]!
633    if (ch === '\\' && t[i + 1] === '|') {
634      cur += '|'
635      i++
636      continue
637    }
638    // a citation's bars, escaped or not, stay in its cell
639    const end = code ? -1 : citeEnd(t, i)
640    if (end >= 0) {
641      cur += t.slice(i, end).replaceAll('\\|', '|')
642      i = end - 1
643      continue
644    }
645    if (ch === '`') code = !code
646    if (ch === '|' && !code) {
647      cells.push(cur.trim())
648      cur = ''
649      continue
650    }
651    cur += ch
652  }
653  cells.push(cur.trim())
654  return cells
655}
656
657const ALIGN_ROW = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/
658
659/** A Markdown table's lines as rows of runs, the alignment row read and left out. */
660export function tableRuns(lines: string[]): TableRuns {
661  const rows: Run[][][] = []
662  let align: TableRuns['align'] = []
663  for (const line of lines) {
664    if (ALIGN_ROW.test(line) && line.includes('-')) {
665      align = tableCells(line).map(c => (c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left'))
666      continue
667    }
668    rows.push(tableCells(line).map(c => inlineRuns(c)))
669  }
670  return { rows, align }
671}
672
673/** A reply block cut into what the engine draws as Markdown (no citation in it), the cards it embeds, and the rich
674 *  blocks thimble-term draws itself so their citations can be chips. Fences and tables stay Markdown whole. */
675export function parseReply(text: string): Block[] {
676  const lines = text.split('\n')
677  const out: Block[] = []
678  let md: string[] = []
679  let blank = false // the line before the next block was blank
680  const flush = () => {
681    const first = md.findIndex(l => l.trim() !== '')
682    if (first >= 0) {
683      let last = md.length - 1
684      while (!md[last]!.trim()) last--
685      out.push({ type: 'md', text: md.slice(first, last + 1).join('\n'), gap: blank || first > 0 })
686      blank = last < md.length - 1
687    } else if (md.length) blank = true
688    md = []
689  }
690  let i = 0
691  while (i < lines.length) {
692    const line = lines[i]!
693    const embed = EMBED_RE.exec(line)
694    if (embed) {
695      flush()
696      // a figure's caption (report.ts normalizeDoc) is the italic line right under its embed
697      const cap = /^\s*\*([^*\n].*?)\*\s*$/.exec(lines[i + 1] ?? '')
698      out.push({ type: 'card', id: (embed[1] ?? embed[2])!, gap: blank, ...(cap ? { caption: cap[1]!.trim() } : {}) })
699      blank = false
700      i += cap ? 2 : 1
701      continue
702    }
703    if (/^\s*```/.test(line)) {
704      const start = i
705      i++
706      while (i < lines.length && !/^\s*```/.test(lines[i]!)) i++
707      md.push(...lines.slice(start, Math.min(i + 1, lines.length)))
708      i++
709      continue
710    }
711    if (/^\s*\|/.test(line)) {
712      const start = i
713      while (i < lines.length && /^\s*\|/.test(lines[i]!)) i++
714      const rows = lines.slice(start, i)
715      if (citations(rows.join('\n')).length === 0) {
716        md.push(...rows)
717        continue
718      }
719      flush()
720      const table = tableRuns(rows)
721      out.push({ type: 'rich', prefix: '', heading: 0, quote: false, runs: table.rows.flat(2), gap: blank, table })
722      blank = false
723      continue
724    }
725    if (!line.trim()) {
726      md.push(line)
727      i++
728      continue
729    }
730    // one block: a heading, a list item (with its continuation lines), a quote, or a paragraph
731    const head = /^(#{1,6})\s+(.*)$/.exec(line)
732    const item = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)
733    const quote = /^\s*>\s?(.*)$/.exec(line)
734    const start = i
735    let body: string[]
736    let prefix = ''
737    let heading = 0
738    let isQuote = false
739    if (head) {
740      heading = head[1]!.length
741      body = [head[2]!]
742      i++
743    } else if (item) {
744      prefix = `${item[1]!.replace(/\t/g, '  ')}${item[2]!} `
745      body = [item[3]!]
746      i++
747      while (i < lines.length && lines[i]!.trim() && /^\s{2,}\S/.test(lines[i]!) && !/^\s*([-*+]|\d+[.)])\s/.test(lines[i]!)) body.push(lines[i++]!.trim())
748    } else if (quote) {
749      isQuote = true
750      body = []
751      while (i < lines.length && /^\s*>/.test(lines[i]!)) body.push(lines[i++]!.replace(/^\s*>\s?/, ''))
752    } else {
753      // its first line whatever it starts with (an indented "# " is no heading), then up to the next block
754      body = [lines[i++]!]
755      while (
756        i < lines.length && lines[i]!.trim() && !EMBED_RE.test(lines[i]!) && !/^\s*(```|\||#{1,6}\s|>|([-*+]|\d+[.)])\s)/.test(lines[i]!)
757      ) body.push(lines[i++]!)
758    }
759    const joined = body.join(' ')
760    if (citations(joined).length === 0) {
761      md.push(...lines.slice(start, i))
762      continue
763    }
764    flush()
765    out.push({ type: 'rich', prefix, heading, quote: isQuote, runs: inlineRuns(joined), gap: blank })
766    blank = false
767  }
768  flush()
769  if (out[0]) out[0].gap = false
770  return out
771}
772
773/** A chunk of Markdown cut into its paragraphs (at blank lines outside a fence), each whole: what one "ask ›" asks
774 *  about. A fence, a table or a list stays one piece. */
775export function mdPieces(text: string): string[] {
776  const out: string[] = []
777  let cur: string[] = []
778  let fence = false
779  for (const line of text.split('\n')) {
780    if (/^\s*```/.test(line)) fence = !fence
781    if (!fence && !line.trim()) {
782      if (cur.length) out.push(cur.join('\n'))
783      cur = []
784    } else cur.push(line)
785  }
786  if (cur.length) out.push(cur.join('\n'))
787  return out
788}
789
790const ITEM_LINE = /^([-*+]|\d+[.)])\s/
791
792/** mdPieces with each top-level item of a list a piece of its own, so a report's "?" asks about one item; an item's
793 *  indented lines stay with it. `join`: the piece follows the one before without a blank line. */
794export function askPieces(text: string): { text: string; join: boolean }[] {
795  const out: { text: string; join: boolean }[] = []
796  for (const piece of mdPieces(text)) {
797    const lines = piece.split('\n')
798    if (/^\s*```/.test(lines[0]!) || !lines.some(l => ITEM_LINE.test(l))) {
799      out.push({ text: piece, join: false })
800      continue
801    }
802    let cur: string[] = []
803    let join = false
804    for (const l of lines) {
805      if (ITEM_LINE.test(l) && cur.length) {
806        out.push({ text: cur.join('\n'), join })
807        cur = []
808        join = true
809      }
810      cur.push(l)
811    }
812    out.push({ text: cur.join('\n'), join })
813  }
814  return out
815}
816
817/** Each heading of a reply text and its section: the heading, and every line up to the next heading of its level or
818 *  higher (its paragraphs, lists, tables and cards), keyed by the heading line as written. What "ask ›" beside a
819 *  heading asks about. Headings inside a fence are not headings. */
820export function sectionsOf(text: string): Map<string, string> {
821  const lines = text.split('\n')
822  const heads: { at: number; level: number }[] = []
823  let fence = false
824  lines.forEach((l, i) => {
825    if (/^\s*```/.test(l)) fence = !fence
826    const m = fence ? null : /^(#{1,6})\s+\S/.exec(l)
827    if (m) heads.push({ at: i, level: m[1]!.length })
828  })
829  const out = new Map<string, string>()
830  heads.forEach((h, k) => {
831    const end = heads.slice(k + 1).find(x => x.level <= h.level)?.at ?? lines.length
832    out.set(lines[h.at]!.trim(), lines.slice(h.at, end).join('\n').trim())
833  })
834  return out
835}
836
837/** Whether a reply block needs thimble-term's drawing: it embeds a card or holds a citation. */
838export function needsDrawing(text: string): boolean {
839  return text.split('\n').some(l => EMBED_RE.test(l)) || citations(text).length > 0
840}
841
842// ---------------------------------------------------------------------------------------- numbers (cite.py port)
843
844const NUM_RE = /(?<![\w:./#\-−])[-−]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?%?(?![\w%:])/g
845const PLAIN_RE = /^[-−]?(?:(?:0|[1-9][0-9]*)(?:\.[0-9]*)?|\.[0-9]+)$/
846const QUOTES = '"\'“”‘’'
847
848type Num = { v: bigint; scale: number }
849
850function parts(s: string): Num | null {
851  const t = s.trim().replaceAll(',', '').replaceAll('−', '-').replace(/%+$/, '').trim()
852  if (!PLAIN_RE.test(t)) return null
853  const neg = t.startsWith('-')
854  const body = neg ? t.slice(1) : t
855  const [int = '', frac = ''] = body.split('.')
856  const v = BigInt((int || '0') + frac)
857  return { v: neg ? -v : v, scale: frac.length }
858}
859
860function scaled(n: Num, scale: number): bigint {
861  return n.v * 10n ** BigInt(scale - n.scale)
862}
863
864function eq(a: Num, b: Num): boolean {
865  const s = Math.max(a.scale, b.scale)
866  return scaled(a, s) === scaled(b, s)
867}
868
869function rounded(b: Num, d: number): Num[] {
870  if (d >= b.scale) return [b]
871  const div = 10n ** BigInt(b.scale - d)
872  const neg = b.v < 0n
873  const mag = neg ? -b.v : b.v
874  const q = mag / div
875  const r = mag % div
876  const up = r * 2n >= div ? q + 1n : q
877  const even = r * 2n > div || (r * 2n === div && q % 2n === 1n) ? q + 1n : q
878  const sign = (x: bigint) => (neg ? -x : x)
879  return [{ v: sign(up), scale: d }, { v: sign(even), scale: d }]
880}
881
882function norm(tok: string): string {
883  const s = tok.replaceAll(',', '').replaceAll('−', '-').replace(/%+$/, '').trim()
884  const n = parts(s)
885  if (!n) return s
886  let { v, scale } = n
887  while (scale > 0 && v % 10n === 0n) {
888    v /= 10n
889    scale -= 1
890  }
891  if (v === 0n) return '0'
892  const neg = v < 0n
893  const digits = (neg ? -v : v).toString().padStart(scale + 1, '0')
894  const text = scale ? `${digits.slice(0, -scale)}.${digits.slice(-scale)}` : digits
895  return neg ? `-${text}` : text
896}
897
898/** The same value, or `shown` with only decimals dropped by rounding: "91%" cites 91.2, "6,500" does not cite 6,543. */
899export function shownMatches(token: string, shown: string): boolean {
900  const a = parts(token)
901  const b = parts(shown)
902  if (!a || !b) return norm(token) !== '' && norm(token) === norm(shown)
903  if (eq(a, b)) return true
904  return a.scale < b.scale && rounded(b, a.scale).some(r => eq(a, r))
905}
906
907const MONTHS: Record<string, number> = Object.fromEntries(
908  [['jan', 'january'], ['feb', 'february'], ['mar', 'march'], ['apr', 'april'], ['may'], ['jun', 'june'], ['jul', 'july'], ['aug', 'august'], ['sep', 'sept', 'september'], ['oct', 'october'], ['nov', 'november'], ['dec', 'december']].flatMap((names, i) => names.map(n => [n, i + 1])),
909)
910// a day and a month in words, as prose writes a date (`23 June`, `June 23`, `23rd June`, `Jun. 23`), with or without a
911// year and a time after it (`4 June 2026 at 10:53:40 UTC`); a date as an output writes it, ISO (`2026-06-23`, also at a
912// time stamp's head) or month and day (`06-23`), and the time a stamp writes right after its date
913const DAY_MONTH_RE = /^(?:(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3,9})\.?|([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?)(?:,?\s+(\d{4}))?(?:,?\s+(?:at\s+)?(\d{1,2}:\d{2}(?::\d{2})?)(?:\s*(?:UTC|GMT|Z))?)?$/i
914const ISO_DATE_RE = /(?<![\d-])(?:(\d{4})-)?(\d{2})-(\d{2})(?![\d-])/g
915const STAMP_CLOCK_RE = /^[T ](\d{1,2}:\d{2}(?::\d{2})?)(?![\d:])/
916const CLOCK_RE = /(?<![\d:])(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?(?![\d:])/g
917
918/** The month, day, year and time of a shown value that is a day and a month in words (`23 June`, `4 June 2026 at
919 *  10:53:40 UTC`), or null for any other words (backend cite._date_words). */
920export function dayMonth(display: string): { month: number; day: number; year: number | null; clock: string | null } | null {
921  const m = DAY_MONTH_RE.exec(display.trim())
922  if (!m) return null
923  const [day, word] = m[1] ? [m[1], m[2]!] : [m[4]!, m[3]!]
924  const month = MONTHS[word.toLowerCase()]
925  if (!month || Number(day) < 1 || Number(day) > 31) return null
926  return { month, day: Number(day), year: m[5] ? Number(m[5]) : null, clock: m[6] ?? null }
927}
928
929/** Whether every clock time the shown value writes is one the text writes: the same hour and minute, the same second
930 *  when the value gives one (backend cite.clocks_in). */
931export function clocksIn(display: string, text: string): boolean {
932  const have = [...text.matchAll(CLOCK_RE)].map(c => [Number(c[1]), c[2], c[3] ?? ''] as const)
933  return [...display.matchAll(CLOCK_RE)].every(c => have.some(([h, mi, se]) => h === Number(c[1]) && mi === c[2] && (!c[3] || c[3] === se)))
934}
935
936/** Whether a shown value that is a day and a month in words (`23 June`) names a date the text writes as ISO
937 *  (`2026-06-23`) or as month and day (`06-23`): the same month and day, the year too when both give one (backend
938 *  cite.date_in; live check term-fix6, new quirk 7: `23 June` citing a cell `06-23` was red). A time after the date is
939 *  the time the stamp writes after that date, or with none there, a clock time of the text (live check term-fix7, new
940 *  quirk 1: `4 June 2026 at 10:53:40 UTC` against 2026-06-04T10:53:40Z was not found). */
941export function dateIn(display: string, text: string): boolean {
942  const want = dayMonth(display)
943  if (!want) return false
944  for (const d of text.matchAll(ISO_DATE_RE)) {
945    if (Number(d[2]) !== want.month || Number(d[3]) !== want.day || (want.year !== null && d[1] && Number(d[1]) !== want.year)) continue
946    if (want.clock === null) return true
947    const stamp = STAMP_CLOCK_RE.exec(text.slice(d.index! + d[0].length))
948    if (clocksIn(want.clock, stamp ? stamp[1]! : text)) return true
949  }
950  // a date the text writes in words (`23 June`, a row named `last delete on 30 June`; backend cite.date_in, live check
951  // term-fix9, quirk 11)
952  return datesInWords(text).some(d => sameDate(want, d.date, text))
953}
954
955/** Whether a date in words (`got`, at its place in `text`) is the day and month `want` names, the year too when both
956 *  give one, and its clock time among the text's when `want` gives one. */
957function sameDate(want: NonNullable<ReturnType<typeof dayMonth>>, got: NonNullable<ReturnType<typeof dayMonth>>, text: string): boolean {
958  if (got.month !== want.month || got.day !== want.day || (want.year !== null && got.year !== null && got.year !== want.year)) return false
959  return want.clock === null || clocksIn(want.clock, got.clock ?? text)
960}
961
962const MONTH_WORD = `(?:${Object.keys(MONTHS).sort((a, b) => b.length - a.length).join('|')})`
963// a day and a month in words inside a text, with its year and time when it has them (backend cite._DATE_IN_TEXT_RE)
964const DATE_IN_TEXT_RE = new RegExp(`(?<![\\w.])(?:\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH_WORD}\\b\\.?|${MONTH_WORD}\\.?\\s+\\d{1,2}(?:st|nd|rd|th)?(?![\\d:]))(?:,?\\s+\\d{4}(?!\\d))?(?:,?\\s+(?:at\\s+)?\\d{1,2}:\\d{2}(?::\\d{2})?(?:\\s*(?:UTC|GMT|Z)\\b)?)?`, 'gi')
965
966/** Each day and month a text writes in words, where it stands and what it names; `may` in lower case is the verb. */
967export function datesInWords(text: string): { at: number; end: number; date: NonNullable<ReturnType<typeof dayMonth>> }[] {
968  const out: { at: number; end: number; date: NonNullable<ReturnType<typeof dayMonth>> }[] = []
969  for (const m of text.matchAll(DATE_IN_TEXT_RE)) {
970    const words = m[0].replace(/[,\s]+$/, '').replace(/\.$/, '')
971    const date = dayMonth(words)
972    if (date && !/\bmay\b/.test(words)) out.push({ at: m.index!, end: m.index! + words.length, date })
973  }
974  return out
975}
976
977const STAMP_RE = /(?<![\d-])(?:\d{4}-)?\d{2}-\d{2}(?:[T ]\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?Z?)?(?![\d-])/g
978
979/** Where a line writes the date a shown value names in words, in digits (`23 June` at `2026-06-23`) or in words: each
980 *  stamp dateIn accepts, and each date in words of the same day, as [start, end], so a citation's panel marks the date
981 *  it cites. */
982export function dateSpans(display: string, line: string): number[][] {
983  const want = dayMonth(display)
984  if (!want) return []
985  const stamps = [...line.matchAll(STAMP_RE)].filter(m => dateIn(display, m[0])).map(m => [m.index!, m.index! + m[0].length])
986  return [...stamps, ...datesInWords(line).filter(d => sameDate(want, d.date, line)).map(d => [d.at, d.end])].sort((a, b) => a[0]! - b[0]!)
987}
988
989/** Whether a shown value is in a text: a number must match a whole number of it, a day and a month in words a date it
990 *  writes in digits (dateIn), anything else is a substring. */
991export function valueIn(display: string, text: string): boolean {
992  for (const m of text.matchAll(NUM_RE)) if (shownMatches(display, m[0])) return true
993  if (dateIn(display, text)) return true
994  const d = display.trim()
995  if (new RegExp(`^(?:${NUM_RE.source})$`).test(d)) return false
996  let words = d
997  if (words.length > 2 && QUOTES.includes(words[0]!) && QUOTES.includes(words.at(-1)!)) words = words.slice(1, -1)
998  return text.replaceAll(',', '').includes(norm(words))
999}
1000
1001/** The value a verification script printed: its last line of the form `RESULT: <value>`, or null. */
1002export function scriptResult(stdout: string): string | null {
1003  const hits = [...stdout.matchAll(/^RESULT:\s*(.+?)\s*$/gm)]
1004  return hits.length ? hits.at(-1)![1]! : null
1005}
1006
1007/** A value as a card shows it: an integer whole, a float to at most 3 decimals. */
1008export function fmt(v: unknown): string {
1009  if (typeof v === 'number') {
1010    if (!Number.isFinite(v)) return String(v)
1011    if (Number.isInteger(v)) return String(v)
1012    const s = v.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
1013    return s === '-0' ? '0' : s
1014  }
1015  return v === null || v === undefined ? '' : String(v)
1016}
1017
1018// the number formats thimble gives a table's columns (backend frames.default_format, which d3-format reads in the
1019// browser): `,d` and `d` for whole numbers, `,.N~f` and `.N~f` for the others
1020const FORMAT_RE = /^(,)?(?:d|\.(\d)~f)$/
1021
1022/** A number in a table column's format, as the browser's table writes it (FrameTable's cellText, d3-format), for the
1023 *  formats thimble gives; null for any other format. A negative number takes the minus sign, as d3-format writes it. */
1024export function formatted(v: number, spec: string | undefined): string | null {
1025  const m = spec ? FORMAT_RE.exec(spec) : null
1026  if (!m || !Number.isFinite(v)) return null
1027  const places = m[2] !== undefined ? Number(m[2]) : 0
1028  // a tie rounds away from zero, as the backend's ROUND_HALF_UP of the float's exact value and JS's toFixed do
1029  let text = Math.abs(v).toFixed(places)
1030  const [whole = '0', frac0 = ''] = text.split('.')
1031  const frac = m[2] !== undefined ? frac0.replace(/0+$/, '') : ''
1032  const grouped = m[1] ? whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : whole
1033  const zero = Number(text) === 0
1034  text = `${grouped}${frac ? `.${frac}` : ''}`
1035  return (v < 0 || Object.is(v, -0)) && !zero ? `−${text}` : text
1036}
1037
1038/** A record's fields as a label's example shows them, when its words are a JSON object (a line of a JSON lines file):
1039 *  `read`, the fields the label's rule reads (a code label's `unit['name']` or `.get('name')`, the field a pattern
1040 *  matches, the fields a prompt names, else the record's words), each its value as a string; `rest`, the other fields
1041 *  whose value is one value, in the record's order. null for words that are no JSON object. A cut record (`…` at its
1042 *  end) is read field by field as far as it goes. */
1043export function recordFields(text: string, rule: { kind?: string; spec?: string; match?: string }): { read: [string, string][]; rest: [string, string][] } | null {
1044  const t = text.trim()
1045  if (!t.startsWith('{')) return null
1046  let fields: [string, unknown][] = []
1047  try {
1048    const v = JSON.parse(t) as unknown
1049    if (!v || typeof v !== 'object' || Array.isArray(v)) return null
1050    fields = Object.entries(v as Record<string, unknown>)
1051  } catch {
1052    // its fields as far as the cut words go: each `"key": value` of a scalar value
1053    for (const m of t.matchAll(/"((?:[^"\\]|\\.)+)"\s*:\s*("(?:[^"\\]|\\.)*"|-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?|true|false|null)/g)) {
1054      try {
1055        fields.push([JSON.parse(`"${m[1]!}"`) as string, JSON.parse(m[2]!) as unknown])
1056      } catch {
1057        // a field whose words cannot be read is left out
1058      }
1059    }
1060    if (!fields.length) return null
1061  }
1062  const words = (v: unknown): string => (typeof v === 'string' ? v : v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v))
1063  const keys = fields.map(([k]) => k)
1064  const spec = rule.spec ?? ''
1065  let read: string[] = []
1066  if (rule.kind === 'code') {
1067    for (const m of spec.matchAll(/\[\s*(['"])([^'"\n]+)\1\s*\]|\.get\(\s*(['"])([^'"\n]+)\3/g)) {
1068      const k = m[2] ?? m[4]!
1069      if (keys.includes(k) && !read.includes(k)) read.push(k)
1070    }
1071  } else if (rule.kind === 'regex') {
1072    const hit = (v: unknown) => {
1073      if (typeof v !== 'string') return false
1074      if (rule.match) return v.includes(rule.match)
1075      try {
1076        return new RegExp(spec).test(v)
1077      } catch {
1078        return false
1079      }
1080    }
1081    read = fields.filter(([, v]) => hit(v)).map(([k]) => k).slice(0, 2)
1082  } else if (spec) {
1083    const lower = spec.toLowerCase()
1084    read = keys.filter(k => new RegExp(`\\b${k.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/_/g, '[_ ]')}\\b`).test(lower)).slice(0, 2)
1085  }
1086  if (!read.length) {
1087    // a record's words: its text field, else its longest words
1088    const text = keys.find(k => /^(text|content|message|body|msg|comment|title|name|summary)$/i.test(k) && typeof fields.find(([x]) => x === k)?.[1] === 'string')
1089    const longest = fields.filter(([, v]) => typeof v === 'string').sort((a, b) => words(b[1]).length - words(a[1]).length)[0]?.[0]
1090    read = [text ?? longest ?? keys[0]!].filter(Boolean)
1091  }
1092  const one = (v: unknown) => v === null || ['string', 'number', 'boolean'].includes(typeof v)
1093  return {
1094    read: read.map(k => [k, words(fields.find(([x]) => x === k)?.[1])]),
1095    rest: fields.filter(([k, v]) => !read.includes(k) && one(v)).map(([k, v]) => [k, words(v)]),
1096  }
1097}
1098
1099/** The sentence of a reply that holds a citation, for the prompt that asks for its verification script. */
1100export function sentenceOf(text: string, raw: string): string {
1101  const at = text.indexOf(raw)
1102  if (at < 0) return ''
1103  const before = text.slice(0, at)
1104  const start = Math.max(before.lastIndexOf('. ') + 1, before.lastIndexOf('\n') + 1, 0)
1105  const rest = text.slice(at)
1106  const m = /[.!?](\s|$)|\n/.exec(rest)
1107  return text.slice(start, at + (m ? m.index + 1 : rest.length)).trim()
1108}
1109
1110// ---------------------------------------------------------------------------------------- card files
1111
1112// `label` is the label tool's own card (cell.ts labelCard), never one main writes
1113export const CARD_KINDS = ['bar', 'line', 'timeline', 'table', 'example', 'diagram', 'label'] as const
1114export const MAX_DIAGRAM_NODES = 40
1115export const MAX_DIAGRAM_EDGES = 80
1116
1117const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
1118const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
1119const isStr = (v: unknown): v is string => typeof v === 'string'
1120const isCell = (v: unknown) => v === null || isStr(v) || isNum(v) || typeof v === 'boolean'
1121
1122/** Why a card file cannot be drawn, or null when it fits its kind's spec. The mod draws only these typed specs; a card
1123 *  that fails is drawn as this error, and main is asked to fix it. */
1124export function validateCard(c: unknown, id?: string): string | null {
1125  if (!isObj(c)) return 'the file is not a JSON object'
1126  if (!isStr(c.id) || !/^[A-Za-z0-9_-]+$/.test(c.id)) return 'no valid id'
1127  if (id !== undefined && c.id !== id) return `its id is ${c.id}, not ${id}`
1128  if (!isStr(c.kind) || !(CARD_KINDS as readonly string[]).includes(c.kind)) return `kind must be one of ${CARD_KINDS.join(', ')}`
1129  if (!isStr(c.question) || !c.question.trim()) return 'no question'
1130  const list = (k: string) => (Array.isArray(c[k]) ? (c[k] as unknown[]) : null)
1131  switch (c.kind) {
1132    case 'bar':
1133    case 'label': {
1134      const rows = list('rows')
1135      if (!rows?.length) return `a ${c.kind} card needs rows`
1136      const bad = rows.findIndex(r => !isObj(r) || !isStr(r.label) || !isNum(r.value))
1137      if (bad >= 0) return `${c.kind} row ${bad + 1} needs a label and a finite number value`
1138      if (c.kind === 'bar') break
1139      const l = c.label
1140      if (!isObj(l) || !isStr(l.slug) || !isStr(l.name) || !Array.isArray(l.values) || !l.values.every(isStr)) return 'a label card needs its label: slug, name and values'
1141      const exs = list('examples') ?? []
1142      const badX = exs.findIndex(e => !isObj(e) || !isStr(e.ref) || !isStr(e.quote) || !isStr(e.value) || !(l.values as string[]).includes(e.value))
1143      if (badX >= 0) return `label card example ${badX + 1} needs a ref, its words and one of the label's values`
1144      break
1145    }
1146    case 'line': {
1147      const series = list('series')
1148      if (!series?.length) return 'a line card needs series'
1149      for (const s of series) {
1150        if (!isObj(s) || !isStr(s.name) || !Array.isArray(s.points) || s.points.length === 0) return 'each series needs a name and points'
1151        const bad = (s.points as unknown[]).findIndex(p => !Array.isArray(p) || p.length !== 2 || !(isStr(p[0]) || isNum(p[0])) || !isNum(p[1]))
1152        if (bad >= 0) return `series ${s.name}: point ${bad + 1} must be [x, number]`
1153      }
1154      break
1155    }
1156    case 'timeline': {
1157      const evs = list('events')
1158      if (!evs?.length) return 'a timeline card needs events'
1159      const bad = evs.findIndex(e => !isObj(e) || !isStr(e.time) || !isStr(e.label) || !isStr(e.ref) || !(e.shown === undefined || isStr(e.shown)))
1160      if (bad >= 0) return `event ${bad + 1} needs time, label and ref (shown is optional text)`
1161      break
1162    }
1163    case 'table': {
1164      const cols = list('columns')
1165      const rows = list('rows')
1166      if (!cols?.length || !cols.every(isStr)) return 'a table card needs columns, as strings'
1167      if (!rows) return 'a table card needs rows'
1168      const bad = rows.findIndex(r => !Array.isArray(r) || r.length !== cols.length || !r.every(isCell))
1169      if (bad >= 0) return `table row ${bad + 1} must have ${cols.length} plain values`
1170      break
1171    }
1172    case 'example': {
1173      const exs = list('examples')
1174      if (!exs?.length) return 'an example card needs examples'
1175      const bad = exs.findIndex(e => !isObj(e) || !isStr(e.ref) || !/#L\d+/.test(e.ref) || !isStr(e.quote))
1176      if (bad >= 0) return `example ${bad + 1} needs a ref to lines (file#L12) and a quote`
1177      break
1178    }
1179    case 'diagram': {
1180      const nodes = list('nodes')
1181      const edges = list('edges') ?? []
1182      if (!nodes?.length) return 'a diagram card needs nodes'
1183      if (nodes.length > MAX_DIAGRAM_NODES) return `a diagram card shows at most ${MAX_DIAGRAM_NODES} nodes`
1184      if (edges.length > MAX_DIAGRAM_EDGES) return `a diagram card shows at most ${MAX_DIAGRAM_EDGES} edges`
1185      const opt = (v: unknown) => v === undefined || isStr(v)
1186      const bad = nodes.findIndex(n => !isObj(n) || !isStr(n.id) || !n.id || !isStr(n.label) || !n.label.trim() || !opt(n.ref) || !opt(n.detail))
1187      if (bad >= 0) return `node ${bad + 1} needs an id and a label (ref and detail are optional text)`
1188      const ids = new Set(nodes.map(n => (n as { id: string }).id))
1189      if (ids.size !== nodes.length) return 'each node needs an id of its own'
1190      const badE = edges.findIndex(e => !isObj(e) || !isStr(e.source) || !isStr(e.target) || !ids.has(e.source) || !ids.has(e.target) || !opt(e.label))
1191      if (badE >= 0) return `edge ${badE + 1} needs a source and a target among the node ids`
1192      break
1193    }
1194  }
1195  if (c.labels !== undefined) {
1196    const ls = c.labels
1197    if (!Array.isArray(ls) || ls.some(l => !isObj(l) || !isStr(l.slug) || !isStr(l.name) || !Array.isArray(l.values) || !l.values.every(isStr) || !(l.marks === undefined || isObj(l.marks)))) {
1198      return 'labels must be a list of {slug, name, values, marks?}, as the card helper writes it'
1199    }
1200  }
hooks/model.ts 561 lines
1// What thimble-term reads from `thimble state`, put in the forms its drawing takes (no `$`): cells, a citation's check,
2// a thread's chat as a side thread's turns, the threads list, the agents, the workspace's counts; and the rules for what
3// a turn of main made (the cards each tool call names) and what main's reply shows (no end token).
4//
5// Each reader takes the shape the server's GET route for that surface answers, and is lenient: a field it cannot read
6// is left out rather than failing the drawing.
7import type { ChatThread, ChatThreadTurn, TermAgent, TermHome, TermThreadRow, TermVerdict } from '../types'
8import type { ThimbleCell, ThimbleLabel } from './cell'
9import { STOP_KINDS, clip, curlyQuotes, dateSpans, quoted, shownMatches, valueIn } from './lib'
10import type { Citation } from './lib'
11import { quotedWords, showsValue } from './cite'
12
13type Obj = Record<string, unknown>
14
15const isObj = (v: unknown): v is Obj => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
16const str = (v: unknown): string => (typeof v === 'string' ? v : v === null || v === undefined ? '' : String(v))
17const num = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : Array.isArray(v) ? v.length : 0)
18
19// ------------------------------------------------------------------------------------------------ cells and labels
20
21/** The cells `thimble state cards --since` printed: the canvas route's `{groups, cells}`, or a list of cells. */
22export function cellsOf(v: unknown): ThimbleCell[] {
23  const list = Array.isArray(v) ? v : isObj(v) && Array.isArray(v.cells) ? v.cells : []
24  return list.filter((c): c is ThimbleCell => isObj(c) && typeof c.id === 'string')
25}
26
27/** The cell `thimble state card <id>` printed: the cell route's cell, or `{cell}`. */
28export function cellOf(v: unknown): ThimbleCell | null {
29  const c = isObj(v) && isObj(v.cell) ? v.cell : v
30  return isObj(c) && typeof c.id === 'string' ? (c as ThimbleCell) : null
31}
32
33/** A label with its counts in `label_stats`, where the drawing reads them: the concept routes give them as `counts`
34 *  and `n_labeled` beside the concept's fields (concepts.with_stats); the label's stored file holds `label_stats`. */
35function withStats(c: Obj): ThimbleLabel {
36  if (isObj(c.label_stats) || !isObj(c.counts)) return c as ThimbleLabel
37  return { ...c, label_stats: { counts: c.counts as Record<string, number>, ...(typeof c.n_labeled === 'number' ? { n_labeled: c.n_labeled } : {}) } } as ThimbleLabel
38}
39
40/** The label `thimble state label <id>` printed: the concept route's concept, or `{concept, rows}`. */
41export function labelOf(v: unknown): ThimbleLabel | null {
42  if (!isObj(v)) return null
43  const c = isObj(v.concept) && typeof v.concept.id === 'string' ? { ...v.concept, ...(Array.isArray(v.rows) ? { rows: v.rows } : {}) } : v
44  return typeof c.id === 'string' ? withStats(c) : null
45}
46
47/** The labels `thimble state labels` printed: the concepts route's list. */
48export function labelsOf(v: unknown): ThimbleLabel[] {
49  const list = Array.isArray(v) ? v : isObj(v) && Array.isArray(v.labels) ? v.labels : isObj(v) && Array.isArray(v.concepts) ? v.concepts : []
50  return list.filter((c): c is Obj => isObj(c) && typeof c.id === 'string').map(withStats)
51}
52
53/** The count a link to a label shows now (`concept:<id>/<value>`; the label whole, `value` '', for the records it
54 *  labeled), with the analyst's verdicts, as the label card's bars read it; null when the label or the value is not
55 *  known. */
56export function labelCountNow(l: ThimbleLabel | null | undefined, value: string): number | null {
57  if (!l) return null
58  const counts = l.verdicts?.counts ?? l.label_stats?.counts
59  if (!counts) return null
60  if (!value) return l.label_stats?.n_labeled ?? Object.values(counts).reduce((a, b) => a + b, 0)
61  return value in counts ? counts[value]! : (l.labels ?? []).includes(value) ? 0 : null
62}
63
64/** What a takeaway's link to a label says when the label no longer counts its number (a verdict, or a run, changed the
65 *  counts): the count now, and that the verdicts are in it; '' when the number is the count, or no number (live check
66 *  term-fix7, quirk 8: after a verdict the takeaway's 180 and 4,399 stayed blue beside bars reading 179 and 4,400). */
67export function labelLinkStale(display: string | null, l: ThimbleLabel | null | undefined, value: string): string {
68  const now = labelCountNow(l, value)
69  if (now === null || display === null || !/^[-−]?(?:\d{1,3}(?:,\d{3})+|\d+)$/.test(display.trim())) return ''
70  if (shownMatches(display.trim(), String(now))) return ''
71  return `the label counts ${now.toLocaleString('en-US')} now${l?.verdicts?.set ? ', with your verdicts' : ''}`
72}
73
74/** The label a label card counts: its payload's concept, else the first label it carries. */
75export function labelIdOf(cell: ThimbleCell): string {
76  const p = cell.payload
77  const c = p && typeof p === 'object' ? str((p as Obj).concept) : ''
78  return c || (cell.kind === 'label' ? str(cell.labels?.[0]) : '')
79}
80
81// ------------------------------------------------------------------------------------------------ main's turn
82
83const CARD_TOOLS = /^mcp__plugin_thimble_thimble__(add_card|edit_card|add_cell|edit_cell)$/
84const LABEL_TOOL = /^mcp__plugin_thimble_thimble__apply_label$/
85const RUN_RE = /(?:^|[\s/'"])thimble-run['"]?\s+(card|label|stale)\b\s*['"]?([A-Za-z0-9_-]*)/g
86
87/** The cards a call of main's names as its own: add_card's and edit_card's card, apply_label's label card; for a Bash
88 *  command that runs `thimble-run` (`thimble-run card <id>`, or several in a shell loop), each card it names and each
89 *  card its output names on a line of its own (`card:<id>`, as add_card prints it), the label card of a code label's
90 *  `thimble-run label`. */
91export function cardsOfCall(tool: string, input: unknown, text: string): string[] {
92  const inp = isObj(input) ? input : {}
93  if (CARD_TOOLS.test(tool)) {
94    const own = /^card:([A-Za-z0-9_-]+)\s*$/m.exec(text)?.[1] ?? /\bcard:([A-Za-z0-9_-]+)/.exec(text)?.[1]
95    const named = str(inp.card).replace(/^(?:card|cell):/, '')
96    return [...new Set([own, named].filter((x): x is string => Boolean(x)))].slice(0, 1)
97  }
98  if (LABEL_TOOL.test(tool)) {
99    const card = /The label's card is \[\[card:([A-Za-z0-9_-]+)\]\]/.exec(text)?.[1]
100    return card ? [card] : []
101  }
102  if (tool === 'Bash') {
103    const cmd = str(inp.command)
104    if (!/thimble-run\b/.test(cmd)) return []
105    const out = new Set<string>()
106    for (const m of cmd.matchAll(RUN_RE)) if (m[1] === 'card' && m[2]) out.add(m[2])
107    for (const m of text.matchAll(/^card:([A-Za-z0-9_-]+)\s*$/gm)) out.add(m[1]!)
108    const card = /The label's card is \[\[card:([A-Za-z0-9_-]+)\]\]/.exec(text)?.[1]
109    if (card) out.add(card)
110    return [...out]
111  }
112  return []
113}
114
115/** The card ids a Bash command that runs `thimble-run card` names: after `thimble-run card`, or in a shell loop's list
116 *  (`for c in 2d10d7f3 9b4bb0cb; do $B card $c; done`). */
117export function runIds(command: string): string[] {
118  if (!/thimble-run\b/.test(command) || !/\bcard\b/.test(command)) return []
119  return [...new Set([...command.matchAll(/(?:card:)?\b([0-9a-f]{8})\b/g)].map(m => m[1]!))]
120}
121
122/** A Bash command that runs `thimble-run`, as its row shows it: the verb, and for `card` each card by its question
123 *  (`questionOf`), never the install path or an id; a shell loop's cards too. Cards not read yet are counted. */
124export function runShown(command: string, questionOf: (id: string) => string | undefined): string {
125  const verb = /(?:thimble-run|\$\{?\w+\}?)['"]?\s+['"]?(card|label|stale)\b/.exec(command)?.[1]
126  if (verb !== 'card') return verb ? `thimble-run ${verb}` : 'thimble-run'
127  const ids = runIds(command)
128  const named = ids.map(questionOf).filter((q): q is string => Boolean(q))
129  // each question cut at a word, never mid-word
130  const short = (q: string) => quoted(clip(q, 40))
131  if (named.length && named.length === ids.length) return `thimble-run card ${named.map(short).join(', ')}`
132  return ids.length > 1 ? `thimble-run card · ${ids.length} cards` : 'thimble-run card'
133}
134
135type NamedRow = { id: string; title: string; question?: string; fork?: string }
136
137/** A thread's first question as its rows name it: one line, its citations as their words. */
138function questionWords(q: string | undefined): string {
139  return (q ?? '').replace(/\[\[([^|\]]*)\|[^\]]*\]\]/g, '$1').replace(/\s+/g, ' ').trim()
140}
141
142/** The thread a fork's name names: the slug its forks run under (`fork_name`), its title or its id. */
143function bySlug(rows: readonly NamedRow[], name: string): NamedRow | undefined {
144  return rows.find(x => x.fork === name || x.title === name || x.id === name)
145}
146
147/** main's `↳ thread <name>: …` lines (main.md's form, `<name>` the fork's slug) with each thread named by its first
148 *  question in quotation marks, as the chat names a thread everywhere else. */
149export function namedThreads(text: string, rows: readonly NamedRow[]): string {
150  if (!/↳\s*thread\s+[A-Za-z0-9_-]/.test(text)) return text
151  return text.replace(/(↳\s*thread\s+)([A-Za-z0-9_-]+)(\s*:)/g, (m, lead: string, name: string, colon: string) => {
152    const q = questionWords(bySlug(rows, name)?.question)
153    if (!q) return m
154    return `${lead}${quoted(clip(q, 40))}${colon}`
155  })
156}
157
158/** main's own `↳ thread <name>: …` lines left out for each thread whose row thimble-term drew in main's chat (`told`,
159 *  by id): the row says the thread answered, so the chat does not say it twice. `<name>` is the fork's slug, or the
160 *  thread's first question in quotation marks. */
161export function withoutToldThreads(text: string, rows: readonly NamedRow[], told: ReadonlySet<string>): string {
162  if (!told.size || !/↳\s*thread\s/.test(text)) return text
163  const lines = text.split('\n')
164  const kept = lines.filter(l => {
165    const m = /^\s*↳\s*thread\s+("[^"]*"|“[^”]*”|[A-Za-z0-9_:,.-]+?)\s*:/.exec(l)
166    if (!m) return true
167    const name = m[1]!
168    const words = /^["“]/.test(name) ? questionWords(name.slice(1, -1)).replace(/…$/, '') : ''
169    const r = words ? rows.find(x => words.length >= 8 && questionWords(x.question).startsWith(words)) : bySlug(rows, name)
170    return !(r && told.has(r.id))
171  })
172  if (kept.length === lines.length) return text
173  return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim()
174}
175
176// main's own line about a writer's end (prompts/main.md: `↳ The writer finished; thimble shows it.`, also `↳ The report
177// writer finished, …`), which it writes once for the writer's message and again for Claude Code's task notification
178const WRITER_LINE = /^\s*↳\s*(?:the\s+)?(?:[\w-]+\s+)?writer\b/i
179
180/** Whether a reply holds main's `↳ The writer …` line. */
181export function saysWriter(text: string): boolean {
182  return text.split('\n').some(l => WRITER_LINE.test(l))
183}
184
185/** A reply without main's `↳ The writer …` lines: for a row whose writer's end an earlier row said already (live check
186 *  term-fix6, new quirk 6: `↳ The writer finished the report; thimble shows it.` once after the writer's message and
187 *  again after the task notification). */
188export function withoutWriterLines(text: string): string {
189  if (!saysWriter(text)) return text
190  return text.split('\n').filter(l => !WRITER_LINE.test(l)).join('\n').replace(/\n{3,}/g, '\n\n').trim()
191}
192
193/** An Agent call's description or a task notification's words with each thread's fork (`thread:<slug>`, in quotation
194 *  marks or not) named by the thread's first question, as everywhere else: `thread "How many of the 2,994…"`. A fork
195 *  of a thread not listed keeps its words. */
196export function namedForks(text: string, rows: readonly NamedRow[], n = 40): string {
197  if (!/\bthread:/.test(text)) return text
198  return text.replace(/"?\bthread:([^\s"]+?)([.,;:]*)"?(?=\s|$)/g, (m, slug: string, tail: string) => {
199    const q = questionWords(bySlug(rows, slug)?.question)
200    return q ? `thread ${quoted(clip(q, n))}${tail}` : m
201  })
202}
203
204/** A reply without main's own `↳` lines (a hand-back's or a thread's note to the terminal): the answer a thread about
205 *  it is told. */
206export function withoutNotes(text: string): string {
207  return text.split('\n').filter(l => !/^\s*↳/.test(l)).join('\n').replace(/\n{3,}/g, '\n\n').trim()
208}
209
210/** The description main's Agent call for a thread's fork runs with: the thread's first question, `thread: How many…`,
211 *  in place of `thread:<slug>`, so that Claude Code's agent tray and exit dialog name the thread as the chat does. No
212 *  quotation marks around the question, since Claude Code puts the description in its own (`Agent "thread: How
213 *  many…" finished`); straight ones inside it are curly. null
214 *  for any other call, for a thread not listed, and when the call's prompt is not `thread:<name>`, which must stay to
215 *  name the fork (backend threads.fork_ref). */
216export function forkDescription(input: Record<string, unknown>, rows: readonly NamedRow[]): string | null {
217  if (input.subagent_type !== 'fork') return null
218  const ref = /^\s*thread:([A-Za-z0-9_-]{1,64})\s*$/
219  const prompt = typeof input.prompt === 'string' ? ref.exec(input.prompt) : null
220  if (!prompt || typeof input.description !== 'string' || !ref.test(input.description)) return null
221  const q = questionWords(bySlug(rows, prompt[1]!)?.question)
222  return q ? `thread: ${curlyQuotes(clip(q, 40))}` : null
223}
224
225/** main's end token: what it ends a turn with when it has nothing for the analyst, which no chat shows (session.py). */
226export const END_TOKEN = '(shown in the dashboard)'
227const END_RE = /[ \t]*[*_]*\(shown in the dashboard\)\.?[*_]*\.?\s*$/i
228
229/** A reply block as the analyst reads it: without main's end token. */
230export function withoutEnd(text: string): string {
231  const m = END_RE.exec(text)
232  return m ? text.slice(0, m.index).replace(/\s+$/, '') : text
233}
234
235// ------------------------------------------------------------------------------------------------ a citation's check
236
237type Resolution = Obj & { error?: unknown; excerpt?: unknown; meta?: unknown; path?: unknown; line?: unknown; kind?: unknown; record?: unknown; context?: unknown; blocks?: unknown; end_line?: unknown }
238
239/** The resolution of one ref out of what `thimble state resolve` printed: `{ref: resolution}`, a list of resolutions
240 *  (each naming its `ref`), or `{refs: …}` around either. */
241export function resolutionOf(v: unknown, ref: string): Resolution | null {
242  const inner = isObj(v) && (isObj(v.refs) || Array.isArray(v.refs)) ? v.refs : v
243  if (Array.isArray(inner)) {
244    const hit = inner.find(x => isObj(x) && x.ref === ref)
245    return isObj(hit) ? (hit as Resolution) : null
246  }
247  if (isObj(inner) && isObj(inner[ref])) return inner[ref] as Resolution
248  return null
249}
250
251function blocksText(b: unknown): string {
252  return Array.isArray(b) ? b.map(x => (isObj(x) ? str(x.text) : str(x))).join('\n') : ''
253}
254
255function recordText(r: unknown): string {
256  if (!isObj(r)) return str(r)
257  const blocks = blocksText(r.blocks)
258  return blocks || str(r.text) || (isObj(r.record) ? JSON.stringify(r.record) : str(r.record))
259}
260
261/** Where the shown value stands in a line: each [start, end) of it. */
262function spansIn(line: string, display: string | null): number[][] {
263  const words = quotedWords(display) || (display ?? '').trim()
264  if (!words) return []
265  const out: number[][] = []
266  const lower = line.toLowerCase()
267  const w = words.toLowerCase()
268  for (let i = lower.indexOf(w); i >= 0 && out.length < 4; i = lower.indexOf(w, i + w.length)) out.push([i, i + w.length])
269  if (out.length) return out
270  // a date in words (`23 June`) where the line writes it in digits (2026-06-23)
271  const dates = dateSpans(words, line)
272  if (dates.length) return dates.slice(0, 4)
273  // a number written another way (3,908 against 3908): each number of the line that matches it
274  for (const m of line.matchAll(/[-−]?\d[\d,]*(?:\.\d+)?%?/g)) if (shownMatches(m[0], words)) out.push([m.index!, m.index! + m[0].length])
275  return out.slice(0, 4)
276}
277
278/** A value as one line of JSON written as Python writes it (`{"a": 1, "b": [2, 3]}`), the way a JSON lines file holds
279 *  its records. */
280export function jsonLine(v: unknown): string {
281  if (Array.isArray(v)) return `[${v.map(jsonLine).join(', ')}]`
282  if (isObj(v)) return `{${Object.entries(v).map(([k, x]) => `${JSON.stringify(k)}: ${jsonLine(x)}`).join(', ')}}`
283  return JSON.stringify(v) ?? 'null'
284}
285
286/** A record around a cited one as one line, as Raw draws the file's lines: a JSON record as its line of JSON, a text
287 *  line as its words. */
288export function recordLine(r: unknown): string {
289  if (!isObj(r)) return str(r).replace(/\s*\n\s*/g, ' ')
290  const rec = r.record
291  if (isObj(rec) && Object.keys(rec).length === 1 && typeof rec._raw === 'string') return rec._raw
292  if (isObj(rec) && Object.keys(rec).length === 1 && typeof rec.text === 'string') return rec.text
293  if (isObj(rec) || Array.isArray(rec)) return jsonLine(rec)
294  return recordText(r).replace(/\s*\n\s*/g, ' ')
295}
296
297/** A record around a cited one as one line, drawn as the cited record is drawn: its words, as the file's reader shows
298 *  them (a transcript's message, an event's params), when thimble reads words in it; else as Raw draws it (recordLine: a
299 *  JSON record as its line of JSON). Live check term-fix10, low quirk: the cited line read as its words and the lines
300 *  around it as raw JSON. */
301export function aroundLine(r: unknown): string {
302  const blocks = isObj(r) && Array.isArray(r.blocks) ? r.blocks.filter(isObj) : []
303  const words = blocks.length && blocks.every(b => typeof b.kind === 'string' && b.kind !== 'raw') ? blocksText(blocks) : ''
304  return words.trim() ? words.replace(/\s*\n\s*/g, ' ') : recordLine(r)
305}
306
307/** The lines of a resolved place, the cited ones hit (a record over its rows, at most 31), each record around them one
308 *  line, drawn as the cited one is (aroundLine). */
309function linesOf(res: Resolution, display: string | null): TermVerdict['lines'] {
310  const out: TermVerdict['lines'] = []
311  const line = typeof res.line === 'number' ? res.line : 0
312  const ctx = isObj(res.context) ? res.context : {}
313  const push = (n: number, text: string, hit: boolean) => {
314    for (const [k, t] of text.split('\n').entries()) {
315      if (k > 30) break
316      out.push({ n: k === 0 ? n : 0, text: t, hit, ...(hit ? { spans: spansIn(t, display) } : {}) })
317    }
318  }
319  const before = Array.isArray(ctx.before) ? ctx.before : []
320  const after = Array.isArray(ctx.after) ? ctx.after : []
321  const around = (n: number, r: unknown) => out.push({ n, text: aroundLine(r), hit: false })
322  before.forEach((r, i) => around(isObj(r) && typeof r.line === 'number' ? r.line : line - before.length + i, r))
323  if (Array.isArray(res.records) && res.records.length) res.records.forEach((r, i) => push(isObj(r) && typeof r.line === 'number' ? r.line : line + i, recordText(r), true))
324  else push(line, line ? blocksText(res.blocks) || str(res.excerpt) : str(res.excerpt), true)
325  after.forEach((r, i) => around(isObj(r) && typeof r.line === 'number' ? r.line : line + 1 + i, r))
326  return out
327}
328
329/** A citation's check from its resolution: missing when the place does not resolve (or a card's cited cell or line is
330 *  gone), differs when it resolves and its shown value (a number or quoted words) is not there, else ok. */
331export function verdictOf(c: Citation, res: Resolution | null, at = 0): TermVerdict {
332  const base = { ref: c.ref, display: c.display, kind: '', lines: [] as TermVerdict['lines'], at }
333  if (!res) return { ...base, status: 'pending', why: 'not checked yet' }
334  if (res.error !== undefined) return { ...base, status: 'missing', why: `the place does not resolve: ${str(res.error)}` }
335  const kind = str(res.kind)
336  const meta = isObj(res.meta) ? res.meta : {}
337  const span = isObj(meta.span) ? meta.span : null
338  const out: TermVerdict = { ...base, kind, status: 'ok', why: 'the place resolves', lines: [] }
339  if (kind === 'cell') {
340    out.card = str(res.cell_id)
341    if (span && 'col' in span) {
342      out.column = str(span.col)
343      out.row = str(span.row)
344      out.value = str(span.value)
345    }
346    if (meta.span_missing) return { ...out, status: 'missing', why: 'the card no longer shows the cited cell or line' }
347    // lines the card printed: the cited ones lit among the two on each side the excerpt holds (refs.SPAN_CONTEXT_LINES)
348    if (span && 'line' in span && typeof span.line === 'number') {
349      const first = span.line
350      const last = typeof span.end_line === 'number' ? span.end_line : first
351      const start = Math.max(1, first - 2)
352      out.lines = str(res.excerpt).split('\n').map((t, k) => {
353        const n = start + k
354        const hit = n >= first && n <= last
355        // the value marked where it stands; a citation of the line with no words marks the line
356        return { n, text: t, hit, ...(hit ? { spans: c.display === null ? [[0, t.length]] : spansIn(t, c.display) } : {}) }
357      })
358    }
359  } else {
360    out.path = str(res.path)
361    if (typeof res.line === 'number') out.line = res.line
362    out.lines = linesOf(res, c.display)
363  }
364  if (!showsValue(c.display)) return { ...out, why: 'the place resolves; the citation shows no value' }
365  const where = span && 'value' in span ? str(span.value) : span && 'text' in span ? str(span.text) : kind === 'cell' ? str(res.excerpt) : out.lines.filter(l => l.hit).map(l => l.text).join('\n') || str(res.excerpt)
366  const holds = span && 'value' in span ? shownMatches(str(span.value), c.display!) || valueIn(c.display!, where) : valueIn(c.display!, where)
367  return holds ? { ...out, why: 'the value is at its place' } : { ...out, status: 'differs', why: span && 'value' in span ? `the place shows ${str(span.value)}` : 'the value is not at its place' }
368}
369
370// ------------------------------------------------------------------------------------------------ threads and agents
371
372/** A chat's meta and events as `thimble state thread` printed them (`{meta, events}`). */
373export function chatOf(v: unknown): { meta: Obj; events: Obj[] } {
374  if (!isObj(v)) return { meta: {}, events: [] }
375  return { meta: isObj(v.meta) ? v.meta : {}, events: Array.isArray(v.events) ? v.events.filter(isObj) : [] }
376}
377
378/** A chat as a side thread's turns: each analyst message a question, the text after it its answer, its tool calls
379 *  counted, answered by its first reply (`reply_in_thread`'s `text` record, marked `reply`) or `done`, failed (or
380 *  stopped) by `error`. In terminal mode main often answers with `reply_in_thread` alone, and no `done` follows. The
381 *  answer is that first reply (SPEC.md, "The threads panel"): what the fork writes after it, as it makes a card, is its
382 *  working, not the answer. Two texts a tool call or a whole message parts are two paragraphs, never one run of words.
383 *  `cards`: the cards the turn made or changed, by its tool results' `cell_id`. */
384export function threadOf(meta: Obj, events: readonly Obj[]): ChatThread {
385  const turns: ChatThreadTurn[] = []
386  let cur: ChatThreadTurn | null = null
387  // a tool call since the turn's last text: the next text starts a paragraph
388  let parted = false
389  const open = (q: string) => {
390    cur = { q, a: '', state: 'running', tools: 0, partial: '', cards: [] }
391    turns.push(cur)
392    parted = false
393  }
394  for (const e of events) {
395    const t = str(e.type)
396    if (t === 'user') {
397      open(str(e.text))
398      continue
399    }
400    if (t === 'again' && (!cur || (cur as ChatThreadTurn).state !== 'running')) open(str(e.text))
401    if (!cur) open(str(meta.anchor_text) || str(meta.title))
402    const c = cur as unknown as ChatThreadTurn
403    if (t === 'text') {
404      // words after an end that said the turn went unanswered (main's turn ended before its fork replied): the
405      // answer, which replaces what the end said
406      if (c.state === 'error') {
407        c.state = 'running'
408        c.a = ''
409        delete c.stopped
410      }
411      // the answer is the turn's first reply: later words are the fork's working
412      if (c.state === 'done') continue
413      const words = str(e.delta ?? e.text)
414      const whole = e.reply === true || Boolean(e.by)
415      // the reply alone is the answer, without the working words before it
416      if (e.reply === true) c.a = ''
417      const sep = c.a.trim() && (parted || whole) ? '\n\n' : ''
418      c.a = `${sep ? c.a.trimEnd() : c.a}${sep}${sep ? words.trimStart() : words}`
419      c.partial = c.a
420      parted = false
421      if (e.reply === true && c.state === 'running') c.state = 'done'
422    } else if (t === 'tool_use') {
423      c.tools++
424      parted = true
425    } else if (t === 'tool_result') {
426      const card = str(e.cell_id)
427      if (card && !c.cards!.includes(card)) c.cards!.push(card)
428    } else if (t === 'done') {
429      c.state = 'done'
430      if (!c.a.trim() && e.result) c.a = str(e.result)
431    } else if (t === 'error') {
432      c.state = 'error'
433      c.a = str(e.error ?? e.message ?? e.text) || c.a
434      if (STOP_KINDS.includes(str(e.kind))) c.stopped = true
435    }
436  }
437  const last = turns.at(-1)
438  if (last && last.state === 'running' && meta.running === false && last.a.trim()) last.state = 'done'
439  const at = Date.parse(str(meta.last_ts) || str(meta.created_at))
440  return {
441    id: str(meta.id),
442    label: str(meta.anchor_text) || str(meta.title) || 'the analyst\'s question',
443    ref: str(meta.anchor),
444    context: '',
445    agentId: '',
446    engine: '',
447    turns,
448    file: '',
449    parent: str(meta.parent) === 'main' ? '' : str(meta.parent),
450    ...(Number.isFinite(at) ? { at } : {}),
451  }
452}
453
454/** How many answers a chat has given (`done` records), from its meta's count when the list gives one. */
455function answersOf(meta: Obj): number {
456  for (const k of ['answers', 'n_answers']) if (typeof meta[k] === 'number') return meta[k] as number
457  return meta.status === 'done' ? 1 : 0
458}
459
460/** The side threads `thimble state threads` listed (the chats route's metas), oldest first, each with what the rows
461 *  above the prompt and the home panel need. Unread: the meta's `unread`, else its answers past `seen`. */
462export function threadRowsOf(v: unknown): TermThreadRow[] {
463  const list = Array.isArray(v) ? v : isObj(v) && Array.isArray(v.chats) ? v.chats : isObj(v) && Array.isArray(v.threads) ? v.threads : []
464  return list
465    .filter(isObj)
466    .filter(m => str(m.kind) === 'thread')
467    .map(m => {
468      const answers = answersOf(m)
469      const seen = typeof m.seen === 'number' ? (m.seen as number) : answers
470      const unread = typeof m.unread === 'number' ? (m.unread as number) : typeof m.unread === 'boolean' ? (m.unread ? 1 : 0) : Math.max(0, answers - seen)
471      return {
472        id: str(m.id),
473        title: str(m.title),
474        anchor: str(m.anchor),
475        anchorText: str(m.anchor_text),
476        running: Boolean(m.running),
477        answers,
478        seen,
479        unread,
480        at: str(m.last_ts) || str(m.created_at),
481        parent: str(m.parent),
482        created: str(m.created_at),
483        element: str(m.anchor_element),
484        question: str(m.question),
485        fork: str(m.fork_name),
486        turn: str(m.turn),
487      }
488    })
489}
490
491/** thimble's agents as `thimble state agents` listed them (the agents route's `{rows}`). */
492export function agentsOf(v: unknown): TermAgent[] {
493  const rows = Array.isArray(v) ? v : isObj(v) && Array.isArray(v.rows) ? v.rows : isObj(v) && Array.isArray(v.agents) ? v.agents : []
494  return rows.filter(isObj).map(r => ({
495    name: str(r.name),
496    label: str(r.label) || str(r.name),
497    state: str(r.state) || str(r.status),
498    kind: str(r.kind),
499    chat: str(r.chat),
500    role: str(r.role),
501    started: str(r.started ?? r.started_at ?? r.created_at),
502  }))
503}
504
505/** The workspace's counts from `thimble state home`: a number or a list for each kind. */
506export function homeOf(v: unknown, at = 0): TermHome | null {
507  if (!isObj(v)) return null
508  const counts = isObj(v.counts) ? v.counts : v
509  const docs = counts.docs ?? counts.reports ?? counts.documents
510  return {
511    cards: num(counts.cards),
512    labels: num(counts.labels),
513    docs: isObj(docs) ? Object.values(docs).filter(d => !isObj(d) || d.exists !== false).length : num(docs),
514    threads: num(counts.threads),
515    views: num(counts.views),
516    files: num(counts.files),
517    at,
518  }
519}
520
521/** The documents `thimble state docs` listed (the types route's `{slug: {exists, title, …}}`, or a list), those that
522 *  exist or are being written. */
523export function docsOf(v: unknown): { slug: string; title: string; renderer: string; status: string; generation: number; at: string }[] {
524  const entries: [string, Obj][] = Array.isArray(v) ? v.filter(isObj).map(d => [str(d.slug ?? d.type), d]) : isObj(v) ? Object.entries(v).filter((e): e is [string, Obj] => isObj(e[1])) : []
525  return entries
526    .filter(([, d]) => d.exists !== false || d.status === 'generating')
527    .map(([slug, d]) => ({ slug, title: str(d.title) || str(d.name) || slug, renderer: str(d.renderer) || 'document', status: str(d.status) || (d.exists === false ? 'generating' : 'written'), generation: typeof d.generation === 'number' ? d.generation : 0, at: str(d.generated_at) }))
528}
529
530/** One ui.jsonl record as `thimble state ui --after <n>` printed it. */
531export type UiRecord = { n: number; kind: string; args: Obj }
532
533export function uiRecordsOf(v: unknown): UiRecord[] {
534  const list = Array.isArray(v) ? v : isObj(v) && Array.isArray(v.records) ? v.records : []
535  return list.filter(isObj).map(r => ({ n: typeof r.n === 'number' ? r.n : 0, kind: str(r.kind), args: isObj(r.args) ? r.args : {} }))
536}
537
538// ------------------------------------------------------------------------------------------------ documents
539
540export type DocSentence = { id?: string; text?: string; bullet?: string }
541export type DocFigure = { cell?: string; caption?: string; after_paragraph?: string }
542// a unit of a document: a report's or a story's section (paragraphs), a deck's slide or a story's beat (sentences, and
543// one `figure` or a list of `figures`), as report_types.units reads them
544export type DocSection = { id?: string; heading?: string; paragraphs?: { id?: string; sentences?: DocSentence[] }[]; sentences?: DocSentence[]; figures?: DocFigure[]; figure?: DocFigure | null }
545const UNIT_KEYS = [['sections', 'section'], ['slides', 'slide'], ['beats', 'beat'], ['lines', 'line']] as const
546
547/** The document's units (report_types.units) and their word, a slide's sentences as one paragraph each bullet a line. */
548export function docUnits(doc: Obj): { units: DocSection[]; word: string } {
549  for (const [key, word] of UNIT_KEYS) {
550    const v = doc[key]
551    if (!Array.isArray(v)) continue
552    const units = (v as DocSection[]).filter(u => u && typeof u === 'object').map(u => {
553      if (Array.isArray(u.paragraphs)) return { ...u, figures: u.figures ?? (u.figure ? [u.figure] : []) }
554      const sentences = (u.sentences ?? []).map(x => ({ ...x, text: x.bullet ? `${x.bullet} ${String(x.text ?? '')}` : String(x.text ?? '') }))
555      return { ...u, paragraphs: sentences.length ? [{ id: `${u.id ?? ''}-s`, sentences }] : [], figures: u.figures ?? (u.figure ? [u.figure] : []) }
556    })
557    return { units, word }
558  }
559  return { units: [], word: 'section' }
560}
561
hooks/home.ts 641 lines
1// The home panel (/thimble-home, and the title row's first step): one panel listing what this folder's sessions made,
2// as thimble's workbench lists them in its tabs: views (built, building, proposed), reports, side threads (those with
3// answers not yet read first), cards grouped by the question that made them, labels with their counts, and the files
4// by folder with what this session read of them. Each is a click away from the panel that opens it. A file in the
5// `listed` state counts neither its records nor its reading: it shows its kind and its size.
6//
7// This file lays the panel out as styled lines and their hit regions, without `$`; register.tsx gathers the data
8// (homeData), draws the lines in the Client homeview.tsx and acts on a click or a key. It follows the visual system
9// (SPEC.md, section 7, "Home"): one column under the title row's `Home`; a section heading bold with its count dim in
10// parentheses and `N new` in green, a blank row above it; an item's state glyph at A0 and its name at A2, regular,
11// its metadata dim against the right edge and `new` in green there while it is new; card groups and folders that fold
12// with `▸ ▾`, the newest group and the first folder open. Each line starts with the 2-cell margin, where `❯` marks the
13// row the keys chose.
14import type { Line, Seg } from './draw'
15import { lineWidth, share as pct, valueColour, width, wrapRows } from './draw'
16import { FRESH, MARGIN_W, fitTo, headingLine, hintLines, pointed, ruleLine, spread } from './chrome'
17import { COLORS } from './paint'
18import { cutMiddle, quoted } from './lib'
19
20export type SectionId = 'views' | 'reports' | 'threads' | 'cards' | 'labels' | 'files'
21
22/** What a click opens: an item in its own panel, or a section's own panel. */
23export type HomeOpen =
24  | { kind: 'view'; slug: string; built: boolean }
25  | { kind: 'report'; slug: string; title?: string }
26  | { kind: 'thread'; id: string }
27  | { kind: 'card'; id: string }
28  | { kind: 'label'; name: string }
29  | { kind: 'file'; path: string }
30  | { kind: 'pane'; view: 'views' | 'reports' | 'threads' | 'labels' | 'coverage'; title: string; folder?: string }
31
32/** A click's act: open something, fold or unfold a card group or a folder (`key`, `open` as it is drawn now), show a
33 *  section whole. */
34export type HomeAct = { op: 'open'; open: HomeOpen } | { op: 'fold'; key: string; open: boolean } | { op: 'more'; sec: SectionId; next?: string }
35
36/** What the analyst chose: the groups and folders folded or unfolded against their default (the newest group and the
37 *  first folder open), the sections shown whole, and the row the keys chose (its key). */
38export type HomeUi = { folded: string[]; unfolded: string[]; more: string[]; pick: string }
39
40export const HOME_UI_EMPTY: HomeUi = { folded: [], unfolded: [], more: [], pick: '' }
41
42// ------------------------------------------------------------------------------------------------ the data
43
44/** `term`: built with a terminal program (view.term.js), so the panel draws it (hooks/viewhost.ts). */
45export type HomeView = { slug: string; name: string; state: string; words: string; files: string[]; unit: string; drawable: boolean; left: number; at: number; fresh?: boolean; term?: boolean }
46export type HomeReport = { slug: string; title: string; form: string; state: string; cards: number; tools: number; at: number; fresh?: boolean }
47export type HomeThread = { id: string; title: string; about: string; words: string; tone: string; unread: number; earlier: boolean; at: number }
48/** `fresh`: made since home was last opened, `new` in green at R as every new item. */
49export type HomeCard = { id: string; kind: string; question: string; fresh?: boolean }
50/** Cards by the question they answered: an answer's prompt, a side thread's question, a report's title, or none. */
51export type HomeCardGroup = { head: string; from: 'answer' | 'thread' | 'report' | 'other'; cards: HomeCard[]; at: number }
52/** `ran`: whether the label has a run, as its panel's `last run on …` or `not run yet` says (a label a stopped thread
53 *  left is none); absent is true. */
54export type HomeLabel = { slug: string; name: string; kind: string; trial: boolean; counts: Record<string, number>; values: string[]; paths: string[]; running: boolean; ran?: boolean; state?: string; fresh?: boolean; colors?: Record<string, number> }
55/** thimble-term: a file whose reading thimble does not count (`listed`) has no state glyph and shows its kind. */
56export type HomeFile = { file: string; records: number | null; size: number; seen: number; state: 'read' | 'scanned' | 'untouched' | 'listed'; ranges: number[][]; kind?: string }
57export type HomeData = {
58  views: HomeView[]
59  reports: HomeReport[]
60  threads: HomeThread[]
61  cardGroups: HomeCardGroup[]
62  labels: HomeLabel[]
63  files: HomeFile[]
64  coverage: string
65  /** the folder's own name, which heads the files at its top */
66  root?: string
67}
68
69/** The cards of answers, threads and reports, each under the first question that made it (`groups` in the order the
70 *  questions were asked), then the cards no question names. A group whose cards all stand under an earlier one is
71 *  left out. Newest first. */
72export function groupCards(groups: readonly HomeCardGroup[], all: readonly HomeCard[]): HomeCardGroup[] {
73  const placed = new Set<string>()
74  const out: HomeCardGroup[] = []
75  for (const g of [...groups].sort((a, b) => a.at - b.at)) {
76    const cards = g.cards.filter(c => !placed.has(c.id))
77    for (const c of cards) placed.add(c.id)
78    if (cards.length) out.push({ ...g, cards })
79  }
80  const rest = all.filter(c => !placed.has(c.id))
81  const sorted = out.reverse()
82  if (rest.length) sorted.push({ head: 'other cards', from: 'other', cards: rest, at: 0 })
83  return sorted
84}
85
86// ------------------------------------------------------------------------------------------------ the rows
87
88type Glyph = { mark: string; fg?: string }
89
90/** One row of a section: its glyph (or fold marker) at A0, its name at A2 (or A4 under a folder), its metadata dim
91 *  against the right edge, `new` in green there, a bar before the figure; a secondary row at A2; what a click does. A
92 *  row with `kids` folds (`fold`: its key, `open`: shown open). */
93export type HomeRow = {
94  key: string
95  glyph: Glyph | null
96  title: string
97  /** dim words right after the title (a folder's count of files) */
98  after?: Seg[]
99  /** what stands at R in place of `right` when `right` would cut the title (a thread's row without its subject) */
100  short?: Seg[]
101  /** cut in its middle, so its end shows: a card of a group whose questions share their first words (lib.ts cutMiddle) */
102  middle?: boolean
103  fresh?: boolean
104  right?: Seg[]
105  bar?: { n: number; fg: string }[]
106  meta?: Seg[]
107  kids?: HomeRow[]
108  fold?: string
109  open?: boolean
110  depth?: number
111  more?: number
112  act: HomeAct
113}
114
115/** A section: its heading's name and count, what is new, the panel its heading opens, the column heads at the right,
116 *  and its rows. `summary` says in words what the glyphs show (for the tests and a thread about the panel). */
117export type HomeSection = { id: SectionId; name: string; count: number; news?: number; heads?: Seg[]; summary: Seg[]; rows: HomeRow[]; pane?: HomeOpen; coverage?: string }
118
119// state glyphs (SPEC.md, "The visual system", section 5): done ● and working ◌ in the text colour, not started ○
120// dim, a problem × or ! in red
121const DONE: Glyph = { mark: '●' }
122const WORKING: Glyph = { mark: '◌' }
123const NOT_STARTED: Glyph = { mark: '○', fg: COLORS.dim }
124
125export function num(n: number): string {
126  return Math.round(n).toLocaleString('en-US')
127}
128
129function plural(n: number, one: string, many = `${one}s`): string {
130  return `${num(n)} ${n === 1 ? one : many}`
131}
132
133const dim = (s: string): Seg => ({ s, fg: COLORS.dim })
134
135/** Words parted by a dim dot, each dim unless it is a segment of its own. */
136function joined(parts: (Seg | string | null | undefined | false)[]): Seg[] {
137  const out: Seg[] = []
138  for (const p of parts) {
139    if (!p) continue
140    if (out.length) out.push(dim(' · '))
141    out.push(typeof p === 'string' ? dim(p) : p)
142  }
143  return out
144}
145
146const ACTIVE_VIEW = new Set(['building', 'checking', 'reviewing', 'revising'])
147
148function viewGlyph(v: HomeView): Glyph {
149  if (v.state === 'failed') return { mark: '×', fg: COLORS.problem }
150  if (v.left) return { mark: '!', fg: COLORS.problem }
151  if (ACTIVE_VIEW.has(v.state)) return WORKING
152  if (v.state === 'built' || (v.state === 'stopped' && v.drawable)) return DONE
153  return NOT_STARTED
154}
155
156/** A view's state words where its glyph already says what they would: without "built", "proposed", "not built" or
157 *  "failed". */
158function afterMark(words: string): string {
159  return words.replace(/^(built|proposed|not built)(\s·\s|$)/, '').replace(/^failed:\s*/, '')
160}
161
162function countBy(words: readonly string[], order: readonly string[]): string[] {
163  const by = new Map<string, number>()
164  for (const w of words) by.set(w, (by.get(w) ?? 0) + 1)
165  return order.filter(w => by.has(w)).map(w => `${by.get(w)} ${w}`)
166}
167
168/** What stands against the right edge: the metadata dim, then `new` in green while the item is new. */
169function rightOf(meta: (string | null | undefined | false)[], fresh?: boolean): Seg[] {
170  const m = joined(meta)
171  return [...m, ...(fresh ? [...(m.length ? [{ s: '  ' }] : []), { s: 'new', fg: FRESH }] : [])]
172}
173
174function viewsSection(vs: readonly HomeView[]): HomeSection {
175  const sorted = [...vs].sort((a, b) => b.at - a.at)
176  const word = (v: HomeView) => (v.state === 'failed' ? 'failed' : v.state === 'built' || (v.state === 'stopped' && v.drawable) ? 'built' : ACTIVE_VIEW.has(v.state) ? (v.drawable ? 'built' : 'building') : 'proposed')
177  return {
178    id: 'views',
179    name: 'Views',
180    count: vs.length,
181    news: vs.filter(v => v.fresh).length,
182    summary: joined(countBy(sorted.map(word), ['built', 'building', 'proposed', 'failed'])),
183    pane: { kind: 'pane', view: 'views', title: 'Views' },
184    rows: sorted.map(v => ({
185      key: `view:${v.slug}`,
186      glyph: viewGlyph(v),
187      title: v.name,
188      fresh: v.fresh,
189      right: rightOf([v.files.join(', '), afterMark(v.words)], v.fresh),
190      // without its files where they would cut its name
191      short: rightOf([afterMark(v.words)], v.fresh),
192      act: { op: 'open', open: { kind: 'view', slug: v.slug, built: v.drawable } },
193    })),
194  }
195}
196
197function reportsSection(rs: readonly HomeReport[]): HomeSection {
198  const sorted = [...rs].sort((a, b) => b.at - a.at)
199  const word = (r: HomeReport) => (r.state === 'writing' ? 'writing' : r.state === 'error' ? 'failed' : 'written')
200  return {
201    id: 'reports',
202    // the browser's word for them, which the list panel and the path use too
203    name: 'Documents',
204    count: rs.length,
205    news: rs.filter(r => r.fresh).length,
206    summary: joined(countBy(sorted.map(word), ['written', 'writing', 'failed'])),
207    pane: { kind: 'pane', view: 'reports', title: 'Documents' },
208    rows: sorted.map(r => ({
209      key: `report:${r.slug}`,
210      glyph: r.state === 'writing' ? WORKING : r.state === 'error' ? { mark: '×', fg: COLORS.problem } : DONE,
211      title: r.title,
212      fresh: r.fresh,
213      // what the glyph does not say (◌ writing, × failed, ● written): its kind, its tool calls while writing, its cards
214      right: rightOf([r.form, r.state === 'writing' && r.tools ? plural(r.tools, 'tool call') : '', r.cards ? plural(r.cards, 'card') : ''], r.fresh),
215      act: { op: 'open', open: { kind: 'report', slug: r.slug, title: r.title } },
216    })),
217  }
218}
219
220function threadsSection(ts: readonly HomeThread[]): HomeSection {
221  // the threads with answers not yet read first, then the newest
222  const sorted = [...ts].sort((a, b) => Number(b.unread > 0) - Number(a.unread > 0) || b.at - a.at)
223  const fresh = ts.filter(t => t.unread).length
224  const running = ts.filter(t => t.tone === 'run').length
225  return {
226    id: 'threads',
227    name: 'Threads',
228    count: ts.length,
229    news: fresh,
230    summary: joined([fresh ? `${fresh} with new answers` : '', running ? `${running} answering` : '', ts.some(t => t.earlier) ? `${ts.filter(t => t.earlier).length} from earlier sessions` : '']),
231    pane: { kind: 'pane', view: 'threads', title: 'Threads' },
232    rows: sorted.map(t => ({
233      key: `thread:${t.id}`,
234      glyph: t.tone === 'run' ? WORKING : t.tone === 'problem' ? { mark: '×', fg: COLORS.problem } : t.tone === 'ok' || t.unread ? DONE : NOT_STARTED,
235      title: t.title,
236      fresh: t.unread > 0,
237      // what it is about, then `earlier session`, dim at R (on its one row); without its subject when that would cut
238      // its question
239      right: rightOf([t.about, t.earlier ? 'earlier session' : ''], t.unread > 0),
240      short: rightOf([t.earlier ? 'earlier session' : ''], t.unread > 0),
241      act: { op: 'open', open: { kind: 'thread', id: t.id } },
242    })),
243  }
244}
245
246/** What a group of cards holds, in words: the question it answered, the report or the thread it stands in. */
247function groupName(g: HomeCardGroup): string {
248  const head = g.head.replace(/\s+/g, ' ').trim()
249  if (g.from === 'answer') return `answer to ${quoted(head)}`
250  if (g.from === 'report') return `in the report ${quoted(head)}`
251  if (g.from === 'thread') return `in the thread ${/^["“]/.test(head) ? head : quoted(head)}`
252  return head
253}
254
255/** The fewest characters of first words a group's questions share before its rows are cut in their middle. */
256const SHARED_HEAD = 16
257
258/** Whether two or more questions share their first SHARED_HEAD characters or more, whole words, and differ after. */
259function sharedHead(qs: readonly string[]): boolean {
260  if (qs.length < 2) return false
261  let n = 0
262  const first = qs[0]!
263  while (n < first.length && qs.every(q => q[n] === first[n])) n++
264  const head = first.slice(0, n)
265  const words = head.includes(' ') ? head.slice(0, head.lastIndexOf(' ')) : ''
266  return words.length >= SHARED_HEAD && qs.some(q => q !== first)
267}
268
269function cardsSection(groups: readonly HomeCardGroup[], ui: HomeUi): HomeSection {
270  const n = groups.reduce((k, g) => k + g.cards.length, 0)
271  const by = (from: HomeCardGroup['from']) => groups.filter(g => g.from === from).reduce((k, g) => k + g.cards.length, 0)
272  return {
273    id: 'cards',
274    name: 'Cards',
275    count: n,
276    news: groups.reduce((k, g) => k + g.cards.filter(c => c.fresh).length, 0),
277    summary: joined([by('answer') ? `${num(by('answer'))} in answers` : '', by('thread') ? `${num(by('thread'))} in side threads` : '', by('report') ? `${num(by('report'))} in reports` : '']),
278    rows: groups.map((g, i) => {
279      const fold = `cards:${g.from}:${g.head}`
280      const open = isOpen(ui, fold, i === 0)
281      return {
282        key: `group:${fold}`,
283        glyph: { mark: open ? '▾' : '▸' },
284        title: groupName(g),
285        right: [dim(plural(g.cards.length, 'card'))],
286        fold,
287        open,
288        kids: g.cards.map(c => ({ key: `card:${c.id}`, glyph: null, title: c.question, fresh: c.fresh, right: rightOf([c.kind], c.fresh), ...(sharedHead(g.cards.map(x => x.question)) ? { middle: true } : {}), act: { op: 'open', open: { kind: 'card', id: c.id } } })),
289        act: { op: 'fold', key: fold, open },
290      }
291    }),
292  }
293}
294
295/** The cells a label's bar takes at most, and the fewest it is drawn in: a narrower bar is left out. */
296const BAR_W = 20
297const BAR_MIN = 6
298/** The cells of a row's name a bar leaves it at least, or the whole name when it is shorter: a name is cut at a word,
299 *  so a bar that left it fewer cells cut most names to their first word. */
300const NAME_KEEP = 24
301
302/** A label's counts, each value with its colour as the label panel draws it (draw.ts valueColour): the bar's parts,
303 *  their cells set when the row is laid out at its width (barCells). */
304function countBar(values: readonly string[], counts: Record<string, number>, colors?: Record<string, number>): { n: number; fg: string }[] {
305  return values.map(v => ({ n: counts[v] ?? 0, fg: valueColour(values, v, colors)! })).filter(x => x.n > 0)
306}
307
308/** A bar's parts in `w` cells by their counts, the cells left over given to the largest remainders, so the bar is `w`
309 *  wide. */
310function barCells(parts: readonly { n: number; fg: string }[], w: number): { n: number; fg: string }[] {
311  const total = parts.reduce((k, p) => k + p.n, 0)
312  if (!total || w <= 0) return []
313  const cells = parts.map(p => (p.n * w) / total)
314  const out = cells.map(c => Math.floor(c))
315  const order = cells.map((c, i) => ({ i, r: c - Math.floor(c) })).sort((a, b) => b.r - a.r)
316  for (let k = 0, left = w - out.reduce((a, b) => a + b, 0); k < left; k++) out[order[k % order.length]!.i]!++
317  return parts.map((p, i) => ({ n: out[i]!, fg: p.fg })).filter(x => x.n > 0)
318}
319
320/** A label's color, as the label panel's ● beside its name shows it: its first value's (its class's color when it has
321 *  `colors`), else the first series hue. */
322export function labelHue(values: readonly string[], colors?: Record<string, number>): string {
323  const hue = values.length ? valueColour(values, values[0]!, colors) : undefined
324  return hue && hue !== COLORS.dim ? hue : COLORS.series[0]!
325}
326
327function labelsSection(ls: readonly HomeLabel[]): HomeSection {
328  return {
329    id: 'labels',
330    name: 'Labels',
331    count: ls.length,
332    // the labels new since home was last seen, as the toast counts them (live check term-fix9, low quirk)
333    news: ls.filter(l => l.fresh).length,
334    summary: joined(countBy(ls.map(l => (l.running ? 'running' : l.ran === false ? (l.state?.startsWith('stopped') ? 'stopped' : 'not run yet') : l.trial ? 'on a sample' : 'on every record')), ['on every record', 'on a sample', 'running', 'stopped', 'not run yet'])),
335    pane: { kind: 'pane', view: 'labels', title: 'Labels' },
336    rows: ls.map(l => {
337      const labeled = l.values.reduce((k, v) => k + (l.counts[v] ?? 0), 0)
338      // a label with no run says so, as its panel does, with no bar and no counts (live check term-fix7, new quirk 6:
339      // home showed `yes 0 · no 0` and 0 for a label a stopped thread left)
340      // one whose first run is going says so (no counts yet)
341      if (l.ran === false && !labeled)
342        return {
343          key: `label:${l.slug}`,
344          glyph: l.running ? WORKING : NOT_STARTED,
345          title: l.name,
346          fresh: l.fresh,
347          right: rightOf([l.running ? 'labeling' : 'not run yet'], l.fresh),
348          meta: joined([l.kind, l.paths.join(', ')]),
349          act: { op: 'open', open: { kind: 'label', name: l.name } },
350        }
351      // a legend: each value's ● in its hue, its word and count dim as the rest of the secondary row
352      const legend: Seg[] = []
353      l.values.forEach((v, i) => {
354        if (i) legend.push(dim('  '))
355        legend.push({ s: '● ', fg: valueColour(l.values, v, l.colors) }, dim(`${v} ${num(l.counts[v] ?? 0)}`))
356      })
357      // what its runs say: a run going (`◌ labeling 3,000 of 4,579`), a first run stopped part way (`stopped at 3,150 of
358      // 4,579`), else what the last run covered (live check term-fix9, quirk 4)
359      const runWords = l.state || (l.trial ? `a sample of ${num(labeled)}` : 'every record')
360      return {
361        key: `label:${l.slug}`,
362        // its ● in the label's color, as the label panel draws it beside its name
363        glyph: l.running ? WORKING : { mark: '●', fg: labelHue(l.values, l.colors) },
364        title: l.name,
365        fresh: l.fresh,
366        bar: countBar(l.values, l.counts, l.colors),
367        right: [{ s: num(labeled) }, ...(l.fresh ? [{ s: '  ' }, { s: 'new', fg: FRESH }] : [])],
368        meta: [...joined([l.kind, runWords, l.paths.join(', ')]), dim('  '), ...legend],
369        act: { op: 'open', open: { kind: 'label', name: l.name } },
370      }
371    }),
372  }
373}
374
375/** A share of records read, in whole percent as every share (draw.ts share). */
376function share(seen: number, total: number): string {
377  if (!total || !seen) return ''
378  return pct(seen, total)
379}
380
381/** thimble-term: a size in words (`756 KB`), for a file whose records are not counted. */
382function sizeWords(n: number): string {
383  return n >= 1e9 ? `${(n / 1e9).toFixed(1)} GB` : n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`
384}
385
386/** The files at most an open folder lists before `… N more`. */
387const FOLDER_FILES = 20
388
389function filesSection(fs: readonly HomeFile[], root: string, ui: HomeUi): HomeSection {
390  const by = new Map<string, HomeFile[]>()
391  // a folder by the name the file browser gives it: its path in the corpus (`collusion-wiki/`), and the corpus's own
392  // files under the corpus folder's name
393  for (const f of fs) {
394    const cut = f.file.lastIndexOf('/')
395    const folder = cut < 0 ? `${root}/` : `${f.file.slice(0, cut)}/`
396    by.set(folder, [...(by.get(folder) ?? []), f])
397  }
398  // the corpus's own folder first, the others in natural order, as the file browser lists them
399  const top = `${root}/`
400  const folders = [...by.entries()].sort(([a], [b]) => (a === b ? 0 : a === top ? -1 : b === top ? 1 : a.localeCompare(b, undefined, { numeric: true })))
401  const recs = (xs: readonly HomeFile[]) => xs.reduce((k, f) => k + (f.records ?? 0), 0)
402  const seen = (xs: readonly HomeFile[]) => xs.reduce((k, f) => k + Math.min(f.seen, f.records ?? 0), 0)
403  const cols = (records: string, read: string): Seg[] => [{ s: records }, { s: '  ' }, dim(read)]
404  // thimble-term: where no reading is counted, the columns are the files' kind (text, left) then size (a number, on R)
405  const listed = fs.length > 0 && fs.every(f => f.state === 'listed')
406  return {
407    id: 'files',
408    name: 'Files',
409    count: fs.length,
410    heads: listed ? [dim('type'), { s: '  ' }, dim('size')] : [dim('records'), { s: '  ' }, dim('read')],
411    summary: joined([fs.length && !listed ? `${num(fs.filter(f => f.state === 'read').length)} of ${num(fs.length)} read` : '']),
412    pane: { kind: 'pane', view: 'coverage', title: 'Coverage' },
413    rows: folders.map(([folder, files], i) => {
414      const fold = `files:${folder}`
415      const open = isOpen(ui, fold, i === 0)
416      const sorted = [...files].sort((a, b) => a.file.localeCompare(b.file, undefined, { numeric: true }))
417      return {
418        key: `folder:${folder}`,
419        glyph: { mark: open ? '▾' : '▸' },
420        title: folder,
421        // its count of files dim after its name, as the file browser shows it; under the columns only what they head
422        after: [dim(`  ${num(files.length)}`)],
423        right: listed ? [{ s: '' }, { s: '  ' }, dim(sizeWords(files.reduce((k, f) => k + f.size, 0)))] : cols(num(recs(files)), share(seen(files), recs(files)) || '0%'),
424        fold,
425        open,
426        // a folder of 21 shows all 21: `… 1 more` would take the row the file takes
427        more: sorted.length > FOLDER_FILES + 1 ? sorted.length - FOLDER_FILES : 0,
428        kids: sorted.slice(0, sorted.length > FOLDER_FILES + 1 ? FOLDER_FILES : sorted.length).map(f => ({
429          key: `file:${f.file}`,
430          // a listed file has no state glyph: a blank in its place keeps its name at A4
431          glyph: f.state === 'listed' ? { mark: ' ' } : f.state === 'read' ? DONE : NOT_STARTED,
432          title: f.file.split('/').at(-1) ?? f.file,
433          right: f.state === 'listed' ? [dim(f.kind ?? ''), { s: '  ' }, dim(sizeWords(f.size))] : cols(f.records !== null ? num(f.records) : `${num(f.size)} B`, f.state === 'read' ? share(f.seen, f.records ?? 0) || '0%' : f.state === 'scanned' ? 'counted' : ''),
434          act: { op: 'open', open: { kind: 'file', path: f.file } } as HomeAct,
435        })),
436        act: { op: 'fold', key: fold, open },
437      }
438    }),
439  }
440}
441
442/** Whether a group or folder shows open: its default unless the analyst folded or unfolded it. */
443function isOpen(ui: HomeUi, key: string, byDefault: boolean): boolean {
444  return byDefault ? !ui.folded.includes(key) : ui.unfolded.includes(key)
445}
446
447/** Every section, in the order of thimble's workbench: what it built, what it wrote, what it asked, then its parts. */
448export function homeSections(d: HomeData, ui: HomeUi = HOME_UI_EMPTY): HomeSection[] {
449  return [viewsSection(d.views), reportsSection(d.reports), threadsSection(d.threads), cardsSection(d.cardGroups, ui), labelsSection(d.labels), { ...filesSection(d.files, d.root || 'folder', ui), ...(d.coverage ? { coverage: d.coverage } : {}) }]
450}
451
452// ------------------------------------------------------------------------------------------------ the layout
453
454/** A region a click acts on: its line, its cells, and whether the pointer lights its row (else it is a control, which
455 *  the pointer inverts). `pick`: the key of the row it stands for, which the keys step through. */
456export type HomeHit = { y: number; x0: number; x1: number; row: boolean; act: HomeAct; pick?: string }
457/** `hintRows`: how many of its last lines are the key hints (chrome.ts hintLines); `heads`: the line of each section's
458 *  heading. */
459export type HomeLayout = { lines: Line[]; hits: HomeHit[]; picks: { key: string; act: HomeAct }[]; hintRows: number; heads: number[] }
460
461/** The items a section shows before `… N more` (card groups and folders count as items). */
462export const FIRST = 5
463
464export const HOME_HINTS = ['↑↓ to choose', 'Enter to open', 'Space to fold', 'x to close']
465
466class Lines {
467  lines: Line[] = []
468  hits: HomeHit[] = []
469  picks: HomeLayout['picks'] = []
470  constructor(readonly pick: string) {}
471  /** A line of the type area, its margin before it: `❯` when it is the row the keys chose. */
472  push(l: Line, hit?: Omit<HomeHit, 'y'>): void {
473    const on = Boolean(hit?.pick) && hit!.pick === this.pick
474    if (hit) this.hits.push({ ...hit, x0: hit.x0 + MARGIN_W, x1: hit.x1 + MARGIN_W, y: this.lines.length })
475    if (hit?.pick) this.picks.push({ key: hit.pick, act: hit.act })
476    this.lines.push(pointed(l, on))
477  }
478  blank(): void {
479    if (this.lines.length && lineWidth(this.lines.at(-1)!) > MARGIN_W) this.lines.push([])
480  }
481}
482
483/** The two cells a glyph or fold marker hangs in, or two spaces. */
484function glyphSeg(g: Glyph | null): Seg[] {
485  if (!g) return [{ s: '  ' }]
486  return [{ s: g.mark, ...(g.fg ? { fg: g.fg } : {}) }, { s: ' ' }]
487}
488
489/** The fewest cells of a row's title kept beside its full right part (itemLine). */
490const TITLE_MIN = 32
491
492/** An item's line at `x`: its glyph hanging, its name, its bar and figure against the right edge. The bar takes what
493 *  the name leaves (NAME_KEEP cells of it, or all of a shorter one), up to BAR_W cells, and is left out under BAR_MIN:
494 *  in a narrow pane the name stays readable (live check term-fix10, new quirk 6: `● age…  ████████████████████  1,900`). */
495function itemLine(row: HomeRow, x: number, w: number): Line {
496  const lead: Line = [...(x ? [{ s: ' '.repeat(x) }] : []), ...glyphSeg(row.glyph)]
497  const barRoom = w - lineWidth(lead) - Math.min(NAME_KEEP, width(row.title)) - lineWidth(row.after ?? []) - 2 - lineWidth(row.right ?? []) - 2
498  const barW = Math.min(BAR_W, barRoom)
499  const bar: Line = row.bar?.length && barW >= BAR_MIN ? [...barCells(row.bar, barW).map(b => ({ s: '█'.repeat(b.n), fg: b.fg })), { s: '  ' }] : []
500  const left: Line = [...lead, { s: row.title }, ...(row.after ?? [])]
501  // the full right part (a thread's subject) whenever the title keeps TITLE_MIN cells beside it, the title cut at a word
502  // to make room; else the shorter right part (live check term-fix6, quirk 10: `about 602` was left out beside a long
503  // question)
504  const full: Line = [...bar, ...(row.right ?? [])]
505  const room = w - lineWidth(full) - 2 - (lineWidth(left) - width(row.title))
506  const chosen = row.short && lineWidth(left) + 2 + lineWidth(full) > w && room < Math.min(TITLE_MIN, width(row.title)) ? [...bar, ...row.short] : full
507  // the right part never takes the title's first cells: it is cut where it would leave the title fewer than 12
508  const keep = Math.min(width(row.title), 12) + lineWidth(left) - width(row.title)
509  const right = lineWidth(chosen) + 2 + keep > w ? fitTo(chosen, Math.max(0, w - 2 - keep)) : chosen
510  // a row whose end tells it from the rows beside it, cut in its middle
511  if (row.middle) {
512    const fit = w - lineWidth(right) - 2 - (lineWidth(left) - width(row.title))
513    if (fit > 0 && width(row.title) > fit) return spread([...lead, { s: cutMiddle(row.title, fit) }, ...(row.after ?? [])], right, w)
514  }
515  return spread(left, right, w)
516}
517
518/** The figures of a section's rows set in shared columns: each row's right part padded so its last column ends on R
519 *  and the columns before it line up (the files' records and share). */
520function alignRight(sec: HomeSection): void {
521  if (sec.id !== 'files') return
522  const rows = sec.rows.flatMap(r => [r, ...(r.kids ?? [])])
523  // the files' right parts are [count?, gap, records, gap, read]: pad records and read to their columns
524  const parts = rows.map(r => r.right ?? [])
525  const first = sec.heads?.[0]?.s.trim() || 'records'
526  const recW = Math.max(width(first), ...parts.map(p => width(p.at(-3)?.s ?? '')))
527  const readW = Math.max(width(sec.heads?.at(-1)?.s.trim() || 'read'), ...parts.map(p => width(p.at(-1)?.s ?? '')))
528  // text aligns left, numbers right: a listed file's kind is text (its first column), its size a number
529  const textFirst = first === 'type'
530  for (const r of rows) {
531    const p = r.right ?? []
532    if (p.length < 3) continue
533    const rec = p.at(-3)!
534    const read = p.at(-1)!
535    r.right = [...p.slice(0, -3), { ...rec, s: textFirst ? rec.s.padEnd(recW) : rec.s.padStart(recW) }, { s: '  ' }, { ...read, s: read.s.padStart(readW) }]
536  }
537  const last = sec.heads?.at(-1)?.s.trim() || 'read'
538  sec.heads = [dim(textFirst ? first.padEnd(recW) : first.padStart(recW)), { s: '  ' }, dim(last.padStart(readW))]
539}
540
541/** A row without `new` in a section where some row has it: its right part ends where the others' words end, before the
542 *  cells `new` takes (live check term-fix9, low quirk: `table  new` beside `table` put the kinds in two columns). */
543function besideNew(r: HomeRow): HomeRow {
544  const pad = (xs: Seg[] | undefined) => (xs?.length ? [...xs, { s: ' '.repeat(NEW_W) }] : xs)
545  return r.fresh ? r : { ...r, right: pad(r.right), short: pad(r.short) }
546}
547const NEW_W = '  new'.length
548
549/** The cells of a file's name the type column never cuts: a name up to this long is whole beside the column, or the
550 *  column goes. */
551export const NAME_WHOLE = 32
552
553/** The files section without its type column, when beside it a file's name (up to NAME_WHOLE cells) would be cut (live
554 *  check term-fix10, new quirk 6: `agent-c…` beside the column); else as it is. */
555function withoutType(sec: HomeSection, w: number): HomeSection {
556  if (sec.id !== 'files' || sec.heads?.[0]?.s.trim() !== 'type') return sec
557  const files = sec.rows.flatMap(r => r.kids ?? [])
558  const longest = Math.max(0, ...files.map(f => width(f.title)))
559  const rightW = Math.max(0, ...files.map(f => lineWidth(f.right ?? [])))
560  if (w - 4 - 2 - rightW >= Math.min(NAME_WHOLE, longest)) return sec
561  const sizeOnly = (r: HomeRow): HomeRow => ({ ...r, ...(r.right && r.right.length >= 3 ? { right: [...r.right.slice(0, -3), r.right.at(-1)!] } : {}), ...(r.kids ? { kids: r.kids.map(sizeOnly) } : {}) })
562  return { ...sec, heads: sec.heads.slice(-1), rows: sec.rows.map(sizeOnly) }
563}
564
565function sectionLines(out: Lines, sec: HomeSection, ui: HomeUi, w: number): void {
566  alignRight(sec)
567  sec = withoutType(sec, w)
568  if (sec.rows.some(r => r.fresh || (r.open && r.kids?.some(k => k.fresh)))) sec = { ...sec, rows: sec.rows.map(r => ({ ...besideNew(r), ...(r.kids ? { kids: r.kids.map(besideNew) } : {}) })) }
569  const head = headingLine(sec.name, sec.count, sec.news ?? 0)
570  out.push(spread(head, sec.heads ?? [], w), sec.pane ? { x0: 0, x1: lineWidth(head), row: false, act: { op: 'open', open: sec.pane } } : undefined)
571  // the orientation's coverage line, dim under the Files heading, whole up to four rows
572  if (sec.coverage) for (const l of wrapRows(sec.coverage, Math.max(10, w - 2), 4)) out.push([{ s: '  ' }, dim(l)])
573  if (!sec.rows.length) {
574    out.push([{ s: '  ' }, dim('none')])
575    return
576  }
577  const whole = ui.more.includes(sec.id) || sec.rows.length <= FIRST + 1
578  const shown = whole ? sec.rows : sec.rows.slice(0, FIRST)
579  for (const r of shown) {
580    out.push(itemLine(r, 0, w), { x0: 0, x1: w, row: true, act: r.act, pick: r.key })
581    if (r.meta?.length) out.push([{ s: '  ' }, ...fitTo(r.meta, w - 2)], { x0: 0, x1: w, row: true, act: r.act })
582    if (r.kids && r.open) {
583      // a group's cards at A2 under its name; a folder's files with their glyphs at A2 and their names at A4
584      for (const k of r.kids) out.push(itemLine(k, k.glyph ? 2 : 0, w), { x0: 2, x1: w, row: true, act: k.act, pick: k.key })
585      // a folder's `… N more` is a row the keys choose too: Enter shows the folder whole in the file browser
586      if (r.more) out.push([{ s: '    ' }, dim(`… ${num(r.more)} more`)], { x0: 4, x1: 4 + width(`… ${num(r.more)} more`), row: false, act: { op: 'open', open: { kind: 'pane', view: 'coverage', title: 'Coverage', folder: r.title } }, pick: `more:${r.key}` })
587    }
588  }
589  // the section's `… N more` is a row the keys choose: Enter or Space shows the section whole, the choice on its first
590  // row shown then (live check term-fix9, quirk 6: ↑↓ skipped it, so two groups of 18 cards could not be reached)
591  const left = sec.rows.length - shown.length
592  if (left > 0) out.push([{ s: '  ' }, dim(`… ${num(left)} more`)], { x0: 2, x1: 2 + width(`… ${num(left)} more`), row: false, act: { op: 'more', sec: sec.id, next: sec.rows[shown.length]!.key }, pick: `more:${sec.id}` })
593}
594
595/** The whole panel below its title row (`Home`, the path's one step): the rule, every section, the key hints
596 *  (`hints`: HOME_HINTS while the panel holds the keys, else what gives it them). */
597export function homeLayout(d: HomeData, ui: HomeUi, w: number, hints: readonly string[] = HOME_HINTS): HomeLayout {
598  const sections = homeSections(d, ui)
599  // the row the keys chose: the one named, else the first
600  const first = sections.flatMap(s => s.rows.slice(0, 1).map(r => r.key))[0] ?? ''
601  const out = new Lines(ui.pick || first)
602  out.push(ruleLine(w))
603  const heads: number[] = []
604  sections.forEach((sec, i) => {
605    if (i) out.blank()
606    heads.push(out.lines.length)
607    sectionLines(out, sec, ui, w)
608  })
609  const hintRows = hintLines(hints, w)
610  for (const l of hintRows) out.push(l)
611  return { lines: out.lines, hits: out.hits, picks: out.picks, hintRows: hintRows.length, heads }
612}
613
614/** The UI state after an act that changes it (an act that opens something leaves it as it is). */
615export function homeReduce(ui: HomeUi, act: HomeAct): HomeUi {
616  const without = (xs: string[], x: string) => xs.filter(y => y !== x)
617  switch (act.op) {
618    case 'fold':
619      return act.open ? { ...ui, folded: [...without(ui.folded, act.key), act.key], unfolded: without(ui.unfolded, act.key) } : { ...ui, folded: without(ui.folded, act.key), unfolded: [...without(ui.unfolded, act.key), act.key] }
620    case 'more':
621      return { ...ui, more: ui.more.includes(act.sec) ? without(ui.more, act.sec) : [...ui.more, act.sec] }
622    default:
623      return ui
624  }
625}
626
627/** The row the keys choose after `key` (up or down, home or end), from the layout's rows in order. */
628export function homePick(lay: HomeLayout, ui: HomeUi, key: string): string {
629  const keys = lay.picks.map(p => p.key)
630  if (!keys.length) return ui.pick
631  const at = Math.max(0, keys.indexOf(ui.pick || keys[0]!))
632  const to = key === 'up' || key === 'k' ? at - 1 : key === 'down' || key === 'j' ? at + 1 : key === 'home' ? 0 : key === 'end' ? keys.length - 1 : at
633  return keys[Math.max(0, Math.min(keys.length - 1, to))]!
634}
635
636/** The lines as plain text, for the tests: without the margin's two cells. */
637export function plainLines(lines: readonly Line[]): string[] {
638  return lines.map(l => l.map(s => s.s).join('').slice(MARGIN_W).replace(/\s+$/, ''))
639}
640
641
hooks/kept.ts 150 lines
1// What main's chat draws under its rows, kept in the workspace so a resumed session draws it again. Claude Code keeps a
2// row's uuid across `--continue` and `--resume`, and the drawings stand under rows by that uuid (their `requestId`), but
3// the plugin's state lives only as long as its process. So each row's cards, its answer's footer, its `↳ thread` rows
4// and its `↳ view` rows are written to `<workspace>/terminal/chat.json` as they are set, and read back into the state
5// when the session starts. The file is the renderer's own: browser mode does not read it.
6//
7// No `$` here: register.tsx reads and writes through the context (hooks/ctx.ts).
8import type { ChatSignal, TermAnswer, TermHome } from '../types'
9import type { Ctx } from './ctx'
10
11/** What the file is read and written with. */
12type Io = Pick<Ctx, 'read' | 'write'>
13
14/** What one row of main's chat carries under it; `writer`, the writer run its `↳ The writer …` line reports (its chat)
15 *  and whether it said that run's end first. */
16export type KeptRow = { cards?: string[]; answer?: TermAnswer; threads?: ChatSignal[]; views?: string[]; writer?: { chat: string; first: boolean } }
17
18/** The file: each row's drawings by its uuid, oldest first, the last turn's text and cards (`/thimble cite` and
19 *  `/thimble card` open them by number), and what the workspace held when home was last seen (`seen`), so the row above
20 *  the prompt counts what came after across a relaunch. */
21export type Kept = { rows: Record<string, KeptRow>; last: { reply: string; cards: string[] }; seen?: TermHome }
22
23/** The workspace file, under the workspace folder. */
24export const KEPT_FILE = 'terminal/chat.json'
25/** The rows kept, the newest. */
26const ROWS_MAX = 400
27
28export const keptEmpty = (): Kept => ({ rows: {}, last: { reply: '', cards: [] } })
29
30const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x !== '') : [])
31
32function signalsOf(v: unknown): ChatSignal[] {
33  return Array.isArray(v) ? v.filter((s): s is ChatSignal => Boolean(s) && typeof (s as ChatSignal).thread === 'string' && typeof (s as ChatSignal).turn === 'number').map(s => ({ thread: s.thread, turn: s.turn })) : []
34}
35
36function answerOf(v: unknown): TermAnswer | undefined {
37  const a = v as Partial<TermAnswer> | null | undefined
38  if (!a || typeof a !== 'object' || typeof a.text !== 'string') return undefined
39  return { rows: strings(a.rows), text: a.text, cards: strings(a.cards) }
40}
41
42/** The file's text as Kept: lenient, a part it cannot read is left out. */
43export function parseKept(raw: string): Kept {
44  let v: unknown
45  try {
46    v = JSON.parse(raw)
47  } catch {
48    return keptEmpty()
49  }
50  const o = (v ?? {}) as { rows?: unknown; last?: { reply?: unknown; cards?: unknown } }
51  const rows: Record<string, KeptRow> = {}
52  for (const [k, r] of Object.entries(o.rows && typeof o.rows === 'object' ? (o.rows as Record<string, unknown>) : {})) {
53    const x = (r ?? {}) as Record<string, unknown>
54    const row: KeptRow = {}
55    const cards = strings(x.cards)
56    const threads = signalsOf(x.threads)
57    const views = strings(x.views)
58    const answer = answerOf(x.answer)
59    const writer = x.writer as { chat?: unknown; first?: unknown } | null | undefined
60    if (writer && typeof writer === 'object' && typeof writer.chat === 'string' && writer.chat) row.writer = { chat: writer.chat, first: writer.first === true }
61    if (cards.length) row.cards = cards
62    if (threads.length) row.threads = threads
63    if (views.length) row.views = views
64    if (answer) row.answer = answer
65    if (Object.keys(row).length) rows[k] = row
66  }
67  const seen = seenOf((o as { seen?: unknown }).seen)
68  return { rows, last: { reply: typeof o.last?.reply === 'string' ? o.last.reply : '', cards: strings(o.last?.cards) }, ...(seen ? { seen } : {}) }
69}
70
71function seenOf(v: unknown): TermHome | undefined {
72  const o = v as Partial<Record<keyof TermHome, unknown>> | null | undefined
73  if (!o || typeof o !== 'object') return undefined
74  const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
75  return { cards: n(o.cards), labels: n(o.labels), docs: n(o.docs), threads: n(o.threads), views: n(o.views), files: n(o.files), at: n(o.at) }
76}
77
78/** The file's text: the newest ROWS_MAX rows. */
79export function keptJson(k: Kept): string {
80  const keys = Object.keys(k.rows)
81  const rows = Object.fromEntries(keys.slice(Math.max(0, keys.length - ROWS_MAX)).map(key => [key, k.rows[key]!]))
82  return JSON.stringify({ rows, last: k.last, ...(k.seen ? { seen: k.seen } : {}) })
83}
84
85/** `kept` with one row's part set (a row moves to the end, the newest). */
86export function withRow(k: Kept, row: string, part: KeptRow): Kept {
87  const rows = { ...k.rows }
88  const cur = rows[row] ?? {}
89  delete rows[row]
90  rows[row] = { ...cur, ...part }
91  return { ...k, rows }
92}
93
94// what this process holds of the file, and the writes in order
95let kept: Kept = keptEmpty()
96let writes: Promise<void> = Promise.resolve()
97
98function pathOf(ws: string): string {
99  return `${ws.replace(/\/+$/, '')}/${KEPT_FILE}`
100}
101
102/** The file as earlier sessions left it, merged under what this process set already (a reload keeps its own). */
103export async function loadKept(cx: Io, ws: string): Promise<Kept> {
104  const got = parseKept(await cx.read(pathOf(ws)).catch(() => ''))
105  const seen = kept.seen ?? got.seen
106  kept = { rows: { ...got.rows, ...kept.rows }, last: kept.last.reply || kept.last.cards.length ? kept.last : got.last, ...(seen ? { seen } : {}) }
107  return kept
108}
109
110/** What the workspace held when home was last seen, kept. */
111export async function keepSeen(cx: Io, ws: string, seen: TermHome): Promise<void> {
112  const k = kept.seen
113  if (k && (Object.keys(seen) as (keyof TermHome)[]).every(x => k[x] === seen[x])) return
114  kept = { ...kept, seen }
115  await save(cx, ws)
116}
117
118/** What the workspace held when home was last seen, as the file read at the session's start (or this process) keeps it. */
119export function keptSeen(): TermHome | null {
120  return kept.seen ?? null
121}
122
123/** One row's part kept, and the file written (one write at a time, in order). */
124export async function keepRow(cx: Io, ws: string, row: string, part: KeptRow): Promise<void> {
125  if (!row) return
126  kept = withRow(kept, row, part)
127  await save(cx, ws)
128}
129
130/** The last turn's text and cards kept. */
131export async function keepLast(cx: Io, ws: string, reply: string, cards: string[]): Promise<void> {
132  kept = { ...kept, last: { reply, cards } }
133  await save(cx, ws)
134}
135
136async function save(cx: Io, ws: string): Promise<void> {
137  const text = keptJson(kept)
138  writes = writes.then(() => cx.write(pathOf(ws), text)).catch(() => undefined)
139  await writes
140}
141
142/** For the tests: what this process holds, and a fresh start. */
143export function keptNow(): Kept {
144  return kept
145}
146export function resetKept(): void {
147  kept = keptEmpty()
148  writes = Promise.resolve()
149}
150
hooks/lines.tsx 88 lines
1// Parts of the panel and the chat drawn from styled lines by the Client homeview.tsx (no `$`): the home panel, the
2// threads tree, the lists, a link (a citation's title, a record's place, a passage's ↳). A click on a hit runs its
3// closure, kept here by the drawing's stamp; a key goes to the drawing's key handler once a click gave it the keyboard.
4import type { RenderElement, ResolveInput } from 'claude-code'
5
6import type { Ctx } from './ctx'
7import type { Line } from './draw'
8import { noControls } from './lib'
9
10/** A region of a part drawn from lines (homeview.tsx) and what a click on it does. */
11export type LineHit = { y: number; x0: number; x1: number; row: boolean; run: () => Promise<void> | void }
12// what each drawing's hits and keys do, by its stamp; the oldest are let go
13const lineStamps = new Map<string, { runs: (() => Promise<void> | void)[]; key?: (k: string) => Promise<void> | void }>()
14const lineSeen = new Map<string, number>()
15
16function stampOf(key: string, lines: readonly Line[], hits: readonly LineHit[]): string {
17  let h = 0x811c9dc5
18  const s = `${key}\u0000${JSON.stringify(lines)}\u0000${hits.map(x => `${x.y},${x.x0},${x.x1},${x.row ? 1 : 0}`).join(';')}`
19  for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
20  return `${key}:${(h >>> 0).toString(36)}`
21}
22
23// the key handler of the panel's list drawn last (a part whose key starts `m:`), which the panel's own keys reach too
24// while the pane holds the keyboard (panel.tsx listKeysEl): a Client takes keys only once a click gave it them
25let listKeys: ((k: string) => Promise<void> | void) | null = null
26
27/** The key handler of a part the panel drew that takes the list's keys without a list of lines (a terminal view,
28 *  panel.tsx drawView). */
29export function setListKeys(fn: (k: string) => Promise<void> | void): void {
30  listKeys = fn
31}
32
33/** The key handler of the list the panel drew since the last call, and none from then on. */
34export function takeListKeys(): ((k: string) => Promise<void> | void) | null {
35  const k = listKeys
36  listKeys = null
37  return k
38}
39
40/** A part drawn from styled lines by the Client homeview.tsx, `cols` wide: a click (left or right) on a hit runs it; its
41 *  keys go to `onKey` once a click gave it the keyboard. Its key starts with `m:` when its lines bring the margin. */
42export function linesEl(cx: Ctx, e: ResolveInput, key: string, lines: Line[], hits: LineHit[], cols: number, onKey?: (k: string) => Promise<void> | void): RenderElement {
43  if (e.surface !== 'terminal' && e.surface !== 'desktop') {
44    const { Text } = cx.els(e)
45    return <Text key={key}>{lines.map(l => l.map(x => x.s).join('')).join('\n')}</Text>
46  }
47  const { Client } = cx.els(e)
48  if (onKey && key.startsWith('m:')) listKeys = onKey
49  const stamp = stampOf(key, lines, hits)
50  lineStamps.delete(stamp)
51  lineStamps.set(stamp, { runs: hits.map(h => h.run), ...(onKey ? { key: onKey } : {}) })
52  for (const k of [...lineStamps.keys()].slice(0, Math.max(0, lineStamps.size - 400))) lineStamps.delete(k)
53  const packed = hits.flatMap((h, i) => [h.y, h.x0, h.x1, h.row ? 1 : 0, i])
54  // a control character a record's words still hold would make the drawing fail to validate
55  const shown = lines.map(l => l.map(x => (noControls(x.s) === x.s ? x : { ...x, s: noControls(x.s) })))
56  return <Client key={key} module="./homeview.tsx" width={cols} height={Math.max(1, lines.length)} props={JSON.parse(JSON.stringify({ lines: shown, hits: packed, stamp, cols, ...(onKey ? { keys: true } : {}) })) as never} />
57}
58
59/** What homeview.tsx posts for a click on no hit of a list with keys, which gave its Client the keyboard. */
60export const CLIENT_CLICK = '\u0000click'
61let onClientClick: ((cx: Ctx) => Promise<void>) | null = null
62
63/** What a click on no hit of a list does (term.ts takeKeys: the keys back to the pane). */
64export function onListClick(fn: (cx: Ctx) => Promise<void>): void {
65  onClientClick = fn
66}
67
68/** A post of homeview.tsx: each click and key not seen yet, by the drawing it was made in. */
69export async function linesMessage(cx: Ctx, origin: unknown, raw: unknown): Promise<void> {
70  if (!Array.isArray(raw) || typeof origin !== 'string') return
71  for (const a of raw as { seq?: unknown; i?: unknown; k?: unknown; s?: unknown }[]) {
72    if (typeof a?.seq !== 'number' || a.seq <= (lineSeen.get(origin) ?? 0)) continue
73    lineSeen.set(origin, a.seq)
74    const got = lineStamps.get(String(a.s))
75    if (!got) continue
76    if (a.k === CLIENT_CLICK) await onClientClick?.(cx)
77    else if (typeof a.k === 'string') await got.key?.(a.k)
78    else {
79      await got.runs[Number(a.i)]?.()
80      // a click on a row of a list with keys gave its Client the keyboard too: the keys go back to the pane (live check
81      // term-fix8, quirk 2: after a click on a home row x did nothing and the hint said to click the panel)
82      if (got.key) await onClientClick?.(cx)
83    }
84    await cx.bumpPanel()
85  }
86}
87
88
hooks/panel.tsx 3262 lines
1// The panel: thimble-term's one pane, drawn by the view `panel` names (hooks/term.ts), on the panel's look in SPEC.md
2// ("The visual system": rules 1 to 15, sections 2 and 7). A cell of padding at each side,
3// then the 2-cell margin where `❯`, `?` and `↳` hang, then the type area. Every view opens with its title row, the path
4// from home with the current step last in the accent colour and bold (`home › Threads`; on home `show all threads` and
5// `N new` in green at R), a dim subtitle, a rule; its actions sit at its bottom after a second rule; a dim italic row of
6// key hints ends it.
7// A right-click does what a click does: there is no menu.
8//
9//   home      everything the workspace holds (home.ts laid out, homeview.tsx drawn): views, documents, threads, cards by
10//             group (the newest open, the others folded), labels, files by folder
11//   card      a card in its border, its takeaway; its code (`mode: code`)
12//   cite      a citation: its value as a link, its status in plain words, its lines with the value marked, or the card
13//             it names in its border with the cited mark lit; red when its place does not hold it
14//   ask       the question field of a new side thread about what was asked about
15//   threads   the threads as a tree under main, the selected one (`thread`) under the second rule, the ask field
16//   label     a label after the browser's label editor: its name, type and scope; its prompt (or pattern or code) to edit;
17//             run on a sample or on all; counts, examples and cards folded. labels: every label
18//   docs      the documents; doc, one document drawn as main's chat draws a reply, its figures as cards, its comments
19//             under the passages they are on (report.ts); its edit as Markdown (`mode: edit`, docedit.tsx)
20//   files     the file browser and a file: filesview.tsx, through drawsView
21//   agent     one of thimble's agents: what it is doing and its latest steps
22//   views     the views, one row each; view, one view as one line (the browser draws views)
23import type { BoxProps, ButtonProps, ElementConstructor, MatchedEvent, RenderElement, TextProps } from 'claude-code'
24
25import type { ChatNavStep, ChatThread, TermPanel, TermThread, TermVerdict } from '../types'
26import type { ThimbleLabel } from './cell'
27import { ACCENT, FRESH, LINK, MARGIN_W, controlsEl, fieldEls, fitHints, fitTo, freshSeg, hasMargin, headerEls, hintLines, hintsEl, lineEl, linkSeg, marginKey, pointed, ruleEl, setHead, spread, subLine, takeHead } from './chrome'
28import { chipLook, chipName, citeLabel, plainCites, quoteSpan, quotedWords, wrapAround } from './cite'
29import { MAX_BARS, MAX_NODES, MAX_TABLE_ROWS, amount, cardLayout, cut, cutRef, demojibake, labelHead, lineWidth, placeWords, shade, share, shares, turnTimes, valueColour, width, wrapRows } from './draw'
30import type { BarRow, CardData, Cell, Item, Layout, Line, Seg } from './draw'
31import { fileRef, fileType, filesOf } from './files'
32import { citationOf, placeOf, targetLabel } from './gestures'
33import type { Gesture, Target } from './gestures'
34import { FIRST as HOME_FIRST, HOME_HINTS, groupCards, homeLayout, homePick, homeReduce, labelHue, NAME_WHOLE } from './home'
35import type { HomeAct, HomeCardGroup, HomeData, HomeFile, HomeLabel, HomeLayout, HomeOpen, HomeReport, HomeThread, HomeUi, HomeView } from './home'
36import { chipLabel, chipWords, cid, clip, cutLine, fmt, itemsRow, labelRef, middleCut, labelState, labelStateWords, noControls, outputLine, quoted, recordFields, reportRef, stoppedTurn, windowAt } from './lib'
37import type { Citation } from './lib'
38import { linesEl, setListKeys, takeListKeys } from './lines'
39import type { LineHit } from './lines'
40import { aroundLine, docUnits, docsOf, labelOf, labelsOf, threadOf } from './model'
41import type { DocFigure, DocSection } from './model'
42import { TITLE_ID, beginEdit, checksOf, commentFacts, commentLines, commentWho, commentsOf, discardEdit, draftEdit, editChanged, editOf, flipResolved, passageWords, pickOf, resolvedShown, saveEdit, setComment, setPick, shownComments, stepPick, unitOf, unitParts } from './report'
43import type { DocComment } from './report'
44import { focusFromRef } from './anim'
45import type { Focus } from './anim'
46import { NAV_EMPTY, backTarget, crumbSteps, fitPath, pathWidth, threadBehind, threadOnTrail, threadTitle, threadTree, withBack } from './nav'
47import { COLORS, paintLines } from './paint'
48import { PANEL_MARGIN, cardBlock, cardName, citeStatus, claimCard, claimSentence, drawReply, linkCheck, placeName, plainWhy, scrubIds } from './reply'
49import { PANEL, citePage, closePanel, deleteLabel, filterLabel, handBack, inPanelNow, loadCards, navBack, navGo, openHome, openList, openPanel, panelOfStep, queueCitations, readDoc, readFilePage, readSurface, rt, runLabel, saveLabel, showLabel, startThread, stepOf, stopLabel, subjectFile, surfaceValue, threadMessage, undeleteLabel } from './term'
50import { COLOR_NAMES, LABEL_WHEEL, MORE_ROWS, agreementLine, classColors, hueOf, labelArgs, labelGone, labelRows, setLabelGone } from './labels'
51import type { LabelPatch } from './term'
52import type { Ctx } from './ctx'
53import { act } from './data'
54import { closeView, onViewAct, openViewState, retryView, sendEvent, viewFaultOf, viewFor } from './viewhost'
55import type { ViewFrame } from './viewhost'
56
57export type PaneEvent = MatchedEvent<'ui.render', { component: 'Pane'; requestId: string }>
58
59type Obj = Record<string, unknown>
60type El = { Box: ElementConstructor<BoxProps>; Text: ElementConstructor<TextProps>; Button: ElementConstructor<ButtonProps> }
61type Key = { key: string; hotkey: string; onPress: () => void }
62const isObj = (v: unknown): v is Obj => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
63const str = (v: unknown): string => (typeof v === 'string' ? v : v === null || v === undefined ? '' : String(v))
64const num = (n: number) => Math.round(n).toLocaleString('en-US')
65const plural = (n: number, w: string, many = `${w}s`) => `${num(n)} ${n === 1 ? w : many}`
66const dim = (s: string): Seg => ({ s, fg: COLORS.dim })
67
68/** What the card pane's subtitle says of a card check that ended in an error: its revision of the card would not run,
69 *  or it could not finish (it ran past its time, or the reading or the drawing failed). */
70const CHECK_UNRUN = "its check's revision would not run"
71const CHECK_UNFINISHED = 'its check could not finish'
72
73// ------------------------------------------------------------------------------------------------ opening
74
75/** The `element` of a thread asked about a whole answer, from the answer's footer. */
76export const ANSWER_ELEMENT = 'answer'
77
78/** What a new thread is told it was asked about, beside its anchor: `anchor` (a ref, or none for words on screen),
79 *  `anchorText` (the words), `element` (where it was asked, a document's passage). */
80export type AskOver = { anchor?: string | null; anchorText?: string; element?: string; about?: string }
81
82/** A side thread about a target: the ask view, its question field first. A thread asked from inside the panel hangs
83 *  under the thread on the panel's trail. Its words: a passage's, a mark's name and value, a card by its question, a
84 *  citation's sentence. */
85export async function openAsk(cx: Ctx, t: Target, over: AskOver = {}): Promise<void> {
86  const c = citationOf(t)
87  const anchor = over.anchor !== undefined ? over.anchor : t.kind === 'sentence' ? null : t.cardId && t.kind !== 'record' && t.kind !== 'citation' ? `card:${t.cardId}` : c?.ref ?? (t.cardId ? `card:${t.cardId}` : null)
88  const anchorText =
89    over.anchorText !== undefined
90      ? over.anchorText
91      : t.kind === 'sentence'
92        ? plainCites(t.text ?? '')
93        : t.kind === 'card'
94          ? t.cardId
95            ? await cardName(cx, t.cardId)
96            : ''
97          : t.kind === 'citation'
98            ? plainCites(claimSentence(t.claim)) || (c ? citeLabel(c) : '')
99            : plainCites(t.label ? `${t.label}` : t.text ?? '')
100  const parent = (await inPanelNow(cx)) ? threadOnTrail(((await cx.nav()) ?? NAV_EMPTY).trail) : ''
101  // the subject as the thread and home name it (anchorName): a card by its question, a citation by its words
102  const about = over.about ?? (t.kind === 'sentence' && t.label ? t.label : t.kind === 'card' && !t.text && anchorText ? anchorText : targetLabel(t, 60))
103  await openPanel(cx, { view: 'ask', title: 'Ask', target: t, anchor, anchorText, about, ...(parent ? { parent } : {}), ...(over.element ? { element: over.element } : {}) })
104}
105
106/** The views as home lists them (`thimble state home`): each by its slug and name, its state (built, building,
107 *  proposed, failed), when it was proposed or built, the files it claims, and `fresh` once built and not yet opened. */
108export async function homeViews(cx: Ctx): Promise<HomeView[]> {
109  const homeRaw = await surfaceValue<Obj>(cx, 'home-full')
110  if (!homeRaw?.ok || !Array.isArray(homeRaw.value.views)) return []
111  return (homeRaw.value.views as unknown[]).filter(isObj).map(v => {
112    const st = str(v.status)
113    const state = st === 'built' ? 'built' : st === 'building' ? 'building' : st === 'failed' ? 'failed' : 'proposed'
114    const slug = str(v.slug)
115    const fresh = state === 'built' && !rt.viewsSeen.has(slug) && rt.viewsBuiltBefore !== null && !rt.viewsBuiltBefore.has(slug)
116    return { slug, name: str(v.name) || slug, state, words: '', files: Array.isArray(v.files) ? (v.files as unknown[]).map(String) : [], unit: '', drawable: state === 'built', left: 0, at: Date.parse(str(v.ts)) || 0, ...(fresh ? { fresh: true } : {}), ...(v.term === true ? { term: true } : {}) }
117  })
118}
119
120/** A document in the panel, under its title. Opened, it is no longer new. */
121export async function openDoc(cx: Ctx, slug: string, title: string): Promise<void> {
122  const docs = await surfaceValue(cx, 'docs')
123  const d = docs?.ok ? docsOf(docs.value).find(x => x.slug === slug) : undefined
124  if (d) rt.docsKnown.set(slug, d.generation)
125  await openPanel(cx, { view: 'doc', title: d?.title || title || slug, slug })
126}
127
128/** A document opened at the section, slide or beat that holds `unit` (a sentence's, a heading's or a paragraph's id,
129 *  `p<id>`), from a link to it (`report:<slug>#<unit>`). */
130async function openDocAt(cx: Ctx, slug: string, unit: string): Promise<void> {
131  await readDoc(cx, slug)
132  const got = await surfaceValue<Obj>(cx, `doc:${slug}`)
133  const doc = got?.ok ? got.value : {}
134  const holds = (u: DocSection) => u.id === unit || (u.paragraphs ?? []).some(p => p.id === unit || `p${p.id}` === unit || (p.sentences ?? []).some(x => x.id === unit))
135  const at = unit ? docUnits(doc).units.findIndex(holds) : -1
136  const docs = await surfaceValue(cx, 'docs')
137  const d = docs?.ok ? docsOf(docs.value).find(x => x.slug === slug) : undefined
138  if (d) rt.docsKnown.set(slug, d.generation)
139  await openPanel(cx, { view: 'doc', title: d?.title || str(doc.title) || slug, slug, ...(at > 0 ? { start: at } : {}) })
140}
141
142/** A view's line in the panel: the browser draws views. Opened, it is no longer new. */
143export async function openView(cx: Ctx, slug: string, name: string): Promise<void> {
144  rt.viewsSeen.add(slug)
145  await openPanel(cx, { view: 'view', title: name || slug, slug })
146}
147
148/** A citation's panel, its step named by the cited value (or its place, for a citation that shows none). */
149/** A citation by its words, or, for a place cited without words, the place in words (`revisions.jsonl line 10879`, a
150 *  card by its question): the citation panel's title and its step in the path. */
151async function citeTitle(cx: Ctx, c: Citation): Promise<string> {
152  if (c.display !== null) return c.display
153  if (fileRef(c.ref) || /^call:/.test(c.ref)) return placeWords(c.ref)
154  return /^(?:card|cell):/.test(c.ref) ? placeName(cx, c.ref) : chipWords(c)
155}
156
157/** A citation's panel, its step named by the cited value (or its place, for a citation that shows none); `sentence`
158 *  the sentence it stands in, `quote` the passage an example's record quotes. */
159export async function openCite(cx: Ctx, ref: string, display: string | null, more: { sentence?: string; quote?: string; of?: string } = {}): Promise<void> {
160  // a label's link: the label's panel at the value it names (live check term-fix5, new quirk 2)
161  const lr = labelRef(ref)
162  if (lr) return openLabelAt(cx, lr.id, lr.value)
163  // a document's link: the document, from the section that holds the passage
164  const rr = reportRef(ref)
165  if (rr) return openDocAt(cx, rr.slug, rr.unit)
166  // its step in the path: its words cut at a word; for a place cited with no words, the words of the chip the reply
167  // draws (`agent-chat.jsonl line 2`)
168  const c = { raw: '', ref, display }
169  const title = display === null && fileRef(ref) ? chipWords(c) : clip(await citeTitle(cx, c), 40)
170  // opened anew, a file citation's window starts at the cited lines again
171  citeTops.delete(ref)
172  await openPanel(cx, { view: 'cite', title, ref, display, ...(more.sentence ? { sentence: more.sentence } : {}), ...(more.quote ? { quote: more.quote } : {}), ...(more.of ? { of: more.of } : {}) })
173}
174
175export async function openCard(cx: Ctx, id: string, mode = ''): Promise<void> {
176  // its step named by its question: a card no drawing read yet (one a side thread made) is read first (live check
177  // term-fix8, low quirk: the path read `card "Card"` on its first open)
178  if (!(await cx.card(id))?.data) await loadCards(cx, [id]).catch(() => undefined)
179  const tc = await cx.card(id)
180  await openPanel(cx, { view: 'card', title: clip((tc?.data as CardData | undefined)?.question ?? 'Card', 60), card: id, ...(mode ? { mode } : {}) })
181}
182
183/** A thread opens in the threads panel, selected under the tree; its step named by its first question (`question` when
184 *  it was just asked), never thimble's slug of it. */
185export async function openThread(cx: Ctx, id: string, question = '', opts: { replace?: boolean } = {}): Promise<void> {
186  const row = (await cx.threads()).find(t => t.id === id)
187  const tt = await cx.thread(id)
188  const q = question || (tt?.events.length ? threadOf(tt.meta, tt.events).turns[0]?.q : '') || row?.question || row?.title || ''
189  await openPanel(cx, { view: 'thread', title: q ? quoted(clip(plainCites(q), 60)) : 'thread', thread: id }, opts)
190}
191
192/** A file in the panel from line `start`, `line` the record a citation or a click chose, lit there. */
193export async function openFile(cx: Ctx, path: string, start = 1, line?: number): Promise<void> {
194  await openPanel(cx, { view: 'file', title: path.split('/').at(-1) ?? path, path, start, ...(line ? { line } : {}) })
195}
196
197/** A label's panel, as it opens: what a save or a run said there before is gone. With `value`, its counts and examples
198 *  open, the value lit in them. */
199export async function openLabel(cx: Ctx, id: string, name: string, value = ''): Promise<void> {
200  // the labels list's `undo` of a delete is offered only until another label opens
201  setLabelGone(null)
202  const ui = await cx.labelUi()
203  const said = { ...ui.said }
204  if (ui.said[id] && !ui.runs[id]) delete said[id]
205  const open = value ? [...new Set([...ui.open, `${id}:counts`, `${id}:examples`])] : ui.open
206  if (said[id] !== ui.said[id] || open !== ui.open) await cx.setLabelUi({ ...ui, said, open })
207  await openPanel(cx, { view: 'label', title: name, label: id, ...(value ? { value } : {}) })
208}
209
210/** A label's panel opened from a link to it (`concept:<id>/<value>`), named as the labels list names it. */
211async function openLabelAt(cx: Ctx, id: string, value: string): Promise<void> {
212  await readSurface(cx, 'labels', 'labels')
213  const got = await surfaceValue(cx, 'labels')
214  const name = (got?.ok ? labelsOf(got.value) : []).find(l => l.id === id)?.name ?? 'label'
215  await openLabel(cx, id, name, value)
216}
217
218/** What a click (or a right-click: it does what a click does) on a target does, as the mod's: open the place it cites
219 *  (a citation's, a record's), or ask a side thread about a card's mark; plain words open nothing (only what is drawn as
220 *  a link opens a panel). */
221export async function onGesture(cx: Ctx, _gesture: Gesture, t: Target): Promise<void> {
222  const place = placeOf(t)
223  if (place) {
224    // a citation of a reply: the sentence it stands in, and the card whose takeaway holds it; an example's record: the
225    // passage the card quotes
226    const sentence = t.kind === 'citation' ? claimSentence(t.claim) : ''
227    const of = t.kind === 'citation' ? claimCard(t.claim) : ''
228    let quote = ''
229    if (t.kind === 'record' && t.cardId) {
230      const card = (await cx.card(t.cardId))?.data as CardData | null | undefined
231      quote = card?.examples?.find(x => x.ref === t.ref)?.quote ?? ''
232    }
233    return openCite(cx, place.ref, place.display, { ...(sentence ? { sentence } : {}), ...(quote ? { quote } : {}), ...(of ? { of } : {}) })
234  }
235  if (t.cardId) return openAsk(cx, t)
236}
237
238// ------------------------------------------------------------------------------------------------ the frame
239
240/** Hotkeys with no label of their own (rule 26: the key-hint row says them): plain Buttons in a Box no row tall. Each
241 *  is kept by its letter too, for the list's keys (listKeysEl), whose field takes the letters while it holds the ring;
242 *  none is drawn while the panel's typing goes to the prompt (typeThrough), so the next letter reaches it. */
243export function hiddenKeys(cx: Ctx, e: PaneEvent, keys: Key[]): RenderElement | null {
244  for (const k of keys) hotkeysDrawing.set(k.hotkey, k.onPress)
245  if (!keys.length || e.surface === 'mobile' || rt.typeThrough) return null
246  const { Box, Button } = cx.els(e)
247  return (
248    <Box key="hidden-keys" width={0} height={0} flexShrink={0} overflow="hidden" flexDirection="row">
249      {keys.map(k => (
250        <Button key={`hk-${k.key}`} label={k.hotkey} hotkey={k.hotkey} plain onPress={typedOr(cx, k.hotkey, k.onPress)} />
251      ))}
252    </Box>
253  )
254}
255
256/** A hotkey's press, or, once the panel's typing goes to the prompt (typeThrough), its letter typed into the prompt: a
257 *  key typed before the panel drew again without its hotkeys reached one (live check term-fix10: `table` typed into the
258 *  prompt opened the threads at its `t` and reached main as `able`). */
259function typedOr(cx: Ctx, hotkey: string, onPress: () => void): () => void {
260  return () => (rt.typeThrough ? void cx.fill(hotkey) : onPress())
261}
262
263/** A Button's hotkey prop (`hotkey` for its key) and its press (typedOr), kept by its letter for the list's keys as
264 *  hiddenKeys keeps one: no hotkey for a Button past the first nine, or while the panel's typing goes to the prompt. */
265function hotkeyOf(cx: Ctx, hotkey: string | null, onPress: () => void): { hotkey?: string; onPress: () => void } {
266  if (!hotkey) return { onPress }
267  hotkeysDrawing.set(hotkey, onPress)
268  return rt.typeThrough ? { onPress: typedOr(cx, hotkey, onPress) } : { hotkey, onPress: typedOr(cx, hotkey, onPress) }
269}
270
271/** What a step of the path says: a lower-case kind word and its name, or the name alone after the list it is in. */
272function crumbText(s: ChatNavStep): string {
273  switch (s.view) {
274    case 'thread':
275      return s.title
276    case 'cite':
277      return `citation ${s.title === 'Citation' ? '' : s.title}`.trim()
278    case 'card':
279      // its code view's step keeps ` · code` whole: the question inside its marks is cut, once (lib.ts cut)
280      return panelOfStep(s)?.mode === 'code' ? `card ${quoted(s.title)} · code` : `card ${quoted(s.title)}`
281    case 'label':
282    case 'file':
283    case 'agent':
284    case 'view':
285      return s.title
286    case 'doc':
287      // its edit takes the document's step, as a card's code view takes the card's
288      return panelOfStep(s)?.mode === 'edit' ? `${quoted(s.title)} · edit` : quoted(s.title)
289    case 'docs':
290      return 'documents'
291    case 'ask':
292      return 'new thread'
293    default:
294      return s.view
295  }
296}
297
298/** The list a step stands in, which the path shows before it unless the step before it is that list. */
299function upOf(s: ChatNavStep, earlier: readonly ChatNavStep[]): TermPanel | null {
300  const before = earlier.at(-1)
301  if (s.view === 'thread') return earlier.some(x => x.view === 'thread' || x.view === 'threads') ? null : { view: 'threads', title: 'Threads' }
302  if (s.view === 'label') return before?.view === 'labels' ? null : { view: 'labels', title: 'Labels' }
303  if (s.view === 'doc') return before?.view === 'docs' ? null : { view: 'docs', title: 'Documents' }
304  if (s.view === 'file') return before?.view === 'files' ? null : { view: 'files', title: 'Files' }
305  if (s.view === 'view') return before?.view === 'views' ? null : { view: 'views', title: 'Views' }
306  return null
307}
308
309// the cells of the title row's separator (` › `)
310const SEP_W = 3
311
312// a list's step as the title names it where it is the current step (`home › Threads`); earlier on the path it reads as
313// its lower-case kind word (`home › threads › "…"`)
314const LIST_TITLES: Record<string, string> = { home: 'Home', threads: 'Threads', labels: 'Labels', docs: 'Documents', files: 'Files', views: 'Views', ask: 'New thread' }
315
316/** The current step's words: a list's title; a thread by its question; else the step as the path names it, with the
317 *  subject's name as the view gave it (a card's whole question). */
318function hereText(s: ChatNavStep, title: string | undefined): string {
319  const list = LIST_TITLES[s.view]
320  if (list) return list
321  return s.view === 'thread' || !title ? crumbText(s) : crumbText({ ...s, title })
322}
323
324/** The title row (SPEC.md, "A panel's header"; Matt, 2026-10-07: "can just be one line for title"): the path from
325 *  home, each earlier step dim and a click away, a dim ` › ` between steps, and the current step last, its title in the
326 *  accent and bold (a citation's value its link); a thread's step followed by `new` in green while answers wait, one
327 *  that answers starting with `◌`. Against R what the view puts there (`◌ loading…`), and on home `show all threads`
328 *  and `N new` in green. No `‹ back`: b goes back. Where the path does not fit, the earlier steps shorten first, then
329 *  the current one is cut with `…`. */
330async function wayRow(cx: Ctx, e: PaneEvent, view: string): Promise<RenderElement> {
331  const { Box, Text, Button } = cx.els(e)
332  const els = cx.els(e) as El
333  // the row's own width: never more than the pane gives it, or it wraps (live check term-fix9, quirk 3)
334  const cols = Math.max(10, e.props.bodyColumns)
335  const nav = (await cx.nav()) ?? NAV_EMPTY
336  const back = backTarget(nav) !== null
337  const head = takeHead()
338  // `show all threads` only where the threads are the subject, home; the threads panel is the threads, and on a view,
339  // a file, a card or a citation home is one step away (Matt, 2026-10-07: "does 'show all threads' really need to be
340  // there when you're not in a thread?")
341  const showThreads = view === 'home'
342  const threads = (await cx.threads()) ?? []
343  const fresh = ((await cx.news()) ?? { n: 0 }).n
344  const { steps, skipped } = crumbSteps(nav.trail)
345  type Crumb = { text: string; mark: string; go: () => void; here?: boolean; up?: boolean }
346  const crumbs: Crumb[] = []
347  for (const [i, s] of steps.entries()) {
348    const up = upOf(s, steps.slice(0, i))
349    // the list a step stands in goes back to that list: the trail up to the step, then the list, never a step pushed
350    // after the step (`home › files › a.jsonl › files`)
351    const upTrail = nav.trail.slice(0, i + skipped)
352    if (up) crumbs.push({ text: up.view === 'docs' ? 'documents' : up.view, mark: '', up: true, go: () => void navGo(cx, { trail: [...upTrail, stepOf(up)], back: withBack(nav.back, nav.trail) }) })
353    const t = s.view === 'thread' ? threads.find(x => x.id === s.thread) : undefined
354    const here = i === steps.length - 1
355    // a card's step taken before the card was read names it by its question once it is
356    const card = s.view === 'card' && (s.title === 'Card' || (here && !head.title)) ? ((await cx.card(panelOfStep(s)?.card ?? ''))?.data as CardData | null | undefined) : null
357    const named = card?.question ? { ...s, title: here ? card.question : clip(card.question, 60) } : s
358    crumbs.push({ text: here ? hereText(named, head.title) : crumbText(named), mark: t?.running ? '◌' : t?.unread ? 'new' : '', go: () => void navGo(cx, { trail: nav.trail.slice(0, i + 1 + skipped), back: withBack(nav.back, nav.trail) }), here })
359  }
360  const home = !crumbs.length
361  // the current step's own words, where the view draws them (a citation's value as its link), after the step's kind
362  // word in the accent and bold (`citation`)
363  const own: Line | null = head.line && crumbs.length ? head.line : null
364  const kind = own && view === 'cite' ? 'citation ' : ''
365  if (own) crumbs[crumbs.length - 1]!.text = `${kind}${own.map(x => x.s).join('')}`
366  const marksW = crumbs.reduce((n, c) => n + (c.mark ? c.mark.length + 1 : 0), 0)
367  // at R `show all threads` and `N new`, while the steps keep the room they need beside them (home, the last step at
368  // up to 12 cells); else `threads` and `N new`, else `threads` alone, so the threads stay one click away in a narrow
369  // pane; else neither. No letter opens them: `t` did, unnamed, and a word typed while the panel held the keys (`table`)
370  // opened the threads and lost its first letter (live check term-fix10, new quirk 4)
371  const labels = [home ? LIST_TITLES.home! : 'home', ...crumbs.map(c => c.text)]
372  const rightW = head.right ? 2 + 12 : 0
373  const room = cols - marksW - rightW
374  const newW = fresh ? `${fresh} new`.length : 0
375  type Tail = { all: string; fresh: boolean; w: number }
376  const tail0 = (all: string, withNew: boolean): Tail => ({ all, fresh: withNew, w: (all ? 2 + all.length : 0) + (withNew ? 2 + newW : 0) })
377  const tails: Tail[] = !showThreads
378    ? [tail0('', false)]
379    : [tail0('show all threads', Boolean(fresh)), ...(fresh ? [tail0('threads', true)] : []), tail0('threads', false), tail0('', false)]
380  const need = Math.min(pathWidth(labels.map((l, i) => (i < labels.length - 1 ? clip(l, 34) : l))), 4 + (labels.length > 2 ? 4 : 0) + (labels.length > 1 ? SEP_W + Math.min(12, width(labels.at(-1)!)) : 0))
381  const tail = tails.find(t => room - t.w >= need) ?? tails.at(-1)!
382  const fitted = fitPath(labels, Math.max(4, room - tail.w))
383  const parts: RenderElement[] = []
384  let drawn = false
385  fitted.forEach((text, i) => {
386    if (text === null) {
387      if (drawn && fitted[i - 1] !== null) parts.push(<Text dimColor>{' › …'}</Text>)
388      return
389    }
390    if (drawn) parts.push(<Text dimColor>{' › '}</Text>)
391    drawn = true
392    const c = i ? crumbs[i - 1]! : null
393    if (c?.mark === '◌') parts.push(<Text>{'◌ '}</Text>)
394    if (c?.here || (!c && home)) {
395      // the current step: the title, in the accent and bold, or the view's own words for it, cut to the room
396      if (!own) parts.push(lineEl(els, [{ s: text, fg: ACCENT, b: true }]))
397      else {
398        if (kind) parts.push(lineEl(els, [{ s: cut(kind, width(text)), fg: ACCENT, b: true }]))
399        const line = fitTo(own, Math.max(1, width(text) - width(kind)))
400        if (width(text) > width(kind)) parts.push(head.press ? linesEl(cx, e, head.key ?? 'way-here', [line], [{ y: 0, x0: 0, x1: lineWidth(line), row: false, run: head.press }], lineWidth(line)) : lineEl(els, line, head.key))
401      }
402    } else if (c) parts.push(<Button key={c.up ? `crumb-up-${i}` : `crumb-${i}`} label={text} plain dimColor onPress={c.go} />)
403    else parts.push(<Button key="crumb-home" label={text} plain dimColor onPress={() => void openHome(cx)} />)
404    if (c?.mark === 'new') parts.push(<Text color={FRESH}>{' new'}</Text>)
405  })
406  const showAll = () => void openPanel(cx, { view: 'threads', title: 'Threads' })
407  return (
408    <Box key="way" flexDirection="row">
409      <Box flexShrink={1} flexDirection="row">
410        {parts}
411      </Box>
412      <Box flexGrow={1} />
413      {head.right ? <Box flexShrink={0}>{head.right}</Box> : null}
414      {tail.all ? <Button key="threads" label={tail.all} plain onPress={showAll} /> : null}
415      {tail.fresh ? <Text color={FRESH}>{`${tail.all ? '  ' : ''}${fresh} new`}</Text> : null}
416      {hiddenKeys(cx, e, [...(back ? [{ key: 'back', hotkey: 'b', onPress: () => void navBack(cx) }] : []), { key: 'close', hotkey: 'x', onPress: () => void closePanel(cx) }])}
417      {listKeysEl(cx, e)}
418    </Box>
419  )
420}
421
422/** The title row, then the view's rows, each with an empty margin unless it brings its own (a key starting `m:`). */
423async function withWay(cx: Ctx, e: PaneEvent, view: string, body: RenderElement): Promise<RenderElement> {
424  const way = await wayRow(cx, e, view)
425  const { Box } = cx.els(e)
426  const b = body as unknown as { type?: string; props?: { flexDirection?: string }; children?: unknown[] }
427  const kids = (b.type === 'Box' && b.props?.flexDirection === 'column' ? (b.children ?? []) : [body]).filter(k => Boolean(k)) as RenderElement[]
428  const rows = [way, ...kids].map(k => (hasMargin(k) ? k : <Box paddingLeft={MARGIN_W} flexDirection="column">{k}</Box>))
429  return (
430    <Box flexDirection="column" paddingLeft={1} paddingRight={1}>
431      {rows}
432    </Box>
433  )
434}
435
436/** A list's rows as lines: each item's glyph at A0 and its name at A2, its metadata dim at R, `❯` on the chosen one. */
437function listLines(items: { key: string; glyph: Seg | null; name: string; right: Line; run: () => Promise<void> | void }[], pick: string, cols: number): { lines: Line[]; hits: LineHit[] } {
438  const lines: Line[] = []
439  const hits: LineHit[] = []
440  for (const it of items) {
441    hits.push({ y: lines.length, x0: MARGIN_W, x1: cols + MARGIN_W, row: true, run: it.run })
442    lines.push(pointed(spread([it.glyph ?? { s: ' ' }, { s: ' ' }, { s: it.name }], it.right, cols), it.key === pick))
443  }
444  if (!items.length) lines.push(pointed([{ s: '  ' }, dim('none')], false))
445  return { lines, hits }
446}
447
448// ------------------------------------------------------------------------------------------------ shared parts
449
450/** Code as Claude Code colours it (its `Code` element), its dim gutter of line numbers from `startLine`. */
451function codeRows(cx: Ctx, e: PaneEvent, source: string, max = 400, language = 'python', startLine: number | null = 1): RenderElement {
452  const { Box, Text, Code } = cx.els(e)
453  const lines = source.replace(/\t/g, '    ').split('\n')
454  while (lines.length && !lines.at(-1)!.trim()) lines.pop()
455  const shown = lines.slice(0, lines.length > max + 1 ? max : lines.length)
456  return (
457    <Box flexDirection="column">
458      <Code source={shown.join('\n') || ' '} language={language} {...(startLine !== null ? { startLine } : {})} wrap="truncate-end" />
459      {lines.length > shown.length ? <Text dimColor>{`… ${lines.length - shown.length} more`}</Text> : null}
460    </Box>
461  )
462}
463
464// the way's keys the panel binds now (wayRow): `b to go back` only when there is a way back, `x to close` always
465let wayHints: string[] = ['x to close']
466// whether the pane holds the keys as it is drawn (its `isFocused`)
467let paneFocused = true
468// the key handler of the list the view draws (lines.tsx takeListKeys), which the panel's own keys reach (listKeysEl)
469let listRelay: ((k: string) => Promise<void> | void) | null = null
470// the panel's hotkeys by their letters, as the drawing shown last bound them (hiddenKeys, hotkeyOf), and as the one being
471// drawn does
472let hotkeys = new Map<string, () => void>()
473let hotkeysDrawing = new Map<string, () => void>()
474// the list's own keys besides ↑↓ and Enter, as the view's hint row names them (endHints): Space folds, Backspace goes
475// back; as the drawing shown last named them, and as the one being drawn does
476let listExtra = { space: false, backspace: false }
477let listExtraDrawing = { space: false, backspace: false }
478
479/** The panel's own keys for the list a view draws (live checks term-fix7, quirk 2, and term-fix8, quirks 1, 2 and 7). A
480 *  list is drawn by a Client, and a Client takes keys only from a click, which leaves the pane without them, so the
481 *  list's keys are the pane's: an Input between two Buttons, all no row tall, the ring on the Input (`autoFocus`). While
482 *  an Input holds the ring Claude Code moves the ring at ↑ and ↓ and never scrolls the pane, so the `ui.focus` hook
483 *  (register.tsx) turns a move onto either Button into ↑ or ↓ for the list and keeps the ring where it is; Enter submits
484 *  the Input; a letter, a digit or Space goes into it, and its change is that key (relayInput): the panel's hotkey by its
485 *  letter, Space for the list where its hint names it, any other key typing for the prompt (typeThrough). ←, →, the page
486 *  keys, Home and End reach no element while an Input holds the ring, so the hint row never names them. The Input holds
487 *  a mark of its own, drawn anew after each key, so a Backspace changes it too. */
488export const RELAY = { up: 'keys-up', pick: 'keys-pick', down: 'keys-down' } as const
489const RELAY_KEYS: readonly string[] = Object.values(RELAY)
490// the two marks the relay's Input holds in turn: drawing the other one gives it that text again (Claude Code keeps
491// what the person typed until the hook draws another value)
492const MARKS = ['\u200b', '\u200c'] as const
493// the mark drawn last, whether a key came since (the next drawing draws the other one), and the text last seen
494let relayMark = 0
495let relayFlip = false
496let relayLast = ''
497
498/** The key a move of the panel's focus ring onto `element` stands for while it rests on the relay's Input (`up`,
499 *  `down`), `park` for a move onto a neighbour from elsewhere (the ring goes to the Input), or '' for any other move. */
500export function relayMove(from: string, element: string | undefined): 'up' | 'down' | 'park' | '' {
501  if (element !== RELAY.up && element !== RELAY.down) return ''
502  if (from !== RELAY.pick) return 'park'
503  return element === RELAY.up ? 'up' : 'down'
504}
505
506/** Whether the panel shows a list whose keys the relay passes on. */
507export function hasList(): boolean {
508  return listRelay !== null
509}
510
511/** A key for the list the panel shows now, from the panel's own keys (the relay); false when it shows none. */
512export async function relayKey(k: string): Promise<boolean> {
513  if (!listRelay) return false
514  await listRelay(k)
515  return true
516}
517
518/** What the person typed into the relay's Input, from its text now (`value`) and the text seen last (`last`): each
519 *  character, or `backspace` when the text got shorter. Its text starts as the mark drawn; a key typed before the next
520 *  drawing adds to what is there. */
521export function relayTyped(value: string, last: string): string[] {
522  const mark = (v: string) => (v && (MARKS as readonly string[]).includes(v[0]!) ? v[0]! : '')
523  const m = mark(value)
524  const before = mark(last) === m && m ? last : m
525  if (value.length < before.length || (!m && !value)) return ['backspace']
526  return [...value.slice(before.length)]
527}
528
529/** The mark the relay's Input holds as drawn now, for the tests. */
530export function relayValue(): string {
531  return MARKS[relayMark]!
532}
533
534/** A change of the relay's Input: each key typed is the panel's hotkey by its letter or digit, Space or Backspace for
535 *  the list where its hint names them, else typing meant for the prompt (typeThrough). The Input is drawn again with
536 *  the other mark, so its next change starts afresh. */
537export async function relayInput(cx: Ctx, value: string): Promise<void> {
538  const typed = relayTyped(value, relayLast)
539  relayLast = value
540  relayFlip = true
541  for (const ch of typed) {
542    if (rt.typeThrough) {
543      if (ch !== 'backspace') await cx.fill(ch)
544      continue
545    }
546    if (ch === 'backspace') {
547      if (listExtra.backspace) await relayKey('backspace')
548      continue
549    }
550    if (ch === ' ' && listExtra.space) {
551      await relayKey('space')
552      continue
553    }
554    const run = hotkeys.get(ch)
555    if (run) {
556      run()
557      continue
558    }
559    await typeThrough(cx, ch)
560  }
561  await cx.bumpPanel()
562}
563
564// while a field of a terminal view takes typing (its search), the relay's Input is that field: it holds the field's
565// text, each change of which goes to the view whole, and Enter ends it (drawView)
566let viewField: { slug: string; text: string; send: (text: string) => Promise<void>; enter: () => Promise<void> } | null = null
567// where the open view's frame stands in the panel as last drawn: the pane tree's row of the frame's first row (under the
568// title row, the subtitle and the rule), the window's offset then, and the frame's seq (viewWheelAt); null with no
569// frame. viewAtDrawing is the drawing being made's, which becomes viewAt once it is done
570type ViewAt = { top: number; offset: number; seq: number }
571let viewAt: ViewAt | null = null
572let viewAtDrawing: ViewAt | null = null
573
574/** The cell of the open view's frame under the person's wheel (`pointer`, the pane body's cell, as ui.scroll gives it):
575 *  `{seq, x, y}`, `x` from the frame's left edge (its margin's two cells first) and `y` from its first row, as a click's
576 *  cell, so that the view moves the list under the pointer alone; none where the panel shows no frame. */
577export function viewWheelAt(pointer?: { row: number; column: number } | null): { seq?: number; x?: number; y?: number } {
578  if (!viewAt || !pointer || !Number.isFinite(pointer.row) || !Number.isFinite(pointer.column)) return {}
579  // the panel's cell of padding left of the frame (withWay)
580  return { seq: viewAt.seq, x: pointer.column - 1, y: pointer.row + viewAt.offset - viewAt.top }
581}
582// while a field of a panel's own takes typing (the file browser's find), the relay's Input is that field as it is for a
583// view's: the panel sets it on each drawing that types into it (relayField), and no other drawing keeps it
584type PanelField = { text: string; send: (text: string) => Promise<void>; enter: () => Promise<void> }
585let panelField: PanelField | null = null
586
587/** The relay's Input as a field of the panel being drawn, which takes typing until a drawing sets none: it holds
588 *  `text`, each change goes to `send` whole, Enter to `enter`; ↑↓ still move the list's choice. */
589export function relayField(f: PanelField | null): void {
590  panelField = f
591}
592
593/** The hint row while a field of the panel's own takes typing through the relay (relayField): what its keys do there,
594 *  then `Esc to leave the field`; while the prompt holds the keys, only how to give the panel them. */
595export function fieldHintsRow(els: El, hints: readonly string[], cols: number): RenderElement {
596  endHints(hints)
597  return hintsEl(els, paneFocused && !rt.typeThrough ? [...hints, 'Esc to leave the field'] : [UNFOCUSED_HINT], cols)
598}
599
600/** Whether the pane holds the keys as it is drawn, its typing not on the way to the prompt. */
601export function panelHasKeys(): boolean {
602  return paneFocused && !rt.typeThrough
603}
604
605/** The pane's body rows as the drawing measured them, 0 when not known (a list is then drawn whole). */
606export function paneRows(): number {
607  return bodyRows
608}
609
610/** A panel view drawn by a module of its own (filesview.tsx: the file browser and a file), by its name. */
611type Drawer = (cx: Ctx, e: PaneEvent, p: TermPanel) => Promise<RenderElement>
612const drawers = new Map<string, Drawer>()
613export function drawsView(view: string, draw: Drawer): void {
614  drawers.set(view, draw)
615}
616
617/** Enter on the relay's Input: Enter for the list. */
618export async function relaySubmit(cx: Ctx): Promise<void> {
619  relayLast = ''
620  relayFlip = true
621  if (!rt.typeThrough) await relayKey('return')
622  await cx.bumpPanel()
623}
624
625/** A key the panel does not bind, typed while its list held the keys: it goes to the prompt, as Claude Code sends a
626 *  pane's unbound letter there. Until the prompt has the keys (the next key the person types, which no element and no
627 *  hotkey of the panel takes now) the panel draws neither the relay nor its hotkeys, and its hint row says to click it
628 *  for its keys; a move of its ring or a click gives them back (register.tsx, term.ts takeKeys). */
629async function typeThrough(cx: Ctx, ch: string): Promise<void> {
630  rt.typeThrough = true
631  rt.panelFocus = NO_RING
632  await cx.fill(ch)
633}
634
635// the ring on no element (register.tsx NO_FOCUS)
636const NO_RING = '-'
637
638// the pane's body rows as the drawing measured them (0: not known, and no list is cut)
639let bodyRows = 0
640// the list the drawing cut to the pane's rows (windowList), and the one the drawing shown last cut, which the wheel moves
641let windowed = ''
642let shownWindow = ''
643// each cut list's first row shown, the row the keys chose then, how many rows it has and shows, and whether the wheel
644// or a click on a count row moved it since the choice last moved (`free`), by the list's key
645const windowTops = new Map<string, { top: number; at: number; len: number; room: number; free: boolean }>()
646
647/** The rows of a cut list that `top` leaves for the list's own lines: a dim row counts the lines above, another those
648 *  below. */
649function windowFit(top: number, len: number, room: number): { up: number; down: number; n: number } {
650  const up = top > 0 ? 1 : 0
651  let n = room - up
652  const down = top + n < len ? 1 : 0
653  n -= down
654  return { up, down, n }
655}
656
657/** A list's lines cut to `room` rows so that its chosen line (`at`, -1 for none) shows, the hits moved with them: a
658 *  list taller than its pane pushed the chosen row and the hint row out of view, and ↑↓ scrolled the pane instead of
659 *  choosing (live check term-fix8, quirk 1). The wheel and a click on a count row move the rows shown (wheelWindow)
660 *  until the choice moves again; the lines cut off above and below are counted on a dim row each, which a click moves
661 *  by a page. A list that fits, or a pane whose rows are not known, is drawn whole. */
662export function windowList(key: string, lines: Line[], hits: LineHit[], at: number, room: number, bump: () => Promise<void>, lead?: number): { lines: Line[]; hits: LineHit[] } {
663  windowed = key
664  const len = lines.length
665  if (room < 5 || len <= room) {
666    windowTops.delete(key)
667    return { lines, hits }
668  }
669  const last = len - room + 1
670  const prev = windowTops.get(key)
671  const free = Boolean(prev?.free && prev.at === at)
672  let top = Math.max(0, Math.min(prev?.top ?? 0, last))
673  if (at >= 0 && !free) {
674    for (let i = 0; i < 3; i++) {
675      const f = windowFit(top, len, room)
676      // a choice that moves up to the window's first row or above it: the window starts at the row that leads it (its
677      // section's heading, or the list's top for its first row), so those rows can be reached by keys (live check
678      // term-fix10, new quirk 5: ↑ back to home's first row left `↑ 4 more` and hid the Views and Documents headings)
679      if (at < top || (lead !== undefined && lead < top && at === top)) top = lead !== undefined && lead <= at ? lead : at
680      else if (at >= top + f.n) top = at - f.n + 1
681      else break
682      top = Math.max(0, Math.min(top, last))
683    }
684  }
685  windowTops.set(key, { top, at, len, room, free })
686  const f = windowFit(top, len, room)
687  const page = Math.max(1, f.n - 1)
688  const move = (to: number) => async () => {
689    const cur = windowTops.get(key)
690    if (cur) windowTops.set(key, { ...cur, top: Math.max(0, Math.min(to, last)), free: true })
691    await bump()
692  }
693  const out: Line[] = []
694  const outHits: LineHit[] = []
695  if (f.up) {
696    const words = `↑ ${num(top)} more`
697    outHits.push({ y: 0, x0: MARGIN_W + 2, x1: MARGIN_W + 2 + width(words), row: false, run: move(top - page) })
698    out.push(pointed([{ s: '  ' }, dim(words)], false))
699  }
700  out.push(...lines.slice(top, top + f.n))
701  for (const h of hits) if (h.y >= top && h.y < top + f.n) outHits.push({ ...h, y: h.y - top + f.up })
702  if (f.down) {
703    const words = `↓ ${num(len - top - f.n)} more`
704    outHits.push({ y: out.length, x0: MARGIN_W + 2, x1: MARGIN_W + 2 + width(words), row: false, run: move(top + page) })
705    out.push(pointed([{ s: '  ' }, dim(words)], false))
706  }
707  return { lines: out, hits: outHits }
708}
709
710/** The wheel over the panel while it shows a cut list: the list's rows move by `by`, the choice where it is; false when
711 *  the panel shows no cut list. */
712export function wheelWindow(by: number): boolean {
713  if (scrollShown && by) {
714    scrollShown(by)
715    return true
716  }
717  const cur = shownWindow ? windowTops.get(shownWindow) : undefined
718  if (!cur || !by) return false
719  const top = Math.max(0, Math.min(cur.top + by, cur.len - cur.room + 1))
720  windowTops.set(shownWindow, { ...cur, top, free: true })
721  return true
722}
723
724function listKeysEl(cx: Ctx, e: PaneEvent): RenderElement | null {
725  if (!listRelay || e.surface === 'mobile' || rt.typeThrough) return null
726  const { Box, Button, Input } = cx.els(e)
727  const key = (k: string) => () => void relayKey(k)
728  if (relayFlip) {
729    relayMark = 1 - relayMark
730    relayFlip = false
731  }
732  return (
733    <Box key="list-keys" width={0} height={0} flexShrink={0} overflow="hidden" flexDirection="row">
734      <Button key={RELAY.up} label="↑" plain onPress={key('up')} />
735      {viewField || panelField ? (
736        <Input key={RELAY.pick} value={(viewField ?? panelField)!.text} autoFocus onInput={v => {
737          const f = viewField ?? panelField
738          if (!f) return
739          f.text = v
740          void f.send(v)
741        }} onSubmit={() => void (viewField ?? panelField)?.enter()} />
742      ) : (
743        <Input key={RELAY.pick} value={MARKS[relayMark]} autoFocus onInput={v => void relayInput(cx, v)} onSubmit={() => void relaySubmit(cx)} />
744      )}
745      <Button key={RELAY.down} label="↓" plain onPress={key('down')} />
746    </Box>
747  )
748}
749
750/** Whether the panel's focus ring rests on its list's keys (the relay), as the last `ui.focus` put it. */
751function listKeysHeld(): boolean {
752  return paneFocused && !rt.typeThrough && RELAY_KEYS.includes(rt.panelFocus)
753}
754
755// a list's keys, which the relay passes on while the ring rests on it: ↑↓, Enter, Space and Backspace; ← and → reach no
756// element of a pane, nor do the page keys while the relay's Input holds the ring
757const LIST_HINT = /^(?:↑↓|←|→|Enter |Space |Backspace |PgUp|PgDn)/
758const UNRELAYED_HINT = /^(?:←|→|PgUp|PgDn)/
759
760// the text fields the drawing being made draws, by their keys (rt.fields once it is shown)
761let fieldsDrawing = new Set<string>()
762
763/** A text field's key, noted as drawn by the drawing being made: its ring's hint is named only while it is drawn. */
764function fieldKey(key: string): string {
765  fieldsDrawing.add(key)
766  return key
767}
768
769// the panel's text fields by their keys, with what Enter does in each
770const FIELD_KEYS: [RegExp, string][] = [
771  [/^(?:ask-new|ask-|follow-)/, 'Enter to ask'],
772  [/^(?:lb-glob-|lb-values-)/, 'Enter to save'],
773  [/^lbs-describe$/, 'Enter to make it'],
774  [/^lb-name-/, 'Enter to rename'],
775]
776
777/** What Enter does in the text field that holds the panel's focus ring now; '' when no field holds it. */
778export function focusedField(key: string): string {
779  return FIELD_KEYS.find(([re]) => re.test(key))?.[1] ?? ''
780}
781
782/** A view's key hints with the way's after them, as bound now (rule 26: the hint row names only keys bound on it).
783 *  While a text field holds the focus a letter goes into the field, so the hints are what Enter does there and that
784 *  Esc leaves it. The list's Space and Backspace are bound as the view names them. */
785function endHints(hints: readonly string[], autoFocus = ''): string[] {
786  listExtraDrawing = { space: hints.some(h => h.startsWith('Space ')), backspace: hints.some(h => h.startsWith('Backspace ')) }
787  // while the prompt holds the keys (Esc left a field, or a click on the prompt), a letter or Enter goes to the prompt,
788  // and Enter would send it to main: no key of the panel's is named, only how to give the panel the keys; so too while
789  // the panel's typing goes to the prompt (typeThrough)
790  if (!paneFocused || rt.typeThrough) return [UNFOCUSED_HINT]
791  // `autoFocus`: the view's field that takes the ring as it opens, before any ui.focus says where the ring is; a ring
792  // on a field this drawing does not draw (one of the view before, live check term-fix9, quirk 1) is on none
793  const field = rt.panelFocus ? (fieldsDrawing.has(rt.panelFocus) ? focusedField(rt.panelFocus) : '') : focusedField(autoFocus)
794  if (field) return [field, 'Esc to leave the field']
795  // a list's keys only while the ring rests on them (listKeysHeld): until a ui.focus says so, ↑↓ walk the panel's
796  // buttons and Enter presses the one the ring is on
797  const held = listKeysHeld()
798  const own = hints
799    .map(h => (h === 'Enter or a to ask' && !held ? 'a to ask' : h))
800    .filter(h => h !== 'b to go back' && h !== 'x to close' && !(held ? UNRELAYED_HINT : LIST_HINT).test(h))
801  return [...own, ...wayHints]
802}
803
804/** The hint row while the panel does not hold the keys. */
805export const UNFOCUSED_HINT = 'click the panel for its keys'
806
807export function hintsRow(els: El, hints: readonly string[], cols: number, autoFocus = ''): RenderElement {
808  return hintsEl(els, endHints(hints, autoFocus), cols)
809}
810
811/** The rows a view's key hints take as the panel draws them now (hintLines): what a list cut to the pane leaves them. */
812export function hintHeight(hints: readonly string[], cols: number, autoFocus = ''): number {
813  return hintLines(endHints(hints, autoFocus), cols).length
814}
815
816/** The panel's bottom part (rule 25): the second rule, the actions 2 cells apart, the fields under them; then the
817 *  key-hint row, the panel's last. */
818export function bottomRows(cx: Ctx, e: PaneEvent, cols: number, controls: (RenderElement | null | false)[], fields: (RenderElement | null | false)[], hints: string[], rule = true, autoFocus = ''): RenderElement[] {
819  const els = cx.els(e) as El
820  const ctl = controlsEl(els, controls, 'bottom-controls')
821  const fs = fields.filter((f): f is RenderElement => Boolean(f))
822  return [...(rule && (ctl || fs.length) ? [ruleEl(els, cols, 'rule-bottom')] : []), ...(ctl ? [ctl] : []), ...fs, hintsRow(els, hints, cols, autoFocus)]
823}
824
825/** An empty region (rule 28): dim words at A2. */
826export function none(cx: Ctx, e: PaneEvent, words = 'none'): RenderElement {
827  const { Box, Text } = cx.els(e)
828  return (
829    <Box flexDirection="column" marginLeft={2}>
830      <Text dimColor>{words}</Text>
831    </Box>
832  )
833}
834
835/** A field of the panel (rule 27): its label dim and lower case, the field after a gutter. */
836function fieldRow(cx: Ctx, e: PaneEvent, label: string, field: RenderElement, key: string): RenderElement {
837  const { Box, Text } = cx.els(e)
838  return (
839    <Box key={key} flexDirection="row">
840      <Text dimColor>{`${label}  `}</Text>
841      <Box flexGrow={1} flexShrink={1}>
842        {field}
843      </Box>
844    </Box>
845  )
846}
847
848// ------------------------------------------------------------------------------------------------ home
849
850/** What the home panel lists, from what `thimble state` printed for its surfaces. */
851export async function homeData(cx: Ctx): Promise<HomeData> {
852  const homeRaw = await surfaceValue<Obj>(cx, 'home-full')
853  const views: HomeView[] = await homeViews(cx)
854  const docs = await surfaceValue(cx, 'docs')
855  const first = docs?.ok ? !rt.docsRead : false
856  if (docs?.ok) rt.docsRead = true
857  const reports: HomeReport[] = docs?.ok
858    ? docsOf(docs.value).map(d => {
859        // a document written since the session first read the list, and not opened since, is new
860        const known = rt.docsKnown.get(d.slug)
861        if (first) rt.docsKnown.set(d.slug, d.generation)
862        return { slug: d.slug, title: d.title, form: d.renderer, state: d.status === 'generating' ? 'writing' : 'written', cards: 0, tools: 0, at: Date.parse(d.at) || 0, ...(!first && known !== d.generation && d.status !== 'generating' ? { fresh: true } : {}) }
863      })
864    : []
865  const threads: HomeThread[] = []
866  for (const t of (await cx.threads()) ?? []) {
867    const st = t.running ? 'run' : rt.failed.has(t.id) ? 'problem' : t.answers ? 'ok' : 'dim'
868    // its subject at R only when a card or a citation names it (a few words): a passage's sentence would cut the
869    // question beside it
870    const about = await anchorName(cx, t)
871    const made = Date.parse(t.created ?? '') || 0
872    threads.push({ id: t.id, title: quoted(clip(plainCites(t.question || t.title || t.anchorText || 'side thread'), 80)), about: about ? `about ${clip(about, 32)}` : '', words: t.running ? 'answering' : plural(t.answers, 'answer'), tone: st, unread: t.unread, earlier: Boolean(made && rt.startedAt && made < rt.startedAt), at: Date.parse(t.at) || 0 })
873  }
874  const canvas = await surfaceValue<Obj>(cx, 'canvas')
875  const groups = canvas?.ok && Array.isArray(canvas.value.groups) ? (canvas.value.groups as unknown[]).filter(isObj) : []
876  const cells = canvas?.ok && Array.isArray(canvas.value.cells) ? (canvas.value.cells as unknown[]).filter(isObj) : []
877  // a card made since home was last seen is new on it (openHome)
878  const cardOf = (c: Obj) => ({ id: str(c.id), kind: str(c.kind) || 'code', question: str(c.title) || 'a card', ...(rt.homeSince && madeAt(c) > rt.homeSince ? { fresh: true } : {}) })
879  // a group by what it holds: a side thread's cards, a document's figures, or a group main named; a card no listed
880  // group holds under `other cards`, last
881  // a side thread's group by the thread's first question, as everywhere a thread is named (its title is a slug)
882  const rowsNow = (await cx.threads()) ?? []
883  const threadHead = (chat: string): string => {
884    const r = rowsNow.find(t => t.id === chat)
885    const q = r ? plainCites(r.question || r.title || '') : ''
886    return q.trim() ? quoted(clip(q, 60)) : ''
887  }
888  const cardGroups: HomeCardGroup[] = groupCards(
889    groups.map(g => {
890      const from: HomeCardGroup['from'] = g.anchor || g.chat ? 'thread' : g.role === 'figures' ? 'report' : 'other'
891      const head = (g.chat ? threadHead(str(g.chat)) : '') || str(g.title) || 'cards'
892      return { head, from, cards: cells.filter(c => c.notebook === g.id).map(cardOf), at: Date.parse(str(g.ts)) || 0 }
893    }),
894    cells.map(cardOf),
895  )
896  const labelsRaw = await surfaceValue(cx, 'labels')
897  // a run the session's own process holds shows only as the chat that follows it (lib.ts labelRunning)
898  const agents = (await cx.agents()) ?? []
899  const labels: HomeLabel[] = labelsRaw?.ok
900    ? labelsOf(labelsRaw.value).map(l => {
901        // its runs as the labels list, its panel and its card say them (lib.ts labelState)
902        const st = labelState(l, agents)
903        // made since home was last seen, as a card is new (openHome)
904        const made = Date.parse(str((l as { ts?: unknown }).ts)) || 0
905        const colors = classColors(l.classes)
906        return {
907          slug: l.id,
908          name: l.name ?? l.id,
909          kind: l.kind ?? '',
910          trial: Boolean(l.trial),
911          // as thimble.labels() reads the rows: a record the analyst set to another value under that value
912          counts: l.verdicts?.counts ?? l.label_stats?.counts ?? {},
913          values: l.labels ?? Object.keys(l.label_stats?.counts ?? {}),
914          paths: (l.glob ?? '').split(/,\s*/).filter(Boolean),
915          running: st.running,
916          ran: st.ran,
917          state: labelStateWords(st),
918          ...(rt.homeSince && made > rt.homeSince ? { fresh: true } : {}),
919          // its values' colors as its classes have them, as its panel draws them
920          ...(colors ? { colors } : {}),
921        }
922      })
923    : []
924  const files: HomeFile[] = filesOf(await surfaceValue(cx, 'files')).map(f => ({ file: f.path, records: null, size: f.size, seen: 0, state: 'listed', ranges: [], kind: fileType(f.path, f.kind, rt.opens.get(f.path)?.as) }))
925  const root = (await cx.root().catch(() => '')).split('/').filter(Boolean).at(-1) ?? 'folder'
926  return { views, reports, threads, cardGroups, labels, files, coverage: homeRaw?.ok ? str(homeRaw.value.coverage) : '', root }
927}
928
929/** When a card was made (ms), as the canvas lists it. */
930function madeAt(c: Obj): number {
931  return Date.parse(str(c.created_ts) || str(c.ts)) || 0
932}
933
934/** Home opened from the row above the prompt (its `open ›`): the group that holds the first new card unfolded (its
935 *  section shown whole when the group is past the first few), the choice on that card; with no new card, on the first
936 *  new document, view or label (live check term-fix8, quirk 6: the new card stood folded, the choice on an old row). */
937export async function openHomeNew(cx: Ctx): Promise<void> {
938  const seen = await cx.homeSeen()
939  const since = seen?.at ?? 0
940  await Promise.all([readSurface(cx, 'canvas', 'cards', ['--since', new Date(0).toISOString()]), readSurface(cx, 'labels', 'labels'), readSurface(cx, 'docs', 'docs')])
941  const canvas = await surfaceValue<Obj>(cx, 'canvas')
942  const cells = canvas?.ok && Array.isArray(canvas.value.cells) ? (canvas.value.cells as unknown[]).filter(isObj) : []
943  const fresh = new Set(cells.filter(c => since && madeAt(c) > since).map(c => str(c.id)))
944  const d = await homeData(cx)
945  let ui = (await cx.homeUi()) as HomeUi
946  const without = (xs: string[], x: string) => xs.filter(y => y !== x)
947  const gi = d.cardGroups.findIndex(g => g.cards.some(c => fresh.has(c.id)))
948  if (gi >= 0) {
949    const g = d.cardGroups[gi]!
950    const fold = `cards:${g.from}:${g.head}`
951    ui = { ...ui, folded: without(ui.folded, fold), unfolded: [...without(ui.unfolded, fold), fold], pick: `card:${g.cards.find(c => fresh.has(c.id))!.id}`, more: gi >= HOME_FIRST && !ui.more.includes('cards') ? [...ui.more, 'cards'] : ui.more }
952  } else {
953    const r = d.reports.find(x => x.fresh)
954    const v = d.views.find(x => x.fresh)
955    const newLabels = seen ? Math.max(0, d.labels.length - seen.labels) : 0
956    const l = newLabels ? d.labels[d.labels.length - newLabels] : undefined
957    const pick = v ? `view:${v.slug}` : r ? `report:${r.slug}` : l ? `label:${l.slug}` : ''
958    if (pick) ui = { ...ui, pick }
959  }
960  await cx.setHomeUi(ui)
961  await openHome(cx)
962}
963
964// the home panel's last layout, which a key steps through
965let homeLast: HomeLayout | null = null
966
967/** The home panel (SPEC.md, "Home"), drawn from home.ts's lines: a click on a row opens it, on a heading its
968 *  section's panel, on a group or folder folds it; ↑↓ choose a row, Enter opens it and Space folds it. */
969async function drawHome(cx: Ctx, e: PaneEvent): Promise<RenderElement> {
970  const { Box, Text } = cx.els(e)
971  if (e.surface !== 'terminal' && e.surface !== 'desktop') return <Text dimColor>The home panel needs the terminal or the desktop app.</Text>
972  // the pane's own width, never more: a pane 40 columns wide cut each row's right part (live check term-fix10)
973  const cols = e.props.bodyColumns
974  const ui = (await cx.homeUi()) as HomeUi
975  // its keys named only while it holds them, as every view's (endHints; live check term-fix6, new quirk 4: home opened
976  // from the toast named its keys while they went to the prompt)
977  const lay = homeLayout(await homeData(cx), ui, cols, endHints(HOME_HINTS))
978  homeLast = lay
979  const run = (a: HomeAct) => async () => {
980    if (a.op === 'open') return homeOpen(cx, a.open)
981    const cur = (await cx.homeUi()) as HomeUi
982    const next = homeReduce(cur, a)
983    // a section shown whole: the choice moves from its `… N more` onto the first row it showed
984    await cx.setHomeUi(a.op === 'more' && a.next && cur.pick === `more:${a.sec}` ? { ...next, pick: a.next } : next)
985  }
986  const allHits: LineHit[] = lay.hits.map(h => ({
987    y: h.y,
988    x0: h.x0,
989    x1: h.x1,
990    row: h.row,
991    run: async () => {
992      if (h.pick) await cx.setHomeUi({ ...((await cx.homeUi()) as HomeUi), pick: h.pick })
993      await run(h.act)()
994    },
995  }))
996  // the sections cut to the pane's rows around the chosen row, the rule and the hint rows kept (windowList): the title
997  // row, the rule and the hint rows take the rest
998  const pick = ui.pick || lay.picks[0]?.key || ''
999  const pickY = lay.hits.find(h => h.pick === pick)?.y ?? -1
1000  const last = lay.lines.length - lay.hintRows
1001  // the rows above the sections: the rule (the title is the title row's)
1002  const top = 1
1003  // the row that leads the chosen one: the top for the first row the keys choose, else its section's heading
1004  const lead = pick === lay.picks[0]?.key ? top : Math.max(top, ...lay.heads.filter(y => y <= pickY))
1005  const win = windowList('home', lay.lines.slice(top, last), allHits.filter(h => h.y >= top && h.y < last).map(h => ({ ...h, y: h.y - top })), pickY - top, bodyRows - 1 - top - lay.hintRows, () => cx.bumpPanel(), lead - top)
1006  const lines = [...lay.lines.slice(0, top), ...win.lines, ...lay.lines.slice(last)]
1007  const hits: LineHit[] = [...allHits.filter(h => h.y < top), ...win.hits.map(h => ({ ...h, y: h.y + top }))]
1008  const onKey = async (k: string) => {
1009    const cur = (await cx.homeUi()) as HomeUi
1010    const l = homeLast
1011    if (!l) return
1012    const pick = cur.pick || l.picks[0]?.key || ''
1013    const at = l.picks.find(x => x.key === pick)
1014    if (k === 'return' || k === 'enter') return at ? run(at.act)() : undefined
1015    if (k === 'space' || k === ' ') return at?.act.op === 'fold' || at?.act.op === 'more' ? run(at.act)() : undefined
1016    const next = homePick(l, cur, k)
1017    if (next !== cur.pick) await cx.setHomeUi({ ...cur, pick: next })
1018  }
1019  return <Box flexDirection="column">{linesEl(cx, e, marginKey('home'), lines, hits, cols + MARGIN_W, onKey)}</Box>
1020}
1021
1022async function homeOpen(cx: Ctx, o: HomeOpen): Promise<void> {
1023  switch (o.kind) {
1024    case 'view': {
1025      const v = (await homeViews(cx)).find(x => x.slug === o.slug)
1026      return openView(cx, o.slug, v?.name ?? o.slug)
1027    }
1028    case 'report':
1029      return openDoc(cx, o.slug, o.title ?? o.slug)
1030    case 'thread':
1031      return openThread(cx, o.id)
1032    case 'card':
1033      return openCard(cx, o.id)
1034    case 'label': {
1035      const got = await surfaceValue(cx, 'labels')
1036      const hit = got?.ok ? labelsOf(got.value).find(l => l.name === o.name || l.id === o.name) : undefined
1037      return openLabel(cx, hit?.id ?? o.name, o.name)
1038    }
1039    case 'file':
1040      return openFile(cx, o.path)
1041    case 'pane': {
1042      // a folder's `… N more`: the file browser with that folder whole
1043      if (o.view === 'coverage' && o.folder) {
1044        const root = `${(await cx.root().catch(() => '')).split('/').filter(Boolean).at(-1) ?? 'folder'}/`
1045        // home names a folder as the file browser does: its path, the corpus's own files by the corpus folder's name
1046        const dir = o.folder === root ? '' : o.folder
1047        const ui = await cx.filesUi()
1048        await cx.setFilesUi({ ...ui, whole: [...(ui.whole ?? []).filter(x => x !== dir), dir], unfolded: [...ui.unfolded.filter(x => x !== `dir:${dir}`), `dir:${dir}`], folded: ui.folded.filter(x => x !== `dir:${dir}`) })
1049      }
1050      const to: Record<string, TermPanel> = {
1051        reports: { view: 'docs', title: 'Documents' },
1052        threads: { view: 'threads', title: 'Threads' },
1053        labels: { view: 'labels', title: 'Labels' },
1054        coverage: { view: 'files', title: 'Files' },
1055        views: { view: 'views', title: 'Views' },
1056      }
1057      return openPanel(cx, to[o.view] ?? { view: 'home', title: 'Home' })
1058    }
1059  }
1060}
1061
1062// ------------------------------------------------------------------------------------------------ a card
1063
1064/** A card in the panel (SPEC.md, "Cards", the card pane): its question is the panel's title; its kind, who made
1065 *  it, how its last run ended (`last run failed` in red) and a card check that ended in an error (CHECK_UNRUN or
1066 *  CHECK_UNFINISHED) the dim subtitle; the card in its border, its takeaway
1067 *  under it; at the bottom `code  run again  ask about it` (`◌ running` while it runs). Its code (`mode: code`)
1068 *  through the `Code` element with its gutter at A0, then `output`, the last 8 lines its run printed; `card  run again
1069 *  ask about it` at the bottom. A run again goes to main, whose Bash runs a card (`thimble-run card`). */
1070async function drawCard(cx: Ctx, e: PaneEvent, p: TermPanel): Promise<RenderElement> {
1071  const els = cx.els(e) as El
1072  const { Box, Text, Button } = cx.els(e)
1073  const id = p.card ?? ''
1074  const tc = await cx.card(id)
1075  const cols = Math.max(30, e.props.bodyColumns)
1076  if (!tc) return none(cx, e, '◌ reading the card')
1077  if (!tc.data) return <Box flexDirection="column"><Text color={COLORS.problem} wrap="wrap">{`× this card cannot be drawn: ${tc.error || 'the card is not in this workspace'}`}</Text></Box>
1078  const data = tc.data as CardData
1079  const by = tc.by ? `made by ${/^(main|terminal|user)$/.test(tc.by) ? 'main' : /^chat:/.test(tc.by) ? 'a side thread' : tc.by}` : ''
1080  const ran: Seg | null = tc.ran === 'error' ? { s: 'last run failed', fg: COLORS.problem } : tc.ran === 'ok' ? dim('last run ok') : null
1081  // a card check that ended in an error says so in plain words, dim: the card itself is fine, and red is only for a
1082  // problem with the card (SPEC.md, Checks 3); why it ended is in the check's record, for main to read
1083  const checked: Seg | null = tc.check?.state === 'error' ? dim(tc.check.unrun ? CHECK_UNRUN : CHECK_UNFINISHED) : null
1084  const ask = () => void openAsk(cx, { kind: 'card', ref: `card:${id}`, cardId: id, text: data.question })
1085  const busy = Boolean(tc.busy)
1086  const again = () => {
1087    if (busy) return
1088    void cx.submit(`Run this card again with Bash: thimble-run card ${id}`)
1089  }
1090  const code = p.mode === 'code'
1091  const body: RenderElement[] = [...headerEls(els, { title: data.question, cols, sub: subLine([code ? 'its code' : tc.kind, by, ran, checked]) })]
1092  const runEl = busy ? <Text key="card-running">◌ running</Text> : tc.code ? <Button key="card-again" label="run again" plain onPress={again} /> : null
1093  const keys: Key[] = [{ key: 'ask', hotkey: 'a', onPress: ask }, ...(tc.code && !busy ? [{ key: 'again', hotkey: 'r', onPress: again }] : [])]
1094  const runHint = tc.code && !busy ? ['r to run again'] : []
1095  if (code) {
1096    body.push(tc.code ? codeRows(cx, e, tc.code, 400) : none(cx, e, 'no code'))
1097    // what its last run printed: its last 8 lines
1098    const out = (tc.printed ?? '').split('\n').filter(l => l.trim()).slice(-8)
1099    body.push(
1100      fieldRow(
1101        cx,
1102        e,
1103        'output',
1104        out.length ? (
1105          <Box flexDirection="column">
1106            {out.map((l, i) => (
1107              <Text key={`out-${i}`} wrap="truncate-end">
1108                {demojibake(l)}
1109              </Text>
1110            ))}
1111          </Box>
1112        ) : (
1113          <Text dimColor>nothing printed</Text>
1114        ),
1115        'card-output',
1116      ),
1117    )
1118    keys.push({ key: 'card', hotkey: 'c', onPress: () => void openCard(cx, id) })
1119    body.push(...bottomRows(cx, e, cols, [<Button key="card-card" label="card" plain onPress={() => void openCard(cx, id)} />, runEl, <Button key="card-ask" label="ask about it" plain onPress={ask} />], [], ['c for the card', ...runHint, 'a to ask', 'b to go back', 'x to close']))
1120  } else {
1121    body.push(<Box key="card-box" flexDirection="column">{await cardBlock(cx, e, id, cols, 'pane', { pane: true })}</Box>)
1122    if (tc.code) keys.push({ key: 'code', hotkey: 'c', onPress: () => void openCard(cx, id, 'code') })
1123    body.push(...bottomRows(cx, e, cols, [tc.code ? <Button key="card-code" label="code" plain onPress={() => void openCard(cx, id, 'code')} /> : null, runEl, <Button key="card-ask" label="ask about it" plain onPress={ask} />], [], [...(tc.code ? ['c for its code'] : []), ...runHint, 'a to ask', 'b to go back', 'x to close']))
1124  }
1125  const hk = hiddenKeys(cx, e, keys)
1126  return <Box flexDirection="column">{[...(hk ? [hk] : []), ...body]}</Box>
1127}
1128
1129// ------------------------------------------------------------------------------------------------ a citation
1130
1131/** Where position `p` of a line lands once each tab is drawn as two spaces and its mojibake is mended. */
1132function shifted(raw: string, p: number): number {
1133  return demojibake(raw.slice(0, p)).replace(/\t/g, '  ').length
1134}
1135
1136/** Whether a citation's cited lines are lit whole: none of them holds the value it shows (a place cited without words,
1137 *  `[[README.md#L5]]`, or a value the line does not hold), and no example's quote marks a passage, so the cited part is
1138 *  the lines themselves (Matt, 2026-10-07: "the part it cited isn't highlighted"). */
1139function wholeLit(hits: readonly TermVerdict['lines'][number][], quote: string): boolean {
1140  return !quote && !hits.some(l => l.spans?.length)
1141}
1142
1143/** The cited lines nested at A2 (SPEC.md, section 7, "The citation panel"): each line's number right-aligned in
1144 *  a dim column (the cited one's in the text colour), its text after a gutter; a cited line wrapped over 3 to 8 rows
1145 *  (by the pane's rows) around the value or the quoted passage on the selection background; two lines of context
1146 *  around the cited ones when a cited line wraps; a long context line cut with `…` around its value. */
1147function lineRows(cx: Ctx, e: PaneEvent, v: TermVerdict, cols: number, quote: string): RenderElement[] {
1148  const { Text } = cx.els(e)
1149  // at most 60 rows, never ending on part of a record: a record's rows after its first have no number
1150  let all = v.lines.slice(0, 60)
1151  if (v.lines.length > all.length && v.lines[all.length]!.n === 0) {
1152    let end = all.length
1153    while (end > 0 && all[end - 1]!.n === 0) end--
1154    if (all.slice(0, end - 1).some(l => l.hit)) all = all.slice(0, end - 1)
1155  }
1156  const gutter = Math.max(1, ...all.map(l => String(l.n || '').length))
1157  const room = Math.max(10, cols - gutter - 4)
1158  const hits = all.filter(l => l.hit)
1159  const hitRows = Math.max(3, Math.min(8, Math.floor(((e.props.scroll?.bodyRows || 20) - 14) / Math.max(1, hits.length))))
1160  const wraps = hits.some(l => l.text.length > room)
1161  const near = wraps ? 2 : 99
1162  const firstHit = all.findIndex(l => l.hit)
1163  const lastHit = all.length - 1 - [...all].reverse().findIndex(l => l.hit)
1164  const lines = firstHit < 0 ? all : all.filter((l, i) => l.hit || (i >= firstHit - near && i <= lastHit + near))
1165  const whole = wholeLit(hits, quote)
1166  const out: RenderElement[] = []
1167  for (const l of lines) {
1168    const text = demojibake(l.text).replace(/\t/g, '  ')
1169    const n = l.n ? String(l.n).padStart(gutter) : ' '.repeat(gutter)
1170    const spans = (l.spans ?? []).map(([a, b]) => [shifted(l.text, a!), shifted(l.text, b!)] as [number, number])
1171    if (!l.hit) {
1172      // a context line: cut around its value when it is wider than the room
1173      let t = text
1174      let sp = spans
1175      if (t.length > room) {
1176        // each end cut at a word, `…` right against the words: no space between them
1177        const w = windowAt(t, sp.length ? sp[0]![0] : 0, room)
1178        t = w.text
1179        sp = sp.map(([a, b]) => [a - w.shift, b - w.shift] as [number, number])
1180      }
1181      out.push(
1182        <Text wrap="truncate-end">
1183          <Text dimColor>{`  ${n}  `}</Text>
1184          <Text dimColor>{t || ' '}</Text>
1185        </Text>,
1186      )
1187      continue
1188    }
1189    const span: [number, number] | null = quote ? quoteSpan(text, quote) : (spans[0] ?? (whole ? [0, text.length] : null))
1190    wrapAround(text, span, room, hitRows).forEach((r, i) =>
1191      out.push(
1192        <Text wrap="truncate-end">
1193          <Text>{`  ${i === 0 ? n : ' '.repeat(gutter)}  `}</Text>
1194          <Text>{r.hi ? r.text.slice(0, r.hi[0]) : r.text || ' '}</Text>
1195          {r.hi ? <Text backgroundColor={COLORS.selected}>{r.text.slice(r.hi[0], r.hi[1])}</Text> : null}
1196          {r.hi ? <Text>{r.text.slice(r.hi[1])}</Text> : null}
1197        </Text>,
1198      ),
1199    )
1200  }