dsh-code 0.6.1 → 0.8.0

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 (56) hide show
  1. package/README.en.md +20 -6
  2. package/README.md +20 -6
  3. package/lib/index.mjs +3952 -1315
  4. package/lib/startup.mjs +21 -9
  5. package/lib/theme-BEi4i_aN.mjs +624 -0
  6. package/lib/types/app.d.ts +108 -4
  7. package/lib/types/history.d.ts +15 -4
  8. package/lib/types/index.d.ts +49 -0
  9. package/lib/types/kernel-panels.d.ts +28 -0
  10. package/lib/types/mentions.d.ts +29 -12
  11. package/lib/types/models.d.ts +66 -0
  12. package/lib/types/permissions.d.ts +37 -0
  13. package/lib/types/presets.d.ts +2 -0
  14. package/lib/types/provider-settings.d.ts +144 -0
  15. package/lib/types/questions.d.ts +2 -0
  16. package/lib/types/render/animations.d.ts +177 -2
  17. package/lib/types/render/lines.d.ts +6 -0
  18. package/lib/types/render/markdown.d.ts +3 -3
  19. package/lib/types/render/projection.d.ts +123 -3
  20. package/lib/types/render/status.d.ts +35 -24
  21. package/lib/types/render/text.d.ts +14 -7
  22. package/lib/types/render/tool-detail.d.ts +3 -1
  23. package/lib/types/render/tool-preview.d.ts +4 -1
  24. package/lib/types/session-directory.d.ts +15 -0
  25. package/lib/types/startup.d.ts +12 -4
  26. package/lib/types/store.d.ts +13 -2
  27. package/lib/types/theme-panel.d.ts +24 -0
  28. package/lib/types/theme.d.ts +158 -2
  29. package/lib/types/version.d.ts +5 -0
  30. package/package.json +1 -1
  31. package/src/app.ts +1283 -206
  32. package/src/approval.ts +11 -2
  33. package/src/history.ts +20 -5
  34. package/src/index.ts +1207 -905
  35. package/src/kernel-panels.ts +518 -419
  36. package/src/mentions.ts +57 -27
  37. package/src/models.ts +200 -66
  38. package/src/permissions.ts +85 -0
  39. package/src/presets.ts +12 -0
  40. package/src/provider-settings.ts +520 -0
  41. package/src/questions.ts +15 -5
  42. package/src/render/animations.ts +373 -2
  43. package/src/render/lines.ts +21 -6
  44. package/src/render/markdown.ts +302 -4
  45. package/src/render/projection.ts +1419 -659
  46. package/src/render/status.ts +650 -603
  47. package/src/render/text.ts +28 -9
  48. package/src/render/tool-detail.ts +81 -40
  49. package/src/render/tool-preview.ts +18 -2
  50. package/src/session-directory.ts +44 -5
  51. package/src/skills.ts +8 -4
  52. package/src/startup.ts +119 -109
  53. package/src/store.ts +26 -8
  54. package/src/theme-panel.ts +72 -0
  55. package/src/theme.ts +206 -70
  56. package/src/version.ts +16 -0
