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,337 @@
1
+ /**
2
+ * Terminal text measurement and layout primitives.
3
+ *
4
+ * The dashboard must align in a terminal that may be 80 columns wide and may
5
+ * carry CJK text, so measuring a string with `.length` is wrong twice over:
6
+ * Han characters occupy two cells, and a model's output can contain escape
7
+ * sequences that would otherwise corrupt the screen. Everything here works on
8
+ * plain code points and on styled *segment* lists, so colors never have to be
9
+ * parsed back out of a string.
10
+ *
11
+ * A row is `Segment[]`; a {@link Segment} is `{ text, sgr? }` where `sgr` is a
12
+ * raw SGR parameter list such as `"1;38;5;39"`.
13
+ *
14
+ * @module dsh-live-trace/width
15
+ */
16
+
17
+ /**
18
+ * @typedef {{ text: string, sgr?: string }} Segment
19
+ */
20
+
21
+ /** Ranges whose code points occupy two terminal cells. */
22
+ const WIDE_RANGES = [
23
+ [0x1100, 0x115f],
24
+ [0x2e80, 0x303e],
25
+ [0x3041, 0x33ff],
26
+ [0x3400, 0x4dbf],
27
+ [0x4e00, 0x9fff],
28
+ [0xa000, 0xa4cf],
29
+ [0xa960, 0xa97f],
30
+ [0xac00, 0xd7a3],
31
+ [0xf900, 0xfaff],
32
+ [0xfe10, 0xfe19],
33
+ [0xfe30, 0xfe6f],
34
+ [0xff00, 0xff60],
35
+ [0xffe0, 0xffe6],
36
+ // Emoji_Presentation code points that terminals render double width.
37
+ [0x231a, 0x231b],
38
+ [0x23e9, 0x23ec],
39
+ [0x23f0, 0x23f0],
40
+ [0x23f3, 0x23f3],
41
+ [0x25fd, 0x25fe],
42
+ [0x2614, 0x2615],
43
+ [0x2648, 0x2653],
44
+ [0x267f, 0x267f],
45
+ [0x2693, 0x2693],
46
+ [0x26aa, 0x26ab],
47
+ [0x26bd, 0x26be],
48
+ [0x26c4, 0x26c5],
49
+ [0x26ce, 0x26ce],
50
+ [0x26d4, 0x26d4],
51
+ [0x26ea, 0x26ea],
52
+ [0x26f2, 0x26f3],
53
+ [0x26f5, 0x26f5],
54
+ [0x26fa, 0x26fa],
55
+ [0x26fd, 0x26fd],
56
+ [0x2705, 0x2705],
57
+ [0x270a, 0x270b],
58
+ [0x2728, 0x2728],
59
+ [0x274c, 0x274c],
60
+ [0x274e, 0x274e],
61
+ [0x2753, 0x2755],
62
+ [0x2757, 0x2757],
63
+ [0x2795, 0x2797],
64
+ [0x27b0, 0x27b0],
65
+ [0x27bf, 0x27bf],
66
+ [0x2b1b, 0x2b1c],
67
+ [0x2b50, 0x2b50],
68
+ [0x2b55, 0x2b55],
69
+ [0x1f300, 0x1f64f],
70
+ [0x1f680, 0x1f6ff],
71
+ [0x1f7e0, 0x1f7eb],
72
+ [0x1f900, 0x1f9ff],
73
+ [0x1fa70, 0x1faff],
74
+ [0x20000, 0x2fffd],
75
+ [0x30000, 0x3fffd]
76
+ ]
77
+
78
+ /** Ranges of zero-width marks that attach to the preceding cell. */
79
+ const ZERO_RANGES = [
80
+ [0x0300, 0x036f],
81
+ [0x0483, 0x0489],
82
+ [0x0591, 0x05bd],
83
+ [0x0610, 0x061a],
84
+ [0x064b, 0x065f],
85
+ [0x0e31, 0x0e31],
86
+ [0x0e34, 0x0e3a],
87
+ [0x0eb1, 0x0eb1],
88
+ [0x1ab0, 0x1aff],
89
+ [0x1dc0, 0x1dff],
90
+ [0x200b, 0x200f],
91
+ [0x20d0, 0x20ff],
92
+ [0xfe00, 0xfe0f],
93
+ [0xfe20, 0xfe2f],
94
+ [0xfeff, 0xfeff],
95
+ [0xe0100, 0xe01ef]
96
+ ]
97
+
98
+ function inRanges(codePoint, ranges) {
99
+ for (const [start, end] of ranges) {
100
+ if (codePoint >= start && codePoint <= end) return true
101
+ }
102
+ return false
103
+ }
104
+
105
+ /**
106
+ * Terminal cells one code point occupies: 0 for combining marks and zero-width
107
+ * joiners, 2 for East Asian Wide/Fullwidth and emoji, 1 otherwise.
108
+ * @param {number} codePoint
109
+ * @returns {number}
110
+ */
111
+ export function codePointWidth(codePoint) {
112
+ if (!Number.isFinite(codePoint) || codePoint < 0) return 0
113
+ if (codePoint === 0) return 0
114
+ if (codePoint < 32 || (codePoint >= 0x7f && codePoint < 0xa0)) return 0
115
+ if (inRanges(codePoint, ZERO_RANGES)) return 0
116
+ if (inRanges(codePoint, WIDE_RANGES)) return 2
117
+ return 1
118
+ }
119
+
120
+ /**
121
+ * Display width of plain text.
122
+ * @param {string} text
123
+ * @returns {number}
124
+ */
125
+ export function displayWidth(text) {
126
+ if (typeof text !== 'string' || text.length === 0) return 0
127
+ let width = 0
128
+ for (const character of text) width += codePointWidth(character.codePointAt(0) ?? 0)
129
+ return width
130
+ }
131
+
132
+ /**
133
+ * Remove anything that would let untrusted model output drive the terminal.
134
+ *
135
+ * ANSI escape sequences are stripped outright, then remaining C0/C1 controls
136
+ * are replaced with a space so they cannot move the cursor or beep.
137
+ *
138
+ * @param {unknown} input
139
+ * @returns {string}
140
+ */
141
+ export function sanitize(input) {
142
+ if (input === null || input === undefined) return ''
143
+ let text = String(input)
144
+ // CSI / OSC / DCS / APC / PM / SOS and the simple two-byte escapes.
145
+ text = text.replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, '')
146
+ text = text.replace(/\u001b[P^_X][^\u001b]*(?:\u001b\\)?/g, '')
147
+ text = text.replace(/\u001b\[[0-9;?<>=]*[ -/]*[@-~]/g, '')
148
+ text = text.replace(/\u001b[@-Z\\-_]/g, '')
149
+ // eslint-disable-next-line no-control-regex
150
+ text = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, ' ')
151
+ return text
152
+ }
153
+
154
+ /**
155
+ * Split styled segments into one entry per code point, preserving style.
156
+ * @param {Segment[]} segments
157
+ * @returns {Array<{ ch: string, sgr?: string }>}
158
+ */
159
+ export function explode(segments) {
160
+ const out = []
161
+ for (const segment of segments) {
162
+ if (segment === null || segment === undefined) continue
163
+ const text = sanitize(segment.text)
164
+ if (text.length === 0) continue
165
+ for (const character of text) out.push({ ch: character, sgr: segment.sgr })
166
+ }
167
+ return out
168
+ }
169
+
170
+ /**
171
+ * Re-join per-code-point entries into the fewest segments.
172
+ * @param {Array<{ ch: string, sgr?: string }>} chars
173
+ * @returns {Segment[]}
174
+ */
175
+ export function collapse(chars) {
176
+ const out = []
177
+ for (const item of chars) {
178
+ const last = out[out.length - 1]
179
+ if (last !== undefined && last.sgr === item.sgr) last.text += item.ch
180
+ else out.push({ text: item.ch, sgr: item.sgr })
181
+ }
182
+ return out
183
+ }
184
+
185
+ /**
186
+ * Display width of a row.
187
+ * @param {Segment[]} segments
188
+ * @returns {number}
189
+ */
190
+ export function rowWidth(segments) {
191
+ let width = 0
192
+ for (const segment of segments ?? []) {
193
+ if (segment === null || segment === undefined) continue
194
+ width += displayWidth(sanitize(segment.text))
195
+ }
196
+ return width
197
+ }
198
+
199
+ /**
200
+ * Truncate a plain string to a cell budget.
201
+ * @param {string} text
202
+ * @param {number} width
203
+ * @param {string} [ellipsis]
204
+ * @returns {string}
205
+ */
206
+ export function truncate(text, width, ellipsis = '…') {
207
+ return truncateSegments([{ text }], width, ellipsis)
208
+ .map((segment) => segment.text)
209
+ .join('')
210
+ }
211
+
212
+ /**
213
+ * Truncate a row to a cell budget, appending an ellipsis when it was cut.
214
+ * @param {Segment[]} segments
215
+ * @param {number} width
216
+ * @param {string} [ellipsis]
217
+ * @returns {Segment[]}
218
+ */
219
+ export function truncateSegments(segments, width, ellipsis = '…') {
220
+ if (width <= 0) return []
221
+ const chars = explode(segments)
222
+ let total = 0
223
+ for (const item of chars) total += codePointWidth(item.ch.codePointAt(0) ?? 0)
224
+ if (total <= width) return collapse(chars)
225
+ const budget = Math.max(0, width - displayWidth(ellipsis))
226
+ const kept = []
227
+ let used = 0
228
+ for (const item of chars) {
229
+ const itemWidth = codePointWidth(item.ch.codePointAt(0) ?? 0)
230
+ if (used + itemWidth > budget) break
231
+ kept.push(item)
232
+ used += itemWidth
233
+ }
234
+ const result = collapse(kept)
235
+ if (displayWidth(ellipsis) > 0) result.push({ text: ellipsis, sgr: kept[kept.length - 1]?.sgr })
236
+ return result
237
+ }
238
+
239
+ /**
240
+ * Wrap a row into lines no wider than `width`.
241
+ *
242
+ * Breaks at a space when one is available on the line; otherwise hard-breaks,
243
+ * which is the correct behavior for CJK text that carries no spaces.
244
+ *
245
+ * @param {Segment[]} segments
246
+ * @param {number} width
247
+ * @returns {Segment[][]}
248
+ */
249
+ export function wrapSegments(segments, width) {
250
+ if (width <= 0) return [segments]
251
+ const chars = explode(segments)
252
+ /** @type {Segment[][]} */
253
+ const lines = []
254
+ /** @type {Array<{ ch: string, sgr?: string }>} */
255
+ let line = []
256
+ let lineWidth = 0
257
+ let lastSpace = -1
258
+
259
+ const widthUpTo = (list) => {
260
+ let width = 0
261
+ for (const item of list) width += codePointWidth(item.ch.codePointAt(0) ?? 0)
262
+ return width
263
+ }
264
+
265
+ const breakLine = () => {
266
+ if (lastSpace > 0) {
267
+ lines.push(collapse(line.slice(0, lastSpace)))
268
+ line = line.slice(lastSpace + 1)
269
+ } else {
270
+ lines.push(collapse(line))
271
+ line = []
272
+ }
273
+ lineWidth = widthUpTo(line)
274
+ lastSpace = -1
275
+ for (let index = 0; index < line.length; index += 1) {
276
+ if (line[index].ch === ' ') lastSpace = index
277
+ }
278
+ }
279
+
280
+ for (const item of chars) {
281
+ const itemWidth = codePointWidth(item.ch.codePointAt(0) ?? 0)
282
+ // A character wider than the whole budget cannot be displayed at all; the
283
+ // only alternatives are overflowing the line or dropping it, and the
284
+ // renderer's contract is that every produced line fits.
285
+ if (itemWidth > width) continue
286
+ if (line.length > 0 && lineWidth + itemWidth > width) breakLine()
287
+ line.push(item)
288
+ lineWidth += itemWidth
289
+ if (item.ch === ' ') lastSpace = line.length - 1
290
+ }
291
+ if (line.length > 0 || lines.length === 0) lines.push(collapse(line))
292
+ return lines
293
+ }
294
+
295
+ /**
296
+ * Serialize a row into an ANSI string.
297
+ * @param {Segment[]} segments
298
+ * @returns {string}
299
+ */
300
+ export function renderRow(segments) {
301
+ let out = ''
302
+ for (const segment of segments ?? []) {
303
+ if (segment === null || segment === undefined || segment.text.length === 0) continue
304
+ if (segment.sgr === undefined || segment.sgr === '') out += segment.text
305
+ else out += `\u001b[${segment.sgr}m${segment.text}\u001b[0m`
306
+ }
307
+ return out
308
+ }
309
+
310
+ /**
311
+ * Right-pad a row to a full cell width with a background style.
312
+ * @param {Segment[]} segments
313
+ * @param {number} width
314
+ * @param {string} [fillSgr]
315
+ * @returns {Segment[]}
316
+ */
317
+ export function padRow(segments, width, fillSgr) {
318
+ const used = rowWidth(segments)
319
+ if (used >= width) return segments
320
+ return [...segments, { text: ' '.repeat(width - used), sgr: fillSgr }]
321
+ }
322
+
323
+ /**
324
+ * Pad plain text to a cell budget (not a code-unit count).
325
+ *
326
+ * `String.prototype.padEnd` counts UTF-16 units, so a translated label made of
327
+ * Han characters would come out short and break column alignment.
328
+ *
329
+ * @param {string} text
330
+ * @param {number} width
331
+ * @returns {string}
332
+ */
333
+ export function padTo(text, width) {
334
+ const value = typeof text === 'string' ? text : String(text ?? '')
335
+ const used = displayWidth(value)
336
+ return used >= width ? value : value + ' '.repeat(width - used)
337
+ }