dsh-live-trace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- package/src/cli/working/state.js +528 -0
package/lib/transport.js
ADDED
|
@@ -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
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
|
package/picture/tui1.png
ADDED
|
Binary file
|
package/picture/tui2.png
ADDED
|
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
|
+
|