@oh-my-pi/pi-tui 18.1.22 → 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 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
@@ -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 (audiopus/audiopus_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.
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.3.1 (https://github.com/SpaceManiac/opus-rs)
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.1.22",
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.1.22",
41
- "@oh-my-pi/pi-utils": "18.1.22"
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"
@@ -1,4 +1,5 @@
1
- import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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.onChange?.(this.getText());
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
- if (this.onChange) {
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
- if (this.onChange) {
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 && this.onChange) this.onChange(this.getText());
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
- if (this.onChange) this.onChange(this.getText());
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 if (this.onChange) this.onChange(this.getText());
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.onChange?.(this.getText());
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) this.onChange("");
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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
- if (this.onChange) {
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.onChange?.(this.getText());
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
- function hasNeedleBefore(line: string, needle: string, limit: number): boolean {
130
- const index = line.indexOf(needle);
131
- return index !== -1 && index + needle.length <= limit;
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
- if (this.imageProtocol === ImageProtocol.Sixel) {
187
- return hasSixelDcsStart(line);
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
- // 512-unit window: placeholder cells can sit deep in a composed row —
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
- $env,
6
- isBunTestRuntime,
7
- isTerminalHeadless,
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 { $flag, getDebugLogPath, logger, postmortem } from "@oh-my-pi/pi-utils";
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: string, screenRow = -1, frameRow = -1, committedTo = -1): string {
2513
- if (TERMINAL.isImageLine(line)) return this.#imageLineSequence(line, screenRow, frameRow, committedTo);
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
- if (this.#providerWindow[index] === prepared[index]) continue;
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
- prepared[index] ?? "",
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 mutablePrepared = replayViewportRows > 0 ? prepared.slice(replayViewportRows) : prepared;
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 = mutablePrepared;
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 = mutablePrepared.length;
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
- /** Stateless variant for overlay-composited windows and alt-screen frames. */
2939
- #prepareLinesArray(lines: readonly string[], width: number): string[] {
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(lines.length);
2942
- for (let i = 0; i < lines.length; i++) {
2943
- prepared[i] = this.#prepareLine(lines[i]!, width).line;
2944
- }
2945
- return prepared;
2946
- }
2947
-
2948
- #prepareLine(raw: string, width: number): PreparedLine {
2949
- if (TERMINAL.isImageLine(raw)) {
2950
- return { raw, width, line: raw };
2951
- }
2952
- const source = this.#lineFitSource(raw, width);
2953
- const normalized = normalizeTerminalOutput(source);
2954
- const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
2955
- if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
2956
- return { raw, width, line: normalized };
2957
- }
2958
- const line = truncateToWidth(normalized, width, Ellipsis.Omit);
2959
- return { raw, width, line };
2960
- }
2961
-
2962
- #lineFitSource(raw: string, width: number): string {
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
- #ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
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
- for (let i = 0; i < line.length;) {
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
- let j = i + 2;
3074
- while (j < line.length) {
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 OSC.
3085
- // Fall back to visibleWidth() so scaled cells stay exact.
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
- return undefined;
3247
+ ascii = false;
3092
3248
  }
3093
- let j = i + 2;
3094
- while (j < line.length) {
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
- return undefined;
3253
+ ascii = false;
3254
+ continue;
3110
3255
  }
3111
- if (code < 0x20 || code > 0x7e) return undefined;
3112
- col++;
3113
- if (col > maxWidth) return col;
3114
- i++;
3256
+ if (code < 0x20 || code > 0x7e) {
3257
+ ascii = false;
3258
+ continue;
3259
+ }
3260
+ if (ascii && col <= maxWidth) col++;
3115
3261
  }
3116
- return col;
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: string,
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 (TERMINAL.isImageLine(line)) {
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
- const asciiWidth = this.#ansiAsciiLineWidth(line, width);
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
- let lines = this.#compositeOverlaysIntoWindow(base, width, height);
3377
+ const lines = this.#compositeOverlaysIntoWindow(base, width, height);
3232
3378
  this.#extractCursorMarkers(lines);
3233
- lines = this.#prepareLinesArray(lines, width);
3234
- this.#emitAltFrame(lines, width, height);
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(lines: string[], width: number, height: number): void {
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
- if (fitted[r] !== this.#altPreviousLines[r]) {
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) return;
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(fitted[r], width, r, -1, -1, this.#osc66SpacerGlyphWidth(fitted, r));
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 = fitted;
3282
- this.#debugPaint = { lines: fitted, windowTop: 0, altScreen: true };
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
  }