@f5-sales-demo/pi-tui 21.16.0 → 21.18.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 +18 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +108 -6
- 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 +129 -6
- package/src/terminal-multiplexer.ts +12 -0
- package/src/terminal.ts +16 -1
- package/src/tui.ts +97 -6
- package/src/utils.ts +1 -15
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@
|
|
|
6
6
|
|
|
7
7
|
### Fixed
|
|
8
8
|
|
|
9
|
+
- Added conservative runtime OSC 8 capability control, safe Markdown link extraction and targets,
|
|
10
|
+
ST normalization, and physical-wrap link isolation. Semantically ported from
|
|
11
|
+
[can1357/oh-my-pi#10196](https://github.com/can1357/oh-my-pi/pull/10196),
|
|
12
|
+
[#5887](https://github.com/can1357/oh-my-pi/pull/5887),
|
|
13
|
+
[#6282](https://github.com/can1357/oh-my-pi/pull/6282), and
|
|
14
|
+
[#11086](https://github.com/can1357/oh-my-pi/pull/11086)
|
|
15
|
+
([#3758](https://github.com/f5-sales-demo/xcsh/issues/3758)).
|
|
16
|
+
- Added shared SGR mouse routing and fullscreen alternate-screen ownership for interactive overlays,
|
|
17
|
+
including horizontal-wheel filtering, Herdr-safe redraw behavior, and idempotent mouse/screen
|
|
18
|
+
restoration. Semantically ported from [can1357/oh-my-pi#3402](https://github.com/can1357/oh-my-pi/pull/3402),
|
|
19
|
+
[#7810](https://github.com/can1357/oh-my-pi/pull/7810),
|
|
20
|
+
[#8433](https://github.com/can1357/oh-my-pi/pull/8433), and
|
|
21
|
+
[#11045](https://github.com/can1357/oh-my-pi/pull/11045)
|
|
22
|
+
([#3748](https://github.com/f5-sales-demo/xcsh/issues/3748)).
|
|
23
|
+
- Delivered structured completion, request, and error notifications to validated Herdr panes while
|
|
24
|
+
preserving existing terminal fallbacks and explicit Kitty graphics negotiation. Semantically
|
|
25
|
+
ported from [can1357/oh-my-pi#11054](https://github.com/can1357/oh-my-pi/pull/11054)
|
|
26
|
+
([#3748](https://github.com/f5-sales-demo/xcsh/issues/3748)).
|
|
9
27
|
- Bounded oversized ANSI-heavy render rows before width measurement while preserving visible text,
|
|
10
28
|
complete OSC 66 payloads, and reset semantics. Semantically ported from
|
|
11
29
|
[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.18.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.18.0",
|
|
41
|
+
"@f5-sales-demo/pi-utils": "21.18.0",
|
|
42
42
|
"marked": "^18.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
1
3
|
import { Marked, type Token, type TokenizerExtension, type Tokens } from "marked";
|
|
2
4
|
import { renderLatex } from "../latex";
|
|
3
5
|
import type { SymbolTheme } from "../symbols";
|
|
@@ -147,6 +149,74 @@ const markdownParser = new Marked({ gfm: true, async: false }).use({
|
|
|
147
149
|
extensions: [...LATEX_MARKDOWN_EXTENSIONS],
|
|
148
150
|
});
|
|
149
151
|
|
|
152
|
+
const OSC8_SEQUENCE_RE = /\x1b\]8;[^\x07\x1b]*(?:\x07|\x1b\\)/gu;
|
|
153
|
+
const OSC8_ST_PREFIX_RE = /(\x1b\]8;[^\x07\x1b]*)\x1b\\/gu;
|
|
154
|
+
const UNSAFE_URI_BYTES_RE = /[\x00-\x1f\x7f-\x9f]/u;
|
|
155
|
+
|
|
156
|
+
function normalizeOsc8Terminators(text: string): string {
|
|
157
|
+
return text.replace(OSC8_ST_PREFIX_RE, "$1\x07");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function plainInlineTokens(tokens: readonly Token[] | undefined): string {
|
|
161
|
+
if (!tokens) return "";
|
|
162
|
+
let result = "";
|
|
163
|
+
for (const token of tokens) {
|
|
164
|
+
const nestedTokens = (token as Token & { tokens?: Token[] }).tokens;
|
|
165
|
+
if (token.type === "image") continue;
|
|
166
|
+
if (token.type === "codespan") {
|
|
167
|
+
result += token.text;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (token.type === "br") {
|
|
171
|
+
result += " ";
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (token.type === "text" || token.type === "escape") {
|
|
175
|
+
result += nestedTokens?.length ? plainInlineTokens(nestedTokens) : token.text;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
result += plainInlineTokens(nestedTokens);
|
|
179
|
+
}
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A hyperlink exactly as the configured Markdown lexer resolves it. */
|
|
184
|
+
export interface MarkdownLink {
|
|
185
|
+
text: string;
|
|
186
|
+
href: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Extract rendered links in document order, retaining duplicates and excluding code and images. */
|
|
190
|
+
export function extractMarkdownLinks(text: string): MarkdownLink[] {
|
|
191
|
+
const links: MarkdownLink[] = [];
|
|
192
|
+
const walk = (tokens: readonly Token[] | undefined): void => {
|
|
193
|
+
if (!tokens) return;
|
|
194
|
+
for (const token of tokens) {
|
|
195
|
+
if (token.type === "code" || token.type === "codespan" || token.type === "image") continue;
|
|
196
|
+
if (token.type === "link") {
|
|
197
|
+
const link = token as Tokens.Link;
|
|
198
|
+
if (typeof link.href === "string" && link.href.length > 0) {
|
|
199
|
+
const label = plainInlineTokens(link.tokens).replace(/\s+/gu, " ").trim();
|
|
200
|
+
links.push({ text: label || link.href, href: link.href });
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const nested = token as unknown as {
|
|
205
|
+
tokens?: Token[];
|
|
206
|
+
items?: Array<{ tokens?: Token[] }>;
|
|
207
|
+
header?: Array<{ tokens?: Token[] }>;
|
|
208
|
+
rows?: Array<Array<{ tokens?: Token[] }>>;
|
|
209
|
+
};
|
|
210
|
+
walk(nested.tokens);
|
|
211
|
+
if (nested.items) for (const item of nested.items) walk(item.tokens);
|
|
212
|
+
if (nested.header) for (const cell of nested.header) walk(cell.tokens);
|
|
213
|
+
if (nested.rows) for (const row of nested.rows) for (const cell of row) walk(cell.tokens);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
walk(markdownParser.lexer(normalizeOsc8Terminators(text)));
|
|
217
|
+
return links;
|
|
218
|
+
}
|
|
219
|
+
|
|
150
220
|
/**
|
|
151
221
|
* Default text styling for markdown content.
|
|
152
222
|
* Applied to all text unless overridden by markdown formatting.
|
|
@@ -172,6 +242,8 @@ export interface MarkdownOptions {
|
|
|
172
242
|
codeBlockIndent?: number;
|
|
173
243
|
/** Resolve and render Markdown image tokens. */
|
|
174
244
|
mediaOptions?: MarkdownMediaOptions;
|
|
245
|
+
/** Session cwd used to turn relative Markdown paths into lexical file:// targets. */
|
|
246
|
+
linkBasePath?: string;
|
|
175
247
|
}
|
|
176
248
|
|
|
177
249
|
/** Default text styling for markdown content. */
|
|
@@ -243,17 +315,44 @@ function isImageToken(token: Token): token is Tokens.Image {
|
|
|
243
315
|
);
|
|
244
316
|
}
|
|
245
317
|
|
|
246
|
-
function
|
|
318
|
+
function safeHyperlinkTarget(target: string, linkBasePath?: string): string | undefined {
|
|
319
|
+
if (!target || UNSAFE_URI_BYTES_RE.test(target)) return undefined;
|
|
320
|
+
try {
|
|
321
|
+
if (UNSAFE_URI_BYTES_RE.test(decodeURIComponent(target))) return undefined;
|
|
322
|
+
} catch {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const absolute = new URL(target);
|
|
327
|
+
return absolute.protocol === "http:" || absolute.protocol === "https:" || absolute.protocol === "file:"
|
|
328
|
+
? absolute.href
|
|
329
|
+
: undefined;
|
|
330
|
+
} catch {
|
|
331
|
+
if (!linkBasePath || /^(?:#|\?|\/\/)/u.test(target)) return undefined;
|
|
332
|
+
const suffixIndex = target.search(/[?#]/u);
|
|
333
|
+
const rawPath = suffixIndex < 0 ? target : target.slice(0, suffixIndex);
|
|
334
|
+
const suffix = suffixIndex < 0 ? "" : target.slice(suffixIndex);
|
|
335
|
+
if (!rawPath) return undefined;
|
|
336
|
+
try {
|
|
337
|
+
return `${pathToFileURL(path.resolve(linkBasePath, decodeURIComponent(rawPath))).href}${suffix}`;
|
|
338
|
+
} catch {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function formatHyperlink(text: string, target: string, linkBasePath?: string): string {
|
|
247
345
|
if (!TERMINAL.hyperlinks || !target) {
|
|
248
346
|
return text;
|
|
249
347
|
}
|
|
250
348
|
|
|
251
|
-
const safeTarget = target
|
|
349
|
+
const safeTarget = safeHyperlinkTarget(target, linkBasePath);
|
|
252
350
|
if (!safeTarget) {
|
|
253
351
|
return text;
|
|
254
352
|
}
|
|
255
353
|
|
|
256
|
-
|
|
354
|
+
const safeLabel = text.replace(OSC8_SEQUENCE_RE, "");
|
|
355
|
+
return `\x1b]8;;${safeTarget}\x07${safeLabel}\x1b]8;;\x07`;
|
|
257
356
|
}
|
|
258
357
|
|
|
259
358
|
export class Markdown implements Component {
|
|
@@ -267,6 +366,7 @@ export class Markdown implements Component {
|
|
|
267
366
|
#codeBlockIndent: number;
|
|
268
367
|
#mediaOptions?: MarkdownMediaOptions;
|
|
269
368
|
#renderLatex: boolean;
|
|
369
|
+
#linkBasePath?: string;
|
|
270
370
|
#mediaCache = new Map<
|
|
271
371
|
string,
|
|
272
372
|
{ status: "pending" } | { status: "loaded"; media: ResolvedMarkdownMedia } | { status: "error"; message: string }
|
|
@@ -318,6 +418,7 @@ export class Markdown implements Component {
|
|
|
318
418
|
this.#codeBlockIndent = Math.max(0, Math.floor(options.codeBlockIndent ?? 2));
|
|
319
419
|
this.#mediaOptions = options.mediaOptions;
|
|
320
420
|
this.#renderLatex = options.renderLatex !== false;
|
|
421
|
+
this.#linkBasePath = options.linkBasePath;
|
|
321
422
|
}
|
|
322
423
|
|
|
323
424
|
setText(text: string): void {
|
|
@@ -351,7 +452,7 @@ export class Markdown implements Component {
|
|
|
351
452
|
}
|
|
352
453
|
|
|
353
454
|
// Replace tabs with 3 spaces for consistent rendering
|
|
354
|
-
const normalizedText = replaceTabs(this.#text);
|
|
455
|
+
const normalizedText = normalizeOsc8Terminators(replaceTabs(this.#text));
|
|
355
456
|
|
|
356
457
|
// Parse markdown to HTML-like tokens
|
|
357
458
|
const tokens = markdownParser.lexer(normalizedText);
|
|
@@ -820,7 +921,7 @@ export class Markdown implements Component {
|
|
|
820
921
|
case "link": {
|
|
821
922
|
const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
|
|
822
923
|
const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
|
|
823
|
-
const clickableLinkText = formatHyperlink(styledLinkText, token.href);
|
|
924
|
+
const clickableLinkText = formatHyperlink(styledLinkText, token.href, this.#linkBasePath);
|
|
824
925
|
// If link text matches href, only show the link once
|
|
825
926
|
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
|
|
826
927
|
// For mailto: links, strip the prefix before comparing (autolinked emails have
|
|
@@ -830,7 +931,8 @@ export class Markdown implements Component {
|
|
|
830
931
|
result += clickableLinkText + stylePrefix;
|
|
831
932
|
else {
|
|
832
933
|
const styledLinkUrl = this.#theme.linkUrl(` (${token.href})`);
|
|
833
|
-
result +=
|
|
934
|
+
result +=
|
|
935
|
+
clickableLinkText + formatHyperlink(styledLinkUrl, token.href, this.#linkBasePath) + stylePrefix;
|
|
834
936
|
}
|
|
835
937
|
break;
|
|
836
938
|
}
|
|
@@ -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
|
}
|
|
@@ -174,13 +230,14 @@ export function resolveTerminalId(env: NodeJS.ProcessEnv): TerminalId {
|
|
|
174
230
|
/** Resolve terminal capabilities from an explicit environment and stdout TTY state. */
|
|
175
231
|
export function resolveTerminalInfo(env: NodeJS.ProcessEnv, stdoutIsTTY: boolean): TerminalInfo {
|
|
176
232
|
const terminal = getTerminalInfo(resolveTerminalId(env));
|
|
233
|
+
const hyperlinks = shouldEnableTerminalHyperlinks(env, stdoutIsTTY, terminal.id);
|
|
177
234
|
const forcedImageProtocol = getForcedImageProtocol(env);
|
|
178
235
|
if (forcedImageProtocol !== undefined) {
|
|
179
236
|
return new TerminalInfo(
|
|
180
237
|
terminal.id,
|
|
181
238
|
forcedImageProtocol,
|
|
182
239
|
terminal.trueColor,
|
|
183
|
-
|
|
240
|
+
hyperlinks,
|
|
184
241
|
terminal.notifyProtocol,
|
|
185
242
|
);
|
|
186
243
|
}
|
|
@@ -191,12 +248,72 @@ export function resolveTerminalInfo(env: NodeJS.ProcessEnv, stdoutIsTTY: boolean
|
|
|
191
248
|
terminal.id,
|
|
192
249
|
fallbackImageProtocol,
|
|
193
250
|
terminal.trueColor,
|
|
194
|
-
|
|
251
|
+
hyperlinks,
|
|
195
252
|
terminal.notifyProtocol,
|
|
196
253
|
);
|
|
197
254
|
}
|
|
198
255
|
}
|
|
199
|
-
return
|
|
256
|
+
return new TerminalInfo(
|
|
257
|
+
terminal.id,
|
|
258
|
+
terminal.imageProtocol,
|
|
259
|
+
terminal.trueColor,
|
|
260
|
+
hyperlinks,
|
|
261
|
+
terminal.notifyProtocol,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function versionAtLeast(raw: string | undefined, major: number, minor: number, patch = 0): boolean {
|
|
266
|
+
if (!raw) return false;
|
|
267
|
+
const match = /^(\d+)\.(\d+)(?:\.(\d+))?/u.exec(raw.trim());
|
|
268
|
+
if (!match) return false;
|
|
269
|
+
const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)];
|
|
270
|
+
const wanted = [major, minor, patch];
|
|
271
|
+
for (let index = 0; index < wanted.length; index++) {
|
|
272
|
+
if (actual[index] !== wanted[index]) return actual[index]! > wanted[index]!;
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function localCommandVersion(env: NodeJS.ProcessEnv, command: "herdr" | "tmux"): string | undefined {
|
|
278
|
+
if (env !== Bun.env) return undefined;
|
|
279
|
+
try {
|
|
280
|
+
const result = Bun.spawnSync([command, command === "tmux" ? "-V" : "--version"], {
|
|
281
|
+
stdin: "ignore",
|
|
282
|
+
stdout: "pipe",
|
|
283
|
+
stderr: "ignore",
|
|
284
|
+
});
|
|
285
|
+
if (result.exitCode !== 0) return undefined;
|
|
286
|
+
return /(\d+\.\d+(?:\.\d+)?(?:[-+._A-Za-z0-9]*)?)/u.exec(result.stdout.toString())?.[1];
|
|
287
|
+
} catch {
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Resolve the immutable startup OSC 8 capability snapshot.
|
|
294
|
+
*
|
|
295
|
+
* Unknown terminals and lossy multiplexers are deliberately conservative.
|
|
296
|
+
* Herdr and tmux are enabled only at versions that preserve OSC 8 spans.
|
|
297
|
+
*/
|
|
298
|
+
function shouldEnableTerminalHyperlinks(
|
|
299
|
+
env: NodeJS.ProcessEnv,
|
|
300
|
+
stdoutIsTTY: boolean,
|
|
301
|
+
terminalId: TerminalId = resolveTerminalId(env),
|
|
302
|
+
): boolean {
|
|
303
|
+
if (!stdoutIsTTY || env.NO_COLOR) return false;
|
|
304
|
+
if (env.STY || env.ZELLIJ) return false;
|
|
305
|
+
if (isInsideHerdr(env)) return versionAtLeast(env.HERDR_VERSION ?? localCommandVersion(env, "herdr"), 0, 7, 5);
|
|
306
|
+
if (env.TMUX) {
|
|
307
|
+
const declaredVersion =
|
|
308
|
+
env.TMUX_VERSION ?? (env.TERM_PROGRAM?.toLowerCase() === "tmux" ? env.TERM_PROGRAM_VERSION : undefined);
|
|
309
|
+
return versionAtLeast(declaredVersion ?? localCommandVersion(env, "tmux"), 3, 4);
|
|
310
|
+
}
|
|
311
|
+
const term = env.TERM?.toLowerCase() ?? "";
|
|
312
|
+
if (term.startsWith("screen") || term.startsWith("tmux") || term.startsWith("zellij")) return false;
|
|
313
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
|
|
314
|
+
return terminalId !== "base" && terminalId !== "trueColor";
|
|
315
|
+
}
|
|
316
|
+
return terminalId !== "base" && terminalId !== "trueColor";
|
|
200
317
|
}
|
|
201
318
|
|
|
202
319
|
export const TERMINAL_ID: TerminalId = resolveTerminalId(Bun.env);
|
|
@@ -205,6 +322,7 @@ export const TERMINAL = resolveTerminalInfo(Bun.env, process.stdout.isTTY ?? fal
|
|
|
205
322
|
|
|
206
323
|
type MutableTerminalInfo = {
|
|
207
324
|
imageProtocol: ImageProtocol | null;
|
|
325
|
+
hyperlinks: boolean;
|
|
208
326
|
};
|
|
209
327
|
|
|
210
328
|
/**
|
|
@@ -214,6 +332,11 @@ export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): v
|
|
|
214
332
|
(TERMINAL as unknown as MutableTerminalInfo).imageProtocol = imageProtocol;
|
|
215
333
|
}
|
|
216
334
|
|
|
335
|
+
/** Override OSC 8 hyperlink emission after applying the user's policy. */
|
|
336
|
+
export function setTerminalHyperlinks(enabled: boolean): void {
|
|
337
|
+
(TERMINAL as unknown as MutableTerminalInfo).hyperlinks = enabled;
|
|
338
|
+
}
|
|
339
|
+
|
|
217
340
|
export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
|
|
218
341
|
return KNOWN_TERMINALS[terminalId];
|
|
219
342
|
}
|
|
@@ -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
|
package/src/utils.ts
CHANGED
|
@@ -24,22 +24,8 @@ export function truncateToWidth(
|
|
|
24
24
|
return nativeTruncateToWidth(text, maxWidth, ellipsisKind, pad, getDefaultTabWidth());
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
const OSC8_RE = /\x1b\]8;;([^\x07]*)\x07/g;
|
|
28
|
-
|
|
29
|
-
function fixOsc8Boundaries(lines: string[]): string[] {
|
|
30
|
-
let activeUrl: string | null = null;
|
|
31
|
-
return lines.map(line => {
|
|
32
|
-
const prefix = activeUrl !== null ? `\x1b]8;;${activeUrl}\x07` : "";
|
|
33
|
-
for (const match of line.matchAll(OSC8_RE)) {
|
|
34
|
-
activeUrl = match[1] === "" ? null : match[1];
|
|
35
|
-
}
|
|
36
|
-
const suffix = activeUrl !== null ? `\x1b]8;;\x07` : "";
|
|
37
|
-
return prefix + line + suffix;
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
27
|
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
|
42
|
-
return
|
|
28
|
+
return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth());
|
|
43
29
|
}
|
|
44
30
|
|
|
45
31
|
export function extractSegments(
|