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/main.js
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `dsh-live-trace` command.
|
|
3
|
+
*
|
|
4
|
+
* The command discovers a running Harness observer, connects to its socket, and
|
|
5
|
+
* renders the live dashboard in either the alternate screen or plain
|
|
6
|
+
* line-oriented mode. It never writes to the Harness and never sends anything
|
|
7
|
+
* but a session selection, a replay request, or a ping.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-live-trace/main
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
|
|
14
|
+
import { connectTrace } from '../../lib/client.js'
|
|
15
|
+
import { resolveRuntimeDir, serversDir } from '../../lib/paths.js'
|
|
16
|
+
import { pruneServerRecords, selectServerRecord, selectSession } from '../../lib/registry.js'
|
|
17
|
+
import { createTranslator, nextLanguage, resolveLanguage } from './i18n.js'
|
|
18
|
+
import { renderFrame, renderPlainEntry } from './renderer.js'
|
|
19
|
+
import { Screen } from './screen.js'
|
|
20
|
+
import { createTheme } from './theme.js'
|
|
21
|
+
import { activeSession, applyRecord, bindSession, createViewState } from './view-state.js'
|
|
22
|
+
|
|
23
|
+
const HELP = `dsh-live-trace — read-only live terminal dashboard for DeepSeek Harness sessions
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
dsh-live-trace [options]
|
|
27
|
+
|
|
28
|
+
Discovery:
|
|
29
|
+
The dashboard attaches to a running Harness process whose \`dsh-live-trace\`
|
|
30
|
+
plugin is loaded. It reads that process's registry record from
|
|
31
|
+
<runtime-dir>/servers and connects to its socket in <runtime-dir>/sockets.
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-s, --session <id> bind this session id instead of the newest active one
|
|
35
|
+
--socket <path> connect to an explicit plugin socket (skips discovery)
|
|
36
|
+
--server <pid> prefer the Harness process with this pid
|
|
37
|
+
--cwd <path> prefer the server and session for this working directory
|
|
38
|
+
--runtime-dir <p> override the runtime directory (default: $DSH_LIVE_TRACE_DIR
|
|
39
|
+
or $DSH_HOME/live-trace)
|
|
40
|
+
--backlog <n> entries to replay on attach (default: 500)
|
|
41
|
+
--interval <ms> repaint interval (default: 120)
|
|
42
|
+
--lang <code> UI language: auto, en, or zh (default: auto, from $LANG)
|
|
43
|
+
--thinking-lines <n> thinking lines shown while collapsed (default: 3)
|
|
44
|
+
--no-mouse do not claim the mouse; use the terminal's own wheel
|
|
45
|
+
translation (drag-select then works without Shift)
|
|
46
|
+
--list list discovered Harness observers and sessions, then exit
|
|
47
|
+
--plain line-oriented output, one line per event (no full screen)
|
|
48
|
+
--alt-screen force the full-screen view even when stdout is not a TTY
|
|
49
|
+
--no-color disable ANSI color
|
|
50
|
+
-h, --help show this help
|
|
51
|
+
-V, --version show the version
|
|
52
|
+
|
|
53
|
+
Keys (full-screen mode):
|
|
54
|
+
q / Ctrl-C quit ↑/↓ scroll PgUp/PgDn page
|
|
55
|
+
Home/End oldest/newest s next session p pause
|
|
56
|
+
r replay ? help
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {string[]} argv
|
|
61
|
+
* @returns {{ error?: string, help?: boolean, version?: boolean }}
|
|
62
|
+
*/
|
|
63
|
+
export function parseArgs(argv) {
|
|
64
|
+
const options = {
|
|
65
|
+
session: undefined,
|
|
66
|
+
socket: undefined,
|
|
67
|
+
server: undefined,
|
|
68
|
+
cwd: undefined,
|
|
69
|
+
runtimeDir: undefined,
|
|
70
|
+
backlog: 500,
|
|
71
|
+
interval: 120,
|
|
72
|
+
thinkingLines: 3,
|
|
73
|
+
mouse: true,
|
|
74
|
+
lang: 'auto',
|
|
75
|
+
list: false,
|
|
76
|
+
plain: false,
|
|
77
|
+
altScreen: false,
|
|
78
|
+
color: undefined,
|
|
79
|
+
wait: false
|
|
80
|
+
}
|
|
81
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
82
|
+
const arg = argv[index]
|
|
83
|
+
const next = () => {
|
|
84
|
+
const value = argv[index + 1]
|
|
85
|
+
if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${arg}`)
|
|
86
|
+
index += 1
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
switch (arg) {
|
|
91
|
+
case '-h':
|
|
92
|
+
case '--help':
|
|
93
|
+
options.help = true
|
|
94
|
+
break
|
|
95
|
+
case '-V':
|
|
96
|
+
case '--version':
|
|
97
|
+
options.version = true
|
|
98
|
+
break
|
|
99
|
+
case '-s':
|
|
100
|
+
case '--session':
|
|
101
|
+
options.session = next()
|
|
102
|
+
break
|
|
103
|
+
case '--socket':
|
|
104
|
+
options.socket = next()
|
|
105
|
+
break
|
|
106
|
+
case '--server':
|
|
107
|
+
options.server = Number.parseInt(next(), 10)
|
|
108
|
+
break
|
|
109
|
+
case '--cwd':
|
|
110
|
+
options.cwd = next()
|
|
111
|
+
break
|
|
112
|
+
case '--runtime-dir':
|
|
113
|
+
options.runtimeDir = next()
|
|
114
|
+
break
|
|
115
|
+
case '--backlog':
|
|
116
|
+
options.backlog = clamp(Number.parseInt(next(), 10), 1, 5000, 500)
|
|
117
|
+
break
|
|
118
|
+
case '--interval':
|
|
119
|
+
options.interval = clamp(Number.parseInt(next(), 10), 40, 2000, 120)
|
|
120
|
+
break
|
|
121
|
+
case '--thinking-lines':
|
|
122
|
+
options.thinkingLines = clamp(Number.parseInt(next(), 10), 1, 200, 3)
|
|
123
|
+
break
|
|
124
|
+
case '--lang':
|
|
125
|
+
options.lang = next()
|
|
126
|
+
break
|
|
127
|
+
case '--mouse':
|
|
128
|
+
options.mouse = true
|
|
129
|
+
break
|
|
130
|
+
case '--no-mouse':
|
|
131
|
+
options.mouse = false
|
|
132
|
+
break
|
|
133
|
+
case '--list':
|
|
134
|
+
options.list = true
|
|
135
|
+
break
|
|
136
|
+
case '--plain':
|
|
137
|
+
options.plain = true
|
|
138
|
+
break
|
|
139
|
+
case '--alt-screen':
|
|
140
|
+
options.altScreen = true
|
|
141
|
+
break
|
|
142
|
+
case '--no-color':
|
|
143
|
+
options.color = false
|
|
144
|
+
break
|
|
145
|
+
case '--color':
|
|
146
|
+
options.color = true
|
|
147
|
+
break
|
|
148
|
+
case '--wait':
|
|
149
|
+
options.wait = true
|
|
150
|
+
break
|
|
151
|
+
default:
|
|
152
|
+
if (arg.startsWith('-')) return { error: `unknown option: ${arg}` }
|
|
153
|
+
return { error: `unexpected argument: ${arg}` }
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { error: error instanceof Error ? error.message : String(error) }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return options
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function clamp(value, min, max, fallback) {
|
|
163
|
+
if (!Number.isFinite(value)) return fallback
|
|
164
|
+
return Math.max(min, Math.min(max, value))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {string[]} argv
|
|
169
|
+
* @param {{ stdout?: NodeJS.WriteStream, stderr?: NodeJS.WriteStream, stdin?: NodeJS.ReadStream, env?: NodeJS.ProcessEnv, version?: string, cwd?: string }} [io]
|
|
170
|
+
* @returns {Promise<number>} process exit code
|
|
171
|
+
*/
|
|
172
|
+
export async function run(argv, io = {}) {
|
|
173
|
+
const stdout = io.stdout ?? process.stdout
|
|
174
|
+
const stderr = io.stderr ?? process.stderr
|
|
175
|
+
const env = io.env ?? process.env
|
|
176
|
+
const cwd = io.cwd ?? process.cwd()
|
|
177
|
+
|
|
178
|
+
const parsed = parseArgs(argv)
|
|
179
|
+
if (parsed.error !== undefined) {
|
|
180
|
+
stderr.write(`${parsed.error}\n\n${HELP}`)
|
|
181
|
+
return 2
|
|
182
|
+
}
|
|
183
|
+
if (parsed.help === true) {
|
|
184
|
+
stdout.write(HELP)
|
|
185
|
+
return 0
|
|
186
|
+
}
|
|
187
|
+
if (parsed.version === true) {
|
|
188
|
+
stdout.write(`${io.version ?? '0.1.0'}\n`)
|
|
189
|
+
return 0
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const runtimeDir = parsed.runtimeDir !== undefined ? parsed.runtimeDir : resolveRuntimeDir(env)
|
|
193
|
+
const registryDir = serversDir(runtimeDir)
|
|
194
|
+
|
|
195
|
+
if (parsed.list === true) {
|
|
196
|
+
printDiscovery(stdout, runtimeDir, registryDir)
|
|
197
|
+
return 0
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const server = await discover(parsed, registryDir, cwd, stderr, { wait: parsed.wait === true })
|
|
201
|
+
if (server === null) return 1
|
|
202
|
+
|
|
203
|
+
const session = server.sessions === undefined ? undefined : selectSession(server, { sessionId: parsed.session, cwd: parsed.cwd })
|
|
204
|
+
const socketPath = server.socket
|
|
205
|
+
|
|
206
|
+
if (!existsSync(socketPath) && parsed.socket !== undefined) {
|
|
207
|
+
stderr.write(`dsh-live-trace: no socket at ${socketPath}\n`)
|
|
208
|
+
return 1
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const color = parsed.color ?? (env.NO_COLOR === undefined && stdout.isTTY === true)
|
|
212
|
+
const theme = createTheme({ color })
|
|
213
|
+
const translator = createTranslator(resolveLanguage(parsed.lang, env))
|
|
214
|
+
const state = createViewState({ now: Date.now() })
|
|
215
|
+
if (Array.isArray(server.sessions)) state.sessions = server.sessions
|
|
216
|
+
bindSession(state, session?.id ?? null)
|
|
217
|
+
|
|
218
|
+
// Landing rule: one session means the trace is what you came for; several
|
|
219
|
+
// concurrent sessions mean choosing is the first thing you need to do. An
|
|
220
|
+
// explicit --session always wins.
|
|
221
|
+
const known = state.sessions.length
|
|
222
|
+
if (parsed.session === undefined && known > 1) {
|
|
223
|
+
state.sessionsOverlay = true
|
|
224
|
+
const boundIndex = state.sessions.findIndex((item) => item.id === state.sessionId)
|
|
225
|
+
state.cursor.sessions = boundIndex === -1 ? 0 : boundIndex
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const isTty = stdout.isTTY === true
|
|
229
|
+
const useFullScreen = parsed.plain !== true && (parsed.altScreen === true || isTty)
|
|
230
|
+
|
|
231
|
+
if (!useFullScreen) {
|
|
232
|
+
return await runPlain({ stdout, stderr, socketPath, state, parsed, translator })
|
|
233
|
+
}
|
|
234
|
+
return await runFullScreen({
|
|
235
|
+
stdout,
|
|
236
|
+
stdin: io.stdin ?? process.stdin,
|
|
237
|
+
stderr,
|
|
238
|
+
socketPath,
|
|
239
|
+
state,
|
|
240
|
+
parsed,
|
|
241
|
+
theme,
|
|
242
|
+
translator
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/* ------------------------------------------------------------------ *
|
|
247
|
+
* Discovery
|
|
248
|
+
* ------------------------------------------------------------------ */
|
|
249
|
+
|
|
250
|
+
async function discover(parsed, registryDir, cwd, stderr, options) {
|
|
251
|
+
for (;;) {
|
|
252
|
+
const records = pruneServerRecords(registryDir)
|
|
253
|
+
const server = selectServerRecord(records, {
|
|
254
|
+
socket: parsed.socket,
|
|
255
|
+
pid: Number.isFinite(parsed.server) ? parsed.server : undefined,
|
|
256
|
+
cwd: parsed.cwd ?? cwd
|
|
257
|
+
})
|
|
258
|
+
if (server !== undefined) return server
|
|
259
|
+
if (options.wait !== true) {
|
|
260
|
+
stderr.write(discoveryHelp(registryDir))
|
|
261
|
+
return null
|
|
262
|
+
}
|
|
263
|
+
stderr.write(`dsh-live-trace: no observer yet in ${registryDir}; waiting…\n`)
|
|
264
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function discoveryHelp(registryDir) {
|
|
269
|
+
return [
|
|
270
|
+
'dsh-live-trace: no running Harness observer found.',
|
|
271
|
+
'',
|
|
272
|
+
`Looked in: ${registryDir}`,
|
|
273
|
+
'',
|
|
274
|
+
'The dashboard reads events published by the dsh-live-trace Host plugin.',
|
|
275
|
+
'Install and enable it in the profile your Harness runs, then restart (or',
|
|
276
|
+
'let hot reload pick it up) and run this command again:',
|
|
277
|
+
'',
|
|
278
|
+
' dsh plugin --profile web add <path-to>/dsh-live-trace',
|
|
279
|
+
'',
|
|
280
|
+
'Use `dsh-live-trace --list` to see what was discovered, or `--socket <path>`',
|
|
281
|
+
'to attach to a known socket directly. Add `--wait` to poll instead of exiting.',
|
|
282
|
+
''
|
|
283
|
+
].join('\n')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function printDiscovery(stdout, runtimeDir, registryDir) {
|
|
287
|
+
const records = pruneServerRecords(registryDir)
|
|
288
|
+
stdout.write(`runtime dir: ${runtimeDir}\n`)
|
|
289
|
+
stdout.write(`servers dir: ${registryDir}\n\n`)
|
|
290
|
+
if (records.length === 0) {
|
|
291
|
+
stdout.write('No running Harness observers.\n')
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
for (const record of records) {
|
|
295
|
+
stdout.write(`pid ${record.pid} profile=${record.profile ?? '-'} cwd=${record.cwd ?? '-'}\n`)
|
|
296
|
+
stdout.write(` socket: ${record.socket}\n`)
|
|
297
|
+
stdout.write(` version: ${record.version ?? '-'} heartbeat: ${record.heartbeat ?? '-'}\n`)
|
|
298
|
+
const sessions = Array.isArray(record.sessions) ? record.sessions : []
|
|
299
|
+
if (sessions.length === 0) {
|
|
300
|
+
stdout.write(' sessions: none yet\n')
|
|
301
|
+
} else {
|
|
302
|
+
stdout.write(' sessions:\n')
|
|
303
|
+
for (const session of sessions) {
|
|
304
|
+
const mark = session.id === record.activeSessionId ? '*' : ' '
|
|
305
|
+
const title = session.title !== undefined ? ` "${session.title}"` : ''
|
|
306
|
+
stdout.write(
|
|
307
|
+
` ${mark} ${session.id} turn=${session.turn ?? 0} step=${session.step ?? 0} ${session.cwd ?? ''}${title}\n`
|
|
308
|
+
)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
stdout.write('\n')
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* ------------------------------------------------------------------ *
|
|
316
|
+
* Plain mode
|
|
317
|
+
* ------------------------------------------------------------------ */
|
|
318
|
+
|
|
319
|
+
async function runPlain({ stdout, stderr, socketPath, state, parsed, translator }) {
|
|
320
|
+
const client = connectTrace({
|
|
321
|
+
socketPath,
|
|
322
|
+
sessionId: state.sessionId,
|
|
323
|
+
replayLimit: parsed.backlog,
|
|
324
|
+
onRecord: (record) => {
|
|
325
|
+
const result = applyRecord(state, record)
|
|
326
|
+
// A settled tool block replaces the running row it opened, so plain mode
|
|
327
|
+
// logs both: the call when it starts, the outcome when it lands.
|
|
328
|
+
if (result.kind === 'entry' && (result.appended || result.replaced)) {
|
|
329
|
+
stdout.write(`${renderPlainEntry(state.entries[state.entries.length - 1], 200, translator.t)}\n`)
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
onError: (error) => stderr.write(`dsh-live-trace: ${error.message}\n`)
|
|
333
|
+
})
|
|
334
|
+
return await new Promise((resolvePromise) => {
|
|
335
|
+
const stop = () => {
|
|
336
|
+
process.off('SIGINT', stop)
|
|
337
|
+
process.off('SIGTERM', stop)
|
|
338
|
+
client.close()
|
|
339
|
+
resolvePromise(0)
|
|
340
|
+
}
|
|
341
|
+
process.once('SIGINT', stop)
|
|
342
|
+
process.once('SIGTERM', stop)
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* ------------------------------------------------------------------ *
|
|
347
|
+
* Full-screen mode
|
|
348
|
+
* ------------------------------------------------------------------ */
|
|
349
|
+
|
|
350
|
+
async function runFullScreen({ stdout, stdin, stderr, socketPath, state, parsed, theme, translator }) {
|
|
351
|
+
const screen = new Screen({ stdout, stdin, altScreen: true, mouse: parsed.mouse !== false })
|
|
352
|
+
let paused = false
|
|
353
|
+
let help = false
|
|
354
|
+
let spinner = 0
|
|
355
|
+
let quitResolve
|
|
356
|
+
const done = new Promise((resolvePromise) => {
|
|
357
|
+
quitResolve = resolvePromise
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const client = connectTrace({
|
|
361
|
+
socketPath,
|
|
362
|
+
sessionId: state.sessionId,
|
|
363
|
+
replayLimit: parsed.backlog,
|
|
364
|
+
onRecord: (record) => {
|
|
365
|
+
if (paused && record.kind === 'entry') return
|
|
366
|
+
// No scroll adjustment here on purpose. The trace is bottom-anchored, so
|
|
367
|
+
// an offset of zero already follows new content; forcing it back to zero
|
|
368
|
+
// would yank the view away from anyone reading history.
|
|
369
|
+
applyRecord(state, record)
|
|
370
|
+
},
|
|
371
|
+
onError: () => {
|
|
372
|
+
/* reconnect is automatic; a transient error is not worth a stderr write */
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// The last painted window, so mouse input can map a screen row back to the
|
|
377
|
+
// list item it shows.
|
|
378
|
+
const lastFrame = {}
|
|
379
|
+
let translator2 = translator
|
|
380
|
+
|
|
381
|
+
const draw = () => {
|
|
382
|
+
const { cols, rows } = screen.size()
|
|
383
|
+
spinner += 1
|
|
384
|
+
screen.paint(
|
|
385
|
+
renderFrame(
|
|
386
|
+
state,
|
|
387
|
+
{
|
|
388
|
+
cols,
|
|
389
|
+
rows,
|
|
390
|
+
theme,
|
|
391
|
+
now: Date.now(),
|
|
392
|
+
frame: spinner,
|
|
393
|
+
scrollOffset: state.scroll.trace,
|
|
394
|
+
panelOffset: state.scroll[state.sessionsOverlay ? 'sessions' : state.view] ?? 0,
|
|
395
|
+
view: state.view,
|
|
396
|
+
sessionsOverlay: state.sessionsOverlay,
|
|
397
|
+
paused,
|
|
398
|
+
help,
|
|
399
|
+
rawText: state.rawText,
|
|
400
|
+
expandThinking: state.expandThinking,
|
|
401
|
+
thinkingLines: parsed.thinkingLines,
|
|
402
|
+
t: translator2.t
|
|
403
|
+
},
|
|
404
|
+
lastFrame
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
screen.enter()
|
|
410
|
+
screen.onResize(draw)
|
|
411
|
+
|
|
412
|
+
const pageSize = () => Math.max(1, screen.size().rows - 6)
|
|
413
|
+
const activePanel = () => (state.sessionsOverlay ? 'sessions' : state.view)
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Scroll the active panel. Positive means "further down" — toward newer
|
|
417
|
+
* entries in the bottom-anchored trace, toward the end of a top-anchored
|
|
418
|
+
* list. One sign convention keeps the wheel, the arrows, and paging from
|
|
419
|
+
* disagreeing about which way is up.
|
|
420
|
+
*/
|
|
421
|
+
const scrollBy = (lines) => {
|
|
422
|
+
const panel = activePanel()
|
|
423
|
+
if (panel === 'trace') {
|
|
424
|
+
state.scroll.trace = Math.max(0, state.scroll.trace - lines)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
const next = (state.scroll[panel] ?? 0) + lines
|
|
428
|
+
state.scroll[panel] = Math.max(0, Math.min(next, Number.MAX_SAFE_INTEGER))
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
screen.onKey((key) => {
|
|
432
|
+
if (typeof key !== 'string') {
|
|
433
|
+
// A mouse click selects whatever row it landed on.
|
|
434
|
+
selectClickedItem(state, key, lastFrame, client)
|
|
435
|
+
draw()
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
switch (key) {
|
|
439
|
+
case 'quit':
|
|
440
|
+
case 'q':
|
|
441
|
+
quitResolve(0)
|
|
442
|
+
return
|
|
443
|
+
case 'escape':
|
|
444
|
+
// Escape backs out of a panel (or the picker) before it ever quits.
|
|
445
|
+
if (state.sessionsOverlay) state.sessionsOverlay = false
|
|
446
|
+
else if (state.view !== 'trace') state.view = 'trace'
|
|
447
|
+
else quitResolve(0)
|
|
448
|
+
return
|
|
449
|
+
case 'up':
|
|
450
|
+
moveSelection(state, -1)
|
|
451
|
+
break
|
|
452
|
+
case 'down':
|
|
453
|
+
moveSelection(state, 1)
|
|
454
|
+
break
|
|
455
|
+
case 'wheel-up':
|
|
456
|
+
case 'wheel-left':
|
|
457
|
+
// One wheel notch is deliberately three lines: a single line per notch
|
|
458
|
+
// reads as no movement at all on a high-resolution wheel.
|
|
459
|
+
scrollBy(-WHEEL_LINES)
|
|
460
|
+
break
|
|
461
|
+
case 'wheel-down':
|
|
462
|
+
case 'wheel-right':
|
|
463
|
+
scrollBy(WHEEL_LINES)
|
|
464
|
+
break
|
|
465
|
+
case 'page-up':
|
|
466
|
+
case 'ctrl-up':
|
|
467
|
+
scrollBy(-pageSize())
|
|
468
|
+
break
|
|
469
|
+
case 'page-down':
|
|
470
|
+
case 'ctrl-down':
|
|
471
|
+
scrollBy(pageSize())
|
|
472
|
+
break
|
|
473
|
+
case 'home':
|
|
474
|
+
scrollBy(-Number.MAX_SAFE_INTEGER)
|
|
475
|
+
break
|
|
476
|
+
case 'end':
|
|
477
|
+
scrollBy(Number.MAX_SAFE_INTEGER)
|
|
478
|
+
break
|
|
479
|
+
case 'enter':
|
|
480
|
+
case '\r':
|
|
481
|
+
case '\n':
|
|
482
|
+
openSelectedSession(state, client)
|
|
483
|
+
break
|
|
484
|
+
case 'tab':
|
|
485
|
+
cycleView(state, 1)
|
|
486
|
+
break
|
|
487
|
+
case 'm':
|
|
488
|
+
state.rawText = !state.rawText
|
|
489
|
+
break
|
|
490
|
+
case 'e':
|
|
491
|
+
state.expandThinking = !state.expandThinking
|
|
492
|
+
break
|
|
493
|
+
case 'l':
|
|
494
|
+
translator2 = createTranslator(nextLanguage(translator2.language))
|
|
495
|
+
break
|
|
496
|
+
case 'p':
|
|
497
|
+
paused = !paused
|
|
498
|
+
break
|
|
499
|
+
case '?':
|
|
500
|
+
help = !help
|
|
501
|
+
break
|
|
502
|
+
case 'r':
|
|
503
|
+
client.replay(parsed.backlog)
|
|
504
|
+
break
|
|
505
|
+
case '1':
|
|
506
|
+
case 't':
|
|
507
|
+
state.sessionsOverlay = false
|
|
508
|
+
state.view = 'trace'
|
|
509
|
+
break
|
|
510
|
+
case '2':
|
|
511
|
+
case 's':
|
|
512
|
+
state.sessionsOverlay = true
|
|
513
|
+
break
|
|
514
|
+
case '3':
|
|
515
|
+
case 'd':
|
|
516
|
+
state.sessionsOverlay = false
|
|
517
|
+
state.view = 'edits'
|
|
518
|
+
break
|
|
519
|
+
case '4':
|
|
520
|
+
case 'c':
|
|
521
|
+
state.sessionsOverlay = false
|
|
522
|
+
state.view = 'commands'
|
|
523
|
+
break
|
|
524
|
+
default:
|
|
525
|
+
break
|
|
526
|
+
}
|
|
527
|
+
draw()
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
const timer = setInterval(draw, parsed.interval)
|
|
531
|
+
timer.unref?.()
|
|
532
|
+
draw()
|
|
533
|
+
|
|
534
|
+
const code = await done
|
|
535
|
+
clearInterval(timer)
|
|
536
|
+
client.close()
|
|
537
|
+
screen.leave()
|
|
538
|
+
const session = activeSession(state)
|
|
539
|
+
if (session !== null && stderr.isTTY === true) {
|
|
540
|
+
stdout.write(`dsh-live-trace: detached from ${session.id}\n`)
|
|
541
|
+
}
|
|
542
|
+
return code
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Lines one wheel notch moves. */
|
|
546
|
+
const WHEEL_LINES = 3
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Select the list item a click landed on.
|
|
550
|
+
*
|
|
551
|
+
* Clicks outside the body, on a non-item row (a header or a diff line), or in
|
|
552
|
+
* a panel with no selection are ignored rather than guessed at.
|
|
553
|
+
*/
|
|
554
|
+
function selectClickedItem(state, event, frame, client) {
|
|
555
|
+
if (event?.type !== 'click') return
|
|
556
|
+
if (!Number.isInteger(frame.start) || !Number.isInteger(frame.bodyRows)) return
|
|
557
|
+
const bodyRow = event.y - 1 - frame.headerRows
|
|
558
|
+
if (bodyRow < 0 || bodyRow >= frame.bodyRows) return
|
|
559
|
+
const item = frame.rowItems?.[frame.start + bodyRow]
|
|
560
|
+
if (item === undefined || item < 0) return
|
|
561
|
+
|
|
562
|
+
if (frame.view === 'sessions') {
|
|
563
|
+
state.cursor.sessions = clampIndex(item, state.sessions.length)
|
|
564
|
+
openSelectedSession(state, client)
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
if (frame.view === 'edits') {
|
|
568
|
+
state.cursor.edits = clampIndex(item, state.edits.length)
|
|
569
|
+
state.scroll.edits = 0
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Move the cursor in whichever list currently owns the arrow keys. */
|
|
574
|
+
function moveSelection(state, delta) {
|
|
575
|
+
const panel = state.sessionsOverlay ? 'sessions' : state.view
|
|
576
|
+
if (panel === 'trace') {
|
|
577
|
+
// Arrows scroll the trace; only the list panels have a cursor.
|
|
578
|
+
state.scroll.trace = Math.max(0, state.scroll.trace - delta)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
if (panel === 'sessions') {
|
|
582
|
+
const count = state.sessions.length
|
|
583
|
+
if (count === 0) return
|
|
584
|
+
state.cursor.sessions = clampIndex(state.cursor.sessions + delta, count)
|
|
585
|
+
return
|
|
586
|
+
}
|
|
587
|
+
if (panel === 'edits') {
|
|
588
|
+
const count = state.edits.length
|
|
589
|
+
if (count === 0) return
|
|
590
|
+
state.cursor.edits = clampIndex(state.cursor.edits + delta, count)
|
|
591
|
+
state.scroll.edits = 0
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function clampIndex(value, count) {
|
|
596
|
+
return Math.max(0, Math.min(count - 1, value))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Bind the session under the picker's cursor. */
|
|
600
|
+
function openSelectedSession(state, client) {
|
|
601
|
+
const row = state.sessions[state.cursor.sessions]
|
|
602
|
+
if (row === undefined) return
|
|
603
|
+
if (row.id !== state.sessionId) {
|
|
604
|
+
bindSession(state, row.id)
|
|
605
|
+
client.select(row.id)
|
|
606
|
+
}
|
|
607
|
+
state.sessionsOverlay = false
|
|
608
|
+
state.view = 'trace'
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Step through the four panels.
|
|
613
|
+
*
|
|
614
|
+
* The session picker is modelled as an overlay rather than a fourth `view`, so
|
|
615
|
+
* leaving it returns to whichever panel was underneath instead of always to the
|
|
616
|
+
* trace.
|
|
617
|
+
*/
|
|
618
|
+
function cycleView(state, delta) {
|
|
619
|
+
const order = ['trace', 'sessions', 'edits', 'commands']
|
|
620
|
+
const current = state.sessionsOverlay ? 'sessions' : state.view
|
|
621
|
+
const next = order[(order.indexOf(current) + delta + order.length) % order.length]
|
|
622
|
+
if (next === 'sessions') {
|
|
623
|
+
state.sessionsOverlay = true
|
|
624
|
+
} else {
|
|
625
|
+
state.sessionsOverlay = false
|
|
626
|
+
state.view = next
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
|