@oh-my-pi/pi-tui 16.5.0 → 16.5.2

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,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.5.2] - 2026-07-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed animated Loader ANSI updates causing unnecessary text layout invalidation and re-wrapping on shimmer-only frames (#5230).
10
+ - Fixed Ctrl+W (delete word backward) stopping at underscores in snake_case identifiers, treating them as single words (#4776).
11
+ - Fixed automatic file completion incorrectly treating punctuation, trailing spaces, and slash-command text as paths, and improved autocomplete dismissal behavior (#5376).
12
+ - Fixed Kitty graphics rendering under tmux, ensuring images correctly follow pane scrolling and reflow (#5381).
13
+ - Fixed tmux sessions becoming unresponsive after terminal capability replies by falling back to legacy keyboard input mode when the Kitty protocol is unavailable (#5378).
14
+ - Fixed PageUp and PageDown keys on an empty prompt editor incorrectly navigating prompt history instead of scrolling the editor viewport (#4754).
15
+
16
+ ## [16.5.1] - 2026-07-14
17
+
18
+ ### Fixed
19
+
20
+ - Optimized the Markdown rendering cache to prevent large documents from indefinitely occupying cache slots, improving memory usage and performance ([#4820](https://github.com/can1357/oh-my-pi/issues/4820)).
21
+ - Fixed viewport corruption on macOS caused by unmanaged stderr writes (such as libmalloc or framework diagnostics) while the terminal is active.
22
+ - Fixed an issue where streamed diff code fences retained unhighlighted rows in native scrollback when long transient blocks left the viewport before finalization ([#5126](https://github.com/can1357/oh-my-pi/issues/5126)).
23
+ - Fixed native Windows Terminal sessions failing to detect mid-run light/dark theme changes when Mode 2031 appearance notifications are unavailable ([#5091](https://github.com/can1357/oh-my-pi/issues/5091)).
24
+ - Hid empty HTML comment separators in Markdown-rendered TUI output instead of displaying them literally ([#4911](https://github.com/can1357/oh-my-pi/issues/4911)).
25
+
5
26
  ## [16.5.0] - 2026-07-13
6
27
 
7
28
  ### Changed
@@ -1,9 +1,14 @@
1
1
  import type { TUI } from "../tui.js";
2
2
  import { Text } from "./text.js";
3
3
  type ColorFn = (str: string) => string;
4
+ /**
5
+ * Styles Loader message fragments without changing their visible text or width.
6
+ * Set `animated` for colorizers whose ANSI output changes over time.
7
+ */
4
8
  export type LoaderMessageColorFn = ColorFn & {
5
9
  readonly animated?: true;
6
10
  };
11
+ /** Animates a spinner and colorized message while asynchronous work is pending. */
7
12
  export declare class Loader extends Text {
8
13
  #private;
9
14
  private spinnerColorFn;
@@ -26,18 +26,15 @@ export interface KittyGraphicsFeatures {
26
26
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
27
27
  * U+10EEEE with row/column diacritics).
28
28
  *
29
- * Only `kitty` (the protocol's origin) and `ghostty` ship a working
30
- * implementation; WezTerm advertises Kitty graphics but treats placeholder
31
- * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
32
- * still unchecked), and the tmux/screen fallback can land on any outer
33
- * terminal. Enabling placeholders on those paths emits a `columns × rows`
34
- * grid of U+10EEEE per image per frame; the cells render as boxed fallback
35
- * glyphs and re-emit on every repaint, which is exactly the
36
- * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
29
+ * Kitty and Ghostty advertise placeholder support directly. A tmux session
30
+ * cannot use cursor-positioned placements because the outer terminal does not
31
+ * know pane scroll/reflow state, so an explicit `PI_FORCE_IMAGE_PROTOCOL=kitty`
32
+ * also opts into placeholders there matching `timg -pk`. Automatic tmux
33
+ * fallback stays off because the unknown outer terminal may render U+10EEEE as
34
+ * literal PUA boxes (#1877).
37
35
  *
38
- * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
39
- * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
40
- * for a wezterm nightly that has merged placeholder support).
36
+ * `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
37
+ * opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
41
38
  */
42
39
  export declare function detectKittyUnicodePlaceholdersSupport(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
43
40
  export declare function getKittyGraphics(): Readonly<KittyGraphicsFeatures>;
@@ -1,3 +1,4 @@
1
+ export { isInsideTmux, wrapTmuxPassthrough } from "./tmux.js";
1
2
  export declare enum ImageProtocol {
2
3
  Kitty = "\u001B_G",
3
4
  Iterm2 = "\u001B]1337;File=",
@@ -34,12 +35,6 @@ export declare class TerminalInfo {
34
35
  formatNotification(message: string | TerminalNotification): string;
35
36
  sendNotification(message: string | TerminalNotification): void;
36
37
  }
37
- /**
38
- * Whether the agent process is running inside a tmux session. Read fresh on
39
- * each call so tests can toggle `Bun.env.TMUX` per case without re-importing
40
- * the module and so a tmux session attached/detached mid-run is observed.
41
- */
42
- export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
43
38
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
44
39
  export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): boolean;
45
40
  /**
@@ -48,19 +43,6 @@ export declare function isInsideTerminalMultiplexer(env?: NodeJS.ProcessEnv): bo
48
43
  * is observed and tests can toggle `Bun.env.ZELLIJ` per case.
49
44
  */
50
45
  export declare function isInsideZellij(env?: NodeJS.ProcessEnv): boolean;
51
- /**
52
- * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
53
- * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
54
- * the envelope and forwards the unwrapped payload to the outer terminal only
55
- * when the user opts in with `set -g allow-passthrough on`; otherwise tmux
56
- * silently consumes the envelope, which is identical to the pre-wrap baseline
57
- * (tmux already swallowed the bare OSC).
58
- *
59
- * Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in
60
- * `terminal.ts` to keep notifications alive for terminals that understand
61
- * OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux.
62
- */
63
- export declare function wrapTmuxPassthrough(payload: string): string;
64
46
  export declare function isNotificationSuppressed(): boolean;
65
47
  /**
66
48
  * Returns true when running in Windows Terminal with known SIXEL support.
@@ -0,0 +1,6 @@
1
+ /** Whether the process is running inside a tmux session. */
2
+ export declare function isInsideTmux(env?: NodeJS.ProcessEnv): boolean;
3
+ /** Wrap a control sequence in tmux's DCS passthrough envelope. */
4
+ export declare function wrapTmuxPassthrough(payload: string): string;
5
+ /** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
6
+ export declare function wrapTmuxPassthroughIfNeeded(payload: string, env?: NodeJS.ProcessEnv): string;
@@ -97,6 +97,13 @@ export interface NativeScrollbackLiveRegion {
97
97
  export interface NativeScrollbackCommittedRows {
98
98
  setNativeScrollbackCommittedRows(rows: number): void;
99
99
  }
100
+ /**
101
+ * A component that discards rows after they enter native scrollback implements
102
+ * this hook so a destructive full replay can rehydrate its complete frame.
103
+ */
104
+ export interface NativeScrollbackReplay {
105
+ prepareNativeScrollbackReplay(): void;
106
+ }
100
107
  /**
101
108
  * Opt-in stability report for components that mutate their returned render
102
109
  * array in place across frames (instead of returning a fresh array per
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "16.5.0",
4
+ "version": "16.5.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "16.5.0",
41
- "@oh-my-pi/pi-utils": "16.5.0",
40
+ "@oh-my-pi/pi-natives": "16.5.2",
41
+ "@oh-my-pi/pi-utils": "16.5.2",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -456,6 +456,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
456
456
  prefix: isMidPromptSkillLookup ? commandText : textBeforeCursor,
457
457
  };
458
458
  }
459
+ if (!isMidPromptSkillLookup && slashStart === leadingSlashStart && !commandText.slice(1).includes("/")) {
460
+ return null;
461
+ }
462
+
459
463
  // A slash token with no matching command may still be an absolute
460
464
  // path (`/tmp/fo` at prompt start, `see /tmp` mid-prompt); fall
461
465
  // through to file-path completion.
@@ -679,15 +683,14 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
679
683
  return pathPrefix;
680
684
  }
681
685
 
682
- // For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
683
- // Only return empty string if the text looks like it's starting a path context
684
- if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
685
- return pathPrefix;
686
- }
687
-
688
- // Return empty string only after a space (not for completely empty text)
689
- // Empty text should not trigger file suggestions - that's for forced Tab completion
690
- if (pathPrefix === "" && text.endsWith(" ")) {
686
+ // Automatic updates complete only unambiguous path syntax. Bare relative
687
+ // tokens remain available through explicit Tab completion.
688
+ if (
689
+ pathPrefix.startsWith("/") ||
690
+ pathPrefix.startsWith("./") ||
691
+ pathPrefix.startsWith("../") ||
692
+ pathPrefix.startsWith("~/")
693
+ ) {
691
694
  return pathPrefix;
692
695
  }
693
696
 
@@ -1364,21 +1364,14 @@ export class Editor implements Component, Focusable {
1364
1364
  } else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
1365
1365
  this.#moveToLineEnd();
1366
1366
  }
1367
- // Page navigation (PageUp/PageDown)
1367
+ // Page navigation (PageUp/PageDown): page the editor viewport only. On a
1368
+ // short draft this is a no-op — it never steps prompt history (that stays
1369
+ // on Up/Down), so an idle empty editor swallows the keys instead of
1370
+ // surprising the user by loading the previous prompt (#4754).
1368
1371
  else if (kb.matches(data, "tui.editor.pageUp")) {
1369
- if (this.#isEditorEmpty()) {
1370
- this.#navigateHistory(-1);
1371
- } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
1372
- this.#navigateHistory(-1);
1373
- } else {
1374
- this.#pageScroll(-1);
1375
- }
1372
+ this.#pageScroll(-1);
1376
1373
  } else if (kb.matches(data, "tui.editor.pageDown")) {
1377
- if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
1378
- this.#navigateHistory(1);
1379
- } else {
1380
- this.#pageScroll(1);
1381
- }
1374
+ this.#pageScroll(1);
1382
1375
  }
1383
1376
  // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
1384
1377
  else if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
@@ -2077,15 +2070,13 @@ export class Editor implements Component, Focusable {
2077
2070
  this.#resetKillSequence();
2078
2071
  this.#recordUndoState();
2079
2072
 
2080
- let removedMidPromptSlashTrigger = false;
2073
+ let removedSlashTrigger = false;
2081
2074
 
2082
2075
  if (this.#state.cursorCol > 0) {
2083
2076
  const line = this.#state.lines[this.#state.cursorLine] || "";
2084
2077
  const textBeforeCursor = line.slice(0, this.#state.cursorCol);
2085
2078
  const trailingSlashStart = findTrailingSlashCommandStart(textBeforeCursor);
2086
- removedMidPromptSlashTrigger =
2087
- trailingSlashStart === this.#state.cursorCol - 1 &&
2088
- (!this.#hasOnlyWhitespaceBeforeCursorLine() || textBeforeCursor.slice(0, trailingSlashStart).trim() !== "");
2079
+ removedSlashTrigger = trailingSlashStart === this.#state.cursorCol - 1;
2089
2080
  // An atomic placeholder token (image/paste marker) deletes as a unit, so a single
2090
2081
  // backspace never leaves a half-eaten `[Paste #1, +30 lines` behind as stray text.
2091
2082
  const token = this.#atomicTokenAt(line, this.#state.cursorCol - 1);
@@ -2125,7 +2116,7 @@ export class Editor implements Component, Focusable {
2125
2116
 
2126
2117
  // Update or re-trigger autocomplete after backspace
2127
2118
  if (this.#autocompleteState) {
2128
- if (removedMidPromptSlashTrigger) {
2119
+ if (removedSlashTrigger) {
2129
2120
  this.#cancelAutocomplete();
2130
2121
  this.onAutocompleteUpdate?.();
2131
2122
  } else {
@@ -3046,17 +3037,17 @@ export class Editor implements Component, Focusable {
3046
3037
  } else if (this.#isInMidPromptSkillSlashContext()) {
3047
3038
  await this.#handleSlashCommandCompletion();
3048
3039
  if (!this.#autocompleteState) {
3049
- await this.#forceFileAutocomplete(true);
3040
+ await this.#forceFileAutocomplete();
3050
3041
  }
3051
3042
  } else {
3052
- await this.#forceFileAutocomplete(true);
3043
+ await this.#forceFileAutocomplete();
3053
3044
  }
3054
3045
  }
3055
3046
  async #handleSlashCommandCompletion(): Promise<void> {
3056
3047
  await this.#tryTriggerAutocomplete();
3057
3048
  }
3058
3049
 
3059
- async #forceFileAutocomplete(explicitTab: boolean = false): Promise<void> {
3050
+ async #forceFileAutocomplete(): Promise<void> {
3060
3051
  if (!this.#autocompleteProvider) return;
3061
3052
 
3062
3053
  // File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
@@ -3076,27 +3067,6 @@ export class Editor implements Component, Focusable {
3076
3067
  if (requestId !== this.#autocompleteRequestId) return;
3077
3068
 
3078
3069
  if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
3079
- // If there's exactly one suggestion and this was an explicit Tab press, apply it immediately
3080
- if (explicitTab && suggestions.items.length === 1) {
3081
- const item = suggestions.items[0]!;
3082
- const result = this.#autocompleteProvider.applyCompletion(
3083
- this.#state.lines,
3084
- this.#state.cursorLine,
3085
- this.#state.cursorCol,
3086
- item,
3087
- suggestions.prefix,
3088
- );
3089
-
3090
- this.#state.lines = result.lines;
3091
- this.#state.cursorLine = result.cursorLine;
3092
- this.#setCursorCol(result.cursorCol);
3093
-
3094
- if (this.onChange) {
3095
- this.onChange(this.getText());
3096
- }
3097
- return;
3098
- }
3099
-
3100
3070
  this.#autocompletePrefix = suggestions.prefix;
3101
3071
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3102
3072
  this.#autocompleteState = "force";
@@ -1,28 +1,29 @@
1
1
  import type { TUI } from "../tui";
2
- import { sliceByColumn, visibleWidth } from "../utils";
2
+ import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
3
3
  import { Text } from "./text";
4
4
 
5
- /**
6
- * Loader component. Spinner frames advance at `SPINNER_ADVANCE_MS`.
7
- *
8
- * Message colorizers that are time-dependent can opt into 30fps redraws by
9
- * setting `animated` to `true` on the function object.
10
- */
11
5
  const RENDER_INTERVAL_MS = 1000 / 30;
12
6
  const SPINNER_ADVANCE_MS = 80;
13
7
 
14
8
  type ColorFn = (str: string) => string;
15
9
 
10
+ /**
11
+ * Styles Loader message fragments without changing their visible text or width.
12
+ * Set `animated` for colorizers whose ANSI output changes over time.
13
+ */
16
14
  export type LoaderMessageColorFn = ColorFn & {
17
15
  readonly animated?: true;
18
16
  };
19
17
 
18
+ /** Animates a spinner and colorized message while asynchronous work is pending. */
20
19
  export class Loader extends Text {
21
20
  #frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
22
21
  #currentFrame = 0;
23
22
  #intervalId?: NodeJS.Timeout;
24
23
  #ui: TUI | null = null;
25
24
  #lastSpinnerTick = 0;
25
+ #layoutSource?: readonly string[];
26
+ #layout?: readonly { leading: string; content: string; trailing: string }[];
26
27
 
27
28
  constructor(
28
29
  ui: TUI,
@@ -40,11 +41,36 @@ export class Loader extends Text {
40
41
  }
41
42
 
42
43
  render(width: number): readonly string[] {
43
- const lines = ["", ...super.render(width)];
44
- for (let i = 0; i < lines.length; i++) {
45
- const line = lines[i];
46
- if (visibleWidth(line) > width) {
47
- lines[i] = sliceByColumn(line, 0, width, true);
44
+ const source = super.render(width);
45
+ if (source !== this.#layoutSource) {
46
+ const paddingX = getPaddingX(1);
47
+ this.#layoutSource = source;
48
+ this.#layout = source.map(line => {
49
+ const clamped = visibleWidth(line) > width ? sliceByColumn(line, 0, width, true) : line;
50
+ const body = clamped.slice(paddingX);
51
+ const content = body.trimEnd();
52
+ return {
53
+ leading: clamped.slice(0, paddingX),
54
+ content,
55
+ trailing: body.slice(content.length),
56
+ };
57
+ });
58
+ }
59
+
60
+ const frame = this.#frames[this.#currentFrame];
61
+ const lines = [""];
62
+ const layout = this.#layout ?? [];
63
+ for (let i = 0; i < layout.length; i++) {
64
+ const { leading, content, trailing } = layout[i];
65
+ if (i === 0 && content.startsWith(frame)) {
66
+ const remainder = content.slice(frame.length);
67
+ const separator = remainder.startsWith(" ") ? " " : "";
68
+ const message = remainder.slice(separator.length);
69
+ lines.push(
70
+ `${leading}${this.spinnerColorFn(frame)}${separator}${message ? this.messageColorFn(message) : ""}${trailing}`,
71
+ );
72
+ } else {
73
+ lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
48
74
  }
49
75
  }
50
76
  return lines;
@@ -91,8 +117,8 @@ export class Loader extends Text {
91
117
 
92
118
  #updateDisplay() {
93
119
  const frame = this.#frames[this.#currentFrame];
94
- const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
95
- if (this.setText(text) && this.#ui) {
120
+ const textChanged = this.setText(`${frame} ${this.message}`);
121
+ if ((textChanged || this.messageColorFn.animated === true) && this.#ui) {
96
122
  // Direct write: a loader tick changes only this component, so the TUI
97
123
  // can update the already-positioned rows without driving the full
98
124
  // compose/prepare/diff pipeline. Lightweight test stubs may not carry
@@ -86,6 +86,7 @@ function createHtmlNormalizationState(): HtmlNormalizationState {
86
86
  return { lists: [], openItems: [], itemHasContent: [] };
87
87
  }
88
88
 
89
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
89
90
  const HTML_TAG_REGEX = /<\/?(?:br|p|ol|ul|li|span|text|code|hr|blockquote)\b(?:\s[^>]*)?\s*\/?>/gi;
90
91
  // Block-level HTML that needs structural (not just textual) rendering: standalone
91
92
  // `<hr>` becomes a rule and balanced `<blockquote>…</blockquote>` renders with
@@ -136,11 +137,12 @@ function normalizeHtmlForTerminal(
136
137
  let output = "";
137
138
  let lastIndex = 0;
138
139
  let inCode = false;
140
+ const withoutComments = raw.replace(HTML_COMMENT_REGEX, "");
139
141
 
140
- for (const match of raw.matchAll(HTML_TAG_REGEX)) {
142
+ for (const match of withoutComments.matchAll(HTML_TAG_REGEX)) {
141
143
  const tag = match[0];
142
144
  const index = match.index ?? 0;
143
- const textBeforeTag = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex, index));
145
+ const textBeforeTag = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex, index));
144
146
  const name = htmlTagName(tag);
145
147
  // Most tags handled here are block-level. Inline contexts — span, text, and
146
148
  // the content inside a `<code>` run — keep their surrounding whitespace
@@ -238,7 +240,7 @@ function normalizeHtmlForTerminal(
238
240
  }
239
241
  }
240
242
 
241
- const remainingText = normalizeHtmlEntitiesForTerminal(raw.slice(lastIndex));
243
+ const remainingText = normalizeHtmlEntitiesForTerminal(withoutComments.slice(lastIndex));
242
244
  markCurrentHtmlItemContent(state, remainingText);
243
245
  return output + (inCode && codeHook ? codeHook(remainingText) : remainingText);
244
246
  }
@@ -602,8 +604,21 @@ markdownParser.use({ extensions: [customHrExtension, mathBlockExtension, mathEnv
602
604
  // (Rust FFI) work for content/layout combinations already seen this session.
603
605
 
604
606
  const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
607
+ const RENDER_CACHE_MAX_SIZE = 512 * 1024;
608
+ const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
605
609
  const EMPTY_RENDER_LINES: readonly string[] = [];
606
- const renderCache = new LRUCache<string, readonly string[]>({ max: RENDER_CACHE_MAX });
610
+ const renderCache = new LRUCache<string, readonly string[]>({
611
+ max: RENDER_CACHE_MAX,
612
+ maxSize: RENDER_CACHE_MAX_SIZE,
613
+ maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
614
+ sizeCalculation: renderedLinesCacheSize,
615
+ });
616
+
617
+ function renderedLinesCacheSize(lines: readonly string[]): number {
618
+ let size = lines.length;
619
+ for (let i = 0; i < lines.length; i++) size += lines[i]!.length;
620
+ return Math.max(1, size);
621
+ }
607
622
 
608
623
  // A reference-link definition (`[label]: dest`) resolves across the whole
609
624
  // document, so a split lex cannot reproduce it — disable the streaming fast path
@@ -937,6 +952,11 @@ interface StreamPrefixLineCache extends RenderSignature {
937
952
  tokenCount: number;
938
953
  lines: readonly string[];
939
954
  }
955
+ interface StreamingDiffLineCache extends RenderSignature {
956
+ lang: string | undefined;
957
+ text: string;
958
+ lines: readonly string[];
959
+ }
940
960
 
941
961
  export class Markdown implements Component {
942
962
  #text: string;
@@ -979,8 +999,12 @@ export class Markdown implements Component {
979
999
  // True while #renderStreamingContentLines renders the frozen token range:
980
1000
  // frozen code blocks highlight even in transient mode so their bytes match
981
1001
  // the finalized render (they render once into the prefix line cache, so
982
- // the FFI cost is amortized); the volatile tail stays unhighlighted.
1002
+ // the FFI cost is amortized). The volatile tail normally stays
1003
+ // unhighlighted; streaming diff fences line-highlight completed rows so
1004
+ // semantic colors reach native scrollback before rows leave the viewport.
983
1005
  #renderingFrozenPrefix = false;
1006
+ #streamingDiffLineCache?: StreamingDiffLineCache;
1007
+ #activeRenderSignature?: RenderSignature;
984
1008
 
985
1009
  #ignoreTight = false;
986
1010
 
@@ -1190,9 +1214,15 @@ export class Markdown implements Component {
1190
1214
 
1191
1215
  // Parse markdown to HTML-like tokens
1192
1216
  const tokens = this.#lexTokens(normalizedText);
1193
- const contentLines = this.transientRenderCache
1194
- ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1195
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1217
+ let contentLines: string[];
1218
+ this.#activeRenderSignature = signature;
1219
+ try {
1220
+ contentLines = this.transientRenderCache
1221
+ ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1222
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1223
+ } finally {
1224
+ this.#activeRenderSignature = undefined;
1225
+ }
1196
1226
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1197
1227
 
1198
1228
  // Combine top padding, content, and bottom padding
@@ -1386,6 +1416,122 @@ export class Markdown implements Component {
1386
1416
  return contentLines;
1387
1417
  }
1388
1418
 
1419
+ #renderCodeBodyLines(token: Token, codeIndent: string): string[] {
1420
+ const bodyLines: string[] = [];
1421
+ const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
1422
+ const lang = "lang" in token && typeof token.lang === "string" ? token.lang : undefined;
1423
+ const normalizedLang = lang?.toLowerCase();
1424
+ const canStreamDiff =
1425
+ this.transientRenderCache &&
1426
+ !this.#renderingFrozenPrefix &&
1427
+ this.#theme.highlightCode &&
1428
+ (normalizedLang === "diff" || normalizedLang === "patch" || normalizedLang === "udiff");
1429
+
1430
+ if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1431
+ const highlightedLines = this.#theme.highlightCode(tokenText, lang);
1432
+ for (const hlLine of highlightedLines) {
1433
+ bodyLines.push(`${codeIndent}${hlLine}`);
1434
+ }
1435
+ return bodyLines;
1436
+ }
1437
+
1438
+ if (canStreamDiff) {
1439
+ const closedFence = this.#codeTokenHasClosingFence(token);
1440
+ const lineEnd = tokenText.lastIndexOf("\n");
1441
+ if (closedFence || lineEnd >= 0) {
1442
+ const completedText = closedFence ? tokenText : tokenText.slice(0, lineEnd);
1443
+ for (const hlLine of this.#highlightStreamingDiffLines(completedText, lang)) {
1444
+ bodyLines.push(`${codeIndent}${hlLine}`);
1445
+ }
1446
+ if (!closedFence) {
1447
+ for (const codeLine of tokenText.slice(lineEnd + 1).split("\n")) {
1448
+ bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1449
+ }
1450
+ }
1451
+ return bodyLines;
1452
+ }
1453
+ }
1454
+
1455
+ for (const codeLine of tokenText.split("\n")) {
1456
+ bodyLines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1457
+ }
1458
+ return bodyLines;
1459
+ }
1460
+
1461
+ #codeTokenHasClosingFence(token: Token): boolean {
1462
+ const raw = "raw" in token && typeof token.raw === "string" ? token.raw : "";
1463
+ const firstLineEnd = raw.indexOf("\n");
1464
+ if (firstLineEnd < 0) return false;
1465
+ const openingLine = raw.slice(0, firstLineEnd);
1466
+ const openingTrimmed = openingLine.trimStart();
1467
+ const openingIndent = openingLine.length - openingTrimmed.length;
1468
+ if (openingIndent > 3) return false;
1469
+ const fenceChar = openingTrimmed.charAt(0);
1470
+ if (fenceChar !== "`" && fenceChar !== "~") return false;
1471
+ let fenceLength = 0;
1472
+ while (openingTrimmed.charAt(fenceLength) === fenceChar) fenceLength++;
1473
+ if (fenceLength < 3) return false;
1474
+
1475
+ let lineStart = firstLineEnd + 1;
1476
+ while (lineStart <= raw.length) {
1477
+ const lineEnd = raw.indexOf("\n", lineStart);
1478
+ const line = lineEnd >= 0 ? raw.slice(lineStart, lineEnd) : raw.slice(lineStart);
1479
+ const trimmed = line.trimStart();
1480
+ const indent = line.length - trimmed.length;
1481
+ let closingLength = 0;
1482
+ while (trimmed.charAt(closingLength) === fenceChar) closingLength++;
1483
+ if (indent <= 3 && closingLength >= fenceLength && trimmed.slice(closingLength).trim().length === 0) {
1484
+ return true;
1485
+ }
1486
+ if (lineEnd < 0) break;
1487
+ lineStart = lineEnd + 1;
1488
+ }
1489
+ return false;
1490
+ }
1491
+
1492
+ #highlightStreamingDiffLines(completedText: string, lang: string | undefined): readonly string[] {
1493
+ const highlightCode = this.#theme.highlightCode;
1494
+ if (!highlightCode) return [];
1495
+ const signature = this.#activeRenderSignature;
1496
+ const cache = this.#streamingDiffLineCache;
1497
+ if (
1498
+ signature &&
1499
+ cache &&
1500
+ completedText.startsWith(cache.text) &&
1501
+ (cache.text.length === completedText.length || completedText.charCodeAt(cache.text.length) === 0x0a) &&
1502
+ cache.lang === lang &&
1503
+ cache.width === signature.width &&
1504
+ cache.paddingX === signature.paddingX &&
1505
+ cache.paddingY === signature.paddingY &&
1506
+ cache.codeBlockIndent === signature.codeBlockIndent &&
1507
+ cache.themeId === signature.themeId &&
1508
+ cache.defaultTextStyleId === signature.defaultTextStyleId &&
1509
+ cache.imageProtocol === signature.imageProtocol &&
1510
+ cache.hyperlinks === signature.hyperlinks &&
1511
+ cache.textSizing === signature.textSizing &&
1512
+ cache.bgColorProbe === signature.bgColorProbe &&
1513
+ cache.headingProbe === signature.headingProbe
1514
+ ) {
1515
+ if (completedText.length === cache.text.length) return cache.lines;
1516
+ const lines = cache.lines.slice();
1517
+ const addedText = completedText.slice(cache.text.length + 1);
1518
+ for (const codeLine of addedText.split("\n")) {
1519
+ lines.push(...highlightCode(codeLine, lang));
1520
+ }
1521
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1522
+ return lines;
1523
+ }
1524
+
1525
+ const lines: string[] = [];
1526
+ for (const codeLine of completedText.split("\n")) {
1527
+ lines.push(...highlightCode(codeLine, lang));
1528
+ }
1529
+ if (signature) {
1530
+ this.#streamingDiffLineCache = { ...signature, lang, text: completedText, lines };
1531
+ }
1532
+ return lines;
1533
+ }
1534
+
1389
1535
  #renderEmptyPaddingLines(signature: RenderSignature): string[] {
1390
1536
  const emptyLine = padding(signature.width);
1391
1537
  const emptyLines: string[] = [];
@@ -1563,17 +1709,8 @@ export class Markdown implements Component {
1563
1709
 
1564
1710
  const codeIndent = padding(this.#codeBlockIndent);
1565
1711
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
1566
- if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1567
- const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1568
- for (const hlLine of highlightedLines) {
1569
- lines.push(`${codeIndent}${hlLine}`);
1570
- }
1571
- } else {
1572
- // Split code by newlines and style each line
1573
- const codeLines = token.text.split("\n");
1574
- for (const codeLine of codeLines) {
1575
- lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1576
- }
1712
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
1713
+ lines.push(bodyLine);
1577
1714
  }
1578
1715
  lines.push(this.#theme.codeBlockBorder("```"));
1579
1716
  if (nextTokenType && nextTokenType !== "space") {
@@ -1953,16 +2090,8 @@ export class Markdown implements Component {
1953
2090
  // Code block in list item
1954
2091
  const codeIndent = padding(this.#codeBlockIndent);
1955
2092
  lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1956
- if (this.#theme.highlightCode && (!this.transientRenderCache || this.#renderingFrozenPrefix)) {
1957
- const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
1958
- for (const hlLine of highlightedLines) {
1959
- lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
1960
- }
1961
- } else {
1962
- const codeLines = token.text.split("\n");
1963
- for (const codeLine of codeLines) {
1964
- lines.push({ text: `${codeIndent}${this.#theme.codeBlock(codeLine)}`, nested: false });
1965
- }
2093
+ for (const bodyLine of this.#renderCodeBodyLines(token, codeIndent)) {
2094
+ lines.push({ text: bodyLine, nested: false });
1966
2095
  }
1967
2096
  lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
1968
2097
  } else if (isMathToken(token)) {
@@ -15,6 +15,8 @@
15
15
  * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
16
16
  */
17
17
 
18
+ import { wrapTmuxPassthroughIfNeeded } from "./tmux";
19
+
18
20
  /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
19
21
  export const KITTY_PLACEHOLDER = "\u{10eeee}";
20
22
 
@@ -60,18 +62,15 @@ export interface KittyGraphicsFeatures {
60
62
  * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
61
63
  * U+10EEEE with row/column diacritics).
62
64
  *
63
- * Only `kitty` (the protocol's origin) and `ghostty` ship a working
64
- * implementation; WezTerm advertises Kitty graphics but treats placeholder
65
- * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
66
- * still unchecked), and the tmux/screen fallback can land on any outer
67
- * terminal. Enabling placeholders on those paths emits a `columns × rows`
68
- * grid of U+10EEEE per image per frame; the cells render as boxed fallback
69
- * glyphs and re-emit on every repaint, which is exactly the
70
- * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
65
+ * Kitty and Ghostty advertise placeholder support directly. A tmux session
66
+ * 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).
71
71
  *
72
- * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
73
- * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
74
- * for a wezterm nightly that has merged placeholder support).
72
+ * `PI_NO_KITTY_PLACEHOLDERS=1` and `PI_KITTY_PLACEHOLDERS=0` remain hard
73
+ * opt-outs; `PI_KITTY_PLACEHOLDERS=1` explicitly opts in anywhere else.
75
74
  */
76
75
  export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
77
76
  const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
@@ -79,6 +78,7 @@ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: N
79
78
  const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
80
79
  if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
81
80
  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
82
  return terminalId === "kitty" || terminalId === "ghostty";
83
83
  }
84
84
 
@@ -120,7 +120,7 @@ export function encodeKittyVirtualPlacement(opts: {
120
120
  const params = ["a=p", "U=1", "q=2", `i=${opts.imageId}`];
121
121
  if (opts.placementId) params.push(`p=${opts.placementId}`);
122
122
  params.push(`c=${opts.columns}`, `r=${opts.rows}`);
123
- return `\x1b_G${params.join(",")}\x1b\\`;
123
+ return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
124
124
  }
125
125
 
126
126
  /**
@@ -9,6 +9,9 @@ import {
9
9
  renderKittyPlaceholderLines,
10
10
  setKittyGraphics,
11
11
  } from "./kitty-graphics";
12
+ import { isInsideTmux, wrapTmuxPassthrough, wrapTmuxPassthroughIfNeeded } from "./tmux";
13
+
14
+ export { isInsideTmux, wrapTmuxPassthrough } from "./tmux";
12
15
 
13
16
  export enum ImageProtocol {
14
17
  Kitty = "\x1b_G",
@@ -142,15 +145,6 @@ export class TerminalInfo {
142
145
  }
143
146
  }
144
147
 
145
- /**
146
- * Whether the agent process is running inside a tmux session. Read fresh on
147
- * each call so tests can toggle `Bun.env.TMUX` per case without re-importing
148
- * the module and so a tmux session attached/detached mid-run is observed.
149
- */
150
- export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
151
- return Boolean(env.TMUX);
152
- }
153
-
154
148
  /** Detect terminal multiplexers where scrollback clearing and height-change redraws are hostile. */
155
149
  export function isInsideTerminalMultiplexer(env: NodeJS.ProcessEnv = Bun.env): boolean {
156
150
  // TMUX/STY/ZELLIJ/CMUX workspace+surface ids are authoritative session
@@ -172,22 +166,6 @@ export function isInsideZellij(env: NodeJS.ProcessEnv = Bun.env): boolean {
172
166
  return Boolean(env.ZELLIJ);
173
167
  }
174
168
 
175
- /**
176
- * Wrap a control-sequence payload in tmux's DCS passthrough envelope. Each
177
- * ESC byte inside `payload` is doubled per tmux's escape rules. tmux strips
178
- * the envelope and forwards the unwrapped payload to the outer terminal only
179
- * when the user opts in with `set -g allow-passthrough on`; otherwise tmux
180
- * silently consumes the envelope, which is identical to the pre-wrap baseline
181
- * (tmux already swallowed the bare OSC).
182
- *
183
- * Used by `TerminalInfo.sendNotification` and the OSC 99 capability probe in
184
- * `terminal.ts` to keep notifications alive for terminals that understand
185
- * OSC 9 / OSC 99 (kitty, ghostty, wezterm, iterm2) when running under tmux.
186
- */
187
- export function wrapTmuxPassthrough(payload: string): string {
188
- return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
189
- }
190
-
191
169
  export function isNotificationSuppressed(): boolean {
192
170
  const value = $env.PI_NOTIFICATIONS;
193
171
  if (!value) return false;
@@ -628,7 +606,7 @@ export function setCellDimensions(dims: CellDimensions): void {
628
606
  function chunkKittyApc(leadParams: string, base64Data: string): string {
629
607
  const CHUNK_SIZE = 4096;
630
608
  if (base64Data.length <= CHUNK_SIZE) {
631
- return `\x1b_G${leadParams};${base64Data}\x1b\\`;
609
+ return wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams};${base64Data}\x1b\\`);
632
610
  }
633
611
 
634
612
  const chunks: string[] = [];
@@ -640,12 +618,12 @@ function chunkKittyApc(leadParams: string, base64Data: string): string {
640
618
  const isLast = offset + CHUNK_SIZE >= base64Data.length;
641
619
 
642
620
  if (isFirst) {
643
- chunks.push(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`);
621
+ chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_G${leadParams},m=1;${chunk}\x1b\\`));
644
622
  isFirst = false;
645
623
  } else if (isLast) {
646
- chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
624
+ chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=0;${chunk}\x1b\\`));
647
625
  } else {
648
- chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
626
+ chunks.push(wrapTmuxPassthroughIfNeeded(`\x1b_Gq=2,m=1;${chunk}\x1b\\`));
649
627
  }
650
628
 
651
629
  offset += CHUNK_SIZE;
@@ -699,7 +677,7 @@ export function encodeKittyPlacement(options: {
699
677
  if (options.placementId) params.push(`p=${options.placementId}`);
700
678
  if (options.columns) params.push(`c=${options.columns}`);
701
679
  if (options.rows) params.push(`r=${options.rows}`);
702
- return `\x1b_G${params.join(",")}\x1b\\`;
680
+ return wrapTmuxPassthroughIfNeeded(`\x1b_G${params.join(",")}\x1b\\`);
703
681
  }
704
682
 
705
683
  /**
@@ -710,7 +688,7 @@ export function encodeKittyPlacement(options: {
710
688
  * this is the only way to actually purge a placed image.
711
689
  */
712
690
  export function encodeKittyDeleteImage(imageId: number): string {
713
- return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`;
691
+ return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
714
692
  }
715
693
 
716
694
  export function encodeITerm2(
package/src/terminal.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
- import { $env, isBunTestRuntime, isTerminalHeadless, logger, postmortem } from "@oh-my-pi/pi-utils";
3
+ import {
4
+ $env,
5
+ isBunTestRuntime,
6
+ isTerminalHeadless,
7
+ logger,
8
+ postmortem,
9
+ restoreTerminalStderr,
10
+ suppressTerminalStderr,
11
+ } from "@oh-my-pi/pi-utils";
4
12
  import { setKittyProtocolActive } from "./keys";
5
13
  import { StdinBuffer } from "./stdin-buffer";
6
14
  import {
@@ -16,6 +24,7 @@ import { type HangulCompatibilityJamoWidth, setHangulCompatibilityJamoWidth } fr
16
24
  const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
17
25
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
18
26
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
27
+ const WINDOWS_TERMINAL_OSC11_POLL_MS = 30_000;
19
28
  // Hangul Compatibility Jamo (U+3131..=U+318E) render width is terminal-dependent:
20
29
  // Ghostty follows UAX#11 (2 cells); Terminal.app and iTerm2 render narrow (1),
21
30
  // matching the macOS platform default. Override only for terminals known to
@@ -36,10 +45,16 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
36
45
  }
37
46
 
38
47
  function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
48
+ if (isInsideTmux(env)) return false;
39
49
  if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
40
50
  return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
41
51
  }
42
52
 
53
+ function shouldPollWindowsTerminalAppearance(env: NodeJS.ProcessEnv = Bun.env): boolean {
54
+ if (process.platform !== "win32") return false;
55
+ if (!env.WT_SESSION) return false;
56
+ return !env.TERM_PROGRAM || env.TERM_PROGRAM.toLowerCase() === "windows_terminal";
57
+ }
43
58
  /**
44
59
  * Maximum encoded UTF-8 bytes per `process.stdout.write` call on Windows.
45
60
  *
@@ -275,6 +290,9 @@ function createConsoleCodepageGuard(): (() => void) | null {
275
290
  */
276
291
  export function emergencyTerminalRestore(): void {
277
292
  try {
293
+ // Crash paths must surface subsequent stderr (fatal reports) on the
294
+ // real terminal; no-op when the stderr guard is inactive.
295
+ restoreTerminalStderr();
278
296
  const terminal = activeTerminal;
279
297
  if (terminal) {
280
298
  terminal.stop();
@@ -488,6 +506,7 @@ export class ProcessTerminal implements Terminal {
488
506
  #reportedColumns?: number;
489
507
  #reportedRows?: number;
490
508
  #mode2031DebounceTimer?: Timer;
509
+ #windowsTerminalAppearancePollTimer?: Timer;
491
510
  #progressTimer?: Timer;
492
511
 
493
512
  get kittyProtocolActive(): boolean {
@@ -551,6 +570,11 @@ export class ProcessTerminal implements Terminal {
551
570
  activeTerminal = this;
552
571
  terminalEverStarted = true;
553
572
 
573
+ // Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
574
+ // the viewport while we own the terminal; released in stop(). See
575
+ // stderr-guard in pi-utils (mirrors openai/codex#24459).
576
+ suppressTerminalStderr();
577
+
554
578
  // Save previous state and enable raw mode
555
579
  this.#wasRaw = process.stdin.isRaw || false;
556
580
  if (process.stdin.setRawMode) {
@@ -611,7 +635,8 @@ export class ProcessTerminal implements Terminal {
611
635
  // WezTerm) detect the appearance once at startup and pick up later OS
612
636
  // theme changes on next launch. Earlier builds polled OSC 11 every 30 s
613
637
  // here for those terminals, but each poll's OSC 11/DA1 write wiped the
614
- // user's active text selection on several of them (#3297).
638
+ // user's active text selection on several of them (#3297). Native Windows
639
+ // Terminal gets a scoped fallback after DECRQM confirms 2031 is unsupported.
615
640
 
616
641
  // Probe DEC private-mode support via DECRQM. 2026 (synchronized output)
617
642
  // gates the renderer's begin/end markers; 2048 (in-band resize) is enabled
@@ -1151,8 +1176,25 @@ export class ProcessTerminal implements Terminal {
1151
1176
  }
1152
1177
  }
1153
1178
  if (mode === 2048 && supported) this.#enableInBandResize();
1179
+ if (mode === 2031) this.#syncWindowsTerminalAppearancePolling(supported);
1154
1180
  }
1155
1181
 
1182
+ #syncWindowsTerminalAppearancePolling(mode2031Supported: boolean): void {
1183
+ if (mode2031Supported || !shouldPollWindowsTerminalAppearance() || this.#dead) {
1184
+ this.#clearWindowsTerminalAppearancePoll();
1185
+ return;
1186
+ }
1187
+ if (this.#windowsTerminalAppearancePollTimer) return;
1188
+ this.#windowsTerminalAppearancePollTimer = setInterval(() => {
1189
+ this.#queryBackgroundColor();
1190
+ }, WINDOWS_TERMINAL_OSC11_POLL_MS);
1191
+ }
1192
+
1193
+ #clearWindowsTerminalAppearancePoll(): void {
1194
+ if (!this.#windowsTerminalAppearancePollTimer) return;
1195
+ clearInterval(this.#windowsTerminalAppearancePollTimer);
1196
+ this.#windowsTerminalAppearancePollTimer = undefined;
1197
+ }
1156
1198
  #disableXtermScrollToBottomMode(mode: number): void {
1157
1199
  if (this.#xtermScrollToBottomRestoreModes.has(mode) || this.#dead) return;
1158
1200
  this.#xtermScrollToBottomRestoreModes.add(mode);
@@ -1269,6 +1311,11 @@ export class ProcessTerminal implements Terminal {
1269
1311
  activeTerminal = null;
1270
1312
  }
1271
1313
 
1314
+ // Release terminal ownership of fd 2 first so external programs,
1315
+ // suspend, and shutdown see the real stderr even if a later teardown
1316
+ // step throws.
1317
+ restoreTerminalStderr();
1318
+
1272
1319
  if (this.#clearProgressTimer()) {
1273
1320
  this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
1274
1321
  }
@@ -1305,6 +1352,7 @@ export class ProcessTerminal implements Terminal {
1305
1352
  }
1306
1353
  this.#appearanceCallbacks = [];
1307
1354
  this.#osc11Pending = false;
1355
+ this.#clearWindowsTerminalAppearancePoll();
1308
1356
  this.#osc11QueryQueued = false;
1309
1357
  this.#osc11ResponseBuffer = "";
1310
1358
  this.#osc99PendingId = undefined;
package/src/tmux.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Whether the process is running inside a tmux session. */
2
+ export function isInsideTmux(env: NodeJS.ProcessEnv = Bun.env): boolean {
3
+ return Boolean(env.TMUX);
4
+ }
5
+
6
+ /** Wrap a control sequence in tmux's DCS passthrough envelope. */
7
+ export function wrapTmuxPassthrough(payload: string): string {
8
+ return `\x1bPtmux;${payload.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`;
9
+ }
10
+
11
+ /** Pass a control sequence through tmux, leaving direct-terminal output unchanged. */
12
+ export function wrapTmuxPassthroughIfNeeded(payload: string, env: NodeJS.ProcessEnv = Bun.env): string {
13
+ return isInsideTmux(env) ? wrapTmuxPassthrough(payload) : payload;
14
+ }
package/src/tui.ts CHANGED
@@ -211,6 +211,18 @@ export interface NativeScrollbackCommittedRows {
211
211
  setNativeScrollbackCommittedRows(rows: number): void;
212
212
  }
213
213
 
214
+ /**
215
+ * A component that discards rows after they enter native scrollback implements
216
+ * this hook so a destructive full replay can rehydrate its complete frame.
217
+ */
218
+ export interface NativeScrollbackReplay {
219
+ prepareNativeScrollbackReplay(): void;
220
+ }
221
+
222
+ function prepareNativeScrollbackReplay(component: Component): void {
223
+ (component as Component & Partial<NativeScrollbackReplay>).prepareNativeScrollbackReplay?.();
224
+ }
225
+
214
226
  function setNativeScrollbackCommittedRows(component: Component, rows: number): void {
215
227
  (component as Component & Partial<NativeScrollbackCommittedRows>).setNativeScrollbackCommittedRows?.(rows);
216
228
  }
@@ -2739,6 +2751,21 @@ export class TUI extends Container {
2739
2751
  return;
2740
2752
  }
2741
2753
 
2754
+ // A destructive replay erases native history and must receive the complete
2755
+ // component frame. Give virtualized roots one compose to rehydrate rows
2756
+ // they dropped after commit. Height-only and net-unchanged resize events
2757
+ // count too: both enter the geometry rebuild path below.
2758
+ const replayFullHistory =
2759
+ this.#hasEverRendered &&
2760
+ !resizeRepaintsInPlace() &&
2761
+ (this.#clearScrollbackOnNextRender ||
2762
+ this.#resizeEventPending ||
2763
+ (this.#previousWidth > 0 && this.#previousWidth !== width) ||
2764
+ (this.#previousHeight > 0 && this.#previousHeight !== height));
2765
+ if (replayFullHistory) {
2766
+ for (const child of this.children) prepareNativeScrollbackReplay(child);
2767
+ }
2768
+
2742
2769
  // 1. Compose the frame. Bracket the render so the image budget observes
2743
2770
  // every inline image in display order (overlays carry none). A
2744
2771
  // component-scoped frame skips the budget pass instead — it is gated on
package/src/utils.ts CHANGED
@@ -394,6 +394,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
394
394
  const ch = firstCodePointChar(grapheme);
395
395
  if (!ch) return "other";
396
396
  if (WORD_NAV_RE_WHITESPACE.test(ch)) return "whitespace";
397
+ if (ch === "_") return "word";
397
398
  if (WORD_NAV_RE_PUNCT.test(ch) || WORD_NAV_RE_SYMBOL.test(ch)) return "delimiter";
398
399
  if (
399
400
  WORD_NAV_RE_HAN.test(ch) ||
@@ -403,7 +404,7 @@ export function getWordNavKind(grapheme: string): WordNavKind {
403
404
  ) {
404
405
  return "cjk";
405
406
  }
406
- if (ch === "_" || WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
407
+ if (WORD_NAV_RE_LETTER.test(ch) || WORD_NAV_RE_NUMBER.test(ch)) return "word";
407
408
  return "other";
408
409
  }
409
410