@linxiraos/pi-tui 1.1.7 → 1.1.8

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.
@@ -137,7 +137,7 @@ function normalizeHtmlEntitiesForTerminal(raw: string): string {
137
137
  if (Number.isFinite(value) && value >= 0 && value <= 0x10ffff) {
138
138
  try {
139
139
  return String.fromCodePoint(value);
140
- } catch (_) {
140
+ } catch {
141
141
  // Fallback to empty string or original if invalid codepoint
142
142
  }
143
143
  }
@@ -261,7 +261,7 @@ function normalizeHtmlForTerminal(
261
261
  }
262
262
  lastIndex = index + tag.length;
263
263
 
264
- const isClosing = /^<\//.test(tag);
264
+ const isClosing = tag.startsWith("</");
265
265
  const isSelfClosing = /\/\s*>$/.test(tag);
266
266
 
267
267
  switch (name) {
@@ -794,7 +794,7 @@ export function urlTokenPossible(src: string): boolean {
794
794
  }
795
795
  if (i === 0) return false;
796
796
  if (i >= URL_GATE_EMAIL_SCAN_LIMIT) return true; // over-long run: give up conservatively
797
- return src.charCodeAt(i) === 64 /* @ */;
797
+ return src.charCodeAt(i) === 64; /* @ */
798
798
  }
799
799
 
800
800
  // Setext-underline pre-gate for marked's `lheading` rule. The rule's lazy body
@@ -1131,7 +1131,7 @@ function listMayContinueAt(text: string, tailStart: number, listRaw: string): bo
1131
1131
  // bare newline, or end-of-input (which appends can still extend).
1132
1132
  if (i >= n) return true;
1133
1133
  const after = text.charCodeAt(i);
1134
- return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a /* \n */;
1134
+ return after === 0x20 /* space */ || after === 0x09 /* tab */ || after === 0x0a; /* \n */
1135
1135
  }
1136
1136
 
1137
1137
  const NO_BLOCK_BOUNDARY = { end: 0, count: 0 } as const;
@@ -2355,8 +2355,11 @@ export class Markdown implements Component {
2355
2355
  }
2356
2356
 
2357
2357
  const recorder: TailRenderRecorder = {
2358
+ // [suppressed] render-cache length preallocation
2358
2359
  rows: new Array(tokens.length - spliceEnd).fill(undefined),
2360
+ // [suppressed] render-cache length preallocation
2359
2361
  raws: new Array(tokens.length - spliceEnd).fill(undefined),
2362
+ // [suppressed] render-cache length preallocation
2360
2363
  nextTypes: new Array(tokens.length - spliceEnd).fill(undefined),
2361
2364
  };
2362
2365
  const fresh = this.#renderContentLines(tokens, spliceEnd, tokens.length, contentWidth, signature, recorder);
