@oh-my-pi/pi-tui 18.1.21 → 18.2.0
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 +12 -0
- package/THIRD-PARTY-NOTICES.txt +54 -25
- package/dist/types/components/editor.d.ts +6 -0
- package/dist/types/terminal-capabilities.d.ts +6 -0
- package/package.json +3 -3
- package/src/components/editor.ts +53 -53
- package/src/terminal-capabilities.ts +52 -34
- package/src/terminal.ts +4 -10
- package/src/tui.ts +260 -97
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.2.0] - 2026-09-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `Editor.textRevision` for content-dependent render caches, including undo and draft restoration.
|
|
10
|
+
- Added collapseToAtom method to compress text spans into UI-friendly atoms
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Redrawing unchanged terminal rows now avoids rescanning ANSI, hyperlinks, and images.
|
|
15
|
+
- Terminal UIs reach their first frame with a smaller startup module graph.
|
|
16
|
+
|
|
5
17
|
## [18.1.17] - 2026-09-10
|
|
6
18
|
|
|
7
19
|
### Added
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -10843,7 +10843,7 @@ RUST RUNTIME DEPENDENCY LICENSES
|
|
|
10843
10843
|
|
|
10844
10844
|
Generated from Cargo.lock by cargo-about 0.8.2 in locked, offline, workspace, all-feature, all-target mode, then restricted using cargo metadata to normal and build edges reachable from the workspace (development-only edges are excluded). cargo-deny independently evaluates the complete all-target graph.
|
|
10845
10845
|
|
|
10846
|
-
Native Opus (
|
|
10846
|
+
Native Opus (opus/opusic-sys) and PCRE2 (pcre2/pcre2-sys) are covered by the exact crate license payloads below. inferno 0.12.8 is package-scoped as CDDL-1.0: its CDDL-covered source remains available in the locked crates.io source archive at https://crates.io/crates/inferno/0.12.8 and https://github.com/jonhoo/inferno. The full locked license text follows below.
|
|
10847
10847
|
|
|
10848
10848
|
-------------------------------------------------------------------------------
|
|
10849
10849
|
License: Apache License 2.0 (Apache-2.0)
|
|
@@ -12509,6 +12509,58 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
|
12509
12509
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
12510
12510
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
12511
12511
|
|
|
12512
|
+
-------------------------------------------------------------------------------
|
|
12513
|
+
License: BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)
|
|
12514
|
+
Used by:
|
|
12515
|
+
- opusic-sys 0.7.5 (https://github.com/DoumanAsh/opusic-sys)
|
|
12516
|
+
|
|
12517
|
+
Full license text:
|
|
12518
|
+
|
|
12519
|
+
Copyright 2001-2023 Xiph.Org, Skype Limited, Octasic,
|
|
12520
|
+
Jean-Marc Valin, Timothy B. Terriberry,
|
|
12521
|
+
CSIRO, Gregory Maxwell, Mark Borgerding,
|
|
12522
|
+
Erik de Castro Lopo, Mozilla, Amazon
|
|
12523
|
+
|
|
12524
|
+
Redistribution and use in source and binary forms, with or without
|
|
12525
|
+
modification, are permitted provided that the following conditions
|
|
12526
|
+
are met:
|
|
12527
|
+
|
|
12528
|
+
- Redistributions of source code must retain the above copyright
|
|
12529
|
+
notice, this list of conditions and the following disclaimer.
|
|
12530
|
+
|
|
12531
|
+
- Redistributions in binary form must reproduce the above copyright
|
|
12532
|
+
notice, this list of conditions and the following disclaimer in the
|
|
12533
|
+
documentation and/or other materials provided with the distribution.
|
|
12534
|
+
|
|
12535
|
+
- Neither the name of Internet Society, IETF or IETF Trust, nor the
|
|
12536
|
+
names of specific contributors, may be used to endorse or promote
|
|
12537
|
+
products derived from this software without specific prior written
|
|
12538
|
+
permission.
|
|
12539
|
+
|
|
12540
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
12541
|
+
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
12542
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
|
12543
|
+
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
|
|
12544
|
+
OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
|
12545
|
+
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
12546
|
+
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
|
12547
|
+
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
|
12548
|
+
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
|
12549
|
+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
12550
|
+
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
12551
|
+
|
|
12552
|
+
Opus is subject to the royalty-free patent licenses which are
|
|
12553
|
+
specified at:
|
|
12554
|
+
|
|
12555
|
+
Xiph.Org Foundation:
|
|
12556
|
+
https://datatracker.ietf.org/ipr/1524/
|
|
12557
|
+
|
|
12558
|
+
Microsoft Corporation:
|
|
12559
|
+
https://datatracker.ietf.org/ipr/1914/
|
|
12560
|
+
|
|
12561
|
+
Broadcom Corporation:
|
|
12562
|
+
https://datatracker.ietf.org/ipr/1526/
|
|
12563
|
+
|
|
12512
12564
|
-------------------------------------------------------------------------------
|
|
12513
12565
|
License: BSD 3-Clause "New" or "Revised" License (BSD-3-Clause)
|
|
12514
12566
|
Used by:
|
|
@@ -13290,29 +13342,6 @@ jurisdiction of the Federal Courts of the Northern District of
|
|
|
13290
13342
|
California and the state courts of the State of California, with
|
|
13291
13343
|
venue lying in Santa Clara County, California.
|
|
13292
13344
|
|
|
13293
|
-
-------------------------------------------------------------------------------
|
|
13294
|
-
License: ISC License (ISC)
|
|
13295
|
-
Used by:
|
|
13296
|
-
- audiopus_sys 0.2.2 (https://github.com/lakelezz/audiopus_sys.git)
|
|
13297
|
-
|
|
13298
|
-
Full license text:
|
|
13299
|
-
|
|
13300
|
-
ISC License
|
|
13301
|
-
|
|
13302
|
-
Copyright (c) 2019, Lakelezz
|
|
13303
|
-
|
|
13304
|
-
Permission to use, copy, modify, and/or distribute this software for any
|
|
13305
|
-
purpose with or without fee is hereby granted, provided that the above
|
|
13306
|
-
copyright notice and this permission notice appear in all copies.
|
|
13307
|
-
|
|
13308
|
-
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
13309
|
-
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
13310
|
-
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
13311
|
-
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13312
|
-
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
13313
|
-
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13314
|
-
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
13315
|
-
|
|
13316
13345
|
-------------------------------------------------------------------------------
|
|
13317
13346
|
License: ISC License (ISC)
|
|
13318
13347
|
Used by:
|
|
@@ -18734,7 +18763,7 @@ DEALINGS IN THE SOFTWARE.
|
|
|
18734
18763
|
-------------------------------------------------------------------------------
|
|
18735
18764
|
License: MIT License (MIT)
|
|
18736
18765
|
Used by:
|
|
18737
|
-
- opus 0.
|
|
18766
|
+
- opus 0.4.0 (https://github.com/SpaceManiac/opus-rs)
|
|
18738
18767
|
|
|
18739
18768
|
Full license text:
|
|
18740
18769
|
|
|
@@ -196,6 +196,8 @@ export declare class Editor implements Component, Focusable {
|
|
|
196
196
|
render(width: number): readonly string[];
|
|
197
197
|
handleInput(data: string): void;
|
|
198
198
|
getText(): string;
|
|
199
|
+
/** Monotonic buffer-content revision for render caches. Cursor-only movement does not advance it. */
|
|
200
|
+
get textRevision(): number;
|
|
199
201
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
200
202
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
201
203
|
textEquals(value: string): boolean;
|
|
@@ -206,6 +208,10 @@ export declare class Editor implements Component, Focusable {
|
|
|
206
208
|
* to `expansion` on submit. Pair with {@link atomicTokenPattern} so the label deletes as a
|
|
207
209
|
* unit. */
|
|
208
210
|
insertAtom(label: string, expansion: string): void;
|
|
211
|
+
/** Collapse the typed span `[start, end)` on `line` into the atom `label` (expanding back
|
|
212
|
+
* to `expansion` on submit). A cursor at or past the span keeps its position relative to
|
|
213
|
+
* the span end; a cursor inside it lands after the label. */
|
|
214
|
+
collapseToAtom(line: number, start: number, end: number, label: string, expansion: string): void;
|
|
209
215
|
/** Drop every registered atom expansion (draft cleared or replaced by the host). */
|
|
210
216
|
clearAtoms(): void;
|
|
211
217
|
/**
|
|
@@ -45,6 +45,12 @@ export declare class TerminalInfo {
|
|
|
45
45
|
* reconstructing positional constructor args.
|
|
46
46
|
*/
|
|
47
47
|
clone(): RuntimeTerminal;
|
|
48
|
+
/**
|
|
49
|
+
* Whether an image marker begins at `start`. Kept as the shared primitive
|
|
50
|
+
* for both standalone image checks and the renderer's combined ANSI/width
|
|
51
|
+
* scan, so their protocol windows and sixel grammar cannot drift.
|
|
52
|
+
*/
|
|
53
|
+
hasImageMarkerAt(line: string, start: number): boolean;
|
|
48
54
|
isImageLine(line: string): boolean;
|
|
49
55
|
formatNotification(message: string | TerminalNotification): string;
|
|
50
56
|
sendNotification(message: string | TerminalNotification): void;
|
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.
|
|
4
|
+
"version": "18.2.0",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.2.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.2.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/components/editor.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getProjectDir
|
|
1
|
+
import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
|
|
2
|
+
import * as logger from "@oh-my-pi/pi-utils/logger";
|
|
2
3
|
import {
|
|
3
4
|
type AutocompleteItem,
|
|
4
5
|
type AutocompleteProvider,
|
|
@@ -630,6 +631,7 @@ export class Editor implements Component, Focusable {
|
|
|
630
631
|
onSubmit?: (text: string) => void | Promise<void>;
|
|
631
632
|
onAltEnter?: (text: string) => void;
|
|
632
633
|
onChange?: (text: string) => void;
|
|
634
|
+
#textRevision = 0;
|
|
633
635
|
/** Called for a "marker-sized" paste — the point where the editor would otherwise collapse it
|
|
634
636
|
* into a `[Paste #N]` token (> 10 lines or > 1000 characters). Return `true` to intercept:
|
|
635
637
|
* the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
|
|
@@ -962,9 +964,7 @@ export class Editor implements Component, Focusable {
|
|
|
962
964
|
this.#state.cursorLine = this.#state.lines.length - 1;
|
|
963
965
|
this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
|
|
964
966
|
}
|
|
965
|
-
|
|
966
|
-
this.onChange(this.getText());
|
|
967
|
-
}
|
|
967
|
+
this.#notifyChange();
|
|
968
968
|
}
|
|
969
969
|
|
|
970
970
|
invalidate(): void {
|
|
@@ -1661,9 +1661,7 @@ export class Editor implements Component, Focusable {
|
|
|
1661
1661
|
this.#cancelAutocomplete();
|
|
1662
1662
|
this.onAutocompleteUpdate?.();
|
|
1663
1663
|
|
|
1664
|
-
|
|
1665
|
-
this.onChange(this.getText());
|
|
1666
|
-
}
|
|
1664
|
+
this.#notifyChange();
|
|
1667
1665
|
|
|
1668
1666
|
result.onApplied?.();
|
|
1669
1667
|
|
|
@@ -1737,9 +1735,7 @@ export class Editor implements Component, Focusable {
|
|
|
1737
1735
|
this.#cancelAutocomplete();
|
|
1738
1736
|
this.onAutocompleteUpdate?.();
|
|
1739
1737
|
|
|
1740
|
-
|
|
1741
|
-
this.onChange(this.getText());
|
|
1742
|
-
}
|
|
1738
|
+
this.#notifyChange();
|
|
1743
1739
|
|
|
1744
1740
|
result.onApplied?.();
|
|
1745
1741
|
if (shouldChainDirectoryCompletion) {
|
|
@@ -2168,7 +2164,7 @@ export class Editor implements Component, Focusable {
|
|
|
2168
2164
|
#afterVimEdit(): void {
|
|
2169
2165
|
this.#historyIndex = -1;
|
|
2170
2166
|
this.#resetKillSequence();
|
|
2171
|
-
this
|
|
2167
|
+
this.#notifyChange();
|
|
2172
2168
|
}
|
|
2173
2169
|
|
|
2174
2170
|
/**
|
|
@@ -2389,6 +2385,16 @@ export class Editor implements Component, Focusable {
|
|
|
2389
2385
|
return this.#state.lines.join("\n");
|
|
2390
2386
|
}
|
|
2391
2387
|
|
|
2388
|
+
/** Monotonic buffer-content revision for render caches. Cursor-only movement does not advance it. */
|
|
2389
|
+
get textRevision(): number {
|
|
2390
|
+
return this.#textRevision;
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
#notifyChange(text?: string): void {
|
|
2394
|
+
this.#textRevision++;
|
|
2395
|
+
this.onChange?.(text ?? this.getText());
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2392
2398
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
2393
2399
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
2394
2400
|
textEquals(value: string): boolean {
|
|
@@ -2437,6 +2443,24 @@ export class Editor implements Component, Focusable {
|
|
|
2437
2443
|
});
|
|
2438
2444
|
}
|
|
2439
2445
|
|
|
2446
|
+
/** Collapse the typed span `[start, end)` on `line` into the atom `label` (expanding back
|
|
2447
|
+
* to `expansion` on submit). A cursor at or past the span keeps its position relative to
|
|
2448
|
+
* the span end; a cursor inside it lands after the label. */
|
|
2449
|
+
collapseToAtom(line: number, start: number, end: number, label: string, expansion: string): void {
|
|
2450
|
+
const text = this.#state.lines[line];
|
|
2451
|
+
if (text === undefined || start < 0 || end > text.length || start >= end) return;
|
|
2452
|
+
this.#resetKillSequence();
|
|
2453
|
+
this.#recordUndoState();
|
|
2454
|
+
this.registerAtom(label, expansion);
|
|
2455
|
+
this.#state.lines[line] = text.slice(0, start) + label + text.slice(end);
|
|
2456
|
+
if (this.#state.cursorLine === line && this.#state.cursorCol > start) {
|
|
2457
|
+
const col = this.#state.cursorCol;
|
|
2458
|
+
this.#setCursorCol(col >= end ? col - (end - start) + label.length : start + label.length);
|
|
2459
|
+
}
|
|
2460
|
+
this.#lastAction = null;
|
|
2461
|
+
this.#notifyChange();
|
|
2462
|
+
}
|
|
2463
|
+
|
|
2440
2464
|
/** Drop every registered atom expansion (draft cleared or replaced by the host). */
|
|
2441
2465
|
clearAtoms(): void {
|
|
2442
2466
|
this.#atoms.clear();
|
|
@@ -2517,9 +2541,7 @@ export class Editor implements Component, Focusable {
|
|
|
2517
2541
|
}
|
|
2518
2542
|
|
|
2519
2543
|
if (this.#undoStack.length === 0) {
|
|
2520
|
-
|
|
2521
|
-
this.onChange(this.getText());
|
|
2522
|
-
}
|
|
2544
|
+
this.#notifyChange();
|
|
2523
2545
|
return;
|
|
2524
2546
|
}
|
|
2525
2547
|
|
|
@@ -2566,9 +2588,7 @@ export class Editor implements Component, Focusable {
|
|
|
2566
2588
|
line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
|
|
2567
2589
|
this.#setCursorCol(this.#state.cursorCol - removable);
|
|
2568
2590
|
this.#lastAction = null;
|
|
2569
|
-
|
|
2570
|
-
this.onChange(this.getText());
|
|
2571
|
-
}
|
|
2591
|
+
this.#notifyChange();
|
|
2572
2592
|
}
|
|
2573
2593
|
|
|
2574
2594
|
/** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
|
|
@@ -2585,7 +2605,7 @@ export class Editor implements Component, Focusable {
|
|
|
2585
2605
|
if (text) this.#insertTextAtCursor(text);
|
|
2586
2606
|
});
|
|
2587
2607
|
this.#volatileTextLen = text.length;
|
|
2588
|
-
if (!text
|
|
2608
|
+
if (!text) this.#notifyChange();
|
|
2589
2609
|
}
|
|
2590
2610
|
|
|
2591
2611
|
/** Remove the current volatile preview without committing it. */
|
|
@@ -2593,7 +2613,7 @@ export class Editor implements Component, Focusable {
|
|
|
2593
2613
|
if (this.#volatileTextLen === 0) return;
|
|
2594
2614
|
this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
|
|
2595
2615
|
this.#volatileTextLen = 0;
|
|
2596
|
-
|
|
2616
|
+
this.#notifyChange();
|
|
2597
2617
|
}
|
|
2598
2618
|
|
|
2599
2619
|
/** Drop any volatile preview, then insert `text` as a single undoable edit. */
|
|
@@ -2602,7 +2622,7 @@ export class Editor implements Component, Focusable {
|
|
|
2602
2622
|
this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
|
|
2603
2623
|
this.#volatileTextLen = 0;
|
|
2604
2624
|
if (text) this.#insertTextAtCursor(text);
|
|
2605
|
-
else
|
|
2625
|
+
else this.#notifyChange();
|
|
2606
2626
|
}
|
|
2607
2627
|
|
|
2608
2628
|
/** Delete `count` UTF-16 code units immediately before the cursor, crossing line
|
|
@@ -2662,7 +2682,7 @@ export class Editor implements Component, Focusable {
|
|
|
2662
2682
|
const after = line.slice(this.#state.cursorCol);
|
|
2663
2683
|
this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
|
|
2664
2684
|
this.#setCursorCol(before.length + replacement.insert.length);
|
|
2665
|
-
this
|
|
2685
|
+
this.#notifyChange();
|
|
2666
2686
|
if (this.#autocompleteState) {
|
|
2667
2687
|
this.#cancelAutocomplete();
|
|
2668
2688
|
this.onAutocompleteUpdate?.();
|
|
@@ -2688,9 +2708,7 @@ export class Editor implements Component, Focusable {
|
|
|
2688
2708
|
this.#state.lines[this.#state.cursorLine] = before + char + after;
|
|
2689
2709
|
this.#setCursorCol(this.#state.cursorCol + char.length);
|
|
2690
2710
|
|
|
2691
|
-
|
|
2692
|
-
this.onChange(this.getText());
|
|
2693
|
-
}
|
|
2711
|
+
this.#notifyChange();
|
|
2694
2712
|
|
|
2695
2713
|
// Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
|
|
2696
2714
|
// Runs before autocomplete trigger so the popup doesn't briefly chase a
|
|
@@ -2899,9 +2917,7 @@ export class Editor implements Component, Focusable {
|
|
|
2899
2917
|
this.#state.cursorLine++;
|
|
2900
2918
|
this.#setCursorCol(0);
|
|
2901
2919
|
|
|
2902
|
-
|
|
2903
|
-
this.onChange(this.getText());
|
|
2904
|
-
}
|
|
2920
|
+
this.#notifyChange();
|
|
2905
2921
|
}
|
|
2906
2922
|
|
|
2907
2923
|
#shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
|
|
@@ -2926,7 +2942,7 @@ export class Editor implements Component, Focusable {
|
|
|
2926
2942
|
this.#scrollOffset = 0;
|
|
2927
2943
|
this.#undoStack.length = 0;
|
|
2928
2944
|
|
|
2929
|
-
|
|
2945
|
+
this.#notifyChange("");
|
|
2930
2946
|
if (this.onSubmit) this.onSubmit(result);
|
|
2931
2947
|
}
|
|
2932
2948
|
|
|
@@ -3029,9 +3045,7 @@ export class Editor implements Component, Focusable {
|
|
|
3029
3045
|
this.#setCursorCol(previousLine.length);
|
|
3030
3046
|
}
|
|
3031
3047
|
|
|
3032
|
-
|
|
3033
|
-
this.onChange(this.getText());
|
|
3034
|
-
}
|
|
3048
|
+
this.#notifyChange();
|
|
3035
3049
|
|
|
3036
3050
|
// Update or re-trigger autocomplete after backspace
|
|
3037
3051
|
if (this.#autocompleteState) {
|
|
@@ -3205,9 +3219,7 @@ export class Editor implements Component, Focusable {
|
|
|
3205
3219
|
this.#preferredVisualCol = null;
|
|
3206
3220
|
Object.assign(this.#state, snapshot);
|
|
3207
3221
|
|
|
3208
|
-
|
|
3209
|
-
this.onChange(this.getText());
|
|
3210
|
-
}
|
|
3222
|
+
this.#notifyChange();
|
|
3211
3223
|
|
|
3212
3224
|
if (this.#autocompleteState) {
|
|
3213
3225
|
this.#debouncedUpdateAutocomplete();
|
|
@@ -3295,9 +3307,7 @@ export class Editor implements Component, Focusable {
|
|
|
3295
3307
|
this.#setCursorCol((lines[lines.length - 1] || "").length);
|
|
3296
3308
|
}
|
|
3297
3309
|
|
|
3298
|
-
|
|
3299
|
-
this.onChange(this.getText());
|
|
3300
|
-
}
|
|
3310
|
+
this.#notifyChange();
|
|
3301
3311
|
this.#retriggerAutocompleteAtCursor();
|
|
3302
3312
|
}
|
|
3303
3313
|
|
|
@@ -3402,9 +3412,7 @@ export class Editor implements Component, Focusable {
|
|
|
3402
3412
|
|
|
3403
3413
|
this.#recordKill(deletedText, "backward");
|
|
3404
3414
|
|
|
3405
|
-
|
|
3406
|
-
this.onChange(this.getText());
|
|
3407
|
-
}
|
|
3415
|
+
this.#notifyChange();
|
|
3408
3416
|
this.#retriggerAutocompleteAtCursor();
|
|
3409
3417
|
}
|
|
3410
3418
|
|
|
@@ -3434,9 +3442,7 @@ export class Editor implements Component, Focusable {
|
|
|
3434
3442
|
|
|
3435
3443
|
this.#recordKill(deletedText, "forward");
|
|
3436
3444
|
|
|
3437
|
-
|
|
3438
|
-
this.onChange(this.getText());
|
|
3439
|
-
}
|
|
3445
|
+
this.#notifyChange();
|
|
3440
3446
|
this.#retriggerAutocompleteAtCursor();
|
|
3441
3447
|
}
|
|
3442
3448
|
|
|
@@ -3469,9 +3475,7 @@ export class Editor implements Component, Focusable {
|
|
|
3469
3475
|
this.#recordKill(deletedText, "backward");
|
|
3470
3476
|
}
|
|
3471
3477
|
|
|
3472
|
-
|
|
3473
|
-
this.onChange(this.getText());
|
|
3474
|
-
}
|
|
3478
|
+
this.#notifyChange();
|
|
3475
3479
|
this.#retriggerAutocompleteAtCursor();
|
|
3476
3480
|
}
|
|
3477
3481
|
|
|
@@ -3501,9 +3505,7 @@ export class Editor implements Component, Focusable {
|
|
|
3501
3505
|
this.#recordKill(deletedText, "forward");
|
|
3502
3506
|
}
|
|
3503
3507
|
|
|
3504
|
-
|
|
3505
|
-
this.onChange(this.getText());
|
|
3506
|
-
}
|
|
3508
|
+
this.#notifyChange();
|
|
3507
3509
|
this.#retriggerAutocompleteAtCursor();
|
|
3508
3510
|
}
|
|
3509
3511
|
|
|
@@ -3541,9 +3543,7 @@ export class Editor implements Component, Focusable {
|
|
|
3541
3543
|
this.#state.lines.splice(this.#state.cursorLine + 1, 1);
|
|
3542
3544
|
}
|
|
3543
3545
|
|
|
3544
|
-
|
|
3545
|
-
this.onChange(this.getText());
|
|
3546
|
-
}
|
|
3546
|
+
this.#notifyChange();
|
|
3547
3547
|
|
|
3548
3548
|
// Update or re-trigger autocomplete after forward delete
|
|
3549
3549
|
if (this.#autocompleteState) {
|
|
@@ -4023,7 +4023,7 @@ export class Editor implements Component, Focusable {
|
|
|
4023
4023
|
this.#lastAction = null;
|
|
4024
4024
|
this.#cancelAutocomplete();
|
|
4025
4025
|
this.onAutocompleteUpdate?.();
|
|
4026
|
-
this
|
|
4026
|
+
this.#notifyChange();
|
|
4027
4027
|
}
|
|
4028
4028
|
async #handleSlashCommandCompletion(): Promise<void> {
|
|
4029
4029
|
await this.#tryTriggerAutocomplete();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { encodeSixel } from "@oh-my-pi/pi-natives";
|
|
2
|
-
import { $env, isBunTestRuntime, isTerminalHeadless, isWsl } from "@oh-my-pi/pi-utils";
|
|
2
|
+
import { $env, isBunTestRuntime, isTerminalHeadless, isWsl } from "@oh-my-pi/pi-utils/env";
|
|
3
3
|
import { sendDesktopNotification, shouldDeliverDesktopNotification } from "./desktop-notify";
|
|
4
4
|
import {
|
|
5
5
|
detectKittyUnicodePlaceholdersSupport,
|
|
@@ -126,30 +126,10 @@ function sendHerdrNotification(message: string | TerminalNotification, env: Node
|
|
|
126
126
|
return true;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
function hasSixelDcsStart(line: string): boolean {
|
|
135
|
-
const limit = Math.min(line.length, 128);
|
|
136
|
-
let from = 0;
|
|
137
|
-
for (;;) {
|
|
138
|
-
const start = line.indexOf("\x1bP", from);
|
|
139
|
-
if (start === -1 || start + 3 > limit) return false;
|
|
140
|
-
let i = start + 2;
|
|
141
|
-
while (i < limit) {
|
|
142
|
-
const code = line.charCodeAt(i);
|
|
143
|
-
if ((code >= 0x30 && code <= 0x39) || code === 0x3b) {
|
|
144
|
-
i++;
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
break;
|
|
148
|
-
}
|
|
149
|
-
if (i < limit && line.charCodeAt(i) === 0x71) return true;
|
|
150
|
-
from = start + 2;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
129
|
+
const IMAGE_MARKER_SCAN_LIMIT = 512;
|
|
130
|
+
const SIXEL_MARKER_SCAN_LIMIT = 128;
|
|
131
|
+
const KITTY_PLACEHOLDER_HIGH_SURROGATE = KITTY_PLACEHOLDER.charCodeAt(0);
|
|
132
|
+
const KITTY_PLACEHOLDER_LOW_SURROGATE = KITTY_PLACEHOLDER.charCodeAt(1);
|
|
153
133
|
|
|
154
134
|
/** Terminal capability details used for rendering and protocol selection. */
|
|
155
135
|
export class TerminalInfo {
|
|
@@ -181,18 +161,56 @@ export class TerminalInfo {
|
|
|
181
161
|
return Object.assign(Object.create(TerminalInfo.prototype), this) as RuntimeTerminal;
|
|
182
162
|
}
|
|
183
163
|
|
|
164
|
+
/**
|
|
165
|
+
* Whether an image marker begins at `start`. Kept as the shared primitive
|
|
166
|
+
* for both standalone image checks and the renderer's combined ANSI/width
|
|
167
|
+
* scan, so their protocol windows and sixel grammar cannot drift.
|
|
168
|
+
*/
|
|
169
|
+
hasImageMarkerAt(line: string, start: number): boolean {
|
|
170
|
+
const protocol = this.imageProtocol;
|
|
171
|
+
if (!protocol) return false;
|
|
172
|
+
if (protocol === ImageProtocol.Sixel) {
|
|
173
|
+
const limit = Math.min(line.length, SIXEL_MARKER_SCAN_LIMIT);
|
|
174
|
+
if (start + 3 > limit || line.charCodeAt(start) !== 0x1b || line.charCodeAt(start + 1) !== 0x50) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
let i = start + 2;
|
|
178
|
+
while (i < limit) {
|
|
179
|
+
const code = line.charCodeAt(i);
|
|
180
|
+
if ((code >= 0x30 && code <= 0x39) || code === 0x3b) {
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
return i < limit && line.charCodeAt(i) === 0x71;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const limit = Math.min(line.length, IMAGE_MARKER_SCAN_LIMIT);
|
|
190
|
+
let protocolMatches = start + protocol.length <= limit;
|
|
191
|
+
for (let offset = 0; protocolMatches && offset < protocol.length; offset++) {
|
|
192
|
+
protocolMatches = line.charCodeAt(start + offset) === protocol.charCodeAt(offset);
|
|
193
|
+
}
|
|
194
|
+
return (
|
|
195
|
+
protocolMatches ||
|
|
196
|
+
(start + 2 <= limit &&
|
|
197
|
+
line.charCodeAt(start) === KITTY_PLACEHOLDER_HIGH_SURROGATE &&
|
|
198
|
+
line.charCodeAt(start + 1) === KITTY_PLACEHOLDER_LOW_SURROGATE)
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
184
202
|
isImageLine(line: string): boolean {
|
|
185
203
|
if (!this.imageProtocol) return false;
|
|
186
|
-
|
|
187
|
-
|
|
204
|
+
const limit = Math.min(
|
|
205
|
+
line.length,
|
|
206
|
+
this.imageProtocol === ImageProtocol.Sixel ? SIXEL_MARKER_SCAN_LIMIT : IMAGE_MARKER_SCAN_LIMIT,
|
|
207
|
+
);
|
|
208
|
+
for (let i = 0; i < limit; i++) {
|
|
209
|
+
const code = line.charCodeAt(i);
|
|
210
|
+
if (code !== 0x1b && code !== KITTY_PLACEHOLDER_HIGH_SURROGATE) continue;
|
|
211
|
+
if (this.hasImageMarkerAt(line, i)) return true;
|
|
188
212
|
}
|
|
189
|
-
|
|
190
|
-
// the composer attachment band prefixes each thumbnail row with border
|
|
191
|
-
// SGRs and stacks cards side by side, so the first placeholder of a
|
|
192
|
-
// later card starts hundreds of units in. Rows past the window would
|
|
193
|
-
// silently lose the verbatim image-line path (no truncation, no SGR
|
|
194
|
-
// coalescing) that placeholder grids and placement APCs rely on.
|
|
195
|
-
return hasNeedleBefore(line, this.imageProtocol, 512) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 512);
|
|
213
|
+
return false;
|
|
196
214
|
}
|
|
197
215
|
|
|
198
216
|
formatNotification(message: string | TerminalNotification): string {
|
package/src/terminal.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { TtyWriter } from "@oh-my-pi/pi-natives";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
isWsl,
|
|
9
|
-
logger,
|
|
10
|
-
postmortem,
|
|
11
|
-
restoreTerminalStderr,
|
|
12
|
-
suppressTerminalStderr,
|
|
13
|
-
} from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { $env, isBunTestRuntime, isTerminalHeadless, isWsl } from "@oh-my-pi/pi-utils/env";
|
|
5
|
+
import * as logger from "@oh-my-pi/pi-utils/logger";
|
|
6
|
+
import * as postmortem from "@oh-my-pi/pi-utils/postmortem";
|
|
7
|
+
import { restoreTerminalStderr, suppressTerminalStderr } from "@oh-my-pi/pi-utils/stderr-guard";
|
|
14
8
|
import { setKittyProtocolActive } from "./keys";
|
|
15
9
|
import { StdinBuffer } from "./stdin-buffer";
|
|
16
10
|
import {
|
package/src/tui.ts
CHANGED
|
@@ -14,10 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import { performance } from "node:perf_hooks";
|
|
17
|
-
import {
|
|
17
|
+
import { getDebugLogPath } from "@oh-my-pi/pi-utils/dirs";
|
|
18
|
+
import { $flag } from "@oh-my-pi/pi-utils/env";
|
|
19
|
+
import * as logger from "@oh-my-pi/pi-utils/logger";
|
|
20
|
+
import * as postmortem from "@oh-my-pi/pi-utils/postmortem";
|
|
18
21
|
import { DEFAULT_MAX_INLINE_IMAGES, ImageBudget } from "./components/image";
|
|
19
22
|
import { TuiDebugServer } from "./debug-server";
|
|
20
23
|
import { isKeyRelease, matchesKey } from "./keys";
|
|
24
|
+
import { KITTY_PLACEHOLDER } from "./kitty-graphics";
|
|
21
25
|
import { LoopWatchdog } from "./loop-watchdog";
|
|
22
26
|
import { STDOUT_BACKLOG_CLEAR_BYTES, setAltScreenActive, type Terminal } from "./terminal";
|
|
23
27
|
import {
|
|
@@ -38,6 +42,7 @@ import {
|
|
|
38
42
|
import {
|
|
39
43
|
Ellipsis,
|
|
40
44
|
extractSegments,
|
|
45
|
+
getWidthConfigEpoch,
|
|
41
46
|
isOsc66Line,
|
|
42
47
|
normalizeTerminalOutput,
|
|
43
48
|
osc66MaxScale,
|
|
@@ -522,7 +527,24 @@ interface HardwareCursorState {
|
|
|
522
527
|
interface PreparedLine {
|
|
523
528
|
raw: string;
|
|
524
529
|
width: number;
|
|
530
|
+
widthEpoch: number;
|
|
531
|
+
imageProtocol: ImageProtocol | null;
|
|
525
532
|
line: string;
|
|
533
|
+
terminalContent: string;
|
|
534
|
+
asciiWidth: number | undefined;
|
|
535
|
+
isImage: boolean;
|
|
536
|
+
hasOsc8: boolean;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
interface PreparedLines {
|
|
540
|
+
lines: string[];
|
|
541
|
+
rows: PreparedLine[];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
interface LineClassification {
|
|
545
|
+
asciiWidth: number | undefined;
|
|
546
|
+
isImage: boolean;
|
|
547
|
+
hasOsc8: boolean;
|
|
526
548
|
}
|
|
527
549
|
|
|
528
550
|
// SGR coalescing. The renderer's component tree emits a styled span as
|
|
@@ -538,10 +560,14 @@ interface PreparedLine {
|
|
|
538
560
|
// must process. On by default; `PI_NO_SGR_COALESCE=1` disables it.
|
|
539
561
|
const SGR_COALESCE_ENABLED = !$flag("PI_NO_SGR_COALESCE");
|
|
540
562
|
const CC_ESC = 0x1b;
|
|
563
|
+
const CC_KITTY_PLACEHOLDER_HIGH = KITTY_PLACEHOLDER.charCodeAt(0);
|
|
541
564
|
const CC_BRACKET = 0x5b; // [
|
|
542
565
|
const CC_M = 0x6d; // m
|
|
543
566
|
const CC_SEMI = 0x3b; // ;
|
|
544
567
|
const CC_COLON = 0x3a; // :
|
|
568
|
+
const ANSI_TEXT = 0;
|
|
569
|
+
const ANSI_CSI = 1;
|
|
570
|
+
const ANSI_OSC = 2;
|
|
545
571
|
// Max parameter tokens per emitted merged SGR. Kept well under xterm.js's
|
|
546
572
|
// 32-param cap (and the tighter limits of some real terminals) so a long
|
|
547
573
|
// adjacent run is split into several valid CSIs instead of overflowing one.
|
|
@@ -726,7 +752,11 @@ export class TUI extends Container {
|
|
|
726
752
|
#cprColumnTags = new Map<number, number>();
|
|
727
753
|
#cprProbeSeq = 0;
|
|
728
754
|
// Prepared rows painted by the previous provider frame, for row diffing.
|
|
755
|
+
// The structured sidecar owns classification/coalescing results for reuse;
|
|
756
|
+
// #providerWindow remains the exact normalized string projection used by
|
|
757
|
+
// the established differential comparison and resize accounting.
|
|
729
758
|
#providerWindow: string[] = [];
|
|
759
|
+
#providerPreparedRows: PreparedLine[] = [];
|
|
730
760
|
#previousFrameLength = 0;
|
|
731
761
|
#previousWidth = 0;
|
|
732
762
|
#previousHeight = 0;
|
|
@@ -839,6 +869,7 @@ export class TUI extends Container {
|
|
|
839
869
|
/** Product-owned probe for opt-in normal-buffer click capture (`tui.mouse`). Read every frame. */
|
|
840
870
|
#inlineMouseProvider: (() => boolean) | undefined;
|
|
841
871
|
#altPreviousLines: string[] = [];
|
|
872
|
+
#altPreparedRows: PreparedLine[] = [];
|
|
842
873
|
#altEnterWidth = 0;
|
|
843
874
|
#altEnterHeight = 0;
|
|
844
875
|
#resizeAltActive = false;
|
|
@@ -886,6 +917,7 @@ export class TUI extends Container {
|
|
|
886
917
|
setFrameProvider(provider: TerminalFrameProvider | undefined): void {
|
|
887
918
|
this.#frameProvider = provider;
|
|
888
919
|
this.#providerWindow = [];
|
|
920
|
+
this.#providerPreparedRows = [];
|
|
889
921
|
this.#resizeReplaySize = undefined;
|
|
890
922
|
this.requestRender(true);
|
|
891
923
|
}
|
|
@@ -1415,6 +1447,7 @@ export class TUI extends Container {
|
|
|
1415
1447
|
this.#resizeAltActive = true;
|
|
1416
1448
|
setAltScreenActive(true);
|
|
1417
1449
|
this.#altPreviousLines = [];
|
|
1450
|
+
this.#altPreparedRows = [];
|
|
1418
1451
|
this.#forgetHardwareCursorState();
|
|
1419
1452
|
this.#recordHardwareCursorHidden();
|
|
1420
1453
|
// Blank the live region up front so a reflow-driven scroll can only push
|
|
@@ -1435,6 +1468,7 @@ export class TUI extends Container {
|
|
|
1435
1468
|
// viewport top and overwrite visible committed rows.
|
|
1436
1469
|
this.#resizeProbeOffset = 0;
|
|
1437
1470
|
this.#providerWindow = [];
|
|
1471
|
+
this.#providerPreparedRows = [];
|
|
1438
1472
|
this.#parkedViewportOffset = 0;
|
|
1439
1473
|
}
|
|
1440
1474
|
if (this.#hasEverRendered && this.#providerWindow.length > 0 && isInsideTerminalMultiplexer()) {
|
|
@@ -1446,6 +1480,7 @@ export class TUI extends Container {
|
|
|
1446
1480
|
// settled repaint overwrites the live region at the clip-model anchor
|
|
1447
1481
|
// and erases below it, race-free after the quiet window.
|
|
1448
1482
|
this.#providerWindow = [];
|
|
1483
|
+
this.#providerPreparedRows = [];
|
|
1449
1484
|
this.#parkedViewportOffset = 0;
|
|
1450
1485
|
}
|
|
1451
1486
|
this.#noteAltBufferToggle();
|
|
@@ -1461,6 +1496,7 @@ export class TUI extends Container {
|
|
|
1461
1496
|
this.terminal.write(`${this.#keyboardEnhancementExit()}\x1b[?1049l`);
|
|
1462
1497
|
setAltScreenActive(false);
|
|
1463
1498
|
this.#altPreviousLines = [];
|
|
1499
|
+
this.#altPreparedRows = [];
|
|
1464
1500
|
this.#beginResizeAnchorProbe();
|
|
1465
1501
|
}, TUI.#RESIZE_VIEWPORT_SETTLE_MS);
|
|
1466
1502
|
this.requestRender(true);
|
|
@@ -1658,7 +1694,7 @@ export class TUI extends Container {
|
|
|
1658
1694
|
} while (this.#imageBudget.endPass());
|
|
1659
1695
|
const viewport = rendered.length > height ? rendered.slice(rendered.length - height) : Array.from(rendered);
|
|
1660
1696
|
this.#extractCursorMarkers(viewport);
|
|
1661
|
-
this.#emitAltFrame(this.#prepareLinesArray(viewport, width), width, height);
|
|
1697
|
+
this.#emitAltFrame(this.#prepareLinesArray(viewport, width, this.#altPreparedRows, height), width, height);
|
|
1662
1698
|
}
|
|
1663
1699
|
|
|
1664
1700
|
/**
|
|
@@ -1883,6 +1919,7 @@ export class TUI extends Container {
|
|
|
1883
1919
|
this.#altActive = false;
|
|
1884
1920
|
this.#mouseTracking = "off";
|
|
1885
1921
|
this.#altPreviousLines = [];
|
|
1922
|
+
this.#altPreparedRows = [];
|
|
1886
1923
|
this.#pendingAltExit = "";
|
|
1887
1924
|
} else if (this.#mouseTracking !== "off") {
|
|
1888
1925
|
// Inline capture with no overlay: still owned by us at quit, so
|
|
@@ -2509,10 +2546,8 @@ export class TUI extends Container {
|
|
|
2509
2546
|
});
|
|
2510
2547
|
}
|
|
2511
2548
|
|
|
2512
|
-
#terminalLine(line:
|
|
2513
|
-
|
|
2514
|
-
const coalesced = coalesceAdjacentSgr(line);
|
|
2515
|
-
return coalesced + (line.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2549
|
+
#terminalLine(line: PreparedLine): string {
|
|
2550
|
+
return line.terminalContent + (line.hasOsc8 ? LINE_TERMINATOR : SEGMENT_RESET);
|
|
2516
2551
|
}
|
|
2517
2552
|
#renderProviderFrame(width: number, height: number): void {
|
|
2518
2553
|
const provider = this.#frameProvider;
|
|
@@ -2659,9 +2694,9 @@ export class TUI extends Container {
|
|
|
2659
2694
|
}
|
|
2660
2695
|
}
|
|
2661
2696
|
const markers = this.#extractCursorMarkers(viewport);
|
|
2662
|
-
const prepared = this.#prepareLinesArray(viewport, width);
|
|
2697
|
+
const prepared = this.#prepareLinesArray(viewport, width, this.#providerPreparedRows);
|
|
2663
2698
|
const preparedHistory = this.#prepareLinesArray(historyRows, width);
|
|
2664
|
-
const rows = prepared.length;
|
|
2699
|
+
const rows = prepared.lines.length;
|
|
2665
2700
|
// Destructive reset (session replace, /tree, explicit clear, or a settled
|
|
2666
2701
|
// resize in rebuild mode): erase native history and the viewport,
|
|
2667
2702
|
// then repaint from row zero.
|
|
@@ -2669,6 +2704,7 @@ export class TUI extends Container {
|
|
|
2669
2704
|
if (destructiveReset) {
|
|
2670
2705
|
this.#providerViewportTop = 0;
|
|
2671
2706
|
this.#providerWindow = [];
|
|
2707
|
+
this.#providerPreparedRows = [];
|
|
2672
2708
|
}
|
|
2673
2709
|
// The viewport stays anchored directly below whatever history remains on
|
|
2674
2710
|
// screen. Appending K history rows moves the anchor down by K; the write
|
|
@@ -2709,14 +2745,23 @@ export class TUI extends Container {
|
|
|
2709
2745
|
this.#providerWindow.length > 0;
|
|
2710
2746
|
if (diffable) {
|
|
2711
2747
|
for (let index = 0; index < rows; index++) {
|
|
2712
|
-
|
|
2748
|
+
const previous = this.#providerPreparedRows[index];
|
|
2749
|
+
const current = prepared.rows[index]!;
|
|
2750
|
+
if (
|
|
2751
|
+
this.#providerWindow[index] === prepared.lines[index] &&
|
|
2752
|
+
previous !== undefined &&
|
|
2753
|
+
previous.widthEpoch === current.widthEpoch &&
|
|
2754
|
+
previous.imageProtocol === current.imageProtocol
|
|
2755
|
+
) {
|
|
2756
|
+
continue;
|
|
2757
|
+
}
|
|
2713
2758
|
buffer += `\x1b[${newTop + index + 1};1H${this.#lineRewriteSequence(
|
|
2714
|
-
|
|
2759
|
+
current,
|
|
2715
2760
|
width,
|
|
2716
2761
|
newTop + index,
|
|
2717
2762
|
-1,
|
|
2718
2763
|
-1,
|
|
2719
|
-
this.#osc66SpacerGlyphWidth(prepared, index),
|
|
2764
|
+
this.#osc66SpacerGlyphWidth(prepared.lines, index),
|
|
2720
2765
|
)}`;
|
|
2721
2766
|
}
|
|
2722
2767
|
if (this.#providerWindow.length > rows && newTop + rows < height) {
|
|
@@ -2728,40 +2773,41 @@ export class TUI extends Container {
|
|
|
2728
2773
|
// old viewport are committed history (correct to push), but old live
|
|
2729
2774
|
// viewport rows are not — erase them first so a scroll can only push
|
|
2730
2775
|
// committed rows and blanks, never an unfinished frame.
|
|
2731
|
-
const pushed = Math.max(0, startTop + preparedHistory.length + rows - height);
|
|
2776
|
+
const pushed = Math.max(0, startTop + preparedHistory.lines.length + rows - height);
|
|
2732
2777
|
if (pushed > this.#providerViewportTop && this.#providerWindow.length > 0) {
|
|
2733
2778
|
buffer += this.#eraseBelowRow(this.#providerViewportTop, height);
|
|
2734
2779
|
}
|
|
2735
2780
|
buffer += `\x1b[${startTop + 1};1H`;
|
|
2736
2781
|
let screenRow = startTop;
|
|
2737
|
-
for (let index = 0; index < preparedHistory.length; index++) {
|
|
2782
|
+
for (let index = 0; index < preparedHistory.lines.length; index++) {
|
|
2738
2783
|
if (screenRow > startTop) buffer += "\r\n";
|
|
2739
2784
|
buffer += this.#lineRewriteSequence(
|
|
2740
|
-
preparedHistory[index]
|
|
2785
|
+
preparedHistory.rows[index]!,
|
|
2741
2786
|
width,
|
|
2742
2787
|
Math.min(screenRow, height - 1),
|
|
2743
2788
|
-1,
|
|
2744
2789
|
-1,
|
|
2745
|
-
this.#osc66SpacerGlyphWidth(preparedHistory, index),
|
|
2790
|
+
this.#osc66SpacerGlyphWidth(preparedHistory.lines, index),
|
|
2746
2791
|
);
|
|
2747
2792
|
screenRow++;
|
|
2748
2793
|
}
|
|
2749
2794
|
for (let index = 0; index < rows; index++) {
|
|
2750
2795
|
if (screenRow > startTop) buffer += "\r\n";
|
|
2751
2796
|
buffer += this.#lineRewriteSequence(
|
|
2752
|
-
prepared[index]
|
|
2797
|
+
prepared.rows[index]!,
|
|
2753
2798
|
width,
|
|
2754
2799
|
Math.min(screenRow, height - 1),
|
|
2755
2800
|
-1,
|
|
2756
2801
|
-1,
|
|
2757
|
-
this.#osc66SpacerGlyphWidth(prepared, index),
|
|
2802
|
+
this.#osc66SpacerGlyphWidth(prepared.lines, index),
|
|
2758
2803
|
);
|
|
2759
2804
|
screenRow++;
|
|
2760
2805
|
}
|
|
2761
2806
|
if (newTop + rows < height) buffer += `\x1b[${newTop + rows + 1};1H\x1b[J`;
|
|
2762
2807
|
}
|
|
2763
2808
|
const mutableTop = newTop + replayViewportRows;
|
|
2764
|
-
const
|
|
2809
|
+
const mutablePreparedLines = replayViewportRows > 0 ? prepared.lines.slice(replayViewportRows) : prepared.lines;
|
|
2810
|
+
const mutablePreparedRows = replayViewportRows > 0 ? prepared.rows.slice(replayViewportRows) : prepared.rows;
|
|
2765
2811
|
const marker = markers[0];
|
|
2766
2812
|
const target =
|
|
2767
2813
|
marker !== undefined && rows > 0
|
|
@@ -2780,7 +2826,7 @@ export class TUI extends Container {
|
|
|
2780
2826
|
buffer += this.#paintEndSequence;
|
|
2781
2827
|
this.terminal.write(buffer);
|
|
2782
2828
|
this.#debugPaint = {
|
|
2783
|
-
lines: prepared,
|
|
2829
|
+
lines: prepared.lines,
|
|
2784
2830
|
windowTop: this.#debugNextWindowTop,
|
|
2785
2831
|
altScreen: false,
|
|
2786
2832
|
...(target === null ? {} : { cursor: { x: target.col, y: target.row, visible: target.visible } }),
|
|
@@ -2792,7 +2838,8 @@ export class TUI extends Container {
|
|
|
2792
2838
|
}
|
|
2793
2839
|
if (target) this.#recordHardwareCursorState(target);
|
|
2794
2840
|
else this.#recordHardwareCursorHidden();
|
|
2795
|
-
this.#providerWindow =
|
|
2841
|
+
this.#providerWindow = mutablePreparedLines;
|
|
2842
|
+
this.#providerPreparedRows = mutablePreparedRows;
|
|
2796
2843
|
this.#providerViewportTop = mutableTop;
|
|
2797
2844
|
this.#providerViewportPadTop = replayViewportRows - replayPrependedBlanks;
|
|
2798
2845
|
this.#previousWidth = width;
|
|
@@ -2800,7 +2847,7 @@ export class TUI extends Container {
|
|
|
2800
2847
|
this.#resizeBurstGrew = false;
|
|
2801
2848
|
this.#resizeBurstLastHeight = undefined;
|
|
2802
2849
|
this.#resizeBurstPull = 0;
|
|
2803
|
-
this.#previousFrameLength =
|
|
2850
|
+
this.#previousFrameLength = mutablePreparedLines.length;
|
|
2804
2851
|
this.#clearScrollbackOnNextRender = false;
|
|
2805
2852
|
this.#forceViewportRepaintOnNextRender = false;
|
|
2806
2853
|
this.#hasEverRendered = true;
|
|
@@ -2864,6 +2911,7 @@ export class TUI extends Container {
|
|
|
2864
2911
|
this.#recordHardwareCursorHidden();
|
|
2865
2912
|
this.#altActive = true;
|
|
2866
2913
|
this.#altPreviousLines = [];
|
|
2914
|
+
this.#altPreparedRows = [];
|
|
2867
2915
|
this.#altEnterWidth = width;
|
|
2868
2916
|
this.#altEnterHeight = height;
|
|
2869
2917
|
} else if (!wantAlt && this.#altActive) {
|
|
@@ -2891,6 +2939,7 @@ export class TUI extends Container {
|
|
|
2891
2939
|
this.#altActive = false;
|
|
2892
2940
|
this.#mouseTracking = wantMouse;
|
|
2893
2941
|
this.#altPreviousLines = [];
|
|
2942
|
+
this.#altPreparedRows = [];
|
|
2894
2943
|
// The alt-buffer restore put the pre-overlay normal screen back. If
|
|
2895
2944
|
// that buffer resized while covered, its cursor moved with width
|
|
2896
2945
|
// rewrap or a height-grow scrollback pull, while our viewport anchor
|
|
@@ -2935,38 +2984,115 @@ export class TUI extends Container {
|
|
|
2935
2984
|
this.#emitPlanFrame(width, height, viewport, undefined, undefined);
|
|
2936
2985
|
}
|
|
2937
2986
|
|
|
2938
|
-
/**
|
|
2939
|
-
|
|
2987
|
+
/**
|
|
2988
|
+
* Prepare one string projection plus its structured write sidecar. A prior
|
|
2989
|
+
* sidecar entry is reusable only under identical raw content, width, width
|
|
2990
|
+
* configuration, and image protocol; those are every mutable input to
|
|
2991
|
+
* normalization, fitting, classification, and terminal coalescing.
|
|
2992
|
+
*/
|
|
2993
|
+
#prepareLinesArray(
|
|
2994
|
+
lines: readonly string[],
|
|
2995
|
+
width: number,
|
|
2996
|
+
previous: readonly PreparedLine[] = [],
|
|
2997
|
+
length = lines.length,
|
|
2998
|
+
): PreparedLines {
|
|
2940
2999
|
// oxlint-disable-next-line unicorn/no-new-array -- render-frame length preallocation
|
|
2941
|
-
const prepared: string[] = new Array(
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
3000
|
+
const prepared: string[] = new Array(length);
|
|
3001
|
+
// oxlint-disable-next-line unicorn/no-new-array -- render-frame sidecar preallocation
|
|
3002
|
+
const rows: PreparedLine[] = new Array(length);
|
|
3003
|
+
const widthEpoch = getWidthConfigEpoch();
|
|
3004
|
+
const imageProtocol = TERMINAL.imageProtocol;
|
|
3005
|
+
for (let i = 0; i < length; i++) {
|
|
3006
|
+
const raw = lines[i] ?? "";
|
|
3007
|
+
const cached = previous[i];
|
|
3008
|
+
const row =
|
|
3009
|
+
cached !== undefined &&
|
|
3010
|
+
cached.raw === raw &&
|
|
3011
|
+
cached.width === width &&
|
|
3012
|
+
cached.widthEpoch === widthEpoch &&
|
|
3013
|
+
cached.imageProtocol === imageProtocol
|
|
3014
|
+
? cached
|
|
3015
|
+
: this.#prepareLine(raw, width, widthEpoch, imageProtocol);
|
|
3016
|
+
prepared[i] = row.line;
|
|
3017
|
+
rows[i] = row;
|
|
3018
|
+
}
|
|
3019
|
+
return { lines: prepared, rows };
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
#prepareLine(raw: string, width: number, widthEpoch: number, imageProtocol: ImageProtocol | null): PreparedLine {
|
|
2963
3023
|
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
|
2964
3024
|
const maxSourceLength = Math.min(
|
|
2965
3025
|
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
|
2966
3026
|
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
|
2967
3027
|
);
|
|
2968
|
-
if (raw.length <= maxSourceLength) return raw;
|
|
2969
3028
|
|
|
3029
|
+
let source: string;
|
|
3030
|
+
let classification: LineClassification;
|
|
3031
|
+
if (raw.length <= maxSourceLength) {
|
|
3032
|
+
// The overwhelmingly common path classifies image markers, OSC 8,
|
|
3033
|
+
// ANSI validity, OSC 66, non-ASCII, and exact ASCII width together.
|
|
3034
|
+
classification = this.#classifyLine(raw, safeWidth);
|
|
3035
|
+
if (classification.isImage) {
|
|
3036
|
+
return {
|
|
3037
|
+
raw,
|
|
3038
|
+
width,
|
|
3039
|
+
widthEpoch,
|
|
3040
|
+
imageProtocol,
|
|
3041
|
+
line: raw,
|
|
3042
|
+
terminalContent: raw,
|
|
3043
|
+
asciiWidth: undefined,
|
|
3044
|
+
isImage: true,
|
|
3045
|
+
hasOsc8: false,
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
source = raw;
|
|
3049
|
+
} else {
|
|
3050
|
+
// Fitting a giant source can discard everything after the visible
|
|
3051
|
+
// prefix. Preserve image rows verbatim first, but do not scan the
|
|
3052
|
+
// entire source for width/OSC metadata that will immediately vanish.
|
|
3053
|
+
if (TERMINAL.isImageLine(raw)) {
|
|
3054
|
+
return {
|
|
3055
|
+
raw,
|
|
3056
|
+
width,
|
|
3057
|
+
widthEpoch,
|
|
3058
|
+
imageProtocol,
|
|
3059
|
+
line: raw,
|
|
3060
|
+
terminalContent: raw,
|
|
3061
|
+
asciiWidth: undefined,
|
|
3062
|
+
isImage: true,
|
|
3063
|
+
hasOsc8: false,
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
source = this.#lineFitSource(raw, safeWidth, maxSourceLength);
|
|
3067
|
+
// Preserve the former lineRewriteSequence classification: fitting can
|
|
3068
|
+
// move a marker from outside the raw scan window into the prepared
|
|
3069
|
+
// line's window, at which point terminal image dispatch owns it.
|
|
3070
|
+
classification = this.#classifyLine(source, safeWidth);
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
const normalized = normalizeTerminalOutput(source);
|
|
3074
|
+
// Normalization only decomposes Thai/Lao AM vowels. It cannot introduce
|
|
3075
|
+
// or remove ANSI/image markers, and such a row was already non-ASCII, so
|
|
3076
|
+
// the source classification remains exact when normalization allocates.
|
|
3077
|
+
let line = normalized;
|
|
3078
|
+
if ((classification.asciiWidth ?? visibleWidth(normalized)) > width) {
|
|
3079
|
+
line = truncateToWidth(normalized, width, Ellipsis.Omit);
|
|
3080
|
+
classification = this.#classifyLine(line, safeWidth);
|
|
3081
|
+
}
|
|
3082
|
+
return {
|
|
3083
|
+
raw,
|
|
3084
|
+
width,
|
|
3085
|
+
widthEpoch,
|
|
3086
|
+
imageProtocol,
|
|
3087
|
+
line,
|
|
3088
|
+
terminalContent: classification.isImage ? line : coalesceAdjacentSgr(line),
|
|
3089
|
+
asciiWidth: classification.asciiWidth,
|
|
3090
|
+
isImage: classification.isImage,
|
|
3091
|
+
hasOsc8: classification.isImage ? false : classification.hasOsc8,
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
#lineFitSource(raw: string, safeWidth: number, maxSourceLength: number): string {
|
|
2970
3096
|
let output = "";
|
|
2971
3097
|
let cells = 0;
|
|
2972
3098
|
for (let i = 0; i < raw.length && cells < safeWidth;) {
|
|
@@ -3063,57 +3189,78 @@ export class TUI extends Container {
|
|
|
3063
3189
|
);
|
|
3064
3190
|
}
|
|
3065
3191
|
|
|
3066
|
-
|
|
3192
|
+
/**
|
|
3193
|
+
* One code-unit pass classifies every per-row write decision. Image markers
|
|
3194
|
+
* are checked before ANSI state consumes their bytes, preserving the legacy
|
|
3195
|
+
* "marker anywhere in the protocol window" behavior even inside malformed
|
|
3196
|
+
* control strings. Width saturates once it is known to exceed the viewport,
|
|
3197
|
+
* while the pass continues for image and OSC 8 markers.
|
|
3198
|
+
*/
|
|
3199
|
+
#classifyLine(line: string, maxWidth: number): LineClassification {
|
|
3067
3200
|
let col = 0;
|
|
3068
|
-
|
|
3201
|
+
let ascii = true;
|
|
3202
|
+
let hasOsc8 = false;
|
|
3203
|
+
let state: typeof ANSI_TEXT | typeof ANSI_CSI | typeof ANSI_OSC = ANSI_TEXT;
|
|
3204
|
+
|
|
3205
|
+
for (let i = 0; i < line.length; i++) {
|
|
3069
3206
|
const code = line.charCodeAt(i);
|
|
3207
|
+
if ((code === CC_ESC || code === CC_KITTY_PLACEHOLDER_HIGH) && TERMINAL.hasImageMarkerAt(line, i)) {
|
|
3208
|
+
return { asciiWidth: undefined, isImage: true, hasOsc8 };
|
|
3209
|
+
}
|
|
3210
|
+
if (
|
|
3211
|
+
code === 0x1b &&
|
|
3212
|
+
line.charCodeAt(i + 1) === 0x5d &&
|
|
3213
|
+
line.charCodeAt(i + 2) === 0x38 &&
|
|
3214
|
+
line.charCodeAt(i + 3) === 0x3b
|
|
3215
|
+
) {
|
|
3216
|
+
hasOsc8 = true;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
if (state === ANSI_CSI) {
|
|
3220
|
+
if (code >= 0x40 && code <= 0x7e) state = ANSI_TEXT;
|
|
3221
|
+
continue;
|
|
3222
|
+
}
|
|
3223
|
+
if (state === ANSI_OSC) {
|
|
3224
|
+
if (code === 0x07) {
|
|
3225
|
+
state = ANSI_TEXT;
|
|
3226
|
+
} else if (code === 0x1b && line.charCodeAt(i + 1) === 0x5c) {
|
|
3227
|
+
state = ANSI_TEXT;
|
|
3228
|
+
i++;
|
|
3229
|
+
}
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3070
3232
|
if (code === 0x1b) {
|
|
3071
3233
|
const next = line.charCodeAt(i + 1);
|
|
3072
3234
|
if (next === 0x5b) {
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
const final = line.charCodeAt(j);
|
|
3076
|
-
if (final >= 0x40 && final <= 0x7e) break;
|
|
3077
|
-
j++;
|
|
3078
|
-
}
|
|
3079
|
-
if (j >= line.length) return undefined;
|
|
3080
|
-
i = j + 1;
|
|
3235
|
+
state = ANSI_CSI;
|
|
3236
|
+
i++;
|
|
3081
3237
|
continue;
|
|
3082
3238
|
}
|
|
3083
3239
|
if (next === 0x5d) {
|
|
3084
|
-
// OSC 66 text-sizing spans carry visible payload inside the
|
|
3085
|
-
//
|
|
3240
|
+
// OSC 66 text-sizing spans carry visible payload inside the
|
|
3241
|
+
// control string. Defer to visibleWidth() for their scaled cells.
|
|
3086
3242
|
if (
|
|
3087
3243
|
line.charCodeAt(i + 2) === 0x36 &&
|
|
3088
3244
|
line.charCodeAt(i + 3) === 0x36 &&
|
|
3089
3245
|
line.charCodeAt(i + 4) === 0x3b
|
|
3090
3246
|
) {
|
|
3091
|
-
|
|
3247
|
+
ascii = false;
|
|
3092
3248
|
}
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
const osc = line.charCodeAt(j);
|
|
3096
|
-
if (osc === 0x07) {
|
|
3097
|
-
i = j + 1;
|
|
3098
|
-
break;
|
|
3099
|
-
}
|
|
3100
|
-
if (osc === 0x1b && line.charCodeAt(j + 1) === 0x5c) {
|
|
3101
|
-
i = j + 2;
|
|
3102
|
-
break;
|
|
3103
|
-
}
|
|
3104
|
-
j++;
|
|
3105
|
-
}
|
|
3106
|
-
if (j >= line.length) return undefined;
|
|
3249
|
+
state = ANSI_OSC;
|
|
3250
|
+
i++;
|
|
3107
3251
|
continue;
|
|
3108
3252
|
}
|
|
3109
|
-
|
|
3253
|
+
ascii = false;
|
|
3254
|
+
continue;
|
|
3110
3255
|
}
|
|
3111
|
-
if (code < 0x20 || code > 0x7e)
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3256
|
+
if (code < 0x20 || code > 0x7e) {
|
|
3257
|
+
ascii = false;
|
|
3258
|
+
continue;
|
|
3259
|
+
}
|
|
3260
|
+
if (ascii && col <= maxWidth) col++;
|
|
3115
3261
|
}
|
|
3116
|
-
|
|
3262
|
+
if (state !== ANSI_TEXT) ascii = false;
|
|
3263
|
+
return { asciiWidth: ascii ? col : undefined, isImage: false, hasOsc8 };
|
|
3117
3264
|
}
|
|
3118
3265
|
|
|
3119
3266
|
/**
|
|
@@ -3137,7 +3284,7 @@ export class TUI extends Container {
|
|
|
3137
3284
|
}
|
|
3138
3285
|
|
|
3139
3286
|
#lineRewriteSequence(
|
|
3140
|
-
line:
|
|
3287
|
+
line: PreparedLine,
|
|
3141
3288
|
width: number,
|
|
3142
3289
|
screenRow = -1,
|
|
3143
3290
|
frameRow = -1,
|
|
@@ -3154,15 +3301,14 @@ export class TUI extends Container {
|
|
|
3154
3301
|
if (spacerGlyphWidth >= width) return "";
|
|
3155
3302
|
return `${SEGMENT_RESET}\x1b[${spacerGlyphWidth}C${ERASE_TO_END_OF_LINE}`;
|
|
3156
3303
|
}
|
|
3157
|
-
if (
|
|
3158
|
-
return ERASE_LINE + this.#imageLineSequence(line, screenRow, frameRow, committedTo);
|
|
3304
|
+
if (line.isImage) {
|
|
3305
|
+
return ERASE_LINE + this.#imageLineSequence(line.line, screenRow, frameRow, committedTo);
|
|
3159
3306
|
}
|
|
3160
3307
|
const terminalLine = this.#terminalLine(line);
|
|
3161
|
-
|
|
3162
|
-
if (asciiWidth !== undefined) {
|
|
3308
|
+
if (line.asciiWidth !== undefined) {
|
|
3163
3309
|
// Exact width model: skip the erase only when the row truly fills
|
|
3164
3310
|
// the line (an EL there would eat the last cell via pending-wrap).
|
|
3165
|
-
return asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
|
|
3311
|
+
return line.asciiWidth >= width ? terminalLine : terminalLine + ERASE_TO_END_OF_LINE;
|
|
3166
3312
|
}
|
|
3167
3313
|
// Non-ASCII rows: the native measure can over-count combining-heavy
|
|
3168
3314
|
// scripts, so a row it calls "full" may render short and leave stale
|
|
@@ -3228,10 +3374,10 @@ export class TUI extends Container {
|
|
|
3228
3374
|
#renderAltFrame(width: number, height: number): void {
|
|
3229
3375
|
// oxlint-disable-next-line unicorn/no-new-array -- alt-frame length preallocation
|
|
3230
3376
|
const base: string[] = new Array(Math.max(0, height)).fill("");
|
|
3231
|
-
|
|
3377
|
+
const lines = this.#compositeOverlaysIntoWindow(base, width, height);
|
|
3232
3378
|
this.#extractCursorMarkers(lines);
|
|
3233
|
-
|
|
3234
|
-
this.#emitAltFrame(
|
|
3379
|
+
const prepared = this.#prepareLinesArray(lines, width, this.#altPreparedRows, height);
|
|
3380
|
+
this.#emitAltFrame(prepared, width, height);
|
|
3235
3381
|
}
|
|
3236
3382
|
|
|
3237
3383
|
/**
|
|
@@ -3239,10 +3385,7 @@ export class TUI extends Container {
|
|
|
3239
3385
|
* brackets, a cursor home, and per-row rewrites — never ED3 or any
|
|
3240
3386
|
* native-scrollback byte. The hardware cursor stays hidden here.
|
|
3241
3387
|
*/
|
|
3242
|
-
#emitAltFrame(
|
|
3243
|
-
// oxlint-disable-next-line unicorn/no-new-array -- alt-frame length preallocation
|
|
3244
|
-
const fitted: string[] = new Array(height);
|
|
3245
|
-
for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
|
|
3388
|
+
#emitAltFrame(prepared: PreparedLines, width: number, height: number): void {
|
|
3246
3389
|
// Flush queued image-data transmits (`a=t`, no visible output) before the
|
|
3247
3390
|
// paint so id-keyed placements and placeholder cells composed into this
|
|
3248
3391
|
// frame resolve against loaded data. The normal-screen path flushes these
|
|
@@ -3264,22 +3407,42 @@ export class TUI extends Container {
|
|
|
3264
3407
|
if (!force && this.#altPreviousLines.length === height) {
|
|
3265
3408
|
let same = true;
|
|
3266
3409
|
for (let r = 0; r < height; r++) {
|
|
3267
|
-
|
|
3410
|
+
const previous = this.#altPreparedRows[r];
|
|
3411
|
+
const current = prepared.rows[r]!;
|
|
3412
|
+
if (
|
|
3413
|
+
prepared.lines[r] !== this.#altPreviousLines[r] ||
|
|
3414
|
+
previous === undefined ||
|
|
3415
|
+
previous.width !== current.width ||
|
|
3416
|
+
previous.widthEpoch !== current.widthEpoch ||
|
|
3417
|
+
previous.imageProtocol !== current.imageProtocol
|
|
3418
|
+
) {
|
|
3268
3419
|
same = false;
|
|
3269
3420
|
break;
|
|
3270
3421
|
}
|
|
3271
3422
|
}
|
|
3272
|
-
if (same)
|
|
3423
|
+
if (same) {
|
|
3424
|
+
this.#altPreviousLines = prepared.lines;
|
|
3425
|
+
this.#altPreparedRows = prepared.rows;
|
|
3426
|
+
return;
|
|
3427
|
+
}
|
|
3273
3428
|
}
|
|
3274
3429
|
let buffer = `${this.#paintBeginSequence}\x1b[H`;
|
|
3275
3430
|
for (let r = 0; r < height; r++) {
|
|
3276
3431
|
if (r > 0) buffer += "\r\n";
|
|
3277
|
-
buffer += this.#lineRewriteSequence(
|
|
3432
|
+
buffer += this.#lineRewriteSequence(
|
|
3433
|
+
prepared.rows[r]!,
|
|
3434
|
+
width,
|
|
3435
|
+
r,
|
|
3436
|
+
-1,
|
|
3437
|
+
-1,
|
|
3438
|
+
this.#osc66SpacerGlyphWidth(prepared.lines, r),
|
|
3439
|
+
);
|
|
3278
3440
|
}
|
|
3279
3441
|
buffer += this.#paintEndSequence;
|
|
3280
3442
|
this.terminal.write(buffer);
|
|
3281
|
-
this.#altPreviousLines =
|
|
3282
|
-
this.#
|
|
3443
|
+
this.#altPreviousLines = prepared.lines;
|
|
3444
|
+
this.#altPreparedRows = prepared.rows;
|
|
3445
|
+
this.#debugPaint = { lines: prepared.lines, windowTop: 0, altScreen: true };
|
|
3283
3446
|
this.#fullRedrawCount += 1;
|
|
3284
3447
|
}
|
|
3285
3448
|
}
|