@gajae-code/tui 0.13.3 → 0.14.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.14.0] - 2026-08-17
6
+
7
+ ### Fixed
8
+ - iTerm2 pet capability probes now wait for pending input without disabling Kitty keyboard mode, disabling `modifyOtherKeys`, or detaching the live input handler.
9
+ - iTerm2 Gajae Pet frames now carry a dedicated filename, so dragging one retains iTerm's native drag behavior while the coding agent can discard only the temporary pet pathname pasted back into its composer.
10
+ - iTerm2 Gajae Pet now uses one animated GIF upload per meaningful state change and a raster lease that excludes its cell rectangle from generic TUI redraw erases, eliminating animation flicker while preserving geometry-derived art placement and leaving Kitty and Sixel encoding unchanged.
11
+
12
+ - Layout-only animation and selector frames can now reuse an unchanged revisioned transcript subtree instead of rebuilding every off-screen transcript component, anchor row, and Kitty placement on each tick. Ordinary render requests remain conservative, and transcript revision, width, identity, and global invalidation changes still force a full subtree render.
13
+
14
+ - A fast double-Esc (or triple-Esc) whose ESC bytes coalesce into one stdin chunk — which tmux always produces within its escape-time window, and SSH batching produces routinely — is now emitted as individual Escape key presses instead of a single `"\x1b\x1b"` sequence that parsed as the unbound `alt+escape` and silently swallowed both presses. This restores the double-Esc draft-clear and double-Esc selector gestures under tmux/SSH. Option-as-Meta sequences with a real continuation (e.g. Option+Up as `ESC ESC [ A`) remain atomic, and an ESC-cancelled incomplete sequence is still emitted whole.
15
+ - An ambiguous trailing run of Escape bytes now stays buffered until a continuation or the flush timeout resolves it, so `ESC ESC ESC` followed by `[A` in the next chunk still decodes as Escape then `alt+up` instead of two Escapes plus a plain Up that fired the destructive double-Escape gesture.
16
+ - Escape presses immediately followed by a bracketed paste in the same read are now emitted as individual Escape presses instead of one coalesced sequence that parsed as the unbound `alt+escape` and swallowed every press.
17
+ - A long run of Escape bytes arriving as many small reads no longer rescans the accumulated buffer on every read; only the two-byte ambiguous tail stays buffered, so 50,000 byte-by-byte Escape reads cost 50ms instead of 2.4s.
18
+ - A long run of Escape bytes followed by another key now decodes in linear time instead of rescanning the remaining input on every step, which blocked the event loop for over a second on a 50,000-byte run.
19
+ - Apple Terminal.app now retains its default keyboard mode when it does not support the Kitty keyboard protocol, avoiding the modifyOtherKeys fallback that breaks Korean/Hangul IME composition.
20
+
5
21
  ## [0.13.3] - 2026-08-15
6
22
 
7
23
  ### Added
@@ -56,7 +56,8 @@ export declare function resolvePetMode(value: string): PetMode;
56
56
  export type GajaePixelFrameName = "base" | "gazeL" | "gazeR" | "flicker" | "flex" | "danceL" | "danceR" | "cry1" | "cry2" | "cry3";
57
57
  export type PetFrameName = GajaePixelFrameName | OuroborosFrameName;
58
58
  /** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */
59
- export declare const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]>;
59
+ export type GajaeGifFrameTuple = readonly [GajaePixelFrameName, number];
60
+ export declare const PARA_PARA_STEPS: readonly GajaeGifFrameTuple[];
60
61
  export declare const GAJAE_IDLE_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]>;
