@north-light/crouter 0.3.192 → 0.3.193

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 (167) hide show
  1. package/dist/api/client.d.ts +31 -10
  2. package/dist/api/client.js +83 -24
  3. package/dist/api/dto/bash-jobs.d.ts +22 -0
  4. package/dist/api/dto/bash-jobs.js +9 -0
  5. package/dist/api/dto/human.d.ts +3 -4
  6. package/dist/api/dto/inbox.d.ts +106 -65
  7. package/dist/api/dto/inbox.js +2 -8
  8. package/dist/api/index.d.ts +1 -0
  9. package/dist/api/index.js +1 -0
  10. package/dist/api/routes.d.ts +7 -0
  11. package/dist/api/routes.js +7 -0
  12. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/serial/provider-rotation.test.ts +101 -3
  14. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +6 -2
  15. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +68 -14
  16. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +87 -13
  17. package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +2 -2
  18. package/dist/clients/attach/__tests__/group-activity.test.js +2 -2
  19. package/dist/clients/attach/chrome/canvas-panels.d.ts +1 -1
  20. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  21. package/dist/clients/attach/input/overlay-owner.js +1 -1
  22. package/dist/clients/attach/overlays/auth.js +34 -8
  23. package/dist/clients/attach/overlays/pickers.d.ts +5 -8
  24. package/dist/clients/attach/overlays/pickers.js +37 -43
  25. package/dist/clients/attach/viewer.js +643 -620
  26. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +38 -128
  27. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +72 -148
  28. package/dist/clients/inbox/controller.d.ts +2 -3
  29. package/dist/clients/inbox/controller.js +41 -42
  30. package/dist/clients/inbox/page-adapter.d.ts +33 -0
  31. package/dist/clients/inbox/page-adapter.js +54 -0
  32. package/dist/clients/inbox/resolve.d.ts +2 -2
  33. package/dist/clients/inbox/resolve.js +1 -1
  34. package/dist/clients/inbox/review-visibility.d.ts +1 -1
  35. package/dist/clients/inbox/review-visibility.js +1 -1
  36. package/dist/clients/inbox/tui/ansi.d.ts +1 -1
  37. package/dist/clients/inbox/tui/ansi.js +2 -2
  38. package/dist/clients/inbox/tui/input.d.ts +1 -3
  39. package/dist/clients/inbox/tui/input.js +242 -553
  40. package/dist/clients/inbox/tui/panel.d.ts +3 -1
  41. package/dist/clients/inbox/tui/panel.js +59 -187
  42. package/dist/clients/inbox/tui/render.d.ts +0 -25
  43. package/dist/clients/inbox/tui/render.js +165 -416
  44. package/dist/clients/inbox/tui/slots.d.ts +32 -0
  45. package/dist/clients/inbox/tui/slots.js +156 -0
  46. package/dist/clients/inbox/tui/types.d.ts +25 -13
  47. package/dist/clients/inbox/tui.js +5 -3
  48. package/dist/commands/attention.js +2 -2
  49. package/dist/commands/human/doc.js +66 -20
  50. package/dist/commands/human/prompts.d.ts +3 -3
  51. package/dist/commands/human/prompts.js +158 -112
  52. package/dist/commands/human/queue.d.ts +0 -1
  53. package/dist/commands/human/queue.js +34 -94
  54. package/dist/commands/human/shared.d.ts +10 -4
  55. package/dist/commands/human/shared.js +62 -13
  56. package/dist/commands/human.js +8 -4
  57. package/dist/commands/memory/read.js +13 -5
  58. package/dist/commands/node/bash.js +15 -28
  59. package/dist/commands/surface/node/focus.js +1 -1
  60. package/dist/core/__tests__/human-deliver.test.js +40 -46
  61. package/dist/core/__tests__/seam/broker-attach-stream.test.js +7 -1
  62. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +40 -31
  63. package/dist/core/__tests__/serial/spawn-root.test.js +3 -1
  64. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +3 -1
  65. package/dist/core/auth-interaction.d.ts +2 -2
  66. package/dist/core/bash-jobs.d.ts +32 -0
  67. package/dist/core/bash-jobs.js +72 -1
  68. package/dist/core/canvas/__tests__/attention.test.js +16 -10
  69. package/dist/core/canvas/__tests__/render-remote.test.js +9 -3
  70. package/dist/core/canvas/attention.js +1 -1
  71. package/dist/core/canvas/browse/app.js +1 -1
  72. package/dist/core/canvas/nav-model.d.ts +1 -1
  73. package/dist/core/canvas/nav-model.js +1 -1
  74. package/dist/core/canvas/remote-canvas-source.js +1 -1
  75. package/dist/core/config.js +4 -1
  76. package/dist/core/human/__tests__/page-render.test.d.ts +1 -0
  77. package/dist/core/human/__tests__/page-render.test.js +49 -0
  78. package/dist/core/human/__tests__/page-tickets.test.d.ts +1 -0
  79. package/dist/core/human/__tests__/page-tickets.test.js +112 -0
  80. package/dist/core/human/__tests__/page.test.d.ts +1 -0
  81. package/dist/core/human/__tests__/page.test.js +139 -0
  82. package/dist/core/human/__tests__/serial/inbox-core.test.js +42 -32
  83. package/dist/core/human/claim.js +2 -2
  84. package/dist/core/human/convention.d.ts +4 -4
  85. package/dist/core/human/convention.js +6 -13
  86. package/dist/core/human/markdown-html.d.ts +8 -0
  87. package/dist/core/human/markdown-html.js +581 -0
  88. package/dist/core/human/page-catalog.d.ts +6 -0
  89. package/dist/core/human/page-catalog.js +43 -0
  90. package/dist/core/human/page-render.d.ts +3 -0
  91. package/dist/core/human/page-render.js +131 -0
  92. package/dist/core/human/page-schema.d.ts +312 -0
  93. package/dist/core/human/page-schema.js +291 -0
  94. package/dist/core/human/page-synth.d.ts +9 -0
  95. package/dist/core/human/page-synth.js +17 -0
  96. package/dist/core/human/page.d.ts +22 -0
  97. package/dist/core/human/page.js +427 -0
  98. package/dist/core/human/review-schema.d.ts +19 -0
  99. package/dist/core/human/review-schema.js +34 -0
  100. package/dist/core/human/scan.d.ts +12 -3
  101. package/dist/core/human/scan.js +69 -26
  102. package/dist/core/human/summary.d.ts +5 -6
  103. package/dist/core/human/summary.js +54 -37
  104. package/dist/core/human/tickets.d.ts +61 -11
  105. package/dist/core/human/tickets.js +100 -57
  106. package/dist/core/human/types.d.ts +21 -66
  107. package/dist/core/human/types.js +1 -5
  108. package/dist/core/preview-registry.js +0 -1
  109. package/dist/core/review/ticket-filter.d.ts +1 -1
  110. package/dist/core/review/ticket-filter.js +2 -8
  111. package/dist/core/runtime/broker/read-ops.js +3 -0
  112. package/dist/core/runtime/broker.js +19 -10
  113. package/dist/core/runtime/package-health.js +1 -1
  114. package/dist/core/termrender/termrender.d.ts +1 -1
  115. package/dist/core/termrender/termrender.js +2 -2
  116. package/dist/core/tui/page-host.d.ts +1 -1
  117. package/dist/core/tui/page-host.js +2 -2
  118. package/dist/core/user-settings.d.ts +19 -0
  119. package/dist/core/user-settings.js +22 -0
  120. package/dist/daemon/api/handlers/bash-jobs.d.ts +2 -0
  121. package/dist/daemon/api/handlers/bash-jobs.js +89 -0
  122. package/dist/daemon/api/handlers/human.js +16 -14
  123. package/dist/daemon/api/handlers/inbox.js +311 -227
  124. package/dist/daemon/api/router.d.ts +5 -1
  125. package/dist/daemon/api/router.js +5 -0
  126. package/dist/daemon/api/server.js +2 -0
  127. package/dist/daemon/cron-run.js +23 -10
  128. package/dist/daemon/human/finish.d.ts +8 -3
  129. package/dist/daemon/human/finish.js +35 -15
  130. package/dist/pages/bundle.css +1 -0
  131. package/dist/pages/bundle.js +1347 -0
  132. package/dist/pages/comments.d.ts +34 -0
  133. package/dist/pages/comments.js +72 -0
  134. package/dist/pages/elements/cards.d.ts +17 -0
  135. package/dist/pages/elements/cards.js +785 -0
  136. package/dist/pages/elements/chart.d.ts +1 -0
  137. package/dist/pages/elements/chart.js +648 -0
  138. package/dist/pages/elements/options.d.ts +21 -0
  139. package/dist/pages/elements/options.js +604 -0
  140. package/dist/pages/elements/pages.d.ts +24 -0
  141. package/dist/pages/elements/pages.js +355 -0
  142. package/dist/pages/elements/slot.d.ts +21 -0
  143. package/dist/pages/elements/slot.js +105 -0
  144. package/dist/pages/elements/table.d.ts +19 -0
  145. package/dist/pages/elements/table.js +906 -0
  146. package/dist/pages/elements/text.d.ts +25 -0
  147. package/dist/pages/elements/text.js +951 -0
  148. package/dist/pages/entry.d.ts +8 -0
  149. package/dist/pages/entry.js +8 -0
  150. package/dist/pages/host.d.ts +132 -0
  151. package/dist/pages/host.js +149 -0
  152. package/dist/pages/register.d.ts +2 -0
  153. package/dist/pages/register.js +3 -0
  154. package/dist/pages/slot-config.d.ts +36 -0
  155. package/dist/pages/slot-config.js +50 -0
  156. package/dist/pages/types.d.ts +98 -0
  157. package/dist/pages/types.js +23 -0
  158. package/dist/types.d.ts +4 -0
  159. package/dist/types.js +2 -0
  160. package/package.json +8 -8
  161. package/runtime.lock.json +156 -101
  162. package/dist/clients/inbox/deck-adapter.d.ts +0 -29
  163. package/dist/clients/inbox/deck-adapter.js +0 -63
  164. package/dist/core/human/deck-factories.d.ts +0 -8
  165. package/dist/core/human/deck-factories.js +0 -15
  166. package/dist/core/human/deck-schema.d.ts +0 -70
  167. package/dist/core/human/deck-schema.js +0 -92
