@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.
- package/CHANGELOG.md +20 -0
- package/dist/types/animation-scheduler.d.ts +5 -1
- package/dist/types/components/sayknow-pet.d.ts +9 -47
- package/dist/types/components/sayo-placeholder.d.ts +23 -0
- package/dist/types/components/sayo-sprite-source.d.ts +6 -0
- package/dist/types/components/sayo-sprite.d.ts +13 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/tui.d.ts +20 -1
- package/package.json +3 -3
- package/src/animation-scheduler.ts +5 -1
- package/src/components/loader.ts +4 -1
- package/src/components/sayknow-pet.ts +94 -25
- package/src/components/sayo-placeholder.ts +102 -0
- package/src/components/sayo-sprite-source.ts +459 -0
- package/src/components/sayo-sprite.ts +389 -0
- package/src/index.ts +2 -0
- package/src/tui.ts +38 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.1] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
## [0.7.0] - 2026-09-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `renderSayoPose` / `decodePng` (sayo-sprite): Sayo mascot poses and tints rendered from an embedded image; `prepareSayoPlaceholderImages` (sayo-placeholder): kitty Unicode-placeholder images for text lines.
|
|
12
|
+
- Pet skin `orange` (Sayo), now first in `PET_SKIN_IDS`; kitty pet frames draw the Sayo image and are sent with `o=z`.
|
|
13
|
+
|
|
14
|
+
## [0.6.9] - 2026-09-29
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `TUI.setPostRenderEmitter` passes `PostRenderFrameInfo` (line count, full-redraw count, terminal size) so an overlay can skip re-emitting when nothing that could move or erase it changed.
|
|
19
|
+
- The fast animation cadence is 33 ms (~30 fps) instead of 16 ms; `AnimationCadence` is `33 | 80`.
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- `TUI.isChildLineVisible(child, line)`: whether a line of a click-tracked top-level child was on screen in the last frame, so animations can stop before they would force a full redraw.
|
|
24
|
+
|
|
5
25
|
## [0.6.8] - 2026-09-29
|
|
6
26
|
|
|
7
27
|
### Changed
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared timer cadences. 33 ms (~30 fps) drives colors that change over time (the
|
|
3
|
+
* working-message shimmer); 80 ms drives frame-stepped animations (spinners, the pet).
|
|
4
|
+
*/
|
|
5
|
+
export type AnimationCadence = 33 | 80;
|
|
2
6
|
type AnimationCallback = (now: number) => void;
|
|
3
7
|
export interface AnimationRegistration {
|
|
4
8
|
unregister(): void;
|
|
@@ -1,53 +1,9 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ┌─ SAYKNOW PET SPRITE SPEC ────────────────────────────────────────────────┐
|
|
3
|
-
* The pet is a 16×16 pixel octopus drawn beside the composer. Everything here
|
|
4
|
-
* is data: no PNGs, no assets — each frame is 16 strings of 16 chars, encoded
|
|
5
|
-
* to a sixel or kitty escape at runtime. Author a new frame by drawing a grid.
|
|
6
|
-
*
|
|
7
|
-
* GRID RULES
|
|
8
|
-
* - Exactly 16 rows × 16 columns. Only PALETTE keys below are valid chars.
|
|
9
|
-
* - `.` = transparent. Keep the outer columns transparent so the sprite sits
|
|
10
|
-
* snug beside the input box (the widget reserves +1 column of slack).
|
|
11
|
-
*
|
|
12
|
-
* PALETTE (char → role) — see PALETTE for exact RGB:
|
|
13
|
-
* .=transparent K=dark outline R=mantle body r=body highlight
|
|
14
|
-
* W=eye white V=pupil G=eye sparkle b=underside
|
|
15
|
-
* w=tear H h A=reserved (unused by the octopus art)
|
|
16
|
-
*
|
|
17
|
-
* FRAME CATALOG (SayknowPixelFrameName → PIXEL_GRIDS):
|
|
18
|
-
* base idle rest; also the dance "drop/settle" beat
|
|
19
|
-
* gazeL eyes glance left ┐ idle loop (see sayknow-pet-widget IDLE_LOOP)
|
|
20
|
-
* gazeR eyes glance right │
|
|
21
|
-
* flicker eyes blink ┘
|
|
22
|
-
* flex sparkly "yay" eyes; dance accent + random idle flex burst
|
|
23
|
-
* danceL tentacles sway left ┐ work loop (PARA_PARA_STEPS)
|
|
24
|
-
* danceR tentacles sway right ┘
|
|
25
|
-
* cry1..3 a tear trails from the outer eye corners (BlueOcto sob)
|
|
26
|
-
*
|
|
27
|
-
* RENDERING: buildSayknowPixelFrames({ protocol, cellWidthPx, cellHeightPx,
|
|
28
|
-
* targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
|
|
29
|
-
* once. Kitty uses a native `Y=` sub-cell drop (set by the widget) to sit on the
|
|
30
|
-
* composer border; sixel uses transparent top padding.
|
|
31
|
-
*
|
|
32
|
-
* BEHAVIOR (timing, positioning, on/off) lives in
|
|
33
|
-
* packages/coding-agent/src/modes/components/sayknow-pet-widget.ts.
|
|
34
|
-
*
|
|
35
|
-
* ADD A FRAME: draw the grid → add its name to SayknowPixelFrameName → register it in
|
|
36
|
-
* PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
|
|
37
|
-
*
|
|
38
|
-
* ADD A PET (skin): append one entry to PET_SKINS below — { id, label, description,
|
|
39
|
-
* palette, burst }. The id flows into PetSkinId/PetMode automatically, the settings
|
|
40
|
-
* enum, `/pet` command and both selectors derive their options from PET_SKINS, and the
|
|
41
|
-
* widget reads `burst` to animate — no other file needs editing. Recolor with a palette
|
|
42
|
-
* spread (see BLUE_PALETTE); add frames only for poses the catalog lacks.
|
|
43
|
-
* └────────────────────────────────────────────────────────────────────────┘
|
|
44
|
-
*/
|
|
45
1
|
type Rgb = readonly [number, number, number];
|
|
46
2
|
export type Palette = Record<string, Rgb | null>;
|
|
47
|
-
export declare const PET_SKIN_IDS: readonly ["red", "blue"];
|
|
3
|
+
export declare const PET_SKIN_IDS: readonly ["orange", "red", "blue"];
|
|
48
4
|
export type PetSkinId = (typeof PET_SKIN_IDS)[number];
|
|
49
5
|
/** Every pet mode: "off" plus each skin id, in menu order. */
|
|
50
|
-
export declare const PET_MODE_IDS: readonly ["off", "red", "blue"];
|
|
6
|
+
export declare const PET_MODE_IDS: readonly ["off", "orange", "red", "blue"];
|
|
51
7
|
export type PetMode = (typeof PET_MODE_IDS)[number];
|
|
52
8
|
/** Narrow an arbitrary string to a PetMode. */
|
|
53
9
|
export declare function isPetMode(value: string): value is PetMode;
|
|
@@ -93,6 +49,12 @@ export declare const __sayknowPetTestHooks: {
|
|
|
93
49
|
};
|
|
94
50
|
/** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
|
|
95
51
|
export declare function encodeGridSixel(grid: string[], scale: number, topPaddingPx?: number, palette?: Palette): string;
|
|
52
|
+
/**
|
|
53
|
+
* Kitty escape for a straight-RGBA canvas already laid out to the placement box: delete
|
|
54
|
+
* the previous image with this id, then transmit and display the new one in `cols`×`rows`
|
|
55
|
+
* cells. `Y=` drops it by sub-cell pixels; `C=1` keeps the cursor where it was.
|
|
56
|
+
*/
|
|
57
|
+
export declare function encodeRgbaKitty(rgba: Uint8Array, w: number, h: number, imageId: number, cols: number, rows: number, cellYOffsetPx?: number): string;
|
|
96
58
|
/** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
|
|
97
59
|
export declare function encodeGridKitty(grid: string[], scale: number, imageId: number, cols: number, rows: number, topPaddingPx?: number, cellYOffsetPx?: number, leftPaddingPx?: number, rightPaddingPx?: number, palette?: Palette): string;
|
|
98
60
|
export interface SayknowPixelFrames {
|
|
@@ -122,7 +84,7 @@ export declare function buildSayknowPixelFrames(options: {
|
|
|
122
84
|
/** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
|
|
123
85
|
kittyCellYOffsetPx?: number;
|
|
124
86
|
kittyImageId?: number;
|
|
125
|
-
/** Color skin
|
|
87
|
+
/** Color skin (default "orange", Sayo). */
|
|
126
88
|
skin?: PetSkinId;
|
|
127
89
|
}): SayknowPixelFrames;
|
|
128
90
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type SayoPose, type SayoTint } from "./sayo-sprite";
|
|
2
|
+
/** Largest image this module can address in cells. */
|
|
3
|
+
export declare const SAYO_PLACEHOLDER_MAX_CELLS: number;
|
|
4
|
+
export interface SayoPlaceholderImages {
|
|
5
|
+
columns: number;
|
|
6
|
+
rows: number;
|
|
7
|
+
/** Escape payload that uploads every pose and creates its virtual placement. */
|
|
8
|
+
upload: string;
|
|
9
|
+
/** Text row `row` of `pose`: placeholder cells colored with that pose's image id. */
|
|
10
|
+
line(pose: SayoPose, row: number): string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Prepare `poses` of Sayo in `tint`, `rows` cells tall and square in pixels. Image ids
|
|
14
|
+
* are `idBase + index` and must fit in 24 bits (the id travels as an RGB color).
|
|
15
|
+
*/
|
|
16
|
+
export declare function prepareSayoPlaceholderImages(options: {
|
|
17
|
+
tint: SayoTint;
|
|
18
|
+
rows: number;
|
|
19
|
+
cellWidthPx: number;
|
|
20
|
+
cellHeightPx: number;
|
|
21
|
+
poses: readonly SayoPose[];
|
|
22
|
+
idBase: number;
|
|
23
|
+
}): SayoPlaceholderImages;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sayo, the Sayknow octopus mascot (Sayknow Kit `public/sayo-logo.png`), as a 200×200
|
|
3
|
+
* RGBA PNG. Embedded as text so single-file builds carry it; decoded once at runtime by
|
|
4
|
+
* sayo-sprite.ts, which draws every pose and color from it.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SAYO_SOURCE_PNG_BASE64: string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type SayoPose = "base" | "gazeL" | "gazeR" | "flicker" | "flex" | "danceL" | "danceR" | "cry1" | "cry2" | "cry3";
|
|
2
|
+
export type SayoTint = "orange" | "red" | "blue";
|
|
3
|
+
/** Straight (non-premultiplied) RGBA pixels. */
|
|
4
|
+
export interface RgbaImage {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
data: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
export declare function decodePng(bytes: Uint8Array): RgbaImage;
|
|
10
|
+
/** Render one pose, tinted, into a `size`×`size` straight-RGBA image. */
|
|
11
|
+
export declare function renderSayoPose(pose: SayoPose, tint: SayoTint, size: number): RgbaImage;
|
|
12
|
+
/** Every pose the pet state machine names. */
|
|
13
|
+
export declare const SAYO_POSES: SayoPose[];
|
package/dist/types/index.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export * from "./components/input";
|
|
|
8
8
|
export * from "./components/loader";
|
|
9
9
|
export * from "./components/markdown";
|
|
10
10
|
export * from "./components/sayknow-pet";
|
|
11
|
+
export * from "./components/sayo-placeholder";
|
|
12
|
+
export * from "./components/sayo-sprite";
|
|
11
13
|
export * from "./components/secret-input";
|
|
12
14
|
export * from "./components/select-list";
|
|
13
15
|
export * from "./components/settings-list";
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -238,6 +238,18 @@ type TuiRenderCounterSnapshot = {
|
|
|
238
238
|
/**
|
|
239
239
|
* TUI - Main class for managing terminal UI with differential rendering
|
|
240
240
|
*/
|
|
241
|
+
/**
|
|
242
|
+
* What the frame about to be written looks like, for a post-render overlay to decide
|
|
243
|
+
* whether it must redraw: a new line count means the screen may have scrolled, a new
|
|
244
|
+
* full-redraw count means it was cleared or replayed.
|
|
245
|
+
*/
|
|
246
|
+
export interface PostRenderFrameInfo {
|
|
247
|
+
totalLines: number;
|
|
248
|
+
fullRedraws: number;
|
|
249
|
+
columns: number;
|
|
250
|
+
rows: number;
|
|
251
|
+
}
|
|
252
|
+
export type PostRenderEmitter = (frame: PostRenderFrameInfo) => string | null;
|
|
241
253
|
export declare class TUI extends Container {
|
|
242
254
|
#private;
|
|
243
255
|
private readonly options;
|
|
@@ -312,6 +324,13 @@ export declare class TUI extends Container {
|
|
|
312
324
|
*/
|
|
313
325
|
setMouseEnabled(enabled: boolean): void;
|
|
314
326
|
get mouseEnabled(): boolean;
|
|
327
|
+
/**
|
|
328
|
+
* Whether `line` of a top-level child was on screen in the last frame. Only children
|
|
329
|
+
* with a `handleClick` hook are tracked; others report false. A line scrolled above
|
|
330
|
+
* the viewport is not visible — and changing it would force a full redraw, so
|
|
331
|
+
* animations should stop there.
|
|
332
|
+
*/
|
|
333
|
+
isChildLineVisible(child: Component, line: number): boolean;
|
|
315
334
|
/** The component that currently receives keyboard input. */
|
|
316
335
|
getFocusedComponent(): Component | null;
|
|
317
336
|
start(): void;
|
|
@@ -369,5 +388,5 @@ export declare class TUI extends Container {
|
|
|
369
388
|
* Used for absolute-positioned overlays such as pixel-image pets that live
|
|
370
389
|
* outside the line-based component model. Return null to emit nothing.
|
|
371
390
|
*/
|
|
372
|
-
setPostRenderEmitter(emitter:
|
|
391
|
+
setPostRenderEmitter(emitter: PostRenderEmitter | undefined): void;
|
|
373
392
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.7.1",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://sayknow-cli.com",
|
|
7
7
|
"author": "jaybeyond",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.
|
|
42
|
-
"@sayknow-cli/utils": "0.
|
|
41
|
+
"@sayknow-cli/natives": "0.7.1",
|
|
42
|
+
"@sayknow-cli/utils": "0.7.1",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared timer cadences. 33 ms (~30 fps) drives colors that change over time (the
|
|
3
|
+
* working-message shimmer); 80 ms drives frame-stepped animations (spinners, the pet).
|
|
4
|
+
*/
|
|
5
|
+
export type AnimationCadence = 33 | 80;
|
|
2
6
|
|
|
3
7
|
type TimerHandle = ReturnType<typeof setInterval>;
|
|
4
8
|
type AnimationCallback = (now: number) => void;
|
package/src/components/loader.ts
CHANGED
|
@@ -70,7 +70,10 @@ export class Loader extends Text {
|
|
|
70
70
|
}
|
|
71
71
|
this.#updateDisplay();
|
|
72
72
|
},
|
|
73
|
-
|
|
73
|
+
// Time-dependent colors (the working-message shimmer) at ~30 fps: every tick
|
|
74
|
+
// redraws the whole UI, which in a long session costs as much as the rest of
|
|
75
|
+
// the turn. 60 fps doubled that cost for motion the eye cannot tell apart.
|
|
76
|
+
this.#timeDependentColor ? 33 : 80,
|
|
74
77
|
);
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ┌─ SAYKNOW PET SPRITE SPEC ────────────────────────────────────────────────┐
|
|
3
|
-
* The pet is
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* The pet is Sayo, the Sayknow octopus, beside the composer. Kitty-graphics terminals
|
|
4
|
+
* (Ghostty, kitty, WezTerm) draw the Sayo image itself, one transformed image per pose
|
|
5
|
+
* (sayo-sprite.ts). Sixel terminals and the half-block text fallback draw the 16×16
|
|
6
|
+
* pixel octopus below instead: each frame is 16 strings of 16 chars, encoded at runtime.
|
|
7
|
+
* A new pose needs both: a grid here and a transform in sayo-sprite.ts POSES.
|
|
6
8
|
*
|
|
7
9
|
* GRID RULES
|
|
8
10
|
* - Exactly 16 rows × 16 columns. Only PALETTE keys below are valid chars.
|
|
@@ -42,10 +44,13 @@
|
|
|
42
44
|
* spread (see BLUE_PALETTE); add frames only for poses the catalog lacks.
|
|
43
45
|
* └────────────────────────────────────────────────────────────────────────┘
|
|
44
46
|
*/
|
|
47
|
+
import { deflateSync } from "node:zlib";
|
|
48
|
+
import { renderSayoPose } from "./sayo-sprite";
|
|
49
|
+
|
|
45
50
|
type Rgb = readonly [number, number, number];
|
|
46
51
|
|
|
47
52
|
export type Palette = Record<string, Rgb | null>;
|
|
48
|
-
export const PET_SKIN_IDS = ["red", "blue"] as const;
|
|
53
|
+
export const PET_SKIN_IDS = ["orange", "red", "blue"] as const;
|
|
49
54
|
export type PetSkinId = (typeof PET_SKIN_IDS)[number];
|
|
50
55
|
/** Every pet mode: "off" plus each skin id, in menu order. */
|
|
51
56
|
export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
|
|
@@ -69,6 +74,16 @@ const RED_PALETTE: Palette = {
|
|
|
69
74
|
A: [196, 60, 30], // reserved
|
|
70
75
|
w: [200, 230, 255], // tear (BlueOcto sob)
|
|
71
76
|
};
|
|
77
|
+
// Sayo's orange for the dot fallback (sixel and text terminals); kitty terminals draw
|
|
78
|
+
// the Sayo image itself (sayo-sprite.ts).
|
|
79
|
+
const ORANGE_PALETTE: Palette = {
|
|
80
|
+
...RED_PALETTE,
|
|
81
|
+
K: [120, 40, 10], // outline
|
|
82
|
+
R: [255, 122, 30], // mantle body
|
|
83
|
+
r: [255, 176, 100], // highlight
|
|
84
|
+
b: [232, 70, 40], // underside
|
|
85
|
+
A: [214, 80, 20], // reserved
|
|
86
|
+
};
|
|
72
87
|
// BlueOcto recolors the octopus for the blue-octopus theme: ocean outline, a bright
|
|
73
88
|
// mantle, azure highlight and foam tears. Reserved hat keys are shared but unused.
|
|
74
89
|
const BLUE_PALETTE: Palette = {
|
|
@@ -194,6 +209,16 @@ export interface PetSkin {
|
|
|
194
209
|
|
|
195
210
|
/** Skin registry — the single source for palettes, behavior and selector/command copy. */
|
|
196
211
|
export const PET_SKINS: Record<PetSkinId, PetSkin> = {
|
|
212
|
+
orange: {
|
|
213
|
+
id: "orange",
|
|
214
|
+
label: "Sayo",
|
|
215
|
+
description: "Sayo, the orange Sayknow octopus.",
|
|
216
|
+
palette: ORANGE_PALETTE,
|
|
217
|
+
burst: {
|
|
218
|
+
intro: PARA_PARA_STEPS,
|
|
219
|
+
tail: { frames: ["flex", "base"], stepMs: 220, ms: 880 },
|
|
220
|
+
},
|
|
221
|
+
},
|
|
197
222
|
red: {
|
|
198
223
|
id: "red",
|
|
199
224
|
label: "RedOctopus",
|
|
@@ -306,6 +331,35 @@ export function encodeGridSixel(
|
|
|
306
331
|
return `${out}\x1b\\`;
|
|
307
332
|
}
|
|
308
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Kitty escape for a straight-RGBA canvas already laid out to the placement box: delete
|
|
336
|
+
* the previous image with this id, then transmit and display the new one in `cols`×`rows`
|
|
337
|
+
* cells. `Y=` drops it by sub-cell pixels; `C=1` keeps the cursor where it was.
|
|
338
|
+
*/
|
|
339
|
+
export function encodeRgbaKitty(
|
|
340
|
+
rgba: Uint8Array,
|
|
341
|
+
w: number,
|
|
342
|
+
h: number,
|
|
343
|
+
imageId: number,
|
|
344
|
+
cols: number,
|
|
345
|
+
rows: number,
|
|
346
|
+
cellYOffsetPx = 0,
|
|
347
|
+
): string {
|
|
348
|
+
// zlib-compressed (`o=z`): a mostly transparent sprite shrinks several-fold.
|
|
349
|
+
const data = deflateSync(rgba).toString("base64");
|
|
350
|
+
const CHUNK = 4000;
|
|
351
|
+
const yParam = cellYOffsetPx > 0 ? `,Y=${Math.round(cellYOffsetPx)}` : "";
|
|
352
|
+
let out = `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
|
|
353
|
+
for (let off = 0, first = true; off < data.length; off += CHUNK, first = false) {
|
|
354
|
+
const chunk = data.slice(off, off + CHUNK);
|
|
355
|
+
const more = off + CHUNK < data.length ? 1 : 0;
|
|
356
|
+
out += first
|
|
357
|
+
? `\x1b_Ga=T,f=32,o=z,s=${w},v=${h},c=${cols},r=${rows},i=${imageId},q=2,C=1${yParam},m=${more};${chunk}\x1b\\`
|
|
358
|
+
: `\x1b_Gm=${more};${chunk}\x1b\\`;
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
309
363
|
/** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
|
|
310
364
|
export function encodeGridKitty(
|
|
311
365
|
grid: string[],
|
|
@@ -345,21 +399,36 @@ export function encodeGridKitty(
|
|
|
345
399
|
rgba[o + 3] = 255;
|
|
346
400
|
}
|
|
347
401
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
402
|
+
return encodeRgbaKitty(rgba, w, h, imageId, cols, rows, cellYOffsetPx);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Sayo for kitty: the pose rendered at the sprite size and placed in the padded box the
|
|
407
|
+
* grid frames use, so placement, the sub-cell drop and cleanup are unchanged.
|
|
408
|
+
*/
|
|
409
|
+
function encodeSayoKitty(
|
|
410
|
+
pose: SayknowPixelFrameName,
|
|
411
|
+
skin: PetSkinId,
|
|
412
|
+
spritePx: number,
|
|
413
|
+
imageId: number,
|
|
414
|
+
cols: number,
|
|
415
|
+
rows: number,
|
|
416
|
+
topPaddingPx: number,
|
|
417
|
+
cellYOffsetPx: number,
|
|
418
|
+
leftPaddingPx: number,
|
|
419
|
+
rightPaddingPx: number,
|
|
420
|
+
): string {
|
|
421
|
+
const sprite = renderSayoPose(pose, skin, spritePx);
|
|
422
|
+
const w = spritePx + leftPaddingPx + rightPaddingPx;
|
|
423
|
+
const h = spritePx + topPaddingPx;
|
|
424
|
+
const rgba = new Uint8Array(w * h * 4);
|
|
425
|
+
for (let y = 0; y < spritePx; y++) {
|
|
426
|
+
rgba.set(
|
|
427
|
+
sprite.data.subarray(y * spritePx * 4, (y + 1) * spritePx * 4),
|
|
428
|
+
((y + topPaddingPx) * w + leftPaddingPx) * 4,
|
|
429
|
+
);
|
|
361
430
|
}
|
|
362
|
-
return
|
|
431
|
+
return encodeRgbaKitty(rgba, w, h, imageId, cols, rows, cellYOffsetPx);
|
|
363
432
|
}
|
|
364
433
|
|
|
365
434
|
export interface SayknowPixelFrames {
|
|
@@ -390,7 +459,7 @@ export function buildSayknowPixelFrames(options: {
|
|
|
390
459
|
/** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
|
|
391
460
|
kittyCellYOffsetPx?: number;
|
|
392
461
|
kittyImageId?: number;
|
|
393
|
-
/** Color skin
|
|
462
|
+
/** Color skin (default "orange", Sayo). */
|
|
394
463
|
skin?: PetSkinId;
|
|
395
464
|
}): SayknowPixelFrames {
|
|
396
465
|
const targetRows = options.targetRows ?? 2;
|
|
@@ -411,15 +480,16 @@ export function buildSayknowPixelFrames(options: {
|
|
|
411
480
|
const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
|
|
412
481
|
const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
|
|
413
482
|
const imageId = options.kittyImageId ?? 0xc0de;
|
|
414
|
-
const palette = PET_SKINS[options.skin ?? "
|
|
483
|
+
const palette = PET_SKINS[options.skin ?? "orange"].palette;
|
|
415
484
|
const frames = {} as Record<SayknowPixelFrameName, string>;
|
|
416
485
|
for (const name of Object.keys(PIXEL_GRIDS) as SayknowPixelFrameName[]) {
|
|
417
486
|
frames[name] =
|
|
418
487
|
options.protocol === "sixel"
|
|
419
488
|
? encodeGridSixel(PIXEL_GRIDS[name], scale, topPaddingPx, palette)
|
|
420
|
-
:
|
|
421
|
-
|
|
422
|
-
|
|
489
|
+
: encodeSayoKitty(
|
|
490
|
+
name,
|
|
491
|
+
options.skin ?? "orange",
|
|
492
|
+
widthPx,
|
|
423
493
|
imageId,
|
|
424
494
|
columns,
|
|
425
495
|
rows,
|
|
@@ -427,7 +497,6 @@ export function buildSayknowPixelFrames(options: {
|
|
|
427
497
|
options.kittyCellYOffsetPx ?? 0,
|
|
428
498
|
leftPaddingPx,
|
|
429
499
|
rightPaddingPx,
|
|
430
|
-
palette,
|
|
431
500
|
);
|
|
432
501
|
}
|
|
433
502
|
|
|
@@ -485,7 +554,7 @@ const COMPACT_PET_ROWS = 5;
|
|
|
485
554
|
*/
|
|
486
555
|
export function renderPetHalfBlocks(
|
|
487
556
|
frame: SayknowPixelFrameName,
|
|
488
|
-
skin: PetSkinId = "
|
|
557
|
+
skin: PetSkinId = "orange",
|
|
489
558
|
colorMode: "truecolor" | "256color" = "truecolor",
|
|
490
559
|
options: { scale?: "full" | "compact" } = {},
|
|
491
560
|
): string[] {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sayo in text lines, through kitty graphics Unicode placeholders (Ghostty, kitty).
|
|
3
|
+
*
|
|
4
|
+
* Each pose is uploaded once as an image with a virtual placement (`U=1`). A line then
|
|
5
|
+
* shows it by writing placeholder cells (U+10EEEE plus row/column diacritics) in a
|
|
6
|
+
* foreground color that encodes the image id. The image lives in ordinary text: it
|
|
7
|
+
* scrolls, redraws and clears with the transcript, and switching poses is a color change
|
|
8
|
+
* on those cells rather than a new image.
|
|
9
|
+
*/
|
|
10
|
+
import { deflateSync } from "node:zlib";
|
|
11
|
+
import { renderSayoPose, type SayoPose, type SayoTint } from "./sayo-sprite";
|
|
12
|
+
|
|
13
|
+
const PLACEHOLDER = String.fromCodePoint(0x10eeee);
|
|
14
|
+
|
|
15
|
+
/** Kitty's row/column diacritics (rowcolumn-diacritics.txt), enough for small images. */
|
|
16
|
+
const DIACRITICS = [
|
|
17
|
+
0x0305, 0x030d, 0x030e, 0x0310, 0x0312, 0x033d, 0x033e, 0x033f, 0x0346, 0x034a, 0x034b, 0x034c, 0x0350, 0x0351,
|
|
18
|
+
0x0352, 0x0357, 0x035b, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, 0x036a, 0x036b, 0x036c, 0x036d,
|
|
19
|
+
0x036e, 0x036f,
|
|
20
|
+
].map(code => String.fromCodePoint(code));
|
|
21
|
+
|
|
22
|
+
/** Largest image this module can address in cells. */
|
|
23
|
+
export const SAYO_PLACEHOLDER_MAX_CELLS = DIACRITICS.length;
|
|
24
|
+
|
|
25
|
+
export interface SayoPlaceholderImages {
|
|
26
|
+
columns: number;
|
|
27
|
+
rows: number;
|
|
28
|
+
/** Escape payload that uploads every pose and creates its virtual placement. */
|
|
29
|
+
upload: string;
|
|
30
|
+
/** Text row `row` of `pose`: placeholder cells colored with that pose's image id. */
|
|
31
|
+
line(pose: SayoPose, row: number): string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function kittyChunks(control: string, base64: string): string {
|
|
35
|
+
const CHUNK = 4096;
|
|
36
|
+
let out = "";
|
|
37
|
+
for (let offset = 0, first = true; offset < base64.length || first; offset += CHUNK, first = false) {
|
|
38
|
+
const chunk = base64.slice(offset, offset + CHUNK);
|
|
39
|
+
const more = offset + CHUNK < base64.length ? 1 : 0;
|
|
40
|
+
out += first ? `\x1b_G${control},m=${more};${chunk}\x1b\\` : `\x1b_Gm=${more};${chunk}\x1b\\`;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Prepare `poses` of Sayo in `tint`, `rows` cells tall and square in pixels. Image ids
|
|
47
|
+
* are `idBase + index` and must fit in 24 bits (the id travels as an RGB color).
|
|
48
|
+
*/
|
|
49
|
+
export function prepareSayoPlaceholderImages(options: {
|
|
50
|
+
tint: SayoTint;
|
|
51
|
+
rows: number;
|
|
52
|
+
cellWidthPx: number;
|
|
53
|
+
cellHeightPx: number;
|
|
54
|
+
poses: readonly SayoPose[];
|
|
55
|
+
idBase: number;
|
|
56
|
+
}): SayoPlaceholderImages {
|
|
57
|
+
const rows = Math.max(1, Math.min(SAYO_PLACEHOLDER_MAX_CELLS, Math.round(options.rows)));
|
|
58
|
+
const side = rows * options.cellHeightPx;
|
|
59
|
+
const columns = Math.max(1, Math.min(SAYO_PLACEHOLDER_MAX_CELLS, Math.round(side / options.cellWidthPx)));
|
|
60
|
+
const width = columns * options.cellWidthPx;
|
|
61
|
+
const height = rows * options.cellHeightPx;
|
|
62
|
+
const left = Math.max(0, Math.floor((width - side) / 2));
|
|
63
|
+
const ids = new Map<SayoPose, number>();
|
|
64
|
+
let upload = "";
|
|
65
|
+
for (const [index, pose] of options.poses.entries()) {
|
|
66
|
+
const id = (options.idBase + index) & 0xffffff;
|
|
67
|
+
ids.set(pose, id);
|
|
68
|
+
const sprite = renderSayoPose(pose, options.tint, Math.min(side, width));
|
|
69
|
+
const rgba = new Uint8Array(width * height * 4);
|
|
70
|
+
const top = height - sprite.height;
|
|
71
|
+
for (let y = 0; y < sprite.height; y++) {
|
|
72
|
+
rgba.set(
|
|
73
|
+
sprite.data.subarray(y * sprite.width * 4, (y + 1) * sprite.width * 4),
|
|
74
|
+
((y + top) * width + left) * 4,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const data = deflateSync(rgba).toString("base64");
|
|
78
|
+
upload += `\x1b_Ga=d,d=I,i=${id},q=2\x1b\\`;
|
|
79
|
+
upload += kittyChunks(`a=t,f=32,o=z,s=${width},v=${height},i=${id},q=2`, data);
|
|
80
|
+
upload += `\x1b_Ga=p,U=1,i=${id},c=${columns},r=${rows},q=2\x1b\\`;
|
|
81
|
+
}
|
|
82
|
+
const rowCache = new Map<string, string>();
|
|
83
|
+
return {
|
|
84
|
+
columns,
|
|
85
|
+
rows,
|
|
86
|
+
upload,
|
|
87
|
+
line(pose, row) {
|
|
88
|
+
const id = ids.get(pose) ?? ids.values().next().value ?? options.idBase;
|
|
89
|
+
const key = `${id}:${row}`;
|
|
90
|
+
let text = rowCache.get(key);
|
|
91
|
+
if (text === undefined) {
|
|
92
|
+
const color = `\x1b[38;2;${(id >> 16) & 255};${(id >> 8) & 255};${id & 255}m`;
|
|
93
|
+
let cells = "";
|
|
94
|
+
for (let column = 0; column < columns; column++)
|
|
95
|
+
cells += `${PLACEHOLDER}${DIACRITICS[row]}${DIACRITICS[column]}`;
|
|
96
|
+
text = `${color}${cells}\x1b[39m`;
|
|
97
|
+
rowCache.set(key, text);
|
|
98
|
+
}
|
|
99
|
+
return text;
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|