dsh-code 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +201 -55
  2. package/README.zh.md +204 -61
  3. package/bin/deepseek.mjs +70 -0
  4. package/cordis.patch.yml +62 -6
  5. package/lib/index.mjs +4073 -1802
  6. package/lib/startup.mjs +34 -17
  7. package/lib/types/app.d.ts +23 -22
  8. package/lib/types/commands.d.ts +2 -0
  9. package/lib/types/index.d.ts +5 -5
  10. package/lib/types/internals.d.ts +2 -0
  11. package/lib/types/kernel-panels.d.ts +23 -0
  12. package/lib/types/plugin-inventory.d.ts +11 -0
  13. package/lib/types/presets.d.ts +32 -0
  14. package/lib/types/render/export.d.ts +15 -0
  15. package/lib/types/render/inspector.d.ts +34 -0
  16. package/lib/types/render/lines.d.ts +31 -0
  17. package/lib/types/render/projection.d.ts +95 -3
  18. package/lib/types/render/status.d.ts +19 -0
  19. package/lib/types/render/text.d.ts +27 -0
  20. package/lib/types/render/tool-detail.d.ts +92 -0
  21. package/lib/types/session-directory.d.ts +54 -0
  22. package/lib/types/session-switch.d.ts +17 -0
  23. package/lib/types/skills.d.ts +2 -0
  24. package/lib/types/startup.d.ts +11 -1
  25. package/lib/types/store.d.ts +2 -0
  26. package/package.json +16 -1
  27. package/src/app.ts +1367 -277
  28. package/src/commands.ts +15 -1
  29. package/src/index.ts +373 -128
  30. package/src/internals.ts +5 -0
  31. package/src/kernel-panels.ts +254 -0
  32. package/src/plugin-inventory.ts +47 -0
  33. package/src/presets.ts +64 -0
  34. package/src/render/export.ts +81 -0
  35. package/src/render/inspector.ts +88 -0
  36. package/src/render/lines.ts +207 -0
  37. package/src/render/markdown.ts +15 -1
  38. package/src/render/projection.ts +279 -16
  39. package/src/render/status.ts +51 -1
  40. package/src/render/text.ts +107 -0
  41. package/src/render/tool-detail.ts +197 -0
  42. package/src/session-directory.ts +102 -0
  43. package/src/session-switch.ts +58 -0
  44. package/src/skills.ts +20 -7
  45. package/src/startup.ts +38 -20
  46. package/src/store.ts +8 -0
@@ -0,0 +1,207 @@
1
+ /** Width-safe styled physical rows for bounded terminal panels. */
2
+
3
+ import type { TranscriptEntry } from './projection.ts'
4
+ import type { ToolDetail } from './tool-detail.ts'
5
+ import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
6
+ import { formatTokens } from './status.ts'
7
+ import { displayText } from './text.ts'
8
+
9
+ /** Presentation classes mapped to Ink colors by the app boundary. */
10
+ export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic'
11
+
12
+ /** One styled run within a physical terminal row. */
13
+ export interface StyledSegment {
14
+ text: string
15
+ style: LineStyle
16
+ }
17
+
18
+ /** One row guaranteed not to exceed the requested terminal width. */
19
+ export interface StyledLine {
20
+ segments: readonly StyledSegment[]
21
+ }
22
+
23
+ /** Construct one segment without leaking mutable objects into cached rows. */
24
+ export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
25
+ return { text, style }
26
+ }
27
+
28
+ /** Append a character while merging adjacent runs with the same style. */
29
+ function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
30
+ const previous = target[target.length - 1]
31
+ if (previous?.style === style) {
32
+ target[target.length - 1] = { text: previous.text + text, style }
33
+ return
34
+ }
35
+ target.push({ text, style })
36
+ }
37
+
38
+ /**
39
+ * Sanitize and hard-wrap styled content into exact physical rows.
40
+ * Tabs become two visible spaces because terminal tab stops are contextual
41
+ * and therefore cannot participate in a deterministic row budget.
42
+ */
43
+ export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
44
+ const width = Math.max(1, Math.floor(columns))
45
+ const lines: StyledLine[] = []
46
+ let current: StyledSegment[] = []
47
+ let used = 0
48
+ const flush = (): void => {
49
+ lines.push({ segments: current })
50
+ current = []
51
+ used = 0
52
+ }
53
+
54
+ for (const segment of segments) {
55
+ const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
56
+ for (const char of safe) {
57
+ if (char === '\n') {
58
+ flush()
59
+ continue
60
+ }
61
+ const cells = visibleColumns(char)
62
+ if (used > 0 && used + cells > width) flush()
63
+ appendSegment(current, char, segment.style)
64
+ used += cells
65
+ }
66
+ }
67
+ if (current.length > 0 || lines.length === 0) flush()
68
+ return lines
69
+ }
70
+
71
+ /** Plain/dim text convenience over {@link styledLines}. */
72
+ export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
73
+ return styledLines([lineSegment(text, style)], columns)
74
+ }
75
+
76
+ /** Markdown rows re-hardened so a single long word cannot escape the budget. */
77
+ export function markdownLines(text: string, columns: number): readonly StyledLine[] {
78
+ const width = Math.max(1, Math.floor(columns))
79
+ const parsed = renderMarkdown(displayText(text), Math.max(10, width))
80
+ return parsed.flatMap(line => styledLines(
81
+ line.segments.map(segment => lineSegment(segment.text, segment.style)),
82
+ width,
83
+ ))
84
+ }
85
+
86
+ /** Expanded structured tool detail as scrollable, width-safe rows. */
87
+ function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
88
+ switch (detail.kind) {
89
+ case 'diff':
90
+ return detail.diffs.flatMap(diff => [
91
+ ...styledLines([
92
+ lineSegment(' ── ', 'dim'),
93
+ lineSegment(diff.path, 'dim'),
94
+ lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
95
+ ], columns),
96
+ ...diff.lines.flatMap(line => styledLines([
97
+ lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
98
+ ], columns)),
99
+ ])
100
+ case 'read':
101
+ return [
102
+ ...textLines(
103
+ ` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`,
104
+ columns,
105
+ 'dim',
106
+ ),
107
+ ...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
108
+ ]
109
+ case 'web-search':
110
+ return [
111
+ ...detail.sources.flatMap(source => [
112
+ ...styledLines([
113
+ lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
114
+ lineSegment(` - ${source.url}`, 'dim'),
115
+ ], columns),
116
+ ...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
117
+ ]),
118
+ ...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
119
+ ]
120
+ case 'web-fetch':
121
+ return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
122
+ case 'raw':
123
+ return [
124
+ ...textLines(` ${detail.text}`, columns, 'dim'),
125
+ ...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
126
+ ]
127
+ default: {
128
+ const exhaustive: never = detail
129
+ return exhaustive
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Convert one durable transcript entry to its complete scrollable row model.
136
+ * The source entry stays intact; only the caller's visible slice is rendered.
137
+ */
138
+ export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
139
+ const width = Math.max(1, Math.floor(columns))
140
+ switch (entry.kind) {
141
+ case 'user':
142
+ return styledLines([
143
+ lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
144
+ lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
145
+ ], width)
146
+ case 'assistant':
147
+ return [
148
+ ...(entry.reasoning === ''
149
+ ? []
150
+ : styledLines([
151
+ lineSegment(' ✻ ', 'dimItalic'),
152
+ lineSegment(entry.reasoning, 'dimItalic'),
153
+ ], width)),
154
+ ...markdownLines(entry.text, width),
155
+ ]
156
+ case 'tool': {
157
+ const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
158
+ const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
159
+ return [
160
+ ...styledLines([
161
+ lineSegment(`${mark} `, markStyle),
162
+ lineSegment(entry.name, 'brand'),
163
+ lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
164
+ ], width),
165
+ ...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
166
+ ...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
167
+ ]
168
+ }
169
+ case 'command': {
170
+ const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
171
+ const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
172
+ return [
173
+ ...styledLines([
174
+ lineSegment(`${mark} `, markStyle),
175
+ lineSegment(`/${entry.name}`, 'brand'),
176
+ lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
177
+ ], width),
178
+ ...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
179
+ ]
180
+ }
181
+ case 'turn-marker':
182
+ return textLines(` ⏹ ${entry.text}`, width, 'dim')
183
+ case 'compaction':
184
+ return textLines(entry.ok
185
+ ? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
186
+ : ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
187
+ case 'retry':
188
+ return textLines(
189
+ ` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
190
+ width,
191
+ entry.state === 'running' ? 'warn' : 'dim',
192
+ )
193
+ case 'files':
194
+ return entry.paths.length === 0
195
+ ? textLines(' ⎄ no changed files', width, 'dim')
196
+ : [
197
+ ...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
198
+ ...entry.paths.flatMap(path => textLines(` ${path}`, width, 'dim')),
199
+ ]
200
+ case 'error':
201
+ return textLines(entry.text, width, 'error')
202
+ default: {
203
+ const exhaustive: never = entry
204
+ return exhaustive
205
+ }
206
+ }
207
+ }
@@ -166,11 +166,18 @@ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
166
166
  /** Render markdown text into styled lines of at most `width` columns. */
167
167
  export function renderMarkdown(text: string, width: number): readonly MdLine[] {
168
168
  const lines: MdLine[] = []
169
+ let separatorPending = false
169
170
  const push = (segments: readonly MdSegment[]): void => {
170
171
  for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
171
172
  lines.push({ segments: merge(wrapped) })
172
173
  }
173
174
  }
175
+ const startBlock = (): void => {
176
+ if (separatorPending && lines.length > 0 && lines.at(-1)?.segments.length !== 0) {
177
+ lines.push({ segments: [] })
178
+ }
179
+ separatorPending = false
180
+ }
174
181
  const raw = text.replaceAll('\r', '')
175
182
  const source = raw.split('\n')
176
183
  let index = 0
@@ -178,6 +185,14 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
178
185
  const line = source[index] ?? ''
179
186
  index += 1
180
187
 
188
+ // Preserve one deliberate row between source blocks. Repeated blank
189
+ // lines collapse, and leading/trailing whitespace never grows output.
190
+ if (line.trim() === '') {
191
+ separatorPending = lines.length > 0
192
+ continue
193
+ }
194
+ startBlock()
195
+
181
196
  // Fenced code block: verbatim lines in code style, language label first.
182
197
  const fence = FENCE.exec(line)
183
198
  if (fence !== null) {
@@ -191,7 +206,6 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
191
206
  continue
192
207
  }
193
208
 
194
- if (line.trim() === '') continue
195
209
  if (RULE.test(line.trim())) {
196
210
  push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
197
211
  continue
@@ -9,13 +9,29 @@
9
9
 
10
10
  import { boundContextSummary, type ContentBlock } from '@deepseek-ai/dsh-llm'
11
11
  import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
12
- // Type-only imports merge the plugin-owned SessionEventMap variants (command/*
13
- // from dsh-commands, plan/mode, permission/preset) into the union this reducer
14
- // switches on.
12
+ // Type-only imports merge the plugin-owned SessionEventMap variants
13
+ // (command/*, compaction/*, goal/change, llm/retry*, plan/mode,
14
+ // permission/preset, sandbox/mode, session/title) into the union this
15
+ // reducer switches on.
15
16
  import type {} from '@deepseek-ai/dsh-commands'
17
+ import type {} from '@deepseek-ai/dsh-compaction'
18
+ import type {} from '@deepseek-ai/dsh-goal'
19
+ import type {} from '@deepseek-ai/dsh-llm-retry'
16
20
  import type {} from '@deepseek-ai/dsh-plan-mode'
17
21
  import type {} from '@deepseek-ai/dsh-permission-presets'
22
+ import type {} from '@deepseek-ai/dsh-sandbox-policy'
23
+ import type {} from '@deepseek-ai/dsh-session-title'
18
24
  import { toolArgumentsPreview } from './tool-preview.ts'
25
+ import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
26
+
27
+ /** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
28
+ const MAX_STREAMING_CHARS = 65_536
29
+
30
+ /** Append one delta without retaining an unbounded duplicate of the live reply. */
31
+ function appendStreamingTail(current: string, delta: string): string {
32
+ const next = current + delta
33
+ return next.length <= MAX_STREAMING_CHARS ? next : next.slice(-MAX_STREAMING_CHARS)
34
+ }
19
35
 
20
36
  /** One user prompt line. */
21
37
  export interface UserEntry {
@@ -51,6 +67,12 @@ export interface ToolEntry {
51
67
  state: 'running' | 'done' | 'error'
52
68
  /** Bounded first text block of the result, empty until it lands. */
53
69
  summary: string
70
+ /**
71
+ * Bounded expansion payload for the verbose transcript (Ctrl+O), derived
72
+ * from the tool's persisted presentation metadata; undefined until the
73
+ * result lands and only when something renderable exists.
74
+ */
75
+ detail: ToolDetail | undefined
54
76
  }
55
77
 
56
78
  /** One slash-command execution dispatched through `ctx.commands`. */
@@ -75,8 +97,62 @@ export interface ErrorEntry {
75
97
  text: string
76
98
  }
77
99
 
100
+ /** One non-error turn outcome surfaced from `turn/end`. */
101
+ export interface TurnMarkerEntry {
102
+ kind: 'turn-marker'
103
+ /** Human-readable outcome line, dim-rendered. */
104
+ text: string
105
+ }
106
+
107
+ /** One completed compaction lifecycle surfaced from `compaction/end`. */
108
+ export interface CompactionEntry {
109
+ kind: 'compaction'
110
+ /** True when the compaction completed, false when it failed. */
111
+ ok: boolean
112
+ /** Heuristic tokens shadowed by the compaction (summary or prune price). */
113
+ tokens: number
114
+ /** Failure text when `ok` is false, empty otherwise. */
115
+ error: string
116
+ }
117
+
118
+ /** One provider-routed model-request retry (the `llm/retry` pair). */
119
+ export interface RetryEntry {
120
+ kind: 'retry'
121
+ /** Correlation id shared with the matching `llm/retry-started`. */
122
+ retryId: string
123
+ /** Attempt ordinal and its cap. */
124
+ attempt: number
125
+ max: number
126
+ /** Failure code that triggered the retry. */
127
+ code: string
128
+ /** Backoff wait before the next attempt, in ms. */
129
+ delayMs: number
130
+ /** `running` while the backoff waits, `done` once the attempt started. */
131
+ state: 'running' | 'done'
132
+ }
133
+
134
+ /** Turn-tail deliverables: files mutated by the turn's diff-bearing tools. */
135
+ export interface FilesEntry {
136
+ kind: 'files'
137
+ /** Unique mutated paths in call order, bounded. */
138
+ paths: readonly string[]
139
+ }
140
+
78
141
  /** Ordered transcript items the renderer draws. */
79
- export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry
142
+ export type TranscriptEntry = UserEntry | AssistantEntry | ToolEntry | CommandEntry | ErrorEntry | TurnMarkerEntry | CompactionEntry | RetryEntry | FilesEntry
143
+
144
+ /** The live goal the status line badges, folded from `goal/change`. */
145
+ export interface GoalFold {
146
+ /** Human-requested completion objective. */
147
+ objective: string
148
+ /** Durable lifecycle phase. */
149
+ phase: 'active' | 'paused' | 'blocked' | 'complete'
150
+ /** Highest admitted continuation round and its cap. */
151
+ rounds: number
152
+ max: number
153
+ /** Blocked explanation, empty outside the blocked phase. */
154
+ blocked: string
155
+ }
80
156
 
81
157
  /** Cumulative token accounting folded from `assistant/message` usage reports. */
82
158
  export interface UsageTotals {
@@ -100,20 +176,34 @@ export interface TranscriptStats {
100
176
  toolMs: number
101
177
  /** Cumulative token accounting; input stays 0 until a report lands. */
102
178
  usage: UsageTotals
179
+ /** Prompt-side size of the most recent reported request (context pressure). */
180
+ lastPromptTokens: number
181
+ /** Newest advertised route capacity, 0 when no adapter ever advertised one. */
182
+ contextWindow: number
183
+ /** Summed first-token waits: `step/start` → first non-empty chunk, in ms. */
184
+ ttftMs: number
185
+ /** Steps that produced a first chunk (the TTFT average's denominator). */
186
+ ttftSteps: number
187
+ /** Summed decode spans: first chunk → `assistant/message`, in ms. */
188
+ decodeMs: number
189
+ /** Completion tokens over timed decode spans (the tok/s numerator). */
190
+ decodeTokens: number
103
191
  }
104
192
 
105
193
  /** The complete TUI transcript view for one session. */
106
194
  export interface TranscriptView {
107
195
  /** Settled entries in log order. */
108
196
  entries: readonly TranscriptEntry[]
109
- /** Text accumulated from `assistant/chunk` deltas since the last flush. */
197
+ /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
110
198
  streaming: string
111
- /** Thinking accumulated from `assistant/chunk` reasoning deltas since the last flush. */
199
+ /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
112
200
  streamingReasoning: string
113
201
  /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
114
202
  todos: readonly TodoItem[]
115
203
  /** True while a durable turn is open (`turn/start` … `turn/end`). */
116
204
  busy: boolean
205
+ /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
206
+ busySince: number
117
207
  /** Figures the status line renders. */
118
208
  stats: TranscriptStats
119
209
  /**
@@ -127,12 +217,18 @@ export interface TranscriptView {
127
217
  plan: boolean
128
218
  /** Active permission preset folded from the last `permission/preset` event, empty before one. */
129
219
  permission: string
220
+ /** Latest session title folded from the last `session/title` event, empty before one. */
221
+ title: string
222
+ /** Sandbox-mode override folded from the last `sandbox/mode` event, empty when never switched. */
223
+ sandbox: string
224
+ /** Current long-running goal folded from the last `goal/change`, undefined when cleared. */
225
+ goal: GoalFold | undefined
130
226
  /**
131
227
  * Fold-internal timing anchors, never rendered: open step and tool-call
132
228
  * start timestamps the next `assistant/message` / `tool/result` resolves
133
229
  * against. Keyed `turn:step` and by call id.
134
230
  */
135
- readonly anchors: { stepStart: Map<string, number>; toolStart: Map<string, number> }
231
+ 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>> }
136
232
  }
137
233
 
138
234
  /** Join the text blocks of a content list; non-text blocks contribute nothing. */
@@ -153,11 +249,15 @@ export function createTranscriptView(): TranscriptView {
153
249
  streamingReasoning: '',
154
250
  todos: [],
155
251
  busy: false,
252
+ busySince: 0,
156
253
  model: '',
157
254
  plan: false,
158
255
  permission: '',
159
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
160
- anchors: { stepStart: new Map(), toolStart: new Map() },
256
+ title: '',
257
+ sandbox: '',
258
+ goal: undefined,
259
+ 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 },
260
+ anchors: { stepStart: new Map(), toolStart: new Map(), firstChunkAt: new Map(), compactionTokens: new Map(), lastPruneTokens: 0, turnFiles: new Map() },
161
261
  }
162
262
  }
163
263
 
@@ -184,11 +284,27 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
184
284
  }
185
285
  case 'assistant/chunk': {
186
286
  const chunk = event.data.chunk
287
+ // First-token latency: the first non-empty delta of a step anchors the
288
+ // TTFT (empty keep-alive deltas do not count as tokens).
289
+ const key = `${event.data.turn}:${event.data.step}`
290
+ const delta = chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' ? chunk.text : ''
291
+ let stats = view.stats
292
+ if (delta !== '' && !view.anchors.firstChunkAt.has(key)) {
293
+ view.anchors.firstChunkAt.set(key, event.time)
294
+ const started = view.anchors.stepStart.get(key)
295
+ if (started !== undefined) {
296
+ stats = {
297
+ ...stats,
298
+ ttftMs: stats.ttftMs + Math.max(0, event.time - started),
299
+ ttftSteps: stats.ttftSteps + 1,
300
+ }
301
+ }
302
+ }
187
303
  if (chunk.type === 'text-delta') {
188
- return { ...view, streaming: view.streaming + chunk.text }
304
+ return { ...view, streaming: appendStreamingTail(view.streaming, chunk.text), stats }
189
305
  }
190
306
  if (chunk.type === 'reasoning-delta') {
191
- return { ...view, streamingReasoning: view.streamingReasoning + chunk.text }
307
+ return { ...view, streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text), stats }
192
308
  }
193
309
  return view
194
310
  }
@@ -197,6 +313,8 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
197
313
  const key = `${event.data.turn}:${event.data.step}`
198
314
  const started = view.anchors.stepStart.get(key)
199
315
  view.anchors.stepStart.delete(key)
316
+ const firstChunk = view.anchors.firstChunkAt.get(key)
317
+ view.anchors.firstChunkAt.delete(key)
200
318
  const usage = event.data.usage
201
319
  const totals = view.stats.usage
202
320
  return {
@@ -216,6 +334,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
216
334
  outputTokens: totals.outputTokens + usage.outputTokens,
217
335
  cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
218
336
  },
337
+ lastPromptTokens: usage === undefined ? view.stats.lastPromptTokens
338
+ : usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0),
339
+ // Decode span and its tokens pair up: an un-timed step (no first
340
+ // chunk landed) contributes neither, so the rate stays honest.
341
+ decodeMs: view.stats.decodeMs + (firstChunk === undefined ? 0 : Math.max(0, event.time - firstChunk)),
342
+ decodeTokens: view.stats.decodeTokens + (firstChunk === undefined || usage === undefined ? 0 : usage.outputTokens),
219
343
  },
220
344
  }
221
345
  }
@@ -232,6 +356,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
232
356
  preview: toolArgumentsPreview(data.arguments, data.name),
233
357
  state: 'running',
234
358
  summary: '',
359
+ detail: undefined,
235
360
  }],
236
361
  }
237
362
  }
@@ -239,10 +364,21 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
239
364
  const block = event.data.message.content[0]
240
365
  const started = view.anchors.toolStart.get(block.toolCallId)
241
366
  view.anchors.toolStart.delete(block.toolCallId)
242
- const summary = boundContextSummary(textOf(block.content))
367
+ const rawText = textOf(block.content)
368
+ const summary = boundContextSummary(rawText)
369
+ // The verbose expansion self-serves from the persisted presentation
370
+ // metadata (diffs, read windows, web sources) with the bounded raw text
371
+ // as the universal fallback — the capable-UI degradation ladder.
372
+ const detail = toolResultDetail(event.data.meta, rawText)
373
+ // Turn-tail deliverables: a diff-bearing mutation records its paths.
374
+ if (detail?.kind === 'diff') {
375
+ const set = view.anchors.turnFiles.get(event.data.turn) ?? new Set<string>()
376
+ for (const diff of detail.diffs) set.add(diff.path)
377
+ view.anchors.turnFiles.set(event.data.turn, set)
378
+ }
243
379
  const entries = view.entries.map((entry) => {
244
380
  if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
245
- return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary }
381
+ return { ...entry, state: block.isError === true ? 'error' as const : 'done' as const, summary, detail }
246
382
  })
247
383
  return {
248
384
  ...view,
@@ -262,6 +398,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
262
398
  return {
263
399
  ...view,
264
400
  busy: true,
401
+ busySince: view.busy ? view.busySince : event.time,
265
402
  todos: [],
266
403
  stats: { ...view.stats, turns: view.stats.turns + 1 },
267
404
  }
@@ -270,13 +407,119 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
270
407
  return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
271
408
  case 'turn/end': {
272
409
  const reason = event.data.reason
273
- if (reason.kind !== 'error') return { ...view, busy: false }
410
+ const appended: TranscriptEntry[] = []
411
+ if (reason.kind === 'error') {
412
+ appended.push({ kind: 'error', text: `${reason.error.code}: ${reason.error.message}` })
413
+ } else {
414
+ // Non-error outcomes deserve their own durable row (the web renders
415
+ // distinct max-tokens / abort / interruption nodes); `completed` stays
416
+ // silent so an ordinary turn never grows a marker.
417
+ const marker = reason.kind === 'aborted'
418
+ ? reason.reason.kind === 'user' ? 'turn cancelled by the user' : `turn cancelled (${reason.reason.kind})`
419
+ : reason.kind === 'max-tokens'
420
+ ? 'turn hit the output-token ceiling (max-tokens)'
421
+ : reason.kind === 'blocked'
422
+ ? 'turn ended blocked'
423
+ : reason.kind === 'interrupted'
424
+ ? 'turn was interrupted by a restart'
425
+ : undefined
426
+ if (marker !== undefined) appended.push({ kind: 'turn-marker', text: marker })
427
+ }
428
+ // Deliverables ride the turn tail (the web's turnTail chips): the
429
+ // turn's mutated files flush as one bounded row, then the set resets.
430
+ const files = view.anchors.turnFiles.get(event.data.turn)
431
+ view.anchors.turnFiles.delete(event.data.turn)
432
+ if (files !== undefined && files.size > 0) appended.push({ kind: 'files', paths: [...files].slice(0, 12) })
433
+ if (appended.length === 0) return { ...view, busy: false, busySince: 0 }
434
+ return { ...view, busy: false, busySince: 0, entries: [...view.entries, ...appended] }
435
+ }
436
+ case 'llm/retry': {
437
+ const data = event.data
274
438
  return {
275
439
  ...view,
276
- busy: false,
277
- entries: [...view.entries, { kind: 'error', text: `${reason.error.code}: ${reason.error.message}` }],
440
+ entries: [...view.entries, {
441
+ kind: 'retry',
442
+ retryId: data.retryId,
443
+ attempt: data.retry,
444
+ max: 'maxRetries' in data ? data.maxRetries : data.retry,
445
+ code: data.failure.code,
446
+ delayMs: data.delayMs,
447
+ state: 'running',
448
+ }],
278
449
  }
279
450
  }
451
+ case 'llm/retry-started': {
452
+ const data = event.data
453
+ const entries = view.entries.map((entry) => {
454
+ if (entry.kind !== 'retry' || entry.retryId !== data.retryId) return entry
455
+ return { ...entry, state: 'done' as const }
456
+ })
457
+ return { ...view, entries }
458
+ }
459
+ case 'sandbox/mode':
460
+ // Log-only override switch; last write wins for the status badge.
461
+ return { ...view, sandbox: event.data.mode }
462
+ case 'goal/change': {
463
+ const data = event.data
464
+ const clip = (text: string): string => (text.length > 60 ? `${text.slice(0, 59)}…` : text)
465
+ if (data.operation === 'clear') {
466
+ return {
467
+ ...view,
468
+ goal: undefined,
469
+ entries: [...view.entries, { kind: 'turn-marker', text: '◎ goal cleared' }],
470
+ }
471
+ }
472
+ const goal: GoalFold = {
473
+ objective: data.goal.objective,
474
+ phase: data.goal.phase,
475
+ rounds: data.roundsStarted,
476
+ max: data.goal.maxGoalRounds,
477
+ blocked: data.goal.blockedReason?.message ?? '',
478
+ }
479
+ const line = data.operation === 'create'
480
+ ? `◎ goal: ${clip(data.goal.objective)}`
481
+ : data.operation === 'complete'
482
+ ? '◎ goal complete'
483
+ : data.operation === 'pause'
484
+ ? '◎ goal paused'
485
+ : data.operation === 'resume'
486
+ ? '◎ goal resumed'
487
+ : data.operation === 'block'
488
+ ? `◎ goal blocked: ${clip(goal.blocked)}`
489
+ : undefined
490
+ return {
491
+ ...view,
492
+ goal,
493
+ entries: line === undefined ? view.entries : [...view.entries, { kind: 'turn-marker', text: line }],
494
+ }
495
+ }
496
+ case 'session/title':
497
+ // Latest-wins title snapshot, log-only; the status line prefers it.
498
+ return { ...view, title: event.data.title }
499
+ case 'compaction/summary':
500
+ // Remember the shadow price so the matching `compaction/end` row can
501
+ // state what the compaction reclaimed.
502
+ view.anchors.compactionTokens.set(event.data.compactionId, event.data.shadowedTokenCount)
503
+ return view
504
+ case 'compaction/prune':
505
+ // A model-free prune carries no compaction id; its price serves the next
506
+ // `compaction/end` that cannot find a summary price.
507
+ return { ...view, anchors: { ...view.anchors, lastPruneTokens: event.data.shadowedTokenCount } }
508
+ case 'compaction/end': {
509
+ const ok = event.data.error === undefined
510
+ const tokens = view.anchors.compactionTokens.get(event.data.compactionId) ?? view.anchors.lastPruneTokens
511
+ view.anchors.compactionTokens.delete(event.data.compactionId)
512
+ return {
513
+ ...view,
514
+ entries: [...view.entries, { kind: 'compaction', ok, tokens, error: event.data.error ?? '' }],
515
+ }
516
+ }
517
+ case 'request/context':
518
+ // Route capacity, logged only when it changes; last one wins.
519
+ return {
520
+ ...view,
521
+ stats: { ...view.stats, contextWindow: event.data.contextWindow ?? view.stats.contextWindow },
522
+ }
280
523
  case 'request/header': {
281
524
  // The session's own model record: the latest snapshot's provider/model
282
525
  // pair, exactly what a resumed TUI restores as the selection.
@@ -327,3 +570,23 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
327
570
  export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
328
571
  return events.reduce(projectEvent, createTranscriptView())
329
572
  }
573
+
574
+ /**
575
+ * How many leading transcript entries can never change again: only a
576
+ * `running` tool or retry can still mutate in place — everything before the
577
+ * first one (including a completed tail: later events only APPEND new rows)
578
+ * is final. The renderer currently draws the whole transcript dynamically
579
+ * (a `<Static>` flush proved unstable with CJK wrapping on real terminals);
580
+ * this boundary stays as the append-only contract for when flushing is
581
+ * reintroduced.
582
+ * @param entries - the view's transcript entries in order.
583
+ * @returns the count of entries safe to flush (0 for an empty transcript).
584
+ */
585
+ export function settledEntryCount(entries: readonly TranscriptEntry[]): number {
586
+ for (let index = 0; index < entries.length; index++) {
587
+ const entry = entries[index]
588
+ if (entry.kind === 'tool' && entry.state === 'running') return index
589
+ if (entry.kind === 'retry' && entry.state === 'running') return index
590
+ }
591
+ return entries.length
592
+ }