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,569 @@
1
+ /**
2
+ * `dsh-live-working` — a window onto what the orca is doing.
3
+ *
4
+ * The trace dashboard answers "what happened"; this answers "what is it doing
5
+ * right now". It attaches to the same running Harness process as
6
+ * `dsh-live-trace` and draws one animated scene: the orca at its desk, typing,
7
+ * reading, looking things up, on the telephone to a subagent, or asleep.
8
+ *
9
+ * Nothing is sent to the agent. It only reads.
10
+ *
11
+ * @module dsh-live-working/main
12
+ */
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 { shortPath, shortSessionId } from '../format.js'
18
+ import { createTranslator, nextLanguage, resolveLanguage } from '../i18n.js'
19
+ import { Screen } from '../screen.js'
20
+ import { sanitize, truncate } from '../width.js'
21
+ import { createTheme } from '../theme.js'
22
+ import { applyPickerKey, createPickerState } from './picker.js'
23
+ import { bundledRainFile, clampVolume, createRainSound, DEFAULT_VOLUME, VOLUME_STEP } from './sound.js'
24
+ import { renderCells, sceneFrame, sceneSizeFor } from './scene.js'
25
+ import { createWorkState, previewOf, reduceWork, WORK_STATES, workStateOf } from './state.js'
26
+
27
+ /** State keys addressable by number key, in a stable order. */
28
+ const PIN_KEYS = WORK_STATES
29
+
30
+ /** Repaint interval, in milliseconds. */
31
+ const DEFAULT_INTERVAL = 120
32
+
33
+ /**
34
+ * Parse the command line.
35
+ *
36
+ * @param {string[]} argv
37
+ * @returns {Record<string, any>}
38
+ */
39
+ export function parseArgs(argv) {
40
+ const options = {
41
+ interval: DEFAULT_INTERVAL,
42
+ lang: 'auto',
43
+ sound: false,
44
+ rainVolume: DEFAULT_VOLUME,
45
+ scene: 'room',
46
+ backlog: 20,
47
+ replayLimit: 200,
48
+ color: undefined,
49
+ state: null,
50
+ help: false,
51
+ version: false,
52
+ list: false,
53
+ wait: false,
54
+ session: undefined,
55
+ socket: undefined,
56
+ server: undefined,
57
+ cwd: undefined,
58
+ runtimeDir: undefined
59
+ }
60
+ for (let index = 0; index < argv.length; index += 1) {
61
+ const arg = argv[index]
62
+ const next = () => {
63
+ const value = argv[index + 1]
64
+ if (value === undefined || value.startsWith('-')) throw new Error(`missing value for ${arg}`)
65
+ index += 1
66
+ return value
67
+ }
68
+ switch (arg) {
69
+ case '-h':
70
+ case '--help':
71
+ options.help = true
72
+ break
73
+ case '-V':
74
+ case '--version':
75
+ options.version = true
76
+ break
77
+ case '-s':
78
+ case '--session':
79
+ options.session = next()
80
+ break
81
+ case '--socket':
82
+ options.socket = next()
83
+ break
84
+ case '--server':
85
+ options.server = Number.parseInt(next(), 10)
86
+ break
87
+ case '--cwd':
88
+ options.cwd = next()
89
+ break
90
+ case '--runtime-dir':
91
+ options.runtimeDir = next()
92
+ break
93
+ case '--interval': {
94
+ const value = Number.parseInt(next(), 10)
95
+ options.interval = Number.isFinite(value) ? Math.min(2000, Math.max(40, value)) : DEFAULT_INTERVAL
96
+ break
97
+ }
98
+ case '--state':
99
+ options.state = next()
100
+ break
101
+ case '--lang':
102
+ options.lang = next()
103
+ break
104
+ case '--sound':
105
+ options.sound = true
106
+ break
107
+ case '--no-sound':
108
+ options.sound = false
109
+ break
110
+ case '--rain-volume': {
111
+ // A percentage on the command line, a fraction inside.
112
+ const value = Number.parseInt(next(), 10)
113
+ options.rainVolume = clampVolume(Number.isFinite(value) ? value / 100 : DEFAULT_VOLUME)
114
+ break
115
+ }
116
+ case '--scene':
117
+ options.scene = next() === 'nature' ? 'nature' : 'room'
118
+ break
119
+ case '--list':
120
+ options.list = true
121
+ break
122
+ case '--wait':
123
+ options.wait = true
124
+ break
125
+ case '--color':
126
+ options.color = true
127
+ break
128
+ case '--no-color':
129
+ options.color = false
130
+ break
131
+ default:
132
+ throw new Error(`unknown option: ${arg}`)
133
+ }
134
+ }
135
+ return options
136
+ }
137
+
138
+ /**
139
+ * The footer's rain note, or an empty string when sound is not relevant.
140
+ *
141
+ * Kept out of `draw` so the level it prints — and the case where the chosen
142
+ * player cannot apply that level to a recording — can be asserted directly
143
+ * rather than by scraping a frame.
144
+ *
145
+ * @param {{ t: (key: string, params?: object) => string }} translator
146
+ * @param {{ available: boolean, enabled: boolean, honoured: boolean, level: number, player: string | null, wet: boolean }} state
147
+ * @returns {string}
148
+ */
149
+ export function soundFooter(translator, state) {
150
+ if (!state.available || !(state.wet || state.enabled)) return ''
151
+ const key = state.enabled ? (state.honoured ? 'work.soundOn' : 'work.soundFixed') : 'work.soundOff'
152
+ const note = translator.t(key, { level: state.level, player: state.player ?? '' })
153
+ return ` ${note}`
154
+ }
155
+
156
+ const HELP = `dsh-live-working — a live orca animation for a DeepSeek Harness session
157
+
158
+ Usage:
159
+ dsh-live-working [options]
160
+
161
+ The orca sits at a desk with a keyboard, a stack of books and a red telephone.
162
+ What it does follows the session: it types while the model works, reads with the
163
+ occasional squint while a file is read, leafs through the books while a search
164
+ runs, picks up the telephone when a subagent starts, and falls asleep when there
165
+ is nothing to do — including when a running command has been silent for a while.
166
+
167
+ Options:
168
+ -s, --session <id> bind this session instead of the server's default
169
+ --socket <path> attach to a known observer socket
170
+ --server <pid> prefer the Harness process with this pid
171
+ --cwd <path> prefer the server and session for this working directory
172
+ --runtime-dir <p> override the runtime directory (default: $DSH_HOME/live-trace)
173
+ --state <name> pin one animation and do not follow the session
174
+ --interval <ms> repaint interval (default: 120)
175
+ --lang <code> UI language: auto, en, or zh (default: auto, from $LANG)
176
+ --sound play rain noise while it is raining (needs aplay, paplay,
177
+ sox or ffplay; off by default)
178
+ --rain-volume <p> rain level, 0-100 (default: 40); aplay cannot change
179
+ the level of a recording, only of the built-in noise
180
+ --rain-file <p> use a recording of rain instead of the built-in noise
181
+ --scene <name> room (a desk by a window) or nature (the whole
182
+ backdrop is outdoors); default: room
183
+ --list list discovered observers, then exit
184
+ --wait poll for an observer instead of exiting
185
+ --no-color disable ANSI color
186
+ -h, --help show this help
187
+ -V, --version show the version
188
+
189
+ States: ${WORK_STATES.join(', ')}
190
+
191
+ Keys:
192
+ 1-8 pin one of the states above; 0 returns to automatic
193
+ s choose a session when several are running
194
+ n rain sound on or off (only when it is raining outside)
195
+ - / + rain volume down or up (shown in the footer)
196
+ b background: the room, or the outdoors
197
+ l switch language
198
+ ? toggle this help
199
+ q / Ctrl-C quit
200
+
201
+ The animation is read-only: it never sends anything to the agent.
202
+ `
203
+
204
+ /**
205
+ * Resolve the server to attach to.
206
+ *
207
+ * @returns {object | null}
208
+ */
209
+ async function discover(parsed, registryDir, cwd, stderr) {
210
+ for (;;) {
211
+ const records = pruneServerRecords(registryDir)
212
+ const server = selectServerRecord(records, {
213
+ socket: parsed.socket,
214
+ pid: Number.isFinite(parsed.server) ? parsed.server : undefined,
215
+ cwd: parsed.cwd ?? cwd
216
+ })
217
+ if (server !== undefined) return server
218
+ if (parsed.wait !== true) {
219
+ stderr.write(
220
+ [
221
+ 'dsh-live-working: no running Harness observer found.',
222
+ '',
223
+ `Looked in: ${registryDir}`,
224
+ '',
225
+ 'This command reads events published by the dsh-live-trace Host plugin.',
226
+ 'Enable that plugin in the profile your Harness runs, then try again:',
227
+ '',
228
+ ' dsh plugin --profile web add <path-to>/dsh-live-trace',
229
+ '',
230
+ 'Use `--socket <path>` to attach to a known socket directly.',
231
+ ''
232
+ ].join('\n')
233
+ )
234
+ return null
235
+ }
236
+ stderr.write(`dsh-live-working: waiting for an observer in ${registryDir}…\n`)
237
+ await new Promise((resolve) => setTimeout(resolve, 1000))
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Run the animation.
243
+ *
244
+ * @param {{ stdout: NodeJS.WriteStream, stdin: NodeJS.ReadStream, stderr: NodeJS.WriteStream, argv?: string[], env?: NodeJS.ProcessEnv }} io
245
+ * @returns {Promise<number>} exit code
246
+ */
247
+ export async function run(io) {
248
+ const { stdout, stderr } = io
249
+ const env = io.env ?? process.env
250
+ const argv = io.argv ?? process.argv.slice(2)
251
+
252
+ let parsed
253
+ try {
254
+ parsed = parseArgs(argv)
255
+ } catch (error) {
256
+ stderr.write(`dsh-live-working: ${error.message}\n`)
257
+ return 2
258
+ }
259
+
260
+ if (parsed.help === true) {
261
+ stdout.write(HELP)
262
+ return 0
263
+ }
264
+ if (parsed.version === true) {
265
+ const pkg = await import('../../../package.json', { with: { type: 'json' } })
266
+ stdout.write(`${pkg.default.version}\n`)
267
+ return 0
268
+ }
269
+
270
+ const runtimeDir = parsed.runtimeDir ?? resolveRuntimeDir(env)
271
+ const registryDir = serversDir(runtimeDir)
272
+
273
+ if (parsed.list === true) {
274
+ const records = pruneServerRecords(registryDir)
275
+ stdout.write(`runtime dir: ${runtimeDir}\n`)
276
+ stdout.write(`servers dir: ${registryDir}\n\n`)
277
+ if (records.length === 0) stdout.write('No running Harness observers.\n')
278
+ for (const record of records) {
279
+ stdout.write(`pid ${record.pid} profile=${record.profile ?? '-'} cwd=${record.cwd ?? '-'}\n`)
280
+ for (const session of record.sessions ?? []) {
281
+ stdout.write(` ${session.id === record.activeSessionId ? '*' : ' '} ${session.id}\n`)
282
+ }
283
+ }
284
+ return 0
285
+ }
286
+
287
+ const server = await discover(parsed, registryDir, io.cwd ?? process.cwd(), stderr)
288
+ if (server === null) return 1
289
+
290
+ const color = parsed.color ?? (env.NO_COLOR === undefined && stdout.isTTY === true)
291
+ const theme = createTheme({ color })
292
+ let translator = createTranslator(resolveLanguage(parsed.lang, env))
293
+
294
+ const chosen = selectSession(server, { sessionId: parsed.session, cwd: parsed.cwd ?? io.cwd })
295
+ let sessionId = chosen?.id ?? server.activeSessionId ?? null
296
+
297
+ const work = createWorkState()
298
+ let pinned = parsed.state !== null && WORK_STATES.includes(parsed.state) ? parsed.state : null
299
+ let sessions = []
300
+ let sessionLabel = sessionId
301
+ // With several sessions running and no explicit choice, ask rather than
302
+ // guessing: the default is a guess the user cannot see the basis of.
303
+ let picking = parsed.session === undefined || parsed.session === null
304
+ let pickerIndex = 0
305
+ let turn = 0
306
+ let step = 0
307
+ let help = false
308
+ let frame = 0
309
+ // Speech runs on its own clock, so a new message is always spoken from its
310
+ // start rather than appearing fully formed.
311
+ let lastBubble = null
312
+ let bubbleSince = 0
313
+ // The handset's travel between the cradle and the ear. Time-based, so the
314
+ // pickup takes the same half second whatever the repaint interval is.
315
+ let phoneLift = 0
316
+ // How long the current pose has been held. Sleeping uses it to lie the tail
317
+ // down slowly rather than dropping it the instant the eyes shut.
318
+ let lastKind = null
319
+ let kindSince = 0
320
+ let lastDrawAt = Date.now()
321
+ // Off unless asked for. It follows the weather in the window: rain outside,
322
+ // rain inside.
323
+ // Prefer a recording the user pointed at, then the one shipped here, then
324
+ // the synthesised noise.
325
+ const sound = createRainSound({ file: parsed.rainFile ?? bundledRainFile(), volume: parsed.rainVolume })
326
+ if (parsed.sound === true) sound.setEnabled(true)
327
+ let backdrop = parsed.scene === 'nature' ? 'nature' : 'room'
328
+
329
+ const client = connectTrace({
330
+ socketPath: server.socket,
331
+ sessionId,
332
+ replayLimit: parsed.replayLimit,
333
+ onRecord: (record) => {
334
+ reduceWork(work, record)
335
+ if (record.kind === 'status') {
336
+ turn = record.turn ?? turn
337
+ step = record.step ?? step
338
+ }
339
+ if (record.kind === 'sessions') {
340
+ sessions = Array.isArray(record.sessions) ? record.sessions : []
341
+ if (record.activeSessionId !== undefined && sessionId === null) sessionLabel = record.activeSessionId
342
+ // Only one session means there is nothing to choose.
343
+ if (sessions.length <= 1) picking = false
344
+ else pickerIndex = createPickerState(sessions, sessionLabel).index
345
+ }
346
+ }
347
+ })
348
+
349
+ const screen = new Screen({ stdout, stdin: io.stdin ?? process.stdin, altScreen: true, mouse: false })
350
+
351
+ /**
352
+ * What the telephone bubble says.
353
+ *
354
+ * While a call is out it shows the *call as it was written* — the tool name
355
+ * and its arguments — plus where it sits in the queue when several subagents
356
+ * are out at once. When one rings back it shows what it came back with.
357
+ */
358
+ const bubbleFor = (state) => {
359
+ const sub = state.subagent
360
+ if (state.kind === 'ringing') {
361
+ const reply = typeof sub?.reply === 'string' && sub.reply.length > 0 ? sub.reply : translator.t('work.noReply')
362
+ return { header: translator.t('work.bubbleReply', { n: sub?.index ?? 1 }), body: reply }
363
+ }
364
+ const header = sub !== null && sub !== undefined && sub.total > 1
365
+ ? translator.t('work.bubbleQueue', { n: sub.index, total: sub.total, q: sub.queued ?? 0 })
366
+ : translator.t('work.bubbleCalling', { n: sub?.index ?? 1 })
367
+ // The body is the call *as it was written*: the tool name and its
368
+ // arguments, not just the instruction inside it.
369
+ const body = typeof sub?.format === 'string' && sub.format.length > 0
370
+ ? sub.format
371
+ : (typeof sub?.input === 'string' && sub.input.length > 0 ? sub.input : translator.t('work.noInput'))
372
+ return { header, body }
373
+ }
374
+
375
+ /** Draw the session chooser, or the scene when a session is settled. */
376
+ const drawPicker = (cols, rows) => {
377
+ const title = ` ${translator.t('working.pick')}`
378
+ const lines = [title, '']
379
+ // Lead with the title, the same way the trace dashboard's session list
380
+ // does. The id is still here — shortened — because it is what you type and
381
+ // the title is not unique.
382
+ sessions.forEach((session, index) => {
383
+ const mark = index === pickerIndex ? '▸' : ' '
384
+ const id = shortSessionId(String(session.id), 20)
385
+ const title = typeof session.title === 'string' && session.title.length > 0 ? `${sanitize(session.title)} ` : ''
386
+ const cwd = session.cwd === undefined ? '' : ` ${shortPath(String(session.cwd), 36)}`
387
+ const bound = session.id === sessionLabel ? ' *' : ' '
388
+ lines.push(truncate(` ${mark} ${index + 1}. ${title}${id}${bound}${cwd}`, cols))
389
+ })
390
+ lines.push('')
391
+ lines.push(` ${translator.t('working.pickKeys')}`)
392
+ const top = Math.max(0, Math.floor((rows - lines.length) / 2))
393
+ const padded = []
394
+ for (let index = 0; index < top; index += 1) padded.push('')
395
+ padded.push(...lines)
396
+ while (padded.length < rows) padded.push('')
397
+ screen.paint(padded.slice(0, rows))
398
+ }
399
+
400
+ const draw = () => {
401
+ const { cols, rows } = screen.size()
402
+ if (picking && sessions.length > 1) {
403
+ drawPicker(cols, rows)
404
+ return
405
+ }
406
+ if (cols < 32 || rows < 10) {
407
+ screen.paint(
408
+ [` ${translator.t('work.tooSmall')}`, ''].concat(
409
+ Array.from({ length: Math.max(0, rows - 2) }, () => '')
410
+ )
411
+ )
412
+ return
413
+ }
414
+ const size = sceneSizeFor(cols, rows)
415
+ const state = workStateOf(work)
416
+ const kind = pinned ?? state.kind
417
+ if (kind !== lastKind) {
418
+ lastKind = kind
419
+ kindSince = frame
420
+ }
421
+ const sleepFor = kind === 'sleep' ? frame - kindSince : 0
422
+ const bubble = kind === 'calling' || kind === 'ringing' ? bubbleFor({ ...state, kind }) : null
423
+ const bubbleKey = bubble === null ? null : `${bubble.header}\n${bubble.body}`
424
+ if (bubbleKey !== lastBubble) {
425
+ lastBubble = bubbleKey
426
+ bubbleSince = frame
427
+ }
428
+ const wanted = kind === 'calling' || kind === 'ringing' ? 1 : 0
429
+ const nowMs = Date.now()
430
+ const elapsed = Math.min(250, Math.max(0, nowMs - lastDrawAt))
431
+ lastDrawAt = nowMs
432
+ const travel = elapsed / 550
433
+ phoneLift = phoneLift < wanted ? Math.min(wanted, phoneLift + travel) : Math.max(wanted, phoneLift - travel)
434
+
435
+ const scene = sceneFrame({ kind, subagent: state.subagent }, frame, {
436
+ scene: backdrop,
437
+ phoneLift,
438
+ palette: theme.scene,
439
+ bubbleHeader: bubble?.header ?? null,
440
+ bubbleBody: bubble?.body ?? '',
441
+ speechFrame: frame - bubbleSince,
442
+ sleepFor,
443
+ // While dozing on outstanding subagents the window carries the queue:
444
+ // the call-on-the-line moment is too brief to show it, and the queue is
445
+ // exactly what the orca is waiting on.
446
+ previewText: state.waiting === true
447
+ ? translator.t('work.waitingForSubagents', { n: state.pending ?? 0 })
448
+ : previewOf(work),
449
+ size
450
+ })
451
+
452
+ const lines = []
453
+ const top = Math.max(0, Math.floor((rows - scene.height - 1) / 2))
454
+ for (let index = 0; index < top; index += 1) lines.push('')
455
+ for (const row of scene.cells) {
456
+ const text = renderCells(row)
457
+ const pad = Math.max(0, Math.floor((cols - scene.width) / 2))
458
+ lines.push(' '.repeat(pad) + text)
459
+ }
460
+ while (lines.length < rows - 1) lines.push('')
461
+
462
+ const stateLabel = translator.t(`work.state.${kind}`)
463
+ const sky = scene.sky
464
+ const clock = sky === undefined ? '' : sky.clock
465
+ const weather = sky === undefined ? '' : translator.t(`work.weather.${sky.weather.kind}`)
466
+ const wet = sky !== undefined && (sky.weather.kind === 'rain' || sky.weather.kind === 'storm')
467
+ sound.update(wet)
468
+ const soundNote = soundFooter(translator, {
469
+ available: sound.available,
470
+ enabled: sound.enabled,
471
+ // Never claim a level that cannot reach the speakers: aplay has no volume
472
+ // argument, so a recording is heard at the level it was recorded at.
473
+ honoured: sound.volumeHonoured,
474
+ level: Math.round(sound.volume * 100),
475
+ player: sound.player,
476
+ wet
477
+ })
478
+ const sceneNote = ` ${translator.t(backdrop === 'nature' ? 'work.sceneNature' : 'work.sceneRoom')}`
479
+ const mode = pinned === null ? translator.t('work.automatic') : translator.t('work.pinned')
480
+ const left = ` ${translator.t('work.title')} · ${stateLabel}${help ? ` ${mode}` : ''}${clock === '' ? '' : ` ${clock} ${weather}`}${sceneNote}${soundNote}`
481
+ const right = `${sessionLabel === null || sessionLabel === undefined ? translator.t('work.noSession') : String(sessionLabel).slice(-12)} T${turn}·S${step} ${translator.t('work.helpKey')}:? `
482
+ const width = Math.max(0, cols - left.length - right.length)
483
+ lines.push(left + ' '.repeat(width) + right)
484
+ screen.paint(lines.slice(0, rows))
485
+ }
486
+
487
+ let quitResolve = () => {}
488
+ const done = new Promise((resolve) => {
489
+ quitResolve = resolve
490
+ })
491
+
492
+ screen.enter()
493
+ screen.onResize(draw)
494
+
495
+ screen.onKey((key) => {
496
+ if (typeof key !== 'string') return
497
+ if (key === 'quit' || key === 'q' || key === '\u0003') {
498
+ quitResolve(0)
499
+ return
500
+ }
501
+ if (picking && sessions.length > 1) {
502
+ const step = applyPickerKey({ index: pickerIndex, count: sessions.length, boundId: sessionLabel }, key, sessions)
503
+ pickerIndex = step.index
504
+ if (step.intent === 'bind' && step.sessionId !== null) {
505
+ // `sessionId` must stay reassignable: this is the line that was a
506
+ // crash when it was declared const.
507
+ sessionId = step.sessionId
508
+ sessionLabel = step.sessionId
509
+ client.select(sessionId)
510
+ picking = false
511
+ } else if (step.intent === 'cancel') {
512
+ picking = false
513
+ }
514
+ draw()
515
+ return
516
+ }
517
+ if (key === 'l') translator = createTranslator(nextLanguage(translator.language))
518
+ else if (key === '?' || key === 'h') help = !help
519
+ else if (key === 'n') sound.toggle()
520
+ else if (key === '-' || key === '_') sound.adjustVolume(-VOLUME_STEP)
521
+ else if (key === '+' || key === '=') sound.adjustVolume(VOLUME_STEP)
522
+ else if (key === 'b') backdrop = backdrop === 'room' ? 'nature' : 'room'
523
+ else if (key === 's' && sessions.length > 1) picking = true
524
+ else if (key === '0') pinned = null
525
+ else if (/^[1-9]$/.test(key)) {
526
+ const candidate = PIN_KEYS[Number(key) - 1]
527
+ if (candidate !== undefined) pinned = candidate
528
+ }
529
+ draw()
530
+ })
531
+
532
+ // The sound is a separate process, and an orphaned one keeps raining after
533
+ // this command is gone. Ctrl-C and a signal both skip the normal path, so
534
+ // they are wired to the same cleanup, and `exit` is the last resort.
535
+ const finish = () => {
536
+ sound.stop()
537
+ }
538
+ process.once('exit', finish)
539
+ const onSignal = (signal) => {
540
+ finish()
541
+ screen.leave()
542
+ // Re-raise so the shell sees the real reason, with the default handler.
543
+ process.removeListener(signal, onSignal)
544
+ process.kill(process.pid, signal)
545
+ }
546
+ process.once('SIGINT', onSignal)
547
+ process.once('SIGTERM', onSignal)
548
+ process.once('SIGHUP', onSignal)
549
+
550
+ const timer = setInterval(() => {
551
+ frame += 1
552
+ draw()
553
+ }, parsed.interval)
554
+ timer.unref?.()
555
+ draw()
556
+
557
+ const code = await done
558
+ clearInterval(timer)
559
+ sound.stop()
560
+ process.removeListener('exit', finish)
561
+ process.removeListener('SIGINT', onSignal)
562
+ process.removeListener('SIGTERM', onSignal)
563
+ process.removeListener('SIGHUP', onSignal)
564
+ client.close()
565
+ screen.leave()
566
+ return code
567
+ }
568
+
569
+ export default run