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