@pi-unipi/unipi 3.0.0-alpha.2 → 3.0.0-alpha.21

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 (359) hide show
  1. package/CHANGELOG.md +292 -0
  2. package/README.md +8 -11
  3. package/docs/prefix-cache-architecture.md +14 -0
  4. package/package.json +22 -24
  5. package/packages/ask-user/README.md +55 -107
  6. package/packages/ask-user/ask-ui.ts +306 -732
  7. package/packages/ask-user/clipboard.ts +50 -0
  8. package/packages/ask-user/config.ts +74 -78
  9. package/packages/ask-user/package.json +2 -2
  10. package/packages/ask-user/questions.ts +151 -0
  11. package/packages/ask-user/skills/ask-user/SKILL.md +59 -133
  12. package/packages/ask-user/tools.ts +198 -432
  13. package/packages/autocomplete/package.json +1 -1
  14. package/packages/autocomplete/src/constants.ts +38 -43
  15. package/packages/autocomplete/src/sorting.ts +8 -0
  16. package/packages/background-tasks/package.json +2 -2
  17. package/packages/background-tasks/src/config.ts +0 -73
  18. package/packages/background-tasks/src/index.ts +20 -16
  19. package/packages/background-tasks/src/registry.ts +1 -171
  20. package/packages/background-tasks/src/types.ts +0 -32
  21. package/packages/btw/README.md +13 -56
  22. package/packages/btw/extensions/btw.ts +403 -1796
  23. package/packages/btw/package.json +5 -2
  24. package/packages/btw/skills/btw/SKILL.md +7 -143
  25. package/packages/compactor/README.md +56 -287
  26. package/packages/compactor/package.json +5 -9
  27. package/packages/compactor/skills/compactor/SKILL.md +13 -20
  28. package/packages/compactor/src/card.ts +90 -0
  29. package/packages/compactor/src/commands/index.ts +73 -130
  30. package/packages/compactor/src/compaction/compact-args.ts +5 -3
  31. package/packages/compactor/src/compaction/filter-noise.ts +1 -1
  32. package/packages/compactor/src/compaction/hooks.ts +487 -311
  33. package/packages/compactor/src/compaction/source.ts +178 -0
  34. package/packages/compactor/src/compaction/summarize.ts +586 -52
  35. package/packages/compactor/src/config/manager.ts +130 -127
  36. package/packages/compactor/src/config/schema.ts +32 -48
  37. package/packages/compactor/src/index.ts +40 -431
  38. package/packages/compactor/src/info-screen.ts +44 -190
  39. package/packages/compactor/src/session/recall-blocks.ts +1 -1
  40. package/packages/compactor/src/stats.ts +76 -0
  41. package/packages/compactor/src/tools/context-budget.ts +17 -34
  42. package/packages/compactor/src/tools/ctx-doctor.ts +45 -36
  43. package/packages/compactor/src/tools/register.ts +29 -269
  44. package/packages/compactor/src/types.ts +47 -186
  45. package/packages/core/README.md +25 -0
  46. package/packages/core/child-agent.ts +1 -0
  47. package/packages/core/command-echo.ts +63 -0
  48. package/packages/core/compaction-context.ts +41 -0
  49. package/packages/core/constants.ts +8 -38
  50. package/packages/core/events.ts +0 -53
  51. package/packages/core/fusion-status.ts +4 -0
  52. package/packages/core/global.d.ts +10 -0
  53. package/packages/core/harness-messages.ts +392 -0
  54. package/packages/core/index.ts +14 -0
  55. package/packages/core/kanboard-status.ts +49 -0
  56. package/packages/core/long-horizon-owner-status.ts +46 -0
  57. package/packages/core/model-cache.ts +81 -7
  58. package/packages/core/package.json +6 -3
  59. package/packages/core/src/attach/detect.ts +163 -0
  60. package/packages/core/src/child-agent/child-args.ts +31 -0
  61. package/packages/core/src/child-agent/delivery.ts +41 -0
  62. package/packages/core/src/child-agent/index.ts +5 -0
  63. package/packages/core/src/child-agent/readers.ts +203 -0
  64. package/packages/core/src/child-agent/runtime.ts +584 -0
  65. package/packages/core/src/evidence.ts +78 -0
  66. package/packages/core/src/hints/browser.ts +278 -0
  67. package/packages/core/src/hints/crab-data.ts +19 -0
  68. package/packages/core/src/hints/index.ts +865 -0
  69. package/packages/core/src/hints/lines.ts +607 -0
  70. package/packages/core/src/hints/store.ts +120 -0
  71. package/packages/core/src/jev/chatter.ts +24 -0
  72. package/packages/core/src/jev/settings.ts +177 -14
  73. package/packages/core/src/package-colors.ts +2 -0
  74. package/packages/core/src/settings/catalog.ts +39 -15
  75. package/packages/core/src/settings/engine.ts +48 -2
  76. package/packages/core/src/settings/hub.ts +83 -20
  77. package/packages/core/src/settings/open.ts +49 -0
  78. package/packages/core/src/settings/schema.ts +27 -7
  79. package/packages/core/src/tui/hub-kit.ts +18 -5
  80. package/packages/core/src/tui/kit.ts +198 -0
  81. package/packages/core/src/tui/progress.ts +73 -0
  82. package/packages/core/src/turn/arbiter.ts +290 -0
  83. package/packages/footer/README.md +4 -6
  84. package/packages/footer/package.json +3 -3
  85. package/packages/footer/src/glance-editor.ts +19 -11
  86. package/packages/footer/src/help.ts +1 -1
  87. package/packages/footer/src/index.ts +36 -3
  88. package/packages/footer/src/presets.ts +3 -4
  89. package/packages/footer/src/rendering/icons.ts +0 -20
  90. package/packages/footer/src/segments/compactor.ts +52 -142
  91. package/packages/footer/src/segments/status-ext.ts +0 -1
  92. package/packages/fusion/README.md +14 -4
  93. package/packages/fusion/package.json +4 -3
  94. package/packages/fusion/src/child-args.ts +1 -0
  95. package/packages/fusion/src/index.ts +161 -8
  96. package/packages/fusion/src/pi-settings.ts +57 -0
  97. package/packages/fusion/src/picker.ts +186 -51
  98. package/packages/fusion/src/preset.ts +56 -2
  99. package/packages/fusion/src/prompts.ts +1 -1
  100. package/packages/fusion/src/sidekick-runtime.ts +1 -420
  101. package/packages/fusion/src/tools.ts +107 -128
  102. package/packages/fusion/src/transcript.ts +27 -85
  103. package/packages/info-screen/package.json +2 -2
  104. package/packages/input-shortcuts/README.md +18 -30
  105. package/packages/input-shortcuts/package.json +2 -2
  106. package/packages/input-shortcuts/src/burst.ts +88 -0
  107. package/packages/input-shortcuts/src/chord-overlay.ts +20 -70
  108. package/packages/input-shortcuts/src/cleared-input.ts +97 -0
  109. package/packages/input-shortcuts/src/index.ts +99 -193
  110. package/packages/input-shortcuts/src/last-response.ts +40 -0
  111. package/packages/input-shortcuts/src/registers.ts +8 -23
  112. package/packages/input-shortcuts/src/types.ts +4 -16
  113. package/packages/input-shortcuts/src/undo-redo.ts +59 -47
  114. package/packages/kanboard/README.md +51 -47
  115. package/packages/kanboard/index.ts +293 -55
  116. package/packages/kanboard/package.json +8 -8
  117. package/packages/kanboard/skills/kanboard/SKILL.md +61 -27
  118. package/packages/kanboard/src/badges.ts +93 -0
  119. package/packages/kanboard/src/bin.ts +4 -2
  120. package/packages/kanboard/src/commands.ts +218 -84
  121. package/packages/kanboard/src/debug.ts +20 -0
  122. package/packages/kanboard/src/guard.ts +267 -109
  123. package/packages/kanboard/src/monitor.ts +330 -0
  124. package/packages/kanboard/src/notice-buffer.ts +74 -0
  125. package/packages/kanboard/src/progress.ts +27 -0
  126. package/packages/kanboard/src/reminders.ts +234 -0
  127. package/packages/kanboard/src/settings.ts +69 -15
  128. package/packages/kanboard/src/shapes.ts +5 -0
  129. package/packages/long-horizon/index.ts +190 -13
  130. package/packages/long-horizon/package.json +3 -3
  131. package/packages/long-horizon/src/commands.ts +103 -7
  132. package/packages/long-horizon/src/compaction-brief.ts +35 -0
  133. package/packages/long-horizon/src/engine/continuation.ts +84 -15
  134. package/packages/long-horizon/src/engine/goal-state.ts +64 -13
  135. package/packages/long-horizon/src/engine/nudge-stash.ts +71 -0
  136. package/packages/long-horizon/src/engine/ralph.ts +3 -2
  137. package/packages/long-horizon/src/engine/runaway.ts +68 -11
  138. package/packages/long-horizon/src/engine/verifier.ts +19 -0
  139. package/packages/long-horizon/src/gate.ts +37 -12
  140. package/packages/long-horizon/src/judge/resolve.ts +1 -0
  141. package/packages/long-horizon/src/owner.ts +35 -2
  142. package/packages/long-horizon/src/progress.ts +87 -0
  143. package/packages/long-horizon/src/prompts/goal.ts +1 -1
  144. package/packages/long-horizon/src/runtime.ts +172 -18
  145. package/packages/long-horizon/src/settings.ts +54 -39
  146. package/packages/long-horizon/src/tools/goal.ts +6 -0
  147. package/packages/long-horizon/src/tools/graph.ts +1 -1
  148. package/packages/long-horizon/src/tools/swarm.ts +3 -3
  149. package/packages/mcp/package.json +2 -2
  150. package/packages/memory/commands.ts +7 -2
  151. package/packages/memory/index.ts +210 -32
  152. package/packages/memory/package.json +3 -3
  153. package/packages/memory/save-session.ts +264 -0
  154. package/packages/memory/settings.ts +17 -0
  155. package/packages/memory/tools.ts +253 -203
  156. package/packages/notify/package.json +2 -2
  157. package/packages/skill-registry/README.md +53 -16
  158. package/packages/skill-registry/index.ts +230 -0
  159. package/packages/skill-registry/package.json +19 -8
  160. package/packages/skill-registry/src/editor.ts +206 -0
  161. package/packages/skill-registry/src/judge.ts +289 -0
  162. package/packages/skill-registry/src/registry.ts +87 -0
  163. package/packages/skill-registry/src/settings.ts +259 -0
  164. package/packages/skill-registry/src/vault.ts +80 -0
  165. package/packages/subagents/README.md +39 -119
  166. package/packages/subagents/package.json +6 -13
  167. package/packages/subagents/skills/subagents/SKILL.md +18 -42
  168. package/packages/subagents/src/agents.ts +213 -0
  169. package/packages/subagents/src/cards.ts +182 -0
  170. package/packages/subagents/src/index.ts +609 -1084
  171. package/packages/subagents/src/manager.ts +429 -0
  172. package/packages/subagents/src/profiles.ts +130 -0
  173. package/packages/subagents/src/transcript.ts +154 -0
  174. package/packages/subagents/src/ui.ts +389 -0
  175. package/packages/unipi/bundled.js +39756 -45569
  176. package/packages/unipi/index.ts +55 -10
  177. package/packages/updater/package.json +2 -2
  178. package/packages/utility/README.md +76 -69
  179. package/packages/utility/package.json +2 -2
  180. package/packages/utility/skills/image/SKILL.md +37 -0
  181. package/packages/utility/skills/utility/SKILL.md +17 -60
  182. package/packages/utility/src/answer/extract.ts +128 -0
  183. package/packages/utility/src/answer/index.ts +213 -0
  184. package/packages/utility/src/answer/reply.ts +133 -0
  185. package/packages/utility/src/answer/web.ts +167 -0
  186. package/packages/utility/src/attach/index.ts +205 -0
  187. package/packages/utility/src/commands.ts +38 -142
  188. package/packages/utility/src/diagnostics/engine.ts +50 -5
  189. package/packages/utility/src/image/index.ts +33 -0
  190. package/packages/utility/src/image/models.ts +221 -0
  191. package/packages/{image/src → utility/src/image}/recognize.ts +2 -2
  192. package/packages/utility/src/image/settings.ts +178 -0
  193. package/packages/{image/src/image-source.ts → utility/src/image/source.ts} +1 -1
  194. package/packages/utility/src/image/tools.ts +168 -0
  195. package/packages/utility/src/image/transports.ts +186 -0
  196. package/packages/utility/src/index.ts +68 -389
  197. package/packages/utility/src/lifecycle/cleanup.ts +116 -311
  198. package/packages/utility/src/rename/gate.ts +102 -0
  199. package/packages/utility/src/rename/index.ts +191 -0
  200. package/packages/utility/src/rename/session.ts +170 -0
  201. package/packages/utility/src/render/delegated.ts +285 -0
  202. package/packages/utility/src/render/harness.ts +683 -0
  203. package/packages/utility/src/render/parse.ts +156 -0
  204. package/packages/utility/src/render/pi-settings.ts +38 -0
  205. package/packages/utility/src/render/reply-bg.ts +307 -0
  206. package/packages/utility/src/render/simple.ts +724 -0
  207. package/packages/utility/src/render/spacing.ts +235 -0
  208. package/packages/utility/src/render/styled.ts +251 -0
  209. package/packages/utility/src/render/tools.ts +353 -0
  210. package/packages/utility/src/settings.ts +166 -233
  211. package/packages/utility/src/types.ts +1 -222
  212. package/packages/watchdog/index.ts +17 -6
  213. package/packages/watchdog/package.json +3 -3
  214. package/packages/watchdog/src/config.ts +3 -2
  215. package/packages/web-api/package.json +2 -2
  216. package/packages/workflow/README.md +3 -0
  217. package/packages/workflow/index.ts +0 -2
  218. package/packages/workflow/package.json +2 -2
  219. package/packages/workflow/src/permission/decide.ts +6 -4
  220. package/packages/workflow/src/permission/index.ts +17 -7
  221. package/packages/workflow/src/permission/settings.ts +39 -4
  222. package/packages/workflow/src/plan/index.ts +19 -4
  223. package/packages/background-tasks/extensions/delegate-child.ts +0 -1
  224. package/packages/background-tasks/src/context-parent-snapshot.ts +0 -142
  225. package/packages/background-tasks/src/context-token-budget.ts +0 -903
  226. package/packages/background-tasks/src/context-visible-conversation-v2.ts +0 -551
  227. package/packages/background-tasks/src/delegate/artifacts.ts +0 -487
  228. package/packages/background-tasks/src/delegate/budget.ts +0 -415
  229. package/packages/background-tasks/src/delegate/hook-contract-evidence.json +0 -18
  230. package/packages/background-tasks/src/delegate/hook-contract.ts +0 -154
  231. package/packages/background-tasks/src/delegate/launch.ts +0 -484
  232. package/packages/background-tasks/src/delegate/result-package.ts +0 -459
  233. package/packages/background-tasks/src/delegate/runner.ts +0 -430
  234. package/packages/background-tasks/src/delegate/seed.ts +0 -423
  235. package/packages/background-tasks/src/delegate/types.ts +0 -323
  236. package/packages/background-tasks/src/delegate-child-extension.ts +0 -978
  237. package/packages/background-tasks/src/delegate-extension.ts +0 -654
  238. package/packages/background-tasks/src/fixtures/delegate-context-incident.json +0 -17
  239. package/packages/compactor/skills/compactor-detail/SKILL.md +0 -116
  240. package/packages/compactor/skills/compactor-doctor/SKILL.md +0 -71
  241. package/packages/compactor/skills/compactor-stats/SKILL.md +0 -51
  242. package/packages/compactor/src/compaction/build-sections.ts +0 -76
  243. package/packages/compactor/src/compaction/extract/goals.ts +0 -36
  244. package/packages/compactor/src/compaction/extract/preferences.ts +0 -40
  245. package/packages/compactor/src/compaction/format.ts +0 -53
  246. package/packages/compactor/src/compaction/merge.ts +0 -143
  247. package/packages/compactor/src/config/presets.ts +0 -160
  248. package/packages/compactor/src/executor/executor.ts +0 -233
  249. package/packages/compactor/src/executor/runtime.ts +0 -115
  250. package/packages/compactor/src/security/evaluator.ts +0 -89
  251. package/packages/compactor/src/security/policy.ts +0 -96
  252. package/packages/compactor/src/security/scanner.ts +0 -65
  253. package/packages/compactor/src/session/auto-inject.ts +0 -60
  254. package/packages/compactor/src/session/db.ts +0 -325
  255. package/packages/compactor/src/session/extract.ts +0 -144
  256. package/packages/compactor/src/session/resume-inject.ts +0 -64
  257. package/packages/compactor/src/session/snapshot.ts +0 -327
  258. package/packages/compactor/src/tools/ctx-batch-execute.ts +0 -41
  259. package/packages/compactor/src/tools/ctx-execute-file.ts +0 -23
  260. package/packages/compactor/src/tools/ctx-execute.ts +0 -23
  261. package/packages/compactor/src/tools/ctx-stats.ts +0 -96
  262. package/packages/image/README.md +0 -99
  263. package/packages/image/package.json +0 -61
  264. package/packages/image/skills/image/SKILL.md +0 -76
  265. package/packages/image/src/generate.ts +0 -256
  266. package/packages/image/src/index.ts +0 -135
  267. package/packages/image/src/models.ts +0 -519
  268. package/packages/image/src/openai-images-api.ts +0 -282
  269. package/packages/image/src/register-providers.ts +0 -209
  270. package/packages/image/src/settings.ts +0 -173
  271. package/packages/image/src/tools.ts +0 -326
  272. package/packages/input-shortcuts/src/clipboard.ts +0 -119
  273. package/packages/kanboard/src/runner.ts +0 -817
  274. package/packages/skill-registry/skills/auto/SKILL.md +0 -465
  275. package/packages/skill-registry/skills/brainstorm/SKILL.md +0 -219
  276. package/packages/skill-registry/skills/chore-create/SKILL.md +0 -315
  277. package/packages/skill-registry/skills/chore-execute/SKILL.md +0 -314
  278. package/packages/skill-registry/skills/consolidate/SKILL.md +0 -142
  279. package/packages/skill-registry/skills/consultant/SKILL.md +0 -97
  280. package/packages/skill-registry/skills/debug/SKILL.md +0 -228
  281. package/packages/skill-registry/skills/document/SKILL.md +0 -120
  282. package/packages/skill-registry/skills/fix/SKILL.md +0 -212
  283. package/packages/skill-registry/skills/gather-context/SKILL.md +0 -145
  284. package/packages/skill-registry/skills/plan/SKILL.md +0 -231
  285. package/packages/skill-registry/skills/quick-fix/SKILL.md +0 -135
  286. package/packages/skill-registry/skills/quick-work/SKILL.md +0 -110
  287. package/packages/skill-registry/skills/research/SKILL.md +0 -251
  288. package/packages/skill-registry/skills/review-work/SKILL.md +0 -187
  289. package/packages/skill-registry/skills/scan-issues/SKILL.md +0 -195
  290. package/packages/skill-registry/skills/work/SKILL.md +0 -218
  291. package/packages/skill-registry/skills/worktree-create/SKILL.md +0 -69
  292. package/packages/skill-registry/skills/worktree-list/SKILL.md +0 -78
  293. package/packages/skill-registry/skills/worktree-merge/SKILL.md +0 -89
  294. package/packages/subagents/agents/delegate.md +0 -14
  295. package/packages/subagents/agents/oracle.md +0 -78
  296. package/packages/subagents/agents/researcher.md +0 -52
  297. package/packages/subagents/agents/reviewer.md +0 -80
  298. package/packages/subagents/agents/scout.md +0 -50
  299. package/packages/subagents/agents/worker.md +0 -59
  300. package/packages/subagents/prompts/council.md +0 -48
  301. package/packages/subagents/prompts/gather-context-and-clarify.md +0 -13
  302. package/packages/subagents/prompts/parallel-cleanup.md +0 -59
  303. package/packages/subagents/prompts/parallel-research.md +0 -50
  304. package/packages/subagents/prompts/parallel-review.md +0 -54
  305. package/packages/subagents/prompts/review-loop.md +0 -43
  306. package/packages/subagents/skills/council-mode/SKILL.md +0 -230
  307. package/packages/subagents/skills/subagents/references/constraints-and-recipes.md +0 -259
  308. package/packages/subagents/skills/subagents/references/execution-controls.md +0 -454
  309. package/packages/subagents/skills/subagents/references/management-authoring-rpc.md +0 -161
  310. package/packages/subagents/skills/subagents/references/multi-lane-orchestration.md +0 -39
  311. package/packages/subagents/skills/subagents/references/prompting-and-roles.md +0 -267
  312. package/packages/subagents/src/acceptance.ts +0 -331
  313. package/packages/subagents/src/agent-manager.ts +0 -405
  314. package/packages/subagents/src/agent-memory.ts +0 -300
  315. package/packages/subagents/src/agent-overrides.ts +0 -183
  316. package/packages/subagents/src/agent-runner.ts +0 -271
  317. package/packages/subagents/src/async-runner.ts +0 -422
  318. package/packages/subagents/src/authority-policy.ts +0 -47
  319. package/packages/subagents/src/budgets.ts +0 -285
  320. package/packages/subagents/src/child-safety.ts +0 -140
  321. package/packages/subagents/src/config.ts +0 -249
  322. package/packages/subagents/src/conversation-viewer.ts +0 -281
  323. package/packages/subagents/src/core-compat.ts +0 -94
  324. package/packages/subagents/src/custom-agents.ts +0 -343
  325. package/packages/subagents/src/file-system-retry.ts +0 -50
  326. package/packages/subagents/src/fleet-data.ts +0 -105
  327. package/packages/subagents/src/fleet-view.ts +0 -322
  328. package/packages/subagents/src/foreground-detach.ts +0 -59
  329. package/packages/subagents/src/fork-context.ts +0 -285
  330. package/packages/subagents/src/global.d.ts +0 -7
  331. package/packages/subagents/src/guide.ts +0 -129
  332. package/packages/subagents/src/mission-state.ts +0 -133
  333. package/packages/subagents/src/mission-store.ts +0 -436
  334. package/packages/subagents/src/output-limits.ts +0 -142
  335. package/packages/subagents/src/parity-types.ts +0 -315
  336. package/packages/subagents/src/pi-args.ts +0 -167
  337. package/packages/subagents/src/result-files.ts +0 -232
  338. package/packages/subagents/src/result-watcher.ts +0 -221
  339. package/packages/subagents/src/retained-children.ts +0 -176
  340. package/packages/subagents/src/run-fanout-budget.ts +0 -356
  341. package/packages/subagents/src/scheduled-runs.ts +0 -349
  342. package/packages/subagents/src/schemas.ts +0 -263
  343. package/packages/subagents/src/slash-commands.ts +0 -80
  344. package/packages/subagents/src/supervisor-channel.ts +0 -249
  345. package/packages/subagents/src/tool-handler.ts +0 -1144
  346. package/packages/subagents/src/types.ts +0 -238
  347. package/packages/subagents/src/widget.ts +0 -457
  348. package/packages/subagents/src/workflow-script.ts +0 -737
  349. package/packages/subagents/src/workflow-worker.ts +0 -384
  350. package/packages/subagents/src/worktree.ts +0 -614
  351. package/packages/utility/src/analytics/collector.ts +0 -293
  352. package/packages/utility/src/info-screen.ts +0 -80
  353. package/packages/utility/src/lifecycle/process.ts +0 -162
  354. package/packages/utility/src/prefix-cache.ts +0 -263
  355. package/packages/utility/src/skill-discovery.ts +0 -542
  356. package/packages/utility/src/tools/env.ts +0 -113
  357. package/packages/utility/src/tui/name-badge-state.ts +0 -315
  358. package/packages/utility/src/tui/name-badge.ts +0 -109
  359. /package/packages/{subagents/src → core/src/child-agent}/pi-spawn.ts +0 -0
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @pi-unipi/utility — /unipi:answer
3
+ *
4
+ * Answer the questions in the agent's last reply without scrolling:
5
+ * editor — pi's editor pre-filled with a Q/A template (Ctrl+G → $EDITOR)
6
+ * web — a local form with the full reply beside the answer boxes
7
+ * `/unipi:answer editor|web` overrides the configured default. Over SSH the
8
+ * web form prints a port-forward command instead of opening a browser.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+ import { hostname } from "node:os";
13
+ import { ExtensionEditorComponent, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
15
+ import { boxInnerWidth, frameOverlay, HUB_OVERLAY_OPTIONS, hubBoldText as bold, hubDimText as dim, hubExactRow, hubTheme, setHubTheme, UNIPI_PREFIX, UTILITY_COMMANDS } from "@pi-unipi/core";
16
+ import { readUtilSettings } from "../settings.js";
17
+ import { ReplyPanel, type ReplyPanelResult } from "./reply.js";
18
+ import { buildTemplate, composeAnswers, extractQuestions, messageText, parseTemplate } from "./extract.js";
19
+ import { startWebForm, type WebAnswer } from "./web.js";
20
+
21
+ /** Fixed port tried first over SSH, so one `ssh -L` keeps working. */
22
+ export const SSH_DEFAULT_PORT = 47321;
23
+
24
+ export function isSsh(env: NodeJS.ProcessEnv = process.env): boolean {
25
+ return Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT);
26
+ }
27
+
28
+ function lastAssistantText(ctx: ExtensionContext): string {
29
+ const sm = ctx.sessionManager as unknown as { getBranch?: () => unknown[]; getEntries: () => unknown[] };
30
+ const entries = (sm.getBranch?.() ?? sm.getEntries()) as Array<{ type?: string; message?: { role?: string; content?: unknown } }>;
31
+ for (let i = entries.length - 1; i >= 0; i--) {
32
+ const m = entries[i]!.message;
33
+ if (entries[i]!.type === "message" && m?.role === "assistant") {
34
+ const text = messageText(m.content).trim();
35
+ if (text) return text;
36
+ }
37
+ }
38
+ return "";
39
+ }
40
+
41
+ function openBrowser(url: string): void {
42
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
43
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
44
+ try {
45
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).on("error", () => {}).unref();
46
+ } catch {
47
+ // no browser — the URL is on screen
48
+ }
49
+ }
50
+
51
+ /**
52
+ * pi's own editor dialog (Enter submits, Ctrl+G opens $EDITOR), hosted here so
53
+ * the cursor can start on the first answer line instead of the end.
54
+ */
55
+ function answerEditor(ctx: ExtensionContext, title: string, template: string): Promise<string | undefined> {
56
+ const target = template.split("\n").findIndex((l) => l.startsWith("A1:"));
57
+ return ctx.ui.custom<string | undefined>((tui, _theme, keybindings, done) => {
58
+ const component = new ExtensionEditorComponent(tui, keybindings, title, template, (text: string) => done(text), () => done(undefined));
59
+ const editor = (component as unknown as { editor: { getCursor(): { line: number }; getText(): string; handleInput(d: string): void } }).editor;
60
+ const goTo = (line: number) => {
61
+ for (let i = 0; i < 500 && editor.getCursor().line > line; i++) editor.handleInput("\x1b[A");
62
+ for (let i = 0; i < 500 && editor.getCursor().line < line; i++) editor.handleInput("\x1b[B");
63
+ editor.handleInput("\x1b[F");
64
+ };
65
+ if (target >= 0) goTo(target);
66
+ // Tab / Shift+Tab jump between answer lines (wrapping around).
67
+ const inner = component.handleInput.bind(component);
68
+ component.handleInput = (data: string) => {
69
+ if (data !== "\t" && data !== "\x1b[Z") return inner(data);
70
+ const answers = editor.getText().split("\n").map((l, i) => (/^A\d+:/.test(l) ? i : -1)).filter((i) => i >= 0);
71
+ if (answers.length === 0) return;
72
+ const at = editor.getCursor().line;
73
+ const next = data === "\t"
74
+ ? answers.find((i) => i > at) ?? answers[0]!
75
+ : [...answers].reverse().find((i) => i < at) ?? answers.at(-1)!;
76
+ goTo(next);
77
+ tui.requestRender();
78
+ };
79
+ return component;
80
+ });
81
+ }
82
+
83
+ /** Small capturing overlay while the browser form is open; Esc cancels. */
84
+ function waitForWeb(ctx: ExtensionContext, lines: string[], result: Promise<WebAnswer | null>, cancel: () => void): Promise<WebAnswer | null> {
85
+ return ctx.ui.custom<WebAnswer | null>((tui, theme, _kb, done) => {
86
+ setHubTheme(theme);
87
+ void result.then((r) => done(r));
88
+ return {
89
+ focused: true,
90
+ render: (width: number) => {
91
+ const inner = boxInnerWidth(width);
92
+ return frameOverlay([...lines.map((l) => hubExactRow(` ${l}`, inner)), hubExactRow(dim(" Waiting for your answers… esc cancels"), inner)], width, {
93
+ title: bold(" answer — web form "),
94
+ borderFg: (t) => hubTheme.fg("borderMuted", t),
95
+ });
96
+ },
97
+ invalidate: () => tui.requestRender(),
98
+ handleInput: (data: string) => {
99
+ // matchesKey: kitty-protocol terminals send Esc as a CSI sequence, not a bare \x1b.
100
+ if (matchesKey(data, Key.escape) || data === "\x1b" || data === "\x03") {
101
+ cancel();
102
+ done(null);
103
+ }
104
+ },
105
+ };
106
+ }, HUB_OVERLAY_OPTIONS);
107
+ }
108
+
109
+ const METHODS = [
110
+ { value: "reply", label: "reply", description: "The reply above a fixed input box — scroll it while you type" },
111
+ { value: "questions", label: "questions", description: "One answer per detected question (Q/A template)" },
112
+ { value: "web", label: "web", description: "Answer the questions in a browser form (works over SSH)" },
113
+ ];
114
+
115
+ /** Reply panel in place of the input area (no overlay: pi restores the editor). */
116
+ function replyPanel(ctx: ExtensionContext, reply: string, questions: number): Promise<ReplyPanelResult> {
117
+ return ctx.ui.custom<ReplyPanelResult>((tui, theme, _kb, done) => new ReplyPanel(tui, theme, reply, questions, done));
118
+ }
119
+
120
+ const HINT_WIDGET = "unipi-answer-hint";
121
+
122
+ export function registerAnswerCommand(pi: ExtensionAPI): void {
123
+ // "N questions detected" hint above the editor after a reply that asks some;
124
+ // cleared as soon as the user sends anything.
125
+ let hinted = false;
126
+ pi.on("agent_end", (_event, ctx) => {
127
+ try {
128
+ if (!ctx.hasUI || !readUtilSettings(ctx.cwd).answer.hint) return;
129
+ const n = extractQuestions(lastAssistantText(ctx)).length;
130
+ if (n === 0) return;
131
+ ctx.ui.setWidget(HINT_WIDGET, [dim(` ${n} question${n === 1 ? "" : "s"} in the reply — /unipi:answer questions to answer them one by one`)], { placement: "aboveEditor" });
132
+ hinted = true;
133
+ } catch {
134
+ // hint is cosmetic
135
+ }
136
+ });
137
+ const clearHint = (ctx: ExtensionContext) => {
138
+ if (!hinted) return;
139
+ hinted = false;
140
+ try {
141
+ ctx.ui.setWidget(HINT_WIDGET, undefined);
142
+ } catch {
143
+ // ignore
144
+ }
145
+ };
146
+ // Any new turn (typed, /unipi:answer, or an extension message) clears it.
147
+ pi.on("input", (_event, ctx) => clearHint(ctx));
148
+ pi.on("before_agent_start", (_event, ctx) => clearHint(ctx));
149
+
150
+ pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.ANSWER}`, {
151
+ description: "Answer the last reply — a fixed input box under the scrollable reply (reply), per question (questions), or a browser form (web)",
152
+ getArgumentCompletions: (prefix: string) => {
153
+ const needle = (prefix ?? "").trim().toLowerCase();
154
+ const matches = METHODS.filter((m) => m.value.startsWith(needle));
155
+ return matches.length > 0 ? matches : null;
156
+ },
157
+ handler: async (args: string, ctx: ExtensionContext) => {
158
+ if (!ctx.hasUI) {
159
+ ctx.ui?.notify?.("/unipi:answer needs the interactive TUI", "warning");
160
+ return;
161
+ }
162
+ const reply = lastAssistantText(ctx);
163
+ if (!reply) {
164
+ ctx.ui.notify("No agent reply to answer yet.", "info");
165
+ return;
166
+ }
167
+ const questions = extractQuestions(reply);
168
+ const settings = readUtilSettings(ctx.cwd).answer;
169
+ const arg = args.trim().toLowerCase();
170
+ let method: "reply" | "questions" | "web" =
171
+ arg === "web" ? "web" : arg === "questions" || arg === "editor" ? "questions" : arg === "reply" ? "reply" : settings.method;
172
+ if (method !== "reply" && questions.length === 0 && method === "web") method = "reply";
173
+
174
+ let message: string | undefined;
175
+ if (method === "reply") {
176
+ const result = await replyPanel(ctx, reply, questions.length);
177
+ if (result.type === "cancel") return;
178
+ if (result.type === "send") message = result.text;
179
+ else method = "questions";
180
+ }
181
+ if (method === "questions") {
182
+ const title = questions.length
183
+ ? `Answer ${questions.length} question${questions.length === 1 ? "" : "s"} — Tab next answer · Ctrl+G your editor`
184
+ : "Reply to the last message (no questions found) — Ctrl+G your editor";
185
+ const text = await answerEditor(ctx, title, buildTemplate(questions));
186
+ if (text === undefined) return;
187
+ message = composeAnswers(questions, parseTemplate(text, questions.length));
188
+ } else if (method === "web") {
189
+ const ssh = isSsh();
190
+ const port = settings.port > 0 ? settings.port : ssh ? SSH_DEFAULT_PORT : 0;
191
+ const form = await startWebForm(reply, questions, port).catch(() => startWebForm(reply, questions, 0));
192
+ const lines = ssh
193
+ ? [
194
+ bold("Open on your own machine:"),
195
+ form.url,
196
+ dim("First forward the port from that machine:"),
197
+ `ssh -L ${form.port}:127.0.0.1:${form.port} ${hostname()}`,
198
+ ]
199
+ : [bold("Opened in your browser:"), form.url];
200
+ if (!ssh) openBrowser(form.url);
201
+ const answer = await waitForWeb(ctx, lines, form.result, form.close);
202
+ form.close();
203
+ if (!answer) return;
204
+ message = composeAnswers(questions, answer.answers, answer.note);
205
+ }
206
+ if (!message) {
207
+ ctx.ui.notify("Nothing answered — nothing sent.", "info");
208
+ return;
209
+ }
210
+ pi.sendUserMessage(message, ctx.isIdle() ? undefined : { deliverAs: "followUp" });
211
+ },
212
+ });
213
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @pi-unipi/utility — /unipi:answer "reply" panel (the default method)
3
+ *
4
+ * Like /unipi:btw: the panel replaces the input area (ctx.ui.custom without
5
+ * `overlay`; pi restores the editor and your draft when it closes). The agent's
6
+ * last reply is shown in a scrollable viewport above a FIXED input box, so you
7
+ * can read any part of a long reply while typing — nothing scrolls away.
8
+ *
9
+ * ── /unipi:answer · reply ───────────── 3 questions · tab answers them one by one ──
10
+ * …the reply, markdown…
11
+ * ────────────────────────────────────────────── (the input box's own top border)
12
+ * your answer (multi-line: shift+enter)
13
+ * ──────────────────────────────────────────────
14
+ * ↵ send · ↑↓ scroll · alt+↑↓ / pgup pgdn scroll while typing · … · lines 12–40 of 88
15
+ *
16
+ * ↑/↓ scroll the reply while the input is EMPTY; once you type they belong to
17
+ * the input (cursor movement in a multi-line answer). Alt+↑/↓ and PgUp/PgDn
18
+ * always scroll.
19
+ */
20
+
21
+ import { Editor, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
22
+ import { getMarkdownTheme, getSelectListTheme, type Theme } from "@earendil-works/pi-coding-agent";
23
+
24
+ export type ReplyPanelResult = { type: "send"; text: string } | { type: "questions"; draft: string } | { type: "cancel" };
25
+
26
+ export class ReplyPanel implements Component, Focusable {
27
+ private readonly editor: Editor;
28
+ private _focused = false;
29
+ get focused(): boolean {
30
+ return this._focused;
31
+ }
32
+ set focused(value: boolean) {
33
+ this._focused = value;
34
+ this.editor.focused = value;
35
+ }
36
+ private scroll = 0;
37
+ /** Start at the end — questions usually close a reply. */
38
+ private atEnd = true;
39
+ private body: string[] = [];
40
+ private bodyWidth = 0;
41
+
42
+ constructor(
43
+ private readonly tui: TUI,
44
+ private readonly theme: Theme,
45
+ private readonly reply: string,
46
+ private readonly questionCount: number,
47
+ private readonly done: (result: ReplyPanelResult) => void,
48
+ draft = "",
49
+ ) {
50
+ this.editor = new Editor(tui, { borderColor: (text: string) => theme.fg("borderMuted", text), selectList: getSelectListTheme() });
51
+ if (draft) this.editor.setText(draft);
52
+ this.editor.onSubmit = (text: string) => {
53
+ const value = text.trim();
54
+ if (value) this.done({ type: "send", text: value });
55
+ };
56
+ }
57
+
58
+ invalidate(): void {
59
+ this.bodyWidth = 0;
60
+ this.editor.invalidate?.();
61
+ }
62
+
63
+ private viewport(): number {
64
+ return Math.max(4, (process.stdout.rows ?? 30) - 12);
65
+ }
66
+
67
+ private lines(width: number): string[] {
68
+ if (this.bodyWidth !== width) {
69
+ const md = new Markdown(this.reply, 0, 0, getMarkdownTheme());
70
+ this.body = md.render(width).flatMap((l) => wrapTextWithAnsi(l, Math.max(1, width)));
71
+ this.bodyWidth = width;
72
+ }
73
+ return this.body;
74
+ }
75
+
76
+ render(width: number): string[] {
77
+ const w = Math.max(20, width);
78
+ const t = this.theme;
79
+ const body = this.lines(w - 1);
80
+ const view = this.viewport();
81
+ const max = Math.max(0, body.length - view);
82
+ if (this.atEnd) this.scroll = max;
83
+ this.scroll = Math.max(0, Math.min(this.scroll, max));
84
+ const shown = body.slice(this.scroll, this.scroll + view);
85
+
86
+ const title = " /unipi:answer · reply ";
87
+ const qs = this.questionCount > 0 ? ` ${this.questionCount} question${this.questionCount === 1 ? "" : "s"} · tab answers them one by one ` : "";
88
+ const top = `${t.fg("borderMuted", "──")}${t.fg("accent", title)}${t.fg("borderMuted", "─".repeat(Math.max(0, w - 2 - visibleWidth(title) - visibleWidth(qs))))}${t.fg("dim", qs)}`;
89
+ const pos = body.length > view ? ` · lines ${this.scroll + 1}–${this.scroll + shown.length} of ${body.length}` : "";
90
+ const scrollKeys = this.editor.getText() === "" ? "↑↓ scroll" : "alt+↑↓ scroll";
91
+ const hint = t.fg("dim", `↵ send · shift+↵ newline · ${scrollKeys} · pgup/pgdn page${this.questionCount > 0 ? " · tab questions" : ""} · esc back${pos}`);
92
+
93
+ // The input box draws its own top/bottom border — no extra rule above it.
94
+ return [
95
+ truncateToWidth(top, w, ""),
96
+ ...shown.map((l) => truncateToWidth(l, w, "")),
97
+ ...Array.from({ length: Math.max(0, view - shown.length) }, () => ""),
98
+ ...this.editor.render(w),
99
+ truncateToWidth(hint, w, ""),
100
+ ];
101
+ }
102
+
103
+ private scrollBy(delta: number): void {
104
+ const max = Math.max(0, this.body.length - this.viewport());
105
+ this.scroll = Math.max(0, Math.min(max, this.scroll + delta));
106
+ this.atEnd = this.scroll >= max;
107
+ this.tui.requestRender();
108
+ }
109
+
110
+ handleInput(data: string): void {
111
+ if (matchesKey(data, Key.escape) || data === "\x1b") {
112
+ this.done({ type: "cancel" });
113
+ return;
114
+ }
115
+ if (matchesKey(data, Key.tab) && this.questionCount > 0) {
116
+ this.done({ type: "questions", draft: this.editor.getText() });
117
+ return;
118
+ }
119
+ const page = Math.max(1, this.viewport() - 2);
120
+ if (matchesKey(data, Key.pageUp)) return this.scrollBy(-page);
121
+ if (matchesKey(data, Key.pageDown)) return this.scrollBy(page);
122
+ if (matchesKey(data, "alt+up")) return this.scrollBy(-1);
123
+ if (matchesKey(data, "alt+down")) return this.scrollBy(1);
124
+ // Plain ↑/↓ scroll only while nothing is typed; after that they are the input's.
125
+ const empty = this.editor.getText() === "";
126
+ if (empty && matchesKey(data, Key.up)) return this.scrollBy(-1);
127
+ if (empty && matchesKey(data, Key.down)) return this.scrollBy(1);
128
+ this.editor.handleInput(data);
129
+ this.tui.requestRender();
130
+ }
131
+
132
+ dispose(): void {}
133
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * @pi-unipi/utility — /unipi:answer web form
3
+ *
4
+ * A one-shot local page: the agent's full reply on the left, one answer box
5
+ * per question on the right (plus a free-text note), submit → resolves. Bound
6
+ * to 127.0.0.1 with a random token in the URL; over SSH the caller shows a
7
+ * port-forward command instead of opening a browser.
8
+ */
9
+
10
+ import { randomBytes } from "node:crypto";
11
+ import { createServer, type Server } from "node:http";
12
+
13
+ export interface WebAnswer {
14
+ answers: string[];
15
+ note: string;
16
+ }
17
+
18
+ export interface WebForm {
19
+ url: string;
20
+ port: number;
21
+ result: Promise<WebAnswer | null>;
22
+ close: () => void;
23
+ }
24
+
25
+ const esc = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
26
+
27
+ /** Tiny markdown → HTML for the reply pane: fences, headings, lists, bold, code. */
28
+ export function renderReply(md: string): string {
29
+ const out: string[] = [];
30
+ let code: string[] | null = null;
31
+ let list = false;
32
+ let table: string[][] | null = null;
33
+ const inline = (s: string) => esc(s).replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
34
+ const flushTable = () => {
35
+ if (!table) return;
36
+ const [head, ...body] = table;
37
+ out.push(`<table><thead><tr>${head!.map((c) => `<th>${inline(c)}</th>`).join("")}</tr></thead><tbody>${body.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("")}</tbody></table>`);
38
+ table = null;
39
+ };
40
+ for (const line of md.split("\n")) {
41
+ if (!code && /^\s*\|.*\|\s*$/.test(line)) {
42
+ if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) continue; // separator row
43
+ (table ??= []).push(line.trim().slice(1, -1).split("|").map((c) => c.trim()));
44
+ continue;
45
+ }
46
+ flushTable();
47
+ if (/^\s*```/.test(line)) {
48
+ if (code) {
49
+ out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
50
+ code = null;
51
+ } else code = [];
52
+ continue;
53
+ }
54
+ if (code) {
55
+ code.push(line);
56
+ continue;
57
+ }
58
+ const li = line.match(/^\s*(?:[-*+]|\d+[.)])\s+(.*)$/);
59
+ if (li) {
60
+ if (!list) out.push("<ul>");
61
+ list = true;
62
+ out.push(`<li>${inline(li[1]!)}</li>`);
63
+ continue;
64
+ }
65
+ if (list) {
66
+ out.push("</ul>");
67
+ list = false;
68
+ }
69
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
70
+ if (h) out.push(`<h${Math.min(4, h[1]!.length + 1)}>${inline(h[2]!)}</h${Math.min(4, h[1]!.length + 1)}>`);
71
+ else if (line.trim()) out.push(`<p>${inline(line)}</p>`);
72
+ }
73
+ flushTable();
74
+ if (code) out.push(`<pre><code>${esc(code.join("\n"))}</code></pre>`);
75
+ if (list) out.push("</ul>");
76
+ return out.join("\n");
77
+ }
78
+
79
+ export function formPage(reply: string, questions: readonly string[], token: string): string {
80
+ const qs = questions.length ? questions : ["Your reply"];
81
+ const fields = qs.map((q, i) => `
82
+ <label><span class="n">${i + 1}</span><span class="q">${esc(q)}</span></label>
83
+ <textarea name="a${i}" rows="3" placeholder="Answer (empty = skip)"></textarea>`).join("");
84
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
85
+ <title>Answer — pi</title>
86
+ <style>
87
+ :root{color-scheme:dark;--bg:#16181d;--panel:#1e2128;--line:#2c313a;--fg:#d7dae0;--dim:#8a919e;--accent:#53a0d7}
88
+ *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.55 system-ui,sans-serif}
89
+ main{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,1fr);height:100vh}
90
+ section{overflow:auto;padding:24px 28px} .reply{border-right:1px solid var(--line)}
91
+ h1{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin:0 0 16px}
92
+ table{border-collapse:collapse;margin:10px 0} th,td{border:1px solid var(--line);padding:5px 10px;text-align:left} th{color:var(--dim);font-weight:600}
93
+ pre{background:var(--panel);padding:10px 12px;border-radius:6px;overflow:auto} code{font:12.5px ui-monospace,monospace}
94
+ label{display:flex;gap:10px;margin:18px 0 6px} .n{color:var(--accent);font-weight:600} .q{flex:1}
95
+ textarea{width:100%;background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:8px 10px;font:inherit;resize:vertical}
96
+ textarea:focus{outline:none;border-color:var(--accent)}
97
+ .bar{position:sticky;bottom:0;background:var(--bg);padding:14px 0;display:flex;gap:12px;align-items:center}
98
+ button{background:var(--accent);color:#0b0d10;border:0;border-radius:6px;padding:9px 18px;font-weight:600;cursor:pointer}
99
+ .hint{color:var(--dim);font-size:12px} .done{padding:40px;font-size:16px}
100
+ @media (max-width:900px){main{grid-template-columns:1fr;height:auto}.reply{border-right:0;border-bottom:1px solid var(--line)}}
101
+ </style></head><body><main>
102
+ <section class="reply"><h1>Agent's reply</h1>${renderReply(reply)}</section>
103
+ <section><h1>Your answers</h1>
104
+ <form id="f">${fields}
105
+ <label><span class="q">Anything else</span></label>
106
+ <textarea name="note" rows="3" placeholder="Optional note"></textarea>
107
+ <div class="bar"><button type="submit">Send to pi</button><span class="hint">Ctrl+Enter sends</span></div>
108
+ </form>
109
+ </section>
110
+ </main>
111
+ <script>
112
+ const f=document.getElementById("f");
113
+ const send=async()=>{const d=new FormData(f);const answers=${JSON.stringify(qs.map((_, i) => `a${i}`))}.map(k=>d.get(k)||"");
114
+ const r=await fetch("/a/${token}",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({answers,note:d.get("note")||""})});
115
+ document.body.innerHTML='<div class="done">'+(r.ok?"Sent to pi. You can close this tab.":"Could not send — is pi still waiting?")+'</div>';};
116
+ f.addEventListener("submit",e=>{e.preventDefault();send();});
117
+ f.addEventListener("keydown",e=>{if(e.key==="Enter"&&(e.ctrlKey||e.metaKey)){e.preventDefault();send();}});
118
+ document.querySelector("textarea")?.focus();
119
+ </script></body></html>`;
120
+ }
121
+
122
+ /** Start the form server. `port` 0 = any free port. */
123
+ export function startWebForm(reply: string, questions: readonly string[], port = 0): Promise<WebForm> {
124
+ const token = randomBytes(12).toString("hex");
125
+ let settle: (v: WebAnswer | null) => void = () => {};
126
+ const result = new Promise<WebAnswer | null>((resolve) => { settle = resolve; });
127
+ const page = formPage(reply, questions, token);
128
+ const server: Server = createServer((req, res) => {
129
+ if (req.url !== `/a/${token}`) {
130
+ res.writeHead(404).end();
131
+ return;
132
+ }
133
+ if (req.method === "GET") {
134
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }).end(page);
135
+ return;
136
+ }
137
+ if (req.method === "POST") {
138
+ let body = "";
139
+ req.on("data", (c) => { body += c; if (body.length > 1_000_000) req.destroy(); });
140
+ req.on("end", () => {
141
+ try {
142
+ const data = JSON.parse(body) as { answers?: unknown; note?: unknown };
143
+ const answers = Array.isArray(data.answers) ? data.answers.map((a) => String(a ?? "")) : [];
144
+ res.writeHead(200).end("ok");
145
+ settle({ answers, note: typeof data.note === "string" ? data.note : "" });
146
+ } catch {
147
+ res.writeHead(400).end();
148
+ }
149
+ });
150
+ return;
151
+ }
152
+ res.writeHead(405).end();
153
+ });
154
+ const close = () => {
155
+ settle(null);
156
+ server.close();
157
+ server.closeAllConnections?.();
158
+ };
159
+ void result.then(() => setTimeout(() => { server.close(); server.closeAllConnections?.(); }, 500).unref());
160
+ return new Promise((resolve, reject) => {
161
+ server.once("error", reject);
162
+ server.listen(port, "127.0.0.1", () => {
163
+ const actual = (server.address() as { port: number }).port;
164
+ resolve({ url: `http://127.0.0.1:${actual}/a/${token}`, port: actual, result, close });
165
+ });
166
+ });
167
+ }