dsh-code 0.8.0 → 0.9.1
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.en.md +19 -3
- package/README.md +19 -3
- package/lib/index.mjs +1175 -227
- package/lib/types/app.d.ts +13 -0
- package/lib/types/approval.d.ts +3 -1
- package/lib/types/kernel-panels.d.ts +58 -8
- package/lib/types/models.d.ts +15 -1
- package/lib/types/render/animations.d.ts +14 -2
- package/lib/types/render/projection.d.ts +2 -0
- package/lib/types/render/tool-preview.d.ts +10 -0
- package/lib/types/session-directory.d.ts +46 -2
- package/lib/types/subagents.d.ts +60 -0
- package/package.json +25 -1
- package/src/app.ts +400 -103
- package/src/approval.ts +161 -135
- package/src/index.ts +175 -8
- package/src/kernel-panels.ts +310 -30
- package/src/models.ts +26 -0
- package/src/render/animations.ts +49 -5
- package/src/render/lines.ts +236 -233
- package/src/render/projection.ts +5 -1
- package/src/render/tool-preview.ts +77 -50
- package/src/session-directory.ts +128 -6
- package/src/subagents.ts +165 -0
package/src/render/lines.ts
CHANGED
|
@@ -1,233 +1,236 @@
|
|
|
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
|
-
/**
|
|
87
|
-
* Codex-style reasoning rows: the marker occupies the reply gutter and every
|
|
88
|
-
* wrapped or explicit continuation starts with the same two-column indent, so
|
|
89
|
-
* reasoning content and assistant Markdown share one left edge.
|
|
90
|
-
*/
|
|
91
|
-
export function reasoningLines(text: string, columns: number): readonly StyledLine[] {
|
|
92
|
-
const width = Math.max(1, Math.floor(columns))
|
|
93
|
-
if (width < 3) return textLines(text, width, 'dimItalic')
|
|
94
|
-
const contentWidth = width - 2
|
|
95
|
-
const content = displayText(text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
96
|
-
.split('\n')
|
|
97
|
-
.flatMap(line => styledLines([lineSegment(line, 'dimItalic')], contentWidth))
|
|
98
|
-
return content.map((line, index) => ({
|
|
99
|
-
segments: [
|
|
100
|
-
lineSegment(index === 0 ? '✻ ' : ' ', 'dimItalic'),
|
|
101
|
-
...line.segments,
|
|
102
|
-
],
|
|
103
|
-
}))
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Expanded structured tool detail as scrollable, width-safe rows. */
|
|
107
|
-
function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
|
|
108
|
-
switch (detail.kind) {
|
|
109
|
-
case 'diff':
|
|
110
|
-
return detail.diffs.flatMap(diff => [
|
|
111
|
-
...styledLines([
|
|
112
|
-
lineSegment(' ── ', 'dim'),
|
|
113
|
-
lineSegment(diff.path, 'dim'),
|
|
114
|
-
lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
|
|
115
|
-
], columns),
|
|
116
|
-
...diff.lines.flatMap(line => styledLines([
|
|
117
|
-
lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
|
|
118
|
-
], columns)),
|
|
119
|
-
])
|
|
120
|
-
case 'read':
|
|
121
|
-
return [
|
|
122
|
-
...textLines(
|
|
123
|
-
` ── ${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)' : ''}`,
|
|
124
|
-
columns,
|
|
125
|
-
'dim',
|
|
126
|
-
),
|
|
127
|
-
...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
|
|
128
|
-
]
|
|
129
|
-
case 'web-search':
|
|
130
|
-
return [
|
|
131
|
-
...detail.sources.flatMap(source => [
|
|
132
|
-
...styledLines([
|
|
133
|
-
lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
|
|
134
|
-
lineSegment(` - ${source.url}`, 'dim'),
|
|
135
|
-
], columns),
|
|
136
|
-
...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
|
|
137
|
-
]),
|
|
138
|
-
...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
|
|
139
|
-
]
|
|
140
|
-
case 'web-fetch':
|
|
141
|
-
return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
|
|
142
|
-
case 'raw':
|
|
143
|
-
return [
|
|
144
|
-
...textLines(` ${detail.text}`, columns, 'dim'),
|
|
145
|
-
...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
|
|
146
|
-
]
|
|
147
|
-
default: {
|
|
148
|
-
const exhaustive: never = detail
|
|
149
|
-
return exhaustive
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
* Convert one durable transcript entry to its complete scrollable row model.
|
|
156
|
-
* The source entry stays intact; only the caller's visible slice is rendered.
|
|
157
|
-
*/
|
|
158
|
-
export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
|
|
159
|
-
const width = Math.max(1, Math.floor(columns))
|
|
160
|
-
switch (entry.kind) {
|
|
161
|
-
case 'user':
|
|
162
|
-
return styledLines([
|
|
163
|
-
lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
|
|
164
|
-
lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
|
|
165
|
-
], width)
|
|
166
|
-
case 'pending':
|
|
167
|
-
// Codex PendingSteer: a queued prompt renders exactly like an ordinary
|
|
168
|
-
// user row, so the durable user/message retires it without any flicker.
|
|
169
|
-
return styledLines([
|
|
170
|
-
lineSegment('❯ ', 'brand'),
|
|
171
|
-
lineSegment(entry.text, 'plain'),
|
|
172
|
-
], width)
|
|
173
|
-
case 'assistant': {
|
|
174
|
-
const reasoning = entry.reasoning === '' ? [] : reasoningLines(entry.reasoning, width)
|
|
175
|
-
// Every reply row carries the composer's two-column gutter, so reply
|
|
176
|
-
// text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
|
|
177
|
-
// wrap budget shrinks by the same amount so no line double-wraps.
|
|
178
|
-
const body = markdownLines(entry.text, Math.max(10, width - 2))
|
|
179
|
-
.map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
|
|
180
|
-
return [...reasoning, ...body]
|
|
181
|
-
}
|
|
182
|
-
case 'tool': {
|
|
183
|
-
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
184
|
-
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
185
|
-
return [
|
|
186
|
-
...styledLines([
|
|
187
|
-
lineSegment(`${mark} `, markStyle),
|
|
188
|
-
lineSegment(entry.name, 'brand'),
|
|
189
|
-
lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
|
|
190
|
-
], width),
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
+
/**
|
|
87
|
+
* Codex-style reasoning rows: the marker occupies the reply gutter and every
|
|
88
|
+
* wrapped or explicit continuation starts with the same two-column indent, so
|
|
89
|
+
* reasoning content and assistant Markdown share one left edge.
|
|
90
|
+
*/
|
|
91
|
+
export function reasoningLines(text: string, columns: number): readonly StyledLine[] {
|
|
92
|
+
const width = Math.max(1, Math.floor(columns))
|
|
93
|
+
if (width < 3) return textLines(text, width, 'dimItalic')
|
|
94
|
+
const contentWidth = width - 2
|
|
95
|
+
const content = displayText(text).replaceAll('\t', ' ').replaceAll('\r', '')
|
|
96
|
+
.split('\n')
|
|
97
|
+
.flatMap(line => styledLines([lineSegment(line, 'dimItalic')], contentWidth))
|
|
98
|
+
return content.map((line, index) => ({
|
|
99
|
+
segments: [
|
|
100
|
+
lineSegment(index === 0 ? '✻ ' : ' ', 'dimItalic'),
|
|
101
|
+
...line.segments,
|
|
102
|
+
],
|
|
103
|
+
}))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Expanded structured tool detail as scrollable, width-safe rows. */
|
|
107
|
+
function toolDetailLines(detail: ToolDetail, columns: number): readonly StyledLine[] {
|
|
108
|
+
switch (detail.kind) {
|
|
109
|
+
case 'diff':
|
|
110
|
+
return detail.diffs.flatMap(diff => [
|
|
111
|
+
...styledLines([
|
|
112
|
+
lineSegment(' ── ', 'dim'),
|
|
113
|
+
lineSegment(diff.path, 'dim'),
|
|
114
|
+
lineSegment(diff.truncated ? ' (diff truncated)' : '', 'dim'),
|
|
115
|
+
], columns),
|
|
116
|
+
...diff.lines.flatMap(line => styledLines([
|
|
117
|
+
lineSegment(` ${line.mark}${line.text}`, line.mark === '+' ? 'success' : line.mark === '-' ? 'error' : 'dim'),
|
|
118
|
+
], columns)),
|
|
119
|
+
])
|
|
120
|
+
case 'read':
|
|
121
|
+
return [
|
|
122
|
+
...textLines(
|
|
123
|
+
` ── ${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)' : ''}`,
|
|
124
|
+
columns,
|
|
125
|
+
'dim',
|
|
126
|
+
),
|
|
127
|
+
...detail.lines.flatMap(line => textLines(` ${String(line.number).padStart(5, ' ')} | ${line.text}`, columns, 'dim')),
|
|
128
|
+
]
|
|
129
|
+
case 'web-search':
|
|
130
|
+
return [
|
|
131
|
+
...detail.sources.flatMap(source => [
|
|
132
|
+
...styledLines([
|
|
133
|
+
lineSegment(` ? ${source.title ?? source.url}`, 'brand'),
|
|
134
|
+
lineSegment(` - ${source.url}`, 'dim'),
|
|
135
|
+
], columns),
|
|
136
|
+
...(source.snippet === '' ? [] : textLines(` ${source.snippet}`, columns, 'dim')),
|
|
137
|
+
]),
|
|
138
|
+
...textLines(` ${detail.sources.length} sources${detail.truncated ? ' (capped)' : ''}`, columns, 'dim'),
|
|
139
|
+
]
|
|
140
|
+
case 'web-fetch':
|
|
141
|
+
return textLines(` ${detail.url} · HTTP ${detail.statusCode}`, columns, 'dim')
|
|
142
|
+
case 'raw':
|
|
143
|
+
return [
|
|
144
|
+
...textLines(` ${detail.text}`, columns, 'dim'),
|
|
145
|
+
...textLines(detail.truncated ? ' … (output truncated)' : ' (end of output)', columns, 'dim'),
|
|
146
|
+
]
|
|
147
|
+
default: {
|
|
148
|
+
const exhaustive: never = detail
|
|
149
|
+
return exhaustive
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Convert one durable transcript entry to its complete scrollable row model.
|
|
156
|
+
* The source entry stays intact; only the caller's visible slice is rendered.
|
|
157
|
+
*/
|
|
158
|
+
export function transcriptEntryLines(entry: TranscriptEntry, columns: number): readonly StyledLine[] {
|
|
159
|
+
const width = Math.max(1, Math.floor(columns))
|
|
160
|
+
switch (entry.kind) {
|
|
161
|
+
case 'user':
|
|
162
|
+
return styledLines([
|
|
163
|
+
lineSegment(entry.notice ? '⤷ ' : '❯ ', entry.notice ? 'dim' : 'brand'),
|
|
164
|
+
lineSegment(entry.text, entry.notice ? 'dim' : 'plain'),
|
|
165
|
+
], width)
|
|
166
|
+
case 'pending':
|
|
167
|
+
// Codex PendingSteer: a queued prompt renders exactly like an ordinary
|
|
168
|
+
// user row, so the durable user/message retires it without any flicker.
|
|
169
|
+
return styledLines([
|
|
170
|
+
lineSegment('❯ ', 'brand'),
|
|
171
|
+
lineSegment(entry.text, 'plain'),
|
|
172
|
+
], width)
|
|
173
|
+
case 'assistant': {
|
|
174
|
+
const reasoning = entry.reasoning === '' ? [] : reasoningLines(entry.reasoning, width)
|
|
175
|
+
// Every reply row carries the composer's two-column gutter, so reply
|
|
176
|
+
// text aligns with the input cursor (Codex LIVE_PREFIX alignment); the
|
|
177
|
+
// wrap budget shrinks by the same amount so no line double-wraps.
|
|
178
|
+
const body = markdownLines(entry.text, Math.max(10, width - 2))
|
|
179
|
+
.map(line => ({ segments: [{ text: ' ', style: 'plain' as const }, ...line.segments] }))
|
|
180
|
+
return [...reasoning, ...body]
|
|
181
|
+
}
|
|
182
|
+
case 'tool': {
|
|
183
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
184
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
185
|
+
return [
|
|
186
|
+
...styledLines([
|
|
187
|
+
lineSegment(`${mark} `, markStyle),
|
|
188
|
+
lineSegment(entry.name, 'brand'),
|
|
189
|
+
lineSegment(entry.preview === '' ? '' : ` ${entry.preview}`, 'dim'),
|
|
190
|
+
], width),
|
|
191
|
+
// A delegation card carries what the child was asked (Codex's
|
|
192
|
+
// SpawnAgent prompt preview) while it runs, before any result.
|
|
193
|
+
...(entry.prompt === '' ? [] : textLines(` └ ${entry.prompt}`, width, 'dim')),
|
|
194
|
+
...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
|
|
195
|
+
...(entry.detail === undefined ? [] : toolDetailLines(entry.detail, width)),
|
|
196
|
+
]
|
|
197
|
+
}
|
|
198
|
+
case 'command': {
|
|
199
|
+
const mark = entry.state === 'running' ? '●' : entry.state === 'error' ? '⨯' : '⏺'
|
|
200
|
+
const markStyle: LineStyle = entry.state === 'running' ? 'brand' : entry.state === 'error' ? 'error' : 'success'
|
|
201
|
+
return [
|
|
202
|
+
...styledLines([
|
|
203
|
+
lineSegment(`${mark} `, markStyle),
|
|
204
|
+
lineSegment(`/${entry.name}`, 'brand'),
|
|
205
|
+
lineSegment(entry.args === '' ? '' : ` ${entry.args}`, 'dim'),
|
|
206
|
+
], width),
|
|
207
|
+
...(entry.summary === '' ? [] : textLines(` ⎿ ${entry.summary}`, width, entry.state === 'error' ? 'error' : 'dim')),
|
|
208
|
+
]
|
|
209
|
+
}
|
|
210
|
+
case 'turn-marker':
|
|
211
|
+
return textLines(` ⏹ ${entry.text}`, width, 'dim')
|
|
212
|
+
case 'compaction':
|
|
213
|
+
return textLines(entry.ok
|
|
214
|
+
? ` ⧉ compacted ~${formatTokens(entry.tokens)} tokens`
|
|
215
|
+
: ` ⧉ compaction failed: ${entry.error}`, width, 'dim')
|
|
216
|
+
case 'retry':
|
|
217
|
+
return textLines(
|
|
218
|
+
` ↻ retry ${entry.attempt}/${entry.max} · ${entry.code} · ${Math.round(entry.delayMs / 100) / 10}s`,
|
|
219
|
+
width,
|
|
220
|
+
entry.state === 'running' ? 'warn' : 'dim',
|
|
221
|
+
)
|
|
222
|
+
case 'files':
|
|
223
|
+
return entry.paths.length === 0
|
|
224
|
+
? textLines(' ⎄ no changed files', width, 'dim')
|
|
225
|
+
: [
|
|
226
|
+
...textLines(` ⎄ ${entry.paths.length} changed file${entry.paths.length === 1 ? '' : 's'}`, width, 'dim'),
|
|
227
|
+
...entry.paths.flatMap(path => textLines(` ${path}`, width, 'dim')),
|
|
228
|
+
]
|
|
229
|
+
case 'error':
|
|
230
|
+
return textLines(entry.text, width, 'error')
|
|
231
|
+
default: {
|
|
232
|
+
const exhaustive: never = entry
|
|
233
|
+
return exhaustive
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/render/projection.ts
CHANGED
|
@@ -22,7 +22,7 @@ import type {} from '@deepseek-ai/dsh-plan-mode'
|
|
|
22
22
|
import type {} from '@deepseek-ai/dsh-permission-presets'
|
|
23
23
|
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
|
24
24
|
import type {} from '@deepseek-ai/dsh-session-title'
|
|
25
|
-
import { toolArgumentsPreview } from './tool-preview.ts'
|
|
25
|
+
import { toolArgumentsPreview, toolPromptPreview } from './tool-preview.ts'
|
|
26
26
|
import { toolResultDetail, type ToolDetail } from './tool-detail.ts'
|
|
27
27
|
|
|
28
28
|
/** In-flight UI buffers are tails; the assembled assistant message is authoritative. */
|
|
@@ -85,6 +85,8 @@ export interface ToolEntry {
|
|
|
85
85
|
arguments: string
|
|
86
86
|
/** Bounded human-meaningful arguments preview for the tool card. */
|
|
87
87
|
preview: string
|
|
88
|
+
/** Bounded delegation prompt (subagent cards' second row), '' when none. */
|
|
89
|
+
prompt: string
|
|
88
90
|
/** Execution state; `running` until the paired result lands. */
|
|
89
91
|
state: 'running' | 'done' | 'error'
|
|
90
92
|
/** Bounded first text block of the result, empty until it lands. */
|
|
@@ -525,6 +527,7 @@ export function projectEvent(view: TranscriptView, event: SessionEvent): Transcr
|
|
|
525
527
|
name: data.name,
|
|
526
528
|
arguments: data.arguments,
|
|
527
529
|
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
530
|
+
prompt: toolPromptPreview(data.name, data.arguments),
|
|
528
531
|
state: 'running',
|
|
529
532
|
summary: '',
|
|
530
533
|
detail: undefined,
|
|
@@ -1098,6 +1101,7 @@ export function replayProjectEvent(acc: ReplayAccumulator, event: SessionEvent):
|
|
|
1098
1101
|
name: data.name,
|
|
1099
1102
|
arguments: data.arguments,
|
|
1100
1103
|
preview: toolArgumentsPreview(data.arguments, data.name),
|
|
1104
|
+
prompt: toolPromptPreview(data.name, data.arguments),
|
|
1101
1105
|
state: 'running',
|
|
1102
1106
|
summary: '',
|
|
1103
1107
|
detail: undefined,
|
|
@@ -1,50 +1,77 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
3
|
-
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
4
|
-
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
5
|
-
* transcript and the approval bar's command preview. Arguments longer than
|
|
6
|
-
* {@link MAX_PARSE_CHARS} are never parsed: the preview is a display concern,
|
|
7
|
-
* and a synchronous `JSON.parse` plus string copies of an unbounded model
|
|
8
|
-
* payload must not run on the approval or projection paths.
|
|
9
|
-
*
|
|
10
|
-
* @module @deepseek-ai/dsh-code/render/tool-preview
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
/** Keys searched in declaration order when building a preview. */
|
|
14
|
-
const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Raw arguments longer than this are skipped without parsing and fall back
|
|
18
|
-
* to the bounded raw preview. Well above any realistic command/path/query
|
|
19
|
-
* string while keeping the synchronous parse cost negligible.
|
|
20
|
-
*/
|
|
21
|
-
const MAX_PARSE_CHARS = 4096
|
|
22
|
-
|
|
23
|
-
/** Bounded raw-arguments fallback shared by the skip-parse and parse-failure paths. */
|
|
24
|
-
function boundedRawPreview(args: string): string {
|
|
25
|
-
return args.length > 80 ? `${args.slice(0, 77)}...` : args
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Resolve one bounded preview for raw tool arguments.
|
|
30
|
-
* @param args - raw JSON arguments string as the model produced it.
|
|
31
|
-
* @param toolName - the tool the arguments belong to (fallback label).
|
|
32
|
-
* @returns the preview line; empty when nothing useful resolves.
|
|
33
|
-
*/
|
|
34
|
-
export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
35
|
-
if (args === '') return toolName
|
|
36
|
-
if (args.length > MAX_PARSE_CHARS) return boundedRawPreview(args)
|
|
37
|
-
try {
|
|
38
|
-
const parsed: unknown = JSON.parse(args)
|
|
39
|
-
if (parsed !== null && typeof parsed === 'object') {
|
|
40
|
-
const record = parsed as Record<string, unknown>
|
|
41
|
-
for (const key of PREVIEW_KEYS) {
|
|
42
|
-
const value = record[key]
|
|
43
|
-
if (typeof value === 'string' && value !== '') return value
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
// Raw JSON parse failed: fall through to the bounded raw arguments.
|
|
48
|
-
}
|
|
49
|
-
return boundedRawPreview(args)
|
|
50
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Bounded preview line for a tool invocation's raw JSON arguments: the first
|
|
3
|
+
* human-meaningful string among the well-known keys (command, path, query, …)
|
|
4
|
+
* with a fallback to the bounded raw JSON. Shared by the tool card in the
|
|
5
|
+
* transcript and the approval bar's command preview. Arguments longer than
|
|
6
|
+
* {@link MAX_PARSE_CHARS} are never parsed: the preview is a display concern,
|
|
7
|
+
* and a synchronous `JSON.parse` plus string copies of an unbounded model
|
|
8
|
+
* payload must not run on the approval or projection paths.
|
|
9
|
+
*
|
|
10
|
+
* @module @deepseek-ai/dsh-code/render/tool-preview
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Keys searched in declaration order when building a preview. */
|
|
14
|
+
const PREVIEW_KEYS = ['command', 'cmd', 'description', 'path', 'pattern', 'query'] as const
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Raw arguments longer than this are skipped without parsing and fall back
|
|
18
|
+
* to the bounded raw preview. Well above any realistic command/path/query
|
|
19
|
+
* string while keeping the synchronous parse cost negligible.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_PARSE_CHARS = 4096
|
|
22
|
+
|
|
23
|
+
/** Bounded raw-arguments fallback shared by the skip-parse and parse-failure paths. */
|
|
24
|
+
function boundedRawPreview(args: string): string {
|
|
25
|
+
return args.length > 80 ? `${args.slice(0, 77)}...` : args
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve one bounded preview for raw tool arguments.
|
|
30
|
+
* @param args - raw JSON arguments string as the model produced it.
|
|
31
|
+
* @param toolName - the tool the arguments belong to (fallback label).
|
|
32
|
+
* @returns the preview line; empty when nothing useful resolves.
|
|
33
|
+
*/
|
|
34
|
+
export function toolArgumentsPreview(args: string, toolName: string): string {
|
|
35
|
+
if (args === '') return toolName
|
|
36
|
+
if (args.length > MAX_PARSE_CHARS) return boundedRawPreview(args)
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(args)
|
|
39
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
40
|
+
const record = parsed as Record<string, unknown>
|
|
41
|
+
for (const key of PREVIEW_KEYS) {
|
|
42
|
+
const value = record[key]
|
|
43
|
+
if (typeof value === 'string' && value !== '') return value
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// Raw JSON parse failed: fall through to the bounded raw arguments.
|
|
48
|
+
}
|
|
49
|
+
return boundedRawPreview(args)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Visible budget for one delegation prompt row on the tool card. */
|
|
53
|
+
const MAX_PROMPT_CHARS = 160
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Bounded prompt preview for delegation-style tools (`subagent`): the
|
|
57
|
+
* `prompt` argument rendered as the card's second row, so the transcript
|
|
58
|
+
* shows what the child agent was asked — not just its description label —
|
|
59
|
+
* while it runs (Codex's SpawnAgent card preview). Anything else returns ''.
|
|
60
|
+
* @param toolName - the tool the arguments belong to.
|
|
61
|
+
* @param args - raw JSON arguments string as the model produced it.
|
|
62
|
+
* @returns the one-line prompt preview, or '' when none applies.
|
|
63
|
+
*/
|
|
64
|
+
export function toolPromptPreview(toolName: string, args: string): string {
|
|
65
|
+
if (toolName !== 'subagent' || args === '' || args.length > MAX_PARSE_CHARS) return ''
|
|
66
|
+
try {
|
|
67
|
+
const parsed: unknown = JSON.parse(args)
|
|
68
|
+
if (parsed === null || typeof parsed !== 'object') return ''
|
|
69
|
+
const prompt = (parsed as Record<string, unknown>)['prompt']
|
|
70
|
+
if (typeof prompt !== 'string' || prompt === '') return ''
|
|
71
|
+
const flat = prompt.replace(/\s+/gu, ' ').trim()
|
|
72
|
+
return flat.length > MAX_PROMPT_CHARS ? `${flat.slice(0, MAX_PROMPT_CHARS - 1)}…` : flat
|
|
73
|
+
} catch {
|
|
74
|
+
// Malformed arguments degrade to no prompt row, never a thrown parse.
|
|
75
|
+
return ''
|
|
76
|
+
}
|
|
77
|
+
}
|