@iowarp/clio-coder 0.3.2 → 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 (115) hide show
  1. package/CHANGELOG.md +229 -457
  2. package/README.md +2 -2
  3. package/dist/{acp-BIYHVZIM.js → acp-P2AQILE2.js} +2 -2
  4. package/dist/{agents-YT6SSRIT.js → agents-72W3BI7I.js} +8 -8
  5. package/dist/assets/codewiki.json +1 -1
  6. package/dist/{chunk-WMSVI4G2.js → chunk-2DJ2KNFG.js} +3 -3
  7. package/dist/{chunk-2EHAIA3X.js → chunk-2TLUCQVG.js} +2 -2
  8. package/dist/{chunk-WVO7V2QY.js → chunk-4XUGQOHA.js} +2 -2
  9. package/dist/{chunk-IGLFWIYI.js → chunk-5UFT4SUX.js} +2 -2
  10. package/dist/{chunk-AO4RKG4M.js → chunk-6SGHMWE3.js} +3 -3
  11. package/dist/{chunk-X75S7HFS.js → chunk-COU2UHX6.js} +45 -19
  12. package/dist/{chunk-KJ5LWLOE.js → chunk-DSELYM6W.js} +2 -2
  13. package/dist/{chunk-G2DE3C7R.js → chunk-DUYJ5IO6.js} +2 -2
  14. package/dist/{chunk-EPVUXGXG.js → chunk-FNTMWMX5.js} +9 -9
  15. package/dist/{chunk-MNA4JGU4.js → chunk-J7CWMCQD.js} +2 -2
  16. package/dist/{chunk-MBS4V7ZP.js → chunk-KZWTDYJF.js} +4 -4
  17. package/dist/{chunk-LBNRH5WM.js → chunk-LM5TQCJZ.js} +3 -3
  18. package/dist/{chunk-OHHN2SO4.js → chunk-LW6DSM3M.js} +7 -7
  19. package/dist/{chunk-MQSRRFWA.js → chunk-LWLEKMDQ.js} +56 -2
  20. package/dist/{chunk-V4RXGQ5Q.js → chunk-OC7FIQPC.js} +2 -2
  21. package/dist/{chunk-3ZXDFGR5.js → chunk-PAJK6MAQ.js} +2 -2
  22. package/dist/{chunk-7EYHLWU7.js → chunk-PIWWS5BL.js} +3 -3
  23. package/dist/{chunk-6EJV5X2W.js → chunk-SRDMMSEP.js} +3 -3
  24. package/dist/{chunk-ARBGF5F7.js → chunk-TZK7PACC.js} +2 -2
  25. package/dist/{chunk-QTYWRVRA.js → chunk-UFIIWP2H.js} +2 -2
  26. package/dist/{chunk-J5HN4RYU.js → chunk-V6RTAOC2.js} +2 -2
  27. package/dist/{chunk-SRF2PJNW.js → chunk-VPAYEGVX.js} +2 -2
  28. package/dist/{chunk-77VKQEHF.js → chunk-X6IAEBZR.js} +2 -2
  29. package/dist/{chunk-4KLWL3UC.js → chunk-XBXAASKX.js} +2 -2
  30. package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
  31. package/dist/cli/index.js +17 -17
  32. package/dist/{clio-4LY5K2AC.js → clio-JOU4FXVA.js} +2 -2
  33. package/dist/{config-GTLUW2PR.js → config-XCDVKR23.js} +7 -7
  34. package/dist/{configure-R6A64DHX.js → configure-4GAP54ZW.js} +5 -5
  35. package/dist/{context-RW5HC47S.js → context-4UOGGLQ5.js} +6 -6
  36. package/dist/{context-JFZEJ7W5.js → context-77FM5DV5.js} +9 -9
  37. package/dist/{context-clear-6ZHBAZZT.js → context-clear-XXJRLCJJ.js} +6 -6
  38. package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-QPRDDBDX.js} +7 -7
  39. package/dist/{doctor-KI767GSN.js → doctor-HR46URBJ.js} +5 -5
  40. package/dist/{evidence-UA6AWDQQ.js → evidence-6HG2PY2B.js} +4 -4
  41. package/dist/{evolve-QNTFGV6Z.js → evolve-K7YU3NCY.js} +4 -4
  42. package/dist/{fleet-Q7UOMUSG.js → fleet-VY3HHKN6.js} +15 -15
  43. package/dist/{init-WBB65ZHQ.js → init-JYGXI3FK.js} +12 -12
  44. package/dist/{memory-MD3O64RI.js → memory-WFZMGYHX.js} +5 -5
  45. package/dist/{models-BZU34YWD.js → models-I5QWSEOM.js} +7 -7
  46. package/dist/{monitor-MEQA5C3I.js → monitor-GE4ID3IA.js} +5 -5
  47. package/dist/{orchestrator-CGFKEP27.js → orchestrator-EM5MC3HM.js} +1482 -1055
  48. package/dist/{run-IV4Q6RLN.js → run-ZU3QMZPZ.js} +18 -18
  49. package/dist/{skills-LQEKRDTN.js → skills-X5VXCRNQ.js} +2 -2
  50. package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WKIHWTHR.js} +5 -5
  51. package/dist/{targets-C4SSGQOB.js → targets-SNCPI2NR.js} +8 -8
  52. package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-BNAHVHBS.js} +2 -2
  53. package/dist/{upgrade-7TT7SQ3G.js → upgrade-JQHHPQ4K.js} +7 -7
  54. package/dist/{usage-GV4PKT3M.js → usage-OR4O5SMZ.js} +5 -5
  55. package/dist/{verify-G6V4D2G7.js → verify-375KUB3Y.js} +4 -4
  56. package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-UEXP2ARI.js} +11 -11
  57. package/dist/worker/entry.js +8 -8
  58. package/docs/README.md +3 -3
  59. package/docs/acp.md +1 -1
  60. package/docs/alcf-provider.md +1 -1
  61. package/docs/architecture.md +2 -2
  62. package/docs/artifact-versions.md +1 -1
  63. package/docs/built-in-agents.md +1 -1
  64. package/docs/capacity-and-scheduling.md +1 -1
  65. package/docs/commands-and-modes.md +1 -1
  66. package/docs/configuration-and-targets.md +1 -1
  67. package/docs/context-engine.md +1 -1
  68. package/docs/development-pipeline.md +1 -1
  69. package/docs/documentation-coverage.md +2 -2
  70. package/docs/documentation-guide.md +1 -1
  71. package/docs/eval-runner.md +1 -1
  72. package/docs/evals-internal.md +1 -1
  73. package/docs/evidence-and-memory.md +2 -2
  74. package/docs/evolution.md +1 -1
  75. package/docs/exit-codes-and-output.md +1 -1
  76. package/docs/extensions-and-sharing.md +2 -2
  77. package/docs/fleet-dispatch.md +1 -1
  78. package/docs/installation-and-lifecycle.md +6 -6
  79. package/docs/middleware-and-components.md +1 -1
  80. package/docs/model-catalog.md +1 -1
  81. package/docs/observability.md +3 -3
  82. package/docs/performance-methodology.md +2 -2
  83. package/docs/proactive-memory.md +1 -1
  84. package/docs/prompt-envelope-and-tools.md +1 -1
  85. package/docs/provider-adapter-cookbook.md +1 -1
  86. package/docs/release-cut-checklist.md +30 -30
  87. package/docs/safety-model.md +2 -2
  88. package/docs/scientific-validation.md +3 -3
  89. package/docs/session-lifecycle.md +2 -2
  90. package/docs/skills-marketplace.md +1 -1
  91. package/docs/tool-usage.md +2 -2
  92. package/docs/trace-store.md +1 -1
  93. package/docs/troubleshooting.md +1 -1
  94. package/docs/tui-design.md +2 -2
  95. package/docs/worker-dispatch-mechanics.md +1 -1
  96. package/package.json +1 -1
  97. package/src/core/git-commit-attribution.ts +46 -21
  98. package/src/domains/config/keybindings.ts +3 -3
  99. package/src/interactive/chat-panel.ts +555 -244
  100. package/src/interactive/chat-renderer.ts +50 -19
  101. package/src/interactive/editor-submit.ts +26 -1
  102. package/src/interactive/footer/widgets.ts +22 -20
  103. package/src/interactive/footer-panel.ts +6 -1
  104. package/src/interactive/interactive-application.ts +2 -0
  105. package/src/interactive/interactive-event-projection.ts +12 -0
  106. package/src/interactive/interactive-slash-runtime.ts +12 -7
  107. package/src/interactive/overlays/ask-user.ts +146 -24
  108. package/src/interactive/renderers/tool-execution.ts +151 -56
  109. package/src/interactive/status/index.ts +12 -1
  110. package/src/interactive/status/reasoning.ts +87 -0
  111. package/src/interactive/status/summary.ts +13 -2
  112. package/src/interactive/transcript-detail.ts +120 -0
  113. package/src/tools/builtin-tool-catalog.ts +7 -1
  114. package/src/tools/presentation.ts +107 -0
  115. package/src/tools/registry.ts +6 -0
