@gajae-code/tui 0.10.0 → 0.10.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.10.1] - 2026-07-13
6
+ ### Fixed
7
+
8
+ - Terminal graphics protocols are no longer assumed under terminal multiplexers: the blind Kitty fallback for `TERM=tmux-*`/`screen-*` (and detected kitty/iTerm2 protocols leaking through multiplexer env) emitted raw graphics escapes the multiplexer consumed, leaving the Gajae composer pet invisible while its out-of-band cursor writes intermittently corrupted the TUI frame. Image protocols are now unconditionally dropped under tmux/screen/zellij (shared multiplexer predicate with the renderer host policy, including `$TMUX_PANE`, `$STY`, `$ZELLIJ`, and `GJC_TMUX_LAUNCHED`) unless `PI_FORCE_IMAGE_PROTOCOL` explicitly forces a protocol, which remains an expert override.
9
+ - Fixed the startup sixel capability probe's response parsing and authority: XTSMGRAPHICS replies are read per spec (`Ps=0` success; `1/2/3` errors — tmux's `CSI ?2;3;0S` error no longer counts as support), the DA1 device-class parameter is no longer misread as the sixel extension attribute (`CSI ?4;6c` identifies a VT132, not sixel), an explicit `PI_FORCE_IMAGE_PROTOCOL` (including `off`) suppresses probing entirely, and the probe never runs inside a multiplexer because tmux advertises DA1 `;4` from compile-time support regardless of the attached client.
10
+ - A configured Gajae pet now re-applies automatically when the asynchronous sixel probe enables graphics after startup (new `onImageProtocolChanged` subscription), instead of staying hidden until `/pet` is re-run; `/pet` also reports multiplexer graphics suppression explicitly instead of suggesting a different terminal.
11
+
5
12
  ## [0.10.0] - 2026-07-12
6
13
  ### Fixed
7
14
 
@@ -22,6 +22,15 @@ export declare class TerminalInfo {
22
22
  sendNotification(message: string): void;
23
23
  }
24
24
  export declare function isNotificationSuppressed(): boolean;
