@f5-sales-demo/pi-tui 21.17.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 +7 -0
- package/package.json +3 -3
- package/src/components/markdown.ts +108 -6
- package/src/terminal-capabilities.ts +70 -3
- package/src/utils.ts +1 -15
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
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)).
|
|
9
16
|
- Added shared SGR mouse routing and fullscreen alternate-screen ownership for interactive overlays,
|
|
10
17
|
including horizontal-wheel filtering, Herdr-safe redraw behavior, and idempotent mouse/screen
|
|
11
18
|
restoration. Semantically ported from [can1357/oh-my-pi#3402](https://github.com/can1357/oh-my-pi/pull/3402),
|
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
|
}
|
|
@@ -230,13 +230,14 @@ export function resolveTerminalId(env: NodeJS.ProcessEnv): TerminalId {
|
|
|
230
230
|
/** Resolve terminal capabilities from an explicit environment and stdout TTY state. */
|
|
231
231
|
export function resolveTerminalInfo(env: NodeJS.ProcessEnv, stdoutIsTTY: boolean): TerminalInfo {
|
|
232
232
|
const terminal = getTerminalInfo(resolveTerminalId(env));
|
|
233
|
+
const hyperlinks = shouldEnableTerminalHyperlinks(env, stdoutIsTTY, terminal.id);
|
|
233
234
|
const forcedImageProtocol = getForcedImageProtocol(env);
|
|
234
235
|
if (forcedImageProtocol !== undefined) {
|
|
235
236
|
return new TerminalInfo(
|
|
236
237
|
terminal.id,
|
|
237
238
|
forcedImageProtocol,
|
|
238
239
|
terminal.trueColor,
|
|
239
|
-
|
|
240
|
+
hyperlinks,
|
|
240
241
|
terminal.notifyProtocol,
|
|
241
242
|
);
|
|
242
243
|
}
|
|
@@ -247,12 +248,72 @@ export function resolveTerminalInfo(env: NodeJS.ProcessEnv, stdoutIsTTY: boolean
|
|
|
247
248
|
terminal.id,
|
|
248
249
|
fallbackImageProtocol,
|
|
249
250
|
terminal.trueColor,
|
|
250
|
-
|
|
251
|
+
hyperlinks,
|
|
251
252
|
terminal.notifyProtocol,
|
|
252
253
|
);
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
|
-
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";
|
|
256
317
|
}
|
|
257
318
|
|
|
258
319
|
export const TERMINAL_ID: TerminalId = resolveTerminalId(Bun.env);
|
|
@@ -261,6 +322,7 @@ export const TERMINAL = resolveTerminalInfo(Bun.env, process.stdout.isTTY ?? fal
|
|
|
261
322
|
|
|
262
323
|
type MutableTerminalInfo = {
|
|
263
324
|
imageProtocol: ImageProtocol | null;
|
|
325
|
+
hyperlinks: boolean;
|
|
264
326
|
};
|
|
265
327
|
|
|
266
328
|
/**
|
|
@@ -270,6 +332,11 @@ export function setTerminalImageProtocol(imageProtocol: ImageProtocol | null): v
|
|
|
270
332
|
(TERMINAL as unknown as MutableTerminalInfo).imageProtocol = imageProtocol;
|
|
271
333
|
}
|
|
272
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
|
+
|
|
273
340
|
export function getTerminalInfo(terminalId: TerminalId): TerminalInfo {
|
|
274
341
|
return KNOWN_TERMINALS[terminalId];
|
|
275
342
|
}
|
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(
|