@iowarp/clio-coder 0.3.2 → 0.3.4

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 (259) hide show
  1. package/CHANGELOG.md +269 -458
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +3 -3
  4. package/dist/{acp-BIYHVZIM.js → acp-S5R4RR5B.js} +7 -6
  5. package/dist/{agents-YT6SSRIT.js → agents-P6DMMVZY.js} +24 -21
  6. package/dist/assets/codewiki.json +1 -1
  7. package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
  8. package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
  9. package/dist/{chunk-WMSVI4G2.js → chunk-2LZI5CAG.js} +133 -13
  10. package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
  11. package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
  12. package/dist/{chunk-WVO7V2QY.js → chunk-35MKKU5R.js} +4 -4
  13. package/dist/{chunk-LBNRH5WM.js → chunk-3HZ5RWN2.js} +5 -5
  14. package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
  15. package/dist/{chunk-MBS4V7ZP.js → chunk-4JUF2NNX.js} +7 -7
  16. package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
  17. package/dist/chunk-5M54SPOL.js +926 -0
  18. package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
  19. package/dist/{chunk-77VKQEHF.js → chunk-A2GZF7DC.js} +5 -5
  20. package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
  21. package/dist/chunk-AOCYTWAV.js +449 -0
  22. package/dist/chunk-BEY543CS.js +258 -0
  23. package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
  24. package/dist/chunk-BPGS2WCQ.js +612 -0
  25. package/dist/{chunk-J5HN4RYU.js → chunk-BRXQQJFP.js} +8 -8
  26. package/dist/chunk-CFGTUFWB.js +67 -0
  27. package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
  28. package/dist/{chunk-G2DE3C7R.js → chunk-EDRHSCIE.js} +4 -4
  29. package/dist/{chunk-4KLWL3UC.js → chunk-EFADSJET.js} +2 -2
  30. package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
  31. package/dist/chunk-FYYLNIL5.js +313 -0
  32. package/dist/{chunk-EPVUXGXG.js → chunk-HV5X7OR2.js} +14 -12
  33. package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
  34. package/dist/{chunk-IGLFWIYI.js → chunk-K6WL7QZT.js} +3 -3
  35. package/dist/chunk-K7VKOLQQ.js +15 -0
  36. package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
  37. package/dist/{chunk-V4RXGQ5Q.js → chunk-KRPY7NTG.js} +10 -7
  38. package/dist/chunk-LL4KHSZI.js +22 -0
  39. package/dist/{chunk-KJ5LWLOE.js → chunk-MEQ45TQ4.js} +15 -9
  40. package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
  41. package/dist/{chunk-AO4RKG4M.js → chunk-N4CZJQRK.js} +5 -5
  42. package/dist/{chunk-ARBGF5F7.js → chunk-NILBFAPG.js} +14 -8
  43. package/dist/chunk-OZNBF4L3.js +23 -0
  44. package/dist/{verify-G6V4D2G7.js → chunk-PCZJO5TI.js} +127 -42
  45. package/dist/chunk-QQK64KLB.js +1360 -0
  46. package/dist/{chunk-6EJV5X2W.js → chunk-QQL5RT5M.js} +979 -1619
  47. package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
  48. package/dist/{chunk-2EHAIA3X.js → chunk-RD5U66HV.js} +3 -3
  49. package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
  50. package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
  51. package/dist/chunk-TW3WDMVS.js +677 -0
  52. package/dist/chunk-TZSKNMZG.js +434 -0
  53. package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
  54. package/dist/{chunk-7EYHLWU7.js → chunk-UZHIZC5S.js} +7 -7
  55. package/dist/{chunk-QTYWRVRA.js → chunk-VAWWTKDP.js} +8 -8
  56. package/dist/{chunk-X75S7HFS.js → chunk-VEZEGCGW.js} +214 -20
  57. package/dist/{chunk-OHHN2SO4.js → chunk-VMNQ6OZA.js} +98 -202
  58. package/dist/chunk-VSNATDE6.js +122 -0
  59. package/dist/chunk-W6GROXXM.js +69 -0
  60. package/dist/chunk-WPQLXFOZ.js +375 -0
  61. package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
  62. package/dist/{chunk-3ZXDFGR5.js → chunk-X6COSD2O.js} +5 -5
  63. package/dist/chunk-ZGVHUX3M.js +66 -0
  64. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  65. package/dist/{chunk-MQSRRFWA.js → chunk-ZYKPLLNQ.js} +563 -546
  66. package/dist/cli/index.js +27 -23
  67. package/dist/{clio-4LY5K2AC.js → clio-J5JIOIDS.js} +7 -6
  68. package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
  69. package/dist/{config-GTLUW2PR.js → config-OEBMIN2U.js} +37 -27
  70. package/dist/{configure-R6A64DHX.js → configure-PUQOSIXQ.js} +16 -13
  71. package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
  72. package/dist/{context-RW5HC47S.js → context-MGSE4Z2T.js} +33 -23
  73. package/dist/{context-JFZEJ7W5.js → context-URSXPBCK.js} +17 -9
  74. package/dist/{context-clear-6ZHBAZZT.js → context-clear-KDAJRNUK.js} +33 -23
  75. package/dist/context-working-set-SBKMPPI2.js +1552 -0
  76. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-MSWN72NK.js} +43 -29
  77. package/dist/{doctor-KI767GSN.js → doctor-7BSE27PJ.js} +10 -10
  78. package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
  79. package/dist/{evidence-UA6AWDQQ.js → evidence-SR7WXB5B.js} +51 -23
  80. package/dist/{evolve-QNTFGV6Z.js → evolve-K7VE2CBX.js} +30 -20
  81. package/dist/{fleet-Q7UOMUSG.js → fleet-7XMJNQNF.js} +48 -38
  82. package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
  83. package/dist/{init-WBB65ZHQ.js → init-JGNPAYXT.js} +41 -31
  84. package/dist/{memory-MD3O64RI.js → memory-4ALKDJ4Q.js} +32 -22
  85. package/dist/{models-BZU34YWD.js → models-ZMMLFJNN.js} +22 -19
  86. package/dist/{monitor-MEQA5C3I.js → monitor-2F3T5KHP.js} +55 -43
  87. package/dist/{orchestrator-CGFKEP27.js → orchestrator-ORHT43JB.js} +2507 -1896
  88. package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
  89. package/dist/{run-IV4Q6RLN.js → run-RF4WJGMT.js} +51 -41
  90. package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
  91. package/dist/{skills-LQEKRDTN.js → skills-PSACKC5Q.js} +2 -2
  92. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WJSI55RZ.js} +34 -24
  93. package/dist/{targets-C4SSGQOB.js → targets-PIIRAOYS.js} +23 -20
  94. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-ULWXWNVY.js} +5 -4
  95. package/dist/{upgrade-7TT7SQ3G.js → upgrade-346TZ6AV.js} +18 -17
  96. package/dist/{usage-GV4PKT3M.js → usage-6KKXR32N.js} +34 -24
  97. package/dist/verifiers-4UUM6TEE.js +1214 -0
  98. package/dist/verify-X5HDROLA.js +25 -0
  99. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-7STOCIFZ.js} +42 -31
  100. package/dist/worker/entry.js +33 -24
  101. package/docs/README.md +8 -7
  102. package/docs/acp.md +1 -1
  103. package/docs/alcf-provider.md +1 -1
  104. package/docs/architecture.md +2 -2
  105. package/docs/artifact-versions.md +1 -1
  106. package/docs/built-in-agents.md +1 -1
  107. package/docs/capacity-and-scheduling.md +1 -1
  108. package/docs/commands-and-modes.md +53 -21
  109. package/docs/config-knobs-audit.md +1 -2
  110. package/docs/configuration-and-targets.md +15 -1
  111. package/docs/context-engine.md +64 -12
  112. package/docs/context-working-set.md +194 -0
  113. package/docs/development-pipeline.md +1 -1
  114. package/docs/documentation-coverage.md +5 -5
  115. package/docs/documentation-guide.md +6 -5
  116. package/docs/environment-variables.md +2 -1
  117. package/docs/eval-runner.md +1 -1
  118. package/docs/evals-internal.md +14 -1
  119. package/docs/evidence-and-memory.md +74 -2
  120. package/docs/evolution.md +1 -1
  121. package/docs/exit-codes-and-output.md +1 -1
  122. package/docs/extensions-and-sharing.md +2 -2
  123. package/docs/fleet-dispatch.md +22 -7
  124. package/docs/glossary.md +21 -1
  125. package/docs/installation-and-lifecycle.md +6 -6
  126. package/docs/middleware-and-components.md +1 -1
  127. package/docs/model-catalog.md +7 -9
  128. package/docs/observability.md +4 -4
  129. package/docs/performance-methodology.md +2 -2
  130. package/docs/proactive-memory.md +1 -1
  131. package/docs/prompt-envelope-and-tools.md +4 -4
  132. package/docs/provider-adapter-cookbook.md +1 -1
  133. package/docs/release-cut-checklist.md +35 -35
  134. package/docs/safety-model.md +23 -4
  135. package/docs/scientific-validation.md +21 -3
  136. package/docs/session-lifecycle.md +3 -3
  137. package/docs/skills-marketplace.md +1 -1
  138. package/docs/tool-usage.md +79 -12
  139. package/docs/trace-store.md +1 -1
  140. package/docs/troubleshooting.md +1 -1
  141. package/docs/tui-design.md +2 -2
  142. package/docs/worker-dispatch-mechanics.md +11 -1
  143. package/package.json +8 -11
  144. package/skills/meta/clio-test/SKILL.md +20 -17
  145. package/skills/meta/clio-test/evals.md +3 -3
  146. package/skills/meta/clio-test/references/harness.md +35 -6
  147. package/skills/meta/clio-test/references/test-map.md +20 -10
  148. package/skills/registry.yaml +2 -2
  149. package/skills/skill-marketplace.json +1 -1
  150. package/src/cli/context-working-set.ts +513 -0
  151. package/src/cli/context.ts +8 -0
  152. package/src/cli/evidence.ts +20 -2
  153. package/src/cli/index.ts +4 -0
  154. package/src/cli/verifiers.ts +325 -0
  155. package/src/core/bash-exec.ts +39 -14
  156. package/src/core/bus-events.ts +19 -4
  157. package/src/core/config.ts +54 -0
  158. package/src/core/defaults.ts +50 -3
  159. package/src/core/git-commit-attribution.ts +46 -21
  160. package/src/core/verification-scripts.ts +6 -0
  161. package/src/domains/agents/builtins/verifier.md +3 -0
  162. package/src/domains/config/classify.ts +1 -0
  163. package/src/domains/config/keybindings.ts +3 -3
  164. package/src/domains/context/working-set/contract.ts +161 -0
  165. package/src/domains/context/working-set/defaults.ts +28 -0
  166. package/src/domains/context/working-set/engine.ts +203 -0
  167. package/src/domains/context/working-set/fold.ts +62 -0
  168. package/src/domains/context/working-set/horizon.ts +38 -0
  169. package/src/domains/context/working-set/marker.ts +103 -0
  170. package/src/domains/context/working-set/path-index.ts +436 -0
  171. package/src/domains/context/working-set/payload.ts +152 -0
  172. package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
  173. package/src/domains/context/working-set/policies/index.ts +21 -0
  174. package/src/domains/context/working-set/policies/structural.ts +160 -0
  175. package/src/domains/context/working-set/project.ts +132 -0
  176. package/src/domains/context/working-set/protect.ts +109 -0
  177. package/src/domains/context/working-set/recall.ts +177 -0
  178. package/src/domains/context/working-set/replay/controls.ts +112 -0
  179. package/src/domains/context/working-set/replay/load-clio.ts +199 -0
  180. package/src/domains/context/working-set/replay/metrics.ts +185 -0
  181. package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
  182. package/src/domains/context/working-set/replay/report.ts +139 -0
  183. package/src/domains/context/working-set/replay/runner.ts +325 -0
  184. package/src/domains/context/working-set/replay/synthetic.ts +422 -0
  185. package/src/domains/context/working-set/replay/trace.ts +21 -0
  186. package/src/domains/context/working-set/visible.ts +54 -0
  187. package/src/domains/evidence/build.ts +112 -45
  188. package/src/domains/evidence/eval.ts +24 -7
  189. package/src/domains/evidence/index.ts +53 -0
  190. package/src/domains/evidence/ordering.ts +12 -0
  191. package/src/domains/evidence/run-trust.ts +221 -0
  192. package/src/domains/evidence/store.ts +46 -6
  193. package/src/domains/evidence/trust-status.ts +854 -0
  194. package/src/domains/evidence/types.ts +26 -0
  195. package/src/domains/middleware/memory-intervention.ts +3 -0
  196. package/src/domains/middleware/stalled-turn.ts +165 -4
  197. package/src/domains/safety/autonomy.ts +1 -1
  198. package/src/domains/safety/default-path-policy.ts +8 -0
  199. package/src/domains/safety/finish-contract.ts +4 -3
  200. package/src/domains/safety/policy-engine.ts +48 -6
  201. package/src/domains/session/compaction/compact.ts +23 -1
  202. package/src/domains/session/compaction/cut-point.ts +2 -0
  203. package/src/domains/session/compaction/tokens.ts +16 -1
  204. package/src/domains/session/context-ledger.ts +2 -0
  205. package/src/domains/session/entries.ts +107 -1
  206. package/src/domains/session/manager.ts +9 -2
  207. package/src/domains/session/migrations/index.ts +22 -3
  208. package/src/engine/acp/server.ts +3 -0
  209. package/src/engine/agent.ts +18 -1
  210. package/src/engine/session.ts +9 -3
  211. package/src/entry/orchestrator.ts +16 -4
  212. package/src/interactive/chat-loop-messages.ts +18 -6
  213. package/src/interactive/chat-panel.ts +571 -244
  214. package/src/interactive/chat-renderer.ts +79 -39
  215. package/src/interactive/context-meter.ts +10 -0
  216. package/src/interactive/context-overlay.ts +81 -6
  217. package/src/interactive/context-recall-command.ts +110 -0
  218. package/src/interactive/editor-submit.ts +26 -1
  219. package/src/interactive/footer/widgets.ts +22 -20
  220. package/src/interactive/footer-panel.ts +6 -1
  221. package/src/interactive/interactive-application.ts +2 -0
  222. package/src/interactive/interactive-event-projection.ts +12 -0
  223. package/src/interactive/interactive-slash-runtime.ts +49 -8
  224. package/src/interactive/model-session-replay.ts +21 -0
  225. package/src/interactive/overlay-general-openers.ts +6 -0
  226. package/src/interactive/overlay-session-lifecycle.ts +8 -4
  227. package/src/interactive/overlays/ask-user.ts +146 -24
  228. package/src/interactive/renderers/tool-execution.ts +167 -56
  229. package/src/interactive/session-transcript.ts +2 -2
  230. package/src/interactive/slash-commands.ts +29 -2
  231. package/src/interactive/status/index.ts +12 -1
  232. package/src/interactive/status/reasoning.ts +87 -0
  233. package/src/interactive/status/summary.ts +13 -2
  234. package/src/interactive/transcript-detail.ts +120 -0
  235. package/src/interactive/turn-context.ts +238 -88
  236. package/src/interactive/turn-middleware.ts +6 -6
  237. package/src/tools/agent-tools.ts +11 -4
  238. package/src/tools/bash.ts +144 -82
  239. package/src/tools/builtin-tool-catalog.ts +18 -6
  240. package/src/tools/context/index.ts +105 -3
  241. package/src/tools/context/surface.ts +3 -2
  242. package/src/tools/core-bootstrap.ts +21 -0
  243. package/src/tools/dispatch-runner.ts +9 -7
  244. package/src/tools/monitor.ts +28 -20
  245. package/src/tools/presentation.ts +107 -0
  246. package/src/tools/registry.ts +65 -7
  247. package/src/tools/result-disposition.ts +550 -0
  248. package/src/tools/result-shaping.ts +262 -19
  249. package/src/tools/safe-exec.ts +2 -0
  250. package/src/tools/verify/authoring.ts +1119 -0
  251. package/src/tools/verify/catalog.ts +346 -0
  252. package/src/tools/verify/index.ts +13 -3
  253. package/src/tools/verify/scripts.ts +135 -37
  254. package/src/tools/verify/surface.ts +9 -5
  255. package/src/tools/worker-evidence.ts +35 -12
  256. package/dist/chunk-MNA4JGU4.js +0 -255
  257. package/dist/chunk-SRF2PJNW.js +0 -184
  258. package/dist/chunk-T6YILFSB.js +0 -80
  259. package/dist/chunk-VAKQQHWR.js +0 -434
