dsh-live-trace 0.1.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/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- package/src/cli/working/state.js +528 -0
package/src/cli/views.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's non-trace panels: the session switcher that is also the
|
|
3
|
+
* landing screen, the file-change (diff) panel, and the command panel.
|
|
4
|
+
*
|
|
5
|
+
* Each builder is pure: it takes the view model plus a size and returns rows of
|
|
6
|
+
* styled segments, already wrapped. The renderer frames them; the terminal is
|
|
7
|
+
* never touched here.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-live-trace/views
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { formatDuration, shortPath, shortSessionId } from './format.js'
|
|
13
|
+
import { DEFAULT_TRANSLATOR, statusText } from './i18n.js'
|
|
14
|
+
import { displayWidth } from './width.js'
|
|
15
|
+
import { highlightLine } from './highlight.js'
|
|
16
|
+
import { renderMarkdown } from './markdown.js'
|
|
17
|
+
import { statusGlyph, statusStyle } from './theme.js'
|
|
18
|
+
import { padTo, rowWidth, sanitize, truncateSegments, wrapSegments } from './width.js'
|
|
19
|
+
import { editTotals, sessionRows } from './view-state.js'
|
|
20
|
+
|
|
21
|
+
/** Rows reserved for the file list above a diff before it starts scrolling. */
|
|
22
|
+
const MAX_FILE_ROWS = 6
|
|
23
|
+
/** Output lines shown for a command that is not selected, and for the selected one. */
|
|
24
|
+
const OUTPUT_PREVIEW_LINES = 3
|
|
25
|
+
const OUTPUT_SELECTED_LINES = 14
|
|
26
|
+
|
|
27
|
+
/** One styled segment. @typedef {{ text: string, sgr?: string }} Segment */
|
|
28
|
+
|
|
29
|
+
/** @param {string} text @param {string} [sgr] @returns {Segment[]} */
|
|
30
|
+
function seg(text, sgr) {
|
|
31
|
+
return [{ text, sgr }]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A section heading row pair: the title and its underline.
|
|
36
|
+
* @returns {Segment[][]}
|
|
37
|
+
*/
|
|
38
|
+
function sectionHeader(title, right, width, theme) {
|
|
39
|
+
const left = seg(title, theme.ui.panelTitle)
|
|
40
|
+
const rightSeg = right === undefined || right.length === 0 ? [] : seg(right, theme.ui.dim)
|
|
41
|
+
const gap = Math.max(1, width - rowWidth(left) - rowWidth(rightSeg))
|
|
42
|
+
return [
|
|
43
|
+
[...left, { text: ' '.repeat(gap) }, ...rightSeg],
|
|
44
|
+
seg('─'.repeat(Math.max(0, width)), theme.ui.border)
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wrap one logical row into a first line plus hanging-indent continuations. */
|
|
49
|
+
function hanging(prefix, body, width, indent) {
|
|
50
|
+
const available = Math.max(8, width - rowWidth(prefix))
|
|
51
|
+
const wrapped = wrapSegments(body, available)
|
|
52
|
+
return wrapped.map((line, index) =>
|
|
53
|
+
index === 0 ? [...prefix, ...line] : [{ text: ' '.repeat(indent), sgr: '' }, ...line]
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* ------------------------------------------------------------------ *
|
|
58
|
+
* Sessions: the landing screen and the switcher
|
|
59
|
+
* ------------------------------------------------------------------ */
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render the session list.
|
|
63
|
+
*
|
|
64
|
+
* @param {import('./view-state.js').ViewState} state
|
|
65
|
+
* @param {{ width: number, theme: any, now?: number, overlay?: boolean }} context
|
|
66
|
+
* @returns {{ rows: Segment[][], cursorRow: number }}
|
|
67
|
+
*/
|
|
68
|
+
export function sessionsView(state, context) {
|
|
69
|
+
const { width, theme } = context
|
|
70
|
+
const t = context.t ?? DEFAULT_TRANSLATOR
|
|
71
|
+
const now = context.now ?? Date.now()
|
|
72
|
+
const rows = sessionRows(state, now)
|
|
73
|
+
const out = []
|
|
74
|
+
/** Body row -> session index, so a mouse click can select one. */
|
|
75
|
+
const rowItems = []
|
|
76
|
+
|
|
77
|
+
const active = rows.filter((row) => row.activity !== 'idle').length
|
|
78
|
+
const headerRows = sectionHeader(
|
|
79
|
+
t('panel.sessions'),
|
|
80
|
+
rows.length === 0 ? t('panel.none') : t('panel.known', { n: rows.length, m: active }),
|
|
81
|
+
width,
|
|
82
|
+
theme
|
|
83
|
+
)
|
|
84
|
+
out.push(...headerRows)
|
|
85
|
+
rowItems.push(...headerRows.map(() => -1))
|
|
86
|
+
|
|
87
|
+
if (rows.length === 0) {
|
|
88
|
+
out.push(seg(t('sessions.empty'), theme.ui.statusIdle))
|
|
89
|
+
out.push(seg(t('sessions.emptyHint'), theme.ui.dim))
|
|
90
|
+
return { rows: out, cursorRow: -1, rowItems: [-1, -1, -1, -1] }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const idWidth = Math.min(26, Math.max(...rows.map((row) => shortSessionId(row.id, 26).length)))
|
|
94
|
+
const activityLabel = (row) => statusText(t, row.activity)
|
|
95
|
+
const activityWidth = Math.min(20, Math.max(...rows.map((row) => displayWidth(activityLabel(row)))))
|
|
96
|
+
const positionWidth = Math.min(12, Math.max(...rows.map((row) => `T${row.turn}·S${row.step}`.length)))
|
|
97
|
+
const ageWidth = 8
|
|
98
|
+
|
|
99
|
+
let cursorRow = -1
|
|
100
|
+
for (const row of rows) {
|
|
101
|
+
if (row.selected) cursorRow = out.length
|
|
102
|
+
const marker = row.selected ? '▸' : ' '
|
|
103
|
+
const bound = row.bound ? '●' : ' '
|
|
104
|
+
const activity = activityLabel(row)
|
|
105
|
+
const position = `T${row.turn}·S${row.step}`
|
|
106
|
+
|
|
107
|
+
out.push([
|
|
108
|
+
{ text: `${marker} `, sgr: row.selected ? theme.ui.cursor : theme.ui.dim },
|
|
109
|
+
{ text: `${bound} `, sgr: row.bound ? theme.tag.turn : theme.ui.dim },
|
|
110
|
+
{ text: `${statusGlyph(row.activity, Math.floor(now / 250))} `, sgr: statusStyle(theme, row.activity) },
|
|
111
|
+
{ text: padTo(activity, activityWidth), sgr: statusStyle(theme, row.activity) },
|
|
112
|
+
{ text: ` ${padTo(position, positionWidth)}`, sgr: theme.ui.value },
|
|
113
|
+
{ text: ` ${padTo(formatAge(row.idleFor, t), ageWidth)}`, sgr: theme.ui.dim },
|
|
114
|
+
{ text: ' ', sgr: '' },
|
|
115
|
+
{ text: row.title !== undefined ? sanitize(row.title) : shortSessionId(row.id, idWidth), sgr: row.selected ? theme.ui.selected : theme.level.info }
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
const detail = [shortSessionId(row.id, 32)]
|
|
119
|
+
if (row.title !== undefined) detail.push(`· ${sanitize(row.title)}`)
|
|
120
|
+
if (row.origin === 'subagent') detail.push(`· ${t('sessions.subagent', { n: row.delegationDepth ?? 1 })}`)
|
|
121
|
+
if (typeof row.cwd === 'string' && row.cwd.length > 0) detail.push(`· ${shortPath(sanitize(row.cwd), 40)}`)
|
|
122
|
+
out.push([
|
|
123
|
+
{ text: `${row.selected ? '▸ ' : ' '} `, sgr: theme.ui.dim },
|
|
124
|
+
{ text: detail.join(' '), sgr: theme.ui.dim }
|
|
125
|
+
])
|
|
126
|
+
// Both rows of a session select that session.
|
|
127
|
+
rowItems.push(row.index, row.index)
|
|
128
|
+
}
|
|
129
|
+
return { rows: out, cursorRow, rowItems }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `now`, `12s`, `4m`, `2h` — how long a session has been quiet. */
|
|
133
|
+
function formatAge(ms, t = DEFAULT_TRANSLATOR) {
|
|
134
|
+
if (ms === null || ms === undefined || !Number.isFinite(ms)) return '-'
|
|
135
|
+
if (ms < 1500) return t('age.now')
|
|
136
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
|
137
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
|
|
138
|
+
return `${Math.round(ms / 3_600_000)}h`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ *
|
|
142
|
+
* Edits: what the model changed on disk
|
|
143
|
+
* ------------------------------------------------------------------ */
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Render the file-change panel: a file list above the selected file's diff.
|
|
147
|
+
*
|
|
148
|
+
* @param {import('./view-state.js').ViewState} state
|
|
149
|
+
* @param {{ width: number, theme: any, height?: number, cursor?: number }} context
|
|
150
|
+
* @returns {{ rows: Segment[][], cursorRow: number }}
|
|
151
|
+
*/
|
|
152
|
+
export function editsView(state, context) {
|
|
153
|
+
const { width, theme } = context
|
|
154
|
+
const t = context.t ?? DEFAULT_TRANSLATOR
|
|
155
|
+
const totals = editTotals(state)
|
|
156
|
+
const out = []
|
|
157
|
+
|
|
158
|
+
const summary =
|
|
159
|
+
totals.files === 0
|
|
160
|
+
? t('edits.noChanges')
|
|
161
|
+
: `${t(totals.files === 1 ? 'edits.fileCount' : 'edits.filePlural', { n: totals.files })} +${totals.added} −${totals.removed}`
|
|
162
|
+
const headerRows = sectionHeader(t('panel.edits'), summary, width, theme)
|
|
163
|
+
out.push(...headerRows)
|
|
164
|
+
const rowItems = headerRows.map(() => -1)
|
|
165
|
+
|
|
166
|
+
if (state.edits.length === 0) {
|
|
167
|
+
out.push(seg(t('edits.empty'), theme.ui.statusIdle))
|
|
168
|
+
out.push(seg(t('edits.emptyHint'), theme.ui.dim))
|
|
169
|
+
return { rows: out, cursorRow: -1, rowItems: [-1, -1, -1, -1] }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const cursor = clamp(context.cursor ?? 0, 0, state.edits.length - 1)
|
|
173
|
+
const listRows = Math.min(state.edits.length, MAX_FILE_ROWS)
|
|
174
|
+
// Keep the selected file inside the visible slice of the list.
|
|
175
|
+
const start = clamp(cursor - listRows + 1, 0, Math.max(0, state.edits.length - listRows))
|
|
176
|
+
|
|
177
|
+
let cursorRow = -1
|
|
178
|
+
for (let index = start; index < start + listRows; index += 1) {
|
|
179
|
+
const edit = state.edits[index]
|
|
180
|
+
if (index === cursor) cursorRow = out.length
|
|
181
|
+
const marker = index === cursor ? '▸' : ' '
|
|
182
|
+
const counts = `+${edit.added} −${edit.removed}`
|
|
183
|
+
const badge =
|
|
184
|
+
edit.operation === 'create' ? t('edits.badge.new') : edit.operation === 'delete' ? t('edits.badge.del') : t('edits.badge.mod')
|
|
185
|
+
const pathRoom = Math.max(12, width - 4 - counts.length - 6)
|
|
186
|
+
out.push([
|
|
187
|
+
{ text: `${marker} `, sgr: index === cursor ? theme.ui.cursor : theme.ui.dim },
|
|
188
|
+
...truncateSegments(seg(sanitize(edit.path), index === cursor ? theme.ui.selected : theme.level.info), pathRoom),
|
|
189
|
+
{ text: ` ${counts}`, sgr: index === cursor ? theme.ui.added : theme.ui.dim },
|
|
190
|
+
{ text: ` ${badge}`, sgr: theme.ui.dim }
|
|
191
|
+
])
|
|
192
|
+
rowItems.push(index)
|
|
193
|
+
}
|
|
194
|
+
if (state.edits.length > listRows) {
|
|
195
|
+
out.push(
|
|
196
|
+
seg(
|
|
197
|
+
` ${t(state.edits.length - listRows === 1 ? 'edits.moreFile' : 'edits.moreFiles', { n: state.edits.length - listRows })}`,
|
|
198
|
+
theme.ui.dim
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
rowItems.push(-1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
out.push(seg('─'.repeat(Math.max(0, width)), theme.ui.border))
|
|
205
|
+
rowItems.push(-1)
|
|
206
|
+
const diff = diffRows(state.edits[cursor], width, theme)
|
|
207
|
+
out.push(...diff)
|
|
208
|
+
rowItems.push(...diff.map(() => -1))
|
|
209
|
+
return { rows: out, cursorRow, rowItems }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Unified-diff rows for one file's aggregated hunks. */
|
|
213
|
+
function diffRows(edit, width, theme, t = DEFAULT_TRANSLATOR) {
|
|
214
|
+
if (edit === undefined) return []
|
|
215
|
+
const out = []
|
|
216
|
+
edit.hunks.forEach((hunk, index) => {
|
|
217
|
+
if (index > 0) out.push(seg(' ⋮', theme.ui.dim))
|
|
218
|
+
out.push(
|
|
219
|
+
seg(` @@ ${sanitize(edit.path)}${hunk.oldText === null ? ` ${t('edits.newFile')}` : ''}`, theme.ui.hunkHeader)
|
|
220
|
+
)
|
|
221
|
+
if (typeof hunk.oldText === 'string' && hunk.oldText.length > 0) {
|
|
222
|
+
for (const line of hunk.oldText.split('\n')) out.push(...diffLine('- ', line, theme.ui.removed, theme.ui.removedBg, width))
|
|
223
|
+
}
|
|
224
|
+
for (const line of hunk.newText.split('\n')) out.push(...diffLine('+ ', line, theme.ui.added, theme.ui.addedBg, width))
|
|
225
|
+
})
|
|
226
|
+
return out
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function diffLine(marker, text, colour, background, width) {
|
|
230
|
+
const prefix = [{ text: ` ${marker}`, sgr: colour }]
|
|
231
|
+
const room = Math.max(8, width - rowWidth(prefix))
|
|
232
|
+
const line = seg(sanitize(text), colour)
|
|
233
|
+
const wrapped = wrapSegments(line, room)
|
|
234
|
+
return wrapped.map((piece, index) =>
|
|
235
|
+
index === 0
|
|
236
|
+
? [...prefix, ...piece.map((segment) => ({ ...segment, sgr: colour }))]
|
|
237
|
+
: [{ text: ' ', sgr: background }, ...piece.map((segment) => ({ ...segment, sgr: colour }))]
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ------------------------------------------------------------------ *
|
|
242
|
+
* Commands: what the model ran and what came back
|
|
243
|
+
* ------------------------------------------------------------------ */
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Render the command panel: one block per shell command the session ran.
|
|
247
|
+
*
|
|
248
|
+
* @param {import('./view-state.js').ViewState} state
|
|
249
|
+
* @param {{ width: number, theme: any, commands?: object[], selected?: number, height?: number }} context
|
|
250
|
+
* @returns {{ rows: Segment[][], cursorRow: number }}
|
|
251
|
+
*/
|
|
252
|
+
export function commandsView(state, context) {
|
|
253
|
+
const { width, theme } = context
|
|
254
|
+
const t = context.t ?? DEFAULT_TRANSLATOR
|
|
255
|
+
const commands = context.commands ?? []
|
|
256
|
+
const out = []
|
|
257
|
+
|
|
258
|
+
const failed = commands.filter((command) => command.ok === false).length
|
|
259
|
+
const running = commands.filter((command) => command.pending).length
|
|
260
|
+
const summary =
|
|
261
|
+
commands.length === 0
|
|
262
|
+
? t('panel.none')
|
|
263
|
+
: [
|
|
264
|
+
t('commands.summaryRun', { n: commands.length }),
|
|
265
|
+
failed > 0 ? t('commands.summaryFailed', { n: failed }) : null,
|
|
266
|
+
running > 0 ? t('commands.summaryRunning', { n: running }) : null
|
|
267
|
+
]
|
|
268
|
+
.filter((part) => part !== null)
|
|
269
|
+
.join(' · ')
|
|
270
|
+
out.push(...sectionHeader(t('panel.commands'), summary, width, theme))
|
|
271
|
+
|
|
272
|
+
if (commands.length === 0) {
|
|
273
|
+
out.push(seg(t('commands.empty'), theme.ui.statusIdle))
|
|
274
|
+
out.push(seg(t('commands.emptyHint'), theme.ui.dim))
|
|
275
|
+
return { rows: out, cursorRow: -1 }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const selected = clamp(context.selected ?? commands.length - 1, 0, commands.length - 1)
|
|
279
|
+
let cursorRow = -1
|
|
280
|
+
|
|
281
|
+
commands.forEach((command, index) => {
|
|
282
|
+
const isSelected = index === selected
|
|
283
|
+
if (isSelected) cursorRow = out.length
|
|
284
|
+
const glyph = command.pending ? statusGlyph('tool', 0) : command.ok === false ? '✗' : '✓'
|
|
285
|
+
const colour = command.pending ? theme.ui.statusTool : command.ok === false ? theme.ui.exitFail : theme.ui.exitOk
|
|
286
|
+
const status = command.pending
|
|
287
|
+
? t('commands.running')
|
|
288
|
+
: command.signal !== undefined && command.signal !== null
|
|
289
|
+
? t('commands.signal', { x: command.signal })
|
|
290
|
+
: t('commands.exit', { n: command.exitCode ?? 0 })
|
|
291
|
+
|
|
292
|
+
const right = `${command.durationMs === undefined ? '' : formatDuration(command.durationMs)} ${status}`.trim()
|
|
293
|
+
const head = [{ text: `${isSelected ? '▸ ' : ' '}${glyph} `, sgr: colour }]
|
|
294
|
+
const room = Math.max(12, width - rowWidth(head) - right.length - 3)
|
|
295
|
+
const oneLineCommand = sanitize(command.command.replace(/\s*\n\s*/g, ' '))
|
|
296
|
+
const commandSegments = truncateSegments(highlightLine(oneLineCommand, 'bash', theme.syn), room)
|
|
297
|
+
// In cells, not code units: a translated status such as `执行中…` is wider
|
|
298
|
+
// than its character count.
|
|
299
|
+
const gap = Math.max(1, width - rowWidth(head) - rowWidth(commandSegments) - displayWidth(right))
|
|
300
|
+
out.push([...head, ...commandSegments, { text: ' '.repeat(gap), sgr: '' }, { text: right, sgr: colour }])
|
|
301
|
+
|
|
302
|
+
if (command.description !== undefined && command.description.length > 0) {
|
|
303
|
+
out.push([{ text: ' ', sgr: '' }, ...truncateSegments(seg(sanitize(command.description), theme.ui.dim), Math.max(8, width - 4))])
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const budget = isSelected ? OUTPUT_SELECTED_LINES : OUTPUT_PREVIEW_LINES
|
|
307
|
+
const lines = sanitize(command.outputText ?? '').split('\n').filter((line, position, all) => position < all.length - 1 || line.length > 0)
|
|
308
|
+
if (lines.length === 0) {
|
|
309
|
+
out.push(seg(` ${t('commands.noOutput')}`, theme.ui.dim))
|
|
310
|
+
} else {
|
|
311
|
+
for (const line of lines.slice(0, budget)) {
|
|
312
|
+
for (const wrapped of wrapSegments(seg(line, theme.ui.output), Math.max(8, width - 4))) {
|
|
313
|
+
out.push([{ text: ' ', sgr: '' }, ...wrapped])
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const hidden = command.output?.omitted ?? 0
|
|
317
|
+
if (lines.length > budget || hidden > 0) {
|
|
318
|
+
const more = hidden + Math.max(0, lines.length - budget)
|
|
319
|
+
out.push(seg(` ${t('trace.moreLines', { n: more })}`, theme.ui.dim))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
out.push(seg('', theme.ui.dim))
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
return { rows: out, cursorRow }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* ------------------------------------------------------------------ *
|
|
329
|
+
* Shared
|
|
330
|
+
* ------------------------------------------------------------------ */
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* A tool block for the trace: the command or arguments, the output, and the
|
|
334
|
+
* status, as one readable card instead of disconnected call and result lines.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} entry
|
|
337
|
+
* @param {{ width: number, theme: any, indent?: number, raw?: boolean }} context
|
|
338
|
+
* @returns {Segment[][]}
|
|
339
|
+
*/
|
|
340
|
+
export function toolDetailRows(entry, context) {
|
|
341
|
+
const { width, theme } = context
|
|
342
|
+
const indent = context.indent ?? 0
|
|
343
|
+
const gutter = ' '.repeat(indent)
|
|
344
|
+
const room = Math.max(8, width - indent)
|
|
345
|
+
const out = []
|
|
346
|
+
|
|
347
|
+
if (entry.shell !== undefined) {
|
|
348
|
+
for (const row of commandRows(gutter, entry.shell.command, room)) {
|
|
349
|
+
out.push([{ text: gutter, sgr: '' }, ...row.map((segment) => ({ ...segment, sgr: theme.ui.command }))])
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (entry.phase === 'result') {
|
|
354
|
+
if (entry.output !== undefined && entry.output.text.length > 0) {
|
|
355
|
+
const lines = entry.output.text.split('\n')
|
|
356
|
+
for (const line of lines.slice(0, 40)) {
|
|
357
|
+
for (const wrapped of wrapSegments(seg(sanitize(line), theme.ui.output), room)) {
|
|
358
|
+
out.push([{ text: gutter, sgr: '' }, ...wrapped])
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (entry.output.truncated === true && entry.output.omitted > 0) {
|
|
362
|
+
out.push(seg(`${gutter}… ${entry.output.omitted} more line${entry.output.omitted === 1 ? '' : 's'}`, theme.ui.dim))
|
|
363
|
+
}
|
|
364
|
+
} else if (entry.rawText !== undefined && entry.rawText.length > 0 && entry.shell === undefined) {
|
|
365
|
+
// Non-shell tools: keep the existing markdown/plain body treatment.
|
|
366
|
+
for (const row of contextualBody(entry, { width: room, theme, raw: context.raw })) {
|
|
367
|
+
out.push([{ text: gutter, sgr: '' }, ...row])
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return out
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Render a shell command the way a terminal shows it: a prompt marker, the
|
|
376
|
+
* command, and a hanging indent for any continuation lines.
|
|
377
|
+
*/
|
|
378
|
+
function commandRows(gutter, command, room) {
|
|
379
|
+
const out = []
|
|
380
|
+
const lines = sanitize(command).split('\n')
|
|
381
|
+
lines.forEach((line, index) => {
|
|
382
|
+
const prompt = index === 0 ? '$ ' : ' '
|
|
383
|
+
for (const wrapped of wrapSegments(seg(prompt + line, ''), room)) {
|
|
384
|
+
out.push(wrapped)
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
return out
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Markdown body of a non-shell tool result, honouring the raw toggle. */
|
|
391
|
+
function contextualBody(entry, context) {
|
|
392
|
+
const text = entry.rawText ?? entry.text ?? ''
|
|
393
|
+
if (context.raw === true) {
|
|
394
|
+
return wrapSegments(seg(sanitize(text), context.theme.ui.output), context.width)
|
|
395
|
+
}
|
|
396
|
+
return renderMarkdown(text, {
|
|
397
|
+
width: context.width,
|
|
398
|
+
md: context.theme.md,
|
|
399
|
+
syn: context.theme.syn
|
|
400
|
+
})
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function clamp(value, min, max) {
|
|
404
|
+
if (!Number.isFinite(value)) return min
|
|
405
|
+
return Math.max(min, Math.min(max, value))
|
|
406
|
+
}
|