@iowarp/clio-coder 0.3.1 → 0.3.3

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 (601) hide show
  1. package/CHANGELOG.md +233 -373
  2. package/CONTRIBUTING.md +23 -23
  3. package/README.md +284 -613
  4. package/dist/{acp-FPR54DGL.js → acp-P2AQILE2.js} +43 -53
  5. package/dist/{agents-OGPIHPJH.js → agents-72W3BI7I.js} +43 -26
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-IC3K6NIZ.js → auth-5TWEIYDN.js} +20 -12
  8. package/dist/chunk-2DJ2KNFG.js +2095 -0
  9. package/dist/{chunk-IS3ONKU3.js → chunk-2IR2NMPA.js} +6 -4
  10. package/dist/chunk-2SFS6XQE.js +122 -0
  11. package/dist/{chunk-PV4JUBVJ.js → chunk-2TLUCQVG.js} +40 -21
  12. package/dist/chunk-2VTFPG5O.js +48 -0
  13. package/dist/chunk-4BJ5BYCE.js +61 -0
  14. package/dist/{chunk-474KN5II.js → chunk-4BPJXDWC.js} +111 -181
  15. package/dist/chunk-4VP4KH3K.js +962 -0
  16. package/dist/chunk-4XUGQOHA.js +797 -0
  17. package/dist/chunk-4ZG3XFUR.js +77 -0
  18. package/dist/chunk-5B2AEOW5.js +5407 -0
  19. package/dist/{chunk-K2ITRMHZ.js → chunk-5TSRNF4G.js} +6 -138
  20. package/dist/{chunk-4QKXUHSR.js → chunk-5UFT4SUX.js} +70 -20
  21. package/dist/{chunk-OLBBMFRD.js → chunk-5UUP6MWO.js} +24 -62
  22. package/dist/chunk-65DEGPJ6.js +52 -0
  23. package/dist/chunk-6EJMN2Y3.js +17 -0
  24. package/dist/chunk-6N5PTWMY.js +136 -0
  25. package/dist/chunk-6SGHMWE3.js +277 -0
  26. package/dist/chunk-6XLNIQDB.js +27 -0
  27. package/dist/chunk-7CR24IG7.js +242 -0
  28. package/dist/chunk-7MNJORFF.js +22 -0
  29. package/dist/{chunk-KY56HMHH.js → chunk-A3CYT5EX.js} +125 -31
  30. package/dist/chunk-AGYYIBLL.js +1069 -0
  31. package/dist/{chunk-GB6QRBXN.js → chunk-APJ265NV.js} +54 -1187
  32. package/dist/{chunk-673JJUWJ.js → chunk-BMEMKKIT.js} +2 -2
  33. package/dist/chunk-CBCAPZAA.js +229 -0
  34. package/dist/chunk-CMZWFGD2.js +352 -0
  35. package/dist/chunk-COU2UHX6.js +400 -0
  36. package/dist/chunk-DSELYM6W.js +1077 -0
  37. package/dist/chunk-DUYJ5IO6.js +644 -0
  38. package/dist/chunk-ECH6PKUQ.js +39 -0
  39. package/dist/chunk-ED4KHGC3.js +143 -0
  40. package/dist/chunk-EKMEHE4H.js +340 -0
  41. package/dist/chunk-FCSXB6T2.js +338 -0
  42. package/dist/chunk-FJ3H4MN5.js +48 -0
  43. package/dist/{chunk-RPTR2H26.js → chunk-FNTMWMX5.js} +21 -15
  44. package/dist/chunk-FQ4SKYE4.js +29 -0
  45. package/dist/chunk-G4BMMOKF.js +182 -0
  46. package/dist/{chunk-ZPY3JZ5E.js → chunk-GGXXDWE4.js} +183 -1233
  47. package/dist/chunk-HC4CLZ2Y.js +68 -0
  48. package/dist/{chunk-LU4TK2PR.js → chunk-HFSBBKSQ.js} +5 -56
  49. package/dist/{chunk-PIUMUEMV.js → chunk-HKIYEGME.js} +10 -6
  50. package/dist/chunk-I4HZDVNP.js +73 -0
  51. package/dist/chunk-IKCO5N3L.js +162 -0
  52. package/dist/chunk-IR4CFBFN.js +56 -0
  53. package/dist/{chunk-R5KLMSBV.js → chunk-J5Q24KAG.js} +2 -2
  54. package/dist/chunk-J7CWMCQD.js +255 -0
  55. package/dist/{chunk-K5XEMXTI.js → chunk-JVCV3ICN.js} +1 -1
  56. package/dist/chunk-KZWTDYJF.js +217 -0
  57. package/dist/chunk-LBMZMYH2.js +285 -0
  58. package/dist/{chunk-G34LV2PF.js → chunk-LM5TQCJZ.js} +84 -170
  59. package/dist/chunk-LW6DSM3M.js +5135 -0
  60. package/dist/chunk-LWLEKMDQ.js +3482 -0
  61. package/dist/{chunk-H6F6BYOH.js → chunk-LZSJBIVT.js} +7003 -7434
  62. package/dist/{chunk-HQQID6OA.js → chunk-M6SHUN7Q.js} +5 -5
  63. package/dist/{chunk-FST4FYJB.js → chunk-MFFY33HR.js} +99 -140
  64. package/dist/{chunk-BSU2YIWB.js → chunk-MVVUPGPW.js} +131 -136
  65. package/dist/chunk-OAO4GE4M.js +619 -0
  66. package/dist/{chunk-Q3RUPKEJ.js → chunk-OC7FIQPC.js} +58 -189
  67. package/dist/chunk-OKGUZO2U.js +34 -0
  68. package/dist/{chunk-GAEBEQVI.js → chunk-OOJYHWRB.js} +32 -346
  69. package/dist/{chunk-Q5WJOSJ7.js → chunk-OQ33BKR3.js} +2 -1
  70. package/dist/chunk-OQE5J4C6.js +73 -0
  71. package/dist/{chunk-KKNLWXI6.js → chunk-ORBHGJC5.js} +8 -8
  72. package/dist/{chunk-MAR7Y6HW.js → chunk-PAJK6MAQ.js} +23 -16
  73. package/dist/{chunk-M5T5VO65.js → chunk-PIWWS5BL.js} +837 -635
  74. package/dist/chunk-POHLU5DW.js +1186 -0
  75. package/dist/chunk-QKMUKYO7.js +4961 -0
  76. package/dist/chunk-SRDMMSEP.js +16405 -0
  77. package/dist/chunk-SST6Z5JA.js +80 -0
  78. package/dist/chunk-STBPMHSX.js +2456 -0
  79. package/dist/chunk-T6YILFSB.js +80 -0
  80. package/dist/chunk-TZK7PACC.js +174 -0
  81. package/dist/chunk-TZTZS7QK.js +227 -0
  82. package/dist/{chunk-ASND7OZK.js → chunk-UFIIWP2H.js} +13 -13
  83. package/dist/chunk-UOV2BYIW.js +107 -0
  84. package/dist/{chunk-PFEFKVGL.js → chunk-V6RTAOC2.js} +13 -11
  85. package/dist/chunk-VAKQQHWR.js +434 -0
  86. package/dist/chunk-VG7TBQIY.js +128 -0
  87. package/dist/chunk-VJWL6YS5.js +244 -0
  88. package/dist/{chunk-EYOKLTMF.js → chunk-VPAYEGVX.js} +17 -3
  89. package/dist/chunk-WEH5XRJQ.js +32 -0
  90. package/dist/chunk-X4RCMKVQ.js +641 -0
  91. package/dist/{chunk-TEKV33Q5.js → chunk-X6IAEBZR.js} +65 -33
  92. package/dist/chunk-XBXAASKX.js +18 -0
  93. package/dist/chunk-XN3L4EYL.js +46 -0
  94. package/dist/{chunk-RDLVBZEO.js → chunk-YCWGATWI.js} +6 -4
  95. package/dist/chunk-YHZX5GEU.js +193 -0
  96. package/dist/chunk-YXLYO42X.js +91 -0
  97. package/dist/{chunk-NMOX6HFD.js → chunk-ZDOOVTXZ.js} +29 -77
  98. package/dist/chunk-ZI647VB5.js +37 -0
  99. package/dist/{chunk-C4PTHK7P.js → chunk-ZWLZP4ZT.js} +5 -5
  100. package/dist/chunk-ZWMF7253.js +1882 -0
  101. package/dist/cli/index.js +62 -54
  102. package/dist/clio-JOU4FXVA.js +25 -0
  103. package/dist/code-nav-7AX6FYE6.js +600 -0
  104. package/dist/codewiki/build-worker.js +66 -0
  105. package/dist/compile-cache-CVJMMODC.js +18 -0
  106. package/dist/{components-DMAOEKFB.js → components-KELWS457.js} +11 -6
  107. package/dist/{config-IRUQ7SE4.js → config-XCDVKR23.js} +92 -55
  108. package/dist/configure-4GAP54ZW.js +42 -0
  109. package/dist/{context-5RADCKTR.js → context-4UOGGLQ5.js} +71 -35
  110. package/dist/context-5VKGUVJJ.js +866 -0
  111. package/dist/{context-3KWFLHJG.js → context-77FM5DV5.js} +15 -13
  112. package/dist/{context-clear-7TSNPAAI.js → context-clear-XXJRLCJJ.js} +54 -28
  113. package/dist/{context-index-W4RLWOQH.js → context-index-BZ4UYMTC.js} +30 -24
  114. package/dist/dispatch-runner-QPRDDBDX.js +1997 -0
  115. package/dist/{docs-5AWSPS37.js → docs-2C2LTVT2.js} +23 -10
  116. package/dist/{doctor-UC5NAJYQ.js → doctor-HR46URBJ.js} +27 -17
  117. package/dist/{eval-U6TJHRLX.js → eval-XSSNATB4.js} +29 -16
  118. package/dist/{evidence-YEGUW4L3.js → evidence-6HG2PY2B.js} +46 -26
  119. package/dist/{evolve-TXARCTPG.js → evolve-K7YU3NCY.js} +45 -25
  120. package/dist/{extensions-OZFJ3A3G.js → extensions-QVDOHDGJ.js} +16 -7
  121. package/dist/{fleet-6G3DHNYE.js → fleet-VY3HHKN6.js} +163 -54
  122. package/dist/{fleet-preflight-DSNT37JK.js → fleet-preflight-DDN536IT.js} +7 -4
  123. package/dist/{init-KZ5QTF6M.js → init-JYGXI3FK.js} +69 -32
  124. package/dist/{memory-73ESV5YC.js → memory-WFZMGYHX.js} +48 -27
  125. package/dist/{models-A4PVNWJK.js → models-I5QWSEOM.js} +39 -25
  126. package/dist/monitor-GE4ID3IA.js +661 -0
  127. package/dist/{chunk-FCIH3BIZ.js → orchestrator-EM5MC3HM.js} +15979 -12407
  128. package/dist/{paths-C4H6IV77.js → paths-UXLN5YYZ.js} +10 -5
  129. package/dist/{preload-6WVMHX3A.js → preload-P6DGH2PZ.js} +2 -2
  130. package/dist/{reset-BGW6OGMV.js → reset-L2FQEE3E.js} +16 -10
  131. package/dist/{run-YTPEYQOH.js → run-ZU3QMZPZ.js} +101 -61
  132. package/dist/{share-YIFFV4NQ.js → share-S5BZQC5I.js} +15 -8
  133. package/dist/{skills-2V6RA3OQ.js → skills-X5VXCRNQ.js} +34 -14
  134. package/dist/{skills-eval-S2TVJO4F.js → skills-eval-WKIHWTHR.js} +70 -34
  135. package/dist/steer-GGWFUJUD.js +77 -0
  136. package/dist/{targets-TYXLPB23.js → targets-SNCPI2NR.js} +43 -27
  137. package/dist/terminal-lease-BNAHVHBS.js +395 -0
  138. package/dist/{trace-GGOJ6Q6Z.js → trace-PNCASAXC.js} +41 -16
  139. package/dist/{chunk-N6F52NLF.js → tree-sitter-HGKH6LG4.js} +28 -2306
  140. package/dist/{uninstall-LLLT4F4W.js → uninstall-FZCQCDKC.js} +10 -5
  141. package/dist/{upgrade-33G2LMM5.js → upgrade-JQHHPQ4K.js} +45 -25
  142. package/dist/{usage-ZAFSXKKG.js → usage-OR4O5SMZ.js} +62 -31
  143. package/dist/verify-375KUB3Y.js +716 -0
  144. package/dist/web-fetch-2YHJ3KTG.js +638 -0
  145. package/dist/{wiki-generate-NUQCVOQ3.js → wiki-generate-UEXP2ARI.js} +74 -34
  146. package/dist/worker/entry.js +221 -36
  147. package/dist/workspace-G4ZWUIPR.js +22 -0
  148. package/docs/README.md +22 -17
  149. package/docs/acp.md +168 -16
  150. package/docs/alcf-provider.md +1 -1
  151. package/docs/architecture.md +136 -7
  152. package/docs/artifact-versions.md +1 -1
  153. package/docs/built-in-agents.md +1 -1
  154. package/docs/capacity-and-scheduling.md +1 -1
  155. package/docs/commands-and-modes.md +114 -71
  156. package/docs/config-knobs-audit.md +1 -3
  157. package/docs/configuration-and-targets.md +174 -46
  158. package/docs/context-engine.md +29 -6
  159. package/docs/development-pipeline.md +26 -1
  160. package/docs/dispatch-architecture-rationale.md +1 -1
  161. package/docs/documentation-coverage.md +2 -2
  162. package/docs/documentation-guide.md +1 -1
  163. package/docs/environment-variables.md +13 -5
  164. package/docs/eval-runner.md +1 -1
  165. package/docs/evals-internal.md +1 -1
  166. package/docs/evidence-and-memory.md +6 -2
  167. package/docs/evolution.md +2 -2
  168. package/docs/exit-codes-and-output.md +15 -9
  169. package/docs/extensions-and-sharing.md +9 -9
  170. package/docs/fleet-dispatch.md +7 -5
  171. package/docs/git-commit-provenance.md +120 -0
  172. package/docs/glossary.md +1 -1
  173. package/docs/installation-and-lifecycle.md +34 -27
  174. package/docs/middleware-and-components.md +1 -1
  175. package/docs/model-catalog.md +45 -14
  176. package/docs/observability.md +8 -5
  177. package/docs/performance-methodology.md +491 -0
  178. package/docs/pi-boundary.md +72 -0
  179. package/docs/proactive-memory.md +3 -3
  180. package/docs/prompt-envelope-and-tools.md +24 -3
  181. package/docs/provider-adapter-cookbook.md +57 -4
  182. package/docs/release-cut-checklist.md +129 -115
  183. package/docs/safety-model.md +9 -5
  184. package/docs/scientific-validation.md +3 -3
  185. package/docs/session-lifecycle.md +55 -12
  186. package/docs/skills-marketplace.md +12 -8
  187. package/docs/time-conventions.md +1 -1
  188. package/docs/tool-usage.md +3 -3
  189. package/docs/trace-store.md +1 -1
  190. package/docs/troubleshooting.md +10 -7
  191. package/docs/tui-design.md +47 -10
  192. package/docs/worker-dispatch-mechanics.md +1 -1
  193. package/package.json +19 -22
  194. package/skills/coding/ast-grep/SKILL.md +136 -0
  195. package/skills/coding/ast-grep/evals.md +56 -0
  196. package/skills/coding/ast-grep/references/rule_reference.md +297 -0
  197. package/skills/coding/coding-standards/SKILL.md +113 -0
  198. package/skills/coding/coding-standards/evals.md +34 -0
  199. package/skills/coding/prototype/SKILL.md +86 -0
  200. package/skills/coding/prototype/evals.md +42 -0
  201. package/skills/coding/prototype/references/LOGIC.md +67 -0
  202. package/skills/coding/prototype/references/UI.md +112 -0
  203. package/skills/coding/tdd/SKILL.md +101 -0
  204. package/skills/coding/tdd/evals.md +41 -0
  205. package/skills/coding/tdd/references/mocking.md +59 -0
  206. package/skills/coding/tdd/references/tests.md +77 -0
  207. package/skills/context/context-handoff/SKILL.md +126 -0
  208. package/skills/context/context-handoff/evals.md +57 -0
  209. package/skills/context/context-handoff/scripts/new-handoff.sh +26 -0
  210. package/skills/context/context-prime/SKILL.md +95 -0
  211. package/skills/context/context-prime/evals.md +54 -0
  212. package/skills/meta/clio-dev/SKILL.md +91 -0
  213. package/skills/meta/clio-dev/evals.md +45 -0
  214. package/skills/meta/clio-test/SKILL.md +130 -0
  215. package/skills/meta/clio-test/evals.md +43 -0
  216. package/skills/meta/clio-test/references/harness.md +97 -0
  217. package/skills/meta/clio-test/references/test-map.md +59 -0
  218. package/skills/meta/credentials/SKILL.md +125 -0
  219. package/skills/meta/credentials/evals.md +104 -0
  220. package/skills/meta/find-skills/SKILL.md +72 -0
  221. package/skills/meta/find-skills/evals.md +47 -0
  222. package/skills/meta/herdr/SKILL.md +127 -0
  223. package/skills/meta/herdr/evals.md +38 -0
  224. package/skills/meta/skill-craft/SKILL.md +102 -0
  225. package/skills/meta/skill-craft/evals.md +41 -0
  226. package/skills/planning/architecture/SKILL.md +129 -0
  227. package/skills/planning/architecture/evals.md +36 -0
  228. package/skills/planning/backlog/SKILL.md +90 -0
  229. package/skills/planning/backlog/evals.md +43 -0
  230. package/skills/planning/prd/SKILL.md +82 -0
  231. package/skills/planning/prd/evals.md +49 -0
  232. package/skills/planning/product-intent/SKILL.md +112 -0
  233. package/skills/planning/product-intent/evals.md +36 -0
  234. package/skills/planning/tech-spec/SKILL.md +115 -0
  235. package/skills/planning/tech-spec/evals.md +47 -0
  236. package/skills/registry.yaml +136 -0
  237. package/skills/research/arxiv-literature/SKILL.md +104 -0
  238. package/skills/research/arxiv-literature/evals.md +58 -0
  239. package/skills/research/experiment-protocol/SKILL.md +122 -0
  240. package/skills/research/experiment-protocol/evals.md +91 -0
  241. package/skills/research/scientific-debugging/SKILL.md +119 -0
  242. package/skills/research/scientific-debugging/evals.md +138 -0
  243. package/skills/research/scientific-modernization/SKILL.md +138 -0
  244. package/skills/research/scientific-modernization/evals.md +84 -0
  245. package/skills/workflow/design-council/SKILL.md +139 -0
  246. package/skills/workflow/design-council/evals.md +97 -0
  247. package/skills/workflow/grill-me/SKILL.md +186 -0
  248. package/skills/workflow/grill-me/evals.md +78 -0
  249. package/skills/workflow/workflow-distiller/SKILL.md +136 -0
  250. package/skills/workflow/workflow-distiller/evals.md +107 -0
  251. package/src/cli/acp.ts +31 -4
  252. package/src/cli/clio.ts +68 -6
  253. package/src/cli/config-inspect.ts +28 -22
  254. package/src/cli/configure.ts +47 -9
  255. package/src/cli/context-clear.ts +2 -2
  256. package/src/cli/context-index.ts +21 -23
  257. package/src/cli/context.ts +13 -8
  258. package/src/cli/default-target.ts +9 -17
  259. package/src/cli/docs.ts +11 -5
  260. package/src/cli/evidence.ts +4 -1
  261. package/src/cli/extensions.ts +10 -1
  262. package/src/cli/fleet.ts +47 -6
  263. package/src/cli/index.ts +55 -26
  264. package/src/cli/memory.ts +3 -1
  265. package/src/cli/models.ts +1 -1
  266. package/src/cli/modes/json-stream.ts +37 -1
  267. package/src/cli/modes/print.ts +24 -9
  268. package/src/cli/run.ts +2 -2
  269. package/src/cli/skills-eval.ts +23 -8
  270. package/src/cli/skills.ts +19 -4
  271. package/src/cli/targets.ts +4 -0
  272. package/src/cli/text-layout.ts +15 -5
  273. package/src/cli/trace.ts +62 -14
  274. package/src/cli/upgrade.ts +18 -2
  275. package/src/cli/usage.ts +10 -3
  276. package/src/cli/wiki-generate.ts +2 -1
  277. package/src/core/agent-environment.ts +7 -0
  278. package/src/core/bash-exec.ts +72 -1
  279. package/src/core/boot-trace.ts +9 -4
  280. package/src/core/bus-events.ts +20 -4
  281. package/src/core/commit-attribution.ts +157 -0
  282. package/src/core/compile-cache.ts +159 -0
  283. package/src/core/config.ts +131 -2
  284. package/src/core/defaults.ts +39 -5
  285. package/src/core/domain-loader.ts +12 -5
  286. package/src/core/git-commit-attribution.ts +387 -0
  287. package/src/core/incomplete-installation.ts +45 -0
  288. package/src/core/response-schema.ts +1 -1
  289. package/src/core/safe-exec.ts +13 -1
  290. package/src/core/settings-layers.ts +155 -21
  291. package/src/core/skill-activation.ts +1 -1
  292. package/src/core/startup-timer.ts +3 -3
  293. package/src/core/state-file-lock.ts +13 -1
  294. package/src/core/termination.ts +78 -5
  295. package/src/domains/config/classify.ts +15 -3
  296. package/src/domains/config/extension.ts +19 -13
  297. package/src/domains/config/index.ts +10 -0
  298. package/src/domains/config/keybindings.ts +45 -9
  299. package/src/domains/context/bootstrap-prompt.ts +1 -1
  300. package/src/domains/context/bootstrap.ts +111 -18
  301. package/src/domains/context/clear.ts +16 -11
  302. package/src/domains/context/clio-md.ts +111 -9
  303. package/src/domains/context/codewiki/artifact.ts +400 -0
  304. package/src/domains/context/codewiki/build-worker-protocol.ts +24 -0
  305. package/src/domains/context/codewiki/build-worker.ts +54 -0
  306. package/src/domains/context/codewiki/coordinator.ts +182 -0
  307. package/src/domains/context/codewiki/indexer.ts +59 -144
  308. package/src/domains/context/codewiki/paths.ts +67 -0
  309. package/src/domains/context/codewiki/schema.ts +80 -0
  310. package/src/domains/context/codewiki/tree-sitter.ts +1 -1
  311. package/src/domains/context/contract.ts +11 -5
  312. package/src/domains/context/extension.ts +94 -143
  313. package/src/domains/context/fingerprint.ts +3 -1
  314. package/src/domains/context/index.ts +12 -22
  315. package/src/domains/context/project-metadata.ts +19 -0
  316. package/src/domains/context/prompt-context.ts +9 -10
  317. package/src/domains/context/refresh.ts +29 -21
  318. package/src/domains/context/runtime.ts +17 -0
  319. package/src/domains/context/wiki/generate.ts +39 -34
  320. package/src/domains/context/wiki/plan.ts +1 -1
  321. package/src/domains/context/wiki/prompts.ts +21 -8
  322. package/src/domains/dispatch/code-step.ts +20 -1
  323. package/src/domains/dispatch/extension.ts +158 -21
  324. package/src/domains/dispatch/failure-classification.ts +6 -0
  325. package/src/domains/dispatch/fleet-commit-attribution.ts +56 -0
  326. package/src/domains/dispatch/orphan-recovery.ts +50 -8
  327. package/src/domains/dispatch/receipt-integrity.ts +5 -0
  328. package/src/domains/dispatch/state.ts +31 -6
  329. package/src/domains/dispatch/transport.ts +2 -1
  330. package/src/domains/dispatch/types.ts +14 -0
  331. package/src/domains/dispatch/worker-spawn.ts +21 -2
  332. package/src/domains/eval/metrics/context.ts +1 -1
  333. package/src/domains/eval/types.ts +0 -1
  334. package/src/domains/evidence/build.ts +41 -1
  335. package/src/domains/lifecycle/migrations/2026-08-18-lmstudio-runtime-id.ts +52 -0
  336. package/src/domains/lifecycle/migrations/index.ts +24 -4
  337. package/src/domains/middleware/hooks-io.ts +12 -0
  338. package/src/domains/middleware/skills-reminder.ts +30 -15
  339. package/src/domains/prompts/compiler.ts +142 -84
  340. package/src/domains/prompts/contract.ts +18 -2
  341. package/src/domains/prompts/extension.ts +39 -7
  342. package/src/domains/prompts/fragment-loader.ts +0 -1
  343. package/src/domains/prompts/fragments/identity/clio.md +2 -4
  344. package/src/domains/prompts/fragments/identity/docs-routing.md +10 -0
  345. package/src/domains/prompts/fragments/identity/self-awareness.md +1 -45
  346. package/src/domains/prompts/fragments/operating/contract.md +4 -50
  347. package/src/domains/prompts/fragments/operating/delegation.md +42 -0
  348. package/src/domains/prompts/fragments/operating/skills.md +26 -0
  349. package/src/domains/prompts/fragments/operating/worker.md +16 -0
  350. package/src/domains/prompts/fragments/safety/auto-edit.md +5 -5
  351. package/src/domains/prompts/fragments/safety/full-auto.md +3 -3
  352. package/src/domains/prompts/fragments/safety/read-only.md +4 -4
  353. package/src/domains/prompts/fragments/safety/suggest.md +2 -2
  354. package/src/domains/prompts/fragments/wiki/page.md +10 -0
  355. package/src/domains/prompts/fragments/wiki/plan.md +10 -0
  356. package/src/domains/prompts/preload.ts +3 -3
  357. package/src/domains/providers/auth/api-key.ts +1 -1
  358. package/src/domains/providers/auth/backend-file.ts +20 -10
  359. package/src/domains/providers/auth/backend-memory.ts +59 -4
  360. package/src/domains/providers/auth/boot-status.ts +65 -0
  361. package/src/domains/providers/auth/oauth.ts +2 -1
  362. package/src/domains/providers/auth/storage.ts +97 -38
  363. package/src/domains/providers/capabilities.ts +12 -4
  364. package/src/domains/providers/contract.ts +15 -4
  365. package/src/domains/providers/extension.ts +18 -6
  366. package/src/domains/providers/model-runtime-capabilities.ts +15 -4
  367. package/src/domains/providers/models/local-models/clio-local-coding-targets.yaml +118 -35
  368. package/src/domains/providers/plugins.ts +5 -3
  369. package/src/domains/providers/probe/fingerprint.ts +25 -5
  370. package/src/domains/providers/registry.ts +31 -10
  371. package/src/domains/providers/runtimes/boot-manifest.ts +55 -0
  372. package/src/domains/providers/runtimes/builtins.ts +2 -2
  373. package/src/domains/providers/runtimes/common/lmstudio-http.ts +423 -0
  374. package/src/domains/providers/runtimes/common/local-synth.ts +6 -7
  375. package/src/domains/providers/runtimes/local-native/lmstudio.ts +241 -0
  376. package/src/domains/providers/support.ts +6 -3
  377. package/src/domains/providers/types/local-model-quirks.ts +7 -9
  378. package/src/domains/providers/types/runtime-descriptor.ts +12 -1
  379. package/src/domains/providers/types/target-descriptor.ts +22 -0
  380. package/src/domains/resources/contract.ts +0 -1
  381. package/src/domains/resources/extension.ts +1 -3
  382. package/src/domains/resources/loader.ts +3 -4
  383. package/src/domains/resources/prompts/loader.ts +16 -2
  384. package/src/domains/resources/prompts/substitute.ts +1 -65
  385. package/src/domains/resources/skills/content-hash.ts +2 -0
  386. package/src/domains/resources/skills/install.ts +17 -0
  387. package/src/domains/resources/skills/loader.ts +17 -10
  388. package/src/domains/resources/skills/marketplace.ts +55 -9
  389. package/src/domains/safety/action-classifier.ts +4 -2
  390. package/src/domains/safety/audit.ts +8 -2
  391. package/src/domains/safety/extension.ts +1 -1
  392. package/src/domains/session/compaction/branch-summary.ts +3 -2
  393. package/src/domains/session/compaction/cut-point.ts +2 -1
  394. package/src/domains/session/compaction/tokens.ts +2 -1
  395. package/src/domains/session/context-ledger.ts +14 -0
  396. package/src/domains/session/contract.ts +15 -0
  397. package/src/domains/session/decision-board.ts +190 -0
  398. package/src/domains/session/entries.ts +66 -3
  399. package/src/domains/session/extension.ts +93 -12
  400. package/src/domains/session/retry.ts +10 -18
  401. package/src/domains/session/session-artifacts.ts +107 -0
  402. package/src/domains/session/task-board.ts +207 -13
  403. package/src/domains/session/tree/active-path.ts +44 -5
  404. package/src/domains/session/tree/fork.ts +26 -27
  405. package/src/domains/session/tree/preview.ts +2 -2
  406. package/src/domains/session/workspace/git-probe.ts +17 -11
  407. package/src/domains/user-tasks/store.ts +297 -0
  408. package/src/engine/acp/errors.ts +96 -0
  409. package/src/engine/acp/server.ts +1728 -146
  410. package/src/engine/acp/transport.ts +135 -14
  411. package/src/engine/acp/types.ts +26 -0
  412. package/src/engine/agent.ts +3 -3
  413. package/src/engine/ai.ts +32 -27
  414. package/src/engine/alcf-oauth.ts +26 -19
  415. package/src/engine/api-registry.ts +223 -0
  416. package/src/engine/apis/index.ts +3 -7
  417. package/src/engine/apis/llamacpp-residency.ts +49 -9
  418. package/src/engine/apis/lmstudio-residency.ts +5 -21
  419. package/src/engine/apis/lmstudio.ts +243 -0
  420. package/src/engine/apis/ollama-native.ts +24 -3
  421. package/src/engine/apis/openai-completions.ts +170 -91
  422. package/src/engine/apis/residency.ts +139 -3
  423. package/src/engine/apis/types.ts +16 -0
  424. package/src/engine/env-api-keys.ts +98 -0
  425. package/src/engine/gemma-channel-filter.ts +223 -0
  426. package/src/engine/instrumented-tui.ts +192 -0
  427. package/src/engine/messages.ts +14 -0
  428. package/src/engine/models.ts +42 -0
  429. package/src/engine/oauth.ts +16 -12
  430. package/src/engine/prompt-templates.ts +1 -0
  431. package/src/engine/provider-payload.ts +16 -59
  432. package/src/engine/strip-tokenizer-sentinels.ts +1 -1
  433. package/src/engine/truncate.ts +9 -0
  434. package/src/engine/tui.ts +17 -9
  435. package/src/engine/types.ts +3 -6
  436. package/src/engine/worker-runtime-capabilities.ts +5 -0
  437. package/src/engine/worker-runtime.ts +1 -1
  438. package/src/engine/worker-tools.ts +9 -4
  439. package/src/entry/boot-options.ts +50 -0
  440. package/src/entry/orchestrator.ts +288 -150
  441. package/src/interactive/application-controller.ts +89 -2
  442. package/src/interactive/chat-loop.ts +266 -41
  443. package/src/interactive/chat-panel.ts +715 -278
  444. package/src/interactive/chat-renderer.ts +306 -85
  445. package/src/interactive/clio-editor.ts +3 -8
  446. package/src/interactive/command-fallbacks.ts +2 -2
  447. package/src/interactive/context-overlay.ts +27 -1
  448. package/src/interactive/editor-submit.ts +253 -24
  449. package/src/interactive/export-html/ansi-to-html.ts +161 -0
  450. package/src/interactive/export-html/index.ts +51 -0
  451. package/src/interactive/export-html/template.ts +45 -0
  452. package/src/interactive/export-html/tool-renderer.ts +54 -0
  453. package/src/interactive/footer/dashboard.ts +4 -0
  454. package/src/interactive/footer/notifications.ts +1 -1
  455. package/src/interactive/footer/widgets.ts +42 -22
  456. package/src/interactive/footer-panel.ts +8 -3
  457. package/src/interactive/format-time.ts +14 -2
  458. package/src/interactive/interactive-application.ts +203 -17
  459. package/src/interactive/interactive-event-projection.ts +18 -1
  460. package/src/interactive/interactive-input-runtime.ts +50 -4
  461. package/src/interactive/interactive-presentation.ts +151 -19
  462. package/src/interactive/interactive-shell.ts +268 -14
  463. package/src/interactive/interactive-slash-runtime.ts +184 -117
  464. package/src/interactive/interactive-tickers.ts +38 -7
  465. package/src/interactive/keybinding-manager.ts +1 -1
  466. package/src/interactive/layout.ts +40 -3
  467. package/src/interactive/overlay-frame.ts +1 -1
  468. package/src/interactive/overlay-general-openers.ts +58 -1
  469. package/src/interactive/overlay-key-routing.ts +3 -0
  470. package/src/interactive/overlay-lifecycle.ts +13 -0
  471. package/src/interactive/overlay-permission-lifecycle.ts +2 -1
  472. package/src/interactive/overlay-session-lifecycle.ts +69 -12
  473. package/src/interactive/overlays/ask-user.ts +146 -24
  474. package/src/interactive/overlays/decisions.ts +300 -0
  475. package/src/interactive/overlays/help-reference.ts +15 -10
  476. package/src/interactive/overlays/model-selector.ts +34 -16
  477. package/src/interactive/overlays/session-selector.ts +18 -0
  478. package/src/interactive/overlays/settings.ts +105 -17
  479. package/src/interactive/overlays/skills-hub.ts +4 -4
  480. package/src/interactive/overlays/tree-selector.ts +41 -6
  481. package/src/interactive/render-trace.ts +499 -90
  482. package/src/interactive/renderers/compaction-summary.ts +2 -2
  483. package/src/interactive/renderers/diff.ts +115 -104
  484. package/src/interactive/renderers/mermaid.ts +53 -0
  485. package/src/interactive/renderers/tool-execution.ts +516 -168
  486. package/src/interactive/renderers/worker-entry.ts +20 -4
  487. package/src/interactive/session-switch-settlement.ts +10 -0
  488. package/src/interactive/slash-autocomplete.ts +6 -114
  489. package/src/interactive/slash-commands.ts +135 -47
  490. package/src/interactive/slash-spec.ts +9 -38
  491. package/src/interactive/status/controller.ts +5 -1
  492. package/src/interactive/status/index.ts +12 -1
  493. package/src/interactive/status/reasoning.ts +87 -0
  494. package/src/interactive/status/summary.ts +13 -2
  495. package/src/interactive/stdout-backpressure.ts +99 -0
  496. package/src/interactive/stream-pacer.ts +530 -0
  497. package/src/interactive/stream-pacing-policy.ts +66 -0
  498. package/src/interactive/tasks-overlay.ts +368 -14
  499. package/src/interactive/terminal-lease.ts +485 -0
  500. package/src/interactive/theme/tokens.ts +1 -1
  501. package/src/interactive/transcript-detail.ts +120 -0
  502. package/src/interactive/turn-context.ts +4 -3
  503. package/src/interactive/turn-persistence.ts +30 -13
  504. package/src/interactive/turn-queues.ts +12 -0
  505. package/src/interactive/turn-recovery.ts +25 -8
  506. package/src/interactive/turn-runtime.ts +79 -12
  507. package/src/interactive/turn-state.ts +10 -0
  508. package/src/interactive/view/artifacts.ts +114 -4
  509. package/src/interactive/view/view-overlay.ts +3 -0
  510. package/src/interactive/welcome-dashboard.ts +17 -16
  511. package/src/interactive/worker-receipts.ts +52 -3
  512. package/src/interactive/worker-stream.ts +5 -1
  513. package/src/tools/agent-tools.ts +23 -3
  514. package/src/tools/artifact.ts +2 -2
  515. package/src/tools/ask-user.ts +23 -13
  516. package/src/tools/bash.ts +30 -2
  517. package/src/tools/bootstrap.ts +34 -431
  518. package/src/tools/builtin-tool-catalog.ts +271 -0
  519. package/src/tools/codewiki/code-nav-surface.ts +29 -0
  520. package/src/tools/codewiki/code-nav.ts +8 -22
  521. package/src/tools/codewiki/shared.ts +41 -38
  522. package/src/tools/context/docs-engine.ts +14 -3
  523. package/src/tools/context/index.ts +107 -28
  524. package/src/tools/context/surface.ts +19 -0
  525. package/src/tools/core-bootstrap.ts +168 -0
  526. package/src/tools/credential-present.ts +5 -5
  527. package/src/tools/dispatch-admission.ts +533 -0
  528. package/src/tools/dispatch-background.ts +54 -0
  529. package/src/tools/dispatch-event-text.ts +6 -0
  530. package/src/tools/dispatch-plan.ts +9 -4
  531. package/src/tools/dispatch-run-events.ts +238 -0
  532. package/src/tools/dispatch-runner.ts +2370 -0
  533. package/src/tools/dispatch-scout-admission.ts +295 -0
  534. package/src/tools/dispatch-types.ts +77 -0
  535. package/src/tools/dispatch.ts +67 -3161
  536. package/src/tools/find.ts +4 -2
  537. package/src/tools/grep.ts +2 -2
  538. package/src/tools/lazy-tool.ts +60 -0
  539. package/src/tools/ledger.ts +3 -3
  540. package/src/tools/monitor-surface.ts +36 -0
  541. package/src/tools/monitor.ts +2 -32
  542. package/src/tools/observers.ts +2 -2
  543. package/src/tools/presentation.ts +107 -0
  544. package/src/tools/registry.ts +45 -27
  545. package/src/tools/safe-exec.ts +2 -2
  546. package/src/tools/steer-surface.ts +17 -0
  547. package/src/tools/steer.ts +2 -13
  548. package/src/tools/tasks.ts +108 -11
  549. package/src/tools/truncate.ts +25 -184
  550. package/src/tools/verify/frontend.ts +3 -1
  551. package/src/tools/verify/index.ts +3 -38
  552. package/src/tools/verify/surface.ts +46 -0
  553. package/src/tools/web-fetch-surface.ts +23 -0
  554. package/src/tools/web-fetch.ts +2 -20
  555. package/src/tools/write.ts +7 -2
  556. package/src/worker/entry.ts +39 -2
  557. package/src/worker/spec-contract.ts +26 -5
  558. package/dist/chunk-7SS2CTV2.js +0 -61361
  559. package/dist/chunk-DKGKUHFA.js +0 -924
  560. package/dist/chunk-GEP36Y4X.js +0 -12796
  561. package/dist/chunk-XYWBQRDM.js +0 -137
  562. package/dist/clio-BZVGEUFJ.js +0 -58
  563. package/dist/configure-S7S6F6CL.js +0 -32
  564. package/docs/html/agents_blueprint.html +0 -936
  565. package/docs/html/alcf_blueprint.html +0 -324
  566. package/docs/html/architecture_blueprint.html +0 -850
  567. package/docs/html/commands_blueprint.html +0 -939
  568. package/docs/html/config_knobs_audit_blueprint.html +0 -178
  569. package/docs/html/configuration_blueprint.html +0 -1080
  570. package/docs/html/context_blueprint.html +0 -603
  571. package/docs/html/documentation_blueprint.html +0 -832
  572. package/docs/html/environment_blueprint.html +0 -404
  573. package/docs/html/eval_blueprint.html +0 -743
  574. package/docs/html/evals_internal_blueprint.html +0 -190
  575. package/docs/html/evolution_blueprint.html +0 -674
  576. package/docs/html/extensions_blueprint.html +0 -2065
  577. package/docs/html/fleet_dispatch_blueprint.html +0 -286
  578. package/docs/html/index.html +0 -919
  579. package/docs/html/lifecycle_blueprint.html +0 -723
  580. package/docs/html/memory_blueprint.html +0 -699
  581. package/docs/html/middleware_blueprint.html +0 -664
  582. package/docs/html/models_blueprint.html +0 -2366
  583. package/docs/html/observability_blueprint.html +0 -683
  584. package/docs/html/provider_adapter_blueprint.html +0 -245
  585. package/docs/html/safety_blueprint.html +0 -1386
  586. package/docs/html/shared.css +0 -571
  587. package/docs/html/shared.js +0 -143
  588. package/docs/html/skills_blueprint.html +0 -671
  589. package/docs/html/soak_blueprint.html +0 -182
  590. package/docs/html/tool_usage_blueprint.html +0 -350
  591. package/docs/html/tools_blueprint.html +0 -2249
  592. package/docs/html/trace_blueprint.html +0 -235
  593. package/docs/html/tui_design_blueprint.html +0 -374
  594. package/docs/html/validation_blueprint.html +0 -961
  595. package/docs/html/worker_dispatch_blueprint.html +0 -231
  596. package/src/core/release.ts +0 -2
  597. package/src/domains/providers/runtimes/common/lmstudio-logger.ts +0 -32
  598. package/src/domains/providers/runtimes/local-native/lmstudio-native.ts +0 -491
  599. package/src/engine/apis/lmstudio-native.ts +0 -1438
  600. package/src/engine/apis/thinking-replay.ts +0 -11
  601. package/src/tools/string-enum.ts +0 -15
