@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 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.17.0",
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.17.0",
41
- "@f5-sales-demo/pi-utils": "21.17.0",
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 formatHyperlink(text: string, target: string): string {
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.replaceAll("\x1b", "").replaceAll("\x07", "");
349
+ const safeTarget = safeHyperlinkTarget(target, linkBasePath);
252
350
  if (!safeTarget) {
253
351
  return text;
254
352
  }
255
353
 
256
- return `\x1b]8;;${safeTarget}\x07${text}\x1b]8;;\x07`;
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 += clickableLinkText + formatHyperlink(styledLinkUrl, token.href) + stylePrefix;
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
- terminal.hyperlinks,
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
- terminal.hyperlinks,
251
+ hyperlinks,
251
252
  terminal.notifyProtocol,
252
253
  );
253
254
  }
254
255
  }
255
- return terminal;
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 fixOsc8Boundaries(nativeWrapTextWithAnsi(text, width, getDefaultTabWidth()));
28
+ return nativeWrapTextWithAnsi(text, width, getDefaultTabWidth());
43
29
  }
44
30
 
45
31
  export function extractSegments(