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-23the-robot-lives/semtext.dev/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.dev

Repo: https://github.com/the-robot-lives/semtext.dev

Stub repo for the semtext.dev domain — reserved, no application yet.

Part of the The Robot Lives portfolio.

What

Currently holds only project docs (CLAUDE.md, AGENTS.md) and this README. No site, no build, no CI.

Why

Reserves the semtext.dev domain within the portfolio so it can later host a SemText-related product or landing page without a land grab. Infra (DNS, TLS, eventual ArgoCD app) lives in trl-infra.

Getting Started

Nothing to run. When an application lands, follow the portfolio static-site or Hologram starter pattern (see sibling Brand repos, e.g. phpspec.org) and add CI + a helm chart at that point.

Web Component Evaluation

A rubric-driven program that grades web components (tobor-kit, the styleguide frontend) on eight weighted dimensions — API clarity, rendering + zero-JS fallback, accessibility, docs, tests, convention fit, CSS-variable theming, packaging — on a 100-point scale mapped to letter grades (A ≥ 90 … F < 50).

  • Rubric (SemText document, rubric_version v1): web/public/spec/web-component-evaluator.sem.html — dimensions, criteria, review-fragment schema, and the manifest/review rendering example.
  • Eval prompts: manifest/tobor-kit.eval.md and manifest/styleguide-eval.md — one per source repo; each grades one component per run and appends one YAML review fragment to the run file (default components/review.yaml in the evaluated repo).
  • Manifest: each evaluated repo holds components/manifest.yaml — the read-only inventory of gradeable component tags. Graders never edit it.
  • Venues: evalnomi (hosted eval lane), subagent (delegated reviewer), script (deterministic checks). Branches: prod (origin/main), stage, dev (develop), oob (experiments; findings never gate the listed slices).

Anything authored as .sem.html for this program must pass node bin/semtext-validate.mjs <file> from the repo root.

Viewing documents — semtext-view

node bin/semtext-view.mjs <file.html | file.md> renders a SemText document with the vendored bundle so elements actually display:

  • Serve (interactive): node bin/semtext-view.mjs <file> --serve [--port N] — mounts /semtext/* from web/public/semtext/ (the vendored bundle is served, never rebuilt), the document plus its sibling assets from its own directory, and prints the full clickable http://127.0.0.1:PORT/<file> URL.
  • Stdout (default): node bin/semtext-view.mjs <file> — prints the assembled HTML to stdout; pipe-friendly.
  • Markdown input: wrapped in a minimal <sem-md> scaffold page (title from the filename) and served identically — e.g. view the eval prompts in manifest/ without hand-writing a shell page.

Consolidation (2026-10-13)

This repo now holds BOTH halves of semtext.dev:

  • landing/ — the Next.js landing (static export, output: "export"), from the feat/landing-semtext-rewrite revamp.
  • site/, demo/, spec/, src/, themes/, bin/, scripts/, test/, root package.json — the SemText format code (format source, bundles, demos, standalone spec pages, validators, e2e suite), imported verbatim from noizu-labs-ml/npl-enhanced-content @ 0d8a708 (main; content SHA fd4a166, PR #48 merge). Layout change on import: npl's web/site and web/demo are now root-level site/ and demo/.

One image serves everything: landing at /, showcase under /site/, /demo/, /spec/ (root Dockerfile + nginx.conf). The Hologram app's chart is app/helm/semtext (the legacy root helm/semtext Next.js chart was deleted 2026-10-17). app-ci.yml builds ops.noizu.com/semtext.dev/app:sha-* and commits the chart tag bump; ArgoCD auto-syncs (never helm-upgrade for release). The npl-enhanced-content repo remains the historical record; it is not deleted.

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