Detect SemText (sem-*) documents and render them specially: inline annotation, rich viewer pane (glyphed file-tree outline, box-drawn frames per sem-* kind…

Claude Code mod that detects SemText (sem-*) documents in Claude Code and renders them specially — inline annotation plus a full interactive viewer. /semtext toggles the pane.
sem-* blocks annotated in place; on detection, SemText detected: N elements — <source>.semtext-outline) — file-tree outline using the mod's glyph vocabulary per kind (📌 ⚠ 🧭 ▦ ⌨ ❓ 🃏 💡 🔑 🗂 👁 …, see GLYPHS in hooks/render.tsx), element counts, unknown-tag warnings, (+N) overflow.sem-* kind: prose/code bodies, tables as rows × cols, reveals/notes/views as tabbed fact sets; breadcrumbs.Width adapts to bodyColumns (then the viewport, then 72 cols, truncated); color uses theme keys + dimColor — no hardcoded ANSI.
.claude-plugin/plugin.json — manifest (semtext 0.3.0); .claude-plugin/types/ — vendored Claude Code API bundles (force-added past the dev-mods gitignore so the mod is self-contained)hooks/hooks.json + hooks/register.tsx — registration, SemText detection, pane/command wiring; hooks/render.tsx — pure viewer reducers + per-kind render registrytypes/index.d.ts — mod-facing type entrypointassets/screens/ — 12 design captures of every surface (outline, viewer, quiz/flashcards, toast, status) — the visual contracttests/semtext.test.tsx — plugin testsclaude --plugin-dir claude-mods/semtext, or keep the folder under ~/.claude/dev-mods/ (hooks hot-reload there).claude plugin validate claude-mods/semtext · claude plugin test claude-mods/semtext.tsconfig.json + the vendored bundles — no npm install.Marketplace packaging is planned (not yet published to a plugin marketplace).
hooks/register.tsx 1119 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, ToolCallInput } from 'claude-code'
3
4import {
5 GLYPHS,
6 INITIAL_VIEWER,
7 RENDERERS,
8 backToOutline,
9 breadcrumbOf,
10 buildQuizOptions,
11 collapseWhitespace,
12 factLine,
13 firstWords,
14 flipCard,
15 focusSection,
16 meterLine,
17 nextItem,
18 outlineTree,
19 pickOption,
20 promptFor,
21 sanitize,
22 setActiveView,
23 setExpanded,
24 startQuiz,
25 truncateCell,
26 truncateSummary,
27 wrapLines,
28} from './render'
29import type { RenderCtx } from './render'
30import type {
31 FactItem,
32 QuizOption,
33 SemBlock,
34 SemNodeContent,
35 SemTextDoc,
36 SemTextNode,
37 SemTextViewer,
38} from '../types'
39
40// Re-exported for the test harness (and any importer of the legacy surface):
41// the pure layer lives in ./render now.
42export {
43 backToOutline,
44 buildQuizOptions,
45 factLine,
46 firstWords,
47 flipCard,
48 focusSection,
49 INITIAL_VIEWER,
50 nextItem,
51 pickOption,
52 promptFor,
53 sanitize,
54 setExpanded,
55 setActiveView,
56 startQuiz,
57 truncateSummary,
58 collapseWhitespace,
59}
60
61// --- constants ---
62
63const PANE_ID = 'semtext-outline'
64const PANE_TITLE = 'SemText'
65const MAX_STORED_NODES = 2000
66const MAX_SEEN_SOURCES = 50
67const MAX_NOTES = 20
68const MAX_TEXT = 4000 // per-node captured text, characters
69const MAX_CODE_LINES = 40 // sem-code body cap, lines
70const MAX_TABLE_ROWS = 10 // sem-table rows cap
71const MAX_BLOCKS = 32 // headings/paragraphs captured per node
72const MAX_FACT_ITEMS = 50 // items captured per facts set
73const MAX_WIDGETS = 10 // interactive widgets drawn per section view
74const NOTE_SUMMARY_WORDS = 10 // collapsed-note summary rule
75const FALLBACK_COLUMNS = 72 // pane width when the surface reports none
76
77// The element vocabulary of sem-text.v0.7.spec.html (the 57 §2 table entries
78// plus the structural authoring elements its §1 names: sem-enhanced-document,
79// sem-list, sem-section, sem-break, sem-question, sem-option).
80// Warn-matching ONLY: an unknown sem-* name still parses, counts and renders —
81// it just earns a validation note.
82const KNOWN_ELEMENTS: ReadonlySet<string> = new Set([
83 'sem-api-call',
84 'sem-api-param',
85 'sem-audiences',
86 'sem-break',
87 'sem-browser',
88 'sem-changelog',
89 'sem-changelogs',
90 'sem-chronology',
91 'sem-code',
92 // sem-conclusion / sem-distractor: the interactive fact markup the browser
93 // fallback enhances (d("conclusion"), d("distractor")) even though the spec
94 // table lists them only as parts of sem-fact content.
95 'sem-conclusion',
96 'sem-distractor',
97 'sem-data-join',
98 'sem-data-query',
99 'sem-data-source',
100 'sem-data-visualizer',
101 'sem-detail',
102 'sem-details',
103 'sem-enhanced-document',
104 'sem-eval',
105 'sem-event',
106 'sem-fact',
107 'sem-facts',
108 'sem-faq',
109 'sem-faqs',
110 'sem-file-tree',
111 'sem-guard-rail',
112 'sem-guard-rails',
113 'sem-howto',
114 'sem-kb',
115 'sem-list',
116 'sem-md',
117 'sem-mockup',
118 'sem-note',
119 'sem-option',
120 'sem-philosophy',
121 'sem-plan',
122 'sem-procedure',
123 'sem-profile',
124 'sem-progress',
125 'sem-properties',
126 'sem-property',
127 'sem-question',
128 'sem-reader',
129 'sem-reference',
130 'sem-references',
131 'sem-requirement',
132 'sem-reveal',
133 'sem-rubric',
134 'sem-runbook',
135 'sem-section',
136 'sem-site-tree',
137 'sem-source',
138 'sem-step',
139 'sem-summary',
140 'sem-table',
141 'sem-tag-attr',
142 'sem-tag-def',
143 'sem-threat-model',
144 'sem-tldr',
145 'sem-tsg',
146 'sem-tsgs',
147 'sem-view',
148 'sem-views',
149 'sem-workflow',
150])
151
152// --- state ---
153
154const DOC = atom({ plugin: 'semtext', key: 'doc' } as const, null)
155const SEEN = atom({ plugin: 'semtext', key: 'seenSources' } as const, [])
156/** Exported for tests; all pane writes go through update($, VIEWER, …). */
157export const VIEWER = atom({ plugin: 'semtext', key: 'viewer' } as const, INITIAL_VIEWER)
158
159// --- detection ---
160
161export interface ParsedSemText {
162 roots: SemTextNode[]
163 elementCount: number
164 unknownElements: string[]
165 notes: string[]
166}
167
168/** Cheap prefix-match gate: a sem-* custom element open tag. */
169export function looksLikeSemText(text: string): boolean {
170 return /<sem-[a-z][a-z0-9-]*(?=[\s/>])/i.test(text)
171}
172
173// --- parser ---
174
175function countNewlines(text: string, from: number, to: number): number {
176 let seen = 0
177 for (let i = from; i < to; i++) if (text.charCodeAt(i) === 10) seen++
178 return seen
179}
180
181/** Index just past the `>` that closes the tag starting at `start`, quotes respected; -1 when unterminated. */
182function tagEnd(text: string, start: number): number {
183 let quote = ''
184 for (let i = start + 1; i < text.length; i++) {
185 const code = text.charCodeAt(i)
186 if (quote !== '') {
187 if (code === quote.charCodeAt(0)) quote = ''
188 } else if (code === 34 || code === 39) {
189 quote = text[i]!
190 } else if (code === 62) {
191 return i + 1
192 }
193 }
194 return -1
195}
196
197const ATTR = /([a-zA-Z_:@][-\w:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g
198
199function parseAttrs(src: string): Record<string, string> {
200 const attrs: Record<string, string> = {}
201 ATTR.lastIndex = 0
202 for (;;) {
203 const m = ATTR.exec(src)
204 if (m === null) break
205 const name = m[1]!.toLowerCase()
206 if (name in attrs) continue
207 attrs[name] = sanitize(m[2] ?? m[3] ?? m[4] ?? '')
208 }
209 return attrs
210}
211
212function attrSrcOf(src: string): string {
213 return src
214 .replace(/^<\/?\s*[a-zA-Z][a-zA-Z0-9-]*/, '')
215 .replace(/\/?\s*>$/, '')
216}
217
218const RAW_TEXT_CLOSE = /<\/(script|style)\s*>/gi
219const COMMENT_BLOCK = /<!--[\s\S]*?-->/g
220const TAG_LIKE = /<[^>]*>/g
221const HEADING_NAME = /^h([1-6])$/
222
223/** Internal parse-time fields, stripped before the tree is stored. */
224type ParseNode = SemTextNode & {
225 raw?: string
226 inheritedSeed?: number | null
227}
228
229type TextContainer = { name: string; start: number }
230
231function parseSeedAttr(value: string | undefined): number | null {
232 if (value === undefined) return null
233 const trimmed = value.trim()
234 if (!/^[+-]?\d+$/.test(trimmed)) return null
235 const parsed = Number.parseInt(trimmed, 10)
236 return Number.isFinite(parsed) ? Math.trunc(parsed) : null
237}
238
239function collectTag(node: SemTextNode, tag: string, out: SemTextNode[]): void {
240 for (const child of node.children) {
241 if (child.tag === tag) out.push(child)
242 collectTag(child, tag, out)
243 }
244}
245
246function contentFor(
247 node: ParseNode,
248 full: string,
249 raw: string,
250 childRaws: readonly string[],
251): SemNodeContent | undefined {
252 switch (node.tag) {
253 case 'sem-facts': {
254 // Every view-as mode captures: quiz/flashcards drive the runner, list
255 // (the bare-tag default) renders as an inline item list — the browser
256 // leaves list markup visible, so a plain <sem-facts> must never
257 // capture as empty. Unknown modes fall back to list (safe display).
258 const viewRaw = node.attrs['view-as'] ?? node.attrs['data-view-as'] ?? 'list'
259 const viewAs = viewRaw === 'quiz' || viewRaw === 'flashcards' ? viewRaw : 'list'
260 const found: SemTextNode[] = []
261 collectTag(node, 'sem-fact', found)
262 const items: FactItem[] = []
263 for (const fact of found) {
264 if (items.length >= MAX_FACT_ITEMS) break
265 const conclusions: SemTextNode[] = []
266 collectTag(fact, 'sem-conclusion', conclusions)
267 const distractorNodes: SemTextNode[] = []
268 collectTag(fact, 'sem-distractor', distractorNodes)
269 const conclusion = collapseWhitespace(conclusions[0]?.text ?? '')
270 const distractors = distractorNodes
271 .map(d => collapseWhitespace(d.text ?? ''))
272 .filter(text => text !== '')
273 let body = fact.text ?? ''
274 if (conclusion !== '') body = body.split(conclusion).join(' ')
275 for (const text of distractors) body = body.split(text).join(' ')
276 items.push({ body: collapseWhitespace(body), conclusion, distractors })
277 }
278 if (items.length === 0) return undefined
279 return { kind: 'facts', viewAs, seed: node.inheritedSeed ?? 0, items }
280 }
281 case 'sem-reveal': {
282 if (full === '') return undefined
283 const summary = node.attrs['summary'] ?? node.attrs['data-summary'] ?? truncateSummary(full)
284 return { kind: 'reveal', summary, body: full, collapsed: node.attrs['collapsed'] !== undefined }
285 }
286 case 'sem-note': {
287 if (node.attrs['collapsed'] === undefined || full === '') return undefined
288 return {
289 kind: 'note',
290 collapsed: true,
291 summary: `${firstWords(full, NOTE_SUMMARY_WORDS)} …`,
292 body: full,
293 }
294 }
295 case 'sem-views': {
296 const views = node.children
297 .filter(child => child.tag === 'sem-view')
298 .map((child, idx) => ({
299 name: child.attrs['name'] ?? child.attrs['data-name'] ?? `view ${idx + 1}`,
300 body: child.text ?? '',
301 active: child.attrs['active'] !== undefined || child.attrs['data-active'] !== undefined,
302 }))
303 if (views.length === 0) return undefined
304 const initial = Math.max(0, views.findIndex(view => view.active))
305 return {
306 kind: 'views',
307 views: views.map(view => ({ name: view.name, body: view.body })),
308 initial,
309 }
310 }
311 case 'sem-properties': {
312 const properties = node.children
313 .filter(child => child.tag === 'sem-property')
314 .map(child => ({
315 key: child.attrs['key'] ?? child.attrs['data-key'] ?? '',
316 value: child.text ?? '',
317 }))
318 if (properties.length === 0) return undefined
319 return { kind: 'properties', properties }
320 }
321 case 'sem-table': {
322 const lines = raw
323 .split('\n')
324 .map(row => row.trim())
325 .filter(row => row !== '')
326 const rows = (lines.length > 0 ? lines : [full].filter(row => row !== '')).slice(
327 0,
328 MAX_TABLE_ROWS,
329 )
330 if (rows.length === 0) return undefined
331 return { kind: 'table', rows }
332 }
333 case 'sem-code': {
334 const source = [raw.trim(), ...childRaws.map(line => line.trim())]
335 .filter(part => part !== '')
336 .join('\n')
337 const body = source.split('\n').slice(0, MAX_CODE_LINES).join('\n')
338 if (body === '') return undefined
339 return { kind: 'code', lang: node.attrs['lang'] ?? node.attrs['data-lang'] ?? '', body }
340 }
341 default:
342 return undefined
343 }
344}
345
346function finalizeNode(node: ParseNode): void {
347 const childRaws = node.children.map(child => (child as ParseNode).raw ?? '')
348 for (const child of node.children) finalizeNode(child as ParseNode)
349 const raw = node.raw ?? ''
350 const direct = collapseWhitespace(raw)
351 const childText = node.children
352 .map(child => child.text ?? '')
353 .filter(text => text !== '')
354 .join(' ')
355 const full = collapseWhitespace(`${direct} ${childText}`)
356 node.text = full.slice(0, MAX_TEXT)
357 node.content = contentFor(node, full, raw, childRaws)
358 delete node.raw
359 delete node.inheritedSeed
360}
361
362/**
363 * Parses the sem-* markup of `text` into a nested element tree, capturing
364 * per-element content (facts/reveal/note/views/properties/table/code) and the
365 * headings/paragraphs/blockquotes inside each sem element as `blocks`.
366 * Skips HTML comments, doctype/PI forms and the raw bodies of <script>/<style>
367 * (a page's fallback JS mentions sem-* names as strings — not markup).
368 * Returns null when the text carries no sem-* element at all.
369 */
370export function parseSemText(text: string): ParsedSemText | null {
371 if (!looksLikeSemText(text)) return null
372 const roots: ParseNode[] = []
373 const stack: ParseNode[] = []
374 const containers: TextContainer[] = []
375 const unknown = new Set<string>()
376 const notes: string[] = []
377 let count = 0
378 let stored = 0
379 let line = 1
380 let i = 0
381
382 const captureGap = (from: number, to: number): void => {
383 if (to <= from) return
384 const top = stack[stack.length - 1]
385 if (top === undefined || containers.length > 0) return
386 top.raw = (top.raw ?? '') + sanitize(text.slice(from, to))
387 }
388 const captureContainer = (container: TextContainer, to: number): void => {
389 const top = stack[stack.length - 1]
390 if (top === undefined) return
391 const inner = text
392 .slice(container.start, to)
393 .replace(COMMENT_BLOCK, ' ')
394 .replace(TAG_LIKE, ' ')
395 const clean = collapseWhitespace(sanitize(inner))
396 if (clean === '') return
397 const blocks: SemBlock[] = (top.blocks ??= [])
398 if (blocks.length >= MAX_BLOCKS) return
399 const heading = HEADING_NAME.exec(container.name)
400 if (heading !== null) blocks.push({ kind: 'heading', level: Number(heading[1]), text: clean })
401 else if (container.name === 'blockquote') blocks.push({ kind: 'quote', text: clean })
402 else blocks.push({ kind: 'text', text: clean })
403 }
404
405 while (i < text.length) {
406 const lt = text.indexOf('<', i)
407 if (lt < 0) {
408 captureGap(i, text.length)
409 break
410 }
411 captureGap(i, lt)
412 line += countNewlines(text, i, lt)
413 i = lt
414 if (text.startsWith('<!--', i)) {
415 const end = text.indexOf('-->', i + 4)
416 const stop = end < 0 ? text.length : end + 3
417 line += countNewlines(text, i, stop)
418 i = stop
419 continue
420 }
421 if (text.startsWith('<!', i) || text.startsWith('<?', i)) {
422 const end = text.indexOf('>', i)
423 const stop = end < 0 ? text.length : end + 1
424 line += countNewlines(text, i, stop)
425 i = stop
426 continue
427 }
428 const end = tagEnd(text, i)
429 if (end < 0) break
430 const src = text.slice(i, end)
431 const nameMatch = /^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(src)
432 if (nameMatch === null) {
433 i += 1
434 continue
435 }
436 const name = nameMatch[1]!.toLowerCase()
437 const closing = src.charCodeAt(1) === 47
438 const selfClosing = /\/\s*>$/.test(src)
439 line += countNewlines(text, i, end)
440 if (!closing && (name === 'script' || name === 'style')) {
441 RAW_TEXT_CLOSE.lastIndex = end
442 const close = RAW_TEXT_CLOSE.exec(text)
443 const stop = close === null ? text.length : RAW_TEXT_CLOSE.lastIndex
444 line += countNewlines(text, end, stop)
445 i = stop
446 continue
447 }
448 if (!name.startsWith('sem-')) {
449 if (closing) {
450 let at = -1
451 for (let s = containers.length - 1; s >= 0; s--) {
452 if (containers[s]!.name === name) {
453 at = s
454 break
455 }
456 }
457 if (at >= 0) {
458 captureContainer(containers[at]!, i)
459 containers.length = at
460 }
461 } else if (
462 !selfClosing &&
463 (name === 'p' || name === 'blockquote' || HEADING_NAME.test(name))
464 ) {
465 containers.push({ name, start: end })
466 }
467 i = end
468 continue
469 }
470 if (closing) {
471 let at = -1
472 for (let s = stack.length - 1; s >= 0; s--) {
473 if (stack[s]!.tag === name) {
474 at = s
475 break
476 }
477 }
478 if (at >= 0) {
479 stack.length = at
480 } else if (notes.length < MAX_NOTES) {
481 notes.push(`line ${line}: </${name}> closes nothing`)
482 }
483 } else {
484 count++
485 if (!KNOWN_ELEMENTS.has(name)) unknown.add(name)
486 if (stored < MAX_STORED_NODES) {
487 stored++
488 const parent = stack[stack.length - 1]
489 const node: ParseNode = {
490 tag: name,
491 attrs: parseAttrs(attrSrcOf(src)),
492 line,
493 children: [],
494 }
495 node.raw = ''
496 node.inheritedSeed =
497 parseSeedAttr(node.attrs['data-sem-seed']) ?? parent?.inheritedSeed ?? null
498 if (parent === undefined) roots.push(node)
499 else parent.children.push(node)
500 if (!selfClosing) stack.push(node)
501 }
502 }
503 i = end
504 }
505 if (count === 0) return null
506 for (const root of roots) finalizeNode(root)
507 return { roots, elementCount: count, unknownElements: [...unknown].sort(), notes }
508}
509
510// --- inline annotation (digest lines around sem-* blocks, never a re-render) ---
511
512/**
513 * Rewrites markdown so a blockquote digest line precedes each top-level sem-*
514 * block that sits outside a fenced code block. Returns `text` unchanged when
515 * nothing qualifies — callers pass such text through byte-identical.
516 */
517export function annotateText(text: string, parsed: ParsedSemText): string {
518 if (parsed.roots.length === 0) return text
519 const lines = text.split('\n')
520 const fenced: boolean[] = []
521 let inFence = false
522 let fenceMark = ''
523 for (const row of lines) {
524 const m = /^\s*(`{3,}|~{3,})/.exec(row)
525 if (m !== null) {
526 const mark = m[1]![0]!
527 if (!inFence) {
528 inFence = true
529 fenceMark = mark
530 } else if (mark === fenceMark) {
531 inFence = false
532 }
533 }
534 fenced.push(inFence)
535 }
536 const marks = new Map<number, SemTextNode>()
537 for (const root of parsed.roots) {
538 const idx = root.line - 1
539 if (idx < 0 || idx >= lines.length) continue
540 if (fenced[idx]) continue
541 marks.set(idx, root)
542 }
543 if (marks.size === 0) return text
544 const unknown = parsed.unknownElements
545 const digest =
546 `> SemText · ${parsed.roots[0]!.tag} · ${parsed.elementCount} elements` +
547 (unknown.length === 0
548 ? ''
549 : ` · ${unknown.length} unknown (${unknown.slice(0, 3).join(', ')})`)
550 const out: string[] = []
551 for (let idx = 0; idx < lines.length; idx++) {
552 if (marks.has(idx)) out.push(digest)
553 out.push(lines[idx]!)
554 }
555 return out.join('\n')
556}
557
558// --- ingestion ---
559
560function fileNameOf(e: ToolCallInput): string | undefined {
561 if ('file_path' in e && typeof e.file_path === 'string') return e.file_path
562 return undefined
563}
564
565function sourceOf(e: ToolCallInput): string {
566 const file = fileNameOf(e)
567 return file !== undefined ? file : `tool:${e.tool}`
568}
569
570/**
571 * A fresh source resets the viewer; a re-read of the same source leaves a
572 * quiz in progress alone. (Pure; ingest applies it.)
573 */
574export function resetsViewer(previous: SemTextDoc | null, source: string): boolean {
575 return previous === null || previous.source !== source
576}
577
578export async function ingest(
579 $: EngineInterface,
580 text: string,
581 source: string,
582 fileName?: string,
583): Promise<void> {
584 const parsed = parseSemText(text)
585 if (parsed === null || parsed.roots.length === 0) return
586 const nextDoc: SemTextDoc = {
587 source,
588 fileName,
589 tree: parsed.roots,
590 elementCount: parsed.elementCount,
591 unknownElements: parsed.unknownElements,
592 firstSeen: await $.clock.now(),
593 }
594 const previous = await read($, DOC)
595 await update($, DOC, () => nextDoc)
596 if (resetsViewer(previous, source)) {
597 await update($, VIEWER, () => ({ ...INITIAL_VIEWER }))
598 }
599 const seen = await read($, SEEN)
600 if (!seen.includes(source)) {
601 await update($, SEEN, list => [...list, source].slice(-MAX_SEEN_SOURCES))
602 $.ui.toast(`SemText detected: ${parsed.elementCount} elements — ${sanitize(source)}`)
603 const open = await $.ui.panes()
604 if (!open.some(pane => pane.id === PANE_ID)) {
605 void $.ui.open({ id: PANE_ID, title: PANE_TITLE })
606 }
607 }
608}
609
610/** The node at an outline row key path ('0.2.1'). */
611export function nodeAt(tree: readonly SemTextNode[], key: string): SemTextNode | undefined {
612 if (key === '') return undefined
613 let nodes = tree
614 let node: SemTextNode | undefined
615 for (const part of key.split('.')) {
616 const idx = Number.parseInt(part, 10)
617 node = Number.isInteger(idx) && idx >= 0 ? nodes[idx] : undefined
618 if (node === undefined) return undefined
619 nodes = node.children
620 }
621 return node
622}
623
624const INTERACTIVE: ReadonlySet<SemNodeContent['kind']> = new Set([
625 'facts',
626 'reveal',
627 'note',
628 'views',
629 'properties',
630 'table',
631 'code',
632])
633
634type Widget = { key: string; node: SemTextNode }
635
636/** Interactive-content descendants of `root`, keyed by outline row key. */
637function widgetsIn(root: SemTextNode, baseKey: string): Widget[] {
638 const out: Widget[] = []
639 const walk = (nodes: readonly SemTextNode[], prefix: string): void => {
640 for (let i = 0; i < nodes.length && out.length < MAX_WIDGETS; i++) {
641 const node = nodes[i]!
642 const key = `${prefix}${i}`
643 if (node.content !== undefined && INTERACTIVE.has(node.content.kind)) {
644 out.push({ key, node })
645 }
646 walk(node.children, `${key}.`)
647 }
648 }
649 walk(root.children, `${baseKey}.`)
650 return out
651}
652
653// --- the mod ---
654
655export const register: Register = on => {
656 on('session.start', async ($, e, next) => {
657 await $.command.register({
658 name: 'semtext',
659 description: 'Toggle the SemText viewer pane.',
660 })
661 return next(e)
662 })
663
664 // Detection: scan what a tool answered.
665 on('tool.call', async ($, e, next) => {
666 const ran = await next(e)
667 if (typeof ran.text === 'string' && looksLikeSemText(ran.text)) {
668 await ingest($, ran.text, sourceOf(e), fileNameOf(e))
669 }
670 return ran
671 })
672
673 // Detection: scan the finished answer.
674 on('turn.complete', async ($, e, next) => {
675 const done = await next(e)
676 if (looksLikeSemText(e.answer)) {
677 const source = e.agentId === undefined ? 'answer' : `answer:${e.agentId}`
678 await ingest($, e.answer, source)
679 }
680 return done
681 })
682
683 // Inline annotation: AssistantMessage markdown text.
684 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
685 const text = e.props.text
686 if (!looksLikeSemText(text)) return next(e)
687 const parsed = parseSemText(text)
688 if (parsed === null || parsed.roots.length === 0) return next(e)
689 const annotated = annotateText(text, parsed)
690 if (annotated === text) return next(e)
691 return next({ ...e, props: { ...e.props, text: annotated } })
692 })
693
694 // Inline annotation: ToolResult rows. `output` is schema-checked by the
695 // engine, so annotate only plain-string outputs; anything else passes
696 // through untouched (detection still ran at tool.call).
697 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
698 const output = e.props.output
699 if (typeof output !== 'string' || !looksLikeSemText(output)) return next(e)
700 const parsed = parseSemText(output)
701 if (parsed === null || parsed.roots.length === 0) return next(e)
702 const annotated = annotateText(output, parsed)
703 if (annotated === output) return next(e)
704 return next({ ...e, props: { ...e.props, output: annotated } })
705 })
706
707 // The pane: outline / section / quiz runner, one mode at a time.
708 on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => {
709 const { Box, Text, Button } = $.ui.resolve(e)
710 const doc = await read($, DOC)
711 const viewer = await read($, VIEWER)
712 if (doc === null) {
713 return (
714 <Box flexDirection="column">
715 <Text dimColor>No SemText document detected yet.</Text>
716 </Box>
717 )
718 }
719 // Width: the pane's own body columns (inside the frame), falling back to
720 // the viewport, then a 72-column assumption with truncation.
721 const width = Math.max(20, e.props.bodyColumns ?? e.viewport?.columns ?? FALLBACK_COLUMNS)
722 const room = Math.max(6, (e.viewport?.rows ?? 24) - 3)
723 const rows = outlineTree(doc.tree)
724 const ctx: RenderCtx = {
725 el: { Box, Text, Button },
726 viewer,
727 width,
728 wkey: '',
729 budget: room,
730 spent: { rows: 0 },
731 showBody: false,
732 press: fn => {
733 void update($, VIEWER, fn)
734 },
735 }
736
737 const outlineView = (): RenderElement => {
738 const unknown = doc.unknownElements
739 const body = rows.slice(0, Math.max(1, room - 3))
740 const selected = viewer.focusedSection ?? body[0]?.key
741 return (
742 <Box flexDirection="column">
743 <Text>
744 {`${GLYPHS.kind.source} ${sanitize(doc.fileName ?? doc.source)}`}
745 </Text>
746 <Text dimColor>
747 {unknown.length === 0
748 ? `${GLYPHS.kind.section} ${doc.elementCount} elements · ${GLYPHS.check} all elements known`
749 : `${GLYPHS.kind.section} ${doc.elementCount} elements`}
750 </Text>
751 {unknown.length > 0 && (
752 <Text color="warning">
753 {`${GLYPHS.cross} ${unknown.length} unknown: ${unknown.slice(0, 4).join(', ')}`}
754 </Text>
755 )}
756 {body.map(row => (
757 <Button
758 key={`sec:${row.key}`}
759 plain
760 dimColor={row.depth > 0}
761 onPress={() => {
762 void update($, VIEWER, v => focusSection(v, row.key))
763 }}
764 >
765 {`${row.key === selected ? GLYPHS.toggle.open : ' '} ${row.prefix}${row.glyph} ${row.label}`}
766 {row.count > 0 && <Text dimColor>{` (${row.count})`}</Text>}
767 </Button>
768 ))}
769 {rows.length > body.length && (
770 <Text dimColor>{`${GLYPHS.more} (+${rows.length - body.length})`}</Text>
771 )}
772 </Box>
773 )
774 }
775
776 const stepSection = (delta: number): void => {
777 void update($, VIEWER, v => {
778 const at = rows.findIndex(row => row.key === v.focusedSection)
779 const next = at < 0 ? 0 : Math.min(rows.length - 1, Math.max(0, at + delta))
780 return focusSection(v, rows[next]!.key)
781 })
782 }
783
784 const navRow = (): RenderElement => (
785 <Box flexDirection="row" gap={1}>
786 <Button
787 key="nav:outline"
788 plain
789 dimColor
790 onPress={() => {
791 void update($, VIEWER, backToOutline)
792 }}
793 >
794 {`${GLYPHS.nav.back} outline`}
795 </Button>
796 <Button
797 key="nav:prev"
798 plain
799 dimColor
800 onPress={() => {
801 stepSection(-1)
802 }}
803 >
804 {`${GLYPHS.nav.back} prev`}
805 </Button>
806 <Button
807 key="nav:next"
808 plain
809 dimColor
810 onPress={() => {
811 stepSection(1)
812 }}
813 >
814 {`${GLYPHS.nav.next} next`}
815 </Button>
816 </Box>
817 )
818
819 // Blocks: headings (`##` dim marker), blockquotes (`▌`), paragraphs plain.
820 // Returns the number of rows actually drawn.
821 const pushBlock = (parts: RenderElement[], block: SemBlock, budget: number): number => {
822 if (budget <= 0) return 0
823 const cellWidth = Math.max(8, width - 2)
824 const draw = (marker: string, text: string, dim: boolean, weight?: 'semibold'): number => {
825 const lines = wrapLines(sanitize(text), Math.max(4, cellWidth - marker.length), budget)
826 lines.forEach((line, i) => {
827 parts.push(
828 i === 0 && marker !== '' ? (
829 <Text key={`b${parts.length}-${i}`} dimColor={dim} weight={weight}>
830 <Text dimColor>{marker}</Text>
831 {line}
832 </Text>
833 ) : (
834 <Text
835 key={`b${parts.length}-${i}`}
836 dimColor={dim}
837 weight={i === 0 ? weight : undefined}
838 >
839 {marker !== '' && i > 0 ? `${' '.repeat(marker.length)}${line}` : line}
840 </Text>
841 ),
842 )
843 })
844 return lines.length
845 }
846 if (block.kind === 'heading') {
847 const mark = `${'#'.repeat(Math.min(6, Math.max(1, block.level)))} `
848 return draw(mark, block.text, false, block.level <= 2 ? 'semibold' : undefined)
849 }
850 if (block.kind === 'quote') {
851 return draw(`${GLYPHS.quote} `, block.text, true)
852 }
853 return draw('', block.text, false)
854 }
855
856 const sectionView = (): RenderElement => {
857 const key = viewer.focusedSection ?? ''
858 const node = nodeAt(doc.tree, key)
859 if (node === undefined) return outlineView()
860 let used = 1 // the breadcrumb row
861 const budget = Math.max(1, room - 2)
862 const parts: RenderElement[] = []
863
864 // Breadcrumb: source › ancestors (dim) › leaf.
865 const { trail, leaf } = breadcrumbOf(doc.tree, key)
866 const crumbs: RenderElement[] = [
867 <Text key="c0" dimColor>
868 {sanitize(doc.fileName ?? doc.source)}
869 </Text>,
870 ]
871 trail.forEach((label, i) => {
872 crumbs.push(
873 <Text key={`c${i + 1}`} dimColor>
874 {`${GLYPHS.crumb} ${label}`}
875 </Text>,
876 )
877 })
878 crumbs.push(
879 <Text key="leaf">
880 {`${GLYPHS.crumb} ${truncateCell(leaf, Math.max(8, width - 4))}`}
881 </Text>,
882 )
883 parts.push(
884 <Box key="crumb" flexDirection="row">
885 {crumbs}
886 </Box>,
887 )
888
889 // Headings / paragraphs / blockquotes captured inside the element.
890 for (const block of node.blocks ?? []) {
891 if (used >= budget) break
892 used += pushBlock(parts, block, budget - used)
893 }
894
895 // The element's own content, then interactive descendants — both
896 // through the render registry. `spent` is shared; only the delta
897 // between calls counts against the budget.
898 const renderWith = (widgetKey: string, target: SemTextNode, showBody: boolean): void => {
899 if (used >= budget) return
900 const render = RENDERERS[target.tag] ?? RENDERERS['*']!
901 const before = ctx.spent.rows
902 const sub: RenderCtx = { ...ctx, wkey: widgetKey, budget: budget - used, showBody }
903 const drawn = render(target, sub)
904 used += ctx.spent.rows - before
905 parts.push(
906 <Box key={`w:${widgetKey}`} flexDirection="column">
907 {drawn}
908 </Box>,
909 )
910 }
911 renderWith(key, node, (node.blocks ?? []).length === 0 && node.content === undefined)
912 for (const widget of widgetsIn(node, key)) {
913 if (used >= budget) break
914 renderWith(widget.key, widget.node, false)
915 }
916
917 return (
918 <Box flexDirection="column">
919 {parts.length === 1 ? <Text dimColor>(no content captured)</Text> : parts}
920 {used >= budget && <Text dimColor>{GLYPHS.more}</Text>}
921 {navRow()}
922 </Box>
923 )
924 }
925
926 const quizView = (): RenderElement => {
927 const setNode =
928 viewer.activeFactSet === null ? undefined : nodeAt(doc.tree, viewer.activeFactSet)
929 const content = setNode?.content
930 if (content === undefined || content.kind !== 'facts') return outlineView()
931 const items = content.items
932 const n = items.length
933 const isQuiz = viewer.factSetKind === 'quiz'
934 const at = Math.min(viewer.itemIndex, n)
935 const position = at < n ? at + 1 : n
936 const budget = Math.max(1, room - 4)
937 const out: RenderElement[] = []
938 let used = 0
939 const push = (el: RenderElement): void => {
940 if (used < budget) {
941 out.push(el)
942 used++
943 }
944 }
945 const cellWidth = Math.max(8, width - 6)
946 if (at >= n || n === 0) {
947 push(
948 <Text key="done">{`${meterLine(isQuiz, n, n, viewer)} — set complete`}</Text>,
949 )
950 } else {
951 const item = items[viewer.itemIndex]!
952 push(
953 <Text key="meter" dimColor>
954 {meterLine(isQuiz, position, n, viewer)}
955 </Text>,
956 )
957 const prompt = promptFor(item, position, viewer.factSetKind, viewer.flipped)
958 for (const line of wrapLines(sanitize(prompt), cellWidth, Math.max(1, budget - used))) {
959 push(<Text key={`p${out.length}`}>{line}</Text>)
960 }
961 if (isQuiz) {
962 if (viewer.picked === null && viewer.order.length === 0) {
963 // Conclusion-less item with an empty distractor pool: nothing to
964 // pick, so offer the skip rather than a dead end.
965 push(
966 <Text key="noopts" dimColor>
967 (no options recorded for this item)
968 </Text>,
969 )
970 push(
971 <Button
972 key="quiz:next"
973 plain
974 onPress={() => {
975 void update($, VIEWER, v =>
976 nextItem(v, buildQuizOptions(items, v.itemIndex + 1, content.seed), n),
977 )
978 }}
979 >
980 {`skip ${GLYPHS.nav.next}`}
981 </Button>,
982 )
983 } else if (viewer.picked === null) {
984 const letters = ['a', 'b', 'c', 'd']
985 viewer.order
986 .slice(0, 4)
987 .forEach((option, idx) =>
988 push(
989 <Button
990 key={`opt:${idx}`}
991 plain
992 hotkey={letters[idx]}
993 onPress={() => {
994 void update($, VIEWER, v => pickOption(v, idx))
995 }}
996 >
997 {`${GLYPHS.option} ${letters[idx]!.toUpperCase()}) ${truncateCell(sanitize(option.text), cellWidth - 5)}`}
998 </Button>,
999 ),
1000 )
1001 } else {
1002 viewer.order.slice(0, 4).forEach((option, idx) => {
1003 const correct = option.correct
1004 const picked = idx === viewer.picked
1005 const mark = correct ? GLYPHS.check : picked ? GLYPHS.cross : '·'
1006 push(
1007 <Text
1008 key={`opt:${idx}`}
1009 color={correct ? 'success' : picked ? 'error' : undefined}
1010 dimColor={!correct && !picked}
1011 >
1012 {`${mark} ${truncateCell(sanitize(option.text), cellWidth - 3)}`}
1013 </Text>,
1014 )
1015 })
1016 push(
1017 <Button
1018 key="quiz:next"
1019 plain
1020 onPress={() => {
1021 void update($, VIEWER, v =>
1022 nextItem(v, buildQuizOptions(items, v.itemIndex + 1, content.seed), n),
1023 )
1024 }}
1025 >
1026 {`next ${GLYPHS.nav.next}`}
1027 </Button>,
1028 )
1029 }
1030 } else {
1031 push(
1032 <Button
1033 key="quiz:flip"
1034 plain
1035 hotkey="f"
1036 onPress={() => {
1037 void update($, VIEWER, flipCard)
1038 }}
1039 >
1040 {`${GLYPHS.kind.flashcards} ${viewer.flipped ? 'hide' : 'flip'}`}
1041 </Button>,
1042 )
1043 push(
1044 <Button
1045 key="quiz:next"
1046 plain
1047 onPress={() => {
1048 void update($, VIEWER, v =>
1049 nextItem(v, buildQuizOptions(items, v.itemIndex + 1, content.seed), n),
1050 )
1051 }}
1052 >
1053 {`next ${GLYPHS.nav.next}`}
1054 </Button>,
1055 )
1056 }
1057 }
1058 return (
1059 <Box flexDirection="column">
1060 {out}
1061 <Box flexDirection="row" gap={1}>
1062 <Button
1063 key="nav:outline"
1064 plain
1065 dimColor
1066 onPress={() => {
1067 void update($, VIEWER, backToOutline)
1068 }}
1069 >
1070 {`${GLYPHS.nav.back} outline`}
1071 </Button>
1072 <Button
1073 key="nav:section"
1074 plain
1075 dimColor
1076 onPress={() => {
1077 void update($, VIEWER, v =>
1078 focusSection(v, viewer.activeFactSet ?? v.focusedSection ?? ''),
1079 )
1080 }}
1081 >
1082 {`${GLYPHS.nav.back} section`}
1083 </Button>
1084 </Box>
1085 </Box>
1086 )
1087 }
1088
1089 if (viewer.mode === 'section') return sectionView()
1090 if (viewer.mode === 'quiz') return quizView()
1091 return outlineView()
1092 })
1093
1094 // The band above the prompt while a document is active.
1095 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1096 const current = await read($, DOC)
1097 if (current === null || e.props.hasSurvey) return next(e)
1098 const { Box, Text } = $.ui.resolve(e)
1099 return (
1100 <Box>
1101 <Text dimColor>
1102 {`${GLYPHS.kind.section} SemText: ${current.elementCount} elements · ${current.unknownElements.length} unknown `}
1103 </Text>
1104 </Box>
1105 )
1106 })
1107
1108 // /semtext toggles the pane.
1109 on('command.run', { command: 'semtext' }, async $ => {
1110 const panes = await $.ui.panes()
1111 if (panes.some(pane => pane.id === PANE_ID)) {
1112 await $.ui.close({ id: PANE_ID })
1113 return { text: 'SemText viewer closed.' }
1114 }
1115 await $.ui.open({ id: PANE_ID, title: PANE_TITLE })
1116 return { text: 'SemText viewer open.' }
1117 })
1118}
1119hooks/render.tsx 1040 lines1// SemText pane rendering layer (v0.3): glyph vocabulary, pure drawing
2// helpers, viewer reducers and the per-kind render registry.
3//
4// Every decoration glyph in the pane comes from GLYPHS — content strings are
5// control-stripped (sanitize) before drawing and never contribute glyphs.
6// Elements are drawn from the VERIFIED surface only: Box/Text/Button with the
7// props BoxProps/TextProps/ButtonProps allow (color via theme keys, dimColor,
8// bold, width, padding, gap; Button plain/hotkey/variant/onPress).
9import type {
10 BoxProps,
11 ButtonProps,
12 ElementConstructor,
13 RenderElement,
14 TextProps,
15} from 'claude-code'
16
17import type {
18 FactItem,
19 QuizOption,
20 SemTextNode,
21 SemTextViewer,
22} from '../types'
23
24// --- hygiene (content strings never carry structure) ---
25
26const ANSI_OR_CONTROL =
27 /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\u0007]*(?:\u0007|\x1b\\)|[\u0000-\u0008\u000b-\u001f\u007f]/g
28
29/** Strips ANSI escape sequences and C0 control characters (keeps \n and \t). */
30export function sanitize(value: string): string {
31 return value.replace(ANSI_OR_CONTROL, '')
32}
33
34export function collapseWhitespace(value: string): string {
35 return value.replace(/\s+/g, ' ').trim()
36}
37
38/** 60-char word-boundary summary of a body — the browser reveal rule. */
39export function truncateSummary(value: string): string {
40 const flat = collapseWhitespace(value)
41 if (flat.length <= 60) return flat
42 const head = flat.slice(0, 60)
43 const cut = head.lastIndexOf(' ')
44 return (cut > 0 ? head.slice(0, cut) : head).replace(/[\s,;:.—-]+$/, '')
45}
46
47/** First `count` words of `value` — the collapsed-note summary rule. */
48export function firstWords(value: string, count: number): string {
49 return collapseWhitespace(value)
50 .split(' ')
51 .slice(0, count)
52 .join(' ')
53}
54
55/** A list-mode facts item line: `body — conclusion`, whichever exists — never empty. */
56export function factLine(item: FactItem): string {
57 if (item.body === '') return item.conclusion === '' ? '(empty item)' : item.conclusion
58 return item.conclusion === '' ? item.body : `${item.body} — ${item.conclusion}`
59}
60
61/**
62 * The runner's prompt: the body, the flipped conclusion on a flashcard, or an
63 * `Item N` placeholder when the text is empty — never an empty string, so a
64 * sparse fact set still shows what item the runner is on.
65 */
66export function promptFor(
67 item: FactItem,
68 position: number,
69 kind: 'quiz' | 'flashcards',
70 flipped: boolean,
71): string {
72 if (kind === 'flashcards' && flipped) {
73 return item.conclusion !== '' ? item.conclusion : '(no conclusion recorded)'
74 }
75 return item.body !== '' ? item.body : `Item ${position}`
76}
77
78// --- quiz randomness (ported from the browser fallback) ---
79
80/**
81 * splitmix32 PRNG — the exact constants of the browser fallback's `se`.
82 * Deterministic on `seed`; a missing data-sem-seed stays deterministic at 0
83 * (the browser falls back to Date.now(); the pane must not).
84 */
85export function rng(seed: number): () => number {
86 let state = seed >>> 0
87 return () => {
88 state = (state + 1831565813) >>> 0
89 let z = state
90 z = Math.imul(z ^ (z >>> 15), z | 1)
91 z ^= z + Math.imul(z ^ (z >>> 7), z | 61)
92 return ((z ^ (z >>> 14)) >>> 0) / 4294967296
93 }
94}
95
96/** Fisher–Yates over a copy, driven by `next` — the browser fallback's `ce`. */
97export function shuffle<T>(list: readonly T[], next: () => number): T[] {
98 const out = list.slice()
99 for (let i = out.length - 1; i > 0; i--) {
100 const j = Math.floor(next() * (i + 1))
101 const swap = out[i]!
102 out[i] = out[j]!
103 out[j] = swap
104 }
105 return out
106}
107
108/**
109 * Options for quiz item `itemIndex` of `items` — PURE and deterministic.
110 * Browser rule: the item's explicit distractors first, then the other items'
111 * conclusions, sliced to 3, plus the answer (exactly once), Fisher–Yates-
112 * shuffled. One PRNG stream per set, continued across items, as the browser
113 * does: item k's shuffle resumes where item k-1's stopped.
114 */
115export function buildQuizOptions(
116 items: readonly FactItem[],
117 itemIndex: number,
118 seed = 0,
119): QuizOption[] {
120 if (itemIndex < 0 || itemIndex >= items.length) return []
121 const next = rng(seed)
122 let built: QuizOption[] = []
123 for (let k = 0; k <= itemIndex; k++) {
124 const item = items[k]!
125 if (item.conclusion === '') {
126 // Conclusion-less item: the browser pools distractors and the other
127 // conclusions around an empty answer; the pane pools the same with no
128 // answer at all — options still show, nothing is gradeable.
129 const others: string[] = []
130 for (let j = 0; j < items.length; j++) {
131 if (j === k) continue
132 const conclusion = items[j]!.conclusion
133 if (conclusion !== '') others.push(conclusion)
134 }
135 built = shuffle(
136 item.distractors
137 .concat(others)
138 .slice(0, 3)
139 .map(text => ({ text, correct: false })),
140 next,
141 )
142 continue
143 }
144 const others: string[] = []
145 for (let j = 0; j < items.length; j++) {
146 if (j === k) continue
147 const conclusion = items[j]!.conclusion
148 if (conclusion !== '') others.push(conclusion)
149 }
150 const pool = item.distractors.concat(others).slice(0, 3)
151 const entries: QuizOption[] = pool.map(text => ({ text, correct: false }))
152 entries.push({ text: item.conclusion, correct: true })
153 built = shuffle(entries, next)
154 }
155 return built
156}
157
158// --- viewer reducers (PURE; the pane's only writers) ---
159
160export const INITIAL_VIEWER: SemTextViewer = {
161 mode: 'outline',
162 focusedSection: null,
163 activeFactSet: null,
164 factSetKind: 'quiz',
165 itemIndex: 0,
166 order: [],
167 picked: null,
168 score: { correct: 0, answered: 0 },
169 flipped: false,
170 expanded: {},
171 activeView: {},
172}
173
174export function focusSection(viewer: SemTextViewer, key: string): SemTextViewer {
175 return {
176 ...viewer,
177 mode: 'section',
178 focusedSection: key,
179 activeFactSet: null,
180 itemIndex: 0,
181 order: [],
182 picked: null,
183 flipped: false,
184 }
185}
186
187export function backToOutline(viewer: SemTextViewer): SemTextViewer {
188 return { ...viewer, mode: 'outline', activeFactSet: null, order: [], picked: null, flipped: false }
189}
190
191export function startQuiz(
192 viewer: SemTextViewer,
193 setKey: string,
194 kind: 'quiz' | 'flashcards',
195 order: readonly QuizOption[],
196): SemTextViewer {
197 return {
198 ...viewer,
199 mode: 'quiz',
200 activeFactSet: setKey,
201 factSetKind: kind,
202 itemIndex: 0,
203 order,
204 picked: null,
205 flipped: false,
206 score: { correct: 0, answered: 0 },
207 }
208}
209
210/** Grades one press; a second press on the same item and flashcards never grade. */
211export function pickOption(viewer: SemTextViewer, index: number): SemTextViewer {
212 if (viewer.mode !== 'quiz' || viewer.factSetKind !== 'quiz' || viewer.picked !== null) {
213 return viewer
214 }
215 const option = viewer.order[index]
216 if (option === undefined) return viewer
217 return {
218 ...viewer,
219 picked: index,
220 score: {
221 correct: viewer.score.correct + (option.correct ? 1 : 0),
222 answered: viewer.score.answered + 1,
223 },
224 }
225}
226
227/** Advances the runner; past the last item parks at `count` (the end state). */
228export function nextItem(
229 viewer: SemTextViewer,
230 order: readonly QuizOption[],
231 count: number,
232): SemTextViewer {
233 if (viewer.mode !== 'quiz') return viewer
234 const itemIndex = viewer.itemIndex + 1
235 if (itemIndex >= count) {
236 return { ...viewer, itemIndex: count, order: [], picked: null, flipped: false }
237 }
238 return { ...viewer, itemIndex, order, picked: null, flipped: false }
239}
240
241export function flipCard(viewer: SemTextViewer): SemTextViewer {
242 if (viewer.mode !== 'quiz' || viewer.factSetKind !== 'flashcards') return viewer
243 return { ...viewer, flipped: !viewer.flipped }
244}
245
246export function setExpanded(viewer: SemTextViewer, key: string, open: boolean): SemTextViewer {
247 return { ...viewer, expanded: { ...viewer.expanded, [key]: open } }
248}
249
250export function setActiveView(viewer: SemTextViewer, key: string, index: number): SemTextViewer {
251 return { ...viewer, activeView: { ...viewer.activeView, [key]: index } }
252}
253
254// --- glyph vocabulary (ours; content never contributes glyphs) ---
255
256export const GLYPHS = {
257 kind: {
258 note: '📌',
259 warning: '⚠',
260 procedure: '🧭',
261 step: '→',
262 table: '▦',
263 code: '⌨',
264 facts: '❓',
265 flashcards: '🃏',
266 reveal: '💡',
267 property: '🔑',
268 properties: '🗂',
269 views: '👁',
270 view: '◦',
271 reader: '📖',
272 source: '◫',
273 section: '§',
274 fact: '❍',
275 misc: '·',
276 },
277 tree: { branch: '├──', last: '└──', spine: '│', blank: ' ' },
278 crumb: '›',
279 toggle: { open: '▸', shut: '▾' },
280 quote: '▌',
281 option: '▢',
282 bar: { full: '▰', empty: '▱' },
283 check: '✓',
284 cross: '✗',
285 more: '…',
286 active: '●',
287 nav: { back: '◂', next: '▸' },
288 frame: {
289 tl: '┌',
290 tr: '┐',
291 bl: '└',
292 br: '┘',
293 h: '─',
294 v: '│',
295 teeDown: '┬',
296 teeUp: '┴',
297 cross: '┼',
298 teeLeft: '├',
299 teeRight: '┤',
300 },
301} as const
302
303/** A run of `n` box-drawing horizontals. */
304const hRule = (n: number): string => GLYPHS.frame.h.repeat(Math.max(0, n))
305
306/** Code-point length (widths in this module count code points, not units). */
307export const cpLen = (value: string): number => Array.from(value).length
308
309/** Which vocabulary glyph names `node`'s kind. */
310export function glyphOf(node: SemTextNode): string {
311 if (node.tag === 'sem-note' && (node.attrs['kind'] ?? node.attrs['data-kind']) === 'warning') {
312 return GLYPHS.kind.warning
313 }
314 const byTag: Record<string, string> = {
315 'sem-note': GLYPHS.kind.note,
316 'sem-guard-rail': GLYPHS.kind.warning,
317 'sem-guard-rails': GLYPHS.kind.warning,
318 'sem-procedure': GLYPHS.kind.procedure,
319 'sem-howto': GLYPHS.kind.procedure,
320 'sem-runbook': GLYPHS.kind.procedure,
321 'sem-workflow': GLYPHS.kind.procedure,
322 'sem-plan': GLYPHS.kind.procedure,
323 'sem-tsg': GLYPHS.kind.procedure,
324 'sem-tsgs': GLYPHS.kind.procedure,
325 'sem-step': GLYPHS.kind.step,
326 'sem-table': GLYPHS.kind.table,
327 'sem-code': GLYPHS.kind.code,
328 'sem-facts': GLYPHS.kind.facts,
329 'sem-faq': GLYPHS.kind.facts,
330 'sem-faqs': GLYPHS.kind.facts,
331 'sem-question': GLYPHS.kind.facts,
332 'sem-fact': GLYPHS.kind.fact,
333 'sem-reveal': GLYPHS.kind.reveal,
334 'sem-detail': GLYPHS.kind.reveal,
335 'sem-details': GLYPHS.kind.reveal,
336 'sem-property': GLYPHS.kind.property,
337 'sem-tag-attr': GLYPHS.kind.property,
338 'sem-tag-def': GLYPHS.kind.property,
339 'sem-properties': GLYPHS.kind.properties,
340 'sem-profile': GLYPHS.kind.properties,
341 'sem-views': GLYPHS.kind.views,
342 'sem-view': GLYPHS.kind.view,
343 'sem-reader': GLYPHS.kind.reader,
344 'sem-kb': GLYPHS.kind.reader,
345 'sem-source': GLYPHS.kind.source,
346 'sem-data-source': GLYPHS.kind.source,
347 'sem-reference': GLYPHS.kind.source,
348 'sem-references': GLYPHS.kind.source,
349 'sem-api-call': GLYPHS.kind.source,
350 'sem-section': GLYPHS.kind.section,
351 'sem-summary': GLYPHS.kind.section,
352 'sem-tldr': GLYPHS.kind.section,
353 'sem-conclusion': GLYPHS.kind.section,
354 }
355 return byTag[node.tag] ?? GLYPHS.kind.misc
356}
357
358/** An element's label: named attr else tag, sanitized, `#id` appended. */
359export function labelOf(node: SemTextNode): string {
360 const named = node.attrs['label'] ?? node.attrs['title'] ?? node.attrs['name']
361 const head =
362 named !== undefined ? `${node.tag} — ${sanitize(named).slice(0, 60)}` : node.tag
363 const id = node.attrs['id']
364 return id !== undefined ? `${head} #${sanitize(id)}` : head
365}
366
367// --- pure drawing helpers ---
368
369/**
370 * Fits `value` into `width` code points, cutting with `…` (which counts as a
371 * cell). `width <= 0` yields ''.
372 */
373export function truncateCell(value: string, width: number): string {
374 if (width <= 0) return ''
375 const chars = Array.from(value)
376 if (chars.length <= width) return value
377 return `${chars.slice(0, width - 1).join('')}${GLYPHS.more}`
378}
379
380/**
381 * Splits `value` into at most `maxLines` rows of `width` code points, cutting
382 * the last row short with `…` when content remains. Code-point safe.
383 */
384export function wrapLines(value: string, width: number, maxLines: number): string[] {
385 const cells = Math.max(1, width)
386 const chars = Array.from(value)
387 const out: string[] = []
388 let at = 0
389 while (at < chars.length && out.length < maxLines) {
390 out.push(chars.slice(at, at + cells).join(''))
391 at += cells
392 }
393 if (at < chars.length && out.length > 0) {
394 const last = out.length - 1
395 // Content was dropped: always mark the cut, reserving a cell for the glyph.
396 out[last] = `${truncateCell(out[last]!, Math.max(1, cells - 1))}${GLYPHS.more}`
397 }
398 return out
399}
400
401/** `├── ` / `└── ` under `│` spines: one row of a file-tree outline. */
402export function treePrefix(ancestorsLast: readonly boolean[], isLast: boolean): string {
403 const spine = ancestorsLast
404 .map(last => (last ? ' ' : `${GLYPHS.tree.spine} `))
405 .join('')
406 return `${spine}${isLast ? GLYPHS.tree.last : GLYPHS.tree.branch} `
407}
408
409/** `▰▰▱▱`: `filled/total` over `cells` cells, rounded, clamped. */
410export function progressBar(filled: number, total: number, cells: number): string {
411 if (cells <= 0) return ''
412 const ratio = total <= 0 ? 0 : Math.min(Math.max(filled, 0), total) / total
413 const full = Math.min(cells, Math.max(0, Math.round(ratio * cells)))
414 return GLYPHS.bar.full.repeat(full) + GLYPHS.bar.empty.repeat(cells - full)
415}
416
417/** The runner's meter line: `▰▰▱▱ 2/4 · score 1/1`. */
418export function meterLine(
419 isQuiz: boolean,
420 position: number,
421 n: number,
422 viewer: SemTextViewer,
423 cells = 8,
424): string {
425 const base = `${progressBar(Math.min(Math.max(position, 0), n), n, cells)} ${position}/${n}`
426 return isQuiz
427 ? `${base} · score ${viewer.score.correct}/${viewer.score.answered}`
428 : base
429}
430
431/** Cells of a textual table row: pipe- or tab-delimited, else one cell. */
432export function tableCells(rows: readonly string[]): string[][] {
433 const out: string[][] = []
434 for (const raw of rows) {
435 const line = raw.trim()
436 if (line === '') continue
437 let cells = line.includes('|')
438 ? line.split('|')
439 : line.includes('\t')
440 ? line.split('\t')
441 : [line]
442 cells = cells.map(cell => collapseWhitespace(sanitize(cell)))
443 if (cells.length > 1 && cells[0] === '') cells = cells.slice(1)
444 if (cells.length > 1 && cells[cells.length - 1] === '') {
445 cells = cells.slice(0, -1)
446 }
447 if (cells.every(cell => cell === '')) continue
448 out.push(cells)
449 }
450 return out
451}
452
453/**
454 * Per-column widths of `cells`: the widest cell each, shrunk one code point at
455 * a time from the widest column until the drawn row (`│ ` + cells + ` │`, i.e.
456 * sum + 3·cols + 1 cells) fits `maxTotal` (3-cell floor per column).
457 */
458export function columnWidths(cells: readonly string[][], maxTotal: number): number[] {
459 const cols = cells.reduce((max, row) => Math.max(max, row.length), 0)
460 if (cols === 0) return []
461 const widths = Array.from({ length: cols }, (_, c) =>
462 cells.reduce((max, row) => Math.max(max, cpLen(row[c] ?? '')), 1),
463 )
464 const min = 3
465 const budget = Math.max(cols * min, maxTotal - (3 * cols + 1))
466 const sum = (ws: readonly number[]): number => ws.reduce((a, b) => a + b, 0)
467 while (sum(widths) > budget) {
468 let at = -1
469 let widest = min - 1
470 widths.forEach((w, i) => {
471 if (w > widest) {
472 widest = w
473 at = i
474 }
475 })
476 if (at < 0) break
477 widths[at] = widest - 1
478 }
479 return widths
480}
481
482/**
483 * A box-drawn table: `┌─┬─┐` top, the first row the header, `├─┼─┤` under it,
484 * at most `maxRows` rows in all, `└─┴─┘` bottom. Cells truncate with `…`.
485 */
486export function drawTable(
487 cells: readonly string[][],
488 width: number,
489 maxRows: number,
490): { lines: string[]; headerRows: number; hidden: number } {
491 if (cells.length === 0 || width < 6) return { lines: [], headerRows: 0, hidden: 0 }
492 const cols = cells.reduce((max, row) => Math.max(max, row.length), 0)
493 const grid = cells.map(row =>
494 Array.from({ length: cols }, (_, c) => row[c] ?? ''),
495 )
496 const widths = columnWidths(grid, width - 2)
497 const rule = (joiner: string, left: string, right: string): string =>
498 `${left}${widths.map(w => hRule(w + 2)).join(joiner)}${right}`
499 const rowLine = (row: readonly string[]): string =>
500 `${GLYPHS.frame.v} ${widths
501 .map((w, c) => truncateCell(row[c] ?? '', w).padEnd(w))
502 .join(` ${GLYPHS.frame.v} `)} ${GLYPHS.frame.v}`
503 const cap = Math.max(1, maxRows)
504 const shown = grid.slice(0, cap)
505 const hidden = Math.max(0, grid.length - shown.length)
506 const lines: string[] = [rule(GLYPHS.frame.teeDown, GLYPHS.frame.tl, GLYPHS.frame.tr)]
507 shown.forEach((row, i) => {
508 if (i === 1) lines.push(rule(GLYPHS.frame.cross, GLYPHS.frame.teeLeft, GLYPHS.frame.teeRight))
509 lines.push(rowLine(row))
510 })
511 lines.push(rule(GLYPHS.frame.teeUp, GLYPHS.frame.bl, GLYPHS.frame.br))
512 return { lines, headerRows: 1, hidden }
513}
514
515/**
516 * A hand-drawn frame (┌─ corner chars) around `lines`, its top border carrying
517 * `badge` left- or right-aligned. Lines longer than the frame truncate.
518 */
519export function frameLines(
520 lines: readonly string[],
521 width: number,
522 badge = '',
523 align: 'left' | 'right' = 'left',
524): string[] {
525 const inner = Math.max(4, width - 2)
526 const cellW = inner - 2 // '│ ' + content + ' │'
527 const badgeText = badge === '' ? '' : ` ${truncateCell(badge, Math.max(0, cellW - 2))} `
528 const open = badgeText === '' ? hRule(inner) : hRule(2) + badgeText
529 const close = badgeText === '' ? '' : hRule(Math.max(0, inner - 2 - cpLen(badgeText)))
530 const top =
531 badgeText !== '' && align === 'right'
532 ? `${GLYPHS.frame.tl}${close}${badgeText}${hRule(2)}${GLYPHS.frame.tr}`
533 : `${GLYPHS.frame.tl}${open}${close}${GLYPHS.frame.tr}`
534 return [
535 top,
536 ...lines.map(line =>
537 `${GLYPHS.frame.v} ${truncateCell(line, cellW).padEnd(cellW)} ${GLYPHS.frame.v}`,
538 ),
539 `${GLYPHS.frame.bl}${hRule(inner)}${GLYPHS.frame.br}`,
540 ]
541}
542
543/** `🔑 ttl ──▸ 3600` rows, keys column-aligned, values truncated. */
544export function propertyLines(
545 properties: readonly { key: string; value: string }[],
546 width: number,
547): string[] {
548 if (properties.length === 0) return []
549 const keyCol = Math.min(
550 24,
551 Math.max(...properties.map(property => cpLen(sanitize(property.key))), 1),
552 )
553 return properties.map(property => {
554 const key = truncateCell(sanitize(property.key), keyCol).padEnd(keyCol)
555 const rest = Math.max(1, width - keyCol - 5)
556 return `${GLYPHS.kind.property} ${key} ──▸ ${truncateCell(sanitize(property.value), rest)}`
557 })
558}
559
560/** Outline rows of `tree`: file-tree prefixes, kind glyphs, child-count badges. */
561export type OutlineRow = {
562 key: string
563 depth: number
564 label: string
565 prefix: string
566 glyph: string
567 count: number
568}
569
570export function outlineTree(tree: readonly SemTextNode[], maxDepth = 8): OutlineRow[] {
571 const rows: OutlineRow[] = []
572 const walk = (nodes: readonly SemTextNode[], depth: number, base: string, ancestors: readonly boolean[]): void => {
573 nodes.forEach((node, i) => {
574 const isLast = i === nodes.length - 1
575 const key = `${base}${i}`
576 rows.push({
577 key,
578 depth,
579 label: labelOf(node),
580 prefix: treePrefix(ancestors, isLast),
581 glyph: glyphOf(node),
582 count: node.children.length,
583 })
584 if (depth < maxDepth) walk(node.children, depth + 1, `${key}.`, [...ancestors, isLast])
585 })
586 }
587 walk(tree, 0, '', [])
588 return rows
589}
590
591/** `source › section › subsection` parts of an outline key: trail then leaf. */
592export function breadcrumbOf(
593 tree: readonly SemTextNode[],
594 key: string,
595): { trail: string[]; leaf: string } {
596 const labels: string[] = []
597 let nodes = tree
598 for (const part of key.split('.')) {
599 const idx = Number.parseInt(part, 10)
600 const node = Number.isInteger(idx) && idx >= 0 ? nodes[idx] : undefined
601 if (node === undefined) break
602 labels.push(labelOf(node))
603 nodes = node.children
604 }
605 if (labels.length === 0) return { trail: [], leaf: '' }
606 return { trail: labels.slice(0, -1), leaf: labels[labels.length - 1]! }
607}
608
609// --- the render registry ---
610
611export type PaneElements = {
612 Box: ElementConstructor<BoxProps>
613 Text: ElementConstructor<TextProps>
614 Button: ElementConstructor<ButtonProps>
615}
616
617export type RenderCtx = {
618 /** The element constructors `$.ui.resolve(e)` handed the pane hook. */
619 el: PaneElements
620 viewer: SemTextViewer
621 /** Body columns of the pane (adapted renders). */
622 width: number
623 /** Outline key path of the node being rendered. */
624 wkey: string
625 /** Rows the section view still has room for. */
626 budget: number
627 /** Rows spent by renderers so far (shared, mutated). */
628 spent: { rows: number }
629 /** May the generic fallback draw the node's flattened body text? */
630 showBody: boolean
631 /** Applies a viewer reducer through the pane's atom. */
632 press: (fn: (viewer: SemTextViewer) => SemTextViewer) => void
633}
634
635export type ElementRenderer = (node: SemTextNode, ctx: RenderCtx) => RenderElement
636
637const roomOf = (ctx: RenderCtx): number => Math.max(0, ctx.budget - ctx.spent.rows)
638const spend = (ctx: RenderCtx, rows: number): void => {
639 ctx.spent.rows += rows
640}
641
642const EMPTY = (ctx: RenderCtx): RenderElement => ctx.el.Box({})
643
644/** Shared text-row builder for renderer bodies. */
645function dimRows(
646 ctx: RenderCtx,
647 lines: readonly string[],
648 key: string,
649 dim = true,
650): RenderElement[] {
651 return lines.map(
652 (line, i) => h(ctx.el.Text, { key: `${key}${i}`, dimColor: dim }, line) as RenderElement,
653 )
654}
655
656/** A framed body (used by note/code/source/reader/procedure). */
657function framedBody(
658 ctx: RenderCtx,
659 body: string,
660 badge: string,
661 align: 'left' | 'right',
662 tabExpand = false,
663): { element: RenderElement | null; hidden: number } {
664 const room = roomOf(ctx)
665 if (room <= 2 || body === '') return { element: null, hidden: 0 }
666 const text = tabExpand ? body.replace(/\t/g, ' ') : body
667 const inner = Math.max(10, ctx.width - 4)
668 const maxBody = room - 2 // top + bottom borders
669 const totalLines = text.split('\n').length
670 const shown: string[] = []
671 for (const line of text.split('\n')) {
672 if (shown.length >= maxBody) break
673 for (const part of wrapLines(line, inner - 4, maxBody - shown.length)) {
674 if (shown.length >= maxBody) break
675 shown.push(part)
676 }
677 }
678 const hidden = Math.max(0, totalLines - shown.length)
679 const lines = frameLines(shown, ctx.width, badge, align)
680 spend(ctx, lines.length + (hidden > 0 ? 1 : 0))
681 return {
682 element: h(
683 ctx.el.Box,
684 { flexDirection: 'column' },
685 lines.map((line, i) => h(ctx.el.Text, { key: `f${i}` }, line)),
686 hidden > 0
687 ? h(ctx.el.Text, { key: 'fh', dimColor: true }, `${GLYPHS.more} (+${hidden} lines)`)
688 : null,
689 ) as RenderElement,
690 hidden,
691 }
692}
693
694// --- per-kind renderers ---
695
696const noteRenderer: ElementRenderer = (node, ctx) => {
697 const content = node.content
698 if (content?.kind !== 'note') return genericRenderer(node, ctx)
699 const { Box, Button } = ctx.el
700 const warn = (node.attrs['kind'] ?? node.attrs['data-kind']) === 'warning'
701 const glyph = warn ? GLYPHS.kind.warning : GLYPHS.kind.note
702 const open = ctx.viewer.expanded[ctx.wkey] ?? false
703 if (!open) {
704 spend(ctx, 1)
705 return (
706 <Box>
707 <Button
708 key={`x:${ctx.wkey}`}
709 plain
710 onPress={() => ctx.press(v => setExpanded(v, ctx.wkey, true))}
711 >
712 {`${glyph} ${truncateCell(content.summary, Math.max(8, ctx.width - 8))} ${GLYPHS.toggle.open}`}
713 </Button>
714 </Box>
715 )
716 }
717 const frame = framedBody(ctx, content.body, `${glyph} note`, 'left')
718 if (frame.element === null) return genericRenderer(node, ctx)
719 spend(ctx, 1) // the hide button
720 return (
721 <Box flexDirection="column">
722 {frame.element}
723 <Button
724 key={`x:${ctx.wkey}`}
725 plain
726 onPress={() => ctx.press(v => setExpanded(v, ctx.wkey, false))}
727 >
728 {`${GLYPHS.toggle.shut} hide`}
729 </Button>
730 </Box>
731 )
732}
733
734const revealRenderer: ElementRenderer = (node, ctx) => {
735 const content = node.content
736 if (content?.kind !== 'reveal') return genericRenderer(node, ctx)
737 const { Box, Button } = ctx.el
738 const open = ctx.viewer.expanded[ctx.wkey] ?? !content.collapsed
739 if (!open) {
740 spend(ctx, 1)
741 return (
742 <Box>
743 <Button
744 key={`x:${ctx.wkey}`}
745 plain
746 onPress={() => ctx.press(v => setExpanded(v, ctx.wkey, true))}
747 >
748 {`${GLYPHS.kind.reveal} ${truncateCell(content.summary, Math.max(8, ctx.width - 8))} ${GLYPHS.toggle.open}`}
749 </Button>
750 </Box>
751 )
752 }
753 const room = roomOf(ctx)
754 const body = wrapLines(sanitize(content.body), Math.max(8, ctx.width - 2), Math.max(1, room - 1))
755 spend(ctx, body.length + 1)
756 return (
757 <Box flexDirection="column">
758 {...dimRows(ctx, body, 'r')}
759 <Button
760 key={`x:${ctx.wkey}`}
761 plain
762 onPress={() => ctx.press(v => setExpanded(v, ctx.wkey, false))}
763 >
764 {`${GLYPHS.toggle.shut} hide`}
765 </Button>
766 </Box>
767 )
768}
769
770const factsRenderer: ElementRenderer = (node, ctx) => {
771 const content = node.content
772 if (content?.kind !== 'facts') return genericRenderer(node, ctx)
773 const { Box, Text, Button } = ctx.el
774 const room = roomOf(ctx)
775 if (room <= 0) return EMPTY(ctx)
776 const name =
777 content.viewAs === 'quiz'
778 ? 'quiz'
779 : content.viewAs === 'flashcards'
780 ? 'flashcards'
781 : 'facts'
782 const glyph = content.viewAs === 'flashcards' ? GLYPHS.kind.flashcards : GLYPHS.kind.facts
783 if (content.viewAs === 'list') {
784 // List mode shows every item with its conclusion inline — the browser
785 // leaves list markup visible, so nothing hides here.
786 const head = `${glyph} ${name} · ${content.items.length} items`
787 const lines = content.items
788 .slice(0, Math.max(0, room - 1))
789 .map(item => `${GLYPHS.kind.fact} ${factLine(item)}`)
790 spend(ctx, lines.length + 1)
791 return (
792 <Box flexDirection="column">
793 <Text dimColor>{head}</Text>
794 {...dimRows(ctx, lines, 'fl')}
795 </Box>
796 )
797 }
798 spend(ctx, 2)
799 const kind: 'quiz' | 'flashcards' = content.viewAs
800 return (
801 <Box flexDirection="column">
802 <Text dimColor>{`${glyph} ${name} · ${content.items.length} items`}</Text>
803 <Button
804 key={`run:${ctx.wkey}`}
805 plain
806 onPress={() => {
807 ctx.press(v => startQuiz(v, ctx.wkey, kind, buildQuizOptions(content.items, 0, content.seed)))
808 }}
809 >
810 {`${GLYPHS.toggle.open} run ${name}`}
811 </Button>
812 </Box>
813 )
814}
815
816const tableRenderer: ElementRenderer = (node, ctx) => {
817 const content = node.content
818 if (content?.kind !== 'table') return genericRenderer(node, ctx)
819 const { Box, Text } = ctx.el
820 const room = roomOf(ctx)
821 if (room <= 1) return EMPTY(ctx)
822 const cells = tableCells(content.rows)
823 if (cells.length === 0) return EMPTY(ctx)
824 const cols = cells.reduce((max, row) => Math.max(max, row.length), 0)
825 const maxRows = Math.max(2, room - 1) // one row for the caption
826 const { lines, hidden } = drawTable(cells, Math.max(12, ctx.width - 2), maxRows)
827 spend(ctx, 1 + lines.length + (hidden > 0 ? 1 : 0))
828 return (
829 <Box flexDirection="column">
830 <Text dimColor>{`${GLYPHS.kind.table} table · ${cells.length} rows × ${cols} cols`}</Text>
831 {/* lines[1] is the header row: emphasized; borders and body dim. */}
832 {lines.map((line, i) =>
833 i === 1
834 ? h(ctx.el.Text, { key: `tb${i}`, bold: true }, line)
835 : h(ctx.el.Text, { key: `tb${i}`, dimColor: true }, line),
836 )}
837 {hidden > 0 ? (
838 <Text dimColor>{`${GLYPHS.more} (+${hidden} rows)`}</Text>
839 ) : null}
840 </Box>
841 )
842}
843
844const codeRenderer: ElementRenderer = (node, ctx) => {
845 const content = node.content
846 const body = content?.kind === 'code' ? content.body : ''
847 const lang = content?.kind === 'code' ? content.lang : (node.attrs['lang'] ?? '')
848 if (content?.kind !== 'code') return genericRenderer(node, ctx)
849 const badge = `${GLYPHS.kind.code}${lang !== '' ? ` ${sanitize(lang)}` : ''}`
850 const frame = framedBody(ctx, body, badge, 'right', true)
851 if (frame.element === null) return EMPTY(ctx)
852 return frame.element
853}
854
855const propertiesRenderer: ElementRenderer = (node, ctx) => {
856 const content = node.content
857 if (content?.kind !== 'properties') return genericRenderer(node, ctx)
858 const { Box, Text } = ctx.el
859 const room = roomOf(ctx)
860 if (room <= 1) return EMPTY(ctx)
861 const lines = propertyLines(content.properties, ctx.width).slice(0, room - 1)
862 spend(ctx, lines.length + 1)
863 return (
864 <Box flexDirection="column">
865 <Text dimColor>{`${GLYPHS.kind.properties} properties · ${content.properties.length}`}</Text>
866 {...dimRows(ctx, lines, 'pr', false)}
867 </Box>
868 )
869}
870
871const viewsRenderer: ElementRenderer = (node, ctx) => {
872 const content = node.content
873 if (content?.kind !== 'views') return genericRenderer(node, ctx)
874 const { Box, Button } = ctx.el
875 const room = roomOf(ctx)
876 if (room <= 0) return EMPTY(ctx)
877 const active = ctx.viewer.activeView[ctx.wkey] ?? content.initial
878 const tabs: RenderElement[] = [h(ctx.el.Text, { key: 'lb', dimColor: true }, '[') as RenderElement]
879 content.views.forEach((view, idx) => {
880 if (idx > 0) tabs.push(h(ctx.el.Text, { key: `sep${idx}`, dimColor: true }, '│') as RenderElement)
881 tabs.push(
882 <Button
883 key={`vt:${idx}`}
884 plain
885 onPress={() => ctx.press(v => setActiveView(v, ctx.wkey, idx))}
886 >
887 {(idx === active ? `${GLYPHS.active} ` : '') + sanitize(view.name)}
888 </Button>,
889 )
890 })
891 tabs.push(h(ctx.el.Text, { key: 'rb', dimColor: true }, ']') as RenderElement)
892 spend(ctx, 1)
893 const body = content.views[active]?.body ?? ''
894 const lines =
895 room > 1
896 ? wrapLines(sanitize(body), Math.max(8, ctx.width - 2), room - 1)
897 : []
898 spend(ctx, lines.length)
899 return (
900 <Box flexDirection="column">
901 <Box flexDirection="row">{tabs}</Box>
902 {...dimRows(ctx, lines, 'vw')}
903 </Box>
904 )
905}
906
907const sectionRenderer: ElementRenderer = (node, ctx) => {
908 // A section's content arrives as the section view's blocks + descendants;
909 // the renderer itself is just the kind-marked heading line.
910 const { Text } = ctx.el
911 spend(ctx, 1)
912 return (
913 <Text dimColor>
914 {`${GLYPHS.kind.section} ${truncateCell(labelOf(node), Math.max(8, ctx.width - 4))}`}
915 </Text>
916 )
917}
918
919const factRenderer: ElementRenderer = (node, ctx) => {
920 const { Text } = ctx.el
921 spend(ctx, 1)
922 return (
923 <Text dimColor>
924 {`${GLYPHS.kind.fact} ${truncateCell(collapseWhitespace(sanitize(node.text ?? '')), Math.max(8, ctx.width - 4))}`}
925 </Text>
926 )
927}
928
929const stepRenderer: ElementRenderer = (node, ctx) => {
930 const { Text } = ctx.el
931 spend(ctx, 1)
932 return (
933 <Text>
934 {`${GLYPHS.kind.step} ${truncateCell(collapseWhitespace(sanitize(node.text ?? '(empty step)')), Math.max(8, ctx.width - 4))}`}
935 </Text>
936 )
937}
938
939const sourceRenderer: ElementRenderer = (node, ctx) => {
940 const content = node.content
941 const body = content !== undefined && 'body' in content ? String(content.body) : (node.text ?? '')
942 const glyph = glyphOf(node)
943 const frame = framedBody(ctx, body, `${glyph} ${node.tag.replace('sem-', '')}`, 'left')
944 if (frame.element === null) return genericRenderer(node, ctx)
945 return frame.element
946}
947
948const procedureRenderer: ElementRenderer = (node, ctx) => {
949 const { Box, Text } = ctx.el
950 const room = roomOf(ctx)
951 if (room <= 2) return genericRenderer(node, ctx)
952 // Steps: direct sem-step children, else the flattened text as one step.
953 // One line per step (truncated), so the drawn count is exact.
954 const steps = node.children.filter(child => child.tag === 'sem-step')
955 const stepTexts =
956 steps.length > 0
957 ? steps.map(step => collapseWhitespace(sanitize(step.text ?? '')))
958 : [collapseWhitespace(sanitize(node.text ?? ''))]
959 const named = node.attrs['title'] ?? node.attrs['label'] ?? node.attrs['name']
960 const badge = `${GLYPHS.kind.procedure}${named !== undefined ? ` ${sanitize(named).slice(0, 24)}` : ''}`
961 const inner = Math.max(10, ctx.width - 4)
962 const maxBody = room - 2
963 const drawn = stepTexts.slice(0, maxBody)
964 const bodyLines = drawn.map((step, i) =>
965 `${i + 1} ${GLYPHS.kind.step} ${truncateCell(step === '' ? '(empty step)' : step, inner - 8)}`,
966 )
967 const hidden = Math.max(0, stepTexts.length - drawn.length)
968 const lines = frameLines(bodyLines, ctx.width, badge, 'left')
969 spend(ctx, lines.length + (hidden > 0 ? 1 : 0))
970 return (
971 <Box flexDirection="column">
972 {lines.map((line, i) => (
973 <Text key={`p${i}`}>{line}</Text>
974 ))}
975 {hidden > 0 ? (
976 <Text dimColor>{`${GLYPHS.more} (+${hidden} steps)`}</Text>
977 ) : null}
978 </Box>
979 )
980}
981
982/** Generic fallback: kind glyph + label, plus the body when nothing else drew it. */
983const genericRenderer: ElementRenderer = (node, ctx) => {
984 const { Box, Text } = ctx.el
985 const room = roomOf(ctx)
986 if (room <= 0) return EMPTY(ctx)
987 const head = `${glyphOf(node)} ${truncateCell(labelOf(node), Math.max(8, ctx.width - 4))}`
988 if (!ctx.showBody || (node.text ?? '') === '') {
989 spend(ctx, 1)
990 return <Text dimColor>{head}</Text>
991 }
992 const body = wrapLines(sanitize(node.text ?? ''), Math.max(8, ctx.width - 2), room - 1)
993 spend(ctx, 1 + body.length)
994 return (
995 <Box flexDirection="column">
996 <Text dimColor>{head}</Text>
997 {...dimRows(ctx, body, 'g', false)}
998 </Box>
999 )
1000}
1001
1002/**
1003 * The extension point: one renderer per sem-* kind, `*` the generic fallback.
1004 * Adding an element kind later = adding one entry (plus a glyphOf mapping).
1005 */
1006export const RENDERERS: Record<string, ElementRenderer> = {
1007 'sem-note': noteRenderer,
1008 'sem-reveal': revealRenderer,
1009 'sem-detail': revealRenderer,
1010 'sem-details': revealRenderer,
1011 'sem-facts': factsRenderer,
1012 'sem-faq': factsRenderer,
1013 'sem-faqs': factsRenderer,
1014 'sem-question': factsRenderer,
1015 'sem-table': tableRenderer,
1016 'sem-code': codeRenderer,
1017 'sem-md': codeRenderer,
1018 'sem-properties': propertiesRenderer,
1019 'sem-property': factRenderer,
1020 'sem-tag-attr': factRenderer,
1021 'sem-tag-def': factRenderer,
1022 'sem-views': viewsRenderer,
1023 'sem-view': factRenderer,
1024 'sem-section': sectionRenderer,
1025 'sem-summary': sectionRenderer,
1026 'sem-tldr': sectionRenderer,
1027 'sem-fact': factRenderer,
1028 'sem-step': stepRenderer,
1029 'sem-procedure': procedureRenderer,
1030 'sem-howto': procedureRenderer,
1031 'sem-runbook': procedureRenderer,
1032 'sem-workflow': procedureRenderer,
1033 'sem-plan': procedureRenderer,
1034 'sem-source': sourceRenderer,
1035 'sem-reader': sourceRenderer,
1036 'sem-data-source': sourceRenderer,
1037 'sem-reference': sourceRenderer,
1038 '*': genericRenderer,
1039}
1040types/index.d.ts 96 lines1export type SemTextNode = {
2 tag: string
3 attrs: Readonly<Record<string, string>>
4 line: number
5 children: SemTextNode[]
6 /**
7 * Full text content (direct prose plus descendants'), ANSI-stripped and
8 * whitespace-collapsed, capped. Present once the element closed at parse.
9 */
10 text?: string
11 /**
12 * Structured capture for the interactive vocabulary (facts/reveal/note/
13 * views/properties/table/code) once the element closed at parse.
14 */
15 content?: SemNodeContent
16 /**
17 * Headings (h1-h6) and paragraphs (p) captured inside this element, in
18 * document order.
19 */
20 blocks?: SemBlock[]
21}
22
23export type SemBlock =
24 | { kind: 'heading'; level: number; text: string }
25 | { kind: 'quote'; text: string }
26 | { kind: 'text'; text: string }
27
28export type FactItem = {
29 /** Prompt text: the fact's content with its conclusion/distractors removed. */
30 body: string
31 conclusion: string
32 distractors: readonly string[]
33}
34
35export type QuizOption = {
36 text: string
37 correct: boolean
38}
39
40export type SemNodeContent =
41 | {
42 kind: 'facts'
43 /** quiz/flashcards drive the runner; list renders items inline. */
44 viewAs: 'quiz' | 'flashcards' | 'list'
45 /** Deterministic shuffle seed: data-sem-seed (own or inherited), else 0. */
46 seed: number
47 items: readonly FactItem[]
48 }
49 | { kind: 'reveal'; summary: string; body: string; collapsed: boolean }
50 | { kind: 'note'; collapsed: true; summary: string; body: string }
51 | { kind: 'views'; views: readonly { name: string; body: string }[]; initial: number }
52 | { kind: 'properties'; properties: readonly { key: string; value: string }[] }
53 | { kind: 'table'; rows: readonly string[] }
54 | { kind: 'code'; lang: string; body: string }
55
56/** Viewer + runner state of the pane; every write goes through update(). */
57export type SemTextViewer = {
58 mode: 'outline' | 'section' | 'quiz'
59 /** Key path (outline row key) of the section in section/quiz mode. */
60 focusedSection: string | null
61 /** Key path of the facts set the runner is on. */
62 activeFactSet: string | null
63 factSetKind: 'quiz' | 'flashcards'
64 /** Zero-based item; === items.length once the set is complete. */
65 itemIndex: number
66 /** Option order of the current quiz item (from buildQuizOptions). */
67 order: readonly QuizOption[]
68 /** Index into `order` the runner graded, null until an option is pressed. */
69 picked: number | null
70 score: { correct: number; answered: number }
71 flipped: boolean
72 /** Reveal/note key path -> expanded? (default = captured collapsed state). */
73 expanded: Readonly<Record<string, boolean>>
74 /** Views key path -> active view index (default = captured initial). */
75 activeView: Readonly<Record<string, number>>
76}
77
78export type SemTextDoc = {
79 source: string
80 fileName?: string
81 tree: SemTextNode[]
82 elementCount: number
83 unknownElements: string[]
84 firstSeen: number
85}
86
87declare module 'claude-code' {
88 interface PluginState {
89 semtext: {
90 doc: SemTextDoc | null
91 seenSources: string[]
92 viewer: SemTextViewer
93 }
94 }
95}
96