61
62
  /**
62
63
  * A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
@@ -102,10 +103,77 @@ export declare function petBurstFrame(burst: PetBurst, elapsed: number, now: num
102
103
  export declare const __gajaePetTestHooks: {
103
104
  getPixelGrid(name: PetFrameName, skin?: PetSkinId): string[];
104
105
  };
106
+ export interface GajaeGifFrame {
107
+ readonly name: PetFrameName;
108
+ readonly delayMs: number;
109
+ }
110
+ export type GajaeGifTimeline = readonly GajaeGifFrame[];
111
+ export interface GajaeGifRectangle {
112
+ readonly width?: number;
113
+ readonly height?: number;
114
+ }
115
+ export interface GajaeGifDisplaySize {
116
+ /** iTerm2 display width: bare numbers are terminal cells; strings may use px or auto. */
117
+ readonly width: number | string;
118
+ /** iTerm2 display height: bare numbers are terminal cells; strings may use px or auto. */
119
+ readonly height: number | string;
120
+ }
121
+ export interface GajaeGifContentInset {
122
+ /** Transparent top padding in source pixels. */
123
+ readonly topPx?: number;
124
+ /** Transparent bottom padding in source pixels. */
125
+ readonly bottomPx?: number;
126
+ }
127
+ export interface GajaePetGifArtifact {
128
+ readonly bytes: Uint8Array;
129
+ readonly base64: string;
130
+ readonly width: number;
131
+ readonly height: number;
132
+ readonly frames: readonly GajaeGifFrame[];
133
+ readonly skin: PetSkinId;
134
+ readonly multipart: readonly string[];
135
+ readonly tmuxDcs: readonly string[];
136
+ }
137
+ export interface GajaePetGifOptions {
138
+ readonly skin?: PetSkinId;
139
+ readonly timeline?: GajaeGifTimeline;
140
+ readonly cellWidthPx?: number;
141
+ readonly cellHeightPx?: number;
142
+ readonly targetRows?: number;
143
+ readonly rectangle?: GajaeGifRectangle;
144
+ readonly displaySize?: GajaeGifDisplaySize;
145
+ readonly contentInset?: GajaeGifContentInset;
146
+ }
147
+ export declare const idleTimeline: () => GajaeGifTimeline;
148
+ export declare const workingTimeline: () => GajaeGifTimeline;
149
+ export declare const burstTimeline: (skin?: PetSkinId) => GajaeGifTimeline;
150
+ export declare const previewTimeline: (skin?: PetSkinId) => GajaeGifTimeline;
151
+ export declare function encodeGajaePetGif(input?: GajaePetGifOptions | GajaeGifTimeline): GajaePetGifArtifact;
152
+ export declare function getGajaePetGifCached(input?: GajaePetGifOptions | GajaeGifTimeline): GajaePetGifArtifact;
153
+ export declare function getGajaePetGifCacheStats(): {
154
+ size: number;
155
+ bytes: number;
156
+ gifBytes: number;
157
+ base64Bytes: number;
158
+ multipartBytes: number;
159
+ tmuxDcsBytes: number;
160
+ evictions: number;
161
+ };
162
+ export declare function resetGajaePetGifCache(): void;
163
+ export declare const clearGajaePetGifCache: typeof resetGajaePetGifCache;
105
164
  /** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
106
165
  export declare function encodeGridSixel(grid: string[], scale: number, topPaddingPx?: number, palette?: Palette): string;
107
- /** Encode a grid as an iTerm2 inline PNG. */
108
- export declare function encodeGridIterm2(grid: string[], scale: number, topPaddingPx?: number, bottomPaddingPx?: number, palette?: Palette): string;
166
+ /**
167
+ * Encode a grid as an iTerm2 inline PNG spanning a terminal cell block.
168
+ *
169
+ * The escape's `width`/`height` are the reserved cell-block footprint in
170
+ * character cells (unitless numbers per the iTerm2 inline-images protocol).
171
+ * iTerm2 resolves cells against its own live font metrics, so the sprite
172
+ * scales with the real terminal geometry — including Retina, where iTerm2
173
+ * divides `Npx` values by the backing-scale factor and would render a fixed
174
+ * pixel box at half size.
175
+ */
176
+ export declare function encodeGridIterm2(grid: string[], scale: number, columns: number, rows: number, topPaddingPx?: number, bottomPaddingPx?: number, leftPaddingPx?: number, rightPaddingPx?: number, palette?: Palette): string;
109
177
  /** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
110
178
  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;
111
179
  export interface GajaePixelFrames {
@@ -113,7 +181,9 @@ export interface GajaePixelFrames {
113
181
  frames: Record<string, string>;
114
182
  /** protocol the frames were encoded for */
115
183
  protocol: "sixel" | "kitty" | "iterm2";
184
+ /** Scaled sprite width before transparent cell-block padding. */
116
185
  widthPx: number;
186
+ /** Encoded raster height, including protocol-specific transparent padding. */
117
187
  heightPx: number;
118
188
  columns: number;
119
189
  rows: number;
