@oh-my-pi/pi-tui 18.1.13 → 18.1.15

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,11 +2,28 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [18.1.12] - 2026-09-06
5
+ ## [18.1.15] - 2026-09-08
6
+
7
+ ### Fixed
8
+
9
+ - Stopped long flicker when moving or resizing an omp pane in Warp. Resize repaints in place there after the drag settles (override with `PI_TUI_RESIZE_IN_PLACE=0`), with no alternate-screen borrow, no scrollback replay, blanked live rows so shrink drags cannot archive unfinished rows, and overlay toggle echoes repainting the modal instead of probing ([#11247](https://github.com/can1357/oh-my-pi/pull/11247) by [@H4vC](https://github.com/H4vC)).
10
+
11
+ ## [18.1.14] - 2026-09-07
12
+
13
+ ### Fixed
14
+
15
+ - `extractMarkdownLinks()` now returns one-row visible labels for formatted and multiline links ([#11086](https://github.com/can1357/oh-my-pi/pull/11086) by [@mustafaabidali](https://github.com/mustafaabidali)).
16
+
17
+ ## [18.1.13] - 2026-09-07
6
18
 
7
19
  ### Fixed
8
20
 
9
21
  - Fixed notifications never arriving in a Herdr pane. Herdr multiplexes panes like tmux but swallows bare OSC 9 / OSC 99 and has no passthrough envelope, so a backgrounded pane got no signal at all; delivery now goes through `herdr notification show` (a waiting question or an error rings `request`, a settled turn rings `done`), and the in-band write stays as the fallback when the pane id or the `herdr` binary is missing.
22
+
23
+ ## [18.1.12] - 2026-09-06
24
+
25
+ ### Fixed
26
+
10
27
  - Avoid inserting a trailing space when auto-completing directory paths with `@`, and keep autocomplete open when accepting a directory with Tab or Enter.
11
28
  - Horizontal wheel reports (the sideways drift of a two-finger trackpad scroll) no longer decode as a vertical wheel direction, so fullscreen selectors such as `/copy` and the rewind picker stop jumping up and back down at the end of a scroll gesture.
12
29
 
@@ -14,7 +14,7 @@ export declare function resetFastTailSplices(): void;
14
14
  export declare function fastLineStartHazard(grownLine: string): boolean;
15
15
  /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
16
16
  export interface MarkdownLink {
17
- /** Visible link text (equals `href` for autolinks and bare URLs). */
17
+ /** Flattened visible label with whitespace collapsed to one row; falls back to `href` when empty. */
18
18
  text: string;
19
19
  /** Destination exactly as marked resolved it (references resolved, no normalization). */
20
20
  href: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.1.13",
4
+ "version": "18.1.15",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,8 +37,8 @@
37
37
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "18.1.13",
41
- "@oh-my-pi/pi-utils": "18.1.13"
40
+ "@oh-my-pi/pi-natives": "18.1.15",
41
+ "@oh-my-pi/pi-utils": "18.1.15"
42
42
  },
43
43
  "devDependencies": {
44
44
  "kitty-vt-wasm": "^0.2.0"
@@ -1256,7 +1256,7 @@ function lexDocument(text: string): Token[] {
1256
1256
 
1257
1257
  /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
1258
1258
  export interface MarkdownLink {
1259
- /** Visible link text (equals `href` for autolinks and bare URLs). */
1259
+ /** Flattened visible label with whitespace collapsed to one row; falls back to `href` when empty. */
1260
1260
  text: string;
1261
1261
  /** Destination exactly as marked resolved it (references resolved, no normalization). */
1262
1262
  href: string;
@@ -1276,10 +1276,8 @@ export function extractMarkdownLinks(text: string): MarkdownLink[] {
1276
1276
  if (token.type === "link") {
1277
1277
  const link = token as Tokens.Link;
1278
1278
  if (typeof link.href === "string" && link.href.length > 0) {
1279
- links.push({
1280
- text: typeof link.text === "string" && link.text.length > 0 ? link.text : link.href,
1281
- href: link.href,
1282
- });
1279
+ const label = plainInlineTokens(link.tokens).replace(/\s+/g, " ").trim();
1280
+ links.push({ text: label || link.href, href: link.href });
1283
1281
  }
1284
1282
  continue;
1285
1283
  }
@@ -1497,6 +1495,9 @@ function plainInlineTokens(tokens: Token[]): string {
1497
1495
  case "codespan":
1498
1496
  result += token.text;
1499
1497
  break;
1498
+ case "br":
1499
+ result += "\n";
1500
+ break;
1500
1501
  default:
1501
1502
  if ("text" in token && typeof token.text === "string") result += token.text;
1502
1503
  break;
package/src/tui.ts CHANGED
@@ -88,6 +88,18 @@ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
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
90
 
91
+ /**
92
+ * `PI_TUI_RESIZE_IN_PLACE=1|true` forces in-place resize (no alt-buffer borrow).
93
+ * `0|false` forces the alt-buffer path even on Warp. Unset defers to Warp detection:
94
+ * Warp re-reports its size on CSI ?1049h / CSI ?1049l, which the resize alt-borrow
95
+ * turns into a flicker loop.
96
+ */
97
+ function resizeInPlaceOverride(): boolean | null {
98
+ const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
99
+ if (override === "1" || override === "true") return true;
100
+ if (override === "0" || override === "false") return false;
101
+ return null;
102
+ }
91
103
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
92
104
  type InputListener = (data: string) => InputListenerResult;
93
105
  type StartListener = () => void;
@@ -822,6 +834,18 @@ export class TUI extends Container {
822
834
  #resizeAltActive = false;
823
835
  #resizeSettleTimer: RenderTimer | undefined;
824
836
  #suppressResizeUntil = 0;
837
+ // Baseline geometry at the last alt-buffer toggle, plus whether its echo is
838
+ // still pending. A Warp-only echo is a height-only ±1 SIGWINCH against this
839
+ // baseline while the CPR probe is in flight. The expectation is single-shot:
840
+ // the first SIGWINCH after the toggle consumes it, so a real one-row resize
841
+ // back to the baseline can never be mistaken for the echo.
842
+ #altToggleColumns = 0;
843
+ #altToggleRows = 0;
844
+ #altToggleEchoPending = false;
845
+ // True while an in-place resize transaction (Warp) is inside its settle
846
+ // window: the normal-buffer anchor is stale until the settled CPR probe
847
+ // resolves, so ordinary paints are dropped until then.
848
+ #resizeInPlaceActive = false;
825
849
  #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
826
850
  #resizeReplaySize: string | undefined;
827
851
  // Holds an alternate-screen exit until its replacement full paint can emit it
@@ -1124,15 +1148,52 @@ export class TUI extends Container {
1124
1148
  data => this.#handleInput(data),
1125
1149
  () => {
1126
1150
  if (this.#resizeProbe) {
1127
- // The anchor being probed is already stale; restart the transaction.
1151
+ // Warp echoes a height-only ±1 SIGWINCH on CSI ?1049l. The echo
1152
+ // must not restart the alt borrow (that is the flicker loop),
1153
+ // but the terminal really did adopt the echoed size, so a CPR
1154
+ // reply already in flight may predate it: retire the probe and
1155
+ // reissue it at the new geometry. DSR-only, no toggle, so this
1156
+ // terminates. A real geometry change restarts the transaction below.
1157
+ if (this.#isWarpAltToggleEcho()) {
1158
+ this.#cancelResizeProbe();
1159
+ this.#trackResizeBurst();
1160
+ this.#beginResizeAnchorProbe();
1161
+ return;
1162
+ }
1128
1163
  this.#cancelResizeProbe();
1129
- this.#beginResizeAltPaint(true);
1164
+ if (this.#resizeRepaintsInPlace()) this.#beginResizeInPlacePaint();
1165
+ else this.#beginResizeAltPaint(true);
1166
+ return;
1167
+ }
1168
+ if (this.#altActive) {
1169
+ // A fullscreen overlay owns the alt buffer: repaint the modal at
1170
+ // the new size. Never snapshot the normal window or probe its
1171
+ // anchor against the alternate grid — not even for a toggle echo.
1172
+ this.requestRender(true);
1173
+ return;
1174
+ }
1175
+ if (!this.#resizeAltActive && this.#isWarpAltToggleEcho()) {
1176
+ // Delayed echo that lost the signal-vs-pty race with its own
1177
+ // probe's CPR reply: the probe already resolved, so re-probe at
1178
+ // the echoed size instead of painting on a stale anchor or
1179
+ // suppressing into a forced replay. DSR-only, no borrow.
1180
+ // While the resize borrow is active the echo is swallowed
1181
+ // without probing: a CPR issued now would snapshot the
1182
+ // alternate grid and anchor the normal viewport to its row.
1183
+ this.#resizeProbeWindow = this.#providerWindow;
1184
+ this.#resizeProbeOffset = this.#parkedViewportOffset;
1185
+ this.#trackResizeBurst();
1186
+ this.#beginResizeAnchorProbe();
1130
1187
  return;
1131
1188
  }
1132
1189
  if (this.#renderScheduler.now() < this.#suppressResizeUntil) {
1133
1190
  this.requestRender(true);
1134
1191
  return;
1135
1192
  }
1193
+ if (this.#resizeRepaintsInPlace()) {
1194
+ this.#beginResizeInPlacePaint();
1195
+ return;
1196
+ }
1136
1197
  this.#beginResizeAltPaint();
1137
1198
  },
1138
1199
  () => this.stop(),
@@ -1156,6 +1217,129 @@ export class TUI extends Container {
1156
1217
  }
1157
1218
  this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
1158
1219
  }
1220
+ /**
1221
+ * Whether a resize repaints the visible window in place — no alternate-screen
1222
+ * borrow. Warp-only: Warp re-reports its size on alt-buffer toggles, so borrowing
1223
+ * there self-sustains. Every other terminal keeps the alt-borrow path. Inside a
1224
+ * multiplexer the mux owns the grid and consumes the toggles itself, so an
1225
+ * inherited Warp marker must not divert the mux-tuned borrow path.
1226
+ */
1227
+ #resizeRepaintsInPlace(): boolean {
1228
+ const override = resizeInPlaceOverride();
1229
+ if (override !== null) return override;
1230
+ if (isInsideTerminalMultiplexer()) return false;
1231
+ return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
1232
+ }
1233
+
1234
+ #noteAltBufferToggle(): void {
1235
+ this.#altToggleColumns = this.terminal.columns;
1236
+ this.#altToggleRows = this.terminal.rows;
1237
+ this.#altToggleEchoPending = true;
1238
+ }
1239
+
1240
+ /**
1241
+ * Warp-only echo: height-only ±1 SIGWINCH against the pending alt-toggle
1242
+ * baseline. Single-shot: the first SIGWINCH after the toggle consumes the
1243
+ * expectation either way, so at most one signal is ever swallowed per toggle.
1244
+ * Never inside a multiplexer, which consumes the toggles itself.
1245
+ */
1246
+ #isWarpAltToggleEcho(): boolean {
1247
+ if (!this.#altToggleEchoPending) return false;
1248
+ this.#altToggleEchoPending = false;
1249
+ // Inside a multiplexer the mux consumes alt toggles itself, so no echo is
1250
+ // possible: every ±1 resize is real and must restart the transaction.
1251
+ if (isInsideTerminalMultiplexer()) return false;
1252
+ if (Bun.env.TERM_PROGRAM?.toLowerCase() !== "warpterminal") return false;
1253
+ return (
1254
+ this.terminal.columns === this.#altToggleColumns && Math.abs(this.terminal.rows - this.#altToggleRows) <= 1
1255
+ );
1256
+ }
1257
+
1258
+ /**
1259
+ * Fold one SIGWINCH step into the coalesced resize-burst accounting shared by
1260
+ * both resize paths: any grow step poisons the multiplexer clip model, the
1261
+ * accumulated pull bounds CPR-less grow anchors, and the epoch retires the
1262
+ * in-flight CPR tag so a rewrap-invalidated reply cannot anchor a new geometry.
1263
+ */
1264
+ #trackResizeBurst(): void {
1265
+ const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1266
+ if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1267
+ this.#resizeBurstLastHeight = this.terminal.rows;
1268
+ this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1269
+ this.#geometryEpoch++;
1270
+ }
1271
+
1272
+ /**
1273
+ * Coalesced in-place resize transaction for Warp-class terminals: never
1274
+ * borrows the alt buffer. Drag SIGWINCHes only re-arm the settle window, so a
1275
+ * drag emits no paints and no scrollback replay; once quiet, the transaction
1276
+ * snapshots the live window and runs the CPR anchor probe, and the single
1277
+ * settled repaint lands on the recovered anchor with no ED3 rewrap.
1278
+ */
1279
+ #beginResizeInPlacePaint(): void {
1280
+ if (this.#altActive) {
1281
+ this.requestRender(true);
1282
+ return;
1283
+ }
1284
+ this.#trackResizeBurst();
1285
+ this.#resizeInPlaceActive = true;
1286
+ this.#resizeSettleTimer?.cancel();
1287
+ this.#forgetHardwareCursorState();
1288
+ this.#recordHardwareCursorHidden();
1289
+ if (this.#eraseLiveViewportForResize()) {
1290
+ // The erase parked the hardware cursor on the viewport's top row;
1291
+ // snapshot the parked offset so the settled probe anchors there.
1292
+ this.#parkedViewportOffset = 0;
1293
+ }
1294
+ this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
1295
+ this.#resizeSettleTimer = undefined;
1296
+ if (this.#stopped) return;
1297
+ this.#resizeProbeWindow = this.#providerWindow;
1298
+ this.#resizeProbeOffset = this.#parkedViewportOffset;
1299
+ this.#beginResizeAnchorProbe();
1300
+ }, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
1301
+ }
1302
+
1303
+ /**
1304
+ * Blank the mutable live viewport on the normal screen before a resize
1305
+ * transaction waits out the drag. The terminal keeps reflowing the normal
1306
+ * buffer during the drag, and a height shrink pushes its top rows into
1307
+ * scrollback; with the live region blanked, only committed history rows
1308
+ * (correct to push) or blanks can leave the screen — never live placeholder
1309
+ * rows such as compact tool dots, whose real blocks must enter scrollback
1310
+ * through the ordered history path. Addressing depends on the resize
1311
+ * direction. Terminals keep the parked cursor attached to its logical line
1312
+ * through width rewrap and height-grow scrollback pull-down, so
1313
+ * cursor-relative movement lands on the viewport's top row. On height shrink
1314
+ * kitty clamps the cursor instead of moving it with pushed rows, so
1315
+ * cursor-relative addressing would start rows late; fall back to the same
1316
+ * bottom-preserving bound as resize-anchor recovery.
1317
+ *
1318
+ * Both erase paths leave the cursor on the viewport's top row. Returns true
1319
+ * when it erased (multiplexers skip it: an immediate erase races the pane
1320
+ * re-layout and blanks pulled-back committed rows).
1321
+ */
1322
+ #eraseLiveViewportForResize(): boolean {
1323
+ if (!this.#hasEverRendered || this.#providerWindow.length === 0 || isInsideTerminalMultiplexer()) {
1324
+ return false;
1325
+ }
1326
+ if (this.terminal.rows < this.#previousHeight) {
1327
+ const staleRows = this.#reflowedRowCount(
1328
+ this.#providerWindow,
1329
+ 0,
1330
+ this.#providerWindow.length,
1331
+ this.terminal.columns,
1332
+ );
1333
+ const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1334
+ this.terminal.write(`\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`);
1335
+ } else {
1336
+ const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
1337
+ const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1338
+ this.terminal.write(`\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`);
1339
+ }
1340
+ return true;
1341
+ }
1342
+
1159
1343
  /**
1160
1344
  * Borrow the alternate buffer for stable, history-free resize repainting.
1161
1345
  * `restartingProbe` marks a transaction restarted by a SIGWINCH that
@@ -1168,62 +1352,26 @@ export class TUI extends Container {
1168
1352
  this.requestRender(true);
1169
1353
  return;
1170
1354
  }
1171
- const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1172
- if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1173
- this.#resizeBurstLastHeight = this.terminal.rows;
1174
- this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1175
- this.#geometryEpoch++;
1355
+ this.#trackResizeBurst();
1176
1356
  if (!this.#resizeAltActive) {
1177
1357
  this.#resizeAltActive = true;
1178
1358
  setAltScreenActive(true);
1179
1359
  this.#altPreviousLines = [];
1180
1360
  this.#forgetHardwareCursorState();
1181
1361
  this.#recordHardwareCursorHidden();
1182
- // Erase the mutable live viewport from the normal screen before borrowing
1183
- // the alt buffer. The terminal keeps reflowing the normal buffer during
1184
- // the drag, and a height shrink pushes its top rows into scrollback;
1185
- // with the live region blanked, only committed history rows (correct to
1186
- // push) or blanks can leave the screen never live placeholder rows
1187
- // such as compact tool dots, whose real blocks must enter scrollback
1188
- // through the ordered history path. Addressing depends on the resize
1189
- // direction. Terminals keep the parked cursor attached to its logical
1190
- // line through width rewrap and height-grow scrollback pull-down, so
1191
- // cursor-relative movement lands on the viewport's top row. On height
1192
- // shrink kitty clamps the cursor instead of moving it with pushed rows,
1193
- // so cursor-relative addressing would start rows late; fall back to the
1194
- // same bottom-preserving bound as resize-anchor recovery. The pre-erase
1195
- // window is stashed for the settled CPR probe: its reflowed row count
1196
- // bounds the anchor to `height - staleRows`, so a mis-parked cursor (a
1197
- // single-step tmux zoom re-lays the pane before SIGWINCH delivery,
1198
- // moving the park target under us) cannot anchor the settled repaint
1199
- // over pulled-back history rows or scroll-push the frame into
1200
- // scrollback again.
1201
- let erase = "";
1362
+ // Blank the live region up front so a reflow-driven scroll can only push
1363
+ // committed rows into scrollback. The pre-erase window is stashed for
1364
+ // the settled CPR probe: its reflowed row count bounds the anchor to
1365
+ // `height - staleRows`, so a mis-parked cursor (a single-step tmux zoom
1366
+ // re-lays the pane before SIGWINCH delivery, moving the park target
1367
+ // under us) cannot anchor the settled repaint over pulled-back history
1368
+ // rows or scroll-push the frame into scrollback again.
1202
1369
  if (!restartingProbe) {
1203
1370
  this.#resizeProbeWindow = this.#providerWindow;
1204
1371
  this.#resizeProbeOffset = this.#parkedViewportOffset;
1205
1372
  }
1206
- if (this.#hasEverRendered && this.#providerWindow.length > 0 && !isInsideTerminalMultiplexer()) {
1207
- if (this.terminal.rows < this.#previousHeight) {
1208
- const staleRows = this.#reflowedRowCount(
1209
- this.#providerWindow,
1210
- 0,
1211
- this.#providerWindow.length,
1212
- this.terminal.columns,
1213
- );
1214
- const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1215
- erase = `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
1216
- } else {
1217
- const up = this.#reflowedRowCount(
1218
- this.#providerWindow,
1219
- 0,
1220
- this.#parkedViewportOffset,
1221
- this.terminal.columns,
1222
- );
1223
- const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1224
- erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`;
1225
- }
1226
- // Both erase paths leave the cursor on the viewport's top row, so the
1373
+ if (this.#eraseLiveViewportForResize()) {
1374
+ // The erase parked the cursor on the viewport's top row, so the
1227
1375
  // parked offset no longer applies; carrying a stale nonzero offset
1228
1376
  // into the probe would anchor the settled repaint above the real
1229
1377
  // viewport top and overwrite visible committed rows.
@@ -1242,7 +1390,8 @@ export class TUI extends Container {
1242
1390
  this.#providerWindow = [];
1243
1391
  this.#parkedViewportOffset = 0;
1244
1392
  }
1245
- this.terminal.write(`${erase}\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
1393
+ this.#noteAltBufferToggle();
1394
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
1246
1395
  }
1247
1396
  this.#resizeSettleTimer?.cancel();
1248
1397
  this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
@@ -1250,6 +1399,7 @@ export class TUI extends Container {
1250
1399
  if (this.#stopped || !this.#resizeAltActive) return;
1251
1400
  this.#resizeAltActive = false;
1252
1401
  this.#suppressResizeUntil = this.#renderScheduler.now() + 100;
1402
+ this.#noteAltBufferToggle();
1253
1403
  this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1254
1404
  setAltScreenActive(false);
1255
1405
  this.#altPreviousLines = [];
@@ -1258,8 +1408,8 @@ export class TUI extends Container {
1258
1408
  this.requestRender(true);
1259
1409
  }
1260
1410
  /**
1261
- * Recover the reflowed viewport anchor after the resize alt-buffer borrow
1262
- * ends. The terminal reflowed the restored normal buffer during the drag, so
1411
+ * Recover the reflowed viewport anchor after the resize settle window ends.
1412
+ * The terminal reflowed the restored normal buffer during the drag, so
1263
1413
  * `#providerViewportTop` is in stale grid coordinates; a DSR (CSI 6n) round
1264
1414
  * trip against the parked cursor reports where the viewport's logical line
1265
1415
  * landed. The settled repaint waits for the reply (or a short timeout).
@@ -1345,6 +1495,7 @@ export class TUI extends Container {
1345
1495
  if (!probe) return;
1346
1496
  probe.timer.cancel();
1347
1497
  this.#resizeProbe = undefined;
1498
+ this.#resizeInPlaceActive = false;
1348
1499
  // Column tags stay live across resolves: their replies are
1349
1500
  // self-identifying and discarded by tag whenever they arrive.
1350
1501
  const width = this.terminal.columns;
@@ -1640,6 +1791,16 @@ export class TUI extends Container {
1640
1791
  this.#debugServer = undefined;
1641
1792
  this.#resizeSettleTimer?.cancel();
1642
1793
  this.#resizeSettleTimer = undefined;
1794
+ if (this.#resizeInPlaceActive && this.terminal.rows > 0) {
1795
+ // The hardware cursor sits wherever the drag left it, but tracking still
1796
+ // describes the pre-resize row (no alt-buffer restore replays it back).
1797
+ // The shell handoff below moves relatively from the tracked row, so park
1798
+ // absolutely on the bottom row and record it first.
1799
+ this.terminal.write(`\x1b[${this.terminal.rows};1H`);
1800
+ this.#hardwareCursorRow = this.terminal.rows - 1;
1801
+ }
1802
+ this.#resizeInPlaceActive = false;
1803
+ this.#altToggleEchoPending = false;
1643
1804
  this.#cancelResizeProbe();
1644
1805
  if (this.#resizeAltActive) {
1645
1806
  this.#resizeAltActive = false;
@@ -2319,7 +2480,11 @@ export class TUI extends Container {
2319
2480
  !this.#hasEverRendered ||
2320
2481
  (this.#previousWidth === width && this.#previousHeight === height) ||
2321
2482
  this.#resizeReplaySize === size ||
2322
- this.#resizeScrollbackMode === "preserve"
2483
+ this.#resizeScrollbackMode === "preserve" ||
2484
+ // In-place resizes (Warp) repaint the settled viewport once the drag
2485
+ // goes quiet: no alt borrow, and no ED3 rewrap or history replay, so a
2486
+ // drag can neither loop on its own echo nor flash destructive repaints.
2487
+ this.#resizeRepaintsInPlace()
2323
2488
  ) {
2324
2489
  return;
2325
2490
  }
@@ -2545,6 +2710,7 @@ export class TUI extends Container {
2545
2710
  ...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
2546
2711
  };
2547
2712
  if (pendingAltExit) {
2713
+ this.#noteAltBufferToggle();
2548
2714
  this.#pendingAltExit = "";
2549
2715
  setAltScreenActive(false);
2550
2716
  }
@@ -2585,6 +2751,14 @@ export class TUI extends Container {
2585
2751
  // use the stale pre-resize anchor.
2586
2752
  return;
2587
2753
  }
2754
+ if (this.#resizeInPlaceActive && !this.#altActive) {
2755
+ // In-place resize settling (Warp): the normal-buffer anchor is stale until
2756
+ // the settled CPR probe resolves. Painting now would overwrite retained
2757
+ // history and record the new geometry over the pending recovery, so drop
2758
+ // the frame — the resolve repaints. Fullscreen overlay paints are
2759
+ // buffer-isolated and still allowed.
2760
+ return;
2761
+ }
2588
2762
 
2589
2763
  // Fullscreen alt-screen short-circuit. While the topmost visible overlay
2590
2764
  // requests it, borrow the terminal's alternate buffer and paint only the
@@ -2597,6 +2771,7 @@ export class TUI extends Container {
2597
2771
  // modified-key reporting sequence on the freshly entered alternate
2598
2772
  // screen, or Esc/modified keys revert to legacy encoding inside
2599
2773
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2774
+ this.#noteAltBufferToggle();
2600
2775
  const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2601
2776
  this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2602
2777
  setAltScreenActive(true);
@@ -2618,6 +2793,7 @@ export class TUI extends Container {
2618
2793
  if (this.#clearScrollbackOnNextRender) {
2619
2794
  this.#pendingAltExit = exitSequence;
2620
2795
  } else {
2796
+ this.#noteAltBufferToggle();
2621
2797
  this.terminal.write(exitSequence);
2622
2798
  setAltScreenActive(false);
2623
2799
  }