@gajae-code/tui 0.2.0 → 0.2.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 +7 -1
- package/dist/types/terminal.d.ts +2 -0
- package/dist/types/tui.d.ts +1 -0
- package/package.json +3 -3
- package/src/terminal.ts +63 -4
- package/src/tui.ts +75 -20
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.1] - 2026-05-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Made TUI terminal writes tolerate `EIO`/closed PTY failures by marking output unavailable, swallowing render/cursor cleanup write errors, and suppressing later render writes after detach.
|
|
10
|
+
|
|
5
11
|
## [0.2.0] - 2026-05-28
|
|
6
12
|
|
|
7
13
|
### Changed
|
|
@@ -833,4 +839,4 @@ Initial release under @gajae-code scope. See previous releases at [badlogic/pi-m
|
|
|
833
839
|
|
|
834
840
|
### Fixed
|
|
835
841
|
|
|
836
|
-
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
|
842
|
+
- **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -16,6 +16,7 @@ export interface Terminal {
|
|
|
16
16
|
*/
|
|
17
17
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
18
18
|
write(data: string): void;
|
|
19
|
+
get available(): boolean;
|
|
19
20
|
get columns(): number;
|
|
20
21
|
get rows(): number;
|
|
21
22
|
get kittyProtocolActive(): boolean;
|
|
@@ -48,6 +49,7 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
48
49
|
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
|
|
49
50
|
stop(): void;
|
|
50
51
|
write(data: string): void;
|
|
52
|
+
get available(): boolean;
|
|
51
53
|
get columns(): number;
|
|
52
54
|
get rows(): number;
|
|
53
55
|
moveBy(lines: number): void;
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -154,6 +154,7 @@ export declare class TUI extends Container {
|
|
|
154
154
|
hasOverlay(): boolean;
|
|
155
155
|
invalidate(): void;
|
|
156
156
|
start(): void;
|
|
157
|
+
get terminalAvailable(): boolean;
|
|
157
158
|
addInputListener(listener: InputListener): () => void;
|
|
158
159
|
removeInputListener(listener: InputListener): void;
|
|
159
160
|
stop(): void;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@gajae-code/natives": "0.2.
|
|
41
|
-
"@gajae-code/utils": "0.2.
|
|
40
|
+
"@gajae-code/natives": "0.2.1",
|
|
41
|
+
"@gajae-code/utils": "0.2.1",
|
|
42
42
|
"lru-cache": "11.3.6",
|
|
43
43
|
"marked": "^18.0.3"
|
|
44
44
|
},
|
package/src/terminal.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
-
import { $env
|
|
3
|
+
import { $env } from "@gajae-code/utils";
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
6
|
|
|
@@ -67,6 +67,9 @@ export interface Terminal {
|
|
|
67
67
|
// Write output to terminal
|
|
68
68
|
write(data: string): void;
|
|
69
69
|
|
|
70
|
+
// Whether terminal output is still writable
|
|
71
|
+
get available(): boolean;
|
|
72
|
+
|
|
70
73
|
// Get terminal dimensions
|
|
71
74
|
get columns(): number;
|
|
72
75
|
get rows(): number;
|
|
@@ -121,7 +124,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
121
124
|
#stdinDataHandler?: (data: string) => void;
|
|
122
125
|
#dead = false;
|
|
123
126
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
127
|
+
#detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
|
|
124
128
|
#windowsVTInputRestore?: () => void;
|
|
129
|
+
#stdoutErrorHandler?: (err: Error) => void;
|
|
125
130
|
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
|
|
126
131
|
#appearance: TerminalAppearance | undefined;
|
|
127
132
|
#osc11Pending = false;
|
|
@@ -166,6 +171,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
166
171
|
|
|
167
172
|
// Set up resize handler immediately
|
|
168
173
|
process.stdout.on("resize", this.#resizeHandler);
|
|
174
|
+
this.#stdoutErrorHandler = (err: Error) => {
|
|
175
|
+
this.#markUnavailable(err, "stdout-error");
|
|
176
|
+
};
|
|
177
|
+
process.stdout.on("error", this.#stdoutErrorHandler);
|
|
169
178
|
|
|
170
179
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
171
180
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
@@ -611,6 +620,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
611
620
|
process.stdout.removeListener("resize", this.#resizeHandler);
|
|
612
621
|
this.#resizeHandler = undefined;
|
|
613
622
|
}
|
|
623
|
+
if (this.#stdoutErrorHandler) {
|
|
624
|
+
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
625
|
+
this.#stdoutErrorHandler = undefined;
|
|
626
|
+
}
|
|
614
627
|
|
|
615
628
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
616
629
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -639,13 +652,59 @@ export class ProcessTerminal implements Terminal {
|
|
|
639
652
|
// Skip control sequences when stdout isn't a TTY (piped output, tests, log
|
|
640
653
|
// files). They serve no purpose there and would surface as visible noise.
|
|
641
654
|
if (!process.stdout.isTTY) return;
|
|
655
|
+
if (
|
|
656
|
+
!process.stdout.writable ||
|
|
657
|
+
process.stdout.destroyed ||
|
|
658
|
+
process.stdout.closed ||
|
|
659
|
+
process.stdout.writableEnded
|
|
660
|
+
) {
|
|
661
|
+
this.#markUnavailable(undefined, "stdout-closed");
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
642
664
|
try {
|
|
643
665
|
process.stdout.write(data);
|
|
644
666
|
} catch (err) {
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
667
|
+
this.#markUnavailable(err, "write");
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#markUnavailable(err: unknown, operation: string): void {
|
|
672
|
+
if (this.#dead) return;
|
|
673
|
+
this.#dead = true;
|
|
674
|
+
this.#clearProgressTimer();
|
|
675
|
+
this.#stopOsc11Poll();
|
|
676
|
+
if (this.#mode2031DebounceTimer) {
|
|
677
|
+
clearTimeout(this.#mode2031DebounceTimer);
|
|
678
|
+
this.#mode2031DebounceTimer = undefined;
|
|
648
679
|
}
|
|
680
|
+
if (this.#modifyOtherKeysTimeout) {
|
|
681
|
+
clearTimeout(this.#modifyOtherKeysTimeout);
|
|
682
|
+
this.#modifyOtherKeysTimeout = undefined;
|
|
683
|
+
}
|
|
684
|
+
this.#appendDetachDebugEvent(operation, err);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
#appendDetachDebugEvent(operation: string, err: unknown): void {
|
|
688
|
+
if (!this.#detachLogPath) return;
|
|
689
|
+
const error = err instanceof Error ? err : undefined;
|
|
690
|
+
const code =
|
|
691
|
+
typeof (err as { code?: unknown } | undefined)?.code === "string" ? (err as { code: string }).code : undefined;
|
|
692
|
+
const line = JSON.stringify({
|
|
693
|
+
at: new Date().toISOString(),
|
|
694
|
+
operation,
|
|
695
|
+
code,
|
|
696
|
+
name: error?.name,
|
|
697
|
+
message: error?.message,
|
|
698
|
+
});
|
|
699
|
+
try {
|
|
700
|
+
fs.appendFileSync(this.#detachLogPath, `${line}\n`, { encoding: "utf8" });
|
|
701
|
+
} catch {
|
|
702
|
+
// Ignore debug logging errors; the terminal is already unavailable.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
get available(): boolean {
|
|
707
|
+
return !this.#dead;
|
|
649
708
|
}
|
|
650
709
|
|
|
651
710
|
get columns(): number {
|
package/src/tui.ts
CHANGED
|
@@ -256,6 +256,7 @@ export class TUI extends Container {
|
|
|
256
256
|
#maxLinesRendered = 0; // Line count from last render, used for viewport calculation
|
|
257
257
|
#fullRedrawCount = 0;
|
|
258
258
|
#stopped = false;
|
|
259
|
+
#terminalUnavailable = false;
|
|
259
260
|
|
|
260
261
|
// Overlay stack for modal components rendered on top of base content
|
|
261
262
|
overlayStack: {
|
|
@@ -285,7 +286,7 @@ export class TUI extends Container {
|
|
|
285
286
|
if (this.#showHardwareCursor === enabled) return;
|
|
286
287
|
this.#showHardwareCursor = enabled;
|
|
287
288
|
if (!enabled) {
|
|
288
|
-
this
|
|
289
|
+
this.#hideCursor();
|
|
289
290
|
}
|
|
290
291
|
this.requestRender();
|
|
291
292
|
}
|
|
@@ -328,7 +329,7 @@ export class TUI extends Container {
|
|
|
328
329
|
if (this.#isOverlayVisible(entry)) {
|
|
329
330
|
this.setFocus(component);
|
|
330
331
|
}
|
|
331
|
-
this
|
|
332
|
+
this.#hideCursor();
|
|
332
333
|
this.requestRender();
|
|
333
334
|
|
|
334
335
|
// Return handle for controlling this overlay
|
|
@@ -342,7 +343,7 @@ export class TUI extends Container {
|
|
|
342
343
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
343
344
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
344
345
|
}
|
|
345
|
-
if (this.overlayStack.length === 0) this
|
|
346
|
+
if (this.overlayStack.length === 0) this.#hideCursor();
|
|
346
347
|
this.requestRender();
|
|
347
348
|
}
|
|
348
349
|
},
|
|
@@ -375,7 +376,7 @@ export class TUI extends Container {
|
|
|
375
376
|
// Find topmost visible overlay, or fall back to preFocus
|
|
376
377
|
const topVisible = this.#getTopmostVisibleOverlay();
|
|
377
378
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
378
|
-
if (this.overlayStack.length === 0) this
|
|
379
|
+
if (this.overlayStack.length === 0) this.#hideCursor();
|
|
379
380
|
this.requestRender();
|
|
380
381
|
}
|
|
381
382
|
|
|
@@ -410,16 +411,62 @@ export class TUI extends Container {
|
|
|
410
411
|
|
|
411
412
|
start(): void {
|
|
412
413
|
this.#stopped = false;
|
|
414
|
+
this.#terminalUnavailable = false;
|
|
413
415
|
this.terminal.start(
|
|
414
416
|
data => this.#handleInput(data),
|
|
415
417
|
() => this.requestRender(),
|
|
416
418
|
);
|
|
417
|
-
this
|
|
419
|
+
this.#hideCursor();
|
|
418
420
|
this.#querySixelSupport();
|
|
419
421
|
this.#queryCellSize();
|
|
420
422
|
this.requestRender(true);
|
|
421
423
|
}
|
|
422
424
|
|
|
425
|
+
get terminalAvailable(): boolean {
|
|
426
|
+
return !this.#terminalUnavailable && this.terminal.available;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#markTerminalUnavailable(): void {
|
|
430
|
+
this.#terminalUnavailable = true;
|
|
431
|
+
this.#stopped = true;
|
|
432
|
+
this.#renderRequested = false;
|
|
433
|
+
if (this.#renderTimer) {
|
|
434
|
+
clearTimeout(this.#renderTimer);
|
|
435
|
+
this.#renderTimer = undefined;
|
|
436
|
+
}
|
|
437
|
+
this.#clearSixelProbeState();
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
#writeTerminal(data: string): boolean {
|
|
441
|
+
return this.#guardTerminalOperation(() => this.terminal.write(data));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
#hideCursor(): boolean {
|
|
445
|
+
return this.#guardTerminalOperation(() => this.terminal.hideCursor());
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#showCursor(): boolean {
|
|
449
|
+
return this.#guardTerminalOperation(() => this.terminal.showCursor());
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
#guardTerminalOperation(operation: () => void): boolean {
|
|
453
|
+
if (!this.terminalAvailable) {
|
|
454
|
+
this.#markTerminalUnavailable();
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
operation();
|
|
459
|
+
} catch {
|
|
460
|
+
this.#markTerminalUnavailable();
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
if (!this.terminal.available) {
|
|
464
|
+
this.#markTerminalUnavailable();
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
|
|
423
470
|
addInputListener(listener: InputListener): () => void {
|
|
424
471
|
this.#inputListeners.add(listener);
|
|
425
472
|
return () => {
|
|
@@ -441,8 +488,8 @@ export class TUI extends Container {
|
|
|
441
488
|
this.#sixelProbePendingDa = true;
|
|
442
489
|
this.#sixelProbePendingGraphics = true;
|
|
443
490
|
this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
|
|
444
|
-
this
|
|
445
|
-
this
|
|
491
|
+
if (!this.#writeTerminal("\x1b[c")) return;
|
|
492
|
+
if (!this.#writeTerminal("\x1b[?2;1;0S")) return;
|
|
446
493
|
this.#sixelProbeTimeout = setTimeout(() => {
|
|
447
494
|
this.#finishSixelProbe(false);
|
|
448
495
|
}, 250);
|
|
@@ -563,7 +610,7 @@ export class TUI extends Container {
|
|
|
563
610
|
}
|
|
564
611
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
565
612
|
// Response format: CSI 6 ; height ; width t
|
|
566
|
-
this
|
|
613
|
+
this.#writeTerminal("\x1b[16t");
|
|
567
614
|
}
|
|
568
615
|
|
|
569
616
|
stop(): void {
|
|
@@ -578,18 +625,26 @@ export class TUI extends Container {
|
|
|
578
625
|
const targetRow = this.#previousLines.length; // Line after the last content
|
|
579
626
|
const lineDiff = targetRow - this.#hardwareCursorRow;
|
|
580
627
|
if (lineDiff > 0) {
|
|
581
|
-
this
|
|
628
|
+
this.#writeTerminal(`\x1b[${lineDiff}B`);
|
|
582
629
|
} else if (lineDiff < 0) {
|
|
583
|
-
this
|
|
630
|
+
this.#writeTerminal(`\x1b[${-lineDiff}A`);
|
|
584
631
|
}
|
|
585
|
-
this
|
|
632
|
+
this.#writeTerminal("\r\n");
|
|
586
633
|
}
|
|
587
634
|
|
|
588
|
-
this
|
|
589
|
-
|
|
635
|
+
this.#showCursor();
|
|
636
|
+
try {
|
|
637
|
+
this.terminal.stop();
|
|
638
|
+
} catch {
|
|
639
|
+
this.#markTerminalUnavailable();
|
|
640
|
+
}
|
|
590
641
|
}
|
|
591
642
|
|
|
592
643
|
requestRender(force = false): void {
|
|
644
|
+
if (!this.terminalAvailable) {
|
|
645
|
+
this.#markTerminalUnavailable();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
593
648
|
if (force) {
|
|
594
649
|
this.#previousLines = [];
|
|
595
650
|
this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
|
|
@@ -1040,7 +1095,7 @@ export class TUI extends Container {
|
|
|
1040
1095
|
}
|
|
1041
1096
|
|
|
1042
1097
|
#doRender(): void {
|
|
1043
|
-
if (this.#stopped) return;
|
|
1098
|
+
if (this.#stopped || !this.terminalAvailable) return;
|
|
1044
1099
|
const width = this.terminal.columns;
|
|
1045
1100
|
const height = this.terminal.rows;
|
|
1046
1101
|
let viewportTop = Math.max(0, this.#maxLinesRendered - height);
|
|
@@ -1091,7 +1146,7 @@ export class TUI extends Container {
|
|
|
1091
1146
|
this.#hardwareCursorRow = toRow;
|
|
1092
1147
|
buffer += seq;
|
|
1093
1148
|
buffer += "\x1b[?2026l"; // End synchronized output
|
|
1094
|
-
this
|
|
1149
|
+
if (!this.#writeTerminal(buffer)) return;
|
|
1095
1150
|
// Reset max lines when clearing, otherwise track growth
|
|
1096
1151
|
if (clear) {
|
|
1097
1152
|
this.#maxLinesRendered = newLines.length;
|
|
@@ -1140,7 +1195,7 @@ export class TUI extends Container {
|
|
|
1140
1195
|
this.#hardwareCursorRow = cursorToRow;
|
|
1141
1196
|
buffer += cursorSeq;
|
|
1142
1197
|
buffer += "\x1b[?2026l";
|
|
1143
|
-
this
|
|
1198
|
+
if (!this.#writeTerminal(buffer)) return;
|
|
1144
1199
|
|
|
1145
1200
|
if ($flag("PI_DEBUG_REDRAW")) {
|
|
1146
1201
|
const logPath = getDebugLogPath();
|
|
@@ -1274,7 +1329,7 @@ export class TUI extends Container {
|
|
|
1274
1329
|
this.#hardwareCursorRow = toRow;
|
|
1275
1330
|
buffer += seq;
|
|
1276
1331
|
buffer += "\x1b[?2026l";
|
|
1277
|
-
this
|
|
1332
|
+
if (!this.#writeTerminal(buffer)) return;
|
|
1278
1333
|
}
|
|
1279
1334
|
this.#previousLines = newLines;
|
|
1280
1335
|
this.#previousWidth = width;
|
|
@@ -1415,7 +1470,7 @@ export class TUI extends Container {
|
|
|
1415
1470
|
}
|
|
1416
1471
|
|
|
1417
1472
|
// Write entire buffer at once
|
|
1418
|
-
this
|
|
1473
|
+
if (!this.#writeTerminal(buffer)) return;
|
|
1419
1474
|
|
|
1420
1475
|
// Track cursor position for next render.
|
|
1421
1476
|
// cursorRow tracks end of content (for viewport calculation).
|
|
@@ -1471,11 +1526,11 @@ export class TUI extends Container {
|
|
|
1471
1526
|
*/
|
|
1472
1527
|
#writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
|
|
1473
1528
|
if (!cursorPos || totalLines <= 0) {
|
|
1474
|
-
this
|
|
1529
|
+
this.#hideCursor();
|
|
1475
1530
|
return;
|
|
1476
1531
|
}
|
|
1477
1532
|
const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
|
|
1478
1533
|
this.#hardwareCursorRow = toRow;
|
|
1479
|
-
this
|
|
1534
|
+
this.#writeTerminal(`\x1b[?2026h${seq}\x1b[?2026l`);
|
|
1480
1535
|
}
|
|
1481
1536
|
}
|