@@ -1,159 +1,69 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import { createServer } from 'node:http';
3
- import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs';
3
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { claimExists } from '#core/human/claim';
7
6
  import { ticketDir } from '#core/human/root';
8
- import { cancelTicket, submitDeck, takeoverAndCompleteDeck } from '#core/human/tickets';
7
+ import { submitPage, takeoverAndCompletePage, writePageProgress } from '#core/human/tickets';
9
8
  import { InboxController } from '../../controller.js';
10
9
  const key = (part = {}) => ({ 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, ...part });
11
10
  function fakeHost() {
12
- let closed = false;
13
- const cleanup = [];
14
- const handle = {
15
- close() {
16
- if (closed)
17
- return;
18
- closed = true;
19
- for (const fn of cleanup)
20
- void fn();
21
- cleanup.length = 0;
22
- },
23
- invalidate() { },
24
- onClose(fn) {
25
- if (!closed)
26
- cleanup.push(fn);
27
- },
28
- };
29
- return {
30
- capabilities: { remote: false, canFocusNode: false, selfPane: undefined },
31
- mount() { return handle; },
32
- notice() { },
33
- suspendForChild() { return () => { }; },
34
- focusNode: async () => false,
35
- pickReviewFile: async () => null,
36
- };
11
+ const handle = { close() { }, invalidate() { }, onClose() { } };
12
+ return { capabilities: { remote: false, canFocusNode: false, selfPane: undefined }, mount: () => handle, notice() { }, suspendForChild: () => () => { }, focusNode: async () => false, pickReviewFile: async () => null };
37
13
  }
38
- const makeController = (options) => new InboxController({ host: fakeHost(), onDirty: () => { }, onClosed: () => { }, ...options });
39
- const tick = () => new Promise((resolve) => setImmediate(resolve));
40
- function select(controller, dir) {
41
- const count = controller.snapshot().items.length;
42
- const matches = () => {
43
- const selected = controller.snapshot().selectedDir;
44
- return selected !== undefined && realpathSync(selected) === realpathSync(dir);
45
- };
46
- for (let i = 0; i < count && !matches(); i++)
47
- controller.handleKey('j', key());
48
- assert.ok(matches(), `expected ${dir} to be selectable`);
49
- }
50
- async function waitFor(predicate, message) {
51
- const deadline = Date.now() + 1_000;
52
- while (!predicate()) {
53
- if (Date.now() >= deadline)
54
- throw new Error(message);
55
- await new Promise((resolve) => setTimeout(resolve, 10));
56
- }
57
- }
58
- // The controller answers a ticket by crossing the daemon `/resolve` seam. This
59
- // fake daemon plays back each failure the seam can return so the controller's
60
- // unmount-or-retry decision is exercised against a real socket.
61
- let mode = 'success';
14
+ const controller = (options = {}) => new InboxController({ host: fakeHost(), onDirty() { }, onClosed() { }, ...options });
15
+ async function waitFor(predicate, message) { const end = Date.now() + 1000; while (!predicate()) {
16
+ if (Date.now() > end)
17
+ throw new Error(message);
18
+ await new Promise((r) => setTimeout(r, 10));
19
+ } }
20
+ const temp = mkdtempSync(join(tmpdir(), 'crtr-inbox-controller-'));
21
+ const sources = mkdtempSync(join(tmpdir(), 'crtr-page-sources-'));
22
+ process.env['CRTR_HOME'] = temp;
23
+ let received;
62
24
  async function startDaemon() {
63
25
  const server = createServer(async (req, res) => {
64
26
  if (!req.url?.endsWith('/resolve')) {
65
27
  res.statusCode = 404;
66
- res.end(JSON.stringify({ error: { code: 'not_found', message: 'not found' } }));
67
- return;
68
- }
69
- if (mode === 'unreachable') {
70
- res.statusCode = 503;
71
- res.end(JSON.stringify({ error: { code: 'daemon_unavailable', message: 'offline' } }));
28
+ res.end();
72
29
  return;
73
30
  }
74
- const body = await new Promise((resolve) => {
75
- let text = '';
76
- req.on('data', (chunk) => { text += chunk; });
77
- req.on('end', () => resolve(text));
78
- });
31
+ const body = await new Promise((resolve) => { let text = ''; req.on('data', (c) => { text += c; }); req.on('end', () => resolve(text)); });
79
32
  const nodeId = req.url.split('/').at(-2);
80
- if (mode === 'already_resolved') {
81
- res.statusCode = 409;
82
- res.end(JSON.stringify({ error: { code: 'ticket_already_resolved', message: 'ticket already resolved' } }));
83
- return;
84
- }
85
- const request = JSON.parse(body);
86
- if (request.responses === undefined)
87
- throw new Error('expected a deck resolution');
88
- takeoverAndCompleteDeck(ticketDir(nodeId), request.responses, { host: 'daemon', pid: 1 });
89
- if (mode === 'delivery_failed') {
90
- res.statusCode = 500;
91
- res.end(JSON.stringify({ error: { code: 'delivery_failed', message: 'final report delivery failed' } }));
92
- return;
93
- }
94
- res.end(JSON.stringify({ delivered: true, kind: 'deck' }));
33
+ received = JSON.parse(body).responses;
34
+ takeoverAndCompletePage(ticketDir(nodeId), received, { host: 'daemon', pid: 1 });
35
+ res.end(JSON.stringify({ delivered: true, kind: 'page' }));
95
36
  });
96
- await new Promise((resolve) => server.listen(join(process.env['CRTR_HOME'], 'crtrd.sock'), resolve));
37
+ await new Promise((resolve) => server.listen(join(temp, 'crtrd.sock'), resolve));
97
38
  return server;
98
39
  }
99
- const temp = mkdtempSync(join(tmpdir(), 'crtr-inbox-controller-'));
100
- process.env['CRTR_HOME'] = temp;
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`);
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>');
101
43
  const server = await startDaemon();
102
- const deck = (id) => ({ title: id, source: { blockedSince: new Date().toISOString(), sessionName: 'Release planning', nodeId: 'node-private-123' }, interactions: [{ id: 'answer', title: 'Answer', subtitle: 'Answer requires your attention.', options: [{ id: 'yes', label: 'Yes', shortcut: 'y' }] }] });
103
44
  try {
104
- const first = submitDeck({ dir: ticketDir('first'), deck: deck('first') });
105
- const active = makeController({ cols: 100, rows: 24 });
45
+ const ticket = submitPage({ dir: ticketDir('page-one'), sourceFile: ask, dialect: 'md' });
46
+ writePageProgress(ticket.dir, { choice: { selectedOptionIds: ['yes'], comments: [] } });
47
+ const active = controller({ cols: 100, rows: 24 });
106
48
  active.activate();
107
- active.handleKey('y', key());
108
- await waitFor(() => existsSync(join(first.dir, 'response.json')), 'a deck resolution crosses the controller daemon seam');
109
- await waitFor(() => active.snapshot().screen === 'list', 'a successful daemon resolution unmounts the ticket');
49
+ assert.equal(active.snapshot().screen, 'detail', 'page ticket opens in the page panel');
50
+ active.handleKey('n', key());
51
+ active.handleKey('', key({ return: true }));
52
+ await waitFor(() => existsSync(join(ticket.dir, 'response.json')), 'restored page responses cross the daemon seam');
53
+ assert.deepEqual(received, { choice: { selectedOptionIds: ['yes'], comments: [] } }, 'progress.json is restored into the response map');
110
54
  active.close();
111
- // A ticket arriving mid-edit must not move selection or clobber the freetext
112
- // the user is part way through typing.
113
- const editing = submitDeck({ dir: ticketDir('editing'), deck: { title: 'Editing', interactions: [{ id: 'note', title: 'Notes', subtitle: 'Notes require your attention.', options: [], allowFreetext: true }] } });
114
- const editingController = makeController({ cols: 100, rows: 24 });
115
- select(editingController, editing.dir);
116
- editingController.activate();
117
- editingController.handleKey('r', key());
118
- for (const char of 'draft')
119
- editingController.handleKey(char, key());
120
- submitDeck({ dir: ticketDir('arrival'), deck: deck('arrival') });
121
- editingController.rescan();
122
- assert.equal(realpathSync(editingController.snapshot().selectedDir), realpathSync(editing.dir), 'arrival does not steal active selection');
123
- assert.equal(editingController.snapshot().inputBuffer, 'draft', 'arrival does not alter freetext input');
124
- editingController.close();
125
- cancelTicket(editing.dir, { reason: 'done editing' });
126
- const already = submitDeck({ dir: ticketDir('already'), deck: deck('already') });
127
- const alreadyController = makeController({ cols: 100, rows: 24 });
128
- alreadyController.activate();
129
- cancelTicket(already.dir, { reason: 'resolved elsewhere' });
130
- mode = 'already_resolved';
131
- alreadyController.handleKey('y', key());
132
- await waitFor(() => alreadyController.snapshot().screen === 'list', 'already_resolved unmounts instead of offering a stale resubmit');
133
- assert.equal(claimExists(already.dir), true, 'already_resolved retains the daemon-taken claim');
134
- alreadyController.close();
135
- const delivery = submitDeck({ dir: ticketDir('delivery'), deck: deck('delivery') });
136
- mode = 'delivery_failed';
137
- const deliveryController = makeController({ cols: 100, rows: 24 });
138
- deliveryController.activate();
139
- deliveryController.handleKey('y', key());
140
- await waitFor(() => deliveryController.snapshot().screen === 'list', 'delivery_failed unmounts because the canonical answer is already recorded');
141
- assert.equal(existsSync(join(delivery.dir, 'response.json')), true, 'delivery_failed still leaves the canonical response on disk');
142
- deliveryController.close();
143
- const retry = submitDeck({ dir: ticketDir('retry'), deck: deck('retry') });
144
- mode = 'unreachable';
145
- const retryController = makeController({ cols: 100, rows: 24 });
146
- retryController.activate();
147
- retryController.handleKey('y', key());
148
- await tick();
149
- assert.equal(retryController.snapshot().screen, 'detail', 'unreachable keeps the mounted answer available for retry');
150
- assert.equal(existsSync(join(retry.dir, 'response.json')), false, 'unreachable does not manufacture a local completion');
151
- retryController.close();
55
+ submitPage({ dir: ticketDir('rich-page'), sourceFile: html, dialect: 'html' });
56
+ const preview = controller({ cols: 100, rows: 24 });
57
+ assert.match(preview.render().join('\n'), /rich page — open in Northlight/, 'html page preview points to the rich surface');
58
+ preview.activate();
59
+ assert.match(preview.render().join('\n'), /chart — bar: day vs count/, 'html page uses the same manifest-backed terminal projection');
60
+ preview.close();
152
61
  }
153
62
  finally {
154
63
  server.closeAllConnections();
155
64
  await new Promise((resolve) => server.close(() => resolve()));
156
65
  rmSync(temp, { recursive: true, force: true });
66
+ rmSync(sources, { recursive: true, force: true });
157
67
  delete process.env['CRTR_HOME'];
158
68
  }
159
69
  console.log('inbox controller tests passed');
@@ -1,151 +1,75 @@
1
- import { mountPanel } from '../../tui/panel.js';
2
1
  import assert from 'node:assert/strict';
3
- // Answer-integrity regressions: every case below is a way the panel once lost or
4
- // invented an answer on the user's behalf.
5
- function mkKey(partial) {
6
- return {
7
- ctrl: false,
8
- meta: false,
9
- upArrow: false,
10
- downArrow: false,
11
- leftArrow: false,
12
- rightArrow: false,
13
- wordLeft: false,
14
- wordRight: false,
15
- home: false,
16
- end: false,
17
- pageUp: false,
18
- pageDown: false,
19
- del: false,
20
- return: false,
21
- newline: false,
22
- escape: false,
23
- tab: false,
24
- backspace: false,
25
- ...partial,
26
- };
27
- }
28
- const RETURN = mkKey({ return: true });
29
- // ── An empty Enter in freetext-only mode records the response ─────────────────
30
- // Regression: an empty buffer failed a truthy guard, so the response was never
31
- // recorded, the deck could never reach a full response set, and onComplete
32
- // never fired — leaving the deck permanently unfinishable.
33
- const freetextOnlyDeck = {
34
- title: 'Test deck',
35
- interactions: [
36
- { id: 'q1', title: 'Optional notes', subtitle: 'Optional notes requires your attention.', options: [], allowFreetext: true },
37
- ],
38
- };
39
- let capturedFreetextEmpty = [];
40
- const ftPanel = mountPanel({
41
- deck: freetextOnlyDeck,
42
- cols: 80,
43
- rows: 24,
44
- onComplete: (responses) => { capturedFreetextEmpty = responses; },
45
- });
46
- // Single-item deck starts directly in item-review (no overview). 'r' opens freetext.
47
- ftPanel.handleKey('r', mkKey({}));
48
- ftPanel.handleKey('', RETURN); // submit with an empty buffer
49
- ftPanel.handleKey('', RETURN); // final phase → complete
50
- assert.deepEqual(capturedFreetextEmpty, [{ id: 'q1', freetext: '' }], 'empty freetext Enter must record response and fire onComplete');
51
- ftPanel.unmount();
52
- // ── An empty Enter in comment mode records the response ───────────────────────
53
- const commentDeck = {
54
- title: 'Test deck',
55
- interactions: [
56
- {
57
- id: 'c1', title: 'Approve deploy?', subtitle: 'Approve deploy? requires your attention.',
58
- options: [{ id: 'yes', label: 'Yes', shortcut: 'y' }],
59
- allowFreetext: true,
60
- },
61
- ],
62
- };
63
- let capturedCommentEmpty = [];
64
- const cmPanel = mountPanel({
65
- deck: commentDeck,
66
- cols: 80,
67
- rows: 24,
68
- onComplete: (responses) => { capturedCommentEmpty = responses; },
69
- });
70
- // Navigate to the [c] row WITHOUT pre-attaching an option, then comment.
71
- cmPanel.handleKey('j', mkKey({}));
72
- cmPanel.handleKey('c', mkKey({}));
73
- cmPanel.handleKey('', RETURN); // submit an empty comment, no option selected
74
- cmPanel.handleKey('', RETURN); // final phase → complete
75
- assert.deepEqual(capturedCommentEmpty, [{ id: 'c1', freetext: '' }], 'empty Enter in comment mode must record response and fire onComplete');
76
- cmPanel.unmount();
77
- // ── Option-shortcut letters typed in comment mode are text, never a choice ────
78
- // Regression: pressing an option's shortcut while commenting submitted that
79
- // option for the user instead of appending the character to the buffer.
80
- const shortcutTypingDeck = {
81
- title: 'Test deck',
82
- interactions: [
83
- {
84
- id: 'q1', title: 'Approve?', subtitle: 'Approve? requires your attention.',
85
- options: [
86
- { id: 'yes', label: 'Yes', shortcut: 'y' },
87
- { id: 'no', label: 'No', shortcut: 'n' },
88
- ],
89
- allowFreetext: true,
90
- },
91
- ],
92
- };
93
- let capturedShortcutTyping = [];
94
- const stPanel = mountPanel({
95
- deck: shortcutTypingDeck,
96
- cols: 80,
97
- rows: 24,
98
- onComplete: (responses) => { capturedShortcutTyping = responses; },
99
- });
100
- stPanel.handleKey('j', mkKey({}));
101
- stPanel.handleKey('j', mkKey({}));
102
- stPanel.handleKey('c', mkKey({}));
103
- // 'y' and 'n' are both option shortcuts; every character must reach the buffer.
104
- for (const ch of 'yes no')
105
- stPanel.handleKey(ch, mkKey({}));
106
- stPanel.handleKey('', RETURN); // submit the comment — must carry no selectedOptionId
107
- stPanel.handleKey('', RETURN); // final → complete
108
- assert.deepEqual(capturedShortcutTyping, [{ id: 'q1', freetext: 'yes no' }], 'option-shortcut letters typed in comment mode must append to buffer, not select option');
109
- stPanel.unmount();
110
- // ── Multi-select needs an explicit confirm, and an empty Enter does nothing ───
111
- // Two regressions on one deck: Enter with nothing toggled committed an empty
112
- // set and finalized, and a confirmed set auto-finalized with no chance to
113
- // review it.
114
- const multiDeck = {
115
- title: 'Test deck',
116
- interactions: [
117
- {
118
- id: 'cs1', title: 'Pick toppings', subtitle: 'Pick toppings requires your attention.', multiSelect: true,
119
- options: [
120
- { id: 'mush', label: 'Mushroom', shortcut: 'a' },
121
- { id: 'onion', label: 'Onion', shortcut: 'b' },
122
- ],
123
- },
124
- ],
125
- };
126
- let multiFired = false;
127
- let multiResponses = [];
128
- const multiPanel = mountPanel({
129
- deck: multiDeck,
130
- cols: 80,
131
- rows: 24,
132
- onComplete: (responses) => { multiFired = true; multiResponses = responses; },
133
- });
134
- // Nothing toggled: Enter must be an inert no-op, not an empty answer.
135
- multiPanel.handleKey('', RETURN);
136
- assert.equal(multiFired, false, 'Enter with an empty multi-select set must NOT finalize');
137
- const stillReview = multiPanel.render().join('\n');
138
- assert.ok(stillReview.includes('Pick toppings'), 'after empty Enter, still on the interaction (no advance)');
139
- assert.ok(!stillReview.includes('Summary'), 'after empty Enter, the deck did NOT advance to the Summary screen');
140
- assert.ok(stillReview.toLowerCase().includes('select at least one'), 'empty multi-select Enter surfaces an inline hint');
141
- // Toggle an option and confirm the set: the first Enter reviews, never submits.
142
- multiPanel.handleKey(' ', mkKey({}));
143
- multiPanel.handleKey('', RETURN);
144
- assert.equal(multiFired, false, 'multi-select first Enter must NOT auto-finalize (lands on the confirm screen)');
145
- assert.ok(multiPanel.render().join('\n').includes('Summary'), 'after confirming a multi-select set, the deck shows the Summary/confirm screen');
146
- // A second deliberate Enter submits exactly the toggled set.
147
- multiPanel.handleKey('', RETURN);
148
- assert.equal(multiFired, true, 'second Enter on the Summary screen finalizes the multi-select deck');
149
- assert.deepEqual(multiResponses, [{ id: 'cs1', selectedOptionIds: ['mush'] }], 'confirmed multi-select set returned on finalize');
2
+ import { mountPanel } from '../../tui/panel.js';
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
+ 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 });
6
+ const projection = (slots, prose = []) => prose.length > 0 ? prose : slots.map((slotIndex) => ({ kind: 'slot', slotIndex }));
7
+ // Option comment is anchored and survives the subsequent single-select completion.
8
+ const options = manifest([{ id: 'choice', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }] } }]);
9
+ let optionResponses = {};
10
+ const optionsPanel = mountPanel({ manifest: options, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { optionResponses = r; } });
11
+ optionsPanel.handleKey('c', key());
12
+ for (const ch of 'ship it')
13
+ optionsPanel.handleKey(ch, key());
14
+ optionsPanel.handleKey('', enter);
15
+ optionsPanel.handleKey('y', key());
16
+ assert.deepEqual(optionResponses, { choice: { selectedOptionIds: ['yes'], comments: [{ id: 'option:yes', anchor: { kind: 'option', optionId: 'yes' }, text: 'ship it' }] } });
17
+ optionsPanel.unmount();
18
+ // Multi-select empty Enter remains inert; selection confirmation lands on Summary before submit.
19
+ const multi = manifest([{ id: 'toppings', kind: 'options', step: 0, config: { mode: 'multi', options: [{ id: 'mush', label: 'Mushroom' }, { id: 'onion', label: 'Onion' }] } }]);
20
+ let multiResponses;
21
+ const multiPanel = mountPanel({ manifest: multi, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { multiResponses = r; } });
22
+ multiPanel.handleKey('', enter);
23
+ assert.equal(multiResponses, undefined);
24
+ assert.match(multiPanel.render().join('\n'), /select at least one/i);
25
+ multiPanel.handleKey(' ', key());
26
+ multiPanel.handleKey('', enter);
27
+ assert.match(multiPanel.render().join('\n'), /Summary/);
28
+ assert.equal(multiResponses, undefined);
29
+ multiPanel.handleKey('', enter);
30
+ assert.deepEqual(multiResponses, { toppings: { selectedOptionIds: ['mush'], comments: [] } });
150
31
  multiPanel.unmount();
32
+ // Table selection uses row ids and permits an empty committed selection by schema.
33
+ const table = manifest([{ id: 'rows', kind: 'table', step: 0, config: { columns: [{ id: 'name', label: 'Name' }], rows: [{ id: 'a', cells: { name: 'Alpha' } }, { id: 'b', cells: { name: 'Beta' } }], rowSelect: 'multi', columnSelect: 'none' } }]);
34
+ let tableResponses = {};
35
+ const tablePanel = mountPanel({ manifest: table, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { tableResponses = r; } });
36
+ assert.match(tablePanel.render().join('\n'), /Alpha/);
37
+ tablePanel.handleKey(' ', key());
38
+ tablePanel.handleKey('', enter);
39
+ tablePanel.handleKey('', enter);
40
+ assert.deepEqual(tableResponses, { rows: { selectedRowIds: ['a'], selectedColumnIds: [], comments: [] } });
41
+ tablePanel.unmount();
42
+ // A display-only page is dismissed with q and completes with an empty response map.
43
+ let displayResponses;
44
+ const displayPanel = mountPanel({ manifest: manifest([]), projection: [{ kind: 'prose', markdown: 'FYI only' }], cols: 80, rows: 24, onComplete: (r) => { displayResponses = r; } });
45
+ displayPanel.handleKey('q', key());
46
+ assert.deepEqual(displayResponses, {});
47
+ displayPanel.unmount();
48
+ // A single-slot page opens in item-review, so its leading prose renders there.
49
+ const lone = manifest([{ id: 'only', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } }]);
50
+ const lonePanel = mountPanel({ manifest: lone, projection: projection([], [{ kind: 'prose', markdown: 'Read this first' }, { kind: 'slot', slotIndex: 0 }]), cols: 80, rows: 24 });
51
+ assert.match(lonePanel.render().join('\n'), /Read this first/);
52
+ lonePanel.unmount();
53
+ // An unvalidated slot takes no terminal input and keeps the page unsubmittable.
54
+ const unvalidated = manifest([{ id: 'widget', kind: 'acme.widget', step: 0, config: {}, unvalidated: true }]);
55
+ let unvalidatedResponses;
56
+ const unvalidatedPanel = mountPanel({ manifest: unvalidated, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { unvalidatedResponses = r; } });
57
+ unvalidatedPanel.handleKey('c', key());
58
+ assert.equal(unvalidatedPanel.getInputBuffer(), undefined);
59
+ unvalidatedPanel.handleKey('n', key());
60
+ assert.match(unvalidatedPanel.render().join('\n'), /must be answered in Northlight/);
61
+ unvalidatedPanel.handleKey('', enter);
62
+ assert.equal(unvalidatedResponses, undefined);
63
+ unvalidatedPanel.unmount();
64
+ // Overview groups slots by step, without adding a progression gate.
65
+ const stepped = manifest([
66
+ { id: 'one', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } },
67
+ { id: 'two', kind: 'options', step: 1, config: { mode: 'single', options: [{ id: 'b', label: 'Beta' }] } },
68
+ ], { dialect: 'html', document: 'page.html', steps: 2 });
69
+ const steppedPanel = mountPanel({ manifest: stepped, projection: projection([0, 1]), cols: 80, rows: 24 });
70
+ const overview = steppedPanel.render().join('\n');
71
+ assert.match(overview, /Step 1\/2/);
72
+ assert.match(overview, /Step 2\/2/);
73
+ assert.match(overview, /rich page — open in Northlight/);
74
+ steppedPanel.unmount();
151
75
  console.log('OK');
@@ -53,12 +53,11 @@ export declare class InboxController {
53
53
  private cancelFromInbox;
54
54
  activate(): void;
55
55
  private activateReview;
56
- reloadSelectedDeck(): void;
57
- private readDeck;
56
+ reloadSelectedPage(): void;
58
57
  close(): void;
59
58
  private complete;
60
59
  /** Edit the active buffer while the host releases the terminal to $EDITOR. */
61
- private editActiveDeckInput;
60
+ private editActivePageInput;
62
61
  private leaveDetail;
63
62
  private select;
64
63
  private detailSize;
@@ -4,16 +4,16 @@ import { BOLD, CYAN, DIM, GRAY, RESET, YELLOW, clipLine } from './tui/ansi.js';
4
4
  import { buildInboxLines } from './tui.js';
5
5
  import { inboxLayout } from './layout.js';
6
6
  import { scanInbox } from '../../core/human/scan.js';
7
+ import { readConfig } from '../../core/config.js';
8
+ import { parsePageSource } from '../../core/human/page.js';
7
9
  import { watchInboxActivity } from '../../core/human/root.js';
8
10
  import { claimTicket, heartbeatClaim, releaseClaim } from '../../core/human/claim.js';
9
- import { readTicketResult } from '../../core/human/tickets.js';
10
- import { clearProgress, deckPath } from '../../core/human/convention.js';
11
- import { readJsonOrNull } from '../../core/fs-utils.js';
12
- import { DeckAdapter } from './deck-adapter.js';
13
- import { validateDeck } from '../../core/human/deck-schema.js';
11
+ import { readPageTicket, readTicketResult } from '../../core/human/tickets.js';
12
+ import { clearProgress } from '../../core/human/convention.js';
13
+ import { PageAdapter } from './page-adapter.js';
14
14
  import { ReviewAdapter } from './review-adapter.js';
15
15
  import { editBufferInEditor } from './review/roundtrip.js';
16
- import { cancelTicketViaDaemon, daemonErrorMessage, nodeIdFor, resolveFailureKind, resolveTicketDeck, } from './resolve.js';
16
+ import { cancelTicketViaDaemon, daemonErrorMessage, nodeIdFor, resolveFailureKind, resolveTicketPage, } from './resolve.js';
17
17
  /** Host-agnostic model for scanning, stable selection, claims, and rendering. */
18
18
  export class InboxController {
19
19
  options;
@@ -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;
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();
@@ -113,7 +113,7 @@ export class InboxController {
113
113
  // Hard-clip the list line to its column: an overflowing row would eat
114
114
  // the pad and push the divider out of alignment for that row alone.
115
115
  const left = clipLine(list[i] ?? '', geometry.listWidth);
116
- // Clip the detail column too: a deck/preview row is only ever entitled to
116
+ // Clip the detail column too: a preview row is only ever entitled to
117
117
  // its own panel, so an over-wide row cannot run past the pane's edge.
118
118
  const right = clipLine(detail[i] ?? '', geometry.detailWidth);
119
119
  const pad = ' '.repeat(Math.max(0, geometry.listWidth - visibleWidth(left)));
@@ -197,7 +197,7 @@ export class InboxController {
197
197
  void this.activateReview(item);
198
198
  return;
199
199
  }
200
- if (item.kind !== 'deck')
200
+ if (item.kind !== 'page')
201
201
  return;
202
202
  const claim = claimTicket(item.dir);
203
203
  if (claim === null) {
@@ -205,23 +205,30 @@ export class InboxController {
205
205
  return;
206
206
  }
207
207
  this.claim = { dir: item.dir, token: claim.token };
208
- const deck = this.readDeck(item.dir);
209
- if (deck === undefined) {
208
+ let loaded;
209
+ try {
210
+ const productKinds = readConfig('user').page_components ?? [];
211
+ const ticket = readPageTicket(item.dir, productKinds);
212
+ loaded = { ticket, projection: parsePageSource(ticket.document, ticket.manifest, productKinds) };
213
+ }
214
+ catch {
210
215
  releaseClaim(item.dir, claim.token);
211
216
  this.claim = undefined;
217
+ this.status = 'page could not be read';
212
218
  this.invalidate();
213
219
  return;
214
220
  }
215
- // Notifications use the same canonical deck panel as every other deck:
216
- // opening is not acknowledgement; panel completion is.
221
+ const productKinds = readConfig('user').page_components ?? [];
217
222
  this.screen = 'detail';
218
- this.adapter = new DeckAdapter({
223
+ this.adapter = new PageAdapter({
219
224
  dir: item.dir,
220
- deck,
225
+ ticket: loaded.ticket,
226
+ projection: loaded.projection,
227
+ productKinds,
221
228
  cols: this.detailSize().cols,
222
229
  rows: this.detailSize().rows,
223
230
  onDirty: () => this.markDirty(),
224
- onEditorRequest: () => this.editActiveDeckInput(),
231
+ onEditorRequest: () => this.editActivePageInput(),
225
232
  onBack: () => { this.leaveDetail(); this.markDirty(); },
226
233
  onComplete: (responses) => { void this.complete(responses); },
227
234
  });
@@ -244,30 +251,20 @@ export class InboxController {
244
251
  this.markDirty();
245
252
  }
246
253
  }
247
- reloadSelectedDeck() {
254
+ reloadSelectedPage() {
248
255
  if (this.adapter === undefined || this.claim === undefined) {
249
256
  this.markDirty();
250
257
  return;
251
258
  }
252
- const deck = this.readDeck(this.claim.dir);
253
- if (deck === undefined) {
254
- this.markDirty();
255
- return;
256
- }
257
- // Reload applies the latest deck shape before the panel preserves surviving answers.
258
- this.adapter.reload(deck);
259
- this.markDirty();
260
- }
261
- readDeck(dir) {
262
- const deck = readJsonOrNull(deckPath(dir));
263
- if (deck === null)
264
- return undefined;
265
259
  try {
266
- return validateDeck(deck);
260
+ const productKinds = readConfig('user').page_components ?? [];
261
+ const ticket = readPageTicket(this.claim.dir, productKinds);
262
+ this.adapter.reload(ticket, parsePageSource(ticket.document, ticket.manifest, productKinds));
267
263
  }
268
264
  catch {
269
- return undefined;
265
+ this.status = 'page could not be read';
270
266
  }
267
+ this.markDirty();
271
268
  }
272
269
  close() {
273
270
  if (this.closed)
@@ -292,7 +289,7 @@ export class InboxController {
292
289
  return;
293
290
  this.submittingDir = claim.dir;
294
291
  try {
295
- await resolveTicketDeck(claim.dir, responses);
292
+ await resolveTicketPage(claim.dir, responses);
296
293
  }
297
294
  catch (error) {
298
295
  this.submittingDir = undefined;
@@ -315,7 +312,7 @@ export class InboxController {
315
312
  this.markDirty();
316
313
  }
317
314
  /** Edit the active buffer while the host releases the terminal to $EDITOR. */
318
- editActiveDeckInput() {
315
+ editActivePageInput() {
319
316
  const buffer = this.adapter?.inputBuffer();
320
317
  if (buffer === undefined)
321
318
  return;
@@ -359,8 +356,8 @@ export class InboxController {
359
356
  if (selected === undefined)
360
357
  return this.passiveDetailLines(width).slice(0, rows);
361
358
  const focusHint = this.focusAvailable(selected.dir) ? ` ${DIM}g${RESET} chat` : '';
362
- const footer = selected.kind === 'deck'
363
- ? [` ${DIM}Enter${RESET} opens the full ticket ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${DIM}Active ask:${RESET} c comment u/d scroll${focusHint} ${DIM}q${RESET} close`]
359
+ const footer = selected.kind === 'page'
360
+ ? [` ${DIM}Enter${RESET} opens the full page ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${DIM}Page:${RESET} c comment u/d scroll${focusHint} ${DIM}q${RESET} close`]
364
361
  : [` ${DIM}Enter${RESET} opens the full review ${DIM}j/k${RESET} select ${DIM}x${RESET} cancel`, ` ${focusHint} ${DIM}q${RESET} close`];
365
362
  const preview = this.passiveDetailLines(width).slice(0, Math.max(0, rows - footer.length));
366
363
  return [...preview, ...footer.map((line) => clipLine(line, width))];
@@ -394,10 +391,12 @@ export class InboxController {
394
391
  const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, ''];
395
392
  for (const rendered of renderMarkdown(selected.subtitle, Math.max(1, width - 2)))
396
393
  lines.push(` ${rendered}`);
397
- const count = selected.kind === 'deck'
398
- ? `${selected.interactionCount} question${selected.interactionCount === 1 ? '' : 's'}`
394
+ const count = selected.kind === 'page'
395
+ ? `${selected.slotKinds.length} slot${selected.slotKinds.length === 1 ? '' : 's'}`
399
396
  : '1 file to review';
400
397
  lines.push('', ` ${DIM}${count}${RESET}`);
398
+ if (selected.kind === 'page' && selected.dialect === 'html')
399
+ lines.push(` ${DIM}rich page — open in Northlight${RESET}`);
401
400
  return lines.map((line) => clipLine(line, width));
402
401
  }
403
402
  withStatus(lines) {
@@ -423,8 +422,8 @@ export class InboxController {
423
422
  this.selectedWatcher?.close();
424
423
  try {
425
424
  this.selectedWatcher = watch(dir, (_event, file) => {
426
- if (file === 'deck.json') {
427
- this.reloadSelectedDeck();
425
+ if (file === 'page.json') {
426
+ this.reloadSelectedPage();
428
427
  return;
429
428
  }
430
429
  this.invalidate();
@@ -439,9 +438,9 @@ function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length;
439
438
  function canonicalResultStatus(result) {
440
439
  if (result === null)
441
440
  return 'ticket was already resolved';
442
- if (result.kind === 'deck')
443
- return `ticket was already resolved: ${result.summary}`;
444
441
  if (result.kind === 'review')
445
442
  return `ticket was already resolved: ${result.result.approved ? 'approved' : 'review submitted'}`;
443
+ if (result.kind === 'page')
444
+ return 'ticket was already resolved: page submitted';
446
445
  return 'ticket was already canceled';
447
446
  }