dsh-code 0.3.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,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
+ }
@@ -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,12 @@ 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
60
78
  /** Whether plan mode is active (folded from `plan/mode`). */
61
79
  plan: boolean
62
80
  /** Active permission preset (folded from `permission/preset`), empty when unknown. */
@@ -82,18 +100,47 @@ export function buildStatusGroups(facts: StatusFacts, stats: TranscriptStats): s
82
100
  groups.push(`T${stats.turns} · S${stats.steps}`)
83
101
  const durations: string[] = []
84
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
+ }
85
109
  if (stats.toolMs > 0) durations.push(`tool ${formatDuration(stats.toolMs)}`)
86
110
  if (durations.length > 0) groups.push(durations.join(' · '))
87
111
  }
88
112
  const cacheHit = cacheHitPercent(stats.usage)
89
113
  if (stats.usage.inputTokens > 0 || stats.usage.outputTokens > 0) {
90
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
+ }
91
120
  groups.push(`↑${formatTokens(stats.usage.inputTokens)} ↓${formatTokens(stats.usage.outputTokens)}`)
92
121
  }
93
- 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)
94
129
  // The permission preset trails the line: switching it changes only the
95
130
  // tail, so the left-aligned bar never shifts its other groups. Plain text,
96
131
  // the Claude-Code permission-mode display (no glyphs).
97
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
+ }
98
145
  return groups
99
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
+ }