dsh-code 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +201 -55
- package/README.zh.md +204 -61
- package/bin/deepseek.mjs +70 -0
- package/cordis.patch.yml +62 -6
- package/lib/index.mjs +4073 -1802
- package/lib/startup.mjs +34 -17
- package/lib/types/app.d.ts +23 -22
- package/lib/types/commands.d.ts +2 -0
- package/lib/types/index.d.ts +5 -5
- package/lib/types/internals.d.ts +2 -0
- package/lib/types/kernel-panels.d.ts +23 -0
- package/lib/types/plugin-inventory.d.ts +11 -0
- package/lib/types/presets.d.ts +32 -0
- package/lib/types/render/export.d.ts +15 -0
- package/lib/types/render/inspector.d.ts +34 -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 +19 -0
- package/lib/types/render/text.d.ts +27 -0
- package/lib/types/render/tool-detail.d.ts +92 -0
- package/lib/types/session-directory.d.ts +54 -0
- package/lib/types/session-switch.d.ts +17 -0
- package/lib/types/skills.d.ts +2 -0
- package/lib/types/startup.d.ts +11 -1
- package/lib/types/store.d.ts +2 -0
- package/package.json +16 -1
- package/src/app.ts +1367 -277
- package/src/commands.ts +15 -1
- package/src/index.ts +373 -128
- package/src/internals.ts +5 -0
- package/src/kernel-panels.ts +254 -0
- package/src/plugin-inventory.ts +47 -0
- package/src/presets.ts +64 -0
- package/src/render/export.ts +81 -0
- package/src/render/inspector.ts +88 -0
- package/src/render/lines.ts +207 -0
- package/src/render/markdown.ts +15 -1
- package/src/render/projection.ts +279 -16
- package/src/render/status.ts +51 -1
- package/src/render/text.ts +107 -0
- package/src/render/tool-detail.ts +197 -0
- package/src/session-directory.ts +102 -0
- package/src/session-switch.ts +58 -0
- package/src/skills.ts +20 -7
- package/src/startup.ts +38 -20
- package/src/store.ts +8 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/** Width-safe styled physical rows for bounded terminal panels. */
|
|
2
|
+
|
|
3
|
+
import type { TranscriptEntry } from './projection.ts'
|
|
4
|
+
import type { ToolDetail } from './tool-detail.ts'
|
|
5
|
+
import { renderMarkdown, visibleColumns, type MdStyle } from './markdown.ts'
|
|
6
|
+
import { formatTokens } from './status.ts'
|
|
7
|
+
import { displayText } from './text.ts'
|
|
8
|
+
|
|
9
|
+
/** Presentation classes mapped to Ink colors by the app boundary. */
|
|
10
|
+
export type LineStyle = MdStyle | 'brand' | 'success' | 'error' | 'warn' | 'dimItalic'
|
|
11
|
+
|
|
12
|
+
/** One styled run within a physical terminal row. */
|
|
13
|
+
export interface StyledSegment {
|
|
14
|
+
text: string
|
|
15
|
+
style: LineStyle
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One row guaranteed not to exceed the requested terminal width. */
|
|
19
|
+
export interface StyledLine {
|
|
20
|
+
segments: readonly StyledSegment[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Construct one segment without leaking mutable objects into cached rows. */
|
|
24
|
+
export function lineSegment(text: string, style: LineStyle = 'plain'): StyledSegment {
|
|
25
|
+
return { text, style }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Append a character while merging adjacent runs with the same style. */
|
|
29
|
+
function appendSegment(target: StyledSegment[], text: string, style: LineStyle): void {
|
|
30
|
+
const previous = target[target.length - 1]
|
|
31
|
+
if (previous?.style === style) {
|
|
32
|
+
target[target.length - 1] = { text: previous.text + text, style }
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
target.push({ text, style })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Sanitize and hard-wrap styled content into exact physical rows.
|
|
40
|
+
* Tabs become two visible spaces because terminal tab stops are contextual
|
|
41
|
+
* and therefore cannot participate in a deterministic row budget.
|
|
42
|
+
*/
|
|
43
|
+
export function styledLines(segments: readonly StyledSegment[], columns: number): readonly StyledLine[] {
|
|
44
|
+
const width = Math.max(1, Math.floor(columns))
|
|
45
|
+
const lines: StyledLine[] = []
|
|
46
|
+
let current: StyledSegment[] = []
|
|
47
|
+
let used = 0
|
|
48
|
+
const flush = (): void => {
|
|
49
|
+
lines.push({ segments: current })
|
|
50
|
+
current = []
|
|
51
|
+
used = 0
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const segment of segments) {
|
|
55
|
+
const safe = displayText(segment.text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
56
|
+
for (const char of safe) {
|
|
57
|
+
if (char === '\n') {
|
|
58
|
+
flush()
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const cells = visibleColumns(char)
|
|
62
|
+
if (used > 0 && used + cells > width) flush()
|
|
63
|
+
appendSegment(current, char, segment.style)
|
|
64
|
+
used += cells
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (current.length > 0 || lines.length === 0) flush()
|
|
68
|
+
return lines
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Plain/dim text convenience over {@link styledLines}. */
|
|
72
|
+
export function textLines(text: string, columns: number, style: LineStyle = 'plain'): readonly StyledLine[] {
|
|
73
|
+
return styledLines([lineSegment(text, style)], columns)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Markdown rows re-hardened so a single long word cannot escape the budget. */
|
|
77
|
+
export function markdownLines(text: string, columns: number): readonly StyledLine[] {
|
|
78
|
+
const width = Math.max(1, Math.floor(columns))
|
|
79
|
+
const parsed = renderMarkdown(displayText(text), Math.max(10, width))
|
|
80
|
+
return parsed.flatMap(line => styledLines(
|
|
81
|
+
line.segments.map(segment => lineSegment(segment.text, segment.style)),
|
|
82
|
+
width,
|
|
83
|
+
))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Expanded structured tool detail as scrollable, width-safe rows. */
|
|
87
|
+
function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
|
|
88
|
+
switch (detail.kind) {
|
|
89
|
+
case 'diff':
|
|
90
|
+
return detail.diffs.flatMap(diff => [
|
|
91
|
+
...styledLines([
|
|
92
|
+
lineSegment(' ── ', 'dim'),
|
|
93
|
+
lineSegment(diff.path, 'dim'),
|
|
94
|
+
lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
|
|
95
|
+
], columns),
|
|
96
|
+
...diff.lines.flatMap(line => styledLines([
|
|
97
|
+
lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
|
|
98
|
+
], columns)),
|
|
99
|
+
])
|
|
100
|
+
case 'read':
|
|
101
|
+
return [
|
|
102
|
+
...textLines(
|
|
103
|
+
` ── ${detail.path} · lines ${detail.offset}-${detail.lines.length > 0 ? detail.lines[detail.lines.length - 1]!.number : detail.offset - 1} of ${detail.totalLines}${detail.truncated ? ' (window truncated)' : ''}`,
|
|
104
|
+
columns,
|
|
105
|
+
'dim',
|
|
106
|
+
),
|
|
107
|
+
...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
|
|
108
|
+
]
|
|
109
|
+
case 'web-search':
|
|
110
|
+
return [
|
|
111
|
+
...detail.sources.flatMap(source => [
|
|
112
|
+
...styledLines([
|
|
113
|
+
lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
|
|
114
|
+
lineSegment(` - ${source.url}`, 'dim'),
|
|
115
|
+
], columns),
|
|
116
|
+
...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
|
|
117
|
+
]),
|
|
118
|
+
...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
|
|
119
|
+
]
|
|
120
|
+
case 'web-fetch':
|
|
121
|
+
return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
|
|
122
|
+
case 'raw':
|
|
123
|
+
return [
|
|
124
|
+
...textLines(` ${detail.text}`, columns, 'dim'),
|
|
125
|
+
...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
|
|
126
|
+
]
|
|
127
|
+
default: {
|
|
128
|
+
const exhaustive: never = detail
|
|
129
|
+
return exhaustive
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Convert one durable transcript entry to its complete scrollable row model.
|
|
136
|
+
* The source entry stays intact; only the caller's visible slice is rendered.
|
|
137
|
+
*/
|
|
138
|
+
export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
|
|
139
|
+
const width = Math.max(1, Math.floor(columns))
|
|
140
|
+
switch (entry.kind) {
|
|
141
|
+
case 'user':
|
|
142
|
+
return styledLines([
|
|
143
|
+
lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
|
|
144
|
+
lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
|
|
145
|
+
], width)
|
|
146
|
+
case 'assistant':
|
|
147
|
+
return [
|
|
148
|
+
...(entry.reasoning === ''
|
|
149
|
+
? []
|
|
150
|
+
: styledLines([
|
|
151
|
+
lineSegment(' ✻ ', 'dimItalic'),
|
|
152
|
+
lineSegment(entry.reasoning, 'dimItalic'),
|
|
153
|
+
], width)),
|
|
154
|
+
...markdownLines(entry.text, width),
|
|
155
|
+
]
|
|
156
|
+
case 'tool': {
|
|
157
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
158
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
159
|
+
return [
|
|
160
|
+
...styledLines([
|
|
161
|
+
lineSegment(`${mark} `, markStyle),
|
|
162
|
+
lineSegment(entry.name, 'brand'),
|
|
163
|
+
lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
|
|
164
|
+
], width),
|
|
165
|
+
...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
|
|
166
|
+
...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
case 'command': {
|
|
170
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
171
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
172
|
+
return [
|
|
173
|
+
...styledLines([
|
|
174
|
+
lineSegment(`${mark} `, markStyle),
|
|
175
|
+
lineSegment(`/${entry.name}`, 'brand'),
|
|
176
|
+
lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
|
|
177
|
+
], width),
|
|
178
|
+
...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
case 'turn-marker':
|
|
182
|
+
return textLines(` ⏹ ${entry.text}`, width, 'dim')
|
|
183
|
+
case 'compaction':
|
|
184
|
+
return textLines(entry.ok
|
|
185
|
+
? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
|
|
186
|
+
: ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
|
|
187
|
+
case 'retry':
|
|
188
|
+
return textLines(
|
|
189
|
+
` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
190
|
+
width,
|
|
191
|
+
entry.state === 'running' ? 'warn' : 'dim',
|
|
192
|
+
)
|
|
193
|
+
case 'files':
|
|
194
|
+
return entry.paths.length === 0
|
|
195
|
+
? textLines(' ⎄ no changed files', width, 'dim')
|
|
196
|
+
: [
|
|
197
|
+
...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
|
|
198
|
+
...entry.paths.flatMap(path => textLines(` ${path}`, width, 'dim')),
|
|
199
|
+
]
|
|
200
|
+
case 'error':
|
|
201
|
+
return textLines(entry.text, width, 'error')
|
|
202
|
+
default: {
|
|
203
|
+
const exhaustive: never = entry
|
|
204
|
+
return exhaustive
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/render/markdown.ts
CHANGED
|
@@ -166,11 +166,18 @@ const ORDERED = /^\s*(\d+)[.)]\s+(.*)$/u
|
|
|
166
166
|
/** Render markdown text into styled lines of at most `width` columns. */
|
|
167
167
|
export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
168
168
|
const lines: MdLine[] = []
|
|
169
|
+
let separatorPending = false
|
|
169
170
|
const push = (segments: readonly MdSegment[]): void => {
|
|
170
171
|
for (const wrapped of wrapSegments(segments, Math.max(10, width))) {
|
|
171
172
|
lines.push({ segments: merge(wrapped) })
|
|
172
173
|
}
|
|
173
174
|
}
|
|
175
|
+
const startBlock = (): void => {
|
|
176
|
+
if (separatorPending && lines.length > 0 && lines.at(-1)?.segments.length !== 0) {
|
|
177
|
+
lines.push({ segments: [] })
|
|
178
|
+
}
|
|
179
|
+
separatorPending = false
|
|
180
|
+
}
|
|
174
181
|
const raw = text.replaceAll('\r', '')
|
|
175
182
|
const source = raw.split('\n')
|
|
176
183
|
let index = 0
|
|
@@ -178,6 +185,14 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
|
178
185
|
const line = source[index] ?? ''
|
|
179
186
|
index += 1
|
|
180
187
|
|
|
188
|
+
// Preserve one deliberate row between source blocks. Repeated blank
|
|
189
|
+
// lines collapse, and leading/trailing whitespace never grows output.
|
|
190
|
+
if (line.trim() === '') {
|
|
191
|
+
separatorPending = lines.length > 0
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
startBlock()
|
|
195
|
+
|
|
181
196
|
// Fenced code block: verbatim lines in code style, language label first.
|
|
182
197
|
const fence = FENCE.exec(line)
|
|
183
198
|
if (fence !== null) {
|
|
@@ -191,7 +206,6 @@ export function renderMarkdown(text: string, width: number): readonly MdLine[] {
|
|
|
191
206
|
continue
|
|
192
207
|
}
|
|
193
208
|
|
|
194
|
-
if (line.trim() === '') continue
|
|
195
209
|
if (RULE.test(line.trim())) {
|
|
196
210
|
push([seg(` ${'─'.repeat(Math.max(1, Math.floor(width / 4)))}`, 'dim')])
|
|
197
211
|
continue
|
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
|
+
}
|