@f5-sales-demo/pi-tui 21.15.1 → 21.17.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 CHANGED
@@ -6,6 +6,17 @@
6
6
 
7
7
  ### Fixed
8
8
 
9
+ - Added shared SGR mouse routing and fullscreen alternate-screen ownership for interactive overlays,
10
+ including horizontal-wheel filtering, Herdr-safe redraw behavior, and idempotent mouse/screen
11
+ restoration. Semantically ported from [can1357/oh-my-pi#3402](https://github.com/can1357/oh-my-pi/pull/3402),
12
+ [#7810](https://github.com/can1357/oh-my-pi/pull/7810),
13
+ [#8433](https://github.com/can1357/oh-my-pi/pull/8433), and
14
+ [#11045](https://github.com/can1357/oh-my-pi/pull/11045)
15
+ ([#3748](https://github.com/f5-sales-demo/xcsh/issues/3748)).
16
+ - Delivered structured completion, request, and error notifications to validated Herdr panes while
17
+ preserving existing terminal fallbacks and explicit Kitty graphics negotiation. Semantically
18
+ ported from [can1357/oh-my-pi#11054](https://github.com/can1357/oh-my-pi/pull/11054)
19
+ ([#3748](https://github.com/f5-sales-demo/xcsh/issues/3748)).
9
20
  - Bounded oversized ANSI-heavy render rows before width measurement while preserving visible text,
10
21
  complete OSC 66 payloads, and reset semantics. Semantically ported from
11
22
  [can1357/oh-my-pi#2047](https://github.com/can1357/oh-my-pi/pull/2047)
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.15.1",
4
+ "version": "21.17.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.15.1",
41
- "@f5-sales-demo/pi-utils": "21.15.1",
40
+ "@f5-sales-demo/pi-natives": "21.17.0",
41
+ "@f5-sales-demo/pi-utils": "21.17.0",
42
42
  "marked": "^18.0"
43
43
  },
44
44
  "devDependencies": {
@@ -1,4 +1,5 @@
1
1
  import { getKeybindings } from "../keybindings";
2
+ import { type MouseRoutable, routeSelectListMouse, type SgrMouseEvent } from "../mouse";
2
3
  import type { SymbolTheme } from "../symbols";
3
4
  import type { Component } from "../tui";
4
5
  import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
@@ -31,6 +32,7 @@ export interface SelectListTheme {
31
32
  scrollInfo: (text: string) => string;
32
33
  noMatch: (text: string) => string;
33
34
  symbols: SymbolTheme;
35
+ hovered?: (text: string) => string;
34
36
  }
35
37
 
36
38
  export interface SelectListTruncatePrimaryContext {
@@ -47,9 +49,11 @@ export interface SelectListLayoutOptions {
47
49
  truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
48
50
  }
49
51
 
50
- export class SelectList implements Component {
52
+ export class SelectList implements Component, MouseRoutable {
51
53
  #filteredItems: ReadonlyArray<SelectItem>;
52
54
  #selectedIndex: number = 0;
55
+ #hoveredIndex: number | null = null;
56
+ #hitRows: (number | undefined)[] = [];
53
57
 
54
58
  onSelect?: (item: SelectItem) => void;
55
59
  onCancel?: () => void;
@@ -74,12 +78,43 @@ export class SelectList implements Component {
74
78
  this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
75
79
  }
76
80
 
81
+ hitTest(line: number): number | undefined {
82
+ return this.#hitRows[line];
83
+ }
84
+
85
+ setHoverIndex(index: number | null): void {
86
+ this.#hoveredIndex = index;
87
+ }
88
+
89
+ handleWheel(delta: -1 | 1): void {
90
+ if (this.#filteredItems.length === 0) return;
91
+ const next = clamp(this.#selectedIndex + delta, 0, this.#filteredItems.length - 1);
92
+ if (next === this.#selectedIndex) return;
93
+ this.#selectedIndex = next;
94
+ this.#notifySelectionChange();
95
+ }
96
+
97
+ clickItem(index: number): void {
98
+ const item = this.#filteredItems[index];
99
+ if (!item) return;
100
+ if (index !== this.#selectedIndex) {
101
+ this.#selectedIndex = index;
102
+ this.#notifySelectionChange();
103
+ }
104
+ this.onSelect?.(item);
105
+ }
106
+
107
+ routeMouse(event: SgrMouseEvent, line: number, _col: number): void {
108
+ routeSelectListMouse(this, event, line);
109
+ }
110
+
77
111
  invalidate(): void {
78
112
  // No cached state to invalidate currently
79
113
  }
80
114
 
81
115
  render(width: number): string[] {
82
116
  const lines: string[] = [];
117
+ this.#hitRows = [];
83
118
 
84
119
  // If no items match filter, show message
85
120
  if (this.#filteredItems.length === 0) {
@@ -103,7 +138,11 @@ export class SelectList implements Component {
103
138
 
104
139
  const isSelected = i === this.#selectedIndex;
105
140
  const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
106
- lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
141
+ const rendered = this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth);
142
+ this.#hitRows[lines.length] = i;
143
+ lines.push(
144
+ !isSelected && i === this.#hoveredIndex && this.theme.hovered ? this.theme.hovered(rendered) : rendered,
145
+ );
107
146
  }
108
147
 
109
148
  // Add scroll indicators if needed
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export * from "./keys";
53
53
  export { type RenderLatexOptions, renderLatex } from "./latex";
54
54
  // Media playback state machine
55
55
  export * from "./media-playback";
56
+ export * from "./mouse";
56
57
  // Mermaid diagram support
57
58
  // Input buffering for batch splitting
58
59
  export * from "./stdin-buffer";
@@ -61,6 +62,7 @@ export type * from "./symbols";
61
62
  export * from "./terminal";
62
63
  // Terminal image support
63
64
  export * from "./terminal-capabilities";
65
+ export * from "./terminal-multiplexer";
64
66
  // TTY ID
65
67
  export * from "./ttyid";
66
68
  export * from "./tui";
package/src/mouse.ts ADDED
@@ -0,0 +1,61 @@
1
+ /** Decoded SGR mouse report with zero-based coordinates. */
2
+ export interface SgrMouseEvent {
3
+ button: number;
4
+ col: number;
5
+ row: number;
6
+ release: boolean;
7
+ wheel: -1 | 1 | null;
8
+ motion: boolean;
9
+ leftClick: boolean;
10
+ }
11
+
12
+ export function parseSgrMouse(data: string): SgrMouseEvent | null {
13
+ const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data);
14
+ if (!match) return null;
15
+ const button = Number(match[1]);
16
+ const col = Number(match[2]) - 1;
17
+ const row = Number(match[3]) - 1;
18
+ const release = match[4] === "m";
19
+ // SGR 66/67 are horizontal wheel reports. They must not move vertical lists.
20
+ const wheel = button & 64 && !(button & 2) ? ((button & 1 ? 1 : -1) as -1 | 1) : null;
21
+ const motion = (button & 32) !== 0 && wheel === null;
22
+ const leftClick = !release && wheel === null && !motion && (button & 3) === 0;
23
+ return { button, col, row, release, wheel, motion, leftClick };
24
+ }
25
+
26
+ export type SgrMouseHandler = (event: SgrMouseEvent) => boolean | undefined;
27
+
28
+ export function routeSgrMouseInput(data: string, handler: SgrMouseHandler): boolean {
29
+ if (!data.startsWith("\x1b[<")) return false;
30
+ const event = parseSgrMouse(data);
31
+ if (!event) return false;
32
+ return handler(event) !== false;
33
+ }
34
+
35
+ export interface SelectListMouseTarget {
36
+ handleWheel(delta: -1 | 1): void;
37
+ hitTest(line: number): number | undefined;
38
+ setHoverIndex(index: number | null): void;
39
+ clickItem(index: number): void;
40
+ }
41
+
42
+ export function routeSelectListMouse(target: SelectListMouseTarget, event: SgrMouseEvent, line: number): boolean {
43
+ if (event.wheel !== null) {
44
+ target.handleWheel(event.wheel);
45
+ return true;
46
+ }
47
+ const index = target.hitTest(line);
48
+ if (event.motion) {
49
+ target.setHoverIndex(index ?? null);
50
+ return true;
51
+ }
52
+ if (event.leftClick && index !== undefined) {
53
+ target.clickItem(index);
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ export interface MouseRoutable {
60
+ routeMouse(event: SgrMouseEvent, line: number, col: number): void;
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { encodeSixel } from "@f5-sales-demo/pi-natives";
2
2
  import { $env } from "@f5-sales-demo/pi-utils";
3
+ import { isInsideHerdr } from "./terminal-multiplexer";
3
4
 
4
5
  export enum ImageProtocol {
5
6
  Kitty = "\x1b_G",
@@ -15,6 +16,59 @@ export enum NotifyProtocol {
15
16
 
16
17
  export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "base" | "trueColor";
17
18
 
19
+ export interface TerminalNotification {
20
+ title?: string;
21
+ body?: string;
22
+ type?: string | string[];
23
+ }
24
+
25
+ const DEFAULT_NOTIFICATION_TITLE = "xcsh";
26
+ const HERDR_PANE_ID_PATTERN = /^[0-9A-Za-z:_-]{1,64}$/u;
27
+ const HERDR_USAGE_TOKENS = new Set(["help", "--help", "-h"]);
28
+
29
+ function notificationTitleAndBody(message: string | TerminalNotification): { title: string; body: string } {
30
+ if (typeof message === "string") return { title: DEFAULT_NOTIFICATION_TITLE, body: message };
31
+ return { title: message.title?.trim() || DEFAULT_NOTIFICATION_TITLE, body: message.body ?? "" };
32
+ }
33
+
34
+ function notificationToLine(message: TerminalNotification): string {
35
+ if (message.title && message.body) return `${message.title}: ${message.body}`;
36
+ return message.title ?? message.body ?? "";
37
+ }
38
+
39
+ export function buildHerdrNotificationCommand(
40
+ message: string | TerminalNotification,
41
+ env: NodeJS.ProcessEnv = Bun.env,
42
+ ): string[] | null {
43
+ if (!isInsideHerdr(env)) return null;
44
+ const paneId = env.HERDR_PANE_ID?.trim();
45
+ if (!paneId || !HERDR_PANE_ID_PATTERN.test(paneId)) return null;
46
+ const parsed = notificationTitleAndBody(message);
47
+ const title = HERDR_USAGE_TOKENS.has(parsed.title) ? DEFAULT_NOTIFICATION_TITLE : parsed.title;
48
+ const types = typeof message === "string" ? [] : [message.type ?? []].flat();
49
+ const sound =
50
+ types.includes("ask") || types.includes("error") ? "request" : types.includes("completion") ? "done" : "none";
51
+ return ["herdr", "notification", "show", title, "--body", parsed.body, "--sound", sound];
52
+ }
53
+
54
+ function sendHerdrNotification(message: string | TerminalNotification, env: NodeJS.ProcessEnv = Bun.env): boolean {
55
+ const cmd = buildHerdrNotificationCommand(message, env);
56
+ if (!cmd) return false;
57
+ if (!Bun.which(cmd[0]!, { PATH: env.PATH })) return false;
58
+ try {
59
+ const child = Bun.spawn({
60
+ cmd,
61
+ stdin: "ignore",
62
+ stdout: "ignore",
63
+ stderr: "ignore",
64
+ });
65
+ child.unref();
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
18
72
  const SIXEL_DCS_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
19
73
  /** Terminal capability details used for rendering and protocol selection. */
20
74
  export class TerminalInfo {
@@ -40,15 +94,17 @@ export class TerminalInfo {
40
94
  return false;
41
95
  }
42
96
 
43
- formatNotification(message: string): string {
97
+ formatNotification(message: string | TerminalNotification): string {
44
98
  if (this.notifyProtocol === NotifyProtocol.Bell) {
45
99
  return NotifyProtocol.Bell;
46
100
  }
47
- return `${this.notifyProtocol}${message}\x1b\\`;
101
+ const line = typeof message === "string" ? message : notificationToLine(message);
102
+ return `${this.notifyProtocol}${line}\x1b\\`;
48
103
  }
49
104
 
50
- sendNotification(message: string): void {
105
+ sendNotification(message: string | TerminalNotification): void {
51
106
  if (isNotificationSuppressed()) return;
107
+ if (sendHerdrNotification(message)) return;
52
108
  process.stdout.write(this.formatNotification(message));
53
109
  }
54
110
  }
@@ -0,0 +1,12 @@
1
+ /** True when this process is running inside a Herdr pane. */
2
+ export function isInsideHerdr(env: NodeJS.ProcessEnv = Bun.env): boolean {
3
+ if (env.HERDR_ENV === "1") return true;
4
+ return Boolean(env.HERDR_PANE_ID || env.HERDR_TAB_ID || env.HERDR_WORKSPACE_ID);
5
+ }
6
+
7
+ /** Detect terminal multiplexers that own the current screen grid and scrollback. */
8
+ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
9
+ if (env.TMUX || env.STY || env.ZELLIJ || isInsideHerdr(env)) return true;
10
+ const term = env.TERM?.toLowerCase() ?? "";
11
+ return term.startsWith("tmux") || term.startsWith("screen");
12
+ }
package/src/terminal.ts CHANGED
@@ -12,6 +12,19 @@ import { StdinBuffer } from "./stdin-buffer";
12
12
  let activeTerminal: ProcessTerminal | null = null;
13
13
  // Track if a terminal was ever started (for emergency restore logic)
14
14
  let terminalEverStarted = false;
15
+ let alternateScreenActive = false;
16
+ const EMERGENCY_MOUSE_RESTORE = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
17
+
18
+ /** Track alternate-screen ownership so crash cleanup does not disturb the normal screen. */
19
+ export function setAlternateScreenActive(active: boolean): void {
20
+ alternateScreenActive = active;
21
+ }
22
+
23
+ function emergencyScreenRestoreSequence(): string {
24
+ const sequence = `${EMERGENCY_MOUSE_RESTORE}${alternateScreenActive ? "\x1b[?1049l" : ""}`;
25
+ alternateScreenActive = false;
26
+ return sequence;
27
+ }
15
28
 
16
29
  const STD_INPUT_HANDLE = -10;
17
30
  const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
@@ -24,12 +37,14 @@ export function emergencyTerminalRestore(): void {
24
37
  const terminal = activeTerminal;
25
38
  if (terminal) {
26
39
  terminal.stop();
40
+ process.stdout.write(emergencyScreenRestoreSequence());
27
41
  terminal.showCursor();
28
42
  } else if (terminalEverStarted) {
29
43
  // Blind restore only if we know a terminal was started but lost track of it
30
44
  // This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
31
45
  process.stdout.write(
32
- "\x1b[?2004l" + // Disable bracketed paste
46
+ emergencyScreenRestoreSequence() +
47
+ "\x1b[?2004l" + // Disable bracketed paste
33
48
  "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
34
49
  "\x1b[<u" + // Pop kitty keyboard protocol
35
50
  "\x1b[>4;0m" + // Disable modifyOtherKeys fallback
package/src/tui.ts CHANGED
@@ -5,14 +5,18 @@ import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { $flag, getDebugLogPath } from "@f5-sales-demo/pi-utils";
7
7
  import { isKeyRelease, matchesKey } from "./keys";
8
- import type { Terminal } from "./terminal";
8
+ import { type MouseRoutable, routeSgrMouseInput } from "./mouse";
9
+ import { setAlternateScreenActive, type Terminal } from "./terminal";
9
10
  import { ImageProtocol, setCellDimensions, setTerminalImageProtocol, TERMINAL } from "./terminal-capabilities";
11
+ import { isInsideTerminalMultiplexer } from "./terminal-multiplexer";
10
12
  import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils";
11
13
 
12
14
  const SEGMENT_RESET = "\x1b[0m";
13
15
  const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
14
16
  const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65_536;
15
17
  const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
18
+ const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
19
+ const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
16
20
 
17
21
  function ansiSequenceEnd(line: string, start: number): number {
18
22
  const next = line.charCodeAt(start + 1);
@@ -186,7 +190,7 @@ function isTermuxSession(): boolean {
186
190
 
187
191
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
188
192
  function isMultiplexer() {
189
- return Boolean(Bun.env.TMUX || Bun.env.STY || Bun.env.ZELLIJ);
193
+ return isInsideTerminalMultiplexer();
190
194
  }
191
195
 
192
196
  /**
@@ -194,6 +198,10 @@ function isMultiplexer() {
194
198
  * Values can be absolute numbers or percentage strings (e.g., "50%").
195
199
  */
196
200
  export interface OverlayOptions {
201
+ /** Borrow the alternate screen and render this overlay as a full viewport. */
202
+ fullscreen?: boolean;
203
+ /** Enable SGR mouse reporting while fullscreen. Defaults to true. */
204
+ mouseTracking?: boolean;
197
205
  // === Sizing ===
198
206
  /** Width in columns, or percentage of terminal width (e.g., "50%") */
199
207
  width?: SizeValue;
@@ -313,6 +321,11 @@ export class TUI extends Container {
313
321
  #maxLinesRendered = 0; // High-water line count used for clear-on-shrink policy
314
322
  #fullRedrawCount = 0;
315
323
  #stopped = false;
324
+ #fullscreenActive = false;
325
+ #mouseTrackingActive = false;
326
+ #fullscreenLines: string[] = [];
327
+ #fullscreenWidth = 0;
328
+ #fullscreenHeight = 0;
316
329
  #viewportObservers = new Map<number, { callback: (visible: boolean) => void; visible: boolean }>();
317
330
  #nextViewportObserverId = 1;
318
331
 
@@ -679,6 +692,7 @@ export class TUI extends Container {
679
692
  stop(): void {
680
693
  this.#clearSixelProbeState();
681
694
  this.#stopped = true;
695
+ this.#leaveFullscreen();
682
696
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
683
697
  if (this.#previousLines.length > 0) {
684
698
  const targetRow = this.#previousLines.length; // Line after the last content
@@ -771,6 +785,21 @@ export class TUI extends Container {
771
785
  }
772
786
  }
773
787
 
788
+ const focusedComponent = this.#focusedComponent as (Component & Partial<MouseRoutable>) | null;
789
+ const routeMouse = focusedComponent?.routeMouse;
790
+ if (
791
+ this.#fullscreenActive &&
792
+ focusedComponent &&
793
+ typeof routeMouse === "function" &&
794
+ routeSgrMouseInput(data, event => {
795
+ routeMouse.call(focusedComponent, event, event.row, event.col);
796
+ return true;
797
+ })
798
+ ) {
799
+ this.requestRender();
800
+ return;
801
+ }
802
+
774
803
  // Pass input to focused component (including Ctrl+C)
775
804
  // The focused component can decide how to handle Ctrl+C
776
805
  if (this.#focusedComponent?.handleInput) {
@@ -1126,6 +1155,27 @@ export class TUI extends Container {
1126
1155
  if (this.#stopped) return;
1127
1156
  const width = this.terminal.columns;
1128
1157
  const height = this.terminal.rows;
1158
+ const topOverlay = this.#getTopmostVisibleOverlay();
1159
+ const wantFullscreen = topOverlay?.options?.fullscreen === true;
1160
+ const wantMouse = wantFullscreen && topOverlay.options?.mouseTracking !== false;
1161
+ if (wantFullscreen && topOverlay) {
1162
+ if (!this.#fullscreenActive) {
1163
+ this.terminal.write(`\x1b[?1049h${wantMouse ? MOUSE_TRACKING_ON : ""}`);
1164
+ setAlternateScreenActive(true);
1165
+ this.#fullscreenActive = true;
1166
+ this.#mouseTrackingActive = wantMouse;
1167
+ this.#fullscreenLines = [];
1168
+ } else if (wantMouse !== this.#mouseTrackingActive) {
1169
+ this.terminal.write(wantMouse ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
1170
+ this.#mouseTrackingActive = wantMouse;
1171
+ }
1172
+ this.#renderFullscreen(topOverlay.component, width, height);
1173
+ return;
1174
+ }
1175
+ const restoredFromFullscreen = this.#fullscreenActive;
1176
+ if (restoredFromFullscreen) {
1177
+ this.#leaveFullscreen();
1178
+ }
1129
1179
  let viewportTop = Math.max(0, this.#maxLinesRendered - height);
1130
1180
  let prevViewportTop = this.#viewportTopRow;
1131
1181
  let hardwareCursorRow = this.#hardwareCursorRow;
@@ -1164,11 +1214,11 @@ export class TUI extends Container {
1164
1214
  const heightChanged = this.#previousHeight !== 0 && this.#previousHeight !== height;
1165
1215
 
1166
1216
  // Helper to clear scrollback and viewport and render all new lines
1167
- const fullRender = (clear: boolean): void => {
1217
+ const fullRender = (clear: boolean, preserveScrollback = false): void => {
1168
1218
  this.#fullRedrawCount += 1;
1169
1219
  let buffer = "\x1b[?2026h"; // Begin synchronized output
1170
1220
  // Skip clearing scrollback (3J) in multiplexers — users actively navigate scrollback history
1171
- if (clear) buffer += isMultiplexer() ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1221
+ if (clear) buffer += isMultiplexer() || preserveScrollback ? "\x1b[2J\x1b[H" : "\x1b[2J\x1b[H\x1b[3J";
1172
1222
  const reset = SEGMENT_RESET;
1173
1223
  for (let i = 0; i < newLines.length; i++) {
1174
1224
  if (i > 0) buffer += "\r\n";
@@ -1210,7 +1260,7 @@ export class TUI extends Container {
1210
1260
  // Width changes always need a full re-render because wrapping changes.
1211
1261
  if (widthChanged) {
1212
1262
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1213
- fullRender(true);
1263
+ fullRender(true, restoredFromFullscreen);
1214
1264
  return;
1215
1265
  }
1216
1266
 
@@ -1219,7 +1269,7 @@ export class TUI extends Container {
1219
1269
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1220
1270
  if (heightChanged && !isTermuxSession() && !isMultiplexer()) {
1221
1271
  logRedraw(`terminal height changed (${this.#previousHeight} -> ${height})`);
1222
- fullRender(true);
1272
+ fullRender(true, restoredFromFullscreen);
1223
1273
  return;
1224
1274
  }
1225
1275
 
@@ -1423,6 +1473,47 @@ export class TUI extends Container {
1423
1473
  this.#previousHeight = height;
1424
1474
  }
1425
1475
 
1476
+ #renderFullscreen(component: Component, width: number, height: number): void {
1477
+ const rendered = component
1478
+ .render(width)
1479
+ .slice(0, height)
1480
+ .map(line => {
1481
+ const withoutCursor = line.replaceAll(CURSOR_MARKER, "");
1482
+ return visibleWidth(withoutCursor) > width ? sliceByColumn(withoutCursor, 0, width, true) : withoutCursor;
1483
+ });
1484
+ const frame = Array.from({ length: height }, (_, row) => rendered[row] ?? "");
1485
+ if (
1486
+ frame.length === this.#fullscreenLines.length &&
1487
+ width === this.#fullscreenWidth &&
1488
+ height === this.#fullscreenHeight &&
1489
+ frame.every((line, row) => line === this.#fullscreenLines[row])
1490
+ ) {
1491
+ return;
1492
+ }
1493
+ let buffer = "\x1b[?2026h\x1b[H";
1494
+ for (let row = 0; row < height; row++) {
1495
+ if (row > 0) buffer += "\r\n";
1496
+ buffer += `\x1b[2K${frame[row]}${SEGMENT_RESET}`;
1497
+ }
1498
+ buffer += "\x1b[?2026l";
1499
+ this.terminal.hideCursor();
1500
+ this.terminal.write(buffer);
1501
+ this.#fullscreenLines = frame;
1502
+ this.#fullscreenWidth = width;
1503
+ this.#fullscreenHeight = height;
1504
+ }
1505
+
1506
+ #leaveFullscreen(): void {
1507
+ if (!this.#fullscreenActive) return;
1508
+ this.terminal.write(`${this.#mouseTrackingActive ? MOUSE_TRACKING_OFF : ""}\x1b[?1049l`);
1509
+ setAlternateScreenActive(false);
1510
+ this.#fullscreenActive = false;
1511
+ this.#mouseTrackingActive = false;
1512
+ this.#fullscreenLines = [];
1513
+ this.#fullscreenWidth = 0;
1514
+ this.#fullscreenHeight = 0;
1515
+ }
1516
+
1426
1517
  /**
1427
1518
  * Position the hardware cursor for IME candidate window.
1428
1519
  * @param cursorPos The cursor position extracted from rendered output, or null