@north-light/crouter 0.3.196 → 0.3.198

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/dist/api/client.d.ts +2 -2
  2. package/dist/api/dto/inbox.d.ts +46 -0
  3. package/dist/builtin-memory/00-runtime-base.md +3 -3
  4. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  5. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  6. package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
  7. package/dist/clients/attach/render/chat-view.d.ts +3 -8
  8. package/dist/clients/attach/render/chat-view.js +3 -24
  9. package/dist/clients/attach/render/markdown-source.js +0 -6
  10. package/dist/clients/attach/render/tool-calls.js +16 -2
  11. package/dist/clients/attach/viewer.js +613 -602
  12. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
  13. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
  14. package/dist/clients/inbox/controller.js +4 -4
  15. package/dist/clients/inbox/page-adapter.d.ts +2 -1
  16. package/dist/clients/inbox/tui/render.js +46 -25
  17. package/dist/clients/inbox/tui/slots.d.ts +1 -3
  18. package/dist/clients/inbox/tui/slots.js +8 -37
  19. package/dist/clients/inbox/tui/types.d.ts +4 -3
  20. package/dist/commands/__tests__/human.test.js +160 -23
  21. package/dist/commands/canvas-browse.js +1 -1
  22. package/dist/commands/human/doc.js +21 -74
  23. package/dist/commands/human/html.d.ts +2 -0
  24. package/dist/commands/human/html.js +81 -0
  25. package/dist/commands/human/prompts.d.ts +5 -4
  26. package/dist/commands/human/prompts.js +90 -168
  27. package/dist/commands/human/shared.d.ts +5 -6
  28. package/dist/commands/human/shared.js +13 -61
  29. package/dist/commands/human.js +7 -2
  30. package/dist/core/__tests__/daemon-boot.test.js +7 -4
  31. package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
  32. package/dist/core/__tests__/human-deliver.test.js +28 -37
  33. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
  34. package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
  35. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
  36. package/dist/core/canvas/__tests__/attention.test.js +2 -2
  37. package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
  38. package/dist/core/canvas/browse/app.d.ts +1 -1
  39. package/dist/core/canvas/browse/app.js +21 -3
  40. package/dist/core/canvas/browse/render.d.ts +4 -0
  41. package/dist/core/canvas/browse/render.js +5 -1
  42. package/dist/core/canvas/canvas.d.ts +26 -0
  43. package/dist/core/canvas/canvas.js +55 -0
  44. package/dist/core/command.js +6 -11
  45. package/dist/core/config.js +3 -2
  46. package/dist/core/help.d.ts +3 -0
  47. package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
  48. package/dist/core/human/__tests__/html-markdown.test.js +52 -0
  49. package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
  50. package/dist/core/human/__tests__/page-catalog.test.js +16 -0
  51. package/dist/core/human/__tests__/page-render.test.js +40 -23
  52. package/dist/core/human/__tests__/page-tickets.test.js +48 -27
  53. package/dist/core/human/__tests__/page.test.js +48 -65
  54. package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
  55. package/dist/core/human/answer-text.d.ts +3 -0
  56. package/dist/core/human/answer-text.js +98 -0
  57. package/dist/core/human/answer.d.ts +51 -0
  58. package/dist/core/human/answer.js +125 -0
  59. package/dist/core/human/component-docs.d.ts +14 -0
  60. package/dist/core/human/component-docs.js +161 -0
  61. package/dist/core/human/html-markdown.d.ts +3 -0
  62. package/dist/core/human/html-markdown.js +288 -0
  63. package/dist/core/human/page-catalog.d.ts +19 -3
  64. package/dist/core/human/page-catalog.js +73 -16
  65. package/dist/core/human/page-render.d.ts +3 -2
  66. package/dist/core/human/page-render.js +19 -45
  67. package/dist/core/human/page-schema.d.ts +108 -8
  68. package/dist/core/human/page-schema.js +88 -31
  69. package/dist/core/human/page-synth.d.ts +2 -4
  70. package/dist/core/human/page-synth.js +23 -7
  71. package/dist/core/human/page.d.ts +14 -7
  72. package/dist/core/human/page.js +178 -90
  73. package/dist/core/human/review-schema.d.ts +1 -0
  74. package/dist/core/human/review-schema.js +1 -1
  75. package/dist/core/human/scan.d.ts +3 -2
  76. package/dist/core/human/scan.js +7 -8
  77. package/dist/core/human/summary.d.ts +4 -7
  78. package/dist/core/human/summary.js +4 -60
  79. package/dist/core/human/tickets.d.ts +10 -9
  80. package/dist/core/human/tickets.js +5 -3
  81. package/dist/core/human/types.d.ts +4 -0
  82. package/dist/core/keybindings/catalog.d.ts +2 -2
  83. package/dist/core/keybindings/catalog.js +1 -0
  84. package/dist/core/review/realize.js +9 -1
  85. package/dist/core/runtime/boot-root.js +6 -1
  86. package/dist/core/runtime/broker.js +8 -6
  87. package/dist/core/runtime/busy.d.ts +4 -3
  88. package/dist/core/runtime/busy.js +4 -3
  89. package/dist/core/runtime/spawn.js +4 -4
  90. package/dist/core/termrender/display.d.ts +7 -3
  91. package/dist/core/termrender/display.js +45 -9
  92. package/dist/core/termrender/termrender.d.ts +3 -12
  93. package/dist/core/termrender/termrender.js +6 -37
  94. package/dist/core/user-settings.d.ts +17 -2
  95. package/dist/core/user-settings.js +32 -11
  96. package/dist/daemon/api/handlers/human.js +1 -1
  97. package/dist/daemon/api/handlers/inbox.js +22 -7
  98. package/dist/daemon/cron-run.js +16 -2
  99. package/dist/daemon/crtrd.js +1 -1
  100. package/dist/daemon/fleet.d.ts +22 -10
  101. package/dist/daemon/fleet.js +43 -24
  102. package/dist/daemon/human/finish.d.ts +2 -1
  103. package/dist/daemon/human/finish.js +45 -28
  104. package/dist/daemon/human/sweep.js +4 -2
  105. package/dist/daemon/reconcilers/broker-supervision.js +11 -8
  106. package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
  107. package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
  108. package/dist/daemon/reconcilers/live-obligation.js +30 -2
  109. package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
  110. package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
  111. package/dist/daemon/review/sweep.js +12 -1
  112. package/dist/pages/bundle.css +1 -1
  113. package/dist/pages/bundle.js +1008 -577
  114. package/dist/pages/comments.d.ts +29 -0
  115. package/dist/pages/comments.js +51 -0
  116. package/dist/pages/controls.d.ts +98 -0
  117. package/dist/pages/controls.js +261 -0
  118. package/dist/pages/elements/cards.d.ts +15 -4
  119. package/dist/pages/elements/cards.js +299 -186
  120. package/dist/pages/elements/chart.d.ts +22 -0
  121. package/dist/pages/elements/chart.js +107 -91
  122. package/dist/pages/elements/options.d.ts +9 -0
  123. package/dist/pages/elements/options.js +268 -155
  124. package/dist/pages/elements/pages.d.ts +6 -0
  125. package/dist/pages/elements/pages.js +60 -38
  126. package/dist/pages/elements/slot.d.ts +5 -0
  127. package/dist/pages/elements/slot.js +17 -10
  128. package/dist/pages/elements/table.d.ts +17 -0
  129. package/dist/pages/elements/table.js +336 -228
  130. package/dist/pages/elements/text.d.ts +9 -3
  131. package/dist/pages/elements/text.js +350 -143
  132. package/dist/pages/host.d.ts +0 -24
  133. package/dist/pages/host.js +0 -17
  134. package/dist/pages/readonly.d.ts +24 -0
  135. package/dist/pages/readonly.js +31 -0
  136. package/dist/pages/responses.d.ts +37 -0
  137. package/dist/pages/responses.js +56 -0
  138. package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
  139. package/dist/pi-extensions/canvas-bash-valve.js +12 -8
  140. package/dist/types.d.ts +19 -5
  141. package/dist/types.js +2 -1
  142. package/package.json +1 -1
  143. package/runtime.lock.json +2 -2
  144. package/scripts/install-runtime.mjs +19 -24
  145. package/dist/clients/attach/render/html-markdown.d.ts +0 -13
  146. package/dist/clients/attach/render/html-markdown.js +0 -206
  147. package/dist/core/human/markdown-html.d.ts +0 -8
  148. package/dist/core/human/markdown-html.js +0 -581
