@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 +6 -0
- package/package.json +3 -3
- package/src/terminal.ts +167 -24
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.
|
|
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.
|
|
37
|
-
"@oh-my-pi/pi-utils": "13.
|
|
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
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
this.#
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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) {
|