@linxiraos/pi-tui 1.1.11 → 1.1.13
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 +4 -0
- package/dist/types/components/settings-list.d.ts +12 -3
- package/package.json +8 -5
- package/src/components/settings-list.ts +22 -4
- package/src/desktop-notify.ts +1 -1
- package/src/stdin-buffer.ts +68 -71
- package/src/terminal-capabilities.ts +2 -2
- package/src/tui.ts +230 -54
package/CHANGELOG.md
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
4
|
+
"version": "1.1.13",
|
|
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": "
|
|
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
|
-
"
|
|
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.
|
|
38
|
-
"@linxiraos/pi-utils": "1.1.
|
|
40
|
+
"@linxiraos/pi-natives": "1.1.13",
|
|
41
|
+
"@linxiraos/pi-utils": "1.1.13"
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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(
|
|
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
|
}
|
package/src/desktop-notify.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { $which } from "@linxiraos/pi-utils";
|
|
|
26
26
|
import type { TerminalId, TerminalNotification } from "./terminal-capabilities";
|
|
27
27
|
|
|
28
28
|
/** Application name surfaced as the notification source. */
|
|
29
|
-
const APP_NAME = "
|
|
29
|
+
const APP_NAME = "Zeta";
|
|
30
30
|
|
|
31
31
|
/** Resolved notifier binary used to fan a notification out to D-Bus. */
|
|
32
32
|
export type DesktopNotifierKind = "notify-send" | "gdbus";
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
+
i++;
|
|
175
172
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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;
|
|
@@ -40,7 +40,7 @@ export type TerminalId =
|
|
|
40
40
|
| "base"
|
|
41
41
|
| "trueColor";
|
|
42
42
|
|
|
43
|
-
const CMUX_NOTIFICATION_TITLE = "
|
|
43
|
+
const CMUX_NOTIFICATION_TITLE = "Zeta";
|
|
44
44
|
const CMUX_SURFACE_ID_PATTERN = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
|
|
45
45
|
|
|
46
46
|
/** Title and body for an out-of-band multiplexer notification (cmux, Herdr). */
|
|
@@ -1320,7 +1320,7 @@ function notificationToLine(n: TerminalNotification): string {
|
|
|
1320
1320
|
// C0/C1 control characters that are unsafe inside an OSC payload (must base64).
|
|
1321
1321
|
const OSC99_UNSAFE = /[\x00-\x1f\x7f\x80-\x9f]/u;
|
|
1322
1322
|
const OSC99_MAX_PAYLOAD_BYTES = 2048;
|
|
1323
|
-
const OSC99_APP_NAME = "
|
|
1323
|
+
const OSC99_APP_NAME = "Zeta";
|
|
1324
1324
|
let nextOsc99NotificationId = 1;
|
|
1325
1325
|
|
|
1326
1326
|
function base64Utf8(value: string): string {
|
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
|
-
//
|
|
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.#
|
|
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
|
-
|
|
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
|
-
//
|
|
1279
|
-
//
|
|
1280
|
-
// the
|
|
1281
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
//
|
|
1284
|
-
//
|
|
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.#
|
|
1303
|
-
|
|
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
|
|
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
|
|
1358
|
-
*
|
|
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);
|