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

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.
Currently holds only project docs (CLAUDE.md, AGENTS.md) and this README. No site, no build, no CI.
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.
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.
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_version v1): web/public/spec/web-component-evaluator.sem.html — dimensions, criteria, review-fragment schema, and the manifest/review rendering example.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).components/manifest.yaml — the read-only inventory of gradeable component tags. Graders never edit it.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.
semtext-viewnode bin/semtext-view.mjs <file.html | file.md> renders a SemText document with the vendored bundle so elements actually display:
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.node bin/semtext-view.mjs <file> — prints the assembled HTML to stdout; pipe-friendly.<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.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.
hooks/register.tsx 1199 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, ToolCallInput } from 'claude-code'
3
4import {
5 GLYPHS,
6 INITIAL_VIEWER,
7 RENDERERS,
8 backToOutline,
9 breadcrumbOf,
10 buildQuizOptions,
11 collapseWhitespace,
12 factLine,
13 firstWords,
14 flipCard,
15 focusSection,
16 meterLine,
17 nextItem,
18 outlineTree,
19 pickOption,
20 promptFor,
21 sanitize,
22 setActiveView,
23 setExpanded,
24 startQuiz,
25 truncateCell,
26 truncateSummary,
27 wrapLines,
28} from './render'
29import type { RenderCtx } from './render'
30import type {
31 FactItem,
32 QuizOption,
33 SemBlock,
34 SemNodeContent,
35 SemTextDoc,
36 SemTextNode,
37 SemTextViewer,
38} from '../types'
39
40// Re-exported for the test harness (and any importer of the legacy surface):
41// the pure layer lives in ./render now.
42export {
43 backToOutline,
44 buildQuizOptions,
45 factLine,
46 firstWords,
47 flipCard,
48 focusSection,
49 INITIAL_VIEWER,
50 nextItem,
51 pickOption,
52 promptFor,
53 sanitize,
54 setExpanded,
55 setActiveView,
56 startQuiz,
57 truncateSummary,
58 collapseWhitespace,
59}
60
61// --- constants ---
62
63const PANE_ID = 'semtext-outline'
64const PANE_TITLE = 'SemText'
65const MAX_STORED_NODES = 2000
66const MAX_SEEN_SOURCES = 50
67const MAX_NOTES = 20
68const MAX_TEXT = 4000 // per-node captured text, characters
69const MAX_CODE_LINES = 40 // sem-code body cap, lines
70const MAX_TABLE_ROWS = 10 // sem-table rows cap
71const MAX_BLOCKS = 32 // headings/paragraphs captured per node
72const MAX_FACT_ITEMS = 50 // items captured per facts set
73const MAX_WIDGETS = 10 // interactive widgets drawn per section view
74const NOTE_SUMMARY_WORDS = 10 // collapsed-note summary rule
75const FALLBACK_COLUMNS = 72 // pane width when the surface reports none
76
77// 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}
1199hooks/render.tsx 1131 lines1// SemText pane rendering layer (v0.3): glyph vocabulary, pure drawing
2// helpers, viewer reducers and the per-kind render registry.
3//
4// Every decoration glyph in the pane comes from GLYPHS — content strings are
5// control-stripped (sanitize) before drawing and never contribute glyphs.
6// Elements are drawn from the VERIFIED surface only: Box/Text/Button with the
7// props BoxProps/TextProps/ButtonProps allow (color via theme keys, dimColor,
8// bold, width, padding, gap; Button plain/hotkey/variant/onPress).
9import type {
10 BoxProps,
11 ButtonProps,
12 ElementConstructor,
13 RenderElement,
14 TextProps,
15} from 'claude-code'
16
17import type {
18 FactItem,
19 QuizOption,
20 SemTextNode,
21 SemTextViewer,
22} from '../types'
23
24// --- hygiene (content strings never carry structure) ---
25
26const ANSI_OR_CONTROL =
27 /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\u0007]*(?:\u0007|\x1b\\)|[\u0000-\u0008\u000b-\u001f\u007f]/g
28
29/** Strips ANSI escape sequences and C0 control characters (keeps \n and \t). */
30export function sanitize(value: string): string {
31 return value.replace(ANSI_OR_CONTROL, '')
32}
33
34export function collapseWhitespace(value: string): string {
35 return value.replace(/\s+/g, ' ').trim()
36}
37
38/** 60-char word-boundary summary of a body — the browser reveal rule. */
39export function truncateSummary(value: string): string {
40 const flat = collapseWhitespace(value)
41 if (flat.length <= 60) return flat
42 const head = flat.slice(0, 60)
43 const cut = head.lastIndexOf(' ')
44 return (cut > 0 ? head.slice(0, cut) : head).replace(/[\s,;:.—-]+$/, '')
45}
46
47/** First `count` words of `value` — the collapsed-note summary rule. */
48export function firstWords(value: string, count: number): string {
49 return collapseWhitespace(value)
50 .split(' ')
51 .slice(0, count)
52 .join(' ')
53}
54
55/** A list-mode facts item line: `body — conclusion`, whichever exists — never empty. */
56export function factLine(item: FactItem): string {
57 if (item.body === '') return item.conclusion === '' ? '(empty item)' : item.conclusion
58 return item.conclusion === '' ? item.body : `${item.body} — ${item.conclusion}`
59}
60
61/**
62 * The runner's prompt: the body, the flipped conclusion on a flashcard, or an
63 * `Item N` placeholder when the text is empty — never an empty string, so a
64 * sparse fact set still shows what item the runner is on.
65 */
66export function promptFor(
67 item: FactItem,
68 position: number,
69 kind: 'quiz' | 'flashcards',
70 flipped: boolean,
71): string {
72 if (kind === 'flashcards' && flipped) {
73 return item.conclusion !== '' ? item.conclusion : '(no conclusion recorded)'
74 }
75 return item.body !== '' ? item.body : `Item ${position}`
76}
77
78// --- quiz randomness (ported from the browser fallback) ---
79
80/**
81 * splitmix32 PRNG — the exact constants of the browser fallback's `se`.
82 * Deterministic on `seed`; a missing data-sem-seed stays deterministic at 0
83 * (the browser falls back to Date.now(); the pane must not).
84 */
85export function rng(seed: number): () => number {
86 let state = seed >>> 0
87 return () => {
88 state = (state + 1831565813) >>> 0
89 let z = state
90 z = Math.imul(z ^ (z >>> 15), z | 1)
91 z ^= z + Math.imul(z ^ (z >>> 7), z | 61)
92 return ((z ^ (z >>> 14)) >>> 0) / 4294967296
93 }
94}
95
96/** Fisher–Yates over a copy, driven by `next` — the browser fallback's `ce`. */
97export function shuffle<T>(list: readonly T[], next: () => number): T[] {
98 const out = list.slice()
99 for (let i = out.length - 1; i > 0; i--) {
100 const j = Math.floor(next() * (i + 1))
101 const swap = out[i]!
102 out[i] = out[j]!
103 out[j] = swap
104 }
105 return out
106}
107
108/**
109 * Options for quiz item `itemIndex` of `items` — PURE and deterministic.
110 * Browser rule: the item's explicit distractors first, then the other items'
111 * conclusions, sliced to 3, plus the answer (exactly once), Fisher–Yates-
112 * shuffled. One PRNG stream per set, continued across items, as the browser
113 * does: item k's shuffle resumes where item k-1's stopped.
114 */
115export function buildQuizOptions(
116 items: readonly FactItem[],
117 itemIndex: number,
118 seed = 0,
119): QuizOption[] {
120 if (itemIndex < 0 || itemIndex >= items.length) return []
121 const next = rng(seed)
122 let built: QuizOption[] = []
123 for (let k = 0; k <= itemIndex; k++) {
124 const item = items[k]!
125 if (item.conclusion === '') {
126 // Conclusion-less item: the browser pools distractors and the other
127 // conclusions around an empty answer; the pane pools the same with no
128 // answer at all — options still show, nothing is gradeable.
129 const others: string[] = []
130 for (let j = 0; j < items.length; j++) {
131 if (j === k) continue
132 const conclusion = items[j]!.conclusion
133 if (conclusion !== '') others.push(conclusion)
134 }
135 built = shuffle(
136 item.distractors
137 .concat(others)
138 .slice(0, 3)
139 .map(text => ({ text, correct: false })),
140 next,
141 )
142 continue
143 }
144 const others: string[] = []
145 for (let j = 0; j < items.length; j++) {
146 if (j === k) continue
147 const conclusion = items[j]!.conclusion
148 if (conclusion !== '') others.push(conclusion)
149 }
150 const pool = item.distractors.concat(others).slice(0, 3)
151 const entries: QuizOption[] = pool.map(text => ({ text, correct: false }))
152 entries.push({ text: item.conclusion, correct: true })
153 built = shuffle(entries, next)
154 }
155 return built
156}
157
158// --- viewer reducers (PURE; the pane's only writers) ---
159
160export const INITIAL_VIEWER: SemTextViewer = {
161 mode: 'outline',
162 focusedSection: null,
163 activeFactSet: null,
164 factSetKind: 'quiz',
165 itemIndex: 0,
166 order: [],
167 picked: null,
168 score: { correct: 0, answered: 0 },
169 flipped: false,
170 expanded: {},
171 activeView: {},
172}
173
174export function focusSection(viewer: SemTextViewer, key: string): SemTextViewer {
175 return {
176 ...viewer,
177 mode: 'section',
178 focusedSection: key,
179 activeFactSet: null,
180 itemIndex: 0,
181 order: [],
182 picked: null,
183 flipped: false,
184 }
185}
186
187export function backToOutline(viewer: SemTextViewer): SemTextViewer {
188 return { ...viewer, mode: 'outline', activeFactSet: null, order: [], picked: null, flipped: false }
189}
190
191export function startQuiz(
192 viewer: SemTextViewer,
193 setKey: string,
194 kind: 'quiz' | 'flashcards',
195 order: readonly QuizOption[],
196): SemTextViewer {
197 return {
198 ...viewer,
199 mode: 'quiz',
200 activeFactSet: setKey,
201 factSetKind: kind,
202 itemIndex: 0,
203 order,
204 picked: null,
205 flipped: false,
206 score: { correct: 0, answered: 0 },
207 }
208}
209
210/** Grades one press; a second press on the same item and flashcards never grade. */
211export function pickOption(viewer: SemTextViewer, index: number): SemTextViewer {
212 if (viewer.mode !== 'quiz' || viewer.factSetKind !== 'quiz' || viewer.picked !== null) {
213 return viewer
214 }
215 const option = viewer.order[index]
216 if (option === undefined) return viewer
217 return {
218 ...viewer,
219 picked: index,
220 score: {
221 correct: viewer.score.correct + (option.correct ? 1 : 0),
222 answered: viewer.score.answered + 1,
223 },
224 }
225}
226
227/** Advances the runner; past the last item parks at `count` (the end state). */
228export function nextItem(
229 viewer: SemTextViewer,
230 order: readonly QuizOption[],
231 count: number,
232): SemTextViewer {
233 if (viewer.mode !== 'quiz') return viewer
234 const itemIndex = viewer.itemIndex + 1
235 if (itemIndex >= count) {
236 return { ...viewer, itemIndex: count, order: [], picked: null, flipped: false }
237 }
238 return { ...viewer, itemIndex, order, picked: null, flipped: false }
239}
240
241export function flipCard(viewer: SemTextViewer): SemTextViewer {
242 if (viewer.mode !== 'quiz' || viewer.factSetKind !== 'flashcards') return viewer
243 return { ...viewer, flipped: !viewer.flipped }
244}
245
246export function setExpanded(viewer: SemTextViewer, key: string, open: boolean): SemTextViewer {
247 return { ...viewer, expanded: { ...viewer.expanded, [key]: open } }
248}
249
250export function setActiveView(viewer: SemTextViewer, key: string, index: number): SemTextViewer {
251 return { ...viewer, activeView: { ...viewer.activeView, [key]: index } }
252}
253
254// --- glyph vocabulary (ours; content never contributes glyphs) ---
255
256export const GLYPHS = {
257 kind: {
258 note: '📌',
259 warning: '⚠',
260 procedure: '🧭',
261 step: '→',
262 table: '▦',
263 code: '⌨',
264 facts: '❓',
265 flashcards: '🃏',
266 reveal: '💡',
267 property: '🔑',
268 properties: '🗂',
269 views: '👁',
270 view: '◦',
271 reader: '📖',
272 source: '◫',
273 section: '§',
274 fact: '❍',
275 misc: '·',
276 },
277 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}
1131types/index.d.ts 96 lines1export type SemTextNode = {
2 tag: string
3 attrs: Readonly<Record<string, string>>
4 line: number
5 children: SemTextNode[]
6 /**
7 * Full text content (direct prose plus descendants'), ANSI-stripped and
8 * whitespace-collapsed, capped. Present once the element closed at parse.
9 */
10 text?: string
11 /**
12 * Structured capture for the interactive vocabulary (facts/reveal/note/
13 * views/properties/table/code) once the element closed at parse.
14 */
15 content?: SemNodeContent
16 /**
17 * Headings (h1-h6) and paragraphs (p) captured inside this element, in
18 * document order.
19 */
20 blocks?: SemBlock[]
21}
22
23export type SemBlock =
24 | { kind: 'heading'; level: number; text: string }
25 | { kind: 'quote'; text: string }
26 | { kind: 'text'; text: string }
27
28export type FactItem = {
29 /** Prompt text: the fact's content with its conclusion/distractors removed. */
30 body: string
31 conclusion: string
32 distractors: readonly string[]
33}
34
35export type QuizOption = {
36 text: string
37 correct: boolean
38}
39
40export type SemNodeContent =
41 | {
42 kind: 'facts'
43 /** quiz/flashcards drive the runner; list renders items inline. */
44 viewAs: 'quiz' | 'flashcards' | 'list'
45 /** Deterministic shuffle seed: data-sem-seed (own or inherited), else 0. */
46 seed: number
47 items: readonly FactItem[]
48 }
49 | { kind: 'reveal'; summary: string; body: string; collapsed: boolean }
50 | { kind: 'note'; collapsed: true; summary: string; body: string }
51 | { kind: 'views'; views: readonly { name: string; body: string }[]; initial: number }
52 | { kind: 'properties'; properties: readonly { key: string; value: string }[] }
53 | { kind: 'table'; rows: readonly string[] }
54 | { kind: 'code'; lang: string; body: string }
55
56/** Viewer + runner state of the pane; every write goes through update(). */
57export type SemTextViewer = {
58 mode: 'outline' | 'section' | 'quiz'
59 /** Key path (outline row key) of the section in section/quiz mode. */
60 focusedSection: string | null
61 /** Key path of the facts set the runner is on. */
62 activeFactSet: string | null
63 factSetKind: 'quiz' | 'flashcards'
64 /** Zero-based item; === items.length once the set is complete. */
65 itemIndex: number
66 /** Option order of the current quiz item (from buildQuizOptions). */
67 order: readonly QuizOption[]
68 /** Index into `order` the runner graded, null until an option is pressed. */
69 picked: number | null
70 score: { correct: number; answered: number }
71 flipped: boolean
72 /** Reveal/note key path -> expanded? (default = captured collapsed state). */
73 expanded: Readonly<Record<string, boolean>>
74 /** Views key path -> active view index (default = captured initial). */
75 activeView: Readonly<Record<string, number>>
76}
77
78export type SemTextDoc = {
79 source: string
80 fileName?: string
81 tree: SemTextNode[]
82 elementCount: number
83 unknownElements: string[]
84 firstSeen: number
85}
86
87declare module 'claude-code' {
88 interface PluginState {
89 semtext: {
90 doc: SemTextDoc | null
91 seenSources: string[]
92 viewer: SemTextViewer
93 }
94 }
95}
96