@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
@@ -1,757 +1,331 @@
1
1
  /**
2
- * @pi-unipi/ask-user — TUI Components
2
+ * @pi-unipi/ask-user — the question panel (Devin-style)
3
3
  *
4
- * Interactive UI for single-select, multi-select, and freeform input.
5
- * Uses ctx.ui.custom() callback pattern following question.ts/questionnaire.ts.
6
- */
7
-
8
- import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, type TUI, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
- import type { Theme, AgentToolResult } from "@earendil-works/pi-coding-agent";
10
- import { adaptiveInnerWidth, contentWidth, normalizeWidth, safeRepeat, shouldRenderBorder, WidthKeyedCache } from "@pi-unipi/core";
11
- import type { NormalizedOption, AskUserResponse } from "./types.js";
12
-
13
- /** Result returned by the ask UI */
14
- export interface AskUIResult {
15
- response: AskUserResponse;
16
- }
17
-
18
- /**
19
- * Render the ask_user interactive UI.
4
+ * ── Planet ✓ · Foods 3 · Last book ─────────────────────────────
5
+ * Which planet would you most like to visit?
6
+ * ❭ 1 Mars
7
+ * The red planet, dusty and cold
8
+ * 2 Saturn
9
+ * Famous for its rings
10
+ * Other (type your own)
11
+ * ────────────────────────────────────────────────────────────────
12
+ * ↑↓ navigate · ↵ select · ←→ switch question · ? help me out · esc cancel
13
+ * ? Not ready to answer, help me out!
20
14
  *
21
- * Supports:
22
- * - Single-select: arrow keys + Enter
23
- * - Multi-select: Space to toggle, Enter to submit
24
- * - Freeform: text input via Editor
25
- * - Timeout: auto-dismiss after N ms
26
- * - Cancel: Escape key
15
+ * Replaces the input area (no overlay; pi restores the editor on close).
16
+ * ↑↓ move · digits pick (single: pick + next; multi: toggle)
17
+ * ␣ toggle (multi)
18
+ * ↵ select and go to the next question; submits on the last one
19
+ * ←→ / tab switch question (on a non-empty "Other" ←→ move the text cursor)
20
+ * Other just type — no Enter needed; paste / drop a file path or Ctrl+V
21
+ * an image to attach it as [Image #N]
22
+ * ? "not ready — help me out" (ends the call; the agent asks what to clarify)
23
+ * esc cancel (the agent's turn stops)
24
+ * Unanswered questions are submitted as skipped — never blocking.
27
25
  */
