@sayknow-cli/tui 0.6.8 → 0.7.1

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.
@@ -0,0 +1,389 @@
1
+ /**
2
+ * Sayo sprite: the pet drawn from the Sayo mascot image instead of the 16×16 pixel grid,
3
+ * for terminals with the kitty graphics protocol (Ghostty, kitty, WezTerm).
4
+ *
5
+ * Every pose is one image transformed: a tilt about the tentacle line (dance, glance),
6
+ * squash and stretch (the "yay" beat), eyelids painted over the eyes (blink) and tears
7
+ * below them (the sob). Colors are hue shifts of the orange original, so red and blue
8
+ * keep the same shading. Nothing here touches the network or the file system; the source
9
+ * PNG is embedded and decoded once.
10
+ */
11
+ import { inflateSync } from "node:zlib";
12
+ import { SAYO_SOURCE_PNG_BASE64 } from "./sayo-sprite-source";
13
+
14
+ export type SayoPose = "base" | "gazeL" | "gazeR" | "flicker" | "flex" | "danceL" | "danceR" | "cry1" | "cry2" | "cry3";
15
+
16
+ export type SayoTint = "orange" | "red" | "blue";
17
+
18
+ /** Straight (non-premultiplied) RGBA pixels. */
19
+ export interface RgbaImage {
20
+ width: number;
21
+ height: number;
22
+ data: Uint8Array;
23
+ }
24
+
25
+ // ── PNG decoding (8-bit RGB/RGBA, non-interlaced: what Bun.Image writes) ──────────────
26
+
27
+ function paeth(a: number, b: number, c: number): number {
28
+ const p = a + b - c;
29
+ const pa = Math.abs(p - a);
30
+ const pb = Math.abs(p - b);
31
+ const pc = Math.abs(p - c);
32
+ return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
33
+ }
34
+
35
+ export function decodePng(bytes: Uint8Array): RgbaImage {
36
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
37
+ let offset = 8;
38
+ let width = 0;
39
+ let height = 0;
40
+ let colorType = 0;
41
+ const idat: Uint8Array[] = [];
42
+ while (offset < bytes.length) {
43
+ const length = view.getUint32(offset);
44
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
45
+ const body = bytes.subarray(offset + 8, offset + 8 + length);
46
+ if (type === "IHDR") {
47
+ width = view.getUint32(offset + 8);
48
+ height = view.getUint32(offset + 12);
49
+ const depth = body[8];
50
+ colorType = body[9]!;
51
+ if (depth !== 8 || (colorType !== 6 && colorType !== 2) || body[12] !== 0) {
52
+ throw new Error(`Unsupported PNG (depth ${depth}, color type ${colorType}, interlace ${body[12]})`);
53
+ }
54
+ } else if (type === "IDAT") idat.push(body);
55
+ else if (type === "IEND") break;
56
+ offset += 12 + length;
57
+ }
58
+ const raw = inflateSync(Buffer.concat(idat));
59
+ const channels = colorType === 6 ? 4 : 3;
60
+ const stride = width * channels;
61
+ const pixels = new Uint8Array(width * height * channels);
62
+ for (let y = 0; y < height; y++) {
63
+ const filter = raw[y * (stride + 1)]!;
64
+ const src = y * (stride + 1) + 1;
65
+ const dst = y * stride;
66
+ for (let x = 0; x < stride; x++) {
67
+ const value = raw[src + x]!;
68
+ const left = x >= channels ? pixels[dst + x - channels]! : 0;
69
+ const up = y > 0 ? pixels[dst - stride + x]! : 0;
70
+ const upLeft = y > 0 && x >= channels ? pixels[dst - stride + x - channels]! : 0;
71
+ pixels[dst + x] =
72
+ (filter === 0
73
+ ? value
74
+ : filter === 1
75
+ ? value + left
76
+ : filter === 2
77
+ ? value + up
78
+ : filter === 3
79
+ ? value + ((left + up) >> 1)
80
+ : value + paeth(left, up, upLeft)) & 0xff;
81
+ }
82
+ }
83
+ if (channels === 4) return { width, height, data: pixels };
84
+ const data = new Uint8Array(width * height * 4);
85
+ for (let i = 0, j = 0; i < pixels.length; i += 3, j += 4) {
86
+ data[j] = pixels[i]!;
87
+ data[j + 1] = pixels[i + 1]!;
88
+ data[j + 2] = pixels[i + 2]!;
89
+ data[j + 3] = 255;
90
+ }
91
+ return { width, height, data };
92
+ }
93
+
94
+ // ── Source: decoded once, cropped to the octopus ─────────────────────────────────────
95
+
96
+ interface SayoSource {
97
+ /** Premultiplied RGBA floats of the cropped octopus. */
98
+ pixels: Float32Array;
99
+ width: number;
100
+ height: number;
101
+ }
102
+
103
+ let cachedSource: SayoSource | undefined;
104
+
105
+ function sayoSource(): SayoSource {
106
+ if (cachedSource) return cachedSource;
107
+ const image = decodePng(Buffer.from(SAYO_SOURCE_PNG_BASE64, "base64"));
108
+ let minX = image.width;
109
+ let minY = image.height;
110
+ let maxX = -1;
111
+ let maxY = -1;
112
+ for (let y = 0; y < image.height; y++) {
113
+ for (let x = 0; x < image.width; x++) {
114
+ if (image.data[(y * image.width + x) * 4 + 3]! < 24) continue;
115
+ if (x < minX) minX = x;
116
+ if (x > maxX) maxX = x;
117
+ if (y < minY) minY = y;
118
+ if (y > maxY) maxY = y;
119
+ }
120
+ }
121
+ // Square crop around the octopus, so every pose shares one box.
122
+ const side = Math.max(maxX - minX + 1, maxY - minY + 1);
123
+ const originX = Math.round((minX + maxX + 1 - side) / 2);
124
+ const originY = Math.round((minY + maxY + 1 - side) / 2);
125
+ const pixels = new Float32Array(side * side * 4);
126
+ for (let y = 0; y < side; y++) {
127
+ for (let x = 0; x < side; x++) {
128
+ const sx = originX + x;
129
+ const sy = originY + y;
130
+ if (sx < 0 || sy < 0 || sx >= image.width || sy >= image.height) continue;
131
+ const i = (sy * image.width + sx) * 4;
132
+ const alpha = image.data[i + 3]! / 255;
133
+ const o = (y * side + x) * 4;
134
+ pixels[o] = (image.data[i]! / 255) * alpha;
135
+ pixels[o + 1] = (image.data[i + 1]! / 255) * alpha;
136
+ pixels[o + 2] = (image.data[i + 2]! / 255) * alpha;
137
+ pixels[o + 3] = alpha;
138
+ }
139
+ }
140
+ cachedSource = { pixels, width: side, height: side };
141
+ return cachedSource;
142
+ }
143
+
144
+ /** Area-average downscale (premultiplied), so small sizes stay smooth instead of aliased. */
145
+ function downscale(source: SayoSource, size: number): SayoSource {
146
+ const out = new Float32Array(size * size * 4);
147
+ const ratio = source.width / size;
148
+ for (let y = 0; y < size; y++) {
149
+ const y0 = y * ratio;
150
+ const y1 = (y + 1) * ratio;
151
+ for (let x = 0; x < size; x++) {
152
+ const x0 = x * ratio;
153
+ const x1 = (x + 1) * ratio;
154
+ let r = 0;
155
+ let g = 0;
156
+ let b = 0;
157
+ let a = 0;
158
+ let weight = 0;
159
+ for (let sy = Math.floor(y0); sy < Math.ceil(y1); sy++) {
160
+ const wy = Math.min(y1, sy + 1) - Math.max(y0, sy);
161
+ for (let sx = Math.floor(x0); sx < Math.ceil(x1); sx++) {
162
+ const w = wy * (Math.min(x1, sx + 1) - Math.max(x0, sx));
163
+ const i = (sy * source.width + sx) * 4;
164
+ r += source.pixels[i]! * w;
165
+ g += source.pixels[i + 1]! * w;
166
+ b += source.pixels[i + 2]! * w;
167
+ a += source.pixels[i + 3]! * w;
168
+ weight += w;
169
+ }
170
+ }
171
+ const o = (y * size + x) * 4;
172
+ out[o] = r / weight;
173
+ out[o + 1] = g / weight;
174
+ out[o + 2] = b / weight;
175
+ out[o + 3] = a / weight;
176
+ }
177
+ }
178
+ return { pixels: out, width: size, height: size };
179
+ }
180
+
181
+ function sampleBilinear(source: SayoSource, x: number, y: number, out: Float32Array): void {
182
+ const fx = x - 0.5;
183
+ const fy = y - 0.5;
184
+ const x0 = Math.floor(fx);
185
+ const y0 = Math.floor(fy);
186
+ const tx = fx - x0;
187
+ const ty = fy - y0;
188
+ out.fill(0);
189
+ for (const [dx, dy, w] of [
190
+ [0, 0, (1 - tx) * (1 - ty)],
191
+ [1, 0, tx * (1 - ty)],
192
+ [0, 1, (1 - tx) * ty],
193
+ [1, 1, tx * ty],
194
+ ] as const) {
195
+ const sx = x0 + dx;
196
+ const sy = y0 + dy;
197
+ if (w === 0 || sx < 0 || sy < 0 || sx >= source.width || sy >= source.height) continue;
198
+ const i = (sy * source.width + sx) * 4;
199
+ out[0] += source.pixels[i]! * w;
200
+ out[1] += source.pixels[i + 1]! * w;
201
+ out[2] += source.pixels[i + 2]! * w;
202
+ out[3] += source.pixels[i + 3]! * w;
203
+ }
204
+ }
205
+
206
+ // ── Poses ────────────────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Eye centers and radius in the cropped source, as fractions of its side (measured on
210
+ * sayo-logo.png: glossy black eyes either side of the smile).
211
+ */
212
+ const EYES = [
213
+ { x: 0.297, y: 0.438 },
214
+ { x: 0.644, y: 0.441 },
215
+ ] as const;
216
+ /** Half the measured eye height (0.13 of the side), so a lid covers the whole glossy eye. */
217
+ const EYE_RADIUS = 0.066;
218
+
219
+ interface PoseTransform {
220
+ /** Tilt in degrees about the tentacle line (positive leans right). */
221
+ tilt: number;
222
+ scaleX: number;
223
+ scaleY: number;
224
+ /** Horizontal shift as a fraction of the box. */
225
+ shiftX: number;
226
+ blink: boolean;
227
+ /** Pupil glance: -1 left, +1 right. */
228
+ glance: number;
229
+ /** Tear progress 1..3, 0 for none. */
230
+ tear: number;
231
+ }
232
+
233
+ const POSES: Record<SayoPose, PoseTransform> = {
234
+ base: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: false, glance: 0, tear: 0 },
235
+ gazeL: { tilt: -3, scaleX: 1, scaleY: 1, shiftX: 0, blink: false, glance: -1, tear: 0 },
236
+ gazeR: { tilt: 3, scaleX: 1, scaleY: 1, shiftX: 0, blink: false, glance: 1, tear: 0 },
237
+ flicker: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 0 },
238
+ flex: { tilt: 0, scaleX: 1.07, scaleY: 0.92, shiftX: 0, blink: false, glance: 0, tear: 0 },
239
+ danceL: { tilt: -9, scaleX: 1, scaleY: 1.02, shiftX: -0.04, blink: false, glance: 0, tear: 0 },
240
+ danceR: { tilt: 9, scaleX: 1, scaleY: 1.02, shiftX: 0.04, blink: false, glance: 0, tear: 0 },
241
+ cry1: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 1 },
242
+ cry2: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 2 },
243
+ cry3: { tilt: 0, scaleX: 1, scaleY: 1, shiftX: 0, blink: true, glance: 0, tear: 3 },
244
+ };
245
+
246
+ /** The box keeps a margin so tilted and squashed poses are not clipped. */
247
+ const POSE_MARGIN = 0.07;
248
+ /** Pivot for tilt and squash: the tentacle line, near the bottom of the octopus. */
249
+ const PIVOT_Y = 0.86;
250
+
251
+ const TEAR_RGB = [0.72, 0.9, 1] as const;
252
+ const LID_DARK = [0.13, 0.08, 0.07] as const;
253
+
254
+ /**
255
+ * Map a pixel of the pose into source coordinates (fractions of the source side).
256
+ * Returns the inverse of: shift ∘ tilt ∘ squash, all about the pivot.
257
+ */
258
+ function inversePose(pose: PoseTransform, u: number, v: number): [number, number] {
259
+ // Box → unmargined sprite space.
260
+ let x = (u - POSE_MARGIN) / (1 - 2 * POSE_MARGIN) - pose.shiftX;
261
+ let y = (v - POSE_MARGIN) / (1 - 2 * POSE_MARGIN);
262
+ const px = 0.5;
263
+ const py = PIVOT_Y;
264
+ const angle = (-pose.tilt * Math.PI) / 180;
265
+ const cos = Math.cos(angle);
266
+ const sin = Math.sin(angle);
267
+ const rx = (x - px) * cos - (y - py) * sin;
268
+ const ry = (x - px) * sin + (y - py) * cos;
269
+ x = px + rx / pose.scaleX;
270
+ y = py + ry / pose.scaleY;
271
+ return [x, y];
272
+ }
273
+
274
+ /** Render one pose, tinted, into a `size`×`size` straight-RGBA image. */
275
+ export function renderSayoPose(pose: SayoPose, tint: SayoTint, size: number): RgbaImage {
276
+ const transform = POSES[pose];
277
+ // Work at up to 3× and area-average down: smooth edges, crisp eyes. Past the source's
278
+ // own resolution supersampling adds nothing but time.
279
+ const sourceSide = sayoSource().width;
280
+ const work = Math.max(8, Math.min(size * 3, Math.max(size, Math.ceil(sourceSide / (1 - 2 * POSE_MARGIN)))));
281
+ const source = downscale(sayoSource(), Math.min(sourceSide, Math.round(work * (1 - 2 * POSE_MARGIN))));
282
+ const side = source.width;
283
+ const sample = new Float32Array(4);
284
+ const bodyAbove = new Float32Array(4);
285
+ const canvas = new Float32Array(work * work * 4);
286
+ for (let y = 0; y < work; y++) {
287
+ for (let x = 0; x < work; x++) {
288
+ const [su, sv] = inversePose(transform, (x + 0.5) / work, (y + 0.5) / work);
289
+ let lx = su;
290
+ const ly = sv;
291
+ let eyeDistance = Number.POSITIVE_INFINITY;
292
+ let eyeIndex = -1;
293
+ for (let e = 0; e < EYES.length; e++) {
294
+ const d = Math.hypot((su - EYES[e]!.x) / EYE_RADIUS, (sv - EYES[e]!.y) / EYE_RADIUS);
295
+ if (d < eyeDistance) {
296
+ eyeDistance = d;
297
+ eyeIndex = e;
298
+ }
299
+ }
300
+ const eye = EYES[eyeIndex]!;
301
+ const inEye = eyeDistance <= 1.25;
302
+ if (inEye && transform.glance !== 0 && !transform.blink) lx = su - transform.glance * EYE_RADIUS * 0.3;
303
+ sampleBilinear(source, lx * side, ly * side, sample);
304
+ if (
305
+ inEye &&
306
+ (transform.blink ||
307
+ (transform.glance !== 0 && Math.hypot((lx - eye.x) / EYE_RADIUS, (ly - eye.y) / EYE_RADIUS) > 1.25))
308
+ ) {
309
+ // Skin from just above the eye covers it; a blink adds a closed-lid line.
310
+ sampleBilinear(source, su * side, (eye.y - EYE_RADIUS * 2.1) * side, bodyAbove);
311
+ sample.set(bodyAbove);
312
+ const lid =
313
+ transform.blink && Math.abs((sv - eye.y - EYE_RADIUS * 0.15) / EYE_RADIUS) < 0.22 && eyeDistance <= 1.05;
314
+ if (lid) {
315
+ sample[0] = LID_DARK[0] * sample[3]!;
316
+ sample[1] = LID_DARK[1] * sample[3]!;
317
+ sample[2] = LID_DARK[2] * sample[3]!;
318
+ }
319
+ }
320
+ if (transform.tear > 0) {
321
+ const outer = eyeIndex === 0 ? -1 : 1;
322
+ const tx = eye.x + outer * EYE_RADIUS * 0.9;
323
+ const ty = eye.y + EYE_RADIUS * (1.2 + transform.tear * 0.8);
324
+ const t = Math.hypot((su - tx) / (EYE_RADIUS * 0.45), (sv - ty) / (EYE_RADIUS * 0.6));
325
+ if (t <= 1 && sample[3]! > 0.5) {
326
+ sample[0] = TEAR_RGB[0];
327
+ sample[1] = TEAR_RGB[1];
328
+ sample[2] = TEAR_RGB[2];
329
+ sample[3] = 1;
330
+ }
331
+ }
332
+ canvas.set(sample, (y * work + x) * 4);
333
+ }
334
+ }
335
+ const scaled = downscale({ pixels: canvas, width: work, height: work }, size);
336
+ const data = new Uint8Array(size * size * 4);
337
+ for (let i = 0; i < size * size; i++) {
338
+ const a = scaled.pixels[i * 4 + 3]!;
339
+ if (a <= 0.002) continue;
340
+ const [r, g, b] = tintRgb(
341
+ scaled.pixels[i * 4]! / a,
342
+ scaled.pixels[i * 4 + 1]! / a,
343
+ scaled.pixels[i * 4 + 2]! / a,
344
+ tint,
345
+ );
346
+ data[i * 4] = Math.round(Math.min(1, r) * 255);
347
+ data[i * 4 + 1] = Math.round(Math.min(1, g) * 255);
348
+ data[i * 4 + 2] = Math.round(Math.min(1, b) * 255);
349
+ data[i * 4 + 3] = Math.round(Math.min(1, a) * 255);
350
+ }
351
+ return { width: size, height: size, data };
352
+ }
353
+
354
+ // ── Tint ─────────────────────────────────────────────────────────────────────────────
355
+
356
+ /** Hue shift in degrees from Sayo's orange; neutral pixels (eyes, highlights) are unaffected. */
357
+ const TINT_HUE: Record<SayoTint, number> = { orange: 0, red: -16, blue: 184 };
358
+ const TINT_SATURATION: Record<SayoTint, number> = { orange: 1, red: 1.05, blue: 0.95 };
359
+
360
+ function tintRgb(r: number, g: number, b: number, tint: SayoTint): [number, number, number] {
361
+ if (tint === "orange") return [r, g, b];
362
+ const max = Math.max(r, g, b);
363
+ const min = Math.min(r, g, b);
364
+ const delta = max - min;
365
+ if (delta < 0.05) return [r, g, b];
366
+ let hue = max === r ? ((g - b) / delta) % 6 : max === g ? (b - r) / delta + 2 : (r - g) / delta + 4;
367
+ hue = (hue * 60 + TINT_HUE[tint] + 360) % 360;
368
+ const saturation = Math.min(1, (delta / max) * TINT_SATURATION[tint]);
369
+ const value = max;
370
+ const c = value * saturation;
371
+ const x = c * (1 - Math.abs(((hue / 60) % 2) - 1));
372
+ const m = value - c;
373
+ const [r1, g1, b1] =
374
+ hue < 60
375
+ ? [c, x, 0]
376
+ : hue < 120
377
+ ? [x, c, 0]
378
+ : hue < 180
379
+ ? [0, c, x]
380
+ : hue < 240
381
+ ? [0, x, c]
382
+ : hue < 300
383
+ ? [x, 0, c]
384
+ : [c, 0, x];
385
+ return [r1 + m, g1 + m, b1 + m];
386
+ }
387
+
388
+ /** Every pose the pet state machine names. */
389
+ export const SAYO_POSES = Object.keys(POSES) as SayoPose[];
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export * from "./components/input";
12
12
  export * from "./components/loader";