@@ -2,9 +2,10 @@ 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
+ import { toolResultPresentationPolicy } from "../tools/result-disposition.js";
8
9
  import type { ChatLoopEvent, RetryStatusPayload } from "./chat-loop.js";
9
10
  import { extractText, isSelfExplainingAbort } from "./chat-loop-messages.js";
10
11
  import type { ApprovalRequestView } from "./permission-overlay.js";
@@ -13,7 +14,6 @@ import { createMermaidMarkdownTransform } from "./renderers/mermaid.js";
13
14
  import { styleTaggedNotice } from "./renderers/notice.js";
14
15
  import { formatRetryStatus } from "./renderers/retry-status.js";
15
16
  import {
16
- classifyResourceRead,
17
17
  renderToolAwaitingApproval,
18
18
  renderToolCallHeader,
19
19
  renderToolExecution,
@@ -22,8 +22,33 @@ import {
22
22
  renderToolSubline,
23
23
  } from "./renderers/tool-execution.js";
24
24
  import { renderWorkerEntryLines } from "./renderers/worker-entry.js";
25
- import { INLINE_STATUS_INDENT_COLS, type StatusPhase, type VerbRender } from "./status/index.js";
25
+ import {
26
+ compactReasoningTokens,
27
+ emptyRunTally,
28
+ foldMessageIntoRunTally,
29
+ formatReasoningChip,
30
+ formatReasoningLabel,
31
+ INLINE_STATUS_INDENT_COLS,
32
+ type ReasoningTokenProvenance,
33
+ type ReasoningUsageView,
34
+ reasoningFromTally,
35
+ type StatusPhase,
36
+ UNMEASURED_REASONING,
37
+ type VerbRender,
38
+ } from "./status/index.js";
26
39
  import { clioTheme, fgSequence, GLYPH, markdownTheme, SGR_DIM, SGR_RESET } from "./theme/index.js";
40
+ import {
41
+ type Fold,
42
+ type FoldOverride,
43
+ policyRunningToolFold,
44
+ policyThinkingFold,
45
+ policyToolFold,
46
+ policyWorkerFold,
47
+ resolveFold,
48
+ type TranscriptDetailPolicy,
49
+ toggledFold,
50
+ transcriptDetail,
51
+ } from "./transcript-detail.js";
27
52
  import { type WorkerEntryState, workerAskedByModel } from "./worker-stream.js";
28
53
 
29
54
  // Fenced code reaches the screen through pi-tui's Markdown component, which
@@ -69,7 +94,7 @@ const USER_GLYPH = GLYPH.user;
69
94
  * through the Markdown renderer. Partial markdown (unclosed fence, half-typed
70
95
  * bullet) would otherwise paint garbage at ~60 fps under streaming.
71
96
  */
72
- export type ReasoningTokenProvenance = "provider" | "estimated" | "mixed";
97
+ export type { ReasoningTokenProvenance } from "./status/index.js";
73
98
 
74
99
  export interface ChatPanelTurnUsage {
75
100
  inputTokens: number;
@@ -139,8 +164,13 @@ type ToolSegment = {
139
164
  settledWithoutResult?: boolean | undefined;
140
165
  /** True when the finished result was an error. Meaningful only after `finished`. */
141
166
  isError: boolean;
142
- /** When true, render the full structured block instead of the collapsed subline. */
143
- expanded: boolean;
167
+ /**
168
+ * The operator's explicit fold for this block, or none. The effective state
169
+ * is this override when set, else what the transcript detail policy gives
170
+ * the call (through the tool's presentation once it has finished). Cleared
171
+ * when `/output` changes and when a session is switched in.
172
+ */
173
+ fold?: FoldOverride;
144
174
  /** Wall-clock start time captured by the chat panel for live duration display. */
145
175
  startedAtMs?: number;
146
176
  /** Completed call duration in milliseconds (event-supplied or measured locally). */
@@ -151,7 +181,8 @@ type ToolSegment = {
151
181
  * Latest cumulative Pi result from `tool_execution_update`, including the
152
182
  * display content and structured progress details. Cleared
153
183
  * back to `undefined` on `tool_execution_end` so the finished `result`
154
- * takes over. Only consumed when `!finished && expanded`. The explicit
184
+ * takes over. Only consumed while the call is in flight and its effective
185
+ * state is expanded. The explicit
155
186
  * `| undefined` is required under `exactOptionalPropertyTypes: true` so
156
187
  * the clear path can re-assign `undefined` without a `delete`.
157
188
  */
@@ -172,6 +203,12 @@ type ToolSegment = {
172
203
  * was refused and leaves the operator no way to learn why.
173
204
  */
174
205
  blockReason?: string | undefined;
206
+ /**
207
+ * Working-set eviction reason for a replayed result whose body the
208
+ * projection has replaced. Set only on rehydrate, from the folded ledger;
209
+ * a live call is never evicted while it is still being rendered.
210
+ */
211
+ evictedReason?: string | undefined;
175
212
  /** View-only marker: historical calls render mutation diffs without live color. */
176
213
  replayed?: true;
177
214
  };
@@ -186,8 +223,32 @@ type ErrorSegment = {
186
223
  kind: "error";
187
224
  text: string;
188
225
  };
189
- type AssistantSegment = TextSegment | ToolSegment | ErrorSegment;
190
- type ReplayBlockRenderer = (width: number) => string[];
226
+ /**
227
+ * One stretch of reasoning, in stream order with the text and tool segments
228
+ * around it. A turn used to hold one `thinking` string, so reasoning could only
229
+ * ever render in one place (above everything or below everything) and a
230
+ * turn that thought, wrote, thought again, called a tool, and thought once more
231
+ * had its reasoning pinned at the tail while the prose streamed in above it.
232
+ * Each stretch is its own segment now: it renders where it happened, and the
233
+ * one still open at the tail is the live indicator.
234
+ */
235
+ type ThinkingSegment = {
236
+ kind: "thinking";
237
+ text: string;
238
+ /** Closed by the first text, tool, or message_end that follows it. */
239
+ finalized: boolean;
240
+ /** Panel clock when the first delta of this stretch arrived. */
241
+ startedAtMs?: number;
242
+ /** The operator's explicit fold for this stretch, or none. */
243
+ fold?: FoldOverride;
244
+ };
245
+ type AssistantSegment = TextSegment | ToolSegment | ErrorSegment | ThinkingSegment;
246
+ /**
247
+ * A caller-rendered block receives the frame's transcript detail policy so a
248
+ * block that owns fold state (the operator's `!` bash row) resolves it the same
249
+ * way the panel resolves a model call. Blocks that ignore it are unaffected.
250
+ */
251
+ type ReplayBlockRenderer = (width: number, detail: TranscriptDetailPolicy) => string[];
191
252
  type AssistantStatusLine = { phase: StatusPhase; verb: string; toneHint: VerbRender["toneHint"] };
192
253
 
193
254
  type TranscriptEntry =
@@ -197,23 +258,13 @@ type TranscriptEntry =
197
258
  role: "assistant";
198
259
  segments: AssistantSegment[];
199
260
  /**
200
- * Raw thinking content from `thinking_delta` events plus
201
- * `thinking` blocks captured on `message_end`. Renders live while
202
- * the turn is pending: a folded `Thinking (N tokens)…` marker by
203
- * default, or the tail of the reasoning down a dim `│ ` rail if
204
- * expanded via `toggleLastThinking()` (Ctrl+T). Once the turn
205
- * settles it collapses to a static `Thinking...` marker (folded) or
206
- * a head-anchored rail (expanded), mirroring the pi-coding-agent
207
- * reference which streams thinking from the partial message.
208
- */
209
- thinking: string;
210
- /**
211
- * Whether the thinking block renders as the full body (true) or
212
- * the one-line dim marker (false/undefined). Toggled by
213
- * `toggleLastThinking()`. New thinking inherits the panel-level
214
- * visibility mode until Ctrl+T toggles it again.
261
+ * `segments.length` when the current model call began, so `message_end`
262
+ * can tell which segments belong to the message it is settling. A
263
+ * provider that delivers thinking only in the final message (no
264
+ * `thinking_delta`) gets its thinking segment inserted here, ahead of
265
+ * the text the same message produced, rather than at the tail.
215
266
  */
216
- expandedThinking?: boolean;
267
+ messageStartSegmentIndex?: number | undefined;
217
268
  pending: boolean;
218
269
  statusLine?: AssistantStatusLine | null | undefined;
219
270
  isError: boolean;
@@ -221,11 +272,11 @@ type TranscriptEntry =
221
272
  }
222
273
  /**
223
274
  * A dispatched worker's attributed block. The panel owns only the fold
224
- * state; `state` is the live object the worker-stream reducer mutates, so a
225
- * streaming delta reaches the screen without copying the entry per frame.
275
+ * override; `state` is the live object the worker-stream reducer mutates, so
276
+ * a streaming delta reaches the screen without copying the entry per frame.
226
277
  * The panel is told when that happened through `applyWorkerState`.
227
278
  */
228
- | { role: "worker"; state: WorkerEntryState; folded: boolean }
279
+ | { role: "worker"; state: WorkerEntryState; fold?: FoldOverride }
229
280
  /**
230
281
  * A block the caller renders itself. Most are settled the moment they are
231
282
  * appended, but a few (the operator's `!` bash row) keep mutating the state
@@ -233,10 +284,30 @@ type TranscriptEntry =
233
284
  * declares `isLive`, which keeps it out of the frozen prefix and keeps the
234
285
  * panel's time-keyed tick running while it is unsettled.
235
286
  */
236
- | { role: "replayBlock"; renderBlock: ReplayBlockRenderer; isLive?: (() => boolean) | undefined };
287
+ | {
288
+ role: "replayBlock";
289
+ renderBlock: ReplayBlockRenderer;
290
+ isLive?: (() => boolean) | undefined;
291
+ fold?: ReplayBlockFoldControl | undefined;
292
+ };
237
293
 
238
294
  type WorkerTranscriptEntry = Extract<TranscriptEntry, { role: "worker" }>;
239
295
 
296
+ /**
297
+ * Fold state a caller-rendered block owns. The panel never stores it: the
298
+ * block's closure reads it when rendering, so the panel only needs to resolve
299
+ * and flip it when the operator uses an expand/collapse key, and to clear it
300
+ * when `/output` changes. Same tri-state as a tool segment: an override, or
301
+ * none, over the fold the policy gives the block.
302
+ */
303
+ export interface ReplayBlockFoldControl {
304
+ /** The fold the policy gives this block when no override is set. */
305
+ policyFold(detail: TranscriptDetailPolicy): Fold;
306
+ /** The operator's override, or none. */
307
+ fold(): FoldOverride;
308
+ setFold(fold: FoldOverride): void;
309
+ }
310
+
240
311
  export interface ChatPanel extends Component {
241
312
  appendUser(text: string): void;
242
313
  /**
@@ -244,7 +315,7 @@ export interface ChatPanel extends Component {
244
315
  * that keeps changing after the append, so the panel keeps re-rendering it
245
316
  * instead of treating the first frame as final.
246
317
  */
247
- appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean): void;
318
+ appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean, fold?: ReplayBlockFoldControl): void;
248
319
  applyEvent(event: ChatLoopEvent): void;
249
320
  /** Mark a just-rehydrated tool segment so its mutation diff remains plain. */
250
321
  markToolReplayed?(toolCallId: string): void;
@@ -262,24 +333,36 @@ export interface ChatPanel extends Component {
262
333
  */
263
334
  workerStates(): ReadonlyArray<WorkerEntryState>;
264
335
  setStatusLine(line: AssistantStatusLine | null): void;
336
+ /**
337
+ * Publish the live run tally's reasoning projection. The pending entry's
338
+ * tail line reads this; passing null returns it to unmeasured, which is what
339
+ * an idle or just-started turn is.
340
+ */
341
+ setLiveReasoning(view: ReasoningUsageView | null): void;
342
+ /**
343
+ * Flip the newest foldable block (tool call, worker card, or fold-owning
344
+ * replay block) away from its effective state. The flip is an override
345
+ * over the transcript detail policy: under `/output verbose` it folds an
346
+ * open block, under `/output minimal` it opens a folded one.
347
+ */
265
348
  toggleLastToolExpanded(): boolean;
349
+ /** Set an explicit override on every tool, worker, and fold-owning block at once. */
266
350
  toggleAllToolsExpanded(): boolean;
267
351
  /**
268
- * Force every tool segment into its collapsed one-line form. Replay
269
- * (`rehydrateChatPanelFromTurns`) calls this so a resumed or forked
270
- * transcript uses compact ledger summaries instead of auto-expanding every
271
- * historical non-resource call. Idempotent.
352
+ * Drop every operator override so each block returns to what the transcript
353
+ * detail policy gives it. `/output` changes and session switches do this:
354
+ * the operator asked for a new baseline, and what they had opened belonged
355
+ * to the transcript they left. Idempotent.
272
356
  */
273
- collapseAllTools(): void;
357
+ clearFoldOverrides(): void;
274
358
  /**
275
- * Flip thinking-bearing assistant turns between the one-line dim marker
276
- * and the full rail-prefixed body. The target visibility is panel-level
277
- * sticky state, then applied to current thinking history so Ctrl+T behaves
278
- * like a transcript-level thinking visibility toggle.
359
+ * Flip the newest thinking stretch between the one-line dim marker
360
+ * and the full rail-prefixed body, as an override over the policy. A turn
361
+ * with no thinking yet is left alone; a new stretch inherits the policy.
279
362
  */
280
363
  toggleLastThinking(): boolean;
281
364
  toggleAllThinking(): boolean;
282
- /** Current panel-level live-thinking visibility used by presentation pacing. */
365
+ /** Whether live thinking would render open this frame, which presentation pacing consults. */
283
366
  isThinkingExpanded(): boolean;
284
367
  /** Toggle whether expanded live tool bodies include cumulative partial output. */
285
368
  toggleLiveToolOutput(): boolean;
@@ -360,39 +443,42 @@ function extractAssistantThinking(message: unknown): string {
360
443
  .join("");
361
444
  }
362
445
 
363
- function finiteToken(value: unknown): number {
364
- return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
365
- }
366
-
446
+ /**
447
+ * The panel's view of one assistant message's spend, folded through the same
448
+ * `foldMessageIntoRunTally` the status machine uses. The panel used to
449
+ * re-derive reasoning here with its own provider-lookup-then-estimate rule, so
450
+ * the transcript receipt and the footer could report the same turn differently.
451
+ */
367
452
  function assistantUsage(message: unknown): ChatPanelTurnUsage | undefined {
368
453
  if (!message || typeof message !== "object" || (message as { role?: unknown }).role !== "assistant") return undefined;
369
- const record = message as Record<string, unknown>;
370
- const usage = record.usage && typeof record.usage === "object" ? (record.usage as Record<string, unknown>) : undefined;
371
- const thinking = extractAssistantThinking(message);
372
- const reportedReasoning = usage ? extractReasoningTokens(usage) : null;
373
- const estimatedReasoning = reportedReasoning === null ? estimateReasoningTextTokens(thinking) : null;
374
- const inputTokens = finiteToken(usage?.input);
375
- const outputTokens = finiteToken(usage?.output);
376
- const cacheReadTokens = finiteToken(usage?.cacheRead);
377
- const cacheWriteTokens = finiteToken(usage?.cacheWrite);
454
+ const tally = foldMessageIntoRunTally(emptyRunTally(), message as AgentMessage);
455
+ const reasoning = reasoningFromTally(tally);
378
456
  if (
379
- inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0 &&
380
- reportedReasoning === null &&
381
- estimatedReasoning === null
457
+ tally.inputTokens + tally.outputTokens + tally.cacheReadTokens + tally.cacheWriteTokens === 0 &&
458
+ reasoning.provenance === "unmeasured"
382
459
  ) {
383
460
  return undefined;
384
461
  }
385
- const turnUsage: ChatPanelTurnUsage = { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, modelCalls: 1 };
386
- if (reportedReasoning !== null) {
387
- turnUsage.reasoningTokens = reportedReasoning;
388
- turnUsage.reasoningTokenProvenance = "provider";
389
- } else if (estimatedReasoning !== null) {
390
- turnUsage.reasoningTokens = estimatedReasoning;
391
- turnUsage.reasoningTokenProvenance = "estimated";
462
+ const turnUsage: ChatPanelTurnUsage = {
463
+ inputTokens: tally.inputTokens,
464
+ outputTokens: tally.outputTokens,
465
+ cacheReadTokens: tally.cacheReadTokens,
466
+ cacheWriteTokens: tally.cacheWriteTokens,
467
+ modelCalls: 1,
468
+ };
469
+ if (reasoning.provenance !== "unmeasured") {
470
+ turnUsage.reasoningTokens = reasoning.tokens;
471
+ turnUsage.reasoningTokenProvenance = reasoning.provenance;
392
472
  }
393
473
  return turnUsage;
394
474
  }
395
475
 
476
+ /** Settled-turn adapter onto the shared projection; the panel's own summary shape. */
477
+ function reasoningFromTurnUsage(usage: ChatPanelTurnUsage | undefined): ReasoningUsageView {
478
+ if (!usage || usage.reasoningTokenProvenance === undefined) return UNMEASURED_REASONING;
479
+ return { tokens: Math.max(0, usage.reasoningTokens ?? 0), provenance: usage.reasoningTokenProvenance };
480
+ }
481
+
396
482
  function aggregateAssistantUsage(messages: unknown): ChatPanelTurnUsage | undefined {
397
483
  if (!Array.isArray(messages)) return undefined;
398
484
  const total: ChatPanelTurnUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
@@ -458,6 +544,24 @@ function scopeTerminalErrorAfterSuccessfulTool(
458
544
  return `[error] ${modelFailure}${detachedDispatchSucceeded ? "; detached runs continue" : ""}`;
459
545
  }
460
546
 
547
+ function hasThinking(entry: Extract<TranscriptEntry, { role: "assistant" }>): boolean {
548
+ return entry.segments.some((seg) => seg.kind === "thinking" && seg.text.length > 0);
549
+ }
550
+
551
+ /** The thinking segment still receiving deltas, which is always the tail. */
552
+ function openThinkingSegment(entry: Extract<TranscriptEntry, { role: "assistant" }>): ThinkingSegment | null {
553
+ const tail = entry.segments[entry.segments.length - 1];
554
+ return tail?.kind === "thinking" && !tail.finalized ? tail : null;
555
+ }
556
+
557
+ /** Index of the last thinking segment, which is where a settled turn's count chip rides. */
558
+ function lastThinkingIndex(entry: Extract<TranscriptEntry, { role: "assistant" }>): number {
559
+ for (let index = entry.segments.length - 1; index >= 0; index -= 1) {
560
+ if (entry.segments[index]?.kind === "thinking") return index;
561
+ }
562
+ return -1;
563
+ }
564
+
461
565
  function hasVisibleOutput(entry: Extract<TranscriptEntry, { role: "assistant" }>): boolean {
462
566
  for (const seg of entry.segments) {
463
567
  if (seg.kind === "tool") return true;
@@ -590,49 +694,60 @@ function hangProseLines(lines: string[], firstPrefix?: string): string[] {
590
694
  */
591
695
  const THINKING_HIDDEN_LABEL = `Thinking${GLYPH.ellipsis}`;
592
696
  const THINKING_LINE_LIMIT = 12;
593
- const REASONING_CHARS_PER_TOKEN = 4;
594
697
 
595
- function estimateThinkingTokens(thinking: string): number {
596
- return estimateReasoningTextTokens(thinking) ?? Math.max(1, Math.round(thinking.length / REASONING_CHARS_PER_TOKEN));
698
+ function dimLine(text: string, width: number): string {
699
+ return `${DIM}${truncateToWidth(text, Math.max(1, width), GLYPH.ellipsis, false)}${RESET}`;
597
700
  }
598
701
 
599
- function reasoningDisplay(usage: ChatPanelTurnUsage | undefined, thinking: string): { tokens: number; marker: string } {
600
- if (usage?.reasoningTokens !== undefined) {
601
- return {
602
- tokens: usage.reasoningTokens,
603
- marker: usage.reasoningTokenProvenance === "provider" ? "provider-reported" : "mixed/estimated",
604
- };
605
- }
606
- return { tokens: estimateThinkingTokens(thinking), marker: "estimated" };
702
+ /**
703
+ * A closed thinking stretch's folded marker, in place in the segment order. The
704
+ * turn's count chip rides on the last marker of a settled turn (`view` is
705
+ * unmeasured everywhere else), and comes from the settled usage, never from
706
+ * measuring the excerpt the panel happens to be holding.
707
+ */
708
+ function renderSettledThinkingMarker(view: ReasoningUsageView, width: number): string {
709
+ const chip = formatReasoningChip(view, compactReasoningTokens);
710
+ return dimLine(
711
+ chip === null ? THINKING_HIDDEN_LABEL : `${THINKING_HIDDEN_LABEL} · ${chip} ${formatReasoningLabel(view)}`,
712
+ width,
713
+ );
607
714
  }
608
715
 
609
716
  /**
610
- * Render the assistant turn's thinking block. Collapsed (default) returns a
611
- * single dim `Thinking...` marker. Expanded returns the full body dimmed and
612
- * prefixed with a dim `│ ` rail, capped at `THINKING_LINE_LIMIT` lines with a
613
- * tail `... N more lines hidden` overflow message. Mirrors the tool toggle's
717
+ * The live turn's one reasoning line, rendered where the open thinking segment
718
+ * sits, which is the tail of the entry by construction: the first text, tool,
719
+ * or message_end closes it. Anchoring reasoning at the head put it above every
720
+ * streamed segment, so on a long turn the only progress indicator scrolled off
721
+ * the top; pinning one line at the tail put it below prose that streamed in
722
+ * after the model had already moved on, so the transcript read out of order.
723
+ *
724
+ * The count is whatever the run tally has folded so far, so between model calls
725
+ * the line states elapsed and nothing else. Visible thinking text is never
726
+ * counted: that number moved with how much reasoning the provider chose to
727
+ * display, not with what the turn spent.
728
+ */
729
+ function renderLiveReasoningLine(view: ReasoningUsageView, elapsedMs: number | undefined, width: number): string {
730
+ const chip = formatReasoningChip(view, compactReasoningTokens);
731
+ const head = chip === null ? THINKING_HIDDEN_LABEL : `Thinking · ${chip} ${formatReasoningLabel(view)}`;
732
+ const seconds = elapsedMs === undefined ? 0 : Math.floor(Math.max(0, elapsedMs) / 1000);
733
+ return dimLine(seconds > 0 ? `${head} · ${seconds}s` : head, width);
734
+ }
735
+
736
+ /**
737
+ * Render the expanded thinking body: the text dimmed behind a dim `│ ` rail,
738
+ * capped at `THINKING_LINE_LIMIT` lines. A streaming turn keeps the tail (the
739
+ * reasoning still arriving); a settled one keeps the head with a
740
+ * `... N more lines hidden` overflow message. Mirrors the tool toggle's
614
741
  * lab-notebook minimalism: no colored glyphs, no boxes.
615
742
  */
616
- function renderThinkingLines(
617
- thinking: string,
618
- expanded: boolean,
619
- width: number,
620
- streaming: boolean,
621
- usage?: ChatPanelTurnUsage,
622
- ): string[] {
743
+ function renderThinkingRail(thinking: string, width: number, streaming: boolean, unbounded = false): string[] {
623
744
  if (thinking.length === 0) return [];
624
- const dimWrap = (s: string): string => `${DIM}${s}${RESET}`;
625
- if (!expanded) {
626
- const lineBudget = Math.max(1, width);
627
- const display = reasoningDisplay(usage, thinking);
628
- const label = streaming
629
- ? `Thinking (${display.tokens} tokens)${display.marker === "provider-reported" ? "" : " ≈ estimated"}…`
630
- : THINKING_HIDDEN_LABEL;
631
- return [dimWrap(truncateToWidth(label, lineBudget, GLYPH.ellipsis, false))];
632
- }
633
745
  const splitLines = thinking.split("\n");
634
746
  let visible: string[];
635
- if (streaming) {
747
+ if (unbounded) {
748
+ // /export reproduces the whole transcript; the live cap is a screen budget.
749
+ visible = splitLines;
750
+ } else if (streaming) {
636
751
  if (splitLines.length > THINKING_LINE_LIMIT) {
637
752
  const hiddenCount = splitLines.length - THINKING_LINE_LIMIT;
638
753
  visible = [`… ${hiddenCount} earlier lines hidden`, ...splitLines.slice(-THINKING_LINE_LIMIT)];
@@ -664,9 +779,13 @@ function renderThinkingLines(
664
779
  * reported 717676 input tokens against a 500k window; it was 65 calls of about
665
780
  * 11k, which the preceding one-call turns had already shown.
666
781
  */
667
- function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity: OutputVerbosity): string[] {
668
- if (verbosity === "minimal") return [];
669
- if (verbosity === "default") {
782
+ function renderTurnUsageLine(
783
+ usage: ChatPanelTurnUsage,
784
+ width: number,
785
+ receipt: TranscriptDetailPolicy["receipt"],
786
+ ): string[] {
787
+ if (receipt === "none") return [];
788
+ if (receipt === "compact") {
670
789
  const receipt = truncateToWidth(
671
790
  ` turn · in ${usage.inputTokens} · out ${usage.outputTokens}`,
672
791
  width,
@@ -686,9 +805,10 @@ function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity
686
805
  // provenance of zero, and at narrow widths `reasoning 0 provider` orphaned
687
806
  // the word `provider` on its own line. Zero suppresses the whole suffix, the
688
807
  // same rule the caveat below already follows.
808
+ const view = reasoningFromTurnUsage(usage);
689
809
  const reason =
690
- usage.reasoningTokens !== undefined && usage.reasoningTokens > 0
691
- ? ` reasoning ${usage.reasoningTokenProvenance === "provider" ? `${usage.reasoningTokens} provider` : `≈${usage.reasoningTokens} estimated`}`
810
+ view.tokens > 0 && view.provenance !== "unmeasured"
811
+ ? ` reasoning ${view.provenance === "provider" ? "" : "≈"}${view.tokens} ${formatReasoningLabel(view)}`
692
812
  : "";
693
813
  const cache =
694
814
  usage.cacheReadTokens > 0 || usage.cacheWriteTokens > 0
@@ -697,10 +817,7 @@ function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity
697
817
  // The caveat is about reasoning text the panel displayed. A turn that spent
698
818
  // no reasoning tokens displayed none, so appending it there warned about
699
819
  // something absent and cost a wrapped line per turn at narrow widths.
700
- const caveat =
701
- usage.reasoningTokens !== undefined && usage.reasoningTokens > 0
702
- ? " · reasoning text is a UI excerpt, not a verification"
703
- : "";
820
+ const caveat = view.tokens > 0 ? " · reasoning text is a UI excerpt, not a verification" : "";
704
821
  return wrapTextWithAnsi(
705
822
  `${DIM} turn · in ${usage.inputTokens}${calls} · out ${usage.outputTokens}${cache}${reason}${caveat}${RESET}`,
706
823
  width,
@@ -717,6 +834,22 @@ function styleStatusVerb(text: string, toneHint: VerbRender["toneHint"]): string
717
834
  return `${DIM}${text}${RESET}`;
718
835
  }
719
836
 
837
+ /**
838
+ * The fold the policy gives a tool segment this frame: the running-tool rule
839
+ * while in flight, the tool body rule (through the tool's own presentation)
840
+ * once finished, and the error rule for a finished failure.
841
+ */
842
+ function policySegmentFold(seg: ToolSegment, detail: TranscriptDetailPolicy): Fold {
843
+ if (!seg.finished) return policyRunningToolFold(detail);
844
+ if (seg.isError && detail.errors === "body") return "expanded";
845
+ return policyToolFold(detail, toolResultPresentationPolicy(seg.result) ?? toolPresentationPolicy(seg.name, seg.args));
846
+ }
847
+
848
+ /** Effective state of a tool segment: the operator's override, else the policy. */
849
+ function toolSegmentExpanded(seg: ToolSegment, detail: TranscriptDetailPolicy): boolean {
850
+ return resolveFold(seg.fold, policySegmentFold(seg, detail)) === "expanded";
851
+ }
852
+
720
853
  function renderToolSegmentLines(
721
854
  seg: ToolSegment,
722
855
  width: number,
@@ -724,11 +857,11 @@ function renderToolSegmentLines(
724
857
  latestHintToolId: string | null,
725
858
  nowMs: number,
726
859
  unboundedToolBodies: boolean,
727
- verbosity: OutputVerbosity,
860
+ detail: TranscriptDetailPolicy,
728
861
  liveToolOutput: boolean,
729
862
  ): string[] {
730
863
  const hintKey = seg.id === latestHintToolId ? expandKey : undefined;
731
- const expanded = verbosity === "verbose" || (verbosity !== "minimal" && seg.expanded);
864
+ const expanded = toolSegmentExpanded(seg, detail);
732
865
  const elapsedMs = seg.startedAtMs !== undefined ? Math.max(0, rawDurationMs(seg.startedAtMs, nowMs)) : undefined;
733
866
  const phase: "forming" | "ready" | "running" = seg.executionStarted
734
867
  ? "running"
@@ -758,10 +891,15 @@ function renderToolSegmentLines(
758
891
  resultSummary: seg.resultSummary,
759
892
  outcome: seg.settlement,
760
893
  blockReason: seg.blockReason,
894
+ evictedReason: seg.evictedReason,
761
895
  }
762
896
  : { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs, phase },
763
897
  width,
764
898
  hintKey,
899
+ {
900
+ diffStyle: seg.replayed === true ? "plain" : "color",
901
+ foldedExtras: detail.toolBody === "folded" ? "none" : "per-tool",
902
+ },
765
903
  );
766
904
  }
767
905
  if (!seg.finished) {
@@ -786,6 +924,7 @@ function renderToolSegmentLines(
786
924
  resultSummary: seg.resultSummary,
787
925
  outcome: seg.settlement,
788
926
  blockReason: seg.blockReason,
927
+ evictedReason: seg.evictedReason,
789
928
  },
790
929
  width,
791
930
  { unbounded: unboundedToolBodies, diffStyle: seg.replayed === true ? "plain" : "color" },
@@ -793,14 +932,17 @@ function renderToolSegmentLines(
793
932
  }
794
933
 
795
934
  /**
796
- * Whether a worker block draws its one-line card this frame. `/output verbose`
797
- * opens every block and `/output minimal` folds every block, so the entry's own
798
- * fold decides only in between.
935
+ * Whether a worker block draws its one-line card this frame: the operator's
936
+ * override when set, else the policy's worker rule, which under the balanced
937
+ * level is the origin default (a run the model asked for folds).
799
938
  */
800
- function workerEntryFolded(entry: WorkerTranscriptEntry, verbosity: OutputVerbosity): boolean {
801
- if (verbosity === "verbose") return false;
802
- if (verbosity === "minimal") return true;
803
- return entry.folded;
939
+ function workerEntryFolded(entry: WorkerTranscriptEntry, detail: TranscriptDetailPolicy): boolean {
940
+ return resolveFold(entry.fold, policyWorkerFold(detail, workerAskedByModel(entry.state))) === "folded";
941
+ }
942
+
943
+ /** Effective state of one thinking stretch: the operator's override, else the policy. */
944
+ function thinkingExpanded(segment: ThinkingSegment, detail: TranscriptDetailPolicy): boolean {
945
+ return resolveFold(segment.fold, policyThinkingFold(detail)) === "expanded";
804
946
  }
805
947
 
806
948
  function renderEntryLines(
@@ -811,11 +953,12 @@ function renderEntryLines(
811
953
  latestFoldedWorkerId: string | null,
812
954
  nowMs: number,
813
955
  unboundedToolBodies: boolean,
814
- verbosity: OutputVerbosity,
956
+ detail: TranscriptDetailPolicy,
815
957
  liveToolOutput: boolean,
958
+ liveReasoning: ReasoningUsageView,
816
959
  ): string[] {
817
960
  if (entry.role === "replayBlock") {
818
- return entry.renderBlock(width);
961
+ return entry.renderBlock(width, detail);
819
962
  }
820
963
  if (entry.role === "user") {
821
964
  const contentWidth = Math.max(1, width - PROSE_GUTTER_WIDTH);
@@ -830,7 +973,7 @@ function renderEntryLines(
830
973
  }
831
974
  if (entry.role === "worker") {
832
975
  return renderWorkerEntryLines(entry.state, width, {
833
- folded: workerEntryFolded(entry, verbosity),
976
+ folded: workerEntryFolded(entry, detail),
834
977
  ...(expandKey !== undefined && entry.state.assignmentId === latestFoldedWorkerId ? { expandKey } : {}),
835
978
  unbounded: unboundedToolBodies,
836
979
  });
@@ -839,38 +982,50 @@ function renderEntryLines(
839
982
  // A mid-turn notice splits the transcript, so the events after it open a
840
983
  // fresh entry that a stopped turn never fills; that entry used to reach the
841
984
  // tail below and print a lone agent bubble under the notice.
842
- if (
843
- !entry.pending &&
844
- entry.thinking.length === 0 &&
845
- entry.turnUsage === undefined &&
846
- !hasVisibleOutput(entry) &&
847
- entry.segments.length === 0
848
- ) {
985
+ if (!entry.pending && entry.turnUsage === undefined && !hasVisibleOutput(entry) && entry.segments.length === 0) {
849
986
  return [];
850
987
  }
851
988
  const lines: string[] = [];
852
- // Thinking renders BEFORE assistant text/tool segments so the folded marker
853
- // or expanded rail sits above the response, matching the order the
854
- // pi-coding-agent reference uses. It streams live while `pending === true`
855
- // (folded shows a dynamic token count; expanded tail-anchors the tail) and
856
- // collapses to a static marker / head-anchored rail once the turn settles.
857
- // The generic "thinking" status verb is suppressed while this marker is
858
- // active so only one indicator shows (see `shouldRenderStatus` below).
859
- if (entry.thinking.length > 0) {
860
- lines.push(
861
- ...renderThinkingLines(
862
- entry.thinking,
863
- verbosity === "verbose" || (verbosity !== "minimal" && entry.expandedThinking === true),
864
- width,
865
- entry.pending,
866
- entry.turnUsage,
867
- ),
868
- );
869
- }
989
+ // Reasoning renders in stream order, between the text and tool segments it
990
+ // came between. A closed stretch is a folded marker (or a head-anchored rail
991
+ // when expanded); the stretch still open while the turn is pending is the
992
+ // live indicator, with the tally's count and its own elapsed. The turn's
993
+ // settled count chip rides on the last marker once the turn has settled.
994
+ const chipIndex = entry.pending ? -1 : lastThinkingIndex(entry);
870
995
  const clioPrefix = entry.isError ? CLIO_PREFIX_ERROR : CLIO_PREFIX;
871
996
  const proseWidth = Math.max(1, width - PROSE_GUTTER_WIDTH);
872
997
  let labeled = false;
873
- for (const seg of entry.segments) {
998
+ let liveIndicatorShown = false;
999
+ let latestThinkingExpanded = policyThinkingFold(detail) === "expanded";
1000
+ for (let segIndex = 0; segIndex < entry.segments.length; segIndex += 1) {
1001
+ const seg = entry.segments[segIndex];
1002
+ if (seg === undefined) continue;
1003
+ if (seg.kind === "thinking") {
1004
+ if (seg.text.length === 0) continue;
1005
+ const thinkingExpandedNow = thinkingExpanded(seg, detail);
1006
+ latestThinkingExpanded = thinkingExpandedNow;
1007
+ const live = entry.pending && !seg.finalized;
1008
+ if (thinkingExpandedNow) lines.push(...renderThinkingRail(seg.text, width, live, unboundedToolBodies));
1009
+ if (live) {
1010
+ liveIndicatorShown = true;
1011
+ // The bare marker level states that the model is thinking and
1012
+ // nothing else: no count, no elapsed. An operator who opened the
1013
+ // stretch anyway gets the progress line under the rail.
1014
+ lines.push(
1015
+ detail.thinking === "marker" && !thinkingExpandedNow
1016
+ ? dimLine(THINKING_HIDDEN_LABEL, width)
1017
+ : renderLiveReasoningLine(
1018
+ liveReasoning,
1019
+ seg.startedAtMs === undefined ? undefined : Math.max(0, nowMs - seg.startedAtMs),
1020
+ width,
1021
+ ),
1022
+ );
1023
+ } else if (!thinkingExpandedNow) {
1024
+ const view = segIndex === chipIndex ? reasoningFromTurnUsage(entry.turnUsage) : UNMEASURED_REASONING;
1025
+ lines.push(renderSettledThinkingMarker(view, width));
1026
+ }
1027
+ continue;
1028
+ }
874
1029
  if (seg.kind === "tool") {
875
1030
  lines.push(
876
1031
  ...renderToolSegmentLines(
@@ -880,7 +1035,7 @@ function renderEntryLines(
880
1035
  latestHintToolId,
881
1036
  nowMs,
882
1037
  unboundedToolBodies,
883
- verbosity,
1038
+ detail,
884
1039
  liveToolOutput,
885
1040
  ),
886
1041
  );
@@ -913,21 +1068,30 @@ function renderEntryLines(
913
1068
  lines.push(...hangProseLines(rendered));
914
1069
  }
915
1070
  }
916
- if (entry.turnUsage && !entry.pending) lines.push(...renderTurnUsageLine(entry.turnUsage, width, verbosity));
1071
+ // A thinking stretch closes when text or a tool follows it so the historical
1072
+ // marker stays in stream order. The turn-level reasoning projection is still
1073
+ // live, though, and must remain beside the current tail instead of scrolling
1074
+ // away with that marker. An open tail stretch already rendered this line.
1075
+ if (entry.pending && hasThinking(entry) && !liveIndicatorShown) {
1076
+ lines.push(
1077
+ detail.thinking === "marker" && !latestThinkingExpanded
1078
+ ? dimLine(THINKING_HIDDEN_LABEL, width)
1079
+ : renderLiveReasoningLine(liveReasoning, undefined, width),
1080
+ );
1081
+ liveIndicatorShown = true;
1082
+ }
1083
+ if (entry.turnUsage && !entry.pending) lines.push(...renderTurnUsageLine(entry.turnUsage, width, detail.receipt));
1084
+ // The open thinking segment's line is the one that speaks for reasoning while
1085
+ // the turn runs. The generic thinking verb is suppressed while it shows so
1086
+ // the entry never carries two indicators for the same thing.
917
1087
  const shouldRenderStatus =
918
1088
  entry.pending &&
919
1089
  entry.statusLine !== null &&
920
1090
  entry.statusLine !== undefined &&
921
1091
  !(entry.statusLine.phase === "writing" && hasStreamingText(entry)) &&
922
- !(entry.statusLine.phase === "thinking" && entry.thinking.length > 0);
923
- if (!labeled && !hasVisibleOutput(entry)) {
924
- lines.push(clioPrefix.trimEnd());
925
- if (shouldRenderStatus) {
926
- lines.push(
927
- `${STATUS_INDENT}${styleStatusVerb(entry.statusLine?.verb ?? "", entry.statusLine?.toneHint ?? "muted")}`,
928
- );
929
- }
930
- } else if (shouldRenderStatus) {
1092
+ !(entry.statusLine.phase === "thinking" && liveIndicatorShown);
1093
+ if (!labeled && !hasVisibleOutput(entry)) lines.push(clioPrefix.trimEnd());
1094
+ if (shouldRenderStatus) {
931
1095
  lines.push(`${STATUS_INDENT}${styleStatusVerb(entry.statusLine?.verb ?? "", entry.statusLine?.toneHint ?? "muted")}`);
932
1096
  }
933
1097
  return lines;
@@ -941,8 +1105,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
941
1105
  let cachedWidth: number | undefined;
942
1106
  let cachedLines: string[] = [];
943
1107
  let cachedExpandKey: string | undefined;
944
- let cachedVerbosity: OutputVerbosity | undefined;
1108
+ let cachedDetail: TranscriptDetailPolicy | undefined;
945
1109
  let cachedLiveToolOutput: boolean | undefined;
1110
+ /**
1111
+ * The verbosity the last frame rendered under, or null before any frame.
1112
+ * A change between frames is the operator asking for a new baseline
1113
+ * (`/output`, or Settings → Terminal), and every override goes with it.
1114
+ */
1115
+ let lastVerbosity: OutputVerbosity | undefined | null = null;
946
1116
  let cachedTick = 0;
947
1117
  /**
948
1118
  * Did the last executed render put a counting elapsed line on screen? It is
@@ -974,8 +1144,13 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
974
1144
  * the render key changes.
975
1145
  */
976
1146
  let frozen: { lines: string[]; through: number; key: string } | null = null;
977
- let thinkingExpanded = false;
978
1147
  let liveToolOutput = true;
1148
+ /**
1149
+ * The run tally's reasoning, projected. It is panel-level rather than
1150
+ * per-entry because only the pending tail entry ever renders it, and that
1151
+ * entry is by definition unfrozen and uncached.
1152
+ */
1153
+ let liveReasoning: ReasoningUsageView = UNMEASURED_REASONING;
979
1154
  const unboundedToolBodies = options.unboundedToolBodies === true;
980
1155
 
981
1156
  const markDirty = (): void => {
@@ -1007,6 +1182,32 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1007
1182
 
1008
1183
  const now = (): number => options.now?.() ?? Date.now();
1009
1184
 
1185
+ /** The policy for a frame or a keypress, from whatever the settings say right now. */
1186
+ const currentDetail = (): TranscriptDetailPolicy => transcriptDetail(options.getOutputVerbosity?.());
1187
+
1188
+ /**
1189
+ * Drop every operator override. Shared by the panel method and the
1190
+ * verbosity-change path in render, so both leave the same state behind.
1191
+ */
1192
+ const dropFoldOverrides = (): void => {
1193
+ for (const entry of transcript) {
1194
+ if (entry.role === "replayBlock") {
1195
+ entry.fold?.setFold(undefined);
1196
+ continue;
1197
+ }
1198
+ if (entry.role === "worker") {
1199
+ entry.fold = undefined;
1200
+ continue;
1201
+ }
1202
+ if (entry.role !== "assistant") continue;
1203
+ for (const seg of entry.segments) {
1204
+ if (seg.kind === "tool" || seg.kind === "thinking") seg.fold = undefined;
1205
+ }
1206
+ }
1207
+ clearRenderCaches();
1208
+ markDirty();
1209
+ };
1210
+
1010
1211
  /**
1011
1212
  * Force an in-flight tool segment to a settled error line. A call blocked at
1012
1213
  * admission (loop guard, safety) or one whose `tool_execution_end` never
@@ -1069,8 +1270,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1069
1270
  const entry: Extract<TranscriptEntry, { role: "assistant" }> = {
1070
1271
  role: "assistant",
1071
1272
  segments: [],
1072
- thinking: "",
1073
- expandedThinking: thinkingExpanded,
1074
1273
  pending: false,
1075
1274
  isError: false,
1076
1275
  };
@@ -1078,9 +1277,34 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1078
1277
  return entry;
1079
1278
  };
1080
1279
 
1280
+ /**
1281
+ * Close the thinking stretch at the tail, if one is open. Anything that
1282
+ * follows reasoning in the stream (text, a tool call, the message settling)
1283
+ * ends that stretch; a later `thinking_delta` opens a new segment after it,
1284
+ * so the transcript keeps the order the model actually worked in.
1285
+ */
1286
+ const closeOpenThinking = (entry: Extract<TranscriptEntry, { role: "assistant" }>): void => {
1287
+ const open = openThinkingSegment(entry);
1288
+ if (open === null) return;
1289
+ open.finalized = true;
1290
+ invalidateEntryCache(entry);
1291
+ };
1292
+
1293
+ const appendThinkingDelta = (entry: Extract<TranscriptEntry, { role: "assistant" }>, delta: string): void => {
1294
+ if (delta.length === 0) return;
1295
+ invalidateEntryCache(entry);
1296
+ const open = openThinkingSegment(entry);
1297
+ if (open !== null) {
1298
+ open.text += delta;
1299
+ return;
1300
+ }
1301
+ entry.segments.push({ kind: "thinking", text: delta, finalized: false, startedAtMs: now() });
1302
+ };
1303
+
1081
1304
  const appendTextDelta = (entry: Extract<TranscriptEntry, { role: "assistant" }>, delta: string): void => {
1082
1305
  if (delta.length === 0) return;
1083
1306
  invalidateEntryCache(entry);
1307
+ closeOpenThinking(entry);
1084
1308
  const tail = entry.segments[entry.segments.length - 1];
1085
1309
  if (tail && tail.kind === "text" && !tail.finalized) {
1086
1310
  tail.text += delta;
@@ -1090,15 +1314,22 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1090
1314
  };
1091
1315
 
1092
1316
  /**
1093
- * Canonicalize a streamed text segment from a completed assistant message.
1094
- * When streaming produced a prefix of the final text (the common case),
1095
- * the tail segment is overwritten and flipped to finalized so the next
1096
- * render pipes it through Markdown. When the message arrived fully formed
1097
- * with no deltas (non-streaming test path, synthetic notices), a fresh
1098
- * finalized text segment is appended after any tool segments that may
1099
- * have landed in this turn already. `replaceTail` forces the overwrite for
1100
- * messages the chat loop rewrote after streaming (locked-turn markup
1101
- * sanitation): the streamed tail is dead text there, not a prefix.
1317
+ * Canonicalize the streamed text of a completed assistant message.
1318
+ *
1319
+ * The streamed text is wherever this message put it, not necessarily at the
1320
+ * tail: a message that thinks, writes, and thinks again leaves its text
1321
+ * behind a thinking segment. Looking only at the tail appended the message
1322
+ * text a second time under the reasoning marker, so the answer read twice.
1323
+ *
1324
+ * One streamed segment that is a prefix of the final text (the common case)
1325
+ * is overwritten in place and flipped to finalized so the next render pipes
1326
+ * it through Markdown. Several streamed segments (text split by reasoning)
1327
+ * are each finalized where they stand; the deltas already are the text.
1328
+ * When the message arrived fully formed with no deltas (non-streaming
1329
+ * path, synthetic notices, replay), a fresh finalized segment is appended.
1330
+ * `replaceTail` forces the overwrite for messages the chat loop rewrote
1331
+ * after streaming (locked-turn markup sanitation): the streamed text is dead
1332
+ * there, not a prefix.
1102
1333
  */
1103
1334
  const canonicalizeMessageText = (
1104
1335
  entry: Extract<TranscriptEntry, { role: "assistant" }>,
@@ -1106,15 +1337,36 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1106
1337
  replaceTail = false,
1107
1338
  ): void => {
1108
1339
  if (text.length === 0) return;
1109
- const tail = entry.segments[entry.segments.length - 1];
1110
- if (tail?.kind === "text" && !tail.finalized && (replaceTail || text.startsWith(tail.text))) {
1111
- tail.text = text;
1112
- tail.finalized = true;
1340
+ closeOpenThinking(entry);
1341
+ const messageStart = Math.min(entry.messageStartSegmentIndex ?? 0, entry.segments.length);
1342
+ const streamed: TextSegment[] = [];
1343
+ for (let index = messageStart; index < entry.segments.length; index += 1) {
1344
+ const segment = entry.segments[index];
1345
+ if (segment?.kind === "text" && !segment.finalized) streamed.push(segment);
1346
+ }
1347
+ const finalize = (segment: TextSegment, value: string): void => {
1348
+ segment.text = value;
1349
+ segment.finalized = true;
1113
1350
  // The streaming wrap cache assumes append-only text. This is the one
1114
1351
  // path that rewrites it wholesale, and finalized segments render through
1115
1352
  // Markdown instead, so the cache is dead here either way.
1116
- delete tail.wrapCache;
1117
- if (tail.md) tail.md.setText(text);
1353
+ delete segment.wrapCache;
1354
+ if (segment.md) segment.md.setText(value);
1355
+ };
1356
+ if (replaceTail && streamed.length > 0) {
1357
+ const [first, ...rest] = streamed;
1358
+ if (first) finalize(first, text);
1359
+ for (const dead of rest) entry.segments.splice(entry.segments.indexOf(dead), 1);
1360
+ return;
1361
+ }
1362
+ if (streamed.length === 1 && streamed[0] !== undefined) {
1363
+ const only = streamed[0];
1364
+ if (text.startsWith(only.text)) {
1365
+ finalize(only, text);
1366
+ return;
1367
+ }
1368
+ } else if (streamed.length > 1) {
1369
+ for (const segment of streamed) finalize(segment, segment.text);
1118
1370
  return;
1119
1371
  }
1120
1372
  entry.segments.push({ kind: "text", text, finalized: true });
@@ -1133,25 +1385,30 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1133
1385
  };
1134
1386
 
1135
1387
  /**
1136
- * Who advertises the fold key this frame: the newest folded worker card, or
1137
- * the newest finished collapsed tool subline with no worker card behind it.
1388
+ * Who advertises the fold key this frame: the newest worker card whose
1389
+ * effective state is folded, or the newest finished tool subline whose
1390
+ * effective state is folded with no worker card behind it. Effective means
1391
+ * override-or-policy, so the hint follows the block whatever the verbosity.
1138
1392
  * One surface at most, because the key reaches the newest foldable thing of
1139
1393
  * either kind, and a chord shown anywhere else would open something the
1140
1394
  * operator was not looking at. An already-open newest card advertises
1141
1395
  * nothing, since folding it again needs no invitation.
1142
1396
  */
1143
- const expandHintOwner = (): { toolId: string | null; workerId: string | null } => {
1397
+ const expandHintOwner = (detail: TranscriptDetailPolicy): { toolId: string | null; workerId: string | null } => {
1144
1398
  let workerMayOwn = true;
1145
1399
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1146
1400
  const entry = transcript[entryIndex];
1147
1401
  if (entry?.role === "worker") {
1148
- return { toolId: null, workerId: workerMayOwn && entry.folded ? entry.state.assignmentId : null };
1402
+ return {
1403
+ toolId: null,
1404
+ workerId: workerMayOwn && workerEntryFolded(entry, detail) ? entry.state.assignmentId : null,
1405
+ };
1149
1406
  }
1150
1407
  if (entry?.role !== "assistant") continue;
1151
1408
  for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1152
1409
  const seg = entry.segments[segIndex];
1153
1410
  if (seg?.kind !== "tool") continue;
1154
- if (seg.finished && !seg.expanded) return { toolId: seg.id, workerId: null };
1411
+ if (seg.finished && !toolSegmentExpanded(seg, detail)) return { toolId: seg.id, workerId: null };
1155
1412
  workerMayOwn = false;
1156
1413
  }
1157
1414
  }
@@ -1187,9 +1444,12 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1187
1444
  /** True when the entry renders at least one counting elapsed line this frame. */
1188
1445
  const entryHasRunningTool = (entry: TranscriptEntry): boolean =>
1189
1446
  (entry.role === "assistant" &&
1190
- entry.segments.some(
1447
+ (entry.segments.some(
1191
1448
  (segment) => segment.kind === "tool" && !segment.finished && segment.startedAtMs !== undefined,
1192
- )) ||
1449
+ // The live reasoning line counts its own elapsed, so a turn that is
1450
+ // only thinking still needs the time-keyed render key.
1451
+ ) ||
1452
+ (entry.pending && openThinkingSegment(entry)?.startedAtMs !== undefined))) ||
1193
1453
  (entry.role === "replayBlock" && entry.isLive?.() === true);
1194
1454
 
1195
1455
  /**
@@ -1210,7 +1470,12 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1210
1470
  const render = (width: number): string[] => {
1211
1471
  const startedAt = performance.now();
1212
1472
  const expandKey = resolveExpandKey();
1213
- const verbosity = options.getOutputVerbosity?.() ?? "default";
1473
+ const verbosity = options.getOutputVerbosity?.();
1474
+ // A new verbosity is a new baseline: the operator's per-block overrides
1475
+ // were answers to the old one, so they go before the frame is built.
1476
+ if (lastVerbosity !== null && verbosity !== lastVerbosity) dropFoldOverrides();
1477
+ lastVerbosity = verbosity;
1478
+ const detail = transcriptDetail(verbosity);
1214
1479
  const nowMs = now();
1215
1480
  // `dirty` is set on mutation and never on a tick, so without time in the
1216
1481
  // key a running tool's elapsed counter advanced only when something
@@ -1228,14 +1493,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1228
1493
  !dirty &&
1229
1494
  cachedWidth === width &&
1230
1495
  cachedExpandKey === expandKey &&
1231
- cachedVerbosity === verbosity &&
1496
+ cachedDetail === detail &&
1232
1497
  cachedLiveToolOutput === liveToolOutput &&
1233
1498
  cachedTick === tick
1234
1499
  ) {
1235
1500
  options.onRenderMetrics?.({ durationMs: performance.now() - startedAt, cacheHit: true, entriesRendered: 0 });
1236
1501
  return cachedLines;
1237
1502
  }
1238
- const { toolId: latestHintToolId, workerId: latestFoldedWorkerId } = expandHintOwner();
1503
+ const { toolId: latestHintToolId, workerId: latestFoldedWorkerId } = expandHintOwner(detail);
1239
1504
  // The hint id is deliberately NOT part of the shared key: it changes on
1240
1505
  // every finished collapsed tool, and keying every entry on it re-rendered
1241
1506
  // the entire transcript per tool completion. Only the entry that contains
@@ -1244,7 +1509,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1244
1509
  // bytes at every tick, and keying it on time would drop the entry cache
1245
1510
  // and the frozen prefix ten times a second. Only the panel-level guard
1246
1511
  // above is time-keyed, so a tick re-renders the live tail and nothing else.
1247
- const baseKey = `${width}|${expandKey ?? ""}|${verbosity}|${liveToolOutput}`;
1512
+ const baseKey = `${width}|${expandKey ?? ""}|${detail.toolBody}:${detail.runningTool}:${detail.thinking}:${detail.worker}:${detail.receipt}:${detail.errors}|${liveToolOutput}`;
1248
1513
  const capacity = entryCacheCapacity();
1249
1514
  if (frozen !== null && frozen.key !== baseKey) frozen = null;
1250
1515
  const out: string[] = frozen === null ? [] : frozen.lines.slice();
@@ -1270,8 +1535,8 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1270
1535
  const stacksOnPrevious =
1271
1536
  entry.role === "worker" &&
1272
1537
  previous?.role === "worker" &&
1273
- workerEntryFolded(entry, verbosity) &&
1274
- workerEntryFolded(previous, verbosity);
1538
+ workerEntryFolded(entry, detail) &&
1539
+ workerEntryFolded(previous, detail);
1275
1540
  if (i > 0 && !stacksOnPrevious) out.push("");
1276
1541
  const containsHint =
1277
1542
  entryContainsHint(entry, latestHintToolId) ||
@@ -1293,8 +1558,9 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1293
1558
  latestFoldedWorkerId,
1294
1559
  nowMs,
1295
1560
  unboundedToolBodies,
1296
- verbosity,
1561
+ detail,
1297
1562
  liveToolOutput,
1563
+ liveReasoning,
1298
1564
  );
1299
1565
  for (const line of renderedEntry) out.push(line);
1300
1566
  if (cacheable) {
@@ -1317,7 +1583,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1317
1583
  cachedLines = out;
1318
1584
  cachedWidth = width;
1319
1585
  cachedExpandKey = expandKey;
1320
- cachedVerbosity = verbosity;
1586
+ cachedDetail = detail;
1321
1587
  cachedLiveToolOutput = liveToolOutput;
1322
1588
  cachedTick = tick;
1323
1589
  renderedRunningTool = sawRunningTool;
@@ -1331,8 +1597,8 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1331
1597
  transcript.push({ role: "user", text });
1332
1598
  markDirty();
1333
1599
  },
1334
- appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean): void {
1335
- transcript.push({ role: "replayBlock", renderBlock, isLive });
1600
+ appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean, fold?: ReplayBlockFoldControl): void {
1601
+ transcript.push({ role: "replayBlock", renderBlock, isLive, fold });
1336
1602
  markDirty();
1337
1603
  },
1338
1604
  applyWorkerState(state: WorkerEntryState): void {
@@ -1344,7 +1610,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1344
1610
  markDirty();
1345
1611
  return;
1346
1612
  }
1347
- const entry: WorkerTranscriptEntry = { role: "worker", state, folded: workerAskedByModel(state) };
1613
+ const entry: WorkerTranscriptEntry = { role: "worker", state };
1348
1614
  workerEntries.set(state.assignmentId, entry);
1349
1615
  const at = workerInsertionIndex(state);
1350
1616
  if (at === null) {
@@ -1361,13 +1627,26 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1361
1627
  return [...workerEntries.values()].map((entry) => entry.state);
1362
1628
  },
1363
1629
  toggleLastToolExpanded(): boolean {
1364
- // Ctrl+O owns the newest foldable thing, whichever kind it is. A worker
1630
+ // The key owns the newest foldable thing, whichever kind it is. A worker
1365
1631
  // block the operator just watched land is what they mean by "expand
1366
- // that", not the tool call two screens up that spawned it.
1632
+ // that", not the tool call two screens up that spawned it. Every flip
1633
+ // is an override away from the block's effective state, so the same
1634
+ // key opens a folded block under minimal and folds an open one under
1635
+ // verbose.
1636
+ const detail = currentDetail();
1367
1637
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1368
1638
  const entry = transcript[entryIndex];
1639
+ // A caller-rendered block that owns fold state (the operator's own
1640
+ // `!` bash row) is foldable too, and it is usually the newest thing
1641
+ // on screen when the key is pressed.
1642
+ if (entry?.role === "replayBlock" && entry.fold !== undefined) {
1643
+ entry.fold.setFold(toggledFold(resolveFold(entry.fold.fold(), entry.fold.policyFold(detail))));
1644
+ clearRenderCaches();
1645
+ markDirty();
1646
+ return true;
1647
+ }
1369
1648
  if (entry?.role === "worker") {
1370
- entry.folded = !entry.folded;
1649
+ entry.fold = workerEntryFolded(entry, detail) ? "expanded" : "folded";
1371
1650
  clearRenderCaches();
1372
1651
  markDirty();
1373
1652
  return true;
@@ -1376,7 +1655,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1376
1655
  for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1377
1656
  const seg = entry.segments[segIndex];
1378
1657
  if (seg?.kind !== "tool") continue;
1379
- seg.expanded = !seg.expanded;
1658
+ seg.fold = toolSegmentExpanded(seg, detail) ? "folded" : "expanded";
1380
1659
  clearRenderCaches();
1381
1660
  markDirty();
1382
1661
  return true;
@@ -1385,9 +1664,15 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1385
1664
  return false;
1386
1665
  },
1387
1666
  toggleAllToolsExpanded(): boolean {
1667
+ const detail = currentDetail();
1388
1668
  const tools: ToolSegment[] = [];
1389
1669
  const workers: WorkerTranscriptEntry[] = [];
1670
+ const blocks: ReplayBlockFoldControl[] = [];
1390
1671
  for (const entry of transcript) {
1672
+ if (entry.role === "replayBlock") {
1673
+ if (entry.fold !== undefined) blocks.push(entry.fold);
1674
+ continue;
1675
+ }
1391
1676
  if (entry.role === "worker") {
1392
1677
  workers.push(entry);
1393
1678
  continue;
@@ -1397,62 +1682,70 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1397
1682
  if (seg.kind === "tool") tools.push(seg);
1398
1683
  }
1399
1684
  }
1400
- if (tools.length === 0 && workers.length === 0) return false;
1401
- const expand = tools.some((seg) => !seg.expanded) || workers.some((entry) => entry.folded);
1402
- for (const seg of tools) seg.expanded = expand;
1403
- for (const entry of workers) entry.folded = !expand;
1685
+ if (tools.length === 0 && workers.length === 0 && blocks.length === 0) return false;
1686
+ // One folded block anywhere means "open everything"; otherwise fold
1687
+ // everything. Either way every block gets an explicit override.
1688
+ const expand =
1689
+ tools.some((seg) => !toolSegmentExpanded(seg, detail)) ||
1690
+ workers.some((entry) => workerEntryFolded(entry, detail)) ||
1691
+ blocks.some((fold) => resolveFold(fold.fold(), fold.policyFold(detail)) === "folded");
1692
+ const next: Fold = expand ? "expanded" : "folded";
1693
+ for (const seg of tools) seg.fold = next;
1694
+ for (const entry of workers) entry.fold = next;
1695
+ for (const fold of blocks) fold.setFold(next);
1404
1696
  clearRenderCaches();
1405
1697
  markDirty();
1406
1698
  return true;
1407
1699
  },
1408
- collapseAllTools(): void {
1409
- for (const entry of transcript) {
1410
- if (entry.role === "worker") {
1411
- // The settled view for a worker is its origin default, not
1412
- // universally folded: the operator's own run is the one block
1413
- // /run exists to show them.
1414
- entry.folded = workerAskedByModel(entry.state);
1415
- continue;
1416
- }
1417
- if (entry.role !== "assistant") continue;
1418
- for (const seg of entry.segments) {
1419
- if (seg.kind === "tool") seg.expanded = false;
1420
- }
1421
- }
1422
- clearRenderCaches();
1423
- markDirty();
1700
+ clearFoldOverrides(): void {
1701
+ dropFoldOverrides();
1424
1702
  },
1425
1703
  toggleLastThinking(): boolean {
1704
+ const detail = currentDetail();
1426
1705
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1427
1706
  const entry = transcript[entryIndex];
1428
1707
  if (entry?.role !== "assistant") continue;
1429
- if (entry.thinking.length === 0) continue;
1430
- entry.expandedThinking = entry.expandedThinking !== true;
1431
- thinkingExpanded = entry.expandedThinking === true;
1432
- clearRenderCaches();
1433
- markDirty();
1434
- return true;
1708
+ for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1709
+ const segment = entry.segments[segIndex];
1710
+ if (segment?.kind !== "thinking" || segment.text.length === 0) continue;
1711
+ segment.fold = thinkingExpanded(segment, detail) ? "folded" : "expanded";
1712
+ clearRenderCaches();
1713
+ markDirty();
1714
+ return true;
1715
+ }
1435
1716
  }
1436
- thinkingExpanded = !thinkingExpanded;
1437
- return true;
1717
+ return false;
1438
1718
  },
1439
1719
  toggleAllThinking(): boolean {
1440
- const entries: Array<Extract<TranscriptEntry, { role: "assistant" }>> = [];
1720
+ const detail = currentDetail();
1721
+ const segments: ThinkingSegment[] = [];
1441
1722
  for (const entry of transcript) {
1442
- if (entry.role === "assistant" && entry.thinking.length > 0) entries.push(entry);
1443
- }
1444
- if (entries.length === 0) {
1445
- thinkingExpanded = !thinkingExpanded;
1446
- return true;
1723
+ if (entry.role !== "assistant") continue;
1724
+ for (const segment of entry.segments) {
1725
+ if (segment.kind === "thinking" && segment.text.length > 0) segments.push(segment);
1726
+ }
1447
1727
  }
1448
- const expand = entries.some((entry) => entry.expandedThinking !== true);
1449
- for (const entry of entries) entry.expandedThinking = expand;
1450
- thinkingExpanded = expand;
1728
+ if (segments.length === 0) return false;
1729
+ const expand = segments.some((segment) => !thinkingExpanded(segment, detail));
1730
+ for (const segment of segments) segment.fold = expand ? "expanded" : "folded";
1451
1731
  clearRenderCaches();
1452
1732
  markDirty();
1453
1733
  return true;
1454
1734
  },
1455
- isThinkingExpanded: () => thinkingExpanded,
1735
+ isThinkingExpanded(): boolean {
1736
+ // The live stretch lives on the newest assistant entry; its own override,
1737
+ // if any, is the one that applies. Before a stretch arrives, the policy answers.
1738
+ const detail = currentDetail();
1739
+ const index = lastAssistantIndex(transcript);
1740
+ const entry = index === null ? undefined : transcript[index];
1741
+ if (entry?.role === "assistant") {
1742
+ for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1743
+ const segment = entry.segments[segIndex];
1744
+ if (segment?.kind === "thinking" && segment.text.length > 0) return thinkingExpanded(segment, detail);
1745
+ }
1746
+ }
1747
+ return policyThinkingFold(detail) === "expanded";
1748
+ },
1456
1749
  toggleLiveToolOutput(): boolean {
1457
1750
  liveToolOutput = !liveToolOutput;
1458
1751
  markDirty();
@@ -1461,6 +1754,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1461
1754
  reset(): void {
1462
1755
  transcript.length = 0;
1463
1756
  workerEntries.clear();
1757
+ liveReasoning = UNMEASURED_REASONING;
1464
1758
  clearRenderCaches();
1465
1759
  markDirty();
1466
1760
  },
@@ -1524,6 +1818,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1524
1818
  } else if (existing === undefined) {
1525
1819
  const assistant = ensureAssistant();
1526
1820
  assistant.pending = true;
1821
+ closeOpenThinking(assistant);
1527
1822
  assistant.segments.push({
1528
1823
  kind: "tool",
1529
1824
  id: streamed.id,
@@ -1533,7 +1828,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1533
1828
  executionStarted: false,
1534
1829
  argsComplete: assistantEvent.type === "toolcall_end",
1535
1830
  isError: false,
1536
- expanded: false,
1537
1831
  });
1538
1832
  }
1539
1833
  markDirty();
@@ -1547,16 +1841,18 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1547
1841
  return;
1548
1842
  }
1549
1843
  if (event.type === "thinking_delta") {
1550
- // Capture for downstream consumers but never render inline.
1844
+ // The text itself is never rendered unless the operator expands it;
1845
+ // the segment is what keeps reasoning in its place in the stream.
1551
1846
  const assistant = ensureAssistant();
1552
1847
  assistant.pending = true;
1553
- assistant.thinking += event.delta;
1554
- assistant.expandedThinking = thinkingExpanded;
1848
+ appendThinkingDelta(assistant, event.delta);
1555
1849
  markDirty();
1556
1850
  return;
1557
1851
  }
1558
1852
  if (event.type === "message_start" && event.message.role === "assistant") {
1559
- ensureAssistant().pending = true;
1853
+ const assistant = ensureAssistant();
1854
+ assistant.pending = true;
1855
+ assistant.messageStartSegmentIndex = assistant.segments.length;
1560
1856
  markDirty();
1561
1857
  return;
1562
1858
  }
@@ -1570,7 +1866,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1570
1866
  streamed.executionStarted = true;
1571
1867
  streamed.argsComplete = true;
1572
1868
  streamed.startedAtMs = now();
1573
- streamed.expanded = classifyResourceRead(event.toolName, event.args) === null;
1574
1869
  markDirty();
1575
1870
  return;
1576
1871
  }
@@ -1585,10 +1880,11 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1585
1880
  }
1586
1881
  }
1587
1882
  const assistant = ensureAssistant();
1588
- // Compact resource reads (SKILL.md, CLIO-CODER.md, AGENTS.md, docs/) stay
1589
- // collapsed to one labeled line until explicitly expanded.
1590
- const expanded = classifyResourceRead(event.toolName, event.args) === null;
1883
+ // No fold is stored here: the segment's effective state is resolved
1884
+ // per frame from the transcript detail policy and the tool's
1885
+ // registered presentation, with the operator's override on top.
1591
1886
  assistant.pending = true;
1887
+ closeOpenThinking(assistant);
1592
1888
  assistant.segments.push({
1593
1889
  kind: "tool",
1594
1890
  id: event.toolCallId,
@@ -1598,7 +1894,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1598
1894
  executionStarted: true,
1599
1895
  argsComplete: true,
1600
1896
  isError: false,
1601
- expanded,
1602
1897
  startedAtMs: now(),
1603
1898
  });
1604
1899
  markDirty();
@@ -1650,7 +1945,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1650
1945
  resultSummary?: unknown;
1651
1946
  outcome?: unknown;
1652
1947
  blockReason?: unknown;
1948
+ evictedReason?: unknown;
1653
1949
  };
1950
+ // Replay-only: the rehydrator reads the working-set fold and tags
1951
+ // the rows whose bodies the projection has replaced for the model.
1952
+ tool.evictedReason =
1953
+ typeof enriched.evictedReason === "string" && enriched.evictedReason.length > 0
1954
+ ? enriched.evictedReason
1955
+ : undefined;
1654
1956
  // Settlement is that verdict, never an inference from result text.
1655
1957
  // The text of a tool result is the tool's own output: `node --test`
1656
1958
  // prints `cancelled 0` on every run and a linter can print
@@ -1716,10 +2018,22 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1716
2018
  invalidateEntryCache(assistant);
1717
2019
  if (usage !== undefined) assistant.turnUsage = usage;
1718
2020
  if (terminalError.length > 0) assistant.isError = true;
2021
+ // The message is settled, so whatever reasoning it streamed is closed.
2022
+ // A message that carried thinking the panel never saw as deltas (a
2023
+ // non-streaming provider, a replayed message) gets one segment at the
2024
+ // point this message began, ahead of the text the same message
2025
+ // produced, rather than a marker dangling after the answer.
2026
+ closeOpenThinking(assistant);
1719
2027
  if (thinking.length > 0) {
1720
- assistant.thinking = thinking;
1721
- assistant.expandedThinking = thinkingExpanded;
2028
+ const messageStart = Math.min(assistant.messageStartSegmentIndex ?? 0, assistant.segments.length);
2029
+ const streamedThisMessage = assistant.segments
2030
+ .slice(messageStart)
2031
+ .some((segment) => segment.kind === "thinking" && segment.text.length > 0);
2032
+ if (!streamedThisMessage) {
2033
+ assistant.segments.splice(messageStart, 0, { kind: "thinking", text: thinking, finalized: true });
2034
+ }
1722
2035
  }
2036
+ assistant.messageStartSegmentIndex = undefined;
1723
2037
  // The chat loop marks messages it sanitized after streaming (dead
1724
2038
  // tool-call markup on a synthesis-locked turn); the streamed tail
1725
2039
  // must be replaced, not kept alongside a duplicate segment.
@@ -1790,13 +2104,26 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1790
2104
  }
1791
2105
  if (entry.pending) {
1792
2106
  invalidateEntryCache(entry);
2107
+ closeOpenThinking(entry);
1793
2108
  entry.pending = false;
1794
2109
  entry.statusLine = null;
2110
+ entry.messageStartSegmentIndex = undefined;
1795
2111
  }
1796
2112
  }
1797
2113
  markDirty();
1798
2114
  }
1799
2115
  },
2116
+ setLiveReasoning(view: ReasoningUsageView | null): void {
2117
+ const next = view ?? UNMEASURED_REASONING;
2118
+ if (next.tokens === liveReasoning.tokens && next.provenance === liveReasoning.provenance) return;
2119
+ liveReasoning = next;
2120
+ // The line lives on the pending tail entry, which the freeze may already
2121
+ // cover if nothing has mutated since the last settle.
2122
+ unfreezeTail();
2123
+ const last = transcript[transcript.length - 1];
2124
+ if (last !== undefined) invalidateEntryCache(last);
2125
+ markDirty();
2126
+ },
1800
2127
  setStatusLine(line): void {
1801
2128
  if (line) {
1802
2129
  const assistant = ensureAssistant();