@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,130 +1,50 @@
1
+ import { isResponseBearingSlot } from '../../../core/human/page-schema.js';
1
2
  import { panItemReview, verticalCursor } from './render.js';
2
- const RESERVED = new Set(['c', 'r', 'n', 'p', 'q', 'j', 'k', 'u', 'd', ' ']);
3
- export function assignShortcuts(interactions) {
4
- for (const it of interactions) {
5
- const used = new Set(it.options.map((o) => o.shortcut).filter((s) => s !== undefined));
6
- for (const opt of it.options) {
7
- if (opt.shortcut !== undefined)
8
- continue;
9
- const letters = [...opt.label.toLowerCase()].filter((c) => /[a-z]/.test(c));
10
- let chosen;
11
- for (const letter of letters) {
12
- if (!used.has(letter) && !RESERVED.has(letter)) {
13
- chosen = letter;
14
- break;
15
- }
16
- }
17
- if (chosen === undefined) {
18
- for (let d = 1; d <= 9; d++) {
19
- const s = String(d);
20
- if (!used.has(s)) {
21
- chosen = s;
22
- break;
23
- }
24
- }
25
- }
26
- if (chosen !== undefined) {
27
- opt.shortcut = chosen;
28
- used.add(chosen);
29
- }
30
- }
31
- }
32
- }
3
+ import { commentIdFor, pageComplete, slotActions, slotAnswered, terminalAnswerable } from './slots.js';
4
+ /** Only a single-mode options/cards pick jumps ahead; a table pick leaves the user on the table. */
5
+ const autoAdvances = (slot, mode) => mode === 'single' && (slot.kind === 'options' || slot.kind === 'cards');
33
6
  export function handleKeypress(input, key, state, render, exit) {
34
7
  if (key.ctrl && input === 'c') {
35
- exit();
8
+ exit(false);
36
9
  return;
37
10
  }
38
- // Clear any transient hint from the previous keypress. Handlers below may set
39
- // a fresh one (e.g. an empty multi-select Enter), so it survives exactly one
40
- // render cycle.
41
11
  state.hint = undefined;
42
- if (state.inputMode) {
12
+ if (state.inputMode !== null) {
43
13
  handleInputMode(input, key, state, render);
44
- checkAutoExit(state, exit);
45
14
  return;
46
15
  }
47
- switch (state.phase) {
48
- case 'overview':
49
- handleOverview(input, key, state, render, exit);
50
- break;
51
- case 'item-review':
52
- handleItemReview(input, key, state, render);
53
- checkAutoExit(state, exit);
54
- break;
55
- case 'final':
56
- handleFinal(input, key, state, render, exit);
57
- break;
58
- }
59
- }
60
- function checkAutoExit(state, exit) {
61
- if (state.phase !== 'final')
62
- return;
63
- if (state.responses.size < state.interactions.length)
64
- return;
65
- // Multi-select commits route THROUGH the Summary/confirm screen instead of
66
- // auto-exiting on the first Enter: the commit advances the deck to `final`,
67
- // but the human must press Enter again (handleFinal) to actually submit.
68
- // The interaction that pushed us into `final` is still at currentIndex — the
69
- // advance helpers leave it there when they fall through to `final` — so a
70
- // multi-select there means "just confirmed a set, await deliberate submit".
71
- // Single-select keeps its submit-on-pick fast path (auto-exit below).
72
- const justCommitted = state.interactions[state.currentIndex];
73
- if (justCommitted?.multiSelect === true)
74
- return;
75
- exit();
16
+ if (state.phase === 'overview')
17
+ handleOverview(input, key, state, render, exit);
18
+ else if (state.phase === 'item-review')
19
+ handleItemReview(input, key, state, render, exit);
20
+ else
21
+ handleFinal(input, key, state, render, exit);
76
22
  }
77
- // ── Overview ─────────────────────────────────────────────────────────────────
78
23
  function handleOverview(input, key, state, render, exit) {
79
- if (input === 'j' || key.downArrow) {
80
- state.currentIndex = Math.min(state.currentIndex + 1, state.interactions.length - 1);
81
- render();
82
- return;
83
- }
84
- if (input === 'k' || key.upArrow) {
24
+ if (input === 'j' || key.downArrow)
25
+ state.currentIndex = Math.min(state.currentIndex + 1, Math.max(0, state.slots.length - 1));
26
+ else if (input === 'k' || key.upArrow)
85
27
  state.currentIndex = Math.max(state.currentIndex - 1, 0);
86
- render();
87
- return;
88
- }
89
- if (key.return || input === ' ') {
28
+ else if ((key.return || input === ' ') && state.slots.length > 0) {
90
29
  state.phase = 'item-review';
91
- state.selectedAction = 0;
92
- state.bodyMode = 'question';
93
- state.scrollOffset = state.bodyScrollOffsets.question;
94
- render();
95
- return;
30
+ resetItem(state);
96
31
  }
97
- if (input === 'q') {
98
- if (state.responses.size >= state.interactions.length) {
99
- exit();
100
- }
101
- else {
102
- state.phase = 'final';
103
- render();
104
- }
32
+ else if (input === 'q' || key.escape) {
33
+ exit(!state.manifest.slots.some(isResponseBearingSlot));
105
34
  return;
106
35
  }
107
- // Quick-answer: option shortcut for the focused interaction. Lets users
108
- // answer from the overview list without pressing Enter first.
109
- const interaction = state.interactions[state.currentIndex];
110
- if (interaction !== undefined) {
111
- const matched = interaction.options.find((o) => o.shortcut === input);
112
- if (matched !== undefined) {
113
- if (interaction.multiSelect) {
114
- toggleMulti(state, interaction, matched.id);
115
- }
116
- else {
117
- submitOption(state, interaction, matched.id, undefined);
118
- }
119
- // Don't auto-advance the cursor — users may want to re-answer the same
120
- // question. The response icon flips ✓ and they can j/k away when ready.
121
- render();
122
- }
123
- }
36
+ else
37
+ return;
38
+ render();
124
39
  }
125
- // ── Item Review ──────────────────────────────────────────────────────────────
126
- function handleItemReview(input, key, state, render) {
127
- const interaction = state.interactions[state.currentIndex];
40
+ function handleItemReview(input, key, state, render, exit) {
41
+ const slot = state.slots[state.currentIndex];
42
+ if (slot === undefined) {
43
+ state.phase = 'overview';
44
+ render();
45
+ return;
46
+ }
47
+ const actions = slotActions(slot);
128
48
  if (input === 'n') {
129
49
  advanceItem(state, 1);
130
50
  render();
@@ -135,519 +55,288 @@ function handleItemReview(input, key, state, render) {
135
55
  render();
136
56
  return;
137
57
  }
138
- // q / Esc step back to the deck overview (one level up from a card).
139
58
  if (input === 'q' || key.escape) {
140
- state.bodyScrollOffsets[state.bodyMode] = state.scrollOffset;
141
- state.phase = 'overview';
142
- render();
59
+ exit(false);
143
60
  return;
144
61
  }
145
- // Space toggles the focused option for multi-select.
146
- if (input === ' ' && interaction.multiSelect
147
- && state.selectedAction < interaction.options.length) {
148
- toggleMulti(state, interaction, interaction.options[state.selectedAction].id);
149
- render();
150
- return;
151
- }
152
- // Body scroll: u/d, PageUp/PageDown, or Ctrl+D / Ctrl+U (half-page), Ctrl+E / Ctrl+Y (line).
153
- // Plain u/d exists because tmux configs commonly bind C-d/C-u for pane scroll
154
- // and intercept them before they reach the app. Render clamps state.scrollOffset,
155
- // so over-scroll past the bottom is harmless.
156
62
  if (input === 'd' || key.pageDown || (key.ctrl && (input === 'd' || input === 'e'))) {
157
- state.scrollOffset = (state.scrollOffset ?? 0) + (input === 'e' ? 1 : 10);
63
+ state.scrollOffset += input === 'e' ? 1 : 10;
158
64
  render();
159
65
  return;
160
66
  }
161
67
  if (input === 'u' || key.pageUp || (key.ctrl && (input === 'u' || input === 'y'))) {
162
- state.scrollOffset = Math.max(0, (state.scrollOffset ?? 0) - (input === 'y' ? 1 : 10));
68
+ state.scrollOffset = Math.max(0, state.scrollOffset - (input === 'y' ? 1 : 10));
163
69
  render();
164
70
  return;
165
71
  }
166
- // Body pan: contextual, so h/l only claim the key while the visible body
167
- // actually overflows its rectangle (a wide diagram) AND no option owns that
168
- // shortcut. Otherwise they fall through to the option/action handling below.
169
- if ((input === 'h' || input === 'l' || key.leftArrow || key.rightArrow)
170
- && (state.hscrollMax ?? 0) > 0
171
- && !interaction.options.some((o) => o.shortcut === input)) {
72
+ if ((input === 'h' || input === 'l' || key.leftArrow || key.rightArrow) && state.hscrollMax > 0 && !actions.some((a) => a.kind === 'select' && a.shortcut === input)) {
172
73
  panItemReview(state, input === 'h' || key.leftArrow ? -1 : 1);
173
74
  render();
174
75
  return;
175
76
  }
176
77
  if (input === 'j' || key.downArrow) {
177
- const max = actionCount(interaction) - 1;
178
- state.selectedAction = Math.min(state.selectedAction + 1, max);
78
+ state.selectedAction = Math.min(state.selectedAction + 1, Math.max(0, actions.length - 1));
179
79
  render();
180
80
  return;
181
81
  }
182
82
  if (input === 'k' || key.upArrow) {
183
- state.selectedAction = Math.max(state.selectedAction - 1, 0);
184
- render();
185
- return;
186
- }
187
- handleInteractionAction(input, key, state, interaction, render);
188
- }
189
- function handleInteractionAction(input, key, state, interaction, render) {
190
- const opts = interaction.options;
191
- // Match by shortcut. Multi-select toggles (stay put); single-select submits.
192
- const matched = opts.find((o) => o.shortcut === input);
193
- if (matched !== undefined) {
194
- if (interaction.multiSelect) {
195
- toggleMulti(state, interaction, matched.id);
196
- render();
197
- return;
198
- }
199
- submitOption(state, interaction, matched.id, undefined);
200
- advanceToNextUnanswered(state);
83
+ state.selectedAction = Math.max(0, state.selectedAction - 1);
201
84
  render();
202
85
  return;
203
86
  }
204
- // Comment mode: allowFreetext + options exist.
205
- //
206
- // Single-select: pre-attach the focused option (the comment qualifies that
207
- // pick). Multi-select: also pre-attach the focused option — the comment is
208
- // saved under `optionComments[id]` (per-option note) and submitting
209
- // auto-checks the option. From the [c] freetext row in multi-select,
210
- // start with no attachment so the comment becomes the overall freetext.
211
- if (input === 'c' && interaction.allowFreetext && opts.length > 0) {
212
- const onOption = state.selectedAction < opts.length;
213
- if (onOption) {
214
- const optId = opts[state.selectedAction].id;
215
- let prefill = '';
216
- if (interaction.multiSelect) {
217
- const existing = state.responses.get(interaction.id);
218
- const prior = existing?.optionComments?.[optId];
219
- if (typeof prior === 'string')
220
- prefill = prior;
221
- }
222
- state.inputMode = { kind: 'comment', buffer: prefill, cursor: [...prefill].length, selectedOptionId: optId };
223
- }
224
- else {
225
- // On the [c] row in multi-select: pre-fill from existing overall freetext.
226
- let prefill = '';
227
- if (interaction.multiSelect) {
228
- const existing = state.responses.get(interaction.id);
229
- if (existing !== undefined && typeof existing.freetext === 'string') {
230
- prefill = existing.freetext;
231
- }
232
- }
233
- state.inputMode = { kind: 'comment', buffer: prefill, cursor: [...prefill].length };
234
- }
87
+ if (input === 'c' && terminalAnswerable(slot)) {
88
+ startComment(state, slot, actions);
235
89
  render();
236
90
  return;
237
91
  }
238
- // Freetext-only: 'r' or enter opens input mode
239
- if (interaction.allowFreetext && opts.length === 0) {
240
- if (input === 'r' || key.return) {
241
- const existing = state.responses.get(interaction.id);
242
- const prefill = existing !== undefined && existing.freetext !== undefined ? existing.freetext : '';
243
- state.inputMode = { kind: 'freetext', buffer: prefill, cursor: [...prefill].length };
244
- render();
245
- return;
246
- }
247
- }
248
- // Enter on selected option row. Multi-select confirms the accumulated set
249
- // and advances; single-select picks that one option.
250
- if (key.return && state.selectedAction < opts.length) {
251
- if (interaction.multiSelect) {
252
- const checked = state.responses.get(interaction.id)?.selectedOptionIds ?? [];
253
- if (checked.length === 0) {
254
- // Accidental Enter with nothing toggled is a no-op: don't finalize or
255
- // advance. A deliberate empty finish is still reachable via `q` →
256
- // overview → finish (partial); freetext-only via the [c] row.
257
- state.hint = 'Select at least one option (space to toggle), or q to skip';
258
- render();
259
- return;
260
- }
261
- commitMulti(state, interaction);
262
- advanceToNextUnanswered(state);
263
- render();
264
- return;
265
- }
266
- const o = opts[state.selectedAction];
267
- submitOption(state, interaction, o.id, undefined);
268
- advanceToNextUnanswered(state);
92
+ const shortcut = actions.find((a) => a.kind === 'select' && a.shortcut === input);
93
+ if (shortcut?.kind === 'select') {
94
+ commitSelection(state, slot, shortcut, autoAdvances(slot, shortcut.mode));
269
95
  render();
96
+ if (autoAdvances(slot, shortcut.mode) && state.phase === 'final' && pageComplete(state))
97
+ exit(true);
270
98
  return;
271
99
  }
272
- // Enter on the [c] row (allowFreetext + options exist)
273
- if (key.return && state.selectedAction === opts.length
274
- && interaction.allowFreetext && opts.length > 0) {
275
- state.inputMode = { kind: 'comment', buffer: '', cursor: 0 };
100
+ if (input === ' ' && actions[state.selectedAction]?.kind === 'select') {
101
+ const action = actions[state.selectedAction];
102
+ commitSelection(state, slot, action, autoAdvances(slot, action.mode));
276
103
  render();
104
+ if (autoAdvances(slot, action.mode) && state.phase === 'final' && pageComplete(state))
105
+ exit(true);
277
106
  return;
278
107
  }
279
- }
280
- // ── Input Mode ───────────────────────────────────────────────────────────────
281
- function handleInputMode(input, key, state, render) {
282
- const mode = state.inputMode;
283
- if (key.escape) {
284
- state.inputMode = null;
108
+ if (input === 'r' && actions.some((a) => a.kind === 'freetext')) {
109
+ const r = responseFor(slot, state.responses.get(slot.id));
110
+ state.inputMode = { kind: 'freetext', buffer: r.freetext ?? '', cursor: [...(r.freetext ?? '')].length };
285
111
  render();
286
112
  return;
287
113
  }
288
- // Tab cycles attached option in comment mode: (none) → opt1 → opt2 → ... → (none)
289
- if (key.tab && mode.kind === 'comment') {
290
- const interaction = state.interactions[state.currentIndex];
291
- const opts = interaction.options;
292
- if (opts.length > 0) {
293
- const cur = mode.selectedOptionId;
294
- const curIdx = cur === undefined ? -1 : opts.findIndex((o) => o.id === cur);
295
- const nextIdx = curIdx + 1; // -1 → 0, last → length (which we map to none)
296
- if (nextIdx >= opts.length) {
297
- delete mode.selectedOptionId;
298
- }
299
- else {
300
- mode.selectedOptionId = opts[nextIdx].id;
301
- }
302
- render();
303
- }
304
- return;
305
- }
306
- if (key.pageDown) {
307
- state.scrollOffset = (state.scrollOffset ?? 0) + 10;
114
+ if (input === 'e' && actions.some((a) => a.kind === 'edit')) {
115
+ const r = responseFor(slot, state.responses.get(slot.id));
116
+ state.inputMode = { kind: 'text', buffer: r.text, cursor: [...r.text].length };
308
117
  render();
309
118
  return;
310
119
  }
311
- if (key.pageUp) {
312
- state.scrollOffset = Math.max(0, (state.scrollOffset ?? 0) - 10);
313
- render();
314
- return;
315
- }
316
- if (key.downArrow) {
317
- mode.cursor = verticalCursor(mode.buffer, mode.cursor, 1, inputWrapWidth(state));
318
- render();
319
- return;
320
- }
321
- if (key.upArrow) {
322
- mode.cursor = verticalCursor(mode.buffer, mode.cursor, -1, inputWrapWidth(state));
120
+ if (input === 'a' && actions.some((a) => a.kind === 'accept')) {
121
+ setResponse(state, slot, { text: slot.config.initialText, edited: false, comments: commentsFor(state.responses.get(slot.id)) });
122
+ advanceToNextIncomplete(state);
323
123
  render();
324
124
  return;
325
125
  }
326
126
  if (key.return) {
327
- const interaction = state.interactions[state.currentIndex];
328
- const perOption = interaction.multiSelect === true
329
- && mode.kind === 'comment'
330
- && mode.selectedOptionId !== undefined;
331
- if (perOption) {
332
- // Per-option comment: save under optionComments[id], auto-check the
333
- // option (idempotent), stay on this interaction so the human can comment
334
- // on another option.
335
- setOptionComment(state, interaction, mode.selectedOptionId, mode.buffer);
336
- state.inputMode = null;
127
+ const action = actions[state.selectedAction];
128
+ if (action?.kind === 'freetext') {
129
+ const r = responseFor(slot, state.responses.get(slot.id));
130
+ state.inputMode = { kind: 'freetext', buffer: r.freetext ?? '', cursor: [...(r.freetext ?? '')].length };
337
131
  render();
338
132
  return;
339
133
  }
340
- if (interaction.multiSelect) {
341
- commitMulti(state, interaction, mode.buffer);
134
+ if (action?.kind === 'edit') {
135
+ const r = responseFor(slot, state.responses.get(slot.id));
136
+ state.inputMode = { kind: 'text', buffer: r.text, cursor: [...r.text].length };
137
+ render();
138
+ return;
342
139
  }
343
- else {
344
- const attached = mode.kind === 'comment' ? mode.selectedOptionId : undefined;
345
- submitOption(state, interaction, attached, mode.buffer);
140
+ if (action?.kind === 'accept') {
141
+ setResponse(state, slot, { text: slot.config.initialText, edited: false, comments: commentsFor(state.responses.get(slot.id)) });
142
+ advanceToNextIncomplete(state);
143
+ render();
144
+ return;
346
145
  }
347
- state.inputMode = null;
348
- advanceToNextUnanswered(state);
349
- render();
350
- return;
351
- }
352
- // Newline-insert chord (ctrl+j / alt+enter) — distinct from key.return
353
- // (submit), handled above.
354
- if (key.newline) {
355
- insertAtCursor(mode, '\n');
356
- render();
357
- return;
358
- }
359
- // ── Cursor motion ──────────────────────────────────────────────────────
360
- if (key.leftArrow) {
361
- mode.cursor = Math.max(0, mode.cursor - 1);
362
- render();
363
- return;
364
- }
365
- if (key.rightArrow) {
366
- mode.cursor = Math.min([...mode.buffer].length, mode.cursor + 1);
367
- render();
368
- return;
369
- }
370
- if (key.wordLeft) {
371
- mode.cursor = wordLeftIndex(mode.buffer, mode.cursor);
372
- render();
373
- return;
374
- }
375
- if (key.wordRight) {
376
- mode.cursor = wordRightIndex(mode.buffer, mode.cursor);
377
- render();
378
- return;
379
- }
380
- if (key.home || (key.ctrl && input === 'a')) {
381
- mode.cursor = lineStart(mode.buffer, mode.cursor);
382
- render();
383
- return;
384
- }
385
- if (key.end || (key.ctrl && input === 'e')) {
386
- mode.cursor = lineEnd(mode.buffer, mode.cursor);
387
- render();
388
- return;
389
- }
390
- // ── Edits ───────────────────────────────────────────────────────────────
391
- if (key.backspace && key.meta) {
392
- deleteWordAtCursor(mode);
146
+ if (slot.kind === 'table' && terminalAnswerable(slot)) {
147
+ const r = responseFor(slot, state.responses.get(slot.id));
148
+ setResponse(state, slot, r);
149
+ advanceToNextIncomplete(state);
150
+ render();
151
+ return;
152
+ }
153
+ if (slotAnswered(state, slot)) {
154
+ advanceToNextIncomplete(state);
155
+ render();
156
+ return;
157
+ }
158
+ if (isResponseBearingSlot(slot)) {
159
+ state.hint = terminalAnswerable(slot) ? 'Select at least one option (space to toggle), or q to go back' : 'This must be answered in Northlight';
160
+ render();
161
+ return;
162
+ }
163
+ advanceItem(state, 1);
393
164
  render();
394
- return;
395
165
  }
396
- // Ctrl-U: delete from line start to cursor (readline "unix-line-discard").
397
- // iTerm2 maps Cmd+Backspace to a bare 0x15, which arrives here as ctrl+'u'.
398
- if (key.ctrl && input === 'u') {
399
- deleteToLineStart(mode);
166
+ }
167
+ function handleFinal(input, key, state, render, exit) {
168
+ if (input === 'q' || key.escape) {
169
+ state.phase = state.slots.length === 1 ? 'item-review' : 'overview';
170
+ state.scrollOffset = 0;
400
171
  render();
401
172
  return;
402
173
  }
403
- if (key.del) {
404
- deleteAtCursor(mode);
405
- render();
174
+ if (key.return && (pageComplete(state) || !state.manifest.slots.some(isResponseBearingSlot)))
175
+ exit(true);
176
+ }
177
+ function resetItem(state) { state.selectedAction = 0; state.scrollOffset = 0; state.hscrollOffset = 0; state.hscrollMax = 0; }
178
+ function advanceItem(state, delta) {
179
+ const next = state.currentIndex + delta;
180
+ if (next < 0) {
181
+ state.phase = state.slots.length > 1 ? 'overview' : 'item-review';
406
182
  return;
407
183
  }
408
- if (key.backspace) {
409
- backspaceAtCursor(mode);
410
- render();
184
+ if (next >= state.slots.length) {
185
+ state.phase = 'final';
186
+ state.scrollOffset = 0;
411
187
  return;
412
188
  }
413
- // Typed input / paste. Bracketed-paste markers and other control chars are
414
- // stripped, but \n (0x0A) is preserved so pasted newlines land as real
415
- // newlines in the buffer instead of vanishing (CRLF is normalized to LF).
416
- const cleaned = input
417
- .replace(/\x1b\[20[01]~/g, '')
418
- .replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
419
- .replace(/\r\n?/g, '\n')
420
- .replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
421
- if (cleaned.length > 0) {
422
- insertAtCursor(mode, cleaned);
423
- render();
424
- }
425
- }
426
- function insertAtCursor(mode, text) {
427
- const chars = [...mode.buffer];
428
- const insert = [...text];
429
- chars.splice(mode.cursor, 0, ...insert);
430
- mode.buffer = chars.join('');
431
- mode.cursor += insert.length;
432
- }
433
- function backspaceAtCursor(mode) {
434
- if (mode.cursor <= 0)
435
- return;
436
- const chars = [...mode.buffer];
437
- chars.splice(mode.cursor - 1, 1);
438
- mode.buffer = chars.join('');
439
- mode.cursor -= 1;
440
- }
441
- function deleteAtCursor(mode) {
442
- const chars = [...mode.buffer];
443
- if (mode.cursor >= chars.length)
444
- return;
445
- chars.splice(mode.cursor, 1);
446
- mode.buffer = chars.join('');
447
- }
448
- function deleteWordAtCursor(mode) {
449
- const start = wordLeftIndex(mode.buffer, mode.cursor);
450
- const chars = [...mode.buffer];
451
- chars.splice(start, mode.cursor - start);
452
- mode.buffer = chars.join('');
453
- mode.cursor = start;
189
+ state.currentIndex = next;
190
+ resetItem(state);
454
191
  }
455
- function deleteToLineStart(mode) {
456
- const start = lineStart(mode.buffer, mode.cursor);
457
- const chars = [...mode.buffer];
458
- chars.splice(start, mode.cursor - start);
459
- mode.buffer = chars.join('');
460
- mode.cursor = start;
192
+ function advanceToNextIncomplete(state) {
193
+ for (let i = state.currentIndex + 1; i < state.slots.length; i++) {
194
+ const slot = state.slots[i];
195
+ if (isResponseBearingSlot(slot) && !slotAnswered(state, slot)) {
196
+ state.currentIndex = i;
197
+ resetItem(state);
198
+ return;
199
+ }
200
+ }
201
+ state.phase = 'final';
202
+ state.scrollOffset = 0;
461
203
  }
462
- /** Skip whitespace backward from `cursor`, then skip the non-whitespace word
463
- * before it — lands on the word's start (readline alt-b / ctrl+left). */
464
- function wordLeftIndex(buffer, cursor) {
465
- const chars = [...buffer];
466
- let i = Math.min(cursor, chars.length);
467
- while (i > 0 && /\s/.test(chars[i - 1]))
468
- i--;
469
- while (i > 0 && !/\s/.test(chars[i - 1]))
470
- i--;
471
- return i;
204
+ function responseFor(slot, existing) {
205
+ if (existing !== undefined)
206
+ return existing;
207
+ if (slot.kind === 'options')
208
+ return { selectedOptionIds: [], comments: [] };
209
+ if (slot.kind === 'cards')
210
+ return { selectedCardIds: [], comments: [] };
211
+ if (slot.kind === 'table')
212
+ return { selectedRowIds: [], selectedColumnIds: [], comments: [] };
213
+ if (slot.kind === 'text')
214
+ return { text: slot.config.initialText, edited: false, comments: [] };
215
+ return {};
472
216
  }
473
- /** Skip whitespace forward from `cursor`, then skip the non-whitespace word
474
- * after it — lands just past the word's end (readline alt-f / ctrl+right). */
475
- function wordRightIndex(buffer, cursor) {
476
- const chars = [...buffer];
477
- let i = Math.min(cursor, chars.length);
478
- while (i < chars.length && /\s/.test(chars[i]))
479
- i++;
480
- while (i < chars.length && !/\s/.test(chars[i]))
481
- i++;
482
- return i;
217
+ function commentsFor(response) { return [...((response?.comments) ?? [])]; }
218
+ function setResponse(state, slot, response) { if (slot.id === undefined)
219
+ return; state.responses.set(slot.id, response); state.persist?.(); }
220
+ function commitSelection(state, slot, action, advance) {
221
+ const response = responseFor(slot, state.responses.get(slot.id));
222
+ if (slot.kind === 'options') {
223
+ const r = response;
224
+ const ids = toggleIds(r.selectedOptionIds, action.id, action.mode);
225
+ setResponse(state, slot, { ...r, selectedOptionIds: ids });
226
+ }
227
+ else if (slot.kind === 'cards') {
228
+ const r = response;
229
+ setResponse(state, slot, { ...r, selectedCardIds: toggleIds(r.selectedCardIds, action.id, action.mode) });
230
+ }
231
+ else if (slot.kind === 'table') {
232
+ const r = response;
233
+ if (action.group === 'row')
234
+ setResponse(state, slot, { ...r, selectedRowIds: toggleIds(r.selectedRowIds, action.id, action.mode) });
235
+ else
236
+ setResponse(state, slot, { ...r, selectedColumnIds: toggleIds(r.selectedColumnIds, action.id, action.mode) });
237
+ }
238
+ if (advance)
239
+ advanceToNextIncomplete(state);
483
240
  }
484
- /** Index just after the previous `\n` (or 0), i.e. the start of the current
485
- * visual line — the multiline analog of ctrl+a/home. */
486
- function lineStart(buffer, cursor) {
487
- const chars = [...buffer];
488
- let i = Math.min(cursor, chars.length);
489
- while (i > 0 && chars[i - 1] !== '\n')
490
- i--;
491
- return i;
241
+ function toggleIds(ids, id, mode) { if (mode === 'single')
242
+ return [id]; return ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]; }
243
+ function anchorFor(action) {
244
+ if (action?.kind !== 'select')
245
+ return { kind: 'whole' };
246
+ if (action.group === 'option')
247
+ return { kind: 'option', optionId: action.id };
248
+ if (action.group === 'card')
249
+ return { kind: 'card', cardId: action.id };
250
+ if (action.group === 'row')
251
+ return { kind: 'row', rowId: action.id };
252
+ return { kind: 'column', columnId: action.id };
492
253
  }
493
- /** Index at the next `\n` (or end of buffer) — the multiline analog of
494
- * ctrl+e/end. */
495
- function lineEnd(buffer, cursor) {
496
- const chars = [...buffer];
497
- let i = Math.min(cursor, chars.length);
498
- while (i < chars.length && chars[i] !== '\n')
499
- i++;
500
- return i;
254
+ function startComment(state, slot, actions) {
255
+ const anchor = anchorFor(actions[state.selectedAction]);
256
+ const comments = commentsFor(slot.id === undefined ? undefined : state.responses.get(slot.id));
257
+ const prior = comments.find((c) => c.id === commentIdFor(anchor))?.text ?? '';
258
+ state.inputMode = { kind: 'comment', buffer: prior, cursor: [...prior].length, anchor };
501
259
  }
502
- /** Width the input buffer is wrapped to on screen — kept in step with the
503
- * `renderInputBuffer(..., maxW - 1)` call in the item-review layout, so up/down
504
- * step by the rows the human sees rather than by logical lines. */
505
- function inputWrapWidth(state) {
506
- return Math.max(10, Math.min(state.wrapWidth - 4, 120) - 1);
260
+ function cycleCommentAnchor(state) {
261
+ if (state.inputMode?.kind !== 'comment')
262
+ return;
263
+ const slot = state.slots[state.currentIndex];
264
+ const anchors = [{ kind: 'whole' }, ...slotActions(slot).filter((a) => a.kind === 'select').map(anchorFor)];
265
+ const current = commentIdFor(state.inputMode.anchor ?? { kind: 'whole' });
266
+ const index = anchors.findIndex((a) => commentIdFor(a) === current);
267
+ const anchor = anchors[(index + 1) % anchors.length];
268
+ const prior = commentsFor(slot.id === undefined ? undefined : state.responses.get(slot.id)).find((c) => c.id === commentIdFor(anchor))?.text ?? '';
269
+ state.inputMode = { kind: 'comment', anchor, buffer: prior, cursor: [...prior].length };
507
270
  }
508
- // ── Final ────────────────────────────────────────────────────────────────────
509
- function handleFinal(input, key, state, render, exit) {
510
- if (key.return) {
511
- exit();
512
- }
513
- else if (key.escape) {
514
- state.phase = 'overview';
271
+ function handleInputMode(input, key, state, render) {
272
+ const mode = state.inputMode;
273
+ if (key.escape) {
274
+ state.inputMode = null;
515
275
  render();
276
+ return;
516
277
  }
517
- else if (input === 'p') {
518
- state.phase = 'item-review';
519
- state.currentIndex = state.interactions.length - 1;
278
+ if (key.tab && mode.kind === 'comment') {
279
+ cycleCommentAnchor(state);
520
280
  render();
521
- }
522
- }
523
- // ── Helpers ──────────────────────────────────────────────────────────────────
524
- function advanceItem(state, direction) {
525
- const next = state.currentIndex + direction;
526
- if (next < 0)
527
281
  return;
528
- if (next >= state.interactions.length) {
529
- state.phase = 'final';
282
+ }
283
+ if (key.return) {
284
+ commitInput(state);
285
+ render();
530
286
  return;
531
287
  }
532
- state.currentIndex = next;
533
- state.selectedAction = 0;
534
- state.bodyMode = 'question';
535
- state.scrollOffset = 0;
536
- state.bodyScrollOffsets = { question: 0 };
537
- state.hscrollOffset = 0;
538
- }
539
- /**
540
- * Move to the next interaction WITHOUT a response, falling through to the
541
- * final phase if every following interaction is already answered (whether
542
- * user-answered or `preAnswered`-seeded). Used by all post-submit advance
543
- * sites so the human flies through pre-approved items by hitting Enter; raw
544
- * `n`/`p` still step one at a time via `advanceItem`.
545
- */
546
- function advanceToNextUnanswered(state) {
547
- let next = state.currentIndex + 1;
548
- while (next < state.interactions.length && state.responses.has(state.interactions[next].id)) {
549
- next++;
550
- }
551
- if (next >= state.interactions.length) {
552
- state.phase = 'final';
288
+ if (key.newline) {
289
+ insert(mode, '\n');
290
+ render();
553
291
  return;
554
292
  }
555
- state.currentIndex = next;
556
- state.selectedAction = 0;
557
- state.bodyMode = 'question';
558
- state.scrollOffset = 0;
559
- state.bodyScrollOffsets = { question: 0 };
560
- state.hscrollOffset = 0;
561
- }
562
- function actionCount(interaction) {
563
- return interaction.options.length + (interaction.allowFreetext && interaction.options.length > 0 ? 1 : 0);
564
- }
565
- function submitOption(state, interaction, selectedOptionId, freetext) {
566
- const response = { id: interaction.id };
567
- if (selectedOptionId !== undefined)
568
- response.selectedOptionId = selectedOptionId;
569
- if (freetext !== undefined)
570
- response.freetext = freetext;
571
- state.responses.set(interaction.id, response);
572
- // Explicit user submission overrides any preAnswered seed — flip the icon
573
- // from "previously answered" to user-answered.
574
- state.preAnsweredIds.delete(interaction.id);
575
- state.persist?.();
576
- }
577
- // ── Multi-select ─────────────────────────────────────────────────────────────
578
- // Toggle/commit write progressively into state.responses (mirrors single-select
579
- // submitOption immediacy); Enter just confirms the accumulated set + advances.
580
- function toggleMulti(state, interaction, optionId) {
581
- const existing = state.responses.get(interaction.id);
582
- const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
583
- ? existing.selectedOptionIds
584
- : [];
585
- const set = new Set(priorIds);
586
- if (set.has(optionId))
587
- set.delete(optionId);
588
- else
589
- set.add(optionId);
590
- const response = { id: interaction.id, selectedOptionIds: [...set] };
591
- if (existing !== undefined && existing.freetext !== undefined)
592
- response.freetext = existing.freetext;
593
- if (existing !== undefined && existing.optionComments !== undefined) {
594
- response.optionComments = { ...existing.optionComments };
595
- }
596
- state.responses.set(interaction.id, response);
597
- // User edited the checked set — no longer a passive carry-over.
598
- state.preAnsweredIds.delete(interaction.id);
599
- state.persist?.();
600
- }
601
- /** Ensure a (possibly empty) response exists so the interaction counts as
602
- * answered, optionally setting/replacing freetext. */
603
- function commitMulti(state, interaction, freetext) {
604
- const existing = state.responses.get(interaction.id);
605
- const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
606
- ? existing.selectedOptionIds
607
- : [];
608
- const response = {
609
- id: interaction.id,
610
- selectedOptionIds: [...priorIds],
611
- };
612
- let ft;
613
- if (freetext !== undefined)
614
- ft = freetext;
615
- else if (existing !== undefined)
616
- ft = existing.freetext;
617
- if (ft !== undefined)
618
- response.freetext = ft;
619
- if (existing !== undefined && existing.optionComments !== undefined) {
620
- response.optionComments = { ...existing.optionComments };
621
- }
622
- state.responses.set(interaction.id, response);
623
- // Explicit confirm overrides any preAnswered seed.
624
- state.preAnsweredIds.delete(interaction.id);
625
- state.persist?.();
293
+ if (key.leftArrow)
294
+ mode.cursor = Math.max(0, mode.cursor - 1);
295
+ else if (key.rightArrow)
296
+ mode.cursor = Math.min([...mode.buffer].length, mode.cursor + 1);
297
+ else if (key.upArrow)
298
+ mode.cursor = verticalCursor(mode.buffer, mode.cursor, -1, inputWrapWidth(state));
299
+ else if (key.downArrow)
300
+ mode.cursor = verticalCursor(mode.buffer, mode.cursor, 1, inputWrapWidth(state));
301
+ else if (key.home || (key.ctrl && input === 'a'))
302
+ mode.cursor = lineStart(mode.buffer, mode.cursor);
303
+ else if (key.end || (key.ctrl && input === 'e'))
304
+ mode.cursor = lineEnd(mode.buffer, mode.cursor);
305
+ else if (key.backspace || input === '\x7f')
306
+ backspace(mode);
307
+ else if (key.del || (key.ctrl && input === 'd'))
308
+ del(mode);
309
+ else if (input.length > 0 && !key.ctrl && !key.meta)
310
+ insert(mode, input);
311
+ render();
626
312
  }
627
- /**
628
- * Multi-select per-option comment: write `comment` to optionComments[optionId]
629
- * and auto-check the option. Empty comment still records the entry (the user
630
- * committed deliberately via Enter; Esc cancels without write).
631
- */
632
- function setOptionComment(state, interaction, optionId, comment) {
633
- const existing = state.responses.get(interaction.id);
634
- const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
635
- ? existing.selectedOptionIds
636
- : [];
637
- const set = new Set(priorIds);
638
- set.add(optionId);
639
- const priorComments = existing !== undefined && existing.optionComments !== undefined
640
- ? existing.optionComments
641
- : {};
642
- const nextComments = { ...priorComments, [optionId]: comment };
643
- const response = {
644
- id: interaction.id,
645
- selectedOptionIds: [...set],
646
- optionComments: nextComments,
647
- };
648
- if (existing !== undefined && existing.freetext !== undefined)
649
- response.freetext = existing.freetext;
650
- state.responses.set(interaction.id, response);
651
- state.preAnsweredIds.delete(interaction.id);
652
- state.persist?.();
313
+ function commitInput(state) {
314
+ const mode = state.inputMode;
315
+ const slot = state.slots[state.currentIndex];
316
+ const response = responseFor(slot, slot.id === undefined ? undefined : state.responses.get(slot.id));
317
+ if (mode.kind === 'comment') {
318
+ const anchor = mode.anchor ?? { kind: 'whole' };
319
+ const id = commentIdFor(anchor);
320
+ const comments = commentsFor(response).filter((c) => c.id !== id);
321
+ if (mode.buffer.trim() !== '')
322
+ comments.push({ id, anchor, text: mode.buffer });
323
+ setResponse(state, slot, { ...response, comments });
324
+ }
325
+ else if (mode.kind === 'freetext') {
326
+ setResponse(state, slot, { ...response, freetext: mode.buffer });
327
+ advanceToNextIncomplete(state);
328
+ }
329
+ else {
330
+ setResponse(state, slot, { text: mode.buffer, edited: mode.buffer !== slot.config.initialText, comments: commentsFor(response) });
331
+ advanceToNextIncomplete(state);
332
+ }
333
+ state.inputMode = null;
653
334
  }
335
+ function insert(mode, text) { const chars = [...mode.buffer]; chars.splice(mode.cursor, 0, ...text); mode.buffer = chars.join(''); mode.cursor += [...text].length; }
336
+ function backspace(mode) { if (mode.cursor === 0)
337
+ return; const chars = [...mode.buffer]; chars.splice(mode.cursor - 1, 1); mode.buffer = chars.join(''); mode.cursor--; }
338
+ function del(mode) { const chars = [...mode.buffer]; if (mode.cursor >= chars.length)
339
+ return; chars.splice(mode.cursor, 1); mode.buffer = chars.join(''); }
340
+ function lineStart(buffer, cursor) { const before = [...buffer].slice(0, cursor).join(''); const i = before.lastIndexOf('\n'); return i < 0 ? 0 : [...before.slice(0, i + 1)].length; }
341
+ function lineEnd(buffer, cursor) { const chars = [...buffer]; const i = chars.slice(cursor).indexOf('\n'); return i < 0 ? chars.length : cursor + i; }
342
+ function inputWrapWidth(state) { return Math.max(10, Math.min(state.wrapWidth - 8, 116)); }