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.
- package/README.md +7 -1
- package/README.zh.md +5 -1
- package/lib/index.mjs +3116 -1703
- package/lib/types/app.d.ts +4 -21
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +30 -0
- package/lib/types/render/lines.d.ts +31 -0
- package/lib/types/render/projection.d.ts +95 -3
- package/lib/types/render/status.d.ts +17 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/store.d.ts +2 -0
- package/package.json +11 -1
- package/src/app.ts +1087 -233
- package/src/index.ts +46 -0
- package/src/pictures/1.png +0 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +79 -0
- package/src/render/lines.ts +207 -0
- package/src/render/projection.ts +279 -16
- package/src/render/status.ts +48 -1
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/store.ts +8 -0
package/src/render/projection.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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
|
-
/**
|
|
197
|
+
/** Bounded text tail accumulated from `assistant/chunk` deltas since the last flush. */
|
|
110
198
|
streaming: string
|
|
111
|
-
/**
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
+
}
|
package/src/render/status.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/render/text.ts
CHANGED
|
@@ -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
|
+
}
|