@sayknow-cli/tui 0.6.7 → 0.6.9
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 +17 -0
- package/dist/types/animation-scheduler.d.ts +5 -1
- package/dist/types/components/sayknow-pet.d.ts +3 -3
- 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 +55 -28
- package/src/tui.ts +38 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.9] - 2026-09-29
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- `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.
|
|
10
|
+
- The fast animation cadence is 33 ms (~30 fps) instead of 16 ms; `AnimationCadence` is `33 | 80`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `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.
|
|
15
|
+
|
|
16
|
+
## [0.6.8] - 2026-09-29
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- `renderPetHalfBlocks(..., { scale: "compact" })` draws the pet's own sprite downscaled to 12×5 (eyes kept by feature-first pixel merging), replacing the redrawn `"half"` octopus. Every pose has the same height.
|
|
21
|
+
|
|
5
22
|
## [0.6.7] - 2026-09-28
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -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;
|
|
@@ -129,10 +129,10 @@ export declare function buildSayknowPixelFrames(options: {
|
|
|
129
129
|
* Draw a pet frame as text with half blocks, one terminal row per two pixel rows,
|
|
130
130
|
* so the same octopus shows up where no pixel protocol is available (the launch
|
|
131
131
|
* card). Fully transparent pixel rows at the top and bottom are cropped; columns
|
|
132
|
-
* are kept so every frame of the animation keeps the same width. `scale: "
|
|
133
|
-
* draws the
|
|
132
|
+
* are kept so every frame of the animation keeps the same width. `scale: "compact"`
|
|
133
|
+
* draws the same sprite at three quarters size (12 columns, 5 rows).
|
|
134
134
|
*/
|
|
135
135
|
export declare function renderPetHalfBlocks(frame: SayknowPixelFrameName, skin?: PetSkinId, colorMode?: "truecolor" | "256color", options?: {
|
|
136
|
-
scale?: "full" | "
|
|
136
|
+
scale?: "full" | "compact";
|
|
137
137
|
}): string[];
|
|
138
138
|
export {};
|
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.6.
|
|
4
|
+
"version": "0.6.9",
|
|
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.6.
|
|
42
|
-
"@sayknow-cli/utils": "0.6.
|
|
41
|
+
"@sayknow-cli/natives": "0.6.9",
|
|
42
|
+
"@sayknow-cli/utils": "0.6.9",
|
|
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
|
|
|
@@ -434,49 +434,76 @@ export function buildSayknowPixelFrames(options: {
|
|
|
434
434
|
return { frames, protocol: options.protocol, widthPx, heightPx, columns, rows, rasterRows };
|
|
435
435
|
}
|
|
436
436
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
];
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
437
|
+
/**
|
|
438
|
+
* Pixel priority when several pixels merge into one on downscale: features first, so
|
|
439
|
+
* the eyes survive (a block holding a pupil stays a pupil), then outline over body.
|
|
440
|
+
*/
|
|
441
|
+
const DOWNSCALE_PRIORITY = "VWGwKrRbHhA";
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The pet's own sprite, scaled to `size` columns (rows in proportion; nearest block, feature-first),
|
|
445
|
+
* so compact surfaces show the same octopus rather than a redraw. A block becomes
|
|
446
|
+
* transparent only when most of it is.
|
|
447
|
+
*/
|
|
448
|
+
function downscaleGrid(grid: readonly string[], size: number, palette: Palette, rowCount?: number): string[] {
|
|
449
|
+
const sourceRows = grid.length;
|
|
450
|
+
const source = grid[0]?.length ?? 0;
|
|
451
|
+
// Rows in proportion to columns unless a height is asked for.
|
|
452
|
+
const rows = rowCount ?? Math.round((sourceRows * size) / source);
|
|
453
|
+
const out: string[] = [];
|
|
454
|
+
for (let row = 0; row < rows; row++) {
|
|
455
|
+
const rowStart = Math.floor((row * sourceRows) / rows);
|
|
456
|
+
const rowEnd = Math.floor(((row + 1) * sourceRows) / rows);
|
|
457
|
+
let line = "";
|
|
458
|
+
for (let col = 0; col < size; col++) {
|
|
459
|
+
const colStart = Math.floor((col * source) / size);
|
|
460
|
+
const colEnd = Math.floor(((col + 1) * source) / size);
|
|
461
|
+
const cells: string[] = [];
|
|
462
|
+
for (let y = rowStart; y < rowEnd; y++) for (let x = colStart; x < colEnd; x++) cells.push(grid[y]![x] ?? ".");
|
|
463
|
+
const opaque = cells.filter(ch => palette[ch] != null);
|
|
464
|
+
line +=
|
|
465
|
+
opaque.length * 2 < cells.length
|
|
466
|
+
? "."
|
|
467
|
+
: ([...DOWNSCALE_PRIORITY].find(ch => opaque.includes(ch)) ?? opaque[0]!);
|
|
468
|
+
}
|
|
469
|
+
out.push(line);
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Pixel size of the compact pet: three quarters of the 16×16 sprite (12 columns, 5 rows). */
|
|
475
|
+
const COMPACT_PET_PIXELS = 12;
|
|
476
|
+
/** Terminal rows of the compact pet (two pixel rows each). */
|
|
477
|
+
const COMPACT_PET_ROWS = 5;
|
|
459
478
|
|
|
460
479
|
/**
|
|
461
480
|
* Draw a pet frame as text with half blocks, one terminal row per two pixel rows,
|
|
462
481
|
* so the same octopus shows up where no pixel protocol is available (the launch
|
|
463
482
|
* card). Fully transparent pixel rows at the top and bottom are cropped; columns
|
|
464
|
-
* are kept so every frame of the animation keeps the same width. `scale: "
|
|
465
|
-
* draws the
|
|
483
|
+
* are kept so every frame of the animation keeps the same width. `scale: "compact"`
|
|
484
|
+
* draws the same sprite at three quarters size (12 columns, 5 rows).
|
|
466
485
|
*/
|
|
467
486
|
export function renderPetHalfBlocks(
|
|
468
487
|
frame: SayknowPixelFrameName,
|
|
469
488
|
skin: PetSkinId = "red",
|
|
470
489
|
colorMode: "truecolor" | "256color" = "truecolor",
|
|
471
|
-
options: { scale?: "full" | "
|
|
490
|
+
options: { scale?: "full" | "compact" } = {},
|
|
472
491
|
): string[] {
|
|
473
492
|
const palette = PET_SKINS[skin].palette;
|
|
474
|
-
const
|
|
493
|
+
const compact = options.scale === "compact";
|
|
494
|
+
// Compact: the sprite's own pixel rows without its empty top and bottom margins (the
|
|
495
|
+
// same for every pose), scaled to 12×10 pixels = 12 columns × 5 rows. Cropping per pose
|
|
496
|
+
// would change the height while the tentacles move and make the card jump.
|
|
497
|
+
const grid = compact
|
|
498
|
+
? downscaleGrid(PIXEL_GRIDS[frame].slice(1, 15), COMPACT_PET_PIXELS, palette, COMPACT_PET_ROWS * 2)
|
|
499
|
+
: PIXEL_GRIDS[frame];
|
|
475
500
|
const opaque = (row: string): boolean => [...row].some(ch => palette[ch] != null);
|
|
476
501
|
let top = 0;
|
|
477
|
-
while (top < grid.length && !opaque(grid[top]!)) top++;
|
|
478
502
|
let bottom = grid.length - 1;
|
|
479
|
-
|
|
503
|
+
if (!compact) {
|
|
504
|
+
while (top < grid.length && !opaque(grid[top]!)) top++;
|
|
505
|
+
while (bottom > top && !opaque(grid[bottom]!)) bottom--;
|
|
506
|
+
}
|
|
480
507
|
const format = colorMode === "truecolor" ? "ansi-16m" : "ansi-256";
|
|
481
508
|
const fg = (rgb: Rgb): string => Bun.color(`rgb(${rgb[0]},${rgb[1]},${rgb[2]})`, format) ?? "";
|
|
482
509
|
const bg = (rgb: Rgb): string => fg(rgb).replace("\x1b[38;", "\x1b[48;");
|
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:
|
|
3732
|
+
setPostRenderEmitter(emitter: PostRenderEmitter | undefined): void {
|
|
3703
3733
|
this.#postRenderEmitter = emitter;
|
|
3704
3734
|
}
|
|
3705
3735
|
|
|
3706
|
-
#postRenderEmitter:
|
|
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
|