@@ -2,28 +2,52 @@ import { performance } from "node:perf_hooks";
2
2
  import type { OutputVerbosity } from "../core/defaults.js";
3
3
  import { SKILL_SUGGESTION_PREFIX } from "../core/skill-activation.js";
4
4
  import { rawDurationMs } from "../core/timers.js";
5
- import { estimateReasoningTextTokens, extractReasoningTokens } from "../domains/session/context-accounting.js";
6
5
  import { type Component, Markdown, truncateToWidth, wrapTextWithAnsi } from "../engine/tui.js";
7
6
  import type { AgentMessage } from "../engine/types.js";
7
+ import { toolPresentationPolicy } from "../tools/presentation.js";
8
8
  import type { ChatLoopEvent, RetryStatusPayload } from "./chat-loop.js";
9
9
  import { extractText, isSelfExplainingAbort } from "./chat-loop-messages.js";
10
+ import type { ApprovalRequestView } from "./permission-overlay.js";
10
11
  import { codeInk } from "./renderers/code-ink.js";
12
+ import { createMermaidMarkdownTransform } from "./renderers/mermaid.js";
11
13
  import { styleTaggedNotice } from "./renderers/notice.js";
12
14
  import { formatRetryStatus } from "./renderers/retry-status.js";
13
15
  import {
14
- classifyResourceRead,
15
- previewResult,
16
16
  renderToolAwaitingApproval,
17
17
  renderToolCallHeader,
18
18
  renderToolExecution,
19
19
  renderToolRunningStatus,
20
20
  renderToolStreamingExecution,
21
21
  renderToolSubline,
22
- unwrapResultEnvelope,
23
22
  } from "./renderers/tool-execution.js";