@@ -1,659 +1,1419 @@
1
- /**
2
- * Pure session-event-to-view projection for the TUI transcript: one reducer
3
- * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
- * Rendering never reads the session directly — this module owns the view
5
- * model, so tests drive it with plain event arrays.
6
- *
7
- * @module @deepseek-ai/dsh-tui/render/projection
8
- */
9
-
10
- import { boundContextSummary, type ContentBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
- import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
- // Type-only imports merge the plugin-owned SessionEventMap variants
13
- // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
14
- // plan/mode, permission/preset, sandbox/mode, session/title) into the union
15
- // this reducer switches on.
16
- import type {} from '@deepseek-ai/dsh-agent'
17
- import type {} from '@deepseek-ai/dsh-commands'
18
- import type {} from '@deepseek-ai/dsh-compaction'
19
- import type {} from '@deepseek-ai/dsh-goal'
20
- import type {} from '@deepseek-ai/dsh-llm-retry'
21
- import type {} from '@deepseek-ai/dsh-plan-mode'
22
- import type {} from '@deepseek-ai/dsh-permission-presets'
23
- import type {} from '@deepseek-ai/dsh-sandbox-policy'
24
- import type {} from '@deepseek-ai/dsh-session-title'
25
- import { toolArgumentsPreview } from './tool-preview.ts'
26
- import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
27
-
28
- /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
29
- const MAX_STREAMING_CHARS = 65_536
30
-
31
- /** Append one delta without retaining an unbounded duplicate of the live reply. */
32
- function appendStreamingTail(current: string, delta: string): string {
33
- const next = current + delta
34
- return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
35
- }
36
-
37
- /** One user prompt line. */
38
- export interface UserEntry {
39
- kind: 'user'
40
- /** Joined text blocks of the user message. */
41
- text: string
42
- /** True for collapsed injected context (plugin/continuation notices), which
43
- * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
44
- notice: boolean
45
- }
46
-
47
- /** One user message waiting in the agent inbox (the web's queued-message row). */
48
- export interface PendingEntry {
49
- kind: 'pending'
50
- /** Stable message identity shared with the durable `user/message` that retires it. */
51
- messageId: MessageId
52
- /** Which inbox list holds the message: steering is consumed at the next step boundary. */
53
- target: 'next-turn' | 'next-step'
54
- /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
55
- text: string
56
- }
57
-
58
- /** One assembled assistant reply. */
59
- export interface AssistantEntry {
60
- kind: 'assistant'
61
- /** Joined text blocks of the assistant message. */
62
- text: string
63
- /** Joined reasoning blocks of the same message, empty when the model thought out loud. */
64
- reasoning: string
65
- }
66
-
67
- /** One model-requested tool invocation and its settled state. */
68
- export interface ToolEntry {
69
- kind: 'tool'
70
- /** Correlation id shared with the matching `tool/result`. */
71
- callId: string
72
- /** Tool name as the model addressed it. */
73
- name: string
74
- /** Raw arguments JSON string exactly as the model produced it. */
75
- arguments: string
76
- /** Bounded human-meaningful arguments preview for the tool card. */
77
- preview: string
78
- /** Execution state; `running` until the paired result lands. */
79
- state: 'running' | 'done' | 'error'
80
- /** Bounded first text block of the result, empty until it lands. */
81
- summary: string
82
- /**
83
- * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
84
- * from the tool's persisted presentation metadata; undefined until the
85
- * result lands and only when something renderable exists.
86
- */
87
- detail: ToolDetail | undefined
88
- }
89
-
90
- /** One slash-command execution dispatched through `ctx.commands`. */
91
- export interface CommandEntry {
92
- kind: 'command'
93
- /** Pairing id shared with the matching `command/done`. */
94
- commandId: string
95
- /** Lowercase command name without the leading slash. */
96
- name: string
97
- /** Verbatim text following the command name. */
98
- args: string
99
- /** Execution state; `running` until the paired lifecycle event lands. */
100
- state: 'running' | 'done' | 'error'
101
- /** Handler outcome text, empty until it lands. */
102
- summary: string
103
- }
104
-
105
- /** One turn-level failure surfaced from `turn/end`. */
106
- export interface ErrorEntry {
107
- kind: 'error'
108
- /** `code: message` of the failure. */
109
- text: string
110
- }
111
-
112
- /** One non-error turn outcome surfaced from `turn/end`. */
113
- export interface TurnMarkerEntry {
114
- kind: 'turn-marker'
115
- /** Human-readable outcome line, dim-rendered. */
116
- text: string
117
- }
118
-
119
- /** One completed compaction lifecycle surfaced from `compaction/end`. */
120
- export interface CompactionEntry {
121
- kind: 'compaction'
122
- /** True when the compaction completed, false when it failed. */
123
- ok: boolean
124
- /** Heuristic tokens shadowed by the compaction (summary or prune price). */
125
- tokens: number
126
- /** Failure text when `ok` is false, empty otherwise. */
127
- error: string
128
- }
129
-
130
- /** One provider-routed model-request retry (the `llm/retry` pair). */
131
- export interface RetryEntry {
132
- kind: 'retry'
133
- /** Correlation id shared with the matching `llm/retry-started`. */
134
- retryId: string
135
- /** Attempt ordinal and its cap. */
136
- attempt: number
137
- max: number
138
- /** Failure code that triggered the retry. */
139
- code: string
140
- /** Backoff wait before the next attempt, in ms. */
141
- delayMs: number
142
- /** `running` while the backoff waits, `done` once the attempt started. */
143
- state: 'running' | 'done'
144
- }
145
-
146
- /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
147
- export interface FilesEntry {
148
- kind: 'files'
149
- /** Unique mutated paths in call order, bounded. */
150
- paths: readonly string[]
151
- }
152
-
153
- /** Ordered transcript items the renderer draws. */
154
- export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
155
-
156
- /** The live goal the status line badges, folded from `goal/change`. */
157
- export interface GoalFold {
158
- /** Human-requested completion objective. */
159
- objective: string
160
- /** Durable lifecycle phase. */
161
- phase: 'active' | 'paused' | 'blocked' | 'complete'
162
- /** Highest admitted continuation round and its cap. */
163
- rounds: number
164
- max: number
165
- /** Blocked explanation, empty outside the blocked phase. */
166
- blocked: string
167
- }
168
-
169
- /** Cumulative token accounting folded from `assistant/message` usage reports. */
170
- export interface UsageTotals {
171
- /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
172
- inputTokens: number
173
- /** Completion-side tokens over the whole log. */
174
- outputTokens: number
175
- /** Cache-read tokens over the whole log (0 when the adapter reports none). */
176
- cacheReadTokens: number
177
- }
178
-
179
- /** Window-scoped figures the status line shows; timing uses event timestamps. */
180
- export interface TranscriptStats {
181
- /** Durable turns opened (`turn/start` events). */
182
- turns: number
183
- /** Model requests made (`step/start` events). */
184
- steps: number
185
- /** Summed model wall time: `step/start` `assistant/message`, in ms. */
186
- llmMs: number
187
- /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
188
- toolMs: number
189
- /** Cumulative token accounting; input stays 0 until a report lands. */
190
- usage: UsageTotals
191
- /** Prompt-side size of the most recent reported request (context pressure). */
192
- lastPromptTokens: number
193
- /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
194
- contextWindow: number
195
- /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
196
- ttftMs: number
197
- /** Steps that produced a first chunk (the TTFT average's denominator). */
198
- ttftSteps: number
199
- /** Summed decode spans: first chunk → `assistant/message`, in ms. */
200
- decodeMs: number
201
- /** Completion tokens over timed decode spans (the tok/s numerator). */
202
- decodeTokens: number
203
- }
204
-
205
- /** The complete TUI transcript view for one session. */
206
- export interface TranscriptView {
207
- /** Settled entries in log order. */
208
- entries: readonly TranscriptEntry[]
209
- /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
210
- streaming: string
211
- /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
212
- streamingReasoning: string
213
- /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
214
- todos: readonly TodoItem[]
215
- /** True while a durable turn is open (`turn/start` … `turn/end`). */
216
- busy: boolean
217
- /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
218
- busySince: number
219
- /** Figures the status line renders. */
220
- stats: TranscriptStats
221
- /**
222
- * The `provider/model` pair of the last `request/header` snapshot the
223
- * session's own model record, which a resumed TUI prefers over the
224
- * deployment default (mirrors the web host's resume selection order).
225
- * Empty before the session's first request.
226
- */
227
- model: string
228
- /** Plan mode state folded from the last `plan/mode` event. */
229
- plan: boolean
230
- /** Active permission preset folded from the last `permission/preset` event, empty before one. */
231
- permission: string
232
- /** Latest session title folded from the last `session/title` event, empty before one. */
233
- title: string
234
- /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
235
- sandbox: string
236
- /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
237
- goal: GoalFold | undefined
238
- /**
239
- * Ordered live message ids per inbox target, mirrored from
240
- * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
241
- * coordinates later removals resolve against.
242
- */
243
- pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
244
- /**
245
- * Fold-internal timing anchors, never rendered: open step and tool-call
246
- * start timestamps the next `assistant/message` / `tool/result` resolves
247
- * against. Keyed `turn:step` and by call id.
248
- */
249
- readonly anchors: { stepStart: Map<string, number>; toolStart: Map<string, number>; firstChunkAt: Map<string, number>; compactionTokens: Map<string, number>; lastPruneTokens: number; turnFiles: Map<number, Set<string>> }
250
- }
251
-
252
- /** Join the text blocks of a content list; non-text blocks contribute nothing. */
253
- function textOf(content: readonly ContentBlock[]): string {
254
- return content.filter(block => block.type === 'text').map(block => block.text).join('')
255
- }
256
-
257
- /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
258
- function reasoningOf(content: readonly ContentBlock[]): string {
259
- return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
260
- }
261
-
262
- /** A fresh, empty transcript view. */
263
- export function createTranscriptView(): TranscriptView {
264
- return {
265
- entries: [],
266
- streaming: '',
267
- streamingReasoning: '',
268
- todos: [],
269
- busy: false,
270
- busySince: 0,
271
- model: '',
272
- plan: false,
273
- permission: '',
274
- title: '',
275
- sandbox: '',
276
- goal: undefined,
277
- pending: { 'next-turn': [], 'next-step': [] },
278
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 },
279
- anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map() },
280
- }
281
- }
282
-
283
- /** Full prompt text of a queued message (identical to the durable user row it retires into). */
284
- function pendingText(content: readonly ContentBlock[]): string {
285
- return textOf(content)
286
- }
287
-
288
- /**
289
- * Fold one session event into an updated view (copy-on-write).
290
- * @param view - the view before the event.
291
- * @param event - one durable session event from `session/event` or the log.
292
- * @returns the view after the event; the input view is never mutated.
293
- */
294
- export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
295
- switch (event.type) {
296
- case 'user/message': {
297
- // A queued row retires when its durable user message lands (the agent
298
- // claims the inbox and logs the same message identity) — the transient
299
- // steering/queued preview yields to the real transcript entry.
300
- const message = event.data
301
- let entries = view.entries
302
- let pending = view.pending
303
- for (const target of ['next-turn', 'next-step'] as const) {
304
- const index = pending[target].indexOf(message.id)
305
- if (index < 0) continue
306
- pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
307
- entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
308
- }
309
- // Injected context (plugin/model-continuation sources) stays collapsed
310
- // to a bounded notice row, exactly like collapsed transcript context
311
- // elsewhere in the product; only direct human prompts render in full.
312
- if (message.source.kind === 'user') {
313
- return { ...view, pending, entries: [...entries, { kind: 'user', text: textOf(message.content), notice: false }] }
314
- }
315
- const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
316
- ? message.source.summary
317
- : message.source.kind
318
- return { ...view, pending, entries: [...entries, { kind: 'user', text: boundContextSummary(notice), notice: true }] }
319
- }
320
- case 'agent/inbox/spliced': {
321
- // The durable inbox mutation (web queue-mirror contract, event-sourced):
322
- // removals drop the projected rows at their inbox coordinates, inserted
323
- // messages gain a pending row at their log position.
324
- const { target, start, removedCount = 0, inserted } = event.data
325
- const ids = view.pending[target]
326
- const removed = ids.slice(start, start + removedCount)
327
- const nextIds = [
328
- ...ids.slice(0, start),
329
- ...ids.slice(start + removedCount),
330
- ...inserted.map(message => message.id),
331
- ]
332
- let entries = view.entries
333
- if (removed.length > 0) {
334
- const removedSet = new Set(removed)
335
- entries = entries.filter(entry =>
336
- !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
337
- }
338
- for (const message of inserted) {
339
- entries = [...entries, {
340
- kind: 'pending',
341
- messageId: message.id,
342
- target,
343
- text: pendingText(message.content),
344
- }]
345
- }
346
- return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
347
- }
348
- case 'assistant/chunk': {
349
- const chunk = event.data.chunk
350
- // First-token latency: the first non-empty delta of a step anchors the
351
- // TTFT (empty keep-alive deltas do not count as tokens).
352
- const key = `${event.data.turn}:${event.data.step}`
353
- const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
354
- let stats = view.stats
355
- if (delta !== '' && !view.anchors.firstChunkAt.has(key)) {
356
- view.anchors.firstChunkAt.set(key, event.time)
357
- const started = view.anchors.stepStart.get(key)
358
- if (started !== undefined) {
359
- stats = {
360
- ...stats,
361
- ttftMs: stats.ttftMs + Math.max(0, event.time - started),
362
- ttftSteps: stats.ttftSteps + 1,
363
- }
364
- }
365
- }
366
- if (chunk.type === 'text-delta') {
367
- return { ...view, streaming: appendStreamingTail(view.streaming, chunk.text), stats }
368
- }
369
- if (chunk.type === 'reasoning-delta') {
370
- return { ...view, streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text), stats }
371
- }
372
- return view
373
- }
374
- case 'assistant/message': {
375
- // The assembled message is authoritative; drop the streamed buffers.
376
- const key = `${event.data.turn}:${event.data.step}`
377
- const started = view.anchors.stepStart.get(key)
378
- view.anchors.stepStart.delete(key)
379
- const firstChunk = view.anchors.firstChunkAt.get(key)
380
- view.anchors.firstChunkAt.delete(key)
381
- const usage = event.data.usage
382
- const totals = view.stats.usage
383
- return {
384
- ...view,
385
- streaming: '',
386
- streamingReasoning: '',
387
- entries: [...view.entries, {
388
- kind: 'assistant',
389
- text: textOf(event.data.message.content),
390
- reasoning: reasoningOf(event.data.message.content),
391
- }],
392
- stats: {
393
- ...view.stats,
394
- llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
395
- usage: usage === undefined ? totals : {
396
- inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
397
- outputTokens: totals.outputTokens + usage.outputTokens,
398
- cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
399
- },
400
- lastPromptTokens: usage === undefined ? view.stats.lastPromptTokens
401
- : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
402
- // Decode span and its tokens pair up: an un-timed step (no first
403
- // chunk landed) contributes neither, so the rate stays honest.
404
- decodeMs: view.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
405
- decodeTokens: view.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
406
- },
407
- }
408
- }
409
- case 'tool/call': {
410
- const data = event.data
411
- view.anchors.toolStart.set(data.callId, event.time)
412
- return {
413
- ...view,
414
- entries: [...view.entries, {
415
- kind: 'tool',
416
- callId: data.callId,
417
- name: data.name,
418
- arguments: data.arguments,
419
- preview: toolArgumentsPreview(data.arguments, data.name),
420
- state: 'running',
421
- summary: '',
422
- detail: undefined,
423
- }],
424
- }
425
- }
426
- case 'tool/result': {
427
- const block = event.data.message.content[0]
428
- const started = view.anchors.toolStart.get(block.toolCallId)
429
- view.anchors.toolStart.delete(block.toolCallId)
430
- const rawText = textOf(block.content)
431
- const summary = boundContextSummary(rawText)
432
- // The verbose expansion self-serves from the persisted presentation
433
- // metadata (diffs, read windows, web sources) with the bounded raw text
434
- // as the universal fallback — the capable-UI degradation ladder.
435
- const detail = toolResultDetail(event.data.meta, rawText)
436
- // Turn-tail deliverables: a diff-bearing mutation records its paths.
437
- if (detail?.kind === 'diff') {
438
- const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
439
- for (const diff of detail.diffs) set.add(diff.path)
440
- view.anchors.turnFiles.set(event.data.turn, set)
441
- }
442
- const entries = view.entries.map((entry) => {
443
- if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
444
- return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
445
- })
446
- return {
447
- ...view,
448
- entries,
449
- stats: {
450
- ...view.stats,
451
- toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
452
- },
453
- }
454
- }
455
- case 'todo/write':
456
- return { ...view, todos: event.data.todos }
457
- case 'turn/start':
458
- // The web todo projection clears on turn/start: a fresh turn's first
459
- // write is the authoritative list, and a stale snapshot must not linger
460
- // through a turn that has not written one yet.
461
- return {
462
- ...view,
463
- busy: true,
464
- busySince: view.busy ? view.busySince : event.time,
465
- todos: [],
466
- stats: { ...view.stats, turns: view.stats.turns + 1 },
467
- }
468
- case 'step/start':
469
- view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time)
470
- return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
471
- case 'turn/end': {
472
- const reason = event.data.reason
473
- const appended: TranscriptEntry[] = []
474
- if (reason.kind === 'error') {
475
- appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}` })
476
- } else {
477
- // Non-error outcomes deserve their own durable row (the web renders
478
- // distinct max-tokens / abort / interruption nodes); `completed` stays
479
- // silent so an ordinary turn never grows a marker.
480
- const marker = reason.kind === 'aborted'
481
- ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
482
- : reason.kind === 'max-tokens'
483
- ? 'turn hit the output-token ceiling (max-tokens)'
484
- : reason.kind === 'blocked'
485
- ? 'turn ended blocked'
486
- : reason.kind === 'interrupted'
487
- ? 'turn was interrupted by a restart'
488
- : undefined
489
- if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
490
- }
491
- // Deliverables ride the turn tail (the web's turnTail chips): the
492
- // turn's mutated files flush as one bounded row, then the set resets.
493
- const files = view.anchors.turnFiles.get(event.data.turn)
494
- view.anchors.turnFiles.delete(event.data.turn)
495
- if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
496
- if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
497
- return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
498
- }
499
- case 'llm/retry': {
500
- const data = event.data
501
- return {
502
- ...view,
503
- entries: [...view.entries, {
504
- kind: 'retry',
505
- retryId: data.retryId,
506
- attempt: data.retry,
507
- max: 'maxRetries' in data ? data.maxRetries : data.retry,
508
- code: data.failure.code,
509
- delayMs: data.delayMs,
510
- state: 'running',
511
- }],
512
- }
513
- }
514
- case 'llm/retry-started': {
515
- const data = event.data
516
- const entries = view.entries.map((entry) => {
517
- if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
518
- return { ...entry, state: 'done' as const }
519
- })
520
- return { ...view, entries }
521
- }
522
- case 'sandbox/mode':
523
- // Log-only override switch; last write wins for the status badge.
524
- return { ...view, sandbox: event.data.mode }
525
- case 'goal/change': {
526
- const data = event.data
527
- const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
528
- if (data.operation === 'clear') {
529
- return {
530
- ...view,
531
- goal: undefined,
532
- entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
533
- }
534
- }
535
- const goal: GoalFold = {
536
- objective: data.goal.objective,
537
- phase: data.goal.phase,
538
- rounds: data.roundsStarted,
539
- max: data.goal.maxGoalRounds,
540
- blocked: data.goal.blockedReason?.message ?? '',
541
- }
542
- const line = data.operation === 'create'
543
- ? `◎ goal: ${clip(data.goal.objective)}`
544
- : data.operation === 'complete'
545
- ? '◎ goal complete'
546
- : data.operation === 'pause'
547
- ? '◎ goal paused'
548
- : data.operation === 'resume'
549
- ? '◎ goal resumed'
550
- : data.operation === 'block'
551
- ? `◎ goal blocked: ${clip(goal.blocked)}`
552
- : undefined
553
- return {
554
- ...view,
555
- goal,
556
- entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
557
- }
558
- }
559
- case 'session/title':
560
- // Latest-wins title snapshot, log-only; the status line prefers it.
561
- return { ...view, title: event.data.title }
562
- case 'compaction/summary':
563
- // Remember the shadow price so the matching `compaction/end` row can
564
- // state what the compaction reclaimed.
565
- view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
566
- return view
567
- case 'compaction/prune':
568
- // A model-free prune carries no compaction id; its price serves the next
569
- // `compaction/end` that cannot find a summary price.
570
- return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
571
- case 'compaction/end': {
572
- const ok = event.data.error === undefined
573
- const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
574
- view.anchors.compactionTokens.delete(event.data.compactionId)
575
- return {
576
- ...view,
577
- entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
578
- }
579
- }
580
- case 'request/context':
581
- // Route capacity, logged only when it changes; last one wins.
582
- return {
583
- ...view,
584
- stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
585
- }
586
- case 'request/header': {
587
- // The session's own model record: the latest snapshot's provider/model
588
- // pair, exactly what a resumed TUI restores as the selection.
589
- const config = event.data.header.config
590
- return { ...view, model: `${config.provider}/${config.model}` }
591
- }
592
- case 'plan/mode':
593
- // Whole-value replace; the last one wins (upstream fold semantics).
594
- return { ...view, plan: event.data.active }
595
- case 'permission/preset':
596
- return { ...view, permission: event.data.preset }
597
- case 'command/run': {
598
- const data = event.data
599
- return {
600
- ...view,
601
- entries: [...view.entries, {
602
- kind: 'command',
603
- commandId: data.commandId,
604
- name: data.name,
605
- args: data.args ?? '',
606
- state: 'running',
607
- summary: '',
608
- }],
609
- }
610
- }
611
- case 'command/done': {
612
- const data = event.data
613
- const entries = view.entries.map((entry) => {
614
- if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
615
- return {
616
- ...entry,
617
- state: data.kind === 'success' ? 'done' as const : 'error' as const,
618
- summary: boundContextSummary(data.text ?? ''),
619
- }
620
- })
621
- return { ...view, entries }
622
- }
623
- default:
624
- return view
625
- }
626
- }
627
-
628
- /**
629
- * Fold a replayed event history into one view.
630
- * @param events - events in `seq` order.
631
- * @returns the folded view.
632
- */
633
- export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
634
- return events.reduce(projectEvent, createTranscriptView())
635
- }
636
-
637
- /**
638
- * The append-only flush boundary for a transcript view: the count of entries
639
- * no later event can remove. Entries at or beyond this index are mutable and
640
- * must stay in the live tree.
641
- *
642
- * `pending` rows are excluded even though they are not a running tool/retry:
643
- * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
644
- * `user/message` retirement), and an append-only `<Static>` flush cannot
645
- * erase a row that vanishes from the view — the retired row would ghost on
646
- * screen until the next source-backed replay. Everything else (including a
647
- * completed tail) is final: later events only APPEND new rows.
648
- * @param entries - the view's transcript entries in order.
649
- * @returns the count of entries safe to flush (0 for an empty transcript).
650
- */
651
- export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
652
- for (let index = 0; index < entries.length; index++) {
653
- const entry = entries[index]
654
- if (entry.kind === 'pending') return index
655
- if (entry.kind === 'tool' && entry.state === 'running') return index
656
- if (entry.kind === 'retry' && entry.state === 'running') return index
657
- }
658
- return entries.length
659
- }
1
+ /**
2
+ * Pure session-event-to-view projection for the TUI transcript: one reducer
3
+ * over {@link SessionEvent}s producing the ordered entries the renderer draws.
4
+ * Rendering never reads the session directly — this module owns the view
5
+ * model, so tests drive it with plain event arrays.
6
+ *
7
+ * @module @deepseek-ai/dsh-tui/render/projection
8
+ */
9
+
10
+ import { boundContextSummary, type ContentBlock, type MessageId } from '@deepseek-ai/dsh-llm'
11
+ import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
+ // Type-only imports merge the plugin-owned SessionEventMap variants
13
+ // (agent/inbox/spliced, command/*, compaction/*, goal/change, llm/retry*,
14
+ // plan/mode, permission/preset, sandbox/mode, session/title) into the union
15
+ // this reducer switches on.
16
+ import type {} from '@deepseek-ai/dsh-agent'
17
+ import type {} from '@deepseek-ai/dsh-commands'
18
+ import type {} from '@deepseek-ai/dsh-compaction'
19
+ import type {} from '@deepseek-ai/dsh-goal'
20
+ import type {} from '@deepseek-ai/dsh-llm-retry'
21
+ import type {} from '@deepseek-ai/dsh-plan-mode'
22
+ import type {} from '@deepseek-ai/dsh-permission-presets'
23
+ import type {} from '@deepseek-ai/dsh-sandbox-policy'
24
+ import type {} from '@deepseek-ai/dsh-session-title'
25
+ import { toolArgumentsPreview } from './tool-preview.ts'
26
+ import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
27
+
28
+ /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
29
+ const MAX_STREAMING_CHARS = 65_536
30
+
31
+ /**
32
+ * Upper bound on remembered `compaction/summary` shadow prices waiting for a
33
+ * matching `compaction/end`. Compactions are sequential and rare, so a few
34
+ * slots suffice; an aborted compaction (summary without end) otherwise leaves
35
+ * an unbounded residue in `anchors.compactionTokens`. An evicted price
36
+ * degrades to the documented `lastPruneTokens` fallback, exactly like a
37
+ * missing summary.
38
+ */
39
+ const MAX_COMPACTION_SUMMARY_RESIDUE = 16
40
+
41
+ /** Append one delta without retaining an unbounded duplicate of the live reply. */
42
+ function appendStreamingTail(current: string, delta: string): string {
43
+ const next = current + delta
44
+ return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
45
+ }
46
+
47
+ /** One user prompt line. */
48
+ export interface UserEntry {
49
+ kind: 'user'
50
+ /** Joined text blocks of the user message. */
51
+ text: string
52
+ /** True for collapsed injected context (plugin/continuation notices), which
53
+ * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
54
+ notice: boolean
55
+ }
56
+
57
+ /** One user message waiting in the agent inbox (the web's queued-message row). */
58
+ export interface PendingEntry {
59
+ kind: 'pending'
60
+ /** Stable message identity shared with the durable `user/message` that retires it. */
61
+ messageId: MessageId
62
+ /** Which inbox list holds the message: steering is consumed at the next step boundary. */
63
+ target: 'next-turn' | 'next-step'
64
+ /** Full message text — Codex PendingSteer renders queued prompts exactly like user rows. */
65
+ text: string
66
+ }
67
+
68
+ /** One assembled assistant reply. */
69
+ export interface AssistantEntry {
70
+ kind: 'assistant'
71
+ /** Joined text blocks of the assistant message. */
72
+ text: string
73
+ /** Joined reasoning blocks of the same message, empty when the model thought out loud. */
74
+ reasoning: string
75
+ }
76
+
77
+ /** One model-requested tool invocation and its settled state. */
78
+ export interface ToolEntry {
79
+ kind: 'tool'
80
+ /** Correlation id shared with the matching `tool/result`. */
81
+ callId: string
82
+ /** Tool name as the model addressed it. */
83
+ name: string
84
+ /** Raw arguments JSON string exactly as the model produced it. */
85
+ arguments: string
86
+ /** Bounded human-meaningful arguments preview for the tool card. */
87
+ preview: string
88
+ /** Execution state; `running` until the paired result lands. */
89
+ state: 'running' | 'done' | 'error'
90
+ /** Bounded first text block of the result, empty until it lands. */
91
+ summary: string
92
+ /**
93
+ * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
94
+ * from the tool's persisted presentation metadata; undefined until the
95
+ * result lands and only when something renderable exists.
96
+ */
97
+ detail: ToolDetail | undefined
98
+ }
99
+
100
+ /** One slash-command execution dispatched through `ctx.commands`. */
101
+ export interface CommandEntry {
102
+ kind: 'command'
103
+ /** Pairing id shared with the matching `command/done`. */
104
+ commandId: string
105
+ /** Lowercase command name without the leading slash. */
106
+ name: string
107
+ /** Verbatim text following the command name. */
108
+ args: string
109
+ /** Execution state; `running` until the paired lifecycle event lands. */
110
+ state: 'running' | 'done' | 'error'
111
+ /** Handler outcome text, empty until it lands. */
112
+ summary: string
113
+ }
114
+
115
+ /** One turn-level failure surfaced from `turn/end`. */
116
+ export interface ErrorEntry {
117
+ kind: 'error'
118
+ /** `code: message` of the failure. */
119
+ text: string
120
+ }
121
+
122
+ /** One non-error turn outcome surfaced from `turn/end`. */
123
+ export interface TurnMarkerEntry {
124
+ kind: 'turn-marker'
125
+ /** Human-readable outcome line, dim-rendered. */
126
+ text: string
127
+ }
128
+
129
+ /** One completed compaction lifecycle surfaced from `compaction/end`. */
130
+ export interface CompactionEntry {
131
+ kind: 'compaction'
132
+ /** True when the compaction completed, false when it failed. */
133
+ ok: boolean
134
+ /** Heuristic tokens shadowed by the compaction (summary or prune price). */
135
+ tokens: number
136
+ /** Failure text when `ok` is false, empty otherwise. */
137
+ error: string
138
+ }
139
+
140
+ /** One provider-routed model-request retry (the `llm/retry` pair). */
141
+ export interface RetryEntry {
142
+ kind: 'retry'
143
+ /** Correlation id shared with the matching `llm/retry-started`. */
144
+ retryId: string
145
+ /** Attempt ordinal and its cap. */
146
+ attempt: number
147
+ max: number
148
+ /** Failure code that triggered the retry. */
149
+ code: string
150
+ /** Backoff wait before the next attempt, in ms. */
151
+ delayMs: number
152
+ /** `running` while the backoff waits, `done` once the attempt started. */
153
+ state: 'running' | 'done'
154
+ }
155
+
156
+ /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
157
+ export interface FilesEntry {
158
+ kind: 'files'
159
+ /** Unique mutated paths in call order, bounded. */
160
+ paths: readonly string[]
161
+ }
162
+
163
+ /** Ordered transcript items the renderer draws. */
164
+ export type TranscriptEntry = UserEntry | PendingEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
165
+
166
+ /** The live goal the status line badges, folded from `goal/change`. */
167
+ export interface GoalFold {
168
+ /** Human-requested completion objective. */
169
+ objective: string
170
+ /** Durable lifecycle phase. */
171
+ phase: 'active' | 'paused' | 'blocked' | 'complete'
172
+ /** Highest admitted continuation round and its cap. */
173
+ rounds: number
174
+ max: number
175
+ /** Blocked explanation, empty outside the blocked phase. */
176
+ blocked: string
177
+ }
178
+
179
+ /** Cumulative token accounting folded from `assistant/message` usage reports. */
180
+ export interface UsageTotals {
181
+ /** Prompt-side billed tokens: `inputTokens` plus both cache buckets. */
182
+ inputTokens: number
183
+ /** Completion-side tokens over the whole log. */
184
+ outputTokens: number
185
+ /** Cache-read tokens over the whole log (0 when the adapter reports none). */
186
+ cacheReadTokens: number
187
+ }
188
+
189
+ /**
190
+ * Estimated used tokens per context content type, folded from transcript
191
+ * events via {@link estimateTokens}. The segmented context bar's composition
192
+ * source: proportions across types are meaningful, absolute values are not
193
+ * (they never touch billing or the reported `lastPromptTokens`).
194
+ */
195
+ export interface ContextSegments {
196
+ /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
197
+ system: number
198
+ /** Direct human prompts (durable `user/message` rows). */
199
+ prompt: number
200
+ /** Assistant text blocks (visible replies). */
201
+ assistant: number
202
+ /** Assistant reasoning blocks (hidden thinking). */
203
+ thinking: number
204
+ /** Tool call arguments plus result text. */
205
+ tools: number
206
+ }
207
+
208
+ /** Window-scoped figures the status line shows; timing uses event timestamps. */
209
+ export interface TranscriptStats {
210
+ /** Durable turns opened (`turn/start` events). */
211
+ turns: number
212
+ /** Model requests made (`step/start` events). */
213
+ steps: number
214
+ /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
215
+ llmMs: number
216
+ /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
217
+ toolMs: number
218
+ /** Cumulative token accounting; input stays 0 until a report lands. */
219
+ usage: UsageTotals
220
+ /** Prompt-side size of the most recent reported request (context pressure). */
221
+ lastPromptTokens: number
222
+ /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
223
+ contextWindow: number
224
+ /** Estimated used tokens per content type (the segmented bar's composition). */
225
+ contextSegments: ContextSegments
226
+ /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
227
+ ttftMs: number
228
+ /** Steps that produced a first chunk (the TTFT average's denominator). */
229
+ ttftSteps: number
230
+ /** Summed decode spans: first chunk `assistant/message`, in ms. */
231
+ decodeMs: number
232
+ /** Completion tokens over timed decode spans (the tok/s numerator). */
233
+ decodeTokens: number
234
+ /**
235
+ * Adapter-owned reasoning effort of the latest `request/header` config —
236
+ * the EFFECTIVE effort the session actually uses (a materialized model
237
+ * default is included, exactly as the adapter resolved it). Empty when the
238
+ * header carried none (provider-default behavior). The status line appends
239
+ * it to the model segment as `provider/model@effort`.
240
+ */
241
+ reasoningEffort: string
242
+ }
243
+
244
+ /** The complete TUI transcript view for one session. */
245
+ export interface TranscriptView {
246
+ /** Settled entries in log order. */
247
+ entries: readonly TranscriptEntry[]
248
+ /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
249
+ streaming: string
250
+ /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
251
+ streamingReasoning: string
252
+ /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
253
+ todos: readonly TodoItem[]
254
+ /** True while a durable turn is open (`turn/start` `turn/end`). */
255
+ busy: boolean
256
+ /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
257
+ busySince: number
258
+ /** Figures the status line renders. */
259
+ stats: TranscriptStats
260
+ /**
261
+ * The `provider/model` pair of the last `request/header` snapshot — the
262
+ * session's own model record, which a resumed TUI prefers over the
263
+ * deployment default (mirrors the web host's resume selection order).
264
+ * Empty before the session's first request.
265
+ */
266
+ model: string
267
+ /** Plan mode state folded from the last `plan/mode` event. */
268
+ plan: boolean
269
+ /** Active permission preset folded from the last `permission/preset` event, empty before one. */
270
+ permission: string
271
+ /** Latest session title folded from the last `session/title` event, empty before one. */
272
+ title: string
273
+ /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
274
+ sandbox: string
275
+ /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
276
+ goal: GoalFold | undefined
277
+ /**
278
+ * Ordered live message ids per inbox target, mirrored from
279
+ * `agent/inbox/spliced` exactly like the upstream Inbox projection the
280
+ * coordinates later removals resolve against.
281
+ */
282
+ pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
283
+ /**
284
+ * Fold-internal timing anchors, never rendered: open step and tool-call
285
+ * start timestamps the next `assistant/message` / `tool/result` resolves
286
+ * against. Keyed `turn:step` and by call id. `turnSteps`/`turnTools`
287
+ * track which step/tool anchors still belong to the open turn so
288
+ * `turn/end` (and a superseding `step/start`) can sweep anchors an
289
+ * interruption left behind; `turnFiles` keys mutated paths by turn.
290
+ */
291
+ readonly anchors: {
292
+ stepStart: Map<string, number>
293
+ toolStart: Map<string, number>
294
+ firstChunkAt: Map<string, number>
295
+ compactionTokens: Map<string, number>
296
+ lastPruneTokens: number
297
+ turnFiles: Map<number, Set<string>>
298
+ turnSteps: Map<number, string>
299
+ turnTools: Map<number, Set<string>>
300
+ }
301
+ }
302
+
303
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
304
+ function textOf(content: readonly ContentBlock[]): string {
305
+ return content.filter(block => block.type === 'text').map(block => block.text).join('')
306
+ }
307
+
308
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
309
+ function reasoningOf(content: readonly ContentBlock[]): string {
310
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
311
+ }
312
+
313
+ /**
314
+ * Rough token estimate for the segmented context bar (pi-nano-context's ~4
315
+ * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
316
+ * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
317
+ * drive bar PROPORTIONS, never billing, so precision is not required.
318
+ * @param text - the text to estimate.
319
+ * @returns an integer token estimate, 0 for empty text.
320
+ */
321
+ function estimateTokens(text: string): number {
322
+ let wide = 0
323
+ let narrow = 0
324
+ for (const char of text) {
325
+ if ((char.codePointAt(0) ?? 0) > 0x2e7f) wide += 1
326
+ else narrow += 1
327
+ }
328
+ return wide + Math.ceil(narrow / 4)
329
+ }
330
+
331
+ /** A fresh, empty transcript view. */
332
+ export function createTranscriptView(): TranscriptView {
333
+ return {
334
+ entries: [],
335
+ streaming: '',
336
+ streamingReasoning: '',
337
+ todos: [],
338
+ busy: false,
339
+ busySince: 0,
340
+ model: '',
341
+ plan: false,
342
+ permission: '',
343
+ title: '',
344
+ sandbox: '',
345
+ goal: undefined,
346
+ pending: { 'next-turn': [], 'next-step': [] },
347
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
348
+ anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map(), turnSteps: new Map(), turnTools: new Map() },
349
+ }
350
+ }
351
+
352
+ /** Full prompt text of a queued message (identical to the durable user row it retires into). */
353
+ function pendingText(content: readonly ContentBlock[]): string {
354
+ return textOf(content)
355
+ }
356
+
357
+ /**
358
+ * Fold one session event into an updated view (copy-on-write).
359
+ * @param view - the view before the event.
360
+ * @param event - one durable session event from `session/event` or the log.
361
+ * @returns the view after the event; the input view is never mutated.
362
+ */
363
+ export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
364
+ switch (event.type) {
365
+ case 'user/message': {
366
+ // A queued row retires when its durable user message lands (the agent
367
+ // claims the inbox and logs the same message identity) the transient
368
+ // steering/queued preview yields to the real transcript entry.
369
+ const message = event.data
370
+ let entries = view.entries
371
+ let pending = view.pending
372
+ for (const target of ['next-turn', 'next-step'] as const) {
373
+ const index = pending[target].indexOf(message.id)
374
+ if (index < 0) continue
375
+ pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
376
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
377
+ }
378
+ // Injected context (plugin/model-continuation sources) stays collapsed
379
+ // to a bounded notice row, exactly like collapsed transcript context
380
+ // elsewhere in the product; only direct human prompts render in full.
381
+ const text = textOf(message.content)
382
+ if (message.source.kind === 'user') {
383
+ return {
384
+ ...view,
385
+ pending,
386
+ entries: [...entries, { kind: 'user', text, notice: false }],
387
+ stats: {
388
+ ...view.stats,
389
+ contextSegments: {
390
+ ...view.stats.contextSegments,
391
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
392
+ },
393
+ },
394
+ }
395
+ }
396
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
397
+ ? message.source.summary
398
+ : message.source.kind
399
+ const summary = boundContextSummary(notice)
400
+ return {
401
+ ...view,
402
+ pending,
403
+ entries: [...entries, { kind: 'user', text: summary, notice: true }],
404
+ stats: {
405
+ ...view.stats,
406
+ contextSegments: {
407
+ ...view.stats.contextSegments,
408
+ system: view.stats.contextSegments.system + estimateTokens(summary),
409
+ },
410
+ },
411
+ }
412
+ }
413
+ case 'agent/inbox/spliced': {
414
+ // The durable inbox mutation (web queue-mirror contract, event-sourced):
415
+ // removals drop the projected rows at their inbox coordinates, inserted
416
+ // messages gain a pending row at their log position.
417
+ const { target, start, removedCount = 0, inserted } = event.data
418
+ const ids = view.pending[target]
419
+ const removed = ids.slice(start, start + removedCount)
420
+ const nextIds = [
421
+ ...ids.slice(0, start),
422
+ ...ids.slice(start + removedCount),
423
+ ...inserted.map(message => message.id),
424
+ ]
425
+ let entries = view.entries
426
+ if (removed.length > 0) {
427
+ const removedSet = new Set(removed)
428
+ entries = entries.filter(entry =>
429
+ !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
430
+ }
431
+ for (const message of inserted) {
432
+ entries = [...entries, {
433
+ kind: 'pending',
434
+ messageId: message.id,
435
+ target,
436
+ text: pendingText(message.content),
437
+ }]
438
+ }
439
+ return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
440
+ }
441
+ case 'assistant/chunk': {
442
+ const chunk = event.data.chunk
443
+ // First-token latency: the first non-empty delta of a step anchors the
444
+ // TTFT (empty keep-alive deltas do not count as tokens).
445
+ const key = `${event.data.turn}:${event.data.step}`
446
+ const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
447
+ let stats = view.stats
448
+ if (delta !== '' && !view.anchors.firstChunkAt.has(key)) {
449
+ view.anchors.firstChunkAt.set(key, event.time)
450
+ const started = view.anchors.stepStart.get(key)
451
+ if (started !== undefined) {
452
+ stats = {
453
+ ...stats,
454
+ ttftMs: stats.ttftMs + Math.max(0, event.time - started),
455
+ ttftSteps: stats.ttftSteps + 1,
456
+ }
457
+ }
458
+ }
459
+ if (chunk.type === 'text-delta') {
460
+ return { ...view, streaming: appendStreamingTail(view.streaming, chunk.text), stats }
461
+ }
462
+ if (chunk.type === 'reasoning-delta') {
463
+ return { ...view, streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text), stats }
464
+ }
465
+ return view
466
+ }
467
+ case 'assistant/message': {
468
+ // The assembled message is authoritative; drop the streamed buffers.
469
+ const key = `${event.data.turn}:${event.data.step}`
470
+ const started = view.anchors.stepStart.get(key)
471
+ view.anchors.stepStart.delete(key)
472
+ const firstChunk = view.anchors.firstChunkAt.get(key)
473
+ view.anchors.firstChunkAt.delete(key)
474
+ // The assembled message consumes the turn's current step anchor; a
475
+ // later `turn/end` sweep then has nothing left to clean for this step.
476
+ if (view.anchors.turnSteps.get(event.data.turn) === key) view.anchors.turnSteps.delete(event.data.turn)
477
+ const usage = event.data.usage
478
+ const totals = view.stats.usage
479
+ const text = textOf(event.data.message.content)
480
+ const reasoning = reasoningOf(event.data.message.content)
481
+ return {
482
+ ...view,
483
+ streaming: '',
484
+ streamingReasoning: '',
485
+ entries: [...view.entries, {
486
+ kind: 'assistant',
487
+ text,
488
+ reasoning,
489
+ }],
490
+ stats: {
491
+ ...view.stats,
492
+ llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
493
+ usage: usage === undefined ? totals : {
494
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
495
+ outputTokens: totals.outputTokens + usage.outputTokens,
496
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
497
+ },
498
+ lastPromptTokens: usage === undefined ? view.stats.lastPromptTokens
499
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
500
+ // Decode span and its tokens pair up: an un-timed step (no first
501
+ // chunk landed) contributes neither, so the rate stays honest.
502
+ decodeMs: view.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
503
+ decodeTokens: view.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
504
+ contextSegments: {
505
+ ...view.stats.contextSegments,
506
+ thinking: view.stats.contextSegments.thinking + estimateTokens(reasoning),
507
+ assistant: view.stats.contextSegments.assistant + estimateTokens(text),
508
+ },
509
+ },
510
+ }
511
+ }
512
+ case 'tool/call': {
513
+ const data = event.data
514
+ view.anchors.toolStart.set(data.callId, event.time)
515
+ // Remember the call's turn so `turn/end` can sweep a start that never
516
+ // pairs with a result (an interrupted tool otherwise leaks its anchor).
517
+ const turnTools = view.anchors.turnTools.get(data.turn) ?? new Set<string>()
518
+ turnTools.add(data.callId)
519
+ view.anchors.turnTools.set(data.turn, turnTools)
520
+ return {
521
+ ...view,
522
+ entries: [...view.entries, {
523
+ kind: 'tool',
524
+ callId: data.callId,
525
+ name: data.name,
526
+ arguments: data.arguments,
527
+ preview: toolArgumentsPreview(data.arguments, data.name),
528
+ state: 'running',
529
+ summary: '',
530
+ detail: undefined,
531
+ }],
532
+ stats: {
533
+ ...view.stats,
534
+ contextSegments: {
535
+ ...view.stats.contextSegments,
536
+ tools: view.stats.contextSegments.tools
537
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
538
+ },
539
+ },
540
+ }
541
+ }
542
+ case 'tool/result': {
543
+ const block = event.data.message.content[0]
544
+ const started = view.anchors.toolStart.get(block.toolCallId)
545
+ view.anchors.toolStart.delete(block.toolCallId)
546
+ // Deregister the call from its turn's registry so `turn/end` does not
547
+ // sweep a start that already paired with a result.
548
+ const turnTools = view.anchors.turnTools.get(event.data.turn)
549
+ if (turnTools !== undefined) {
550
+ turnTools.delete(block.toolCallId)
551
+ if (turnTools.size === 0) view.anchors.turnTools.delete(event.data.turn)
552
+ }
553
+ const rawText = textOf(block.content)
554
+ const summary = boundContextSummary(rawText)
555
+ // The verbose expansion self-serves from the persisted presentation
556
+ // metadata (diffs, read windows, web sources) with the bounded raw text
557
+ // as the universal fallback — the capable-UI degradation ladder.
558
+ const detail = toolResultDetail(event.data.meta, rawText)
559
+ // Turn-tail deliverables: a diff-bearing mutation records its paths.
560
+ if (detail?.kind === 'diff') {
561
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
562
+ for (const diff of detail.diffs) set.add(diff.path)
563
+ view.anchors.turnFiles.set(event.data.turn, set)
564
+ }
565
+ const entries = view.entries.map((entry) => {
566
+ if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
567
+ return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
568
+ })
569
+ return {
570
+ ...view,
571
+ entries,
572
+ stats: {
573
+ ...view.stats,
574
+ toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
575
+ contextSegments: {
576
+ ...view.stats.contextSegments,
577
+ tools: view.stats.contextSegments.tools + estimateTokens(rawText),
578
+ },
579
+ },
580
+ }
581
+ }
582
+ case 'todo/write':
583
+ return { ...view, todos: event.data.todos }
584
+ case 'turn/start':
585
+ // The web todo projection clears on turn/start: a fresh turn's first
586
+ // write is the authoritative list, and a stale snapshot must not linger
587
+ // through a turn that has not written one yet.
588
+ return {
589
+ ...view,
590
+ busy: true,
591
+ busySince: view.busy ? view.busySince : event.time,
592
+ todos: [],
593
+ stats: { ...view.stats, turns: view.stats.turns + 1 },
594
+ }
595
+ case 'step/start': {
596
+ // A step supersedes the turn's previous step: if that step never
597
+ // assembled a message (interrupted), its timing anchors are stale the
598
+ // moment the next step opens and are swept here instead of leaking.
599
+ const key = `${event.data.turn}:${event.data.step}`
600
+ const previous = view.anchors.turnSteps.get(event.data.turn)
601
+ if (previous !== undefined && previous !== key) {
602
+ view.anchors.stepStart.delete(previous)
603
+ view.anchors.firstChunkAt.delete(previous)
604
+ }
605
+ view.anchors.turnSteps.set(event.data.turn, key)
606
+ view.anchors.stepStart.set(key, event.time)
607
+ return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
608
+ }
609
+ case 'turn/end': {
610
+ const reason = event.data.reason
611
+ const appended: TranscriptEntry[] = []
612
+ if (reason.kind === 'error') {
613
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
614
+ ? ' · open /model to add an API key'
615
+ : ''
616
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
617
+ } else {
618
+ // Non-error outcomes deserve their own durable row (the web renders
619
+ // distinct max-tokens / abort / interruption nodes); `completed` stays
620
+ // silent so an ordinary turn never grows a marker.
621
+ const marker = reason.kind === 'aborted'
622
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
623
+ : reason.kind === 'max-tokens'
624
+ ? 'turn hit the output-token ceiling (max-tokens)'
625
+ : reason.kind === 'blocked'
626
+ ? 'turn ended blocked'
627
+ : reason.kind === 'interrupted'
628
+ ? 'turn was interrupted by a restart'
629
+ : undefined
630
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
631
+ }
632
+ // Deliverables ride the turn tail (the web's turnTail chips): the
633
+ // turn's mutated files flush as one bounded row, then the set resets.
634
+ const files = view.anchors.turnFiles.get(event.data.turn)
635
+ view.anchors.turnFiles.delete(event.data.turn)
636
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
637
+ // Derivable boundary sweep: the turn is over, so any step/tool anchors
638
+ // it left behind (interruptions that never produced their message or
639
+ // result) can never be resolved and are reclaimed now.
640
+ const stepKey = view.anchors.turnSteps.get(event.data.turn)
641
+ if (stepKey !== undefined) {
642
+ view.anchors.stepStart.delete(stepKey)
643
+ view.anchors.firstChunkAt.delete(stepKey)
644
+ view.anchors.turnSteps.delete(event.data.turn)
645
+ }
646
+ const turnToolSet = view.anchors.turnTools.get(event.data.turn)
647
+ if (turnToolSet !== undefined) {
648
+ for (const callId of turnToolSet) view.anchors.toolStart.delete(callId)
649
+ view.anchors.turnTools.delete(event.data.turn)
650
+ }
651
+ if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
652
+ return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
653
+ }
654
+ case 'llm/retry': {
655
+ const data = event.data
656
+ return {
657
+ ...view,
658
+ entries: [...view.entries, {
659
+ kind: 'retry',
660
+ retryId: data.retryId,
661
+ attempt: data.retry,
662
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
663
+ code: data.failure.code,
664
+ delayMs: data.delayMs,
665
+ state: 'running',
666
+ }],
667
+ }
668
+ }
669
+ case 'llm/retry-started': {
670
+ const data = event.data
671
+ const entries = view.entries.map((entry) => {
672
+ if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
673
+ return { ...entry, state: 'done' as const }
674
+ })
675
+ return { ...view, entries }
676
+ }
677
+ case 'sandbox/mode':
678
+ // Log-only override switch; last write wins for the status badge.
679
+ return { ...view, sandbox: event.data.mode }
680
+ case 'goal/change': {
681
+ const data = event.data
682
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
683
+ if (data.operation === 'clear') {
684
+ return {
685
+ ...view,
686
+ goal: undefined,
687
+ entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
688
+ }
689
+ }
690
+ const goal: GoalFold = {
691
+ objective: data.goal.objective,
692
+ phase: data.goal.phase,
693
+ rounds: data.roundsStarted,
694
+ max: data.goal.maxGoalRounds,
695
+ blocked: data.goal.blockedReason?.message ?? '',
696
+ }
697
+ const line = data.operation === 'create'
698
+ ? `◎ goal: ${clip(data.goal.objective)}`
699
+ : data.operation === 'complete'
700
+ ? '◎ goal complete'
701
+ : data.operation === 'pause'
702
+ ? '◎ goal paused'
703
+ : data.operation === 'resume'
704
+ ? '◎ goal resumed'
705
+ : data.operation === 'block'
706
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
707
+ : undefined
708
+ return {
709
+ ...view,
710
+ goal,
711
+ entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
712
+ }
713
+ }
714
+ case 'session/title':
715
+ // Latest-wins title snapshot, log-only; the status line prefers it.
716
+ return { ...view, title: event.data.title }
717
+ case 'compaction/summary':
718
+ // Remember the shadow price so the matching `compaction/end` row can
719
+ // state what the compaction reclaimed. The map is capped so an aborted
720
+ // compaction (summary without end) cannot leave an unbounded residue.
721
+ if (view.anchors.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
722
+ const oldest = view.anchors.compactionTokens.keys().next().value
723
+ if (oldest !== undefined) view.anchors.compactionTokens.delete(oldest)
724
+ }
725
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
726
+ return view
727
+ case 'compaction/prune':
728
+ // A model-free prune carries no compaction id; its price serves the next
729
+ // `compaction/end` that cannot find a summary price.
730
+ return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
731
+ case 'compaction/end': {
732
+ const ok = event.data.error === undefined
733
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
734
+ view.anchors.compactionTokens.delete(event.data.compactionId)
735
+ return {
736
+ ...view,
737
+ entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
738
+ }
739
+ }
740
+ case 'request/context':
741
+ // Route capacity, logged only when it changes; last one wins.
742
+ return {
743
+ ...view,
744
+ stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
745
+ }
746
+ case 'request/header': {
747
+ // The session's own model record: the latest snapshot's provider/model
748
+ // pair, exactly what a resumed TUI restores as the selection, plus the
749
+ // effective reasoning effort that snapshot carried (the adapter may
750
+ // materialize the model default, which is what the status line shows).
751
+ // The snapshot's rendered system prompt is the current system slot, so
752
+ // it REPLACES the estimate (an older system prompt is not re-sent).
753
+ const config = event.data.header.config
754
+ return {
755
+ ...view,
756
+ model: `${config.provider}/${config.model}`,
757
+ stats: {
758
+ ...view.stats,
759
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
760
+ contextSegments: {
761
+ ...view.stats.contextSegments,
762
+ system: estimateTokens(event.data.header.system ?? ''),
763
+ },
764
+ },
765
+ }
766
+ }
767
+ case 'plan/mode':
768
+ // Whole-value replace; the last one wins (upstream fold semantics).
769
+ return { ...view, plan: event.data.active }
770
+ case 'permission/preset':
771
+ return { ...view, permission: event.data.preset }
772
+ case 'command/run': {
773
+ const data = event.data
774
+ return {
775
+ ...view,
776
+ entries: [...view.entries, {
777
+ kind: 'command',
778
+ commandId: data.commandId,
779
+ name: data.name,
780
+ args: data.args ?? '',
781
+ state: 'running',
782
+ summary: '',
783
+ }],
784
+ }
785
+ }
786
+ case 'command/done': {
787
+ const data = event.data
788
+ const entries = view.entries.map((entry) => {
789
+ if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
790
+ return {
791
+ ...entry,
792
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
793
+ summary: boundContextSummary(data.text ?? ''),
794
+ }
795
+ })
796
+ return { ...view, entries }
797
+ }
798
+ default:
799
+ return view
800
+ }
801
+ }
802
+
803
+ /**
804
+ * Mutable replay accumulator: folds a persisted log into the identical view
805
+ * `projectEvent` would produce, but in near-linear time. Where `projectEvent`
806
+ * is copy-on-write — every append/scan rebuilds the whole `entries` array, so
807
+ * folding a full log costs O(N²) — the accumulator appends by push, resolves
808
+ * id-keyed updates (tool/result, command/done, retry-started) through index
809
+ * maps, and tombstones retired pending rows, so the whole log folds in O(N)
810
+ * plus one compaction pass when tombstones exist.
811
+ *
812
+ * Index maps never delete: every appended row registers its index, so an id
813
+ * lookup miss provably means no matching row exists and the update is an O(1)
814
+ * no-op (a malicious/orphan-heavy log cannot force per-orphan full-array
815
+ * scans). Each id maps to ALL of its indices, so a duplicate id updates every
816
+ * matching row exactly like the copy-on-write reducer.
817
+ *
818
+ * @internal Exported only so tests can (a) prove replay ≡ sequential
819
+ * `projectEvent` folds and (b) assert the linear complexity deterministically
820
+ * via {@link ReplayAccumulator.ops}, which counts entry-level container work
821
+ * instead of relying on wall-clock thresholds. No public consumer.
822
+ */
823
+ export interface ReplayAccumulator {
824
+ /** Working entry list; `undefined` marks a retired pending row (tombstone). */
825
+ entries: (TranscriptEntry | undefined)[]
826
+ /** callId → every index into `entries` holding a `tool` row with that id. */
827
+ toolIndex: Map<string, number[]>
828
+ /** commandId → every index into `entries` holding a `command` row with that id. */
829
+ commandIndex: Map<string, number[]>
830
+ /** retryId → every index into `entries` holding a `retry` row with that id. */
831
+ retryIndex: Map<string, number[]>
832
+ /** messageId → every index into `entries` holding a `pending` row with that id. */
833
+ pendingIndex: Map<string, number[]>
834
+ /** Tombstone count; zero means `entries` is already the final array. */
835
+ removedCount: number
836
+ /** Mutable inbox id lists, mirroring `view.pending` order per target. */
837
+ pendingTurn: string[]
838
+ pendingStep: string[]
839
+ streaming: string
840
+ streamingReasoning: string
841
+ todos: readonly TodoItem[]
842
+ busy: boolean
843
+ busySince: number
844
+ model: string
845
+ plan: boolean
846
+ permission: string
847
+ title: string
848
+ sandbox: string
849
+ goal: GoalFold | undefined
850
+ stats: TranscriptStats
851
+ stepStart: Map<string, number>
852
+ toolStart: Map<string, number>
853
+ firstChunkAt: Map<string, number>
854
+ compactionTokens: Map<string, number>
855
+ lastPruneTokens: number
856
+ turnFiles: Map<number, Set<string>>
857
+ turnSteps: Map<number, string>
858
+ turnTools: Map<number, Set<string>>
859
+ /** Entry-level container operations performed so far (test instrumentation). */
860
+ ops: number
861
+ }
862
+
863
+ /** @internal A fresh replay accumulator whose state mirrors `createTranscriptView()`. */
864
+ export function createReplayAccumulator(): ReplayAccumulator {
865
+ return {
866
+ entries: [],
867
+ toolIndex: new Map(),
868
+ commandIndex: new Map(),
869
+ retryIndex: new Map(),
870
+ pendingIndex: new Map(),
871
+ removedCount: 0,
872
+ pendingTurn: [],
873
+ pendingStep: [],
874
+ streaming: '',
875
+ streamingReasoning: '',
876
+ todos: [],
877
+ busy: false,
878
+ busySince: 0,
879
+ model: '',
880
+ plan: false,
881
+ permission: '',
882
+ title: '',
883
+ sandbox: '',
884
+ goal: undefined,
885
+ stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }, lastPromptTokens: 0, contextWindow: 0, contextSegments: { system: 0, prompt: 0, assistant: 0, thinking: 0, tools: 0 }, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, reasoningEffort: '' },
886
+ stepStart: new Map(),
887
+ toolStart: new Map(),
888
+ firstChunkAt: new Map(),
889
+ compactionTokens: new Map(),
890
+ lastPruneTokens: 0,
891
+ turnFiles: new Map(),
892
+ turnSteps: new Map(),
893
+ turnTools: new Map(),
894
+ ops: 0,
895
+ }
896
+ }
897
+
898
+ /** Append one entry (O(1)) and account the push. */
899
+ function appendReplayEntry(acc: ReplayAccumulator, entry: TranscriptEntry): void {
900
+ acc.entries.push(entry)
901
+ acc.ops += 1
902
+ }
903
+
904
+ /**
905
+ * Get (or create) the index list an id owns. Lists are never removed: every
906
+ * appended row registers its index, so a lookup miss later proves no matching
907
+ * row exists and the caller can no-op in O(1).
908
+ */
909
+ function indexList(map: Map<string, number[]>, id: string): number[] {
910
+ let list = map.get(id)
911
+ if (list === undefined) {
912
+ list = []
913
+ map.set(id, list)
914
+ }
915
+ return list
916
+ }
917
+
918
+ /**
919
+ * Apply an id-keyed update to every row that registered the id, mirroring the
920
+ * copy-on-write reducer's full-array map semantics (all matching rows update,
921
+ * in order). Each registered index is O(1), so a duplicate id costs
922
+ * O(#duplicates) — never a full-array scan. The kind+id re-check is defensive:
923
+ * registered indices are valid by construction, because tool/command/retry
924
+ * rows are never removed and tombstones never shift indices.
925
+ */
926
+ function updateReplayById<T extends TranscriptEntry>(
927
+ acc: ReplayAccumulator,
928
+ map: Map<string, number[]>,
929
+ id: string,
930
+ isMatch: (entry: T) => boolean,
931
+ update: (entry: T) => T,
932
+ ): void {
933
+ const list = map.get(id)
934
+ if (list === undefined) return // miss provably means no matching row
935
+ for (const index of list) {
936
+ const entry = acc.entries[index]
937
+ if (entry === undefined || !isMatch(entry as T)) continue
938
+ acc.entries[index] = update(entry as T)
939
+ acc.ops += 1
940
+ }
941
+ }
942
+
943
+ /** Tombstone a retired pending row, keeping every other index stable. */
944
+ function retireReplayEntry(acc: ReplayAccumulator, index: number): void {
945
+ if (acc.entries[index] !== undefined) {
946
+ acc.entries[index] = undefined
947
+ acc.removedCount += 1
948
+ acc.ops += 1
949
+ }
950
+ }
951
+
952
+ /**
953
+ * Fold one session event into a replay accumulator. This mirrors
954
+ * {@link projectEvent} case for case — same stats arithmetic, same anchor
955
+ * set/delete behavior, same entry shapes — so the finished view is identical
956
+ * to a sequential fold; only the `entries` container operations are mutable.
957
+ *
958
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
959
+ */
960
+ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent): void {
961
+ switch (event.type) {
962
+ case 'user/message': {
963
+ const message = event.data
964
+ for (const target of ['next-turn', 'next-step'] as const) {
965
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
966
+ const index = ids.indexOf(message.id)
967
+ acc.ops += index < 0 ? ids.length : index + 1
968
+ if (index < 0) continue
969
+ ids.splice(index, 1)
970
+ acc.ops += 1
971
+ // Retire every pending row carrying this message id (duplicate ids
972
+ // included), exactly like the reducer's full-array filter.
973
+ const list = acc.pendingIndex.get(message.id)
974
+ if (list !== undefined) {
975
+ for (const entryIndex of list) retireReplayEntry(acc, entryIndex)
976
+ acc.ops += 1
977
+ }
978
+ }
979
+ const text = textOf(message.content)
980
+ if (message.source.kind === 'user') {
981
+ appendReplayEntry(acc, { kind: 'user', text, notice: false })
982
+ acc.stats = {
983
+ ...acc.stats,
984
+ contextSegments: {
985
+ ...acc.stats.contextSegments,
986
+ prompt: acc.stats.contextSegments.prompt + estimateTokens(text),
987
+ },
988
+ }
989
+ return
990
+ }
991
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
992
+ ? message.source.summary
993
+ : message.source.kind
994
+ const summary = boundContextSummary(notice)
995
+ appendReplayEntry(acc, { kind: 'user', text: summary, notice: true })
996
+ acc.stats = {
997
+ ...acc.stats,
998
+ contextSegments: {
999
+ ...acc.stats.contextSegments,
1000
+ system: acc.stats.contextSegments.system + estimateTokens(summary),
1001
+ },
1002
+ }
1003
+ return
1004
+ }
1005
+ case 'agent/inbox/spliced': {
1006
+ const { target, start, removedCount = 0, inserted } = event.data
1007
+ const ids = target === 'next-turn' ? acc.pendingTurn : acc.pendingStep
1008
+ const removed = ids.slice(start, start + removedCount)
1009
+ acc.ops += removed.length
1010
+ ids.splice(start, removedCount)
1011
+ acc.ops += removed.length
1012
+ for (const id of removed) {
1013
+ const list = acc.pendingIndex.get(id)
1014
+ if (list === undefined) continue
1015
+ for (const entryIndex of list) {
1016
+ const entry = acc.entries[entryIndex]
1017
+ if (entry !== undefined && entry.kind === 'pending' && entry.target === target) {
1018
+ retireReplayEntry(acc, entryIndex)
1019
+ }
1020
+ }
1021
+ }
1022
+ for (const message of inserted) {
1023
+ appendReplayEntry(acc, { kind: 'pending', messageId: message.id, target, text: pendingText(message.content) })
1024
+ indexList(acc.pendingIndex, message.id).push(acc.entries.length - 1)
1025
+ ids.push(message.id)
1026
+ acc.ops += 1
1027
+ }
1028
+ return
1029
+ }
1030
+ case 'assistant/chunk': {
1031
+ const chunk = event.data.chunk
1032
+ const key = `${event.data.turn}:${event.data.step}`
1033
+ const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
1034
+ if (delta !== '' && !acc.firstChunkAt.has(key)) {
1035
+ acc.firstChunkAt.set(key, event.time)
1036
+ const started = acc.stepStart.get(key)
1037
+ if (started !== undefined) {
1038
+ acc.stats = {
1039
+ ...acc.stats,
1040
+ ttftMs: acc.stats.ttftMs + Math.max(0, event.time - started),
1041
+ ttftSteps: acc.stats.ttftSteps + 1,
1042
+ }
1043
+ }
1044
+ }
1045
+ if (chunk.type === 'text-delta') {
1046
+ acc.streaming = appendStreamingTail(acc.streaming, chunk.text)
1047
+ return
1048
+ }
1049
+ if (chunk.type === 'reasoning-delta') {
1050
+ acc.streamingReasoning = appendStreamingTail(acc.streamingReasoning, chunk.text)
1051
+ return
1052
+ }
1053
+ return
1054
+ }
1055
+ case 'assistant/message': {
1056
+ const key = `${event.data.turn}:${event.data.step}`
1057
+ const started = acc.stepStart.get(key)
1058
+ acc.stepStart.delete(key)
1059
+ const firstChunk = acc.firstChunkAt.get(key)
1060
+ acc.firstChunkAt.delete(key)
1061
+ if (acc.turnSteps.get(event.data.turn) === key) acc.turnSteps.delete(event.data.turn)
1062
+ const usage = event.data.usage
1063
+ const totals = acc.stats.usage
1064
+ const text = textOf(event.data.message.content)
1065
+ const reasoning = reasoningOf(event.data.message.content)
1066
+ acc.streaming = ''
1067
+ acc.streamingReasoning = ''
1068
+ appendReplayEntry(acc, { kind: 'assistant', text, reasoning })
1069
+ acc.stats = {
1070
+ ...acc.stats,
1071
+ llmMs: acc.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1072
+ usage: usage === undefined ? totals : {
1073
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1074
+ outputTokens: totals.outputTokens + usage.outputTokens,
1075
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
1076
+ },
1077
+ lastPromptTokens: usage === undefined ? acc.stats.lastPromptTokens
1078
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
1079
+ decodeMs: acc.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
1080
+ decodeTokens: acc.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
1081
+ contextSegments: {
1082
+ ...acc.stats.contextSegments,
1083
+ thinking: acc.stats.contextSegments.thinking + estimateTokens(reasoning),
1084
+ assistant: acc.stats.contextSegments.assistant + estimateTokens(text),
1085
+ },
1086
+ }
1087
+ return
1088
+ }
1089
+ case 'tool/call': {
1090
+ const data = event.data
1091
+ acc.toolStart.set(data.callId, event.time)
1092
+ const turnTools = acc.turnTools.get(data.turn) ?? new Set<string>()
1093
+ turnTools.add(data.callId)
1094
+ acc.turnTools.set(data.turn, turnTools)
1095
+ appendReplayEntry(acc, {
1096
+ kind: 'tool',
1097
+ callId: data.callId,
1098
+ name: data.name,
1099
+ arguments: data.arguments,
1100
+ preview: toolArgumentsPreview(data.arguments, data.name),
1101
+ state: 'running',
1102
+ summary: '',
1103
+ detail: undefined,
1104
+ })
1105
+ indexList(acc.toolIndex, data.callId).push(acc.entries.length - 1)
1106
+ acc.stats = {
1107
+ ...acc.stats,
1108
+ contextSegments: {
1109
+ ...acc.stats.contextSegments,
1110
+ tools: acc.stats.contextSegments.tools
1111
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
1112
+ },
1113
+ }
1114
+ return
1115
+ }
1116
+ case 'tool/result': {
1117
+ const block = event.data.message.content[0]
1118
+ const started = acc.toolStart.get(block.toolCallId)
1119
+ acc.toolStart.delete(block.toolCallId)
1120
+ const turnTools = acc.turnTools.get(event.data.turn)
1121
+ if (turnTools !== undefined) {
1122
+ turnTools.delete(block.toolCallId)
1123
+ if (turnTools.size === 0) acc.turnTools.delete(event.data.turn)
1124
+ }
1125
+ const rawText = textOf(block.content)
1126
+ const summary = boundContextSummary(rawText)
1127
+ const detail = toolResultDetail(event.data.meta, rawText)
1128
+ if (detail?.kind === 'diff') {
1129
+ const set = acc.turnFiles.get(event.data.turn) ?? new Set<string>()
1130
+ for (const diff of detail.diffs) set.add(diff.path)
1131
+ acc.turnFiles.set(event.data.turn, set)
1132
+ }
1133
+ const update = (entry: ToolEntry): ToolEntry => ({
1134
+ ...entry,
1135
+ state: block.isError === true ? 'error' as const : 'done' as const,
1136
+ summary,
1137
+ detail,
1138
+ })
1139
+ // Every matching row updates (duplicate callIds included); an id with no
1140
+ // registered index is a provable no-op — no full-array fallback scan.
1141
+ updateReplayById<ToolEntry>(acc, acc.toolIndex, block.toolCallId, entry => entry.callId === block.toolCallId, update)
1142
+ acc.stats = {
1143
+ ...acc.stats,
1144
+ toolMs: acc.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
1145
+ contextSegments: {
1146
+ ...acc.stats.contextSegments,
1147
+ tools: acc.stats.contextSegments.tools + estimateTokens(rawText),
1148
+ },
1149
+ }
1150
+ return
1151
+ }
1152
+ case 'todo/write':
1153
+ acc.todos = event.data.todos
1154
+ return
1155
+ case 'turn/start': {
1156
+ const wasBusy = acc.busy
1157
+ acc.busy = true
1158
+ acc.busySince = wasBusy ? acc.busySince : event.time
1159
+ acc.todos = []
1160
+ acc.stats = { ...acc.stats, turns: acc.stats.turns + 1 }
1161
+ return
1162
+ }
1163
+ case 'step/start': {
1164
+ const key = `${event.data.turn}:${event.data.step}`
1165
+ const previous = acc.turnSteps.get(event.data.turn)
1166
+ if (previous !== undefined && previous !== key) {
1167
+ acc.stepStart.delete(previous)
1168
+ acc.firstChunkAt.delete(previous)
1169
+ }
1170
+ acc.turnSteps.set(event.data.turn, key)
1171
+ acc.stepStart.set(key, event.time)
1172
+ acc.stats = { ...acc.stats, steps: acc.stats.steps + 1 }
1173
+ return
1174
+ }
1175
+ case 'turn/end': {
1176
+ const reason = event.data.reason
1177
+ const appended: TranscriptEntry[] = []
1178
+ if (reason.kind === 'error') {
1179
+ const recovery = reason.error.code === 'MISSING_CREDENTIAL'
1180
+ ? ' · open /model to add an API key'
1181
+ : ''
1182
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}${recovery}` })
1183
+ } else {
1184
+ const marker = reason.kind === 'aborted'
1185
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
1186
+ : reason.kind === 'max-tokens'
1187
+ ? 'turn hit the output-token ceiling (max-tokens)'
1188
+ : reason.kind === 'blocked'
1189
+ ? 'turn ended blocked'
1190
+ : reason.kind === 'interrupted'
1191
+ ? 'turn was interrupted by a restart'
1192
+ : undefined
1193
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
1194
+ }
1195
+ const files = acc.turnFiles.get(event.data.turn)
1196
+ acc.turnFiles.delete(event.data.turn)
1197
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
1198
+ const stepKey = acc.turnSteps.get(event.data.turn)
1199
+ if (stepKey !== undefined) {
1200
+ acc.stepStart.delete(stepKey)
1201
+ acc.firstChunkAt.delete(stepKey)
1202
+ acc.turnSteps.delete(event.data.turn)
1203
+ }
1204
+ const turnToolSet = acc.turnTools.get(event.data.turn)
1205
+ if (turnToolSet !== undefined) {
1206
+ for (const callId of turnToolSet) acc.toolStart.delete(callId)
1207
+ acc.turnTools.delete(event.data.turn)
1208
+ }
1209
+ acc.busy = false
1210
+ acc.busySince = 0
1211
+ for (const entry of appended) appendReplayEntry(acc, entry)
1212
+ return
1213
+ }
1214
+ case 'llm/retry': {
1215
+ const data = event.data
1216
+ appendReplayEntry(acc, {
1217
+ kind: 'retry',
1218
+ retryId: data.retryId,
1219
+ attempt: data.retry,
1220
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
1221
+ code: data.failure.code,
1222
+ delayMs: data.delayMs,
1223
+ state: 'running',
1224
+ })
1225
+ indexList(acc.retryIndex, data.retryId).push(acc.entries.length - 1)
1226
+ return
1227
+ }
1228
+ case 'llm/retry-started': {
1229
+ const data = event.data
1230
+ updateReplayById<RetryEntry>(acc, acc.retryIndex, data.retryId, entry => entry.retryId === data.retryId, entry => ({ ...entry, state: 'done' as const }))
1231
+ return
1232
+ }
1233
+ case 'sandbox/mode':
1234
+ acc.sandbox = event.data.mode
1235
+ return
1236
+ case 'goal/change': {
1237
+ const data = event.data
1238
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
1239
+ if (data.operation === 'clear') {
1240
+ acc.goal = undefined
1241
+ appendReplayEntry(acc, { kind: 'turn-marker', text: '◎ goal cleared' })
1242
+ return
1243
+ }
1244
+ const goal: GoalFold = {
1245
+ objective: data.goal.objective,
1246
+ phase: data.goal.phase,
1247
+ rounds: data.roundsStarted,
1248
+ max: data.goal.maxGoalRounds,
1249
+ blocked: data.goal.blockedReason?.message ?? '',
1250
+ }
1251
+ const line = data.operation === 'create'
1252
+ ? `◎ goal: ${clip(data.goal.objective)}`
1253
+ : data.operation === 'complete'
1254
+ ? '◎ goal complete'
1255
+ : data.operation === 'pause'
1256
+ ? '◎ goal paused'
1257
+ : data.operation === 'resume'
1258
+ ? '◎ goal resumed'
1259
+ : data.operation === 'block'
1260
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
1261
+ : undefined
1262
+ acc.goal = goal
1263
+ if (line !== undefined) appendReplayEntry(acc, { kind: 'turn-marker', text: line })
1264
+ return
1265
+ }
1266
+ case 'session/title':
1267
+ acc.title = event.data.title
1268
+ return
1269
+ case 'compaction/summary':
1270
+ if (acc.compactionTokens.size >= MAX_COMPACTION_SUMMARY_RESIDUE) {
1271
+ const oldest = acc.compactionTokens.keys().next().value
1272
+ if (oldest !== undefined) acc.compactionTokens.delete(oldest)
1273
+ }
1274
+ acc.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
1275
+ return
1276
+ case 'compaction/prune':
1277
+ acc.lastPruneTokens = event.data.shadowedTokenCount
1278
+ return
1279
+ case 'compaction/end': {
1280
+ const ok = event.data.error === undefined
1281
+ const tokens = acc.compactionTokens.get(event.data.compactionId) ?? acc.lastPruneTokens
1282
+ acc.compactionTokens.delete(event.data.compactionId)
1283
+ appendReplayEntry(acc, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' })
1284
+ return
1285
+ }
1286
+ case 'request/context':
1287
+ acc.stats = { ...acc.stats, contextWindow: event.data.contextWindow ?? acc.stats.contextWindow }
1288
+ return
1289
+ case 'request/header': {
1290
+ const config = event.data.header.config
1291
+ acc.model = `${config.provider}/${config.model}`
1292
+ acc.stats = {
1293
+ ...acc.stats,
1294
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
1295
+ contextSegments: {
1296
+ ...acc.stats.contextSegments,
1297
+ system: estimateTokens(event.data.header.system ?? ''),
1298
+ },
1299
+ }
1300
+ return
1301
+ }
1302
+ case 'plan/mode':
1303
+ acc.plan = event.data.active
1304
+ return
1305
+ case 'permission/preset':
1306
+ acc.permission = event.data.preset
1307
+ return
1308
+ case 'command/run': {
1309
+ const data = event.data
1310
+ appendReplayEntry(acc, {
1311
+ kind: 'command',
1312
+ commandId: data.commandId,
1313
+ name: data.name,
1314
+ args: data.args ?? '',
1315
+ state: 'running',
1316
+ summary: '',
1317
+ })
1318
+ indexList(acc.commandIndex, data.commandId).push(acc.entries.length - 1)
1319
+ return
1320
+ }
1321
+ case 'command/done': {
1322
+ const data = event.data
1323
+ const update = (candidate: CommandEntry): CommandEntry => ({
1324
+ ...candidate,
1325
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
1326
+ summary: boundContextSummary(data.text ?? ''),
1327
+ })
1328
+ updateReplayById<CommandEntry>(acc, acc.commandIndex, data.commandId, entry => entry.commandId === data.commandId, update)
1329
+ return
1330
+ }
1331
+ default:
1332
+ return
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * Materialize the accumulated fold as a `TranscriptView`, compacting any
1338
+ * retired tombstones. The anchors maps are handed through as-is (their
1339
+ * content is identical to a sequential fold's).
1340
+ *
1341
+ * @internal Test-instrumentation path; `projectEvents` is the public entry.
1342
+ */
1343
+ export function finishReplay(acc: ReplayAccumulator): TranscriptView {
1344
+ const entries: readonly TranscriptEntry[] = acc.removedCount === 0
1345
+ ? acc.entries as TranscriptEntry[]
1346
+ : acc.entries.filter((entry): entry is TranscriptEntry => entry !== undefined)
1347
+ if (acc.removedCount > 0) acc.ops += acc.entries.length
1348
+ return {
1349
+ entries,
1350
+ streaming: acc.streaming,
1351
+ streamingReasoning: acc.streamingReasoning,
1352
+ todos: acc.todos,
1353
+ busy: acc.busy,
1354
+ busySince: acc.busySince,
1355
+ model: acc.model,
1356
+ plan: acc.plan,
1357
+ permission: acc.permission,
1358
+ title: acc.title,
1359
+ sandbox: acc.sandbox,
1360
+ goal: acc.goal,
1361
+ pending: { 'next-turn': [...acc.pendingTurn], 'next-step': [...acc.pendingStep] },
1362
+ stats: acc.stats,
1363
+ anchors: {
1364
+ stepStart: acc.stepStart,
1365
+ toolStart: acc.toolStart,
1366
+ firstChunkAt: acc.firstChunkAt,
1367
+ compactionTokens: acc.compactionTokens,
1368
+ lastPruneTokens: acc.lastPruneTokens,
1369
+ turnFiles: acc.turnFiles,
1370
+ turnSteps: acc.turnSteps,
1371
+ turnTools: acc.turnTools,
1372
+ },
1373
+ }
1374
+ }
1375
+
1376
+ /**
1377
+ * Fold a replayed event history into one view.
1378
+ *
1379
+ * Folding is near-linear in the log size: the mutable replay accumulator
1380
+ * appends in place and resolves id-keyed updates through index maps, so a
1381
+ * long persisted session replays without the O(N²) copy-on-write rebuilds a
1382
+ * naive sequential fold would incur. The result is identical to folding
1383
+ * {@link projectEvent} per event in order.
1384
+ * @param events - events in `seq` order.
1385
+ * @returns the folded view.
1386
+ */
1387
+ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
1388
+ const acc = createReplayAccumulator()
1389
+ for (const event of events) replayProjectEvent(acc, event)
1390
+ return finishReplay(acc)
1391
+ }
1392
+
1393
+ /**
1394
+ * The append-only flush boundary for a transcript view: the count of entries
1395
+ * no later event can remove. Entries at or beyond this index are mutable and
1396
+ * must stay in the live tree.
1397
+ *
1398
+ * `pending` rows are excluded even though they are not a running tool/retry:
1399
+ * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
1400
+ * `user/message` retirement), and an append-only `<Static>` flush cannot
1401
+ * erase a row that vanishes from the view — the retired row would ghost on
1402
+ * screen until the next source-backed replay. Running commands join the
1403
+ * mutable boundary for the same reason in reverse: `command/done` mutates the
1404
+ * row's state/summary, so a flushed row would keep its stale running mark
1405
+ * until a resize-triggered replay. Everything else (including a completed
1406
+ * tail) is final: later events only APPEND new rows.
1407
+ * @param entries - the view's transcript entries in order.
1408
+ * @returns the count of entries safe to flush (0 for an empty transcript).
1409
+ */
1410
+ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
1411
+ for (let index = 0; index < entries.length; index++) {
1412
+ const entry = entries[index]
1413
+ if (entry.kind === 'pending') return index
1414
+ if (entry.kind === 'tool' && entry.state === 'running') return index
1415
+ if (entry.kind === 'retry' && entry.state === 'running') return index
1416
+ if (entry.kind === 'command' && entry.state === 'running') return index
1417
+ }
1418
+ return entries.length
1419
+ }