@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,95 +1,53 @@
1
1
  import stringWidth from 'string-width';
2
+ import { isResponseBearingSlot } from '../../../core/human/page-schema.js';
2
3
  import { renderMarkdownBlockAwareLines } from '../../../core/termrender/termrender.js';
3
- import { RESET, BOLD, DIM, ITALIC, GREEN, YELLOW, CYAN, REVERSE, sanitize, singleLine, truncate, hline, wrap, hardWrap, centerHorizontal, clipLine, panLine, visibleWidth, } from './ansi.js';
4
- /** Cells one h/l press pans by in the card body. */
4
+ import { anchorLabel, describeSlot, pageComplete, slotActions, slotAnswered, summarizeSlotResponse, tableMarkdown, terminalAnswerable } from './slots.js';
5
+ import { RESET, BOLD, DIM, GREEN, YELLOW, CYAN, REVERSE, sanitize, singleLine, truncate, hline, hardWrap, centerHorizontal, clipLine, panLine, visibleWidth } from './ansi.js';
5
6
  const PAN_STEP = 8;
6
- // ── Renderers ────────────────────────────────────────────────────────────────
7
+ 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
+ }
7
11
  export function renderOverview(state, cols, rows) {
8
- const lines = [];
9
- const title = `${BOLD}${CYAN} Decisions ${RESET}`;
10
- const progress = `${state.responses.size}/${state.interactions.length} answered`;
11
- lines.push('');
12
- lines.push(` ${title} ${DIM}${progress}${RESET}`);
13
- lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
14
- lines.push('');
15
- const rowsBuf = [];
16
- for (let i = 0; i < state.interactions.length; i++) {
17
- const interaction = state.interactions[i];
18
- const response = state.responses.get(interaction.id);
19
- const preAnswered = state.preAnsweredIds.has(interaction.id);
20
- const icon = response
21
- ? (preAnswered ? `${DIM}◆${RESET}` : `${GREEN}✓${RESET}`)
22
- : `${DIM}○${RESET}`;
23
- const label = singleLine(interaction.title);
12
+ const maxW = Math.min(Math.max(20, cols - 4), 120);
13
+ const lines = ['', ` ${BOLD}${CYAN}${sanitize(state.manifest.title)}${RESET}`];
14
+ if (state.manifest.subtitle)
15
+ lines.push(` ${DIM}${sanitize(state.manifest.subtitle)}${RESET}`);
16
+ 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}`);
22
+ let priorStep = -1;
23
+ for (let i = 0; i < state.slots.length; i++) {
24
+ const slot = state.slots[i];
25
+ if (state.manifest.steps > 1 && slot.step !== priorStep) {
26
+ if (priorStep !== -1)
27
+ lines.push('');
28
+ lines.push(` ${BOLD}Step ${slot.step + 1}/${state.manifest.steps}${RESET}`);
29
+ priorStep = slot.step;
30
+ }
31
+ const response = slot.id === undefined ? undefined : state.responses.get(slot.id);
32
+ const icon = isResponseBearingSlot(slot) ? (slotAnswered(state, slot) ? `${GREEN}✓${RESET}` : `${DIM}○${RESET}`) : `${DIM}·${RESET}`;
24
33
  const cursor = i === state.currentIndex ? `${CYAN}▸${RESET} ` : ' ';
25
- const labelMax = Math.max(10, cols - 16);
26
- rowsBuf.push({
27
- line: ` ${cursor}${icon} ${truncate(label, labelMax)}`,
28
- questionIndex: i,
29
- });
30
- if (response) {
31
- const summary = singleLine(responseSummary(response, interaction));
32
- const summaryMax = Math.max(10, cols - 10);
33
- rowsBuf.push({
34
- line: ` ${DIM}${truncate(summary, summaryMax)}${RESET}`,
35
- questionIndex: i,
36
- });
37
- }
38
- }
39
- const reserved = 4 + 3 + 2;
40
- const available = Math.max(1, rows - reserved);
41
- let scroll = state.scrollOffset || 0;
42
- const focusRow = rowsBuf.findIndex((r) => r.questionIndex === state.currentIndex);
43
- if (focusRow >= 0) {
44
- if (focusRow < scroll)
45
- scroll = focusRow;
46
- if (focusRow >= scroll + available)
47
- scroll = focusRow - available + 1;
48
- }
49
- scroll = Math.max(0, Math.min(scroll, Math.max(0, rowsBuf.length - available)));
50
- if (scroll > 0) {
51
- lines.push(` ${DIM}↑ ${scroll} more above${RESET}`);
52
- }
53
- else {
54
- lines.push('');
55
- }
56
- const end = Math.min(rowsBuf.length, scroll + available);
57
- for (let i = scroll; i < end; i++)
58
- lines.push(rowsBuf[i].line);
59
- if (end < rowsBuf.length) {
60
- lines.push(` ${DIM}↓ ${rowsBuf.length - end} more below${RESET}`);
61
- }
62
- else {
63
- lines.push('');
64
- }
65
- lines.push(` ${DIM}${hline(Math.min(cols - 4, 60))}${RESET}`);
66
- lines.push(` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} finish`);
67
- while (lines.length < rows)
68
- lines.push('');
69
- // Overview content extends roughly cols-16 wide for option labels; center
70
- // against a 60-col cap (the divider width) when the terminal is much wider.
71
- const centered = centerHorizontal(lines.slice(0, rows), cols, Math.min(cols, 60) + 2);
72
- return centered;
34
+ lines.push(` ${cursor}${icon} ${truncate(singleLine(describeSlot(slot)), Math.max(10, cols - 16))}`);
35
+ if (response !== undefined)
36
+ lines.push(` ${DIM}${truncate(singleLine(summarizeSlotResponse(slot, response)), Math.max(10, cols - 10))}${RESET}`);
37
+ }
38
+ if (state.slots.length === 0)
39
+ lines.push(` ${DIM}No interactive slots${RESET}`);
40
+ lines.push('', ` ${DIM}${hline(maxW)}${RESET}`);
41
+ const display = !state.manifest.slots.some(isResponseBearingSlot);
42
+ lines.push(display ? ` ${DIM}q${RESET} dismiss` : ` ${DIM}enter${RESET} review ${DIM}j/k${RESET} navigate ${DIM}q${RESET} close`);
43
+ return centeredWindow(lines, state, cols, rows, maxW + 2);
73
44
  }
74
- /**
75
- * Render a (possibly multiline) input buffer with a visible cursor at
76
- * `cursor` (a code-point index). A private-use sentinel is inserted at the
77
- * cursor position, the result is hard-wrapped (so `\n` and width-wrapping
78
- * behave exactly as they would without a cursor), then the sentinel is
79
- * swapped for a reverse-video block on whichever wrapped line it landed on.
80
- * `stringWidth('\uE000') === 1`, so it occupies exactly one column and never
81
- * throws off the wrap math.
82
- */
83
45
  export function renderInputBuffer(buffer, cursor, maxWidth) {
84
46
  const chars = [...buffer];
85
47
  const at = Math.max(0, Math.min(cursor, chars.length));
86
48
  const withCaret = [...chars.slice(0, at), '\uE000', ...chars.slice(at)].join('');
87
- const wrapped = hardWrap(withCaret, maxWidth);
88
- return wrapped.map((line) => (line.includes('\uE000') ? line.replace('\uE000', `${REVERSE} ${RESET}`) : line));
49
+ return hardWrap(withCaret, maxWidth).map((line) => line.includes('\uE000') ? line.replace('\uE000', `${REVERSE} ${RESET}`) : line);
89
50
  }
90
- /** Code-point bounds of every visual row `renderInputBuffer` paints, mirroring
91
- * `hardWrap` (character-based, lossless) so a cursor index maps to the row the
92
- * human actually sees. `end` is exclusive of the row's trailing '\n', if any. */
93
51
  export function wrappedRowBounds(buffer, width) {
94
52
  const chars = [...buffer];
95
53
  const w = Math.max(1, width);
@@ -110,16 +68,12 @@ export function wrappedRowBounds(buffer, width) {
110
68
  start = i;
111
69
  rowW = cw;
112
70
  }
113
- else {
71
+ else
114
72
  rowW += cw;
115
- }
116
73
  }
117
74
  rows.push({ start, end: chars.length });
118
75
  return rows;
119
76
  }
120
- /** Vertical motion by *visual* row — what up/down mean in a wrapped input box,
121
- * where one long soft-wrapped paragraph spans many rows. Returns the new
122
- * code-point cursor, unchanged when there is no row in that direction. */
123
77
  export function verticalCursor(buffer, cursor, delta, width) {
124
78
  const rows = wrappedRowBounds(buffer, width);
125
79
  const at = Math.max(0, Math.min(cursor, [...buffer].length));
@@ -128,349 +82,144 @@ export function verticalCursor(buffer, cursor, delta, width) {
128
82
  if (rows[i].start <= at)
129
83
  r = i;
130
84
  const target = rows[r + delta];
131
- if (target === undefined)
132
- return at;
133
- return Math.min(target.start + (at - rows[r].start), target.end);
85
+ return target === undefined ? at : Math.min(target.start + (at - rows[r].start), target.end);
134
86
  }
135
- /**
136
- * Build the item-review frame parts (pre/body/post) and derive the body's
137
- * scroll bounds. Pure: reads state, mutates nothing. Both `renderItemReview`
138
- * and the pre-render `clampItemReviewScroll` go through this, so the layout
139
- * math has a single source of truth.
140
- */
141
- function buildItemReviewLayout(state, cols, rows) {
142
- const interaction = state.interactions[state.currentIndex];
143
- const response = state.responses.get(interaction.id);
144
- const maxW = Math.min(cols - 4, 120);
145
- // Diagrams are pictures, not prose: they render at the pane's full width
146
- // rather than the readability cap. The card stops being centered when one
147
- // claims that width, so the extra columns are actually available to it.
87
+ function responseComments(state, slot) {
88
+ if (slot.id === undefined)
89
+ return [];
90
+ const response = state.responses.get(slot.id);
91
+ return response?.comments ?? [];
92
+ }
93
+ function selected(state, slot, group, id) {
94
+ if (slot.id === undefined)
95
+ return false;
96
+ const r = state.responses.get(slot.id);
97
+ if (group === 'option')
98
+ return r?.selectedOptionIds?.includes(id) === true;
99
+ if (group === 'card')
100
+ return r?.selectedCardIds?.includes(id) === true;
101
+ if (group === 'row')
102
+ return r?.selectedRowIds?.includes(id) === true;
103
+ return r?.selectedColumnIds?.includes(id) === true;
104
+ }
105
+ function itemLines(state, cols) {
106
+ const slot = state.slots[state.currentIndex];
107
+ const maxW = Math.min(Math.max(20, cols - 4), 120);
148
108
  const paneW = Math.max(20, cols - 4);
149
- const mdLines = (md) => renderMarkdownBlockAwareLines(md, maxW, paneW);
150
- // Pre-body: position, divider, title, subtitle (always visible)
151
- const preLines = [];
152
- const pos = `${state.currentIndex + 1}/${state.interactions.length}`;
153
- preLines.push('');
154
- preLines.push(` ${BOLD}${CYAN}[${pos}]${RESET}`);
155
- preLines.push(` ${DIM}${hline(maxW)}${RESET}`);
156
- preLines.push('');
157
- for (const line of wrap(sanitize(interaction.title), maxW)) {
158
- preLines.push(` ${BOLD}${line}${RESET}`);
159
- }
160
- // "Previously answered" marker — shown only while the seed is intact (no user
161
- // override yet). The label comes from preAnswered.label so callers can be
162
- // domain-specific ("Previously approved", "Carried over from prior pass").
163
- if (state.preAnsweredIds.has(interaction.id)) {
164
- const customLabel = interaction.preAnswered !== undefined ? interaction.preAnswered.label : undefined;
165
- const label = typeof customLabel === 'string' && customLabel.length > 0
166
- ? customLabel
167
- : 'Previously answered';
168
- preLines.push(` ${DIM}${ITALIC}◆ ${sanitize(label)} — press n/p to review, or any option to override${RESET}`);
169
- }
170
- // Rendered subtitle and question body are scrollable so long content never
171
- // overflows the fixed header.
172
- const bodyLines = [];
173
- if (interaction.subtitle) {
174
- bodyLines.push('');
175
- for (const line of mdLines(interaction.subtitle)) {
176
- bodyLines.push(` ${line}`);
177
- }
178
- }
179
- if (interaction.body) {
180
- bodyLines.push('');
181
- for (const line of mdLines(interaction.body)) {
182
- bodyLines.push(` ${line}`);
183
- }
109
+ 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
+ // A single-slot page opens straight into item-review, so its leading prose has nowhere else to render.
111
+ 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}`);
117
+ if (!terminalAnswerable(slot))
118
+ lines.push('', ` ${DIM}${sanitize(slot.kind)} — cannot be answered in the terminal; open in Northlight${RESET}`);
119
+ else if (slot.kind === 'chart') {
120
+ const c = slot.config;
121
+ const title = c.title ?? `${c.x} vs ${(c.y ?? []).map((y) => y.label ?? y.field).join(', ')}`;
122
+ lines.push('', ` ${DIM}chart — ${sanitize(c.kind)}: ${sanitize(title)}${RESET}`);
123
+ }
124
+ else if (slot.kind === 'table') {
125
+ lines.push('');
126
+ for (const line of mdLines(tableMarkdown(slot.config), maxW, paneW))
127
+ lines.push(` ${line}`);
128
+ const caption = slot.config.caption;
129
+ if (caption)
130
+ lines.push(` ${DIM}${sanitize(caption)}${RESET}`);
131
+ }
132
+ else if (slot.kind === 'text') {
133
+ const response = slot.id === undefined ? undefined : state.responses.get(slot.id);
134
+ const text = response?.text ?? slot.config.initialText;
135
+ lines.push('');
136
+ for (const line of mdLines(text, maxW, paneW))
137
+ lines.push(` ${line}`);
184
138
  }
