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…

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.
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.
[[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 ×.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.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.↳ 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).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.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).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.? 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.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:
| Call | Read 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-reopen | a 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.
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.
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).
hooks/register.tsx 1141 lines1// 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}
1141hooks/ctx.ts 104 lines1// 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 }
104hooks/data.ts 161 lines1// 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}
161hooks/gestures.tsx 159 lines1// 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}
159hooks/cite.ts 842 lines1// 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}
842hooks/draw.ts 2069 lines1// 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 lines1// 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 lines1// 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}
561hooks/home.ts 641 lines1// 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
641hooks/kept.ts 150 lines1// 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}
150hooks/lines.tsx 88 lines1// 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
88hooks/panel.tsx 3262 lines1// 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 }