@sayknow-cli/tui 0.3.12 → 0.3.15

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.
@@ -53,6 +53,77 @@ 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.SKC_TMUX_LAUNCHED)
78
+ ) {
79
+ return true;
80
+ }
81
+ const term = env.TERM?.trim().toLowerCase() ?? "";
82
+ return term.startsWith("tmux") || term.startsWith("screen");
83
+ }
84
+
85
+ let terminalGraphicsFallbackDepth = 0;
86
+ let cursorNeutralImageAllowedDepth = 0;
87
+
88
+ export interface TerminalGraphicsFallbackOptions {
89
+ /**
90
+ * Permit cursor-neutral image escapes (kitty `a=p,C=1` placements) to render
91
+ * inside this fallback scope. Cursor-advancing protocols (iTerm2/SIXEL)
92
+ * remain suppressed. A nested scope without this option revokes the
93
+ * permission for its own subtree.
94
+ */
95
+ allowCursorNeutralImages?: boolean;
96
+ }
97
+
98
+ /**
99
+ * Synchronously suppress terminal graphics while rendering a text-only surface.
100
+ * Nested scopes remain active until the outermost scope exits.
101
+ */
102
+ export function withTerminalGraphicsFallback<T>(fn: () => T, options?: TerminalGraphicsFallbackOptions): T {
103
+ terminalGraphicsFallbackDepth++;
104
+ const allow = options?.allowCursorNeutralImages === true;
105
+ if (allow) cursorNeutralImageAllowedDepth++;
106
+ try {
107
+ return fn();
108
+ } finally {
109
+ if (allow) cursorNeutralImageAllowedDepth--;
110
+ terminalGraphicsFallbackDepth--;
111
+ }
112
+ }
113
+
114
+ /** Returns whether terminal graphics are currently suppressed by a render scope. */
115
+ export function isTerminalGraphicsFallbackActive(): boolean {
116
+ return terminalGraphicsFallbackDepth > 0;
117
+ }
118
+
119
+ /**
120
+ * Returns whether cursor-neutral image escapes may render despite an active
121
+ * graphics-fallback scope. True only when every active fallback scope opted in.
122
+ */
123
+ export function isCursorNeutralImagePermittedInFallback(): boolean {
124
+ return terminalGraphicsFallbackDepth > 0 && cursorNeutralImageAllowedDepth === terminalGraphicsFallbackDepth;
125
+ }
126
+
56
127
  function getForcedImageProtocol(): ImageProtocol | null | undefined {
57
128
  const raw = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
58
129
  if (!raw) return undefined;
@@ -63,6 +134,15 @@ function getForcedImageProtocol(): ImageProtocol | null | undefined {
63
134
  return null;
64
135
  }
65
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
+
66
146
  function parseMajorMinorVersion(versionRaw?: string): { major: number; minor: number } | null {
67
147
  if (!versionRaw) return null;
68
148
  const match = /^(\d+)\.(\d+)/u.exec(versionRaw.trim());
@@ -95,7 +175,7 @@ function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null
95
175
  if (!process.stdout.isTTY) return null;
96
176
  if (terminalId === "vscode" || terminalId === "alacritty") return null;
97
177
  const term = Bun.env.TERM?.toLowerCase() ?? "";
98
- if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
178
+ if (term.includes("ghostty")) {
99
179
  return ImageProtocol.Kitty;
100
180
  }
101
181
  return null;
@@ -178,10 +258,10 @@ export const TERMINAL = (() => {
178
258
  );
179
259
  }
180
260
  }
261
+ const underMultiplexer = isUnderTerminalMultiplexer();
181
262
  // tmux and screen multiplexers do not reliably forward OSC 8 hyperlinks
182
263
  // to the outer terminal, so force them off regardless of detected terminal.
183
- const term = Bun.env.TERM?.toLowerCase() ?? "";
184
- if (resolved.hyperlinks && (Bun.env.TMUX || term.startsWith("tmux") || term.startsWith("screen"))) {
264
+ if (resolved.hyperlinks && underMultiplexer) {
185
265
  resolved = new TerminalInfo(
186
266
  resolved.id,
187
267
  resolved.imageProtocol,
@@ -190,6 +270,17 @@ export const TERMINAL = (() => {
190
270
  resolved.notifyProtocol,
191
271
  );
192
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
+ }
193
284
  return resolved;
194
285
  })();
195
286
 
@@ -197,11 +288,35 @@ type MutableTerminalInfo = {
197
288
  imageProtocol: ImageProtocol | null;
198
289
  };
199
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
+
200
306
  /**
201
307
  * Override terminal image protocol at runtime after capability probes complete.
202
308
  */
203
309
  export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): void {
204
- (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
+ }
205
320
  }
206
321
 
207
322
  export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {