dsh-code 0.2.0 → 0.4.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.
@@ -9,15 +9,38 @@
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) into the union this reducer 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.
14
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'
20
+ import type {} from '@deepseek-ai/dsh-plan-mode'
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'
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
+ }
15
35
 
16
36
  /** One user prompt line. */
17
37
  export interface UserEntry {
18
38
  kind: 'user'
19
39
  /** Joined text blocks of the user message. */
20
40
  text: string
41
+ /** True for collapsed injected context (plugin/continuation notices), which
42
+ * the renderer marks with a dim ↳ instead of the user ❯ prompt. */
43
+ notice: boolean
21
44
  }
22
45
 
23
46
  /** One assembled assistant reply. */
@@ -25,6 +48,8 @@ export interface AssistantEntry {
25
48
  kind: 'assistant'
26
49
  /** Joined text blocks of the assistant message. */
27
50
  text: string
51
+ /** Joined reasoning blocks of the same message, empty when the model thought out loud. */
52
+ reasoning: string
28
53
  }
29
54
 
30
55
  /** One model-requested tool invocation and its settled state. */
@@ -36,10 +61,18 @@ export interface ToolEntry {
36
61
  name: string
37
62
  /** Raw arguments JSON string exactly as the model produced it. */
38
63
  arguments: string
64
+ /** Bounded human-meaningful arguments preview for the tool card. */
65
+ preview: string
39
66
  /** Execution state; `running` until the paired result lands. */
40
67
  state: 'running' | 'done' | 'error'
41
68
  /** Bounded first text block of the result, empty until it lands. */
42
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
43
76
  }
44
77
 
45
78
  /** One slash-command execution dispatched through `ctx.commands`. */
@@ -64,8 +97,62 @@ export interface ErrorEntry {
64
97
  text: string
65
98
  }
66
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
+
67
141
  /** Ordered transcript items the renderer draws. */
68
- 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
+ }
69
156
 
70
157
  /** Cumulative token accounting folded from `assistant/message` usage reports. */
71
158
  export interface UsageTotals {
@@ -89,18 +176,34 @@ export interface TranscriptStats {
89
176
  toolMs: number
90
177
  /** Cumulative token accounting; input stays 0 until a report lands. */
91
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
92
191
  }
93
192
 
94
193
  /** The complete TUI transcript view for one session. */
95
194
  export interface TranscriptView {
96
195
  /** Settled entries in log order. */
97
196
  entries: readonly TranscriptEntry[]
98
- /** Text accumulated from `assistant/chunk` deltas since the last flush. */
197
+ /** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
99
198
  streaming: string
199
+ /** Bounded thinking tail accumulated from reasoning deltas since the last flush. */
200
+ streamingReasoning: string
100
201
  /** Latest whole-list todo snapshot from `todo/write`, empty when none. */
101
202
  todos: readonly TodoItem[]
102
203
  /** True while a durable turn is open (`turn/start` … `turn/end`). */
103
204
  busy: boolean
205
+ /** `turn/start` time of the open turn (0 while idle) — the web TurnStatus clock anchor. */
206
+ busySince: number
104
207
  /** Figures the status line renders. */
105
208
  stats: TranscriptStats
106
209
  /**
@@ -110,12 +213,22 @@ export interface TranscriptView {
110
213
  * Empty before the session's first request.
111
214
  */
112
215
  model: string
216
+ /** Plan mode state folded from the last `plan/mode` event. */
217
+ plan: boolean
218
+ /** Active permission preset folded from the last `permission/preset` event, empty before one. */
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
113
226
  /**
114
227
  * Fold-internal timing anchors, never rendered: open step and tool-call
115
228
  * start timestamps the next `assistant/message` / `tool/result` resolves
116
229
  * against. Keyed `turn:step` and by call id.
117
230
  */
118
- 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>> }
119
232
  }
120
233
 
121
234
  /** Join the text blocks of a content list; non-text blocks contribute nothing. */
@@ -123,16 +236,28 @@ function textOf(content: readonly ContentBlock[]): string {
123
236
  return content.filter(block => block.type === 'text').map(block => block.text).join('')
124
237
  }
125
238
 
239
+ /** Join the reasoning blocks of a content list; non-reasoning blocks contribute nothing. */
240
+ function reasoningOf(content: readonly ContentBlock[]): string {
241
+ return content.filter(block => block.type === 'reasoning').map(block => block.text).join('')
242
+ }
243
+
126
244
  /** A fresh, empty transcript view. */
127
245
  export function createTranscriptView(): TranscriptView {
128
246
  return {
129
247
  entries: [],
130
248
  streaming: '',
249
+ streamingReasoning: '',
131
250
  todos: [],
132
251
  busy: false,
252
+ busySince: 0,
133
253
  model: '',
134
- stats: { turns: 0, steps: 0, llmMs: 0, toolMs: 0, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 } },
135
- anchors: { stepStart: new Map(), toolStart: new Map() },
254
+ plan: false,
255
+ permission: '',
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() },
136
261
  }
137
262
  }
138
263
 
@@ -150,29 +275,57 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
150
275
  // elsewhere in the product; only direct human prompts render in full.
151
276
  const message = event.data
152
277
  if (message.source.kind === 'user') {
153
- return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content) }] }
278
+ return { ...view, entries: [...view.entries, { kind: 'user', text: textOf(message.content), notice: false }] }
154
279
  }
155
280
  const notice = message.source.kind === 'plugin' && message.source.form === 'notice'
156
281
  ? message.source.summary
157
282
  : message.source.kind
158
- return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice) }] }
283
+ return { ...view, entries: [...view.entries, { kind: 'user', text: boundContextSummary(notice), notice: true }] }
159
284
  }
160
285
  case 'assistant/chunk': {
161
286
  const chunk = event.data.chunk
162
- if (chunk.type !== 'text-delta') return view
163
- return { ...view, streaming: view.streaming + chunk.text }
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
+ }
303
+ if (chunk.type === 'text-delta') {
304
+ return { ...view, streaming: appendStreamingTail(view.streaming, chunk.text), stats }
305
+ }
306
+ if (chunk.type === 'reasoning-delta') {
307
+ return { ...view, streamingReasoning: appendStreamingTail(view.streamingReasoning, chunk.text), stats }
308
+ }
309
+ return view
164
310
  }
165
311
  case 'assistant/message': {
166
- // The assembled message is authoritative; drop the streamed buffer.
312
+ // The assembled message is authoritative; drop the streamed buffers.
167
313
  const key = `${event.data.turn}:${event.data.step}`
168
314
  const started = view.anchors.stepStart.get(key)
169
315
  view.anchors.stepStart.delete(key)
316
+ const firstChunk = view.anchors.firstChunkAt.get(key)
317
+ view.anchors.firstChunkAt.delete(key)
170
318
  const usage = event.data.usage
171
319
  const totals = view.stats.usage
172
320
  return {
173
321
  ...view,
174
322
  streaming: '',
175
- entries: [...view.entries, { kind: 'assistant', text: textOf(event.data.message.content) }],
323
+ streamingReasoning: '',
324
+ entries: [...view.entries, {
325
+ kind: 'assistant',
326
+ text: textOf(event.data.message.content),
327
+ reasoning: reasoningOf(event.data.message.content),
328
+ }],
176
329
  stats: {
177
330
  ...view.stats,
178
331
  llmMs: view.stats.llmMs + (started === undefined ? 0 : Math.max(0, event.time - started)),
@@ -181,6 +334,12 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
181
334
  outputTokens: totals.outputTokens + usage.outputTokens,
182
335
  cacheReadTokens: totals.cacheReadTokens + (usage.cacheReadTokens ?? 0),
183
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),
184
343
  },
185
344
  }
186
345
  }
@@ -194,8 +353,10 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
194
353
  callId: data.callId,
195
354
  name: data.name,
196
355
  arguments: data.arguments,
356
+ preview: toolArgumentsPreview(data.arguments, data.name),
197
357
  state: 'running',
198
358
  summary: '',
359
+ detail: undefined,
199
360
  }],
200
361
  }
201
362
  }
@@ -203,10 +364,21 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
203
364
  const block = event.data.message.content[0]
204
365
  const started = view.anchors.toolStart.get(block.toolCallId)
205
366
  view.anchors.toolStart.delete(block.toolCallId)
206
- 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
+ }
207
379
  const entries = view.entries.map((entry) => {
208
380
  if (entry.kind !== 'tool' || entry.callId !== block.toolCallId) return entry
209
- 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 }
210
382
  })
211
383
  return {
212
384
  ...view,
@@ -226,6 +398,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
226
398
  return {
227
399
  ...view,
228
400
  busy: true,
401
+ busySince: view.busy ? view.busySince : event.time,
229
402
  todos: [],
230
403
  stats: { ...view.stats, turns: view.stats.turns + 1 },
231
404
  }
@@ -234,19 +407,130 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
234
407
  return { ...view, stats: { ...view.stats, steps: view.stats.steps + 1 } }
235
408
  case 'turn/end': {
236
409
  const reason = event.data.reason
237
- 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
238
438
  return {
239
439
  ...view,
240
- busy: false,
241
- 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
+ }],
449
+ }
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 ?? '' }],
242
515
  }
243
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
+ }
244
523
  case 'request/header': {
245
524
  // The session's own model record: the latest snapshot's provider/model
246
525
  // pair, exactly what a resumed TUI restores as the selection.
247
526
  const config = event.data.header.config
248
527
  return { ...view, model: `${config.provider}/${config.model}` }
249
528
  }