185
- // Post-body: input buffer or actions, then footer.
186
- const postLines = [];
187
- postLines.push('');
188
- if (state.inputMode) {
189
- postLines.push(` ${DIM}${hline(maxW)}${RESET}`);
190
- const label = interaction.freetextLabel !== undefined
191
- ? interaction.freetextLabel
192
- : state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
193
- // Show attached option in comment mode. For single-select the comment
194
- // qualifies the pick; for multi-select an attached option means the
195
- // comment is saved as a per-option note (and auto-checks the option).
196
- let attachedLine;
197
- if (state.inputMode.kind === 'comment') {
198
- const attachedId = state.inputMode.selectedOptionId;
199
- const opts = interaction.options;
200
- if (opts.length > 0) {
201
- const attached = attachedId !== undefined
202
- ? opts.find((o) => o.id === attachedId)
203
- : undefined;
204
- const valueText = attached !== undefined
205
- ? `${CYAN}${truncate(singleLine(attached.label), Math.max(10, maxW - 28))}${RESET}`
206
- : `${DIM}none (overall)${RESET}`;
207
- attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
208
- }
209
- }
210
- for (const labelLine of wrap(`${singleLine(label)}:`, maxW)) {
211
- postLines.push(` ${YELLOW}${labelLine}${RESET}`);
212
- }
213
- const bufLines = renderInputBuffer(state.inputMode.buffer, state.inputMode.cursor, maxW - 1);
214
- for (const line of bufLines)
215
- postLines.push(` ${line}`);
216
- if (attachedLine !== undefined) {
217
- postLines.push('');
218
- postLines.push(attachedLine);
219
- }
220
- postLines.push('');
221
- postLines.push(` ${DIM}enter${RESET} submit ${DIM}^J/⌥⏎${RESET} newline${state.editorAvailable ? ` ${DIM}^O${RESET} editor` : ''} ${DIM}esc${RESET} cancel`);
139
+ lines.push('', ` ${DIM}${hline(maxW)}${RESET}`);
140
+ 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';
142
+ lines.push(` ${BOLD}${label}${RESET}`);
143
+ for (const line of renderInputBuffer(state.inputMode.buffer, state.inputMode.cursor, Math.max(10, maxW - 4)))
144
+ lines.push(` ${line}`);
145
+ lines.push(` ${DIM}enter save esc cancel${state.editorAvailable ? ' ^o editor' : ''}${RESET}`);
222
146
  }
