@linxiraos/pi-tui 1.0.1 → 1.0.2

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +42 -6
  2. package/README.md +1 -1
  3. package/package.json +67 -68
  4. package/src/components/editor.ts +64 -3
  5. package/src/components/image.ts +169 -3
  6. package/src/components/loader.ts +2 -3
  7. package/src/components/markdown.ts +146 -11
  8. package/src/components/text.ts +10 -0
  9. package/src/latex-block.ts +116 -6
  10. package/src/terminal-capabilities.ts +84 -0
  11. package/src/tui.ts +1109 -56
  12. package/src/utils.ts +28 -0
  13. package/dist/types/autocomplete.d.ts +0 -116
  14. package/dist/types/bracketed-paste.d.ts +0 -51
  15. package/dist/types/components/box.d.ts +0 -31
  16. package/dist/types/components/cancellable-loader.d.ts +0 -21
  17. package/dist/types/components/editor.d.ts +0 -162
  18. package/dist/types/components/image.d.ts +0 -112
  19. package/dist/types/components/input.d.ts +0 -25
  20. package/dist/types/components/loader.d.ts +0 -25
  21. package/dist/types/components/markdown.d.ts +0 -88
  22. package/dist/types/components/scroll-view.d.ts +0 -62
  23. package/dist/types/components/select-list.d.ts +0 -69
  24. package/dist/types/components/settings-list.d.ts +0 -123
  25. package/dist/types/components/spacer.d.ts +0 -11
  26. package/dist/types/components/tab-bar.d.ts +0 -89
  27. package/dist/types/components/text.d.ts +0 -27
  28. package/dist/types/components/truncated-text.d.ts +0 -10
  29. package/dist/types/deccara.d.ts +0 -49
  30. package/dist/types/desktop-notify.d.ts +0 -52
  31. package/dist/types/editor-component.d.ts +0 -38
  32. package/dist/types/fuzzy.d.ts +0 -48
  33. package/dist/types/index.d.ts +0 -32
  34. package/dist/types/keybindings.d.ts +0 -197
  35. package/dist/types/keys.d.ts +0 -210
  36. package/dist/types/kill-ring.d.ts +0 -20
  37. package/dist/types/kitty-graphics.d.ts +0 -76
  38. package/dist/types/latex-block.d.ts +0 -8
  39. package/dist/types/latex-to-unicode.d.ts +0 -50
  40. package/dist/types/loop-watchdog.d.ts +0 -44
  41. package/dist/types/mouse.d.ts +0 -67
  42. package/dist/types/stdin-buffer.d.ts +0 -60
  43. package/dist/types/symbols.d.ts +0 -25
  44. package/dist/types/terminal-capabilities.d.ts +0 -285
  45. package/dist/types/terminal.d.ts +0 -175
  46. package/dist/types/tmux.d.ts +0 -6
  47. package/dist/types/ttyid.d.ts +0 -9
  48. package/dist/types/tui.d.ts +0 -457
  49. package/dist/types/utils.d.ts +0 -100
