dsh-code 0.6.0 → 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.
- package/README.en.md +4 -2
- package/README.md +4 -2
- package/bin/deepseek.mjs +38 -3
- package/lib/index.mjs +1485 -906
- package/lib/startup.mjs +21 -9
- package/lib/theme-BEi4i_aN.mjs +624 -0
- package/lib/types/app.d.ts +31 -3
- package/lib/types/index.d.ts +1 -0
- package/lib/types/kernel-panels.d.ts +21 -0
- package/lib/types/mentions.d.ts +29 -12
- package/lib/types/models.d.ts +66 -0
- package/lib/types/render/animations.d.ts +175 -2
- package/lib/types/render/projection.d.ts +38 -7
- package/lib/types/render/status.d.ts +34 -13
- package/lib/types/startup.d.ts +12 -4
- package/lib/types/theme-panel.d.ts +24 -0
- package/lib/types/theme.d.ts +158 -2
- package/package.json +1 -1
- package/src/app.ts +510 -130
- package/src/index.ts +964 -900
- package/src/kernel-panels.ts +481 -419
- package/src/mentions.ts +57 -27
- package/src/models.ts +200 -66
- package/src/render/animations.ts +359 -2
- package/src/render/projection.ts +764 -655
- package/src/render/status.ts +744 -603
- package/src/startup.ts +119 -109
- package/src/theme-panel.ts +72 -0
- package/src/theme.ts +206 -70
package/src/render/projection.ts
CHANGED
|
@@ -1,655 +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
|
-
/**
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
*
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
/**
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
*
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
return {
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
return
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
//
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
+
}
|