SLOPSHOPPER

semtext

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

newpanebandrowsguardcommand
v0.3.0no licenseupdated 2026-10-18the-robot-lives/semtext.dev/claude-mods/semtext
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · semtext
│ ┃ SemText ✕ › fix the failing auth test and add an audit log call │ ┃ No SemText document detected yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /semtext │ ⎿ semtext: SemText viewer open. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SemText
No SemText document detected yet.
README

semtext — Claude Code mod

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.

Surfaces

  • Inline annotation + toast — sem-* blocks annotated in place; on detection, SemText detected: N elements — <source>.
  • Outline pane (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.
  • Section viewer — box-drawn frames per sem-* kind: prose/code bodies, tables as rows × cols, reveals/notes/views as tabbed fact sets; breadcrumbs.
  • Quiz + flashcard runners — shuffled options, grading on first pick only (second presses and flashcards never grade), running score, progress meters.

Width adapts to bodyColumns (then the viewport, then 72 cols, truncated); color uses theme keys + dimColor — no hardcoded ANSI.

Layout

  • .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 registry
  • types/index.d.ts — mod-facing type entrypoint
  • assets/screens/ — 12 design captures of every surface (outline, viewer, quiz/flashcards, toast, status) — the visual contract
  • tests/semtext.test.tsx — plugin tests

Development

  • Load: claude --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.
  • Types resolve via repo-root tsconfig.json + the vendored bundles — no npm install.

Marketplace packaging is planned (not yet published to a plugin marketplace).

Source 3 files
hooks/register.tsx 1119 lines
1import { 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}
1119
hooks/render.tsx 1040 lines
1// 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}
1040
types/index.d.ts 96 lines
1export 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