@oh-my-pi/pi-tui 13.10.1 → 13.11.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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.11.0] - 2026-03-12
6
+ ### Fixed
7
+
8
+ - Fixed OSC 11 background color detection to correctly handle partial escape sequences that arrive mid-buffer, preventing user input from being swallowed
9
+ - Fixed race condition where overlapping OSC 11 queries would be incorrectly cancelled by DA1 sentinels from previous queries
10
+
5
11
  ## [13.7.5] - 2026-03-04
6
12
  ### Changed
7
13
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "13.10.1",
4
+ "version": "13.11.1",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -33,8 +33,8 @@
33
33
  "test": "bun test test/*.test.ts"
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "13.10.1",
37
- "@oh-my-pi/pi-utils": "13.10.1",
36
+ "@oh-my-pi/pi-natives": "13.11.1",
37
+ "@oh-my-pi/pi-utils": "13.11.1",
38
38
  "marked": "^17.0"
39
39
  },
40
40
  "devDependencies": {
package/src/terminal.ts CHANGED
@@ -86,13 +86,13 @@ export interface Terminal {
86
86
  setTitle(title: string): void; // Set terminal window title
87
87
 
88
88
  /**
89
- * Register a callback for Mode 2031 dark/light appearance change notifications.
90
- * Supported by Ghostty, Kitty, Contour, VTE (GNOME Terminal), and tmux 3.6+.
91
- * The callback fires when the terminal reports a change; it does NOT fire for the initial query.
89
+ * Register a callback for terminal appearance (dark/light) changes.
90
+ * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
91
+ * Fires when the detected appearance changes, including the initial detection.
92
92
  */
93
93
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
94
94
 
95
- /** The last appearance reported by the terminal, or undefined if not yet known. */
95
+ /** The last detected terminal appearance, or undefined if not yet known. */
96
96
  get appearance(): TerminalAppearance | undefined;
97
97
  }
98
98
 
@@ -113,6 +113,13 @@ export class ProcessTerminal implements Terminal {
113
113
  #windowsVTInputRestore?: () => void;
114
114
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
115
115
  #appearance: TerminalAppearance | undefined;
116
+ #osc11Pending = false;
117
+ #osc11QueryQueued = false;
118
+ #osc11ResponseBuffer = "";
119
+ #pendingDa1Sentinels = 0;
120
+ #osc11PollTimer?: Timer;
121
+ #mode2031Active = false;
122
+ #mode2031DebounceTimer?: Timer;
116
123
 
117
124
  get kittyProtocolActive(): boolean {
118
125
  return this.#kittyProtocolActive;
@@ -164,12 +171,21 @@ export class ProcessTerminal implements Terminal {
164
171
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
165
172
  this.#queryAndEnableKittyProtocol();
166
173
 
167
- // Enable Mode 2031: terminal will send DSR notifications on dark/light appearance changes.
168
- // Query current mode with CSI ? 996 n, then subscribe with CSI ? 2031 h.
169
- // Supported by Ghostty, Kitty, Contour, VTE/GNOME Terminal, tmux 3.6+.
170
- // Unsupported terminals silently ignore these sequences.
171
- this.#safeWrite("\x1b[?996n"); // Query current appearance
172
- this.#safeWrite("\x1b[?2031h"); // Subscribe to appearance changes
174
+ // Query terminal background color via OSC 11 for dark/light detection.
175
+ // Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
176
+ // sequences in order, so if DA1 arrives before OSC 11 response,
177
+ // the terminal does not support OSC 11. This avoids indefinite hangs.
178
+ // Technique used by Neovim, bat, fish, and terminal-colorsaurus.
179
+ this.#queryBackgroundColor();
180
+
181
+ // Subscribe to Mode 2031 appearance change notifications.
182
+ // When the terminal reports a change, we re-query OSC 11 to get the
183
+ // actual background color (following Neovim convention) with 100ms debounce.
184
+ this.#safeWrite("\x1b[?2031h");
185
+
186
+ // Start periodic OSC 11 re-query for terminals without Mode 2031
187
+ // (Warp, Alacritty, WezTerm, iTerm2). Self-disables once Mode 2031 fires.
188
+ this.#startOsc11Poll();
173
189
  }
174
190
 
175
191
  /**
@@ -236,9 +252,16 @@ export class ProcessTerminal implements Terminal {
236
252
  // Kitty protocol response pattern: \x1b[?<flags>u
237
253
  const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
238
254
 
239
- // Mode 2031 DSR response pattern: \x1b[?997;{1=dark,2=light}n
255
+ // Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
240
256
  const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
241
257
 
258
+ // OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
259
+ const osc11ResponsePattern =
260
+ /^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
261
+
262
+ // DA1 (Primary Device Attributes) response: \x1b[?...c
263
+ const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
264
+
242
265
  // Forward individual sequences to the input handler
243
266
  this.#stdinBuffer.on("data", (sequence: string) => {
244
267
  // Check for Kitty protocol response (only if not already enabled)
@@ -261,22 +284,60 @@ export class ProcessTerminal implements Terminal {
261
284
  }
262
285
  }
263
286
 
264
- // Check for Mode 2031 appearance DSR response: CSI ? 997 ; {1,2} n
287
+ // DA1 response: swallow our sentinel reply regardless of whether OSC 11
288
+ // already succeeded. Other terminal probes should never see these replies.
289
+ if (da1ResponsePattern.test(sequence) && this.#pendingDa1Sentinels > 0) {
290
+ this.#pendingDa1Sentinels--;
291
+ if (this.#osc11Pending) {
292
+ // DA1 arrived before OSC 11 response: terminal does not support
293
+ // OSC 11. Clear the pending state without starting a queued query
294
+ // (queued query is started below, after sentinel is consumed).
295
+ this.#osc11Pending = false;
296
+ this.#osc11ResponseBuffer = "";
297
+ }
298
+ // Now that this DA1 cycle is complete, start any queued query.
299
+ if (this.#osc11QueryQueued && !this.#dead) {
300
+ this.#osc11QueryQueued = false;
301
+ this.#startOsc11Query();
302
+ }
303
+ return;
304
+ }
305
+
306
+ // OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
307
+ // Accumulate fragments until the BEL/ST terminator arrives, then parse once.
308
+ // If a new escape sequence arrives (not the ST terminator), abort buffering
309
+ // and forward it as normal input so user keystrokes are never swallowed.
310
+ if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
311
+ if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
312
+ // New escape sequence arrived mid-buffer — not an OSC 11 continuation.
313
+ this.#osc11ResponseBuffer = "";
314
+ // Fall through to normal input handling below.
315
+ } else {
316
+ this.#osc11ResponseBuffer += sequence;
317
+ const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
318
+ if (!osc11Match) return;
319
+ const [, rHex, gHex, bHex] = osc11Match;
320
+ this.#osc11Pending = false;
321
+ this.#osc11ResponseBuffer = "";
322
+ this.#handleOsc11Response(rHex!, gHex!, bHex!);
323
+ return;
324
+ }
325
+ }
326
+
327
+ // Mode 2031 change notification: re-query OSC 11 with 100ms debounce
328
+ // (Neovim convention — coalesces rapid notifications during transitions)
265
329
  const appearanceMatch = sequence.match(appearanceDsrPattern);
266
330
  if (appearanceMatch) {
267
- const mode: TerminalAppearance = appearanceMatch[1] === "1" ? "dark" : "light";
268
- const changed = mode !== this.#appearance;
269
- this.#appearance = mode;
270
- if (changed) {
271
- for (const cb of this.#appearanceCallbacks) {
272
- try {
273
- cb(mode);
274
- } catch {
275
- /* ignore callback errors */
276
- }
277
- }
331
+ if (!this.#mode2031Active) {
332
+ this.#mode2031Active = true;
333
+ this.#stopOsc11Poll();
278
334
  }
279
- return; // Don't forward DSR to TUI
335
+ if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
336
+ this.#mode2031DebounceTimer = setTimeout(() => {
337
+ this.#mode2031DebounceTimer = undefined;
338
+ this.#queryBackgroundColor();
339
+ }, 100);
340
+ return;
280
341
  }
