@oh-my-pi/pi-tui 18.0.6 → 18.0.7

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
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.7] - 2026-08-26
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed the `inlineMathSpanEnd` and `mathStartIndex` exports; the math delimiter grammar now lives in `@oh-my-pi/pi-utils/math-delimiters`.
10
+
11
+ ### Fixed
12
+
13
+ - Math spans now end at the first unescaped delimiter, so a TeX row break no longer closes a span early: `\(a \\) b\)` renders as one equation, and an escaped `\$` no longer ends `$$…$$`.
14
+ - Fixed image previews displaying as garbled characters in Paseo terminals.
15
+ - Fixed terminal resizing from duplicating committed history in native scrollback.
16
+ - Fixed autocomplete suggestions for bare-name skills such as `/batch` when no command matches the prefix more strongly.
17
+
5
18
  ## [18.0.6] - 2026-08-26
6
19
 
7
20
  ### Added
@@ -1,8 +1,6 @@
1
1
  import type { SymbolTheme } from "../symbols.js";
2
2
  import type { Component } from "../tui.js";
3
3
  /** @internal exported for tests — must stay index-identical to the old regex scan. */
4
- export declare function mathStartIndex(src: string): number | undefined;
5
- /** @internal exported for tests — must stay index-identical to the old regex scan. */
6
4
  export declare function autolinkSchemeScanIndex(src: string): number | undefined;
7
5
  /** @internal exported for tests — must never return false for a src the built-in url regex matches. */
8
6
  export declare function urlTokenPossible(src: string): boolean;
@@ -39,12 +39,3 @@ export declare function isBareMathEnvironment(env: string): boolean;
39
39
  * $10" is left untouched.
40
40
  */
41
41
  export declare function renderMathInText(text: string): string;
