@inerrata-corporation/errata 2.0.21 → 2.0.22-dev.4049

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,44 +1,41 @@
1
1
  import { atom, read, update } from 'claude-code'
2
2
  import type { EngineInterface, Register, Timer } from 'claude-code'
3
3
 
4
- import type { Burst, MapGraph, Verdict } from '../types'
4
+ import type { Burst, ChainFacts, MapGraph, Verdict } from '../types'
5
+ import { BINDING_GLYPH, BINDING_NAME, bindingOf, fitTitle, focusBook, librarySpots, PALETTE, rasterizeLibrary } from './bookcase'
6
+ import type { Binding, LibraryFrame, LibraryOptions, Room } from './bookcase'
7
+ import { bookHolding, callNumber, catalogue, enrichTargets, parseNeighbors } from './chains'
8
+ import type { Catalog } from './chains'
9
+ import { colorOf, crossEdges, DARK_BG, glyphOf, layoutRadial, LIGHT_BG, rasterizeGraph, SCOPE_COLOR, SCOPE_ORDER, VERDICT_COLOR } from './graph'
10
+ import type { RasterOptions } from './graph'
5
11
  import {
6
- colorOf,
7
- DARK_BG,
8
- FULL,
9
- LIGHT_BG,
10
12
  displayName,
11
- glyphOf,
12
- hex,
13
13
  JUDGMENT_STYLE,
14
14
  layoutBurst,
15
- parseJudgments,
16
- resolveJudgmentRef,
17
- neighborIds,
18
15
  parseAgentBurst,
19
16
  parseAgentSearch,
20
17
  parseFileRecall,
18
+ parseJudgments,
21
19
  parsePromptPrime,
22
- rasterize,
23
- SCOPE_COLOR,
24
- SCOPE_ORDER,
25
- seedNode,
26
20
  relPath,
21
+ resolveJudgmentRef,
22
+ seedNode,
27
23
  shortName,
28
24
  statusLine,
29
25
  textsOf,
30
26
  } from './minimap'
31
- import type { Frame, HandleTable, JudgmentKind, RasterOptions } from './minimap'
27
+ import type { HandleTable, JudgmentKind } from './minimap'
28
+ import { hasTags, TAG_MODES, tidyTags } from './tags'
29
+ import type { TagMode } from './tags'
32
30
 
33
31
  const PANE = 'errata-minimap'
34
32
  const ERRATA = 'errata'
35
- const ENRICH_MAX = 8
36
- const TOP_ITEMS = 3
37
33
 
38
34
  const focus = atom({ plugin: 'errata-minimap', key: 'focus' } as const, '')
39
35
  const burst = atom({ plugin: 'errata-minimap', key: 'burst' } as const, null as Burst | null)
40
- const graph = atom({ plugin: 'errata-minimap', key: 'graph' } as const, { title: '', nodes: [], edges: [], at: 0 } as MapGraph)
36
+ const graph = atom({ plugin: 'errata-minimap', key: 'graph' } as const, { title: '', nodes: [], at: 0 } as MapGraph)
41
37
  const verdicts = atom({ plugin: 'errata-minimap', key: 'verdicts' } as const, [] as Verdict[])
38
+ const facts = atom({ plugin: 'errata-minimap', key: 'facts' } as const, {} as Record<string, ChainFacts | null>)
42
39
 
43
40
  const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
44
41
  const BURST_FILE = /\.errata\/g\/burst\/([^/]+)/
@@ -46,67 +43,138 @@ const BURST_FILE = /\.errata\/g\/burst\/([^/]+)/
46
43
  const ORIGIN_LABEL = { agent: 'agent burst', 'daemon-file': 'file recall', 'daemon-prompt': 'prompt prime' } as const
47
44
 
48
45
  // Module-level on purpose: a reload starts by redrawing whatever burst the session holds.
49
- const pending = { isRefreshing: false, drawnAt: -1 }
46
+ const pending = { isRefreshing: false, drawnAt: -1, cataloguedAt: -1, askedFor: -1, asked: 0 }
47
+
48
+ /**
49
+ * `errata_neighbors` calls per burst: each completes and places one chain (its
50
+ * cause/fix beyond the burst, package, local community, patterns, anchor). Run
51
+ * after the first draw, a few per tick, so the shelves fill in without ever
52
+ * holding the first picture back.
53
+ */
54
+ const ENRICH_PER_BURST = 10
55
+ const ENRICH_PER_TICK = 4
56
+ /** Facts kept across bursts (the same chains recur); past this the cache keeps only the current burst's. */
57
+ const FACTS_KEPT = 300
50
58
 
51
59
  /**
52
60
  * Whether the pane is drawn decides the UI. Drawn — docked beside a fullscreen
53
- * transcript, or inline above the prompt — it is the full map; waiting undrawn
54
- * on a narrow terminal, behind another tab or closed by hand, the map collapses
55
- * into the status line. The engine does the seating: an unasked pane waits
56
- * below 144 columns and takes its place once the terminal widens. The pane is
57
- * never closed here — closing an inline one dropped that request, and outside
58
- * fullscreen every seat is inline, so the map stayed collapsed for the session.
61
+ * transcript, or inline above the prompt — it is the full library; waiting
62
+ * undrawn on a narrow terminal, behind another tab or closed by hand, it
63
+ * collapses into the status line. The engine does the seating: an unasked pane
64
+ * waits below 144 columns and takes its place once the terminal widens. The
65
+ * pane is never closed here — closing an inline one dropped that request, and
66
+ * outside fullscreen every seat is inline, so the map stayed collapsed for the
67
+ * session.
59
68
  */
60
69
  const view = { isShown: false, status: undefined as string | undefined }
61
70
 