13
13
  export * from "./components/markdown";
14
14
  export * from "./components/sayknow-pet";
15
+ export * from "./components/sayo-placeholder";
16
+ export * from "./components/sayo-sprite";
15
17
  export * from "./components/secret-input";
16
18
  export * from "./components/select-list";
17
19
  export * from "./components/settings-list";
package/src/tui.ts CHANGED
@@ -673,6 +673,20 @@ type RenderCommitWaiter = {
673
673
  /**
674
674
  * TUI - Main class for managing terminal UI with differential rendering
675
675
  */
676
+ /**
677
+ * What the frame about to be written looks like, for a post-render overlay to decide
678
+ * whether it must redraw: a new line count means the screen may have scrolled, a new
679
+ * full-redraw count means it was cleared or replayed.
680
+ */
681
+ export interface PostRenderFrameInfo {
682
+ totalLines: number;
683
+ fullRedraws: number;
684
+ columns: number;
685
+ rows: number;
686
+ }
687
+
688
+ export type PostRenderEmitter = (frame: PostRenderFrameInfo) => string | null;
689
+
676
690
  export class TUI extends Container {
677
691
  terminal: Terminal;
678
692
  #previousLines: string[] = [];
@@ -1343,6 +1357,22 @@ export class TUI extends Container {
1343
1357
  return this.#mouseEnabled;
1344
1358
  }
1345
1359
 
1360
+ /**
1361
+ * Whether `line` of a top-level child was on screen in the last frame. Only children
1362
+ * with a `handleClick` hook are tracked; others report false. A line scrolled above
1363
+ * the viewport is not visible — and changing it would force a full redraw, so
1364
+ * animations should stop there.
1365
+ */
1366
+ isChildLineVisible(child: Component, line: number): boolean {
1367
+ const rows = this.#childRows.get(child);
1368
+ if (!rows || line < 0 || line >= rows.count) return false;
1369
+ const absolute = rows.start + line;
1370
+ if (this.#manualViewportTop === undefined) {
1371
+ return absolute >= this.#viewportTopRow && absolute < this.#viewportTopRow + this.terminal.rows;
1372
+ }
1373
+ return this.#committedTranscriptRows.includes(absolute);
1374
+ }
1375
+
1346
1376
  /** The component that currently receives keyboard input. */
1347
1377
  getFocusedComponent(): Component | null {
1348
1378
  return this.#focusedComponent;
@@ -3699,11 +3729,11 @@ export class TUI extends Container {
3699
3729
  * Used for absolute-positioned overlays such as pixel-image pets that live
3700
3730
  * outside the line-based component model. Return null to emit nothing.
3701
3731
  */
3702
- setPostRenderEmitter(emitter: (() => string | null) | undefined): void {
3732
+ setPostRenderEmitter(emitter: PostRenderEmitter | undefined): void {
3703
3733
  this.#postRenderEmitter = emitter;
3704
3734
  }
3705
3735
 
3706
- #postRenderEmitter: (() => string | null) | undefined;
3736
+ #postRenderEmitter: PostRenderEmitter | undefined;
3707
3737
 
3708
3738
  #writeRenderBufferAndReanchorImeCursor(
3709
3739
  buffer: string,
@@ -3711,7 +3741,12 @@ export class TUI extends Container {
3711
3741
  totalLines: number,
3712
3742
  onBufferWritten?: () => void,
3713
3743
  ): boolean {
3714
- const overlay = this.#postRenderEmitter?.();
3744
+ const overlay = this.#postRenderEmitter?.({
3745
+ totalLines,
3746
+ fullRedraws: this.#fullRedrawCount,
3747
+ columns: this.terminal.columns,
3748
+ rows: this.terminal.rows,
3749
+ });
3715
3750
  if (overlay) {
3716
3751
  // DECSC/DECRC keep the hardware cursor stable; the dedicated
3717
3752
  // synchronized block prevents visible tearing while the overlay