25
+ /**
26
+ * Returns whether the process runs under a terminal multiplexer (tmux, GNU
27
+ * screen, or zellij). Recognizes the same host markers as the renderer's
28
+ * multiplexer predicate in tui.ts so capability selection and viewport-repaint
29
+ * policy agree on what counts as a multiplexed host. Multiplexers intercept
30
+ * graphics escapes and OSC 8 hyperlinks instead of forwarding them to the
31
+ * outer terminal.
32
+ */
33
+ export declare function isUnderTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
25
34
  export interface TerminalGraphicsFallbackOptions {
26
35
  /**
27
36
  * Permit cursor-neutral image escapes (kitty `a=p,C=1` placements) to render
@@ -43,6 +52,12 @@ export declare function isTerminalGraphicsFallbackActive(): boolean;
43
52
  * graphics-fallback scope. True only when every active fallback scope opted in.
44
53
  */
45
54
  export declare function isCursorNeutralImagePermittedInFallback(): boolean;
55
+ /**
56
+ * Returns whether PI_FORCE_IMAGE_PROTOCOL explicitly configures the image
57
+ * protocol, including an explicit "off". An explicit configuration is
58
+ * authoritative: runtime capability probes must not override it.
59
+ */
60
+ export declare function isImageProtocolForced(): boolean;
46
61
  /**
47
62
  * Returns true when running in Windows Terminal with known SIXEL support.
48
63
  *
@@ -51,6 +66,13 @@ export declare function isCursorNeutralImagePermittedInFallback(): boolean;
51
66
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
52
67
  export declare const TERMINAL_ID: TerminalId;
53
68
  export declare const TERMINAL: TerminalInfo;
69
+ type ImageProtocolChangeListener = (imageProtocol: ImageProtocol | null) => void;
70
+ /**
71
+ * Subscribe to runtime image-protocol changes (e.g. the asynchronous sixel
72
+ * capability probe enabling graphics after startup). Returns an unsubscribe
73
+ * function. Listeners fire only on actual changes.
74
+ */
75
+ export declare function onImageProtocolChanged(listener: ImageProtocolChangeListener): () => void;
54
76
  /**
55
77
  * Override terminal image protocol at runtime after capability probes complete.
56
78
  */
@@ -162,3 +184,4 @@ export interface RenderedImage {
162
184
  }
163
185
  export declare function renderImage(base64Data: string, imageDimensions: ImageDimensions, options?: ImageRenderOptions): RenderedImage | null;
164
186
  export declare function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string;
187
+ export {};
@@ -98,6 +98,18 @@ export interface OverlayMargin {
98
98
  }
99
99
  /** Value that can be absolute (number) or percentage (string like "50%") */
100
100
  export type SizeValue = number | `${number}%`;
101
+ /**
102
+ * Startup sixel capability probe policy (pure; exported for tests):
103
+ * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
104
+ * configuration (including "off") is authoritative.
105
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
106
+ * whenever it was compiled with sixel support, regardless of whether the
107
+ * attached client terminal can render sixel, so a positive reply is not
108
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
109
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
110
+ * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
111
+ */
112
+ export declare function shouldProbeSixelCapability(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
101
113
  /**
102
114
  * True when repainting only the live viewport is safer than clearing/replaying
103
115
  * the full transcript. Native Windows console hosts are included even when
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.10.0",
4
+ "version": "0.10.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -35,8 +35,8 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@gajae-code/natives": "0.10.0",
39
- "@gajae-code/utils": "0.10.0",
38
+ "@gajae-code/natives": "0.10.1",
39
+ "@gajae-code/utils": "0.10.1",
40
40
  "lru-cache": "11.3.6",
41
41
  "marked": "^18.0.3"
42
42
  },
@@ -53,6 +53,35 @@ export function isNotificationSuppressed(): boolean {
53
53
  return value === "off" || value === "0" || value === "false";
54
54
  }
55
55
 
56
+ const MULTIPLEXER_DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
57
+
58
+ function multiplexerEnvEnabled(value: string | undefined): boolean {
59
+ const normalized = value?.trim().toLowerCase();
60
+ return normalized !== undefined && normalized.length > 0 && !MULTIPLEXER_DISABLED_ENV_VALUES.has(normalized);
61
+ }
62
+
63
+ /**
64
+ * Returns whether the process runs under a terminal multiplexer (tmux, GNU
65
+ * screen, or zellij). Recognizes the same host markers as the renderer's
66
+ * multiplexer predicate in tui.ts so capability selection and viewport-repaint
67
+ * policy agree on what counts as a multiplexed host. Multiplexers intercept
68
+ * graphics escapes and OSC 8 hyperlinks instead of forwarding them to the
69
+ * outer terminal.
70
+ */
71
+ export function isUnderTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
72
+ if (
73
+ multiplexerEnvEnabled(env.TMUX) ||
74
+ multiplexerEnvEnabled(env.TMUX_PANE) ||
75
+ multiplexerEnvEnabled(env.STY) ||
76
+ multiplexerEnvEnabled(env.ZELLIJ) ||
77
+ multiplexerEnvEnabled(env.GJC_TMUX_LAUNCHED)
78
+ ) {
79
+ return true;
80
+ }
81
+ const term = env.TERM?.trim().toLowerCase() ?? "";
82
+ return term.startsWith("tmux") || term.startsWith("screen");
83
+ }
84
+
56
85
  let terminalGraphicsFallbackDepth = 0;
57
86
  let cursorNeutralImageAllowedDepth = 0;
58
87
 
@@ -105,6 +134,15 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined {
105
134
  return null;
106
135
  }
107
136
 
137
+ /**
138
+ * Returns whether PI_FORCE_IMAGE_PROTOCOL explicitly configures the image
139
+ * protocol, including an explicit "off". An explicit configuration is
140
+ * authoritative: runtime capability probes must not override it.
141
+ */
142
+ export function isImageProtocolForced(): boolean {
143
+ return getForcedImageProtocol() !== undefined;
144
+ }
145
+
108
146
  function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
109
147
  if (!versionRaw) return null;
110
148
  const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
@@ -137,7 +175,7 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
137
175
  if (!process.stdout.isTTY) return null;
138
176
  if (terminalId === "vscode" || terminalId === "alacritty") return null;
139
177
  const term = Bun.env.TERM?.toLowerCase() ?? "";
140
- if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
178
+ if (term.includes("ghostty")) {
141
179
  return ImageProtocol.Kitty;
142
180
  }
143
181
  return null;
@@ -220,10 +258,10 @@ export const TERMINAL = (() => {
220
258
  );
221
259
  }
222
260
  }
261
+ const underMultiplexer = isUnderTerminalMultiplexer();
223
262
  // tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
224
263
  // to the outer terminal, so force them off regardless of detected terminal.
