@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.
- package/dist/api/client.d.ts +2 -2
- package/dist/api/dto/inbox.d.ts +46 -0
- package/dist/builtin-memory/00-runtime-base.md +3 -3
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +1 -1
- package/dist/clients/attach/render/chat-view.d.ts +3 -8
- package/dist/clients/attach/render/chat-view.js +3 -24
- package/dist/clients/attach/render/markdown-source.js +0 -6
- package/dist/clients/attach/render/tool-calls.js +16 -2
- package/dist/clients/attach/viewer.js +613 -602
- package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +2 -2
- package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +11 -4
- package/dist/clients/inbox/controller.js +4 -4
- package/dist/clients/inbox/page-adapter.d.ts +2 -1
- package/dist/clients/inbox/tui/render.js +46 -25
- package/dist/clients/inbox/tui/slots.d.ts +1 -3
- package/dist/clients/inbox/tui/slots.js +8 -37
- package/dist/clients/inbox/tui/types.d.ts +4 -3
- package/dist/commands/__tests__/human.test.js +160 -23
- package/dist/commands/canvas-browse.js +1 -1
- package/dist/commands/human/doc.js +21 -74
- package/dist/commands/human/html.d.ts +2 -0
- package/dist/commands/human/html.js +81 -0
- package/dist/commands/human/prompts.d.ts +5 -4
- package/dist/commands/human/prompts.js +90 -168
- package/dist/commands/human/shared.d.ts +5 -6
- package/dist/commands/human/shared.js +13 -61
- package/dist/commands/human.js +7 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -4
- package/dist/core/__tests__/dead-node-policy-table.test.js +20 -36
- package/dist/core/__tests__/human-deliver.test.js +28 -37
- package/dist/core/__tests__/seam/broker-crash-teardown.test.js +13 -11
- package/dist/core/__tests__/seam/dormancy-release.test.js +63 -4
- package/dist/core/__tests__/serial/human-deliver-e2e.test.js +3 -13
- package/dist/core/canvas/__tests__/attention.test.js +2 -2
- package/dist/core/canvas/__tests__/render-remote.test.js +2 -2
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +21 -3
- package/dist/core/canvas/browse/render.d.ts +4 -0
- package/dist/core/canvas/browse/render.js +5 -1
- package/dist/core/canvas/canvas.d.ts +26 -0
- package/dist/core/canvas/canvas.js +55 -0
- package/dist/core/command.js +6 -11
- package/dist/core/config.js +3 -2
- package/dist/core/help.d.ts +3 -0
- package/dist/core/human/__tests__/html-markdown.test.d.ts +1 -0
- package/dist/core/human/__tests__/html-markdown.test.js +52 -0
- package/dist/core/human/__tests__/page-catalog.test.d.ts +1 -0
- package/dist/core/human/__tests__/page-catalog.test.js +16 -0
- package/dist/core/human/__tests__/page-render.test.js +40 -23
- package/dist/core/human/__tests__/page-tickets.test.js +48 -27
- package/dist/core/human/__tests__/page.test.js +48 -65
- package/dist/core/human/__tests__/serial/inbox-core.test.js +5 -15
- package/dist/core/human/answer-text.d.ts +3 -0
- package/dist/core/human/answer-text.js +98 -0
- package/dist/core/human/answer.d.ts +51 -0
- package/dist/core/human/answer.js +125 -0
- package/dist/core/human/component-docs.d.ts +14 -0
- package/dist/core/human/component-docs.js +161 -0
- package/dist/core/human/html-markdown.d.ts +3 -0
- package/dist/core/human/html-markdown.js +288 -0
- package/dist/core/human/page-catalog.d.ts +19 -3
- package/dist/core/human/page-catalog.js +73 -16
- package/dist/core/human/page-render.d.ts +3 -2
- package/dist/core/human/page-render.js +19 -45
- package/dist/core/human/page-schema.d.ts +108 -8
- package/dist/core/human/page-schema.js +88 -31
- package/dist/core/human/page-synth.d.ts +2 -4
- package/dist/core/human/page-synth.js +23 -7
- package/dist/core/human/page.d.ts +14 -7
- package/dist/core/human/page.js +178 -90
- package/dist/core/human/review-schema.d.ts +1 -0
- package/dist/core/human/review-schema.js +1 -1
- package/dist/core/human/scan.d.ts +3 -2
- package/dist/core/human/scan.js +7 -8
- package/dist/core/human/summary.d.ts +4 -7
- package/dist/core/human/summary.js +4 -60
- package/dist/core/human/tickets.d.ts +10 -9
- package/dist/core/human/tickets.js +5 -3
- package/dist/core/human/types.d.ts +4 -0
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +1 -0
- package/dist/core/review/realize.js +9 -1
- package/dist/core/runtime/boot-root.js +6 -1
- package/dist/core/runtime/broker.js +8 -6
- package/dist/core/runtime/busy.d.ts +4 -3
- package/dist/core/runtime/busy.js +4 -3
- package/dist/core/runtime/spawn.js +4 -4
- package/dist/core/termrender/display.d.ts +7 -3
- package/dist/core/termrender/display.js +45 -9
- package/dist/core/termrender/termrender.d.ts +3 -12
- package/dist/core/termrender/termrender.js +6 -37
- package/dist/core/user-settings.d.ts +17 -2
- package/dist/core/user-settings.js +32 -11
- package/dist/daemon/api/handlers/human.js +1 -1
- package/dist/daemon/api/handlers/inbox.js +22 -7
- package/dist/daemon/cron-run.js +16 -2
- package/dist/daemon/crtrd.js +1 -1
- package/dist/daemon/fleet.d.ts +22 -10
- package/dist/daemon/fleet.js +43 -24
- package/dist/daemon/human/finish.d.ts +2 -1
- package/dist/daemon/human/finish.js +45 -28
- package/dist/daemon/human/sweep.js +4 -2
- package/dist/daemon/reconcilers/broker-supervision.js +11 -8
- package/dist/daemon/reconcilers/dormant-inbox.js +11 -6
- package/dist/daemon/reconcilers/live-obligation.d.ts +21 -1
- package/dist/daemon/reconcilers/live-obligation.js +30 -2
- package/dist/daemon/reconcilers/storage-maintenance.d.ts +5 -0
- package/dist/daemon/reconcilers/storage-maintenance.js +28 -1
- package/dist/daemon/review/sweep.js +12 -1
- package/dist/pages/bundle.css +1 -1
- package/dist/pages/bundle.js +1008 -577
- package/dist/pages/comments.d.ts +29 -0
- package/dist/pages/comments.js +51 -0
- package/dist/pages/controls.d.ts +98 -0
- package/dist/pages/controls.js +261 -0
- package/dist/pages/elements/cards.d.ts +15 -4
- package/dist/pages/elements/cards.js +299 -186
- package/dist/pages/elements/chart.d.ts +22 -0
- package/dist/pages/elements/chart.js +107 -91
- package/dist/pages/elements/options.d.ts +9 -0
- package/dist/pages/elements/options.js +268 -155
- package/dist/pages/elements/pages.d.ts +6 -0
- package/dist/pages/elements/pages.js +60 -38
- package/dist/pages/elements/slot.d.ts +5 -0
- package/dist/pages/elements/slot.js +17 -10
- package/dist/pages/elements/table.d.ts +17 -0
- package/dist/pages/elements/table.js +336 -228
- package/dist/pages/elements/text.d.ts +9 -3
- package/dist/pages/elements/text.js +350 -143
- package/dist/pages/host.d.ts +0 -24
- package/dist/pages/host.js +0 -17
- package/dist/pages/readonly.d.ts +24 -0
- package/dist/pages/readonly.js +31 -0
- package/dist/pages/responses.d.ts +37 -0
- package/dist/pages/responses.js +56 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +9 -1
- package/dist/pi-extensions/canvas-bash-valve.js +12 -8
- package/dist/types.d.ts +19 -5
- package/dist/types.js +2 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/scripts/install-runtime.mjs +19 -24
- package/dist/clients/attach/render/html-markdown.d.ts +0 -13
- package/dist/clients/attach/render/html-markdown.js +0 -206
- package/dist/core/human/markdown-html.d.ts +0 -8
- 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.
|
|
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: '
|
|
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: '
|
|
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([
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
lines.push(` ${
|
|
19
|
-
|
|
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(
|
|
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
|
-
|
|
113
|
-
lines.push(
|
|
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
|
|
129
|
-
if (
|
|
130
|
-
lines.push(` ${DIM}${sanitize(
|
|
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 ${
|
|
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: ${
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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:
|
|
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?:
|
|
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?:
|
|
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 {
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
33
|
-
await assert.rejects(() =>
|
|
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.
|
|
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('
|
|
44
|
-
const
|
|
45
|
-
assert.equal(
|
|
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',
|