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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -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
+ }