@sayknow-cli/tui 0.4.4 → 0.4.6

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.
@@ -124,5 +124,10 @@ export declare function buildSayknowPixelFrames(options: {
124
124
  kittyImageId?: number;
125
125
  /** Color skin for the sprite palette (default "red"). */
126
126
  skin?: PetSkinId;
127
+ /**
128
+ * Optional wrapper applied to each encoded sixel frame (e.g. tmux DCS
129
+ * passthrough). Identity when omitted. Never applied to kitty frames.
130
+ */
131
+ wrapSixel?: (frame: string) => string;
127
132
  }): SayknowPixelFrames;
128
133
  export {};
@@ -58,6 +58,26 @@ export declare function isCursorNeutralImagePermittedInFallback(): boolean;
58
58
  * authoritative: runtime capability probes must not override it.
59
59
  */
60
60
  export declare function isImageProtocolForced(): boolean;
61
+ /**
62
+ * Returns whether the sixel-through-multiplexer path (passthrough-wrapped probe
63
+ * + DCS passthrough render) is enabled. On by default; set SKC_SIXEL_MULTIPLEXER=0
64
+ * to force the pre-0.4.5 behavior (graphics unconditionally off under tmux).
65
+ */
66
+ export declare function isSixelMultiplexerEnabled(env?: NodeJS.ProcessEnv): boolean;
67
+ /**
68
+ * Returns whether the process runs under tmux specifically. Only tmux implements
69
+ * the DCS passthrough envelope (`\ePtmux;…\e\\`) that forwards graphics escapes to
70
+ * the outer terminal, so sixel graphics stay suppressed under screen/zellij.
71
+ */
72
+ export declare function isUnderTmux(env?: NodeJS.ProcessEnv): boolean;
73
+ /**
74
+ * Wrap raw terminal escape bytes in tmux's DCS passthrough envelope so tmux
75
+ * forwards them verbatim to the outer terminal (requires `allow-passthrough on`,
76
+ * which SKC-launched tmux sessions set automatically). Each ESC in the payload is
77
+ * doubled per the tmux protocol. Returns the payload unchanged when not under
78
+ * tmux, so non-multiplexed, screen, and zellij paths are untouched.
79
+ */
80
+ export declare function wrapTmuxPassthrough(payload: string, env?: NodeJS.ProcessEnv): string;
61
81
  /**
62
82
  * Returns true when running in Windows Terminal with known SIXEL support.
63
83
  *
@@ -125,11 +125,10 @@ export type SizeValue = number | `${number}%`;
125
125
  * Startup sixel capability probe policy (pure; exported for tests):
126
126
  * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
127
127
  * configuration (including "off") is authoritative.
128
- * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
129
- * whenever it was compiled with sixel support, regardless of whether the
130
- * attached client terminal can render sixel, so a positive reply is not
131
- * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
132
- * via PI_FORCE_IMAGE_PROTOCOL=sixel.
128
+ * - Under tmux, probe when the sixel-multiplexer path is enabled (default): the
129
+ * query is DCS-passthrough wrapped so the OUTER terminal answers, not tmux's
130
+ * unreliable compile-time DA1, making a positive reply genuine end-to-end
131
+ * evidence. screen/zellij have no passthrough envelope, so graphics stay off.
133
132
  * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
134
133
  */
135
134
  export declare function shouldProbeSixelCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.4.4",
