dsh-code 0.6.1 → 0.7.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.
@@ -1,659 +1,764 @@
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
+ /** 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
+ /**
180
+ * Estimated used tokens per context content type, folded from transcript
181
+ * events via {@link estimateTokens}. The segmented context bar's composition
182
+ * source: proportions across types are meaningful, absolute values are not
183
+ * (they never touch billing or the reported `lastPromptTokens`).
184
+ */
185
+ export interface ContextSegments {
186
+ /** Rendered system-prompt text (latest `request/header`) plus injected-context notices. */
187
+ system: number
188
+ /** Direct human prompts (durable `user/message` rows). */
189
+ prompt: number
190
+ /** Assistant text blocks (visible replies). */
191
+ assistant: number
192
+ /** Assistant reasoning blocks (hidden thinking). */
193
+ thinking: number
194
+ /** Tool call arguments plus result text. */
195
+ tools: number
196
+ }
197
+
198
+ /** Window-scoped figures the status line shows; timing uses event timestamps. */
199
+ export interface TranscriptStats {
200
+ /** Durable turns opened (`turn/start` events). */
201
+ turns: number
202
+ /** Model requests made (`step/start` events). */
203
+ steps: number
204
+ /** Summed model wall time: `step/start` → `assistant/message`, in ms. */
205
+ llmMs: number
206
+ /** Summed tool wall time: `tool/call` → `tool/result`, in ms. */
207
+ toolMs: number
208
+ /** Cumulative token accounting; input stays 0 until a report lands. */
209
+ usage: UsageTotals
210
+ /** Prompt-side size of the most recent reported request (context pressure). */
211
+ lastPromptTokens: number
212
+ /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
213
+ contextWindow: number
214
+ /** Estimated used tokens per content type (the segmented bar's composition). */
215
+ contextSegments: ContextSegments
216
+ /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
217
+ ttftMs: number
218
+ /** Steps that produced a first chunk (the TTFT average's denominator). */
219
+ ttftSteps: number
220
+ /** Summed decode spans: first chunk → `assistant/message`, in ms. */
221
+ decodeMs: number
222
+ /** Completion tokens over timed decode spans (the tok/s numerator). */
223
+ decodeTokens: number
224
+ /**
225
+ * Adapter-owned reasoning effort of the latest `request/header` config —
226
+ * the EFFECTIVE effort the session actually uses (a materialized model
227
+ * default is included, exactly as the adapter resolved it). Empty when the
228
+ * header carried none (provider-default behavior). The status line appends
229
+ * it to the model segment as `provider/model@effort`.
230
+ */
231
+ reasoningEffort: string
232
+ }
233
+
234
+ /** The complete TUI transcript view for one session. */
235
+ export interface TranscriptView {
236
+ /** Settled entries in log order. */
237
+ entries: readonly TranscriptEntry[]
238
+ /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
239
+ streaming: string
240
+ /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
241
+ streamingReasoning: string
242
+ /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
243
+ todos: readonly TodoItem[]
244
+ /** True while a durable turn is open (`turn/start` … `turn/end`). */
245
+ busy: boolean
246
+ /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
247
+ busySince: number
248
+ /** Figures the status line renders. */
249
+ stats: TranscriptStats
250
+ /**
251
+ * The `provider/model` pair of the last `request/header` snapshot — the
252
+ * session's own model record, which a resumed TUI prefers over the
253
+ * deployment default (mirrors the web host's resume selection order).
254
+ * Empty before the session's first request.
255
+ */
256
+ model: string
257
+ /** Plan mode state folded from the last `plan/mode` event. */
258
+ plan: boolean
259
+ /** Active permission preset folded from the last `permission/preset` event, empty before one. */
260
+ permission: string
261
+ /** Latest session title folded from the last `session/title` event, empty before one. */
262
+ title: string
263
+ /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
264
+ sandbox: string
265
+ /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
266
+ goal: GoalFold | undefined
267
+ /**
268
+ * Ordered live message ids per inbox target, mirrored from
269
+ * `agent/inbox/spliced` exactly like the upstream Inbox projection — the
270
+ * coordinates later removals resolve against.
271
+ */
272
+ pending: { 'next-turn': readonly string[]; 'next-step': readonly string[] }
273
+ /**
274
+ * Fold-internal timing anchors, never rendered: open step and tool-call
275
+ * start timestamps the next `assistant/message` / `tool/result` resolves
276
+ * against. Keyed `turn:step` and by call id.
277
+ */
278
+ 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>> }
279
+ }
280
+
281
+ /** Join the text blocks of a content list; non-text blocks contribute nothing. */
282
+ function textOf(content: readonly ContentBlock[]): string {
283
+ return content.filter(block => block.type === 'text').map(block => block.text).join('')
284
+ }
285
+
286
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
287
+ function reasoningOf(content: readonly ContentBlock[]): string {
288
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
289
+ }
290
+
291
+ /**
292
+ * Rough token estimate for the segmented context bar (pi-nano-context's ~4
293
+ * chars/token heuristic, CJK-aware so a Chinese prompt is not quartered):
294
+ * CJK/wide chars cost ~1 token each, ASCII ~4 chars per token. Estimates
295
+ * drive bar PROPORTIONS, never billing, so precision is not required.
296
+ * @param text - the text to estimate.
297
+ * @returns an integer token estimate, 0 for empty text.
298
+ */
299
+ function estimateTokens(text: string): number {
300
+ let wide = 0
301
+ let narrow = 0
302
+ for (const char of text) {
303
+ if ((char.codePointAt(0) ?? 0) > 0x2e7f) wide += 1
304
+ else narrow += 1
305
+ }
306
+ return wide + Math.ceil(narrow / 4)
307
+ }
308
+
309
+ /** A fresh, empty transcript view. */
310
+ export function createTranscriptView(): TranscriptView {
311
+ return {
312
+ entries: [],
313
+ streaming: '',
314
+ streamingReasoning: '',
315
+ todos: [],
316
+ busy: false,
317
+ busySince: 0,
318
+ model: '',
319
+ plan: false,
320
+ permission: '',
321
+ title: '',
322
+ sandbox: '',
323
+ goal: undefined,
324
+ pending: { 'next-turn': [], 'next-step': [] },
325
+ 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: '' },
326
+ anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map() },
327
+ }
328
+ }
329
+
330
+ /** Full prompt text of a queued message (identical to the durable user row it retires into). */
331
+ function pendingText(content: readonly ContentBlock[]): string {
332
+ return textOf(content)
333
+ }
334
+
335
+ /**
336
+ * Fold one session event into an updated view (copy-on-write).
337
+ * @param view - the view before the event.
338
+ * @param event - one durable session event from `session/event` or the log.
339
+ * @returns the view after the event; the input view is never mutated.
340
+ */
341
+ export function projectEvent(view: TranscriptView, event: SessionEvent): TranscriptView {
342
+ switch (event.type) {
343
+ case 'user/message': {
344
+ // A queued row retires when its durable user message lands (the agent
345
+ // claims the inbox and logs the same message identity) — the transient
346
+ // steering/queued preview yields to the real transcript entry.
347
+ const message = event.data
348
+ let entries = view.entries
349
+ let pending = view.pending
350
+ for (const target of ['next-turn', 'next-step'] as const) {
351
+ const index = pending[target].indexOf(message.id)
352
+ if (index < 0) continue
353
+ pending = { ...pending, [target]: pending[target].filter((_, i) => i !== index) }
354
+ entries = entries.filter(entry => !(entry.kind === 'pending' && entry.messageId === message.id))
355
+ }
356
+ // Injected context (plugin/model-continuation sources) stays collapsed
357
+ // to a bounded notice row, exactly like collapsed transcript context
358
+ // elsewhere in the product; only direct human prompts render in full.
359
+ const text = textOf(message.content)
360
+ if (message.source.kind === 'user') {
361
+ return {
362
+ ...view,
363
+ pending,
364
+ entries: [...entries, { kind: 'user', text, notice: false }],
365
+ stats: {
366
+ ...view.stats,
367
+ contextSegments: {
368
+ ...view.stats.contextSegments,
369
+ prompt: view.stats.contextSegments.prompt + estimateTokens(text),
370
+ },
371
+ },
372
+ }
373
+ }
374
+ const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
375
+ ? message.source.summary
376
+ : message.source.kind
377
+ const summary = boundContextSummary(notice)
378
+ return {
379
+ ...view,
380
+ pending,
381
+ entries: [...entries, { kind: 'user', text: summary, notice: true }],
382
+ stats: {
383
+ ...view.stats,
384
+ contextSegments: {
385
+ ...view.stats.contextSegments,
386
+ system: view.stats.contextSegments.system + estimateTokens(summary),
387
+ },
388
+ },
389
+ }
390
+ }
391
+ case 'agent/inbox/spliced': {
392
+ // The durable inbox mutation (web queue-mirror contract, event-sourced):
393
+ // removals drop the projected rows at their inbox coordinates, inserted
394
+ // messages gain a pending row at their log position.
395
+ const { target, start, removedCount = 0, inserted } = event.data
396
+ const ids = view.pending[target]
397
+ const removed = ids.slice(start, start + removedCount)
398
+ const nextIds = [
399
+ ...ids.slice(0, start),
400
+ ...ids.slice(start + removedCount),
401
+ ...inserted.map(message => message.id),
402
+ ]
403
+ let entries = view.entries
404
+ if (removed.length > 0) {
405
+ const removedSet = new Set(removed)
406
+ entries = entries.filter(entry =>
407
+ !(entry.kind === 'pending' && entry.target === target && removedSet.has(entry.messageId)))
408
+ }
409
+ for (const message of inserted) {
410
+ entries = [...entries, {
411
+ kind: 'pending',
412
+ messageId: message.id,
413
+ target,
414
+ text: pendingText(message.content),
415
+ }]
416
+ }
417
+ return { ...view, entries, pending: { ...view.pending, [target]: nextIds } }
418
+ }
419
+ case 'assistant/chunk': {
420
+ const chunk = event.data.chunk
421
+ // First-token latency: the first non-empty delta of a step anchors the
422
+ // TTFT (empty keep-alive deltas do not count as tokens).
423
+ const key = `${event.data.turn}:${event.data.step}`
424
+ const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
425
+ let stats = view.stats
426
+ if (delta !== '' && !view.anchors.firstChunkAt.has(key)) {
427
+ view.anchors.firstChunkAt.set(key, event.time)
428
+ const started = view.anchors.stepStart.get(key)
429
+ if (started !== undefined) {
430
+ stats = {
431
+ ...stats,
432
+ ttftMs: stats.ttftMs + Math.max(0, event.time - started),
433
+ ttftSteps: stats.ttftSteps + 1,
434
+ }
435
+ }
436
+ }
437
+ if (chunk.type === 'text-delta') {
438
+ return { ...view, streaming: appendStreamingTail(view.streaming, chunk.text), stats }
439
+ }
440
+ if (chunk.type === 'reasoning-delta') {
441
+ return { ...view, streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text), stats }
442
+ }
443
+ return view
444
+ }
445
+ case 'assistant/message': {
446
+ // The assembled message is authoritative; drop the streamed buffers.
447
+ const key = `${event.data.turn}:${event.data.step}`
448
+ const started = view.anchors.stepStart.get(key)
449
+ view.anchors.stepStart.delete(key)
450
+ const firstChunk = view.anchors.firstChunkAt.get(key)
451
+ view.anchors.firstChunkAt.delete(key)
452
+ const usage = event.data.usage
453
+ const totals = view.stats.usage
454
+ const text = textOf(event.data.message.content)
455
+ const reasoning = reasoningOf(event.data.message.content)
456
+ return {
457
+ ...view,
458
+ streaming: '',
459
+ streamingReasoning: '',
460
+ entries: [...view.entries, {
461
+ kind: 'assistant',
462
+ text,
463
+ reasoning,
464
+ }],
465
+ stats: {
466
+ ...view.stats,
467
+ llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
468
+ usage: usage === undefined ? totals : {
469
+ inputTokens: totals.inputTokens + usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
470
+ outputTokens: totals.outputTokens + usage.outputTokens,
471
+ cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
472
+ },
473
+ lastPromptTokens: usage === undefined ? view.stats.lastPromptTokens
474
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
475
+ // Decode span and its tokens pair up: an un-timed step (no first
476
+ // chunk landed) contributes neither, so the rate stays honest.
477
+ decodeMs: view.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
478
+ decodeTokens: view.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
479
+ contextSegments: {
480
+ ...view.stats.contextSegments,
481
+ thinking: view.stats.contextSegments.thinking + estimateTokens(reasoning),
482
+ assistant: view.stats.contextSegments.assistant + estimateTokens(text),
483
+ },
484
+ },
485
+ }
486
+ }
487
+ case 'tool/call': {
488
+ const data = event.data
489
+ view.anchors.toolStart.set(data.callId, event.time)
490
+ return {
491
+ ...view,
492
+ entries: [...view.entries, {
493
+ kind: 'tool',
494
+ callId: data.callId,
495
+ name: data.name,
496
+ arguments: data.arguments,
497
+ preview: toolArgumentsPreview(data.arguments, data.name),
498
+ state: 'running',
499
+ summary: '',
500
+ detail: undefined,
501
+ }],
502
+ stats: {
503
+ ...view.stats,
504
+ contextSegments: {
505
+ ...view.stats.contextSegments,
506
+ tools: view.stats.contextSegments.tools
507
+ + (typeof data.arguments === 'string' ? estimateTokens(data.arguments) : 0),
508
+ },
509
+ },
510
+ }
511
+ }
512
+ case 'tool/result': {
513
+ const block = event.data.message.content[0]
514
+ const started = view.anchors.toolStart.get(block.toolCallId)
515
+ view.anchors.toolStart.delete(block.toolCallId)
516
+ const rawText = textOf(block.content)
517
+ const summary = boundContextSummary(rawText)
518
+ // The verbose expansion self-serves from the persisted presentation
519
+ // metadata (diffs, read windows, web sources) with the bounded raw text
520
+ // as the universal fallback — the capable-UI degradation ladder.
521
+ const detail = toolResultDetail(event.data.meta, rawText)
522
+ // Turn-tail deliverables: a diff-bearing mutation records its paths.
523
+ if (detail?.kind === 'diff') {
524
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
525
+ for (const diff of detail.diffs) set.add(diff.path)
526
+ view.anchors.turnFiles.set(event.data.turn, set)
527
+ }
528
+ const entries = view.entries.map((entry) => {
529
+ if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
530
+ return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
531
+ })
532
+ return {
533
+ ...view,
534
+ entries,
535
+ stats: {
536
+ ...view.stats,
537
+ toolMs: view.stats.toolMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
538
+ contextSegments: {
539
+ ...view.stats.contextSegments,
540
+ tools: view.stats.contextSegments.tools + estimateTokens(rawText),
541
+ },
542
+ },
543
+ }
544
+ }
545
+ case 'todo/write':
546
+ return { ...view, todos: event.data.todos }
547
+ case 'turn/start':
548
+ // The web todo projection clears on turn/start: a fresh turn's first
549
+ // write is the authoritative list, and a stale snapshot must not linger
550
+ // through a turn that has not written one yet.
551
+ return {
552
+ ...view,
553
+ busy: true,
554
+ busySince: view.busy ? view.busySince : event.time,
555
+ todos: [],
556
+ stats: { ...view.stats, turns: view.stats.turns + 1 },
557
+ }
558
+ case 'step/start':
559
+ view.anchors.stepStart.set(`${event.data.turn}:${event.data.step}`, event.time)
560
+ return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
561
+ case 'turn/end': {
562
+ const reason = event.data.reason
563
+ const appended: TranscriptEntry[] = []
564
+ if (reason.kind === 'error') {
565
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}` })
566
+ } else {
567
+ // Non-error outcomes deserve their own durable row (the web renders
568
+ // distinct max-tokens / abort / interruption nodes); `completed` stays
569
+ // silent so an ordinary turn never grows a marker.
570
+ const marker = reason.kind === 'aborted'
571
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
572
+ : reason.kind === 'max-tokens'
573
+ ? 'turn hit the output-token ceiling (max-tokens)'
574
+ : reason.kind === 'blocked'
575
+ ? 'turn ended blocked'
576
+ : reason.kind === 'interrupted'
577
+ ? 'turn was interrupted by a restart'
578
+ : undefined
579
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
580
+ }
581
+ // Deliverables ride the turn tail (the web's turnTail chips): the
582
+ // turn's mutated files flush as one bounded row, then the set resets.
583
+ const files = view.anchors.turnFiles.get(event.data.turn)
584
+ view.anchors.turnFiles.delete(event.data.turn)
585
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
586
+ if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
587
+ return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
588
+ }
589
+ case 'llm/retry': {
590
+ const data = event.data
591
+ return {
592
+ ...view,
593
+ entries: [...view.entries, {
594
+ kind: 'retry',
595
+ retryId: data.retryId,
596
+ attempt: data.retry,
597
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
598
+ code: data.failure.code,
599
+ delayMs: data.delayMs,
600
+ state: 'running',
601
+ }],
602
+ }
603
+ }
604
+ case 'llm/retry-started': {
605
+ const data = event.data
606
+ const entries = view.entries.map((entry) => {
607
+ if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
608
+ return { ...entry, state: 'done' as const }
609
+ })
610
+ return { ...view, entries }
611
+ }
612
+ case 'sandbox/mode':
613
+ // Log-only override switch; last write wins for the status badge.
614
+ return { ...view, sandbox: event.data.mode }
615
+ case 'goal/change': {
616
+ const data = event.data
617
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
618
+ if (data.operation === 'clear') {
619
+ return {
620
+ ...view,
621
+ goal: undefined,
622
+ entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
623
+ }
624
+ }
625
+ const goal: GoalFold = {
626
+ objective: data.goal.objective,
627
+ phase: data.goal.phase,
628
+ rounds: data.roundsStarted,
629
+ max: data.goal.maxGoalRounds,
630
+ blocked: data.goal.blockedReason?.message ?? '',
631
+ }
632
+ const line = data.operation === 'create'
633
+ ? `◎ goal: ${clip(data.goal.objective)}`
634
+ : data.operation === 'complete'
635
+ ? '◎ goal complete'
636
+ : data.operation === 'pause'
637
+ ? '◎ goal paused'
638
+ : data.operation === 'resume'
639
+ ? '◎ goal resumed'
640
+ : data.operation === 'block'
641
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
642
+ : undefined
643
+ return {
644
+ ...view,
645
+ goal,
646
+ entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
647
+ }
648
+ }
649
+ case 'session/title':
650
+ // Latest-wins title snapshot, log-only; the status line prefers it.
651
+ return { ...view, title: event.data.title }
652
+ case 'compaction/summary':
653
+ // Remember the shadow price so the matching `compaction/end` row can
654
+ // state what the compaction reclaimed.
655
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
656
+ return view
657
+ case 'compaction/prune':
658
+ // A model-free prune carries no compaction id; its price serves the next
659
+ // `compaction/end` that cannot find a summary price.
660
+ return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
661
+ case 'compaction/end': {
662
+ const ok = event.data.error === undefined
663
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
664
+ view.anchors.compactionTokens.delete(event.data.compactionId)
665
+ return {
666
+ ...view,
667
+ entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
668
+ }
669
+ }
670
+ case 'request/context':
671
+ // Route capacity, logged only when it changes; last one wins.
672
+ return {
673
+ ...view,
674
+ stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
675
+ }
676
+ case 'request/header': {
677
+ // The session's own model record: the latest snapshot's provider/model
678
+ // pair, exactly what a resumed TUI restores as the selection, plus the
679
+ // effective reasoning effort that snapshot carried (the adapter may
680
+ // materialize the model default, which is what the status line shows).
681
+ // The snapshot's rendered system prompt is the current system slot, so
682
+ // it REPLACES the estimate (an older system prompt is not re-sent).
683
+ const config = event.data.header.config
684
+ return {
685
+ ...view,
686
+ model: `${config.provider}/${config.model}`,
687
+ stats: {
688
+ ...view.stats,
689
+ reasoningEffort: config.reasoningEffort === undefined ? '' : String(config.reasoningEffort),
690
+ contextSegments: {
691
+ ...view.stats.contextSegments,
692
+ system: estimateTokens(event.data.header.system ?? ''),
693
+ },
694
+ },
695
+ }
696
+ }
697
+ case 'plan/mode':
698
+ // Whole-value replace; the last one wins (upstream fold semantics).
699
+ return { ...view, plan: event.data.active }
700
+ case 'permission/preset':
701
+ return { ...view, permission: event.data.preset }
702
+ case 'command/run': {
703
+ const data = event.data
704
+ return {
705
+ ...view,
706
+ entries: [...view.entries, {
707
+ kind: 'command',
708
+ commandId: data.commandId,
709
+ name: data.name,
710
+ args: data.args ?? '',
711
+ state: 'running',
712
+ summary: '',
713
+ }],
714
+ }
715
+ }
716
+ case 'command/done': {
717
+ const data = event.data
718
+ const entries = view.entries.map((entry) => {
719
+ if (entry.kind !== 'command' || entry.commandId !== data.commandId) return entry
720
+ return {
721
+ ...entry,
722
+ state: data.kind === 'success' ? 'done' as const : 'error' as const,
723
+ summary: boundContextSummary(data.text ?? ''),
724
+ }
725
+ })
726
+ return { ...view, entries }
727
+ }
728
+ default:
729
+ return view
730
+ }
731
+ }
732
+
733
+ /**
734
+ * Fold a replayed event history into one view.
735
+ * @param events - events in `seq` order.
736
+ * @returns the folded view.
737
+ */
738
+ export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
739
+ return events.reduce(projectEvent, createTranscriptView())
740
+ }
741
+
742
+ /**
743
+ * The append-only flush boundary for a transcript view: the count of entries
744
+ * no later event can remove. Entries at or beyond this index are mutable and
745
+ * must stay in the live tree.
746
+ *
747
+ * `pending` rows are excluded even though they are not a running tool/retry:
748
+ * the inbox claims or cancels them durably (`agent/inbox/spliced` removals,
749
+ * `user/message` retirement), and an append-only `<Static>` flush cannot
750
+ * erase a row that vanishes from the view — the retired row would ghost on
751
+ * screen until the next source-backed replay. Everything else (including a
752
+ * completed tail) is final: later events only APPEND new rows.
753
+ * @param entries - the view's transcript entries in order.
754
+ * @returns the count of entries safe to flush (0 for an empty transcript).
755
+ */
756
+ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
757
+ for (let index = 0; index < entries.length; index++) {
758
+ const entry = entries[index]
759
+ if (entry.kind === 'pending') return index
760
+ if (entry.kind === 'tool' && entry.state === 'running') return index
761
+ if (entry.kind === 'retry' && entry.state === 'running') return index
762
+ }
763
+ return entries.length
764
+ }