225
- const term = Bun.env.TERM?.toLowerCase() ?? "";
226
- if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
264
+ if (resolved.hyperlinks && underMultiplexer) {
227
265
  resolved = new TerminalInfo(
228
266
  resolved.id,
229
267
  resolved.imageProtocol,
@@ -232,6 +270,17 @@ export const TERMINAL = (() => {
232
270
  resolved.notifyProtocol,
233
271
  );
234
272
  }
273
+ // Multiplexers (tmux/screen/zellij) consume raw kitty/iTerm2 graphics
274
+ // escapes instead of forwarding them (no DCS passthrough wrapping is
275
+ // emitted), so a detected image protocol draws nothing while its
276
+ // out-of-band cursor writes corrupt the frame. Graphics are therefore
277
+ // unconditionally suppressed under a multiplexer; the runtime sixel probe
278
+ // never runs there (tmux advertises DA1 ";4" from compile-time support
279
+ // regardless of the attached client), and PI_FORCE_IMAGE_PROTOCOL=sixel
280
+ // is the only opt-in for chains that render sixel end-to-end.
281
+ if (resolved.imageProtocol && forcedImageProtocol === undefined && underMultiplexer) {
282
+ resolved = new TerminalInfo(resolved.id, null, resolved.trueColor, resolved.hyperlinks, resolved.notifyProtocol);
283
+ }
235
284
  return resolved;
236
285
  })();
237
286
 
@@ -239,11 +288,35 @@ type MutableTerminalInfo = {
239
288
  imageProtocol: ImageProtocol | null;
240
289
  };
241
290
 
291
+ type ImageProtocolChangeListener = (imageProtocol: ImageProtocol | null) => void;
292
+ const imageProtocolChangeListeners = new Set<ImageProtocolChangeListener>();
293
+
294
+ /**
295
+ * Subscribe to runtime image-protocol changes (e.g. the asynchronous sixel
296
+ * capability probe enabling graphics after startup). Returns an unsubscribe
297
+ * function. Listeners fire only on actual changes.
298
+ */
299
+ export function onImageProtocolChanged(listener: ImageProtocolChangeListener): () => void {
300
+ imageProtocolChangeListeners.add(listener);
301
+ return () => {
302
+ imageProtocolChangeListeners.delete(listener);
303
+ };
304
+ }
305
+
242
306
  /**
243
307
  * Override terminal image protocol at runtime after capability probes complete.
244
308
  */
245
309
  export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void {
246
- (TERMINAL as unknown as MutableTerminalInfo).imageProtocol = imageProtocol;
310
+ const mutable = TERMINAL as unknown as MutableTerminalInfo;
311
+ if (mutable.imageProtocol === imageProtocol) return;
312
+ mutable.imageProtocol = imageProtocol;
313
+ for (const listener of imageProtocolChangeListeners) {
314
+ try {
315
+ listener(imageProtocol);
316
+ } catch {
317
+ // Listener failures must not break protocol switching.
318
+ }
319
+ }
247
320
  }
248
321
 
249
322
  export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
package/src/tui.ts CHANGED
@@ -9,7 +9,14 @@ import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
11
11
  import type { Terminal } from "./terminal";
12
- import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
12
+ import {
13
+ ImageProtocol,
14
+ isImageProtocolForced,
15
+ isUnderTerminalMultiplexer,
16
+ setCellDimensions,
17
+ setTerminalImageProtocol,
18
+ TERMINAL,
19
+ } from "./terminal-capabilities";
13
20
  import {
14
21
  Ellipsis,
15
22
  extractSegments,
@@ -217,7 +224,6 @@ function isTermuxSession(env: Record<string, string | undefined> = Bun.env): boo
217
224
  return Boolean(env.TERMUX_VERSION);
218
225
  }
219
226
 
220
- const GJC_TMUX_LAUNCHED_ENV = "GJC_TMUX_LAUNCHED";
221
227
  const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "no"]);
222
228
  const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on", "y"]);
223
229
 
@@ -231,25 +237,38 @@ function envFlagEnabled(value: string | undefined): boolean {
231
237
  return normalized !== undefined && TRUTHY_ENV_VALUES.has(normalized);
232
238
  }
233
239
 
234
- function termLooksMultiplexed(value: string | undefined): boolean {
235
- const term = value?.trim().toLowerCase() ?? "";
236
- return term.startsWith("tmux") || term.startsWith("screen");
237
- }
238
-
239
240
  function isWindowsTerminalSession(env: Record<string, string | undefined> = Bun.env): boolean {
240
241
  return envIsEnabled(env.WT_SESSION) || env.TERM_PROGRAM === "Windows_Terminal";
241
242
  }
242
243
 
