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
|
@@ -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
|
+
}
|