@@ -38,11 +38,11 @@ async function startDaemon() {
38
38
  return server;
39
39
  }
40
40
  const source = (name, body) => { const path = join(sources, name); writeFileSync(path, body); return path; };
41
- const ask = source('ask.md', `---\ntitle: Restored page\nsubtitle: Pick one\nplacement: panel\n---\n\nLeading prose\n\n\`\`\`slot:options\n{"id":"choice","mode":"single","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}\n\`\`\`\n`);
41
+ const ask = source('ask.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Restored page"><meta name="crtr-page-subtitle" content="Pick one"></head><body><p>Leading prose</p><crtr-pages><crtr-page><crtr-options id="choice"><script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}</script></crtr-options></crtr-page></crtr-pages></body></html>');
42
42
  const html = source('rich.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Rich page"><meta name="crtr-page-subtitle" content="Browser content"></head><body><crtr-pages><crtr-page><crtr-chart><script type="application/json">{"kind":"bar","x":"day","y":[{"field":"count"}]}</script></crtr-chart></crtr-page></crtr-pages></body></html>');
43
43
  const server = await startDaemon();
44
44
  try {
45
- const ticket = submitPage({ dir: ticketDir('page-one'), sourceFile: ask, dialect: 'md' });
45
+ const ticket = submitPage({ dir: ticketDir('page-one'), sourceFile: ask, dialect: 'html' });
46
46
  writePageProgress(ticket.dir, { choice: { selectedOptionIds: ['yes'], comments: [] } });
47
47
  const active = controller({ cols: 100, rows: 24 });
48
48
  active.activate();
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
2
2
  import { mountPanel } from '../../tui/panel.js';
3
3
  function key(partial = {}) { return { ctrl: false, meta: false, upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, wordLeft: false, wordRight: false, home: false, end: false, pageUp: false, pageDown: false, del: false, return: false, newline: false, escape: false, tab: false, backspace: false, ...partial }; }
4
4
  const enter = key({ return: true });
5
- const manifest = (slots, extra = {}) => ({ schema: 'crtr.page/v1', dialect: 'md', title: 'Test page', placement: 'panel', document: 'page.md', steps: 1, slots, ...extra });
5
+ const manifest = (slots, extra = {}) => ({ schema: 'crtr.page/v1', dialect: 'html', title: 'Test page', placement: 'panel', document: 'page.html', steps: 1, slots, ...extra });
6
6
  const projection = (slots, prose = []) => prose.length > 0 ? prose : slots.map((slotIndex) => ({ kind: 'slot', slotIndex }));
7
7
  // Option comment is anchored and survives the subsequent single-select completion.
8
8
  const options = manifest([{ id: 'choice', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }] } }]);
@@ -53,7 +53,7 @@ lonePanel.unmount();
53
53
  // An unvalidated slot takes no terminal input and keeps the page unsubmittable.
54
54
  const unvalidated = manifest([{ id: 'widget', kind: 'acme.widget', step: 0, config: {}, unvalidated: true }]);
55
55
  let unvalidatedResponses;
56
- const unvalidatedPanel = mountPanel({ manifest: unvalidated, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { unvalidatedResponses = r; } });
56
+ const unvalidatedPanel = mountPanel({ manifest: unvalidated, projection: projection([0]), cols: 80, rows: 24, productKinds: [{ kind: 'acme.widget' }], onComplete: (r) => { unvalidatedResponses = r; } });
57
57
  unvalidatedPanel.handleKey('c', key());
58
58
  assert.equal(unvalidatedPanel.getInputBuffer(), undefined);
59
59
  unvalidatedPanel.handleKey('n', key());
@@ -66,10 +66,17 @@ const stepped = manifest([
66
66
  { id: 'one', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } },
67
67
  { id: 'two', kind: 'options', step: 1, config: { mode: 'single', options: [{ id: 'b', label: 'Beta' }] } },
68
68
  ], { dialect: 'html', document: 'page.html', steps: 2 });
69
- const steppedPanel = mountPanel({ manifest: stepped, projection: projection([0, 1]), cols: 80, rows: 24 });
69
+ const steppedPanel = mountPanel({ manifest: stepped, projection: projection([], [
70
+ { kind: 'prose', markdown: '<h2>Read <em>this</em></h2><p>Use <span>semantic prose</span>.</p><div class="custom-chrome">Widget</div>' },
71
+ { kind: 'slot', slotIndex: 0 },
72
+ { kind: 'slot', slotIndex: 1 },
73
+ ]), cols: 80, rows: 24 });
70
74
  const overview = steppedPanel.render().join('\n');