281
342
  if (this.#inputHandler) {
282
343
  this.#inputHandler(sequence);
@@ -296,6 +357,78 @@ export class ProcessTerminal implements Terminal {
296
357
  };
297
358
  }
298
359
 
360
+ /**
361
+ * Send OSC 11 background color query followed by DA1 sentinel.
362
+ * DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
363
+ * the terminal does not support OSC 11.
364
+ */
365
+ #queryBackgroundColor(): void {
366
+ if (this.#dead) return;
367
+ // Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
368
+ // consumed yet. Starting a new query while a DA1 is outstanding would
369
+ // increment the sentinel counter, and the old DA1 arrival would then
370
+ // prematurely clear the new query's pending state.
371
+ if (this.#osc11Pending || this.#pendingDa1Sentinels > 0) {
372
+ this.#osc11QueryQueued = true;
373
+ return;
374
+ }
375
+ this.#startOsc11Query();
376
+ }
377
+
378
+ #startOsc11Query(): void {
379
+ this.#osc11Pending = true;
380
+ this.#osc11ResponseBuffer = "";
381
+ this.#pendingDa1Sentinels++;
382
+ this.#safeWrite("\x1b]11;?\x07"); // OSC 11 query (BEL terminated)
383
+ this.#safeWrite("\x1b[c"); // DA1 sentinel
384
+ }
385
+ /**
386
+ * Parse an OSC 11 background color response and compute BT.601 luminance.
387
+ * Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
388
+ */
389
+ #handleOsc11Response(rHex: string, gHex: string, bHex: string): void {
390
+ const normalize = (hex: string): number => {
391
+ const value = parseInt(hex, 16);
392
+ if (Number.isNaN(value)) return 0;
393
+ const max = 16 ** hex.length - 1;
394
+ return max > 0 ? value / max : 0;
395
+ };
396
+ const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
397
+ const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
398
+ if (mode === this.#appearance) return;
399
+ this.#appearance = mode;
400
+ for (const cb of this.#appearanceCallbacks) {
401
+ try {
402
+ cb(mode);
403
+ } catch {
404
+ /* ignore callback errors */
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Start periodic OSC 11 re-queries for terminals without Mode 2031 (Warp, Alacritty, WezTerm).
411
+ * Self-disables once Mode 2031 fires (push-based is better than polling).
412
+ */
413
+ #startOsc11Poll(): void {
414
+ this.#stopOsc11Poll();
415
+ this.#osc11PollTimer = setInterval(() => {
416
+ if (this.#dead) {
417
+ this.#stopOsc11Poll();
418
+ return;
419
+ }
420
+ this.#queryBackgroundColor();
421
+ }, 2_000);
422
+ this.#osc11PollTimer.unref();
423
+ }
424
+
425
+ #stopOsc11Poll(): void {
426
+ if (this.#osc11PollTimer) {
427
+ clearInterval(this.#osc11PollTimer);
428
+ this.#osc11PollTimer = undefined;
429
+ }
430
+ }
431
+
299
432
  /**
300
433
  * Query terminal for Kitty keyboard protocol support and enable if available.
301
434
  *
@@ -372,7 +505,17 @@ export class ProcessTerminal implements Terminal {
372
505
 
373
506
  // Disable Mode 2031 appearance change notifications
374
507
  this.#safeWrite("\x1b[?2031l");
508
+ this.#stopOsc11Poll();
509
+ if (this.#mode2031DebounceTimer) {
510
+ clearTimeout(this.#mode2031DebounceTimer);
511
+ this.#mode2031DebounceTimer = undefined;
512
+ }
375
513
  this.#appearanceCallbacks = [];
514
+ this.#osc11Pending = false;
515
+ this.#osc11QueryQueued = false;
516
+ this.#osc11ResponseBuffer = "";
517
+ this.#pendingDa1Sentinels = 0;
518
+ this.#mode2031Active = false;
376
519
 
377
520
  // Disable Kitty keyboard protocol if not already done by drainInput()
378
521
  if (this.#kittyProtocolActive) {