@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.
@@ -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;
@@ -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 PI_FORCE_IMAGE_PROTOCOL is set — an explicit
133
+ * - Never probe when SKC_FORCE_IMAGE_PROTOCOL is set — an explicit
134
134
  * configuration (including "off") is authoritative.
135
- * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
136
- * whenever it was compiled with sixel support, regardless of whether the
137
- * attached client terminal can render sixel, so a positive reply is not
138
- * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
139
- * via PI_FORCE_IMAGE_PROTOCOL=sixel.
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.1",
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.1",
42
- "@sayknow-cli/utils": "0.5.1",
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 SIXEL_MULTIPLEXER_DISABLED_ENV_VALUES = new Set(["0", "off", "false", "no"]);
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 === "" || !SIXEL_MULTIPLEXER_DISABLED_ENV_VALUES.has(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 (no DCS passthrough wrapping is
354
- // emitted), so a detected image protocol draws nothing while its
355
- // out-of-band cursor writes corrupt the frame. Graphics are therefore
356
- // unconditionally suppressed under a multiplexer; the runtime sixel probe
357
- // never runs there (tmux advertises DA1 ";4" from compile-time support
358
- // regardless of the attached client), and PI_FORCE_IMAGE_PROTOCOL=sixel
359
- // is the only opt-in for chains that render sixel end-to-end.
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
- // Under tmux, forward the raster to the outer terminal via the DCS
851
- // passthrough envelope (no-op when not under tmux).
852
- return { sequence: wrapTmuxPassthrough(raster), rows: fit.rows };
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 PI_FORCE_IMAGE_PROTOCOL is set — an explicit
334
+ * - Never probe when SKC_FORCE_IMAGE_PROTOCOL is set — an explicit
325
335
  * configuration (including "off") is authoritative.
326
- * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
327
- * whenever it was compiled with sixel support, regardless of whether the
328
- * attached client terminal can render sixel, so a positive reply is not
329
- * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
330
- * via PI_FORCE_IMAGE_PROTOCOL=sixel.
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 false;
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
- if (!this.#writeTerminal("\x1b[c")) return;
1427
- if (!this.#writeTerminal("\x1b[?2;1;0S")) return;
1428
- this.#sixelProbeTimeout = setTimeout(() => {
1429
- this.#finishSixelProbe(false);
1430
- }, 250);
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
- // Only query if terminal supports images (cell size is only used for image rendering)
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
- // Response format: CSI 6 ; height ; width t
1559
- this.#writeTerminal("\x1b[16t");
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) {