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,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
+