@f5-sales-demo/pi-tui 21.15.0 → 21.16.0
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 +9 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/terminal.ts +50 -9
- package/src/tui.ts +75 -5
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@
|
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
+
- Bounded oversized ANSI-heavy render rows before width measurement while preserving visible text,
|
|
10
|
+
complete OSC 66 payloads, and reset semantics. Semantically ported from
|
|
11
|
+
[can1357/oh-my-pi#2047](https://github.com/can1357/oh-my-pi/pull/2047)
|
|
12
|
+
([#3743](https://github.com/f5-sales-demo/xcsh/issues/3743)).
|
|
13
|
+
- Stopped rendering and initiated best-effort SIGHUP teardown when terminal input or output is
|
|
14
|
+
disconnected, including revoked-PTY and throwing-handler paths. Semantically ported from
|
|
15
|
+
[can1357/oh-my-pi#5837](https://github.com/can1357/oh-my-pi/pull/5837) and
|
|
16
|
+
[#6788](https://github.com/can1357/oh-my-pi/pull/6788)
|
|
17
|
+
([#3743](https://github.com/f5-sales-demo/xcsh/issues/3743)).
|
|
9
18
|
- Kept `@` directory completion open after Tab or Enter without inserting a trailing space ([#3724](https://github.com/f5-sales-demo/xcsh/issues/3724)).
|
|
10
19
|
|
|
11
20
|
## [20.3.0] - 2026-08-04
|
package/README.md
CHANGED
|
@@ -521,7 +521,7 @@ The TUI works with any object implementing the `Terminal` interface:
|
|
|
521
521
|
|
|
522
522
|
```typescript
|
|
523
523
|
interface Terminal {
|
|
524
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
524
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
525
525
|
stop(): void;
|
|
526
526
|
write(data: string): void;
|
|
527
527
|
get columns(): number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-tui",
|
|
4
|
-
"version": "21.
|
|
4
|
+
"version": "21.16.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@f5-sales-demo/pi-natives": "21.
|
|
41
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
40
|
+
"@f5-sales-demo/pi-natives": "21.16.0",
|
|
41
|
+
"@f5-sales-demo/pi-utils": "21.16.0",
|
|
42
42
|
"marked": "^18.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
package/src/terminal.ts
CHANGED
|
@@ -46,8 +46,8 @@ export function emergencyTerminalRestore(): void {
|
|
|
46
46
|
/** Terminal-reported appearance (dark/light mode). */
|
|
47
47
|
export type TerminalAppearance = "dark" | "light";
|
|
48
48
|
export interface Terminal {
|
|
49
|
-
// Start the terminal with input and
|
|
50
|
-
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
49
|
+
// Start the terminal with input, resize, and host-disconnect handlers.
|
|
50
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void;
|
|
51
51
|
|
|
52
52
|
// Stop the terminal and restore state
|
|
53
53
|
stop(): void;
|
|
@@ -108,6 +108,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
108
108
|
#modifyOtherKeysTimeout?: ReturnType<typeof setTimeout>;
|
|
109
109
|
#stdinBuffer?: StdinBuffer;
|
|
110
110
|
#stdinDataHandler?: (data: string) => void;
|
|
111
|
+
#disconnectHandler?: () => void;
|
|
112
|
+
#stdinEndHandler = () => this.#markTerminalDisconnected("stdin ended");
|
|
113
|
+
#stdinCloseHandler = () => this.#markTerminalDisconnected("stdin closed");
|
|
114
|
+
#stdinErrorHandler = (error: Error) => this.#markTerminalDisconnected("stdin failed", error);
|
|
115
|
+
#stdoutErrorHandler = (error: Error) => this.#markTerminalDisconnected("stdout failed", error);
|
|
111
116
|
#dead = false;
|
|
112
117
|
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
|
|
113
118
|
#windowsVTInputRestore?: () => void;
|
|
@@ -142,8 +147,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
142
147
|
this.#appearanceCallbacks.push(callback);
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
150
|
+
start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void {
|
|
146
151
|
this.#resizeHandler = onResize;
|
|
152
|
+
this.#disconnectHandler = onDisconnect;
|
|
147
153
|
|
|
148
154
|
// Register for emergency cleanup
|
|
149
155
|
activeTerminal = this;
|
|
@@ -155,6 +161,10 @@ export class ProcessTerminal implements Terminal {
|
|
|
155
161
|
process.stdin.setRawMode(true);
|
|
156
162
|
}
|
|
157
163
|
process.stdin.setEncoding("utf8");
|
|
164
|
+
process.stdin.on("end", this.#stdinEndHandler);
|
|
165
|
+
process.stdin.on("close", this.#stdinCloseHandler);
|
|
166
|
+
process.stdin.on("error", this.#stdinErrorHandler);
|
|
167
|
+
process.stdout.on("error", this.#stdoutErrorHandler);
|
|
158
168
|
process.stdin.resume();
|
|
159
169
|
|
|
160
170
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
@@ -610,6 +620,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
610
620
|
process.stdin.removeListener("data", this.#stdinDataHandler);
|
|
611
621
|
this.#stdinDataHandler = undefined;
|
|
612
622
|
}
|
|
623
|
+
process.stdin.removeListener("end", this.#stdinEndHandler);
|
|
624
|
+
process.stdin.removeListener("close", this.#stdinCloseHandler);
|
|
625
|
+
process.stdin.removeListener("error", this.#stdinErrorHandler);
|
|
626
|
+
process.stdout.removeListener("error", this.#stdoutErrorHandler);
|
|
627
|
+
this.#disconnectHandler = undefined;
|
|
613
628
|
this.#inputHandler = undefined;
|
|
614
629
|
this.#appearance = undefined;
|
|
615
630
|
if (this.#resizeHandler) {
|
|
@@ -622,9 +637,37 @@ export class ProcessTerminal implements Terminal {
|
|
|
622
637
|
// where Ctrl+D could close the parent shell over SSH.
|
|
623
638
|
process.stdin.pause();
|
|
624
639
|
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
640
|
+
// A revoked PTY cannot be restored. Suppress that failure only after a
|
|
641
|
+
// disconnect; normal live-terminal restoration failures must still surface.
|
|
642
|
+
try {
|
|
643
|
+
process.stdin.setRawMode?.(this.#wasRaw);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (!this.#dead) throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#markTerminalDisconnected(reason: string, error?: unknown): void {
|
|
650
|
+
if (this.#dead) return;
|
|
651
|
+
this.#dead = true;
|
|
652
|
+
logger.warn("terminal disconnected; stopping interactive rendering", { reason, error });
|
|
653
|
+
|
|
654
|
+
const disconnectHandler = this.#disconnectHandler;
|
|
655
|
+
this.#disconnectHandler = undefined;
|
|
656
|
+
if (!disconnectHandler) return;
|
|
657
|
+
try {
|
|
658
|
+
disconnectHandler();
|
|
659
|
+
} catch (handlerError) {
|
|
660
|
+
logger.error("terminal disconnect handler failed; exiting anyway", { error: handlerError });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (process.platform === "win32") {
|
|
664
|
+
process.exit(129);
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
process.kill(process.pid, "SIGHUP");
|
|
668
|
+
} catch (signalError) {
|
|
669
|
+
logger.error("terminal disconnect signal failed; exiting directly", { error: signalError });
|
|
670
|
+
process.exit(129);
|
|
628
671
|
}
|
|
629
672
|
}
|
|
630
673
|
|
|
@@ -644,9 +687,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
644
687
|
try {
|
|
645
688
|
process.stdout.write(data);
|
|
646
689
|
} catch (err) {
|
|
647
|
-
|
|
648
|
-
this.#dead = true;
|
|
649
|
-
logger.warn("terminal is dead - no recovery possible", { error: err, data });
|
|
690
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
650
691
|
}
|
|
651
692
|
}
|
|
652
693
|
|
package/src/tui.ts
CHANGED
|
@@ -10,6 +10,75 @@ import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL }
|
|
|
10
10
|
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
|
|
11
11
|
|
|
12
12
|
const SEGMENT_RESET = "\x1b[0m";
|
|
13
|
+
const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
|
|
14
|
+
const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65_536;
|
|
15
|
+
const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
|
|
16
|
+
|
|
17
|
+
function ansiSequenceEnd(line: string, start: number): number {
|
|
18
|
+
const next = line.charCodeAt(start + 1);
|
|
19
|
+
if (next === 0x5b) {
|
|
20
|
+
let index = start + 2;
|
|
21
|
+
while (index < line.length) {
|
|
22
|
+
const final = line.charCodeAt(index);
|
|
23
|
+
if (final >= 0x40 && final <= 0x7e) return index + 1;
|
|
24
|
+
index++;
|
|
25
|
+
}
|
|
26
|
+
return -1;
|
|
27
|
+
}
|
|
28
|
+
if (next === 0x5d) {
|
|
29
|
+
let index = start + 2;
|
|
30
|
+
while (index < line.length) {
|
|
31
|
+
const code = line.charCodeAt(index);
|
|
32
|
+
if (code === 0x07) return index + 1;
|
|
33
|
+
if (code === 0x1b && line.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
34
|
+
index++;
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
}
|
|
38
|
+
return start + 2 <= line.length ? start + 2 : -1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
|
42
|
+
return line.startsWith("\x1b]66;", start);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Bound work before width measurement while retaining later visible text. */
|
|
46
|
+
function lineFitSource(raw: string, width: number): string {
|
|
47
|
+
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
48
|
+
const maxSourceLength = Math.min(
|
|
49
|
+
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
50
|
+
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
51
|
+
);
|
|
52
|
+
if (raw.length <= maxSourceLength) return raw;
|
|
53
|
+
|
|
54
|
+
const chunks: string[] = [];
|
|
55
|
+
let emitted = 0;
|
|
56
|
+
for (let index = 0; index < raw.length && emitted < maxSourceLength; ) {
|
|
57
|
+
if (raw.charCodeAt(index) === 0x1b) {
|
|
58
|
+
const end = ansiSequenceEnd(raw, index);
|
|
59
|
+
if (end === -1) break;
|
|
60
|
+
const sequenceLength = end - index;
|
|
61
|
+
if (ansiSequenceHasVisiblePayload(raw, index)) {
|
|
62
|
+
chunks.push(raw.slice(index, end));
|
|
63
|
+
emitted += sequenceLength;
|
|
64
|
+
} else if (emitted > 0 && sequenceLength <= maxSourceLength - emitted) {
|
|
65
|
+
chunks.push(raw.slice(index, end));
|
|
66
|
+
emitted += sequenceLength;
|
|
67
|
+
}
|
|
68
|
+
index = end;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const start = index;
|
|
73
|
+
const end = Math.min(raw.length, start + maxSourceLength - emitted);
|
|
74
|
+
while (index < end && raw.charCodeAt(index) !== 0x1b) index++;
|
|
75
|
+
if (index === start) break;
|
|
76
|
+
chunks.push(raw.slice(start, index));
|
|
77
|
+
emitted += index - start;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return chunks.join("") + SEGMENT_RESET;
|
|
81
|
+
}
|
|
13
82
|
|
|
14
83
|
type InputListenerResult = { consume?: boolean; data?: string } | undefined;
|
|
15
84
|
type InputListener = (data: string) => InputListenerResult;
|
|
@@ -451,7 +520,9 @@ export class TUI extends Container {
|
|
|
451
520
|
this.#handleInput(data);
|
|
452
521
|
},
|
|
453
522
|
() => this.requestRender(),
|
|
523
|
+
() => this.stop(),
|
|
454
524
|
);
|
|
525
|
+
if (this.#stopped) return;
|
|
455
526
|
this.terminal.hideCursor();
|
|
456
527
|
this.#querySixelSupport();
|
|
457
528
|
this.#queryCellSize();
|
|
@@ -1074,11 +1145,10 @@ export class TUI extends Container {
|
|
|
1074
1145
|
// as visible chars; the marker is extracted later in #extractCursorPosition.
|
|
1075
1146
|
for (let i = 0; i < newLines.length; i++) {
|
|
1076
1147
|
if (TERMINAL.isImageLine(newLines[i])) continue;
|
|
1077
|
-
const
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
}
|
|
1148
|
+
const hadMarker = newLines[i].includes(CURSOR_MARKER);
|
|
1149
|
+
let fitted = lineFitSource(newLines[i].replaceAll(CURSOR_MARKER, ""), width);
|
|
1150
|
+
if (visibleWidth(fitted) > width) fitted = sliceByColumn(fitted, 0, width, true);
|
|
1151
|
+
newLines[i] = fitted + (hadMarker ? CURSOR_MARKER : "");
|
|
1082
1152
|
}
|
|
1083
1153
|
|
|
1084
1154
|
// Composite overlays into the rendered lines (before differential compare)
|