@linxiraos/pi-tui 1.1.12 → 1.1.14

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,9 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [1.1.12] - 2026-09-10
5
+ ## [1.1.14] - 2026-09-12
6
6
 
7
- - 侧边栏开关默认关闭;开启状态下状态行/输入框不再被侧边栏遮挡。新增强制换行与色盲友好配色开关支持侧边栏分区(运行中会话 pin、用量行、快捷操作)。
7
+ - 随 1.1.14 版本线发布:bazel 构建面(crates/*/BUILD.bazel)版本号纳入一致性检查,CI 原生构建与桌面冒烟守卫修复。
8
+
9
+ ## [1.1.13] - 2026-09-10
10
+
11
+ - SettingsList 新增 `valueLabel`/`valueLabels`:布尔设置的显示文案与机器值分离,中文界面可本地化显示 开/关 且不再破坏循环切换匹配。
8
12
 
9
13
  ## [1.1.11] - 2026-09-08
10
14
 
@@ -9,11 +9,20 @@ export interface SettingItem {
9
9
  description?: string;
10
10
  /** Optional risk note shown in warning styling above the description, with a glyph on the row. */
11
11
  warning?: string;
12
- /** Current value to display (right side) */
12
+ /** Current value to display (right side). Kept separate from the cycled
13
+ * machine values so localized displays never break `values` matching. */
13
14
  currentValue: string;
14
- /** If provided, Enter/Space cycles through these values */
15
+ /** Optional machine-readable value list cycled on confirm. Localized
16
+ * display text lives in `valueLabel`/`valueLabels`, keeping `values`
17
+ * matching stable across languages. */
15
18
  values?: string[];
16
- /** If provided, Enter opens this submenu. Receives current value and done callback. */
19
+ /** Optional display text overriding `currentValue` in the value column
20
+ * (e.g. a localized on/off label for a boolean whose `values` stay
21
+ * machine-readable). */
22
+ valueLabel?: string;
23
+ /** Display labels aligned with `values`; consumed whenever `valueLabel`
24
+ * is set so cycled values keep their localized text. */
25
+ valueLabels?: string[];
17
26
  submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
18
27
  /** True when the displayed setting differs from its default value. */
19
28
  changed?: boolean;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-tui",
4
- "version": "1.1.12",
4
+ "version": "1.1.14",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Stencil Labs, Inc.",
@@ -29,13 +29,16 @@
29
29
  "main": "./src/index.ts",
30
30
  "types": "./dist/types/index.d.ts",
31
31
  "scripts": {
32
- "check": "tsgo -p tsconfig.json --noEmit",
32
+ "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
33
33
  "check:types": "tsgo -p tsconfig.json --noEmit",
34
- "test": "bun test --parallel test/*.test.ts"
34
+ "lint": "oxlint .",
35
+ "test": "bun test --parallel test/*.test.ts",
36
+ "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
37
+ "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
35
38
  },
36
39
  "dependencies": {
37
- "@linxiraos/pi-natives": "1.1.12",
38
- "@linxiraos/pi-utils": "1.1.12"
40
+ "@linxiraos/pi-natives": "1.1.14",
41
+ "@linxiraos/pi-utils": "1.1.14"
39
42
  },
40
43
  "devDependencies": {
41
44
  "kitty-vt-wasm": "^0.2.0"
@@ -22,11 +22,20 @@ export interface SettingItem {
22
22
  description?: string;
23
23
  /** Optional risk note shown in warning styling above the description, with a glyph on the row. */
24
24
  warning?: string;
25
- /** Current value to display (right side) */
25
+ /** Current value to display (right side). Kept separate from the cycled
26
+ * machine values so localized displays never break `values` matching. */
26
27
  currentValue: string;
27
- /** If provided, Enter/Space cycles through these values */
28
+ /** Optional machine-readable value list cycled on confirm. Localized
29
+ * display text lives in `valueLabel`/`valueLabels`, keeping `values`
30
+ * matching stable across languages. */
28
31
  values?: string[];
29
- /** If provided, Enter opens this submenu. Receives current value and done callback. */
32
+ /** Optional display text overriding `currentValue` in the value column
33
+ * (e.g. a localized on/off label for a boolean whose `values` stay
34
+ * machine-readable). */
35
+ valueLabel?: string;
36
+ /** Display labels aligned with `values`; consumed whenever `valueLabel`
37
+ * is set so cycled values keep their localized text. */
38
+ valueLabels?: string[];
30
39
  submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
31
40
  /** True when the displayed setting differs from its default value. */
32
41
  changed?: boolean;
@@ -533,7 +542,11 @@ export class SettingsList implements Component {
533
542
  const labelPad = padding(Math.max(0, maxLabelWidth - visibleWidth(labelPlain)));
534
543
  const separator = " ";
535
544
  const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
536
- const valuePlain = truncateToWidth(String(item.currentValue ?? ""), valueMaxWidth, Ellipsis.Omit);
545
+ const valuePlain = truncateToWidth(
546
+ String(item.valueLabel ?? item.currentValue ?? ""),
547
+ valueMaxWidth,
548
+ Ellipsis.Omit,
549
+ );
537
550
  const hovered = !isSelected && this.#theme.hovered !== undefined && item.id === this.#hoveredItemId;
538
551
  // De-emphasized rows (outside the active section) render as plain text
539
552
  // under one dim wash so inner label/value colors don't fight it.
@@ -821,6 +834,11 @@ export class SettingsList implements Component {
821
834
  const nextIndex = (currentIndex + 1) % item.values.length;
822
835
  const newValue = item.values[nextIndex];
823
836
  item.currentValue = newValue;
837
+ // Keep a localized value label in sync with the cycled value.
838
+ if (item.valueLabel !== undefined) {
839
+ const labels = item.valueLabels;
840
+ item.valueLabel = labels?.[nextIndex] ?? newValue;
841
+ }
824
842
  this.#onChange(item.id, newValue);
825
843
  }
826
844
  }
@@ -132,88 +132,85 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
132
132
  case 0x1b /* ESC */:
133
133
  // Meta-ESC handled by the caller.
134
134
  return -1;
135
- case 0x5b /* [ */:
136
- {
137
- // CSI: ESC [ ... final byte in 0x40-0x7E.
138
- if (pos + 2 >= length) return -1;
139
- // Old-style X10 mouse: ESC [ M + 3 arbitrary bytes.
140
- if (buffer.charCodeAt(pos + 2) === 0x4d /* M */) {
141
- if (pos + 6 <= length) return pos + 6;
142
- // Fewer than 6 bytes buffered is always under MAX_CSI_BYTES,
143
- // so this is a plain "wait for more", never a cap flush.
144
- return -1;
145
- }
146
- const capEnd = Math.min(length, pos + MAX_CSI_BYTES);
147
- const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c; /* < */
148
- // No resume hint for CSI: `extractCompleteSequences` records
149
- // hints only for OSC/DCS/APC. A partial CSI rescans from its
150
- // head, bounded by the tight MAX_CSI_BYTES cap.
151
- let i = pos + 2;
152
- while (i < capEnd) {
153
- const code = buffer.charCodeAt(i);
154
- if (code >= 0x40 && code <= 0x7e) {
155
- if (isSgrMouse) {
156
- // SGR mouse only terminates on M/m. Any other final
157
- // byte would be a malformed body — keep scanning to
158
- // match the prior `isCompleteCsiSequence` semantics.
159
- if (code !== 0x4d && code !== 0x6d) {
160
- i++;
161
- continue;
162
- }
163
- const payload = buffer.slice(pos + 2, i + 1);
164
- if (SGR_MOUSE_COMPLETE.test(payload)) return i + 1;
165
- // Malformed body ending in M/m — keep scanning for a
166
- // real terminator. Bounded by capEnd.
135
+ case 0x5b /* [ */: {
136
+ // CSI: ESC [ ... final byte in 0x40-0x7E.
137
+ if (pos + 2 >= length) return -1;
138
+ // Old-style X10 mouse: ESC [ M + 3 arbitrary bytes.
139
+ if (buffer.charCodeAt(pos + 2) === 0x4d /* M */) {
140
+ if (pos + 6 <= length) return pos + 6;
141
+ // Fewer than 6 bytes buffered is always under MAX_CSI_BYTES,
142
+ // so this is a plain "wait for more", never a cap flush.
143
+ return -1;
144
+ }
145
+ const capEnd = Math.min(length, pos + MAX_CSI_BYTES);
146
+ const isSgrMouse = buffer.charCodeAt(pos + 2) === 0x3c; /* < */
147
+ // No resume hint for CSI: `extractCompleteSequences` records
148
+ // hints only for OSC/DCS/APC. A partial CSI rescans from its
149
+ // head, bounded by the tight MAX_CSI_BYTES cap.
150
+ let i = pos + 2;
151
+ while (i < capEnd) {
152
+ const code = buffer.charCodeAt(i);
153
+ if (code >= 0x40 && code <= 0x7e) {
154
+ if (isSgrMouse) {
155
+ // SGR mouse only terminates on M/m. Any other final
156
+ // byte would be a malformed body — keep scanning to
157
+ // match the prior `isCompleteCsiSequence` semantics.
158
+ if (code !== 0x4d && code !== 0x6d) {
167
159
  i++;
168
160
  continue;
169
161
  }
170
- return i + 1;
162
+ const payload = buffer.slice(pos + 2, i + 1);
163
+ if (SGR_MOUSE_COMPLETE.test(payload)) return i + 1;
164
+ // Malformed body ending in M/m — keep scanning for a
165
+ // real terminator. Bounded by capEnd.
166
+ i++;
167
+ continue;
171
168
  }
172
- i++;
169
+ return i + 1;
173
170
  }
174
- return length - pos >= MAX_CSI_BYTES ? -2 : -1;
171
+ i++;
175
172
  }
176
- case 0x5d /* ] */:
177
- {
178
- // OSC: ESC ] ... BEL or ST (ESC \). Scan is bounded to
179
- // [searchFrom, scanLimit): `String#indexOf` has no end bound, so
180
- // an unterminated payload delivered as one huge chunk would
181
- // otherwise be scanned to the end of the buffer — past the cap
182
- // this function exists to enforce. `resumeSearchFrom - 1` keeps
183
- // the one-byte overlap so an `ESC \` split across chunks is
184
- // still found (the prior call's trailing ESC is re-inspected).
185
- const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
186
- const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
187
- for (let i = searchFrom; i < scanLimit; i++) {
188
- const code = buffer.charCodeAt(i);
189
- if (code === 0x07 /* BEL */) return i + 1;
190
- if (code === 0x1b /* ESC */) {
191
- // `ESC \` (ST) must end within the cap; a lone trailing
192
- // ESC at the buffer edge stays incomplete and is
193
- // re-examined next call via the resume overlap.
194
- if (i + 1 < scanLimit && buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
195
- }
173
+ return length - pos >= MAX_CSI_BYTES ? -2 : -1;
174
+ }
175
+ case 0x5d /* ] */: {
176
+ // OSC: ESC ] ... BEL or ST (ESC \). Scan is bounded to
177
+ // [searchFrom, scanLimit): `String#indexOf` has no end bound, so
178
+ // an unterminated payload delivered as one huge chunk would
179
+ // otherwise be scanned to the end of the buffer — past the cap
180
+ // this function exists to enforce. `resumeSearchFrom - 1` keeps
181
+ // the one-byte overlap so an `ESC \` split across chunks is
182
+ // still found (the prior call's trailing ESC is re-inspected).
183
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
184
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
185
+ for (let i = searchFrom; i < scanLimit; i++) {
186
+ const code = buffer.charCodeAt(i);
187
+ if (code === 0x07 /* BEL */) return i + 1;
188
+ if (code === 0x1b /* ESC */) {
189
+ // `ESC \` (ST) must end within the cap; a lone trailing
190
+ // ESC at the buffer edge stays incomplete and is
191
+ // re-examined next call via the resume overlap.
192
+ if (i + 1 < scanLimit && buffer.charCodeAt(i + 1) === 0x5c /* \ */) return i + 2;
196
193
  }
197
- return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
198
194
  }
195
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
196
+ }
199
197
  case 0x50 /* P */:
200
- case 0x5f /* _ */:
201
- {
202
- // DCS / APC: ESC P/_ ... ST (ESC \). Same bounded scan and
203
- // split-ST overlap as the OSC branch, minus BEL.
204
- const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
205
- const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
206
- for (let i = searchFrom; i < scanLimit; i++) {
207
- if (
208
- buffer.charCodeAt(i) === 0x1b /* ESC */ &&
209
- i + 1 < scanLimit &&
210
- buffer.charCodeAt(i + 1) === 0x5c /* \ */
211
- ) {
212
- return i + 2;
213
- }
198
+ case 0x5f /* _ */: {
199
+ // DCS / APC: ESC P/_ ... ST (ESC \). Same bounded scan and
200
+ // split-ST overlap as the OSC branch, minus BEL.
201
+ const searchFrom = Math.max(pos + 2, resumeSearchFrom - 1);
202
+ const scanLimit = Math.min(length, pos + MAX_STRING_SEQ_BYTES);
203
+ for (let i = searchFrom; i < scanLimit; i++) {
204
+ if (
205
+ buffer.charCodeAt(i) === 0x1b /* ESC */ &&
206
+ i + 1 < scanLimit &&
207
+ buffer.charCodeAt(i + 1) === 0x5c /* \ */
208
+ ) {
209
+ return i + 2;
214
210
  }
215
- return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
216
211
  }
212
+ return length - pos >= MAX_STRING_SEQ_BYTES ? -2 : -1;
213
+ }
217
214
  case 0x4f /* O */:
218
215
  // SS3: ESC O + 1 char.
219
216
  return pos + 3 <= length ? pos + 3 : -1;
package/src/tui.ts CHANGED
@@ -89,6 +89,18 @@ const PAINT_END_NO_SYNC = ENABLE_AUTOWRAP;
89
89
  const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
90
90
  const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
91
91
 
92
+ /**
93
+ * `PI_TUI_RESIZE_IN_PLACE=1|true` forces in-place resize (no alt-buffer borrow).
94
+ * `0|false` forces the alt-buffer path even on Warp. Unset defers to Warp detection:
95
+ * Warp re-reports its size on CSI ?1049h / CSI ?1049l, which the resize alt-borrow
96
+ * turns into a flicker loop.
97
+ */
98
+ function resizeInPlaceOverride(): boolean | null {
99
+ const override = Bun.env.PI_TUI_RESIZE_IN_PLACE;
100
+ if (override === "1" || override === "true") return true;
101
+ if (override === "0" || override === "false") return false;
102
+ return null;
103
+ }
92
104
  type InputListenerResult = { consume?: boolean; data?: string } | undefined;
93
105
  type InputListener = (data: string) => InputListenerResult;
94
106
  type StartListener = () => void;
@@ -840,6 +852,18 @@ export class TUI extends Container {
840
852
  #resizeAltActive = false;
841
853
  #resizeSettleTimer: RenderTimer | undefined;
842
854
  #suppressResizeUntil = 0;
855
+ // Baseline geometry at the last alt-buffer toggle, plus whether its echo is
856
+ // still pending. A Warp-only echo is a height-only ±1 SIGWINCH against this
857
+ // baseline while the CPR probe is in flight. The expectation is single-shot:
858
+ // the first SIGWINCH after the toggle consumes it, so a real one-row resize
859
+ // back to the baseline can never be mistaken for the echo.
860
+ #altToggleColumns = 0;
861
+ #altToggleRows = 0;
862
+ #altToggleEchoPending = false;
863
+ // True while an in-place resize transaction (Warp) is inside its settle
864
+ // window: the normal-buffer anchor is stale until the settled CPR probe
865
+ // resolves, so ordinary paints are dropped until then.
866
+ #resizeInPlaceActive = false;
843
867
  #resizeScrollbackMode: ResizeScrollbackMode = TUI.#initialResizeScrollbackMode();
844
868
  #resizeReplaySize: string | undefined;
845
869
  // Holds an alternate-screen exit until its replacement full paint can emit it
@@ -1220,15 +1244,52 @@ export class TUI extends Container {
1220
1244
  data => this.#handleInput(data),
1221
1245
  () => {
1222
1246
  if (this.#resizeProbe) {
1223
- // The anchor being probed is already stale; restart the transaction.
1247
+ // Warp echoes a height-only ±1 SIGWINCH on CSI ?1049l. The echo
1248
+ // must not restart the alt borrow (that is the flicker loop),
1249
+ // but the terminal really did adopt the echoed size, so a CPR
1250
+ // reply already in flight may predate it: retire the probe and
1251
+ // reissue it at the new geometry. DSR-only, no toggle, so this
1252
+ // terminates. A real geometry change restarts the transaction below.
1253
+ if (this.#isWarpAltToggleEcho()) {
1254
+ this.#cancelResizeProbe();
1255
+ this.#trackResizeBurst();
1256
+ this.#beginResizeAnchorProbe();
1257
+ return;
1258
+ }
1224
1259
  this.#cancelResizeProbe();
1225
- this.#beginResizeAltPaint(true);
1260
+ if (this.#resizeRepaintsInPlace()) this.#beginResizeInPlacePaint();
1261
+ else this.#beginResizeAltPaint(true);
1262
+ return;
1263
+ }
1264
+ if (this.#altActive) {
1265
+ // A fullscreen overlay owns the alt buffer: repaint the modal at
1266
+ // the new size. Never snapshot the normal window or probe its
1267
+ // anchor against the alternate grid — not even for a toggle echo.
1268
+ this.requestRender(true);
1269
+ return;
1270
+ }
1271
+ if (!this.#resizeAltActive && this.#isWarpAltToggleEcho()) {
1272
+ // Delayed echo that lost the signal-vs-pty race with its own
1273
+ // probe's CPR reply: the probe already resolved, so re-probe at
1274
+ // the echoed size instead of painting on a stale anchor or
1275
+ // suppressing into a forced replay. DSR-only, no borrow.
1276
+ // While the resize borrow is active the echo is swallowed
1277
+ // without probing: a CPR issued now would snapshot the
1278
+ // alternate grid and anchor the normal viewport to its row.
1279
+ this.#resizeProbeWindow = this.#providerWindow;
1280
+ this.#resizeProbeOffset = this.#parkedViewportOffset;
1281
+ this.#trackResizeBurst();
1282
+ this.#beginResizeAnchorProbe();
1226
1283
  return;
1227
1284
  }
1228
1285
  if (this.#renderScheduler.now() < this.#suppressResizeUntil) {
1229
1286
  this.requestRender(true);
1230
1287
  return;
1231
1288
  }
1289
+ if (this.#resizeRepaintsInPlace()) {
1290
+ this.#beginResizeInPlacePaint();
1291
+ return;
1292
+ }
1232
1293
  this.#beginResizeAltPaint();
1233
1294
  },
1234
1295
  () => this.stop(),
@@ -1252,6 +1313,129 @@ export class TUI extends Container {
1252
1313
  }
1253
1314
  this.requestRender(true, { clearScrollback: options?.clearScrollback === true });
1254
1315
  }
1316
+ /**
1317
+ * Whether a resize repaints the visible window in place — no alternate-screen
1318
+ * borrow. Warp-only: Warp re-reports its size on alt-buffer toggles, so borrowing
1319
+ * there self-sustains. Every other terminal keeps the alt-borrow path. Inside a
1320
+ * multiplexer the mux owns the grid and consumes the toggles itself, so an
1321
+ * inherited Warp marker must not divert the mux-tuned borrow path.
1322
+ */
1323
+ #resizeRepaintsInPlace(): boolean {
1324
+ const override = resizeInPlaceOverride();
1325
+ if (override !== null) return override;
1326
+ if (isInsideTerminalMultiplexer()) return false;
1327
+ return Bun.env.TERM_PROGRAM?.toLowerCase() === "warpterminal";
1328
+ }
1329
+
1330
+ #noteAltBufferToggle(): void {
1331
+ this.#altToggleColumns = this.terminal.columns;
1332
+ this.#altToggleRows = this.terminal.rows;
1333
+ this.#altToggleEchoPending = true;
1334
+ }
1335
+
1336
+ /**
1337
+ * Warp-only echo: height-only ±1 SIGWINCH against the pending alt-toggle
1338
+ * baseline. Single-shot: the first SIGWINCH after the toggle consumes the
1339
+ * expectation either way, so at most one signal is ever swallowed per toggle.
1340
+ * Never inside a multiplexer, which consumes the toggles itself.
1341
+ */
1342
+ #isWarpAltToggleEcho(): boolean {
1343
+ if (!this.#altToggleEchoPending) return false;
1344
+ this.#altToggleEchoPending = false;
1345
+ // Inside a multiplexer the mux consumes alt toggles itself, so no echo is
1346
+ // possible: every ±1 resize is real and must restart the transaction.
1347
+ if (isInsideTerminalMultiplexer()) return false;
1348
+ if (Bun.env.TERM_PROGRAM?.toLowerCase() !== "warpterminal") return false;
1349
+ return (
1350
+ this.terminal.columns === this.#altToggleColumns && Math.abs(this.terminal.rows - this.#altToggleRows) <= 1
1351
+ );
1352
+ }
1353
+
1354
+ /**
1355
+ * Fold one SIGWINCH step into the coalesced resize-burst accounting shared by
1356
+ * both resize paths: any grow step poisons the multiplexer clip model, the
1357
+ * accumulated pull bounds CPR-less grow anchors, and the epoch retires the
1358
+ * in-flight CPR tag so a rewrap-invalidated reply cannot anchor a new geometry.
1359
+ */
1360
+ #trackResizeBurst(): void {
1361
+ const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1362
+ if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1363
+ this.#resizeBurstLastHeight = this.terminal.rows;
1364
+ this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1365
+ this.#geometryEpoch++;
1366
+ }
1367
+
1368
+ /**
1369
+ * Coalesced in-place resize transaction for Warp-class terminals: never
1370
+ * borrows the alt buffer. Drag SIGWINCHes only re-arm the settle window, so a
1371
+ * drag emits no paints and no scrollback replay; once quiet, the transaction
1372
+ * snapshots the live window and runs the CPR anchor probe, and the single
1373
+ * settled repaint lands on the recovered anchor with no ED3 rewrap.
1374
+ */
1375
+ #beginResizeInPlacePaint(): void {
1376
+ if (this.#altActive) {
1377
+ this.requestRender(true);
1378
+ return;
1379
+ }
1380
+ this.#trackResizeBurst();
1381
+ this.#resizeInPlaceActive = true;
1382
+ this.#resizeSettleTimer?.cancel();
1383
+ this.#forgetHardwareCursorState();
1384
+ this.#recordHardwareCursorHidden();
1385
+ if (this.#eraseLiveViewportForResize()) {
1386
+ // The erase parked the hardware cursor on the viewport's top row;
1387
+ // snapshot the parked offset so the settled probe anchors there.
1388
+ this.#parkedViewportOffset = 0;
1389
+ }
1390
+ this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
1391
+ this.#resizeSettleTimer = undefined;
1392
+ if (this.#stopped) return;
1393
+ this.#resizeProbeWindow = this.#providerWindow;
1394
+ this.#resizeProbeOffset = this.#parkedViewportOffset;
1395
+ this.#beginResizeAnchorProbe();
1396
+ }, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
1397
+ }
1398
+
1399
+ /**
1400
+ * Blank the mutable live viewport on the normal screen before a resize
1401
+ * transaction waits out the drag. The terminal keeps reflowing the normal
1402
+ * buffer during the drag, and a height shrink pushes its top rows into
1403
+ * scrollback; with the live region blanked, only committed history rows
1404
+ * (correct to push) or blanks can leave the screen — never live placeholder
1405
+ * rows such as compact tool dots, whose real blocks must enter scrollback
1406
+ * through the ordered history path. Addressing depends on the resize
1407
+ * direction. Terminals keep the parked cursor attached to its logical line
1408
+ * through width rewrap and height-grow scrollback pull-down, so
1409
+ * cursor-relative movement lands on the viewport's top row. On height shrink
1410
+ * kitty clamps the cursor instead of moving it with pushed rows, so
1411
+ * cursor-relative addressing would start rows late; fall back to the same
1412
+ * bottom-preserving bound as resize-anchor recovery.
1413
+ *
1414
+ * Both erase paths leave the cursor on the viewport's top row. Returns true
1415
+ * when it erased (multiplexers skip it: an immediate erase races the pane
1416
+ * re-layout and blanks pulled-back committed rows).
1417
+ */
1418
+ #eraseLiveViewportForResize(): boolean {
1419
+ if (!this.#hasEverRendered || this.#providerWindow.length === 0 || isInsideTerminalMultiplexer()) {
1420
+ return false;
1421
+ }
1422
+ if (this.terminal.rows < this.#previousHeight) {
1423
+ const staleRows = this.#reflowedRowCount(
1424
+ this.#providerWindow,
1425
+ 0,
1426
+ this.#providerWindow.length,
1427
+ this.terminal.columns,
1428
+ );
1429
+ const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1430
+ this.terminal.write(`\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`);
1431
+ } else {
1432
+ const up = this.#reflowedRowCount(this.#providerWindow, 0, this.#parkedViewportOffset, this.terminal.columns);
1433
+ const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1434
+ this.terminal.write(`\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`);
1435
+ }
1436
+ return true;
1437
+ }
1438
+
1255
1439
  /**
1256
1440
  * Borrow the alternate buffer for stable, history-free resize repainting.
1257
1441
  * `restartingProbe` marks a transaction restarted by a SIGWINCH that
@@ -1264,62 +1448,26 @@ export class TUI extends Container {
1264
1448
  this.requestRender(true);
1265
1449
  return;
1266
1450
  }
1267
- const burstLastHeight = this.#resizeBurstLastHeight ?? this.#previousHeight;
1268
- if (this.terminal.rows > burstLastHeight) this.#resizeBurstGrew = true;
1269
- this.#resizeBurstLastHeight = this.terminal.rows;
1270
- this.#resizeBurstPull += Math.max(0, this.terminal.rows - burstLastHeight);
1271
- this.#geometryEpoch++;
1451
+ this.#trackResizeBurst();
1272
1452
  if (!this.#resizeAltActive) {
1273
1453
  this.#resizeAltActive = true;
1274
1454
  setAltScreenActive(true);
1275
1455
  this.#altPreviousLines = [];
1276
1456
  this.#forgetHardwareCursorState();
1277
1457
  this.#recordHardwareCursorHidden();
1278
- // Erase the mutable live viewport from the normal screen before borrowing
1279
- // the alt buffer. The terminal keeps reflowing the normal buffer during
1280
- // the drag, and a height shrink pushes its top rows into scrollback;
1281
- // with the live region blanked, only committed history rows (correct to
1282
- // push) or blanks can leave the screen — never live placeholder rows
1283
- // such as compact tool dots, whose real blocks must enter scrollback
1284
- // through the ordered history path. Addressing depends on the resize
1285
- // direction. Terminals keep the parked cursor attached to its logical
1286
- // line through width rewrap and height-grow scrollback pull-down, so
1287
- // cursor-relative movement lands on the viewport's top row. On height
1288
- // shrink kitty clamps the cursor instead of moving it with pushed rows,
1289
- // so cursor-relative addressing would start rows late; fall back to the
1290
- // same bottom-preserving bound as resize-anchor recovery. The pre-erase
1291
- // window is stashed for the settled CPR probe: its reflowed row count
1292
- // bounds the anchor to `height - staleRows`, so a mis-parked cursor (a
1293
- // single-step tmux zoom re-lays the pane before SIGWINCH delivery,
1294
- // moving the park target under us) cannot anchor the settled repaint
1295
- // over pulled-back history rows or scroll-push the frame into
1296
- // scrollback again.
1297
- let erase = "";
1458
+ // Blank the live region up front so a reflow-driven scroll can only push
1459
+ // committed rows into scrollback. The pre-erase window is stashed for
1460
+ // the settled CPR probe: its reflowed row count bounds the anchor to
1461
+ // `height - staleRows`, so a mis-parked cursor (a single-step tmux zoom
1462
+ // re-lays the pane before SIGWINCH delivery, moving the park target
1463
+ // under us) cannot anchor the settled repaint over pulled-back history
1464
+ // rows or scroll-push the frame into scrollback again.
1298
1465
  if (!restartingProbe) {
1299
1466
  this.#resizeProbeWindow = this.#providerWindow;
1300
1467
  this.#resizeProbeOffset = this.#parkedViewportOffset;
1301
1468
  }
1302
- if (this.#hasEverRendered && this.#providerWindow.length > 0 && !isInsideTerminalMultiplexer()) {
1303
- if (this.terminal.rows < this.#previousHeight) {
1304
- const staleRows = this.#reflowedRowCount(
1305
- this.#providerWindow,
1306
- 0,
1307
- this.#providerWindow.length,
1308
- this.terminal.columns,
1309
- );
1310
- const top = Math.max(0, Math.min(this.#providerViewportTop, this.terminal.rows - staleRows));
1311
- erase = `\x1b[?25l${this.#eraseBelowRow(top, this.terminal.rows)}`;
1312
- } else {
1313
- const up = this.#reflowedRowCount(
1314
- this.#providerWindow,
1315
- 0,
1316
- this.#parkedViewportOffset,
1317
- this.terminal.columns,
1318
- );
1319
- const eraseBelow = this.#eraseBelowCursorRow(this.terminal.columns, this.terminal.rows);
1320
- erase = `\x1b[?25l${up > 0 ? `\x1b[${up}A` : ""}${eraseBelow}`;
1321
- }
1322
- // Both erase paths leave the cursor on the viewport's top row, so the
1469
+ if (this.#eraseLiveViewportForResize()) {
1470
+ // The erase parked the cursor on the viewport's top row, so the
1323
1471
  // parked offset no longer applies; carrying a stale nonzero offset
1324
1472
  // into the probe would anchor the settled repaint above the real
1325
1473
  // viewport top and overwrite visible committed rows.
@@ -1338,7 +1486,8 @@ export class TUI extends Container {
1338
1486
  this.#providerWindow = [];
1339
1487
  this.#parkedViewportOffset = 0;
1340
1488
  }
1341
- this.terminal.write(`${erase}\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
1489
+ this.#noteAltBufferToggle();
1490
+ this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}`);
1342
1491
  }
1343
1492
  this.#resizeSettleTimer?.cancel();
1344
1493
  this.#resizeSettleTimer = this.#renderScheduler.scheduleRender(() => {
@@ -1346,6 +1495,7 @@ export class TUI extends Container {
1346
1495
  if (this.#stopped || !this.#resizeAltActive) return;
1347
1496
  this.#resizeAltActive = false;
1348
1497
  this.#suppressResizeUntil = this.#renderScheduler.now() + 100;
1498
+ this.#noteAltBufferToggle();
1349
1499
  this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
1350
1500
  setAltScreenActive(false);
1351
1501
  this.#altPreviousLines = [];
@@ -1354,8 +1504,8 @@ export class TUI extends Container {
1354
1504
  this.requestRender(true);
1355
1505
  }
1356
1506
  /**
1357
- * Recover the reflowed viewport anchor after the resize alt-buffer borrow
1358
- * ends. The terminal reflowed the restored normal buffer during the drag, so
1507
+ * Recover the reflowed viewport anchor after the resize settle window ends.
1508
+ * The terminal reflowed the restored normal buffer during the drag, so
1359
1509
  * `#providerViewportTop` is in stale grid coordinates; a DSR (CSI 6n) round
1360
1510
  * trip against the parked cursor reports where the viewport's logical line
1361
1511
  * landed. The settled repaint waits for the reply (or a short timeout).
@@ -1441,6 +1591,7 @@ export class TUI extends Container {
1441
1591
  if (!probe) return;
1442
1592
  probe.timer.cancel();
1443
1593
  this.#resizeProbe = undefined;
1594
+ this.#resizeInPlaceActive = false;
1444
1595
  // Column tags stay live across resolves: their replies are
1445
1596
  // self-identifying and discarded by tag whenever they arrive.
1446
1597
  const width = this.terminal.columns;
@@ -1736,6 +1887,16 @@ export class TUI extends Container {
1736
1887
  this.#debugServer = undefined;
1737
1888
  this.#resizeSettleTimer?.cancel();
1738
1889
  this.#resizeSettleTimer = undefined;
1890
+ if (this.#resizeInPlaceActive && this.terminal.rows > 0) {
1891
+ // The hardware cursor sits wherever the drag left it, but tracking still
1892
+ // describes the pre-resize row (no alt-buffer restore replays it back).
1893
+ // The shell handoff below moves relatively from the tracked row, so park
1894
+ // absolutely on the bottom row and record it first.
1895
+ this.terminal.write(`\x1b[${this.terminal.rows};1H`);
1896
+ this.#hardwareCursorRow = this.terminal.rows - 1;
1897
+ }
1898
+ this.#resizeInPlaceActive = false;
1899
+ this.#altToggleEchoPending = false;
1739
1900
  this.#cancelResizeProbe();
1740
1901
  if (this.#resizeAltActive) {
1741
1902
  this.#resizeAltActive = false;
@@ -2444,7 +2605,11 @@ export class TUI extends Container {
2444
2605
  !this.#hasEverRendered ||
2445
2606
  (this.#previousPhysicalWidth === width && this.#previousHeight === height) ||
2446
2607
  this.#resizeReplaySize === size ||
2447
- this.#resizeScrollbackMode === "preserve"
2608
+ this.#resizeScrollbackMode === "preserve" ||
2609
+ // In-place resizes (Warp) repaint the settled viewport once the drag
2610
+ // goes quiet: no alt borrow, and no ED3 rewrap or history replay, so a
2611
+ // drag can neither loop on its own echo nor flash destructive repaints.
2612
+ this.#resizeRepaintsInPlace()
2448
2613
  ) {
2449
2614
  return;
2450
2615
  }
@@ -2670,6 +2835,7 @@ export class TUI extends Container {
2670
2835
  ...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
2671
2836
  };
2672
2837
  if (pendingAltExit) {
2838
+ this.#noteAltBufferToggle();
2673
2839
  this.#pendingAltExit = "";
2674
2840
  setAltScreenActive(false);
2675
2841
  }
@@ -2710,6 +2876,14 @@ export class TUI extends Container {
2710
2876
  // use the stale pre-resize anchor.
2711
2877
  return;
2712
2878
  }
2879
+ if (this.#resizeInPlaceActive && !this.#altActive) {
2880
+ // In-place resize settling (Warp): the normal-buffer anchor is stale until
2881
+ // the settled CPR probe resolves. Painting now would overwrite retained
2882
+ // history and record the new geometry over the pending recovery, so drop
2883
+ // the frame — the resolve repaints. Fullscreen overlay paints are
2884
+ // buffer-isolated and still allowed.
2885
+ return;
2886
+ }
2713
2887
 
2714
2888
  // Fullscreen alt-screen short-circuit. While the topmost visible overlay
2715
2889
  // requests it, borrow the terminal's alternate buffer and paint only the
@@ -2722,6 +2896,7 @@ export class TUI extends Container {
2722
2896
  // modified-key reporting sequence on the freshly entered alternate
2723
2897
  // screen, or Esc/modified keys revert to legacy encoding inside
2724
2898
  // fullscreen overlays (Ghostty/kitty/iTerm2).
2899
+ this.#noteAltBufferToggle();
2725
2900
  const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
2726
2901
  this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
2727
2902
  setAltScreenActive(true);
@@ -2743,6 +2918,7 @@ export class TUI extends Container {
2743
2918
  if (this.#clearScrollbackOnNextRender) {
2744
2919
  this.#pendingAltExit = exitSequence;
2745
2920
  } else {
2921
+ this.#noteAltBufferToggle();
2746
2922
  this.terminal.write(exitSequence);
2747
2923
  setAltScreenActive(false);
2748
2924
  }
@@ -2833,7 +3009,7 @@ export class TUI extends Container {
2833
3009
 
2834
3010
  let output = "";
2835
3011
  let cells = 0;
2836
- for (let i = 0; i < raw.length && cells < safeWidth; ) {
3012
+ for (let i = 0; i < raw.length && cells < safeWidth;) {
2837
3013
  if (raw.charCodeAt(i) === 0x1b) {
2838
3014
  const end = this.#ansiSequenceEnd(raw, i);
2839
3015
  if (end < 0) break;
@@ -2929,7 +3105,7 @@ export class TUI extends Container {
2929
3105
 
2930
3106
  #ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
2931
3107
  let col = 0;
2932
- for (let i = 0; i < line.length; ) {
3108
+ for (let i = 0; i < line.length;) {
2933
3109
  const code = line.charCodeAt(i);
2934
3110
  if (code === 0x1b) {
2935
3111
  const next = line.charCodeAt(i + 1);