@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,724 @@
1
+ /**
2
+ * @pi-unipi/utility — "simple" render style: MiniMax Code (mcode) transcript.
3
+ *
4
+ * mcode collapses tool calls into tight one-line rows with a dim `├`/`└` tree
5
+ * gutter, and re-renders runs of read-like calls into a single summary row
6
+ * ("Read 3 files", "Explored 4 operations"). This module reproduces that:
7
+ *
8
+ * - every tool is wrapped so its collapsed view is an mcode row (Ctrl+O
9
+ * falls back to the tool's own renderer; execute/schema untouched);
10
+ * - consecutive read-like calls between two pieces of assistant text form a
11
+ * group that collapses to the mcode summary while/after it runs;
12
+ * - the latest live call is the only row that shows running state.
13
+ *
14
+ * mcode reference: minimax-code packages/tui/src/tui/transcript/{view,tool-definitions}.ts
15
+ * (verbs, connectors, read-group rules at view.ts:1171-1223, "· N output lines").
16
+ */
17
+
18
+ import { homedir } from "node:os";
19
+ import { isAbsolute, relative } from "node:path";
20
+ import type {
21
+ AgentToolResult,
22
+ Theme,
23
+ ToolDefinition,
24
+ ToolRenderResultOptions,
25
+ } from "@earendil-works/pi-coding-agent";
26
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
27
+ import { installReplyBackground, isAssistant } from "./reply-bg.js";
28
+ import { findTranscriptContainer, patchTranscriptSpacing } from "./spacing.js";
29
+
30
+ type AnyTool = ToolDefinition<any, any, any>;
31
+
32
+ // ─── verbs (mcode tool-definitions.ts) ────────────────────────────────────
33
+
34
+ /** [running, completed, failed] verb triple per tool name. */
35
+ const VERBS: Record<string, [string, string, string]> = {
36
+ bash: ["Running", "Ran", "Command failed"],
37
+ powershell: ["Running", "Ran", "Command failed"],
38
+ read: ["Reading", "Read", "Read failed"],
39
+ edit: ["Editing", "Edited", "Edit failed"],
40
+ write: ["Writing", "Wrote", "Write failed"],
41
+ grep: ["Searching", "Searched", "Search failed"],
42
+ find: ["Listing", "Listed", "List failed"],
43
+ glob: ["Listing", "Listed", "List failed"],
44
+ ls: ["Listing", "Listed", "List failed"],
45
+ list: ["Listing", "Listed", "List failed"],
46
+ web_search: ["Using WebSearch", "Used WebSearch", "WebSearch failed"],
47
+ web_fetch: ["Using WebFetch", "Used WebFetch", "WebFetch failed"],
48
+ task: ["Delegating", "Delegated", "Delegation failed"],
49
+ delegate: ["Delegating", "Delegated", "Delegation failed"],
50
+ spawn_agent: ["Delegating", "Delegated", "Delegation failed"],
51
+ run_subagent: ["Delegating", "Delegated", "Delegation failed"],
52
+ ask_user: ["Asking user", "Asked user", "Ask failed"],
53
+ };
54
+
55
+ /** mcode's fallback for unknown tools: `get_goal` → `Get Goal`. */
56
+ export function titleCaseTool(name: string): string {
57
+ return (name || "tool")
58
+ .trim()
59
+ .toLowerCase()
60
+ .split(/[-_]+/)
61
+ .filter(Boolean)
62
+ .map((w) => w[0]!.toUpperCase() + w.slice(1))
63
+ .join(" ");
64
+ }
65
+
66
+ export function verbsFor(name: string): [string, string, string] {
67
+ return VERBS[name.trim().toLowerCase()] ?? [titleCaseTool(name), titleCaseTool(name), `${titleCaseTool(name)} failed`];
68
+ }
69
+
70
+ // ─── target / meta (pure) ─────────────────────────────────────────────────
71
+
72
+ function shortPath(p: string, cwd: string): string {
73
+ if (!p) return "";
74
+ const abs = isAbsolute(p) ? p : `${cwd}/${p}`;
75
+ const rel = relative(cwd, abs);
76
+ if (!rel.startsWith("..") && !isAbsolute(rel)) return `./${rel}`;
77
+ const home = homedir();
78
+ return abs.startsWith(home) ? `~${abs.slice(home.length)}` : abs;
79
+ }
80
+
81
+ /** Args keys whose string value is a filesystem path (gets ~ / ./ shortening). */
82
+ const PATH_KEYS = ["path", "file_path", "file", "cwd", "dir", "directory", "notebook_path"];
83
+
84
+ /** The raw argument mcode would show in the parenthetical. */
85
+ function rawTargetArg(name: string, args: Record<string, unknown>, cwd: string): string {
86
+ if (name === "bash" || name === "powershell") return String(args.command ?? args.script ?? "");
87
+ if (name === "grep") {
88
+ const pattern = String(args.pattern ?? args.query ?? "");
89
+ const path = args.path ? ` in ${shortPath(String(args.path), cwd)}` : "";
90
+ return `${pattern}${path}`;
91
+ }
92
+ for (const key of PATH_KEYS) {
93
+ const v = args[key];
94
+ if (typeof v === "string" && v) return shortPath(v, cwd);
95
+ }
96
+ for (const key of ["query", "pattern", "url", "skill", "command", "title", "name", "id", "question"]) {
97
+ const v = args[key];
98
+ if (typeof v === "string" && v) return v;
99
+ }
100
+ for (const v of Object.values(args)) {
101
+ if (typeof v === "string" && v) return v;
102
+ if (v !== undefined && v !== null && typeof v !== "object") return String(v);
103
+ }
104
+ return "";
105
+ }
106
+
107
+ /** The single argument mcode would show in the parenthetical (always one line). */
108
+ export function targetArg(name: string, args: Record<string, unknown>, cwd: string): string {
109
+ const raw = rawTargetArg(name, args, cwd);
110
+ const first = raw.split("\n").find((l) => l.trim()) ?? "";
111
+ return first.trim() + (raw.trim().includes("\n") ? " …" : "");
112
+ }
113
+
114
+ export function outputMeta(result: AgentToolResult<any> | undefined): string {
115
+ const text = (result?.content ?? [])
116
+ .filter((c: any) => c.type === "text")
117
+ .map((c: any) => c.text ?? "")
118
+ .join("\n");
119
+ if (!text) return "";
120
+ const n = text.split("\n").length;
121
+ return ` · ${n} output line${n === 1 ? "" : "s"}`;
122
+ }
123
+
124
+ // ─── group registry (module-level shared state) ───────────────────────────
125
+
126
+ interface CallRec {
127
+ id: string;
128
+ name: string;
129
+ args: Record<string, unknown>;
130
+ cwd: string;
131
+ running: boolean;
132
+ done: boolean;
133
+ failed: boolean;
134
+ meta: string;
135
+ group: CallRec[];
136
+ invalidate?: () => void;
137
+ invalidating?: boolean;
138
+ /** Bumped whenever args change (render-cache key; args can be a whole file). */
139
+ rev?: number;
140
+ /** Set when the call's execution starts — drives the live `· Ns` row timer. */
141
+ startedAt?: number;
142
+ endedAt?: number;
143
+ /**
144
+ * Runtime pinned once at the final result (endedAt − startedAt). Kept out of
145
+ * `meta` on purpose: renderResult compares `meta` against the fresh bare
146
+ * outputMeta to detect change, and a duration baked into `meta` made that
147
+ * comparison true on every re-render, re-arming the invalidate microtask in
148
+ * a self-sustaining loop that OOM'd the process (alpha.18).
149
+ */
150
+ durationMs?: number;
151
+ /** Latest result details — the input a tool's `simpleMeta` hook reads. */
152
+ details?: unknown;
153
+ /** Live meta evaluated per paint (tool opt-in: live stats such as a
154
+ * subagent's tool-call count ticking under the row). */
155
+ metaFn?: () => string | undefined;
156
+ }
157
+
158
+ const byId = new Map<string, CallRec>();
159
+ let currentGroup: CallRec[] = [];
160
+ let textBreakPending = false;
161
+
162
+ /** Assistant text (or a new user turn) breaks the current tool group (mcode rule). */
163
+ export function noteGroupBreak(): void {
164
+ textBreakPending = true;
165
+ }
166
+
167
+ /**
168
+ * Wire the group-break signal to pi events: assistant text between tool calls
169
+ * and new user turns both start a fresh group (mcode groups consecutive tool
170
+ * cells; any text row breaks the run).
171
+ */
172
+ export function installSimpleGroupEvents(pi: {
173
+ on: (event: any, handler: (event: any, ctx?: any) => void) => void;
174
+ registerMarkdownTransformer?: (fn: (markdown: string, context: { messageType: string }) => string) => void;
175
+ }): void {
176
+ try {
177
+ // Groups follow the message's own block order: visible text before a tool
178
+ // call starts a new group at that call; thinking and tool-only messages
179
+ // never break. Extension events can land after pi already rendered the
180
+ // row, so a late signal re-splits the group (markBreakBefore).
181
+ pi.on("message_update", (event: any) => {
182
+ const msg = event?.message ?? event?.assistantMessageEvent?.partial;
183
+ if (msg?.role === "assistant") applyMessageOrder(msg);
184
+ });
185
+ pi.on("message_end", (event: any) => {
186
+ const msg = event?.message;
187
+ if (msg?.role === "assistant" && applyMessageOrder(msg)) noteGroupBreak();
188
+ });
189
+ pi.on("message_start", (event: any) => {
190
+ if (event?.message?.role === "user") noteGroupBreak();
191
+ });
192
+ } catch {
193
+ // grouping is cosmetic; never block load
194
+ }
195
+ hideThinking(pi);
196
+ installReplyBackground(pi, (tui) => {
197
+ try {
198
+ reconcileSimpleGroups(tui);
199
+ } catch {}
200
+ // The container is re-created on session resume — re-patch when found.
201
+ try {
202
+ const transcript = findTranscriptContainer(tui);
203
+ if (transcript !== undefined) patchTranscriptSpacing(transcript);
204
+ } catch {}
205
+ });
206
+ }
207
+
208
+ /**
209
+ * mcode anchors assistant prose with `● ` (view.ts:736 anchorAssistantLines).
210
+ * Through the markdown transformer we can only prefix the first line, and only
211
+ * when it's a plain paragraph — a heading/list/quote/table/fence would stop
212
+ * being markdown if we prefixed it, so those are left alone.
213
+ */
214
+ export function anchorAssistant(markdown: string): string {
215
+ const lead = markdown.match(/^\s*/)?.[0] ?? "";
216
+ const body = markdown.slice(lead.length);
217
+ if (!body || body.startsWith("● ")) return markdown;
218
+ if (/^(#{1,6}\s|[-*+]\s|>|\||```|~~~|\d+[.)]\s|<|---|\*\*\*|___)/.test(body)) return markdown;
219
+ return `${lead}● ${body}`;
220
+ }
221
+
222
+ /** Tool calls that must open a new group (text came right before them). */
223
+ const breakBefore = new Set<string>();
224
+
225
+ /**
226
+ * Walk an assistant message in block order; every tool call preceded by
227
+ * visible text opens a new group. Returns true when the message ends with
228
+ * visible text (the next tool call, in a later message, opens a new group).
229
+ */
230
+ export function applyMessageOrder(msg: { content?: unknown }): boolean {
231
+ let sawText = false;
232
+ for (const c of Array.isArray(msg.content) ? msg.content : []) {
233
+ if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) sawText = true;
234
+ else if (c?.type === "toolCall" && typeof c.id === "string") {
235
+ if (sawText) markBreakBefore(c.id);
236
+ sawText = false;
237
+ }
238
+ }
239
+ return sawText;
240
+ }
241
+
242
+ /** Start a new group at `id`; if its row already joined an older group, split it off. */
243
+ export function markBreakBefore(id: string): void {
244
+ if (breakBefore.has(id)) return;
245
+ breakBefore.add(id);
246
+ const rec = byId.get(id);
247
+ if (!rec) return;
248
+ const old = rec.group;
249
+ const at = old.indexOf(rec);
250
+ if (at <= 0) return;
251
+ const moved = old.splice(at);
252
+ for (const r of moved) r.group = moved;
253
+ if (currentGroup === old) currentGroup = moved;
254
+ for (const r of old) touch(r);
255
+ for (const r of moved) touch(r);
256
+ }
257
+
258
+ /** Visible assistant text (whitespace-only text blocks don't count). */
259
+ export function messageHasText(msg: { content?: unknown }): boolean {
260
+ return Array.isArray(msg.content) && msg.content.some((c: any) => c?.type === "text" && typeof c.text === "string" && c.text.trim());
261
+ }
262
+
263
+ // ─── replay reconciliation (resume / re-rendered history) ─────────────────
264
+
265
+ /** Duck-typed pi ToolExecutionComponent (a direct child of the chat container). */
266
+ function isToolExec(c: unknown): c is { toolCallId: string } {
267
+ const t = c as { toolCallId?: unknown; updateResult?: unknown } | null;
268
+ return !!t && typeof t === "object" && typeof t.toolCallId === "string" && typeof t.updateResult === "function";
269
+ }
270
+
271
+ /** User-message components: a Container carrying `.text` or a parsed `.skillBlock`. */
272
+ function isUserMessage(c: unknown): boolean {
273
+ const t = c as { text?: unknown; children?: unknown; skillBlock?: unknown } | null;
274
+ return !!t && typeof t === "object" && Array.isArray(t.children) && (typeof t.text === "string" || "skillBlock" in t);
275
+ }
276
+
277
+ /** The chat container = the outermost node whose direct children include tool executions. */
278
+ function findGroupHost(root: unknown, depth = 0): unknown[] | undefined {
279
+ if (!root || typeof root !== "object" || depth > 14) return undefined;
280
+ const children = (root as { children?: unknown }).children;
281
+ if (!Array.isArray(children)) return undefined;
282
+ if (children.some(isToolExec)) return children;
283
+ for (const child of children) {
284
+ const hit = findGroupHost(child, depth + 1);
285
+ if (hit) return hit;
286
+ }
287
+ return undefined;
288
+ }
289
+
290
+ /**
291
+ * Rebuild group membership from the rendered transcript order. Resumed and
292
+ * re-rendered sessions (renderSessionItems, boundary-compaction re-renders)
293
+ * construct the tool components without firing message_* events, so no group
294
+ * breaks ever happen: every call lands in one group and only the last
295
+ * component paints — a wall of tool rows with all the text elsewhere. Walking
296
+ * the chat container's children in order reproduces the live rule: a call
297
+ * preceded by visible text (its message's own block order → breakBefore) or a
298
+ * user message starts a fresh group. Idempotent: when the tree already agrees
299
+ * with the groups, no rec moves and nothing repaints.
300
+ */
301
+ export function reconcileSimpleGroups(root: unknown): void {
302
+ const children = findGroupHost(root);
303
+ if (!children) return;
304
+ let group: CallRec[] = [];
305
+ let breakNext = false;
306
+ for (const child of children) {
307
+ if (isAssistant(child)) {
308
+ if (applyMessageOrder((child.lastMessage ?? {}) as { content?: unknown })) breakNext = true;
309
+ continue;
310
+ }
311
+ if (!isToolExec(child)) {
312
+ if (isUserMessage(child)) breakNext = true;
313
+ continue;
314
+ }
315
+ const rec = byId.get(child.toolCallId);
316
+ const brk = breakNext || breakBefore.has(child.toolCallId);
317
+ breakNext = false;
318
+ if (!rec) continue;
319
+ if (brk) group = [];
320
+ if (rec.group !== group) {
321
+ const old = rec.group;
322
+ const at = old.indexOf(rec);
323
+ if (at >= 0) old.splice(at, 1);
324
+ rec.group = group;
325
+ for (const r of old) touch(r);
326
+ touch(rec);
327
+ }
328
+ if (!group.includes(rec)) group.push(rec);
329
+ }
330
+ currentGroup = group;
331
+ }
332
+
333
+ /**
334
+ * Simple mode shows no thinking at all, via public API only:
335
+ * - collapsed thinking (pi `hideThinkingBlock`, Ctrl+T) renders its label as a
336
+ * Text — an empty label renders zero lines (pi-tui Text.render);
337
+ * - expanded thinking goes through the "assistant-thinking" markdown
338
+ * transform — returning "" makes the Markdown render zero lines.
339
+ */
340
+ function hideThinking(pi: {
341
+ on: (event: any, handler: (event: any, ctx?: any) => void) => void;
342
+ registerMarkdownTransformer?: (fn: (markdown: string, context: { messageType: string }) => string) => void;
343
+ }): void {
344
+ try {
345
+ pi.registerMarkdownTransformer?.((markdown, context) => {
346
+ if (context?.messageType === "assistant-thinking") return "";
347
+ if (context?.messageType === "assistant") return anchorAssistant(markdown);
348
+ return markdown;
349
+ });
350
+ } catch {}
351
+ try {
352
+ pi.on("session_start", (_event: any, ctx: any) => {
353
+ try {
354
+ ctx?.ui?.setHiddenThinkingLabel?.("");
355
+ } catch {}
356
+ });
357
+ } catch {}
358
+ }
359
+
360
+ function touch(rec: CallRec): void {
361
+ if (rec.invalidating) return;
362
+ rec.invalidating = true;
363
+ // Async: invalidate() synchronously re-enters updateDisplay → renderers;
364
+ // doing that from inside a renderer recurses to stack overflow.
365
+ queueMicrotask(() => {
366
+ rec.invalidating = false;
367
+ try {
368
+ rec.invalidate?.();
369
+ } catch {}
370
+ });
371
+ }
372
+
373
+ // ─── pure group planner (mcode view.ts:373-497, 1171-1223) ────────────────
374
+
375
+ export interface GroupCall {
376
+ id: string;
377
+ name: string;
378
+ target: string;
379
+ meta: string;
380
+ failed: boolean;
381
+ running: boolean;
382
+ /** Elapsed label (`12s`) shown on the running row instead of the `…` tail. */
383
+ tick?: string;
384
+ }
385
+
386
+ type Category = "read" | "search" | "list";
387
+
388
+ function categoryOf(name: string): Category {
389
+ const n = name.trim().toLowerCase();
390
+ if (n === "grep" || n === "search") return "search";
391
+ if (n === "glob" || n === "list" || n === "list_files" || n === "ls" || n === "find") return "list";
392
+ return "read";
393
+ }
394
+
395
+ /** mcode's read-like tools (view.ts:1171 isReadToolCell), plus our ls/find aliases. */
396
+ export function isReadLike(name: string): boolean {
397
+ const n = name.trim().toLowerCase();
398
+ return n === "read" || n === "read_file" || n === "readfile" || n === "grep" || n === "search" ||
399
+ n === "glob" || n === "list" || n === "list_files" || n === "ls" || n === "find";
400
+ }
401
+
402
+ export interface RowFnArgs {
403
+ connector: "├" | "└";
404
+ running: boolean;
405
+ failed: boolean;
406
+ }
407
+ export type RowFn = (call: GroupCall, opts: RowFnArgs) => string;
408
+ export type SummaryFn = (args: {
409
+ connector: "├" | "└";
410
+ running: boolean;
411
+ failedCount: number;
412
+ total: number;
413
+ opCount: number;
414
+ actionRunning: string;
415
+ actionDone: string;
416
+ noun: string;
417
+ }) => string;
418
+
419
+ /**
420
+ * mcode collapse rules (view.ts renderReadGroup/groupNoun/renderGroupAction):
421
+ * - consecutive read-like calls (≥2) collapse to one summary row:
422
+ * "Read N files" / "Searched N searches" / "Listed N paths"; mixed
423
+ * categories → "Explored N operations"; ` · N failed` on partial failure;
424
+ * - while the group is running, the summary shows finished calls so far and
425
+ * the live latest call keeps its own row below it;
426
+ * - every other call (bash, edits, failures of non-read tools…) is its own row;
427
+ * - connectors: `├` for every row except the group's last, which gets `└`.
428
+ * Returns one entry per call; `row: null` = render nothing (hidden inside the summary).
429
+ */
430
+ export function planGroupRows(calls: GroupCall[], row: RowFn, summary: SummaryFn): Array<{ id: string; row: string | null }> {
431
+ const units: GroupCall[][] = [];
432
+ let run: GroupCall[] = [];
433
+ for (const c of calls) {
434
+ if (isReadLike(c.name)) {
435
+ run.push(c);
436
+ continue;
437
+ }
438
+ if (run.length >= 2) units.push(run);
439
+ else if (run.length === 1) units.push([run[0]!]);
440
+ run = [];
441
+ units.push([c]);
442
+ }
443
+ if (run.length >= 2) units.push(run);
444
+ else if (run.length === 1) units.push([run[0]!]);
445
+
446
+ const out: Array<{ id: string; row: string | null }> = [];
447
+ units.forEach((unit, i) => {
448
+ const anyRunning = unit.some((c) => c.running);
449
+ const isLast = i === units.length - 1;
450
+ const collapsed = unit.length >= 2;
451
+ // a running group appends the live row after its summary, so the summary
452
+ // is never the visually-last row while the group is active
453
+ const connector: "├" | "└" = isLast && !(collapsed && anyRunning) ? "└" : "├";
454
+ if (unit.length < 2) {
455
+ out.push({ id: unit[0]!.id, row: row(unit[0]!, { connector, running: unit[0]!.running, failed: unit[0]!.failed }) });
456
+ return;
457
+ }
458
+ const failedCount = unit.filter((c) => c.failed).length;
459
+ const cats = new Set(unit.map((c) => categoryOf(c.name)));
460
+ const opKeys = new Set(unit.map((c) => `${c.name}\u0000${c.target}`));
461
+ const opCount = opKeys.size;
462
+ const noun = cats.size > 1 ? "operations" : cats.has("search") ? "searches" : cats.has("list") ? "paths" : "files";
463
+ const actionRunning = cats.size > 1 ? "Exploring" : cats.has("search") ? "Searching" : cats.has("list") ? "Listing" : "Reading";
464
+ const actionDone = cats.size > 1 ? "Explored" : cats.has("search") ? "Searched" : cats.has("list") ? "Listed" : "Read";
465
+ out.push({
466
+ id: unit[0]!.id,
467
+ row: summary({
468
+ connector,
469
+ running: anyRunning,
470
+ failedCount,
471
+ total: unit.length,
472
+ opCount,
473
+ actionRunning,
474
+ actionDone,
475
+ noun,
476
+ }),
477
+ });
478
+ const hiddenCount = anyRunning ? unit.length - 1 : unit.length - 1;
479
+ for (const hidden of unit.slice(1, anyRunning ? -1 : undefined)) out.push({ id: hidden.id, row: null });
480
+ void hiddenCount;
481
+ if (anyRunning) {
482
+ const live = unit[unit.length - 1]!;
483
+ out.push({ id: live.id, row: row(live, { connector: "└", running: true, failed: false }) });
484
+ }
485
+ });
486
+ return out;
487
+ }
488
+
489
+ // ─── row painters ─────────────────────────────────────────────────────────
490
+
491
+ function marker(theme: Theme, running: boolean, failed: boolean): string {
492
+ return theme.fg(failed ? "error" : running ? "accent" : "success", failed ? "×" : "•");
493
+ }
494
+
495
+ export function simpleToolLine(
496
+ theme: Theme,
497
+ name: string,
498
+ target: string,
499
+ opts: { running: boolean; failed?: boolean; meta?: string; tick?: string; connector?: "├" | "└"; width: number },
500
+ ): string {
501
+ const [runningVerb, doneVerb, failedVerb] = verbsFor(name);
502
+ const verb = opts.failed ? failedVerb : opts.running ? runningVerb : doneVerb;
503
+ const connector = theme.fg("borderMuted", `${opts.connector ?? "└"} `);
504
+ const isShell = name === "bash" || name === "powershell";
505
+ const tail = opts.running
506
+ ? theme.fg("muted", opts.tick ? ` · ${opts.tick}` : opts.meta ? opts.meta : " …")
507
+ : opts.meta ? theme.fg(opts.failed ? "error" : "muted", opts.meta) : "";
508
+ let line: string;
509
+ if (!target) line = `${marker(theme, opts.running, !!opts.failed)} ${theme.bold(verb)}`;
510
+ else if (isShell) line = `${marker(theme, opts.running, !!opts.failed)} ${theme.bold(verb)} ${target}`;
511
+ else line = `${marker(theme, opts.running, !!opts.failed)} ${theme.bold(verb)} ${theme.fg("muted", `(${target})`)}`;
512
+ return truncateToWidth(`${connector}${line}${tail}`, Math.max(0, opts.width), theme.fg("muted", "…"));
513
+ }
514
+
515
+ /** One-line component (mcode rows never wrap; they truncate). */
516
+ /** pi pads assistant prose by 1 column (outputPad); mcode aligns tool rails with the `●` anchor. */
517
+ const ROW_INDENT = " ";
518
+
519
+ class SimpleLine implements Component {
520
+ constructor(private readonly build: (width: number) => string[]) {}
521
+ render(width: number): string[] {
522
+ const inner = Math.max(0, width - ROW_INDENT.length);
523
+ return this.build(inner).map((l) => `${ROW_INDENT}${truncateToWidth(l, inner, "…")}`);
524
+ }
525
+ invalidate(): void {}
526
+ }
527
+
528
+ /** Definitions already wearing the mcode wrapper (double-wrap guard). */
529
+ export const simpleWrapped = new WeakSet<object>();
530
+
531
+ /** Test hook: clear the module-level group registry. */
532
+ export function resetSimpleGroups(): void {
533
+ byId.clear();
534
+ breakBefore.clear();
535
+ currentGroup = [];
536
+ textBreakPending = false;
537
+ }
538
+
539
+ // ─── the wrapper ──────────────────────────────────────────────────────────
540
+
541
+ /**
542
+ * Display meta for a finished row: the bare outputMeta plus the pinned `· Ns`
543
+ * runtime. Appended here (paint time) instead of being stored in `meta`, so
544
+ * the renderResult change-detection never sees the suffix.
545
+ */
546
+ function pinnedMeta(rec: CallRec): string {
547
+ return !rec.running && rec.durationMs !== undefined ? `${rec.meta} · ${formatSeconds(rec.durationMs)}` : rec.meta;
548
+ }
549
+
550
+ function toGroupCall(rec: CallRec): GroupCall {
551
+ const liveMeta = rec.metaFn?.();
552
+ const meta = liveMeta ?? pinnedMeta(rec);
553
+ const tick = rec.running && rec.startedAt !== undefined && liveMeta === undefined ? formatSeconds(Date.now() - rec.startedAt) : undefined;
554
+ return { id: rec.id, name: rec.name, target: targetArg(rec.name, rec.args, rec.cwd), meta, failed: rec.failed, running: rec.running, tick };
555
+ }
556
+
557
+ /** The live row timer: whole seconds, `12s` / `1m05s`. */
558
+ export function formatSeconds(ms: number): string {
559
+ const s = Math.max(0, Math.floor(ms / 1000));
560
+ if (s < 60) return `${String(s)}s`;
561
+ return `${String(Math.floor(s / 60))}m${String(s % 60).padStart(2, "0")}s`;
562
+ }
563
+
564
+ export function simpleWrapTool(def: AnyTool): AnyTool {
565
+ const renderCall = (args: any, theme: Theme, ctx: any) => {
566
+ if (ctx.expanded && def.renderCall) return def.renderCall(args, theme, ctx);
567
+ let rec = byId.get(ctx.toolCallId);
568
+ if (!rec) {
569
+ if (textBreakPending || (breakBefore.has(ctx.toolCallId) && currentGroup.length > 0)) {
570
+ currentGroup = [];
571
+ textBreakPending = false;
572
+ }
573
+ rec = { id: ctx.toolCallId, name: def.name, args: args ?? {}, cwd: ctx.cwd, running: true, done: false, failed: false, meta: "", group: currentGroup };
574
+ byId.set(ctx.toolCallId, rec);
575
+ currentGroup.push(rec);
576
+ for (const r of currentGroup) touch(r);
577
+ }
578
+ // Rebind every call: on history re-renders pi makes a fresh component for
579
+ // the same toolCallId, and invalidate() must reach the live one.
580
+ rec.invalidate = () => ctx.invalidate();
581
+ if (args && args !== rec.args) {
582
+ rec.args = args;
583
+ rec.rev = (rec.rev ?? 0) + 1;
584
+ }
585
+ // Live row timer: execution start stamps the row and a per-second tick
586
+ // repaints `· Ns` on it (same pattern pi's own bash renderer uses:
587
+ // interval on ctx.state, cleared when the final result lands).
588
+ if (ctx.executionStarted === true && rec.startedAt === undefined) {
589
+ rec.startedAt = Date.now();
590
+ const state = ctx.state as { timer?: ReturnType<typeof setInterval> } | undefined;
591
+ if (state !== undefined && state.timer === undefined) {
592
+ state.timer = setInterval(() => {
593
+ try {
594
+ ctx.invalidate();
595
+ } catch {}
596
+ }, 1000);
597
+ state.timer.unref?.();
598
+ }
599
+ }
600
+ let cache: { key: string; lines: string[] } | undefined;
601
+ const paint = (width: number): string[] => {
602
+ // pi prefixes every non-empty self-rendered tool with a blank line
603
+ // (tool-execution.js render: lines.push("")), so one row per component
604
+ // leaves gaps inside a group. Paint the whole group in its LAST component;
605
+ // earlier ones render [] (pi skips the blank for empty components).
606
+ // PERF: bail out BEFORE planning — pi re-renders every component on each
607
+ // keystroke, and planning the whole group in every row was O(n²) per
608
+ // keypress (typing lag in long sessions).
609
+ const g = rec!.group;
610
+ if (g[g.length - 1] !== rec) return [];
611
+ // Cache by width + the group's render-relevant state; a render with
612
+ // nothing changed (every keystroke) is then a string compare, not n
613
+ // truncateToWidth calls. The running row's elapsed seconds are part of
614
+ // the key so the per-second tick repaints it but keystrokes don't.
615
+ let key = `${width}`;
616
+ for (const c of g) {
617
+ const meta = c.metaFn?.() ?? pinnedMeta(c);
618
+ const tick = c.running && c.startedAt !== undefined ? Math.floor((Date.now() - c.startedAt) / 1000) : 0;
619
+ key += `\u0001${c.id}\u0002${c.running ? 1 : 0}${c.failed ? 1 : 0}${meta}\u0002${c.rev ?? 0}\u0002${String(tick)}`;
620
+ }
621
+ if (cache && cache.key === key) return cache.lines;
622
+ // Read the group live: a late text signal can move this row to a new group.
623
+ const rows = planGroupRows(
624
+ rec!.group.map(toGroupCall),
625
+ (call, o) =>
626
+ simpleToolLine(theme, call.name, call.target, { running: o.running, failed: o.failed, meta: call.meta, tick: call.tick, connector: o.connector, width }),
627
+ (s) => {
628
+ const label = theme.bold(`${s.running ? s.actionRunning : s.actionDone} ${s.opCount} ${s.noun}`);
629
+ const failure = s.failedCount === s.total ? theme.fg("error", " · failed") : s.failedCount > 0 ? theme.fg("error", ` · ${s.failedCount} failed`) : "";
630
+ const attempts = s.total > s.opCount ? theme.fg("muted", ` · ${s.total} calls`) : "";
631
+ return `${theme.fg("borderMuted", `${s.connector} `)}${marker(theme, s.running, s.failedCount === s.total)} ${label}${failure}${attempts}`;
632
+ },
633
+ );
634
+ const lines = rows.map((r) => r.row).filter((r): r is string => !!r);
635
+ cache = { key, lines };
636
+ return lines;
637
+ };
638
+ return new SimpleLine(paint);
639
+ };
640
+
641
+ const renderResult = (
642
+ result: AgentToolResult<any>,
643
+ options: ToolRenderResultOptions,
644
+ theme: Theme,
645
+ ctx: any,
646
+ ) => {
647
+ try {
648
+ if (options.expanded && def.renderResult) return def.renderResult(result, options, theme, ctx);
649
+ const rec = byId.get(ctx.toolCallId);
650
+ if (rec) {
651
+ rec.invalidate = () => ctx.invalidate();
652
+ rec.details = (result as { details?: unknown } | undefined)?.details;
653
+ const liveMeta = (def as { simpleMeta?: (details: unknown, ctx: unknown) => string | undefined }).simpleMeta;
654
+ if (liveMeta !== undefined) {
655
+ rec.metaFn = () => {
656
+ try {
657
+ return liveMeta(rec.details, ctx) ?? pinnedMeta(rec);
658
+ } catch {
659
+ return pinnedMeta(rec);
660
+ }
661
+ };
662
+ }
663
+ const meta = outputMeta(result);
664
+ // Compare settled values: bare meta against bare meta, and boolean
665
+ // against boolean (a raw `!== ctx.isError` never settles when the ctx
666
+ // field is undefined). Either mismatch would re-arm touch() on every
667
+ // re-render — the alpha.18 OOM loop.
668
+ const changed = !rec.done || rec.meta !== meta || rec.failed !== !!ctx.isError;
669
+ if (!options.isPartial) {
670
+ // Final result: stop the live timer and pin the runtime. The pinned
671
+ // duration goes to `durationMs`, never into `meta` — `meta` must stay
672
+ // the bare outputMeta so `changed` settles to false on re-renders;
673
+ // the old suffix-in-meta made every replayed final result "changed",
674
+ // which re-armed touch() → invalidate() → updateDisplay → renderResult
675
+ // as a self-sustaining loop (~5M iters → 4 GB heap, alpha.18 OOM).
676
+ const state = ctx.state as { timer?: ReturnType<typeof setInterval> } | undefined;
677
+ if (state?.timer !== undefined) clearInterval(state.timer);
678
+ if (state !== undefined) state.timer = undefined;
679
+ rec.endedAt = Date.now();
680
+ if (rec.durationMs === undefined && rec.startedAt !== undefined && rec.endedAt >= rec.startedAt) {
681
+ rec.durationMs = rec.endedAt - rec.startedAt;
682
+ }
683
+ rec.meta = meta;
684
+ rec.done = true;
685
+ rec.running = false;
686
+ } else {
687
+ rec.meta = meta;
688
+ rec.running = true;
689
+ }
690
+ rec.failed = !!ctx.isError;
691
+ if (changed && !options.isPartial) for (const r of rec.group) touch(r);
692
+ }
693
+ // Tools that opt in keep a compact result under their collapsed row
694
+ // (ask_user's Q→A list, run_subagent's `└ Completed · 7s · 2 calls`).
695
+ // The hook is invoked lazily, per paint, exactly like before — its
696
+ // callback may read live state (e.g. subagent stats that update). It
697
+ // may return precomposed lines (SimpleLine indents and truncates them
698
+ // per width) or a width-aware Component, whose render(width) is
699
+ // delegated. Errors or undefined yield no result lines.
700
+ const simpleResult = (def as {
701
+ simpleResult?: (result: unknown, theme: Theme, ctx: unknown) => string[] | Component | undefined;
702
+ }).simpleResult;
703
+ if (!options.isPartial && simpleResult !== undefined) {
704
+ return new SimpleLine((width) => {
705
+ try {
706
+ const out = simpleResult(result, theme, ctx);
707
+ if (out !== null && typeof out === "object" && "render" in out) return out.render(width);
708
+ return Array.isArray(out) ? (out as string[]) : [];
709
+ } catch {
710
+ return [];
711
+ }
712
+ });
713
+ }
714
+ return new SimpleLine(() => []);
715
+ } catch {
716
+ // fall through to pi's generic result rendering rather than crash the row
717
+ return new SimpleLine(() => []);
718
+ }
719
+ };
720
+
721
+ const wrapped = { ...def, renderShell: "self" as const, renderCall, renderResult };
722
+ simpleWrapped.add(wrapped);
723
+ return wrapped;
724
+ }