@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.
- package/CHANGELOG.md +229 -457
- package/README.md +2 -2
- package/dist/{acp-BIYHVZIM.js → acp-P2AQILE2.js} +2 -2
- package/dist/{agents-YT6SSRIT.js → agents-72W3BI7I.js} +8 -8
- package/dist/assets/codewiki.json +1 -1
- package/dist/{chunk-WMSVI4G2.js → chunk-2DJ2KNFG.js} +3 -3
- package/dist/{chunk-2EHAIA3X.js → chunk-2TLUCQVG.js} +2 -2
- package/dist/{chunk-WVO7V2QY.js → chunk-4XUGQOHA.js} +2 -2
- package/dist/{chunk-IGLFWIYI.js → chunk-5UFT4SUX.js} +2 -2
- package/dist/{chunk-AO4RKG4M.js → chunk-6SGHMWE3.js} +3 -3
- package/dist/{chunk-X75S7HFS.js → chunk-COU2UHX6.js} +45 -19
- package/dist/{chunk-KJ5LWLOE.js → chunk-DSELYM6W.js} +2 -2
- package/dist/{chunk-G2DE3C7R.js → chunk-DUYJ5IO6.js} +2 -2
- package/dist/{chunk-EPVUXGXG.js → chunk-FNTMWMX5.js} +9 -9
- package/dist/{chunk-MNA4JGU4.js → chunk-J7CWMCQD.js} +2 -2
- package/dist/{chunk-MBS4V7ZP.js → chunk-KZWTDYJF.js} +4 -4
- package/dist/{chunk-LBNRH5WM.js → chunk-LM5TQCJZ.js} +3 -3
- package/dist/{chunk-OHHN2SO4.js → chunk-LW6DSM3M.js} +7 -7
- package/dist/{chunk-MQSRRFWA.js → chunk-LWLEKMDQ.js} +56 -2
- package/dist/{chunk-V4RXGQ5Q.js → chunk-OC7FIQPC.js} +2 -2
- package/dist/{chunk-3ZXDFGR5.js → chunk-PAJK6MAQ.js} +2 -2
- package/dist/{chunk-7EYHLWU7.js → chunk-PIWWS5BL.js} +3 -3
- package/dist/{chunk-6EJV5X2W.js → chunk-SRDMMSEP.js} +3 -3
- package/dist/{chunk-ARBGF5F7.js → chunk-TZK7PACC.js} +2 -2
- package/dist/{chunk-QTYWRVRA.js → chunk-UFIIWP2H.js} +2 -2
- package/dist/{chunk-J5HN4RYU.js → chunk-V6RTAOC2.js} +2 -2
- package/dist/{chunk-SRF2PJNW.js → chunk-VPAYEGVX.js} +2 -2
- package/dist/{chunk-77VKQEHF.js → chunk-X6IAEBZR.js} +2 -2
- package/dist/{chunk-4KLWL3UC.js → chunk-XBXAASKX.js} +2 -2
- package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
- package/dist/cli/index.js +17 -17
- package/dist/{clio-4LY5K2AC.js → clio-JOU4FXVA.js} +2 -2
- package/dist/{config-GTLUW2PR.js → config-XCDVKR23.js} +7 -7
- package/dist/{configure-R6A64DHX.js → configure-4GAP54ZW.js} +5 -5
- package/dist/{context-RW5HC47S.js → context-4UOGGLQ5.js} +6 -6
- package/dist/{context-JFZEJ7W5.js → context-77FM5DV5.js} +9 -9
- package/dist/{context-clear-6ZHBAZZT.js → context-clear-XXJRLCJJ.js} +6 -6
- package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-QPRDDBDX.js} +7 -7
- package/dist/{doctor-KI767GSN.js → doctor-HR46URBJ.js} +5 -5
- package/dist/{evidence-UA6AWDQQ.js → evidence-6HG2PY2B.js} +4 -4
- package/dist/{evolve-QNTFGV6Z.js → evolve-K7YU3NCY.js} +4 -4
- package/dist/{fleet-Q7UOMUSG.js → fleet-VY3HHKN6.js} +15 -15
- package/dist/{init-WBB65ZHQ.js → init-JYGXI3FK.js} +12 -12
- package/dist/{memory-MD3O64RI.js → memory-WFZMGYHX.js} +5 -5
- package/dist/{models-BZU34YWD.js → models-I5QWSEOM.js} +7 -7
- package/dist/{monitor-MEQA5C3I.js → monitor-GE4ID3IA.js} +5 -5
- package/dist/{orchestrator-CGFKEP27.js → orchestrator-EM5MC3HM.js} +1482 -1055
- package/dist/{run-IV4Q6RLN.js → run-ZU3QMZPZ.js} +18 -18
- package/dist/{skills-LQEKRDTN.js → skills-X5VXCRNQ.js} +2 -2
- package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WKIHWTHR.js} +5 -5
- package/dist/{targets-C4SSGQOB.js → targets-SNCPI2NR.js} +8 -8
- package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-BNAHVHBS.js} +2 -2
- package/dist/{upgrade-7TT7SQ3G.js → upgrade-JQHHPQ4K.js} +7 -7
- package/dist/{usage-GV4PKT3M.js → usage-OR4O5SMZ.js} +5 -5
- package/dist/{verify-G6V4D2G7.js → verify-375KUB3Y.js} +4 -4
- package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-UEXP2ARI.js} +11 -11
- package/dist/worker/entry.js +8 -8
- package/docs/README.md +3 -3
- package/docs/acp.md +1 -1
- package/docs/alcf-provider.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/artifact-versions.md +1 -1
- package/docs/built-in-agents.md +1 -1
- package/docs/capacity-and-scheduling.md +1 -1
- package/docs/commands-and-modes.md +1 -1
- package/docs/configuration-and-targets.md +1 -1
- package/docs/context-engine.md +1 -1
- package/docs/development-pipeline.md +1 -1
- package/docs/documentation-coverage.md +2 -2
- package/docs/documentation-guide.md +1 -1
- package/docs/eval-runner.md +1 -1
- package/docs/evals-internal.md +1 -1
- package/docs/evidence-and-memory.md +2 -2
- package/docs/evolution.md +1 -1
- package/docs/exit-codes-and-output.md +1 -1
- package/docs/extensions-and-sharing.md +2 -2
- package/docs/fleet-dispatch.md +1 -1
- package/docs/installation-and-lifecycle.md +6 -6
- package/docs/middleware-and-components.md +1 -1
- package/docs/model-catalog.md +1 -1
- package/docs/observability.md +3 -3
- package/docs/performance-methodology.md +2 -2
- package/docs/proactive-memory.md +1 -1
- package/docs/prompt-envelope-and-tools.md +1 -1
- package/docs/provider-adapter-cookbook.md +1 -1
- package/docs/release-cut-checklist.md +30 -30
- package/docs/safety-model.md +2 -2
- package/docs/scientific-validation.md +3 -3
- package/docs/session-lifecycle.md +2 -2
- package/docs/skills-marketplace.md +1 -1
- package/docs/tool-usage.md +2 -2
- package/docs/trace-store.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/tui-design.md +2 -2
- package/docs/worker-dispatch-mechanics.md +1 -1
- package/package.json +1 -1
- package/src/core/git-commit-attribution.ts +46 -21
- package/src/domains/config/keybindings.ts +3 -3
- package/src/interactive/chat-panel.ts +555 -244
- package/src/interactive/chat-renderer.ts +50 -19
- package/src/interactive/editor-submit.ts +26 -1
- package/src/interactive/footer/widgets.ts +22 -20
- package/src/interactive/footer-panel.ts +6 -1
- package/src/interactive/interactive-application.ts +2 -0
- package/src/interactive/interactive-event-projection.ts +12 -0
- package/src/interactive/interactive-slash-runtime.ts +12 -7
- package/src/interactive/overlays/ask-user.ts +146 -24
- package/src/interactive/renderers/tool-execution.ts +151 -56
- package/src/interactive/status/index.ts +12 -1
- package/src/interactive/status/reasoning.ts +87 -0
- package/src/interactive/status/summary.ts +13 -2
- package/src/interactive/transcript-detail.ts +120 -0
- package/src/tools/builtin-tool-catalog.ts +7 -1
- package/src/tools/presentation.ts +107 -0
- 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 {
|
|
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
|
|
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
|
-
/**
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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;
|
|
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
|
-
| {
|
|
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
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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
|
-
|
|
350
|
+
clearFoldOverrides(): void;
|
|
274
351
|
/**
|
|
275
|
-
* Flip thinking
|
|
276
|
-
* and the full rail-prefixed body
|
|
277
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
370
|
-
const
|
|
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
|
-
|
|
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 = {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
596
|
-
return
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
*
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
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
|
|
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 (
|
|
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(
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
691
|
-
? ` reasoning ${
|
|
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
|
-
|
|
853
|
+
detail: TranscriptDetailPolicy,
|
|
728
854
|
liveToolOutput: boolean,
|
|
729
855
|
): string[] {
|
|
730
856
|
const hintKey = seg.id === latestHintToolId ? expandKey : undefined;
|
|
731
|
-
const 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
|
|
797
|
-
*
|
|
798
|
-
*
|
|
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,
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" &&
|
|
923
|
-
if (!labeled && !hasVisibleOutput(entry))
|
|
924
|
-
|
|
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
|
|
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
|
|
1094
|
-
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1098
|
-
*
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
1101
|
-
*
|
|
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
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
|
1117
|
-
if (
|
|
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
|
|
1137
|
-
* the newest finished
|
|
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 {
|
|
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
|
|
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?.()
|
|
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
|
-
|
|
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 ?? ""}|${
|
|
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,
|
|
1274
|
-
workerEntryFolded(previous,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
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
|
-
|
|
1437
|
-
return true;
|
|
1708
|
+
return false;
|
|
1438
1709
|
},
|
|
1439
1710
|
toggleAllThinking(): boolean {
|
|
1440
|
-
const
|
|
1711
|
+
const detail = currentDetail();
|
|
1712
|
+
const segments: ThinkingSegment[] = [];
|
|
1441
1713
|
for (const entry of transcript) {
|
|
1442
|
-
if (entry.role
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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()
|
|
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
|
-
//
|
|
1589
|
-
//
|
|
1590
|
-
|
|
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.
|
|
1721
|
-
|
|
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();
|