@north-light/crouter 0.3.198 → 0.3.200

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 (316) hide show
  1. package/dist/api/client.d.ts +18 -18
  2. package/dist/api/client.js +29 -37
  3. package/dist/api/dto/broker-ops.d.ts +3 -1
  4. package/dist/api/dto/broker.d.ts +13 -2
  5. package/dist/api/dto/broker.js +4 -4
  6. package/dist/api/dto/human.d.ts +6 -5
  7. package/dist/api/dto/inbox.d.ts +81 -34
  8. package/dist/api/dto/nodes.d.ts +8 -2
  9. package/dist/api/dto/profiles.d.ts +23 -0
  10. package/dist/api/dto/profiles.js +2 -2
  11. package/dist/api/dto/worktree.d.ts +3 -5
  12. package/dist/api/routes.d.ts +3 -5
  13. package/dist/api/routes.js +4 -6
  14. package/dist/builtin-memory/00-runtime-base.md +6 -6
  15. package/dist/builtin-memory/01-spine/01-no-manager.md +1 -1
  16. package/dist/builtin-memory/02-lifecycle/00-terminal.md +3 -3
  17. package/dist/builtin-memory/04-orchestration-kernel.md +6 -6
  18. package/dist/builtin-memory/05-kinds/design/00-base.md +1 -1
  19. package/dist/builtin-memory/05-kinds/plan/reviewers/security.md +2 -2
  20. package/dist/builtin-memory/05-kinds/review/00-base.md +2 -2
  21. package/dist/builtin-memory/05-kinds/review/01-orchestrator.md +1 -1
  22. package/dist/builtin-memory/05-kinds/spec/01-orchestrator.md +1 -1
  23. package/dist/builtin-memory/insights/capture.md +2 -2
  24. package/dist/builtin-memory/insights/init.md +3 -3
  25. package/dist/builtin-memory/internal/INDEX.md +1 -1
  26. package/dist/builtin-memory/internal/memory-loading.md +1 -1
  27. package/dist/builtin-memory/internal/nodes-and-canvas.md +2 -2
  28. package/dist/builtin-memory/internal/plugins.md +59 -2
  29. package/dist/builtin-memory/internal/storage-tiers.md +3 -1
  30. package/dist/builtin-memory/spec/roadmap.md +2 -2
  31. package/dist/builtin-memory/testing.md +1 -1
  32. package/dist/clients/attach/chrome/bash-jobs.js +3 -3
  33. package/dist/clients/attach/render/chat-view.d.ts +26 -1
  34. package/dist/clients/attach/render/chat-view.js +61 -11
  35. package/dist/clients/attach/render/page-block.d.ts +34 -0
  36. package/dist/clients/attach/render/page-block.js +226 -0
  37. package/dist/clients/attach/session/whip.d.ts +2 -0
  38. package/dist/clients/attach/session/whip.js +2 -0
  39. package/dist/clients/attach/viewer.js +1419 -993
  40. package/dist/clients/conversation/projection.js +18 -3
  41. package/dist/clients/inbox/__tests__/serial/inbox-controller.test.js +9 -6
  42. package/dist/clients/inbox/__tests__/serial/mount-panel.test.js +13 -21
  43. package/dist/clients/inbox/controller.js +10 -16
  44. package/dist/clients/inbox/page-adapter.d.ts +1 -3
  45. package/dist/clients/inbox/page-adapter.js +3 -4
  46. package/dist/clients/inbox/resolve.d.ts +1 -1
  47. package/dist/clients/inbox/resolve.js +3 -3
  48. package/dist/clients/inbox/review/document-surface.js +3 -1
  49. package/dist/clients/inbox/review/state.d.ts +2 -1
  50. package/dist/clients/inbox/review/state.js +5 -16
  51. package/dist/clients/inbox/tui/input.js +37 -19
  52. package/dist/clients/inbox/tui/panel.js +5 -31
  53. package/dist/clients/inbox/tui/render.d.ts +11 -0
  54. package/dist/clients/inbox/tui/render.js +71 -27
  55. package/dist/clients/inbox/tui/slots.d.ts +3 -1
  56. package/dist/clients/inbox/tui/slots.js +16 -12
  57. package/dist/clients/inbox/tui/types.d.ts +2 -7
  58. package/dist/clients/inbox/tui.js +2 -2
  59. package/dist/commands/__tests__/human.test.js +71 -114
  60. package/dist/commands/__tests__/node-message.test.js +14 -0
  61. package/dist/commands/attention.js +4 -4
  62. package/dist/commands/canvas-snapshot.js +1 -1
  63. package/dist/commands/canvas.js +1 -1
  64. package/dist/commands/dashboard.js +1 -1
  65. package/dist/commands/human/{html.d.ts → components.d.ts} +1 -1
  66. package/dist/commands/human/components.js +77 -0
  67. package/dist/commands/human/feedback.d.ts +2 -0
  68. package/dist/commands/human/feedback.js +108 -0
  69. package/dist/commands/human/prompts.d.ts +13 -5
  70. package/dist/commands/human/prompts.js +160 -97
  71. package/dist/commands/human/queue.js +57 -88
  72. package/dist/commands/human/shared.d.ts +2 -12
  73. package/dist/commands/human/shared.js +17 -11
  74. package/dist/commands/human.js +29 -21
  75. package/dist/commands/memory/origin.js +2 -2
  76. package/dist/commands/node/bash.js +4 -4
  77. package/dist/commands/node/inspect.js +3 -3
  78. package/dist/commands/node/lifecycle.js +1 -1
  79. package/dist/commands/node/message.js +2 -2
  80. package/dist/commands/node-worktree.js +8 -8
  81. package/dist/commands/pkg/browse.js +1 -1
  82. package/dist/commands/pkg/plugin-manage.js +129 -10
  83. package/dist/commands/profile/delete.js +41 -15
  84. package/dist/commands/profile.js +1 -1
  85. package/dist/commands/push.js +1 -1
  86. package/dist/commands/surface/node/focus.js +29 -14
  87. package/dist/commands/surface-edit.js +3 -3
  88. package/dist/commands/surface-inbox.d.ts +2 -0
  89. package/dist/commands/{human/inbox.js → surface-inbox.js} +14 -18
  90. package/dist/commands/surface.js +2 -1
  91. package/dist/commands/sys/doctor.js +60 -3
  92. package/dist/commands/sys/feedback.js +79 -25
  93. package/dist/commands/sys/settings.js +1 -1
  94. package/dist/core/__tests__/cron-broker-capacity.test.js +1 -1
  95. package/dist/core/__tests__/extension-abort.test.js +5 -2
  96. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -2
  97. package/dist/core/__tests__/fixtures/fake-engine.js +47 -3
  98. package/dist/core/__tests__/helpers/harness.js +17 -55
  99. package/dist/core/__tests__/human-cancel-guard.test.js +31 -10
  100. package/dist/core/__tests__/human-deliver.test.js +31 -26
  101. package/dist/core/__tests__/human-node-not-supervised.test.js +1 -1
  102. package/dist/core/__tests__/plugin-page-components.test.js +157 -0
  103. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +4 -5
  104. package/dist/core/__tests__/seam/dormancy-release.test.js +40 -0
  105. package/dist/core/__tests__/serial/flagship-lifecycle.test.js +2 -2
  106. package/dist/core/__tests__/serial/human-deliver-e2e.test.js +12 -6
  107. package/dist/core/__tests__/serial/live-mutation.test.js +1 -1
  108. package/dist/core/__tests__/serial/tmux-surface.test.js +4 -2
  109. package/dist/core/__tests__/serial/worktree.test.js +24 -198
  110. package/dist/core/__tests__/session-cycles.test.js +22 -0
  111. package/dist/core/__tests__/stop-guard.test.js +4 -4
  112. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +19 -4
  113. package/dist/core/bash-jobs.d.ts +6 -0
  114. package/dist/core/bash-jobs.js +10 -0
  115. package/dist/core/canvas/__tests__/attention.test.js +8 -5
  116. package/dist/core/canvas/__tests__/render-remote.test.js +5 -5
  117. package/dist/core/canvas/attention.js +8 -6
  118. package/dist/core/canvas/browse/model.d.ts +8 -2
  119. package/dist/core/canvas/browse/model.js +11 -4
  120. package/dist/core/canvas/canvas.d.ts +7 -0
  121. package/dist/core/canvas/canvas.js +37 -0
  122. package/dist/core/canvas/crons.d.ts +9 -0
  123. package/dist/core/canvas/crons.js +51 -0
  124. package/dist/core/canvas/node-order.d.ts +22 -1
  125. package/dist/core/canvas/node-order.js +26 -1
  126. package/dist/core/canvas/render-source.d.ts +5 -0
  127. package/dist/core/canvas/render-source.js +1 -0
  128. package/dist/core/canvas/types.d.ts +9 -3
  129. package/dist/core/command-plugins/bundle.d.ts +6 -1
  130. package/dist/core/command-plugins/bundle.js +26 -13
  131. package/dist/core/command.js +2 -1
  132. package/dist/core/config.d.ts +47 -1
  133. package/dist/core/config.js +137 -4
  134. package/dist/core/help.js +1 -1
  135. package/dist/core/human/__tests__/page-catalog.test.js +4 -0
  136. package/dist/core/human/__tests__/page-tickets.test.js +65 -52
  137. package/dist/core/human/__tests__/page.test.js +56 -98
  138. package/dist/core/human/__tests__/serial/inbox-core.test.js +6 -6
  139. package/dist/core/human/answer-text.js +3 -3
  140. package/dist/core/human/answer.d.ts +6 -4
  141. package/dist/core/human/answer.js +4 -10
  142. package/dist/core/human/component-docs.d.ts +11 -7
  143. package/dist/core/human/component-docs.js +361 -88
  144. package/dist/core/human/convention.d.ts +17 -3
  145. package/dist/core/human/convention.js +36 -5
  146. package/dist/core/human/feedback-companion.d.ts +20 -0
  147. package/dist/core/human/feedback-companion.js +98 -0
  148. package/dist/core/human/feedback.d.ts +65 -0
  149. package/dist/core/human/feedback.js +116 -0
  150. package/dist/core/human/page-catalog.d.ts +40 -5
  151. package/dist/core/human/page-catalog.js +102 -30
  152. package/dist/core/human/page-errors.d.ts +4 -0
  153. package/dist/core/human/page-errors.js +7 -0
  154. package/dist/core/human/page-eval.d.ts +20 -0
  155. package/dist/core/human/page-eval.js +123 -0
  156. package/dist/core/human/page-schema.d.ts +23 -191
  157. package/dist/core/human/page-schema.js +60 -89
  158. package/dist/core/human/page-synth.d.ts +1 -1
  159. package/dist/core/human/page-synth.js +15 -18
  160. package/dist/core/human/page.d.ts +12 -27
  161. package/dist/core/human/page.js +142 -474
  162. package/dist/core/human/root.d.ts +6 -2
  163. package/dist/core/human/root.js +12 -4
  164. package/dist/core/human/scan.d.ts +3 -4
  165. package/dist/core/human/scan.js +16 -15
  166. package/dist/core/human/summary.d.ts +1 -2
  167. package/dist/core/human/summary.js +2 -2
  168. package/dist/core/human/tickets.d.ts +20 -14
  169. package/dist/core/human/tickets.js +91 -31
  170. package/dist/core/human/types.d.ts +10 -2
  171. package/dist/core/preview-registry.js +8 -10
  172. package/dist/core/profiles/default-binding.d.ts +4 -0
  173. package/dist/core/profiles/default-binding.js +24 -0
  174. package/dist/core/profiles/deletion-reservation.d.ts +8 -0
  175. package/dist/core/profiles/deletion-reservation.js +38 -0
  176. package/dist/core/profiles/manifest.d.ts +3 -0
  177. package/dist/core/profiles/manifest.js +12 -0
  178. package/dist/core/profiles/select.js +2 -2
  179. package/dist/core/review/realize.js +3 -3
  180. package/dist/core/review/store.d.ts +6 -0
  181. package/dist/core/review/store.js +31 -0
  182. package/dist/core/runtime/bearings.d.ts +0 -4
  183. package/dist/core/runtime/bearings.js +4 -62
  184. package/dist/core/runtime/bin-contributions.d.ts +44 -0
  185. package/dist/core/runtime/bin-contributions.js +186 -0
  186. package/dist/core/runtime/broker/client-registry.d.ts +4 -0
  187. package/dist/core/runtime/broker/client-registry.js +13 -1
  188. package/dist/core/runtime/broker/extension-abort.d.ts +1 -1
  189. package/dist/core/runtime/broker/extension-abort.js +4 -2
  190. package/dist/core/runtime/broker/fault-retry.js +4 -0
  191. package/dist/core/runtime/broker/frame-dispatch.d.ts +3 -1
  192. package/dist/core/runtime/broker/frame-dispatch.js +5 -6
  193. package/dist/core/runtime/broker/read-ops.d.ts +1 -1
  194. package/dist/core/runtime/broker/read-ops.js +2 -2
  195. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  196. package/dist/core/runtime/broker/rebind.js +6 -1
  197. package/dist/core/runtime/broker-extension-render.js +19 -0
  198. package/dist/core/runtime/broker-protocol.d.ts +14 -0
  199. package/dist/core/runtime/broker.d.ts +1 -1
  200. package/dist/core/runtime/broker.js +17 -6
  201. package/dist/core/runtime/front-door-env.d.ts +5 -0
  202. package/dist/core/runtime/front-door-env.js +16 -0
  203. package/dist/core/runtime/front-door.d.ts +1 -5
  204. package/dist/core/runtime/front-door.js +2 -5
  205. package/dist/core/runtime/host.d.ts +1 -1
  206. package/dist/core/runtime/invocation.d.ts +6 -0
  207. package/dist/core/runtime/invocation.js +10 -0
  208. package/dist/core/runtime/kickoff.js +34 -0
  209. package/dist/core/runtime/launch.d.ts +2 -6
  210. package/dist/core/runtime/node-read.js +4 -1
  211. package/dist/core/runtime/nodes.js +2 -0
  212. package/dist/core/runtime/promote.d.ts +1 -0
  213. package/dist/core/runtime/promote.js +3 -1
  214. package/dist/core/runtime/session-cycles.d.ts +6 -0
  215. package/dist/core/runtime/session-cycles.js +15 -1
  216. package/dist/core/runtime/session-visibility.d.ts +17 -8
  217. package/dist/core/runtime/session-visibility.js +19 -10
  218. package/dist/core/runtime/spawn-env.d.ts +9 -1
  219. package/dist/core/runtime/spawn-env.js +62 -2
  220. package/dist/core/runtime/stop-guard.d.ts +2 -2
  221. package/dist/core/runtime/stop-guard.js +2 -2
  222. package/dist/core/runtime/stop-signals.d.ts +1 -1
  223. package/dist/core/runtime/stop-signals.js +2 -2
  224. package/dist/core/runtime/tmux-bindings.js +1 -1
  225. package/dist/core/session-model/session-state.d.ts +4 -3
  226. package/dist/core/session-model/session-state.js +14 -1
  227. package/dist/core/user-settings.d.ts +21 -2
  228. package/dist/core/user-settings.js +32 -11
  229. package/dist/core/worktree.d.ts +15 -45
  230. package/dist/core/worktree.js +59 -261
  231. package/dist/daemon/api/__tests__/seam/profile-delete.test.d.ts +1 -0
  232. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +386 -0
  233. package/dist/daemon/api/handlers/bash-jobs.js +1 -1
  234. package/dist/daemon/api/handlers/broker-ops.js +9 -5
  235. package/dist/daemon/api/handlers/feedback-comments.d.ts +2 -0
  236. package/dist/daemon/api/handlers/feedback-comments.js +207 -0
  237. package/dist/daemon/api/handlers/human.js +24 -20
  238. package/dist/daemon/api/handlers/inbox.d.ts +10 -0
  239. package/dist/daemon/api/handlers/inbox.js +39 -115
  240. package/dist/daemon/api/handlers/profiles.js +11 -29
  241. package/dist/daemon/api/map.d.ts +6 -1
  242. package/dist/daemon/api/map.js +23 -0
  243. package/dist/daemon/api/server.js +3 -1
  244. package/dist/daemon/companion-retire.d.ts +2 -0
  245. package/dist/daemon/companion-retire.js +33 -0
  246. package/dist/daemon/cron-run.js +14 -16
  247. package/dist/daemon/human/finish.d.ts +12 -7
  248. package/dist/daemon/human/finish.js +101 -38
  249. package/dist/daemon/human/sweep.js +5 -1
  250. package/dist/daemon/profile-delete.d.ts +7 -0
  251. package/dist/daemon/profile-delete.js +306 -0
  252. package/dist/daemon/reconcilers/broker-supervision.js +3 -1
  253. package/dist/daemon/review/deliver.js +3 -3
  254. package/dist/daemon/review/finish.d.ts +0 -2
  255. package/dist/daemon/review/finish.js +1 -29
  256. package/dist/pi-extensions/__tests__/canvas-bash-valve.test.js +56 -1
  257. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +4 -4
  258. package/dist/pi-extensions/canvas-bash-valve.js +19 -5
  259. package/dist/pi-extensions/canvas-inbox-watcher.js +1 -1
  260. package/dist/pi-extensions/canvas-review-boundary.d.ts +4 -0
  261. package/dist/pi-extensions/canvas-review-boundary.js +18 -9
  262. package/dist/pi-extensions/canvas-stophook.js +4 -4
  263. package/dist/shared/generated-context.js +2 -2
  264. package/dist/types.d.ts +44 -2
  265. package/dist/types.js +7 -2
  266. package/package.json +6 -5
  267. package/runtime.lock.json +5 -33
  268. package/dist/builtin-memory/init.md +0 -38
  269. package/dist/builtin-memory/plan.md +0 -19
  270. package/dist/builtin-memory/spec.md +0 -19
  271. package/dist/commands/human/doc.d.ts +0 -2
  272. package/dist/commands/human/doc.js +0 -91
  273. package/dist/commands/human/html.js +0 -81
  274. package/dist/commands/human/inbox.d.ts +0 -2
  275. package/dist/core/human/__tests__/html-markdown.test.js +0 -52
  276. package/dist/core/human/__tests__/page-render.test.js +0 -66
  277. package/dist/core/human/html-markdown.d.ts +0 -3
  278. package/dist/core/human/html-markdown.js +0 -288
  279. package/dist/core/human/page-render.d.ts +0 -4
  280. package/dist/core/human/page-render.js +0 -105
  281. package/dist/pages/bundle.css +0 -1
  282. package/dist/pages/bundle.js +0 -1778
  283. package/dist/pages/comments.d.ts +0 -72
  284. package/dist/pages/comments.js +0 -176
  285. package/dist/pages/controls.d.ts +0 -98
  286. package/dist/pages/controls.js +0 -261
  287. package/dist/pages/elements/cards.d.ts +0 -28
  288. package/dist/pages/elements/cards.js +0 -915
  289. package/dist/pages/elements/chart.d.ts +0 -23
  290. package/dist/pages/elements/chart.js +0 -664
  291. package/dist/pages/elements/options.d.ts +0 -30
  292. package/dist/pages/elements/options.js +0 -742
  293. package/dist/pages/elements/pages.d.ts +0 -30
  294. package/dist/pages/elements/pages.js +0 -377
  295. package/dist/pages/elements/slot.d.ts +0 -26
  296. package/dist/pages/elements/slot.js +0 -112
  297. package/dist/pages/elements/table.d.ts +0 -36
  298. package/dist/pages/elements/table.js +0 -1043
  299. package/dist/pages/elements/text.d.ts +0 -31
  300. package/dist/pages/elements/text.js +0 -1175
  301. package/dist/pages/entry.d.ts +0 -8
  302. package/dist/pages/entry.js +0 -8
  303. package/dist/pages/host.d.ts +0 -134
  304. package/dist/pages/host.js +0 -165
  305. package/dist/pages/readonly.d.ts +0 -24
  306. package/dist/pages/readonly.js +0 -31
  307. package/dist/pages/register.d.ts +0 -2
  308. package/dist/pages/register.js +0 -3
  309. package/dist/pages/responses.d.ts +0 -37
  310. package/dist/pages/responses.js +0 -56
  311. package/dist/pages/slot-config.d.ts +0 -36
  312. package/dist/pages/slot-config.js +0 -50
  313. package/dist/pages/types.d.ts +0 -98
  314. package/dist/pages/types.js +0 -23
  315. /package/dist/{core/human/__tests__/html-markdown.test.d.ts → commands/__tests__/node-message.test.d.ts} +0 -0
  316. /package/dist/core/{human/__tests__/page-render.test.d.ts → __tests__/plugin-page-components.test.d.ts} +0 -0
