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,837 @@
1
+ /**
2
+ * The scene: the orca at its desk, animated for whatever it is doing.
3
+ *
4
+ * The scene is **sized to the terminal** rather than fixed. A frame is composed
5
+ * as a pixel grid and then packed two-pixels-per-cell into terminal cells, so
6
+ * the horizontal resolution available is the terminal width: a fixed 96-column
7
+ * scene would sit in the middle of a wide terminal looking small and losing
8
+ * detail that was there to be had. The sprites scale by whole numbers, because
9
+ * pixel art scaled fractionally looks like a rendering fault.
10
+ *
11
+ * Text — the telephone bubble — is written into the cell grid directly, since
12
+ * glyphs are not pixels.
13
+ *
14
+ * @module dsh-live-working/scene
15
+ */
16
+
17
+ import { drawNature, drawWall, drawWindowView, skyState } from './sky.js'
18
+ import { displayWidth, truncate } from '../width.js'
19
+ import { orcaWithTail, ORCA_H, ORCA_W, scaleGrid, setEyes, tailDroopFor, tailSwayFor } from './art.js'
20
+ import {
21
+ blit,
22
+ canvas,
23
+ drawBooks,
24
+ drawBubble,
25
+ drawDesk,
26
+ drawFlipper,
27
+ drawKeyboard,
28
+ drawLimb,
29
+ drawOpenBook,
30
+ drawPhone,
31
+ drawRingArcs,
32
+ drawSleepZ,
33
+ drawWindow,
34
+ rect
35
+ } from './props.js'
36
+
37
+ /** The scene size used when the caller does not say (tests, previews). */
38
+ export const SCENE_W = 112
39
+ export const SCENE_H = 64
40
+
41
+ /** Largest scene, in cells, and the smallest a readable one can be. */
42
+ const MIN_W = 40
43
+ const MAX_W = 200
44
+ const MIN_H = 24
45
+
46
+ /** Which palette key each pixel class uses. */
47
+ const CLASS_KEY = {
48
+ b: 'body',
49
+ f: 'flipper',
50
+ o: 'outline',
51
+ w: 'belly',
52
+ s: 'spout',
53
+ a: 'accent',
54
+ d: 'desk',
55
+ D: 'deskDark',
56
+ k: 'keyboard',
57
+ K: 'key',
58
+ r: 'phone',
59
+ R: 'phoneDark',
60
+ p: 'page',
61
+ g: 'bookGreen',
62
+ y: 'bookYellow',
63
+ c: 'bookCyan',
64
+ n: 'bubble',
65
+ N: 'bubbleFill',
66
+ q: 'pageShade',
67
+ Q: 'pageDark',
68
+ e: 'windowEdge',
69
+ E: 'windowFill',
70
+ F: 'windowFrame',
71
+ S: 'sky',
72
+ H: 'skyLow',
73
+ B: 'landscape',
74
+ O: 'glow',
75
+ i: 'starDim',
76
+ T: 'tree',
77
+ j: 'ground',
78
+ L: 'cloud',
79
+ U: 'sun',
80
+ M: 'moon',
81
+ I: 'star',
82
+ X: 'rain',
83
+ Z: 'snow',
84
+ G: 'fog',
85
+ W: 'wall',
86
+ V: 'wallShade',
87
+ Y: 'floor',
88
+ z: 'sleepZ',
89
+ t: 'text'
90
+ }
91
+
92
+ /**
93
+ * Priority when one cell holds two different pixels. Furniture is drawn over
94
+ * the orca deliberately, so it wins; outline wins over body so the rim reads.
95
+ */
96
+ const CLASS_ORDER = [
97
+ 't', 'n', 'N', 'z', 'e', 'E', 'F', 'a', 'r', 'R', 'o', 'K', 'k', 'd', 'D',
98
+ 'X', 'Z', 'G', 'L', 'U', 'M', 'O', 'I', 'i', 'T', 'j', 'B', 'H', 'S', 'V', 'W', 'Y',
99
+ 'g', 'y', 'c', 'p', 'q', 'Q', 'w', 's', 'f', 'b'
100
+ ]
101
+
102
+ const GLYPHS = [' ', '▀', '▄', '█']
103
+ const EMPTY = '.'
104
+
105
+ /** Frame length of the blink, bobs and page turns. */
106
+ const CYCLE = 12
107
+
108
+ /**
109
+ * How big the scene should be, in terminal cells, for a given terminal.
110
+ *
111
+ * @param {number} cols
112
+ * @param {number} rows
113
+ * @returns {{ width: number, height: number }}
114
+ */
115
+ export function sceneSizeFor(cols, rows) {
116
+ const safeCols = Number.isFinite(cols) ? cols : 80
117
+ const safeRows = Number.isFinite(rows) ? rows : 24
118
+ // Never wider than the terminal. There is no minimum floor here on purpose:
119
+ // a floor that ignored the terminal would push the scene, and the telephone
120
+ // bubble with it, off the screen. The caller refuses to draw a scene in a
121
+ // terminal too small to hold one.
122
+ const width = Math.min(MAX_W, Math.max(16, Math.floor(safeCols) - 2))
123
+ // The scene is a wide band: keep its proportions when the terminal is short.
124
+ const preferred = Math.round(width * 0.3)
125
+ // Two rows are left over for the status line. The minimum height is a
126
+ // preference, not a floor: on a short terminal a scene that insisted on 24
127
+ // rows would be sliced by the caller and the status line would vanish with
128
+ // it.
129
+ const available = Math.max(8, Math.floor(safeRows) - 2)
130
+ const height = Math.min(Math.max(preferred, Math.min(MIN_H, available)), available)
131
+ return { width, height }
132
+ }
133
+
134
+ /**
135
+ * What whole-number scale the orca should be drawn at.
136
+ *
137
+ * One is the native size; two is for a terminal with room to spare, where the
138
+ * creature would otherwise look lost in the middle of the desk.
139
+ *
140
+ * @param {{ width: number, height: number }} size scene size in cells
141
+ * @returns {number}
142
+ */
143
+ export function orcaScaleFor(size) {
144
+ const pixelW = size.width
145
+ const pixelH = size.height * 2
146
+ const fit = Math.min(Math.floor((pixelW * 0.68) / ORCA_W), Math.floor((pixelH * 0.78) / ORCA_H))
147
+ return Math.max(1, Math.min(3, fit))
148
+ }
149
+
150
+ /** Positive modulo. */
151
+ function mod(value, base) {
152
+ const n = Math.trunc(Number(value))
153
+ if (!Number.isFinite(n)) return 0
154
+ return ((n % base) + base) % base
155
+ }
156
+
157
+ /**
158
+ * Eyes for the current state and frame.
159
+ *
160
+ * Reading is punctuated by an occasional squint, and sleep closes them; the
161
+ * model's other states keep them open.
162
+ */
163
+ /**
164
+ * Whether the orca is rubbing its eyes.
165
+ *
166
+ * Only at night, and only while it is working: the joke is that the work
167
+ * carries on. Every so often it shuts its eyes and lifts a flipper to its face
168
+ * for a moment, then carries on exactly where it was.
169
+ *
170
+ * @param {string} kind
171
+ * @param {number} frame
172
+ * @param {{ phase?: string } | undefined} sky
173
+ */
174
+ export function isRubbingEyes(kind, frame, sky) {
175
+ if (sky?.phase !== 'night') return false
176
+ const working = kind === 'typing' || kind === 'writing' || kind === 'reading' || kind === 'searching'
177
+ if (!working) return false
178
+ return mod(frame + 23, RUB_BETWEEN) < RUB_LENGTH
179
+ }
180
+
181
+ /** Frames between eye rubs, and how long one lasts. */
182
+ export const RUB_BETWEEN = 72
183
+ export const RUB_LENGTH = 16
184
+
185
+ export function eyeMode(kind, frame) {
186
+ const tick = mod(frame, CYCLE)
187
+ switch (kind) {
188
+ case 'sleep':
189
+ return 'closed'
190
+ case 'reading':
191
+ // A long blink every cycle, so the squint reads as "reading" rather than
192
+ // as a fixed expression.
193
+ return tick >= 8 ? 'closed' : tick >= 6 ? 'squint' : 'open'
194
+ case 'thinking':
195
+ return tick % 4 === 3 ? 'squint' : 'open'
196
+ case 'calling':
197
+ return tick >= 6 ? 'happy' : 'open'
198
+ case 'searching':
199
+ return tick % 6 === 0 ? 'squint' : 'open'
200
+ default:
201
+ return 'open'
202
+ }
203
+ }
204
+
205
+ /** Where everything goes, as fractions of the scene, resolved to pixels. */
206
+ export function layoutFor(size) {
207
+ // Tolerate a missing or partial size: the layout is also used to place the
208
+ // bubble on its own.
209
+ const W = Number.isFinite(size?.width) ? size.width : SCENE_W
210
+ const H = (Number.isFinite(size?.height) ? size.height : SCENE_H / 2) * 2
211
+ const scale = orcaScaleFor(size)
212
+ const deskTop = Math.round(H * 0.8)
213
+ const orcaH = ORCA_H * scale
214
+ return {
215
+ scale,
216
+ desk: { top: deskTop, left: Math.max(2, Math.round(W * 0.02)), right: W - Math.max(2, Math.round(W * 0.02)), thickness: Math.max(2, Math.round(H * 0.05)) },
217
+ orca: {
218
+ // Clamped so a narrow scene cannot push the creature off the right edge.
219
+ x: Math.max(0, Math.min(Math.round(W * 0.17), W - ORCA_W * scale)),
220
+ // Seated *behind* the desk: the furniture overlaps the last pixels of the
221
+ // belly, which is what makes it look like it is sitting at the desk
222
+ // rather than hovering above it.
223
+ y: Math.max(2, deskTop - orcaH + Math.max(1, Math.round(H * 0.03))),
224
+ width: ORCA_W * scale,
225
+ height: orcaH
226
+ },
227
+ keyboard: { x: Math.round(W * 0.3), y: deskTop - Math.max(3, Math.round(H * 0.05)), width: Math.round(W * 0.28), height: Math.max(3, Math.round(H * 0.05)) },
228
+ books: { x: Math.round(W * 0.68), y: deskTop - Math.max(8, Math.round(H * 0.2)), width: Math.round(W * 0.16), height: Math.max(8, Math.round(H * 0.2)) },
229
+ phone: { x: Math.round(W * 0.015), y: deskTop - Math.max(9, Math.round(H * 0.19)), width: Math.max(8, Math.round(W * 0.115)), height: Math.max(9, Math.round(H * 0.19)) },
230
+ document: { x: Math.round(W * 0.32), y: deskTop - Math.max(8, Math.round(H * 0.2)), width: Math.round(W * 0.36), height: Math.max(8, Math.round(H * 0.2)) },
231
+ // A heads-up panel showing the text being typed. Upper right, because the
232
+ // creature owns the left half of the scene and a window over the desk
233
+ // covers it.
234
+ // The whole outdoors, when there is no room around it.
235
+ nature: {
236
+ x: 0,
237
+ y: 0,
238
+ width: W,
239
+ // The whole scene: the ground runs to the bottom, so the desk stands on it.
240
+ height: H
241
+ },
242
+ // The window on the back wall. Drawn first, so the orca and the desk stand
243
+ // in front of it.
244
+ sky: {
245
+ x: Math.round(W * 0.4),
246
+ y: Math.round(H * 0.05),
247
+ width: Math.round(W * 0.57),
248
+ height: Math.max(10, Math.round(H * 0.58))
249
+ },
250
+ window: {
251
+ // On the wall at the top left. Over the sky it sat across the arc the sun
252
+ // and moon travel, and covered whichever of them happened to be up.
253
+ x: Math.round(W * 0.02),
254
+ y: Math.max(2, Math.round(H * 0.03)),
255
+ width: Math.round(W * 0.3),
256
+ height: Math.max(6, Math.round(H * 0.13))
257
+ },
258
+ // The book the orca is holding: large enough that the turning leaf is
259
+ // legible, which a book the size of the desk stack never was.
260
+ openBook: {
261
+ // In front of the body rather than the head, so the creature still reads
262
+ // as an orca while it holds the book open.
263
+ x: Math.round(W * 0.42),
264
+ y: deskTop - Math.max(10, Math.round(H * 0.28)),
265
+ width: Math.round(W * 0.3),
266
+ height: Math.max(8, Math.round(H * 0.2))
267
+ }
268
+ }
269
+ }
270
+
271
+ /** The point beside the orca's head where a handset is held. */
272
+ function earFor(layout) {
273
+ // Against the face. The eye sits at (8, 21) in the 64x40 sprite and the
274
+ // snout is the left edge, so the handset runs from the cheek down past the
275
+ // chin, mostly clear of the body rather than across it.
276
+ return {
277
+ x: layout.orca.x + 7 * layout.scale,
278
+ y: layout.orca.y + 15 * layout.scale,
279
+ scale: layout.scale
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Whether the preview window has a frame in this state.
285
+ *
286
+ * @param {string} kind
287
+ * @param {{ width: number, height: number }} size
288
+ */
289
+ export function windowVisible(kind, size) {
290
+ if (kind === 'sleep') return false
291
+ const area = layoutFor(size).window
292
+ return area.width >= 14 && area.height >= 6
293
+ }
294
+
295
+ /**
296
+ * Build the pixel grid for one frame.
297
+ *
298
+ * @param {{ kind: string, subagent?: object | null }} work
299
+ * @param {number} frame
300
+ * @param {{ size?: { width: number, height: number } }} [options]
301
+ * @returns {string[][]}
302
+ */
303
+ export function sceneGrid(work, frame, options = {}) {
304
+ const kind = work?.kind ?? 'sleep'
305
+ const size = options.size ?? { width: SCENE_W, height: SCENE_H / 2 }
306
+ const layout = layoutFor(size)
307
+ const tick = mod(frame, CYCLE)
308
+ const grid = canvas(size.width, size.height * 2)
309
+ const sky = options.sky ?? skyState(options.clockMs)
310
+
311
+ // --- behind everything --------------------------------------------------
312
+ if (options.scene === 'nature') {
313
+ // No wall and no window: the backdrop *is* the outdoors, so the weather
314
+ // covers the whole scene instead of one pane.
315
+ drawNature(grid, layout.nature, sky, frame, {
316
+ horizon: layout.desk.top - 2,
317
+ trees: Math.max(3, Math.round(size.width / 16))
318
+ })
319
+ } else {
320
+ drawWall(grid, size, layout.desk.top)
321
+ drawWindowView(grid, layout.sky, sky, frame)
322
+ }
323
+
324
+ // --- the orca -----------------------------------------------------------
325
+ const rubbing = isRubbingEyes(kind, frame, sky)
326
+ const sleepFor = Number.isFinite(options.sleepFor) ? options.sleepFor : 0
327
+ // Two tails, drawn: the renderer picks one rather than transforming the art.
328
+ const pose = kind === 'sleep' && tailDroopFor(kind, sleepFor) >= 0.5 ? 'sleep' : 'up'
329
+ const orca = orcaWithTail({ pose, sway: tailSwayFor(kind, frame) })
330
+ // Rubbing closes the eyes whatever the pose was doing.
331
+ setEyes(orca, rubbing ? 'closed' : eyeMode(kind, frame))
332
+ // Sleep and thinking bob more slowly than typing; the phone call holds still.
333
+ const bob = kind === 'sleep' ? (tick >= 6 ? layout.scale : 0) : kind === 'calling' ? 0 : tick % 4 === 0 ? layout.scale : 0
334
+ blit(grid, scaleGrid(orca, layout.scale), layout.orca.x, layout.orca.y + bob)
335
+
336
+ // --- the desk -----------------------------------------------------------
337
+ drawDesk(grid, layout.desk.top, layout.desk.left, layout.desk.right, layout.desk.thickness)
338
+
339
+ // --- what is on the desk, by activity ----------------------------------
340
+ const keyboard = layout.keyboard
341
+ switch (kind) {
342
+ case 'searching':
343
+ // Leafing through a book: the stack stays on the desk and a large open
344
+ // book is held in front, its pages turning.
345
+ drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
346
+ drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
347
+ drawOpenBook(grid, layout.openBook.x, layout.openBook.y, layout.openBook.width, layout.openBook.height, frame)
348
+ break
349
+ case 'reading':
350
+ // The same open book, held still while reading.
351
+ drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
352
+ drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
353
+ drawOpenBook(grid, layout.openBook.x, layout.openBook.y, layout.openBook.width, layout.openBook.height, 0)
354
+ break
355
+ case 'writing':
356
+ drawDocument(grid, layout.document, frame)
357
+ break
358
+ case 'waiting':
359
+ drawTerminal(grid, keyboard, frame)
360
+ break
361
+ case 'calling':
362
+ // On the telephone: hands off the keyboard entirely.
363
+ drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
364
+ drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
365
+ break
366
+ case 'thinking':
367
+ // Hands off the keys while it thinks, with a trail of dots above.
368
+ drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
369
+ drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
370
+ drawThoughtDots(grid, layout, frame)
371
+ break
372
+ case 'sleep':
373
+ drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, -1)
374
+ drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
375
+ break
376
+ default:
377
+ drawKeyboard(grid, keyboard.x, keyboard.y, keyboard.width, keyboard.height, frame)
378
+ drawBooks(grid, layout.books.x, layout.books.y, layout.books.width, layout.books.height, false, frame)
379
+ break
380
+ }
381
+
382
+ // The window frame is drawn whenever the preview text may be shown. Text
383
+ // without a frame reads as a stray floating bubble — which is exactly how it
384
+ // was reported.
385
+ if (windowVisible(kind, size)) drawTypingWindow(grid, layout)
386
+
387
+ // --- the orca's flippers -------------------------------------------------
388
+ // Drawn after the desk furniture, so they rest *on* it rather than
389
+ // disappearing behind it. The sampled sprite is a side view with no separate
390
+ // limb, so any movement has to come from these.
391
+ const atKeyboard = kind === 'typing' || kind === 'writing' || kind === 'waiting'
392
+ const flipperX = layout.orca.x + Math.round(layout.orca.width * 0.38)
393
+ const flipperY = layout.orca.y + Math.round(layout.orca.height * 0.78)
394
+ const flipperSize = Math.max(2, layout.scale * 3)
395
+ const flipperGap = Math.round(layout.orca.width * 0.17)
396
+ // Whether the handset is up. Exactly one set of flippers is drawn at a time:
397
+ // the pair on the keyboard, or the one holding the telephone. Drawing both
398
+ // gave the orca three hands, which is how it was reported.
399
+ const holding = (Number.isFinite(options.phoneLift) ? options.phoneLift : (kind === 'calling' || kind === 'ringing' ? 1 : 0)) >= 0.2
400
+ if (kind !== 'sleep' && !holding && !(kind === 'calling' || kind === 'ringing')) {
401
+ for (const [offset, phase] of [[0, 0], [flipperGap, 1]]) {
402
+ const down = atKeyboard ? tick % 2 === phase : false
403
+ drawFlipper(grid, flipperX + offset, flipperY - (offset === 0 ? 0 : 4 * layout.scale), flipperSize, down)
404
+ }
405
+ }
406
+
407
+ // A flipper up to the face for the night-time rub, drawn with the others so
408
+ // it shares their colour and their size.
409
+ if (rubbing) {
410
+ drawFlipper(grid, layout.orca.x + 9 * layout.scale, layout.orca.y + bob + 15 * layout.scale, flipperSize, false)
411
+ }
412
+
413
+ // The telephone is always on the desk, and is lifted for a subagent.
414
+ const ringing = kind === 'ringing'
415
+ const onThePhone = kind === 'calling' || ringing
416
+ const ear = earFor(layout)
417
+ if (ringing) {
418
+ // Shaken, not held still: the telephone is ringing.
419
+ ear.shake = (tick % 4 < 2 ? 1 : -1) * Math.max(1, layout.scale)
420
+ }
421
+ // `phoneLift` is the pickup/hangup animation: 0 on the cradle, 1 at the ear,
422
+ // and whatever is in between while the handset is travelling. Callers that
423
+ // do not animate it get the settled pose.
424
+ const lift = Number.isFinite(options.phoneLift) ? options.phoneLift : onThePhone ? 1 : 0
425
+ ear.lift = lift
426
+ const handset = drawPhone(grid, layout.phone.x, layout.phone.y, layout.phone.width, layout.phone.height, ear)
427
+ if (ringing && lift > 0.5) {
428
+ drawRingArcs(grid, layout.phone.x + Math.round(layout.phone.width / 2), layout.phone.y, layout.scale, frame)
429
+ drawRingArcs(grid, ear.x, ear.y - 4 * layout.scale, layout.scale, frame)
430
+ }
431
+
432
+ // On the telephone the orca holds the handset: a flipper runs from the
433
+ // mouthpiece back to the body, drawn over it so the fin reads as gripping.
434
+ if (holding && handset !== undefined) {
435
+ // Anchored at the mouthpiece end of the handset as it is right now, so the
436
+ // fin follows it up and down instead of staying behind at the ear.
437
+ const dx = Math.cos(handset.angle)
438
+ const dy = Math.sin(handset.angle)
439
+ const mouthX = handset.cx - dx * (handset.length / 2)
440
+ const mouthY = handset.cy - dy * (handset.length / 2)
441
+ drawLimb(
442
+ grid,
443
+ Math.round(mouthX),
444
+ Math.round(mouthY + 2 * layout.scale),
445
+ Math.max(2, layout.scale * 2.5),
446
+ 8 * layout.scale,
447
+ 5 * layout.scale,
448
+ 'f'
449
+ )
450
+ }
451
+
452
+ // --- effects ------------------------------------------------------------
453
+ if (kind === 'sleep') {
454
+ const s = layout.scale
455
+ for (let index = 0; index < 3; index += 1) {
456
+ const rise = mod(tick + index * 4, CYCLE)
457
+ // Big enough to read from across the room: at two or three pixels a z
458
+ // was a speck.
459
+ const zsize = (4 + index * 2) * s
460
+ // Above the head, on the wall: drawn over the window they were swallowed
461
+ // by the sky.
462
+ const zx = layout.orca.x + (6 + index * 7) * s + Math.floor(rise / 4) * s
463
+ const zy = Math.max(2, layout.orca.y + 10 * s - index * 6 * s - rise * s)
464
+ if (zy + zsize < grid.length && zx + zsize < grid[0].length) drawSleepZ(grid, zx, zy, zsize)
465
+ }
466
+ }
467
+
468
+ return grid
469
+ }
470
+
471
+ /** The window above the keyboard, if the scene is wide enough for one. */
472
+ function drawTypingWindow(grid, layout) {
473
+ const area = layout.window
474
+ if (area.width < 14 || area.height < 6) return
475
+ drawWindow(grid, area.x, area.y, area.width, area.height)
476
+ }
477
+
478
+ /**
479
+ * Where the typing window's text goes, in terminal-cell coordinates, and how
480
+ * much of it fits.
481
+ *
482
+ * @param {{ width: number, height: number }} size
483
+ */
484
+ export function previewArea(size) {
485
+ const layout = layoutFor(size)
486
+ const area = layout.window
487
+ if (area.width < 14 || area.height < 6) return null
488
+ return {
489
+ row: Math.floor(area.y / 2) + 1,
490
+ col: area.x + 2,
491
+ width: Math.max(1, area.width - 4),
492
+ rows: Math.max(1, Math.min(3, Math.floor(area.height / 2) - 2))
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Wrap the tail of some text to a cell budget.
498
+ *
499
+ * The tail is what matters — it is what is being typed right now — so the
500
+ * beginning is dropped rather than the end.
501
+ *
502
+ * @param {string} text
503
+ * @param {number} width cells per line
504
+ * @param {number} rows how many lines fit
505
+ * @returns {string[]}
506
+ */
507
+ export function wrapPreview(text, width, rows) {
508
+ const clean = String(text ?? '').replace(/\s+/g, ' ').trim()
509
+ if (clean.length === 0 || rows <= 0) return []
510
+ const characters = [...clean]
511
+ // A generous tail: enough that the wrapping below always fills `rows` lines.
512
+ const tail = characters.slice(-(width * rows + width))
513
+ const lines = []
514
+ let line = ''
515
+ let used = 0
516
+ for (const character of tail) {
517
+ const cellWidth = displayWidth(character)
518
+ if (used + cellWidth > width && line.length > 0) {
519
+ lines.push(line)
520
+ line = ''
521
+ used = 0
522
+ }
523
+ line += character
524
+ used += cellWidth
525
+ }
526
+ if (line.length > 0) lines.push(line)
527
+ return lines.slice(-rows)
528
+ }
529
+
530
+ /** A small trail of thought dots above the orca's head. */
531
+ function drawThoughtDots(grid, layout, frame) {
532
+ const s = layout.scale
533
+ const dots = 1 + mod(Math.floor(frame / 2), 3)
534
+ for (let index = 0; index < dots; index += 1) {
535
+ const dx = layout.orca.x + layout.orca.width - 10 * s + index * 4 * s
536
+ const dy = Math.max(0, layout.orca.y - 4 * s)
537
+ rect(grid, dx, dy, dx + s - 1, dy + s - 1, 'o')
538
+ }
539
+ }
540
+
541
+ /** The document being written: a sheet that gains a line as the pen moves. */
542
+ function drawDocument(grid, area, frame) {
543
+ const { x, y, width, height } = area
544
+ rect(grid, x, y, x + width - 1, y + height - 1, 'p')
545
+ rect(grid, x, y, x + width - 1, y, 'o')
546
+ rect(grid, x, y, x, y + height - 1, 'o')
547
+ rect(grid, x + width - 1, y, x + width - 1, y + height - 1, 'o')
548
+ const lines = 1 + mod(Math.floor(frame / 2), Math.max(1, height - 3))
549
+ for (let index = 0; index < lines; index += 1) {
550
+ const ly = y + 1 + index
551
+ if (ly > y + height - 2) break
552
+ rect(grid, x + 2, ly, x + width - 3, ly, 'K')
553
+ }
554
+ }
555
+
556
+ /** A terminal window with a blinking cursor, for a command that is running. */
557
+ function drawTerminal(grid, area, frame) {
558
+ const { x, y, width, height } = area
559
+ rect(grid, x, y, x + width - 1, y + height - 1, 'k')
560
+ rect(grid, x, y, x + width - 1, y, 'K')
561
+ const line = Math.max(2, height - 3)
562
+ rect(grid, x + 2, line, x + width - 3, line, 'o')
563
+ const span = Math.max(1, width - 6)
564
+ const cursorX = x + 2 + mod(Math.floor(frame / 2), span)
565
+ rect(grid, x + 2, line + 2, cursorX, line + 2, 'K')
566
+ if (mod(frame, 4) < 2) rect(grid, cursorX + 1, line + 2, cursorX + 1, line + 3, 'a')
567
+ }
568
+
569
+ /**
570
+ * Pack a pixel grid into terminal cells, two pixel rows per cell.
571
+ *
572
+ * @param {string[][]} grid
573
+ * @param {Record<string, string>} palette
574
+ * @returns {{ text: string, sgr: string }[][]}
575
+ */
576
+ export function cellsFromGrid(grid, palette) {
577
+ const width = grid[0].length
578
+ const cells = []
579
+ for (let y = 0; y + 1 < grid.length; y += 2) {
580
+ const row = []
581
+ for (let x = 0; x < width; x += 1) {
582
+ const top = grid[y][x]
583
+ const bottom = grid[y + 1][x]
584
+ const glyph = GLYPHS[(top === EMPTY ? 0 : 1) + (bottom === EMPTY ? 0 : 2)]
585
+ let cls = EMPTY
586
+ for (const candidate of CLASS_ORDER) {
587
+ if (top === candidate || bottom === candidate) {
588
+ cls = candidate
589
+ break
590
+ }
591
+ }
592
+ const key = CLASS_KEY[cls]
593
+ const sgr = glyph === ' ' || key === undefined ? '' : (palette?.[key] ?? '')
594
+ row.push({ text: glyph, sgr })
595
+ }
596
+ cells.push(row)
597
+ }
598
+ return cells
599
+ }
600
+
601
+ /** Render one cell row to an ANSI string, merging runs of equal style. */
602
+ export function renderCells(row) {
603
+ let out = ''
604
+ let current = null
605
+ let buffer = ''
606
+ const flush = () => {
607
+ if (buffer.length === 0) return
608
+ out += current === '' || current === null ? buffer : `\u001b[${current}m${buffer}\u001b[0m`
609
+ buffer = ''
610
+ }
611
+ for (const cell of row) {
612
+ const sgr = cell.sgr ?? ''
613
+ if (sgr !== current) {
614
+ flush()
615
+ current = sgr
616
+ }
617
+ buffer += cell.text
618
+ }
619
+ flush()
620
+ return out
621
+ }
622
+
623
+ /**
624
+ * Where the bubble's text lines go, in terminal-cell coordinates.
625
+ *
626
+ * @param {number} lines
627
+ * @param {string[]} texts
628
+ * @param {{ width?: number, height?: number }} [size]
629
+ */
630
+ export function bubbleLayout(lines, texts, size = {}, layout = null) {
631
+ const list = Array.isArray(texts) ? texts : []
632
+ const text = list.join('\n')
633
+ const geo = bubbleGeometry(list[0] ?? '', list.slice(1).join('\n'), size, layout)
634
+ const grid = canvas(
635
+ Number.isFinite(size?.width) ? size.width : SCENE_W,
636
+ (Number.isFinite(size?.height) ? size.height : SCENE_H / 2) * 2
637
+ )
638
+ drawBubble(grid, geo.x, geo.y, geo.innerWidth, Math.max(2, lines * 2))
639
+ return {
640
+ grid,
641
+ rows: list.map((line, index) => ({
642
+ row: geo.row + index,
643
+ col: geo.col,
644
+ text: truncate(line, geo.innerWidth, '…')
645
+ }))
646
+ }
647
+ }
648
+
649
+ /**
650
+ * How many rows of the *message* the bubble shows before it starts scrolling.
651
+ * The header sits above these and never scrolls: it is what says who is on the
652
+ * line, so losing it to a long instruction would leave the bubble anonymous.
653
+ */
654
+ export const BUBBLE_ROWS = 3
655
+
656
+ /** Rows in the bubble in total: the pinned header plus the scrolling message. */
657
+ export const BUBBLE_TOTAL_ROWS = BUBBLE_ROWS + 1
658
+
659
+ /**
660
+ * Where the bubble sits and how much text fits, for a given message.
661
+ *
662
+ * The box is sized from the whole message so it does not jump about while the
663
+ * text is still being spoken.
664
+ */
665
+ export function bubbleGeometry(header, body, size, layout = null) {
666
+ const sceneW = Number.isFinite(size?.width) ? size.width : SCENE_W
667
+ const sceneH = Number.isFinite(size?.height) ? size.height : SCENE_H / 2
668
+ const geometry = layout ?? layoutFor({ width: sceneW, height: sceneH })
669
+ const widest = [String(header ?? ''), ...String(body ?? '').split('\n')]
670
+ .reduce((max, line) => Math.max(max, displayWidth(line)), 0)
671
+ // Anchored over the orca's head. A speech bubble pinned to the corner of the
672
+ // scene, with its tail pointing at nothing, reads as a stray box floating
673
+ // outside the interface.
674
+ let x = Math.max(1, Math.min(geometry.orca.x - 1, sceneW - 24))
675
+ const y = Math.max(0, Math.min(geometry.orca.y - 2, sceneH * 2 - BUBBLE_TOTAL_ROWS * 2 - 6))
676
+ // Where the bubble and the preview window share rows on a small scene, the
677
+ // bubble moves aside rather than being squeezed: a narrow bubble wraps the
678
+ // message into a column and hides what it is trying to say.
679
+ const hudTop = Math.floor(geometry.window.y / 2)
680
+ const hudBottom = Math.floor((geometry.window.y + geometry.window.height) / 2)
681
+ const bubbleTop = Math.floor(y / 2)
682
+ const sharesRows = bubbleTop <= hudBottom && bubbleTop + BUBBLE_TOTAL_ROWS >= hudTop
683
+ const hudRight = geometry.window.x + geometry.window.width
684
+ if (sharesRows && x < hudRight + 2) x = Math.min(hudRight + 2, Math.max(1, sceneW - 24))
685
+ const room = Math.max(16, Math.min(Math.round(sceneW * 0.55), sceneW - x - 5))
686
+ const innerWidth = Math.max(12, Math.min(room, widest + 2))
687
+ return {
688
+ x,
689
+ y,
690
+ innerWidth,
691
+ rows: BUBBLE_TOTAL_ROWS,
692
+ bodyRows: BUBBLE_ROWS,
693
+ col: x + 2,
694
+ row: Math.floor((y + 1) / 2)
695
+ }
696
+ }
697
+
698
+ /** Wrap plain text to a cell budget, honouring explicit newlines. */
699
+ export function wrapAll(text, width) {
700
+ const out = []
701
+ for (const paragraph of String(text ?? '').split('\n')) {
702
+ if (paragraph.length === 0) {
703
+ out.push('')
704
+ continue
705
+ }
706
+ let line = ''
707
+ let used = 0
708
+ for (const character of paragraph) {
709
+ const cellWidth = displayWidth(character)
710
+ if (used + cellWidth > width && line.length > 0) {
711
+ out.push(line)
712
+ line = ''
713
+ used = 0
714
+ }
715
+ line += character
716
+ used += cellWidth
717
+ }
718
+ out.push(line)
719
+ }
720
+ return out
721
+ }
722
+
723
+ /**
724
+ * The lines the bubble is showing on this frame.
725
+ *
726
+ * The message is revealed a character at a time — a telephone call is speech,
727
+ * and speech arrives in pieces — and once it is longer than the bubble the
728
+ * window scrolls to keep the newest words in view, then cycles so the start is
729
+ * not lost either.
730
+ *
731
+ * @returns {{ lines: string[], typing: boolean, total: number }}
732
+ */
733
+ export function spokenLines(text, width, rows, frame, options = {}) {
734
+ const raw = String(text ?? '')
735
+ if (raw.length === 0) return { lines: [], typing: false, total: 0 }
736
+ const perFrame = Number.isFinite(options.perFrame) ? options.perFrame : 2.5
737
+ // At least one character, so a message is never invisible on its first frame.
738
+ const revealed = raw.slice(0, Math.min(raw.length, Math.max(1, Math.floor(frame * perFrame))))
739
+ const typing = revealed.length < raw.length
740
+ const wrapped = wrapAll(revealed, Math.max(1, width))
741
+ if (wrapped.length <= rows) return { lines: wrapped, typing, total: wrapped.length }
742
+ const maxStart = wrapped.length - rows
743
+ const start = typing ? maxStart : Math.floor(frame / 6) % (maxStart + 1)
744
+ return { lines: wrapped.slice(start, start + rows), typing, total: wrapped.length }
745
+ }
746
+
747
+ /**
748
+ * Compose one complete scene frame.
749
+ *
750
+ * @param {{ kind: string, subagent?: object | null }} work
751
+ * @param {number} frame
752
+ * @param {{ palette?: Record<string, string>, bubbleLines?: string[] | null, size?: { width: number, height: number } }} [options]
753
+ * @returns {{ cells: { text: string, sgr: string }[][], width: number, height: number }}
754
+ */
755
+ export function sceneFrame(work, frame, options = {}) {
756
+ const size = options.size ?? { width: SCENE_W, height: SCENE_H / 2 }
757
+ const sky = options.sky ?? skyState(options.clockMs)
758
+ // The sky is the one colour that changes on its own, so it is folded into the
759
+ // palette rather than baked into the art. A palette that names no sky stays
760
+ // colourless, which is what `--no-color` relies on.
761
+ const palette = { ...(options.palette ?? {}) }
762
+ // The sky is the one group of colours that changes on its own, so they are
763
+ // folded into the palette rather than baked into the art.
764
+ for (const [key, value] of Object.entries(sky.colors.sgr)) {
765
+ if (key in palette) palette[key] = value
766
+ }
767
+ const grid = sceneGrid(work, frame, {
768
+ size,
769
+ phoneLift: options.phoneLift,
770
+ sky,
771
+ scene: options.scene,
772
+ sleepFor: options.sleepFor
773
+ })
774
+ const cells = cellsFromGrid(grid, palette)
775
+
776
+ const preview = options.previewText ?? null
777
+ if (
778
+ preview !== null &&
779
+ preview !== undefined &&
780
+ String(preview).length > 0 &&
781
+ windowVisible(work?.kind ?? 'sleep', size)
782
+ ) {
783
+ const area = previewArea(size)
784
+ if (area !== null) {
785
+ const rows = wrapPreview(preview, area.width, area.rows)
786
+ rows.forEach((text, index) => {
787
+ const row = area.row + index
788
+ if (row < 0 || row >= cells.length) return
789
+ let x = area.col
790
+ for (const character of text) {
791
+ const cellWidth = displayWidth(character)
792
+ if (x >= 0 && x < cells[row].length) cells[row][x] = { text: character, sgr: palette.text ?? '' }
793
+ for (let extra = 1; extra < cellWidth; extra += 1) {
794
+ if (x + extra >= 0 && x + extra < cells[row].length) cells[row][x + extra] = { text: '', sgr: '' }
795
+ }
796
+ x += cellWidth
797
+ }
798
+ })
799
+ }
800
+ }
801
+
802
+ const given = Array.isArray(options.bubbleLines) ? options.bubbleLines : null
803
+ const header = options.bubbleHeader ?? given?.[0] ?? (typeof options.bubbleText === 'string' ? options.bubbleText.split('\n')[0] : null)
804
+ const body = options.bubbleBody ?? (given !== null ? given.slice(1).join('\n') : (typeof options.bubbleText === 'string' ? options.bubbleText.split('\n').slice(1).join('\n') : ''))
805
+ if (header !== null && header !== undefined && String(header).length > 0) {
806
+ const geo = bubbleGeometry(header, body, size, layoutFor(size))
807
+ const boxGrid = canvas(size.width, size.height * 2)
808
+ drawBubble(boxGrid, geo.x, geo.y, geo.innerWidth, geo.rows * 2)
809
+ const boxCells = cellsFromGrid(boxGrid, palette)
810
+ for (let y = 0; y < boxCells.length && y < cells.length; y += 1) {
811
+ for (let x = 0; x < boxCells[y].length; x += 1) {
812
+ if (boxCells[y][x].text !== ' ') cells[y][x] = boxCells[y][x]
813
+ }
814
+ }
815
+
816
+ const put = (row, line) => {
817
+ if (row < 0 || row >= cells.length) return
818
+ let x = geo.col
819
+ for (const character of line) {
820
+ const cellWidth = displayWidth(character)
821
+ if (x >= 0 && x < cells[row].length) cells[row][x] = { text: character, sgr: palette.text ?? '' }
822
+ for (let extra = 1; extra < cellWidth; extra += 1) {
823
+ if (x + extra >= 0 && x + extra < cells[row].length) cells[row][x + extra] = { text: '', sgr: '' }
824
+ }
825
+ x += cellWidth
826
+ }
827
+ }
828
+
829
+ // The header identifies who is on the line, so it is pinned; only the
830
+ // message below it is spoken and scrolled.
831
+ put(geo.row, truncate(String(header), geo.innerWidth, '…'))
832
+ const spoken = spokenLines(body, geo.innerWidth, geo.bodyRows, options.speechFrame ?? frame, options.speech)
833
+ spoken.lines.forEach((line, index) => put(geo.row + 1 + index, line))
834
+ }
835
+
836
+ return { cells, width: size.width, height: size.height, sky }
837
+ }