@@ -2,9 +2,9 @@ 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
10
  import type { ApprovalRequestView } from "./permission-overlay.js";
@@ -13,7 +13,6 @@ import { createMermaidMarkdownTransform } from "./renderers/mermaid.js";
13
13
  import { styleTaggedNotice } from "./renderers/notice.js";
14
14
  import { formatRetryStatus } from "./renderers/retry-status.js";
15
15
  import {
16
- classifyResourceRead,
17
16
  renderToolAwaitingApproval,
18
17
  renderToolCallHeader,
19
18
  renderToolExecution,
@@ -22,8 +21,33 @@ import {
22
21
  renderToolSubline,
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
@@ -69,7 +93,7 @@ const USER_GLYPH = GLYPH.user;
69
93
  * through the Markdown renderer. Partial markdown (unclosed fence, half-typed
70
94
  * bullet) would otherwise paint garbage at ~60 fps under streaming.
71
95
  */
72
- export type ReasoningTokenProvenance = "provider" | "estimated" | "mixed";
96
+ export type { ReasoningTokenProvenance } from "./status/index.js";
73
97
 
74
98
  export interface ChatPanelTurnUsage {
75
99
  inputTokens: number;
@@ -139,8 +163,13 @@ type ToolSegment = {
139
163
  settledWithoutResult?: boolean | undefined;
140
164
  /** True when the finished result was an error. Meaningful only after `finished`. */
141
165
  isError: boolean;
142
- /** When true, render the full structured block instead of the collapsed subline. */
143
- 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;
144
173
  /** Wall-clock start time captured by the chat panel for live duration display. */
145
174
  startedAtMs?: number;
146
175
  /** Completed call duration in milliseconds (event-supplied or measured locally). */
@@ -151,7 +180,8 @@ type ToolSegment = {
151
180
  * Latest cumulative Pi result from `tool_execution_update`, including the
152
181
  * display content and structured progress details. Cleared
153
182
  * back to `undefined` on `tool_execution_end` so the finished `result`
154
- * 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
155
185
  * `| undefined` is required under `exactOptionalPropertyTypes: true` so
156
186
  * the clear path can re-assign `undefined` without a `delete`.
157
187
  */
@@ -186,8 +216,32 @@ type ErrorSegment = {
186
216
  kind: "error";
187
217
  text: string;
188
218
  };
189
- type AssistantSegment = TextSegment | ToolSegment | ErrorSegment;
190
- 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[];
191
245
  type AssistantStatusLine = { phase: StatusPhase; verb: string; toneHint: VerbRender["toneHint"] };
192
246
 
193
247
  type TranscriptEntry =
@@ -197,23 +251,13 @@ type TranscriptEntry =
197
251
  role: "assistant";
198
252
  segments: AssistantSegment[];
199
253
  /**
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.
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.
208
259
  */
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.
215
- */
216
- expandedThinking?: boolean;
260
+ messageStartSegmentIndex?: number | undefined;
217
261
  pending: boolean;
218
262
  statusLine?: AssistantStatusLine | null | undefined;
219
263
  isError: boolean;
@@ -221,11 +265,11 @@ type TranscriptEntry =
221
265
  }
222
266
  /**
223
267
  * 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.
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.
226
270
  * The panel is told when that happened through `applyWorkerState`.
227
271
  */
228
- | { role: "worker"; state: WorkerEntryState; folded: boolean }
272
+ | { role: "worker"; state: WorkerEntryState; fold?: FoldOverride }
229
273
  /**
230
274
  * A block the caller renders itself. Most are settled the moment they are
231
275
  * appended, but a few (the operator's `!` bash row) keep mutating the state
@@ -233,10 +277,30 @@ type TranscriptEntry =
233
277
  * declares `isLive`, which keeps it out of the frozen prefix and keeps the
234
278
  * panel's time-keyed tick running while it is unsettled.
235
279
  */
236
- | { role: "replayBlock"; renderBlock: ReplayBlockRenderer; isLive?: (() => boolean) | undefined };
280
+ | {
281
+ role: "replayBlock";
282
+ renderBlock: ReplayBlockRenderer;
283
+ isLive?: (() => boolean) | undefined;
284
+ fold?: ReplayBlockFoldControl | undefined;
285
+ };
237
286
 
238
287
  type WorkerTranscriptEntry = Extract<TranscriptEntry, { role: "worker" }>;
239
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
+
240
304
  export interface ChatPanel extends Component {
241
305
  appendUser(text: string): void;
242
306
  /**
@@ -244,7 +308,7 @@ export interface ChatPanel extends Component {
244
308
  * that keeps changing after the append, so the panel keeps re-rendering it
245
309
  * instead of treating the first frame as final.
246
310
  */
247
- appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean): void;
311
+ appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean, fold?: ReplayBlockFoldControl): void;
248
312
  applyEvent(event: ChatLoopEvent): void;
249
313
  /** Mark a just-rehydrated tool segment so its mutation diff remains plain. */
250
314
  markToolReplayed?(toolCallId: string): void;
@@ -262,24 +326,36 @@ export interface ChatPanel extends Component {
262
326
  */
263
327
  workerStates(): ReadonlyArray<WorkerEntryState>;
264
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
+ */
265
341
  toggleLastToolExpanded(): boolean;
342
+ /** Set an explicit override on every tool, worker, and fold-owning block at once. */
266
343
  toggleAllToolsExpanded(): boolean;
267
344
  /**
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.
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.
272
349
  */
273
- collapseAllTools(): void;
350
+ clearFoldOverrides(): void;
274
351
  /**
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.
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.
279
355
  */
280
356
  toggleLastThinking(): boolean;
281
357
  toggleAllThinking(): boolean;
282
- /** Current panel-level live-thinking visibility used by presentation pacing. */
358
+ /** Whether live thinking would render open this frame, which presentation pacing consults. */
283
359
  isThinkingExpanded(): boolean;
284
360
  /** Toggle whether expanded live tool bodies include cumulative partial output. */
285
361
  toggleLiveToolOutput(): boolean;
@@ -360,39 +436,42 @@ function extractAssistantThinking(message: unknown): string {
360
436
  .join("");
361
437
  }
362
438
 
363
- function finiteToken(value: unknown): number {
364
- return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
365
- }
366
-
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
+ */
367
445
  function assistantUsage(message: unknown): ChatPanelTurnUsage | undefined {
368
446
  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);
447
+ const tally = foldMessageIntoRunTally(emptyRunTally(), message as AgentMessage);
448
+ const reasoning = reasoningFromTally(tally);
378
449
  if (
379
- inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0 &&
380
- reportedReasoning === null &&
381
- estimatedReasoning === null
450
+ tally.inputTokens + tally.outputTokens + tally.cacheReadTokens + tally.cacheWriteTokens === 0 &&
451
+ reasoning.provenance === "unmeasured"
382
452
  ) {
383
453
  return undefined;
384
454
  }
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";
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;
392
465
  }
393
466
  return turnUsage;
394
467
  }
395
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
+
396
475
  function aggregateAssistantUsage(messages: unknown): ChatPanelTurnUsage | undefined {
397
476
  if (!Array.isArray(messages)) return undefined;
398
477
  const total: ChatPanelTurnUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
@@ -458,6 +537,24 @@ function scopeTerminalErrorAfterSuccessfulTool(
458
537
  return `[error] ${modelFailure}${detachedDispatchSucceeded ? "; detached runs continue" : ""}`;
459
538
  }
460
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
+
461
558
  function hasVisibleOutput(entry: Extract<TranscriptEntry, { role: "assistant" }>): boolean {
462
559
  for (const seg of entry.segments) {
463
560
  if (seg.kind === "tool") return true;
@@ -590,49 +687,60 @@ function hangProseLines(lines: string[], firstPrefix?: string): string[] {
590
687
  */
591
688
  const THINKING_HIDDEN_LABEL = `Thinking${GLYPH.ellipsis}`;
592
689
  const THINKING_LINE_LIMIT = 12;
593
- const REASONING_CHARS_PER_TOKEN = 4;
594
690
 
595
- function estimateThinkingTokens(thinking: string): number {
596
- 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}`;
597
693
  }
598
694
 
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" };
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
+ );
607
707
  }
608
708
 
609
709
  /**
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
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
614
734
  * lab-notebook minimalism: no colored glyphs, no boxes.
615
735
  */
616
- function renderThinkingLines(
617
- thinking: string,
618
- expanded: boolean,
619
- width: number,
620
- streaming: boolean,
621
- usage?: ChatPanelTurnUsage,
622
- ): string[] {
736
+ function renderThinkingRail(thinking: string, width: number, streaming: boolean, unbounded = false): string[] {
623
737
  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
738
  const splitLines = thinking.split("\n");
634
739
  let visible: string[];
635
- 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) {
636
744
  if (splitLines.length > THINKING_LINE_LIMIT) {
637
745
  const hiddenCount = splitLines.length - THINKING_LINE_LIMIT;
638
746
  visible = [`… ${hiddenCount} earlier lines hidden`, ...splitLines.slice(-THINKING_LINE_LIMIT)];
@@ -664,9 +772,13 @@ function renderThinkingLines(
664
772
  * reported 717676 input tokens against a 500k window; it was 65 calls of about
665
773
  * 11k, which the preceding one-call turns had already shown.
666
774
  */
667
- function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity: OutputVerbosity): string[] {
668
- if (verbosity === "minimal") return [];
669
- 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") {
670
782
  const receipt = truncateToWidth(
671
783
  ` turn · in ${usage.inputTokens} · out ${usage.outputTokens}`,
672
784
  width,
@@ -686,9 +798,10 @@ function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity
686
798
  // provenance of zero, and at narrow widths `reasoning 0 provider` orphaned
687
799
  // the word `provider` on its own line. Zero suppresses the whole suffix, the
688
800
  // same rule the caveat below already follows.
801
+ const view = reasoningFromTurnUsage(usage);
689
802
  const reason =
690
- usage.reasoningTokens !== undefined && usage.reasoningTokens > 0
691
- ? ` 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)}`
692
805
  : "";
693
806
  const cache =
694
807
  usage.cacheReadTokens > 0 || usage.cacheWriteTokens > 0
@@ -697,10 +810,7 @@ function renderTurnUsageLine(usage: ChatPanelTurnUsage, width: number, verbosity
697
810
  // The caveat is about reasoning text the panel displayed. A turn that spent
698
811
  // no reasoning tokens displayed none, so appending it there warned about
699
812
  // 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
- : "";
813
+ const caveat = view.tokens > 0 ? " · reasoning text is a UI excerpt, not a verification" : "";
704
814
  return wrapTextWithAnsi(
705
815
  `${DIM} turn · in ${usage.inputTokens}${calls} · out ${usage.outputTokens}${cache}${reason}${caveat}${RESET}`,
706
816
  width,
@@ -717,6 +827,22 @@ function styleStatusVerb(text: string, toneHint: VerbRender["toneHint"]): string
717
827
  return `${DIM}${text}${RESET}`;
718
828
  }
719
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
+
720
846
  function renderToolSegmentLines(
721
847
  seg: ToolSegment,
722
848
  width: number,
@@ -724,11 +850,11 @@ function renderToolSegmentLines(
724
850
  latestHintToolId: string | null,
725
851
  nowMs: number,
726
852
  unboundedToolBodies: boolean,
727
- verbosity: OutputVerbosity,
853
+ detail: TranscriptDetailPolicy,
728
854
  liveToolOutput: boolean,
729
855
  ): string[] {
730
856
  const hintKey = seg.id === latestHintToolId ? expandKey : undefined;
731
- const expanded = verbosity === "verbose" || (verbosity !== "minimal" && seg.expanded);
857
+ const expanded = toolSegmentExpanded(seg, detail);
732
858
  const elapsedMs = seg.startedAtMs !== undefined ? Math.max(0, rawDurationMs(seg.startedAtMs, nowMs)) : undefined;
733
859
  const phase: "forming" | "ready" | "running" = seg.executionStarted
734
860
  ? "running"
@@ -762,6 +888,10 @@ function renderToolSegmentLines(
762
888
  : { toolCallId: seg.id, toolName: seg.name, args: seg.args, elapsedMs, phase },
763
889
  width,
764
890
  hintKey,
891
+ {
892
+ diffStyle: seg.replayed === true ? "plain" : "color",
893
+ foldedExtras: detail.toolBody === "folded" ? "none" : "per-tool",
894
+ },
765
895
  );
766
896
  }
767
897
  if (!seg.finished) {
@@ -793,14 +923,17 @@ function renderToolSegmentLines(
793
923
  }
794
924
 
795
925
  /**
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.
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).
799
929
  */
800
- function workerEntryFolded(entry: WorkerTranscriptEntry, verbosity: OutputVerbosity): boolean {
801
- if (verbosity === "verbose") return false;
802
- if (verbosity === "minimal") return true;
803
- 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";
804
937
  }
805
938
 
806
939
  function renderEntryLines(
@@ -811,11 +944,12 @@ function renderEntryLines(
811
944
  latestFoldedWorkerId: string | null,
812
945
  nowMs: number,
813
946
  unboundedToolBodies: boolean,
814
- verbosity: OutputVerbosity,
947
+ detail: TranscriptDetailPolicy,
815
948
  liveToolOutput: boolean,
949
+ liveReasoning: ReasoningUsageView,
816
950
  ): string[] {
817
951
  if (entry.role === "replayBlock") {
818
- return entry.renderBlock(width);
952
+ return entry.renderBlock(width, detail);
819
953
  }
820
954
  if (entry.role === "user") {
821
955
  const contentWidth = Math.max(1, width - PROSE_GUTTER_WIDTH);
@@ -830,7 +964,7 @@ function renderEntryLines(
830
964
  }
831
965
  if (entry.role === "worker") {
832
966
  return renderWorkerEntryLines(entry.state, width, {
833
- folded: workerEntryFolded(entry, verbosity),
967
+ folded: workerEntryFolded(entry, detail),
834
968
  ...(expandKey !== undefined && entry.state.assignmentId === latestFoldedWorkerId ? { expandKey } : {}),
835
969
  unbounded: unboundedToolBodies,
836
970
  });
@@ -839,38 +973,50 @@ function renderEntryLines(
839
973
  // A mid-turn notice splits the transcript, so the events after it open a
840
974
  // fresh entry that a stopped turn never fills; that entry used to reach the
841
975
  // 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
- ) {
976
+ if (!entry.pending && entry.turnUsage === undefined && !hasVisibleOutput(entry) && entry.segments.length === 0) {
849
977
  return [];
850
978
  }
851
979
  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
- }
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);
870
986
  const clioPrefix = entry.isError ? CLIO_PREFIX_ERROR : CLIO_PREFIX;
871
987
  const proseWidth = Math.max(1, width - PROSE_GUTTER_WIDTH);
872
988
  let labeled = false;
873
- 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
+ }
874
1020
  if (seg.kind === "tool") {
875
1021
  lines.push(
876
1022
  ...renderToolSegmentLines(
@@ -880,7 +1026,7 @@ function renderEntryLines(
880
1026
  latestHintToolId,
881
1027
  nowMs,
882
1028
  unboundedToolBodies,
883
- verbosity,
1029
+ detail,
884
1030
  liveToolOutput,
885
1031
  ),
886
1032
  );
@@ -913,21 +1059,30 @@ function renderEntryLines(
913
1059
  lines.push(...hangProseLines(rendered));
914
1060
  }
915
1061
  }
916
- 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.
917
1078
  const shouldRenderStatus =
918
1079
  entry.pending &&
919
1080
  entry.statusLine !== null &&
920
1081
  entry.statusLine !== undefined &&
921
1082
  !(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) {
1083
+ !(entry.statusLine.phase === "thinking" && liveIndicatorShown);
1084
+ if (!labeled && !hasVisibleOutput(entry)) lines.push(clioPrefix.trimEnd());
1085
+ if (shouldRenderStatus) {
931
1086
  lines.push(`${STATUS_INDENT}${styleStatusVerb(entry.statusLine?.verb ?? "", entry.statusLine?.toneHint ?? "muted")}`);
932
1087
  }
933
1088
  return lines;
@@ -941,8 +1096,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
941
1096
  let cachedWidth: number | undefined;
942
1097
  let cachedLines: string[] = [];
943
1098
  let cachedExpandKey: string | undefined;
944
- let cachedVerbosity: OutputVerbosity | undefined;
1099
+ let cachedDetail: TranscriptDetailPolicy | undefined;
945
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;
946
1107
  let cachedTick = 0;
947
1108
  /**
948
1109
  * Did the last executed render put a counting elapsed line on screen? It is
@@ -974,8 +1135,13 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
974
1135
  * the render key changes.
975
1136
  */
976
1137
  let frozen: { lines: string[]; through: number; key: string } | null = null;
977
- let thinkingExpanded = false;
978
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;
979
1145
  const unboundedToolBodies = options.unboundedToolBodies === true;
980
1146
 
981
1147
  const markDirty = (): void => {
@@ -1007,6 +1173,32 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1007
1173
 
1008
1174
  const now = (): number => options.now?.() ?? Date.now();
1009
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
+
1010
1202
  /**
1011
1203
  * Force an in-flight tool segment to a settled error line. A call blocked at
1012
1204
  * admission (loop guard, safety) or one whose `tool_execution_end` never
@@ -1069,8 +1261,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1069
1261
  const entry: Extract<TranscriptEntry, { role: "assistant" }> = {
1070
1262
  role: "assistant",
1071
1263
  segments: [],
1072
- thinking: "",
1073
- expandedThinking: thinkingExpanded,
1074
1264
  pending: false,
1075
1265
  isError: false,
1076
1266
  };
@@ -1078,9 +1268,34 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1078
1268
  return entry;
1079
1269
  };
1080
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
+
1081
1295
  const appendTextDelta = (entry: Extract<TranscriptEntry, { role: "assistant" }>, delta: string): void => {
1082
1296
  if (delta.length === 0) return;
1083
1297
  invalidateEntryCache(entry);
1298
+ closeOpenThinking(entry);
1084
1299
  const tail = entry.segments[entry.segments.length - 1];
1085
1300
  if (tail && tail.kind === "text" && !tail.finalized) {
1086
1301
  tail.text += delta;
@@ -1090,15 +1305,22 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1090
1305
  };
1091
1306
 
1092
1307
  /**
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.
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.
1102
1324
  */
1103
1325
  const canonicalizeMessageText = (
1104
1326
  entry: Extract<TranscriptEntry, { role: "assistant" }>,
@@ -1106,15 +1328,36 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1106
1328
  replaceTail = false,
1107
1329
  ): void => {
1108
1330
  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;
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;
1113
1341
  // The streaming wrap cache assumes append-only text. This is the one
1114
1342
  // path that rewrites it wholesale, and finalized segments render through
1115
1343
  // Markdown instead, so the cache is dead here either way.
1116
- delete tail.wrapCache;
1117
- 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);
1118
1361
  return;
1119
1362
  }
1120
1363
  entry.segments.push({ kind: "text", text, finalized: true });
@@ -1133,25 +1376,30 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1133
1376
  };
1134
1377
 
1135
1378
  /**
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.
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.
1138
1383
  * One surface at most, because the key reaches the newest foldable thing of
1139
1384
  * either kind, and a chord shown anywhere else would open something the
1140
1385
  * operator was not looking at. An already-open newest card advertises
1141
1386
  * nothing, since folding it again needs no invitation.
1142
1387
  */
1143
- const expandHintOwner = (): { toolId: string | null; workerId: string | null } => {
1388
+ const expandHintOwner = (detail: TranscriptDetailPolicy): { toolId: string | null; workerId: string | null } => {
1144
1389
  let workerMayOwn = true;
1145
1390
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1146
1391
  const entry = transcript[entryIndex];
1147
1392
  if (entry?.role === "worker") {
1148
- 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
+ };
1149
1397
  }
1150
1398
  if (entry?.role !== "assistant") continue;
1151
1399
  for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1152
1400
  const seg = entry.segments[segIndex];
1153
1401
  if (seg?.kind !== "tool") continue;
1154
- if (seg.finished && !seg.expanded) return { toolId: seg.id, workerId: null };
1402
+ if (seg.finished && !toolSegmentExpanded(seg, detail)) return { toolId: seg.id, workerId: null };
1155
1403
  workerMayOwn = false;
1156
1404
  }
1157
1405
  }
@@ -1187,9 +1435,12 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1187
1435
  /** True when the entry renders at least one counting elapsed line this frame. */
1188
1436
  const entryHasRunningTool = (entry: TranscriptEntry): boolean =>
1189
1437
  (entry.role === "assistant" &&
1190
- entry.segments.some(
1438
+ (entry.segments.some(
1191
1439
  (segment) => segment.kind === "tool" && !segment.finished && segment.startedAtMs !== undefined,
1192
- )) ||
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))) ||
1193
1444
  (entry.role === "replayBlock" && entry.isLive?.() === true);
1194
1445
 
1195
1446
  /**
@@ -1210,7 +1461,12 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1210
1461
  const render = (width: number): string[] => {
1211
1462
  const startedAt = performance.now();
1212
1463
  const expandKey = resolveExpandKey();
1213
- 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);
1214
1470
  const nowMs = now();
1215
1471
  // `dirty` is set on mutation and never on a tick, so without time in the
1216
1472
  // key a running tool's elapsed counter advanced only when something
@@ -1228,14 +1484,14 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1228
1484
  !dirty &&
1229
1485
  cachedWidth === width &&
1230
1486
  cachedExpandKey === expandKey &&
1231
- cachedVerbosity === verbosity &&
1487
+ cachedDetail === detail &&
1232
1488
  cachedLiveToolOutput === liveToolOutput &&
1233
1489
  cachedTick === tick
1234
1490
  ) {
1235
1491
  options.onRenderMetrics?.({ durationMs: performance.now() - startedAt, cacheHit: true, entriesRendered: 0 });
1236
1492
  return cachedLines;
1237
1493
  }
1238
- const { toolId: latestHintToolId, workerId: latestFoldedWorkerId } = expandHintOwner();
1494
+ const { toolId: latestHintToolId, workerId: latestFoldedWorkerId } = expandHintOwner(detail);
1239
1495
  // The hint id is deliberately NOT part of the shared key: it changes on
1240
1496
  // every finished collapsed tool, and keying every entry on it re-rendered
1241
1497
  // the entire transcript per tool completion. Only the entry that contains
@@ -1244,7 +1500,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1244
1500
  // bytes at every tick, and keying it on time would drop the entry cache
1245
1501
  // and the frozen prefix ten times a second. Only the panel-level guard
1246
1502
  // above is time-keyed, so a tick re-renders the live tail and nothing else.
1247
- const baseKey = `${width}|${expandKey ?? ""}|${verbosity}|${liveToolOutput}`;
1503
+ const baseKey = `${width}|${expandKey ?? ""}|${detail.toolBody}:${detail.runningTool}:${detail.thinking}:${detail.worker}:${detail.receipt}:${detail.errors}|${liveToolOutput}`;
1248
1504
  const capacity = entryCacheCapacity();
1249
1505
  if (frozen !== null && frozen.key !== baseKey) frozen = null;
1250
1506
  const out: string[] = frozen === null ? [] : frozen.lines.slice();
@@ -1270,8 +1526,8 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1270
1526
  const stacksOnPrevious =
1271
1527
  entry.role === "worker" &&
1272
1528
  previous?.role === "worker" &&
1273
- workerEntryFolded(entry, verbosity) &&
1274
- workerEntryFolded(previous, verbosity);
1529
+ workerEntryFolded(entry, detail) &&
1530
+ workerEntryFolded(previous, detail);
1275
1531
  if (i > 0 && !stacksOnPrevious) out.push("");
1276
1532
  const containsHint =
1277
1533
  entryContainsHint(entry, latestHintToolId) ||
@@ -1293,8 +1549,9 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1293
1549
  latestFoldedWorkerId,
1294
1550
  nowMs,
1295
1551
  unboundedToolBodies,
1296
- verbosity,
1552
+ detail,
1297
1553
  liveToolOutput,
1554
+ liveReasoning,
1298
1555
  );
1299
1556
  for (const line of renderedEntry) out.push(line);
1300
1557
  if (cacheable) {
@@ -1317,7 +1574,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1317
1574
  cachedLines = out;
1318
1575
  cachedWidth = width;
1319
1576
  cachedExpandKey = expandKey;
1320
- cachedVerbosity = verbosity;
1577
+ cachedDetail = detail;
1321
1578
  cachedLiveToolOutput = liveToolOutput;
1322
1579
  cachedTick = tick;
1323
1580
  renderedRunningTool = sawRunningTool;
@@ -1331,8 +1588,8 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1331
1588
  transcript.push({ role: "user", text });
1332
1589
  markDirty();
1333
1590
  },
1334
- appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean): void {
1335
- transcript.push({ role: "replayBlock", renderBlock, isLive });
1591
+ appendReplayBlock(renderBlock: ReplayBlockRenderer, isLive?: () => boolean, fold?: ReplayBlockFoldControl): void {
1592
+ transcript.push({ role: "replayBlock", renderBlock, isLive, fold });
1336
1593
  markDirty();
1337
1594
  },
1338
1595
  applyWorkerState(state: WorkerEntryState): void {
@@ -1344,7 +1601,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1344
1601
  markDirty();
1345
1602
  return;
1346
1603
  }
1347
- const entry: WorkerTranscriptEntry = { role: "worker", state, folded: workerAskedByModel(state) };
1604
+ const entry: WorkerTranscriptEntry = { role: "worker", state };
1348
1605
  workerEntries.set(state.assignmentId, entry);
1349
1606
  const at = workerInsertionIndex(state);
1350
1607
  if (at === null) {
@@ -1361,13 +1618,26 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1361
1618
  return [...workerEntries.values()].map((entry) => entry.state);
1362
1619
  },
1363
1620
  toggleLastToolExpanded(): boolean {
1364
- // 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
1365
1622
  // block the operator just watched land is what they mean by "expand
1366
- // 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();
1367
1628
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1368
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
+ }
1369
1639
  if (entry?.role === "worker") {
1370
- entry.folded = !entry.folded;
1640
+ entry.fold = workerEntryFolded(entry, detail) ? "expanded" : "folded";
1371
1641
  clearRenderCaches();
1372
1642
  markDirty();
1373
1643
  return true;
@@ -1376,7 +1646,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1376
1646
  for (let segIndex = entry.segments.length - 1; segIndex >= 0; segIndex -= 1) {
1377
1647
  const seg = entry.segments[segIndex];
1378
1648
  if (seg?.kind !== "tool") continue;
1379
- seg.expanded = !seg.expanded;
1649
+ seg.fold = toolSegmentExpanded(seg, detail) ? "folded" : "expanded";
1380
1650
  clearRenderCaches();
1381
1651
  markDirty();
1382
1652
  return true;
@@ -1385,9 +1655,15 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1385
1655
  return false;
1386
1656
  },
1387
1657
  toggleAllToolsExpanded(): boolean {
1658
+ const detail = currentDetail();
1388
1659
  const tools: ToolSegment[] = [];
1389
1660
  const workers: WorkerTranscriptEntry[] = [];
1661
+ const blocks: ReplayBlockFoldControl[] = [];
1390
1662
  for (const entry of transcript) {
1663
+ if (entry.role === "replayBlock") {
1664
+ if (entry.fold !== undefined) blocks.push(entry.fold);
1665
+ continue;
1666
+ }
1391
1667
  if (entry.role === "worker") {
1392
1668
  workers.push(entry);
1393
1669
  continue;
@@ -1397,62 +1673,70 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1397
1673
  if (seg.kind === "tool") tools.push(seg);
1398
1674
  }
1399
1675
  }
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;
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);
1404
1687
  clearRenderCaches();
1405
1688
  markDirty();
1406
1689
  return true;
1407
1690
  },
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();
1691
+ clearFoldOverrides(): void {
1692
+ dropFoldOverrides();
1424
1693
  },
1425
1694
  toggleLastThinking(): boolean {
1695
+ const detail = currentDetail();
1426
1696
  for (let entryIndex = transcript.length - 1; entryIndex >= 0; entryIndex -= 1) {
1427
1697
  const entry = transcript[entryIndex];
1428
1698
  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;
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
+ }
1435
1707
  }
1436
- thinkingExpanded = !thinkingExpanded;
1437
- return true;
1708
+ return false;
1438
1709
  },
1439
1710
  toggleAllThinking(): boolean {
1440
- const entries: Array<Extract<TranscriptEntry, { role: "assistant" }>> = [];
1711
+ const detail = currentDetail();
1712
+ const segments: ThinkingSegment[] = [];
1441
1713
  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;
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
+ }
1447
1718
  }
1448
- const expand = entries.some((entry) => entry.expandedThinking !== true);
1449
- for (const entry of entries) entry.expandedThinking = expand;
1450
- 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";
1451
1722
  clearRenderCaches();
1452
1723
  markDirty();
1453
1724
  return true;
1454
1725
  },
1455
- isThinkingExpanded: () => thinkingExpanded,
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
+ },
1456
1740
  toggleLiveToolOutput(): boolean {
1457
1741
  liveToolOutput = !liveToolOutput;
1458
1742
  markDirty();
@@ -1461,6 +1745,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1461
1745
  reset(): void {
1462
1746
  transcript.length = 0;
1463
1747
  workerEntries.clear();
1748
+ liveReasoning = UNMEASURED_REASONING;
1464
1749
  clearRenderCaches();
1465
1750
  markDirty();
1466
1751
  },
@@ -1524,6 +1809,7 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1524
1809
  } else if (existing === undefined) {
1525
1810
  const assistant = ensureAssistant();
1526
1811
  assistant.pending = true;
1812
+ closeOpenThinking(assistant);
1527
1813
  assistant.segments.push({
1528
1814
  kind: "tool",
1529
1815
  id: streamed.id,
@@ -1533,7 +1819,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1533
1819
  executionStarted: false,
1534
1820
  argsComplete: assistantEvent.type === "toolcall_end",
1535
1821
  isError: false,
1536
- expanded: false,
1537
1822
  });
1538
1823
  }
1539
1824
  markDirty();
@@ -1547,16 +1832,18 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1547
1832
  return;
1548
1833
  }
1549
1834
  if (event.type === "thinking_delta") {
1550
- // 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.
1551
1837
  const assistant = ensureAssistant();
1552
1838
  assistant.pending = true;
1553
- assistant.thinking += event.delta;
1554
- assistant.expandedThinking = thinkingExpanded;
1839
+ appendThinkingDelta(assistant, event.delta);
1555
1840
  markDirty();
1556
1841
  return;
1557
1842
  }
1558
1843
  if (event.type === "message_start" && event.message.role === "assistant") {
1559
- ensureAssistant().pending = true;
1844
+ const assistant = ensureAssistant();
1845
+ assistant.pending = true;
1846
+ assistant.messageStartSegmentIndex = assistant.segments.length;
1560
1847
  markDirty();
1561
1848
  return;
1562
1849
  }
@@ -1570,7 +1857,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1570
1857
  streamed.executionStarted = true;
1571
1858
  streamed.argsComplete = true;
1572
1859
  streamed.startedAtMs = now();
1573
- streamed.expanded = classifyResourceRead(event.toolName, event.args) === null;
1574
1860
  markDirty();
1575
1861
  return;
1576
1862
  }
@@ -1585,10 +1871,11 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1585
1871
  }
1586
1872
  }
1587
1873
  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;
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.
1591
1877
  assistant.pending = true;
1878
+ closeOpenThinking(assistant);
1592
1879
  assistant.segments.push({
1593
1880
  kind: "tool",
1594
1881
  id: event.toolCallId,
@@ -1598,7 +1885,6 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1598
1885
  executionStarted: true,
1599
1886
  argsComplete: true,
1600
1887
  isError: false,
1601
- expanded,
1602
1888
  startedAtMs: now(),
1603
1889
  });
1604
1890
  markDirty();
@@ -1716,10 +2002,22 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1716
2002
  invalidateEntryCache(assistant);
1717
2003
  if (usage !== undefined) assistant.turnUsage = usage;
1718
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);
1719
2011
  if (thinking.length > 0) {
1720
- assistant.thinking = thinking;
1721
- 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
+ }
1722
2019
  }
2020
+ assistant.messageStartSegmentIndex = undefined;
1723
2021
  // The chat loop marks messages it sanitized after streaming (dead
1724
2022
  // tool-call markup on a synthesis-locked turn); the streamed tail
1725
2023
  // must be replaced, not kept alongside a duplicate segment.
@@ -1790,13 +2088,26 @@ export function createChatPanel(options: ChatPanelOptions = {}): ChatPanel {
1790
2088
  }
1791
2089
  if (entry.pending) {
1792
2090
  invalidateEntryCache(entry);
2091
+ closeOpenThinking(entry);
1793
2092
  entry.pending = false;
1794
2093
  entry.statusLine = null;
2094
+ entry.messageStartSegmentIndex = undefined;
1795
2095
  }
1796
2096
  }
1797
2097
  markDirty();
1798
2098
  }
1799
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
+ },
1800
2111
  setStatusLine(line): void {
1801
2112
  if (line) {
1802
2113
  const assistant = ensureAssistant();