@sayknow-cli/tui 0.3.8 → 0.3.10

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.
@@ -47,13 +47,73 @@ export interface ImageRenderOptions {
47
47
  maxWidthCells?: number;
48
48
  maxHeightCells?: number;
49
49
  preserveAspectRatio?: boolean;
50
+ /**
51
+ * Kitty-only: stable placement id (`p=`). Re-emitting the same image id +
52
+ * placement id *replaces* the existing placement instead of stacking a new
53
+ * copy, which makes diff-renderer repaints idempotent. Callers that render
54
+ * a persistent component should allocate one id per component instance.
55
+ */
56
+ placementId?: number;
57
+ /**
58
+ * Kitty-only: stable image id (`i=`). Defaults to a content hash of the
59
+ * base64 payload ({@link kittyImageId}). Pass a precomputed id to avoid
60
+ * re-hashing large payloads on every render.
61
+ */
62
+ imageId?: number;
63
+ /**
64
+ * Kitty-only: sink for the out-of-band data transmission (`a=t`) emitted
65
+ * the first time an image id is rendered. Defaults to the process-wide
66
+ * writer configured via {@link setKittyTransmitWriter} (stdout).
67
+ */
68
+ onTransmit?: (sequence: string) => void;
50
69
  }
70
+ /**
71
+ * Derive a stable 32-bit non-zero kitty image id (`i=`) from image content
72
+ * (FNV-1a over the base64 payload). Identical content maps to the same id, so
73
+ * retransmission replaces the stored image instead of accumulating copies.
74
+ */
75
+ export declare function kittyImageId(base64Data: string): number;
51
76
  export declare function getCellDimensions(): CellDimensions;
52
77
  export declare function setCellDimensions(dims: CellDimensions): void;
53
78
  export declare function encodeKitty(base64Data: string, options?: {
54
79
  columns?: number;
55
80
  rows?: number;
56
81
  imageId?: number;
82
+ placementId?: number;
83
+ }): string;
84
+ /** Test hook: forget which kitty image ids were transmitted. */
85
+ export declare function resetKittyTransmissions(): void;
86
+ /**
87
+ * Override where out-of-band kitty data transmissions (`a=t`) are written.
88
+ * The default writes directly to stdout: a transmit-only escape is
89
+ * cursor-neutral (it uploads pixel data without drawing anything), so the
90
+ * only ordering requirement is that it reaches the terminal before the
91
+ * placement escape that references it — which the synchronous write during
92
+ * render guarantees. Tests use this to capture transmissions.
93
+ */
94
+ export declare function setKittyTransmitWriter(writer: (sequence: string) => void): void;
95
+ /**
96
+ * Encode a kitty transmit-only (`a=t`) escape: uploads image data under a
97
+ * stable id without creating a placement. Chunked at 4096 bytes per spec.
98
+ *
99
+ * This is deliberately separate from placement: re-sending data (`a=t`/`a=T`)
100
+ * for an existing image id deletes the image and ALL of its placements, so
101
+ * data must be uploaded exactly once per id and repaints must go through
102
+ * {@link encodeKittyPlacement} only.
103
+ */
104
+ export declare function encodeKittyTransmit(base64Data: string, imageId: number): string;
105
+ /**
106
+ * Encode a kitty placement-only (`a=p`) escape referencing previously
107
+ * transmitted data. Re-emitting the same i=/p= pair replaces that one
108
+ * placement (never stacks, never touches sibling placements), and C=1
109
+ * keeps the cursor where it is so the escape can be emitted from the
110
+ * component's first row without cursor-up tricks.
111
+ */
112
+ export declare function encodeKittyPlacement(options: {
113
+ imageId: number;
114
+ placementId: number;
115
+ columns: number;
116
+ rows: number;
57
117
  }): string;
58
118
  export declare function encodeITerm2(base64Data: string, options?: {
59
119
  width?: number | string;
@@ -68,8 +128,16 @@ export declare function getJpegDimensions(base64Data: string): ImageDimensions |
68
128
  export declare function getGifDimensions(base64Data: string): ImageDimensions | null;
69
129
  export declare function getWebpDimensions(base64Data: string): ImageDimensions | null;
70
130
  export declare function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null;
71
- export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): {
131
+ export interface RenderedImage {
72
132
  sequence: string;
73
133
  rows: number;
74
- } | null;
134
+ /**
135
+ * True when the escape neither moves the cursor nor carries pixel data
136
+ * (kitty `a=p,C=1` placements). Cursor-neutral sequences can be emitted
137
+ * from the component's first row; cursor-advancing protocols
138
+ * (iTerm2/SIXEL) must draw from the last reserved row instead.
139
+ */
140
+ cursorNeutral?: boolean;
141
+ }
142
+ export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): RenderedImage | null;
75
143
  export declare function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string;
