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,231 @@
1
+ /**
2
+ * The Host-side Unix domain socket server: the transport the observer plugin
3
+ * publishes on and the standalone viewer subscribes to.
4
+ *
5
+ * A Unix socket rather than a TCP port is deliberate. It cannot be reached from
6
+ * another machine, it cannot collide with the Harness web server's port, it
7
+ * needs no authentication story, and its permissions are the access control.
8
+ * Each Harness process owns one socket file under
9
+ * `<runtimeDir>/sockets/<pid>.sock`, so two `dsh` processes never fight over
10
+ * one path.
11
+ *
12
+ * @module dsh-live-trace/transport
13
+ */
14
+
15
+ import { chmodSync, mkdirSync, rmSync } from 'node:fs'
16
+ import { createServer } from 'node:net'
17
+
18
+ import { socketsDir } from './paths.js'
19
+ import { CLIENT_KIND, createLineDecoder, encodeRecord, MAX_REPLAY_ENTRIES, PROTOCOL_VERSION, SERVER_KIND } from './protocol.js'
20
+
21
+ /** How often an idle connection receives a heartbeat, in milliseconds. */
22
+ export const HEARTBEAT_MS = 5000
23
+
24
+ /** Entries replayed to a viewer that does not ask for a specific count. */
25
+ export const DEFAULT_REPLAY_LIMIT = 500
26
+
27
+ /**
28
+ * @param {string} runtimeDir
29
+ * @param {number} pid
30
+ * @returns {string} the socket path this process should own
31
+ */
32
+ export function defaultSocketPath(runtimeDir, pid = process.pid) {
33
+ return `${socketsDir(runtimeDir)}/${pid}.sock`
34
+ }
35
+
36
+ /**
37
+ * Start the observer's socket server.
38
+ *
39
+ * @param {object} options
40
+ * @param {string} options.socketPath absolute path to bind
41
+ * @param {import('./tracker.js').TraceHub} options.hub
42
+ * @param {() => object} [options.serverInfo] server identity for the `hello` record
43
+ * @param {number} [options.heartbeatMs]
44
+ * @param {(error: Error) => void} [options.onError]
45
+ * @returns {{ socketPath: string, close: () => Promise<void>, clientCount: () => number, broadcast: (record: object) => void, ready: Promise<void> }}
46
+ */
47
+ export function createTraceServer(options) {
48
+ const { socketPath, hub, serverInfo = () => ({}), heartbeatMs = HEARTBEAT_MS, onError } = options
49
+
50
+ mkdirSync(socketsDir(options.runtimeDir ?? dirnameOf(socketPath)), { recursive: true, mode: 0o700 })
51
+ // A leftover file from a crashed process would make bind() fail with EADDRINUSE.
52
+ rmSync(socketPath, { force: true })
53
+
54
+ const clients = new Set()
55
+ let closed = false
56
+
57
+ const server = createServer((socket) => {
58
+ /** @type {{ socket: import('node:net').Socket, decoder: ReturnType<typeof createLineDecoder>, sessionId: string | null, send: (record: object) => void }} */
59
+ const client = { socket, decoder: createLineDecoder(), sessionId: null, send: () => {} }
60
+ clients.add(client)
61
+ socket.setNoDelay(true)
62
+ socket.setEncoding('utf8')
63
+
64
+ const send = (record) => {
65
+ if (socket.destroyed) return
66
+ try {
67
+ socket.write(encodeRecord({ v: PROTOCOL_VERSION, ...record }))
68
+ } catch {
69
+ /* the socket already died; close handling below cleans up */
70
+ }
71
+ }
72
+
73
+ client.send = send
74
+ client.sessionId = null
75
+
76
+ // The session list is enough to start: the viewer answers with an explicit
77
+ // SELECT, which is what makes per-viewer session routing unambiguous.
78
+ send({
79
+ kind: SERVER_KIND.HELLO,
80
+ protocol: PROTOCOL_VERSION,
81
+ server: serverInfo(),
82
+ sessions: hub.sessionsInfo(),
83
+ activeSessionId: hub.activeSessionId,
84
+ replayLimit: DEFAULT_REPLAY_LIMIT
85
+ })
86
+
87
+ socket.on('data', (chunk) => {
88
+ let records
89
+ try {
90
+ records = client.decoder.push(chunk)
91
+ } catch (error) {
92
+ onError?.(/** @type {Error} */ (error))
93
+ socket.destroy()
94
+ return
95
+ }
96
+ for (const record of records) handleClientRecord(client, record, hub, send)
97
+ })
98
+ socket.on('error', () => {
99
+ /* handled by close */
100
+ })
101
+ socket.on('close', () => {
102
+ clients.delete(client)
103
+ })
104
+ })
105
+
106
+ server.on('error', (error) => onError?.(error))
107
+
108
+ const ready = new Promise((resolvePromise, rejectPromise) => {
109
+ server.once('error', rejectPromise)
110
+ server.listen(socketPath, () => {
111
+ try {
112
+ chmodSync(socketPath, 0o600)
113
+ } catch {
114
+ /* the platform may not support chmod on sockets; the directory mode still applies */
115
+ }
116
+ resolvePromise(undefined)
117
+ })
118
+ })
119
+
120
+ const heartbeat = setInterval(() => {
121
+ const record = { kind: SERVER_KIND.HEARTBEAT, at: Date.now(), clients: clients.size }
122
+ for (const client of clients) client.send(record)
123
+ }, heartbeatMs)
124
+ heartbeat.unref?.()
125
+
126
+ /**
127
+ * Route one hub record: session-scoped records only reach viewers bound to
128
+ * that session; the session list reaches everyone.
129
+ * @param {object} record
130
+ */
131
+ function broadcast(record) {
132
+ if (closed) return
133
+ if (record.kind === SERVER_KIND.SESSIONS) {
134
+ // `boundSessionId` is per-viewer state, so the shared list record is
135
+ // personalized on the way out rather than broadcast verbatim.
136
+ for (const client of clients) client.send({ ...record, boundSessionId: client.sessionId })
137
+ return
138
+ }
139
+ const sessionId = record.sessionId
140
+ for (const client of clients) {
141
+ if (client.sessionId !== null && client.sessionId === sessionId) client.send(record)
142
+ }
143
+ }
144
+
145
+ const unsubscribe = hub.subscribe(broadcast)
146
+
147
+ return {
148
+ socketPath,
149
+ ready,
150
+ clientCount: () => clients.size,
151
+ broadcast,
152
+ async close() {
153
+ if (closed) return
154
+ closed = true
155
+ clearInterval(heartbeat)
156
+ unsubscribe()
157
+ for (const client of clients) client.socket.destroy()
158
+ clients.clear()
159
+ await new Promise((resolvePromise) => server.close(() => resolvePromise(undefined)))
160
+ rmSync(socketPath, { force: true })
161
+ }
162
+ }
163
+ }
164
+
165
+ function dirnameOf(path) {
166
+ const index = path.lastIndexOf('/')
167
+ return index === -1 ? '.' : path.slice(0, index)
168
+ }
169
+
170
+ /**
171
+ * Apply one client request.
172
+ * @param {{ sessionId: string | null }} client
173
+ * @param {any} record
174
+ * @param {import('./tracker.js').TraceHub} hub
175
+ * @param {(record: object) => void} send
176
+ */
177
+ function handleClientRecord(client, record, hub, send) {
178
+ if (record === null || typeof record !== 'object') return
179
+ switch (record.kind) {
180
+ case CLIENT_KIND.SELECT: {
181
+ const explicit = record.sessionId !== undefined && record.sessionId !== null
182
+ const requested = explicit ? String(record.sessionId) : hub.activeSessionId
183
+ if (requested === null || !hub.stateOf(requested)) {
184
+ if (explicit) {
185
+ send({
186
+ kind: SERVER_KIND.ERROR,
187
+ message: `unknown session ${String(record.sessionId)}`,
188
+ sessions: hub.sessionsInfo(),
189
+ boundSessionId: client.sessionId
190
+ })
191
+ } else {
192
+ // No explicit request and nothing to bind to yet: an empty Harness is
193
+ // not an error, so answer with the (empty) session list and wait.
194
+ send({
195
+ kind: SERVER_KIND.SESSIONS,
196
+ sessions: hub.sessionsInfo(),
197
+ activeSessionId: hub.activeSessionId,
198
+ boundSessionId: client.sessionId
199
+ })
200
+ }
201
+ return
202
+ }
203
+ client.sessionId = requested
204
+ const limit = clampLimit(record.limit)
205
+ send({
206
+ kind: SERVER_KIND.SESSIONS,
207
+ sessions: hub.sessionsInfo(),
208
+ activeSessionId: hub.activeSessionId,
209
+ boundSessionId: client.sessionId
210
+ })
211
+ for (const replay of hub.snapshot(requested, limit)) send(replay)
212
+ break
213
+ }
214
+ case CLIENT_KIND.REPLAY: {
215
+ if (client.sessionId === null) return
216
+ for (const replay of hub.snapshot(client.sessionId, clampLimit(record.limit))) send(replay)
217
+ break
218
+ }
219
+ case CLIENT_KIND.PING:
220
+ send({ kind: SERVER_KIND.HEARTBEAT, at: Date.now(), pong: true })
221
+ break
222
+ default:
223
+ // Forward compatibility: an unknown client request is ignored, never fatal.
224
+ break
225
+ }
226
+ }
227
+
228
+ function clampLimit(limit) {
229
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) return DEFAULT_REPLAY_LIMIT
230
+ return Math.max(1, Math.min(MAX_REPLAY_ENTRIES, Math.floor(limit)))
231
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Live Trace",
4
+ "description": "Publishes normalized Harness session events on a local socket so the standalone `dsh-live-trace` terminal dashboard can show live Turn/Step progress, tool calls, streaming text, elapsed time, and token usage."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "实时轨迹看板",
4
+ "description": "把 Harness 会话事件归一化后发布到本地 socket,供独立的 `dsh-live-trace` 终端看板实时展示 Turn/Step 进度、工具调用、流式文本、耗时与 Token 消耗。"
5
+ }
6
+ }
package/package.json ADDED
@@ -0,0 +1,94 @@
1
+ {
2
+ "name": "dsh-live-trace",
3
+ "version": "0.1.0",
4
+ "description": "Read-only live terminal dashboard for DeepSeek Harness sessions: a Host plugin that normalizes session events onto a local socket, plus a standalone full-screen `dsh-live-trace` viewer.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "cordis",
10
+ "observability",
11
+ "terminal",
12
+ "dashboard",
13
+ "trace"
14
+ ],
15
+ "type": "module",
16
+ "license": "MIT",
17
+ "engines": {
18
+ "node": ">=20.11"
19
+ },
20
+ "bin": {
21
+ "dsh-live-trace": "bin/dsh-live-trace.js",
22
+ "dsh-live-working": "bin/dsh-live-working.js",
23
+ "dsh-glyph-probe": "bin/dsh-glyph-probe.js"
24
+ },
25
+ "main": "./index.js",
26
+ "icon": "./icon.svg",
27
+ "exports": {
28
+ ".": "./index.js",
29
+ "./normalize": "./lib/normalize.js",
30
+ "./protocol": "./lib/protocol.js",
31
+ "./registry": "./lib/registry.js",
32
+ "./width": "./src/cli/width.js",
33
+ "./renderer": "./src/cli/renderer.js",
34
+ "./view-state": "./src/cli/view-state.js",
35
+ "./cordis.patch.yml": "./cordis.patch.yml",
36
+ "./package.json": "./package.json",
37
+ "./locale/*.json": "./locale/*.json"
38
+ },
39
+ "files": [
40
+ "index.js",
41
+ "cordis.patch.yml",
42
+ "lib",
43
+ "src",
44
+ "bin",
45
+ "locale",
46
+ "icon.svg",
47
+ "README.md",
48
+ "scripts",
49
+ "assets",
50
+ "picture"
51
+ ],
52
+ "scripts": {
53
+ "test": "node --test 'test/*.test.js'",
54
+ "test:watch": "node --test --watch 'test/*.test.js'",
55
+ "demo": "node scripts/demo.mjs",
56
+ "install:profile": "node scripts/install-profile.mjs",
57
+ "mock-provider": "node scripts/mock-provider.mjs",
58
+ "bench": "node scripts/bench-render.mjs",
59
+ "demo:working": "node scripts/demo-working.mjs"
60
+ },
61
+ "dsh": {
62
+ "bundle": {
63
+ "patch": "./cordis.patch.yml"
64
+ },
65
+ "catalog": {
66
+ "category": "observability",
67
+ "summary": {
68
+ "en": "Read-only live terminal dashboard for Harness sessions: streams Turn/Step progress, tool calls and results, coalesced streaming text, elapsed time and token usage to a standalone `dsh-live-trace` viewer.",
69
+ "zh": "会话实时终端看板(只读):把 Turn/Step 进度、工具调用与结果、合并后的流式思考文本、耗时与 Token 消耗推送到独立的 `dsh-live-trace` 终端窗口。"
70
+ },
71
+ "capabilities": [
72
+ "host-observability",
73
+ "cli"
74
+ ]
75
+ }
76
+ },
77
+ "peerDependencies": {
78
+ "@deepseek-ai/cordis": "~4.0.4"
79
+ },
80
+ "peerDependenciesMeta": {
81
+ "@deepseek-ai/cordis": {
82
+ "optional": true
83
+ }
84
+ },
85
+ "author": "Ausntar",
86
+ "homepage": "https://github.com/Ausntar/dsh-live-trace#readme",
87
+ "repository": {
88
+ "type": "git",
89
+ "url": "git+https://github.com/Ausntar/dsh-live-trace.git"
90
+ },
91
+ "bugs": {
92
+ "url": "https://github.com/Ausntar/dsh-live-trace/issues"
93
+ }
94
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Frame-cost benchmark.
4
+ *
5
+ * A frame only ever shows the tail of the log, so its cost must not grow with
6
+ * the session. This prints the per-frame cost for growing entry counts; the
7
+ * numbers should stay flat and far below the repaint interval (120 ms by
8
+ * default). It exists because an earlier implementation rendered every entry
9
+ * per frame, which reached seconds on a long session and made scrolling feel
10
+ * laggy.
11
+ *
12
+ * node scripts/bench-render.mjs
13
+ */
14
+
15
+ import { renderFrame } from '../src/cli/renderer.js'
16
+ import { createTheme } from '../src/cli/theme.js'
17
+ import { applyRecord, createViewState } from '../src/cli/view-state.js'
18
+
19
+ const THEME = createTheme({ color: true })
20
+ const AT = Date.now()
21
+
22
+ function stateWith(entries) {
23
+ const state = createViewState()
24
+ applyRecord(state, {
25
+ kind: 'hello',
26
+ server: { pid: 1 },
27
+ sessions: [{ id: 's', createdAt: AT, title: 'bench' }],
28
+ activeSessionId: 's'
29
+ })
30
+ for (let index = 0; index < entries; index += 1) {
31
+ applyRecord(state, {
32
+ kind: 'entry',
33
+ sessionId: 's',
34
+ entry: {
35
+ seq: index,
36
+ time: AT + index,
37
+ tag: 'assistant',
38
+ label: 'ASSISTANT',
39
+ text: `message ${index}`,
40
+ rawText:
41
+ `## Step ${index}\n\nFixed \`thing ${index}\` in **src/file${index}.ts**.\n\n` +
42
+ `\`\`\`ts\nexport function f${index}(x: number) { return x + ${index} }\n\`\`\`\n\n- item a\n- item b`,
43
+ reasoning: 'thinking about the change '.repeat(6),
44
+ turn: 1,
45
+ step: index
46
+ }
47
+ })
48
+ }
49
+ return state
50
+ }
51
+
52
+ const BUDGET_MS = 30
53
+ let worst = 0
54
+
55
+ for (const count of [50, 200, 1000, 3000, 10_000]) {
56
+ const state = stateWith(count)
57
+ renderFrame(state, { cols: 80, rows: 30, theme: THEME, now: AT, frame: 0 })
58
+ const runs = 5
59
+ const started = process.hrtime.bigint()
60
+ for (let index = 0; index < runs; index += 1) {
61
+ renderFrame(state, { cols: 80, rows: 30, theme: THEME, now: AT, frame: index })
62
+ }
63
+ const perFrame = Number(process.hrtime.bigint() - started) / 1e6 / runs
64
+ worst = Math.max(worst, perFrame)
65
+ console.log(`entries=${String(count).padStart(6)} per-frame=${perFrame.toFixed(1).padStart(6)}ms`)
66
+ }
67
+
68
+ console.log(`\nworst ${worst.toFixed(1)}ms against a ${BUDGET_MS}ms budget — ${worst < BUDGET_MS ? 'ok' : 'TOO SLOW'}`)
69
+ process.exitCode = worst < BUDGET_MS ? 0 : 1
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A self-contained run through every animation.
4
+ *
5
+ * `dsh-live-working` draws whatever a live session is doing; this plays each
6
+ * state in turn so you can see them all without waiting for the model to
7
+ * happen to read a file or call a subagent. It feeds the same record shapes the
8
+ * plugin publishes, so it exercises the real state machine and the real scene.
9
+ *
10
+ * node scripts/demo-working.mjs # cycle every state
11
+ * node scripts/demo-working.mjs reading # hold one state
12
+ */
13
+
14
+ import { createTranslator, resolveLanguage } from '../src/cli/i18n.js'
15
+ import { Screen } from '../src/cli/screen.js'
16
+ import { createTheme } from '../src/cli/theme.js'
17
+ import { renderCells, sceneFrame, sceneSizeFor } from '../src/cli/working/scene.js'
18
+ import { createWorkState, previewOf, reduceWork, WORK_STATES, workStateOf } from '../src/cli/working/state.js'
19
+
20
+ const wanted = process.argv[2]
21
+ const states = WORK_STATES.includes(wanted) ? [wanted] : WORK_STATES
22
+ const holdMs = Number(process.env.HOLD_MS ?? 2600)
23
+ const theme = createTheme({ color: true })
24
+ let translator = createTranslator(resolveLanguage('auto'))
25
+
26
+ /** Records that put the machine into each state. */
27
+ const SCRIPT = {
28
+ sleep: [{ kind: 'status', status: 'idle' }],
29
+ thinking: [{ kind: 'stream', reasoning: 'working through it' }],
30
+ typing: [{ kind: 'stream', text: 'writing the answer' }],
31
+ writing: [{ kind: 'entry', entry: { tag: 'tool', phase: 'call', callId: 'w', tool: 'write_file' } }],
32
+ waiting: [{ kind: 'entry', entry: { tag: 'tool', phase: 'call', callId: 'b', tool: 'bash' } }],
33
+ reading: [{ kind: 'entry', entry: { tag: 'tool', phase: 'call', callId: 'r', tool: 'read_file' } }],
34
+ searching: [{ kind: 'entry', entry: { tag: 'tool', phase: 'call', callId: 's', tool: 'web_search' } }],
35
+ calling: [
36
+ {
37
+ kind: 'entry',
38
+ entry: {
39
+ tag: 'tool',
40
+ phase: 'call',
41
+ callId: 't',
42
+ tool: 'task',
43
+ arguments: JSON.stringify({ description: '把 auth 模块里的校验逻辑抽出来,补上单元测试' })
44
+ }
45
+ }
46
+ ]
47
+ }
48
+
49
+ /** Build the machine state for one scripted state. */
50
+ function machineFor(kind) {
51
+ const state = createWorkState()
52
+ const now = Date.now()
53
+ for (const record of SCRIPT[kind] ?? []) reduceWork(state, record, now)
54
+ return { state, at: now }
55
+ }
56
+
57
+ const screen = new Screen({ stdout: process.stdout, stdin: process.stdin, altScreen: true, mouse: false })
58
+ let index = 0
59
+ let frame = 0
60
+ let machine = machineFor(states[0])
61
+
62
+ const draw = () => {
63
+ const { cols, rows } = screen.size()
64
+ const now = machine.state
65
+ const derived = workStateOf(now, machine.at)
66
+ const kind = derived.kind
67
+ const bubble =
68
+ kind === 'calling'
69
+ ? {
70
+ header: translator.t('work.bubbleCalling', { n: 1 }),
71
+ body: 'task(description="把 auth 模块里的校验逻辑抽出来", prompt="补上单元测试,并更新文档与类型定义,确保边界情况也被覆盖")'
72
+ }
73
+ : null
74
+ const scene = sceneFrame({ kind, subagent: derived.subagent }, frame, {
75
+ palette: theme.scene,
76
+ bubbleHeader: bubble?.header ?? null,
77
+ bubbleBody: bubble?.body ?? '',
78
+ speechFrame: frame,
79
+ previewText: previewOf(now),
80
+ size: sceneSizeFor(cols, rows)
81
+ })
82
+ const lines = []
83
+ const top = Math.max(0, Math.floor((rows - scene.height - 2) / 2))
84
+ for (let line = 0; line < top; line += 1) lines.push('')
85
+ const pad = ' '.repeat(Math.max(0, Math.floor((cols - scene.width) / 2)))
86
+ for (const row of scene.cells) lines.push(pad + renderCells(row))
87
+ while (lines.length < rows - 1) lines.push('')
88
+ const label = ` ${translator.t(`work.state.${kind}`)} (${index + 1}/${states.length}) q to quit`
89
+ lines.push(label)
90
+ screen.paint(lines.slice(0, rows))
91
+ }
92
+
93
+ // Without a TTY there is nothing to watch: print one frame and exit, so the
94
+ // script is still useful from a pipe or a test harness.
95
+ if (process.stdout.isTTY !== true && process.env.FORCE !== '1') {
96
+ const scene = sceneFrame({ kind: workStateOf(machine.state, machine.at).kind }, 0, { palette: theme.scene })
97
+ for (const row of scene.cells) process.stdout.write(`${renderCells(row)}\n`)
98
+ process.exit(0)
99
+ }
100
+
101
+ screen.enter()
102
+ screen.onResize(draw)
103
+ draw()
104
+
105
+ const frames = setInterval(() => {
106
+ frame += 1
107
+ draw()
108
+ }, 120)
109
+ frames.unref?.()
110
+
111
+ const advance = setInterval(() => {
112
+ index = (index + 1) % states.length
113
+ machine = machineFor(states[index])
114
+ frame = 0
115
+ }, holdMs)
116
+ advance.unref?.()
117
+
118
+ const stop = () => {
119
+ clearInterval(frames)
120
+ clearInterval(advance)
121
+ screen.leave()
122
+ process.exit(0)
123
+ }
124
+ screen.onKey((key) => {
125
+ if (key === 'q' || key === 'quit' || key === '\u0003') stop()
126
+ if (key === 'l') translator = createTranslator(translator.language === 'zh' ? 'en' : 'zh')
127
+ })
128
+ process.once('SIGINT', stop)
129
+ process.once('SIGTERM', stop)
130
+