@sayknow-cli/tui 0.5.1 → 0.5.2
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 +62 -0
- package/dist/types/tui.d.ts +17 -6
- package/package.json +5 -4
- package/src/components/settings-list.ts +5 -2
- package/src/terminal-capabilities.ts +190 -12
- package/src/tui.ts +166 -17
|
@@ -64,6 +64,25 @@ export declare function isImageProtocolForced(): boolean;
|
|
|
64
64
|
* to force the pre-0.4.5 behavior (graphics unconditionally off under tmux).
|
|
65
65
|
*/
|
|
66
66
|
export declare function isSixelMultiplexerEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Returns whether the kitty-graphics-through-tmux path (passthrough-wrapped
|
|
69
|
+
* capability query + DCS passthrough overlay render) is enabled. On by default;
|
|
70
|
+
* set SKC_KITTY_MULTIPLEXER=0 to keep overlay graphics off under tmux for
|
|
71
|
+
* kitty-protocol terminals (Ghostty, kitty, WezTerm).
|
|
72
|
+
*/
|
|
73
|
+
export declare function isKittyMultiplexerEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Image id reserved for the kitty capability query. It is never placed, so a
|
|
76
|
+
* terminal that answers `OK` leaves nothing on screen.
|
|
77
|
+
*/
|
|
78
|
+
export declare const KITTY_PROBE_IMAGE_ID = 1936417537;
|
|
79
|
+
/**
|
|
80
|
+
* Kitty graphics capability query: transmit one black RGB pixel with `a=q`
|
|
81
|
+
* (query only, never displayed). A terminal that implements the protocol
|
|
82
|
+
* answers `\x1b_Gi=<id>;OK\x1b\\`; one that does not stays silent, which is the
|
|
83
|
+
* end-to-end evidence tmux's own DA1 sixel claim can never provide.
|
|
84
|
+
*/
|
|
85
|
+
export declare const KITTY_CAPABILITY_QUERY = "\u001B_Gi=1936417537,s=1,v=1,a=q,t=d,f=24;AAAA\u001B\\";
|
|
67
86
|
/**
|
|
68
87
|
* Returns whether the process runs under tmux specifically. Only tmux implements
|
|
69
88
|
* the DCS passthrough envelope (`\ePtmux;…\e\\`) that forwards graphics escapes to
|
|
@@ -81,6 +100,39 @@ export declare function wrapTmuxPassthrough(payload: string, env?: NodeJS.Proces
|
|
|
81
100
|
export declare function tmuxOwnsSixel(env?: NodeJS.ProcessEnv): boolean;
|
|
82
101
|
/** Testing seam: drop the cached tmux sixel-ownership answer. */
|
|
83
102
|
export declare function resetTmuxSixelOwnershipCache(): void;
|
|
103
|
+
/**
|
|
104
|
+
* Screen-absolute origin of this tmux pane: what has to be added to a
|
|
105
|
+
* pane-relative cell position to hit the same cell in the OUTER terminal.
|
|
106
|
+
*
|
|
107
|
+
* Passthrough payloads bypass tmux entirely, so a cursor-position escape inside
|
|
108
|
+
* the envelope addresses the outer terminal's screen, not the pane. Without the
|
|
109
|
+
* offset a split window or a top status line draws the overlay in the wrong
|
|
110
|
+
* place. tmux never repositions the real cursor before forwarding a passthrough
|
|
111
|
+
* payload (`tty_cmd_rawstring` only invalidates its cursor state), so absolute
|
|
112
|
+
* positioning inside the envelope is the only reliable placement.
|
|
113
|
+
*
|
|
114
|
+
* Cached because it shells out to tmux; every pane geometry change that matters
|
|
115
|
+
* also resizes the pane, and the TUI drops the cache on resize.
|
|
116
|
+
*/
|
|
117
|
+
export interface TmuxPaneOffset {
|
|
118
|
+
top: number;
|
|
119
|
+
left: number;
|
|
120
|
+
}
|
|
121
|
+
export declare function tmuxPaneOffset(env?: NodeJS.ProcessEnv): TmuxPaneOffset;
|
|
122
|
+
/** Drop the cached pane origin (pane geometry changed, or a test needs a clean slate). */
|
|
123
|
+
export declare function resetTmuxPaneOffsetCache(): void;
|
|
124
|
+
/**
|
|
125
|
+
* Ask tmux to forward DCS passthrough for THIS pane.
|
|
126
|
+
*
|
|
127
|
+
* SKC-launched sessions already carry the option from their tmux profile, but a
|
|
128
|
+
* pane the user created by hand inherits the server default (`off`), and then
|
|
129
|
+
* every passthrough payload — including the capability query — is swallowed.
|
|
130
|
+
* `-p` keeps the change pane-local (never global server state) and `-q` stays
|
|
131
|
+
* quiet on tmux < 3.3 where the option does not exist. Runs at most once.
|
|
132
|
+
*/
|
|
133
|
+
export declare function enableTmuxPanePassthrough(env?: NodeJS.ProcessEnv): boolean;
|
|
134
|
+
/** Testing seam: allow the pane passthrough request to run again. */
|
|
135
|
+
export declare function resetTmuxPanePassthroughRequest(): void;
|
|
84
136
|
/**
|
|
85
137
|
* Returns true when running in Windows Terminal with known SIXEL support.
|
|
86
138
|
*
|
|
@@ -100,6 +152,16 @@ export declare function onImageProtocolChanged(listener: ImageProtocolChangeList
|
|
|
100
152
|
* Override terminal image protocol at runtime after capability probes complete.
|
|
101
153
|
*/
|
|
102
154
|
export declare function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void;
|
|
155
|
+
/**
|
|
156
|
+
* Protocol available for absolutely-positioned overlays that reach the outer
|
|
157
|
+
* terminal through tmux's DCS passthrough envelope, when inline graphics are
|
|
158
|
+
* suppressed by the multiplexer. Set by the startup capability probes on proof
|
|
159
|
+
* that the outer terminal answered end to end; never inferred from env vars.
|
|
160
|
+
*/
|
|
161
|
+
export declare function getTmuxOverlayImageProtocol(): ImageProtocol | null;
|
|
162
|
+
export declare function setTmuxOverlayImageProtocol(imageProtocol: ImageProtocol | null): void;
|
|
163
|
+
/** Effective protocol for overlay drawing: inline graphics first, passthrough second. */
|
|
164
|
+
export declare function getOverlayImageProtocol(): ImageProtocol | null;
|
|
103
165
|
export declare function getTerminalInfo(terminalId: TerminalId): TerminalInfo;
|
|
104
166
|
export interface CellDimensions {
|
|
105
167
|
widthPx: number;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -130,16 +130,27 @@ export interface OverlayMargin {
|
|
|
130
130
|
export type SizeValue = number | `${number}%`;
|
|
131
131
|
/**
|
|
132
132
|
* Startup sixel capability probe policy (pure; exported for tests):
|
|
133
|
-
* - Never probe when
|
|
133
|
+
* - Never probe when SKC_FORCE_IMAGE_PROTOCOL is set — an explicit
|
|
134
134
|
* configuration (including "off") is authoritative.
|
|
135
|
-
* -
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
135
|
+
* - Under tmux, probe through the DCS passthrough envelope: the query reaches
|
|
136
|
+
* the OUTER terminal, so its DA1 ";4" is genuine end-to-end evidence. A bare
|
|
137
|
+
* query would only reach tmux, which advertises sixel whenever it was
|
|
138
|
+
* compiled with support, regardless of the attached client.
|
|
139
|
+
* - Never probe under screen/zellij: no passthrough envelope exists there, so
|
|
140
|
+
* no answer can be trusted and nothing can be drawn anyway.
|
|
140
141
|
* - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
|
|
141
142
|
*/
|
|
142
143
|
export declare function shouldProbeSixelCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
144
|
+
/**
|
|
145
|
+
* Startup kitty-graphics probe policy for tmux panes (pure; exported for tests).
|
|
146
|
+
*
|
|
147
|
+
* Kitty-protocol terminals (Ghostty, kitty, WezTerm) never answer a sixel query,
|
|
148
|
+
* so the sixel probe alone leaves them without graphics inside tmux. The kitty
|
|
149
|
+
* capability query has the property the sixel path lacks: tmux itself does not
|
|
150
|
+
* implement the protocol and cannot fake the reply, so an `OK` coming back
|
|
151
|
+
* through passthrough proves the outer terminal drew it.
|
|
152
|
+
*/
|
|
153
|
+
export declare function shouldProbeKittyPassthrough(env?: NodeJS.ProcessEnv): boolean;
|
|
143
154
|
/**
|
|
144
155
|
* True when repainting only the live viewport is safer than clearing/replaying
|
|
145
156
|
* the full transcript. Real process terminals are viewport-sensitive because
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@sayknow-cli/tui",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.2",
|
|
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,14 +38,15 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@sayknow-cli/natives": "0.5.
|
|
42
|
-
"@sayknow-cli/utils": "0.5.
|
|
41
|
+
"@sayknow-cli/natives": "0.5.2",
|
|
42
|
+
"@sayknow-cli/utils": "0.5.2",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"chalk": "^5.6.2",
|
|
48
|
-
"@xterm/headless": "^6.0.0"
|
|
48
|
+
"@xterm/headless": "^6.0.0",
|
|
49
|
+
"node-pty": "^1.0.0"
|
|
49
50
|
},
|
|
50
51
|
"engines": {
|
|
51
52
|
"bun": ">=1.3.14"
|
|
@@ -213,9 +213,12 @@ export class SettingsList implements Component {
|
|
|
213
213
|
|
|
214
214
|
#closeSubmenu(): void {
|
|
215
215
|
this.#submenuComponent = null;
|
|
216
|
-
// Restore selection to the item that opened the submenu
|
|
216
|
+
// Restore selection to the item that opened the submenu. `setItems` leaves an
|
|
217
|
+
// open submenu untouched, so the list may have shrunk past that index while it
|
|
218
|
+
// was open — clamp or the restored selection points at nothing and the main
|
|
219
|
+
// list renders with no cursor at all.
|
|
217
220
|
if (this.#submenuItemIndex !== null) {
|
|
218
|
-
this.#selectedIndex = this.#submenuItemIndex;
|
|
221
|
+
this.#selectedIndex = Math.min(this.#submenuItemIndex, Math.max(0, this.#items.length - 1));
|
|
219
222
|
this.#submenuItemIndex = null;
|
|
220
223
|
this.#notifySelectionChange();
|
|
221
224
|
}
|
|
@@ -143,7 +143,7 @@ export function isImageProtocolForced(): boolean {
|
|
|
143
143
|
return getForcedImageProtocol() !== undefined;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
const
|
|
146
|
+
const MULTIPLEXER_GRAPHICS_DISABLED_ENV_VALUES = new Set(["0", "off", "false", "no"]);
|
|
147
147
|
|
|
148
148
|
/**
|
|
149
149
|
* Returns whether the sixel-through-multiplexer path (passthrough-wrapped probe
|
|
@@ -152,9 +152,34 @@ const SIXEL_MULTIPLEXER_DISABLED_ENV_VALUES = new Set(["0", "off", "false", "no"
|
|
|
152
152
|
*/
|
|
153
153
|
export function isSixelMultiplexerEnabled(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
154
154
|
const raw = env.SKC_SIXEL_MULTIPLEXER?.trim().toLowerCase();
|
|
155
|
-
return raw === undefined || raw === "" || !
|
|
155
|
+
return raw === undefined || raw === "" || !MULTIPLEXER_GRAPHICS_DISABLED_ENV_VALUES.has(raw);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Returns whether the kitty-graphics-through-tmux path (passthrough-wrapped
|
|
160
|
+
* capability query + DCS passthrough overlay render) is enabled. On by default;
|
|
161
|
+
* set SKC_KITTY_MULTIPLEXER=0 to keep overlay graphics off under tmux for
|
|
162
|
+
* kitty-protocol terminals (Ghostty, kitty, WezTerm).
|
|
163
|
+
*/
|
|
164
|
+
export function isKittyMultiplexerEnabled(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
165
|
+
const raw = env.SKC_KITTY_MULTIPLEXER?.trim().toLowerCase();
|
|
166
|
+
return raw === undefined || raw === "" || !MULTIPLEXER_GRAPHICS_DISABLED_ENV_VALUES.has(raw);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Image id reserved for the kitty capability query. It is never placed, so a
|
|
171
|
+
* terminal that answers `OK` leaves nothing on screen.
|
|
172
|
+
*/
|
|
173
|
+
export const KITTY_PROBE_IMAGE_ID = 0x736b_6301;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Kitty graphics capability query: transmit one black RGB pixel with `a=q`
|
|
177
|
+
* (query only, never displayed). A terminal that implements the protocol
|
|
178
|
+
* answers `\x1b_Gi=<id>;OK\x1b\\`; one that does not stays silent, which is the
|
|
179
|
+
* end-to-end evidence tmux's own DA1 sixel claim can never provide.
|
|
180
|
+
*/
|
|
181
|
+
export const KITTY_CAPABILITY_QUERY = `\x1b_Gi=${KITTY_PROBE_IMAGE_ID},s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\`;
|
|
182
|
+
|
|
158
183
|
/**
|
|
159
184
|
* Returns whether the process runs under tmux specifically. Only tmux implements
|
|
160
185
|
* the DCS passthrough envelope (`\ePtmux;…\e\\`) that forwards graphics escapes to
|
|
@@ -222,6 +247,113 @@ export function resetTmuxSixelOwnershipCache(): void {
|
|
|
222
247
|
tmuxSixelOwnershipCache = undefined;
|
|
223
248
|
}
|
|
224
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Screen-absolute origin of this tmux pane: what has to be added to a
|
|
252
|
+
* pane-relative cell position to hit the same cell in the OUTER terminal.
|
|
253
|
+
*
|
|
254
|
+
* Passthrough payloads bypass tmux entirely, so a cursor-position escape inside
|
|
255
|
+
* the envelope addresses the outer terminal's screen, not the pane. Without the
|
|
256
|
+
* offset a split window or a top status line draws the overlay in the wrong
|
|
257
|
+
* place. tmux never repositions the real cursor before forwarding a passthrough
|
|
258
|
+
* payload (`tty_cmd_rawstring` only invalidates its cursor state), so absolute
|
|
259
|
+
* positioning inside the envelope is the only reliable placement.
|
|
260
|
+
*
|
|
261
|
+
* Cached because it shells out to tmux; every pane geometry change that matters
|
|
262
|
+
* also resizes the pane, and the TUI drops the cache on resize.
|
|
263
|
+
*/
|
|
264
|
+
export interface TmuxPaneOffset {
|
|
265
|
+
top: number;
|
|
266
|
+
left: number;
|
|
267
|
+
}
|
|
268
|
+
const NO_PANE_OFFSET: TmuxPaneOffset = Object.freeze({ top: 0, left: 0 });
|
|
269
|
+
let tmuxPaneOffsetCache: TmuxPaneOffset | undefined;
|
|
270
|
+
|
|
271
|
+
function parseStatusLines(raw: string | undefined): number {
|
|
272
|
+
const value = raw?.trim().toLowerCase() ?? "";
|
|
273
|
+
if (value === "on") return 1;
|
|
274
|
+
if (value === "off" || value === "") return 0;
|
|
275
|
+
const parsed = Number.parseInt(value, 10);
|
|
276
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function tmuxPaneOffset(env: NodeJS.ProcessEnv = Bun.env): TmuxPaneOffset {
|
|
280
|
+
if (!isUnderTmux(env)) return NO_PANE_OFFSET;
|
|
281
|
+
if (tmuxPaneOffsetCache) return tmuxPaneOffsetCache;
|
|
282
|
+
let offset = NO_PANE_OFFSET;
|
|
283
|
+
try {
|
|
284
|
+
const probe = Bun.spawnSync({
|
|
285
|
+
cmd: [
|
|
286
|
+
env.SKC_TMUX_COMMAND?.trim() || "tmux",
|
|
287
|
+
"display",
|
|
288
|
+
"-p",
|
|
289
|
+
// Status lines only shift the window down when they sit on top.
|
|
290
|
+
"#{pane_top} #{pane_left} #{?#{==:#{status-position},top},#{status},0}",
|
|
291
|
+
],
|
|
292
|
+
stdout: "pipe",
|
|
293
|
+
stderr: "ignore",
|
|
294
|
+
});
|
|
295
|
+
if (probe.success) {
|
|
296
|
+
const [topRaw, leftRaw, statusRaw] = new TextDecoder().decode(probe.stdout).trim().split(/\s+/u);
|
|
297
|
+
const top = Number.parseInt(topRaw ?? "", 10);
|
|
298
|
+
const left = Number.parseInt(leftRaw ?? "", 10);
|
|
299
|
+
offset = {
|
|
300
|
+
top: (Number.isFinite(top) && top > 0 ? top : 0) + parseStatusLines(statusRaw),
|
|
301
|
+
left: Number.isFinite(left) && left > 0 ? left : 0,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
offset = NO_PANE_OFFSET;
|
|
306
|
+
}
|
|
307
|
+
tmuxPaneOffsetCache = offset;
|
|
308
|
+
return offset;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Drop the cached pane origin (pane geometry changed, or a test needs a clean slate). */
|
|
312
|
+
export function resetTmuxPaneOffsetCache(): void {
|
|
313
|
+
tmuxPaneOffsetCache = undefined;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let tmuxPanePassthroughRequested = false;
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Ask tmux to forward DCS passthrough for THIS pane.
|
|
320
|
+
*
|
|
321
|
+
* SKC-launched sessions already carry the option from their tmux profile, but a
|
|
322
|
+
* pane the user created by hand inherits the server default (`off`), and then
|
|
323
|
+
* every passthrough payload — including the capability query — is swallowed.
|
|
324
|
+
* `-p` keeps the change pane-local (never global server state) and `-q` stays
|
|
325
|
+
* quiet on tmux < 3.3 where the option does not exist. Runs at most once.
|
|
326
|
+
*/
|
|
327
|
+
export function enableTmuxPanePassthrough(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
328
|
+
if (!isUnderTmux(env)) return false;
|
|
329
|
+
if (tmuxPanePassthroughRequested) return true;
|
|
330
|
+
tmuxPanePassthroughRequested = true;
|
|
331
|
+
try {
|
|
332
|
+
const target = env.TMUX_PANE?.trim();
|
|
333
|
+
Bun.spawnSync({
|
|
334
|
+
cmd: [
|
|
335
|
+
env.SKC_TMUX_COMMAND?.trim() || "tmux",
|
|
336
|
+
"set-option",
|
|
337
|
+
"-pq",
|
|
338
|
+
...(target ? ["-t", target] : []),
|
|
339
|
+
"allow-passthrough",
|
|
340
|
+
"on",
|
|
341
|
+
],
|
|
342
|
+
stdout: "ignore",
|
|
343
|
+
stderr: "ignore",
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
// tmux missing or refusing the option: the probe simply gets no answer
|
|
347
|
+
// and graphics stay off, which is the correct conservative outcome.
|
|
348
|
+
}
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Testing seam: allow the pane passthrough request to run again. */
|
|
353
|
+
export function resetTmuxPanePassthroughRequest(): void {
|
|
354
|
+
tmuxPanePassthroughRequested = false;
|
|
355
|
+
}
|
|
356
|
+
|
|
225
357
|
function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
|
|
226
358
|
if (!versionRaw) return null;
|
|
227
359
|
const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
|
|
@@ -350,13 +482,15 @@ export const TERMINAL = (() => {
|
|
|
350
482
|
);
|
|
351
483
|
}
|
|
352
484
|
// Multiplexers (tmux/screen/zellij) consume raw kitty/iTerm2 graphics
|
|
353
|
-
// escapes instead of forwarding them
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
485
|
+
// escapes instead of forwarding them, so an inline image protocol draws
|
|
486
|
+
// nothing while its out-of-band cursor writes corrupt the frame. Inline
|
|
487
|
+
// graphics stay suppressed under a multiplexer; only an explicit
|
|
488
|
+
// SKC_FORCE_IMAGE_PROTOCOL opts back in.
|
|
489
|
+
//
|
|
490
|
+
// Absolutely-positioned OVERLAYS are a separate channel: they can be
|
|
491
|
+
// smuggled to the outer terminal through tmux's DCS passthrough envelope
|
|
492
|
+
// with their own cursor addressing, so the runtime probes enable them via
|
|
493
|
+
// setTmuxOverlayImageProtocol() instead of touching TERMINAL.imageProtocol.
|
|
360
494
|
if (resolved.imageProtocol && forcedImageProtocol === undefined && underMultiplexer) {
|
|
361
495
|
resolved = new TerminalInfo(resolved.id, null, resolved.trueColor, resolved.hyperlinks, resolved.notifyProtocol);
|
|
362
496
|
}
|
|
@@ -389,6 +523,10 @@ export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): v
|
|
|
389
523
|
const mutable = TERMINAL as unknown as MutableTerminalInfo;
|
|
390
524
|
if (mutable.imageProtocol === imageProtocol) return;
|
|
391
525
|
mutable.imageProtocol = imageProtocol;
|
|
526
|
+
notifyImageProtocolChanged(imageProtocol);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function notifyImageProtocolChanged(imageProtocol: ImageProtocol | null): void {
|
|
392
530
|
for (const listener of imageProtocolChangeListeners) {
|
|
393
531
|
try {
|
|
394
532
|
listener(imageProtocol);
|
|
@@ -398,6 +536,32 @@ export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): v
|
|
|
398
536
|
}
|
|
399
537
|
}
|
|
400
538
|
|
|
539
|
+
let tmuxOverlayImageProtocol: ImageProtocol | null = null;
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Protocol available for absolutely-positioned overlays that reach the outer
|
|
543
|
+
* terminal through tmux's DCS passthrough envelope, when inline graphics are
|
|
544
|
+
* suppressed by the multiplexer. Set by the startup capability probes on proof
|
|
545
|
+
* that the outer terminal answered end to end; never inferred from env vars.
|
|
546
|
+
*/
|
|
547
|
+
export function getTmuxOverlayImageProtocol(): ImageProtocol | null {
|
|
548
|
+
return tmuxOverlayImageProtocol;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function setTmuxOverlayImageProtocol(imageProtocol: ImageProtocol | null): void {
|
|
552
|
+
if (tmuxOverlayImageProtocol === imageProtocol) return;
|
|
553
|
+
tmuxOverlayImageProtocol = imageProtocol;
|
|
554
|
+
// Overlay owners (the pet) subscribe through the same channel: for them a
|
|
555
|
+
// passthrough overlay protocol arriving late is the same event as inline
|
|
556
|
+
// graphics arriving late.
|
|
557
|
+
notifyImageProtocolChanged(imageProtocol);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Effective protocol for overlay drawing: inline graphics first, passthrough second. */
|
|
561
|
+
export function getOverlayImageProtocol(): ImageProtocol | null {
|
|
562
|
+
return TERMINAL.imageProtocol ?? tmuxOverlayImageProtocol;
|
|
563
|
+
}
|
|
564
|
+
|
|
401
565
|
export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
|
|
402
566
|
return KNOWN_TERMINALS[terminalId];
|
|
403
567
|
}
|
|
@@ -847,9 +1011,23 @@ export function renderImage(
|
|
|
847
1011
|
const targetHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
|
|
848
1012
|
const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
|
|
849
1013
|
const raster = encodeSixel(decoded, targetWidthPx, targetHeightPx);
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
|
|
1014
|
+
// An INLINE image has no cursor addressing of its own: it is drawn wherever
|
|
1015
|
+
// the cursor currently sits. That only works when the multiplexer is the one
|
|
1016
|
+
// placing it.
|
|
1017
|
+
//
|
|
1018
|
+
// tmux 3.4+ with the sixel terminal-feature parses the raster into its own
|
|
1019
|
+
// screen model, so it owns the placement and every later scroll / erase /
|
|
1020
|
+
// resize moves or clears the image with the text around it.
|
|
1021
|
+
if (!isUnderTmux() || tmuxOwnsSixel()) return { sequence: raster, rows: fit.rows };
|
|
1022
|
+
// Otherwise the only route out is DCS passthrough, which writes pixels
|
|
1023
|
+
// straight into the OUTER terminal's image plane at its PHYSICAL cursor.
|
|
1024
|
+
// tmux neither positions that cursor for the pane nor records the pixels, so
|
|
1025
|
+
// inline passthrough lands in the wrong row and survives every repaint —
|
|
1026
|
+
// transcript images pile up over the text and never clear. The absolutely
|
|
1027
|
+
// positioned overlay channel (the pet) carries its own coordinates and is
|
|
1028
|
+
// the only inline-free user of passthrough. A text placeholder beats an
|
|
1029
|
+
// image welded to the wrong part of the screen.
|
|
1030
|
+
return null;
|
|
853
1031
|
} catch {
|
|
854
1032
|
return null;
|
|
855
1033
|
}
|
package/src/tui.ts
CHANGED
|
@@ -10,12 +10,22 @@ import { isKeyRelease } from "./keys";
|
|
|
10
10
|
import { renderMetrics } from "./metrics";
|
|
11
11
|
import type { Terminal } from "./terminal";
|
|
12
12
|
import {
|
|
13
|
+
enableTmuxPanePassthrough,
|
|
14
|
+
getTmuxOverlayImageProtocol,
|
|
13
15
|
ImageProtocol,
|
|
14
16
|
isImageProtocolForced,
|
|
17
|
+
isKittyMultiplexerEnabled,
|
|
18
|
+
isSixelMultiplexerEnabled,
|
|
15
19
|
isUnderTerminalMultiplexer,
|
|
20
|
+
isUnderTmux,
|
|
21
|
+
KITTY_CAPABILITY_QUERY,
|
|
22
|
+
KITTY_PROBE_IMAGE_ID,
|
|
23
|
+
resetTmuxPaneOffsetCache,
|
|
16
24
|
setCellDimensions,
|
|
17
25
|
setTerminalImageProtocol,
|
|
26
|
+
setTmuxOverlayImageProtocol,
|
|
18
27
|
TERMINAL,
|
|
28
|
+
wrapTmuxPassthrough,
|
|
19
29
|
} from "./terminal-capabilities";
|
|
20
30
|
import {
|
|
21
31
|
Ellipsis,
|
|
@@ -321,13 +331,14 @@ function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env)
|
|
|
321
331
|
|
|
322
332
|
/**
|
|
323
333
|
* Startup sixel capability probe policy (pure; exported for tests):
|
|
324
|
-
* - Never probe when
|
|
334
|
+
* - Never probe when SKC_FORCE_IMAGE_PROTOCOL is set — an explicit
|
|
325
335
|
* configuration (including "off") is authoritative.
|
|
326
|
-
* -
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
336
|
+
* - Under tmux, probe through the DCS passthrough envelope: the query reaches
|
|
337
|
+
* the OUTER terminal, so its DA1 ";4" is genuine end-to-end evidence. A bare
|
|
338
|
+
* query would only reach tmux, which advertises sixel whenever it was
|
|
339
|
+
* compiled with support, regardless of the attached client.
|
|
340
|
+
* - Never probe under screen/zellij: no passthrough envelope exists there, so
|
|
341
|
+
* no answer can be trusted and nothing can be drawn anyway.
|
|
331
342
|
* - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
|
|
332
343
|
*/
|
|
333
344
|
export function shouldProbeSixelCapability(
|
|
@@ -335,10 +346,25 @@ export function shouldProbeSixelCapability(
|
|
|
335
346
|
platform: NodeJS.Platform = process.platform,
|
|
336
347
|
): boolean {
|
|
337
348
|
if (isImageProtocolForced()) return false;
|
|
338
|
-
if (isUnderTerminalMultiplexer(env)) return
|
|
349
|
+
if (isUnderTerminalMultiplexer(env)) return isUnderTmux(env) && isSixelMultiplexerEnabled(env);
|
|
339
350
|
return platform === "win32" && Boolean(env.WT_SESSION?.trim());
|
|
340
351
|
}
|
|
341
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Startup kitty-graphics probe policy for tmux panes (pure; exported for tests).
|
|
355
|
+
*
|
|
356
|
+
* Kitty-protocol terminals (Ghostty, kitty, WezTerm) never answer a sixel query,
|
|
357
|
+
* so the sixel probe alone leaves them without graphics inside tmux. The kitty
|
|
358
|
+
* capability query has the property the sixel path lacks: tmux itself does not
|
|
359
|
+
* implement the protocol and cannot fake the reply, so an `OK` coming back
|
|
360
|
+
* through passthrough proves the outer terminal drew it.
|
|
361
|
+
*/
|
|
362
|
+
export function shouldProbeKittyPassthrough(env: NodeJS.ProcessEnv = Bun.env): boolean {
|
|
363
|
+
if (isImageProtocolForced()) return false;
|
|
364
|
+
if (!isUnderTmux(env)) return false;
|
|
365
|
+
return isKittyMultiplexerEnabled(env);
|
|
366
|
+
}
|
|
367
|
+
|
|
342
368
|
function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
|
|
343
369
|
return envFlagEnabled(env.PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER);
|
|
344
370
|
}
|
|
@@ -709,6 +735,10 @@ export class TUI extends Container {
|
|
|
709
735
|
#sixelProbeBuffer = "";
|
|
710
736
|
#sixelProbeTimeout?: NodeJS.Timeout;
|
|
711
737
|
#sixelProbeUnsubscribe?: () => void;
|
|
738
|
+
#kittyProbePending = false;
|
|
739
|
+
#kittyProbeBuffer = "";
|
|
740
|
+
#kittyProbeTimeout?: NodeJS.Timeout;
|
|
741
|
+
#kittyProbeUnsubscribe?: () => void;
|
|
712
742
|
#showHardwareCursor = $pickflag("SKC_HARDWARE_CURSOR", "PI_HARDWARE_CURSOR");
|
|
713
743
|
#debugRedraw = TUI.#readDebugRedrawFlag();
|
|
714
744
|
// macOS: steady-block cursor anchors CJK IME overlays; disable with SKC_TUI_IME_CURSOR=0.
|
|
@@ -1294,12 +1324,16 @@ export class TUI extends Container {
|
|
|
1294
1324
|
this.terminal.start(
|
|
1295
1325
|
data => this.#handleInput(data),
|
|
1296
1326
|
() => {
|
|
1327
|
+
// A resize can also mean the pane moved or split: passthrough
|
|
1328
|
+
// overlays address the outer screen, so their pane origin is stale.
|
|
1329
|
+
resetTmuxPaneOffsetCache();
|
|
1297
1330
|
this.invalidate();
|
|
1298
1331
|
this.requestResizeRender();
|
|
1299
1332
|
},
|
|
1300
1333
|
);
|
|
1301
1334
|
this.flushTerminalCleanup();
|
|
1302
1335
|
this.#hideCursor();
|
|
1336
|
+
this.#queryKittyPassthroughSupport();
|
|
1303
1337
|
this.#querySixelSupport();
|
|
1304
1338
|
this.#queryCellSize();
|
|
1305
1339
|
this.requestRender(true);
|
|
@@ -1423,17 +1457,128 @@ export class TUI extends Container {
|
|
|
1423
1457
|
this.#sixelProbePendingDa = true;
|
|
1424
1458
|
this.#sixelProbePendingGraphics = true;
|
|
1425
1459
|
this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1460
|
+
// Under tmux both queries travel in ONE passthrough envelope so the outer
|
|
1461
|
+
// terminal answers for itself; tmux would otherwise answer for a client it
|
|
1462
|
+
// knows nothing about. A pane that never had passthrough enabled swallows
|
|
1463
|
+
// the payload silently, hence the request first.
|
|
1464
|
+
if (isUnderTmux()) enableTmuxPanePassthrough();
|
|
1465
|
+
if (!this.#writeTerminal(wrapTmuxPassthrough("\x1b[c\x1b[?2;1;0S"))) return;
|
|
1466
|
+
this.#sixelProbeTimeout = setTimeout(
|
|
1467
|
+
() => {
|
|
1468
|
+
this.#finishSixelProbe(false);
|
|
1469
|
+
},
|
|
1470
|
+
// The passthrough round trip adds tmux's own input latency on top of the
|
|
1471
|
+
// outer terminal's reply time.
|
|
1472
|
+
isUnderTmux() ? 600 : 250,
|
|
1473
|
+
);
|
|
1431
1474
|
}
|
|
1432
1475
|
|
|
1433
1476
|
#isSixelProbeCandidate(): boolean {
|
|
1434
1477
|
return shouldProbeSixelCapability();
|
|
1435
1478
|
}
|
|
1436
1479
|
|
|
1480
|
+
/**
|
|
1481
|
+
* Ask the terminal attached to this tmux client whether it speaks the kitty
|
|
1482
|
+
* graphics protocol, through the passthrough envelope.
|
|
1483
|
+
*
|
|
1484
|
+
* Unlike sixel there is no way for tmux to answer on the terminal's behalf:
|
|
1485
|
+
* tmux does not implement kitty graphics, so a reply can only come from the
|
|
1486
|
+
* outer terminal that received the forwarded query. Success enables the
|
|
1487
|
+
* passthrough OVERLAY channel (absolutely positioned, cursor-neutral art such
|
|
1488
|
+
* as the pet) and deliberately leaves inline image rendering suppressed,
|
|
1489
|
+
* since inline placements would land at tmux's stale physical cursor.
|
|
1490
|
+
*/
|
|
1491
|
+
#queryKittyPassthroughSupport(): void {
|
|
1492
|
+
if (TERMINAL.imageProtocol || getTmuxOverlayImageProtocol()) return;
|
|
1493
|
+
if (!shouldProbeKittyPassthrough()) return;
|
|
1494
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
1495
|
+
|
|
1496
|
+
this.#clearKittyProbeState();
|
|
1497
|
+
this.#kittyProbePending = true;
|
|
1498
|
+
this.#kittyProbeUnsubscribe = this.addInputListener(data => this.#handleKittyProbeInput(data));
|
|
1499
|
+
enableTmuxPanePassthrough();
|
|
1500
|
+
if (!this.#writeTerminal(wrapTmuxPassthrough(KITTY_CAPABILITY_QUERY))) return;
|
|
1501
|
+
this.#kittyProbeTimeout = setTimeout(() => {
|
|
1502
|
+
this.#finishKittyProbe(false);
|
|
1503
|
+
}, 600);
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Consume the kitty graphics reply (`ESC _ G i=<id>;OK ESC \`) so it never
|
|
1508
|
+
* reaches the editor as keystrokes, and pass everything else through.
|
|
1509
|
+
*/
|
|
1510
|
+
#handleKittyProbeInput(data: string): InputListenerResult {
|
|
1511
|
+
if (!this.#kittyProbePending) return undefined;
|
|
1512
|
+
|
|
1513
|
+
this.#kittyProbeBuffer += data;
|
|
1514
|
+
let passthrough = "";
|
|
1515
|
+
let probeOutcome: boolean | null = null;
|
|
1516
|
+
|
|
1517
|
+
while (this.#kittyProbeBuffer.length > 0) {
|
|
1518
|
+
const match = this.#kittyProbeBuffer.match(/\x1b_G([^\x1b]*)\x1b\\/u);
|
|
1519
|
+
if (!match || match.index === undefined) break;
|
|
1520
|
+
passthrough += this.#kittyProbeBuffer.slice(0, match.index);
|
|
1521
|
+
this.#kittyProbeBuffer = this.#kittyProbeBuffer.slice(match.index + match[0].length);
|
|
1522
|
+
const body = match[1] ?? "";
|
|
1523
|
+
const [keys, status] = body.split(";");
|
|
1524
|
+
// Only our own probe id answers the capability question; any other
|
|
1525
|
+
// graphics response is still swallowed rather than typed into the editor.
|
|
1526
|
+
if (!(keys ?? "").split(",").includes(`i=${KITTY_PROBE_IMAGE_ID}`)) continue;
|
|
1527
|
+
probeOutcome = (status ?? "").trim() === "OK";
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
if (probeOutcome === null) {
|
|
1532
|
+
// Hold back anything that could still become our reply; release the rest.
|
|
1533
|
+
const start = this.#kittyProbeBuffer.indexOf("\x1b_G");
|
|
1534
|
+
if (start >= 0) {
|
|
1535
|
+
passthrough += this.#kittyProbeBuffer.slice(0, start);
|
|
1536
|
+
this.#kittyProbeBuffer = this.#kittyProbeBuffer.slice(start);
|
|
1537
|
+
} else {
|
|
1538
|
+
const lastEsc = this.#kittyProbeBuffer.lastIndexOf("\x1b");
|
|
1539
|
+
const tail = lastEsc >= 0 ? this.#kittyProbeBuffer.slice(lastEsc) : "";
|
|
1540
|
+
if (tail && "\x1b_G".startsWith(tail)) {
|
|
1541
|
+
passthrough += this.#kittyProbeBuffer.slice(0, lastEsc);
|
|
1542
|
+
this.#kittyProbeBuffer = tail;
|
|
1543
|
+
} else {
|
|
1544
|
+
passthrough += this.#kittyProbeBuffer;
|
|
1545
|
+
this.#kittyProbeBuffer = "";
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
} else {
|
|
1549
|
+
passthrough += this.#kittyProbeBuffer;
|
|
1550
|
+
this.#kittyProbeBuffer = "";
|
|
1551
|
+
this.#finishKittyProbe(probeOutcome);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
if (passthrough.length === 0) {
|
|
1555
|
+
return { consume: true };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
return { data: passthrough };
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
#finishKittyProbe(supported: boolean): void {
|
|
1562
|
+
this.#clearKittyProbeState();
|
|
1563
|
+
if (!supported || TERMINAL.imageProtocol || getTmuxOverlayImageProtocol()) return;
|
|
1564
|
+
|
|
1565
|
+
setTmuxOverlayImageProtocol(ImageProtocol.Kitty);
|
|
1566
|
+
this.#queryCellSize();
|
|
1567
|
+
this.invalidate();
|
|
1568
|
+
this.requestRender(true);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
#clearKittyProbeState(): void {
|
|
1572
|
+
if (this.#kittyProbeTimeout) {
|
|
1573
|
+
clearTimeout(this.#kittyProbeTimeout);
|
|
1574
|
+
this.#kittyProbeTimeout = undefined;
|
|
1575
|
+
}
|
|
1576
|
+
this.#kittyProbeUnsubscribe?.();
|
|
1577
|
+
this.#kittyProbeUnsubscribe = undefined;
|
|
1578
|
+
this.#kittyProbePending = false;
|
|
1579
|
+
this.#kittyProbeBuffer = "";
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1437
1582
|
#handleSixelProbeInput(data: string): InputListenerResult {
|
|
1438
1583
|
if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
|
|
1439
1584
|
return undefined;
|
|
@@ -1550,18 +1695,22 @@ export class TUI extends Container {
|
|
|
1550
1695
|
this.requestRender(true);
|
|
1551
1696
|
}
|
|
1552
1697
|
#queryCellSize(): void {
|
|
1553
|
-
//
|
|
1554
|
-
if (!TERMINAL.imageProtocol) {
|
|
1698
|
+
// Cell size is only used for image rendering — inline or passthrough overlay.
|
|
1699
|
+
if (!TERMINAL.imageProtocol && !getTmuxOverlayImageProtocol()) {
|
|
1555
1700
|
return;
|
|
1556
1701
|
}
|
|
1557
|
-
// Query terminal for cell size in pixels: CSI 16 t
|
|
1558
|
-
//
|
|
1559
|
-
|
|
1702
|
+
// Query terminal for cell size in pixels: CSI 16 t → CSI 6 ; height ; width t.
|
|
1703
|
+
// Under tmux the query is passthrough-wrapped so the OUTER terminal reports
|
|
1704
|
+
// its real cell size; tmux otherwise answers with a value of its own, which
|
|
1705
|
+
// oversizes the pet sprite and pushes it out of bounds (an out-of-range
|
|
1706
|
+
// position resolves to a null overlay payload, freezing the animation).
|
|
1707
|
+
this.#writeTerminal(wrapTmuxPassthrough("\x1b[16t"));
|
|
1560
1708
|
}
|
|
1561
1709
|
|
|
1562
1710
|
stop(): void {
|
|
1563
1711
|
this.flushTerminalCleanup();
|
|
1564
1712
|
this.#clearSixelProbeState();
|
|
1713
|
+
this.#clearKittyProbeState();
|
|
1565
1714
|
this.#stopped = true;
|
|
1566
1715
|
this.#settleRenderCommitWaiters(false);
|
|
1567
1716
|
if (this.#renderTimer) {
|