24
23
  import { renderWorkerEntryLines } from "./renderers/worker-entry.js";
25
- import { INLINE_STATUS_INDENT_COLS, type StatusPhase, type VerbRender } from "./status/index.js";
24
+ import {
25
+ compactReasoningTokens,
26
+ emptyRunTally,
27
+ foldMessageIntoRunTally,
28
+ formatReasoningChip,
29
+ formatReasoningLabel,
30
+ INLINE_STATUS_INDENT_COLS,
31
+ type ReasoningTokenProvenance,
32
+ type ReasoningUsageView,
33
+ reasoningFromTally,
34
+ type StatusPhase,
35
+ UNMEASURED_REASONING,
36
+ type VerbRender,
37
+ } from "./status/index.js";
26
38
  import { clioTheme, fgSequence, GLYPH, markdownTheme, SGR_DIM, SGR_RESET } from "./theme/index.js";
39
+ import {
40
+ type Fold,
41
+ type FoldOverride,
42
+ policyRunningToolFold,
43
+ policyThinkingFold,
44
+ policyToolFold,
45
+ policyWorkerFold,
46
+ resolveFold,
47
+ type TranscriptDetailPolicy,
48
+ toggledFold,
49
+ transcriptDetail,
50
+ } from "./transcript-detail.js";
27
51
  import { type WorkerEntryState, workerAskedByModel } from "./worker-stream.js";
28
52
 
29
53
  // Fenced code reaches the screen through pi-tui's Markdown component, which
@@ -33,6 +57,13 @@ import { type WorkerEntryState, workerAskedByModel } from "./worker-stream.js";
33
57
  // indentation, and width behavior stay pi-tui's and nothing post-processes
34
58
  // already-rendered output.
35
59
  const CHAT_MARKDOWN_THEME = markdownTheme(clioTheme(), (code, lang) => codeInk(lang, code.split("\n")));
60
+ const CHAT_MARKDOWN_OPTIONS = {
61
+ transform: createMermaidMarkdownTransform(clioTheme()),
62
+ renderLatex: true,
63
+ } as const;
64
+ // TuiAltScreen uses Pi's OSC 133 prompt-start marker for semantic prompt
65
+ // navigation. The sequence is zero-width and stripped before terminal output.
66
+ const OSC133_PROMPT_START = "\x1b]133;A\x07";
36
67
 
37
68
  // Prefix and rail SGR constants, previously re-exported by the deleted
38
69
  // palette.ts. Composing them from fgSequence/GLYPH here yields byte-identical
@@ -49,10 +80,10 @@ const USER_GLYPH = GLYPH.user;
49
80
 
50
81
  /**
51
82
  * An assistant turn is a sequence of text and tool segments interleaved in
52
- * pi-agent-core event order. pi-agent-core emits: `message_start`
53
- * `text_delta`+ `message_end` `tool_execution_*` (next) `message_start`
54
- * `text_delta`+ `message_end`, so tool calls always sit BETWEEN the
55
- * assistant's pre-tool narration and the post-tool summary. Storing a flat
83
+ * pi-agent-core event order. A tool turn emits assistant `message_update`
84
+ * events carrying `toolcall_*` formation, then `message_end`, then the
85
+ * `tool_execution_*` lifecycle before the next assistant message. Tool calls
86
+ * therefore sit BETWEEN the assistant's pre-tool narration and the post-tool summary. Storing a flat
56
87
  * `text` buffer + `tools[]` array (pre-refactor) collapsed that order: all
57
88
  * text across the turn concatenated into one line with every tool block
58
89
  * appended at the end. The segment list preserves the stream order instead.
@@ -62,7 +93,7 @@ const USER_GLYPH = GLYPH.user;
62
93
  * through the Markdown renderer. Partial markdown (unclosed fence, half-typed
63
94
  * bullet) would otherwise paint garbage at ~60 fps under streaming.
64
95
  */
65
- export type ReasoningTokenProvenance = "provider" | "estimated" | "mixed";
96
+ export type { ReasoningTokenProvenance } from "./status/index.js";
66
97
 