28
- export function renderAskUI(params: {
29
- question: string;
30
- context?: string;
31
- options: NormalizedOption[];
32
- allowMultiple: boolean;
33
- allowFreeform: boolean;
34
- timeout?: number;
35
- }): (
36
- tui: TUI,
37
- theme: Theme,
38
- kb: import("@earendil-works/pi-coding-agent").KeybindingsManager,
39
- done: (result: AskUIResult | null) => void,
40
- ) => {
41
- render: (width: number) => string[];
42
- invalidate: () => void;
43
- handleInput: (data: string) => void;
44
- } {
45
- return (tui, theme, _kb, done) => {
46
- const { question, context, options, allowMultiple, allowFreeform, timeout } = params;
47
26
 
48
- // Build display options — add "Custom response" if allowFreeform
49
- const displayOptions: (NormalizedOption & { isFreeform?: boolean })[] = [
50
- ...options,
51
- ];
52
- if (allowFreeform) {
53
- displayOptions.push({
54
- label: "Custom response",
55
- value: "__freeform__",
56
- isFreeform: true,
57
- });
58
- }
27
+ import { Input, Key, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component, type Focusable, type TUI } from "@earendil-works/pi-tui";
28
+ import type { Theme } from "@earendil-works/pi-coding-agent";
29
+ import { formatBytes, stillReferenced, tokenFor, tokenize, type Attachment } from "@pi-unipi/core";
30
+ import { readClipboardImageFile } from "./clipboard.js";
31
+ import { isAnswered, optionValue, type AskOption, type AskQuestion, type QuestionAnswer } from "./questions.js";
32
+
33
+ export type PanelResult =
34
+ | { type: "answered"; answers: QuestionAnswer[]; attachments: Attachment[] }
35
+ | { type: "clarify"; answers: QuestionAnswer[] }
36
+ | { type: "action"; question: number; option: AskOption; answers: QuestionAnswer[] }
37
+ | { type: "cancel" };
38
+
39
+ export interface PanelOptions {
40
+ /** stop: Esc cancels the turn · send: Esc sends what's answered (rest skipped). */
41
+ escape: "stop" | "send";
42
+ /** A digit in a single-choice question also moves on. */
43
+ digitAdvance: boolean;
44
+ /** Show "? Not ready to answer, help me out!". */
45
+ helpLine: boolean;
46
+ }
59
47
 
60
- // State
61
- let optionIndex = 0;
62
- let editMode = false;
63
- let editTarget: "freeform" | number = "freeform"; // which option is being edited
64
- // Width-keyed so a terminal resize can never serve stale, over-wide lines.
65
- const lineCache = new WidthKeyedCache();
66
- const selected = new Set<string>();
67
- let customText: string | null = null; // Store custom text (global freeform)
68
- const optionCustomTexts = new Map<string, string>(); // Per-option custom text for allowCustom
69
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
70
- let remainingMs = timeout;
48
+ export const DEFAULT_PANEL_OPTIONS: PanelOptions = { escape: "stop", digitAdvance: true, helpLine: true };
71
49
 
72
- // Editor for freeform input
73
- const editorTheme: EditorTheme = {
74
- borderColor: (s: any) => theme.fg("accent", s),
75
- selectList: {
76
- selectedPrefix: (t: any) => theme.fg("accent", t),
77
- selectedText: (t: any) => theme.fg("accent", t),
78
- description: (t: any) => theme.fg("muted", t),
79
- scrollInfo: (t: any) => theme.fg("dim", t),
80
- noMatch: (t: any) => theme.fg("warning", t),
81
- },
82
- };
83
- const editor = new Editor(tui, editorTheme);
50
+ interface QState {
51
+ cursor: number;
52
+ picked: Set<string>;
53
+ input: Input;
54
+ }
84
55
 
85
- function getOptionCustomText(optIndex: number): string | null {
86
- const opt = displayOptions[optIndex];
87
- return optionCustomTexts.get(opt.value) ?? null;
56
+ const OTHER_LABEL = "Other (type your own)";
57
+ const PASTE_START = "\x1b[200~";
58
+ const PASTE_END = "\x1b[201~";
59
+
60
+ export class AskPanel implements Component, Focusable {
61
+ private q = 0;
62
+ private readonly states: QState[];
63
+ private attachments: Attachment[] = [];
64
+ private paste: string | null = null;
65
+ private note: string | null = null;
66
+ private finished = false;
67
+ private _focused = false;
68
+
69
+ constructor(
70
+ private readonly tui: TUI,
71
+ private readonly theme: Theme,
72
+ private readonly questions: readonly AskQuestion[],
73
+ private readonly done: (result: PanelResult) => void,
74
+ private readonly readClipboard: () => string | undefined = () => readClipboardImageFile(),
75
+ private readonly options: PanelOptions = DEFAULT_PANEL_OPTIONS,
76
+ ) {
77
+ this.states = questions.map(() => ({ cursor: 0, picked: new Set<string>(), input: new Input({ prompt: "" }) }));
78
+ }
79
+
80
+ get focused(): boolean {
81
+ return this._focused;
82
+ }
83
+ set focused(v: boolean) {
84
+ this._focused = v;
85
+ this.syncFocus();
86
+ }
87
+
88
+ // ── state helpers ──────────────────────────────────────────────────────
89
+ private get question(): AskQuestion {
90
+ return this.questions[this.q]!;
91
+ }
92
+ private get state(): QState {
93
+ return this.states[this.q]!;
94
+ }
95
+ private hasOther(q = this.q): boolean {
96
+ return this.questions[q]!.other !== false;
97
+ }
98
+ private otherIndex(q = this.q): number {
99
+ return this.hasOther(q) ? this.questions[q]!.options.length : -1;
100
+ }
101
+ private rowCount(q = this.q): number {
102
+ return this.questions[q]!.options.length + (this.hasOther(q) ? 1 : 0);
103
+ }
104
+ private onOther(): boolean {
105
+ return this.state.cursor === this.otherIndex();
106
+ }
107
+ private otherText(q = this.q): string {
108
+ return this.states[q]!.input.getValue();
109
+ }
110
+ private syncFocus(): void {
111
+ this.states.forEach((s, i) => (s.input.focused = this._focused && i === this.q && s.cursor === this.otherIndex(i)));
112
+ }
113
+
114
+ answers(): QuestionAnswer[] {
115
+ return this.questions.map((_, i) => {
116
+ const s = this.states[i]!;
117
+ const text = s.input.getValue().trim();
118
+ const a: QuestionAnswer = { selected: [...s.picked], skipped: false, ...(text ? { custom_text: text } : {}) };
119
+ a.skipped = !isAnswered(a);
120
+ return a;
121
+ });
122
+ }
123
+
124
+ private finish(result: PanelResult): void {
125
+ if (this.finished) return;
126
+ this.finished = true;
127
+ this.done(result);
128
+ }
129
+
130
+ private submit(): void {
131
+ const text = this.states.map((s) => s.input.getValue()).join("\n");
132
+ this.finish({ type: "answered", answers: this.answers(), attachments: stillReferenced(text, this.attachments) });
133
+ }
134
+
135
+ /** Enter / pick: go to the next question, or submit from the last. */
136
+ private advance(): void {
137
+ if (this.q < this.questions.length - 1) {
138
+ this.q++;
139
+ this.syncFocus();
140
+ } else this.submit();
141
+ }
142
+
143
+ private switchTo(delta: number): void {
144
+ const next = Math.max(0, Math.min(this.questions.length - 1, this.q + delta));
145
+ if (next !== this.q) {
146
+ this.q = next;
147
+ this.syncFocus();
88
148
  }
89
-
90
- function setOptionCustomText(optIndex: number, text: string | null) {
91
- const opt = displayOptions[optIndex];
92
- if (text) {
93
- optionCustomTexts.set(opt.value, text);
94
- } else {
95
- optionCustomTexts.delete(opt.value);
96
- }
149
+ }
150
+
151
+ private pick(index: number, move = true): void {
152
+ const opt = this.question.options[index];
153
+ if (!opt) return;
154
+ const s = this.state;
155
+ if (this.question.multi_select) {
156
+ const v = optionValue(opt);
157
+ if (s.picked.has(v)) s.picked.delete(v);
158
+ else s.picked.add(v);
159
+ return;
97
160
  }
98
-
99
- /** Get effective action for an option (allowCustom maps to input) */
100
- function getAction(opt: NormalizedOption & { isFreeform?: boolean }): string {
101
- if (opt.isFreeform) return "freeform";
102
- if (opt.action && opt.action !== "select") return opt.action;
103
- if (opt.allowCustom) return "input";
104
- return "select";
161
+ s.picked = new Set([optionValue(opt)]);
162
+ s.input.setValue(""); // single choice: the last action wins
163
+ if (opt.action) {
164
+ this.finish({ type: "action", question: this.q, option: opt, answers: this.answers() });
165
+ return;
105
166
  }
106
-
107
- editor.onSubmit = (value: string) => {
108
- const trimmed = value.trim();
109
- if (editTarget === "freeform") {
110
- // Global freeform input
111
- if (trimmed) {
112
- customText = trimmed;
113
- editMode = false;
114
- editor.setText("");
115
- refresh();
116
- } else {
117
- // If empty and no previous custom text, uncheck freeform option
118
- if (!customText) {
119
- selected.delete("__freeform__");
120
- }
121
- editMode = false;
122
- editor.setText("");
123
- refresh();
124
- }
125
- } else {
126
- // Per-option custom input (allowCustom)
127
- if (trimmed) {
128
- setOptionCustomText(editTarget, trimmed);
129
- editMode = false;
130
- editor.setText("");
131
- // Auto-submit in single-select mode
132
- if (!allowMultiple) {
133
- cleanup();
134
- const opt = displayOptions[editTarget];
135
- done({
136
- response: {
137
- kind: "combined",
138
- selections: [opt.value],
139
- text: trimmed,
140
- },
141
- });
142
- return;
143
- }
144
- refresh();
145
- } else {
146
- // Empty: cancel edit mode, keep option selected but without custom text
147
- editMode = false;
148
- editor.setText("");
149
- refresh();
150
- }
151
- }
152
- };
153
-
154
- function cleanup() {
155
- if (timeoutId) {
156
- clearTimeout(timeoutId);
157
- timeoutId = undefined;
158
- }
167
+ if (move) this.advance();
168
+ }
169
+
170
+ /** Text into "Other": file paths become [Image #N] / [File #N] tokens. */
171
+ private insertText(text: string): void {
172
+ if (!this.hasOther()) return;
173
+ if (!this.onOther()) {
174
+ this.state.cursor = this.otherIndex();
175
+ this.syncFocus();
159
176
  }
160
-
161
- function refresh() {
162
- lineCache.clear();
163
- tui.requestRender();
177
+ const { text: tokens, added } = tokenize(text, this.attachments);
178
+ this.attachments.push(...added);
179
+ this.state.input.handleInput(`${PASTE_START}${tokens}${PASTE_END}`);
180
+ if (!this.question.multi_select && this.otherText().trim()) this.state.picked.clear();
181
+ }
182
+
183
+ private attachClipboardImage(): void {
184
+ const file = this.readClipboard();
185
+ if (!file) {
186
+ this.note = "No image on the clipboard (over SSH, paste a file path instead)";
187
+ return;
164
188
  }
165
-
166
- // Setup timeout if specified
167
- if (timeout && timeout > 0) {
168
- remainingMs = timeout;
169
- const startTime = Date.now();
170
- const tickInterval = setInterval(() => {
171
- const elapsed = Date.now() - startTime;
172
- remainingMs = Math.max(0, timeout - elapsed);
173
- refresh();
174
- if (remainingMs <= 0) {
175
- clearInterval(tickInterval);
176
- }
177
- }, 1000);
178
-
179
- timeoutId = setTimeout(() => {
180
- clearInterval(tickInterval);
181
- cleanup();
182
- done({
183
- response: {
184
- kind: "timed_out",
185
- comment: `Timed out after ${timeout}ms`,
186
- },
187
- });
188
- }, timeout);
189
+ this.insertText(`${this.otherText() && !/\s$/.test(this.otherText()) ? " " : ""}${file} `);
190
+ }
191
+
192
+ // ── input ──────────────────────────────────────────────────────────────
193
+ handleInput(data: string): void {
194
+ if (this.finished) return;
195
+ this.note = null;
196
+ // Bracketed paste may arrive in pieces: collect it whole, then tokenize.
197
+ if (this.paste !== null || data.includes(PASTE_START)) {
198
+ this.paste = (this.paste ?? "") + data;
199
+ const end = this.paste.indexOf(PASTE_END);
200
+ if (end < 0) return;
201
+ const body = this.paste.slice(this.paste.indexOf(PASTE_START) + PASTE_START.length, end);
202
+ this.paste = null;
203
+ this.insertText(body);
204
+ return this.tui.requestRender();
189
205
  }
190
206
 
191
- function handleInput(data: string) {
192
- // Edit mode: route to editor
193
- if (editMode) {
194
- if (matchesKey(data, Key.escape)) {
195
- // Cancel text input
196
- if (editTarget === "freeform") {
197
- // Global freeform: uncheck if no previous text
198
- if (!customText) {
199
- selected.delete("__freeform__");
200
- }
201
- }
202
- // For per-option: just cancel edit, keep option selected
203
- editMode = false;
204
- editor.setText("");
205
- refresh();
206
- return;
207
- }
208
- editor.handleInput(data);
209
- refresh();
210
- return;
211
- }
212
-
213
- // Navigation
214
- if (matchesKey(data, Key.up)) {
215
- optionIndex = Math.max(0, optionIndex - 1);
216
- refresh();
217
- return;
218
- }
219
- if (matchesKey(data, Key.down)) {
220
- optionIndex = Math.min(displayOptions.length - 1, optionIndex + 1);
221
- refresh();
222
- return;
223
- }
224
-
225
- // Multi-select: Space to toggle
226
- if (allowMultiple && matchesKey(data, Key.space)) {
227
- const opt = displayOptions[optionIndex];
228
- const val = opt.value;
229
- const action = getAction(opt);
230
-
231
- if (action === "freeform") {
232
- // Freeform option: toggle and enter edit mode if checking
233
- if (selected.has(val)) {
234
- selected.delete(val);
235
- customText = null;
236
- } else {
237
- selected.add(val);
238
- if (!customText) {
239
- editMode = true;
240
- editTarget = "freeform";
241
- editor.setText("");
242
- }
243
- }
244
- } else if (action === "end_turn") {
245
- // End turn: immediate
246
- cleanup();
247
- done({ response: { kind: "end_turn", selections: [val] } });
248
- return;
249
- } else if (action === "new_session") {
250
- // New session: immediate
251
- cleanup();
252
- done({ response: { kind: "new_session", selections: [val], prefill: opt.prefill } });
253
- return;
254
- } else if (action === "input") {
255
- // Input action: toggle and enter edit mode if checking
256
- if (selected.has(val)) {
257
- selected.delete(val);
258
- optionCustomTexts.delete(val);
259
- } else {
260
- selected.add(val);
261
- if (!getOptionCustomText(optionIndex)) {
262
- editMode = true;
263
- editTarget = optionIndex;
264
- editor.setText("");
265
- }
266
- }
267
- } else {
268
- // Regular option: toggle
269
- if (selected.has(val)) {
270
- selected.delete(val);
271
- } else {
272
- selected.add(val);
273
- }
274
- }
275
- refresh();
276
- return;
277
- }
278
-
279
- // Enter: select or submit
280
- if (matchesKey(data, Key.enter)) {
281
- const opt = displayOptions[optionIndex];
282
- const action = getAction(opt);
283
-
284
- if (action === "freeform") {
285
- // Freeform option: if already checked with text, submit; otherwise enter edit mode
286
- if (selected.has(opt.value) && customText) {
287
- cleanup();
288
- const regularSelections = Array.from(selected).filter(v => v !== "__freeform__");
289
- if (regularSelections.length > 0) {
290
- done({ response: { kind: "combined", selections: regularSelections, text: customText } });
291
- } else {
292
- done({ response: { kind: "freeform", text: customText } });
293
- }
294
- } else {
295
- selected.add(opt.value);
296
- editMode = true;
297
- editTarget = "freeform";
298
- editor.setText("");
299
- }
300
- refresh();
301
- return;
302
- }
303
-
304
- if (action === "end_turn") {
305
- cleanup();
306
- done({ response: { kind: "end_turn", selections: [opt.value] } });
307
- return;
308
- }
309
-
310
- if (action === "new_session") {
311
- cleanup();
312
- done({ response: { kind: "new_session", selections: [opt.value], prefill: opt.prefill } });
313
- return;
314
- }
315
-
316
- if (allowMultiple) {
317
- // In multi-select, Enter submits current selection
318
- if (selected.size > 0) {
319
- cleanup();
320
- if (customText && selected.has("__freeform__")) {
321
- const regularSelections = Array.from(selected).filter(v => v !== "__freeform__");
322
- done({ response: { kind: "combined", selections: regularSelections, text: customText } });
323
- } else {
324
- const selections = Array.from(selected);
325
- const combinedTexts: string[] = [];
326
- for (const sel of selections) {
327
- const txt = optionCustomTexts.get(sel);
328
- if (txt) combinedTexts.push(`${sel}: ${txt}`);
329
- }
330
- if (combinedTexts.length > 0) {
331
- done({ response: { kind: "combined", selections, text: combinedTexts.join("\n") } });
332
- } else {
333
- done({ response: { kind: "selection", selections } });
334
- }
335
- }
336
- }
337
- return;
338
- }
339
-
340
- // Single-select: check if option has input action
341
- if (action === "input") {
342
- const existing = getOptionCustomText(optionIndex);
343
- if (existing) {
344
- cleanup();
345
- done({ response: { kind: "combined", selections: [opt.value], text: existing } });
346
- } else {
347
- editMode = true;
348
- editTarget = optionIndex;
349
- editor.setText("");
350
- refresh();
351
- }
352
- return;
353
- }
354
-
355
- // Single-select without special action: return immediately
356
- cleanup();
357
- done({ response: { kind: "selection", selections: [opt.value] } });
358
- return;
359
- }
360
-
361
- // Escape: cancel
362
- if (matchesKey(data, Key.escape)) {
363
- cleanup();
364
- done(null);
365
- }
207
+ const s = this.state;
208
+ const other = this.onOther();
209
+ const multi = this.question.multi_select === true;
210
+
211
+ if (matchesKey(data, Key.escape)) return this.options.escape === "send" ? this.submit() : this.finish({ type: "cancel" });
212
+ if (matchesKey(data, Key.up)) {
213
+ s.cursor = Math.max(0, s.cursor - 1);
214
+ } else if (matchesKey(data, Key.down)) {
215
+ s.cursor = Math.min(this.rowCount() - 1, s.cursor + 1);
216
+ } else if (matchesKey(data, Key.tab)) {
217
+ this.switchTo(1);
218
+ } else if (matchesKey(data, "shift+tab")) {
219
+ this.switchTo(-1);
220
+ } else if ((matchesKey(data, Key.left) || matchesKey(data, Key.right)) && !(other && this.otherText())) {
221
+ this.switchTo(matchesKey(data, Key.left) ? -1 : 1);
222
+ } else if (matchesKey(data, Key.enter) || data === "\r") {
223
+ if (other) {
224
+ if (!multi && this.otherText().trim()) s.picked.clear();
225
+ this.advance();
226
+ } else if (multi) {
227
+ const opt = this.question.options[s.cursor];
228
+ if (s.picked.size === 0 && opt) s.picked.add(optionValue(opt));
229
+ this.advance();
230
+ } else this.pick(s.cursor);
231
+ } else if (matchesKey(data, Key.ctrl("v")) || matchesKey(data, Key.alt("v"))) {
232
+ if (this.hasOther()) this.attachClipboardImage();
233
+ } else if (other) {
234
+ s.input.handleInput(data);
235
+ if (!multi && this.otherText().trim()) s.picked.clear();
236
+ } else if (data === " " && multi) {
237
+ this.pick(s.cursor);
238
+ } else if (/^[1-9]$/.test(data)) {
239
+ const i = Number(data) - 1;
240
+ if (i < this.question.options.length) {
241
+ s.cursor = i;
242
+ this.pick(i, this.options.digitAdvance);
243
+ } else if (i === this.otherIndex()) s.cursor = i;
244
+ } else if (data === "?") {
245
+ return this.finish({ type: "clarify", answers: this.answers() });
246
+ } else {
247
+ return; // typing is for "Other" — ignored elsewhere, like Devin
366
248
  }
249
+ this.syncFocus();
250
+ this.tui.requestRender();
251
+ }
252
+
253
+ // ── render ─────────────────────────────────────────────────────────────
254
+ invalidate(): void {}
255
+
256
+ private chip(i: number): string {
257
+ const t = this.theme;
258
+ const q = this.questions[i]!;
259
+ const s = this.states[i]!;
260
+ const active = i === this.q;
261
+ const label = active ? t.fg("accent", q.header) : t.fg("dim", q.header);
262
+ let mark = "";
263
+ if (q.multi_select && s.picked.size > 0) mark = ` ${active ? t.fg("accent", String(s.picked.size + (s.input.getValue().trim() ? 1 : 0))) : t.fg("dim", String(s.picked.size))}`;
264
+ else if (s.picked.size > 0 || s.input.getValue().trim()) mark = ` ${t.fg("dim", "✓")}`;
265
+ return label + mark;
266
+ }
267
+
268
+ render(width: number): string[] {
269
+ // Never wider than the terminal (pi-tui throws on over-wide lines).
270
+ const w = Number.isFinite(width) ? Math.max(1, Math.floor(width)) : 1;
271
+ const t = this.theme;
272
+ const fit = (line: string) => truncateToWidth(line, w, "");
273
+ const lines: string[] = [];
367
274
 
368
- function render(rawWidth: number): string[] {
369
- const width = normalizeWidth(rawWidth);
370
- const cached = lineCache.get(width);
371
- if (cached) return cached;
372
-
373
- const lines: string[] = [];
374
- // Never exceed the terminal width — pi-tui throws on over-wide lines.
375
- // On very narrow terminals the box border is dropped so the little
376
- // width available all goes to content.
377
- const innerWidth = adaptiveInnerWidth(width);
378
- const bordered = shouldRenderBorder(width);
379
- const border = (s: string) => theme.fg("accent", s);
380
-
381
- function padVisible(content: string, targetWidth: number): string {
382
- const vw = visibleWidth(content);
383
- return content + safeRepeat(" ", targetWidth - vw);
384
- }
385
-
386
- const frame = (content: string) => {
387
- const body = padVisible(truncateToWidth(content, innerWidth), innerWidth);
388
- return bordered ? border("│") + body + border("│") : body;
389
- };
390
-
391
- const add = (s: string) => lines.push(frame(s));
392
- const addWrapped = (s: string) => {
393
- for (const line of wrapTextWithAnsi(s, innerWidth)) {
394
- lines.push(frame(line));
275
+ const chips = this.questions.map((_, i) => this.chip(i)).join(t.fg("dim", " · "));
276
+ const head = `${t.fg("borderMuted", "──")} ${chips} `;
277
+ lines.push(fit(head + t.fg("borderMuted", "─".repeat(Math.max(0, w - visibleWidth(head))))));
278
+
279
+ for (const l of wrapTextWithAnsi(this.question.question, Math.max(1, w - 4))) lines.push(fit(` ${l}`));
280
+
281
+ const s = this.state;
282
+ const other = this.onOther();
283
+ const multi = this.question.multi_select === true;
284
+ const hl = (text: string) => t.bg("selectedBg", text);
285
+ this.question.options.forEach((opt, i) => {
286
+ const at = s.cursor === i;
287
+ const picked = s.picked.has(optionValue(opt));
288
+ const marker = multi
289
+ ? picked ? t.fg("accent", "■") : t.fg("dim", "□")
290
+ : at ? t.bold(t.fg("accent", "❭")) : " ";
291
+ const num = other ? "" : `${t.fg("dim", String(i + 1))} `;
292
+ const label = at ? t.bold(t.fg("accent", opt.label)) : picked ? t.bold(opt.label) : opt.label;
293
+ const row = ` ${marker} ${num}${label}`;
294
+ lines.push(fit(at ? hl(row) : row));
295
+ if (opt.description) {
296
+ const indent = other ? " " : " ";
297
+ for (const d of wrapTextWithAnsi(opt.description, Math.max(1, w - indent.length - 1))) {
298
+ const desc = `${indent}${t.fg("muted", d)}`;
299
+ lines.push(fit(at ? hl(desc) : desc));
395
300
  }
396
- };
397
- const addEmpty = () => lines.push(frame(""));
398
-
399
- // Top border
400
- if (bordered) lines.push(border(`╭${safeRepeat("─", innerWidth)}╮`));
401
-
402
- // Context
403
- if (context) {
404
- addWrapped(theme.fg("muted", ` ${context}`));
405
- addEmpty();
406
301
  }
407
-
408
- // Question
409
- addWrapped(theme.fg("text", ` ${question}`));
410
- addEmpty();
411
-
412
- // Options (editor is now inline with freeform option)
413
- renderOptions(lines, add, theme, innerWidth);
414
-
415
- // Timeout countdown
416
- if (timeout && remainingMs !== undefined && remainingMs > 0) {
417
- addEmpty();
418
- const secs = Math.ceil(remainingMs / 1000);
419
- add(theme.fg("dim", ` ⏱ ${secs}s remaining`));
420
- }
421
-
422
- addEmpty();
423
- if (editMode) {
424
- add(theme.fg("dim", " Enter to confirm text • Esc to cancel text input"));
425
- } else if (allowMultiple) {
426
- const currentOpt = displayOptions[optionIndex];
427
- const action = currentOpt ? getAction(currentOpt) : "select";
428
- const base = " ↑↓ navigate • Space toggle • Enter submit • Esc cancel";
429
- let hint = "";
430
- if (action === "input" && !optionCustomTexts.get(currentOpt.value)) {
431
- hint = " • Space to add note";
432
- } else if (action === "end_turn") {
433
- hint = " • Space to end turn";
434
- } else if (action === "new_session") {
435
- hint = " • Space to start new session";
436
- }
437
- add(theme.fg("dim", base + hint));
302
+ });
303
+
304
+ if (this.hasOther()) {
305
+ const text = this.otherText();
306
+ const n = this.otherIndex() + 1;
307
+ if (other) {
308
+ lines.push(fit(hl(` ${t.bold(t.fg("accent", "❭"))} ${t.bold(t.fg("accent", OTHER_LABEL))}`)));
309
+ const field = s.input.render(Math.max(4, w - 6))[0] ?? "";
310
+ lines.push(fit(` ${t.fg("accent", "└")} ${field}`));
311
+ } else if (text) {
312
+ lines.push(fit(` ${multi ? t.fg("accent", "■") : " "} ${t.fg("dim", String(n))} ${OTHER_LABEL}`));
313
+ lines.push(fit(` ${t.fg("dim", "└")} ${t.fg("muted", text)}`));
438
314
  } else {
439
- const currentOpt = displayOptions[optionIndex];
440
- const action = currentOpt ? getAction(currentOpt) : "select";
441
- if (action === "input" && !optionCustomTexts.get(currentOpt.value)) {
442
- add(theme.fg("dim", " ↑↓ navigate • Enter to add note • Esc cancel"));
443
- } else if (action === "end_turn") {
444
- add(theme.fg("dim", " ↑↓ navigate • Enter to end turn • Esc cancel"));
445
- } else if (action === "new_session") {
446
- add(theme.fg("dim", " ↑↓ navigate • Enter to start new session • Esc cancel"));
447
- } else {
448
- add(theme.fg("dim", " ↑↓ navigate • Enter select • Esc cancel"));
449
- }
315
+ lines.push(fit(` ${multi ? t.fg("dim", "□") : " "} ${OTHER_LABEL}`));
450
316
  }
451
-
452
- // Bottom border
453
- if (bordered) lines.push(border(`╰${safeRepeat("─", innerWidth)}╯`));
454
-
455
- return lineCache.set(width, lines);
456
- }
457
-
458
- function renderOptions(
459
- lines: string[],
460
- add: (s: string) => void,
461
- theme: Theme,
462
- width: number,
463
- ) {
464
- const addWrappedOptionLine = (prefix: string, content: string) => {
465
- const prefixWidth = visibleWidth(prefix);
466
- const wrapWidth = contentWidth(width, prefixWidth);
467
- const continuationPrefix = safeRepeat(" ", prefixWidth);
468
- const wrapped = wrapTextWithAnsi(content, wrapWidth);
469
- for (let lineIndex = 0; lineIndex < wrapped.length; lineIndex++) {
470
- add((lineIndex === 0 ? prefix : continuationPrefix) + wrapped[lineIndex]);
471
- }
472
- };
473
-
474
- // Indent descriptions under the option label, but give up the indent
475
- // entirely when the terminal is too narrow to afford it.
476
- const descriptionIndent = safeRepeat(" ", width > 10 ? 5 : 0);
477
- const addWrappedDescription = (description: string) => {
478
- addWrappedOptionLine(descriptionIndent, theme.fg("muted", description));
479
- };
480
-
481
- // Inline editor, inset by 3 columns where there is room for it.
482
- const editorIndent = safeRepeat(" ", width > 8 ? 3 : 0);
483
- const addInlineEditor = () => {
484
- add(`${editorIndent}${theme.fg("muted", "Type your response:")}`);
485
- for (const line of editor.render(contentWidth(width, visibleWidth(editorIndent) + 1))) {
486
- add(`${editorIndent}${line}`);
487
- }
488
- };
489
-
490
- for (let i = 0; i < displayOptions.length; i++) {
491
- const opt = displayOptions[i];
492
- const isSelected = i === optionIndex;
493
- const prefix = isSelected ? theme.fg("accent", "> ") : " ";
494
-
495
- if (opt.isFreeform) {
496
- // Freeform option: show checkbox like regular option
497
- const checked = selected.has(opt.value);
498
- const box = checked ? "✓" : " ";
499
- const color = checked ? "success" : isSelected ? "accent" : "text";
500
-
501
- let label = opt.label;
502
- if (checked && customText) {
503
- // Show custom text next to label
504
- label = `${opt.label}: "${customText}"`;
505
- }
506
-
507
- addWrappedOptionLine(
508
- prefix + theme.fg(color, `[${box}]`) + " ",
509
- theme.fg(isSelected ? "accent" : "text", label),
510
- );
511
-
512
- // Show edit indicator if in edit mode for this option
513
- if (editMode && editTarget === "freeform" && isSelected) {
514
- addInlineEditor();
515
- }
516
- } else if (allowMultiple) {
517
- // Multi-select: show checkbox
518
- const checked = selected.has(opt.value);
519
- const box = checked ? "✓" : " ";
520
- const color = checked ? "success" : isSelected ? "accent" : "text";
521
-
522
- let label = opt.label;
523
- const optCustom = optionCustomTexts.get(opt.value);
524
- if (optCustom) {
525
- label = `${opt.label}: "${optCustom}"`;
526
- }
527
-
528
- addWrappedOptionLine(
529
- prefix + theme.fg(color, `[${box}]`) + " ",
530
- theme.fg(isSelected ? "accent" : "text", label),
531
- );
532
-
533
- // Show edit indicator if in edit mode for this option
534
- if (editMode && editTarget === i && isSelected) {
535
- addInlineEditor();
536
- }
537
- } else {
538
- // Single-select: option
539
- let label = opt.label;
540
- const optCustom = optionCustomTexts.get(opt.value);
541
- if (optCustom) {
542
- label = `${opt.label}: "${optCustom}"`;
543
- }
544
-
545
- // Show action indicator
546
- const action = getAction(opt);
547
- if (action === "input" && !optCustom) {
548
- label += theme.fg("dim", " (add note)");
549
- } else if (action === "end_turn") {
550
- label += theme.fg("dim", " ↵");
551
- } else if (action === "new_session") {
552
- label += theme.fg("dim", " ↗");
553
- }
554
-
555
- addWrappedOptionLine(
556
- prefix,
557
- isSelected
558
- ? theme.fg("accent", label)
559
- : theme.fg("text", label),
560
- );
561
-
562
- // Show edit indicator if in edit mode for this option
563
- if (editMode && editTarget === i && isSelected) {
564
- addInlineEditor();
565
- }
566
- }
567
-
568
- // Description
569
- if (opt.description) {
570
- addWrappedDescription(opt.description);
571
- }
572
- }
573
- }
574
-
575
- return {
576
- render,
577
- invalidate: () => {
578
- lineCache.clear();
579
- },
580
- handleInput,
581
- };
582
- };
583
- }
584
-
585
- /**
586
- * Create a renderCall function for the ask_user tool.
587
- */
588
- export function createRenderCall() {
589
- return (args: Record<string, unknown>, theme: Theme, _context: unknown) => {
590
- const question = (args.question as string) || "";
591
- const context = (args.context as string | undefined) || "";
592
- const options = Array.isArray(args.options)
593
- ? (args.options as Array<Record<string, unknown>>)
594
- : [];
595
- const mode = args.allowMultiple ? "multi-select" : "single-select";
596
- const allowFreeform = args.allowFreeform !== false;
597
- const count = options.length;
598
-
599
- const lines: string[] = [];
600
- lines.push(
601
- theme.fg("toolTitle", theme.bold("ask_user")) +
602
- theme.fg("dim", ` (${count} option${count !== 1 ? "s" : ""}, ${mode}${allowFreeform ? ", freeform" : ""})`),
603
- );
604
- if (context) {
605
- lines.push(theme.fg("muted", "Context: ") + theme.fg("text", context));
606
- }
607
- lines.push(theme.fg("muted", "Question: ") + theme.fg("text", question));
608
-
609
- if (count > 0) {
610
- lines.push(theme.fg("muted", "Options:"));
611
- options.forEach((option, index) => {
612
- const label = String(option.label ?? option.value ?? `Option ${index + 1}`);
613
- const action = typeof option.action === "string" && option.action !== "select"
614
- ? theme.fg("dim", ` [${option.action}]`)
615
- : "";
616
- lines.push(
617
- theme.fg("dim", ` ${index + 1}. `) +
618
- theme.fg("text", label) +
619
- action,
620
- );
621
- if (typeof option.description === "string" && option.description.trim()) {
622
- lines.push(theme.fg("muted", ` ${option.description}`));
623
- }
624
- if (typeof option.prefill === "string" && option.prefill.trim()) {
625
- lines.push(theme.fg("dim", ` prefill: ${option.prefill}`));
626
- }
627
- });
317
+ const used = stillReferenced(text, this.attachments);
318
+ for (const a of used) lines.push(fit(` ${t.fg("accent", tokenFor(a))} ${t.fg("dim", `${a.name} · ${formatBytes(a.bytes)}`)}`));
628
319
  }
629
320
 
630
- return new Text(lines.join("\n"), 0, 0);
631
- };
632
- }
321
+ lines.push(t.fg("borderMuted", "─".repeat(w)));
322
+ if (this.note) lines.push(fit(t.fg("warning", this.note)));
323
+ const esc = this.options.escape === "send" ? "esc send" : "esc cancel";
324
+ const keys = ["↑↓ navigate", ...(multi && !other ? ["␣ toggle"] : []), "↵ select", ...(other ? ["ctrl+v image"] : []), ...(this.questions.length > 1 ? ["←→ switch question"] : []), ...(other ? [] : ["? help me out"]), esc];
325
+ lines.push(fit(t.fg("dim", keys.join(" · "))));
326
+ if (this.options.helpLine) lines.push(fit(t.fg("warning", "? Not ready to answer, help me out!")));
327
+ return lines;
328
+ }
633
329
 
634
- /**
635
- * Create a renderResult function for the ask_user tool.
636
- */
637
- export function createRenderResult() {
638
- return (result: AgentToolResult<unknown>, options: unknown, theme: Theme, _context: unknown) => {
639
- const details = result.details as Record<string, unknown> | undefined;
640
- if (!details) {
641
- const content = result.content as unknown as Array<Record<string, unknown>> | undefined;
642
- const text = content?.[0];
643
- return new Text(text?.type === "text" ? (text.text as string) : "", 0, 0);
644
- }
645
-
646
- const response = (details as { response: AskUserResponse }).response;
647
- if (!response) {
648
- return new Text(theme.fg("warning", "No response"), 0, 0);
649
- }
650
-
651
- const renderOptionSummary = (): string[] => {
652
- const rawOptions = (details as { options?: unknown }).options;
653
- if (!Array.isArray(rawOptions) || rawOptions.length === 0) return [];
654
- return rawOptions.map((opt, index) => {
655
- if (typeof opt === "string") {
656
- return theme.fg("dim", ` ${index + 1}. `) + theme.fg("text", opt);
657
- }
658
- const record = opt as Record<string, unknown>;
659
- const label = String(record.label ?? record.value ?? `Option ${index + 1}`);
660
- const value = typeof record.value === "string" && record.value !== label
661
- ? theme.fg("dim", ` (${record.value})`)
662
- : "";
663
- const action = typeof record.action === "string" && record.action !== "select"
664
- ? theme.fg("dim", ` [${record.action}]`)
665
- : "";
666
- const description = typeof record.description === "string" && record.description.trim()
667
- ? `\n${theme.fg("muted", ` ${record.description}`)}`
668
- : "";
669
- return theme.fg("dim", ` ${index + 1}. `) + theme.fg("text", label) + value + action + description;
670
- });
671
- };
672
-
673
- const answerText = (() => {
674
- switch (response.kind) {
675
- case "cancelled":
676
- return theme.fg("warning", "Cancelled");
677
- case "timed_out":
678
- return theme.fg("warning", "Timed out");
679
- case "freeform":
680
- return theme.fg("success", "✓ ") +
681
- theme.fg("muted", "(wrote) ") +
682
- theme.fg("accent", response.text || "");
683
- case "selection": {
684
- const selections = response.selections || [];
685
- const display = selections.length === 1 ? selections[0] : selections.join(", ");
686
- return theme.fg("success", "✓ ") + theme.fg("accent", display);
687
- }
688
- case "combined": {
689
- const selections = response.selections || [];
690
- const selDisplay = selections.length === 1
691
- ? selections[0]
692
- : selections.join(", ");
693
- return theme.fg("success", "✓ ") +
694
- theme.fg("accent", selDisplay) +
695
- theme.fg("muted", " and wrote ") +
696
- theme.fg("accent", response.text || "");
697
- }
698
- case "end_turn":
699
- return theme.fg("success", "✓ ") + theme.fg("muted", "end turn");
700
- case "new_session": {
701
- const prefill = response.prefill || "";
702
- if (response.launchStatus === "editor_prefill") {
703
- const label = response.launchedWith === "compact"
704
- ? "⚠ compact editor prefill → "
705
- : "⚠ direct editor prefill → ";
706
- return theme.fg("warning", label) + theme.fg("accent", prefill);
707
- }
708
- if (response.launchStatus === "failed") {
709
- const label = response.launchedWith === "compact"
710
- ? "handoff failed (compact) → "
711
- : "handoff failed (direct) → ";
712
- return theme.fg("error", label) + theme.fg("accent", prefill);
713
- }
714
- if (response.launchedWith === "compact") {
715
- return theme.fg("success", "✓ queued compact → ") + theme.fg("accent", prefill);
716
- }
717
- if (response.launchedWith === "direct") {
718
- return theme.fg("success", "✓ queued direct → ") + theme.fg("accent", prefill);
719
- }
720
- return theme.fg("success", "✓ ") +
721
- theme.fg("muted", "new session") +
722
- (prefill ? theme.fg("accent", `: ${prefill}`) : "");
723
- }
724
- default:
725
- return theme.fg("text", JSON.stringify(response));
726
- }
727
- })();
728
-
729
- const expanded = typeof options === "object" && options !== null && "expanded" in options
730
- ? Boolean((options as { expanded?: boolean }).expanded)
731
- : false;
732
- if (!expanded) {
733
- const question = typeof (details as { question?: unknown }).question === "string"
734
- ? (details as { question: string }).question
735
- : "";
736
- const expandHint = question
737
- ? theme.fg("dim", " · Ctrl+O question/options")
738
- : "";
739
- return new Text(answerText + expandHint, 0, 0);
740
- }
741
-
742
- const lines = [answerText];
743
- const question = (details as { question?: unknown }).question;
744
- const context = (details as { context?: unknown }).context;
745
- if (typeof question === "string" && question.trim()) {
746
- lines.push(theme.fg("muted", "Question: ") + theme.fg("text", question));
747
- }
748
- if (typeof context === "string" && context.trim()) {
749
- lines.push(theme.fg("muted", "Context: ") + theme.fg("text", context));
750
- }
751
- const optionLines = renderOptionSummary();
752
- if (optionLines.length > 0) {
753
- lines.push(theme.fg("muted", "Options:"), ...optionLines);
754
- }
755
- return new Text(lines.join("\n"), 0, 0);
756
- };
330
+ dispose(): void {}
757
331
  }