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,270 @@
1
+ /**
2
+ * Terminal control: the alternate screen, size, raw input, and frame painting.
3
+ *
4
+ * This is the only module in the viewer that touches the TTY, so every other
5
+ * piece of the dashboard stays unit-testable.
6
+ *
7
+ * @module dsh-live-trace/screen
8
+ */
9
+
10
+ /** Sequences the alternate-screen painter emits. */
11
+ export const SEQUENCE = {
12
+ altEnter: '\u001b[?1049h',
13
+ altLeave: '\u001b[?1049l',
14
+ hideCursor: '\u001b[?25l',
15
+ showCursor: '\u001b[?25h',
16
+ home: '\u001b[H',
17
+ clearToEnd: '\u001b[J',
18
+ clearLine: '\u001b[K',
19
+ /** Begin synchronized output (DEC 2026); ignored by terminals without it. */
20
+ syncBegin: '\u001b[?2026h',
21
+ syncEnd: '\u001b[?2026l',
22
+ /** Button-event reporting plus SGR coordinates, so the wheel is unambiguous. */
23
+ mouseEnter: '\u001b[?1000h\u001b[?1006h',
24
+ mouseLeave: '\u001b[?1000l\u001b[?1006l'
25
+ }
26
+
27
+ /**
28
+ * Decode one SGR mouse report body (`b;x;y`) into a key the dashboard handles.
29
+ *
30
+ * @param {number} button
31
+ * @param {number} x 1-based column
32
+ * @param {number} y 1-based row
33
+ * @returns {string | { type: 'click', x: number, y: number } | null}
34
+ */
35
+ function mouseEvent(button, x, y, released) {
36
+ if (button >= 64) {
37
+ switch (button) {
38
+ case 64:
39
+ return 'wheel-up'
40
+ case 65:
41
+ return 'wheel-down'
42
+ case 66:
43
+ return 'wheel-left'
44
+ case 67:
45
+ return 'wheel-right'
46
+ default:
47
+ return null
48
+ }
49
+ }
50
+ // Bit 5 marks pointer motion, which a dashboard has nothing to do with.
51
+ if ((button & 32) !== 0) return null
52
+ // Low two bits give the button. A release carries no new information, and
53
+ // only the left button opens something.
54
+ if (released) return null
55
+ return (button & 3) === 0 ? { type: 'click', x, y } : null
56
+ }
57
+
58
+ /**
59
+ * Parse raw terminal input into named keys.
60
+ *
61
+ * Escape sequences arrive split across reads often enough that the parser
62
+ * buffers a partial sequence instead of treating its prefix as keystrokes.
63
+ *
64
+ * @returns {(chunk: string) => string[]}
65
+ */
66
+ export function createKeyParser() {
67
+ const sequences = [
68
+ ['\u001b[1;5A', 'ctrl-up'],
69
+ ['\u001b[1;5B', 'ctrl-down'],
70
+ ['\u001b[A', 'up'],
71
+ ['\u001b[B', 'down'],
72
+ ['\u001b[C', 'right'],
73
+ ['\u001b[D', 'left'],
74
+ ['\u001bOA', 'up'],
75
+ ['\u001bOB', 'down'],
76
+ ['\u001b[5~', 'page-up'],
77
+ ['\u001b[6~', 'page-down'],
78
+ ['\u001b[H', 'home'],
79
+ ['\u001b[F', 'end'],
80
+ ['\u001b[1~', 'home'],
81
+ ['\u001b[4~', 'end']
82
+ ]
83
+ let pending = ''
84
+ const parser = (chunk) => {
85
+ pending += chunk
86
+ const keys = []
87
+ for (;;) {
88
+ if (pending.length === 0) break
89
+ if (pending[0] !== '\u001b') {
90
+ keys.push(pending[0])
91
+ pending = pending.slice(1)
92
+ continue
93
+ }
94
+ // SGR mouse reports are self-delimiting: ESC [ < b;x;y M|m
95
+ if (pending.startsWith('\u001b[<')) {
96
+ const terminator = pending.search(/[Mm]/)
97
+ if (terminator === -1) break
98
+ const fields = pending.slice(3, terminator).split(';')
99
+ const released = pending[terminator] === 'm'
100
+ pending = pending.slice(terminator + 1)
101
+ const [button, x, y] = fields.map((value) => Number.parseInt(value, 10))
102
+ const event = mouseEvent(button, x, y, released)
103
+ if (event !== null) keys.push(event)
104
+ continue
105
+ }
106
+ // A lone ESC may still be the prefix of an arrow key.
107
+ if (pending.length === 1) break
108
+ const match = sequences.find(([sequence]) => pending.startsWith(sequence))
109
+ if (match !== undefined) {
110
+ keys.push(match[1])
111
+ pending = pending.slice(match[0].length)
112
+ continue
113
+ }
114
+ const couldExtend = sequences.some(([sequence]) => sequence.startsWith(pending))
115
+ if (couldExtend) break
116
+ // Unknown escape: consume the ESC and re-examine the rest.
117
+ pending = pending.slice(1)
118
+ }
119
+ if (pending.length > 64) pending = ''
120
+ return keys
121
+ }
122
+ parser.pending = () => pending
123
+ parser.takePending = () => {
124
+ const held = pending
125
+ pending = ''
126
+ return held
127
+ }
128
+ return parser
129
+ }
130
+
131
+ export class Screen {
132
+ /**
133
+ * @param {{ stdout?: NodeJS.WriteStream, stdin?: NodeJS.ReadStream, altScreen?: boolean, color?: boolean, mouse?: boolean }} [options]
134
+ */
135
+ constructor(options = {}) {
136
+ this.stdout = options.stdout ?? process.stdout
137
+ this.stdin = options.stdin ?? process.stdin
138
+ this.altScreen = options.altScreen !== false
139
+ this.mouse = options.mouse !== false
140
+ this.entered = false
141
+ this.rawWasSet = false
142
+ this.listeners = { key: new Set(), resize: new Set() }
143
+ this.onStdinData = null
144
+ this.onStdoutResize = null
145
+ }
146
+
147
+ /** @returns {{ cols: number, rows: number }} */
148
+ size() {
149
+ return {
150
+ cols: Math.max(20, this.stdout.columns ?? 80),
151
+ rows: Math.max(6, this.stdout.rows ?? 24)
152
+ }
153
+ }
154
+
155
+ /** Enter the alternate screen, hide the cursor, and start reading raw keys. */
156
+ enter() {
157
+ if (this.entered) return
158
+ this.entered = true
159
+ if (this.altScreen) this.stdout.write(SEQUENCE.altEnter)
160
+ this.stdout.write(SEQUENCE.hideCursor)
161
+ // Wheel events arrive as arrow keys in many terminals, which is reported as
162
+ // a plain keypress and cannot be distinguished from one. Claiming the mouse
163
+ // makes scrolling exact; Shift+drag still selects text.
164
+ if (this.mouse !== false) this.stdout.write(SEQUENCE.mouseEnter)
165
+
166
+ if (this.stdin.isTTY === true) {
167
+ try {
168
+ this.stdin.setRawMode(true)
169
+ this.rawWasSet = true
170
+ } catch {
171
+ /* not a real TTY; key handling stays off */
172
+ }
173
+ }
174
+ this.stdin.resume?.()
175
+ this.stdin.setEncoding?.('utf8')
176
+
177
+ const parseKeys = createKeyParser()
178
+ let escapeTimer = null
179
+ const flushEscape = () => {
180
+ escapeTimer = null
181
+ // A lone ESC never completes into an arrow key, so it must eventually be
182
+ // delivered as the Escape key rather than held forever.
183
+ if (parseKeys.takePending() === '\u001b') this.emitKey('escape')
184
+ }
185
+ this.onStdinData = (chunk) => {
186
+ for (const key of parseKeys(String(chunk))) {
187
+ if (key === '\u0003') {
188
+ this.emitKey('quit')
189
+ continue
190
+ }
191
+ this.emitKey(key)
192
+ }
193
+ if (escapeTimer !== null) clearTimeout(escapeTimer)
194
+ if (parseKeys.pending() === '\u001b' && !/^\u001b\[<$/.test(parseKeys.pending())) {
195
+ escapeTimer = setTimeout(flushEscape, 40)
196
+ escapeTimer.unref?.()
197
+ }
198
+ }
199
+ this.escapeTimer = () => escapeTimer
200
+ this.stdin.on('data', this.onStdinData)
201
+
202
+ this.onStdoutResize = () => this.emitResize()
203
+ this.stdout.on('resize', this.onStdoutResize)
204
+ }
205
+
206
+ /** Restore the terminal, whatever happened. */
207
+ leave() {
208
+ if (!this.entered) return
209
+ this.entered = false
210
+ if (typeof this.escapeTimer === 'function') {
211
+ const timer = this.escapeTimer()
212
+ if (timer !== null) clearTimeout(timer)
213
+ }
214
+ if (this.onStdinData !== null) this.stdin.off('data', this.onStdinData)
215
+ if (this.onStdoutResize !== null) this.stdout.off('resize', this.onStdoutResize)
216
+ this.onStdinData = null
217
+ this.onStdoutResize = null
218
+ if (this.rawWasSet) {
219
+ try {
220
+ this.stdin.setRawMode(false)
221
+ } catch {
222
+ /* the TTY may already be gone */
223
+ }
224
+ this.rawWasSet = false
225
+ }
226
+ this.stdin.pause?.()
227
+ const mouseOff = this.mouse === false ? '' : SEQUENCE.mouseLeave
228
+ this.stdout.write(`${mouseOff}${SEQUENCE.showCursor}${this.altScreen ? SEQUENCE.altLeave : '\n'}`)
229
+ }
230
+
231
+ /**
232
+ * Paint exactly `lines`, one per row, without a full-screen clear flicker.
233
+ * @param {string[]} lines
234
+ */
235
+ paint(lines) {
236
+ const { rows } = this.size()
237
+ let out = `${SEQUENCE.syncBegin}${SEQUENCE.home}`
238
+ for (let index = 0; index < rows; index += 1) {
239
+ out += `${lines[index] ?? ''}${SEQUENCE.clearLine}`
240
+ if (index < rows - 1) out += '\r\n'
241
+ }
242
+ out += SEQUENCE.clearToEnd + SEQUENCE.syncEnd
243
+ this.stdout.write(out)
244
+ }
245
+
246
+ /** @param {string} message a message printed after leaving the alternate screen */
247
+ writeLine(message) {
248
+ this.stdout.write(`${message}\n`)
249
+ }
250
+
251
+ /** @param {(key: string) => void} listener */
252
+ onKey(listener) {
253
+ this.listeners.key.add(listener)
254
+ return () => this.listeners.key.delete(listener)
255
+ }
256
+
257
+ /** @param {() => void} listener */
258
+ onResize(listener) {
259
+ this.listeners.resize.add(listener)
260
+ return () => this.listeners.resize.delete(listener)
261
+ }
262
+
263
+ emitKey(key) {
264
+ for (const listener of this.listeners.key) listener(key)
265
+ }
266
+
267
+ emitResize() {
268
+ for (const listener of this.listeners.resize) listener()
269
+ }
270
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * The dashboard's visual vocabulary: one color and one treatment per event tag.
3
+ *
4
+ * Colors are 256-color SGR parameters rather than truecolor so the board looks
5
+ * right in the widest range of terminals, and every value collapses to an empty
6
+ * style when color is disabled (`NO_COLOR`, `--no-color`, or a non-TTY).
7
+ *
8
+ * @module dsh-live-trace/theme
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} Theme
13
+ * @property {boolean} color
14
+ * @property {Record<string, string>} tag style per {@link import('../../lib/normalize.js').TAG}
15
+ * @property {Record<string, string>} level style per entry level
16
+ * @property {Record<string, string>} ui chrome styles
17
+ * @property {Record<string, string>} md markdown palette for markdown.js
18
+ * @property {Record<string, string>} syn syntax palette for highlight.js
19
+ * @property {Record<string, string>} scene palette for the desk scene
20
+ */
21
+
22
+ /**
23
+ * Build a theme.
24
+ * @param {{ color?: boolean }} [options]
25
+ * @returns {Theme}
26
+ */
27
+ export function createTheme(options = {}) {
28
+ const color = options.color !== false
29
+ const sgr = (code) => (color ? code : '')
30
+
31
+ const tag = {
32
+ session: sgr('1;38;5;141'),
33
+ turn: sgr('1;38;5;39'),
34
+ step: sgr('38;5;44'),
35
+ user: sgr('1;38;5;255'),
36
+ assistant: sgr('38;5;42'),
37
+ tool: sgr('1;38;5;220'),
38
+ result: sgr('38;5;40'),
39
+ error: sgr('1;38;5;203'),
40
+ approval: sgr('1;38;5;214'),
41
+ meta: sgr('38;5;245')
42
+ }
43
+
44
+ const level = {
45
+ info: sgr('38;5;252'),
46
+ success: sgr('38;5;42'),
47
+ warn: sgr('38;5;214'),
48
+ error: sgr('1;38;5;203'),
49
+ muted: sgr('38;5;243')
50
+ }
51
+
52
+ const ui = {
53
+ border: sgr('38;5;238'),
54
+ title: sgr('1;38;5;45'),
55
+ label: sgr('38;5;246'),
56
+ value: sgr('1;38;5;253'),
57
+ dim: sgr('38;5;240'),
58
+ headerBg: sgr('48;5;235'),
59
+ footerBg: sgr('48;5;235'),
60
+ statusRunning: sgr('1;38;5;42'),
61
+ statusTool: sgr('1;38;5;220'),
62
+ statusIdle: sgr('38;5;245'),
63
+ statusApproval: sgr('1;38;5;214'),
64
+ statusError: sgr('1;38;5;203'),
65
+ stream: sgr('3;38;5;246'),
66
+ scrollHint: sgr('38;5;245'),
67
+ hint: sgr('38;5;240'),
68
+ ok: sgr('38;5;42'),
69
+ fail: sgr('38;5;203'),
70
+ // View chrome
71
+ cursor: sgr('1;38;5;45'),
72
+ cursorBg: sgr('48;5;237'),
73
+ selected: sgr('1;38;5;255'),
74
+ panelTitle: sgr('1;38;5;45'),
75
+ added: sgr('38;5;42'),
76
+ removed: sgr('38;5;203'),
77
+ addedBg: sgr('48;5;22'),
78
+ removedBg: sgr('48;5;52'),
79
+ hunkHeader: sgr('38;5;141'),
80
+ gutter: sgr('38;5;240'),
81
+ command: sgr('38;5;252'),
82
+ output: sgr('38;5;246'),
83
+ exitOk: sgr('38;5;42'),
84
+ exitFail: sgr('1;38;5;203')
85
+ }
86
+
87
+ // Markdown and syntax palettes are handed to `markdown.js` / `highlight.js`,
88
+ // which never hard-code a colour of their own.
89
+ const md = {
90
+ heading: sgr('1;38;5;45'),
91
+ heading2: sgr('1;38;5;44'),
92
+ bold: sgr('1'),
93
+ italic: sgr('3'),
94
+ strike: sgr('9'),
95
+ link: sgr('4;38;5;39'),
96
+ inlineCode: sgr('38;5;180'),
97
+ bullet: sgr('38;5;39'),
98
+ quote: sgr('38;5;245'),
99
+ rule: sgr('38;5;238'),
100
+ code: sgr('38;5;252'),
101
+ fenceBorder: sgr('38;5;238'),
102
+ fenceLang: sgr('38;5;245'),
103
+ more: sgr('38;5;245')
104
+ }
105
+
106
+ const syn = {
107
+ keyword: sgr('38;5;170'),
108
+ string: sgr('38;5;114'),
109
+ number: sgr('38;5;180'),
110
+ comment: sgr('3;38;5;243'),
111
+ function: sgr('38;5;39'),
112
+ type: sgr('38;5;80'),
113
+ operator: sgr('38;5;210'),
114
+ punctuation: sgr('38;5;245'),
115
+ variable: sgr('38;5;252'),
116
+ property: sgr('38;5;117'),
117
+ tag: sgr('38;5;170'),
118
+ attribute: sgr('38;5;180'),
119
+ added: sgr('38;5;42'),
120
+ removed: sgr('38;5;203'),
121
+ meta: sgr('38;5;141')
122
+ }
123
+
124
+ // The desk scene owns its own palette: wood, plastic, paper and a bright red
125
+ // telephone against the same blues as the mascot.
126
+ const scene = {
127
+ body: sgr('38;5;69'),
128
+ // A shade off the body: enough to read the limb as a limb, not enough to
129
+ // look like a different animal.
130
+ flipper: sgr('38;5;75'),
131
+ outline: sgr('38;5;25'),
132
+ belly: sgr('38;5;231'),
133
+ spout: sgr('38;5;245'),
134
+ accent: sgr('38;5;222'),
135
+ desk: sgr('38;5;137'),
136
+ deskDark: sgr('38;5;94'),
137
+ keyboard: sgr('38;5;238'),
138
+ key: sgr('38;5;246'),
139
+ phone: sgr('38;5;160'),
140
+ phoneDark: sgr('38;5;88'),
141
+ page: sgr('38;5;255'),
142
+ pageShade: sgr('38;5;250'),
143
+ pageDark: sgr('38;5;243'),
144
+ windowEdge: sgr('38;5;110'),
145
+ // The sky colour is rewritten every frame from the in-game clock; this is
146
+ // only the fallback for a caller that does not provide one.
147
+ sky: sgr('38;5;24'),
148
+ // All four are rewritten every frame from the in-game clock and weather.
149
+ skyLow: sgr('38;5;31'),
150
+ landscape: sgr('38;5;236'),
151
+ glow: sgr('38;5;230'),
152
+ starDim: sgr('38;5;245'),
153
+ windowFrame: sgr('38;5;180'),
154
+ wall: sgr('38;5;145'),
155
+ wallShade: sgr('38;5;102'),
156
+ skirting: sgr('38;5;60'),
157
+ floor: sgr('38;5;236'),
158
+ // The z's sit on the wall, so they cannot be another mid grey.
159
+ sleepZ: sgr('38;5;255'),
160
+ tree: sgr('38;5;22'),
161
+ ground: sgr('38;5;58'),
162
+ cloud: sgr('38;5;252'),
163
+ sun: sgr('38;5;220'),
164
+ moon: sgr('38;5;253'),
165
+ star: sgr('38;5;255'),
166
+ rain: sgr('38;5;111'),
167
+ snow: sgr('38;5;255'),
168
+ fog: sgr('38;5;246'),
169
+ windowFill: sgr('38;5;234'),
170
+ bookGreen: sgr('38;5;65'),
171
+ bookYellow: sgr('38;5;179'),
172
+ bookCyan: sgr('38;5;73'),
173
+ bubble: sgr('38;5;250'),
174
+ bubbleFill: sgr('38;5;236'),
175
+ text: sgr('38;5;253')
176
+ }
177
+
178
+ return { color, tag, level, ui, md, syn, scene }
179
+ }
180
+
181
+ /**
182
+ * Wide-character-safe status glyph.
183
+ * @param {'idle' | 'running' | 'tool' | 'waiting-approval' | 'error'} status
184
+ * @param {number} frame rotating frame index
185
+ * @returns {string}
186
+ */
187
+ export function statusGlyph(status, frame) {
188
+ switch (status) {
189
+ case 'running':
190
+ return ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][frame % 10]
191
+ case 'tool':
192
+ return ['◐', '◓', '◑', '◒'][frame % 4]
193
+ case 'waiting-approval':
194
+ return '▲'
195
+ case 'error':
196
+ return '✖'
197
+ default:
198
+ return '●'
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Style for a status value.
204
+ * @param {Theme} theme
205
+ * @param {string} status
206
+ * @returns {string}
207
+ */
208
+ export function statusStyle(theme, status) {
209
+ switch (status) {
210
+ case 'running':
211
+ return theme.ui.statusRunning
212
+ case 'tool':
213
+ return theme.ui.statusTool
214
+ case 'waiting-approval':
215
+ return theme.ui.statusApproval
216
+ case 'error':
217
+ return theme.ui.statusError
218
+ default:
219
+ return theme.ui.statusIdle
220
+ }
221
+ }