@@ -32,6 +32,7 @@ export interface Terminal {
32
32
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
33
33
  write(data: string): void;
34
34
  get available(): boolean;
35
+ readonly isProcessTerminal?: boolean;
35
36
  get columns(): number;
36
37
  get rows(): number;
37
38
  get kittyProtocolActive(): boolean;
@@ -64,6 +65,7 @@ export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows
64
65
  */
65
66
  export declare class ProcessTerminal implements Terminal {
66
67
  #private;
68
+ get isProcessTerminal(): boolean;
67
69
  get kittyProtocolActive(): boolean;
68
70
  get appearance(): TerminalAppearance | undefined;
69
71
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
@@ -72,6 +72,15 @@ export interface OverlayMargin {
72
72
  }
73
73
  /** Value that can be absolute (number) or percentage (string like "50%") */
74
74
  export type SizeValue = number | `${number}%`;
75
+ /**
76
+ * True when repainting only the live viewport is safer than clearing/replaying
77
+ * the full transcript. Native Windows console hosts are included even when
78
+ * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
79
+ * identity variables while keeping the same scroll-jump behavior.
80
+ */
81
+ export declare function shouldUseViewportRepaintForHost(env?: Record<string, string | undefined>, platform?: NodeJS.Platform, options?: {
82
+ includeNativeWindows?: boolean;
83
+ }): boolean;
75
84
  /**
76
85
  * Options for overlay positioning and sizing.
77
86
  * Values can be absolute numbers or percentage strings (e.g., "50%").
@@ -190,9 +199,9 @@ export declare class TUI extends Container {
190
199
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
191
200
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
192
201
  * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
193
- * high speed" resize storm. Windows Terminal can also visibly jump to the
194
- * transcript top during streaming redraws, so viewport-repaint sessions keep
195
- * force off and let `#doRender` repaint only the live viewport. Set
202
+ * high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
203
+ * the transcript top during streaming redraws, so viewport-repaint sessions
204
+ * keep force off and let `#doRender` repaint only the live viewport. Set
196
205
  * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
197
206
  */
198
207
  requestResizeRender(): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.3.8",
4
+ "version": "0.3.10",
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.3.8",
42
- "@sayknow-cli/utils": "0.3.8",
41
+ "@sayknow-cli/natives": "0.3.10",
42
+ "@sayknow-cli/utils": "0.3.10",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -1914,45 +1914,48 @@ export class Editor implements Component, Focusable {
1914
1914
  #handlePaste(pastedText: string): void {
1915
1915
  this.#historyIndex = -1; // Exit history browsing mode
1916
1916
  this.#resetKillSequence();
1917
- this.#recordUndoState();
1918
1917
  const hadAutocomplete = this.#autocompleteState !== null;
1919
1918
  this.#cancelAutocomplete();
1920
1919
  if (hadAutocomplete) {
1921
1920
  this.onAutocompleteUpdate?.();
1922
1921
  }
1923
1922
 
1924
- this.#withUndoSuspended(() => {
1925
- // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1926
- // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1927
- // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1928
- // per-char filter below preserves newlines instead of stripping ESC and
1929
- // leaking the printable tail (e.g. "[106;5u") into the editor.
1930
- const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1931
- const cp = Number(code);
1932
- if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1933
- if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1934
- return match;
1935
- });
1923
+ // Some terminals (e.g. tmux popups with extended-keys-format=csi-u) re-encode
1924
+ // control bytes inside bracketed paste as CSI-u Ctrl+<letter> sequences
1925
+ // (ESC [ <codepoint> ; 5 u). Decode those back to their literal byte so the
1926
+ // per-char filter below preserves newlines instead of stripping ESC and
1927
+ // leaking the printable tail (e.g. "[106;5u") into the editor.
1928
+ const decodedText = pastedText.replace(/\x1b\[(\d+);5u/g, (match, code) => {
1929
+ const cp = Number(code);
1930
+ if (cp >= 97 && cp <= 122) return String.fromCharCode(cp - 96);
1931
+ if (cp >= 65 && cp <= 90) return String.fromCharCode(cp - 64);
1932
+ return match;
1933
+ });
1934
+
1935
+ // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1936
+ // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1937
+ // land in the buffer as the same precomposed syllables a terminal
1938
+ // renders — without this, cursor column accounting drifts by
1939
+ // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1940
+ // hardware cursor. Matches the `Input` component's prior fix; this
1941
+ // is the same fix on the real SKC prompt component (`Editor`).
1942
+ const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1936
1943
 
1937
- // Clean the pasted text. NFC-normalize so macOS Finder drag-drops of
1938
- // Korean filenames (which arrive as NFD: e.g. `ᄒ`+`ᅪ` instead of `화`)
1939
- // land in the buffer as the same precomposed syllables a terminal
1940
- // renders without this, cursor column accounting drifts by
1941
- // `(NFD cells − NFC cells)` and the visible glyph desyncs from the
1942
- // hardware cursor. Matches the `Input` component's prior fix; this
1943
- // is the same fix on the real SKC prompt component (`Editor`).
1944
- const cleanText = decodedText.replace(/\r\n?/g, "\n").normalize("NFC");
1945
-
1946
- // Convert tabs to spaces (4 spaces per tab)
1947
- const tabExpandedText = cleanText.replace(/\t/g, " ");
1948
-
1949
- // Filter out non-printable characters except newlines
1950
- const filteredText = tabExpandedText
1951
- .split("")
1952
- .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1953
- .join("");
1954
-
1955
- if (filteredText.length === 0) return;
1944
+ // Convert tabs to spaces (4 spaces per tab)
1945
+ const tabExpandedText = cleanText.replace(/\t/g, " ");
1946
+
1947
+ // Filter out non-printable characters except newlines
1948
+ const filteredText = tabExpandedText
1949
+ .split("")
1950
+ .filter(char => char === "\n" || char.charCodeAt(0) >= 32)
1951
+ .join("");
1952
+
1953
+ // Nothing survived filtering: the buffer is untouched, so don't record
1954
+ // an undo snapshot — a no-op entry would make the next undo appear dead.
1955
+ if (filteredText.length === 0) return;
1956
+
1957
+ this.#recordUndoState();
1958
+ this.#withUndoSuspended(() => {
1956
1959
  // Split into lines
1957
1960
  const pastedLines = filteredText.split("\n");
1958
1961
 
@@ -1,12 +1,25 @@
1
1
  import {
2
2
  getImageDimensions,
3
3
  type ImageDimensions,
4
+ ImageProtocol,
4
5
  imageFallback,
6
+ kittyImageId,
5
7
  renderImage,
6
8
  TERMINAL,
7
9
  } from "../terminal-capabilities";
8
10
  import type { Component } from "../tui";
9
11
 
12
+ // Monotonic placement id allocator (kitty `p=`). Each Image instance keeps a
13
+ // stable placement id so diff-renderer repaints replace its own placement
14
+ // instead of stacking new copies, while two components showing identical
15
+ // content (same image id) still coexist as distinct placements.
16
+ let nextPlacementId = 1;
17
+ function allocatePlacementId(): number {
18
+ const id = nextPlacementId;
19
+ nextPlacementId = nextPlacementId >= 0x7fffffff ? 1 : nextPlacementId + 1;
20
+ return id;
21
+ }
22
+
10
23
  export interface ImageTheme {
11
24
  fallbackColor: (str: string) => string;
12
25
  }
@@ -26,6 +39,10 @@ export class Image implements Component {
26
39
 
27
40
  #cachedLines?: string[];
28
41
  #cachedWidth?: number;
42
+ // Kitty graphics: content-derived image id + per-instance placement id.
43
+ // Computed lazily so non-kitty terminals never pay the hash cost.
44
+ #kittyImageId?: number;
45
+ readonly #kittyPlacementId = allocatePlacementId();
29
46
 
30
47
  constructor(
31
48
  base64Data: string,
@@ -57,22 +74,39 @@ export class Image implements Component {
57
74
  let lines: string[];
58
75
 
59
76
  if (TERMINAL.imageProtocol) {
77
+ if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
78
+ this.#kittyImageId ??= kittyImageId(this.#base64Data);
79
+ }
60
80
  const result = renderImage(this.#base64Data, this.#dimensions, {
61
81
  maxWidthCells: maxWidth,
62
82
  maxHeightCells: this.#options.maxHeightCells,
83
+ imageId: this.#kittyImageId,
84
+ placementId: this.#kittyPlacementId,
63
85
  });
64
86
 
65
87
  if (result) {
66
- // Return `rows` lines so TUI accounts for image height
67
- // First (rows-1) lines are empty (TUI clears them)
68
- // Last line: move cursor back up, then output image sequence
69
- lines = [];
70
- for (let i = 0; i < result.rows - 1; i++) {
71
- lines.push("");
88
+ // Return `rows` lines so the TUI accounts for the image height.
89
+ if (result.cursorNeutral) {
90
+ // Kitty a=p,C=1 placements neither move the cursor nor carry
91
+ // pixel data, so the escape lives on the FIRST row — the image
92
+ // anchors to that cell and no cursor-up trick is needed (the
93
+ // old CUU approach clamped at the viewport top edge and placed
94
+ // the image over transcript text when partially scrolled out).
95
+ lines = [result.sequence];
96
+ for (let i = 0; i < result.rows - 1; i++) {
97
+ lines.push("");
98
+ }
99
+ } else {
100
+ // iTerm2/SIXEL draw at the cursor and advance it: reserve
101
+ // rows-1 blank lines (TUI clears them), then move the cursor
102
+ // up and draw from the last line.
103
+ lines = [];
104
+ for (let i = 0; i < result.rows - 1; i++) {
105
+ lines.push("");
106
+ }
107
+ const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
108
+ lines.push(moveUp + result.sequence);
72
109
  }
73
- // Move cursor up to first row, then output image
74
- const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
75
- lines.push(moveUp + result.sequence);
76
110
  } else {
77
111
  const fallback = imageFallback(this.#mimeType, this.#dimensions, this.#options.filename);
78
112
  lines = [this.#theme.fallbackColor(fallback)];
@@ -222,6 +222,40 @@ export interface ImageRenderOptions {
222
222
  maxWidthCells?: number;
223
223
  maxHeightCells?: number;
224
224
  preserveAspectRatio?: boolean;
225
+ /**
226
+ * Kitty-only: stable placement id (`p=`). Re-emitting the same image id +
227
+ * placement id *replaces* the existing placement instead of stacking a new
228
+ * copy, which makes diff-renderer repaints idempotent. Callers that render
229
+ * a persistent component should allocate one id per component instance.
230
+ */
231
+ placementId?: number;
232
+ /**
233
+ * Kitty-only: stable image id (`i=`). Defaults to a content hash of the
234
+ * base64 payload ({@link kittyImageId}). Pass a precomputed id to avoid
235
+ * re-hashing large payloads on every render.
236
+ */
237
+ imageId?: number;
238
+ /**
239
+ * Kitty-only: sink for the out-of-band data transmission (`a=t`) emitted
240
+ * the first time an image id is rendered. Defaults to the process-wide
241
+ * writer configured via {@link setKittyTransmitWriter} (stdout).
242
+ */
243
+ onTransmit?: (sequence: string) => void;
244
+ }
245
+
246
+ /**
247
+ * Derive a stable 32-bit non-zero kitty image id (`i=`) from image content
248
+ * (FNV-1a over the base64 payload). Identical content maps to the same id, so
249
+ * retransmission replaces the stored image instead of accumulating copies.
250
+ */
251
+ export function kittyImageId(base64Data: string): number {
252
+ let hash = 0x811c9dc5;
253
+ for (let i = 0; i < base64Data.length; i++) {
254
+ hash ^= base64Data.charCodeAt(i);
255
+ hash = Math.imul(hash, 0x01000193);
256
+ }
257
+ hash >>>= 0;
258
+ return hash === 0 ? 1 : hash;
225
259
  }
226
260
 
227
261
  // Default cell dimensions - updated by TUI when terminal responds to query
@@ -241,6 +275,7 @@ export function encodeKitty(
241
275
  columns?: number;
242
276
  rows?: number;
243
277
  imageId?: number;
278
+ placementId?: number;
244
279
  } = {},
245
280
  ): string {
246
281
  const CHUNK_SIZE = 4096;
@@ -249,7 +284,77 @@ export function encodeKitty(
249
284
 
250
285
  if (options.columns) params.push(`c=${options.columns}`);
251
286
  if (options.rows) params.push(`r=${options.rows}`);
252
- if (options.imageId) params.push(`i=${options.imageId}`);
287
+ if (options.imageId) {
288
+ params.push(`i=${options.imageId}`);
289
+ // A placement id is only meaningful together with an image id. Same
290
+ // i= + p= replaces the previous placement (kitty graphics spec), so
291
+ // re-emitting this sequence never duplicates the image on screen.
292
+ if (options.placementId) params.push(`p=${options.placementId}`);
293
+ }
294
+
295
+ if (base64Data.length <= CHUNK_SIZE) {
296
+ return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
297
+ }
298
+
299
+ const chunks: string[] = [];
300
+ let offset = 0;
301
+ let isFirst = true;
302
+
303
+ while (offset < base64Data.length) {
304
+ const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
305
+ const isLast = offset + CHUNK_SIZE >= base64Data.length;
306
+
307
+ if (isFirst) {
308
+ chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
309
+ isFirst = false;
310
+ } else if (isLast) {
311
+ chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
312
+ } else {
313
+ chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
314
+ }
315
+
316
+ offset += CHUNK_SIZE;
317
+ }
318
+
319
+ return chunks.join("");
320
+ }
321
+
322
+ /** Kitty image ids already uploaded to the terminal in this process. */
323
+ const transmittedKittyImageIds = new Set<number>();
324
+
325
+ /** Test hook: forget which kitty image ids were transmitted. */
326
+ export function resetKittyTransmissions(): void {
327
+ transmittedKittyImageIds.clear();
328
+ }
329
+
330
+ let kittyTransmitWriter: (sequence: string) => void = sequence => {
331
+ process.stdout.write(sequence);
332
+ };
333
+
334
+ /**
335
+ * Override where out-of-band kitty data transmissions (`a=t`) are written.
336
+ * The default writes directly to stdout: a transmit-only escape is
337
+ * cursor-neutral (it uploads pixel data without drawing anything), so the
338
+ * only ordering requirement is that it reaches the terminal before the
339
+ * placement escape that references it — which the synchronous write during
340
+ * render guarantees. Tests use this to capture transmissions.
341
+ */
342
+ export function setKittyTransmitWriter(writer: (sequence: string) => void): void {
343
+ kittyTransmitWriter = writer;
344
+ }
345
+
346
+ /**
347
+ * Encode a kitty transmit-only (`a=t`) escape: uploads image data under a
348
+ * stable id without creating a placement. Chunked at 4096 bytes per spec.
349
+ *
350
+ * This is deliberately separate from placement: re-sending data (`a=t`/`a=T`)
351
+ * for an existing image id deletes the image and ALL of its placements, so
352
+ * data must be uploaded exactly once per id and repaints must go through
353
+ * {@link encodeKittyPlacement} only.
354
+ */
355
+ export function encodeKittyTransmit(base64Data: string, imageId: number): string {
356
+ const CHUNK_SIZE = 4096;
357
+ const params = ["a=t", "f=100", "q=2", `i=${imageId}`];
253
358
 
254
359
  if (base64Data.length <= CHUNK_SIZE) {
255
360
  return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
@@ -278,6 +383,22 @@ export function encodeKitty(
278
383
  return chunks.join("");
279
384
  }
280
385
 
386
+ /**
387
+ * Encode a kitty placement-only (`a=p`) escape referencing previously
388
+ * transmitted data. Re-emitting the same i=/p= pair replaces that one
389
+ * placement (never stacks, never touches sibling placements), and C=1
390
+ * keeps the cursor where it is so the escape can be emitted from the
391
+ * component's first row without cursor-up tricks.
392
+ */
393
+ export function encodeKittyPlacement(options: {
394
+ imageId: number;
395
+ placementId: number;
396
+ columns: number;
397
+ rows: number;
398
+ }): string {
399
+ return `\x1b_Ga=p,i=${options.imageId},p=${options.placementId},c=${options.columns},r=${options.rows},C=1,q=2\x1b\\`;
400
+ }
401
+
281
402
  export function encodeITerm2(
282
403
  base64Data: string,
283
404
  options: {
@@ -485,11 +606,23 @@ export function getImageDimensions(base64Data: string, mimeType: string): ImageD
485
606
  return null;
486
607
  }
487
608
 
609
+ export interface RenderedImage {
610
+ sequence: string;
611
+ rows: number;
612
+ /**
613
+ * True when the escape neither moves the cursor nor carries pixel data
614
+ * (kitty `a=p,C=1` placements). Cursor-neutral sequences can be emitted
615
+ * from the component's first row; cursor-advancing protocols
616
+ * (iTerm2/SIXEL) must draw from the last reserved row instead.
617
+ */
618
+ cursorNeutral?: boolean;
619
+ }
620
+
488
621
  export function renderImage(
489
622
  base64Data: string,
490
623
  imageDimensions: ImageDimensions,
491
624
  options: ImageRenderOptions = {},
492
- ): { sequence: string; rows: number } | null {
625
+ ): RenderedImage | null {
493
626
  if (!TERMINAL.imageProtocol) {
494
627
  return null;
495
628
  }
@@ -498,11 +631,20 @@ export function renderImage(
498
631
  const fit = calculateImageFit(imageDimensions, options, cellDims);
499
632
 
500
633
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
501
- const sequence = encodeKitty(base64Data, {
502
- columns: fit.columns,
503
- rows: fit.rows,
504
- });
505
- return { sequence, rows: fit.rows };
634
+ const imageId = options.imageId ?? kittyImageId(base64Data);
635
+ const placementId = options.placementId ?? 1;
636
+ // Upload data once per image id (out-of-band; the transmit escape is
637
+ // cursor-neutral), then return only a tiny placement escape. Repaints
638
+ // re-emit just the placement, which replaces/moves that placement —
639
+ // re-sending data (a=T/a=t) for an existing id would delete the image
640
+ // and ALL of its placements (breaking sibling components showing the
641
+ // same content) and would re-send multi-MB payloads on every repaint.
642
+ if (!transmittedKittyImageIds.has(imageId)) {
643
+ transmittedKittyImageIds.add(imageId);
644
+ (options.onTransmit ?? kittyTransmitWriter)(encodeKittyTransmit(base64Data, imageId));
645
+ }
646
+ const sequence = encodeKittyPlacement({ imageId, placementId, columns: fit.columns, rows: fit.rows });
647
+ return { sequence, rows: fit.rows, cursorNeutral: true };
506
648
  }
507
649
 
508
650
  if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
package/src/terminal.ts CHANGED
@@ -128,6 +128,9 @@ export interface Terminal {
128
128
  // Whether terminal output is still writable
129
129
  get available(): boolean;
130
130
 
131
+ // True for the real process stdin/stdout terminal (not virtual test terminals).
132
+ readonly isProcessTerminal?: boolean;
133
+
131
134
  // Get terminal dimensions
132
135
  get columns(): number;
133
136
  get rows(): number;
@@ -238,6 +241,10 @@ export class ProcessTerminal implements Terminal {
238
241
  #mode2031DebounceTimer?: Timer;
239
242
  #progressTimer?: ReturnType<typeof setInterval>;
240
243
 
244
+ get isProcessTerminal(): boolean {
245
+ return true;
246
+ }
247
+
241
248
  get kittyProtocolActive(): boolean {
242
249
  return this.#kittyProtocolActive;
243
250
  }
package/src/tui.ts CHANGED
@@ -137,49 +137,83 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu
137
137
  return undefined;
138
138
  }
139
139
 
140
- function isTermuxSession(): boolean {
141
- return Boolean(process.env.TERMUX_VERSION);
140
+ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boolean {
141
+ return Boolean(env.TERMUX_VERSION);
142
142
  }
143
143
 
144
144
  const SKC_TMUX_LAUNCHED_ENV = "SKC_TMUX_LAUNCHED";
145
145
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
146
+ const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
146
147
 
147
148
  function envIsEnabled(value: string | undefined): boolean {
148
149
  const normalized = value?.trim().toLowerCase();
149
150
  return normalized !== undefined && normalized.length > 0 && !DISABLED_ENV_VALUES.has(normalized);
150
151
  }
151
152
 
153
+ function envFlagEnabled(value: string | undefined): boolean {
154
+ const normalized = value?.trim().toLowerCase();
155
+ return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
156
+ }
157
+
152
158
  function termLooksMultiplexed(value: string | undefined): boolean {
153
159
  const term = value?.trim().toLowerCase() ?? "";
154
160
  return term.startsWith("tmux") || term.startsWith("screen");
155
161
  }
156
162
 
157
- function isWindowsTerminalSession(): boolean {
158
- return envIsEnabled(Bun.env.WT_SESSION) || Bun.env.TERM_PROGRAM === "Windows_Terminal";
159
- }
160
-
161
- function isViewportRepaintSession(): boolean {
162
- return isMultiplexerSession() || isWindowsTerminalSession();
163
+ function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
164
+ return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
163
165
  }
164
166
 
165
167
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
166
- function isMultiplexerSession(): boolean {
168
+ function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
167
169
  return Boolean(
168
- envIsEnabled(Bun.env.TMUX) ||
169
- envIsEnabled(Bun.env.TMUX_PANE) ||
170
- envIsEnabled(Bun.env.STY) ||
171
- envIsEnabled(Bun.env.ZELLIJ) ||
172
- envIsEnabled(Bun.env[SKC_TMUX_LAUNCHED_ENV]) ||
173
- termLooksMultiplexed(Bun.env.TERM),
170
+ envIsEnabled(env.TMUX) ||
171
+ envIsEnabled(env.TMUX_PANE) ||
172
+ envIsEnabled(env.STY) ||
173
+ envIsEnabled(env.ZELLIJ) ||
174
+ envIsEnabled(env[SKC_TMUX_LAUNCHED_ENV]) ||
175
+ termLooksMultiplexed(env.TERM),
176
+ );
177
+ }
178
+
179
+ function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
180
+ return envFlagEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
181
+ }
182
+
183
+ function isViewportSensitiveHost(
184
+ env: Record<string, string | undefined>,
185
+ platform: NodeJS.Platform,
186
+ includeNativeWindows: boolean,
187
+ ): boolean {
188
+ return isMultiplexerSession(env) || isWindowsTerminalSession(env) || (includeNativeWindows && platform === "win32");
189
+ }
190
+ /**
191
+ * True when repainting only the live viewport is safer than clearing/replaying
192
+ * the full transcript. Native Windows console hosts are included even when
193
+ * WT_SESSION is absent because PowerShell/ConPTY launch chains can drop terminal
194
+ * identity variables while keeping the same scroll-jump behavior.
195
+ */
196
+ export function shouldUseViewportRepaintForHost(
197
+ env: Record<string, string | undefined> = Bun.env,
198
+ platform: NodeJS.Platform = process.platform,
199
+ options: { includeNativeWindows?: boolean } = {},
200
+ ): boolean {
201
+ const multiplexed = isMultiplexerSession(env);
202
+ const includeNativeWindows = options.includeNativeWindows ?? true;
203
+ return (
204
+ isViewportSensitiveHost(env, platform, includeNativeWindows) &&
205
+ !(multiplexed && useLegacyMultiplexerFullRender(env))
174
206
  );
175
207
  }
176
208
 
177
- function useLegacyMultiplexerFullRender(): boolean {
178
- return $flag("PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER");
209
+ function useViewportRepaintPath(terminal: Terminal): boolean {
210
+ return shouldUseViewportRepaintForHost(Bun.env, process.platform, {
211
+ includeNativeWindows: terminal.isProcessTerminal === true,
212
+ });
179
213
  }
180
214
 
181
- function useViewportRepaintPath(): boolean {
182
- return isViewportRepaintSession() && !(isMultiplexerSession() && useLegacyMultiplexerFullRender());
215
+ function shouldPreserveScrollbackOnFullClear(terminal: Terminal): boolean {
216
+ return isViewportSensitiveHost(Bun.env, process.platform, terminal.isProcessTerminal === true);
183
217
  }
184
218
 
185
219
  /**
@@ -530,19 +564,13 @@ export class TUI extends Container {
530
564
  const pageStep = Math.max(1, height - 1);
531
565
  const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
532
566
 
533
- if (targetViewportTop >= maxViewportTop) {
534
- this.#manualViewportTop = undefined;
535
- } else {
536
- this.#manualViewportTop = targetViewportTop;
537
- }
538
-
539
- const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
567
+ this.#manualViewportTop = targetViewportTop;
540
568
  return this.#repaintViewportFromLines(
541
569
  this.#previousLines,
542
570
  width,
543
571
  height,
544
572
  targetViewportTop,
545
- cursorPos,
573
+ null,
546
574
  "manual viewport scroll",
547
575
  );
548
576
  }
@@ -914,13 +942,13 @@ export class TUI extends Container {
914
942
  * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
915
943
  * `3J` escape (users navigate scrollback history), so replaying every transcript line
916
944
  * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
917
- * high speed" resize storm. Windows Terminal can also visibly jump to the
918
- * transcript top during streaming redraws, so viewport-repaint sessions keep
919
- * force off and let `#doRender` repaint only the live viewport. Set
945
+ * high speed" resize storm. Windows Terminal/ConPTY can also visibly jump to
946
+ * the transcript top during streaming redraws, so viewport-repaint sessions
947
+ * keep force off and let `#doRender` repaint only the live viewport. Set
920
948
  * `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy tmux redraw.
921
949
  */
922
950
  requestResizeRender(): void {
923
- this.requestRender(!useViewportRepaintPath() && !isTermuxSession(), "resize");
951
+ this.requestRender(!useViewportRepaintPath(this.terminal) && !isTermuxSession(), "resize");
924
952
  }
925
953
 
926
954
  requestRender(force = false, source = "unknown"): void {
@@ -930,7 +958,7 @@ export class TUI extends Container {
930
958
  }
931
959
  if (renderMetrics.enabled) renderMetrics.recordRequest(source);
932
960
  if (force) {
933
- const preserveViewportCursor = useViewportRepaintPath();
961
+ const preserveViewportCursor = useViewportRepaintPath(this.terminal);
934
962
  // A forced full redraw supersedes any queued input-priority render.
935
963
  this.#inputRenderPending = false;
936
964
  this.#previousLines = [];
@@ -1695,9 +1723,8 @@ export class TUI extends Container {
1695
1723
  if (this.#manualViewportTop !== undefined) {
1696
1724
  const maxViewportTop = Math.max(0, newLines.length - height);
1697
1725
  const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
1698
- const followingLive = nextViewportTop >= maxViewportTop;
1699
- this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
1700
- const repaintCursorPos = followingLive ? cursorPos : null;
1726
+ this.#manualViewportTop = nextViewportTop;
1727
+ const repaintCursorPos = null;
1701
1728
  if (
1702
1729
  this.#repaintViewportFromLines(
1703
1730
  newLines,
@@ -1719,8 +1746,10 @@ export class TUI extends Container {
1719
1746
  this.#fullRedrawCount += 1;
1720
1747
  if (renderMetrics.enabled) renderMetrics.recordFullRedraw(reason);
1721
1748
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1722
- // Skip clearing scrollback (3J) in multiplexers users actively navigate scrollback history
1723
- if (clear) buffer += isMultiplexerSession() ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1749
+ // Skip clearing scrollback (3J) in hosts where clear/replay can snap the
1750
+ // native viewport away from the live prompt (tmux/screen, Windows ConPTY).
1751
+ if (clear)
1752
+ buffer += shouldPreserveScrollbackOnFullClear(this.terminal) ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1724
1753
  for (let i = 0; i < newLines.length; i++) {
1725
1754
  if (i > 0) buffer += "\r\n";
1726
1755
  // Lines were pre-terminated/normalized by #applyLineResets; image
@@ -1817,7 +1846,7 @@ export class TUI extends Container {
1817
1846
  // Width changes always need a full re-render because wrapping changes.
1818
1847
  if (widthChanged) {
1819
1848
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1820
- if (useViewportRepaintPath()) {
1849
+ if (useViewportRepaintPath(this.terminal)) {
1821
1850
  // In viewport-repaint sessions a full replay can either pile the transcript
1822
1851
  // back onto scrollback (tmux/screen) or visibly jump to the transcript top
1823
1852
  // (Windows Terminal). Repaint the viewport only, mirroring the height-change
@@ -1833,7 +1862,7 @@ export class TUI extends Container {
1833
1862
  // but Termux changes height when the software keyboard shows or hides.
1834
1863
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1835
1864
  if (heightChanged) {
1836
- if (useViewportRepaintPath()) {
1865
+ if (useViewportRepaintPath(this.terminal)) {
1837
1866
  viewportRepaint(`terminal height changed (${this.#previousHeight} -> ${height})`);
1838
1867
  return;
1839
1868
  }
@@ -1849,7 +1878,7 @@ export class TUI extends Container {
1849
1878
  // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1850
1879
  if (this.#clearOnShrink && newLines.length < this.#previousLines.length && this.overlayStack.length === 0) {
1851
1880
  logRedraw(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1852
- if (useViewportRepaintPath()) {
1881
+ if (useViewportRepaintPath(this.terminal)) {
1853
1882
  viewportRepaint(`clearOnShrink (prev=${this.#previousLines.length}, new=${newLines.length})`);
1854
1883
  } else {
1855
1884
  fullRender(true, "clearOnShrink");
@@ -1910,7 +1939,7 @@ export class TUI extends Container {
1910
1939
  const extraLines = this.#previousLines.length - newLines.length;
1911
1940
  if (extraLines > height) {
1912
1941
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
1913
- if (useViewportRepaintPath()) {
1942
+ if (useViewportRepaintPath(this.terminal)) {
1914
1943
  viewportRepaint(`extraLines > height (${extraLines} > ${height})`);
1915
1944
  } else {
1916
1945
  fullRender(true, "extraLines > height");
@@ -1952,7 +1981,7 @@ export class TUI extends Container {
1952
1981
  // back to live.
1953
1982
  if (firstChanged < prevViewportTop) {
1954
1983
  logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1955
- if (useViewportRepaintPath()) {
1984
+ if (useViewportRepaintPath(this.terminal)) {
1956
1985
  viewportRepaint(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
1957
1986
  return;
1958
1987
  }