223
147
  else {
224
- postLines.push(...renderActions(interaction, state.selectedAction, maxW, response));
225
- }
226
- // Transient hint (e.g. an empty multi-select Enter that was rejected). Sits
227
- // just above the footer; cleared on the next keypress.
228
- if (state.hint !== undefined && state.hint.length > 0) {
229
- postLines.push('');
230
- for (const hintLine of wrap(sanitize(state.hint), maxW)) {
231
- postLines.push(` ${YELLOW}${hintLine}${RESET}`);
232
- }
233
- }
234
- // Derive the scroll window bounds. This builder never writes back into state
235
- // — the host clamps state.scrollOffset via clampItemReviewScroll before render.
236
- const reservedRows = preLines.length + postLines.length + 1; // +1 for footer
237
- const bodyHeight = Math.max(1, rows - reservedRows);
238
- const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
239
- const overflows = bodyLines.length > bodyHeight;
240
- // Horizontal geometry, derived from what is actually on screen: a body row
241
- // wider than the prose column takes the whole pane (which also drops the
242
- // centering pad below), and anything past that pans.
243
- const scroll = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
244
- const visible = visibleItemBodyLines(bodyLines, scroll, bodyHeight);
245
- let widest = 0;
246
- for (const line of visible)
247
- widest = Math.max(widest, visibleWidth(line));
248
- const bodyBoxW = widest > maxW + 2 ? paneW + 2 : maxW + 2;
249
- return {
250
- interaction, preLines, bodyLines, postLines,
251
- maxW, bodyHeight, maxScroll, overflows,
252
- bodyBoxW, maxHScroll: Math.max(0, widest - bodyBoxW),
253
- };
254
- }
255
- /** Body rows as they are actually painted: vertical-scroll indicators replace
256
- * their endpoint rows, so those hidden rows cannot make h/l available. */
257
- function visibleItemBodyLines(bodyLines, scroll, bodyHeight) {
258
- if (bodyLines.length <= bodyHeight)
259
- return bodyLines;
260
- const visible = bodyLines.slice(scroll, scroll + bodyHeight);
261
- if (scroll > 0)
262
- visible[0] = ` ${DIM}↑ ${scroll} more above${RESET}`;
263
- const remainingBelow = bodyLines.length - (scroll + bodyHeight);
264
- if (remainingBelow > 0)
265
- visible[visible.length - 1] = ` ${DIM}↓ ${remainingBelow} more below${RESET}`;
266
- return visible;
148
+ const actions = slotActions(slot);
149
+ actions.forEach((action, index) => {
150
+ const cursor = index === state.selectedAction ? `${CYAN}▸${RESET}` : ' ';
151
+ if (action.kind === 'select') {
152
+ const mark = selected(state, slot, action.group, action.id) ? `${GREEN}✓${RESET}` : ' ';
153
+ lines.push(` ${cursor} [${mark}] ${action.shortcut ? `[${action.shortcut}] ` : ''}${sanitize(action.label)}`);
154
+ if (action.description)
155
+ lines.push(` ${DIM}${sanitize(action.description)}${RESET}`);
156
+ }
157
+ else {
158
+ const key = action.kind === 'freetext' ? 'r' : action.kind === 'edit' ? 'e' : 'a';
159
+ lines.push(` ${cursor} [${key}] ${sanitize(action.label)}`);
160
+ }
161
+ });
162
+ for (const comment of responseComments(state, slot))
163
+ lines.push(` ${DIM}attached: ${anchorLabel(slot, comment.anchor)} — ${sanitize(comment.text)}${RESET}`);
164
+ if (state.hint)
165
+ lines.push(` ${YELLOW}${sanitize(state.hint)}${RESET}`);
166
+ lines.push(` ${DIM}n/p next/previous j/k actions c comment u/d scroll h/l pan q close${RESET}`);
167
+ }
168
+ const maxLine = Math.max(0, ...lines.map(visibleWidth));
169
+ return { lines, maxW, maxH: Math.max(0, maxLine - Math.max(1, cols)) };
267
170
  }