62
- /** Rows an inline pane asks for: room for the map without burying the transcript. */
71
+ /** How the chat draws inline tags; kept in the plugin's store between sessions. */
72
+ const tags = { mode: 'margin' as TagMode }
73
+ const TAG_MODE_KEY = 'tagMode'
74
+
75
+ /**
76
+ * What the pane draws: the library (default) or the original radial graph.
77
+ * Same burst, same enrichment — the graph's cross edges come from the facts
78
+ * the library fetched. Kept in the plugin's store between sessions.
79
+ */
80
+ type PaneView = 'books' | 'graph'
81
+ const PANE_VIEWS: readonly PaneView[] = ['books', 'graph']
82
+ const pane = { view: 'books' as PaneView }
83
+ const PANE_VIEW_KEY = 'paneView'
84
+
85
+ /** Rows an inline pane asks for: room for a tier of bookcases and the card without burying the transcript. */
63
86
  const INLINE_ROWS = 20
87
+ /** Rows on a portrait terminal: the header, one tier of shelves, a short card. */
88
+ const COMPACT_ROWS = 11
89
+ /** The terminal's own colour in a raster cell (bit 24 alone). */
90
+ const DEFAULT_CELL = 0x01000000
64
91
 
65
- const GROW_MS = 1200
66
- const TYPE_MS = 600
92
+ const GROW_MS = 900
93
+ const TYPE_MS = 500
67
94
  const FRAME_MS = 33
68
95
  const HOLD_MS = 700
69
96
  const PULSE_MS = 180
70
97
 
71
98
  /**
72
- * The reveal and the highlight run on blits, not render passes: a new map's
73
- * routes grow out ring by ring, then its names type out; a prior the agent
74
- * bursts off pulses (its route to the seed lit) until the fresh results replace
75
- * the map. Only while docked — the status line has nothing to animate.
99
+ * The reveal and the highlight run on blits, not render passes: a new burst's
100
+ * books rise onto their shelves, then the catalog card inks in; a book the
101
+ * agent bursts off flashes gilt until the fresh results replace the shelves; a
102
+ * verdict turns the card to its book and flashes its stamp. Only while drawn —
103
+ * the status line has nothing to animate.
76
104
  */
77
105
  const MARK_MS = 4000
78
- /** How long a verdict stays in the footer and on the status line. */
106
+ /** How long a verdict stays stamped on its book's plank, and on the status line. */
79
107
  const VERDICT_SHOWN_MS = 10 * 60 * 1000
80
108
  const VERDICT_STATUS_MS = 60 * 1000
81
109
  const VERDICTS_KEPT = 6
82
110
 
111
+ const FULL: LibraryFrame = { grow: 1, type: 1 }
112
+
83
113
  const motion = {
84
- /** Priors the agent just judged → their verdict, pulsing until `markUntil`. */
114
+ /** Priors the agent just judged → their verdict, flashing until `markUntil`. */
85
115
  marks: {} as Record<string, JudgmentKind>,
86
116
  markUntil: 0,
87
117
  isMarkOn: true,
88
118
  graphAt: -1,
89
119
  start: 0,
90
- frame: FULL as Frame,
120
+ frame: FULL,
91
121
  highlight: undefined as string | undefined,
92
122
  highlightStart: 0,
93
123
  isPulseOn: true,
94
124
  raster: undefined as { columns: number; rows: number } | undefined,
95
- bg: DARK_BG,
125
+ room: 'stacks' as Room,
96
126
  timer: undefined as Timer | undefined,
127
+ /** The node whose book the card reads: the latest one judged on the shelves; unset = the seed's book. */
128
+ focus: undefined as string | undefined,
129
+ }
130
+
131
+ async function catalogOf($: EngineInterface, map: MapGraph): Promise<Catalog> {
132
+ return catalogue(map, await read($, facts))
133
+ }
134
+
135
+ async function stamps($: EngineInterface): Promise<Record<string, JudgmentKind>> {
136
+ const now = await $.clock.now()
137
+ const out: Record<string, JudgmentKind> = {}
138
+ for (const v of (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).reverse()) out[v.id] = v.kind
139
+ return out
97
140
  }
98
141
 