@@ -124,6 +194,17 @@ export interface GajaePixelFrames {
124
194
  * Build overlay pixel frames exactly `targetRows` terminal rows tall when the
125
195
  * terminal cells permit it. Each skin owns its source resolution so future
126
196
  * additions can opt into denser art without changing the terminal footprint.
197
+ *
198
+ * Geometry contract:
199
+ * - `scale = max(1, targetRows * cellHeightPx / gridHeight)`
200
+ * - `columns = ceil(scaledSpriteWidthPx / cellWidthPx)`
201
+ * - `rows = ceil(scaledSpriteHeightPx / cellHeightPx)`
202
+ * - the square sprite is centered in a `columns * cellWidthPx` PNG canvas
203
+ *
204
+ * iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the
205
+ * padded block with its live cell metrics. The PNG has that block's pixel aspect
206
+ * ratio, allowing `preserveAspectRatio=0` without stretching the authored square
207
+ * sprite. Kitty and Sixel retain their protocol-specific paths.
127
208
  */
128
209
  export declare function buildGajaePixelFrames(options: {
129
210
  protocol: "sixel" | "kitty" | "iterm2";
@@ -135,9 +216,9 @@ export declare function buildGajaePixelFrames(options: {
135
216
  /** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
136
217
  kittyCellYOffsetPx?: number;
137
218
  kittyImageId?: number;
138
- /** Transparent iTerm2-only top padding for half-cell vertical alignment. */
219
+ /** Additional transparent iTerm2-only top padding for sub-cell vertical alignment. */
139
220
  iterm2TopPaddingPx?: number;
140
- /** Transparent iTerm2-only bottom padding inside the two-row canvas. */
221
+ /** Transparent iTerm2-only bottom padding inside the canvas. */
141
222
  iterm2BottomPaddingPx?: number;
142
223
  /** Color skin for the sprite palette (default "red"). */
143
224
  skin?: PetSkinId;
@@ -195,4 +195,26 @@ export interface RenderedImage {
195
195
  }
196
196
  export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): RenderedImage | null;
197
197
  export declare function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string;
198
+ export type Iterm2Capability = {
199
+ readonly key: string;
200
+ readonly value: string;
201
+ };
202
+ export type Iterm2CapabilityReply = "complete-f" | "missing-f" | "invalid-f" | undefined;
203
+ /**
204
+ * Classifies complete iTerm2 capability replies. An absent result means the
205
+ * input does not yet contain a complete capability frame.
206
+ */
207
+ export declare function parseITerm2CapabilityReply(input: Uint8Array | string): Iterm2CapabilityReply;
208
+ export declare function encodeITerm2Multipart(base64Data: string, options?: {
209
+ width?: number | string;
210
+ height?: number | string;
211
+ }): string[];
212
+ export declare function wrapITerm2RecordForTmux(record: string): string;
213
+ export declare function wrapITerm2RecordsForTmux(records: readonly string[]): string[];
214
+ export declare function parseITerm2Capabilities(input: string): Iterm2Capability[];
215
+ export declare class Iterm2CapabilitiesParser {
216
+ #private;
217
+ push(input: Uint8Array | string): Iterm2Capability[];
218
+ reset(): void;
219
+ }
198
220
  export {};
@@ -45,6 +45,14 @@ export interface Terminal {
45
45
  * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
46
46
  */
47
47
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
48
+ /**
49
+ * Wait for pending stdin to go quiet without changing terminal protocols or
50
+ * the active input handler. Capability probes use this non-destructive drain;
51
+ * shutdown paths must continue using drainInput().
52
+ * @param maxMs - Maximum time to wait (default: 1000ms)
53
+ * @param idleMs - Exit early if no input arrives within this time (default: 50ms)
54
+ */
55
+ drainPendingInput?(maxMs?: number, idleMs?: number): Promise<void>;
48
56
  write(data: string): void;
49
57
  get available(): boolean;
50
58
  readonly isProcessTerminal?: boolean;
@@ -90,6 +98,7 @@ export declare class ProcessTerminal implements Terminal {
90
98
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
91
99
  setMouseEnabled(enabled: boolean): void;
92
100
  start(onInput: (data: string) => void, onResize: () => void): void;
101
+ drainPendingInput(maxMs?: number, idleMs?: number): Promise<void>;
93
102
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
94
103
  stop(): void;
95
104
  write(data: string): void;
@@ -1,5 +1,82 @@
1
1
  import type { Terminal } from "./terminal";
2
2
  import { visibleWidth } from "./utils";
3
+ export type CellRect = Readonly<{
4
+ column: number;
5
+ row: number;
6
+ width: number;
7
+ height: number;
8
+ }>;
9
+ export type RasterLeaseToken = Readonly<{
10
+ ownerId: string;
11
+ generation: number;
12
+ rect: CellRect;
13
+ }>;
14
+ export type RasterLeaseInvalidatedNotification = Readonly<{
15
+ type: "raster-lease-invalidated";
16
+ queueId: number;
17
+ token: RasterLeaseToken;
18
+ cause: "intersecting-generic-output" | "full-redraw" | "resize" | "terminal-loss" | "capability-loss" | "mode-off" | "dispose" | "explicit" | "manual-viewport";
19
+ eraseAck: TerminalOutputAck;
20
+ }>;
21
+ export type RasterLeaseRequest = Readonly<{
22
+ ownerId: string;
23
+ rect: CellRect;
24
+ erase: Readonly<{
25
+ type: "raster-erase";
26
+ bytes: Uint8Array;
27
+ }>;
28
+ onInvalidated?: (notice: RasterLeaseInvalidatedNotification) => void;
29
+ }>;
30
+ export type TerminalOutputOperation = Readonly<{
31
+ type: "generic-render";
32
+ rect: CellRect;
33
+ bytes: Uint8Array;
34
+ }> | Readonly<{
35
+ type: "generic-full-redraw";
36
+ rect: CellRect;
37
+ bytes: Uint8Array;
38
+ }> | Readonly<{
39
+ type: "raster-multipart-batch";
40
+ records: readonly Uint8Array[];
41
+ prefix?: Uint8Array;
42
+ afterPrefix?: () => Promise<boolean>;
43
+ /** Synchronous freshness gate evaluated immediately before terminal output. */
44
+ shouldWrite?: () => boolean;
45
+ replayPrefix?: Uint8Array;
46
+ suffix?: Uint8Array;
47
+ abortSuffix?: Uint8Array;
48
+ restoreCursorVisibility?: boolean;
49
+ }> | Readonly<{
50
+ type: "raster-erase";
51
+ bytes: Uint8Array;
52
+ }> | Readonly<{
53
+ type: "raster-probe";
54
+ bytes: Uint8Array;
55
+ }> | Readonly<{
56
+ type: "queued-output";
57
+ bytes: Uint8Array;
58
+ shouldWrite?: () => boolean;
59
+ /** Runs synchronously at the terminal write boundary after a successful write. */
60
+ onWritten?: () => void;
61
+ }>;
62
+ export type TerminalOutputAck = Readonly<{
63
+ queueId: number;
64
+ operation: TerminalOutputOperation["type"];
65
+ status: "written" | "stale-token" | "revoked" | "failed";
66
+ token?: RasterLeaseToken;
67
+ }>;
68
+ export type LifecycleCleanupAck = Readonly<{
69
+ attempted: number;
70
+ written: number;
71
+ stillPending: number;
72
+ }>;
73
+ export type RasterLeaseAcquireResult = Readonly<{
74
+ status: "acquired";
75
+ token: RasterLeaseToken;
76
+ }> | Readonly<{
77
+ status: "rejected";
78
+ reason: "invalid-geometry" | "terminal-unavailable" | "owner-conflict" | "manual-viewport";
79
+ }>;
3
80
  /** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
4
81
  export declare const DEFAULT_WHEEL_LINES = 3;
5
82
  type InputListenerResult = {
@@ -7,6 +84,10 @@ type InputListenerResult = {
7
84
  data?: string;
8
85
  } | undefined;
9
86
  type InputListener = (data: string) => InputListenerResult;
87
+ type PostRenderEmission = {
88
+ payload: string;
89
+ onWritten?: () => void;
90
+ };
10
91
  /**
11
92
  * Component interface - all components must implement this
12
93
  */
@@ -53,6 +134,12 @@ export interface Component {
53
134
  * Default is false - release events are filtered out.
54
135
  */
55
136
  wantsKeyRelease?: boolean;
137
+ /**
138
+ * Optional monotonic revision for renderer-level subtree reuse. Components that
139
+ * expose this MUST advance it whenever render output can change without a
140
+ * width change. Unversioned components are always rendered normally.
141
+ */
142
+ getRenderRevision?(): bigint;
56
143
  /**
57
144
  * Invalidate any cached rendering state.
58
145
  * Called when theme changes or when component needs to re-render from scratch.
@@ -238,6 +325,7 @@ export interface OverlayHandle {
238
325
  export declare class Container implements ViewportAnchorProvider {
239
326
  #private;
240
327
  children: Component[];
328
+ getRenderRevision(): bigint;
241
329
  addChild(component: Component): void;
242
330
  removeChild(component: Component): void;
243
331
  /** Remove a child without disposing it (for detach-then-readd reuse). */
@@ -254,6 +342,8 @@ export declare class Container implements ViewportAnchorProvider {
254
342
  clear(): void;
255
343
  /** Remove all children without disposing them (for detach-then-readd reuse). */
256
344
  detachAll(): void;
345
+ /** Replace direct children without disposing reusable components. */
346
+ replaceChildren(children: Component[]): void;
257
347
  /** Registers a direct child as eligible for semantic viewport anchoring. */
258
348
  setViewportAnchorSource(component: Component, source: ViewportAnchorSource | null): void;
259
349
  dispose(): void;
@@ -344,6 +434,20 @@ export declare class TUI extends Container {
344
434
  /** Check if there are any visible overlays */
345
435
  hasOverlay(): boolean;
346
436
  invalidate(): void;
437
+ acquireRasterLease(request: RasterLeaseRequest): Promise<RasterLeaseAcquireResult>;
438
+ submitTerminalOutput(request: Readonly<{
439
+ operation: TerminalOutputOperation;
440
+ token?: RasterLeaseToken;
441
+ }>): Promise<TerminalOutputAck>;
442
+ invalidateRasterLease(request: Readonly<{
443
+ token: RasterLeaseToken;
444
+ cause: RasterLeaseInvalidatedNotification["cause"];
445
+ }>): Promise<TerminalOutputAck>;
446
+ notifyTerminalLifecycle(event: {
447
+ kind: "availability-restored" | "explicit-cleanup";
448
+ source: "tui" | "interactive-mode" | "transport";
449
+ terminalGeneration: number;
450
+ }): Promise<LifecycleCleanupAck>;
347
451
  start(): void;
348
452
  /**
349
453
  * Wait for a specific render request generation to be written successfully.
@@ -355,8 +459,15 @@ export declare class TUI extends Container {
355
459
  */
356
460
  waitForRenderCommit(generation: number, timeoutMs?: number): Promise<boolean>;
357
461
  get terminalAvailable(): boolean;
462
+ get isRunning(): boolean;
463
+ get terminalGeneration(): number;
464
+ get manualViewportActive(): boolean;
358
465
  addInputListener(listener: InputListener): () => void;
466
+ drainInput(maxMs: number, quiescenceMs: number): Promise<void>;
467
+ drainPetProbeInput(maxMs: number, quiescenceMs: number): Promise<void>;
359
468
  removeInputListener(listener: InputListener): void;
469
+ /** Refresh terminal cell metrics for a verified external image transport. */
470
+ refreshImageCellSize(): void;
360
471
  stop(): void;
361
472
  /**
362
473
  * Viewport-repaint-aware resize render request.
@@ -372,6 +483,8 @@ export declare class TUI extends Container {
372
483
  */
373
484
  requestResizeRender(): void;
374
485
  requestRender(force?: boolean, source?: string): void;
486
+ /** Request a frame whose mutation is known to be outside the viewport-anchor subtree. */
487
+ requestLayoutRender(source?: string): void;
375
488
  requestRenderWithGeneration(force?: boolean, source?: string): number;
376
489
  getLineRenderCacheStats(): {
377
490
  normalizationSize: number;
@@ -380,13 +493,18 @@ export declare class TUI extends Container {
380
493
  truncationLimit: number;
381
494
  };
382
495
  /** Retain terminal cleanup until a write succeeds, even after its component is disposed. */
383
- queueTerminalCleanup(payload: string, onDelivered?: () => void): void;
384
- /** Retry queued terminal cleanup after terminal recovery or before shutdown. */
385
- flushTerminalCleanup(): void;
496
+ queueTerminalCleanup(payload: string, onDelivered?: () => void): Promise<void>;
497
+ /** Queue protocol-neutral output behind the same terminal ordering as renders. */
498
+ queueTerminalOutput(payload: string, options?: {
499
+ shouldWrite?: () => boolean;
500
+ onWritten?: () => void;
501
+ }): Promise<TerminalOutputAck>;
502
+ /** Retry retained cleanup after recovery or before shutdown. */
503
+ flushTerminalCleanup(restoreTerminalAvailability?: boolean): void;
386
504
  /**
387
505
  * Register an emitter whose payload is delivered after each shared render
388
506
  * transaction. The emitter is an exempt physical overlay: its bytes are
389
507
  * deliberately kept out of the shared transcript write.
390
508
  */
391
- setPostRenderEmitter(emitter: (() => string | null) | undefined): void;
509
+ setPostRenderEmitter(emitter: (() => string | PostRenderEmission | null) | undefined): void;
392
510
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.13.3",
4
+ "version": "0.14.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.13.3",
40
- "@gajae-code/utils": "0.13.3",
39
+ "@gajae-code/natives": "0.14.0",
40
+ "@gajae-code/utils": "0.14.0",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },