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,396 @@
1
+ /**
2
+ * The dashboard's view model: fold protocol records into the state a frame
3
+ * renders from.
4
+ *
5
+ * This module is pure with respect to the terminal — it knows nothing about
6
+ * ANSI, sizes, or input — so the mapping from a wire record to visible state is
7
+ * directly testable, and the renderer stays a dumb projection.
8
+ *
9
+ * @module dsh-live-trace/view-state
10
+ */
11
+
12
+ /** Entries retained in the viewer's own scrollback, independent of the plugin's backlog. */
13
+ export const MAX_VIEW_ENTRIES = 5000
14
+
15
+ /**
16
+ * @typedef {object} ViewState
17
+ * @property {object | null} server
18
+ * @property {boolean} connected
19
+ * @property {object[]} sessions
20
+ * @property {string | null} sessionId
21
+ * @property {object[]} entries
22
+ * @property {object | null} stream
23
+ * @property {object} status
24
+ * @property {object} usage
25
+ * @property {number | null} contextWindow
26
+ * @property {number} lastRecordAt
27
+ * @property {number} recordCount
28
+ * @property {string | null} serverError
29
+ * @property {number} droppedEntries
30
+ * @property {'trace' | 'sessions' | 'edits' | 'commands'} view active panel
31
+ * @property {boolean} sessionsOverlay switcher shown over any panel
32
+ * @property {object[]} edits aggregated file changes for the bound session
33
+ * @property {Map<string, number>} entryIndex entry key -> position, for in-place updates
34
+ * @property {Record<string, number>} scroll per-panel scroll offset
35
+ * @property {{ sessions: number, edits: number }} cursor selection per navigable panel
36
+ * @property {boolean} rawText disable markdown rendering
37
+ * @property {boolean} expandThinking show thinking in full; only a key press changes it
38
+ */
39
+
40
+ /**
41
+ * @param {{ now?: number }} [options]
42
+ * @returns {ViewState}
43
+ */
44
+ export function createViewState(options = {}) {
45
+ return {
46
+ server: null,
47
+ connected: false,
48
+ sessions: [],
49
+ sessionId: null,
50
+ entries: [],
51
+ stream: null,
52
+ status: {
53
+ status: 'idle',
54
+ agentStatus: 'idle',
55
+ turn: 0,
56
+ step: 0,
57
+ maxStep: 0,
58
+ since: null,
59
+ error: null,
60
+ streamActive: false
61
+ },
62
+ usage: {},
63
+ contextWindow: null,
64
+ lastRecordAt: options.now ?? 0,
65
+ recordCount: 0,
66
+ serverError: null,
67
+ droppedEntries: 0,
68
+ turnStartedAt: null,
69
+ pendingCalls: new Map(),
70
+ entrySeq: 0,
71
+ view: 'trace',
72
+ sessionsOverlay: false,
73
+ edits: [],
74
+ entryIndex: new Map(),
75
+ scroll: { trace: 0, sessions: 0, edits: 0, commands: 0 },
76
+ cursor: { sessions: 0, edits: 0 },
77
+ rawText: false,
78
+ // Thinking starts visible and only collapses when the user asks: while the
79
+ // model reasons, the trace should follow it rather than fold it away.
80
+ expandThinking: true
81
+ }
82
+ }
83
+
84
+ /** Panels the viewer can show. */
85
+ export const VIEWS = ['trace', 'sessions', 'edits', 'commands']
86
+
87
+ /**
88
+ * Bind the view to a different session, discarding the previous session's
89
+ * scrollback. The caller is responsible for asking the plugin to replay.
90
+ * @param {ViewState} state
91
+ * @param {string | null} sessionId
92
+ */
93
+ export function bindSession(state, sessionId) {
94
+ state.sessionId = sessionId
95
+ state.entries = []
96
+ state.entryIndex.clear()
97
+ state.stream = null
98
+ state.pendingCalls.clear()
99
+ state.droppedEntries = 0
100
+ state.edits = []
101
+ state.scroll.trace = 0
102
+ state.scroll.commands = 0
103
+ state.scroll.edits = 0
104
+ state.cursor.edits = 0
105
+ state.usage = {}
106
+ state.contextWindow = null
107
+ state.status = {
108
+ status: 'idle',
109
+ agentStatus: 'idle',
110
+ turn: 0,
111
+ step: 0,
112
+ maxStep: 0,
113
+ since: null,
114
+ error: null,
115
+ streamActive: false
116
+ }
117
+ state.turnStartedAt = null
118
+ }
119
+
120
+ /**
121
+ * Fold one server record into the view state.
122
+ * @param {ViewState} state
123
+ * @param {any} record
124
+ * @returns {{ changed: boolean, kind: string, appended: boolean }}
125
+ */
126
+ export function applyRecord(state, record) {
127
+ if (record === null || typeof record !== 'object' || typeof record.kind !== 'string') {
128
+ return { changed: false, kind: 'invalid', appended: false }
129
+ }
130
+ state.recordCount += 1
131
+ state.lastRecordAt = Date.now()
132
+
133
+ switch (record.kind) {
134
+ case 'hello': {
135
+ state.server = record.server ?? null
136
+ state.connected = true
137
+ state.serverError = null
138
+ state.sessions = Array.isArray(record.sessions) ? record.sessions : []
139
+ if (state.sessionId === null) {
140
+ const preferred =
141
+ state.sessions.find((session) => session.id === record.activeSessionId) ??
142
+ state.sessions[0] ??
143
+ null
144
+ bindSession(state, preferred?.id ?? null)
145
+ }
146
+ return { changed: true, kind: 'hello', appended: false }
147
+ }
148
+
149
+ case 'sessions': {
150
+ state.sessions = Array.isArray(record.sessions) ? record.sessions : state.sessions
151
+ const bindingGone =
152
+ state.sessionId === null || !state.sessions.some((session) => session.id === state.sessionId)
153
+ if (bindingGone) {
154
+ const next =
155
+ state.sessions.find((session) => session.id === record.activeSessionId) ?? state.sessions[0] ?? null
156
+ bindSession(state, next?.id ?? null)
157
+ }
158
+ return { changed: true, kind: 'sessions', appended: false }
159
+ }
160
+
161
+ case 'entry': {
162
+ if (record.sessionId !== state.sessionId) return { changed: false, kind: 'entry-other', appended: false }
163
+ const entry = normalizeEntry(record.entry, state)
164
+ if (entry.tag === 'turn' && typeof entry.label === 'string') {
165
+ if (entry.label.endsWith('END')) state.turnStartedAt = null
166
+ else state.turnStartedAt = entry.at
167
+ }
168
+
169
+ // A keyed entry updates the row it already owns. A tool call and its
170
+ // result share a key, so the "running" row is upgraded in place instead
171
+ // of leaving a dangling half behind; the position is preserved so the
172
+ // trace never reorders under the reader.
173
+ const existing = entry.key === undefined ? undefined : state.entryIndex.get(entry.key)
174
+ if (existing !== undefined && state.entries[existing] !== undefined) {
175
+ const previous = state.entries[existing]
176
+ state.entries[existing] = { ...entry, key: previous.key }
177
+ return { changed: true, kind: 'entry', appended: false, replaced: true }
178
+ }
179
+
180
+ state.entries.push(entry)
181
+ if (entry.key !== undefined) state.entryIndex.set(entry.key, state.entries.length - 1)
182
+ if (state.entries.length > MAX_VIEW_ENTRIES) {
183
+ const dropped = state.entries.length - MAX_VIEW_ENTRIES
184
+ state.entries.splice(0, dropped)
185
+ state.droppedEntries += dropped
186
+ reindexEntries(state)
187
+ }
188
+ return { changed: true, kind: 'entry', appended: true }
189
+ }
190
+
191
+ case 'edits': {
192
+ if (record.sessionId !== state.sessionId) return { changed: false, kind: 'edits-other', appended: false }
193
+ state.edits = Array.isArray(record.edits) ? record.edits : []
194
+ if (state.cursor.edits >= state.edits.length) state.cursor.edits = Math.max(0, state.edits.length - 1)
195
+ state.scroll.edits = 0
196
+ return { changed: true, kind: 'edits', appended: false }
197
+ }
198
+
199
+ case 'stream': {
200
+ if (record.sessionId !== state.sessionId) return { changed: false, kind: 'stream-other', appended: false }
201
+ state.stream = {
202
+ text: typeof record.text === 'string' ? record.text : '',
203
+ reasoning: typeof record.reasoning === 'string' ? record.reasoning : '',
204
+ turn: record.turn ?? 0,
205
+ step: record.step ?? 0,
206
+ at: record.at ?? Date.now()
207
+ }
208
+ return { changed: true, kind: 'stream', appended: false }
209
+ }
210
+
211
+ case 'stream-end': {
212
+ if (record.sessionId !== state.sessionId) return { changed: false, kind: 'stream-end-other', appended: false }
213
+ state.stream = null
214
+ return { changed: true, kind: 'stream-end', appended: false }
215
+ }
216
+
217
+ case 'status': {
218
+ if (record.sessionId !== state.sessionId) return { changed: false, kind: 'status-other', appended: false }
219
+ state.status = {
220
+ status: record.status ?? 'idle',
221
+ agentStatus: record.agentStatus ?? 'idle',
222
+ turn: record.turn ?? 0,
223
+ step: record.step ?? 0,
224
+ maxStep: record.maxStep ?? 0,
225
+ since: record.since ?? null,
226
+ error: record.error ?? null,
227
+ streamActive: record.streamActive === true
228
+ }
229
+ return { changed: true, kind: 'status', appended: false }
230
+ }
231
+
232
+ case 'usage': {
233
+ if (record.sessionId !== state.sessionId) return { changed: false, kind: 'usage-other', appended: false }
234
+ state.usage = record.usage ?? {}
235
+ state.contextWindow = record.contextWindow ?? null
236
+ return { changed: true, kind: 'usage', appended: false }
237
+ }
238
+
239
+ case 'heartbeat':
240
+ state.connected = true
241
+ return { changed: false, kind: 'heartbeat', appended: false }
242
+
243
+ case 'error':
244
+ state.serverError = typeof record.message === 'string' ? record.message : 'server error'
245
+ return { changed: true, kind: 'error', appended: false }
246
+
247
+ default:
248
+ return { changed: false, kind: 'unknown', appended: false }
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Normalize one wire entry and enrich it with viewer-side facts (stable key and
254
+ * tool duration, which only the viewer knows because it saw both endpoints).
255
+ */
256
+ function normalizeEntry(raw, state) {
257
+ const entry = raw !== null && typeof raw === 'object' ? { ...raw } : { text: String(raw) }
258
+ state.entrySeq += 1
259
+ // A key supplied by the plugin is authoritative: it is what lets a tool
260
+ // result replace the row its call already occupies. Only keyless entries get
261
+ // a viewer-local identity.
262
+ entry.key =
263
+ typeof entry.key === 'string' && entry.key.length > 0
264
+ ? entry.key
265
+ : `${state.sessionId ?? 's'}:${entry.seq ?? 'x'}:${state.entrySeq}`
266
+ entry.at = typeof entry.time === 'number' ? entry.time : Date.now()
267
+ if (entry.tag === 'tool' && typeof entry.callId === 'string') {
268
+ state.pendingCalls.set(entry.callId, { name: entry.tool ?? '', at: entry.at })
269
+ } else if (entry.tag === 'result') {
270
+ const callId = typeof entry.callId === 'string' ? entry.callId : null
271
+ if (callId !== null) {
272
+ const pending = state.pendingCalls.get(callId)
273
+ if (pending !== undefined) {
274
+ entry.durationMs = Math.max(0, entry.at - pending.at)
275
+ state.pendingCalls.delete(callId)
276
+ }
277
+ } else if (state.pendingCalls.size === 1) {
278
+ // Some tools answer a call the log never paired with an id; fall back to
279
+ // the single outstanding call rather than dropping the duration.
280
+ const [onlyId, pending] = [...state.pendingCalls.entries()][0]
281
+ entry.durationMs = Math.max(0, entry.at - pending.at)
282
+ state.pendingCalls.delete(onlyId)
283
+ }
284
+ }
285
+ return entry
286
+ }
287
+
288
+ /**
289
+ * Rebuild the key index after the ring buffer drops its oldest entries.
290
+ *
291
+ * Without this the index would still point at positions that have shifted,
292
+ * and a tool result would replace the wrong row.
293
+ */
294
+ function reindexEntries(state) {
295
+ state.entryIndex.clear()
296
+ state.entries.forEach((entry, index) => {
297
+ if (entry.key !== undefined) state.entryIndex.set(entry.key, index)
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Every shell command the bound session has run, oldest first.
303
+ *
304
+ * Derived from the trace rather than a separate wire record: the entry already
305
+ * carries the command, its output, and its settlement once both halves of the
306
+ * call have been received.
307
+ *
308
+ * @param {ViewState} state
309
+ * @returns {object[]}
310
+ */
311
+ export function commandsOf(state) {
312
+ const commands = []
313
+ for (const entry of state.entries) {
314
+ if (entry.tag !== 'tool' || entry.shell === undefined) continue
315
+ commands.push({
316
+ key: entry.key,
317
+ callId: entry.callId,
318
+ command: entry.shell.command,
319
+ description: entry.shell.description,
320
+ cwd: entry.shell.cwd,
321
+ at: entry.at,
322
+ turn: entry.turn,
323
+ step: entry.step,
324
+ pending: entry.phase === 'call',
325
+ ok: entry.ok,
326
+ exitCode: entry.shellStatus?.exitCode,
327
+ signal: entry.shellStatus?.signal,
328
+ durationMs: entry.durationMs,
329
+ output: entry.output,
330
+ outputText: entry.output?.text ?? entry.rawText ?? '',
331
+ truncated: entry.output?.truncated === true
332
+ })
333
+ }
334
+ return commands
335
+ }
336
+
337
+ /**
338
+ * Aggregate edit totals for the header of the edits panel.
339
+ * @param {ViewState} state
340
+ * @returns {{ files: number, added: number, removed: number }}
341
+ */
342
+ export function editTotals(state) {
343
+ let added = 0
344
+ let removed = 0
345
+ for (const edit of state.edits) {
346
+ added += edit.added ?? 0
347
+ removed += edit.removed ?? 0
348
+ }
349
+ return { files: state.edits.length, added, removed }
350
+ }
351
+
352
+ /**
353
+ * One row per known session, for the landing screen and the switcher.
354
+ *
355
+ * @param {ViewState} state
356
+ * @param {number} [now]
357
+ * @returns {object[]}
358
+ */
359
+ export function sessionRows(state, now = Date.now()) {
360
+ return state.sessions.map((session, index) => ({
361
+ index,
362
+ id: session.id,
363
+ title: session.title,
364
+ cwd: session.cwd,
365
+ agentPreset: session.agentPreset,
366
+ origin: session.origin,
367
+ delegationDepth: session.delegationDepth,
368
+ turn: session.turn ?? 0,
369
+ step: session.step ?? 0,
370
+ activity: session.activity ?? 'idle',
371
+ lastEventAt: session.lastEventAt ?? null,
372
+ age: typeof session.createdAt === 'number' ? now - session.createdAt : null,
373
+ idleFor: typeof session.lastEventAt === 'number' ? now - session.lastEventAt : null,
374
+ bound: session.id === state.sessionId,
375
+ selected: index === state.cursor.sessions
376
+ }))
377
+ }
378
+
379
+ /**
380
+ * The session summary the view is currently bound to.
381
+ * @param {ViewState} state
382
+ * @returns {object | null}
383
+ */
384
+ export function activeSession(state) {
385
+ return state.sessions.find((session) => session.id === state.sessionId) ?? null
386
+ }
387
+
388
+ /**
389
+ * Total tokens for the footer: the provider total when reported, else input+output.
390
+ * @param {object} usage
391
+ * @returns {number}
392
+ */
393
+ export function usageTotal(usage) {
394
+ if (typeof usage?.totalTokens === 'number' && usage.totalTokens > 0) return usage.totalTokens
395
+ return (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0)
396
+ }