@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.
- package/dist/types/terminal-capabilities.d.ts +70 -2
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +12 -3
- package/package.json +3 -3
- package/src/components/editor.ts +35 -32
- package/src/components/image.ts +43 -9
- package/src/terminal-capabilities.ts +149 -7
- package/src/terminal.ts +7 -0
- package/src/tui.ts +71 -42
|
@@ -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
|
|
131
|
+
export interface RenderedImage {
|
|
72
132
|
sequence: string;
|
|
73
133
|
rows: number;
|
|
74
|
-
|
|
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;
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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
|
|
194
|
-
* transcript top during streaming redraws, so viewport-repaint sessions
|
|
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.
|
|
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.
|
|
42
|
-
"@sayknow-cli/utils": "0.3.
|
|
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
|
},
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
const
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
|
package/src/components/image.ts
CHANGED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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)
|
|
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
|
-
):
|
|
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
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
169
|
-
envIsEnabled(
|
|
170
|
-
envIsEnabled(
|
|
171
|
-
envIsEnabled(
|
|
172
|
-
envIsEnabled(
|
|
173
|
-
termLooksMultiplexed(
|
|
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
|
|
178
|
-
return
|
|
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
|
|
182
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
918
|
-
* transcript top during streaming redraws, so viewport-repaint sessions
|
|
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
|
-
|
|
1699
|
-
|
|
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
|
|
1723
|
-
|
|
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
|
}
|