268
- /**
269
- * Clamp state.scrollOffset to the current body's scroll bounds. The host runs
270
- * this as a pre-render step (from the input path) so `renderItemReview` stays a
271
- * pure function of state — the clamp keeps u/d scrolling responsive without the
272
- * renderer mutating state mid-frame.
273
- */
274
171
  export function clampItemReviewScroll(state, cols, rows) {
275
- const { maxScroll, maxHScroll } = buildItemReviewLayout(state, cols, rows);
276
- state.scrollOffset = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
277
- state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
278
- // Publish the live horizontal reach so the input layer can tell whether h/l
279
- // mean "pan" on this card, and keep the stored pan inside it across vertical
280
- // scrolling and resize.
281
- state.hscrollMax = maxHScroll;
282
- state.hscrollOffset = Math.max(0, Math.min(state.hscrollOffset || 0, maxHScroll));
172
+ if (state.slots[state.currentIndex] === undefined) {
173
+ state.scrollOffset = 0;
174
+ state.hscrollOffset = 0;
175
+ state.hscrollMax = 0;
176
+ return;
177
+ }
178
+ const layout = itemLines(state, cols);
179
+ state.hscrollMax = layout.maxH;
180
+ state.hscrollOffset = Math.max(0, Math.min(state.hscrollOffset, state.hscrollMax));
181
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, Math.max(0, layout.lines.length - rows)));
283
182
  }
284
- /** Pan the card body horizontally. Clamping happens in the pre-render clamp
285
- * the host already runs, exactly like vertical over-scroll. */
286
183
  export function panItemReview(state, direction) {
287
- state.hscrollOffset = Math.max(0, (state.hscrollOffset || 0) + direction * PAN_STEP);
184
+ state.hscrollOffset = Math.max(0, Math.min(state.hscrollMax, state.hscrollOffset + direction * PAN_STEP));
288
185
  }
289
186
  export function renderItemReview(state, cols, rows) {
290
- const { interaction, preLines, bodyLines, postLines, maxW, bodyHeight, maxScroll, overflows, bodyBoxW, maxHScroll } = buildItemReviewLayout(state, cols, rows);
291
- // Read-only clamp: renderer never mutates state (clampItemReviewScroll, run
292
- // pre-render, keeps state.scrollOffset itself in bounds).
293
- const scroll = Math.max(0, Math.min(state.scrollOffset || 0, maxScroll));
294
- let visibleBody = visibleItemBodyLines(bodyLines, scroll, bodyHeight);
295
- // Contain every body row in the card's rectangle: rows that fit are
296
- // untouched, a diagram wider than the pane pans under h/l with ‹/› marking
297
- // the content still off-screen.
298
- const hscroll = Math.max(0, Math.min(state.hscrollOffset || 0, maxHScroll));
299
- visibleBody = visibleBody.map((line) => panLine(line, hscroll, bodyBoxW));
300
- // Footer hint — mention scroll keys when body overflows
301
- const footerParts = interaction.multiSelect === true
302
- ? [
303
- `${DIM}n/p${RESET} prev/next`,
304
- `${DIM}space${RESET} toggle`,
305
- `${DIM}enter${RESET} confirm`,
306
- `${DIM}q${RESET} overview`,
307
- ]
308
- : [
309
- `${DIM}n/p${RESET} prev/next`,
310
- `${DIM}q${RESET} overview`,
311
- ];
312
- if (overflows) {
313
- footerParts.unshift(state.inputMode ? `${DIM}pgup/pgdn${RESET} scroll` : `${DIM}u/d${RESET} scroll`);
314
- }
315
- if (maxHScroll > 0 && state.inputMode === null) {
316
- footerParts.unshift(`${DIM}h/l${RESET} pan`);
317
- }
318
- const footer = ` ${footerParts.join(' ')}`;
319
- // Assemble — pad to fill rows so post-body sits at the bottom
320
- const lines = [...preLines, ...visibleBody, ...postLines];
321
- while (lines.length < rows - 1)
322
- lines.push('');
323
- lines.push(footer);
324
- // Final clamp (safety net for very small terminals)
325
- const clamped = lines.length > rows
326
- ? [...lines.slice(0, rows - 1), footer]
327
- : lines;
328
- // Content occupies the body rectangle (prose column, or the full pane when a
329
- // diagram claims it) — center the whole block when the terminal is wider,
330
- // then contain every row so nothing can wrap past the card's rectangle.
331
- return centerHorizontal(clamped, cols, bodyBoxW).map((line) => clipLine(line, cols));
332
- }
333
- /** The option list. Focus is always drawn so j/k and shortcuts show where they land. */
334
- function renderActions(interaction, selectedAction, maxW, existing) {
335
- const lines = [];
336
- const opts = interaction.options;
337
- // Prefix on first row: " X [s] " — 2 + 1 (cursor) + 1 + 3 ([s]) + 1 = 8 visible cols.
338
- // Continuation rows align under the label so each option reads as a block.
339
- const multi = interaction.multiSelect === true;
340
- const checked = new Set(existing?.selectedOptionIds ?? []);
341
- const prefixWidth = multi ? 12 : 8;
342
- const indent = ' '.repeat(prefixWidth);
343
- const contentMax = Math.max(20, maxW - prefixWidth);
344
- const optionComments = existing !== undefined ? existing.optionComments : undefined;
345
- for (let i = 0; i < opts.length; i++) {
346
- const o = opts[i];
347
- const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
348
- const sc = o.shortcut === undefined ? ' ' : o.shortcut;
349
- const keyBadge = `${DIM}[${sc}]${RESET}`;
350
- const box = multi
351
- ? (checked.has(o.id) ? `${GREEN}[x]${RESET}` : `${DIM}[ ]${RESET}`) + ' '
352
- : '';
353
- const labelLines = wrap(sanitize(o.label), contentMax);
354
- for (let j = 0; j < labelLines.length; j++) {
355
- const prefix = j === 0 ? ` ${cursor} ${box}${keyBadge} ` : indent;
356
- lines.push(`${prefix}${labelLines[j]}`);
357
- }
358
- if (o.description) {
359
- const descLines = wrap(`— ${sanitize(o.description)}`, contentMax);
360
- for (const dl of descLines) {
361
- lines.push(`${indent}${DIM}${dl}${RESET}`);
362
- }
363
- }
364
- if (multi && optionComments !== undefined) {
365
- const note = optionComments[o.id];
366
- if (typeof note === 'string' && note.length > 0) {
367
- const noteLines = wrap(`✎ ${sanitize(note)}`, contentMax);
368
- for (const nl of noteLines) {
369
- lines.push(`${indent}${YELLOW}${nl}${RESET}`);
370
- }
371
- }
372
- }
373
- }
374
- if (interaction.allowFreetext && opts.length > 0) {
375
- const cursor = opts.length === selectedAction ? `${CYAN}▸${RESET}` : ' ';
376
- let label;
377
- if (interaction.freetextLabel !== undefined)
378
- label = interaction.freetextLabel;
379
- else if (multi)
380
- label = 'Add overall comment (c on an option for per-option)';
381
- else
382
- label = 'Add comment';
383
- const ftLines = wrap(sanitize(label), contentMax);
384
- for (let j = 0; j < ftLines.length; j++) {
385
- const prefix = j === 0 ? ` ${cursor} ${DIM}[c]${RESET} ` : ' '.repeat(8);
386
- lines.push(`${prefix}${ftLines[j]}`);
387
- }
388
- }
389
- else if (interaction.allowFreetext && opts.length === 0) {
390
- const ftLabel = interaction.freetextLabel !== undefined ? interaction.freetextLabel : 'Enter response';
391
- const ftLines = wrap(sanitize(ftLabel), contentMax);
392
- for (let j = 0; j < ftLines.length; j++) {
393
- const prefix = j === 0 ? ` ${DIM}[r]${RESET} ` : ' '.repeat(6);
394
- lines.push(`${prefix}${ftLines[j]}`);
395
- }
396
- }
397
- if (existing) {
398
- lines.push('');
399
- for (const curLine of wrap(`Current: ${responseSummary(existing, interaction)}`, maxW)) {
400
- lines.push(` ${GREEN}${curLine}${RESET}`);
401
- }
402
- }
403
- return lines;
187
+ const layout = itemLines(state, cols);
188
+ const start = state.scrollOffset;
189
+ const sliced = layout.lines.slice(start, start + rows);
190
+ while (sliced.length < rows)
191
+ sliced.push('');
192
+ const panned = state.hscrollOffset > 0 ? sliced.map((line) => panLine(line, state.hscrollOffset, cols)) : sliced;
193
+ return centerHorizontal(panned.map((line) => clipLine(line, cols)), cols, Math.min(cols, layout.maxW + 2));
404
194
  }
