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.
- package/README.md +13 -2
- package/README.zh.md +11 -2
- package/cordis.patch.yml +6 -0
- package/lib/index.mjs +2793 -394
- package/lib/types/app.d.ts +12 -0
- package/lib/types/mentions.d.ts +70 -0
- package/lib/types/questions.d.ts +48 -0
- package/lib/types/render/animations.d.ts +15 -0
- 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/markdown.d.ts +27 -0
- package/lib/types/render/projection.d.ts +107 -2
- package/lib/types/render/status.d.ts +21 -0
- package/lib/types/render/text.d.ts +18 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/render/tool-preview.d.ts +15 -0
- package/lib/types/store.d.ts +2 -0
- package/lib/types/theme.d.ts +4 -0
- package/package.json +20 -2
- package/src/app.ts +1507 -149
- package/src/index.ts +156 -46
- package/src/mentions.ts +193 -0
- package/src/pictures/1.png +0 -0
- package/src/questions.ts +143 -0
- package/src/render/animations.ts +22 -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/markdown.ts +235 -0
- package/src/render/projection.ts +322 -18
- package/src/render/status.ts +62 -3
- package/src/render/text.ts +79 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/render/tool-preview.ts +34 -0
- package/src/store.ts +8 -0
- package/src/theme.ts +4 -0
package/src/render/projection.ts
CHANGED
|
@@ -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
|
|
13
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
+
}
|
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,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 = [
|
|
71
|
-
.
|
|
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
|
-
|
|
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
|
}
|
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
|
+
}
|