75
+ assert.match(overview, /Read this/);
76
+ assert.match(overview, /semantic prose/);
77
+ assert.match(overview, /rich page — open in Northlight/);
78
+ assert.doesNotMatch(overview, /<span/);
71
79
  assert.match(overview, /Step 1\/2/);
72
80
  assert.match(overview, /Step 2\/2/);
73
- assert.match(overview, /rich page — open in Northlight/);
74
81
  steppedPanel.unmount();
75
82
  console.log('OK');
@@ -40,7 +40,7 @@ export class InboxController {
40
40
  constructor(options) {
41
41
  this.options = options;
42
42
  this.host = options.host;
43
- this.scan = options.scan ?? (() => scanInbox(readConfig('user').page_components ?? []));
43
+ this.scan = options.scan ?? (() => scanInbox(readConfig('user').page_components));
44
44
  this.cols = options.cols ?? 80;
45
45
  this.rows = options.rows ?? 24;
46
46
  this.rescan();
@@ -207,7 +207,7 @@ export class InboxController {
207
207
  this.claim = { dir: item.dir, token: claim.token };
208
208
  let loaded;
209
209
  try {
210
- const productKinds = readConfig('user').page_components ?? [];
210
+ const productKinds = readConfig('user').page_components;
211
211
  const ticket = readPageTicket(item.dir, productKinds);
212
212
  loaded = { ticket, projection: parsePageSource(ticket.document, ticket.manifest, productKinds) };
213
213
  }
@@ -218,7 +218,7 @@ export class InboxController {
218
218
  this.invalidate();
219
219
  return;
220
220
  }
221
- const productKinds = readConfig('user').page_components ?? [];
221
+ const productKinds = readConfig('user').page_components;
222
222
  this.screen = 'detail';
223
223
  this.adapter = new PageAdapter({
224
224
  dir: item.dir,
@@ -257,7 +257,7 @@ export class InboxController {
257
257
  return;
258
258
  }
259
259
  try {
260
- const productKinds = readConfig('user').page_components ?? [];
260
+ const productKinds = readConfig('user').page_components;
261
261
  const ticket = readPageTicket(this.claim.dir, productKinds);
262
262
  this.adapter.reload(ticket, parsePageSource(ticket.document, ticket.manifest, productKinds));
263
263
  }
@@ -1,3 +1,4 @@
1
+ import type { ProductPageComponents } from '../../types.js';
1
2
  import type { PageResponses } from '../../core/human/page-schema.js';
2
3
  import type { PageSourceProjection } from '../../core/human/page.js';
3
4
  import type { PageTicket } from '../../core/human/tickets.js';
@@ -6,7 +7,7 @@ export interface PageAdapterOptions {
6
7
  dir: string;
7
8
  ticket: PageTicket;
8
9
  projection: PageSourceProjection;
9
- productKinds: string[];
10
+ productKinds: ProductPageComponents;
10
11
  cols: number;
11
12
  rows: number;
12
13
  onProgress?: (responses: PageResponses) => void;
@@ -1,12 +1,36 @@
1
1
  import stringWidth from 'string-width';
2
2
  import { isResponseBearingSlot } from '../../../core/human/page-schema.js';
3
+ import { answerCommentAnchorLabel, describePageAnswer } from '../../../core/human/answer.js';
3
4
  import { renderMarkdownBlockAwareLines } from '../../../core/termrender/termrender.js';
4
- import { anchorLabel, describeSlot, pageComplete, slotActions, slotAnswered, summarizeSlotResponse, tableMarkdown, terminalAnswerable } from './slots.js';
5
+ import { renderHtmlProseAsMarkdown } from '../../../core/human/html-markdown.js';
6
+ import { describeSlot, pageComplete, slotActions, slotAnswered, tableMarkdown, terminalAnswerable } from './slots.js';
5
7
  import { RESET, BOLD, DIM, GREEN, YELLOW, CYAN, REVERSE, sanitize, singleLine, truncate, hline, hardWrap, centerHorizontal, clipLine, panLine, visibleWidth } from './ansi.js';
6
8
  const PAN_STEP = 8;
7
9
  const mdLines = (md, maxW, paneW) => renderMarkdownBlockAwareLines(md, maxW, paneW);
8
- function proseLines(blocks, maxW, paneW) {
9
- return blocks.flatMap((block, index) => [...(index > 0 ? [''] : []), ...mdLines(block, maxW, paneW)]);
10
+ function proseLines(blocks, maxW, paneW, dialect) {
11
+ const rendered = blocks.map((block) => dialect === 'html' ? renderHtmlProseAsMarkdown(block) : block).filter((block) => block.trim() !== '');
12
+ return rendered.flatMap((block, index) => [...(index > 0 ? [''] : []), ...mdLines(block, maxW, paneW)]);
13
+ }
14
+ function pageAnswer(state) {
15
+ return describePageAnswer(state.manifest, Object.fromEntries(state.responses), { completedAt: '', productKinds: state.productKinds });
16
+ }
17
+ function answerSlot(answer, slot) {
18
+ return slot.id === undefined ? undefined : answer.slots.find((candidate) => candidate.id === slot.id);
19
+ }
20
+ function answerSummary(slot) {
21
+ if (slot === undefined || slot.state === 'unanswered')
22
+ return 'unanswered';
23
+ if (slot.raw !== undefined)
24
+ return 'custom response';
25
+ const groups = slot.text !== undefined ? [slot.text.edited ? 'edited' : 'accepted'] : slot.groups.map((group) => group.picked.length === 0 ? 'none' : group.picked.map((item) => item.label).join(', '));
26
+ if (slot.freetext !== undefined)
27
+ groups.push(slot.freetext);
28
+ if (slot.comments.length > 0)
29
+ groups.push(`${slot.comments.length} comment${slot.comments.length === 1 ? '' : 's'}`);
30
+ return groups.join('; ') || 'answered';
31
+ }
32
+ function commentLabel(slot, anchor) {
33
+ return answerCommentAnchorLabel(slot, anchor);
10
34
  }
11
35
  export function renderOverview(state, cols, rows) {
12
36
  const maxW = Math.min(Math.max(20, cols - 4), 120);
@@ -14,11 +38,9 @@ export function renderOverview(state, cols, rows) {
14
38
  if (state.manifest.subtitle)
15
39
  lines.push(` ${DIM}${sanitize(state.manifest.subtitle)}${RESET}`);
16
40
  lines.push(` ${DIM}${hline(maxW)}${RESET}`, '');
17
- if (state.manifest.dialect === 'html')
18
- lines.push(` ${DIM}rich page — open in Northlight${RESET}`, '');
19
- else
20
- for (const line of proseLines(state.overviewProse, maxW, Math.max(20, cols - 4)))
21
- lines.push(` ${line}`);
41
+ for (const line of proseLines(state.overviewProse, maxW, Math.max(20, cols - 4), state.manifest.dialect))
42
+ lines.push(` ${line}`);
43
+ const answer = pageAnswer(state);
22
44
  let priorStep = -1;
23
45
  for (let i = 0; i < state.slots.length; i++) {
24
46
  const slot = state.slots[i];
@@ -33,7 +55,7 @@ export function renderOverview(state, cols, rows) {
33
55
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
34
56
  lines.push(` ${cursor}${icon} ${truncate(singleLine(describeSlot(slot)), Math.max(10, cols - 16))}`);
35
57
  if (response !== undefined)
36
- lines.push(` ${DIM}${truncate(singleLine(summarizeSlotResponse(slot, response)), Math.max(10, cols - 10))}${RESET}`);
58
+ lines.push(` ${DIM}${truncate(singleLine(answerSummary(answerSlot(answer, slot))), Math.max(10, cols - 10))}${RESET}`);
37
59
  }
38
60
  if (state.slots.length === 0)
39
61
  lines.push(` ${DIM}No interactive slots${RESET}`);
@@ -109,11 +131,8 @@ function itemLines(state, cols) {
109
131
  const lines = ['', ` ${BOLD}${CYAN}[${state.currentIndex + 1}/${state.slots.length}]${RESET} ${DIM}${sanitize(state.manifest.title)}${RESET}`, ` ${DIM}${hline(maxW)}${RESET}`, '', ` ${BOLD}${sanitize(describeSlot(slot))}${RESET}`];
110
132
  // A single-slot page opens straight into item-review, so its leading prose has nowhere else to render.
111
133
  const prose = state.slots.length === 1 ? [...state.overviewProse, ...(state.slotProse.get(0) ?? [])] : state.slotProse.get(state.currentIndex) ?? [];
112
- if (state.manifest.dialect === 'html')
113
- lines.push('', ` ${DIM}rich page — open in Northlight${RESET}`);
114
- else
115
- for (const line of proseLines(prose, maxW, paneW))
116
- lines.push(` ${line}`);
134
+ for (const line of proseLines(prose, maxW, paneW, state.manifest.dialect))
135
+ lines.push(` ${line}`);
117
136
  if (!terminalAnswerable(slot))
118
137
  lines.push('', ` ${DIM}${sanitize(slot.kind)} — cannot be answered in the terminal; open in Northlight${RESET}`);
119
138
  else if (slot.kind === 'chart') {
@@ -125,9 +144,9 @@ function itemLines(state, cols) {
125
144
  lines.push('');
126
145
  for (const line of mdLines(tableMarkdown(slot.config), maxW, paneW))
127
146
  lines.push(` ${line}`);
128
- const caption = slot.config.caption;
129
- if (caption)
130
- lines.push(` ${DIM}${sanitize(caption)}${RESET}`);
147
+ const label = slot.config.label;
148
+ if (label)
149
+ lines.push(` ${DIM}${sanitize(label)}${RESET}`);
131
150
  }
132
151
  else if (slot.kind === 'text') {
133
152
  const response = slot.id === undefined ? undefined : state.responses.get(slot.id);
@@ -138,7 +157,7 @@ function itemLines(state, cols) {
138
157
  }
139
158
  lines.push('', ` ${DIM}${hline(maxW)}${RESET}`);
140
159
  if (state.inputMode !== null) {
141
- const label = state.inputMode.kind === 'comment' ? `Comment on ${anchorLabel(slot, state.inputMode.anchor ?? { kind: 'whole' })}` : state.inputMode.kind === 'text' ? 'Edit text' : 'Other response';
160
+ const label = state.inputMode.kind === 'comment' ? `Comment on ${commentLabel(slot, state.inputMode.anchor ?? { kind: 'whole' })}` : state.inputMode.kind === 'text' ? 'Edit text' : 'Other response';
142
161
  lines.push(` ${BOLD}${label}${RESET}`);
143
162
  for (const line of renderInputBuffer(state.inputMode.buffer, state.inputMode.cursor, Math.max(10, maxW - 4)))
144
163
  lines.push(` ${line}`);
@@ -160,7 +179,7 @@ function itemLines(state, cols) {
160
179
  }
161
180
  });
162
181
  for (const comment of responseComments(state, slot))
163
- lines.push(` ${DIM}attached: ${anchorLabel(slot, comment.anchor)} — ${sanitize(comment.text)}${RESET}`);
182
+ lines.push(` ${DIM}attached: ${commentLabel(slot, comment.anchor)} — ${sanitize(comment.text)}${RESET}`);
164
183
  if (state.hint)
165
184
  lines.push(` ${YELLOW}${sanitize(state.hint)}${RESET}`);
166
185
  lines.push(` ${DIM}n/p next/previous j/k actions c comment u/d scroll h/l pan q close${RESET}`);
@@ -195,13 +214,15 @@ export function renderItemReview(state, cols, rows) {
195
214
  export function renderFinal(state, cols, rows) {
196
215
  const maxW = Math.min(Math.max(20, cols - 4), 120);
197
216
  const lines = ['', ` ${BOLD}${CYAN}${sanitize(state.manifest.title)} — Summary${RESET}`, ` ${DIM}${hline(maxW)}${RESET}`, ''];
198
- for (const slot of state.slots) {
199
- const response = slot.id === undefined ? undefined : state.responses.get(slot.id);
200
- lines.push(` ${slotAnswered(state, slot) ? `${GREEN}✓${RESET}` : isResponseBearingSlot(slot) ? `${YELLOW}○${RESET}` : `${DIM}·${RESET}`} ${BOLD}${sanitize(describeSlot(slot))}${RESET} ${DIM}${sanitize(summarizeSlotResponse(slot, response))}${RESET}`);
217
+ const answer = pageAnswer(state);
218
+ for (const slot of answer.slots) {
219
+ const manifestSlot = state.manifest.slots.find((candidate) => candidate.id === slot.id);
220
+ const answered = manifestSlot !== undefined && slotAnswered(state, manifestSlot);
221
+ const mark = answered ? `${GREEN}✓${RESET}` : `${YELLOW}○${RESET}`;
222
+ lines.push(` ${mark} ${BOLD}${sanitize(manifestSlot === undefined ? slot.kind : describeSlot(manifestSlot))}${RESET} ${DIM}${sanitize(answerSummary(slot))}${RESET}`);
201
223
  }
202
- if (state.manifest.dialect !== 'html')
203
- for (const line of proseLines(state.finalProse, maxW, Math.max(20, cols - 4)))
204
- lines.push(` ${line}`);
224
+ for (const line of proseLines(state.finalProse, maxW, Math.max(20, cols - 4), state.manifest.dialect))
225
+ lines.push(` ${line}`);
205
226
  const complete = pageComplete(state);
206
227
  if (!complete) {
207
228
  lines.push('', ` ${YELLOW}Cannot submit yet.${RESET}`);
@@ -1,4 +1,4 @@
1
- import type { CommentAnchor, PageManifest, PageSlot, SlotResponse, TableConfig } from '../../../core/human/page-schema.js';
1
+ import type { CommentAnchor, PageManifest, PageSlot, TableConfig } from '../../../core/human/page-schema.js';
2
2
  import type { TuiState } from './types.js';
3
3
  export type SelectGroup = 'option' | 'card' | 'row' | 'column';
4
4
  export type ActionRow = {
@@ -24,9 +24,7 @@ export declare function describeSlot(slot: PageSlot): string;
24
24
  export declare function terminalAnswerable(slot: PageSlot): boolean;
25
25
  export declare function slotActions(slot: PageSlot): ActionRow[];
26
26
  export declare function tableMarkdown(config: TableConfig): string;
27
- export declare function summarizeSlotResponse(slot: PageSlot, response: SlotResponse | undefined): string;
28
27
  export declare function slotAnswered(state: TuiState, slot: PageSlot): boolean;
29
28
  export declare function pageComplete(state: Pick<TuiState, 'manifest' | 'responses'>): boolean;
30
29
  export declare function commentIdFor(anchor: CommentAnchor): string;
31
- export declare function anchorLabel(slot: PageSlot, anchor: CommentAnchor): string;
32
30
  export declare function awaitsResponse(manifest: PageManifest): boolean;
@@ -1,6 +1,9 @@
1
1
  import { isResponseBearingSlot, validatePageResponses, validatePartialPageResponses } from '../../../core/human/page-schema.js';
2
2
  const RESERVED = new Set(['c', 'r', 'e', 'a', 'n', 'p', 'q', 'j', 'k', 'u', 'd', ' ']);
3
3
  export function describeSlot(slot) {
4
+ const label = slot.config.label;
5
+ if (typeof label === 'string' && label !== '')
6
+ return label;
4
7
  if (slot.unvalidated === true)
5
8
  return slot.kind;
6
9
  if (slot.kind === 'options')
@@ -8,7 +11,7 @@ export function describeSlot(slot) {
8
11
  if (slot.kind === 'text')
9
12
  return 'Text';
10
13
  if (slot.kind === 'table')
11
- return slot.config.caption ?? 'Table';
14
+ return 'Table';
12
15
  if (slot.kind === 'cards')
13
16
  return 'Cards';
14
17
  if (slot.kind === 'chart')
@@ -38,7 +41,10 @@ export function slotActions(slot) {
38
41
  rows = (config.cards ?? []).map((card) => ({ kind: 'select', group: 'card', id: card.id, label: card.subtitle === undefined ? card.title : `${card.title} — ${card.subtitle}`, ...(card.body === undefined ? {} : { description: card.body }), mode: config.mode }));
39
42
  }
40
43
  else if (slot.kind === 'text') {
41
- if (slot.config.editable === true)
44
+ const config = slot.config;
45
+ // Text is a writing surface unless the author explicitly made it review-only, and a
46
+ // one-line field is an input by definition — the same contract the page kit presents.
47
+ if (config.singleLine === true || config.editable !== false)
42
48
  rows.push({ kind: 'edit', label: 'Edit' });
43
49
  rows.push({ kind: 'accept', label: 'Accept as-is' });
44
50
  }
@@ -84,28 +90,6 @@ export function tableMarkdown(config) {
84
90
  const divider = `| ${config.columns.map((column) => column.align === 'right' ? '---:' : '---').join(' | ')} |`;
85
91
  return [header, divider, ...(config.rows ?? []).map((row) => `| ${config.columns.map((column) => escapeCell(row.cells[column.id], column.mono === true)).join(' | ')} |`)].join('\n');
86
92
  }
87
- export function summarizeSlotResponse(slot, response) {
88
- if (response === undefined)
89
- return isResponseBearingSlot(slot) ? 'unanswered' : 'display';
90
- if (slot.kind === 'options') {
91
- const r = response;
92
- const labels = slot.config.options.filter((o) => r.selectedOptionIds.includes(o.id)).map((o) => o.label);
93
- if ((r.freetext?.trim() ?? '') !== '')
94
- labels.push(r.freetext.trim());
95
- return labels.join(', ') || (r.comments.length > 0 ? `${r.comments.length} comment${r.comments.length === 1 ? '' : 's'}` : 'answered');
96
- }
97
- if (slot.kind === 'cards') {
98
- const r = response;
99
- return slot.config.cards?.filter((c) => r.selectedCardIds.includes(c.id)).map((c) => c.title).join(', ') || 'answered';
100
- }
101
- if (slot.kind === 'table') {
102
- const r = response;
103
- return `${r.selectedRowIds.length} row${r.selectedRowIds.length === 1 ? '' : 's'}, ${r.selectedColumnIds.length} column${r.selectedColumnIds.length === 1 ? '' : 's'}`;
104
- }
105
- if (slot.kind === 'text')
106
- return response.edited ? 'edited' : 'accepted';
107
- return 'answered';
108
- }
109
93
  export function slotAnswered(state, slot) {
110
94
  if (!terminalAnswerable(slot) || slot.id === undefined || !state.responses.has(slot.id))
111
95
  return false;
@@ -138,19 +122,6 @@ export function commentIdFor(anchor) {
138
122
  case 'range': return `range:${anchor.start}:${anchor.end}`;
139
123
  }
140
124
  }
141
- export function anchorLabel(slot, anchor) {
142
- if (anchor.kind === 'whole')
143
- return describeSlot(slot);
144
- if (anchor.kind === 'option')
145
- return slot.config.options.find((x) => x.id === anchor.optionId)?.label ?? anchor.optionId;
146
- if (anchor.kind === 'card')
147
- return slot.config.cards?.find((x) => x.id === anchor.cardId)?.title ?? anchor.cardId;
148
- if (anchor.kind === 'row')
149
- return slot.config.rows?.find((x) => x.id === anchor.rowId)?.id ?? anchor.rowId;
150
- if (anchor.kind === 'column')
151
- return slot.config.columns.find((x) => x.id === anchor.columnId)?.label ?? anchor.columnId;
152
- return anchor.quote;
153
- }
154
125
  export function awaitsResponse(manifest) {
155
126
  return manifest.slots.some(isResponseBearingSlot);
156
127
  }
@@ -1,4 +1,5 @@
1
1
  import type { Key } from './keys.js';
2
+ import type { ProductPageComponents } from '../../../types.js';
2
3
  import type { CommentAnchor, PageManifest, PageResponses, PageSlot, SlotResponse } from '../../../core/human/page-schema.js';
3
4
  import type { PageSourceProjection } from '../../../core/human/page.js';
4
5
  export type Phase = 'overview' | 'item-review' | 'final';
@@ -25,7 +26,7 @@ export interface TuiState {
25
26
  slotProse: Map<number, string[]>;
26
27
  finalProse: string[];
27
28
  responses: Map<string, SlotResponse>;
28
- productKinds: string[];
29
+ productKinds: ProductPageComponents;
29
30
  inputMode: InputMode;
30
31
  selectedAction: number;
31
32
  bodyMode: 'question';
@@ -43,7 +44,7 @@ export interface TuiState {
43
44
  export interface MountedPanelOpts {
44
45
  manifest: PageManifest;
45
46
  projection: PageSourceProjection;
46
- productKinds?: string[];
47
+ productKinds?: ProductPageComponents;
47
48
  initialResponses?: PageResponses | null;
48
49
  cols: number;
49
50
  rows: number;
@@ -60,7 +61,7 @@ export interface MountedPanel {
60
61
  unmount(): void;
61
62
  loadPage(manifest: PageManifest, projection: PageSourceProjection, opts?: {
62
63
  initialResponses?: PageResponses | null;
63
- productKinds?: string[];
64
+ productKinds?: ProductPageComponents;
64
65
  }): void;
65
66
  canAcceptHostKeys(): boolean;
66
67
  atPageTop(): boolean;
@@ -1,16 +1,79 @@
1
- // Regression: a caller passing BOTH a positional AND piped stdin (e.g.
2
- // `crtr node new --kind x "title" < prompt.md`, or `crtr human ask -
3
- // <<EOF`) used to have the positional silently win, with the entire piped
4
- // body dropped on the floor. `-` must always mean "read stdin", and any
5
- // OTHER positional alongside genuinely piped, non-empty stdin must error
6
- // loudly rather than silently pick one.
7
- //
8
1
  // Run with: node --import tsx/esm --test 'src/commands/__tests__/human.test.ts'
9
2
  import { test, describe } from 'node:test';
10
3
  import assert from 'node:assert/strict';
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
11
7
  import { Readable } from 'node:stream';
12
8
  import { parseArgv } from '../../core/command.js';
13
- import { humanAsk } from '../human/prompts.js';
9
+ import { renderLeafArgv } from '../../core/help.js';
10
+ import { renderComponentCatalog, renderComponentContract } from '../../core/human/component-docs.js';
11
+ import { humanHtml } from '../human/html.js';
12
+ import { humanAsk, humanNotify } from '../human/prompts.js';
13
+ import { registerHuman } from '../human.js';
14
+ import { pageHelpText } from '../human/shared.js';
15
+ const nlThreadRegistration = {
16
+ kind: 'nl-thread',
17
+ description: 'message conversation with participants and metadata',
18
+ useWhen: 'use when the human needs to read a conversation - an email chain, a LinkedIn DM, an iMessage exchange - in message order, usually above whatever the page asks them to decide',
19
+ display: true,
20
+ doc: [
21
+ '`nl-thread` renders a conversation as bubbles down one column: your own messages on the right in the accent, the other party on the left, consecutive messages from one sender grouped under a single avatar.',
22
+ '',
23
+ 'Display only: it contributes nothing to the response, stays readable and expandable on a resolved page, and does not require a slot `id`.',
24
+ '',
25
+ '## Config',
26
+ '',
27
+ '| field | type | required | meaning |',
28
+ '| --- | --- | --- | --- |',
29
+ '| `label` | string | no | the heading above the thread - what this conversation is, in your own words |',
30
+ '| `platform` | string | no | where it happened (`email`, `linkedin`, `imessage`, `slack`); shown as a small chip |',
31
+ '| `messages` | message[] | no | the conversation, oldest first; an empty or absent list renders "No messages in this thread." |',
32
+ '| `participants` | participant[] | no | display identities, joined to messages by handle |',
33
+ '| `collapsedByDefault` | boolean | no | open on the last 3 messages, the rest behind a "Show N earlier messages" control |',
34
+ '',
35
+ 'Unknown config properties are rejected: the slot then renders an unavailable notice naming the offending key.',
36
+ '',
37
+ '### message',
38
+ '',
39
+ '| field | type | required | meaning |',
40
+ '| --- | --- | --- | --- |',
41
+ '| `sender` | string | yes | `self` is the viewer, `them` the unnamed other party; any other value is a named sender |',
42
+ '| `body` | string | yes | plain text; authored newlines are preserved, markdown is NOT rendered |',
43
+ '| `senderName` | string | no | display name for this message, winning over the participant roster |',
44
+ '| `senderHandle` | string | no | the raw address - phone, email, profile id - joined against `participants` |',
45
+ '| `timestamp` | string | no | ISO 8601; a clock within today, date + time before that. Anything unparseable is shown verbatim |',
46
+ '',
47
+ '### participant',
48
+ '',
49
+ '| field | type | required | meaning |',
50
+ '| --- | --- | --- | --- |',
51
+ '| `handle` | string | yes | matches the `senderHandle` on a message |',
52
+ '| `name` | string | no | display name for every message from that handle |',
53
+ '| `avatarDataUrl` | string | no | a `data:image/...` URL ONLY. A page never fetches, so an http(s) avatar is rejected - omit it and initials are drawn instead |',
54
+ '',
55
+ 'There is no `source` field and no artifact reference: messages are always inline in the config.',
56
+ '',
57
+ '## Example',
58
+ '',
59
+ '```html',
60
+ '<crtr-slot kind="nl-thread" slot-id="conversation">',
61
+ ' <script type="application/json">',
62
+ ' {',
63
+ ' "label": "Your thread with Dana Whitfield",',
64
+ ' "platform": "linkedin",',
65
+ ' "collapsedByDefault": true,',
66
+ ' "participants": [{ "handle": "dana-whitfield", "name": "Dana Whitfield" }],',
67
+ ' "messages": [',
68
+ ' { "sender": "them", "senderHandle": "dana-whitfield", "timestamp": "2026-03-02T15:04:00Z", "body": "Saw the launch, congrats." },',
69
+ ' { "sender": "self", "timestamp": "2026-03-02T16:20:00Z", "body": "Thank you! Want a walkthrough next week?" }',
70
+ ' ]',
71
+ ' }',
72
+ ' </script>',
73
+ '</crtr-slot>',
74
+ '```',
75
+ ].join('\n'),
76
+ };
14
77
  async function withPipedStdin(body, fn) {
15
78
  const original = process.stdin;
16
79
  const stdin = Readable.from([Buffer.from(body)]);
@@ -23,25 +86,99 @@ async function withPipedStdin(body, fn) {
23
86
  Object.defineProperty(process, 'stdin', { value: original, configurable: true });
24
87
  }
25
88
  }
26
- describe('human ask: positional vs piped stdin', () => {
27
- test('`-` positional reads stdin instead of taking the literal value', async () => {
28
- const body = 'Should we ship today?\nmulti-line brief\n';
29
- const result = await withPipedStdin(body, () => parseArgv(humanAsk.help.params ?? [], ['-']));
30
- assert.equal(result['question'], body);
89
+ function withHtmlSurface(htmlSurface, run) {
90
+ const home = mkdtempSync(join(tmpdir(), 'crtr-human-help-'));
91
+ const originalHome = process.env['HOME'];
92
+ process.env['HOME'] = home;
93
+ mkdirSync(join(home, '.crouter'), { recursive: true });
94
+ writeFileSync(join(home, '.crouter', 'config.json'), JSON.stringify({ html_surface: htmlSurface }));
95
+ try {
96
+ return run();
97
+ }
98
+ finally {
99
+ if (originalHome === undefined)
100
+ delete process.env['HOME'];
101
+ else
102
+ process.env['HOME'] = originalHome;
103
+ rmSync(home, { recursive: true, force: true });
104
+ }
105
+ }
106
+ describe('human page input', () => {
107
+ test('stdin supplies the authored page document', async () => {
108
+ const document = '<!doctype html><html><head><meta name="crtr-page-title" content="Question"></head><body></body></html>';
109
+ const result = await withPipedStdin(document, () => parseArgv(humanAsk.help.params ?? [], []));
110
+ assert.equal(result['document'], document);
111
+ });
112
+ test('empty stdin remains empty alongside --page', async () => {
113
+ const result = await withPipedStdin('', () => parseArgv(humanAsk.help.params ?? [], ['--page', 'page.html']));
114
+ assert.equal(result['page'], 'page.html');
115
+ assert.equal(result['document'], '');
31
116
  });
32
- test('positional + piped non-empty stdin → loud bad_invocation naming both sources', async () => {
33
- await assert.rejects(() => withPipedStdin('piped body', () => parseArgv(humanAsk.help.params ?? [], ['Should we ship today?'])), (err) => {
117
+ test('a positional document is rejected', async () => {
118
+ await assert.rejects(() => parseArgv(humanAsk.help.params ?? [], ['page.html']), (err) => {
34
119
  assert.equal(err.code, 'bad_invocation');
35
- assert.match(err.message, /positional/);
36
- assert.match(err.message, /stdin/);
37
- assert.match(err.message, /Should we ship today\?/);
38
- assert.equal(err.details?.field, 'question');
39
- assert.match(err.details?.next ?? '', /exactly one/);
120
+ assert.match(err.message, /takes no positional arguments/);
121
+ assert.match(err.details?.next ?? '', /schema/);
40
122
  return true;
41
123
  });
42
124
  });
43
- test('positional with empty piped stdin (no TTY, no bytes) still uses the positional', async () => {
44
- const result = await withPipedStdin('', () => parseArgv(humanAsk.help.params ?? [], ['Should we ship today?']));
45
- assert.equal(result['question'], 'Should we ship today?');
125
+ test('ask and notify help keep page authoring at the human branch', () => {
126
+ const expected = (name, summary, output, effects) => `human ${name}: ${summary}.\n\nPage authoring: \`crtr human -h\`.\n\nInput\n stdin optional. Page bytes from stdin. Provide exactly one of stdin or --page PATH.\n --page PAGE path, optional. Write the page to \`$CRTR_CONTEXT_DIR/pages/<name>.html\`; pass that path. Provide exactly one of --page or stdin.\n\nOutput (fields carried in the rendered result)\n${output}\n\nEffects\n${effects}`;
127
+ assert.equal(renderLeafArgv(humanAsk.help), expected('ask', "submit a decision ticket to crouter's inbox; returns a job handle immediately", ' job_id string. Node id of this human interaction. Its answer is pushed to your inbox when the human responds.\n dir string. Interaction directory holding page.html, page.json, run.json, and response.json.\n follow_up string. A non-blocking status peek; the answer arrives via your inbox.', ' Creates a kind:"human" node under you and enqueues a page in the human inbox.\n Returns immediately; nothing opens on screen. The human answers from their inbox on their own time.'));
128
+ assert.equal(renderLeafArgv(humanNotify.help), expected('notify', 'enqueue a fire-and-forget acknowledgement in the human inbox; creates no job', ' queued boolean. Always true — the notify page is durable inbox state the human acknowledges from the inbox.\n dir string. Interaction directory holding page.html, page.json, and run.json.', ' Enqueues a durable display page in the human inbox. Creates no node; nothing opens on screen.'));
46
129
  });
47
130
  });
131
+ test('human help pins HTML prompting to the html_surface setting', () => {
132
+ const originalAuthoring = 'Write one complete HTML document to `$CRTR_CONTEXT_DIR/pages/<name>.html`. Its `<head>` has required `<meta name="crtr-page-title" content="…">`, optional `<meta name="crtr-page-subtitle" content="…">`, and optional `<meta name="crtr-page-placement" content="inline|panel">` (default `panel`); interactive or multi-step pages need one `<crtr-pages>` wrapper with `<crtr-page>` steps, and each component config is its first `<script type="application/json">` child. Display-only pages are first-class: a page with no questions is a legitimate, encouraged way to show structured UI with `notify --page`.';
133
+ const originalRootUseWhen = 'when a person must decide, review a document with you, or receive a live display.';
134
+ const htmlAuthoring = 'Use inline HTML pages as the default way to put structured or interactive content in front of the human; the environment renders web content, so a page is not a special occasion. Write one complete HTML document to `$CRTR_CONTEXT_DIR/pages/<name>.html`. Its `<head>` has required `<meta name="crtr-page-title" content="…">`, optional `<meta name="crtr-page-subtitle" content="…">`, and optional `<meta name="crtr-page-placement" content="inline|panel">` (default `panel`); interactive or multi-step pages need one `<crtr-pages>` wrapper with `<crtr-page>` steps, and each component config is its first `<script type="application/json">` child. Display-only pages are first-class: a page with no questions is a legitimate, encouraged way to show structured UI with `notify --page`.';
135
+ const htmlRootUseWhen = 'when a person must decide, review a document with you, or receive a live display; use inline HTML pages by default for structured or interactive content because the environment renders web content.';
136
+ withHtmlSurface(false, () => {
137
+ const human = registerHuman();
138
+ assert.equal(pageHelpText(false).authoring, originalAuthoring);
139
+ assert.equal(human.rootEntry?.useWhen, originalRootUseWhen);
140
+ assert.ok(human.help.model?.includes(originalAuthoring));
141
+ });
142
+ withHtmlSurface(true, () => {
143
+ const human = registerHuman();
144
+ assert.equal(pageHelpText(true).authoring, htmlAuthoring);
145
+ assert.equal(human.rootEntry?.useWhen, htmlRootUseWhen);
146
+ assert.ok(human.help.model?.includes(htmlAuthoring));
147
+ });
148
+ });
149
+ test('human html catalog and contracts teach product registrations', () => {
150
+ const legacyRegistration = { kind: 'product-card' };
151
+ const catalog = renderComponentCatalog([nlThreadRegistration, legacyRegistration]);
152
+ assert.match(catalog, /^<components count="9">\n`<crtr-pages>`/);
153
+ assert.match(catalog, /\n`nl-thread` — message conversation with participants and metadata\. Use when the human needs to read a conversation - an email chain, a LinkedIn DM, an iMessage exchange - in message order, usually above whatever the page asks them to decide\. Display-only\n/);
154
+ assert.match(catalog, /\n`product-card`\nproduct-registered\n<\/components>$/);
155
+ assert.doesNotMatch(catalog, /Config JSON|Response rules/);
156
+ assert.equal(renderComponentContract('nl-thread', [nlThreadRegistration]), `\`nl-thread\`\n\n${nlThreadRegistration.doc}`);
157
+ assert.equal(renderComponentContract('product-card', [legacyRegistration]), "`product-card`\n\nproduct-registered; config is passed through unvalidated (unvalidated: true) — see the product's own docs");
158
+ });
159
+ test('human html show options output is byte-stable', async () => {
160
+ const show = humanHtml.children.find((child) => child.name === 'show');
161
+ assert.ok(show !== undefined && show.kind === 'leaf');
162
+ const result = await show.run({ kind: 'options' });
163
+ assert.ok(result !== undefined);
164
+ assert.equal(show.render?.(result), `\`<crtr-options>\`
165
+
166
+ Response-bearing. The response is \`{selectedOptionIds:string[], comments:Comment[], freetext?:string}\`.
167
+
168
+ Element contract
169
+ - Put the JSON config in the first direct \`<script type="application/json">\` child of the component element. It must contain one JSON object.
170
+ - \`id\` must match \`^[A-Za-z0-9_-]{1,64}$\`, be unique across the page when present, and is required only for response-bearing components. Display-only components do not need an id.
171
+ - When a page has two or more response-bearing components, every one needs a nonempty \`config.label\`.
172
+ - Config objects and every documented nested object reject unknown fields.
173
+
174
+ Config JSON
175
+ - \`options\` — required nonempty array of \`{id:string (nonempty, unique), label:string (nonempty), description?:string}\`.
176
+ - \`mode\` — required: \`single\` or \`multi\`.
177
+ - \`label?:string\` — slot label; required by the multi-response page rule.
178
+ - \`allowFreetext?:boolean\`, \`freetextLabel?:string\`, \`freetextPlaceholder?:string\` — optional free-text affordance fields.
179
+
180
+ Response rules
181
+ - \`selectedOptionIds\` contains unique known option ids. A \`single\` response has at most one selected id and requires exactly one selection or nonempty allowed \`freetext\`; a \`multi\` response requires a selection, comment, or nonempty allowed \`freetext\`.
182
+ - \`freetext\` is accepted only when \`allowFreetext:true\`.
183
+ - Comments are \`{id:string (nonempty), text:string, anchor:Anchor}\`. Every response carries \`comments\` as an array. \`Anchor\` is one of \`{kind:"whole"}\`, \`{kind:"option",optionId:string}\`, \`{kind:"row",rowId:string}\`, \`{kind:"column",columnId:string}\`, \`{kind:"card",cardId:string}\`, or \`{kind:"range",start:nonnegative integer,end:nonnegative integer >= start,quote:string}\`; each component accepts only the anchors named below. Options accept only \`whole\` and \`option\` anchors; \`optionId\` must name a configured option.`);
184
+ });
@@ -12,7 +12,7 @@ import { getDefaultProfileId } from '../core/profiles/default-binding.js';
12
12
  export const browseLeaf = defineLeaf({
13
13
  name: 'browse',
14
14
  description: 'open the interactive canvas navigator (tabs/tree/search)',
15
- whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Attention/Pinned), an expandable tree (children auto-collapsed; → to expand), `/` fuzzy search, `f` to pin, `m` to message and `R` to rename a node in place, and `?` for the full keymap; Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
15
+ whenToUse: 'you want to VIEW and NAVIGATE the whole canvas interactively — a full-screen TUI with tabs (All/Live/Dormant/Attention/Pinned), an expandable tree (children auto-collapsed; → to expand), `/` fuzzy search, `f` to pin, `y` to copy a node id, `m` to message and `R` to rename a node in place, and `?` for the full keymap; it opens on Live (active/idle) and Enter resumes the chosen node. Use this to find your way around a large canvas. Use `canvas dashboard` instead for a one-shot ASCII tree you can pipe, and `node inspect list` for a flat machine-readable roster',
16
16
  help: {
17
17
  name: 'canvas browse',
18
18
  summary: 'interactive full-screen canvas navigator — tabs, an auto-collapsed tree, and `/` fuzzy search; Enter resumes the chosen node via `crtr surface node focus`. Outside a TTY it prints the static forest and exits',