@f5-sales-demo/pi-tui 21.14.1 → 21.15.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
@@ -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.14.1",
4
+ "version": "21.15.1",
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.14.1",
41
- "@f5-sales-demo/pi-utils": "21.14.1",
40
+ "@f5-sales-demo/pi-natives": "21.15.1",
41
+ "@f5-sales-demo/pi-utils": "21.15.1",
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 resize handlers
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
- // Restore raw mode state
626
- if (process.stdin.setRawMode) {
627
- process.stdin.setRawMode(this.#wasRaw);
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
- // Any write failure means terminal is dead - no recovery possible
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 stripped = newLines[i].replaceAll(CURSOR_MARKER, "");
1078
- if (visibleWidth(stripped) > width) {
1079
- const hadMarker = newLines[i] !== stripped;
1080
- newLines[i] = sliceByColumn(stripped, 0, width, true) + (hadMarker ? CURSOR_MARKER : "");
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)