405
195
  export function renderFinal(state, cols, rows) {
406
- const header = [];
407
- const footer = [];
408
- const maxW = Math.min(cols - 4, 60);
409
- const total = state.interactions.length;
410
- const answered = state.responses.size;
411
- header.push('');
412
- header.push(` ${BOLD}${CYAN} Summary ${RESET}`);
413
- header.push(` ${DIM}${hline(maxW)}${RESET}`);
414
- header.push('');
415
- header.push(` ${answered}/${total} questions answered`);
416
- header.push('');
417
- footer.push('');
418
- footer.push(` ${DIM}${hline(maxW)}${RESET}`);
419
- if (answered < total) {
420
- footer.push(` ${YELLOW}${total - answered} unanswered — press p to go back${RESET}`);
421
- }
422
- footer.push(` ${DIM}enter${RESET} submit ${DIM}p${RESET} go back`);
423
- const questionRows = [];
424
- for (const interaction of state.interactions) {
425
- const response = state.responses.get(interaction.id);
426
- const preAnswered = state.preAnsweredIds.has(interaction.id);
427
- const icon = response
428
- ? (preAnswered ? `${DIM}◆${RESET}` : `${GREEN}✓${RESET}`)
429
- : `${YELLOW}○${RESET}`;
430
- const label = singleLine(interaction.title);
431
- questionRows.push(` ${icon} ${truncate(label, Math.max(10, maxW - 4))}`);
432
- if (response) {
433
- questionRows.push(` ${DIM}${truncate(singleLine(responseSummary(response, interaction)), Math.max(10, maxW - 6))}${RESET}`);
434
- }
435
- }
436
- const available = Math.max(1, rows - header.length - footer.length - 1);
437
- let visible = questionRows;
438
- if (questionRows.length > available) {
439
- visible = [
440
- ...questionRows.slice(0, available - 1),
441
- ` ${DIM}… ${questionRows.length - (available - 1)} more rows omitted${RESET}`,
442
- ];
196
+ const maxW = Math.min(Math.max(20, cols - 4), 120);
197
+ 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}`);
201
+ }
202
+ if (state.manifest.dialect !== 'html')
203
+ for (const line of proseLines(state.finalProse, maxW, Math.max(20, cols - 4)))
204
+ lines.push(` ${line}`);
205
+ const complete = pageComplete(state);
206
+ if (!complete) {
207
+ lines.push('', ` ${YELLOW}Cannot submit yet.${RESET}`);
208
+ for (const slot of state.slots.filter(isResponseBearingSlot))
209
+ if (!slotAnswered(state, slot)) {
210
+ lines.push(` ${DIM}${slot.id ?? slot.kind} is unanswered${RESET}`);
211
+ if (!terminalAnswerable(slot))
212
+ lines.push(` ${DIM}${slot.id} must be answered in Northlight${RESET}`);
213
+ }
443
214
  }
444
- const lines = [...header, ...visible, ...footer];
445
- while (lines.length < rows)
446
- lines.push('');
447
- return centerHorizontal(lines.slice(0, rows), cols, maxW + 2);
215
+ lines.push('', ` ${DIM}${hline(maxW)}${RESET}`, complete ? ` ${DIM}enter${RESET} submit ${DIM}q${RESET} back` : ` ${DIM}q${RESET} back`);
216
+ return centeredWindow(lines, state, cols, rows, maxW + 2);
448
217
  }
449
- export function responseSummary(r, interaction) {
450
- if (r.selectedOptionIds !== undefined) {
451
- const oc = r.optionComments;
452
- const parts = r.selectedOptionIds
453
- .map((id) => interaction.options.find((o) => o.id === id))
454
- .filter((o) => o !== undefined)
455
- .map((o) => {
456
- const note = oc !== undefined ? oc[o.id] : undefined;
457
- return typeof note === 'string' && note.length > 0
458
- ? `${sanitize(o.label)} ("${sanitize(note)}")`
459
- : sanitize(o.label);
460
- });
461
- const picks = parts.length > 0 ? parts.join(', ') : '(none)';
462
- if (r.freetext)
463
- return `${picks}: "${sanitize(r.freetext)}"`;
464
- return picks;
465
- }
466
- const opt = r.selectedOptionId
467
- ? interaction.options.find((o) => o.id === r.selectedOptionId)
468
- : undefined;
469
- if (opt && r.freetext)
470
- return `${sanitize(opt.label)}: "${sanitize(r.freetext)}"`;
471
- if (opt)
472
- return sanitize(opt.label);
473
- if (r.freetext)
474
- return sanitize(r.freetext);
475
- return '(empty)';
218
+ function centeredWindow(lines, state, cols, rows, width) {
219
+ const max = Math.max(0, lines.length - rows);
220
+ state.scrollOffset = Math.max(0, Math.min(state.scrollOffset, max));
221
+ const out = lines.slice(state.scrollOffset, state.scrollOffset + rows);
222
+ while (out.length < rows)
223
+ out.push('');
224
+ return centerHorizontal(out.map((line) => clipLine(line, cols)), cols, Math.min(cols, width));
476
225
  }