@f5-sales-demo/pi-tui 21.16.0 → 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 +11 -0
- package/package.json +3 -3
- package/src/components/select-list.ts +41 -2
- package/src/index.ts +2 -0
- package/src/mouse.ts +61 -0
- package/src/terminal-capabilities.ts +59 -3
- package/src/terminal-multiplexer.ts +12 -0
- package/src/terminal.ts +16 -1
- package/src/tui.ts +97 -6
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.
|
|
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.
|
|
41
|
-
"@f5-sales-demo/pi-utils": "21.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|