@linxiraos/pi-tui 1.0.0 → 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.
- package/CHANGELOG.md +48 -7
- package/README.md +1 -1
- package/package.json +67 -68
- package/src/components/editor.ts +64 -3
- package/src/components/image.ts +169 -3
- package/src/components/loader.ts +2 -3
- package/src/components/markdown.ts +146 -11
- package/src/components/text.ts +10 -0
- package/src/latex-block.ts +116 -6
- package/src/terminal-capabilities.ts +84 -0
- package/src/tui.ts +1109 -56
- package/src/utils.ts +28 -0
- package/dist/types/autocomplete.d.ts +0 -116
- package/dist/types/bracketed-paste.d.ts +0 -51
- package/dist/types/components/box.d.ts +0 -31
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -162
- package/dist/types/components/image.d.ts +0 -112
- package/dist/types/components/input.d.ts +0 -25
- package/dist/types/components/loader.d.ts +0 -25
- package/dist/types/components/markdown.d.ts +0 -88
- package/dist/types/components/scroll-view.d.ts +0 -62
- package/dist/types/components/select-list.d.ts +0 -69
- package/dist/types/components/settings-list.d.ts +0 -123
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -89
- package/dist/types/components/text.d.ts +0 -27
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/deccara.d.ts +0 -49
- package/dist/types/desktop-notify.d.ts +0 -52
- package/dist/types/editor-component.d.ts +0 -38
- package/dist/types/fuzzy.d.ts +0 -48
- package/dist/types/index.d.ts +0 -32
- package/dist/types/keybindings.d.ts +0 -197
- package/dist/types/keys.d.ts +0 -210
- package/dist/types/kill-ring.d.ts +0 -20
- package/dist/types/kitty-graphics.d.ts +0 -76
- package/dist/types/latex-block.d.ts +0 -8
- package/dist/types/latex-to-unicode.d.ts +0 -50
- package/dist/types/loop-watchdog.d.ts +0 -44
- package/dist/types/mouse.d.ts +0 -67
- package/dist/types/stdin-buffer.d.ts +0 -60
- package/dist/types/symbols.d.ts +0 -25
- package/dist/types/terminal-capabilities.d.ts +0 -285
- package/dist/types/terminal.d.ts +0 -175
- package/dist/types/tmux.d.ts +0 -6
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -457
- package/dist/types/utils.d.ts +0 -100
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
- Reset the version to 1.0.0 and republished under the `@linxiraos/*` scope, breaking from the `@oh-my-pi` version lineage.
|
|
8
39
|
## [17.2.11] - 2026-08-07
|
|
9
40
|
|
|
10
41
|
### Fixed
|
|
@@ -1179,7 +1210,7 @@
|
|
|
1179
1210
|
|
|
1180
1211
|
### Added
|
|
1181
1212
|
|
|
1182
|
-
- Restored the `Key` runtime helper on `@
|
|
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.
|
|
1183
1214
|
|
|
1184
1215
|
## [15.0.1] - 2026-05-14
|
|
1185
1216
|
|
|
@@ -1260,7 +1291,7 @@
|
|
|
1260
1291
|
- Simplified cache key computation in Box component by removing intermediate hash updates and consolidating hash operations
|
|
1261
1292
|
- Wrapped native text utility functions (`sliceWithWidth`, `truncateToWidth`, `wrapTextWithAnsi`, `extractSegments`) to automatically pass the current default tab width, simplifying the API for consumers
|
|
1262
1293
|
- Added `getIndentationNoescape` wrapper that uses `process.cwd()` as the project root for relative file paths
|
|
1263
|
-
- Re-export `getDefaultTabWidth`, `getIndentation`, and `setDefaultTabWidth` from `@
|
|
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
|
|
1264
1295
|
|
|
1265
1296
|
## [13.16.1] - 2026-03-27
|
|
1266
1297
|
|
|
@@ -1567,7 +1598,7 @@
|
|
|
1567
1598
|
- Changed notification suppression environment variable from `OMP_NOTIFICATIONS` to `PI_NOTIFICATIONS`
|
|
1568
1599
|
- Changed TUI write log environment variable from `OMP_TUI_WRITE_LOG` to `PI_TUI_WRITE_LOG`
|
|
1569
1600
|
- Changed hardware cursor environment variable from `OMP_HARDWARE_CURSOR` to `PI_HARDWARE_CURSOR`
|
|
1570
|
-
- Updated environment variable access to use `getEnv()` utility function from `@
|
|
1601
|
+
- Updated environment variable access to use `getEnv()` utility function from `@linxiraos/pi-utils` for consistent handling
|
|
1571
1602
|
- Renamed `TERMINAL_INFO` export to `TERMINAL` for clearer API semantics
|
|
1572
1603
|
- Reorganized terminal image exports from `terminal-image` to `terminal-capabilities` module
|
|
1573
1604
|
- Updated all internal references to use `TERMINAL` instead of `TERMINAL_INFO`
|
|
@@ -1586,7 +1617,7 @@
|
|
|
1586
1617
|
|
|
1587
1618
|
### Changed
|
|
1588
1619
|
|
|
1589
|
-
- Moved `wrapTextWithAnsi` export to `@
|
|
1620
|
+
- Moved `wrapTextWithAnsi` export to `@linxiraos/pi-natives` package
|
|
1590
1621
|
|
|
1591
1622
|
### Fixed
|
|
1592
1623
|
|
|
@@ -1615,7 +1646,7 @@
|
|
|
1615
1646
|
|
|
1616
1647
|
### Removed
|
|
1617
1648
|
|
|
1618
|
-
- Removed `truncateToWidth`, `sliceWithWidth`, and `extractSegments` functions from public API (now re-exported directly from @
|
|
1649
|
+
- Removed `truncateToWidth`, `sliceWithWidth`, and `extractSegments` functions from public API (now re-exported directly from @linxiraos/pi-natives)
|
|
1619
1650
|
- Removed `ellipsis` property from `SymbolTheme` interface
|
|
1620
1651
|
- Removed `extractAnsiCode` function from public API
|
|
1621
1652
|
|
|
@@ -1956,6 +1987,16 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
|
|
|
1956
1987
|
|
|
1957
1988
|
- Added `getText()` method to Text component for retrieving current text content
|
|
1958
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
|
+
|
|
1994
|
+
## [1.0.0] - 2026-08-13
|
|
1995
|
+
|
|
1996
|
+
### Changed
|
|
1997
|
+
|
|
1998
|
+
- Reset the version to 1.0.0 and republished under the `@linxiraos/*` scope, breaking from the `@oh-my-pi` version lineage.
|
|
1999
|
+
|
|
1959
2000
|
## [0.50.0] - 2026-01-26
|
|
1960
2001
|
|
|
1961
2002
|
### Added
|
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 "@
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
|
550
|
-
* per frame and
|
|
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
|
-
|
|
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();
|
package/src/components/image.ts
CHANGED
|
@@ -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.
|
|
411
|
-
//
|
|
412
|
-
//
|
|
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);
|
package/src/components/loader.ts
CHANGED
|
@@ -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,
|
|
148
|
-
const backpressureDelayMs =
|
|
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;
|