4
+ "version": "0.4.6",
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.4.4",
42
- "@sayknow-cli/utils": "0.4.4",
41
+ "@sayknow-cli/natives": "0.4.6",
42
+ "@sayknow-cli/utils": "0.4.6",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -392,6 +392,11 @@ export function buildSayknowPixelFrames(options: {
392
392
  kittyImageId?: number;
393
393
  /** Color skin for the sprite palette (default "red"). */
394
394
  skin?: PetSkinId;
395
+ /**
396
+ * Optional wrapper applied to each encoded sixel frame (e.g. tmux DCS
397
+ * passthrough). Identity when omitted. Never applied to kitty frames.
398
+ */
399
+ wrapSixel?: (frame: string) => string;
395
400
  }): SayknowPixelFrames {
396
401
  const targetRows = options.targetRows ?? 2;
397
402
  const gridSize = 16;
@@ -416,7 +421,7 @@ export function buildSayknowPixelFrames(options: {
416
421
  for (const name of Object.keys(PIXEL_GRIDS) as SayknowPixelFrameName[]) {
417
422
  frames[name] =
418
423
  options.protocol === "sixel"
419
- ? encodeGridSixel(PIXEL_GRIDS[name], scale, topPaddingPx, palette)
424
+ ? (options.wrapSixel ?? (frame => frame))(encodeGridSixel(PIXEL_GRIDS[name], scale, topPaddingPx, palette))
420
425
  : encodeGridKitty(
421
426
  PIXEL_GRIDS[name],
422
427
  scale,
@@ -143,6 +143,45 @@ 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"]);
147
+
148
+ /**
149
+ * Returns whether the sixel-through-multiplexer path (passthrough-wrapped probe
150
+ * + DCS passthrough render) is enabled. On by default; set SKC_SIXEL_MULTIPLEXER=0
151
+ * to force the pre-0.4.5 behavior (graphics unconditionally off under tmux).
152
+ */
153
+ export function isSixelMultiplexerEnabled(env: NodeJS.ProcessEnv = Bun.env): boolean {
154
+ const raw = env.SKC_SIXEL_MULTIPLEXER?.trim().toLowerCase();
155
+ return raw === undefined || raw === "" || !SIXEL_MULTIPLEXER_DISABLED_ENV_VALUES.has(raw);
156
+ }
157
+
158
+ /**
159
+ * Returns whether the process runs under tmux specifically. Only tmux implements
160
+ * the DCS passthrough envelope (`\ePtmux;…\e\\`) that forwards graphics escapes to
161
+ * the outer terminal, so sixel graphics stay suppressed under screen/zellij.
162
+ */
163
+ export function isUnderTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
164
+ if (
165
+ multiplexerEnvEnabled(env.TMUX) ||
166
+ multiplexerEnvEnabled(env.TMUX_PANE) ||
167
+ multiplexerEnvEnabled(env.SKC_TMUX_LAUNCHED)
168
+ )
169
+ return true;
170
+ return (env.TERM?.trim().toLowerCase() ?? "").startsWith("tmux");
171
+ }
172
+
173
+ /**
174
+ * Wrap raw terminal escape bytes in tmux's DCS passthrough envelope so tmux
175
+ * forwards them verbatim to the outer terminal (requires `allow-passthrough on`,
176
+ * which SKC-launched tmux sessions set automatically). Each ESC in the payload is
177
+ * doubled per the tmux protocol. Returns the payload unchanged when not under
178
+ * tmux, so non-multiplexed, screen, and zellij paths are untouched.
179
+ */
180
+ export function wrapTmuxPassthrough(payload: string, env: NodeJS.ProcessEnv = Bun.env): string {
181
+ if (!payload || !isUnderTmux(env)) return payload;
182
+ return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
183
+ }
184
+
146
185
  function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
147
186
  if (!versionRaw) return null;
148
187
  const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
@@ -767,8 +806,10 @@ export function renderImage(
767
806
  const targetWidthPx = Math.max(1, fit.columns * cellDims.widthPx);
768
807
  const targetHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
769
808
  const decoded = new Uint8Array(Buffer.from(base64Data, "base64"));
770
- const sequence = encodeSixel(decoded, targetWidthPx, targetHeightPx);
771
- return { sequence, rows: fit.rows };
809
+ const raster = encodeSixel(decoded, targetWidthPx, targetHeightPx);
810
+ // Under tmux, forward the raster to the outer terminal via the DCS
811
+ // passthrough envelope (no-op when not under tmux).
812
+ return { sequence: wrapTmuxPassthrough(raster), rows: fit.rows };
772
813
  } catch {
773
814
  return null;
774
815
  }
package/src/tui.ts CHANGED
@@ -12,10 +12,13 @@ import type { Terminal } from "./terminal";
12
12
  import {
13
13
  ImageProtocol,
14
14
  isImageProtocolForced,
15
+ isSixelMultiplexerEnabled,
15
16
  isUnderTerminalMultiplexer,
17
+ isUnderTmux,
16
18
  setCellDimensions,
17
19
  setTerminalImageProtocol,
18
20
  TERMINAL,
21
+ wrapTmuxPassthrough,
19
22
  } from "./terminal-capabilities";
20
23
  import {
21
24
  Ellipsis,
@@ -296,11 +299,10 @@ function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env)
296
299
  * Startup sixel capability probe policy (pure; exported for tests):
297
300
  * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
298
301
  * configuration (including "off") is authoritative.
299
- * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
300
- * whenever it was compiled with sixel support, regardless of whether the
301
- * attached client terminal can render sixel, so a positive reply is not
302
- * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
303
- * via PI_FORCE_IMAGE_PROTOCOL=sixel.
302
+ * - Under tmux, probe when the sixel-multiplexer path is enabled (default): the
303
+ * query is DCS-passthrough wrapped so the OUTER terminal answers, not tmux's
304
+ * unreliable compile-time DA1, making a positive reply genuine end-to-end
305
+ * evidence. screen/zellij have no passthrough envelope, so graphics stay off.
304
306
  * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
305
307
  */
306
308
  export function shouldProbeSixelCapability(
@@ -308,7 +310,7 @@ export function shouldProbeSixelCapability(
308
310
  platform: NodeJS.Platform = process.platform,
309
311
  ): boolean {
310
312
  if (isImageProtocolForced()) return false;
311
- if (isUnderTerminalMultiplexer(env)) return false;
313
+ if (isUnderTerminalMultiplexer(env)) return isUnderTmux(env) && isSixelMultiplexerEnabled(env);
312
314
  return platform === "win32" && Boolean(env.WT_SESSION?.trim());
313
315
  }
314
316
 
@@ -1145,11 +1147,16 @@ export class TUI extends Container {
1145
1147
  this.#sixelProbePendingDa = true;
1146
1148
  this.#sixelProbePendingGraphics = true;
1147
1149
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
1148
- if (!this.#writeTerminal("\x1b[c")) return;
1149
- if (!this.#writeTerminal("\x1b[?2;1;0S")) return;
1150
- this.#sixelProbeTimeout = setTimeout(() => {
1151
- this.#finishSixelProbe(false);
1152
- }, 250);
1150
+ // Under tmux, wrap the DA1 + XTSMGRAPHICS queries in the DCS passthrough
1151
+ // envelope so the outer terminal answers instead of tmux itself. Round-trip
1152
+ // through the outer terminal is slower, so allow a longer settle window.
1153
+ if (!this.#writeTerminal(wrapTmuxPassthrough("\x1b[c\x1b[?2;1;0S"))) return;
1154
+ this.#sixelProbeTimeout = setTimeout(
1155
+ () => {
1156
+ this.#finishSixelProbe(false);
1157
+ },
1158
+ isUnderTmux() ? 600 : 250,
1159
+ );
1153
1160
  }
1154
1161
 
1155
1162
  #isSixelProbeCandidate(): boolean {
@@ -1276,9 +1283,12 @@ export class TUI extends Container {
1276
1283
  if (!TERMINAL.imageProtocol) {
1277
1284
  return;
1278
1285
  }
1279
- // Query terminal for cell size in pixels: CSI 16 t
1280
- // Response format: CSI 6 ; height ; width t
1281
- this.#writeTerminal("\x1b[16t");
1286
+ // Query terminal for cell size in pixels: CSI 16 t → CSI 6 ; height ; width t.
1287
+ // Under tmux, wrap it in the DCS passthrough envelope so the OUTER terminal
1288
+ // reports its real cell size; tmux otherwise answers with a wrong value,
1289
+ // which oversizes the sixel pet and pushes it out of bounds (freezing its
1290
+ // animation because the overflowing position resolves to null).
1291
+ this.#writeTerminal(wrapTmuxPassthrough("\x1b[16t"));
1282
1292
  }
1283
1293
 
1284
1294
  stop(): void {