67
98
  export interface ChatPanelTurnUsage {
68
99
  inputTokens: number;
@@ -117,6 +148,10 @@ type ToolSegment = {
117
148
  result?: unknown;
118
149
  /** True once `tool_execution_end` has landed (success or error). */
119
150
  finished: boolean;
151
+ /** Pi streamed the call row before execution and later starts this same row. */
152
+ executionStarted: boolean;
153
+ /** The assistant stream closed this call's argument block. */
154
+ argsComplete: boolean;
120
155
  /**
121
156
  * True when the segment was force-settled without its own end event
122
157
  * (blocked at admission, aborted mid-batch, id reused). A late
@@ -128,8 +163,13 @@ type ToolSegment = {
128
163
  settledWithoutResult?: boolean | undefined;
129
164
  /** True when the finished result was an error. Meaningful only after `finished`. */
130
165
  isError: boolean;
131
- /** When true, render the full structured block instead of the collapsed subline. */
132
- expanded: boolean;
166
+ /**
167
+ * The operator's explicit fold for this block, or none. The effective state
168
+ * is this override when set, else what the transcript detail policy gives
169
+ * the call (through the tool's presentation once it has finished). Cleared
170
+ * when `/output` changes and when a session is switched in.
171
+ */
172
+ fold?: FoldOverride;
133
173
  /** Wall-clock start time captured by the chat panel for live duration display. */
134
174
  startedAtMs?: number;
135
175
  /** Completed call duration in milliseconds (event-supplied or measured locally). */
@@ -137,13 +177,15 @@ type ToolSegment = {
137
177
  /** Persisted result summary (bytes, truncated, offloadPath, observation) from the chat loop. */
138
178
  resultSummary?: Record<string, unknown> | undefined;
139
179
  /**
140
- * Latest cumulative partial output from `tool_execution_update`. Cleared
180
+ * Latest cumulative Pi result from `tool_execution_update`, including the
181
+ * display content and structured progress details. Cleared
141
182
  * back to `undefined` on `tool_execution_end` so the finished `result`
142
- * takes over. Only consumed when `!finished && expanded`. The explicit
183
+ * takes over. Only consumed while the call is in flight and its effective
184
+ * state is expanded. The explicit
143
185
  * `| undefined` is required under `exactOptionalPropertyTypes: true` so
144
186
  * the clear path can re-assign `undefined` without a `delete`.
145
187
  */
146
- partialOutput?: string | undefined;
188
+ partialResult?: unknown;
147
189
  /**
148
190
  * True while the call is parked at the permission gate. Set/cleared by
149
191
  * `tool_approval_state` events and cleared by any settle so a denied or
@@ -151,6 +193,8 @@ type ToolSegment = {
151
193
  * while `!finished`.
152
194
  */
153
195
  awaitingApproval?: boolean | undefined;
196
+ /** Live, redacted approval facts. Never reconstructed during replay. */
197
+ approvalView?: ApprovalRequestView | undefined;
154
198
  settlement?: "blocked" | "aborted" | "orphaned" | undefined;
155
199
  /**
156
200
  * The admission verdict's short reason, present only on a settlement the
@@ -158,6 +202,8 @@ type ToolSegment = {
158
202
  * was refused and leaves the operator no way to learn why.
159
203
  */
160
204
  blockReason?: string | undefined;
205
+ /** View-only marker: historical calls render mutation diffs without live color. */
206
+ replayed?: true;
161
207
  };
162
208
  /**
163
209
  * A turn's terminal-error marker (`[error] ...`, `[aborted] ...`,
@@ -170,8 +216,32 @@ type ErrorSegment = {
170
216
  kind: "error";
171
217
  text: string;
172
218
  };
173
- type AssistantSegment = TextSegment | ToolSegment | ErrorSegment;
174
- type ReplayBlockRenderer = (width: number) => string[];
219
+ /**
220
+ * One stretch of reasoning, in stream order with the text and tool segments
221
+ * around it. A turn used to hold one `thinking` string, so reasoning could only
222
+ * ever render in one place (above everything or below everything) and a
223
+ * turn that thought, wrote, thought again, called a tool, and thought once more
224
+ * had its reasoning pinned at the tail while the prose streamed in above it.
225
+ * Each stretch is its own segment now: it renders where it happened, and the
226
+ * one still open at the tail is the live indicator.
227
+ */
228
+ type ThinkingSegment = {
229
+ kind: "thinking";
230
+ text: string;
231
+ /** Closed by the first text, tool, or message_end that follows it. */
232
+ finalized: boolean;
233
+ /** Panel clock when the first delta of this stretch arrived. */
234
+ startedAtMs?: number;
235
+ /** The operator's explicit fold for this stretch, or none. */
236
+ fold?: FoldOverride;
237
+ };
238
+ type AssistantSegment = TextSegment | ToolSegment | ErrorSegment | ThinkingSegment;
239
+ /**
240
+ * A caller-rendered block receives the frame's transcript detail policy so a
241
+ * block that owns fold state (the operator's `!` bash row) resolves it the same
242
+ * way the panel resolves a model call. Blocks that ignore it are unaffected.
243
+ */
244
+ type ReplayBlockRenderer = (width: number, detail: TranscriptDetailPolicy) => string[];
175
245
  type AssistantStatusLine = { phase: StatusPhase; verb: string; toneHint: VerbRender["toneHint"] };
176
246
 
177
247
  type TranscriptEntry =
@@ -181,23 +251,13 @@ type TranscriptEntry =
181
251
  role: "assistant";
182
252
  segments: AssistantSegment[];
183
253
  /**
184
- * Raw thinking content from `thinking_delta` events plus
185
- * `thinking` blocks captured on `message_end`. Renders live while
186
- * the turn is pending: a folded `Thinking (N tokens)…` marker by
187
- * default, or the tail of the reasoning down a dim `│ ` rail if
188
- * expanded via `toggleLastThinking()` (Ctrl+T). Once the turn
189
- * settles it collapses to a static `Thinking...` marker (folded) or
190
- * a head-anchored rail (expanded), mirroring the pi-coding-agent
191
- * reference which streams thinking from the partial message.
192
- */
193
- thinking: string;
194
- /**
195
- * Whether the thinking block renders as the full body (true) or
196
- * the one-line dim marker (false/undefined). Toggled by
197
- * `toggleLastThinking()`. New thinking inherits the panel-level
198
- * visibility mode until Ctrl+T toggles it again.
254
+ * `segments.length` when the current model call began, so `message_end`
255
+ * can tell which segments belong to the message it is settling. A
256
+ * provider that delivers thinking only in the final message (no
257
+ * `thinking_delta`) gets its thinking segment inserted here, ahead of
258
+ * the text the same message produced, rather than at the tail.
199
259
  */
200
- expandedThinking?: boolean;
260
+ messageStartSegmentIndex?: number | undefined;
201
261
  pending: boolean;
202
262
  statusLine?: AssistantStatusLine | null | undefined;
203
263
  isError: boolean;
@@ -205,19 +265,53 @@ type TranscriptEntry =
205
265
  }
206
266
  /**
207
267
  * A dispatched worker's attributed block. The panel owns only the fold
208
- * state; `state` is the live object the worker-stream reducer mutates, so a
209
- * streaming delta reaches the screen without copying the entry per frame.
268
+ * override; `state` is the live object the worker-stream reducer mutates, so
269
+ * a streaming delta reaches the screen without copying the entry per frame.
210
270
  * The panel is told when that happened through `applyWorkerState`.
211
271
  */
212
- | { role: "worker"; state: WorkerEntryState; folded: boolean }
213
- | { role: "replayBlock"; renderBlock: ReplayBlockRenderer };
272
+ | { role: "worker"; state: WorkerEntryState; fold?: FoldOverride }
273
+ /**
274
+ * A block the caller renders itself. Most are settled the moment they are
275
+ * appended, but a few (the operator's `!` bash row) keep mutating the state
276
+ * their closure reads until the work behind them finishes. Such a block
277
+ * declares `isLive`, which keeps it out of the frozen prefix and keeps the
278
+ * panel's time-keyed tick running while it is unsettled.
279
+ */
280
+ | {
281
+ role: "replayBlock";
282
+ renderBlock: ReplayBlockRenderer;
283
+ isLive?: (() => boolean) | undefined;
284
+ fold?: ReplayBlockFoldControl | undefined;
285
+ };
214
286
 
215
287
  type WorkerTranscriptEntry = Extract<TranscriptEntry, { role: "worker" }>;
216
288
 
289
+ /**
290
+ * Fold state a caller-rendered block owns. The panel never stores it: the
291
+ * block's closure reads it when rendering, so the panel only needs to resolve
292
+ * and flip it when the operator uses an expand/collapse key, and to clear it
293
+ * when `/output` changes. Same tri-state as a tool segment: an override, or
294
+ * none, over the fold the policy gives the block.
295
+ */
296
+ export interface ReplayBlockFoldControl {
297
+ /** The fold the policy gives this block when no override is set. */
298
+ policyFold(detail: TranscriptDetailPolicy): Fold;
299
+ /** The operator's override, or none. */
300
+ fold(): FoldOverride;
301
+ setFold(fold: FoldOverride): void;
302
+ }
303
+
217
304
  export interface ChatPanel extends Component {
218
305
  appendUser(text: string): void;
219
- appendReplayBlock(renderBlock: ReplayBlockRenderer): void;
306
+ /**
307
+ * Append a caller-rendered block. Pass `isLive` when the closure reads state
308
+ * that keeps changing after the append, so the panel keeps re-rendering it
309
+ * instead of treating the first frame as final.
310
+ */
311
+ appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean, fold?: ReplayBlockFoldControl): void;
220
312
  applyEvent(event: ChatLoopEvent): void;
313
+ /** Mark a just-rehydrated tool segment so its mutation diff remains plain. */
314
+ markToolReplayed?(toolCallId: string): void;
221
315
  /**
222
316
  * Place or refresh a worker's block. The first call for an assignment
223
317
  * inserts the entry (agent origin nests under the tool segment named by
@@ -232,23 +326,37 @@ export interface ChatPanel extends Component {
232
326
  */
233
327
  workerStates(): ReadonlyArray<WorkerEntryState>;
234
328
  setStatusLine(line: AssistantStatusLine | null): void;
329
+ /**
330
+ * Publish the live run tally's reasoning projection. The pending entry's
331
+ * tail line reads this; passing null returns it to unmeasured, which is what
332
+ * an idle or just-started turn is.
333
+ */
334
+ setLiveReasoning(view: ReasoningUsageView | null): void;
335
+ /**
336
+ * Flip the newest foldable block (tool call, worker card, or fold-owning
337
+ * replay block) away from its effective state. The flip is an override
338
+ * over the transcript detail policy: under `/output verbose` it folds an
339
+ * open block, under `/output minimal` it opens a folded one.
340
+ */
235
341
  toggleLastToolExpanded(): boolean;
342
+ /** Set an explicit override on every tool, worker, and fold-owning block at once. */
236
343
  toggleAllToolsExpanded(): boolean;
237
344
  /**
238
- * Force every tool segment into its collapsed one-line form. Replay
239
- * (`rehydrateChatPanelFromTurns`) calls this so a resumed or forked
240
- * transcript reproduces the settled live view, where tools are collapsed to
241
- * their ledger summary rather than the expanded body. Idempotent.
345
+ * Drop every operator override so each block returns to what the transcript
346
+ * detail policy gives it. `/output` changes and session switches do this:
347
+ * the operator asked for a new baseline, and what they had opened belonged
348
+ * to the transcript they left. Idempotent.
242
349
  */
243
- collapseAllTools(): void;
350
+ clearFoldOverrides(): void;
244
351
  /**
245
- * Flip thinking-bearing assistant turns between the one-line dim marker
246
- * and the full rail-prefixed body. The target visibility is panel-level
247
- * sticky state, then applied to current thinking history so Ctrl+T behaves
248
- * like a transcript-level thinking visibility toggle.
352
+ * Flip the newest thinking stretch between the one-line dim marker
353
+ * and the full rail-prefixed body, as an override over the policy. A turn
354
+ * with no thinking yet is left alone; a new stretch inherits the policy.
249
355
  */
250
356
  toggleLastThinking(): boolean;
251
357
  toggleAllThinking(): boolean;
358
+ /** Whether live thinking would render open this frame, which presentation pacing consults. */
359
+ isThinkingExpanded(): boolean;
252
360
  /** Toggle whether expanded live tool bodies include cumulative partial output. */
253
361
  toggleLiveToolOutput(): boolean;
254
362
  /** Clears the visible transcript. /new uses this after rotating the session. */
@@ -328,39 +436,42 @@ function extractAssistantThinking(message: unknown): string {
328
436
  .join("");
329
437
  }
330
438
 
331
- function finiteToken(value: unknown): number {
332
- return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
333
- }
334
-
439
+ /**
440
+ * The panel's view of one assistant message's spend, folded through the same
441
+ * `foldMessageIntoRunTally` the status machine uses. The panel used to
442
+ * re-derive reasoning here with its own provider-lookup-then-estimate rule, so
443
+ * the transcript receipt and the footer could report the same turn differently.
444
+ */
335
445
  function assistantUsage(message: unknown): ChatPanelTurnUsage | undefined {
336
446
  if (!message || typeof message !== "object" || (message as { role?: unknown }).role !== "assistant") return undefined;
337
- const record = message as Record<string, unknown>;
338
- const usage = record.usage && typeof record.usage === "object" ? (record.usage as Record<string, unknown>) : undefined;
339
- const thinking = extractAssistantThinking(message);
340
- const reportedReasoning = usage ? extractReasoningTokens(usage) : null;
341
- const estimatedReasoning = reportedReasoning === null ? estimateReasoningTextTokens(thinking) : null;
342
- const inputTokens = finiteToken(usage?.input);
343
- const outputTokens = finiteToken(usage?.output);
344
- const cacheReadTokens = finiteToken(usage?.cacheRead);
345
- const cacheWriteTokens = finiteToken(usage?.cacheWrite);
447
+ const tally = foldMessageIntoRunTally(emptyRunTally(), message as AgentMessage);
448
+ const reasoning = reasoningFromTally(tally);
346
449
  if (
347
- inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0 &&
348
- reportedReasoning === null &&
349
- estimatedReasoning === null
450
+ tally.inputTokens + tally.outputTokens + tally.cacheReadTokens + tally.cacheWriteTokens === 0 &&
451
+ reasoning.provenance === "unmeasured"
350
452
  ) {
351
453
  return undefined;
352
454
  }
353
- const turnUsage: ChatPanelTurnUsage = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, modelCalls: 1 };
354
- if (reportedReasoning !== null) {
355
- turnUsage.reasoningTokens = reportedReasoning;
356
- turnUsage.reasoningTokenProvenance = "provider";
357
- } else if (estimatedReasoning !== null) {
358
- turnUsage.reasoningTokens = estimatedReasoning;
359
- turnUsage.reasoningTokenProvenance = "estimated";
455
+ const turnUsage: ChatPanelTurnUsage = {
456
+ inputTokens: tally.inputTokens,
457
+ outputTokens: tally.outputTokens,
458
+ cacheReadTokens: tally.cacheReadTokens,
459
+ cacheWriteTokens: tally.cacheWriteTokens,
460
+ modelCalls: 1,
461
+ };
462
+ if (reasoning.provenance !== "unmeasured") {
463
+ turnUsage.reasoningTokens = reasoning.tokens;
464
+ turnUsage.reasoningTokenProvenance = reasoning.provenance;
360
465
  }
361
466
  return turnUsage;
362
467
  }
363
468
 
469
+ /** Settled-turn adapter onto the shared projection; the panel's own summary shape. */
470
+ function reasoningFromTurnUsage(usage: ChatPanelTurnUsage | undefined): ReasoningUsageView {
471
+ if (!usage || usage.reasoningTokenProvenance === undefined) return UNMEASURED_REASONING;
472
+ return { tokens: Math.max(0, usage.reasoningTokens ?? 0), provenance: usage.reasoningTokenProvenance };
473
+ }
474
+
364
475
  function aggregateAssistantUsage(messages: unknown): ChatPanelTurnUsage | undefined {
365
476
  if (!Array.isArray(messages)) return undefined;
366
477
  const total: ChatPanelTurnUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
@@ -426,6 +537,24 @@ function scopeTerminalErrorAfterSuccessfulTool(
426
537
  return `[error] ${modelFailure}${detachedDispatchSucceeded ? "; detached runs continue" : ""}`;
427
538
  }
428
539
 
540
+ function hasThinking(entry: Extract<TranscriptEntry, { role: "assistant" }>): boolean {
541
+ return entry.segments.some((seg) => seg.kind === "thinking" && seg.text.length > 0);
542
+ }
543
+
544
+ /** The thinking segment still receiving deltas, which is always the tail. */
545
+ function openThinkingSegment(entry: Extract<TranscriptEntry, { role: "assistant" }>): ThinkingSegment | null {
546
+ const tail = entry.segments[entry.segments.length - 1];
547
+ return tail?.kind === "thinking" && !tail.finalized ? tail : null;
548
+ }
549
+
550
+ /** Index of the last thinking segment, which is where a settled turn's count chip rides. */
551
+ function lastThinkingIndex(entry: Extract<TranscriptEntry, { role: "assistant" }>): number {
552
+ for (let index = entry.segments.length - 1; index >= 0; index -= 1) {
553
+ if (entry.segments[index]?.kind === "thinking") return index;
554
+ }
555
+ return -1;
556
+ }
557
+
429
558
  function hasVisibleOutput(entry: Extract<TranscriptEntry, { role: "assistant" }>): boolean {
430
559
  for (const seg of entry.segments) {
431
560
  if (seg.kind === "tool") return true;
@@ -510,7 +639,7 @@ function renderTextSegmentLines(seg: TextSegment, width: number): string[] {
510
639
  return wrapped;
511
640
  }
512
641
  if (!seg.md) {
513
- seg.md = new Markdown(seg.text, 0, 0, CHAT_MARKDOWN_THEME);
642
+ seg.md = new Markdown(seg.text, 0, 0, CHAT_MARKDOWN_THEME, undefined, CHAT_MARKDOWN_OPTIONS);
514
643
  }
515
644
  // pi-tui Markdown right-pads lines to the render width. If a long streaming
516
645
  // reply has already scrolled, flipping the finalized segment from unpadded
@@ -558,49 +687,60 @@ function hangProseLines(lines: string[], firstPrefix?: string): string[] {
558
687
  */
559
688
  const THINKING_HIDDEN_LABEL = `Thinking${GLYPH.ellipsis}`;
560
689
  const THINKING_LINE_LIMIT = 12;
561
- const REASONING_CHARS_PER_TOKEN = 4;
562
690
 
563
- function estimateThinkingTokens(thinking: string): number {
564
- return estimateReasoningTextTokens(thinking) ?? Math.max(1, Math.round(thinking.length / REASONING_CHARS_PER_TOKEN));
691
+ function dimLine(text: string, width: number): string {
692
+ return `${DIM}${truncateToWidth(text, Math.max(1, width), GLYPH.ellipsis, false)}${RESET}`;
565
693
  }
566
694
 
567
- function reasoningDisplay(usage: ChatPanelTurnUsage | undefined, thinking: string): { tokens: number; marker: string } {
568
- if (usage?.reasoningTokens !== undefined) {
569
- return {
570
- tokens: usage.reasoningTokens,
571
- marker: usage.reasoningTokenProvenance === "provider" ? "provider-reported" : "mixed/estimated",
572
- };
573
- }
574
- return { tokens: estimateThinkingTokens(thinking), marker: "estimated" };
695
+ /**
696
+ * A closed thinking stretch's folded marker, in place in the segment order. The
697
+ * turn's count chip rides on the last marker of a settled turn (`view` is
698
+ * unmeasured everywhere else), and comes from the settled usage, never from
699
+ * measuring the excerpt the panel happens to be holding.
700
+ */
701
+ function renderSettledThinkingMarker(view: ReasoningUsageView, width: number): string {
702
+ const chip = formatReasoningChip(view, compactReasoningTokens);
703
+ return dimLine(
704
+ chip === null ? THINKING_HIDDEN_LABEL : `${THINKING_HIDDEN_LABEL} · ${chip} ${formatReasoningLabel(view)}`,
705
+ width,
706
+ );
575
707
  }
576
708
 
577
709
  /**
578
- * Render the assistant turn's thinking block. Collapsed (default) returns a
579
- * single dim `Thinking...` marker. Expanded returns the full body dimmed and
580
- * prefixed with a dim `│ ` rail, capped at `THINKING_LINE_LIMIT` lines with a
581
- * tail `... N more lines hidden` overflow message. Mirrors the tool toggle's
710
+ * The live turn's one reasoning line, rendered where the open thinking segment
711
+ * sits, which is the tail of the entry by construction: the first text, tool,
712
+ * or message_end closes it. Anchoring reasoning at the head put it above every
713
+ * streamed segment, so on a long turn the only progress indicator scrolled off
714
+ * the top; pinning one line at the tail put it below prose that streamed in
715
+ * after the model had already moved on, so the transcript read out of order.
716
+ *
717
+ * The count is whatever the run tally has folded so far, so between model calls
718
+ * the line states elapsed and nothing else. Visible thinking text is never
719
+ * counted: that number moved with how much reasoning the provider chose to
720
+ * display, not with what the turn spent.
721
+ */
722
+ function renderLiveReasoningLine(view: ReasoningUsageView, elapsedMs: number | undefined, width: number): string {
723
+ const chip = formatReasoningChip(view, compactReasoningTokens);
724
+ const head = chip === null ? THINKING_HIDDEN_LABEL : `Thinking · ${chip} ${formatReasoningLabel(view)}`;
725
+ const seconds = elapsedMs === undefined ? 0 : Math.floor(Math.max(0, elapsedMs) / 1000);
726
+ return dimLine(seconds > 0 ? `${head} · ${seconds}s` : head, width);
727
+ }
728
+
729
+ /**
730
+ * Render the expanded thinking body: the text dimmed behind a dim `│ ` rail,
731
+ * capped at `THINKING_LINE_LIMIT` lines. A streaming turn keeps the tail (the
732
+ * reasoning still arriving); a settled one keeps the head with a
733
+ * `... N more lines hidden` overflow message. Mirrors the tool toggle's
582
734
  * lab-notebook minimalism: no colored glyphs, no boxes.
583
735
  */
584
- function renderThinkingLines(
585
- thinking: string,
586
- expanded: boolean,
587
- width: number,
588
- streaming: boolean,
589
- usage?: ChatPanelTurnUsage,
590
- ): string[] {
736
+ function renderThinkingRail(thinking: string, width: number, streaming: boolean, unbounded = false): string[] {
591
737
  if (thinking.length === 0) return [];
592
- const dimWrap = (s: string): string => `${DIM}${s}${RESET}`;
593
- if (!expanded) {
594
- const lineBudget = Math.max(1, width);
595
- const display = reasoningDisplay(usage, thinking);
596
- const label = streaming
597
- ? `Thinking (${display.tokens} tokens)${display.marker === "provider-reported" ? "" : " ≈ estimated"}…`
598
- : THINKING_HIDDEN_LABEL;
599
- return [dimWrap(truncateToWidth(label, lineBudget, GLYPH.ellipsis, false))];
600
- }
601
738
  const splitLines = thinking.split("\n");
602
739
  let visible: string[];
603
- if (streaming) {
740
+ if (unbounded) {
741
+ // /export reproduces the whole transcript; the live cap is a screen budget.
742
+ visible = splitLines;
743
+ } else if (streaming) {
604
744
  if (splitLines.length > THINKING_LINE_LIMIT) {
605
745
  const hiddenCount = splitLines.length - THINKING_LINE_LIMIT;
606
746
  visible = [`… ${hiddenCount} earlier lines hidden`, ...splitLines.slice(-THINKING_LINE_LIMIT)];
@@ -632,9 +772,13 @@ function renderThinkingLines(
632
772
  * reported 717676 input tokens against a 500k window; it was 65 calls of about
633
773
  * 11k, which the preceding one-call turns had already shown.
634
774
  */
635
- function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity: OutputVerbosity): string[] {
636
- if (verbosity === "minimal") return [];
637
- if (verbosity === "default") {
775
+ function renderTurnUsageLine(
776
+ usage: ChatPanelTurnUsage,
777
+ width: number,
778
+ receipt: TranscriptDetailPolicy["receipt"],
779
+ ): string[] {
780
+ if (receipt === "none") return [];
781
+ if (receipt === "compact") {
638
782
  const receipt = truncateToWidth(
639
783
  ` turn · in ${usage.inputTokens} · out ${usage.outputTokens}`,
640
784
  width,
@@ -654,9 +798,10 @@ function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity
654
798
  // provenance of zero, and at narrow widths `reasoning 0 provider` orphaned
655
799
  // the word `provider` on its own line. Zero suppresses the whole suffix, the
656
800
  // same rule the caveat below already follows.
801
+ const view = reasoningFromTurnUsage(usage);
657
802
  const reason =
658
- usage.reasoningTokens !== undefined && usage.reasoningTokens > 0
659
- ? ` reasoning ${usage.reasoningTokenProvenance === "provider" ? `${usage.reasoningTokens} provider` : `≈${usage.reasoningTokens} estimated`}`
803
+ view.tokens > 0 && view.provenance !== "unmeasured"
804
+ ? ` reasoning ${view.provenance === "provider" ? "" : "≈"}${view.tokens} ${formatReasoningLabel(view)}`
660
805
  : "";
661
806
  const cache =
662
807
  usage.cacheReadTokens > 0 || usage.cacheWriteTokens > 0
@@ -665,10 +810,7 @@ function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity
665
810
  // The caveat is about reasoning text the panel displayed. A turn that spent
666
811
  // no reasoning tokens displayed none, so appending it there warned about
667
812
  // something absent and cost a wrapped line per turn at narrow widths.
668
- const caveat =
669
- usage.reasoningTokens !== undefined && usage.reasoningTokens > 0
670
- ? " · reasoning text is a UI excerpt, not a verification"
671
- : "";
813
+ const caveat = view.tokens > 0 ? " · reasoning text is a UI excerpt, not a verification" : "";
672
814
  return wrapTextWithAnsi(
673
815
  `${DIM} turn · in ${usage.inputTokens}${calls} · out ${usage.outputTokens}${cache}${reason}${caveat}${RESET}`,
674
816
  width,
@@ -685,6 +827,22 @@ function styleStatusVerb(text: string, toneHint: VerbRender["toneHint"]): string
685
827
  return `${DIM}${text}${RESET}`;
686
828
  }
687
829
 
830
+ /**
831
+ * The fold the policy gives a tool segment this frame: the running-tool rule
832
+ * while in flight, the tool body rule (through the tool's own presentation)
833
+ * once finished, and the error rule for a finished failure.
834
+ */
835
+ function policySegmentFold(seg: ToolSegment, detail: TranscriptDetailPolicy): Fold {
836
+ if (!seg.finished) return policyRunningToolFold(detail);
837
+ if (seg.isError && detail.errors === "body") return "expanded";
838
+ return policyToolFold(detail, toolPresentationPolicy(seg.name, seg.args));
839
+ }
840
+
841
+ /** Effective state of a tool segment: the operator's override, else the policy. */
842
+ function toolSegmentExpanded(seg: ToolSegment, detail: TranscriptDetailPolicy): boolean {
843
+ return resolveFold(seg.fold, policySegmentFold(seg, detail)) === "expanded";
844
+ }
845
+
688
846
  function renderToolSegmentLines(
689
847
  seg: ToolSegment,
690
848
  width: number,
@@ -692,17 +850,26 @@ function renderToolSegmentLines(
692
850
  latestHintToolId: string | null,
693
851
  nowMs: number,
694
852
  unboundedToolBodies: boolean,
695
- verbosity: OutputVerbosity,
853
+ detail: TranscriptDetailPolicy,
696
854
  liveToolOutput: boolean,
697
855
  ): string[] {
698
856
  const hintKey = seg.id === latestHintToolId ? expandKey : undefined;
699
- const expanded = verbosity === "verbose" || (verbosity !== "minimal" && seg.expanded);
857
+ const expanded = toolSegmentExpanded(seg, detail);
700
858
  const elapsedMs = seg.startedAtMs !== undefined ? Math.max(0, rawDurationMs(seg.startedAtMs, nowMs)) : undefined;
859
+ const phase: "forming" | "ready" | "running" = seg.executionStarted
860
+ ? "running"
861
+ : seg.argsComplete
862
+ ? "ready"
863
+ : "forming";
701
864
  // A parked call is not executing: the awaiting-approval line replaces the
702
865
  // counting elapsed spinner in both collapsed and expanded form (there is no
703
866
  // body or partial output to expand while the call sits at the gate).
704
867
  if (!seg.finished && seg.awaitingApproval === true) {
705
- return renderToolAwaitingApproval({ toolCallId: seg.id, toolName: seg.name, args: seg.args }, width);
868
+ return renderToolAwaitingApproval(
869
+ { toolCallId: seg.id, toolName: seg.name, args: seg.args },
870
+ width,
871
+ seg.approvalView,
872
+ );
706
873
  }
707
874
  if (!expanded) {
708
875
  return renderToolSubline(
@@ -718,20 +885,24 @@ function renderToolSegmentLines(
718
885
  outcome: seg.settlement,
719
886
  blockReason: seg.blockReason,
720
887
  }
721
- : { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs },
888
+ : { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs, phase },
722
889
  width,
723
890
  hintKey,
891
+ {
892
+ diffStyle: seg.replayed === true ? "plain" : "color",
893
+ foldedExtras: detail.toolBody === "folded" ? "none" : "per-tool",
894
+ },
724
895
  );
725
896
  }
726
897
  if (!seg.finished) {
727
- if (liveToolOutput && seg.partialOutput !== undefined) {
898
+ if (liveToolOutput && seg.partialResult !== undefined) {
728
899
  return renderToolStreamingExecution(
729
- { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs },
900
+ { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs, phase },
730
901
  width,
731
- seg.partialOutput,
902
+ seg.partialResult,
732
903
  );
733
904
  }
734
- const call = { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs };
905
+ const call = { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs, phase };
735
906
  return liveToolOutput ? renderToolCallHeader(call, width) : renderToolRunningStatus(call, width);
736
907
  }
737
908
  return renderToolExecution(
@@ -747,19 +918,22 @@ function renderToolSegmentLines(
747
918
  blockReason: seg.blockReason,
748
919
  },
749
920
  width,
750
- { unbounded: unboundedToolBodies },
921
+ { unbounded: unboundedToolBodies, diffStyle: seg.replayed === true ? "plain" : "color" },
751
922
  );
752
923
  }
753
924
 
754
925
  /**
755
- * Whether a worker block draws its one-line card this frame. `/output verbose`
756
- * opens every block and `/output minimal` folds every block, so the entry's own
757
- * fold decides only in between.
926
+ * Whether a worker block draws its one-line card this frame: the operator's
927
+ * override when set, else the policy's worker rule, which under the balanced
928
+ * level is the origin default (a run the model asked for folds).
758
929
  */
759
- function workerEntryFolded(entry: WorkerTranscriptEntry, verbosity: OutputVerbosity): boolean {
760
- if (verbosity === "verbose") return false;
761
- if (verbosity === "minimal") return true;
762
- return entry.folded;
930
+ function workerEntryFolded(entry: WorkerTranscriptEntry, detail: TranscriptDetailPolicy): boolean {
931
+ return resolveFold(entry.fold, policyWorkerFold(detail, workerAskedByModel(entry.state))) === "folded";
932
+ }
933
+
934
+ /** Effective state of one thinking stretch: the operator's override, else the policy. */
935
+ function thinkingExpanded(segment: ThinkingSegment, detail: TranscriptDetailPolicy): boolean {
936
+ return resolveFold(segment.fold, policyThinkingFold(detail)) === "expanded";
763
937
  }
764
938
 
765
939
  function renderEntryLines(
@@ -770,24 +944,27 @@ function renderEntryLines(
770
944
  latestFoldedWorkerId: string | null,
771
945
  nowMs: number,
772
946
  unboundedToolBodies: boolean,
773
- verbosity: OutputVerbosity,
947
+ detail: TranscriptDetailPolicy,
774
948
  liveToolOutput: boolean,
949
+ liveReasoning: ReasoningUsageView,
775
950
  ): string[] {
776
951
  if (entry.role === "replayBlock") {
777
- return entry.renderBlock(width);
952
+ return entry.renderBlock(width, detail);
778
953
  }
779
954
  if (entry.role === "user") {
780
955
  const contentWidth = Math.max(1, width - PROSE_GUTTER_WIDTH);
781
956
  const lines: string[] = [];
782
957
  for (const sourceLine of entry.text.split("\n")) lines.push(...wrapTextWithAnsi(sourceLine, contentWidth));
783
- return hangProseLines(lines, USER_PREFIX);
958
+ const rendered = hangProseLines(lines, USER_PREFIX);
959
+ if (rendered[0] !== undefined) rendered[0] = `${OSC133_PROMPT_START}${rendered[0]}`;
960
+ return rendered;
784
961
  }
785
962
  if (entry.role === "retryStatus") {
786
963
  return wrapTextWithAnsi(formatRetryStatus(entry.status), width);
787
964
  }
788
965
  if (entry.role === "worker") {
789
966
  return renderWorkerEntryLines(entry.state, width, {
790
- folded: workerEntryFolded(entry, verbosity),
967
+ folded: workerEntryFolded(entry, detail),
791
968
  ...(expandKey !== undefined && entry.state.assignmentId === latestFoldedWorkerId ? { expandKey } : {}),
792
969
  unbounded: unboundedToolBodies,
793
970
  });
@@ -796,38 +973,50 @@ function renderEntryLines(
796
973
  // A mid-turn notice splits the transcript, so the events after it open a
797
974
  // fresh entry that a stopped turn never fills; that entry used to reach the
798
975
  // tail below and print a lone agent bubble under the notice.
799
- if (
800
- !entry.pending &&
801
- entry.thinking.length === 0 &&
802
- entry.turnUsage === undefined &&
803
- !hasVisibleOutput(entry) &&
804
- entry.segments.length === 0
805
- ) {
976
+ if (!entry.pending && entry.turnUsage === undefined && !hasVisibleOutput(entry) && entry.segments.length === 0) {
806
977
  return [];
807
978
  }
808
979
  const lines: string[] = [];
809
- // Thinking renders BEFORE assistant text/tool segments so the folded marker
810
- // or expanded rail sits above the response, matching the order the
811
- // pi-coding-agent reference uses. It streams live while `pending === true`
812
- // (folded shows a dynamic token count; expanded tail-anchors the tail) and
813
- // collapses to a static marker / head-anchored rail once the turn settles.
814
- // The generic "thinking" status verb is suppressed while this marker is
815
- // active so only one indicator shows (see `shouldRenderStatus` below).
816
- if (entry.thinking.length > 0) {
817
- lines.push(
818
- ...renderThinkingLines(
819
- entry.thinking,
820
- verbosity === "verbose" || (verbosity !== "minimal" && entry.expandedThinking === true),
821
- width,
822
- entry.pending,
823
- entry.turnUsage,
824
- ),
825
- );
826
- }
980
+ // Reasoning renders in stream order, between the text and tool segments it
981
+ // came between. A closed stretch is a folded marker (or a head-anchored rail
982
+ // when expanded); the stretch still open while the turn is pending is the
983
+ // live indicator, with the tally's count and its own elapsed. The turn's
984
+ // settled count chip rides on the last marker once the turn has settled.
985
+ const chipIndex = entry.pending ? -1 : lastThinkingIndex(entry);
827
986
  const clioPrefix = entry.isError ? CLIO_PREFIX_ERROR : CLIO_PREFIX;
828
987
  const proseWidth = Math.max(1, width - PROSE_GUTTER_WIDTH);
829
988
  let labeled = false;
830
- for (const seg of entry.segments) {
989
+ let liveIndicatorShown = false;
990
+ let latestThinkingExpanded = policyThinkingFold(detail) === "expanded";
991
+ for (let segIndex = 0; segIndex < entry.segments.length; segIndex += 1) {
992
+ const seg = entry.segments[segIndex];
993
+ if (seg === undefined) continue;
994
+ if (seg.kind === "thinking") {
995
+ if (seg.text.length === 0) continue;
996
+ const thinkingExpandedNow = thinkingExpanded(seg, detail);
997
+ latestThinkingExpanded = thinkingExpandedNow;
998
+ const live = entry.pending && !seg.finalized;
999
+ if (thinkingExpandedNow) lines.push(...renderThinkingRail(seg.text, width, live, unboundedToolBodies));
1000
+ if (live) {
1001
+ liveIndicatorShown = true;
1002
+ // The bare marker level states that the model is thinking and
1003
+ // nothing else: no count, no elapsed. An operator who opened the
1004
+ // stretch anyway gets the progress line under the rail.
1005
+ lines.push(
1006
+ detail.thinking === "marker" && !thinkingExpandedNow
1007
+ ? dimLine(THINKING_HIDDEN_LABEL, width)
1008
+ : renderLiveReasoningLine(
1009
+ liveReasoning,
1010
+ seg.startedAtMs === undefined ? undefined : Math.max(0, nowMs - seg.startedAtMs),
1011
+ width,
1012
+ ),
1013
+ );
1014
+ } else if (!thinkingExpandedNow) {
1015
+ const view = segIndex === chipIndex ? reasoningFromTurnUsage(entry.turnUsage) : UNMEASURED_REASONING;
1016
+ lines.push(renderSettledThinkingMarker(view, width));
1017
+ }
1018
+ continue;
1019
+ }
831
1020
  if (seg.kind === "tool") {
832
1021
  lines.push(
833
1022
  ...renderToolSegmentLines(
@@ -837,7 +1026,7 @@ function renderEntryLines(
837
1026
  latestHintToolId,
838
1027
  nowMs,
839
1028
  unboundedToolBodies,
840
- verbosity,
1029
+ detail,
841
1030
  liveToolOutput,
842
1031
  ),
843
1032
  );
@@ -870,21 +1059,30 @@ function renderEntryLines(
870
1059
  lines.push(...hangProseLines(rendered));
871
1060
  }
872
1061
  }
873
- if (entry.turnUsage && !entry.pending) lines.push(...renderTurnUsageLine(entry.turnUsage, width, verbosity));
1062
+ // A thinking stretch closes when text or a tool follows it so the historical
1063
+ // marker stays in stream order. The turn-level reasoning projection is still
1064
+ // live, though, and must remain beside the current tail instead of scrolling
1065
+ // away with that marker. An open tail stretch already rendered this line.
1066
+ if (entry.pending && hasThinking(entry) && !liveIndicatorShown) {
1067
+ lines.push(
1068
+ detail.thinking === "marker" && !latestThinkingExpanded
1069
+ ? dimLine(THINKING_HIDDEN_LABEL, width)
1070
+ : renderLiveReasoningLine(liveReasoning, undefined, width),
1071
+ );
1072
+ liveIndicatorShown = true;
1073
+ }
1074
+ if (entry.turnUsage && !entry.pending) lines.push(...renderTurnUsageLine(entry.turnUsage, width, detail.receipt));
1075
+ // The open thinking segment's line is the one that speaks for reasoning while
1076
+ // the turn runs. The generic thinking verb is suppressed while it shows so
1077
+ // the entry never carries two indicators for the same thing.
874
1078
  const shouldRenderStatus =
875
1079
  entry.pending &&
876
1080
  entry.statusLine !== null &&
877
1081
  entry.statusLine !== undefined &&
878
1082
  !(entry.statusLine.phase === "writing" && hasStreamingText(entry)) &&
879
- !(entry.statusLine.phase === "thinking" && entry.thinking.length > 0);
880
- if (!labeled && !hasVisibleOutput(entry)) {
881
- lines.push(clioPrefix.trimEnd());
882
- if (shouldRenderStatus) {
883
- lines.push(
884
- `${STATUS_INDENT}${styleStatusVerb(entry.statusLine?.verb ?? "", entry.statusLine?.toneHint ?? "muted")}`,
885
- );
886
- }
887
- } else if (shouldRenderStatus) {
1083
+ !(entry.statusLine.phase === "thinking" && liveIndicatorShown);
1084
+ if (!labeled && !hasVisibleOutput(entry)) lines.push(clioPrefix.trimEnd());
1085
+ if (shouldRenderStatus) {
888
1086
  lines.push(`${STATUS_INDENT}${styleStatusVerb(entry.statusLine?.verb ?? "", entry.statusLine?.toneHint ?? "muted")}`);
889
1087
  }
890
1088
  return lines;
@@ -898,8 +1096,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
898
1096
  let cachedWidth: number | undefined;
899
1097
  let cachedLines: string[] = [];
900
1098
  let cachedExpandKey: string | undefined;
901
- let cachedVerbosity: OutputVerbosity | undefined;
1099
+ let cachedDetail: TranscriptDetailPolicy | undefined;
902
1100
  let cachedLiveToolOutput: boolean | undefined;
1101
+ /**
1102
+ * The verbosity the last frame rendered under, or null before any frame.
1103
+ * A change between frames is the operator asking for a new baseline
1104
+ * (`/output`, or Settings → Terminal), and every override goes with it.
1105
+ */
1106
+ let lastVerbosity: OutputVerbosity | undefined | null = null;
903
1107
  let cachedTick = 0;
904
1108
  /**
905
1109
  * Did the last executed render put a counting elapsed line on screen? It is
@@ -921,18 +1125,23 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
921
1125
  const entryCacheCapacity = (): number =>
922
1126
  Math.max(MIN_ENTRY_RENDER_CACHE, Math.min(MAX_ENTRY_RENDER_CACHE, transcript.length));
923
1127
  /**
924
- * Windowed-tail build (F19 on pi-tui 0.83 terms): the lines of every settled
1128
+ * Regular-screen windowed-tail build: the lines of every settled
925
1129
  * leading entry are baked into one frozen prefix, so a dirty frame re-renders
926
- * only the live tail and re-emits the prefix by reference. pi-tui still
927
- * receives the full line array, deliberately: the 0.83 renderer keeps every
1130
+ * only the live tail and re-emits the prefix by reference. TuiMainScreen still
1131
+ * receives the full line array, deliberately: the renderer keeps every
928
1132
  * line in `previousLines` and full-redraws (clearing scrollback) when the
929
- * head shrinks, so a real shrinking window needs the 0.84 ScrollView
930
- * architecture. The freeze is dropped whenever a frozen entry is invalidated
931
- * or the render key changes.
1133
+ * head shrinks. Fullscreen mode instead gives the transcript its own pi-tui
1134
+ * ScrollView. The freeze is dropped whenever a frozen entry is invalidated or
1135
+ * the render key changes.
932
1136
  */
933
1137
  let frozen: { lines: string[]; through: number; key: string } | null = null;
934
- let thinkingExpanded = false;
935
1138
  let liveToolOutput = true;
1139
+ /**
1140
+ * The run tally's reasoning, projected. It is panel-level rather than
1141
+ * per-entry because only the pending tail entry ever renders it, and that
1142
+ * entry is by definition unfrozen and uncached.
1143
+ */
1144
+ let liveReasoning: ReasoningUsageView = UNMEASURED_REASONING;
936
1145
  const unboundedToolBodies = options.unboundedToolBodies === true;
937
1146
 
938
1147
  const markDirty = (): void => {
@@ -964,6 +1173,32 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
964
1173
 
965
1174
  const now = (): number => options.now?.() ?? Date.now();
966
1175
 
1176
+ /** The policy for a frame or a keypress, from whatever the settings say right now. */
1177
+ const currentDetail = (): TranscriptDetailPolicy => transcriptDetail(options.getOutputVerbosity?.());
1178
+
1179
+ /**
1180
+ * Drop every operator override. Shared by the panel method and the
1181
+ * verbosity-change path in render, so both leave the same state behind.
1182
+ */
1183
+ const dropFoldOverrides = (): void => {
1184
+ for (const entry of transcript) {
1185
+ if (entry.role === "replayBlock") {
1186
+ entry.fold?.setFold(undefined);
1187
+ continue;
1188
+ }
1189
+ if (entry.role === "worker") {
1190
+ entry.fold = undefined;
1191
+ continue;
1192
+ }
1193
+ if (entry.role !== "assistant") continue;
1194
+ for (const seg of entry.segments) {
1195
+ if (seg.kind === "tool" || seg.kind === "thinking") seg.fold = undefined;
1196
+ }
1197
+ }
1198
+ clearRenderCaches();
1199
+ markDirty();
1200
+ };
1201
+
967
1202
  /**
968
1203
  * Force an in-flight tool segment to a settled error line. A call blocked at
969
1204
  * admission (loop guard, safety) or one whose `tool_execution_end` never
@@ -983,8 +1218,9 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
983
1218
  if (seg.result === undefined)
984
1219
  seg.result = "(no result: the call did not complete; execution was aborted, blocked, or orphaned)";
985
1220
  seg.settlement = settlement;
986
- seg.partialOutput = undefined;
1221
+ seg.partialResult = undefined;
987
1222
  seg.awaitingApproval = undefined;
1223
+ seg.approvalView = undefined;
988
1224
  };
989
1225
 
990
1226
  /**
@@ -1025,8 +1261,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1025
1261
  const entry: Extract<TranscriptEntry, { role: "assistant" }> = {
1026
1262
  role: "assistant",
1027
1263
  segments: [],
1028
- thinking: "",
1029
- expandedThinking: thinkingExpanded,
1030
1264
  pending: false,
1031
1265
  isError: false,
1032
1266
  };
@@ -1034,9 +1268,34 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1034
1268
  return entry;
1035
1269
  };
1036
1270
 
1271
+ /**
1272
+ * Close the thinking stretch at the tail, if one is open. Anything that
1273
+ * follows reasoning in the stream (text, a tool call, the message settling)
1274
+ * ends that stretch; a later `thinking_delta` opens a new segment after it,
1275
+ * so the transcript keeps the order the model actually worked in.
1276
+ */
1277
+ const closeOpenThinking = (entry: Extract<TranscriptEntry, { role: "assistant" }>): void => {
1278
+ const open = openThinkingSegment(entry);
1279
+ if (open === null) return;
1280
+ open.finalized = true;
1281
+ invalidateEntryCache(entry);
1282
+ };
1283
+
1284
+ const appendThinkingDelta = (entry: Extract<TranscriptEntry, { role: "assistant" }>, delta: string): void => {
1285
+ if (delta.length === 0) return;
1286
+ invalidateEntryCache(entry);
1287
+ const open = openThinkingSegment(entry);
1288
+ if (open !== null) {
1289
+ open.text += delta;
1290
+ return;
1291
+ }
1292
+ entry.segments.push({ kind: "thinking", text: delta, finalized: false, startedAtMs: now() });
1293
+ };
1294
+
1037
1295
  const appendTextDelta = (entry: Extract<TranscriptEntry, { role: "assistant" }>, delta: string): void => {
1038
1296
  if (delta.length === 0) return;
1039
1297
  invalidateEntryCache(entry);
1298
+ closeOpenThinking(entry);
1040
1299
  const tail = entry.segments[entry.segments.length - 1];
1041
1300
  if (tail && tail.kind === "text" && !tail.finalized) {
1042
1301
  tail.text += delta;
@@ -1046,15 +1305,22 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1046
1305
  };
1047
1306
 
1048
1307
  /**
1049
- * Canonicalize a streamed text segment from a completed assistant message.
1050
- * When streaming produced a prefix of the final text (the common case),
1051
- * the tail segment is overwritten and flipped to finalized so the next
1052
- * render pipes it through Markdown. When the message arrived fully formed
1053
- * with no deltas (non-streaming test path, synthetic notices), a fresh
1054
- * finalized text segment is appended after any tool segments that may
1055
- * have landed in this turn already. `replaceTail` forces the overwrite for
1056
- * messages the chat loop rewrote after streaming (locked-turn markup
1057
- * sanitation): the streamed tail is dead text there, not a prefix.
1308
+ * Canonicalize the streamed text of a completed assistant message.
1309
+ *
1310
+ * The streamed text is wherever this message put it, not necessarily at the
1311
+ * tail: a message that thinks, writes, and thinks again leaves its text
1312
+ * behind a thinking segment. Looking only at the tail appended the message
1313
+ * text a second time under the reasoning marker, so the answer read twice.
1314
+ *
1315
+ * One streamed segment that is a prefix of the final text (the common case)
1316
+ * is overwritten in place and flipped to finalized so the next render pipes
1317
+ * it through Markdown. Several streamed segments (text split by reasoning)
1318
+ * are each finalized where they stand; the deltas already are the text.
1319
+ * When the message arrived fully formed with no deltas (non-streaming
1320
+ * path, synthetic notices, replay), a fresh finalized segment is appended.
1321
+ * `replaceTail` forces the overwrite for messages the chat loop rewrote
1322
+ * after streaming (locked-turn markup sanitation): the streamed text is dead
1323
+ * there, not a prefix.
1058
1324
  */
1059
1325
  const canonicalizeMessageText = (
1060
1326
  entry: Extract<TranscriptEntry, { role: "assistant" }>,
@@ -1062,15 +1328,36 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1062
1328
  replaceTail = false,
1063
1329
  ): void => {
1064
1330
  if (text.length === 0) return;
1065
- const tail = entry.segments[entry.segments.length - 1];
1066
- if (tail?.kind === "text" && !tail.finalized && (replaceTail || text.startsWith(tail.text))) {
1067
- tail.text = text;
1068
- tail.finalized = true;
1331
+ closeOpenThinking(entry);
1332
+ const messageStart = Math.min(entry.messageStartSegmentIndex ?? 0, entry.segments.length);
1333
+ const streamed: TextSegment[] = [];
1334
+ for (let index = messageStart; index < entry.segments.length; index += 1) {
1335
+ const segment = entry.segments[index];
1336
+ if (segment?.kind === "text" && !segment.finalized) streamed.push(segment);
1337
+ }
1338
+ const finalize = (segment: TextSegment, value: string): void => {
1339
+ segment.text = value;
1340
+ segment.finalized = true;
1069
1341
  // The streaming wrap cache assumes append-only text. This is the one
1070
1342
  // path that rewrites it wholesale, and finalized segments render through
1071
1343
  // Markdown instead, so the cache is dead here either way.
1072
- delete tail.wrapCache;
1073
- if (tail.md) tail.md.setText(text);
1344
+ delete segment.wrapCache;
1345
+ if (segment.md) segment.md.setText(value);
1346
+ };
1347
+ if (replaceTail && streamed.length > 0) {
1348
+ const [first, ...rest] = streamed;
1349
+ if (first) finalize(first, text);
1350
+ for (const dead of rest) entry.segments.splice(entry.segments.indexOf(dead), 1);
1351
+ return;
1352
+ }
1353
+ if (streamed.length === 1 && streamed[0] !== undefined) {
1354
+ const only = streamed[0];
1355
+ if (text.startsWith(only.text)) {
1356
+ finalize(only, text);
1357
+ return;
1358
+ }
1359
+ } else if (streamed.length > 1) {
1360
+ for (const segment of streamed) finalize(segment, segment.text);
1074
1361
  return;
1075
1362
  }
1076
1363
  entry.segments.push({ kind: "text", text, finalized: true });
@@ -1089,25 +1376,30 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1089
1376
  };
1090
1377
 
1091
1378
  /**
1092
- * Who advertises the fold key this frame: the newest folded worker card, or
1093
- * the newest finished collapsed tool subline with no worker card behind it.
1379
+ * Who advertises the fold key this frame: the newest worker card whose
1380
+ * effective state is folded, or the newest finished tool subline whose
1381
+ * effective state is folded with no worker card behind it. Effective means
1382
+ * override-or-policy, so the hint follows the block whatever the verbosity.
1094
1383
  * One surface at most, because the key reaches the newest foldable thing of
1095
1384
  * either kind, and a chord shown anywhere else would open something the
1096
1385
  * operator was not looking at. An already-open newest card advertises
1097
1386
  * nothing, since folding it again needs no invitation.
1098
1387
  */
1099
- const expandHintOwner = (): { toolId: string | null; workerId: string | null } => {
1388
+ const expandHintOwner = (detail: TranscriptDetailPolicy): { toolId: string | null; workerId: string | null } => {
1100
1389
  let workerMayOwn = true;
1101
1390
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1102
1391
  const entry = transcript[entryIndex];
1103
1392
  if (entry?.role === "worker") {
1104
- return { toolId: null, workerId: workerMayOwn && entry.folded ? entry.state.assignmentId : null };
1393
+ return {
1394
+ toolId: null,
1395
+ workerId: workerMayOwn && workerEntryFolded(entry, detail) ? entry.state.assignmentId : null,
1396
+ };
1105
1397
  }
1106
1398
  if (entry?.role !== "assistant") continue;
1107
1399
  for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1108
1400
  const seg = entry.segments[segIndex];
1109
1401
  if (seg?.kind !== "tool") continue;
1110
- if (seg.finished && !seg.expanded) return { toolId: seg.id, workerId: null };
1402
+ if (seg.finished && !toolSegmentExpanded(seg, detail)) return { toolId: seg.id, workerId: null };
1111
1403
  workerMayOwn = false;
1112
1404
  }
1113
1405
  }
@@ -1142,18 +1434,25 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1142
1434
 
1143
1435
  /** True when the entry renders at least one counting elapsed line this frame. */
1144
1436
  const entryHasRunningTool = (entry: TranscriptEntry): boolean =>
1145
- entry.role === "assistant" &&
1146
- entry.segments.some((segment) => segment.kind === "tool" && !segment.finished && segment.startedAtMs !== undefined);
1437
+ (entry.role === "assistant" &&
1438
+ (entry.segments.some(
1439
+ (segment) => segment.kind === "tool" && !segment.finished && segment.startedAtMs !== undefined,
1440
+ // The live reasoning line counts its own elapsed, so a turn that is
1441
+ // only thinking still needs the time-keyed render key.
1442
+ ) ||
1443
+ (entry.pending && openThinkingSegment(entry)?.startedAtMs !== undefined))) ||
1444
+ (entry.role === "replayBlock" && entry.isLive?.() === true);
1147
1445
 
1148
1446
  /**
1149
1447
  * Settled entries whose render is a pure function of the base key. A live
1150
1448
  * worker block is excluded for the same reason a running tool is: the
1151
1449
  * reducer mutates its state object in place, so a cached render would keep
1152
- * serving the answer as it looked several deltas ago.
1450
+ * serving the answer as it looked several deltas ago. A replay block that
1451
+ * declares itself live is excluded on the same grounds.
1153
1452
  */
1154
1453
  const entryIsStable = (entry: TranscriptEntry): boolean =>
1155
1454
  entry.role === "user" ||
1156
- entry.role === "replayBlock" ||
1455
+ (entry.role === "replayBlock" && entry.isLive?.() !== true) ||
1157
1456
  (entry.role === "worker" && !entry.state.pending) ||
1158
1457
  (entry.role === "assistant" &&
1159
1458
  !entry.pending &&
@@ -1162,7 +1461,12 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1162
1461
  const render = (width: number): string[] => {
1163
1462
  const startedAt = performance.now();
1164
1463
  const expandKey = resolveExpandKey();
1165
- const verbosity = options.getOutputVerbosity?.() ?? "default";
1464
+ const verbosity = options.getOutputVerbosity?.();
1465
+ // A new verbosity is a new baseline: the operator's per-block overrides
1466
+ // were answers to the old one, so they go before the frame is built.
1467
+ if (lastVerbosity !== null && verbosity !== lastVerbosity) dropFoldOverrides();
1468
+ lastVerbosity = verbosity;
1469
+ const detail = transcriptDetail(verbosity);
1166
1470
  const nowMs = now();
1167
1471
  // `dirty` is set on mutation and never on a tick, so without time in the
1168
1472
  // key a running tool's elapsed counter advanced only when something
@@ -1180,14 +1484,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1180
1484
  !dirty &&
1181
1485
  cachedWidth === width &&
1182
1486
  cachedExpandKey === expandKey &&
1183
- cachedVerbosity === verbosity &&
1487
+ cachedDetail === detail &&
1184
1488
  cachedLiveToolOutput === liveToolOutput &&
1185
1489
  cachedTick === tick
1186
1490
  ) {
1187
1491
  options.onRenderMetrics?.({ durationMs: performance.now() - startedAt, cacheHit: true, entriesRendered: 0 });
1188
1492
  return cachedLines;
1189
1493
  }
1190
- const { toolId: latestHintToolId, workerId: latestFoldedWorkerId } = expandHintOwner();
1494
+ const { toolId: latestHintToolId, workerId: latestFoldedWorkerId } = expandHintOwner(detail);
1191
1495
  // The hint id is deliberately NOT part of the shared key: it changes on
1192
1496
  // every finished collapsed tool, and keying every entry on it re-rendered
1193
1497
  // the entire transcript per tool completion. Only the entry that contains
@@ -1196,7 +1500,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1196
1500
  // bytes at every tick, and keying it on time would drop the entry cache
1197
1501
  // and the frozen prefix ten times a second. Only the panel-level guard
1198
1502
  // above is time-keyed, so a tick re-renders the live tail and nothing else.
1199
- const baseKey = `${width}|${expandKey ?? ""}|${verbosity}|${liveToolOutput}`;
1503
+ const baseKey = `${width}|${expandKey ?? ""}|${detail.toolBody}:${detail.runningTool}:${detail.thinking}:${detail.worker}:${detail.receipt}:${detail.errors}|${liveToolOutput}`;
1200
1504
  const capacity = entryCacheCapacity();
1201
1505
  if (frozen !== null && frozen.key !== baseKey) frozen = null;
1202
1506
  const out: string[] = frozen === null ? [] : frozen.lines.slice();
@@ -1222,8 +1526,8 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1222
1526
  const stacksOnPrevious =
1223
1527
  entry.role === "worker" &&
1224
1528
  previous?.role === "worker" &&
1225
- workerEntryFolded(entry, verbosity) &&
1226
- workerEntryFolded(previous, verbosity);
1529
+ workerEntryFolded(entry, detail) &&
1530
+ workerEntryFolded(previous, detail);
1227
1531
  if (i > 0 && !stacksOnPrevious) out.push("");
1228
1532
  const containsHint =
1229
1533
  entryContainsHint(entry, latestHintToolId) ||
@@ -1245,8 +1549,9 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1245
1549
  latestFoldedWorkerId,
1246
1550
  nowMs,
1247
1551
  unboundedToolBodies,
1248
- verbosity,
1552
+ detail,
1249
1553
  liveToolOutput,
1554
+ liveReasoning,
1250
1555
  );
1251
1556
  for (const line of renderedEntry) out.push(line);
1252
1557
  if (cacheable) {
@@ -1269,7 +1574,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1269
1574
  cachedLines = out;
1270
1575
  cachedWidth = width;
1271
1576
  cachedExpandKey = expandKey;
1272
- cachedVerbosity = verbosity;
1577
+ cachedDetail = detail;
1273
1578
  cachedLiveToolOutput = liveToolOutput;
1274
1579
  cachedTick = tick;
1275
1580
  renderedRunningTool = sawRunningTool;
@@ -1283,8 +1588,8 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1283
1588
  transcript.push({ role: "user", text });
1284
1589
  markDirty();
1285
1590
  },
1286
- appendReplayBlock(renderBlock: ReplayBlockRenderer): void {
1287
- transcript.push({ role: "replayBlock", renderBlock });
1591
+ appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean, fold?: ReplayBlockFoldControl): void {
1592
+ transcript.push({ role: "replayBlock", renderBlock, isLive, fold });
1288
1593
  markDirty();
1289
1594
  },
1290
1595
  applyWorkerState(state: WorkerEntryState): void {
@@ -1296,7 +1601,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1296
1601
  markDirty();
1297
1602
  return;
1298
1603
  }
1299
- const entry: WorkerTranscriptEntry = { role: "worker", state, folded: workerAskedByModel(state) };
1604
+ const entry: WorkerTranscriptEntry = { role: "worker", state };
1300
1605
  workerEntries.set(state.assignmentId, entry);
1301
1606
  const at = workerInsertionIndex(state);
1302
1607
  if (at === null) {
@@ -1313,13 +1618,26 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1313
1618
  return [...workerEntries.values()].map((entry) => entry.state);
1314
1619
  },
1315
1620
  toggleLastToolExpanded(): boolean {
1316
- // Ctrl+O owns the newest foldable thing, whichever kind it is. A worker
1621
+ // The key owns the newest foldable thing, whichever kind it is. A worker
1317
1622
  // block the operator just watched land is what they mean by "expand
1318
- // that", not the tool call two screens up that spawned it.
1623
+ // that", not the tool call two screens up that spawned it. Every flip
1624
+ // is an override away from the block's effective state, so the same
1625
+ // key opens a folded block under minimal and folds an open one under
1626
+ // verbose.
1627
+ const detail = currentDetail();
1319
1628
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1320
1629
  const entry = transcript[entryIndex];
1630
+ // A caller-rendered block that owns fold state (the operator's own
1631
+ // `!` bash row) is foldable too, and it is usually the newest thing
1632
+ // on screen when the key is pressed.
1633
+ if (entry?.role === "replayBlock" && entry.fold !== undefined) {
1634
+ entry.fold.setFold(toggledFold(resolveFold(entry.fold.fold(), entry.fold.policyFold(detail))));
1635
+ clearRenderCaches();
1636
+ markDirty();
1637
+ return true;
1638
+ }
1321
1639
  if (entry?.role === "worker") {
1322
- entry.folded = !entry.folded;
1640
+ entry.fold = workerEntryFolded(entry, detail) ? "expanded" : "folded";
1323
1641
  clearRenderCaches();
1324
1642
  markDirty();
1325
1643
  return true;
@@ -1328,7 +1646,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1328
1646
  for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1329
1647
  const seg = entry.segments[segIndex];
1330
1648
  if (seg?.kind !== "tool") continue;
1331
- seg.expanded = !seg.expanded;
1649
+ seg.fold = toolSegmentExpanded(seg, detail) ? "folded" : "expanded";
1332
1650
  clearRenderCaches();
1333
1651
  markDirty();
1334
1652
  return true;
@@ -1337,9 +1655,15 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1337
1655
  return false;
1338
1656
  },
1339
1657
  toggleAllToolsExpanded(): boolean {
1658
+ const detail = currentDetail();
1340
1659
  const tools: ToolSegment[] = [];
1341
1660
  const workers: WorkerTranscriptEntry[] = [];
1661
+ const blocks: ReplayBlockFoldControl[] = [];
1342
1662
  for (const entry of transcript) {
1663
+ if (entry.role === "replayBlock") {
1664
+ if (entry.fold !== undefined) blocks.push(entry.fold);
1665
+ continue;
1666
+ }
1343
1667
  if (entry.role === "worker") {
1344
1668
  workers.push(entry);
1345
1669
  continue;
@@ -1349,61 +1673,70 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1349
1673
  if (seg.kind === "tool") tools.push(seg);
1350
1674
  }
1351
1675
  }
1352
- if (tools.length === 0 && workers.length === 0) return false;
1353
- const expand = tools.some((seg) => !seg.expanded) || workers.some((entry) => entry.folded);
1354
- for (const seg of tools) seg.expanded = expand;
1355
- for (const entry of workers) entry.folded = !expand;
1676
+ if (tools.length === 0 && workers.length === 0 && blocks.length === 0) return false;
1677
+ // One folded block anywhere means "open everything"; otherwise fold
1678
+ // everything. Either way every block gets an explicit override.
1679
+ const expand =
1680
+ tools.some((seg) => !toolSegmentExpanded(seg, detail)) ||
1681
+ workers.some((entry) => workerEntryFolded(entry, detail)) ||
1682
+ blocks.some((fold) => resolveFold(fold.fold(), fold.policyFold(detail)) === "folded");
1683
+ const next: Fold = expand ? "expanded" : "folded";
1684
+ for (const seg of tools) seg.fold = next;
1685
+ for (const entry of workers) entry.fold = next;
1686
+ for (const fold of blocks) fold.setFold(next);
1356
1687
  clearRenderCaches();
1357
1688
  markDirty();
1358
1689
  return true;
1359
1690
  },
1360
- collapseAllTools(): void {
1361
- for (const entry of transcript) {
1362
- if (entry.role === "worker") {
1363
- // The settled view for a worker is its origin default, not
1364
- // universally folded: the operator's own run is the one block
1365
- // /run exists to show them.
1366
- entry.folded = workerAskedByModel(entry.state);
1367
- continue;
1368
- }
1369
- if (entry.role !== "assistant") continue;
1370
- for (const seg of entry.segments) {
1371
- if (seg.kind === "tool") seg.expanded = false;
1372
- }
1373
- }
1374
- clearRenderCaches();
1375
- markDirty();
1691
+ clearFoldOverrides(): void {
1692
+ dropFoldOverrides();
1376
1693
  },
1377
1694
  toggleLastThinking(): boolean {
1695
+ const detail = currentDetail();
1378
1696
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1379
1697
  const entry = transcript[entryIndex];
1380
1698
  if (entry?.role !== "assistant") continue;
1381
- if (entry.thinking.length === 0) continue;
1382
- entry.expandedThinking = entry.expandedThinking !== true;
1383
- thinkingExpanded = entry.expandedThinking === true;
1384
- clearRenderCaches();
1385
- markDirty();
1386
- return true;
1699
+ for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1700
+ const segment = entry.segments[segIndex];
1701
+ if (segment?.kind !== "thinking" || segment.text.length === 0) continue;
1702
+ segment.fold = thinkingExpanded(segment, detail) ? "folded" : "expanded";
1703
+ clearRenderCaches();
1704
+ markDirty();
1705
+ return true;
1706
+ }
1387
1707
  }
1388
- thinkingExpanded = !thinkingExpanded;
1389
- return true;
1708
+ return false;
1390
1709
  },
1391
1710
  toggleAllThinking(): boolean {
1392
- const entries: Array<Extract<TranscriptEntry, { role: "assistant" }>> = [];
1711
+ const detail = currentDetail();
1712
+ const segments: ThinkingSegment[] = [];
1393
1713
  for (const entry of transcript) {
1394
- if (entry.role === "assistant" && entry.thinking.length > 0) entries.push(entry);
1395
- }
1396
- if (entries.length === 0) {
1397
- thinkingExpanded = !thinkingExpanded;
1398
- return true;
1714
+ if (entry.role !== "assistant") continue;
1715
+ for (const segment of entry.segments) {
1716
+ if (segment.kind === "thinking" && segment.text.length > 0) segments.push(segment);
1717
+ }
1399
1718
  }
1400
- const expand = entries.some((entry) => entry.expandedThinking !== true);
1401
- for (const entry of entries) entry.expandedThinking = expand;
1402
- thinkingExpanded = expand;
1719
+ if (segments.length === 0) return false;
1720
+ const expand = segments.some((segment) => !thinkingExpanded(segment, detail));
1721
+ for (const segment of segments) segment.fold = expand ? "expanded" : "folded";
1403
1722
  clearRenderCaches();
1404
1723
  markDirty();
1405
1724
  return true;
1406
1725
  },
1726
+ isThinkingExpanded(): boolean {
1727
+ // The live stretch lives on the newest assistant entry; its own override,
1728
+ // if any, is the one that applies. Before a stretch arrives, the policy answers.
1729
+ const detail = currentDetail();
1730
+ const index = lastAssistantIndex(transcript);
1731
+ const entry = index === null ? undefined : transcript[index];
1732
+ if (entry?.role === "assistant") {
1733
+ for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1734
+ const segment = entry.segments[segIndex];
1735
+ if (segment?.kind === "thinking" && segment.text.length > 0) return thinkingExpanded(segment, detail);
1736
+ }
1737
+ }
1738
+ return policyThinkingFold(detail) === "expanded";
1739
+ },
1407
1740
  toggleLiveToolOutput(): boolean {
1408
1741
  liveToolOutput = !liveToolOutput;
1409
1742
  markDirty();
@@ -1412,9 +1745,17 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1412
1745
  reset(): void {
1413
1746
  transcript.length = 0;
1414
1747
  workerEntries.clear();
1748
+ liveReasoning = UNMEASURED_REASONING;
1415
1749
  clearRenderCaches();
1416
1750
  markDirty();
1417
1751
  },
1752
+ markToolReplayed(toolCallId: string): void {
1753
+ const owner = findToolSegmentOwner(toolCallId);
1754
+ if (!owner || owner.segment.finished) return;
1755
+ invalidateEntryCache(owner.entry);
1756
+ owner.segment.replayed = true;
1757
+ markDirty();
1758
+ },
1418
1759
  applyEvent(event: ChatLoopEvent): void {
1419
1760
  if (event.type === "agent_status") {
1420
1761
  return;
@@ -1439,6 +1780,50 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1439
1780
  markDirty();
1440
1781
  return;
1441
1782
  }
1783
+ if (event.type === "message_update") {
1784
+ const assistantEvent = event.assistantMessageEvent as {
1785
+ type?: unknown;
1786
+ contentIndex?: unknown;
1787
+ partial?: { content?: unknown };
1788
+ };
1789
+ if (
1790
+ assistantEvent.type !== "toolcall_start" &&
1791
+ assistantEvent.type !== "toolcall_delta" &&
1792
+ assistantEvent.type !== "toolcall_end"
1793
+ ) {
1794
+ return;
1795
+ }
1796
+ const index = typeof assistantEvent.contentIndex === "number" ? assistantEvent.contentIndex : -1;
1797
+ const content = Array.isArray(assistantEvent.partial?.content) ? assistantEvent.partial.content : [];
1798
+ const block = content[index];
1799
+ if (block === null || typeof block !== "object" || Array.isArray(block)) return;
1800
+ const streamed = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown };
1801
+ if (streamed.type !== "toolCall" || typeof streamed.id !== "string" || streamed.id.length === 0) return;
1802
+ const owner = findToolSegmentOwner(streamed.id);
1803
+ const existing = owner?.segment;
1804
+ if (existing && !existing.finished && !existing.executionStarted) {
1805
+ if (owner) invalidateEntryCache(owner.entry);
1806
+ existing.name = typeof streamed.name === "string" && streamed.name.length > 0 ? streamed.name : existing.name;
1807
+ existing.args = streamed.arguments ?? existing.args;
1808
+ existing.argsComplete = assistantEvent.type === "toolcall_end";
1809
+ } else if (existing === undefined) {
1810
+ const assistant = ensureAssistant();
1811
+ assistant.pending = true;
1812
+ closeOpenThinking(assistant);
1813
+ assistant.segments.push({
1814
+ kind: "tool",
1815
+ id: streamed.id,
1816
+ name: typeof streamed.name === "string" && streamed.name.length > 0 ? streamed.name : "tool",
1817
+ args: streamed.arguments ?? {},
1818
+ finished: false,
1819
+ executionStarted: false,
1820
+ argsComplete: assistantEvent.type === "toolcall_end",
1821
+ isError: false,
1822
+ });
1823
+ }
1824
+ markDirty();
1825
+ return;
1826
+ }
1442
1827
  if (event.type === "text_delta") {
1443
1828
  const assistant = ensureAssistant();
1444
1829
  assistant.pending = true;
@@ -1447,20 +1832,34 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1447
1832
  return;
1448
1833
  }
1449
1834
  if (event.type === "thinking_delta") {
1450
- // Capture for downstream consumers but never render inline.
1835
+ // The text itself is never rendered unless the operator expands it;
1836
+ // the segment is what keeps reasoning in its place in the stream.
1451
1837
  const assistant = ensureAssistant();
1452
1838
  assistant.pending = true;
1453
- assistant.thinking += event.delta;
1454
- assistant.expandedThinking = thinkingExpanded;
1839
+ appendThinkingDelta(assistant, event.delta);
1455
1840
  markDirty();
1456
1841
  return;
1457
1842
  }
1458
1843
  if (event.type === "message_start" && event.message.role === "assistant") {
1459
- ensureAssistant().pending = true;
1844
+ const assistant = ensureAssistant();
1845
+ assistant.pending = true;
1846
+ assistant.messageStartSegmentIndex = assistant.segments.length;
1460
1847
  markDirty();
1461
1848
  return;
1462
1849
  }
1463
1850
  if (event.type === "tool_execution_start") {
1851
+ const streamedOwner = findToolSegmentOwner(event.toolCallId);
1852
+ if (streamedOwner !== undefined && !streamedOwner.segment.finished && !streamedOwner.segment.executionStarted) {
1853
+ invalidateEntryCache(streamedOwner.entry);
1854
+ const streamed = streamedOwner.segment;
1855
+ streamed.name = event.toolName;
1856
+ streamed.args = event.args;
1857
+ streamed.executionStarted = true;
1858
+ streamed.argsComplete = true;
1859
+ streamed.startedAtMs = now();
1860
+ markDirty();
1861
+ return;
1862
+ }
1464
1863
  // A model that reuses a tool-call id across calls would leave the
1465
1864
  // prior same-id segment unsettled (its end matches the first segment
1466
1865
  // on lookup). Settle any such orphan now, wherever it lives, so it
@@ -1472,18 +1871,20 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1472
1871
  }
1473
1872
  }
1474
1873
  const assistant = ensureAssistant();
1475
- // Compact resource reads (SKILL.md, CLIO-CODER.md, AGENTS.md, docs/) stay
1476
- // collapsed to one labeled line until explicitly expanded.
1477
- const expanded = assistant.pending === false && classifyResourceRead(event.toolName, event.args) === null;
1874
+ // No fold is stored here: the segment's effective state is resolved
1875
+ // per frame from the transcript detail policy and the tool's
1876
+ // registered presentation, with the operator's override on top.
1478
1877
  assistant.pending = true;
1878
+ closeOpenThinking(assistant);
1479
1879
  assistant.segments.push({
1480
1880
  kind: "tool",
1481
1881
  id: event.toolCallId,
1482
1882
  name: event.toolName,
1483
1883
  args: event.args,
1484
1884
  finished: false,
1885
+ executionStarted: true,
1886
+ argsComplete: true,
1485
1887
  isError: false,
1486
- expanded,
1487
1888
  startedAtMs: now(),
1488
1889
  });
1489
1890
  markDirty();
@@ -1499,24 +1900,22 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1499
1900
  const tool = owner?.segment;
1500
1901
  if (tool && !tool.finished) {
1501
1902
  tool.awaitingApproval = event.state === "awaiting-approval" ? true : undefined;
1903
+ tool.approvalView = event.state === "awaiting-approval" ? event.view : undefined;
1502
1904
  markDirty();
1503
1905
  }
1504
1906
  return;
1505
1907
  }
1506
1908
  if (event.type === "tool_execution_update") {
1507
- // pi-agent emits `partialResult` as a cumulative tool-result envelope
1508
- // (the bash tool concatenates its rolling tail buffer on every tick).
1509
- // Unwrap with the same helper the finished-result path uses, then
1510
- // REPLACE `partialOutput` rather than appending: the upstream
1511
- // semantics are cumulative, so appending would double-print every
1512
- // snapshot. Render dispatch picks up the new buffer on the next
1513
- // frame via `renderToolSegmentLines`.
1909
+ // pi-agent emits `partialResult` as a cumulative AgentToolResult.
1910
+ // Preserve that full envelope so the renderer can use structured
1911
+ // progress details as well as content. Replace rather than append:
1912
+ // Pi's update semantics are cumulative, and appending would duplicate
1913
+ // every earlier snapshot.
1514
1914
  const owner = findToolSegmentOwner(event.toolCallId);
1515
1915
  if (owner) invalidateEntryCache(owner.entry);
1516
1916
  const tool = owner?.segment;
1517
1917
  if (tool && !tool.finished) {
1518
- const unwrapped = unwrapResultEnvelope(event.partialResult);
1519
- tool.partialOutput = typeof unwrapped === "string" ? unwrapped : previewResult(unwrapped);
1918
+ tool.partialResult = event.partialResult;
1520
1919
  }
1521
1920
  markDirty();
1522
1921
  return;
@@ -1568,32 +1967,57 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1568
1967
  // expanded render switches to `renderToolExecution` and stays
1569
1968
  // stable instead of churning through partial-frame layout. A
1570
1969
  // denied park settles here too, so the awaiting styling must go.
1571
- tool.partialOutput = undefined;
1970
+ tool.partialResult = undefined;
1572
1971
  tool.awaitingApproval = undefined;
1972
+ tool.approvalView = undefined;
1573
1973
  }
1574
1974
  markDirty();
1575
1975
  return;
1576
1976
  }
1577
1977
  if (event.type === "message_end") {
1978
+ const current = transcript[transcript.length - 1];
1979
+ let completedStreamedArgs = false;
1980
+ if (current?.role === "assistant") {
1981
+ for (const segment of current.segments) {
1982
+ if (segment.kind !== "tool" || segment.finished || segment.executionStarted || segment.argsComplete) continue;
1983
+ segment.argsComplete = true;
1984
+ completedStreamedArgs = true;
1985
+ }
1986
+ if (completedStreamedArgs) invalidateEntryCache(current);
1987
+ }
1578
1988
  const text = extractAssistantText(event.message);
1579
1989
  const thinking = extractAssistantThinking(event.message);
1580
1990
  const extractedTerminalError = extractAssistantTerminalError(event.message);
1581
- const current = transcript[transcript.length - 1];
1582
1991
  const terminalError =
1583
1992
  current?.role === "assistant"
1584
1993
  ? scopeTerminalErrorAfterSuccessfulTool(current, extractedTerminalError)
1585
1994
  : extractedTerminalError;
1586
1995
  const usage = assistantUsage(event.message);
1587
- if (text.length === 0 && thinking.length === 0 && terminalError.length === 0 && usage === undefined) return;
1996
+ if (text.length === 0 && thinking.length === 0 && terminalError.length === 0 && usage === undefined) {
1997
+ if (completedStreamedArgs) markDirty();
1998
+ return;
1999
+ }
1588
2000
  const assistant = ensureAssistant();
1589
2001
  // message_end rewrites exactly one entry: the assistant it lands on.
1590
2002
  invalidateEntryCache(assistant);
1591
2003
  if (usage !== undefined) assistant.turnUsage = usage;
1592
2004
  if (terminalError.length > 0) assistant.isError = true;
2005
+ // The message is settled, so whatever reasoning it streamed is closed.
2006
+ // A message that carried thinking the panel never saw as deltas (a
2007
+ // non-streaming provider, a replayed message) gets one segment at the
2008
+ // point this message began, ahead of the text the same message
2009
+ // produced, rather than a marker dangling after the answer.
2010
+ closeOpenThinking(assistant);
1593
2011
  if (thinking.length > 0) {
1594
- assistant.thinking = thinking;
1595
- assistant.expandedThinking = thinkingExpanded;
2012
+ const messageStart = Math.min(assistant.messageStartSegmentIndex ?? 0, assistant.segments.length);
2013
+ const streamedThisMessage = assistant.segments
2014
+ .slice(messageStart)
2015
+ .some((segment) => segment.kind === "thinking" && segment.text.length > 0);
2016
+ if (!streamedThisMessage) {
2017
+ assistant.segments.splice(messageStart, 0, { kind: "thinking", text: thinking, finalized: true });
2018
+ }
1596
2019
  }
2020
+ assistant.messageStartSegmentIndex = undefined;
1597
2021
  // The chat loop marks messages it sanitized after streaming (dead
1598
2022
  // tool-call markup on a synthesis-locked turn); the streamed tail
1599
2023
  // must be replaced, not kept alongside a duplicate segment.
@@ -1664,13 +2088,26 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1664
2088
  }
1665
2089
  if (entry.pending) {
1666
2090
  invalidateEntryCache(entry);
2091
+ closeOpenThinking(entry);
1667
2092
  entry.pending = false;
1668
2093
  entry.statusLine = null;
2094
+ entry.messageStartSegmentIndex = undefined;
1669
2095
  }
1670
2096
  }
1671
2097
  markDirty();
1672
2098
  }
1673
2099
  },
2100
+ setLiveReasoning(view: ReasoningUsageView | null): void {
2101
+ const next = view ?? UNMEASURED_REASONING;
2102
+ if (next.tokens === liveReasoning.tokens && next.provenance === liveReasoning.provenance) return;
2103
+ liveReasoning = next;
2104
+ // The line lives on the pending tail entry, which the freeze may already
2105
+ // cover if nothing has mutated since the last settle.
2106
+ unfreezeTail();
2107
+ const last = transcript[transcript.length - 1];
2108
+ if (last !== undefined) invalidateEntryCache(last);
2109
+ markDirty();
2110
+ },
1674
2111
  setStatusLine(line): void {
1675
2112
  if (line) {
1676
2113
  const assistant = ensureAssistant();