99
- function rasterOptions(map: MapGraph): RasterOptions {
142
+ async function libraryOptions($: EngineInterface, map: MapGraph): Promise<LibraryOptions> {
100
143
  return {
144
+ room: motion.room,
101
145
  frame: motion.graphAt === map.at ? motion.frame : FULL,
102
- bg: motion.bg,
146
+ focus: motion.focus,
103
147
  highlight: motion.highlight,
104
148
  isPulseOn: motion.isPulseOn,
105
149
  marks: motion.marks,
106
150
  isMarkOn: motion.isMarkOn,
151
+ stamps: await stamps($),
107
152
  }
108
153
  }
109
154
 
155
+ function graphOptions(map: MapGraph): RasterOptions {
156
+ return {
157
+ frame: motion.graphAt === map.at ? motion.frame : FULL,
158
+ bg: motion.room === 'reading' ? LIGHT_BG : DARK_BG,
159
+ highlight: motion.highlight,
160
+ isPulseOn: motion.isPulseOn,
161
+ marks: motion.marks,
162
+ isMarkOn: motion.isMarkOn,
163
+ }
164
+ }
165
+
166
+ /** The pane's cells for the current view. */
167
+ async function paneCells($: EngineInterface, map: MapGraph, columns: number, rows: number): Promise<string> {
168
+ if (pane.view === 'graph') return rasterizeGraph(layoutRadial(map, crossEdges(map, await read($, facts))), columns, rows, graphOptions(map))
169
+ return rasterizeLibrary(await catalogOf($, map), columns, rows, await libraryOptions($, map))
170
+ }
171
+
172
+ /** Whether the current view shows this node (a book holding it, or a node on the graph). */
173
+ async function isOnView($: EngineInterface, map: MapGraph, id: string): Promise<boolean> {
174
+ if (pane.view === 'graph') return map.nodes.some(n => !n.isSeed && n.id === id)
175
+ return !!bookHolding(await catalogOf($, map), id)
176
+ }
177
+
110
178
  async function animate($: EngineInterface): Promise<void> {
111
179
  const now = await $.clock.now()
112
180
  const t = now - motion.start
@@ -119,13 +187,16 @@ async function animate($: EngineInterface): Promise<void> {
119
187
  if (isMarking) motion.isMarkOn = Math.floor((now - (motion.markUntil - MARK_MS)) / PULSE_MS) % 2 === 0
120
188
  else motion.marks = {}
121
189
  const map = await read($, graph)
122
- if (motion.raster && map.nodes.length > 0) {
190
+ if (motion.raster && map.nodes.length > 1) {
123
191
  const { columns, rows } = motion.raster
124
- await $.ui.blit({ requestId: PANE, key: 'map', columns, rows, cells: rasterize(map, columns, rows, rasterOptions(map)) }).catch(() => undefined)
192
+ const cells = await paneCells($, map, columns, rows)
193
+ await $.ui.blit({ requestId: PANE, key: 'map', columns, rows, cells }).catch(() => undefined)
125
194
  }
126
195
  if (!isRevealing && !motion.highlight && !isMarking) {
127
196
  motion.timer?.cancel()
128
197
  motion.timer = undefined
198
+ // At rest again: redraw once so the books' hover and click targets are laid (they stay off while blits run).
199
+ if (pane.view === 'books') $.ui.invalidate('ui.render')
129
200
  }
130
201
  }
131
202
 
@@ -133,10 +204,10 @@ function startMotion($: EngineInterface): void {
133
204
  motion.timer ??= $.clock.every(FRAME_MS, () => void animate($))
134
205
  }
135
206
 
136
- /** Lights the prior the agent is about to burst off, if the map shows it. */
207
+ /** Flashes the book the agent is about to burst off, if the shelves hold it. */
137
208
  async function highlightSeed($: EngineInterface, id: string): Promise<boolean> {
138
209
  const map = await read($, graph)
139
- if (!view.isShown || !map.nodes.some(n => !n.isSeed && n.id === id)) return false
210
+ if (!view.isShown || !(await isOnView($, map, id))) return false
140
211
  motion.highlight = id
141
212
  motion.highlightStart = await $.clock.now()
142
213
  motion.isPulseOn = true
@@ -144,7 +215,7 @@ async function highlightSeed($: EngineInterface, id: string): Promise<boolean> {
144
215
  return true
145
216
  }
146
217
 
147
- /** Holds the highlight at least HOLD_MS, so the target reads before the map moves on. */
218
+ /** Holds the highlight at least HOLD_MS, so the target reads before the shelves move on. */
148
219
  async function holdHighlight($: EngineInterface): Promise<void> {
149
220
  if (!motion.highlight) return
150
221
  const left = HOLD_MS - ((await $.clock.now()) - motion.highlightStart)
@@ -153,23 +224,33 @@ async function holdHighlight($: EngineInterface): Promise<void> {
153
224
 
154
225
  /**
155
226
  * The agent's reply judged some priors (cite / confirm / fix / refute tags):
156
- * record each, and pulse the ones on the map in their verdict's colour. Refs
157
- * that resolve to no node — `[id]` in prose ABOUT the protocol — are ignored.
227
+ * record each, flash the stamp of the ones on the shelves, and turn the card
228
+ * to the first book judged. Refs that resolve to no node — `[id]` in prose
229
+ * ABOUT the protocol — are ignored.
158
230
  */
159
231
  async function recordJudgments($: EngineInterface, text: string): Promise<void> {
160
232
  const judgments = parseJudgments(text)
161
233
  if (judgments.length === 0) return
162
234
  const handles = await primingHandles($)
163
235
  const map = await read($, graph)
236
+ const catalog = await catalogOf($, map)
164
237
  const now = await $.clock.now()
165
238
  const fresh: Verdict[] = []
239
+ let judged: string | undefined
166
240
  for (const j of judgments) {
167
241
  const id = resolveJudgmentRef(j.ref, handles)
168
242
  if (!id) continue
169
- const onMap = map.nodes.find(n => n.id === id)
170
- fresh.push({ id, kind: j.kind, name: onMap ? displayName(onMap.label, onMap.name) : j.ref, at: now })
171
- if (onMap) motion.marks[id] = j.kind
243
+ // A part the graph completed (not in the burst) is on the shelves too: judge it there.
244
+ const book = bookHolding(catalog, id)
245
+ const part = book && [book.cause, book.problem, book.fix].find(p => p?.id === id)
246
+ const node = map.nodes.find(n => n.id === id)
247
+ fresh.push({ id, kind: j.kind, name: part?.name ?? (node ? displayName(node.label, node.name) : j.ref), at: now })
248
+ if (book || (pane.view === 'graph' && (await isOnView($, map, id)))) {
249
+ motion.marks[id] = j.kind
250
+ judged ??= id
251
+ }
172
252
  }
253
+ if (judged) motion.focus = judged
173
254
  if (fresh.length === 0) return
174
255
  await update($, verdicts, list => [...fresh, ...list.filter(v => !fresh.some(f => f.id === v.id))].slice(0, VERDICTS_KEPT))
175
256
  if (Object.keys(motion.marks).length > 0 && view.isShown) {
@@ -179,9 +260,10 @@ async function recordJudgments($: EngineInterface, text: string): Promise<void>
179
260
  }
180
261
  }
181
262
 
182
- async function themeBackground($: EngineInterface): Promise<number> {
263
+ /** A light terminal theme reads in the reading room; a dark one in the stacks. */
264
+ async function themeRoom($: EngineInterface): Promise<Room> {
183
265
  const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
184
- return String(row?.value ?? '').includes('light') ? LIGHT_BG : DARK_BG
266
+ return String(row?.value ?? '').includes('light') ? 'reading' : 'stacks'
185
267
  }
186
268
 
187
269
  async function tick($: EngineInterface): Promise<void> {
@@ -207,15 +289,12 @@ async function syncLayout($: EngineInterface): Promise<void> {
207
289
  }
208
290
  }
209
291
 
210
- /**
211
- * Draws a burst the moment it lands and starts its reveal. Enrichment waits:
212
- * the first cut walked up to 12 neighbor calls before drawing anything, so an
213
- * MCP burst showed up seconds after the agent had already read it.
214
- */
292
+ /** Shelves a burst the moment it lands and starts its reveal; the seed's name and the chains' facts are looked up on later ticks. */
215
293
  async function draw($: EngineInterface, b: Burst): Promise<void> {
216
294
  const laid = layoutBurst(b)
217
- // Set the reveal up before the graph lands, so its first render is frame 0, not a flash of the finished map.
295
+ // Set the reveal up before the graph lands, so its first render is frame 0, not a flash of the full shelves.
218
296
  motion.highlight = undefined
297
+ motion.focus = undefined
219
298
  if (view.isShown) {
220
299
  motion.graphAt = laid.at
221
300
  motion.start = await $.clock.now()
@@ -226,7 +305,7 @@ async function draw($: EngineInterface, b: Burst): Promise<void> {
226
305
  pending.drawnAt = b.at
227
306
  }
228
307
 
229
- /** Catches up a burst not drawn yet, then fills in the real edges among what it delivered. */
308
+ /** Catches up a burst not drawn yet, names its seed (a burst names it by id), then catalogues its chains. */
230
309
  async function refresh($: EngineInterface): Promise<void> {
231
310
  if (pending.isRefreshing) return
232
311
  const current = await read($, burst)
@@ -234,21 +313,16 @@ async function refresh($: EngineInterface): Promise<void> {
234
313
  pending.isRefreshing = true
235
314
  try {
236
315
  if (current.at !== pending.drawnAt) await draw($, current)
237
- if (current.isEnriched) return
238
- // Background, after the draw: the seed's real name first (cheap, visible), then the cross edges.
239
- const named = (await resolveSeed($, current)) ?? current
240
- if (named !== current) {
316
+ if (!current.isEnriched) {
317
+ const named = { ...((await resolveSeed($, current)) ?? current), isEnriched: true }
318
+ // A newer burst landed while we asked: it is drawn already; drop this one.
241
319
  if ((await read($, burst))?.at !== current.at) return
242
320
  await update($, burst, () => named)
321
+ // Same `at`: the header renames in place, the reveal keeps running.
243
322
  await update($, graph, () => layoutBurst(named))
323
+ return
244
324
  }
245
- const enriched = { ...named, extra: await crossEdges($, named), isEnriched: true }
246
- // A newer burst landed while we walked: it is drawn already; drop this one.
247
- const latest = await read($, burst)
248
- if (latest && latest.at !== current.at) return
249
- await update($, burst, () => enriched)
250
- // Same `at`, same positions: the cross edges appear in place, the reveal keeps running.
251
- await update($, graph, () => layoutBurst(enriched))
325
+ await enrich($, current.at)
252
326
  } catch (err) {
253
327
  await update($, graph, g => ({ ...g, note: `minimap: ${String(err).slice(0, 80)}` }))
254
328
  } finally {
@@ -256,24 +330,35 @@ async function refresh($: EngineInterface): Promise<void> {
256
330
  }
257
331
  }
258
332
 
259
- async function crossEdges($: EngineInterface, b: Burst): Promise<[string, string][]> {
260
- const ids = new Set([b.seed.id, ...b.nodes.map(n => n.id)])
261
- const answers = await Promise.all(
262
- b.nodes.slice(0, ENRICH_MAX).map(n =>
263
- $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: n.id, limit: 30 }).then(
264
- res => [n.id, res] as const,
265
- () => [n.id, undefined] as const,
266
- ),
267
- ),
268
- )
269
- const out: [string, string][] = []
270
- for (const [id, res] of answers) {
271
- if (!res || res.isError) continue
272
- for (const hop of neighborIds(textsOf(res.content))) {
273
- if (ids.has(hop.id) && hop.id !== id) out.push([id, hop.id])
274
- }
333
+ /**
334
+ * Completes and places a few more chains: one `errata_neighbors` call per
335
+ * chain, strongest first, at most ENRICH_PER_BURST for one burst. A lone fix's
336
+ * answer names its problem, which becomes a target of its own next time round
337
+ * (the problem is where the chain's package lives).
338
+ */
339
+ async function enrich($: EngineInterface, at: number): Promise<void> {
340
+ if (pending.cataloguedAt === at) return
341
+ const map = await read($, graph)
342
+ const known = await read($, facts)
343
+ const catalog = catalogue(map, known)
344
+ if (pending.askedFor !== at) Object.assign(pending, { askedFor: at, asked: 0 })
345
+ const budget = Math.min(ENRICH_PER_TICK, ENRICH_PER_BURST - pending.asked)
346
+ const targets = budget > 0 ? enrichTargets(catalog, known, budget) : []
347
+ if (targets.length === 0) {
348
+ pending.cataloguedAt = at
349
+ return
350
+ }
351
+ const heads = new Set(catalog.books.map(b => b.problem?.id ?? b.key))
352
+ for (const id of targets) {
353
+ pending.asked++
354
+ const res = await $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: id, limit: 40 }).catch(() => undefined)
355
+ const answer = res && !res.isError ? parseNeighbors(textsOf(res.content)) : undefined
356
+ if ((await read($, burst))?.at !== at) return
357
+ await update($, facts, cache => {
358
+ const next = { ...cache, [id]: answer ?? null }
359
+ return Object.keys(next).length <= FACTS_KEPT ? next : Object.fromEntries(Object.entries(next).filter(([k]) => heads.has(k)))
360
+ })
275
361
  }
276
- return out
277
362
  }
278
363
 
279
364
  /** A burst names its seed by id (`file_fb8c…`): ask the graph what that node is. */
@@ -284,7 +369,7 @@ async function resolveSeed($: EngineInterface, b: Burst | null): Promise<Burst |
284
369
  return node ? { ...b, seed: { id: b.seed.id, label: node.label, name: node.name } } : b
285
370
  }
286
371
 
287
- /** Sets the burst the map shows and draws it now; the next tick enriches it. */
372
+ /** Sets the burst the shelves show and draws it now; the next tick names its seed. */
288
373
  async function show($: EngineInterface, next: Burst | null): Promise<void> {
289
374
  if (!next) return
290
375
  await update($, burst, () => next)
@@ -297,18 +382,37 @@ async function primingHandles($: EngineInterface): Promise<HandleTable> {
297
382
  return JSON.parse(String(text)) as HandleTable
298
383
  }
299
384
 
385
+ function isTagMode(value: unknown): value is TagMode {
386
+ return TAG_MODES.includes(value as TagMode)
387
+ }
388
+
389
+ /** A book's hover group: its spine rows and its card light together. */
390
+ function scopeOf(book: { key: string }): string {
391
+ return `book:${book.key}`.slice(0, 64)
392
+ }
393
+
394
+ function isPaneView(value: unknown): value is PaneView {
395
+ return PANE_VIEWS.includes(value as PaneView)
396
+ }
397
+
300
398
  export const register: Register = on => {
301
399
  on('session.start', async ($, e, next) => {
302
- await $.command.register({ name: 'minimap', description: 'Open the errata minimap: the latest burst neighborhood that entered context' })
400
+ await $.command.register({ name: 'minimap', description: 'Open the errata library: the causal chains of the latest burst, in bookcases by technology' })
401
+ await $.command.register({ name: 'errata-tags', description: 'How the chat draws errata inline tags: margin (marks), quiet (hidden) or raw' })
402
+ await $.command.register({ name: 'errata-view', description: 'Switch the errata pane between the library (books) and the graph' })
303
403
  void $.ui.open({ id: PANE, title: 'errata', rows: INLINE_ROWS })
304
- motion.bg = await themeBackground($)
404
+ motion.room = await themeRoom($)
405
+ const saved = await $.store.get(TAG_MODE_KEY).catch(() => undefined)
406
+ if (isTagMode(saved)) tags.mode = saved
407
+ const savedView = await $.store.get(PANE_VIEW_KEY).catch(() => undefined)
408
+ if (isPaneView(savedView)) pane.view = savedView
305
409
  $.clock.every(1500, () => void tick($))
306
410
  return next(e)
307
411
  })
308
412
 
309
413
  on('config.set', { key: 'theme' }, async ($, e, next) => {
310
414
  const done = await next(e)
311
- motion.bg = await themeBackground($)
415
+ motion.room = await themeRoom($)
312
416
  return done
313
417
  })
314
418
 
@@ -316,28 +420,56 @@ export const register: Register = on => {
316
420
  const opened = await $.ui.open({ id: PANE, title: 'errata', columns: 56, rows: INLINE_ROWS })
317
421
  pending.drawnAt = -1
318
422
  void refresh($)
319
- return { text: opened.isPlaced ? 'errata minimap opened.' : `minimap waits: ${opened.reason}` }
423
+ return { text: opened.isPlaced ? 'errata library opened.' : `the library waits: ${opened.reason}` }
424
+ })
425
+
426
+ // `/errata-tags` cycles margin → quiet → raw; `/errata-tags quiet` sets one. Display only: capture reads the stored reply.
427
+ on('command.run', { command: 'errata-tags' }, async ($, e) => {
428
+ const asked = e.args.trim().toLowerCase()
429
+ const nextMode = isTagMode(asked) ? asked : TAG_MODES[(TAG_MODES.indexOf(tags.mode) + 1) % TAG_MODES.length]!
430
+ tags.mode = nextMode
431
+ await $.store.set(TAG_MODE_KEY, nextMode)
432
+ $.ui.invalidate('ui.render')
433
+ const said = { margin: 'tags drawn as margin marks', quiet: 'tags hidden', raw: 'tags drawn as written' }[nextMode]
434
+ return { text: `errata: ${said} (${nextMode}). Capture is unchanged; it reads the stored reply.` }
435
+ })
436
+
437
+ // `/errata-view` flips books ↔ graph; `/errata-view graph` sets one. The burst stays; only the drawing changes.
438
+ on('command.run', { command: 'errata-view' }, async ($, e) => {
439
+ const asked = e.args.trim().toLowerCase()
440
+ const nextView = isPaneView(asked) ? asked : pane.view === 'books' ? 'graph' : 'books'
441
+ pane.view = nextView
442
+ motion.marks = {}
443
+ motion.focus = undefined
444
+ await $.store.set(PANE_VIEW_KEY, nextView)
445
+ $.ui.invalidate('ui.render')
446
+ return { text: nextView === 'books' ? 'errata pane: the library (books).' : 'errata pane: the graph.' }
447
+ })
448
+
449
+ // The agent's inline tags, tidied in the chat. The stored reply keeps them: the daemon harvests from the transcript.
450
+ on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
451
+ if (tags.mode === 'raw' || !hasTags(e.props.text)) return next(e)
452
+ return next({ ...e, props: { ...e.props, text: tidyTags(e.props.text, tags.mode) } })
320
453
  })
321
454
 
322
455
  // The agent bursts through MCP: the result IS the neighborhood that entered its context.
323
456
  on('tool.call', { tool: 'mcp__errata__errata_burst' }, async ($, e, next) => {
324
457
  const input = e as unknown as Record<string, unknown>
325
458
  const seed = String(input['nodeId'] ?? input['qname'] ?? '')
326
- // Bursting off a prior the map already shows: light it while the burst runs.
459
+ // Bursting off a prior the shelves already hold: pull it out while the burst runs.
327
460
  await highlightSeed($, seed)
328
461
  const ran = await next(e)
329
462
  if (ran.deny !== undefined || ran.isError) {
330
463
  motion.highlight = undefined
331
464
  return ran
332
465
  }
333
- // No MCP call before the draw: the seed's name is looked up on the next tick (refresh).
334
466
  const fresh = parseAgentBurst(textsOf([ran.text, ran.result]), seed)
335
467
  await holdHighlight($)
336
468
  await show($, fresh)
337
469
  return ran
338
470
  })
339
471
 
340
- // A search is a neighborhood too: the hits that entered context, ringed around the query.
472
+ // A search is a neighborhood too: the hits that entered context, catalogued under the query.
341
473
  on('tool.call', { tool: 'mcp__errata__errata_search' }, async ($, e, next) => {
342
474
  const ran = await next(e)
343
475
  if (ran.deny !== undefined || ran.isError) return ran
@@ -400,85 +532,245 @@ export const register: Register = on => {
400
532
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
401
533
  const { Box, Text } = $.ui.resolve(e)
402
534
  const width = Math.max(24, e.props.bodyColumns)
403
- // Docked, the map takes the screen's height; inline it sits above the prompt and keeps to its row budget.
535
+ // Docked, the library takes the screen's height; inline it sits above the prompt and keeps to its row budget.
536
+ // A portrait terminal (taller than wide, at ~2 rows per column of width) can't dock and has the least
537
+ // transcript to spare, so inline there the library is compact: one tier of shelves and a short card.
404
538
  const screenRows = e.viewport?.rows ?? 30
405
- const height = e.props.placement === 'inline' ? Math.max(14, Math.min(INLINE_ROWS, screenRows - 10)) : Math.max(14, screenRows - 4)
539
+ const isPortrait = e.props.placement === 'inline' && !!e.viewport && e.viewport.rows * 2 > e.viewport.columns
540
+ const height = isPortrait
541
+ ? Math.max(10, Math.min(COMPACT_ROWS, screenRows - 6))
542
+ : e.props.placement === 'inline'
543
+ ? Math.max(12, Math.min(INLINE_ROWS, screenRows - 10))
544
+ : Math.max(12, screenRows - 4)
406
545
  const map = await read($, graph)
546
+ const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
547
+
548
+ if (pane.view === 'graph') {
549
+ const delivered = map.nodes.filter(n => !n.isSeed).sort((a, b) => b.score - a.score)
550
+ const counts = new Map<string, number>()
551
+ for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
552
+ const legend = [...counts].sort((a, b) => b[1] - a[1])
553
+ const legendRows = Math.ceil(legend.reduce((w, [l, c]) => w + l.length + String(c).length + 6, 0) / width)
554
+ const tiers = SCOPE_ORDER.filter(s => delivered.some(n => n.scope === s))
555
+ const now = await $.clock.now()
556
+ const recent = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).slice(0, 3)
557
+ const topItems = Math.min(3, delivered.length)
558
+ const mapRows = Math.max(5, height - legendRows - (tiers.length > 0 ? 1 : 0) - topItems - recent.length - 2)
559
+ // Blits must match the mounted size: remember what this pass mounts.
560
+ motion.raster = { columns: width, rows: mapRows }
561
+ const cells = await paneCells($, map, width, mapRows)
562
+ return (
563
+ <Box flexDirection="column" width={width}>
564
+ <Text bold wrap="truncate-end">
565
+ {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'}
566
+ <Text dimColor> · {delivered.length} in context · </Text>
567
+ <Text color="#ffd75f">◉ </Text>
568
+ <Text>{shortName(map.title, Math.max(8, width - 30))}</Text>
569
+ <Text dimColor> {map.seedLabel ?? ''}</Text>
570
+ </Text>
571
+ {delivered.length > 0 && e.surface === 'terminal' ? (
572
+ (() => {
573
+ const { Raster } = $.ui.resolve(e)
574
+ return <Raster key="map" columns={width} rows={mapRows} cells={cells} />
575
+ })()
576
+ ) : (
577
+ <Box height={mapRows}>
578
+ <Text dimColor>{map.note ?? (delivered.length === 0 ? 'no burst yet — touch a file or burst a seed' : 'the graph draws in the terminal')}</Text>
579
+ </Box>
580
+ )}
581
+ {tiers.length > 0 && (
582
+ <Text wrap="truncate-end">
583
+ {tiers.map(s => (
584
+ <Text>
585
+ <Text color={hex(SCOPE_COLOR[s])}>━━</Text>
586
+ <Text dimColor> {s} </Text>
587
+ </Text>
588
+ ))}
589
+ </Text>
590
+ )}
591
+ {legend.length > 0 && (
592
+ <Text wrap="wrap">
593
+ {legend.map(([label, count]) => (
594
+ <Text>
595
+ <Text color={hex(colorOf(label))}>{String.fromCodePoint(glyphOf(label))}</Text>
596
+ <Text dimColor> {count} {label} </Text>
597
+ </Text>
598
+ ))}
599
+ </Text>
600
+ )}
601
+ {recent.map(v => (
602
+ <Text wrap="truncate-end">
603
+ <Text color={hex(VERDICT_COLOR[v.kind])} bold>
604
+ {JUDGMENT_STYLE[v.kind].glyph} {JUDGMENT_STYLE[v.kind].verb}{' '}
605
+ </Text>
606
+ <Text>{v.name}</Text>
607
+ </Text>
608
+ ))}
609
+ {delivered.slice(0, topItems).map(n => (
610
+ <Text wrap="truncate-end">
611
+ <Text color={hex(colorOf(n.label))}>{String.fromCodePoint(glyphOf(n.label))} </Text>
612
+ <Text dimColor>{displayName(n.label, n.name)}</Text>
613
+ </Text>
614
+ ))}
615
+ </Box>
616
+ )
617
+ }
407
618
 
408
- const delivered = map.nodes.filter(n => !n.isSeed).sort((a, b) => b.score - a.score)
409
- const counts = new Map<string, number>()
410
- for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
411
- const legend = [...counts].sort((a, b) => b[1] - a[1])
412
- const legendRows = Math.ceil(legend.reduce((w, [l, c]) => w + l.length + String(c).length + 6, 0) / width)
413
- const tiers = SCOPE_ORDER.filter(s => delivered.some(n => n.scope === s))
619
+ const catalog = await catalogOf($, map)
620
+ const palette = PALETTE[motion.room]
621
+
622
+ const books = catalog.books
623
+ const bound = new Set(books.flatMap(b => b.memberIds))
624
+ const others = map.nodes.filter(n => !n.isSeed && !bound.has(n.id)).length
625
+ // The spines carry no words: a one-line key says what their bands mean.
626
+ const kinds = new Set<Binding>()
627
+ for (const b of books) for (const part of [b.cause, b.problem, b.fix]) if (part) kinds.add(bindingOf(part.label))
628
+ const legend = (['cause', 'problem', 'claim', 'fix'] as Binding[]).filter(b => kinds.has(b))
414
629
  const now = await $.clock.now()
415
- const recent = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).slice(0, 3)
416
- const footRows =
417
- legendRows + (tiers.length > 0 ? 1 : 0) + Math.min(TOP_ITEMS, delivered.length) + recent.length
418
- const mapRows = Math.max(5, height - footRows - 2)
630
+ // A verdict on a prior not on the shelves has no plank to be stamped on: it gets a line.
631
+ const unshelved = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS && !bookHolding(catalog, v.id)).slice(0, 2)
632
+ const showLegend = legend.length > 0 && !isPortrait
633
+ const libraryRows = Math.max(8, height - 1 - (showLegend ? 1 : 0) - unshelved.length)
419
634
  // Blits must match the mounted size: remember what this pass mounts.
420
- motion.raster = { columns: width, rows: mapRows }
421
-
422
- const minimap =
423
- e.surface === 'terminal' && map.nodes.length > 0
424
- ? (() => {
425
- const { Raster } = $.ui.resolve(e)
426
- return <Raster key="map" columns={width} rows={mapRows} cells={rasterize(map, width, mapRows, rasterOptions(map))} />
427
- })()
428
- : (
429
- <Box height={mapRows}>
430
- <Text dimColor>
431
- {map.note ?? (map.nodes.length === 0 ? 'no burst yet — touch a file or burst a seed' : 'map draws in the terminal')}
432
- </Text>
635
+ motion.raster = { columns: width, rows: libraryRows }
636
+ const options = await libraryOptions($, map)
637
+ const focus = focusBook(catalog, options.focus)
638
+ const empty = others > 0 ? `no causal chains in this burst — ${others} other ${others === 1 ? 'hit' : 'hits'}` : 'the shelves are empty — touch a file or burst a seed'
639
+
640
+ const library =
641
+ books.length === 0 ? (
642
+ <Box height={libraryRows}>
643
+ <Text dimColor>{map.note ?? empty}</Text>
644
+ </Box>
645
+ ) : e.surface === 'terminal' ? (
646
+ (() => {
647
+ const { Raster, Button } = $.ui.resolve(e)
648
+ const cells = rasterizeLibrary(catalog, width, libraryRows, options)
649
+ // Hover and click ride on positioned replicas of each spine (same glyphs, same colours) laid
650
+ // over the raster, since pointer feedback attaches to elements, not cells. Laid only at rest:
651
+ // a reveal, a burst highlight or a flashing stamp repaints the raster by blit, which a static
652
+ // replica would hide.
653
+ const isAtRest = motion.frame.type >= 1 && !motion.highlight && Object.keys(motion.marks).length === 0
654
+ const spots = isAtRest ? librarySpots(catalog, width, libraryRows, options.focus) : []
655
+ const grid = new Uint32Array(Uint8Array.fromBase64(cells).buffer)
656
+ const cell = (x: number, y: number) => {
657
+ const i = (y * width + x) * 3
658
+ return { ch: String.fromCodePoint(grid[i]!), fg: grid[i + 1]!, bg: grid[i + 2]! }
659
+ }
660
+ const colourOf = (c: number) => (c === DEFAULT_CELL ? undefined : hex(c))
661
+ const cardWidth = Math.min(46, width - 2)
662
+ return (
663
+ <Box key="library" position="relative" width={width} height={libraryRows}>
664
+ <Raster key="map" columns={width} rows={libraryRows} cells={cells} />
665
+ {/* Every book's hit-area first… */}
666
+ {spots.flatMap(s => {
667
+ const scope = scopeOf(s.book)
668
+ const pin = () => {
669
+ motion.focus = s.book.problem?.id ?? s.book.memberIds[0]
670
+ $.ui.invalidate('ui.render')
671
+ }
672
+ return [...Array(s.height).keys()].map(r => (
673
+ <Box key={`${scope}:${r}`} position="absolute" top={s.top + r} left={s.x} hover={{ scope }}>
674
+ <Button key={`${scope}:press:${r}`} plain onPress={pin}>
675
+ {[...Array(s.width).keys()].map(dx => {
676
+ const c = cell(s.x + dx, s.top + r)
677
+ return <Text color={colourOf(c.fg)} backgroundColor={colourOf(c.bg)}>{c.ch}</Text>
678
+ })}
679
+ </Button>
680
+ </Box>
681
+ ))
682
+ })}
683
+ {/* …then every card. Positioned boxes paint in tree order, so a card emitted beside its own
684
+ book was painted over by every book after it. Last in the tree, a card is over all of them;
685
+ the background makes it opaque, or the books behind would show through its blank cells. */}
686
+ {spots.map(s => {
687
+ const scope = scopeOf(s.book)
688
+ const parts = (['cause', 'problem', 'fix'] as const).flatMap(r => (s.book[r] ? [s.book[r]!] : []))
689
+ const cardHeight = parts.length + 3
690
+ const cardLeft = s.x + s.width + 1 + cardWidth <= width ? s.x + s.width + 1 : Math.max(0, s.x - cardWidth - 1)
691
+ const cardTop = Math.max(0, Math.min(s.top, libraryRows - cardHeight))
692
+ const paper = hex(palette.card)
693
+ return (
694
+ <Box
695
+ key={`${scope}:card`}
696
+ position="absolute"
697
+ top={cardTop}
698
+ left={cardLeft}
699
+ width={cardWidth}
700
+ display="none"
701
+ hover={{ scope, display: 'flex', backgroundColor: paper }}
702
+ flexDirection="column"
703
+ borderStyle="round"
704
+ borderColor={hex(palette.gilt)}
705
+ >
706
+ <Text bold wrap="truncate-end" color={hex(palette.binding.problem)} backgroundColor={paper}>
707
+ {fitTitle(`${callNumber(catalog, s.book)} ${s.book.tech ?? 'general'}`, cardWidth - 2).padEnd(cardWidth - 2)}
708
+ </Text>
709
+ {parts.map(part => (
710
+ <Text wrap="truncate-end" backgroundColor={paper}>
711
+ <Text color={hex(palette.binding[bindingOf(part.label)])}>{BINDING_GLYPH[bindingOf(part.label)]} </Text>
712
+ <Text color={hex(part.isDelivered ? palette.cardInk : palette.cardFaint)}>
713
+ {fitTitle(part.name, cardWidth - 4).padEnd(cardWidth - 4)}
714
+ </Text>
715
+ </Text>
716
+ ))}
717
+ <Text color={hex(palette.cardFaint)} backgroundColor={paper} wrap="truncate-end">
718
+ {'click to read it on the card'.padEnd(cardWidth - 2)}
719
+ </Text>
720
+ </Box>
721
+ )
722
+ })}
433
723
  </Box>
434
724
  )
725
+ })()
726
+ ) : (
727
+ // No cell grid off the terminal: the catalog as a reading list, the book in focus first.
728
+ <Box flexDirection="column" height={libraryRows}>
729
+ {[...(focus ? [focus] : []), ...books.filter(b => b !== focus)].slice(0, libraryRows).map(b => {
730
+ const head = (b.problem ?? b.fix ?? b.cause)!
731
+ return (
732
+ <Text wrap="truncate-end">
733
+ <Text color={hex(palette.binding[bindingOf(head.label)])}>{BINDING_GLYPH[bindingOf(head.label)]} </Text>
734
+ <Text dimColor>{callNumber(catalog, b)} </Text>
735
+ <Text>{fitTitle(head.name, width - 12)}</Text>
736
+ </Text>
737
+ )
738
+ })}
739
+ </Box>
740
+ )
435
741
 
436
742
  return (
437
743
  <Box flexDirection="column" width={width}>
438
744
  <Text bold wrap="truncate-end">
439
- {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'}
440
- <Text dimColor> · {delivered.length} in context · </Text>
441
- <Text color="#ffd75f">◉ </Text>
442
- <Text>{shortName(map.title, Math.max(8, width - 30))}</Text>
443
- <Text dimColor> {map.seedLabel ?? ''}</Text>
745
+ <Text color={hex(palette.gilt)}>◉ </Text>
746
+ <Text>{shortName(map.title || 'errata library', Math.max(8, width - 44))}</Text>
747
+ <Text dimColor>
748
+ {' '}
749
+ {map.seedLabel ?? ''} · {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'} · {books.length} {books.length === 1 ? 'chain' : 'chains'} ·{' '}
750
+ {catalog.cases.length} {catalog.cases.length === 1 ? 'case' : 'cases'}
751
+ </Text>
444
752
  </Text>
445
- {minimap}
446
- {tiers.length > 0 && (
753
+ {library}
754
+ {showLegend && (
447
755
  <Text wrap="truncate-end">
448
- {tiers.map(s => (
756
+ {legend.map(b => (
449
757
  <Text>
450
- <Text color={hex(SCOPE_COLOR[s])}>━━</Text>
451
- <Text dimColor> {s} </Text>
758
+ <Text color={hex(palette.binding[b])}>█</Text>
759
+ <Text dimColor> {BINDING_NAME[b]} </Text>
452
760
  </Text>
453
761
  ))}
762
+ <Text dimColor>· spines read top to bottom · the card reads the marked book</Text>
454
763
  </Text>
455
764
  )}
456
- {legend.length > 0 && (
457
- <Text wrap="wrap">
458
- {legend.map(([label, count]) => (
459
- <Text>
460
- <Text color={hex(colorOf(label))}>{String.fromCodePoint(glyphOf(label))}</Text>
461
- <Text dimColor> {count} {label} </Text>
462
- </Text>
463
- ))}
464
- </Text>
465
- )}
466
- {recent.map(v => (
765
+ {unshelved.map(v => (
467
766
  <Text wrap="truncate-end">
468
- <Text color={hex(JUDGMENT_STYLE[v.kind].color)} bold>
767
+ <Text color={hex(palette.verdict[v.kind])} bold>
469
768
  {JUDGMENT_STYLE[v.kind].glyph} {JUDGMENT_STYLE[v.kind].verb}{' '}
470
769
  </Text>
471
- <Text>{v.name}</Text>
472
- </Text>
473
- ))}
474
- {delivered.slice(0, TOP_ITEMS).map(n => (
475
- <Text wrap="truncate-end">
476
- <Text color={hex(colorOf(n.label))}>{String.fromCodePoint(glyphOf(n.label))} </Text>
477
- <Text dimColor>{displayName(n.label, n.name)}</Text>
770
+ <Text>{shortName(v.name, width - 14)}</Text>
478
771
  </Text>
479
772
  ))}
480
773
  </Box>
481
774
  )
482
775
  })
483
776
  }
484
-