42
- /**
43
- * Index of the `$` that closes an inline math span opened at `open` (the index
44
- * of the opening `$`), or -1 when the run is not inline math. Applies pandoc's
45
- * anti-currency heuristics: the opener must not be followed by whitespace, the
46
- * closer must not be preceded by whitespace nor followed by a digit, `\$` is a
47
- * literal dollar, and the span may not span a newline. Shared by
48
- * `renderMathInText` and the markdown math tokenizer so the rule has one home.
49
- */
50
- export declare function inlineMathSpanEnd(text: string, open: number): number;
@@ -153,6 +153,23 @@ export declare function shouldEnableHyperlinksByDefault(env?: NodeJS.ProcessEnv,
153
153
  * without mutating `process.platform`.
154
154
  */
155
155
  export declare function resolveWarpImageProtocol(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): ImageProtocol | null;
156
+ /**
157
+ * Paseo (getpaseo/paseo) hardcodes `TERM_PROGRAM=kitty` into every PTY
158
+ * (`buildTerminalEnvironment`) while its xterm.js renderer implements neither
159
+ * Kitty graphics nor Unicode placeholders — trusting the advertisement turns
160
+ * image previews into literal PUA garbage (getpaseo/paseo#3850). Paseo
161
+ * injects `PASEO_TERMINAL_ID` into every terminal it hosts, which makes a
162
+ * reliable embedder signal.
163
+ */
164
+ export declare function isPaseoEmbedder(env?: NodeJS.ProcessEnv): boolean;
165
+ /**
166
+ * Resolve the image protocol for a non-forced runtime: static per-terminal
167
+ * support (with Warp's platform carve-out), then the multiplexer fallback,
168
+ * then the Paseo embedder carve-out. `isTTY` is injectable because the
169
+ * fallback only fires on a real TTY — a piped subprocess cannot exercise
170
+ * that path, so regression tests call this directly.
171
+ */
172
+ export declare function resolveImageProtocol(terminalId: TerminalId, env?: NodeJS.ProcessEnv, isTTY?: boolean): ImageProtocol | null;
156
173
  /** Resolve terminal identity from environment markers used by common emulators. */
157
174
  export declare function detectTerminalId(env?: NodeJS.ProcessEnv): TerminalId;
158
175
  export declare const TERMINAL_ID: TerminalId;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.0.6",
4
+ "version": "18.0.7",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.0.6",
41
- "@oh-my-pi/pi-utils": "18.0.6"
40
+ "@oh-my-pi/pi-natives": "18.0.7",
41
+ "@oh-my-pi/pi-utils": "18.0.7"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -331,10 +331,19 @@ function buildSlashCommandCompletions(
331
331
  let best: (AutocompleteItem & { score: number; usage: number }) | undefined;
332
332
 
333
333
  const isSkillCommand = name.startsWith(SKILL_NAMESPACE);
334
+ // Skills are matched by their bare name as well as the full
335
+ // `skill:` name so a broken-out or mid-prompt skill ranks at
336
+ // prefix strength (`/batch` → `skill:batch`) instead of a weak
337
+ // full-name fuzzy hit.
334
338
  const nameScore =
335
339
  lowerPrefix.length === 0 && isSkillCommand
336
340
  ? 950
337
- : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
341
+ : isSkillCommand
342
+ ? Math.max(
343
+ scoreCommandTextMatch(lowerPrefix, name.toLowerCase()),
344
+ scoreCommandTextMatch(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()),
345
+ )
346
+ : scoreCommandTextMatch(lowerPrefix, name.toLowerCase());
338
347
  const lowerDesc = staticDesc.toLowerCase();
339
348
  const descScore =
340
349
  lowerDesc && fuzzyMatch(lowerPrefix, lowerDesc) ? fuzzyScore(lowerPrefix, lowerDesc) * 0.5 : 0;
@@ -391,23 +400,56 @@ function hasPromptTextBeforeSlash(
391
400
 
392
401
  export const SKILL_NAMESPACE = "skill:";
393
402
 
403
+ /**
404
+ * Match tier used to compare a skill's bare name against non-skill command
405
+ * names when deciding whether the skill may break out of the collapsed
406
+ * `skill:` group: exact (1000) > prefix (900) > anything weaker (0). Fuzzy
407
+ * hits deliberately map to 0 — a fuzzy skill match is never strong enough to
408
+ * mix skills into the command popup.
409
+ */
410
+ function skillBreakoutTier(lowerPrefix: string, lowerTarget: string): number {
411
+ if (lowerPrefix === lowerTarget) return 1000;
412
+ if (lowerTarget.startsWith(lowerPrefix)) return 900;
413
+ return 0;
414
+ }
415
+
394
416
  /**
395
417
  * Collapse `skill:*` commands into a single `/skill:` namespace row while the
396
- * typed prefix has not committed to the namespace. Until the prefix starts
397
- * with `skill:`, individual skills never list — a lone group entry (shown only
398
- * while the prefix is still a prefix of `skill:`) keeps the `/` popup
399
- * readable. Accepting the group inserts `/skill:` without a trailing space so
400
- * the reopened popup expands to the individual skills.
418
+ * typed prefix has not committed to the namespace. A lone group entry (shown
419
+ * only while the prefix is still a prefix of `skill:`) keeps the `/` popup
420
+ * readable. A skill breaks out of the group only when its bare name matches
421
+ * the prefix at a strictly stronger tier than every non-skill command name
422
+ * and alias (`/batch` → `skill:batch` while no command prefix-matches
423
+ * `batch`); a tie keeps the popup command-only, and fuzzy-only skill hits
424
+ * never surface. Accepting the group inserts `/skill:` without a trailing
425
+ * space so the reopened popup expands to the individual skills.
401
426
  */
402
427
  function collapseSkillNamespace(commands: CommandEntry[], lowerPrefix: string): CommandEntry[] {
403
428
  if (lowerPrefix.startsWith(SKILL_NAMESPACE)) return commands;
429
+ const approachesNamespace = SKILL_NAMESPACE.startsWith(lowerPrefix);
430
+ let commandTier = 0;
431
+ if (!approachesNamespace) {
432
+ for (const cmd of commands) {
433
+ const name = getCommandName(cmd);
434
+ if (!name || name.startsWith(SKILL_NAMESPACE)) continue;
435
+ commandTier = Math.max(commandTier, skillBreakoutTier(lowerPrefix, name.toLowerCase()));
436
+ for (const alias of getCommandAliases(cmd)) {
437
+ commandTier = Math.max(commandTier, skillBreakoutTier(lowerPrefix, alias.toLowerCase()));
438
+ }
439
+ if (commandTier === 1000) break;
440
+ }
441
+ }
404
442
  let skillCount = 0;
405
443
  let skillIcon: string | undefined;
406
444
  const rest = commands.filter(cmd => {
407
- if (!getCommandName(cmd)?.startsWith(SKILL_NAMESPACE)) return true;
445
+ const name = getCommandName(cmd);
446
+ if (!name?.startsWith(SKILL_NAMESPACE)) return true;
408
447
  skillCount += 1;
409
448
  skillIcon ??= cmd.icon;
410
- return false;
449
+ return (
450
+ !approachesNamespace &&
451
+ skillBreakoutTier(lowerPrefix, name.slice(SKILL_NAMESPACE.length).toLowerCase()) > commandTier
452
+ );
411
453
  });
412
454
  if (skillCount === 0) return commands;
413
455
  if (!SKILL_NAMESPACE.startsWith(lowerPrefix)) return rest;
@@ -7,8 +7,9 @@ import {
7
7
  type TokenizerAndRendererExtension,
8
8
  type Tokens,
9
9
  } from "@oh-my-pi/pi-utils/marked";
10
+ import { mathBlockAt, mathSpanAt, mathStartIndex } from "@oh-my-pi/pi-utils/math-delimiters";
10
11
  import { latexToBlock } from "../latex-block";
11
- import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
+ import { isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
12
13
  import type { SymbolTheme } from "../symbols";
13
14
  import { TERMINAL } from "../terminal-capabilities";
14
15
  import type { Component } from "../tui";
@@ -528,7 +529,7 @@ markdownParser.setOptions({
528
529
  // `math` inline token before markdown's escape/emphasis/link rules run, so
529
530
  // backslash commands (`\frac`, `\alpha`) and intraword underscores (`x_i`)
530
531
  // survive intact instead of being mangled or split. The `$…$` form uses
531
- // pandoc's anti-currency heuristic (`inlineMathSpanEnd`) so "$5 and $10" is
532
+ // pandoc's anti-currency heuristic (`mathSpanAt`) so "$5 and $10" is
532
533
  // never math. Inline extensions run before marked's escape tokenizer, so
533
534
  // `\(…\)` becomes math while a genuinely escaped `\$` is left to `escape` and
534
535
  // renders as a literal dollar.
@@ -582,78 +583,33 @@ const customHrExtension: TokenizerAndRendererExtension = {
582
583
  },
583
584
  };
584
585
 
585
- // Leftmost-match scan replacing /\$|\\\(|\\\[/ in mathExtension.start —
586
- // marked calls start() on the remaining source at every inline position, so
587
- // the regex alternation showed up in CPU profiles (part of a ~4.3% start()
588
- // tail). Three indexOf scans yield the identical leftmost index.
589
- /** @internal exported for tests — must stay index-identical to the old regex scan. */
590
- export function mathStartIndex(src: string): number | undefined {
591
- let best = src.indexOf("$");
592
- const paren = src.indexOf("\\(");
593
- if (paren !== -1 && (best === -1 || paren < best)) best = paren;
594
- const bracket = src.indexOf("\\[");
595
- if (bracket !== -1 && (best === -1 || bracket < best)) best = bracket;
596
- return best === -1 ? undefined : best;
597
- }
598
-
586
+ // Delimiters come from `@oh-my-pi/pi-utils/math-delimiters`; rendering policy stays here.
599
587
  const mathExtension: TokenizerAndRendererExtension = {
600
588
  name: "math",
601
589
  level: "inline",
602
- start(src) {
603
- return mathStartIndex(src);
604
- },
590
+ start: mathStartIndex,
605
591
  tokenizer(src) {
606
- if (src.startsWith("$$")) {
607
- const end = src.indexOf("$$", 2);
608
- if (end !== -1 && src.slice(2, end).trim().length > 0) {
609
- return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
610
- }
611
- return undefined;
612
- }
613
- if (src.startsWith("\\[")) {
614
- const end = src.indexOf("\\]", 2);
615
- if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: true };
616
- return undefined;
617
- }
618
- if (src.startsWith("\\(")) {
619
- const end = src.indexOf("\\)", 2);
620
- if (end !== -1) return { type: "math", raw: src.slice(0, end + 2), text: src.slice(2, end), display: false };
621
- return undefined;
622
- }
623
- if (src.charCodeAt(0) === 0x24 /* $ */) {
624
- const end = inlineMathSpanEnd(src, 0);
625
- if (end !== -1) return { type: "math", raw: src.slice(0, end + 1), text: src.slice(1, end), display: false };
626
- }
627
- return undefined;
592
+ const span = mathSpanAt(src, 0);
593
+ if (!span) return undefined;
594
+ return { type: "math", raw: src.slice(0, span.end), text: span.body, display: span.display };
628
595
  },
629
596
  renderer(token) {
630
- return (token as { text?: string }).text ?? "";
597
+ return typeof token.text === "string" ? token.text : "";
631
598
  },
632
599
  };
633
600
 
634
- // Display math blocks: opening `$$` / `\[` and closing `$$` / `\]` each alone on
635
- // their own line (≤3 leading spaces). Matched at the block level — before
636
- // paragraph/list parsing — so a multi-line equation (e.g. a matrix with `\\`
637
- // row breaks) renders across several lines instead of being collapsed onto one,
638
- // and blank lines inside the block don't split it. The own-line requirement
639
- // keeps inline `$$…$$` inside prose for the inline tokenizer above.
640
- const MATH_BLOCK_DOLLAR = /^ {0,3}\$\$[ \t]*\n([\s\S]+?)\n {0,3}\$\$[ \t]*(?:\n|$)/;
641
- const MATH_BLOCK_BRACKET = /^ {0,3}\\\[[ \t]*\n([\s\S]+?)\n {0,3}\\\][ \t]*(?:\n|$)/;
642
- const MATH_BLOCK_START = /(?:^|\n) {0,3}(?:\$\$|\\\[)[ \t]*\n/;
643
601
  const mathBlockExtension: TokenizerAndRendererExtension = {
644
602
  name: "mathBlock",
645
603
  level: "block",
646
- start(src) {
647
- const m = MATH_BLOCK_START.exec(src);
648
- return m ? m.index : undefined;
649
- },
604
+ // No `start` hint: marked only probes block extensions at a block boundary
605
+ // here and never consults their hints.
650
606
  tokenizer(src) {
651
- const m = MATH_BLOCK_DOLLAR.exec(src) ?? MATH_BLOCK_BRACKET.exec(src);
652
- if (!m || m[1].trim().length === 0) return undefined;
653
- return { type: "math", raw: m[0], text: m[1], display: true };
607
+ const block = mathBlockAt(src);
608
+ if (!block) return undefined;
609
+ return { type: "math", raw: block.raw, text: block.body, display: true };
654
610
  },
655
611
  renderer(token) {
656
- return (token as { text?: string }).text ?? "";
612
+ return typeof token.text === "string" ? token.text : "";
657
613
  },
658
614
  };
659
615
 
@@ -718,7 +674,7 @@ const mathEnvBlockExtension: TokenizerAndRendererExtension = {
718
674
  // (return undefined) to marked's own autolink handling unchanged.
719
675
  const AUTOLINK_SCHEME_REGEX = /^(?:www\.|https?:\/\/|ftp:\/\/)/i;
720
676
  // Case-insensitive scheme scan replacing /www\.|https?:\/\/|ftp:\/\//i in
721
- // boundedAutolinkExtension.start — like mathStartIndex above, this runs on the
677
+ // boundedAutolinkExtension.start — like `mathStartIndex`, this runs on the
722
678
  // remaining source at every inline position (part of a ~4.3% CPU start() scan
723
679
  // tail in profiles). charCode-only: no allocation, no toLowerCase copies.
724
680
  // `| 32` lower-cases ASCII letters; `.`/`:`/`/` are compared exactly, matching
@@ -1,3 +1,4 @@
1
+ import { mathSpanAt } from "@oh-my-pi/pi-utils/math-delimiters";
1
2
  import { TERMINAL } from "./terminal-capabilities";
2
3
 
3
4
  // LaTeX → Unicode/ANSI converter.
@@ -1934,47 +1935,34 @@ export function renderMathInText(text: string): string {
1934
1935
  i += 2;
1935
1936
  continue;
1936
1937
  }
1937
- if (d === "(") {
1938
- const close = text.indexOf("\\)", i + 2);
1939
- if (close !== -1) {
1940
- out += conv(text.slice(i + 2, close));
1941
- i = close + 2;
1942
- continue;
1943
- }
1944
- } else if (d === "[") {
1945
- const close = text.indexOf("\\]", i + 2);
1946
- if (close !== -1) {
1947
- out += conv(text.slice(i + 2, close));
1948
- i = close + 2;
1949
- continue;
1950
- }
1951
- } else if (d === "$") {
1938
+ if (d === "$") {
1952
1939
  out += "$";
1953
1940
  i += 2;
1954
1941
  continue;
1955
1942
  }
1943
+ // `i` as the escape bound: the walk above already consumed `\\` and `\$`.
1944
+ const span = mathSpanAt(text, i, i);
1945
+ if (span) {
1946
+ out += conv(span.body);
1947
+ i = span.end;
1948
+ continue;
1949
+ }
1956
1950
  out += c;
1957
1951
  i++;
1958
1952
  continue;
1959
1953
  }
1960
1954
  if (c === "$") {
1955
+ const span = mathSpanAt(text, i, i);
1956
+ if (span) {
1957
+ out += conv(span.body);
1958
+ i = span.end;
1959
+ continue;
1960
+ }
1961
1961
  if (text[i + 1] === "$") {
1962
- const close = text.indexOf("$$", i + 2);
1963
- if (close !== -1 && text.slice(i + 2, close).trim().length > 0) {
1964
- out += conv(text.slice(i + 2, close));
1965
- i = close + 2;
1966
- continue;
1967
- }
1968
1962
  out += "$$";
1969
1963
  i += 2;
1970
1964
  continue;
1971
1965
  }
1972
- const close = inlineMathSpanEnd(text, i);
1973
- if (close !== -1) {
1974
- out += conv(text.slice(i + 1, close));
1975
- i = close + 1;
1976
- continue;
1977
- }
1978
1966
  out += "$";
1979
1967
  i++;
1980
1968
  continue;
@@ -1984,34 +1972,3 @@ export function renderMathInText(text: string): string {
1984
1972
  }
1985
1973
  return renderBareMathInText(out);
1986
1974
  }
1987
-
1988
- /**
1989
- * Index of the `$` that closes an inline math span opened at `open` (the index
1990
- * of the opening `$`), or -1 when the run is not inline math. Applies pandoc's
1991
- * anti-currency heuristics: the opener must not be followed by whitespace, the
1992
- * closer must not be preceded by whitespace nor followed by a digit, `\$` is a
1993
- * literal dollar, and the span may not span a newline. Shared by
1994
- * `renderMathInText` and the markdown math tokenizer so the rule has one home.
1995
- */
1996
- export function inlineMathSpanEnd(text: string, open: number): number {
1997
- const after = text[open + 1];
1998
- if (after === undefined || after === " " || after === "\t" || after === "\n" || after === "$") {
1999
- return -1;
2000
- }
2001
- for (let j = open + 1; j < text.length; j++) {
2002
- const ch = text[j];
2003
- if (ch === "\\") {
2004
- j++;
2005
- continue;
2006
- }
2007
- if (ch === "\n") return -1;
2008
- if (ch === "$") {
2009
- const prev = text[j - 1];
2010
- if (prev === " " || prev === "\t") return -1;
2011
- const next = text[j + 1];
2012
- if (next !== undefined && next >= "0" && next <= "9") continue; // currency: keep scanning
2013
- return text.slice(open + 1, j).trim().length > 0 ? j : -1;
2014
- }
2015
- }
2016
- return -1;
2017
- }
@@ -446,10 +446,14 @@ export function shouldEnableHyperlinksByDefault(
446
446
  return true;
447
447
  }
448
448
 
449
- function getFallbackImageProtocol(terminalId: TerminalId): ImageProtocol | null {
450
- if (!process.stdout.isTTY) return null;
449
+ function getFallbackImageProtocol(
450
+ terminalId: TerminalId,
451
+ env: NodeJS.ProcessEnv = Bun.env,
452
+ isTTY: boolean = process.stdout.isTTY === true,
453
+ ): ImageProtocol | null {
454
+ if (!isTTY) return null;
451
455
  if (terminalId === "vscode" || terminalId === "alacritty") return null;
452
- const term = Bun.env.TERM?.toLowerCase() ?? "";
456
+ const term = env.TERM?.toLowerCase() ?? "";
453
457
  if (term.includes("screen") || term.includes("tmux") || term.includes("ghostty")) {
454
458
  return ImageProtocol.Kitty;
455
459
  }
@@ -470,6 +474,51 @@ export function resolveWarpImageProtocol(
470
474
  return windowsHost ? null : ImageProtocol.Kitty;
471
475
  }
472
476
 
477
+ /**
478
+ * Paseo (getpaseo/paseo) hardcodes `TERM_PROGRAM=kitty` into every PTY
479
+ * (`buildTerminalEnvironment`) while its xterm.js renderer implements neither
480
+ * Kitty graphics nor Unicode placeholders — trusting the advertisement turns
481
+ * image previews into literal PUA garbage (getpaseo/paseo#3850). Paseo
482
+ * injects `PASEO_TERMINAL_ID` into every terminal it hosts, which makes a
483
+ * reliable embedder signal.
484
+ */
485
+ export function isPaseoEmbedder(env: NodeJS.ProcessEnv = Bun.env): boolean {
486
+ return Boolean(env.PASEO_TERMINAL_ID);
487
+ }
488
+
489
+ /**
490
+ * Resolve the image protocol for a non-forced runtime: static per-terminal
491
+ * 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.
495
+ */
496
+ export function resolveImageProtocol(
497
+ terminalId: TerminalId,
498
+ env: NodeJS.ProcessEnv = Bun.env,
499
+ isTTY: boolean = process.stdout.isTTY === true,
500
+ ): ImageProtocol | null {
501
+ let imageProtocol: ImageProtocol | null;
502
+ if (terminalId === "warp") {
503
+ // Warp advertises Kitty graphics on macOS/Linux only; drop it on win32.
504
+ imageProtocol = resolveWarpImageProtocol(process.platform, env);
505
+ } else {
506
+ imageProtocol = getTerminalInfo(terminalId).imageProtocol;
507
+ if (!imageProtocol) {
508
+ const fallbackImageProtocol = getFallbackImageProtocol(terminalId, env, isTTY);
509
+ if (fallbackImageProtocol) imageProtocol = fallbackImageProtocol;
510
+ }
511
+ }
512
+ // Paseo's xterm.js renderer draws neither Kitty APC nor placeholders —
513
+ // applied after the multiplexer fallback so tmux/screen inside a Paseo
514
+ // pane cannot restore Kitty via getFallbackImageProtocol
515
+ // (getpaseo/paseo#3850).
516
+ if (imageProtocol !== null && isPaseoEmbedder(env)) {
517
+ return null;
518
+ }
519
+ return imageProtocol;
520
+ }
521
+
473
522
  function getWarpTerminalInfo(platform: NodeJS.Platform, env: NodeJS.ProcessEnv = Bun.env): TerminalInfo {
474
523
  return new TerminalInfo(
475
524
  "warp",
@@ -572,12 +621,8 @@ export const TERMINAL: RuntimeTerminal = (() => {
572
621
  const forcedImageProtocol = getForcedImageProtocol();
573
622
  if (forcedImageProtocol !== undefined) {
574
623
  resolved.imageProtocol = forcedImageProtocol;
575
- } else if (resolved.id === "warp") {
576
- // Warp advertises Kitty graphics on macOS/Linux only; drop it on win32.
577
- resolved.imageProtocol = resolveWarpImageProtocol();
578
- } else if (!resolved.imageProtocol) {
579
- const fallbackImageProtocol = getFallbackImageProtocol(resolved.id);
580
- if (fallbackImageProtocol) resolved.imageProtocol = fallbackImageProtocol;
624
+ } else {
625
+ resolved.imageProtocol = resolveImageProtocol(resolved.id, Bun.env, process.stdout.isTTY === true);
581
626
  }
582
627
  // Hyperlink (OSC 8) capability. The static per-terminal flag lives on
583
628
  // KNOWN_TERMINALS; shouldEnableHyperlinksByDefault folds in runtime context —
package/src/tui.ts CHANGED
@@ -2218,6 +2218,14 @@ export class TUI extends Container {
2218
2218
  * Append mode leaves the terminal's prior copy in place and writes a
2219
2219
  * current-width copy below it. Rebuild mode routes through the destructive
2220
2220
  * reset latch so ED3 removes stale history before the same replay.
2221
+ *
2222
+ * The append replay only exists to refresh width-shredded scrollback: a
2223
+ * width change leaves the terminal's committed history wrapped at the old
2224
+ * width, so the copy below it is the intended fresh current-width render. A
2225
+ * height-only change reflows nothing (the terminal merely pulls rows back
2226
+ * out of scrollback), so replaying would write an identical duplicate — the
2227
+ * editor/status chrome included — below the retained copy. Skip it, matching
2228
+ * the `widthChanged`-gated commit-ledger logic in {@link #doRender}.
2221
2229
  */
2222
2230
  #prepareResizeReplay(width: number, height: number): void {
2223
2231
  const size = `${width}x${height}`;
@@ -2240,6 +2248,7 @@ export class TUI extends Container {
2240
2248
  this.#prepareForcedRender(true);
2241
2249
  return;
2242
2250
  }
2251
+ if (width === this.#previousWidth) return;
2243
2252
  provider.beginHistoryReplay();
2244
2253
  this.#forceViewportRepaintOnNextRender = true;
2245
2254
  }
@@ -2471,10 +2480,16 @@ export class TUI extends Container {
2471
2480
  this.#altActive = false;
2472
2481
  this.#altMouseTrackingActive = false;
2473
2482
  this.#altPreviousLines = [];
2474
- // The alt buffer restore put the pre-overlay normal screen back; a
2475
- // geometry change while covered invalidates the diff baseline and the
2476
- // writer's dimension check forces the full anchored rewrite.
2483
+ // The alt-buffer restore put the pre-overlay normal screen back. If
2484
+ // that buffer resized while covered, its cursor moved with width
2485
+ // rewrap or a height-grow scrollback pull, while our viewport anchor
2486
+ // stayed frozen. Recover the restored cursor position before any
2487
+ // provider repaint can overwrite history at the stale row.
2477
2488
  if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
2489
+ if (this.#frameProvider !== undefined) {
2490
+ this.#beginResizeAnchorProbe();
2491
+ return;
2492
+ }
2478
2493
  this.#forceViewportRepaintOnNextRender = true;
2479
2494
  }
2480
2495
  } else if (wantMouseTracking !== this.#altMouseTrackingActive) {