529
+ case 'plan/mode':
530
+ // Whole-value replace; the last one wins (upstream fold semantics).
531
+ return { ...view, plan: event.data.active }
532
+ case 'permission/preset':
533
+ return { ...view, permission: event.data.preset }
250
534
  case 'command/run': {
251
535
  const data = event.data
252
536
  return {
@@ -286,3 +570,23 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
286
570
  export function projectEvents(events: readonly SessionEvent[]): TranscriptView {
287
571
  return events.reduce(projectEvent, createTranscriptView())
288
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
+ }
@@ -36,6 +36,18 @@ export function formatDuration(ms: number): string {
36
36
  return `${Math.floor(whole / 60)}m${whole % 60}s`
37
37
  }
38
38
 
39
+ /**
40
+ * Compact decode rate: one decimal under a hundred, whole below a thousand,
41
+ * then thousands (15.3 / 124 / 1.2K).
42
+ * @param n - tokens per second.
43
+ * @returns display string.
44
+ */
45
+ export function formatRate(n: number): string {
46
+ if (n < 100) return String(Math.round(n * 10) / 10)
47
+ if (n < 1_000) return String(Math.round(n))
48
+ return `${Math.round(n / 100) / 10}K`
49
+ }
50
+
39
51
  /**
40
52
  * Cache-hit share of billed prompt-side input.
41
53
  * @param usage - cumulative token totals.
@@ -57,6 +69,16 @@ export interface StatusFacts {
57
69
  branch: string
58
70
  /** Short session identifier (last dash-separated segment or tail). */
59
71
  sessionId: string
72
+ /** Latest session title (folded from `session/title`); shown in place of the id. */
73
+ title: string
74
+ /** Sandbox-mode override (folded from `sandbox/mode`), empty when never switched. */
75
+ sandbox: string
76
+ /** Live goal summary (folded from `goal/change`), undefined when none. */
77
+ goal: { phase: string; rounds: number; max: number } | undefined
78
+ /** Whether plan mode is active (folded from `plan/mode`). */
79
+ plan: boolean
80
+ /** Active permission preset (folded from `permission/preset`), empty when unknown. */
81
+ permission: string
60
82
  }
61
83
 
62
84
  /**
@@ -67,21 +89,58 @@ export interface StatusFacts {
67
89
  */
68
90
  export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): string[] {
69
91
  const groups: string[] = []
70
- const identity = [facts.model, facts.cwd, facts.branch === '' ? undefined : `⑂ ${facts.branch}`]
71
- .filter(part => part !== undefined && part !== '')
92
+ const identity = [
93
+ facts.model,
94
+ facts.cwd,
95
+ facts.branch === '' ? undefined : `⑂ ${facts.branch}`,
96
+ facts.plan ? '⧉ plan' : undefined,
97
+ ].filter(part => part !== undefined && part !== '')
72
98
  if (identity.length > 0) groups.push(identity.join(' · '))
73
99
  if (stats.turns > 0 || stats.steps > 0) {
74
100
  groups.push(`T${stats.turns} · S${stats.steps}`)
75
101
  const durations: string[] = []
76
102
  if (stats.llmMs > 0) durations.push(`llm ${formatDuration(stats.llmMs)}`)
103
+ // Decode latency figures (the web StatsLine's TTFT and throughput):
104
+ // average first-token wait and tokens per second over timed steps.
105
+ if (stats.ttftSteps > 0) durations.push(`ttft ${formatDuration(stats.ttftMs / stats.ttftSteps)}`)
106
+ if (stats.decodeMs > 0 && stats.decodeTokens > 0) {
107
+ durations.push(`${formatRate(stats.decodeTokens / (stats.decodeMs / 1_000))} tok/s`)
108
+ }
77
109
  if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`)
78
110
  if (durations.length > 0) groups.push(durations.join(' · '))
79
111
  }
80
112
  const cacheHit = cacheHitPercent(stats.usage)
81
113
  if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
82
114
  if (cacheHit !== null) groups.push(`cache ${cacheHit}%`)
115
+ // Context occupancy (the web StatsLine's meter): the most recent
116
+ // reported prompt size against the advertised route capacity.
117
+ if (stats.contextWindow > 0 && stats.lastPromptTokens > 0) {
118
+ groups.push(`ctx ${Math.min(999, Math.round(stats.lastPromptTokens / stats.contextWindow * 100))}%`)
119
+ }
83
120
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
84
121
  }
85
- if (facts.sessionId !== '') groups.push(facts.sessionId)
122
+ // The session title replaces the bare short id whenever one has landed
123
+ // (user rename or provider generation), bounded so a long title cannot
124
+ // crowd out the rest of the line.
125
+ const label = facts.title !== undefined && facts.title !== ''
126
+ ? (facts.title.length > 48 ? `${facts.title.slice(0, 47)}…` : facts.title)
127
+ : facts.sessionId
128
+ if (label !== '') groups.push(label)
129
+ // The permission preset trails the line: switching it changes only the
130
+ // tail, so the left-aligned bar never shifts its other groups. Plain text,
131
+ // the Claude-Code permission-mode display (no glyphs).
132
+ if (facts.permission !== undefined && facts.permission !== '') groups.push(facts.permission)
133
+ // The sandbox override stays implicit when it merely echoes the preset —
134
+ // the badge exists to surface a divergence, not to duplicate the label.
135
+ const sandbox = facts.sandbox ?? ''
136
+ if (sandbox !== '' && sandbox.toLowerCase() !== facts.permission.toLowerCase()) {
137
+ groups.push(`sandbox ${sandbox}`)
138
+ }
139
+ // Goal badge: round progress while active, the phase otherwise.
140
+ if (facts.goal !== undefined) {
141
+ groups.push(facts.goal.phase === 'active'
142
+ ? `◎ r${facts.goal.rounds}/${facts.goal.max}`
143
+ : `◎ ${facts.goal.phase}`)
144
+ }
86
145
  return groups
87
146
  }
@@ -22,3 +22,82 @@ const CONTROL_ESCAPE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu
22
22
  export function displayText(text: string): string {
23
23
  return text.replace(CONTROL_ESCAPE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
24
24
  }
25
+
26
+ /** A display-safe suffix bounded by terminal rows and columns. */
27
+ export interface DisplayTail {
28
+ /** Sanitized suffix suitable for direct terminal rendering. */
29
+ text: string
30
+ /** Whether content before the returned suffix was omitted. */
31
+ truncated: boolean
32
+ }
33
+
34
+ /** Terminal-cell width matching the TUI's existing CJK-aware wrapping rule. */
35
+ function cellWidth(text: string): number {
36
+ let columns = 0
37
+ for (const char of text) {
38
+ columns += (char.codePointAt(0) ?? 0) > 0x2e7f ? 2 : 1
39
+ }
40
+ return columns
41
+ }
42
+
43
+ /** Read one Unicode character immediately before `end`. */
44
+ function previousCharacter(text: string, end: number): { char: string; start: number } {
45
+ const last = text.charCodeAt(end - 1)
46
+ if (last >= 0xdc00 && last <= 0xdfff && end >= 2) {
47
+ const first = text.charCodeAt(end - 2)
48
+ if (first >= 0xd800 && first <= 0xdbff) {
49
+ return { char: text.slice(end - 2, end), start: end - 2 }
50
+ }
51
+ }
52
+ return { char: text.slice(end - 1, end), start: end - 1 }
53
+ }
54
+
55
+ /**
56
+ * Keep only the newest display-safe text that fits a terminal rectangle.
57
+ * The scan walks backward and stops as soon as the suffix is full, so a long
58
+ * reasoning stream does not rescan its entire accumulated prefix per chunk.
59
+ * Explicit newlines and terminal wrapping both consume rows.
60
+ * @param text - raw externally sourced text.
61
+ * @param columns - available terminal columns.
62
+ * @param rows - available terminal rows.
63
+ * @returns a sanitized bounded suffix and whether an earlier prefix was cut.
64
+ */
65
+ export function displayTail(text: string, columns: number, rows: number): DisplayTail {
66
+ const columnLimit = Math.max(1, Math.floor(columns))
67
+ const rowLimit = Math.max(1, Math.floor(rows))
68
+ const reversed: string[] = []
69
+ let row = 1
70
+ let used = 0
71
+ let end = text.length
72
+
73
+ while (end > 0) {
74
+ const previous = previousCharacter(text, end)
75
+ if (previous.char === '\n') {
76
+ if (row >= rowLimit) break
77
+ reversed.push('\n')
78
+ row += 1
79
+ used = 0
80
+ end = previous.start
81
+ continue
82
+ }
83
+
84
+ const safe = displayText(previous.char)
85
+ const width = cellWidth(safe)
86
+ if (used > 0 && used + width > columnLimit) {
87
+ if (row >= rowLimit) break
88
+ // Materialize the soft wrap. Ink otherwise reflows at word boundaries
89
+ // and can turn a cell-counted two-row suffix into three rendered rows.
90
+ reversed.push('\n')
91
+ row += 1
92
+ used = 0
93
+ }
94
+ const extraRows = Math.floor(Math.max(0, width - 1) / columnLimit)
95
+ if (row + extraRows > rowLimit) break
96
+ row += extraRows
97
+ reversed.push(safe)
98
+ used = extraRows === 0 ? used + width : width - extraRows * columnLimit
99
+ end = previous.start
100
+ }
101
+
102
+ return { text: reversed.reverse().join(''), truncated: end > 0 }
103
+ }