@oh-my-pi/pi-tui 17.0.0 → 17.0.1

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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.1] - 2026-07-16
6
+
7
+ ### Added
8
+
9
+ - Added native cmux notification delivery targeted to the current terminal surface.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed a tmux regression where every non-Kitty pane was forced into legacy keyboard input, collapsing Ctrl+H into Backspace and Shift+Enter into Enter even with `extended-keys on`; the xterm modifyOtherKeys fallback is requested again so tmux honors or ignores it per its own `extended-keys` setting ([#5620](https://github.com/can1357/oh-my-pi/issues/5620)).
14
+ - Fixed `@` file-reference and path completion falling through incorrectly inside slash command arguments when command-specific argument completion has no matches ([#5580](https://github.com/can1357/oh-my-pi/issues/5580)).
15
+ - Fixed streamed Markdown tables reflowing rows already written to native scrollback when later cells widen a column.
16
+ - Fixed fullscreen session-replacement overlays and resize drags exposing stale normal-buffer frames on terminals without effective DEC 2026: asynchronous replacements now keep their overlay visible until the rebuilt transcript is ready, overlay exit is fused into the destructive paint, and resize viewport frames rewrite the normal buffer without alternate-screen switches. Inconclusive DECRQM probes also no longer disable statically detected synchronized output ([#5319](https://github.com/can1357/oh-my-pi/issues/5319)).
17
+ - Fixed autocomplete popups moving Windows Terminal IME candidate windows away from the prompt by keeping the terminal cursor anchored at the text insertion point ([#4760](https://github.com/can1357/oh-my-pi/issues/4760)).
18
+
5
19
  ## [17.0.0] - 2026-07-15
6
20
 
7
21
  ### Added
@@ -1,5 +1,5 @@
1
1
  import type { SymbolTheme } from "../symbols.js";
2
- import type { Component } from "../tui.js";
2
+ import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui.js";
3
3
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
4
4
  export declare function clearRenderCache(): void;
5
5
  /**
@@ -47,7 +47,7 @@ export interface MarkdownTheme {
47
47
  resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
48
48
  symbols: SymbolTheme;
49
49
  }
50
- export declare class Markdown implements Component {
50
+ export declare class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
51
51
  #private;
52
52
  setIgnoreTight(ignore: boolean): this;
53
53
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
@@ -65,6 +65,14 @@ export declare class Markdown implements Component {
65
65
  * lineage), and on cache-served non-streaming renders.
66
66
  */
67
67
  getLastRenderSettledRows(): number;
68
+ /**
69
+ * Freeze every table whose first physical row is already part of the native
70
+ * scrollback prefix. The recorded widths came from the exact frame that was
71
+ * just emitted, so the next streamed delta cannot retroactively widen it.
72
+ */
73
+ setNativeScrollbackCommittedRows(rows: number): void;
74
+ /** A destructive replay removes the immutable tape this layout was guarding. */
75
+ prepareNativeScrollbackReplay(): void;
68
76
  render(width: number): readonly string[];
69
77
  }
70
78
  /**
@@ -67,9 +67,11 @@ export interface Terminal {
67
67
  get appearance(): TerminalAppearance | undefined;
68
68
  /**
69
69
  * Register a callback fired once per DEC private mode when its DECRQM support
70
- * status resolves. Optional: only real terminals implement capability probing.
70
+ * status resolves. `confirmed` is false when the terminal answered the DA1
71
+ * sentinel without answering DECRQM, which proves only that querying support
72
+ * is unavailable — not that the private mode itself is unsupported.
71
73
  */
72
- onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
74
+ onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
73
75
  }
74
76
  /**
75
77
  * True when stdout flows through a ConPTY pseudo-console (native win32, or
@@ -91,7 +93,7 @@ export declare class ProcessTerminal implements Terminal {
91
93
  get keyboardEnhancementExitSequence(): string | null;
92
94
  get appearance(): TerminalAppearance | undefined;
93
95
  onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
94
- onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void;
96
+ onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
95
97
  start(onInput: (data: string) => void, onResize: () => void): void;
96
98
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
97
99
  stop(): void;
@@ -251,7 +251,7 @@ export interface OverlayHandle {
251
251
  /**
252
252
  * Container - a component that contains other components
253
253
  */
254
- export declare class Container implements Component {
254
+ export declare class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
255
255
  #private;
256
256
  children: Component[];
257
257
  setIgnoreTight(ignore: boolean): this;
@@ -267,6 +267,16 @@ export declare class Container implements Component {
267
267
  * {@link clear} for that). Idempotent per child via each child's own dispose.
268
268
  */
269
269
  dispose(): void;
270
+ /**
271
+ * Split the committed prefix from the container's most recently rendered
272
+ * rows across its children. The memoized child arrays are the exact geometry
273
+ * that produced that frame; when the child list was invalidated or rebuilt,
274
+ * there is no safe old-to-new coordinate mapping, so propagation waits for
275
+ * the next render/post-emit publication.
276
+ */
277
+ setNativeScrollbackCommittedRows(rows: number): void;
278
+ /** Recursively discard layout locks that are meaningful only to the old tape. */
279
+ prepareNativeScrollbackReplay(): void;
270
280
  render(width: number): readonly string[];
271
281
  }
272
282
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "17.0.0",
4
+ "version": "17.0.1",
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": "17.0.0",
41
- "@oh-my-pi/pi-utils": "17.0.0",
40
+ "@oh-my-pi/pi-natives": "17.0.1",
41
+ "@oh-my-pi/pi-utils": "17.0.1",
42
42
  "lru-cache": "11.5.2",
43
43
  "marked": "^18.0.6"
44
44
  },
@@ -464,10 +464,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
464
464
  // path (`/tmp/fo` at prompt start, `see /tmp` mid-prompt); fall
465
465
  // through to file-path completion.
466
466
  } else if (!isMidPromptSkillLookup) {
467
- // Submitted slash commands own their argument text only when the
468
- // matched command accepts args. No-arg slash-looking prompts such
469
- // as `/settings @file` still fall through to prompt-composer
470
- // completions because submit treats them as normal prompt text.
467
+ // Give matched commands first chance to complete arguments, then
468
+ // fall through to prompt-composer file completion when they have
469
+ // no argument provider or it has no matches.
471
470
  const commandName = commandText.slice(1, spaceIndex); // Command without "/"
472
471
  const argumentText = commandText.slice(spaceIndex + 1); // Text after space
473
472
 
@@ -475,20 +474,19 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
475
474
  if (command && "allowArgs" in command && command.allowArgs === false && !/\S/.test(argumentText)) {
476
475
  return null;
477
476
  }
478
- if (command && (!("allowArgs" in command) || command.allowArgs !== false)) {
479
- if (!("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
480
- return null; // No argument completion for this command
481
- }
482
-
477
+ if (
478
+ command &&
479
+ (!("allowArgs" in command) || command.allowArgs !== false) &&
480
+ "getArgumentCompletions" in command &&
481
+ command.getArgumentCompletions
482
+ ) {
483
483
  const argumentSuggestions = await command.getArgumentCompletions(argumentText);
484
- if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
485
- return null;
484
+ if (Array.isArray(argumentSuggestions) && argumentSuggestions.length > 0) {
485
+ return {
486
+ items: argumentSuggestions,
487
+ prefix: argumentText,
488
+ };
486
489
  }
487
-
488
- return {
489
- items: argumentSuggestions,
490
- prefix: argumentText,
491
- };
492
490
  }
493
491
  }
494
492
  }
@@ -858,8 +858,9 @@ export class Editor implements Component, Focusable {
858
858
  }
859
859
 
860
860
  // Render each layout line
861
- // Emit hardware cursor marker only when focused and not showing autocomplete
862
- const emitCursorMarker = this.focused && !this.#autocompleteState;
861
+ // Keep the hardware cursor at the text insertion point while autocomplete
862
+ // rows render below it; terminals use that position to anchor IME candidates.
863
+ const emitCursorMarker = this.focused;
863
864
  const lineContentWidth = contentAreaWidth;
864
865
 
865
866
  // Compute inline hint text (dim ghost text after cursor)
@@ -4,7 +4,7 @@ import { latexToBlock } from "../latex-block";
4
4
  import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
5
5
  import type { SymbolTheme } from "../symbols";
6
6
  import { TERMINAL } from "../terminal-capabilities";
7
- import type { Component } from "../tui";
7
+ import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
8
8
  import {
9
9
  applyBackgroundToLine,
10
10
  Ellipsis,
@@ -607,11 +607,17 @@ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
607
607
  const RENDER_CACHE_MAX_SIZE = 512 * 1024;
608
608
  const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
609
609
  const EMPTY_RENDER_LINES: readonly string[] = [];
610
- const renderCache = new LRUCache<string, readonly string[]>({
610
+
611
+ interface RenderCacheEntry {
612
+ lines: readonly string[];
613
+ tables: readonly RenderedTableLayout[];
614
+ }
615
+
616
+ const renderCache = new LRUCache<string, RenderCacheEntry>({
611
617
  max: RENDER_CACHE_MAX,
612
618
  maxSize: RENDER_CACHE_MAX_SIZE,
613
619
  maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
614
- sizeCalculation: renderedLinesCacheSize,
620
+ sizeCalculation: renderCacheEntrySize,
615
621
  });
616
622
 
617
623
  function renderedLinesCacheSize(lines: readonly string[]): number {
@@ -620,6 +626,12 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
620
626
  return Math.max(1, size);
621
627
  }
622
628
 
629
+ function renderCacheEntrySize(entry: RenderCacheEntry): number {
630
+ let size = renderedLinesCacheSize(entry.lines);
631
+ for (const table of entry.tables) size += table.key.length + table.columnWidths.length + 4;
632
+ return size;
633
+ }
634
+
623
635
  // A reference-link definition (`[label]: dest`) resolves across the whole
624
636
  // document, so a split lex cannot reproduce it — disable the streaming fast path
625
637
  // when one is present (rare in streamed output). The label may contain
@@ -951,6 +963,7 @@ interface StreamPrefixLineCache extends RenderSignature {
951
963
  text: string;
952
964
  tokenCount: number;
953
965
  lines: readonly string[];
966
+ tables: readonly TableRenderSpec[];
954
967
  }
955
968
  interface StreamingDiffLineCache extends RenderSignature {
956
969
  lang: string | undefined;
@@ -958,7 +971,25 @@ interface StreamingDiffLineCache extends RenderSignature {
958
971
  lines: readonly string[];
959
972
  }
960
973
 
961
- export class Markdown implements Component {
974
+ interface TableLayoutLock {
975
+ availableWidth: number;
976
+ columnWidths: readonly number[];
977
+ }
978
+
979
+ interface TableRenderSpec extends TableLayoutLock {
980
+ key: string;
981
+ lineCount: number;
982
+ startRow: number;
983
+ endRow: number;
984
+ }
985
+
986
+ interface RenderedTableLayout extends TableLayoutLock {
987
+ key: string;
988
+ startRow: number;
989
+ endRow: number;
990
+ }
991
+
992
+ export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
962
993
  #text: string;
963
994
  #paddingX: number; // Left/right padding
964
995
  #paddingY: number; // Top/bottom padding
@@ -1005,10 +1036,19 @@ export class Markdown implements Component {
1005
1036
  #renderingFrozenPrefix = false;
1006
1037
  #streamingDiffLineCache?: StreamingDiffLineCache;
1007
1038
  #activeRenderSignature?: RenderSignature;
1039
+ // Streaming tables may grow naturally while wholly repaintable. Once any
1040
+ // physical row of a table enters native scrollback, its current column widths
1041
+ // are locked for the rest of this append-only text lineage: future wider cells
1042
+ // wrap inside those columns instead of reflowing immutable history above.
1043
+ #tableLayoutWidth?: number;
1044
+ #lockedTableLayouts = new Map<string, TableLayoutLock>();
1045
+ #lastRenderedTableLayouts: RenderedTableLayout[] = [];
1046
+ #activeTableRenderSpecs?: TableRenderSpec[];
1008
1047
 
1009
1048
  #ignoreTight = false;
1010
1049
 
1011
1050
  setIgnoreTight(ignore: boolean): this {
1051
+ if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
1012
1052
  this.#ignoreTight = ignore;
1013
1053
  this.invalidate();
1014
1054
  return this;
@@ -1037,6 +1077,7 @@ export class Markdown implements Component {
1037
1077
  // full lex + wrap runs per re-emit — one of the top CPU hotspots during
1038
1078
  // streaming (issue #4353). Mirrors `Text.setText`'s guard.
1039
1079
  if (text === this.#text) return false;
1080
+ if (!text.startsWith(this.#text)) this.#clearTableLayouts();
1040
1081
  this.#text = text;
1041
1082
  if (!text.trim()) {
1042
1083
  // Blank replacement: render() early-returns before #lexTokens can see
@@ -1080,6 +1121,41 @@ export class Markdown implements Component {
1080
1121
  return this.#lastRenderSettledRows;
1081
1122
  }
1082
1123
 
1124
+ /**
1125
+ * Freeze every table whose first physical row is already part of the native
1126
+ * scrollback prefix. The recorded widths came from the exact frame that was
1127
+ * just emitted, so the next streamed delta cannot retroactively widen it.
1128
+ */
1129
+ setNativeScrollbackCommittedRows(rows: number): void {
1130
+ const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
1131
+ let changed = false;
1132
+ for (const table of this.#lastRenderedTableLayouts) {
1133
+ if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
1134
+ this.#lockedTableLayouts.set(table.key, {
1135
+ availableWidth: table.availableWidth,
1136
+ columnWidths: table.columnWidths.slice(),
1137
+ });
1138
+ changed = true;
1139
+ }
1140
+ if (changed) this.invalidate();
1141
+ }
1142
+
1143
+ /** A destructive replay removes the immutable tape this layout was guarding. */
1144
+ prepareNativeScrollbackReplay(): void {
1145
+ this.#clearTableLayouts();
1146
+ this.#tableLayoutWidth = undefined;
1147
+ this.invalidate();
1148
+ }
1149
+
1150
+ #clearTableLayouts(): void {
1151
+ this.#lockedTableLayouts.clear();
1152
+ this.#lastRenderedTableLayouts = [];
1153
+ this.#activeTableRenderSpecs = undefined;
1154
+ // Same-width replay/non-append rewrites could otherwise reuse physical
1155
+ // prefix lines rendered with the retired locked widths.
1156
+ this.#streamPrefixLineCache = undefined;
1157
+ }
1158
+
1083
1159
  // Lex `text` into block tokens, reusing the frozen stable prefix when the text
1084
1160
  // only grew (the streaming path). Falls back to a full lex whenever the prefix
1085
1161
  // is no longer a prefix (non-append edit), the text carries reference-link
@@ -1159,6 +1235,11 @@ export class Markdown implements Component {
1159
1235
  }
1160
1236
 
1161
1237
  render(width: number): readonly string[] {
1238
+ if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
1239
+ this.#clearTableLayouts();
1240
+ this.invalidate();
1241
+ }
1242
+ this.#tableLayoutWidth = width;
1162
1243
  // L1: per-instance cache — fastest path for repeated renders of the same
1163
1244
  // instance at the same width (e.g. resize debounce, repeated redraws).
1164
1245
  // Returning the cached reference is load-bearing: parents memoize their
@@ -1200,29 +1281,40 @@ export class Markdown implements Component {
1200
1281
  // theme.heading is used as the representative theme probe — it's required
1201
1282
  // by MarkdownTheme and is one of the most styling-sensitive entries.
1202
1283
  let cacheKey: string | undefined;
1203
- if (!this.transientRenderCache) {
1284
+ if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
1204
1285
  cacheKey = this.#renderCacheKey(normalizedText, signature);
1205
1286
  const cached = renderCache.get(cacheKey);
1206
1287
  if (cached !== undefined) {
1288
+ // Restore both the rendered rows and the geometry metadata that produced
1289
+ // them. A later scrollback publication must never lock widths from an
1290
+ // older transient frame against rows served from this cache entry.
1291
+ this.#lastRenderedTableLayouts = cached.tables.map(table => ({
1292
+ ...table,
1293
+ columnWidths: table.columnWidths.slice(),
1294
+ }));
1207
1295
  // Populate L1 so subsequent calls from this instance are O(1) map lookup.
1208
1296
  this.#cachedText = this.#text;
1209
1297
  this.#cachedWidth = width;
1210
- this.#cachedLines = cached;
1211
- return cached;
1298
+ this.#cachedLines = cached.lines;
1299
+ return cached.lines;
1212
1300
  }
1213
1301
  }
1214
1302
 
1215
1303
  // Parse markdown to HTML-like tokens
1216
1304
  const tokens = this.#lexTokens(normalizedText);
1217
1305
  let contentLines: string[];
1306
+ const tableRenderSpecs: TableRenderSpec[] = [];
1307
+ this.#activeTableRenderSpecs = tableRenderSpecs;
1218
1308
  this.#activeRenderSignature = signature;
1219
1309
  try {
1220
1310
  contentLines = this.transientRenderCache
1221
1311
  ? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
1222
- : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1312
+ : this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1223
1313
  } finally {
1224
1314
  this.#activeRenderSignature = undefined;
1315
+ this.#activeTableRenderSpecs = undefined;
1225
1316
  }
1317
+ this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
1226
1318
  const emptyLines = this.#renderEmptyPaddingLines(signature);
1227
1319
 
1228
1320
  // Combine top padding, content, and bottom padding
@@ -1239,7 +1331,13 @@ export class Markdown implements Component {
1239
1331
  // Update L2 module-level LRU so future instances with the same key skip
1240
1332
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
1241
1333
  if (cacheKey !== undefined) {
1242
- renderCache.set(cacheKey, result);
1334
+ renderCache.set(cacheKey, {
1335
+ lines: result,
1336
+ tables: this.#lastRenderedTableLayouts.map(table => ({
1337
+ ...table,
1338
+ columnWidths: table.columnWidths.slice(),
1339
+ })),
1340
+ });
1243
1341
  }
1244
1342
 
1245
1343
  return result;
@@ -1276,15 +1374,18 @@ export class Markdown implements Component {
1276
1374
  const frozenText = this.#streamPrefixText;
1277
1375
  const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
1278
1376
  if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
1279
- return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
1377
+ return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
1280
1378
  }
1281
1379
 
1282
1380
  const contentLines: string[] = [];
1283
1381
  const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
1284
1382
  let renderedUntil = 0;
1383
+ let renderedSourceOffset = 0;
1285
1384
  if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
1286
1385
  contentLines.push(...reusablePrefix.lines);
1386
+ this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
1287
1387
  renderedUntil = reusablePrefix.tokenCount;
1388
+ renderedSourceOffset = reusablePrefix.text.length;
1288
1389
  }
1289
1390
 
1290
1391
  if (renderedUntil < frozenTokenCount) {
@@ -1293,7 +1394,15 @@ export class Markdown implements Component {
1293
1394
  this.#renderingFrozenPrefix = true;
1294
1395
  try {
1295
1396
  contentLines.push(
1296
- ...this.#renderContentLines(tokens, renderedUntil, frozenTokenCount, contentWidth, signature),
1397
+ ...this.#renderContentLines(
1398
+ tokens,
1399
+ renderedUntil,
1400
+ frozenTokenCount,
1401
+ contentWidth,
1402
+ signature,
1403
+ contentLines.length,
1404
+ renderedSourceOffset,
1405
+ ),
1297
1406
  );
1298
1407
  } finally {
1299
1408
  this.#renderingFrozenPrefix = false;
@@ -1306,6 +1415,7 @@ export class Markdown implements Component {
1306
1415
  text: frozenText,
1307
1416
  tokenCount: frozenTokenCount,
1308
1417
  lines: contentLines.slice(),
1418
+ tables: this.#activeTableRenderSpecs?.slice() ?? [],
1309
1419
  };
1310
1420
 
1311
1421
  // Settled exposure (hard-monotone): these rows are declared final to
@@ -1322,7 +1432,17 @@ export class Markdown implements Component {
1322
1432
  }
1323
1433
 
1324
1434
  if (renderedUntil < tokens.length) {
1325
- contentLines.push(...this.#renderContentLines(tokens, renderedUntil, tokens.length, contentWidth, signature));
1435
+ contentLines.push(
1436
+ ...this.#renderContentLines(
1437
+ tokens,
1438
+ renderedUntil,
1439
+ tokens.length,
1440
+ contentWidth,
1441
+ signature,
1442
+ contentLines.length,
1443
+ frozenText.length,
1444
+ ),
1445
+ );
1326
1446
  }
1327
1447
 
1328
1448
  return contentLines;
@@ -1356,23 +1476,57 @@ export class Markdown implements Component {
1356
1476
  end: number,
1357
1477
  contentWidth: number,
1358
1478
  signature: RenderSignature,
1479
+ rowOffset: number,
1480
+ startingSourceOffset: number,
1359
1481
  ): string[] {
1360
- const renderedLines: string[] = [];
1482
+ const wrappedLines: string[] = [];
1483
+ let sourceOffset = startingSourceOffset;
1361
1484
  for (let i = start; i < end; i++) {
1362
1485
  const token = tokens[i];
1363
1486
  const nextToken = tokens[i + 1];
1364
- renderedLines.push(...this.#renderToken(token, contentWidth, nextToken?.type));
1365
- }
1366
-
1367
- const wrappedLines: string[] = [];
1368
- for (const line of renderedLines) {
1369
- // Skip wrapping for image protocol lines and OSC 66 sized headings
1370
- // (would corrupt escape sequences / split the indivisible sized span).
1371
- if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1372
- wrappedLines.push(line);
1373
- } else {
1374
- wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
1487
+ const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1488
+ const tokenWrappedRowStart = wrappedLines.length;
1489
+ const tokenRowStart = rowOffset + tokenWrappedRowStart;
1490
+ const renderedTokenLines = this.#renderToken(
1491
+ token,
1492
+ contentWidth,
1493
+ nextToken?.type,
1494
+ undefined,
1495
+ `offset:${sourceOffset}`,
1496
+ );
1497
+ const tokenLineOffsets = [0];
1498
+ for (const line of renderedTokenLines) {
1499
+ // Skip wrapping for image protocol lines and OSC 66 sized headings
1500
+ // (would corrupt escape sequences / split the indivisible sized span).
1501
+ if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
1502
+ wrappedLines.push(line);
1503
+ } else {
1504
+ wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
1505
+ }
1506
+ tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
1375
1507
  }
1508
+ const tableSpecs = this.#activeTableRenderSpecs;
1509
+ if (tableSpecs !== undefined) {
1510
+ for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
1511
+ const spec = tableSpecs[specIndex]!;
1512
+ let relativeStart: number;
1513
+ let relativeEnd: number;
1514
+ if (token.type === "table") {
1515
+ // Exclude the optional inter-block blank from a top-level table's span.
1516
+ relativeStart = 0;
1517
+ relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
1518
+ } else {
1519
+ // Container renderers express nested table spans relative to their
1520
+ // returned lines. Preserve that exact span through this final wrap.
1521
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1522
+ relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
1523
+ relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
1524
+ }
1525
+ spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
1526
+ spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
1527
+ }
1528
+ }
1529
+ sourceOffset += token.raw.length;
1376
1530
  }
1377
1531
 
1378
1532
  const leftMargin = padding(signature.paddingX);
@@ -1416,6 +1570,21 @@ export class Markdown implements Component {
1416
1570
  return contentLines;
1417
1571
  }
1418
1572
 
1573
+ #resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
1574
+ const layouts: RenderedTableLayout[] = [];
1575
+ for (const spec of specs) {
1576
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1577
+ layouts.push({
1578
+ key: spec.key,
1579
+ availableWidth: spec.availableWidth,
1580
+ columnWidths: spec.columnWidths.slice(),
1581
+ startRow: topPadding + spec.startRow,
1582
+ endRow: topPadding + spec.endRow,
1583
+ });
1584
+ }
1585
+ return layouts;
1586
+ }
1587
+
1419
1588
  #renderCodeBodyLines(token: Token, codeIndent: string): string[] {
1420
1589
  const bodyLines: string[] = [];
1421
1590
  const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
@@ -1626,7 +1795,13 @@ export class Markdown implements Component {
1626
1795
  };
1627
1796
  }
1628
1797
 
1629
- #renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: InlineStyleContext): string[] {
1798
+ #renderToken(
1799
+ token: Token,
1800
+ width: number,
1801
+ nextTokenType?: string,
1802
+ styleContext?: InlineStyleContext,
1803
+ tokenKey = "root",
1804
+ ): string[] {
1630
1805
  const lines: string[] = [];
1631
1806
 
1632
1807
  // Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
@@ -1728,7 +1903,7 @@ export class Markdown implements Component {
1728
1903
  }
1729
1904
 
1730
1905
  case "table": {
1731
- const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
1906
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
1732
1907
  lines.push(...tableLines);
1733
1908
  break;
1734
1909
  }
@@ -1741,20 +1916,59 @@ export class Markdown implements Component {
1741
1916
  const quoteContentWidth = Math.max(1, width - 2);
1742
1917
  const quoteTokens = token.tokens || [];
1743
1918
  const renderedQuoteLines: string[] = [];
1919
+ const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1744
1920
 
1745
1921
  for (let i = 0; i < quoteTokens.length; i++) {
1746
1922
  const quoteToken = quoteTokens[i];
1747
1923
  const nextQuoteToken = quoteTokens[i + 1];
1748
- renderedQuoteLines.push(
1749
- ...this.#renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
1924
+ const quoteTokenRowStart = renderedQuoteLines.length;
1925
+ const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
1926
+ const quoteTokenLines = this.#renderToken(
1927
+ quoteToken,
1928
+ quoteContentWidth,
1929
+ nextQuoteToken?.type,
1930
+ quoteInlineStyleContext,
1931
+ `${tokenKey}/quote:${i}`,
1750
1932
  );
1933
+ renderedQuoteLines.push(...quoteTokenLines);
1934
+
1935
+ const tableSpecs = this.#activeTableRenderSpecs;
1936
+ if (tableSpecs !== undefined) {
1937
+ for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
1938
+ const spec = tableSpecs[specIndex]!;
1939
+ if (spec.startRow < 0) {
1940
+ // Direct child tables initially have no row coordinates. Their
1941
+ // structural line count excludes any inter-block blank.
1942
+ spec.startRow = quoteTokenRowStart;
1943
+ spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
1944
+ } else {
1945
+ // A nested blockquote already mapped the table into its own
1946
+ // returned rows; translate those rows into this quote's input.
1947
+ spec.startRow += quoteTokenRowStart;
1948
+ spec.endRow += quoteTokenRowStart;
1949
+ }
1950
+ }
1951
+ }
1751
1952
  }
1752
1953
 
1753
1954
  while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
1754
1955
  renderedQuoteLines.pop();
1755
1956
  }
1756
1957
 
1757
- lines.push(...this.#applyQuoteBorder(renderedQuoteLines, width));
1958
+ const quoteRowOffsets: number[] = [];
1959
+ const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
1960
+ const tableSpecs = this.#activeTableRenderSpecs;
1961
+ if (tableSpecs !== undefined) {
1962
+ for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
1963
+ const spec = tableSpecs[specIndex]!;
1964
+ if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
1965
+ const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
1966
+ const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
1967
+ spec.startRow = quoteRowOffsets[relativeStart]!;
1968
+ spec.endRow = quoteRowOffsets[relativeEnd]!;
1969
+ }
1970
+ }
1971
+ lines.push(...borderedQuoteLines);
1758
1972
  if (nextTokenType && nextTokenType !== "space") {
1759
1973
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
1760
1974
  }
@@ -1801,7 +2015,7 @@ export class Markdown implements Component {
1801
2015
  * Wrap already-rendered lines in the blockquote border and quote styling.
1802
2016
  * `width` is the full content width; the border reserves two cells.
1803
2017
  */
1804
- #applyQuoteBorder(renderedLines: string[], width: number): string[] {
2018
+ #applyQuoteBorder(renderedLines: string[], width: number, sourceRowOffsets?: number[]): string[] {
1805
2019
  const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
1806
2020
  const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
1807
2021
  const applyQuoteStyle = (line: string): string => {
@@ -1813,11 +2027,13 @@ export class Markdown implements Component {
1813
2027
  };
1814
2028
  const quoteContentWidth = Math.max(1, width - 2);
1815
2029
  const lines: string[] = [];
2030
+ sourceRowOffsets?.push(0);
1816
2031
  for (const quoteLine of renderedLines) {
1817
2032
  const styledLine = applyQuoteStyle(quoteLine);
1818
2033
  for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
1819
2034
  lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
1820
2035
  }
2036
+ sourceRowOffsets?.push(lines.length);
1821
2037
  }
1822
2038
  return lines;
1823
2039
  }
@@ -2149,6 +2365,7 @@ export class Markdown implements Component {
2149
2365
  availableWidth: number,
2150
2366
  nextTokenType?: string,
2151
2367
  styleContext?: InlineStyleContext,
2368
+ tableKey = "table",
2152
2369
  ): string[] {
2153
2370
  const lines: string[] = [];
2154
2371
  const numCols = token.header.length;
@@ -2263,6 +2480,17 @@ export class Markdown implements Component {
2263
2480
  }
2264
2481
  }
2265
2482
 
2483
+ const lockedLayout = this.#lockedTableLayouts.get(tableKey);
2484
+ if (
2485
+ lockedLayout !== undefined &&
2486
+ lockedLayout.availableWidth === availableWidth &&
2487
+ lockedLayout.columnWidths.length === numCols &&
2488
+ lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
2489
+ lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
2490
+ ) {
2491
+ columnWidths = lockedLayout.columnWidths.slice();
2492
+ }
2493
+
2266
2494
  const t = this.#theme.symbols.table;
2267
2495
  const h = t.horizontal;
2268
2496
  const v = t.vertical;
@@ -2316,7 +2544,16 @@ export class Markdown implements Component {
2316
2544
 
2317
2545
  // Render bottom border
2318
2546
  const bottomBorderCells = columnWidths.map(w => h.repeat(w));
2319
- lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
2547
+ const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
2548
+ lines.push(bottomBorder);
2549
+ this.#activeTableRenderSpecs?.push({
2550
+ key: tableKey,
2551
+ availableWidth,
2552
+ columnWidths: columnWidths.slice(),
2553
+ lineCount: lines.length,
2554
+ startRow: -1,
2555
+ endRow: -1,
2556
+ });
2320
2557
 
2321
2558
  if (nextTokenType && nextTokenType !== "space") {
2322
2559
  lines.push(""); // Add spacing after table
@@ -36,6 +36,38 @@ export type TerminalId =
36
36
  | "base"
37
37
  | "trueColor";
38
38
 
39
+ const CMUX_NOTIFICATION_TITLE = "Oh My Pi";
40
+ const CMUX_SURFACE_ID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
41
+
42
+ /**
43
+ * Route a notification through cmux when the process belongs to a concrete
44
+ * surface. Workspace/socket state alone is not enough: only the injected
45
+ * surface UUID identifies the pane that should receive the notification.
46
+ * Returns whether cmux owns delivery so the caller can preserve every existing
47
+ * terminal fallback unchanged when no valid surface is present.
48
+ */
49
+ function sendCmuxNotification(message: string | TerminalNotification, env: NodeJS.ProcessEnv = Bun.env): boolean {
50
+ const surfaceId = env.CMUX_SURFACE_ID?.trim();
51
+ if (!surfaceId || !CMUX_SURFACE_ID_PATTERN.test(surfaceId)) return false;
52
+
53
+ const title =
54
+ typeof message === "string" ? CMUX_NOTIFICATION_TITLE : message.title?.trim() || CMUX_NOTIFICATION_TITLE;
55
+ const body = typeof message === "string" ? message : (message.body ?? "");
56
+ try {
57
+ const child = Bun.spawn({
58
+ cmd: ["cmux", "notify", "--surface", surfaceId, "--title", title, "--body", body],
59
+ stdin: "ignore",
60
+ stdout: "ignore",
61
+ stderr: "ignore",
62
+ });
63
+ child.unref();
64
+ } catch {
65
+ // A missing cmux binary leaves delivery to the existing terminal fallback.
66
+ return false;
67
+ }
68
+ return true;
69
+ }
70
+
39
71
  function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
40
72
  const index = line.indexOf(needle);
41
73
  return index !== -1 && index + needle.length <= limit;
@@ -111,6 +143,7 @@ export class TerminalInfo {
111
143
 
112
144
  sendNotification(message: string | TerminalNotification): void {
113
145
  if (isNotificationSuppressed() || isTerminalHeadless()) return;
146
+ if (sendCmuxNotification(message)) return;
114
147
  const formatted = this.formatNotification(message);
115
148
  // Under tmux, terminals whose notify protocol is OSC 9 / OSC 99 would
116
149
  // otherwise lose the notification entirely: tmux does not forward bare
package/src/terminal.ts CHANGED
@@ -13,7 +13,6 @@ import { setKittyProtocolActive } from "./keys";
13
13
  import { StdinBuffer } from "./stdin-buffer";
14
14
  import {
15
15
  isInsideTerminalMultiplexer,
16
- isInsideTmux,
17
16
  NotifyProtocol,
18
17
  setCellDimensions,
19
18
  setOsc99Supported,
@@ -45,7 +44,6 @@ export function resolveHangulCompatibilityJamoWidthFromTerminalIdentity(
45
44
  }
46
45
 
47
46
  function shouldEnableModifyOtherKeysFallback(env: NodeJS.ProcessEnv = Bun.env): boolean {
48
- if (isInsideTmux(env)) return false;
49
47
  if (!env.SSH_CONNECTION && !env.SSH_TTY && !env.SSH_CLIENT) return true;
50
48
  return TERMINAL.id !== "base" && TERMINAL.id !== "trueColor";
51
49
  }
@@ -408,9 +406,11 @@ export interface Terminal {
408
406
  get appearance(): TerminalAppearance | undefined;
409
407
  /**
410
408
  * Register a callback fired once per DEC private mode when its DECRQM support
411
- * status resolves. Optional: only real terminals implement capability probing.
409
+ * status resolves. `confirmed` is false when the terminal answered the DA1
410
+ * sentinel without answering DECRQM, which proves only that querying support
411
+ * is unavailable — not that the private mode itself is unsupported.
412
412
  */
413
- onPrivateModeReport?(callback: (mode: number, supported: boolean) => void): void;
413
+ onPrivateModeReport?(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void;
414
414
  }
415
415
 
416
416
  /**
@@ -498,7 +498,7 @@ export class ProcessTerminal implements Terminal {
498
498
  #da1SentinelOwners: Da1SentinelOwner[] = [];
499
499
  /** Resolved DECRQM support per private mode (mode → supported). */
500
500
  #privateModeSupport = new Map<number, boolean>();
501
- #privateModeCallbacks: Array<(mode: number, supported: boolean) => void> = [];
501
+ #privateModeCallbacks: Array<(mode: number, supported: boolean, confirmed: boolean) => void> = [];
502
502
  /** Whether DEC 2048 in-band resize notifications are currently enabled. */
503
503
  #inBandResizeActive = false;
504
504
  /** Reassembly buffer for a DEC 2048 in-band resize report split across stdin reads. */
@@ -550,7 +550,7 @@ export class ProcessTerminal implements Terminal {
550
550
  }
551
551
  }
552
552
 
553
- onPrivateModeReport(callback: (mode: number, supported: boolean) => void): void {
553
+ onPrivateModeReport(callback: (mode: number, supported: boolean, confirmed?: boolean) => void): void {
554
554
  this.#privateModeCallbacks.push(callback);
555
555
  }
556
556
 
@@ -891,8 +891,10 @@ export class ProcessTerminal implements Terminal {
891
891
  break;
892
892
  }
893
893
  case "privateMode": {
894
- // DA1 beat the DECRPM reply for this mode treat as unsupported.
895
- this.#resolvePrivateMode(owner.mode, false);
894
+ // DA1 beat the DECRPM reply. The terminal cannot report this
895
+ // capability, but may still implement it; keep that distinction
896
+ // so static terminal detection is not incorrectly downgraded.
897
+ this.#resolvePrivateMode(owner.mode, false, false);
896
898
  break;
897
899
  }
898
900
  case "keyboard": {
@@ -1158,7 +1160,7 @@ export class ProcessTerminal implements Terminal {
1158
1160
  }
1159
1161
 
1160
1162
  #handlePrivateModeReport(mode: number, status: string): void {
1161
- this.#resolvePrivateMode(mode, isPrivateModeSupported(status));
1163
+ this.#resolvePrivateMode(mode, isPrivateModeSupported(status), true);
1162
1164
  if (isXtermScrollToBottomMode(mode) && isPrivateModeSet(status)) {
1163
1165
  this.#disableXtermScrollToBottomMode(mode);
1164
1166
  }
@@ -1166,15 +1168,16 @@ export class ProcessTerminal implements Terminal {
1166
1168
 
1167
1169
  /**
1168
1170
  * Record DECRQM support for a private mode (idempotent — first result wins)
1169
- * and notify subscribers. Enables DEC 2048 in-band resize when 2048 resolves
1170
- * supported.
1171
+ * and notify subscribers. `confirmed` distinguishes an explicit DECRPM
1172
+ * unsupported response from an absent response followed by the DA1 sentinel.
1173
+ * Enables DEC 2048 in-band resize only after positive confirmation.
1171
1174
  */
1172
- #resolvePrivateMode(mode: number, supported: boolean): void {
1175
+ #resolvePrivateMode(mode: number, supported: boolean, confirmed: boolean): void {
1173
1176
  if (this.#privateModeSupport.has(mode)) return;
1174
1177
  this.#privateModeSupport.set(mode, supported);
1175
1178
  for (const cb of this.#privateModeCallbacks) {
1176
1179
  try {
1177
- cb(mode, supported);
1180
+ cb(mode, supported, confirmed);
1178
1181
  } catch {
1179
1182
  // Ignore subscriber errors — capability reporting must not crash input.
1180
1183
  }
package/src/tui.ts CHANGED
@@ -87,8 +87,6 @@ const CURSOR_END_NO_SYNC = "";
87
87
  // coordinates so columns/rows past 223 are reported.
88
88
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
89
89
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
90
- const ALT_SCREEN_ENTER = "\x1b[?1049h";
91
- const ALT_SCREEN_EXIT = "\x1b[?1049l";
92
90
 
93
91
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
94
92
  type InputListener = (data: string) => InputListenerResult;
@@ -473,7 +471,7 @@ export interface OverlayHandle {
473
471
  /**
474
472
  * Container - a component that contains other components
475
473
  */
476
- export class Container implements Component {
474
+ export class Container implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
477
475
  children: Component[] = [];
478
476
 
479
477
  // Memoized concatenation of the children's latest renders. Children are
@@ -543,6 +541,34 @@ export class Container implements Component {
543
541
  }
544
542
  }
545
543
 
544
+ /**
545
+ * Split the committed prefix from the container's most recently rendered
546
+ * rows across its children. The memoized child arrays are the exact geometry
547
+ * that produced that frame; when the child list was invalidated or rebuilt,
548
+ * there is no safe old-to-new coordinate mapping, so propagation waits for
549
+ * the next render/post-emit publication.
550
+ */
551
+ setNativeScrollbackCommittedRows(rows: number): void {
552
+ const refs = this.#memoChildLines;
553
+ if (this.#memoLines === undefined || refs.length !== this.children.length) return;
554
+ const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
555
+ let offset = 0;
556
+ for (let i = 0; i < this.children.length; i++) {
557
+ const childRows = refs[i];
558
+ if (childRows === undefined) return;
559
+ setNativeScrollbackCommittedRows(
560
+ this.children[i]!,
561
+ Math.min(childRows.length, Math.max(0, committed - offset)),
562
+ );
563
+ offset += childRows.length;
564
+ }
565
+ }
566
+
567
+ /** Recursively discard layout locks that are meaningful only to the old tape. */
568
+ prepareNativeScrollbackReplay(): void {
569
+ for (const child of this.children) prepareNativeScrollbackReplay(child);
570
+ }
571
+
546
572
  render(width: number): readonly string[] {
547
573
  width = Math.max(1, width);
548
574
  const children = this.children;
@@ -1048,11 +1074,6 @@ export class TUI extends Container {
1048
1074
  // `#fullRedrawCount`: these never enter native scrollback and exist only for
1049
1075
  // the lifetime of the drag. Exposed for tests/diagnostics.
1050
1076
  #resizeViewportPaintCount = 0;
1051
- // During a live resize drag the terminal's normal buffer may reflow full-width
1052
- // rows before our repaint lands. Borrow the alternate screen for throwaway
1053
- // resize frames so width changes truncate the transient viewport instead of
1054
- // pushing wrapped fragments into native scrollback.
1055
- #resizeAltActive = false;
1056
1077
  #stopped = false;
1057
1078
  // Always-on event-loop lag probe. The high default threshold keeps it quiet;
1058
1079
  // it only logs `ui.loop-blocked` (with the current loop phase) when a frame
@@ -1068,6 +1089,9 @@ export class TUI extends Container {
1068
1089
  #altPreviousLines: string[] = [];
1069
1090
  #altEnterWidth = 0;
1070
1091
  #altEnterHeight = 0;
1092
+ // Holds an alternate-screen exit until its replacement full paint can emit it
1093
+ // atomically. It must survive a deferred Ghostty image frame.
1094
+ #pendingAltExit = "";
1071
1095
 
1072
1096
  // Persistent composed frame. The render override splices only rows at/after
1073
1097
  // the stable prefix each frame; cursor markers are stripped at ingestion so
@@ -1491,13 +1515,15 @@ export class TUI extends Container {
1491
1515
  this.#watchdog.start();
1492
1516
  this.#ghosttyInitialImageDelayDone = false;
1493
1517
  this.#ghosttyImageReadyAtMs = this.#renderScheduler.now() + TUI.#GHOSTTY_INITIAL_IMAGE_DELAY_MS;
1494
- // A DECRQM report for mode 2026 is authoritative: enable synchronized
1495
- // output when the terminal reports support (upgrading conservatively
1496
- // defaulted-off hosts like zellij/tmux-master/foot) and disable it when
1497
- // the terminal reports it unsupported. An explicit user opt-out/force
1498
- // (resolved at construction) still wins, so skip the probe in that case.
1499
- this.terminal.onPrivateModeReport?.((mode, supported) => {
1500
- if (mode !== 2026) return;
1518
+ // A confirmed DECRPM report for mode 2026 is authoritative: enable
1519
+ // synchronized output when the terminal reports support and disable it for
1520
+ // an explicit unsupported status. A DA1 sentinel without a DECRPM reply is
1521
+ // inconclusive: many terminals implement synchronized output without
1522
+ // implementing DECRQM, so retain the statically detected default instead of
1523
+ // exposing destructive full paints. An explicit user opt-out/force still
1524
+ // wins, so skip every probe result in that case.
1525
+ this.terminal.onPrivateModeReport?.((mode, supported, confirmed = true) => {
1526
+ if (mode !== 2026 || !confirmed) return;
1501
1527
  if (synchronizedOutputUserOverride() !== null) return;
1502
1528
  this.#setSynchronizedOutput(supported);
1503
1529
  });
@@ -1715,17 +1741,14 @@ export class TUI extends Container {
1715
1741
  }
1716
1742
 
1717
1743
  stop(): void {
1718
- // Leave the alt buffer first so the teardown cursor math below runs against
1719
- // the restored normal screen (which #previousLines still describes).
1720
- if (this.#resizeAltActive) {
1721
- this.terminal.write(this.#leaveResizeAltSequence());
1722
- }
1723
- if (this.#altActive) {
1724
- const enhancementExit = this.#keyboardEnhancementExit();
1725
- this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
1744
+ if (this.#altActive || this.#pendingAltExit) {
1745
+ const exitSequence =
1746
+ this.#pendingAltExit || `${MOUSE_TRACKING_OFF}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
1747
+ this.terminal.write(exitSequence);
1726
1748
  setAltScreenActive(false);
1727
1749
  this.#altActive = false;
1728
1750
  this.#altPreviousLines = [];
1751
+ this.#pendingAltExit = "";
1729
1752
  }
1730
1753
  if (TERMINAL.imageProtocol === ImageProtocol.Kitty) {
1731
1754
  for (const id of this.#imageBudget.takeAllTransmittedIds()) {
@@ -2681,6 +2704,7 @@ export class TUI extends Container {
2681
2704
  // Fullscreen alt-screen short-circuit. While the topmost visible overlay
2682
2705
  // requests it, borrow the terminal's alternate buffer and paint only the
2683
2706
  // modal there; the normal screen and all accounting stay untouched.
2707
+ let deferredAltExit = this.#pendingAltExit;
2684
2708
  const wantAlt = this.#wantsAltScreen();
2685
2709
  if (wantAlt && !this.#altActive) {
2686
2710
  // Enhanced keyboard modes can be buffer-local: re-push the active
@@ -2698,7 +2722,15 @@ export class TUI extends Container {
2698
2722
  this.#altEnterHeight = height;
2699
2723
  } else if (!wantAlt && this.#altActive) {
2700
2724
  const enhancementExit = this.#keyboardEnhancementExit();
2701
- this.terminal.write(`${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`);
2725
+ const exitSequence = `${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`;
2726
+ // Session replacement can finish while a fullscreen selector is still
2727
+ // covering the old normal buffer. Keep the overlay visible until the
2728
+ // replacement is ready, then fuse the buffer restore into that full paint;
2729
+ // a standalone exit exposes the stale session for one terminal frame.
2730
+ if (this.#clearScrollbackOnNextRender) {
2731
+ this.#pendingAltExit = exitSequence;
2732
+ deferredAltExit = exitSequence;
2733
+ } else this.terminal.write(exitSequence);
2702
2734
  setAltScreenActive(false);
2703
2735
  this.#forgetHardwareCursorState();
2704
2736
  this.#altActive = false;
@@ -3027,7 +3059,9 @@ export class TUI extends Container {
3027
3059
  chunkTo,
3028
3060
  windowTop,
3029
3061
  cursorTrackingLineCount,
3062
+ leadingSequence: deferredAltExit,
3030
3063
  });
3064
+ this.#pendingAltExit = "";
3031
3065
  this.#committedPrefix = rawFrame.slice(0, chunkTo);
3032
3066
  this.#committedPrefixAuditRows = Math.min(chunkTo, finalBoundary);
3033
3067
  this.#clearScrollbackOnNextRender = false;
@@ -3406,6 +3440,7 @@ export class TUI extends Container {
3406
3440
  chunkTo: number;
3407
3441
  windowTop: number;
3408
3442
  cursorTrackingLineCount: number;
3443
+ leadingSequence: string;
3409
3444
  },
3410
3445
  ): void {
3411
3446
  this.#fullRedrawCount += 1;
@@ -3443,7 +3478,7 @@ export class TUI extends Container {
3443
3478
  paintCursorPos = paint.cursorPos;
3444
3479
  }
3445
3480
  }
3446
- let buffer = this.#paintBeginSequence + this.#leaveResizeAltSequence() + purgeSequence;
3481
+ let buffer = this.#paintBeginSequence + options.leadingSequence + purgeSequence;
3447
3482
  if (options.clearScrollback) {
3448
3483
  // Clear native history without blanking the live viewport first. The
3449
3484
  // replay below rewrites every visible row from home, including blanks,
@@ -3643,34 +3678,15 @@ export class TUI extends Container {
3643
3678
  return this.terminal.kittyEnableSequence ? "\x1b[<u" : "";
3644
3679
  }
3645
3680
 
3646
- #enterResizeAltSequence(): string {
3647
- if (this.#resizeAltActive || this.#altActive) return "";
3648
- this.#resizeAltActive = true;
3649
- setAltScreenActive(true);
3650
- this.#forgetHardwareCursorState();
3651
- this.#recordHardwareCursorHidden();
3652
- return `${ALT_SCREEN_ENTER}${this.#keyboardEnhancementEnter()}`;
3653
- }
3654
-
3655
- #leaveResizeAltSequence(): string {
3656
- if (!this.#resizeAltActive) return "";
3657
- const enhancementExit = this.#keyboardEnhancementExit();
3658
- this.#resizeAltActive = false;
3659
- setAltScreenActive(false);
3660
- this.#forgetHardwareCursorState();
3661
- return `${enhancementExit}${ALT_SCREEN_EXIT}`;
3662
- }
3663
-
3664
3681
  /**
3665
- * Emit a throwaway viewport repaint for the resize fast path as an alternate-
3666
- * screen per-row overwrite. The normal buffer may reflow full-width rows on a
3667
- * width change before the app can repaint; keeping the drag on the alternate
3668
- * screen makes those transient resizes truncate instead of pushing wrapped
3669
- * fragments into native scrollback. Normal-screen history is rebuilt once at
3670
- * settle via `#emitFullPaint`.
3682
+ * Emit a throwaway viewport repaint for the resize fast path as an in-place
3683
+ * per-row overwrite. Switching buffers exposes the saved normal screen before
3684
+ * the authoritative settle paint on terminals without effective DEC 2026,
3685
+ * which is the resize flicker this fast path exists to avoid. The normal
3686
+ * screen is therefore rewritten directly; history is rebuilt once at settle.
3671
3687
  */
3672
3688
  #emitResizeViewport(window: readonly string[], height: number, contentRows: number, width: number): void {
3673
- let buffer = `${this.#paintBeginSequence + this.#enterResizeAltSequence()}\x1b[H`;
3689
+ let buffer = `${this.#paintBeginSequence}\x1b[H`;
3674
3690
  for (let r = 0; r < height; r++) {
3675
3691
  if (r > 0) buffer += "\r\n";
3676
3692
  buffer += this.#lineRewriteSequence(window[r] ?? "", width);