package/CHANGELOG.md CHANGED
@@ -2,7 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [1.0.1] - 2026-08-14
5
+ ## [1.0.2] - 2026-08-15
6
+
7
+ ## [17.3.3] - 2026-08-14
8
+
9
+ ### Fixed
10
+
11
+ - Fixed Gemini reports rendering their final headings and tables as one raw code block when the model emitted a lone closing Markdown fence without its opener.
12
+
13
+ ## [17.3.1] - 2026-08-13
14
+
15
+ ### Fixed
16
+
17
+ - Fixed screen flashing in Herdr panes during transcript streaming.
18
+
19
+ ## [17.3.0] - 2026-08-13
20
+
21
+ ### Fixed
22
+
23
+ - Fixed an issue where repeated pane-width adjustments or terminal resizing could corrupt native scrollback and soft-wrap behavior.
24
+ - Fixed an issue where scaled OSC 66 Markdown headings (such as "Large Headings" on Kitty) would render as invisible placeholders or get partially cleared after a redraw or terminal resize.
25
+
26
+ ## [17.2.13] - 2026-08-11
27
+
28
+ ### Fixed
29
+
30
+ - Fixed inline images rendering permanently cropped on Kitty direct-placement terminals (WezTerm, Warp) when an image block straddled the viewport top during streaming: placements are now clipped to the visible slice at write time, and a placement id whose cells reached native scrollback is never re-used ([#8070](https://github.com/can1357/oh-my-pi/pull/8070) by [@voonfoo](https://github.com/voonfoo))
31
+
32
+ ## [17.2.12] - 2026-08-08
33
+
34
+ ### Fixed
35
+
36
+ - Fixed slow Loader paints exceeding their cost-aware CPU duty cycle on WSL/ConPTY when a 200 ms backpressure cap was shorter than the proportional delay ([#8012](https://github.com/can1357/oh-my-pi/issues/8012)).
37
+ - Fixed display-math (`$$…$$`) fractions rendering as fragmented text when the numerator and denominator are written on separate source lines: `latexToBlock` treated the top-level newline between `\frac{num}` and `{den}` as a row break, severing `\frac` from its denominator. Such argument-continuation newlines are now preserved so the fraction stays stacked ([#7996](https://github.com/can1357/oh-my-pi/issues/7996)).
6
38
 
7
39
  ## [17.2.11] - 2026-08-07
8
40
 
@@ -1178,7 +1210,7 @@
1178
1210
 
1179
1211
  ### Added
1180
1212
 
1181
- - Restored the `Key` runtime helper on `@oh-my-pi/pi-tui` to mirror upstream `@mariozechner/pi-tui`'s surface. `Key.enter`, `Key.escape`, `Key.tab`, … return the canonical key-name strings; modifier methods (`Key.ctrl(k)`, `Key.shift(k)`, `Key.ctrlShift(k)`, etc.) build precisely-typed `KeyId` literals like `"ctrl+c"`. Pure runtime convenience for typed key-id construction — plugins built against the upstream package surface that import `Key` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) load again now that the specifier shim remaps them onto this package.
1213
+ - Restored the `Key` runtime helper on `@linxiraos/pi-tui` to mirror upstream `@mariozechner/pi-tui`'s surface. `Key.enter`, `Key.escape`, `Key.tab`, … return the canonical key-name strings; modifier methods (`Key.ctrl(k)`, `Key.shift(k)`, `Key.ctrlShift(k)`, etc.) build precisely-typed `KeyId` literals like `"ctrl+c"`. Pure runtime convenience for typed key-id construction — plugins built against the upstream package surface that import `Key` (e.g. `@plannotator/pi-extension`, `@juicesharp/rpiv-ask-user-question`) load again now that the specifier shim remaps them onto this package.
1182
1214
 
1183
1215
  ## [15.0.1] - 2026-05-14
1184
1216
 
@@ -1259,7 +1291,7 @@
1259
1291
  - Simplified cache key computation in Box component by removing intermediate hash updates and consolidating hash operations
1260
1292
  - Wrapped native text utility functions (`sliceWithWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `extractSegments`) to automatically pass the current default tab width, simplifying the API for consumers
1261
1293
  - Added `getIndentationNoescape` wrapper that uses `process.cwd()` as the project root for relative file paths
1262
- - Re-export `getDefaultTabWidth`, `getIndentation`, and `setDefaultTabWidth` from `@oh-my-pi/pi-utils`; native text helpers still receive tab width via wrappers that read the JS default
1294
+ - Re-export `getDefaultTabWidth`, `getIndentation`, and `setDefaultTabWidth` from `@linxiraos/pi-utils`; native text helpers still receive tab width via wrappers that read the JS default
1263
1295
 
1264
1296
  ## [13.16.1] - 2026-03-27
1265
1297
 
@@ -1566,7 +1598,7 @@
1566
1598
  - Changed notification suppression environment variable from `OMP_NOTIFICATIONS` to `PI_NOTIFICATIONS`
1567
1599
  - Changed TUI write log environment variable from `OMP_TUI_WRITE_LOG` to `PI_TUI_WRITE_LOG`
1568
1600
  - Changed hardware cursor environment variable from `OMP_HARDWARE_CURSOR` to `PI_HARDWARE_CURSOR`
1569
- - Updated environment variable access to use `getEnv()` utility function from `@oh-my-pi/pi-utils` for consistent handling
1601
+ - Updated environment variable access to use `getEnv()` utility function from `@linxiraos/pi-utils` for consistent handling
1570
1602
  - Renamed `TERMINAL_INFO` export to `TERMINAL` for clearer API semantics
1571
1603
  - Reorganized terminal image exports from `terminal-image` to `terminal-capabilities` module
1572
1604
  - Updated all internal references to use `TERMINAL` instead of `TERMINAL_INFO`
@@ -1585,7 +1617,7 @@
1585
1617
 
1586
1618
  ### Changed
1587
1619
 
1588
- - Moved `wrapTextWithAnsi` export to `@oh-my-pi/pi-natives` package
1620
+ - Moved `wrapTextWithAnsi` export to `@linxiraos/pi-natives` package
1589
1621
 
1590
1622
  ### Fixed
1591
1623
 
@@ -1614,7 +1646,7 @@
1614
1646
 
1615
1647
  ### Removed
1616
1648
 
1617
- - Removed `truncateToWidth`, `sliceWithWidth`, and `extractSegments` functions from public API (now re-exported directly from @oh-my-pi/pi-natives)
1649
+ - Removed `truncateToWidth`, `sliceWithWidth`, and `extractSegments` functions from public API (now re-exported directly from @linxiraos/pi-natives)
1618
1650
  - Removed `ellipsis` property from `SymbolTheme` interface
1619
1651
  - Removed `extractAnsiCode` function from public API
1620
1652
 
@@ -1955,6 +1987,10 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1955
1987
 
1956
1988
  - Added `getText()` method to Text component for retrieving current text content
1957
1989
 
1990
+ ## [1.0.1] - 2026-08-14
1991
+
1992
+ - Reset the version to 1.0.0 and republished under the `@linxiraos/*` scope, breaking from the `@oh-my-pi` version lineage.
1993
+
1958
1994
  ## [1.0.0] - 2026-08-13
1959
1995
 
1960
1996
  ### Changed
package/README.md CHANGED
@@ -640,7 +640,7 @@ class MyComponent implements Component {
640
640
  - `wrapTextWithAnsi()` preserves ANSI codes while word-wrapping and trimming line ends
641
641
 
642
642
  ```typescript
643
- import chalk from "@oh-my-pi/pi-utils/chalk";
643
+ import chalk from "@linxiraos/pi-utils/chalk";
644
644
 
645
645
  const styled = chalk.red("Hello") + " " + chalk.blue("World");
646
646
  const width = visibleWidth(styled); // 11 (not counting ANSI codes)
package/package.json CHANGED
@@ -1,70 +1,69 @@
1
1
  {
2
- "type": "module",
3
- "name": "@linxiraos/pi-tui",
4
- "version": "1.0.1",
5
- "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
- "homepage": "https://linxira-os.github.io",
7
- "author": "Can Boluk",
8
- "contributors": [
9
- "Mario Zechner"
10
- ],
11
- "license": "MIT",
12
- "repository": {
13
- "type": "git",
14
- "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
- "directory": "packages/tui"
16
- },
17
- "bugs": {
18
- "url": "https://github.com/can1357/oh-my-pi/issues"
19
- },
20
- "keywords": [
21
- "tui",
22
- "terminal",
23
- "ui",
24
- "text-editor",
25
- "differential-rendering",
26
- "typescript",
27
- "cli"
28
- ],
29
- "main": "./src/index.ts",
30
- "types": "./dist/types/index.d.ts",
31
- "scripts": {
32
- "check": "biome check . && bun run check:types",
33
- "check:types": "tsgo -p tsconfig.json --noEmit",
34
- "lint": "biome lint .",
35
- "test": "bun test --parallel test/*.test.ts",
36
- "fix": "biome check --write --unsafe .",
37
- "fmt": "biome format --write ."
38
- },
39
- "dependencies": {
40
- "@linxiraos/pi-natives": "1.0.1",
41
- "@linxiraos/pi-utils": "1.0.1"
42
- },
43
- "devDependencies": {
44
- "ghostty-web": "^0.4.0"
45
- },
46
- "engines": {
47
- "bun": ">=1.3.14"
48
- },
49
- "files": [
50
- "src",
51
- "README.md",
52
- "CHANGELOG.md",
53
- "dist/types"
54
- ],
55
- "exports": {
56
- ".": {
57
- "types": "./dist/types/index.d.ts",
58
- "import": "./src/index.ts"
59
- },
60
- "./*": {
61
- "types": "./dist/types/*.d.ts",
62
- "import": "./src/*.ts"
63
- },
64
- "./components/*": {
65
- "types": "./dist/types/components/*.d.ts",
66
- "import": "./src/components/*.ts"
67
- },
68
- "./*.js": "./src/*.ts"
69
- }
2
+ "type": "module",
3
+ "name": "@linxiraos/pi-tui",
4
+ "version": "1.0.2",
5
+ "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
+ "homepage": "https://linxira-os.github.io",
7
+ "author": "Can Boluk",
8
+ "contributors": [
9
+ "Mario Zechner"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/Linxira-OS/linxira-zeta.git",
15
+ "directory": "packages/tui"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/can1357/oh-my-pi/issues"
19
+ },
20
+ "keywords": [
21
+ "tui",
22
+ "terminal",
23
+ "ui",
24
+ "text-editor",
25
+ "differential-rendering",
26
+ "typescript",
27
+ "cli"
28
+ ],
29
+ "main": "./src/index.ts",
30
+ "types": "./src/index.ts",
31
+ "scripts": {
32
+ "check": "biome check . && bun run check:types",
33
+ "check:types": "tsgo -p tsconfig.json --noEmit",
34
+ "lint": "biome lint .",
35
+ "test": "bun test --parallel test/*.test.ts",
36
+ "fix": "biome check --write --unsafe .",
37
+ "fmt": "biome format --write ."
38
+ },
39
+ "dependencies": {
40
+ "@linxiraos/pi-natives": "catalog:",
41
+ "@linxiraos/pi-utils": "catalog:"
42
+ },
43
+ "devDependencies": {
44
+ "ghostty-web": "catalog:"
45
+ },
46
+ "engines": {
47
+ "bun": ">=1.3.14"
48
+ },
49
+ "files": [
50
+ "src",
51
+ "README.md",
52
+ "CHANGELOG.md"
53
+ ],
54
+ "exports": {
55
+ ".": {
56
+ "types": "./src/index.ts",
57
+ "import": "./src/index.ts"
58
+ },
59
+ "./*": {
60
+ "types": "./src/*.ts",
61
+ "import": "./src/*.ts"
62
+ },
63
+ "./components/*": {
64
+ "types": "./src/components/*.ts",
65
+ "import": "./src/components/*.ts"
66
+ },
67
+ "./*.js": "./src/*.ts"
68
+ }
70
69
  }
@@ -391,6 +391,8 @@ export interface EditorTopBorder {
391
391
  content: string;
392
392
  /** Visible width of the content */
393
393
  width: number;
394
+ /** Optional logical revision that changes independently of available width. */
395
+ revision?: number;
394
396
  }
395
397
 
396
398
  interface HistoryEntry {
@@ -410,6 +412,8 @@ export class Editor implements Component, Focusable {
410
412
  cursorLine: 0,
411
413
  cursorCol: 0,
412
414
  };
415
+ #widthEpochText = "";
416
+ #widthEpochRevision = 0;
413
417
 
414
418
  /** Focusable interface - set by TUI when focus changes */
415
419
  focused: boolean = false;
@@ -515,6 +519,9 @@ export class Editor implements Component, Focusable {
515
519
  // per-event rebuilds down to one per rendered frame (see #4145).
516
520
  #topBorderContent?: EditorTopBorder;
517
521
  #topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
522
+ #topBorderProviderWidth: number | undefined;
523
+ #topBorderProviderSignature: string | undefined;
524
+ #topBorderProviderRevision: number | undefined;
518
525
  #borderVisible = true;
519
526
 
520
527
  constructor(theme: EditorTheme) {
@@ -536,7 +543,10 @@ export class Editor implements Component, Focusable {
536
543
  * per-event rebuilds to one per painted frame.
537
544
  */
538
545
  setTopBorder(content: EditorTopBorder | undefined): void {
546
+ if (this.#topBorderContent?.content === content?.content && this.#topBorderContent?.width === content?.width)
547
+ return;
539
548
  this.#topBorderContent = content;
549
+ this.#widthEpochRevision++;
540
550
  }
541
551
 
542
552
  /**
@@ -546,18 +556,26 @@ export class Editor implements Component, Focusable {
546
556
  *
547
557
  * Use this when the top border derives from state that mutates far faster
548
558
  * than the render cadence (session events, streaming, subagent updates).
549
- * The TUI already throttles renders, so a provider is invoked at most once
550
- * per frame and never does wasted work between paints.
559
+ * The TUI already throttles renders, so a provider is invoked exactly once
560
+ * per frame and does no work between paints. Return a logical `revision` to
561
+ * distinguish concurrent status mutations from pure width reflow.
551
562
  */
552
563
  setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
564
+ if (this.#topBorderProvider === provider) return;
553
565
  this.#topBorderProvider = provider;
566
+ this.#topBorderProviderWidth = undefined;
567
+ this.#topBorderProviderSignature = undefined;
568
+ this.#topBorderProviderRevision = undefined;
569
+ this.#widthEpochRevision++;
554
570
  }
555
571
 
556
572
  /**
557
573
  * Show or hide the editor border chrome.
558
574
  */
559
575
  setBorderVisible(borderVisible: boolean): void {
576
+ if (this.#borderVisible === borderVisible) return;
560
577
  this.#borderVisible = borderVisible;
578
+ this.#widthEpochRevision++;
561
579
  }
562
580
 
563
581
  setPromptGutter(promptGutter: string | undefined): void {
@@ -578,12 +596,16 @@ export class Editor implements Component, Focusable {
578
596
  * Use the real terminal cursor instead of rendering a cursor glyph.
579
597
  */
580
598
  setUseTerminalCursor(useTerminalCursor: boolean): void {
599
+ if (this.#useTerminalCursor === useTerminalCursor) return;
581
600
  this.#useTerminalCursor = useTerminalCursor;
601
+ this.#widthEpochRevision++;
582
602
  }
583
603
 
584
604
  /** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
585
605
  setImeSafeCursorLayout(enabled: boolean): void {
606
+ if (this.#imeSafeCursorLayout === enabled) return;
586
607
  this.#imeSafeCursorLayout = enabled;
608
+ this.#widthEpochRevision++;
587
609
  }
588
610
 
589
611
  getUseTerminalCursor(): boolean {
@@ -593,6 +615,7 @@ export class Editor implements Component, Focusable {
593
615
  setMaxHeight(maxHeight: number | undefined): void {
594
616
  if (this.#maxHeight === maxHeight) return;
595
617
  this.#maxHeight = maxHeight;
618
+ this.#widthEpochRevision++;
596
619
  // Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
597
620
  }
598
621
 
@@ -613,6 +636,10 @@ export class Editor implements Component, Focusable {
613
636
  const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
614
637
  if (this.#autocompleteMaxVisible !== newMaxVisible) {
615
638
  this.#autocompleteMaxVisible = newMaxVisible;
639
+ if (this.#autocompleteState !== null) {
640
+ this.#autocompleteList?.setMaxVisible(newMaxVisible);
641
+ this.#widthEpochRevision++;
642
+ }
616
643
  }
617
644
  }
618
645
 
@@ -900,7 +927,27 @@ export class Editor implements Component, Focusable {
900
927
  // Provider (lazy) wins over eager content — a host that installs both
901
928
  // wants the coalesced path; falling back to eager keeps existing
902
929
  // setTopBorder callers working unchanged.
903
- const topBorder = this.#topBorderProvider ? this.#topBorderProvider(topFillWidth) : this.#topBorderContent;
930
+ let topBorder: EditorTopBorder | undefined;
931
+ if (this.#topBorderProvider) {
932
+ const previousWidth = this.#topBorderProviderWidth;
933
+ topBorder = this.#topBorderProvider(topFillWidth);
934
+ const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
935
+ const revision = topBorder?.revision;
936
+ if (
937
+ (previousWidth !== undefined &&
938
+ revision !== undefined &&
939
+ this.#topBorderProviderRevision !== undefined &&
940
+ revision !== this.#topBorderProviderRevision) ||
941
+ (previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
942
+ ) {
943
+ this.#widthEpochRevision++;
944
+ }
945
+ this.#topBorderProviderWidth = topFillWidth;
946
+ this.#topBorderProviderSignature = signature;
947
+ this.#topBorderProviderRevision = revision;
948
+ } else {
949
+ topBorder = this.#topBorderContent;
950
+ }
904
951
  if (topBorder) {
905
952
  const { content, width: statusWidth } = topBorder;
906
953
  if (statusWidth <= topFillWidth) {
@@ -1240,6 +1287,7 @@ export class Editor implements Component, Focusable {
1240
1287
  kb.matchesCanonical(canonical, "tui.select.pageDown")
1241
1288
  ) {
1242
1289
  this.#autocompleteList.handleInput(data);
1290
+ this.#widthEpochRevision++;
1243
1291
  this.onAutocompleteUpdate?.();
1244
1292
  return;
1245
1293
  }
@@ -1670,6 +1718,15 @@ export class Editor implements Component, Focusable {
1670
1718
  return this.#state.lines.join("\n");
1671
1719
  }
1672
1720
 
1721
+ getNativeScrollbackWidthEpochRevision(): number {
1722
+ const text = this.getText();
1723
+ if (text !== this.#widthEpochText) {
1724
+ this.#widthEpochText = text;
1725
+ this.#widthEpochRevision++;
1726
+ }
1727
+ return this.#widthEpochRevision;
1728
+ }
1729
+
1673
1730
  /** Whether the buffer text equals `value`, without `getText()`'s full join —
1674
1731
  * O(1) for the hot per-keystroke probes against short single-line values. */
1675
1732
  textEquals(value: string): boolean {
@@ -3149,6 +3206,7 @@ export class Editor implements Component, Focusable {
3149
3206
  this.#autocompletePrefix = suggestions.prefix;
3150
3207
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3151
3208
  this.#autocompleteState = "regular";
3209
+ this.#widthEpochRevision++;
3152
3210
  this.onAutocompleteUpdate?.();
3153
3211
  } else {
3154
3212
  this.#cancelAutocomplete();
@@ -3207,6 +3265,7 @@ export class Editor implements Component, Focusable {
3207
3265
  this.#autocompletePrefix = suggestions.prefix;
3208
3266
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3209
3267
  this.#autocompleteState = "force";
3268
+ this.#widthEpochRevision++;
3210
3269
  this.onAutocompleteUpdate?.();
3211
3270
  } else {
3212
3271
  this.#cancelAutocomplete();
@@ -3221,6 +3280,7 @@ export class Editor implements Component, Focusable {
3221
3280
  this.#autocompleteState = null;
3222
3281
  this.#autocompleteList = undefined;
3223
3282
  this.#autocompletePrefix = "";
3283
+ if (wasAutocompleting) this.#widthEpochRevision++;
3224
3284
  if (notifyCancel && wasAutocompleting) {
3225
3285
  this.onAutocompleteCancel?.();
3226
3286
  }
@@ -3252,6 +3312,7 @@ export class Editor implements Component, Focusable {
3252
3312
  this.#autocompletePrefix = suggestions.prefix;
3253
3313
  // Always create new SelectList to ensure update
3254
3314
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
3315
+ this.#widthEpochRevision++;
3255
3316
  this.onAutocompleteUpdate?.();
3256
3317
  } else {
3257
3318
  this.#cancelAutocomplete();
@@ -29,6 +29,7 @@ export interface ImageOptions {
29
29
 
30
30
  const EMPTY_IDS: readonly number[] = [];
31
31
  const EMPTY_TRANSMITS: readonly string[] = [];
32
+ const EMPTY_STALE_EPOCHS: ReadonlyArray<{ imageId: number; lastEpoch: number }> = [];
32
33
  const SAVE_CURSOR = "\x1b7";
33
34
  const RESTORE_CURSOR = "\x1b8";
34
35
  // Direct placements reserve height with leading zero-width rows. Keep them
@@ -38,6 +39,24 @@ const RESERVED_IMAGE_ROW = "\x1b[0m";
38
39
  /** Default count of inline images kept as live graphics before older ones fall back to text. */
39
40
  export const DEFAULT_MAX_INLINE_IMAGES = 8;
40
41
 
42
+ /** Per-image direct-placement emit state tracked by {@link ImageBudget}. */
43
+ interface PlacementEmitState {
44
+ widthPx: number;
45
+ heightPx: number;
46
+ /** Current placement-id (`p=`) generation. */
47
+ epoch: number;
48
+ /** First frame row the current epoch's last emit attached cells to. */
49
+ lastAttachTopFrameRow: number | undefined;
50
+ /**
51
+ * Whether any cell attached by the current epoch's last emit has entered
52
+ * native scrollback. Set by {@link ImageBudget.observeCommitWatermark}
53
+ * comparing each frame's raw commit target against the attach top —
54
+ * era-local comparisons, so a divergence recommit that rewinds and
55
+ * re-advances the ledger is detected the moment it re-crosses the attach
56
+ * top, and a stale pre-rewind peak can never re-trigger.
57
+ */
58
+ cellsArchived: boolean;
59
+ }
41
60
  let nextImageBudgetSeed = Math.floor(Math.random() * 0xffffff);
42
61
  function nextImageIdSeed(): number {
43
62
  nextImageBudgetSeed = (nextImageBudgetSeed + 0x10000) & 0xffffff;
@@ -96,6 +115,22 @@ export class ImageBudget {
96
115
  // id so a partial pass reproduces the on-screen live/text split without a
97
116
  // full, correctly-ordered walk.
98
117
  #suppressedIds = new Set<number>();
118
+ /**
119
+ * Per-image direct-placement emit state: source pixel geometry for the
120
+ * renderer's clipped source rectangle, plus the placement-id epoch (see
121
+ * {@link resolvePlacementEmit}). Entries deliberately live as long as the
122
+ * terminal's own placement registry for the image — they are the ledger the
123
+ * destructive-clear sweep uses to delete every registry entry an image ever
124
+ * placed — and die with it on demotion purge (`d=I`) or full cleanup.
125
+ */
126
+ #placementState = new Map<number, PlacementEmitState>();
127
+ /**
128
+ * States with an un-archived live attach top — the only ones a frame's
129
+ * commit watermark can affect. {@link observeCommitWatermark} runs every
130
+ * rendered frame, so it scans this set (bounded by concurrently live
131
+ * placements) instead of every image ever registered.
132
+ */
133
+ #watchedPlacements = new Set<PlacementEmitState>();
99
134
 
100
135
  constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
101
136
  this.#cap = normalizeCap(cap);
@@ -191,6 +226,7 @@ export class ImageBudget {
191
226
  this.#purgeIds.push(id);
192
227
  // d=I frees the data too, so the image must re-transmit if it returns.
193
228
  this.#transmitted.delete(id);
229
+ this.#deletePlacementState(id);
194
230
  this.#forgetKeyForId(id);
195
231
  }
196
232
  this.#onTerminal = this.#planned;
@@ -223,6 +259,8 @@ export class ImageBudget {
223
259
  this.#pendingTransmits = [];
224
260
  this.#keyToId.clear();
225
261
  this.#idToKey.clear();
262
+ this.#placementState.clear();
263
+ this.#watchedPlacements.clear();
226
264
  return ids;
227
265
  }
228
266
 
@@ -231,6 +269,126 @@ export class ImageBudget {
231
269
  return !this.#transmitted.has(imageId);
232
270
  }
233
271
 
272
+ /**
273
+ * Record a direct-placement image's source pixel geometry so the renderer
274
+ * can clip its placement to the visible slice at write time; cleared when
275
+ * the image is purged from the terminal store.
276
+ */
277
+ registerPlacementGeometry(imageId: number, widthPx: number, heightPx: number): void {
278
+ const state = this.#placementState.get(imageId);
279
+ if (state) {
280
+ state.widthPx = widthPx;
281
+ state.heightPx = heightPx;
282
+ return;
283
+ }
284
+ this.#placementState.set(imageId, {
285
+ widthPx,
286
+ heightPx,
287
+ epoch: 1,
288
+ lastAttachTopFrameRow: undefined,
289
+ cellsArchived: false,
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Record this frame's native-scrollback commit target (the frame-row count
295
+ * that is committed once the frame's writes land). Called once per rendered
296
+ * frame — including frames that emit no placements — so an epoch whose rows
297
+ * commit while its line is never rewritten is still flagged before the next
298
+ * re-emission.
299
+ */
300
+ observeCommitWatermark(committedTo: number): void {
301
+ if (committedTo < 0 || this.#watchedPlacements.size === 0) return;
302
+ for (const state of this.#watchedPlacements) {
303
+ if (state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
304
+ // Latched: the flag only clears when the next emit consumes it,
305
+ // so the state needs no further per-frame scans until then.
306
+ state.cellsArchived = true;
307
+ this.#watchedPlacements.delete(state);
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * End the physical-row coordinate epoch after observing its final commit
314
+ * watermark. Placement ids and latched archive state survive, but attachment
315
+ * rows do not: the next placement emit records them in the new-width frame.
316
+ */
317
+ beginPlacementCoordinateEpoch(): void {
318
+ for (const state of this.#placementState.values()) state.lastAttachTopFrameRow = undefined;
319
+ this.#watchedPlacements.clear();
320
+ }
321
+
322
+ /**
323
+ * Resolve the placement id and geometry for a direct-placement emit whose
324
+ * topmost attached cell sits at `attachTopFrameRow` — the first frame row
325
+ * the placement covers, i.e. the block's first *visible* row, not its
326
+ * origin (-1 when the writer has no frame-space position: alt-screen,
327
+ * resize, ConPTY-truncated replays). `committedTo` is this frame's commit
328
+ * target in the same frame-row space (-1 when unknown).
329
+ *
330
+ * Invariant: a placement id may be re-used (Kitty replace strips that id's
331
+ * cells everywhere, scrollback included) only while none of the cells it
332
+ * attached have entered native scrollback. The epoch — the `p=` id —
333
+ * advances exactly when the archived flag says otherwise; rewrites with no
334
+ * commit progression keep replacing the same id in place.
335
+ */
336
+ resolvePlacementEmit(
337
+ imageId: number,
338
+ attachTopFrameRow: number,
339
+ committedTo: number,
340
+ ): { placementId: number; widthPx: number; heightPx: number } | null {
341
+ const state = this.#placementState.get(imageId);
342
+ if (!state) return null;
343
+ // Frames that commit as they write (seam/full-paint chunk passes) pass
344
+ // their own commit target; fold it in before deciding, so a commit that
345
+ // lands in the same frame as the re-emission still advances the epoch.
346
+ if (committedTo >= 0 && state.lastAttachTopFrameRow !== undefined && committedTo > state.lastAttachTopFrameRow) {
347
+ state.cellsArchived = true;
348
+ this.#watchedPlacements.delete(state);
349
+ }
350
+ if (state.cellsArchived) {
351
+ state.epoch += 1;
352
+ state.cellsArchived = false;
353
+ state.lastAttachTopFrameRow = undefined;
354
+ }
355
+ if (attachTopFrameRow >= 0) {
356
+ state.lastAttachTopFrameRow = attachTopFrameRow;
357
+ this.#watchedPlacements.add(state);
358
+ }
359
+ return { placementId: state.epoch, widthPx: state.widthPx, heightPx: state.heightPx };
360
+ }
361
+
362
+ /**
363
+ * Restart every placement epoch after a destructive history clear (`CSI 3 J`
364
+ * full paint). The clear destroys all placement cells — scrollback rows are
365
+ * gone and the replay rewrites the viewport — so no archive remains to
366
+ * protect. Reverting to epoch 1 lets the replay's placements replace the
367
+ * terminal's stale registry entries; the returned list names every image
368
+ * and the highest epoch it reached so the caller can delete all of its
369
+ * registry entries explicitly (`d=i` keeps the transmitted data) — an image
370
+ * absent from the replay never re-places, so even its epoch-1 entry must go.
371
+ */
372
+ resetPlacementEpochs(): ReadonlyArray<{ imageId: number; lastEpoch: number }> {
373
+ let stale: Array<{ imageId: number; lastEpoch: number }> | undefined;
374
+ for (const [imageId, state] of this.#placementState) {
375
+ stale ??= [];
376
+ stale.push({ imageId, lastEpoch: state.epoch });
377
+ state.epoch = 1;
378
+ state.lastAttachTopFrameRow = undefined;
379
+ state.cellsArchived = false;
380
+ }
381
+ this.#watchedPlacements.clear();
382
+ return stale ?? EMPTY_STALE_EPOCHS;
383
+ }
384
+
385
+ #deletePlacementState(imageId: number): void {
386
+ const state = this.#placementState.get(imageId);
387
+ if (!state) return;
388
+ this.#watchedPlacements.delete(state);
389
+ this.#placementState.delete(imageId);
390
+ }
391
+
234
392
  /**
235
393
  * Queue a one-time transmit for `imageId`. No-op if already transmitted, so a
236
394
  * repeated call (e.g. a width-change re-render) never re-sends the data.
@@ -407,9 +565,17 @@ export class Image implements Component {
407
565
  // Direct placement: return `rows` lines so TUI accounts for image
408
566
  // height. First (rows-1) lines are empty (TUI clears them); the last
409
567
  // saves the final-row cursor, moves up to the image origin, emits the
410
- // image sequence, then restores the final-row cursor. Save/restore is
411
- // required because CUU clamps at the viewport top when leading rows are
412
- // clipped away.
568
+ // image sequence, then restores the final-row cursor. When the block
569
+ // straddles the viewport top, the renderer rewrites this line to the
570
+ // visible slice (encodeKittyPlacementLine) from the geometry
571
+ // registered below.
572
+ if (this.#imageId != null && this.#budget !== undefined) {
573
+ this.#budget.registerPlacementGeometry(
574
+ this.#imageId,
575
+ this.#dimensions.widthPx,
576
+ this.#dimensions.heightPx,
577
+ );
578
+ }
413
579
  lines = [];
414
580
  for (let i = 0; i < result.rows - 1; i++) {
415
581
  lines.push(RESERVED_IMAGE_ROW);
@@ -4,7 +4,6 @@ import { Text } from "./text";
4
4
 
5
5
  const RENDER_INTERVAL_MS = 1000 / 30;
6
6
  const SPINNER_ADVANCE_MS = 80;
7
- const MAX_RENDER_BACKPRESSURE_MS = 200;
8
7
  const RENDER_BACKPRESSURE_MULTIPLIER = 9;
9
8
 
10
9
  type ColorFn = (str: string) => string;
@@ -144,8 +143,8 @@ export class Loader extends Text {
144
143
  if (this.#intervalId !== timer) return;
145
144
  const cadenceDelayMs = Math.max(0, intervalMs - frameCostMs);
146
145
  // Idle for nine times the paint cost to keep animation at or below
147
- // 10% CPU, while cheap frames retain their original cadence.
148
- const backpressureDelayMs = Math.min(MAX_RENDER_BACKPRESSURE_MS, frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER);
146
+ // 10% CPU, even when a slow ConPTY write exceeds the normal cadence.
147
+ const backpressureDelayMs = frameCostMs * RENDER_BACKPRESSURE_MULTIPLIER;
149
148
  this.#scheduleTick(intervalMs, Math.max(cadenceDelayMs, backpressureDelayMs));
150
149
  }, delayMs);
151
150
  this.#intervalId = timer;