@@ -90,6 +90,7 @@ export function applySnapshot(snapshot, previous) {
90
90
  const toolGroupSummaries = summaryTexts(toolGroupSummaryDetails);
91
91
  const isStreaming = snapshot.state?.isStreaming === true;
92
92
  const workingActivity = snapshotWorkingActivity(snapshot);
93
+ const queued = snapshotQueued(snapshot);
93
94
  if (!isStreaming) {
94
95
  return {
95
96
  messages,
@@ -99,7 +100,7 @@ export function applySnapshot(snapshot, previous) {
99
100
  isStreaming: false,
100
101
  activity: null,
101
102
  workingActivity,
102
- queued: [],
103
+ queued,
103
104
  toolGroupSummaries,
104
105
  toolGroupSummaryDetails,
105
106
  };
@@ -115,7 +116,7 @@ export function applySnapshot(snapshot, previous) {
115
116
  isStreaming: true,
116
117
  activity: workingActivity,
117
118
  workingActivity,
118
- queued: [],
119
+ queued,
119
120
  toolGroupSummaries,
120
121
  toolGroupSummaryDetails,
121
122
  };
@@ -133,7 +134,7 @@ export function applySnapshot(snapshot, previous) {
133
134
  isStreaming: true,
134
135
  activity: workingActivity,
135
136
  workingActivity,
136
- queued: [],
137
+ queued,
137
138
  toolGroupSummaries,
138
139
  toolGroupSummaryDetails,
139
140
  };
@@ -142,6 +143,20 @@ function snapshotWorkingActivity(snapshot) {
142
143
  const activity = snapshot.workingActivity;
143
144
  return typeof activity === 'string' && activity.trim() !== '' ? activity : DEFAULT_WORKING_ACTIVITY;
144
145
  }
146
+ /** The queue a `welcome` reports, in the same order `queue_update` composes it.
147
+ * Absent on a snapshot from a broker generation older than the field, and on
148
+ * an offline node (which has no engine and so no queue) — both mean nothing is
149
+ * waiting to run, which is exactly an empty queue. */
150
+ function snapshotQueued(snapshot) {
151
+ const queued = snapshot.queued;
152
+ if (typeof queued !== 'object' || queued === null)
153
+ return [];
154
+ const { steering, followUp } = queued;
155
+ return [...texts(steering), ...texts(followUp)];
156
+ }
157
+ function texts(value) {
158
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === 'string') : [];
159
+ }
145
160
  /** Apply the broker's random choice for the newly received user message. */
146
161
  export function applyWorkingActivity(state, activity) {
147
162
  const next = activity.trim() === '' ? DEFAULT_WORKING_ACTIVITY : activity;
@@ -38,11 +38,14 @@ async function startDaemon() {
38
38
  return server;
39
39
  }
40
40
  const source = (name, body) => { const path = join(sources, name); writeFileSync(path, body); return path; };
41
- const ask = source('ask.html', '<!doctype html><html><head><meta name="crtr-page-title" content="Restored page"><meta name="crtr-page-subtitle" content="Pick one"></head><body><p>Leading prose</p><crtr-pages><crtr-page><crtr-options id="choice"><script type="application/json">{"mode":"single","options":[{"id":"yes","label":"Yes"},{"id":"no","label":"No"}]}</script></crtr-options></crtr-page></crtr-pages></body></html>');
42
- 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>');
41
+ const ask = source('ask.tsx', 'export default () => (\n <Page title="Restored page" subtitle="Pick one">\n <UserQuestion id="choice" label="Restore?" body="Pick one to continue." mode="single" options={[{ id: "yes", label: "Yes" }, { id: "no", label: "No" }]} />\n </Page>\n);\n');
42
+ const rich = source('rich.tsx', 'export default () => (\n <Page title="Rich page" subtitle="Browser content">\n <Chart kind="bar" x="day" y={[{ field: "count" }]} />\n </Page>\n);\n');
43
+ // A ticket reaches the inbox and settles only with a response lifecycle; a page
44
+ // carrying neither is a passive display that never opens here.
45
+ const inboxDelivery = { placement: 'inline', inbox: true, reply: true };
43
46
  const server = await startDaemon();
44
47
  try {
45
- const ticket = submitPage({ dir: ticketDir('page-one'), sourceFile: ask, dialect: 'html' });
48
+ const ticket = submitPage({ dir: ticketDir('page-one'), sourceFile: ask, delivery: inboxDelivery });
46
49
  writePageProgress(ticket.dir, { choice: { selectedOptionIds: ['yes'], comments: [] } });
47
50
  const active = controller({ cols: 100, rows: 24 });
48
51
  active.activate();
@@ -52,11 +55,11 @@ try {
52
55
  await waitFor(() => existsSync(join(ticket.dir, 'response.json')), 'restored page responses cross the daemon seam');
53
56
  assert.deepEqual(received, { choice: { selectedOptionIds: ['yes'], comments: [] } }, 'progress.json is restored into the response map');
54
57
  active.close();
55
- submitPage({ dir: ticketDir('rich-page'), sourceFile: html, dialect: 'html' });
58
+ submitPage({ dir: ticketDir('rich-page'), sourceFile: rich, delivery: inboxDelivery });
56
59
  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');
60
+ assert.match(preview.render().join('\n'), /Rich page/, 'a pending page is listed before it is opened');
58
61
  preview.activate();
59
- assert.match(preview.render().join('\n'), /chart — bar: day vs count/, 'html page uses the same manifest-backed terminal projection');
62
+ assert.match(preview.render().join('\n'), /chart — bar: day vs count/, 'a page uses the manifest-backed terminal projection');
60
63
  preview.close();
61
64
  }
62
65
  finally {
@@ -2,12 +2,11 @@ import assert from 'node:assert/strict';
2
2
  import { mountPanel } from '../../tui/panel.js';
3
3
  function key(partial = {}) { return { ctrl: false, meta: false, upArrow: false, downArrow: false, leftArrow: false, rightArrow: false, wordLeft: false, wordRight: false, home: false, end: false, pageUp: false, pageDown: false, del: false, return: false, newline: false, escape: false, tab: false, backspace: false, ...partial }; }
4
4
  const enter = key({ return: true });
5
- const manifest = (slots, extra = {}) => ({ schema: 'crtr.page/v1', dialect: 'html', title: 'Test page', placement: 'panel', document: 'page.html', steps: 1, slots, ...extra });
6
- const projection = (slots, prose = []) => prose.length > 0 ? prose : slots.map((slotIndex) => ({ kind: 'slot', slotIndex }));
5
+ const manifest = (slots, extra = {}) => ({ schema: 'crtr.page/v2', dialect: 'jsx', title: 'Test page', delivery: { placement: 'panel', inbox: true, reply: true }, document: 'page.tsx', steps: 1, slots, ...extra });
7
6
  // Option comment is anchored and survives the subsequent single-select completion.
8
7
  const options = manifest([{ id: 'choice', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'yes', label: 'Yes' }, { id: 'no', label: 'No' }] } }]);
9
8
  let optionResponses = {};
10
- const optionsPanel = mountPanel({ manifest: options, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { optionResponses = r; } });
9
+ const optionsPanel = mountPanel({ manifest: options, cols: 80, rows: 24, onComplete: (r) => { optionResponses = r; } });
11
10
  optionsPanel.handleKey('c', key());
12
11
  for (const ch of 'ship it')
13
12
  optionsPanel.handleKey(ch, key());
@@ -18,7 +17,7 @@ optionsPanel.unmount();
18
17
  // Multi-select empty Enter remains inert; selection confirmation lands on Summary before submit.
19
18
  const multi = manifest([{ id: 'toppings', kind: 'options', step: 0, config: { mode: 'multi', options: [{ id: 'mush', label: 'Mushroom' }, { id: 'onion', label: 'Onion' }] } }]);
20
19
  let multiResponses;
21
- const multiPanel = mountPanel({ manifest: multi, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { multiResponses = r; } });
20
+ const multiPanel = mountPanel({ manifest: multi, cols: 80, rows: 24, onComplete: (r) => { multiResponses = r; } });
22
21
  multiPanel.handleKey('', enter);
23
22
  assert.equal(multiResponses, undefined);
24
23
  assert.match(multiPanel.render().join('\n'), /select at least one/i);
@@ -32,7 +31,7 @@ multiPanel.unmount();
32
31
  // Table selection uses row ids and permits an empty committed selection by schema.
33
32
  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
33
  let tableResponses = {};
35
- const tablePanel = mountPanel({ manifest: table, projection: projection([0]), cols: 80, rows: 24, onComplete: (r) => { tableResponses = r; } });
34
+ const tablePanel = mountPanel({ manifest: table, cols: 80, rows: 24, onComplete: (r) => { tableResponses = r; } });
36
35
  assert.match(tablePanel.render().join('\n'), /Alpha/);
37
36
  tablePanel.handleKey(' ', key());
38
37
  tablePanel.handleKey('', enter);
@@ -41,19 +40,19 @@ assert.deepEqual(tableResponses, { rows: { selectedRowIds: ['a'], selectedColumn
41
40
  tablePanel.unmount();
42
41
  // A display-only page is dismissed with q and completes with an empty response map.
43
42
  let displayResponses;
44
- const displayPanel = mountPanel({ manifest: manifest([]), projection: [{ kind: 'prose', markdown: 'FYI only' }], cols: 80, rows: 24, onComplete: (r) => { displayResponses = r; } });
43
+ const displayPanel = mountPanel({ manifest: manifest([]), cols: 80, rows: 24, onComplete: (r) => { displayResponses = r; } });
45
44
  displayPanel.handleKey('q', key());
46
45
  assert.deepEqual(displayResponses, {});
47
46
  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/);
47
+ // A single-slot page opens straight in item-review.
48
+ const lone = manifest([{ id: 'only', kind: 'options', step: 0, config: { label: 'Pick one', mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } }]);
49
+ const lonePanel = mountPanel({ manifest: lone, cols: 80, rows: 24 });
50
+ assert.match(lonePanel.render().join('\n'), /Pick one/);
52
51
  lonePanel.unmount();
53
52
  // An unvalidated slot takes no terminal input and keeps the page unsubmittable.
54
53
  const unvalidated = manifest([{ id: 'widget', kind: 'acme.widget', step: 0, config: {}, unvalidated: true }]);
55
54
  let unvalidatedResponses;
56
- const unvalidatedPanel = mountPanel({ manifest: unvalidated, projection: projection([0]), cols: 80, rows: 24, productKinds: [{ kind: 'acme.widget' }], onComplete: (r) => { unvalidatedResponses = r; } });
55
+ const unvalidatedPanel = mountPanel({ manifest: unvalidated, cols: 80, rows: 24, productKinds: [{ kind: 'acme.widget' }], onComplete: (r) => { unvalidatedResponses = r; } });
57
56
  unvalidatedPanel.handleKey('c', key());
58
57
  assert.equal(unvalidatedPanel.getInputBuffer(), undefined);
59
58
  unvalidatedPanel.handleKey('n', key());
@@ -65,17 +64,10 @@ unvalidatedPanel.unmount();
65
64
  const stepped = manifest([
66
65
  { id: 'one', kind: 'options', step: 0, config: { mode: 'single', options: [{ id: 'a', label: 'Alpha' }] } },
67
66
  { 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([], [
70
- { kind: 'prose', markdown: '<h2>Read <em>this</em></h2><p>Use <span>semantic prose</span>.</p><div class="custom-chrome">Widget</div>' },
71
- { kind: 'slot', slotIndex: 0 },
72
- { kind: 'slot', slotIndex: 1 },
73
- ]), cols: 80, rows: 24 });
67
+ ], { steps: 2 });
68
+ const steppedPanel = mountPanel({ manifest: stepped, cols: 80, rows: 24 });
74
69
  const overview = steppedPanel.render().join('\n');
75
- assert.match(overview, /Read this/);
76
- assert.match(overview, /semantic prose/);
77
- assert.match(overview, /rich page — open in Northlight/);
78
- assert.doesNotMatch(overview, /<span/);
70
+ assert.match(overview, /Test page/);
79
71
  assert.match(overview, /Step 1\/2/);
80
72
  assert.match(overview, /Step 2\/2/);
81
73
  steppedPanel.unmount();
@@ -4,8 +4,7 @@ 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
+ import { resolvePageComponents } from '../../core/config.js';
9
8
  import { watchInboxActivity } from '../../core/human/root.js';
10
9
  import { claimTicket, heartbeatClaim, releaseClaim } from '../../core/human/claim.js';
11
10
  import { readPageTicket, readTicketResult } from '../../core/human/tickets.js';
@@ -13,7 +12,7 @@ import { clearProgress } from '../../core/human/convention.js';
13
12
  import { PageAdapter } from './page-adapter.js';
14
13
  import { ReviewAdapter } from './review-adapter.js';
15
14
  import { editBufferInEditor } from './review/roundtrip.js';
16
- import { cancelTicketViaDaemon, daemonErrorMessage, nodeIdFor, resolveFailureKind, resolveTicketPage, } from './resolve.js';
15
+ import { cancelTicketViaDaemon, daemonErrorMessage, ticketIdFor, resolveFailureKind, resolveTicketPage, } from './resolve.js';
17
16
  /** Host-agnostic model for scanning, stable selection, claims, and rendering. */
18
17
  export class InboxController {
19
18
  options;
@@ -40,7 +39,7 @@ export class InboxController {
40
39
  constructor(options) {
41
40
  this.options = options;
42
41
  this.host = options.host;
43
- this.scan = options.scan ?? (() => scanInbox(readConfig('user').page_components));
42
+ this.scan = options.scan ?? (() => scanInbox());
44
43
  this.cols = options.cols ?? 80;
45
44
  this.rows = options.rows ?? 24;
46
45
  this.rescan();
@@ -207,9 +206,7 @@ export class InboxController {
207
206
  this.claim = { dir: item.dir, token: claim.token };
208
207
  let loaded;
209
208
  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) };
209
+ loaded = readPageTicket(item.dir);
213
210
  }
214
211
  catch {
215
212
  releaseClaim(item.dir, claim.token);
@@ -218,12 +215,11 @@ export class InboxController {
218
215
  this.invalidate();
219
216
  return;
220
217
  }
221
- const productKinds = readConfig('user').page_components;
218
+ const productKinds = resolvePageComponents();
222
219
  this.screen = 'detail';
223
220
  this.adapter = new PageAdapter({
224
221
  dir: item.dir,
225
- ticket: loaded.ticket,
226
- projection: loaded.projection,
222
+ ticket: loaded,
227
223
  productKinds,
228
224
  cols: this.detailSize().cols,
229
225
  rows: this.detailSize().rows,
@@ -257,9 +253,7 @@ export class InboxController {
257
253
  return;
258
254
  }
259
255
  try {
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));
256
+ this.adapter.reload(readPageTicket(this.claim.dir));
263
257
  }
264
258
  catch {
265
259
  this.status = 'page could not be read';
@@ -372,7 +366,7 @@ export class InboxController {
372
366
  return;
373
367
  this.focusingSource = true;
374
368
  try {
375
- const focused = await this.host.focusNode(nodeIdFor(item.dir));
369
+ const focused = await this.host.focusNode(ticketIdFor(item.dir));
376
370
  if (focused)
377
371
  this.close();
378
372
  else {
@@ -392,11 +386,11 @@ export class InboxController {
392
386
  for (const rendered of renderMarkdown(selected.subtitle, Math.max(1, width - 2)))
393
387
  lines.push(` ${rendered}`);
394
388
  const count = selected.kind === 'page'
395
- ? `${selected.slotKinds.length} slot${selected.slotKinds.length === 1 ? '' : 's'}`
389
+ ? `${selected.slotKinds.length} component${selected.slotKinds.length === 1 ? '' : 's'}`
396
390
  : '1 file to review';
397
391
  lines.push('', ` ${DIM}${count}${RESET}`);
398
392
  if (selected.kind === 'page' && selected.dialect === 'html')
399
- lines.push(` ${DIM}rich page — open in Northlight${RESET}`);
393
+ lines.push(` ${DIM}HTML pages render in a browser surface${RESET}`);
400
394
  return lines.map((line) => clipLine(line, width));
401
395
  }
402
396
  withStatus(lines) {
@@ -1,12 +1,10 @@
1
1
  import type { ProductPageComponents } from '../../types.js';
2
2
  import type { PageResponses } from '../../core/human/page-schema.js';
3
- import type { PageSourceProjection } from '../../core/human/page.js';
4
3
  import type { PageTicket } from '../../core/human/tickets.js';
5
4
  import type { Key } from './tui/keys.js';
6
5
  export interface PageAdapterOptions {
7
6
  dir: string;
8
7
  ticket: PageTicket;
9
- projection: PageSourceProjection;
10
8
  productKinds: ProductPageComponents;
11
9
  cols: number;
12
10
  rows: number;
@@ -29,6 +27,6 @@ export declare class PageAdapter {
29
27
  setInputBuffer(text: string): void;
30
28
  canAcceptHostKeys(): boolean;
31
29
  handleKey(input: string, key: Key): void;
32
- reload(ticket: PageTicket, projection: PageSourceProjection): void;
30
+ reload(ticket: PageTicket): void;
33
31
  close(): void;
34
32
  }
@@ -12,14 +12,13 @@ export class PageAdapter {
12
12
  this.manifest = opts.ticket.manifest;
13
13
  this.panel = mountPanel({
14
14
  manifest: opts.ticket.manifest,
15
- projection: opts.projection,
16
15
  productKinds: opts.productKinds,
17
16
  initialResponses: opts.ticket.progress,
18
17
  cols: opts.cols,
19
18
  rows: opts.rows,
20
19
  onProgress: (responses) => {
21
20
  try {
22
- writePageProgress(opts.dir, responses, opts.productKinds);
21
+ writePageProgress(opts.dir, responses);
23
22
  }
24
23
  catch { /* partial in-flight input may not validate yet */ }
25
24
  opts.onProgress?.(responses);
@@ -45,9 +44,9 @@ export class PageAdapter {
45
44
  }
46
45
  this.panel.handleKey(input, key);
47
46
  }
48
- reload(ticket, projection) {
47
+ reload(ticket) {
49
48
  this.manifest = ticket.manifest;
50
- this.panel.loadPage(ticket.manifest, projection, { initialResponses: ticket.progress, productKinds: this.opts.productKinds });
49
+ this.panel.loadPage(ticket.manifest, { initialResponses: ticket.progress, productKinds: this.opts.productKinds });
51
50
  this.opts.onDirty();
52
51
  }
53
52
  close() { this.panel.unmount(); }
@@ -1,5 +1,5 @@
1
1
  import type { PageResponses } from '../../core/human/page-schema.js';
2
- export declare function nodeIdFor(dir: string): string;
2
+ export declare function ticketIdFor(dir: string): string;
3
3
  export declare function resolveTicketPage(dir: string, responses: PageResponses): Promise<void>;
4
4
  export declare function cancelTicketViaDaemon(dir: string, opts: {
5
5
  reason: string;
@@ -1,14 +1,14 @@
1
1
  import { basename } from 'node:path';
2
2
  import { ApiError } from '../../api/errors.js';
3
3
  import { cliClient } from '../../commands/api-client.js';
4
- export function nodeIdFor(dir) {
4
+ export function ticketIdFor(dir) {
5
5
  return basename(dir);
6
6
  }
7
7
  export async function resolveTicketPage(dir, responses) {
8
- await cliClient().resolveHumanTicket(nodeIdFor(dir), { responses });
8
+ await cliClient().resolveHumanTicket(ticketIdFor(dir), { responses });
9
9
  }
10
10
  export async function cancelTicketViaDaemon(dir, opts) {
11
- await cliClient().cancelHumanTicket(nodeIdFor(dir), opts);
11
+ await cliClient().cancelHumanTicket(ticketIdFor(dir), opts);
12
12
  }
13
13
  function isUnreachable(err) {
14
14
  if (err instanceof ApiError)
@@ -846,8 +846,10 @@ export class ReviewDocumentSurface {
846
846
  * active. There is deliberately no synthetic fallback row: a marker is
847
847
  * only valid when it belongs to a rendered anchor or the compose/list cursor. */
848
848
  markCursorLocation(lines, state) {
849
+ // The compose caret is reverse video painted over the character it sits on,
850
+ // so its start sequence is the cell the hardware cursor belongs in.
849
851
  if (state.mode === 'compose')
850
- return markAtToken(lines, `${REVERSE} ${RESET}`);
852
+ return markAtToken(lines, REVERSE);
851
853
  if (state.mode === 'list') {
852
854
  const markedListCursor = markAtToken(lines, `${CYAN}▸${RESET}`);
853
855
  if (markedListCursor !== lines)
@@ -132,7 +132,8 @@ export declare function textVertical(buffer: string, cursor: number, delta: numb
132
132
  * a long soft-wrapped comment has many rows even though it is one logical
133
133
  * line. Shares `verticalCursor` with the frame renderer's own wrap. */
134
134
  export declare function textVerticalWrapped(buffer: string, cursor: number, delta: number, width: number): number;
135
- /** Start of the word before the cursor (readline alt-b / alt+left). */
135
+ /** Start of the word before the cursor (readline alt-b / alt+left). Shares one
136
+ * word-motion implementation with the page compose box. */
136
137
  export declare function textWordLeft(buffer: string, cursor: number): number;
137
138
  /** Just past the end of the word after the cursor (readline alt-f / alt+right). */
138
139
  export declare function textWordRight(buffer: string, cursor: number): number;
@@ -1,4 +1,4 @@
1
- import { verticalCursor } from '../tui/render.js';
1
+ import { verticalCursor, wordLeftIndex, wordRightIndex } from '../tui/render.js';
2
2
  import { unitIndexForLine, unitRangeForSpan } from './anchors.js';
3
3
  /** Best unit for a source line: the NARROWEST unit containing it — code/table
4
4
  * chrome units carry their whole block's range and sit before the per-line
@@ -251,25 +251,14 @@ export function textVertical(buffer, cursor, delta) {
251
251
  export function textVerticalWrapped(buffer, cursor, delta, width) {
252
252
  return verticalCursor(buffer, cursor, delta, width);
253
253
  }
254
- /** Start of the word before the cursor (readline alt-b / alt+left). */
254
+ /** Start of the word before the cursor (readline alt-b / alt+left). Shares one
255
+ * word-motion implementation with the page compose box. */
255
256
  export function textWordLeft(buffer, cursor) {
256
- const chars = codePoints(buffer);
257
- let i = Math.max(0, Math.min(cursor, chars.length));
258
- while (i > 0 && /\s/.test(chars[i - 1]))
259
- i--;
260
- while (i > 0 && !/\s/.test(chars[i - 1]))
261
- i--;
262
- return i;
257
+ return wordLeftIndex(buffer, cursor);
263
258
  }
264
259
  /** Just past the end of the word after the cursor (readline alt-f / alt+right). */
265
260
  export function textWordRight(buffer, cursor) {
266
- const chars = codePoints(buffer);
267
- let i = Math.max(0, Math.min(cursor, chars.length));
268
- while (i < chars.length && /\s/.test(chars[i]))
269
- i++;
270
- while (i < chars.length && !/\s/.test(chars[i]))
271
- i++;
272
- return i;
261
+ return wordRightIndex(buffer, cursor);
273
262
  }
274
263
  /** Delete back to the start of the previous word (alt+backspace / ctrl+w). */
275
264
  export function textWordBackspace(buffer, cursor) {
@@ -1,6 +1,6 @@
1
1
  import { isResponseBearingSlot } from '../../../core/human/page-schema.js';
2
- import { panItemReview, verticalCursor } from './render.js';
3
- import { commentIdFor, pageComplete, slotActions, slotAnswered, terminalAnswerable } from './slots.js';
2
+ import { panItemReview, verticalCursor, wordLeftIndex, wordRightIndex } from './render.js';
3
+ import { commentAnchors, commentIdFor, pageComplete, slotActions, slotAnswered, terminalAnswerable } from './slots.js';
4
4
  /** Only a single-mode options/cards pick jumps ahead; a table pick leaves the user on the table. */
5
5
  const autoAdvances = (slot, mode) => mode === 'single' && (slot.kind === 'options' || slot.kind === 'cards');
6
6
  export function handleKeypress(input, key, state, render, exit) {
@@ -84,7 +84,7 @@ function handleItemReview(input, key, state, render, exit) {
84
84
  render();
85
85
  return;
86
86
  }
87
- if (input === 'c' && terminalAnswerable(slot)) {
87
+ if (input === 'c' && commentAnchors(slot).length > 0) {
88
88
  startComment(state, slot, actions);
89
89
  render();
90
90
  return;
@@ -118,7 +118,7 @@ function handleItemReview(input, key, state, render, exit) {
118
118
  return;
119
119
  }
120
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)) });
121
+ setResponse(state, slot, { text: slot.config.initialText, edited: false });
122
122
  advanceToNextIncomplete(state);
123
123
  render();
124
124
  return;
@@ -138,7 +138,7 @@ function handleItemReview(input, key, state, render, exit) {
138
138
  return;
139
139
  }
140
140
  if (action?.kind === 'accept') {
141
- setResponse(state, slot, { text: slot.config.initialText, edited: false, comments: commentsFor(state.responses.get(slot.id)) });
141
+ setResponse(state, slot, { text: slot.config.initialText, edited: false });
142
142
  advanceToNextIncomplete(state);
143
143
  render();
144
144
  return;
@@ -207,11 +207,11 @@ function responseFor(slot, existing) {
207
207
  if (slot.kind === 'options')
208
208
  return { selectedOptionIds: [], comments: [] };
209
209
  if (slot.kind === 'cards')
210
- return { selectedCardIds: [], comments: [] };
210
+ return { selectedCardIds: [] };
211
211
  if (slot.kind === 'table')
212
212
  return { selectedRowIds: [], selectedColumnIds: [], comments: [] };
213
213
  if (slot.kind === 'text')
214
- return { text: slot.config.initialText, edited: false, comments: [] };
214
+ return { text: slot.config.initialText, edited: false };
215
215
  return {};
216
216
  }
217
217
  function commentsFor(response) { return [...((response?.comments) ?? [])]; }
@@ -242,17 +242,15 @@ function toggleIds(ids, id, mode) { if (mode === 'single')
242
242
  return [id]; return ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]; }
243
243
  function anchorFor(action) {
244
244
  if (action?.kind !== 'select')
245
- return { kind: 'whole' };
245
+ return undefined;
246
246
  if (action.group === 'option')
247
247
  return { kind: 'option', optionId: action.id };
248
- if (action.group === 'card')
249
- return { kind: 'card', cardId: action.id };
250
248
  if (action.group === 'row')
251
249
  return { kind: 'row', rowId: action.id };
252
- return { kind: 'column', columnId: action.id };
250
+ return undefined;
253
251
  }
254
252
  function startComment(state, slot, actions) {
255
- const anchor = anchorFor(actions[state.selectedAction]);
253
+ const anchor = anchorFor(actions[state.selectedAction]) ?? commentAnchors(slot)[0];
256
254
  const comments = commentsFor(slot.id === undefined ? undefined : state.responses.get(slot.id));
257
255
  const prior = comments.find((c) => c.id === commentIdFor(anchor))?.text ?? '';
258
256
  state.inputMode = { kind: 'comment', buffer: prior, cursor: [...prior].length, anchor };
@@ -261,8 +259,10 @@ function cycleCommentAnchor(state) {
261
259
  if (state.inputMode?.kind !== 'comment')
262
260
  return;
263
261
  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' });
262
+ const anchors = commentAnchors(slot);
263
+ if (anchors.length === 0)
264
+ return;
265
+ const current = commentIdFor(state.inputMode.anchor);
266
266
  const index = anchors.findIndex((a) => commentIdFor(a) === current);
267
267
  const anchor = anchors[(index + 1) % anchors.length];
268
268
  const prior = commentsFor(slot.id === undefined ? undefined : state.responses.get(slot.id)).find((c) => c.id === commentIdFor(anchor))?.text ?? '';
@@ -290,7 +290,11 @@ function handleInputMode(input, key, state, render) {
290
290
  render();
291
291
  return;
292
292
  }
293
- if (key.leftArrow)
293
+ if (key.wordLeft)
294
+ mode.cursor = wordLeftIndex(mode.buffer, mode.cursor);
295
+ else if (key.wordRight)
296
+ mode.cursor = wordRightIndex(mode.buffer, mode.cursor);
297
+ else if (key.leftArrow)
294
298
  mode.cursor = Math.max(0, mode.cursor - 1);
295
299
  else if (key.rightArrow)
296
300
  mode.cursor = Math.min([...mode.buffer].length, mode.cursor + 1);
@@ -302,12 +306,16 @@ function handleInputMode(input, key, state, render) {
302
306
  mode.cursor = lineStart(mode.buffer, mode.cursor);
303
307
  else if (key.end || (key.ctrl && input === 'e'))
304
308
  mode.cursor = lineEnd(mode.buffer, mode.cursor);
309
+ // Alt+Backspace (and its ctrl+w form) erase a word; both arrive with the meta
310
+ // flag set, so this precedes the single-character backspace below.
311
+ else if (key.meta && key.backspace)
312
+ wordBackspace(mode);
305
313
  else if (key.backspace || input === '\x7f')
306
314
  backspace(mode);
307
315
  else if (key.del || (key.ctrl && input === 'd'))
308
316
  del(mode);
309
- else if (input.length > 0 && !key.ctrl && !key.meta)
310
- insert(mode, input);
317
+ else if (!key.ctrl && !key.meta)
318
+ insertTyped(mode, input);
311
319
  render();
312
320
  }
313
321
  function commitInput(state) {
@@ -315,7 +323,7 @@ function commitInput(state) {
315
323
  const slot = state.slots[state.currentIndex];
316
324
  const response = responseFor(slot, slot.id === undefined ? undefined : state.responses.get(slot.id));
317
325
  if (mode.kind === 'comment') {
318
- const anchor = mode.anchor ?? { kind: 'whole' };
326
+ const anchor = mode.anchor;
319
327
  const id = commentIdFor(anchor);
320
328
  const comments = commentsFor(response).filter((c) => c.id !== id);
321
329
  if (mode.buffer.trim() !== '')
@@ -327,7 +335,7 @@ function commitInput(state) {
327
335
  advanceToNextIncomplete(state);
328
336
  }
329
337
  else {
330
- setResponse(state, slot, { text: mode.buffer, edited: mode.buffer !== slot.config.initialText, comments: commentsFor(response) });
338
+ setResponse(state, slot, { text: mode.buffer, edited: mode.buffer !== slot.config.initialText });
331
339
  advanceToNextIncomplete(state);
332
340
  }
333
341
  state.inputMode = null;
@@ -335,6 +343,16 @@ function commitInput(state) {
335
343
  function insert(mode, text) { const chars = [...mode.buffer]; chars.splice(mode.cursor, 0, ...text); mode.buffer = chars.join(''); mode.cursor += [...text].length; }
336
344
  function backspace(mode) { if (mode.cursor === 0)
337
345
  return; const chars = [...mode.buffer]; chars.splice(mode.cursor - 1, 1); mode.buffer = chars.join(''); mode.cursor--; }
346
+ function wordBackspace(mode) { const to = wordLeftIndex(mode.buffer, mode.cursor); if (to === mode.cursor)
347
+ return; const chars = [...mode.buffer]; chars.splice(to, mode.cursor - to); mode.buffer = chars.join(''); mode.cursor = to; }
348
+ /** Typed and pasted text only: an unrecognized escape sequence reaches this
349
+ * handler as raw bytes, and inserting those would put control characters into
350
+ * the answer. Newlines survive because a paste may legitimately carry them. */
351
+ function insertTyped(mode, input) {
352
+ const clean = input.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\r\n?/g, '\n').replace(/[\x00-\x09\x0B-\x1F\x7F]/g, '');
353
+ if (clean.length > 0)
354
+ insert(mode, clean);
355
+ }
338
356
  function del(mode) { const chars = [...mode.buffer]; if (mode.cursor >= chars.length)
339
357
  return; chars.splice(mode.cursor, 1); mode.buffer = chars.join(''); }
340
358
  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; }
@@ -1,39 +1,13 @@
1
1
  import { handleKeypress } from './input.js';
2
2
  import { clampItemReviewScroll, renderFinal, renderItemReview, renderOverview } from './render.js';
3
3
  import { pageComplete, slotAnswered } from './slots.js';
4
- function projectProse(projection) {
5
- const overviewProse = [];
6
- const slotProse = new Map();
7
- let pending = [];
8
- let seenSlot = false;
9
- for (const block of projection) {
10
- if (block.kind === 'prose') {
11
- if (block.markdown.trim() !== '')
12
- pending.push(block.markdown);
13
- continue;
14
- }
15
- if (!seenSlot)
16
- overviewProse.push(...pending);
17
- else
18
- slotProse.set(block.slotIndex, pending);
19
- pending = [];
20
- seenSlot = true;
21
- }
22
- // A page with no slots has no final phase to reach — its prose is the overview.
23
- if (!seenSlot) {
24
- overviewProse.push(...pending);
25
- return { overviewProse, slotProse, finalProse: [] };
26
- }
27
- return { overviewProse, slotProse, finalProse: pending };
28
- }
29
- function buildInitialState(manifest, projection, initialResponses, productKinds, editorAvailable, wrapWidth) {
30
- const prose = projectProse(projection);
4
+ function buildInitialState(manifest, initialResponses, productKinds, editorAvailable, wrapWidth) {
31
5
  const responses = new Map();
32
6
  const validIds = new Set(manifest.slots.flatMap((slot) => slot.id === undefined ? [] : [slot.id]));
33
7
  for (const [id, response] of Object.entries(initialResponses ?? {}))
34
8
  if (validIds.has(id))
35
9
  responses.set(id, response);
36
- const state = { phase: manifest.slots.length === 1 ? 'item-review' : 'overview', currentIndex: 0, manifest, slots: manifest.slots, ...prose, responses, productKinds, inputMode: null, selectedAction: 0, bodyMode: 'question', scrollOffset: 0, bodyScrollOffsets: { question: 0 }, hscrollOffset: 0, hscrollMax: 0, wrapWidth, editorAvailable };
10
+ const state = { phase: manifest.slots.length === 1 ? 'item-review' : 'overview', currentIndex: 0, manifest, slots: manifest.slots, responses, productKinds, inputMode: null, selectedAction: 0, bodyMode: 'question', scrollOffset: 0, bodyScrollOffsets: { question: 0 }, hscrollOffset: 0, hscrollMax: 0, wrapWidth, editorAvailable };
37
11
  const first = manifest.slots.findIndex((slot) => slot.id !== undefined && !slotAnswered(state, slot));
38
12
  if (first >= 0)
39
13
  state.currentIndex = first;
@@ -42,7 +16,7 @@ function buildInitialState(manifest, projection, initialResponses, productKinds,
42
16
  export function collectResponses(state) { return Object.fromEntries(state.responses); }
43
17
  function rebindPersist(internals) { internals.state.persist = () => internals.callbacks.onProgress?.(collectResponses(internals.state)); }
44
18
  export function mountPanel(opts) {
45
- const internals = { state: buildInitialState(opts.manifest, opts.projection, opts.initialResponses, opts.productKinds ?? [], opts.onEditorRequest !== undefined, opts.cols), cols: opts.cols, rows: opts.rows, mounted: true, callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit, onDirty: opts.onDirty, onEditorRequest: opts.onEditorRequest } };
19
+ const internals = { state: buildInitialState(opts.manifest, opts.initialResponses, opts.productKinds ?? [], opts.onEditorRequest !== undefined, opts.cols), cols: opts.cols, rows: opts.rows, mounted: true, callbacks: { onProgress: opts.onProgress, onComplete: opts.onComplete, onExit: opts.onExit, onDirty: opts.onDirty, onEditorRequest: opts.onEditorRequest } };
46
20
  rebindPersist(internals);
47
21
  const renderLines = () => {
48
22
  if (internals.state.phase === 'overview')
@@ -72,12 +46,12 @@ export function mountPanel(opts) {
72
46
  render: () => internals.mounted ? renderLines() : [],
73
47
  handleResize(cols, rows) { internals.cols = cols; internals.rows = rows; internals.state.wrapWidth = cols; return renderLines(); },
74
48
  unmount() { internals.mounted = false; internals.state.persist = undefined; },
75
- loadPage(manifest, projection, loadOpts) {
49
+ loadPage(manifest, loadOpts) {
76
50
  if (!internals.mounted)
77
51
  return;
78
52
  const prior = collectResponses(internals.state);
79
53
  const merged = { ...(loadOpts?.initialResponses ?? {}), ...prior };
80
- internals.state = buildInitialState(manifest, projection, merged, loadOpts?.productKinds ?? internals.state.productKinds, internals.callbacks.onEditorRequest !== undefined, internals.cols);
54
+ internals.state = buildInitialState(manifest, merged, loadOpts?.productKinds ?? internals.state.productKinds, internals.callbacks.onEditorRequest !== undefined, internals.cols);
81
55
  rebindPersist(internals);
82
56
  },
83
57
  canAcceptHostKeys: () => internals.mounted && internals.state.inputMode === null,
@@ -1,6 +1,17 @@
1
1
  import type { TuiState } from './types.js';
2
2
  export declare function renderOverview(state: TuiState, cols: number, rows: number): string[];
3
+ /** Wrap an input buffer and paint the cursor the way a terminal does: the
4
+ * character under it is drawn in reverse video, occupying its own cell rather
5
+ * than pushing the rest of the line across by one. Only past the end of a row
6
+ * does the cursor need a cell of its own, and there it is a reversed space.
7
+ * Wrapping runs over the plain buffer, so the rows here are exactly the rows
8
+ * `verticalCursor` moves between. */
3
9
  export declare function renderInputBuffer(buffer: string, cursor: number, maxWidth: number): string[];
10
+ /** Start of the word before the cursor (alt+left / readline alt-b). The one
11
+ * implementation of word motion for every inbox input buffer. */
12
+ export declare function wordLeftIndex(buffer: string, cursor: number): number;
13
+ /** Just past the end of the word after the cursor (alt+right / readline alt-f). */
14
+ export declare function wordRightIndex(buffer: string, cursor: number): number;
4
15
  export declare function wrappedRowBounds(buffer: string, width: number): {
5
16
  start: number;
6
17
  end: number;