@@ -2369,8 +2372,11 @@ export class Markdown implements Component {
2369
2372
  // `start`), so a mostly-frozen document allocates only for the
2370
2373
  // unfrozen tail instead of the whole token list every frame.
2371
2374
  const tailCount = tokens.length - start;
2375
+ // [suppressed] render-cache length preallocation
2372
2376
  const rows: (readonly string[] | undefined)[] = new Array(tailCount).fill(undefined);
2377
+ // [suppressed] render-cache length preallocation
2373
2378
  const raws: (string | undefined)[] = new Array(tailCount).fill(undefined);
2379
+ // [suppressed] render-cache length preallocation
2374
2380
  const nextTypes: (string | undefined)[] = new Array(tailCount).fill(undefined);
2375
2381
  if (cache !== undefined && cache.tokenStart === start) {
2376
2382
  for (let i = start; i < Math.min(cache.cachedThrough, spliceEnd); i++) {
@@ -3155,17 +3161,21 @@ export class Markdown implements Component {
3155
3161
  markHtmlItemWhenContent(token.text);
3156
3162
  const linkText = this.#renderInlineTokens(token.tokens || [], resolvedStyleContext);
3157
3163
  const styledLinkText = this.#theme.link(this.#theme.underline(linkText));
3158
- const clickableLinkText = formatHyperlink(styledLinkText, token.href);
3159
- // If link text matches href, only show the link once
3164
+ const href = typeof token.href === "string" ? token.href : "";
3165
+ const clickableLinkText = formatHyperlink(styledLinkText, href);
3166
+ // If link text matches href, only show the link once. A missing
3167
+ // href (malformed/partial link token) renders as plain link text
3168
+ // instead of crashing the renderer or emitting an empty "()"
3169
+ // (issue #10283).
3160
3170
  // Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
3161
3171
  // For mailto: links, strip the prefix before comparing (autolinked emails have
3162
3172
  // text="foo@bar.com" but href="mailto:foo@bar.com")
3163
- const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
3164
- if (token.text === token.href || token.text === hrefForComparison)
3173
+ const hrefForComparison = href.startsWith("mailto:") ? href.slice(7) : href;
3174
+ if (!href || token.text === href || token.text === hrefForComparison)
3165
3175
  result += clickableLinkText + stylePrefix;
3166
3176
  else {
3167
- const styledLinkUrl = this.#theme.linkUrl(`(${token.href})`);
3168
- result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, token.href)}${stylePrefix}`;
3177
+ const styledLinkUrl = this.#theme.linkUrl(`(${href})`);
3178
+ result += `${clickableLinkText} ${formatHyperlink(styledLinkUrl, href)}${stylePrefix}`;
3169
3179
  }
3170
3180
  break;
3171
3181
  }
@@ -3464,6 +3474,7 @@ export class Markdown implements Component {
3464
3474
  let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
3465
3475
 
3466
3476
  if (minCellsWidth > availableForCells) {
3477
+ // [suppressed] column-width allocation
3467
3478
  minColumnWidths = new Array(numCols).fill(1);
3468
3479
  const remaining = availableForCells - numCols;
3469
3480
 
@@ -207,6 +207,7 @@ export class SelectList implements Component, MouseRoutable {
207
207
  // every count is 1, so visualTotal == #filteredItems and overflow falls
208
208
  // back to the original `N > maxVisible` predicate exactly.
209
209
  const conservativeRowWidth = Math.max(0, width - 1);
210
+ // [suppressed] length preallocation
210
211
  const rowCounts = new Array<number>(this.#filteredItems.length);
211
212
  let visualTotal = 0;
212
213
  for (let i = 0; i < this.#filteredItems.length; i++) {
@@ -20,7 +20,6 @@ export class Spacer implements Component {
20
20
  this.#lines = lines;
21
21
  this.#cached = undefined;
22
22
  }
23
-
24
23
  invalidate(): void {
25
24
  // No cached state to invalidate currently
26
25
  }
@@ -28,6 +27,7 @@ export class Spacer implements Component {
28
27
  render(_width: number): readonly string[] {
29
28
  let cached = this.#cached;
30
29
  if (cached === undefined) {
30
+ // [suppressed] cached line allocation
31
31
  cached = new Array(this.#lines).fill("");
32
32
  this.#cached = cached;
33
33
  }
@@ -168,7 +168,7 @@ export class Text implements Component {
168
168
 
169
169
  const result = [...emptyLines, ...contentLines, ...emptyLines];
170
170
  if (resultWidths !== undefined) {
171
- // Pad rows are exactly `width` cells wide.
171
+ // [suppressed] line-width allocation
172
172
  const emptyWidths = new Array<number>(emptyLines.length).fill(width);
173
173
  publishLineWidths(result, [...emptyWidths, ...resultWidths, ...emptyWidths]);
174
174
  }
package/src/deccara.ts CHANGED
@@ -239,7 +239,9 @@ export interface DeccaraPlan {
239
239
  */
240
240
  export function planDeccaraFills(lines: string[], width: number, firstScreenRow = 0): DeccaraPlan {
241
241
  const n = lines.length;
242
+ // [suppressed] length preallocation
242
243
  const texts: string[] = new Array(n);
244
+ // [suppressed] length preallocation
243
245
  const candidates: (FillCandidate | null)[] = new Array(n);
244
246
 
245
247
  for (let k = 0; k < n; k++) {
package/src/keys.ts CHANGED
@@ -198,7 +198,7 @@ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>;
198
198
  * modifier methods return precisely-typed concatenations (e.g. `Key.ctrl("c")`
199
199
  * is `"ctrl+c"`, not just `string`). This mirrors the upstream
200
200
  * `@mariozechner/pi-tui` `Key` export verbatim so plugins built against any
201
- * scope alias (`@mariozechner`, `@earendil-works`, `@oh-my-pi`) keep working
201
+ * scope alias (`@mariozechner`, `@earendil-works`, `@linxiraos`) keep working
202
202
  * once the specifier shim remaps them to this package.
203
203
  */
204
204
  export const Key = {
@@ -15,6 +15,7 @@
15
15
  * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
16
16
  */
17
17
 
18
+ import { isInsideHerdr, isInsideTerminalMultiplexer } from "./terminal-multiplexer";
18
19
  import { wrapTmuxPassthroughIfNeeded } from "./tmux";
19
20
 
20
21
  /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
@@ -62,12 +63,12 @@ export interface KittyGraphicsFeatures {
62
63
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
63
64
  * U+10EEEE with row/column diacritics).
64
65
  *
65
- * Kitty and Ghostty advertise placeholder support directly. A tmux session
66
+ * Kitty and Ghostty advertise placeholder support directly. A multiplexer
66
67
  * cannot use cursor-positioned placements because the outer terminal does not
67
- * know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
68
- * also opts into placeholders there — matching `timg -pk`. Automatic tmux
69
- * fallback stays off because the unknown outer terminal may render U+10EEEE as
70
- * literal PUA boxes (#1877).
68
+ * know pane scroll/reflow state. An explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
69
+ * opts into placeholders under any multiplexer — matching `timg -pk`.
70
+ * Automatic Herdr fallback stays off because its pane marker does not prove
71
+ * that the experimental Kitty renderer is enabled.
71
72
  *
72
73
  * `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
73
74
  * opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
@@ -78,7 +79,9 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N
78
79
  const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
79
80
  if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
80
81
  if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
81
- if (env.TMUX && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
82
+ const insideMultiplexer = isInsideTerminalMultiplexer(env);
83
+ if (insideMultiplexer && env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase() === "kitty") return true;
84
+ if (isInsideHerdr(env)) return false;
82
85
  return terminalId === "kitty" || terminalId === "ghostty";
83
86
  }
84
87
 
@@ -460,6 +460,7 @@ function gridBox(rows: Box[][], align: (col: number) => CellAlign, gap: (col: nu
460
460
  let ncols = 0;
461
461
  for (const row of rows) ncols = Math.max(ncols, row.length);
462
462
  if (ncols === 0 || rows.length === 0) return textBox("");
463
+ // [suppressed] grid-width allocation
463
464
  const widths = new Array<number>(ncols).fill(0);
464
465
  for (const row of rows) {
465
466
  row.forEach((cell, j) => {
@@ -144,7 +144,7 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
144
144
  return -1;
145
145
  }
146
146
  const capEnd = Math.min(length, pos + MAX_CSI_BYTES);
147
- const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c /* < */;
147
+ const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c; /* < */
148
148
  // No resume hint for CSI: `extractCompleteSequences` records
149
149
  // hints only for OSC/DCS/APC. A partial CSI rescans from its
150
150
  // head, bounded by the tight MAX_CSI_BYTES cap.
@@ -378,9 +378,27 @@ export type StdinBufferOptions = {
378
378
  pasteByteLimit?: number;
379
379
  };
380
380
 
381
+ const KITTY_ENTER = /^\x1b\[13(?:;1)?u/u;
382
+
383
+ /**
384
+ * The Enter keypress that immediately follows a bracketed-paste end marker in
385
+ * the same stdin read: legacy CR/LF, or the kitty CSI-u encoding with no
386
+ * modifier. Nothing else qualifies — any other trailing byte takes the normal
387
+ * data route.
388
+ */
389
+ function leadingEnter(input: string): string | undefined {
390
+ if (input.startsWith("\r") || input.startsWith("\n")) return input[0];
391
+ return KITTY_ENTER.exec(input)?.[0];
392
+ }
393
+
381
394
  export type StdinBufferEventMap = {
382
395
  data: [string];
383
- paste: [string];
396
+ /**
397
+ * A completed bracketed paste. `enter` carries an Enter keypress that shared
398
+ * the paste's stdin read, so the terminal can dispatch paste and submit to
399
+ * the same focused component before a paste-triggered overlay can take focus.
400
+ */
401
+ paste: [content: string, enter?: string];
384
402
  };
385
403
 
386
404
  /**
@@ -580,10 +598,15 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
580
598
  this.#pasteBytes = 0;
581
599
  this.#pendingKittyPrintableCodepoint = undefined;
582
600
 
583
- this.emit("paste", pastedContent);
601
+ // A paste-and-Enter burst (automation, terminals that batch reads) must
602
+ // not split into two dispatches: the paste may open an overlay that
603
+ // would then swallow the Enter meant to submit it.
604
+ const enter = leadingEnter(remaining);
605
+ this.emit("paste", pastedContent, enter);
584
606
 
585
- if (remaining.length > 0) {
586
- this.process(remaining);
607
+ const rest = enter === undefined ? remaining : remaining.slice(enter.length);
608
+ if (rest.length > 0) {
609
+ this.process(rest);
587
610
  }
588
611
  }
589
612
 
@@ -1,5 +1,5 @@
1
1
  import { encodeSixel } from "@linxiraos/pi-natives";
2
- import { $env, isBunTestRuntime, isTerminalHeadless } from "@linxiraos/pi-utils";
2
+ import { $env, isBunTestRuntime, isTerminalHeadless, isWsl } from "@linxiraos/pi-utils";
3
3
  import { sendDesktopNotification, shouldDeliverDesktopNotification } from "./desktop-notify";
4
4
  import {
5
5
  detectKittyUnicodePlaceholdersSupport,
@@ -9,9 +9,11 @@ import {
9
9
  renderKittyPlaceholderLines,
10
10
  setKittyGraphics,
11
11
  } from "./kitty-graphics";
12
+ import { isInsideHerdr, isInsideTerminalMultiplexer } from "./terminal-multiplexer";
12
13
  import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
13
14
  import type { HangulCompatibilityJamoWidth } from "./utils";
14
15
 
16
+ export * from "./terminal-multiplexer";
15
17
  export { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
16
18
 
17
19
  export enum ImageProtocol {
@@ -192,19 +194,6 @@ export class TerminalInfo {
192
194
  }
193
195
  }
194
196
 
195
- /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
196
- export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
197
- // TMUX/STY/ZELLIJ, Herdr, and CMUX workspace/surface/remote-transport
198
- // markers are authoritative session signals. TERM can also survive when those are
199
- // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
200
- // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
201
- // outside a CMUX terminal.
202
- if (env.TMUX || env.STY || env.ZELLIJ || env.HERDR_ENV === "1") return true;
203
- if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
204
- const term = env.TERM?.toLowerCase() ?? "";
205
- return term.startsWith("tmux") || term.startsWith("screen");
206
- }
207
-
208
197
  /**
209
198
  * Whether the agent process is running inside a Zellij session. Read fresh on
210
199
  * each call (like {@link isInsideTmux}) so a session attached/detached mid-run
@@ -300,9 +289,15 @@ function advertisesSynchronizedOutput(termFeatures: string | undefined): boolean
300
289
  * 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
301
290
  * 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
302
291
  * WSL/SSH-fronted host alike.
303
- * 4. Known direct terminals with confirmed support. SSH does *not* disable —
292
+ * 4. Herdr panes. Herdr is otherwise treated as a multiplexer so leaked
293
+ * kitty/ghostty identities cannot enable placeholder graphics, but its
294
+ * pane VTE is libghostty and already suppresses compositing while DEC 2026
295
+ * is set. Leaving sync off lets CUP-diff paints and split write(2) chunks
296
+ * composite as dirty-row patches — the live viewport tears, with the top
297
+ * frozen while only the bottom refreshes.
298
+ * 5. Known direct terminals with confirmed support. SSH does *not* disable —
304
299
  * DEC 2026 passes through SSH when the outer terminal honors it.
305
- * 5. Everything else starts off, including risky multiplexers; the runtime
300
+ * 6. Everything else starts off, including risky multiplexers; the runtime
306
301
  * DECRQM probe upgrades any of them when the terminal actually reports
307
302
  * `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
308
303
  */
@@ -315,6 +310,7 @@ export function shouldEnableSynchronizedOutputByDefault(
315
310
 
316
311
  if (advertisesSynchronizedOutput(env.TERM_FEATURES)) return true;
317
312
  if (env.WT_SESSION) return true;
313
+ if (isInsideHerdr(env)) return true;
318
314
 
319
315
  // Risky multiplexers start off even when an inner terminal id leaks through:
320
316
  // older tmux/screen synchronized-output handling is flaky and a mux may not
@@ -469,8 +465,7 @@ export function resolveWarpImageProtocol(
469
465
  platform: NodeJS.Platform = process.platform,
470
466
  env: NodeJS.ProcessEnv = Bun.env,
471
467
  ): ImageProtocol | null {
472
- const windowsHost =
473
- platform === "win32" || (platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP));
468
+ const windowsHost = platform === "win32" || isWsl(platform, env);
474
469
  return windowsHost ? null : ImageProtocol.Kitty;
475
470
  }
476
471
 
@@ -489,9 +484,9 @@ export function isPaseoEmbedder(env: NodeJS.ProcessEnv = Bun.env): boolean {
489
484
  /**
490
485
  * Resolve the image protocol for a non-forced runtime: static per-terminal
491
486
  * support (with Warp's platform carve-out), then the multiplexer fallback,
492
- * then the Paseo embedder carve-out. `isTTY` is injectable because the
493
- * fallback only fires on a real TTY — a piped subprocess cannot exercise
494
- * that path, so regression tests call this directly.
487
+ * then host carve-outs. `isTTY` is injectable because the fallback only fires
488
+ * on a real TTY — a piped subprocess cannot exercise that path, so regression
489
+ * tests call this directly.
495
490
  */
496
491
  export function resolveImageProtocol(
497
492
  terminalId: TerminalId,
@@ -516,6 +511,12 @@ export function resolveImageProtocol(
516
511
  if (imageProtocol !== null && isPaseoEmbedder(env)) {
517
512
  return null;
518
513
  }
514
+ // Herdr owns the pane grid but does not expose whether the attached client
515
+ // enabled its experimental Kitty renderer. Outer-terminal identity variables
516
+ // can leak into the pane, so only the explicit protocol override is safe.
517
+ if (imageProtocol !== null && isInsideHerdr(env)) {
518
+ return null;
519
+ }
519
520
  return imageProtocol;
520
521
  }
521
522
 
@@ -0,0 +1,24 @@
1
+ /** True when this process is running inside a Herdr pane. */
2
+ export function isInsideHerdr(env: NodeJS.ProcessEnv = Bun.env): boolean {
3
+ // HERDR_ENV=1 is canonical. Identity vars survive env-sanitizing launchers
4
+ // that drop HERDR_ENV. Do not use HERDR_SOCKET_PATH, HERDR_BIN_PATH,
5
+ // HERDR_SESSION, HERDR_CONFIG_PATH, or HERDR_CLIENT_SOCKET_PATH here: they
6
+ // are client-side and can be set outside a Herdr pane, matching the
7
+ // CMUX_SOCKET_PATH warning below.
8
+ if (env.HERDR_ENV === "1") return true;
9
+ if (env.HERDR_PANE_ID || env.HERDR_TAB_ID || env.HERDR_WORKSPACE_ID) return true;
10
+ return false;
11
+ }
12
+
13
+ /** Detect whether a terminal multiplexer owns the current screen grid. */
14
+ export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
15
+ // TMUX/STY/ZELLIJ, Herdr, and CMUX workspace/surface/remote-transport
16
+ // markers are authoritative session signals. TERM can also survive when those are
17
+ // stripped (`sudo` without -E, `su`, env-sanitizing launchers/ssh). Do not
18
+ // use CMUX_SOCKET_PATH here: it is a CLI socket override and can be set
19
+ // outside a CMUX terminal.
20
+ if (env.TMUX || env.STY || env.ZELLIJ || isInsideHerdr(env)) return true;
21
+ if (env.CMUX_WORKSPACE_ID || env.CMUX_SURFACE_ID || env.CMUX_REMOTE_TRANSPORT) return true;
22
+ const term = env.TERM?.toLowerCase() ?? "";
23
+ return term.startsWith("tmux") || term.startsWith("screen");
24
+ }