243
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
244
+ /**
245
+ * Detect terminal multiplexers where scrollback clearing and height-change
246
+ * redraws are hostile. Delegates to the shared capability predicate so the
247
+ * renderer and graphics-protocol selection agree on what counts as a
248
+ * multiplexed host.
249
+ */
244
250
  function isMultiplexerSession(env: Record<string, string | undefined> = Bun.env): boolean {
245
- return Boolean(
246
- envIsEnabled(env.TMUX) ||
247
- envIsEnabled(env.TMUX_PANE) ||
248
- envIsEnabled(env.STY) ||
249
- envIsEnabled(env.ZELLIJ) ||
250
- envIsEnabled(env[GJC_TMUX_LAUNCHED_ENV]) ||
251
- termLooksMultiplexed(env.TERM),
252
- );
251
+ return isUnderTerminalMultiplexer(env as NodeJS.ProcessEnv);
252
+ }
253
+
254
+ /**
255
+ * Startup sixel capability probe policy (pure; exported for tests):
256
+ * - Never probe when PI_FORCE_IMAGE_PROTOCOL is set — an explicit
257
+ * configuration (including "off") is authoritative.
258
+ * - Never probe inside a terminal multiplexer: tmux advertises DA1 ";4"
259
+ * whenever it was compiled with sixel support, regardless of whether the
260
+ * attached client terminal can render sixel, so a positive reply is not
261
+ * end-to-end evidence. Graphics under a multiplexer are strictly opt-in
262
+ * via PI_FORCE_IMAGE_PROTOCOL=sixel.
263
+ * - Probe Windows Terminal (>=1.22 renders sixel but exposes no env marker).
264
+ */
265
+ export function shouldProbeSixelCapability(
266
+ env: NodeJS.ProcessEnv = Bun.env,
267
+ platform: NodeJS.Platform = process.platform,
268
+ ): boolean {
269
+ if (isImageProtocolForced()) return false;
270
+ if (isUnderTerminalMultiplexer(env)) return false;
271
+ return platform === "win32" && Boolean(env.WT_SESSION?.trim());
253
272
  }
254
273
 
255
274
  function useLegacyMultiplexerFullRender(env: Record<string, string | undefined> = Bun.env): boolean {
@@ -1008,8 +1027,7 @@ export class TUI extends Container {
1008
1027
 
1009
1028
  #querySixelSupport(): void {
1010
1029
  if (TERMINAL.imageProtocol) return;
1011
- if (process.platform !== "win32") return;
1012
- if (!Bun.env.WT_SESSION) return;
1030
+ if (!this.#isSixelProbeCandidate()) return;
1013
1031
  if (!process.stdin.isTTY || !process.stdout.isTTY) return;
1014
1032
 
1015
1033
  this.#clearSixelProbeState();
@@ -1023,6 +1041,10 @@ export class TUI extends Container {
1023
1041
  }, 250);
1024
1042
  }
1025
1043
 
1044
+ #isSixelProbeCandidate(): boolean {
1045
+ return shouldProbeSixelCapability();
1046
+ }
1047
+
1026
1048
  #handleSixelProbeInput(data: string): InputListenerResult {
1027
1049
  if (!this.#sixelProbePendingDa && !this.#sixelProbePendingGraphics) {
1028
1050
  return undefined;
@@ -1049,11 +1071,15 @@ export class TUI extends Container {
1049
1071
 
1050
1072
  if (useDa && this.#sixelProbePendingDa) {
1051
1073
  this.#sixelProbePendingDa = false;
1052
- const attributes = (match[1] ?? "")
1074
+ const params = (match[1] ?? "")
1053
1075
  .split(";")
1054
1076
  .map(value => Number.parseInt(value, 10))
1055
1077
  .filter(value => Number.isFinite(value));
1056
- const hasSixelAttribute = attributes.includes(4);
1078
+ // The first DA1 parameter is the device/operating class (e.g. 1,
1079
+ // 62, 64), not an extension attribute: `CSI ?4;6c` identifies a
1080
+ // VT132, it does not advertise sixel. Only the parameters after
1081
+ // the class carry attributes like 4 (sixel graphics).
1082
+ const hasSixelAttribute = params.slice(1).includes(4);
1057
1083
  if (hasSixelAttribute) {
1058
1084
  this.#sixelProbePendingGraphics = false;
1059
1085
  probeOutcome = true;
@@ -1062,8 +1088,11 @@ export class TUI extends Container {
1062
1088
  }
1063
1089
  } else if (!useDa && this.#sixelProbePendingGraphics) {
1064
1090
  this.#sixelProbePendingGraphics = false;
1091
+ // XTSMGRAPHICS reply is `CSI ? 2 ; Ps ; ... S` where Ps=0 means
1092
+ // success and 1/2/3 are errors (tmux answers our unsupported
1093
+ // read with `CSI ?2;3;0S`). Only a success reply proves sixel.
1065
1094
  const status = Number.parseInt(match[1] ?? "", 10);
1066
- const supportsSixel = !Number.isNaN(status) && status !== 0;
1095
+ const supportsSixel = status === 0;
1067
1096
  if (supportsSixel) {
1068
1097
  this.#sixelProbePendingDa = false;
1069
1098
  probeOutcome = true;