@oh-my-pi/pi-tui 18.2.2 → 18.2.4
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 +24 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/image.d.ts +18 -16
- package/dist/types/components/markdown.d.ts +0 -6
- package/dist/types/fuzzy.d.ts +1 -1
- package/package.json +3 -3
- package/src/components/editor.ts +36 -0
- package/src/components/image.ts +80 -53
- package/src/components/input.ts +1 -1
- package/src/components/markdown.ts +0 -9
- package/src/fuzzy.ts +1 -1
- package/src/tui.ts +13 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.2.4] - 2026-09-17
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed inline images disappearing or temporarily blanking when resizing the terminal in kitty and Ghostty.
|
|
10
|
+
|
|
11
|
+
## [18.2.3] - 2026-09-17
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Editors support whitespace-delimited `^` mention autocomplete and expose registered atoms for host-defined chip rendering.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `fuzzyRank` accepts readonly candidate arrays without copying them.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Masked inputs no longer expose their text in diagnostic previews.
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
- Removed the internal `fastTailSplices` and `resetFastTailSplices` Markdown instrumentation exports.
|
|
28
|
+
|
|
5
29
|
## [18.2.1] - 2026-09-15
|
|
6
30
|
|
|
7
31
|
### Added
|
|
@@ -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
|
+
/** Host-registered atomic chip labels mapped to their submit-time expansions. */
|
|
200
|
+
get atoms(): ReadonlyMap<string, string>;
|
|
199
201
|
/** Monotonic buffer-content revision for render caches. Cursor-only movement does not advance it. */
|
|
200
202
|
get textRevision(): number;
|
|
201
203
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
@@ -22,10 +22,10 @@ export declare const DEFAULT_MAX_INLINE_IMAGES = 8;
|
|
|
22
22
|
* Bounds how many inline images render as live terminal graphics at once.
|
|
23
23
|
*
|
|
24
24
|
* Terminal graphics protocols — Kitty especially — keep every transmitted image
|
|
25
|
-
* in a per-
|
|
26
|
-
*
|
|
27
|
-
* shows many images piles up placements plus store memory and
|
|
28
|
-
* scrollback.
|
|
25
|
+
* in a per-screen store and re-draw placements as content scrolls; placements
|
|
26
|
+
* that left the viewport survive text-clear escapes (`CSI 2 J`). Unbounded, a
|
|
27
|
+
* session that shows many images piles up placements plus store memory and
|
|
28
|
+
* leaves ghosts in scrollback.
|
|
29
29
|
*
|
|
30
30
|
* The budget keeps the most recent `cap` images live and demotes older ones to
|
|
31
31
|
* their text fallback. Demotion needs a full redraw (so off-screen rows are
|
|
@@ -115,9 +115,9 @@ export declare class ImageBudget {
|
|
|
115
115
|
takeResetPurgeIds(): readonly number[];
|
|
116
116
|
/** Image ids to delete from the terminal this frame; clears the pending set. */
|
|
117
117
|
takePurgeIds(): readonly number[];
|
|
118
|
-
/** All image ids believed to be loaded in
|
|
118
|
+
/** All image ids believed to be loaded in any screen's store; clears tracking. */
|
|
119
119
|
takeAllTransmittedIds(): readonly number[];
|
|
120
|
-
/** Whether `imageId`'s data still needs to be transmitted to the
|
|
120
|
+
/** Whether `imageId`'s data still needs to be transmitted to the surface the in-flight pass paints. */
|
|
121
121
|
shouldTransmit(imageId: number): boolean;
|
|
122
122
|
/**
|
|
123
123
|
* Record a direct-placement image's source pixel geometry so the renderer
|
|
@@ -170,11 +170,12 @@ export declare class ImageBudget {
|
|
|
170
170
|
lastEpoch: number;
|
|
171
171
|
}>;
|
|
172
172
|
/**
|
|
173
|
-
* Queue a one-time transmit for `imageId
|
|
174
|
-
*
|
|
173
|
+
* Queue a one-time transmit for `imageId` on the surface the in-flight pass
|
|
174
|
+
* paints. No-op if that surface already holds the data, so a repeated call
|
|
175
|
+
* (e.g. a width-change re-render) never re-sends it.
|
|
175
176
|
*/
|
|
176
177
|
enqueueTransmit(imageId: number, sequence: string): void;
|
|
177
|
-
/** Whether
|
|
178
|
+
/** Whether the in-flight pass's surface has image data queued but not yet written. */
|
|
178
179
|
hasPendingTransmits(): boolean;
|
|
179
180
|
/**
|
|
180
181
|
* True when the budget has nothing in flight on either surface: no live images
|
|
@@ -183,15 +184,16 @@ export declare class ImageBudget {
|
|
|
183
184
|
* observe pass only then — a partial tree walk would under-count display order.
|
|
184
185
|
*/
|
|
185
186
|
get quiescent(): boolean;
|
|
186
|
-
/** Transmit sequences to write before this frame's placements; clears
|
|
187
|
+
/** Transmit sequences to write before this frame's placements on its surface; clears that queue. */
|
|
187
188
|
takeTransmits(): readonly string[];
|
|
188
189
|
/**
|
|
189
|
-
* Drop transmit tracking so every still-live image
|
|
190
|
-
* (`a=t`) on the next render. Recovers when the terminal
|
|
191
|
-
* transmit — e.g. Ghostty discarding graphics sent during
|
|
192
|
-
* window — where a placement-only replay can never bind a
|
|
193
|
-
* Pair with a component invalidate + forced repaint so
|
|
194
|
-
* re-emit together; keeps no base64 in budget state
|
|
190
|
+
* Drop the normal screen's transmit tracking so every still-live image
|
|
191
|
+
* re-enqueues its data (`a=t`) on the next render. Recovers when the terminal
|
|
192
|
+
* dropped the original transmit — e.g. Ghostty discarding graphics sent during
|
|
193
|
+
* its post-startup window — where a placement-only replay can never bind a
|
|
194
|
+
* Unicode placeholder. Pair with a component invalidate + forced repaint so
|
|
195
|
+
* the data and placement re-emit together; keeps no base64 in budget state
|
|
196
|
+
* (the transmit-once design).
|
|
195
197
|
*/
|
|
196
198
|
forgetTransmitted(): void;
|
|
197
199
|
}
|
|
@@ -4,12 +4,6 @@ import type { Component } from "../tui.js";
|
|
|
4
4
|
export declare function autolinkSchemeScanIndex(src: string): number | undefined;
|
|
5
5
|
/** @internal exported for tests — must never return false for a src the built-in url regex matches. */
|
|
6
6
|
export declare function urlTokenPossible(src: string): boolean;
|
|
7
|
-
/** @internal exported for tests — counts fast-tail splice frames. A future
|
|
8
|
-
* regression that silently disarms the fast path (e.g. an over-broad gate)
|
|
9
|
-
* leaves byte-identity intact but drops the counter to zero. */
|
|
10
|
-
export declare let fastTailSplices: number;
|
|
11
|
-
/** @internal exported for tests — resets the splice counter. */
|
|
12
|
-
export declare function resetFastTailSplices(): void;
|
|
13
7
|
/** @internal exported for tests — the grown-line-start block-kind gate. */
|
|
14
8
|
export declare function fastLineStartHazard(grownLine: string): boolean;
|
|
15
9
|
/** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
|
package/dist/types/fuzzy.d.ts
CHANGED
|
@@ -36,7 +36,7 @@ export declare class FuzzyText {
|
|
|
36
36
|
* Filter and sort items by fuzzy match quality (best matches first).
|
|
37
37
|
* Supports space-separated tokens: all tokens must match.
|
|
38
38
|
*/
|
|
39
|
-
export declare function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[];
|
|
39
|
+
export declare function fuzzyRank<T>(items: readonly T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[];
|
|
40
40
|
export declare function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[];
|
|
41
41
|
/**
|
|
42
42
|
* Clear the fuzzy search-index cache. Intended for tests/benchmarks so a fresh
|
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.2.
|
|
4
|
+
"version": "18.2.4",
|
|
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.2.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.2.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.2.4",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.2.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
package/src/components/editor.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type { EditorBorderStyle, EditorTopBorder };
|
|
|
51
51
|
import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
|
|
52
52
|
|
|
53
53
|
const PASSTHROUGH_COLOR = (text: string): string => text;
|
|
54
|
+
const MENTION_CONTEXT_RE = /(?:^|\s)\^[^\s]*$/;
|
|
54
55
|
|
|
55
56
|
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
56
57
|
overflowSearch: false,
|
|
@@ -2392,6 +2393,11 @@ export class Editor implements Component, Focusable {
|
|
|
2392
2393
|
return this.#state.lines.join("\n");
|
|
2393
2394
|
}
|
|
2394
2395
|
|
|
2396
|
+
/** Host-registered atomic chip labels mapped to their submit-time expansions. */
|
|
2397
|
+
get atoms(): ReadonlyMap<string, string> {
|
|
2398
|
+
return this.#atoms;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2395
2401
|
/** Monotonic buffer-content revision for render caches. Cursor-only movement does not advance it. */
|
|
2396
2402
|
get textRevision(): number {
|
|
2397
2403
|
return this.#textRevision;
|
|
@@ -2787,6 +2793,16 @@ export class Editor implements Component, Focusable {
|
|
|
2787
2793
|
this.#tryTriggerAutocomplete();
|
|
2788
2794
|
}
|
|
2789
2795
|
}
|
|
2796
|
+
// Auto-trigger for "^" model mentions
|
|
2797
|
+
else if (char === "^") {
|
|
2798
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2799
|
+
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
|
|
2800
|
+
// Only trigger if ^ is after whitespace or at start of line
|
|
2801
|
+
const charBeforeCaret = textBeforeCursor[textBeforeCursor.length - 2];
|
|
2802
|
+
if (textBeforeCursor.length === 1 || charBeforeCaret === " " || charBeforeCaret === "\t") {
|
|
2803
|
+
this.#tryTriggerAutocomplete();
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2790
2806
|
// Auto-trigger for "#" prompt actions anywhere in the current token
|
|
2791
2807
|
else if (char === "#") {
|
|
2792
2808
|
this.#tryTriggerAutocomplete();
|
|
@@ -2807,6 +2823,10 @@ export class Editor implements Component, Focusable {
|
|
|
2807
2823
|
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
2808
2824
|
this.#tryTriggerAutocomplete();
|
|
2809
2825
|
}
|
|
2826
|
+
// Check if we're in a model mention context
|
|
2827
|
+
else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
|
|
2828
|
+
this.#tryTriggerAutocomplete();
|
|
2829
|
+
}
|
|
2810
2830
|
// Check if we're in a # prompt action context
|
|
2811
2831
|
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
2812
2832
|
this.#tryTriggerAutocomplete();
|
|
@@ -2926,6 +2946,8 @@ export class Editor implements Component, Focusable {
|
|
|
2926
2946
|
this.#tryTriggerAutocomplete();
|
|
2927
2947
|
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
2928
2948
|
this.#tryTriggerAutocomplete();
|
|
2949
|
+
} else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
|
|
2950
|
+
this.#tryTriggerAutocomplete();
|
|
2929
2951
|
} else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
2930
2952
|
this.#tryTriggerAutocomplete();
|
|
2931
2953
|
} else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
@@ -3101,6 +3123,10 @@ export class Editor implements Component, Focusable {
|
|
|
3101
3123
|
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
3102
3124
|
this.#tryTriggerAutocomplete();
|
|
3103
3125
|
}
|
|
3126
|
+
// model mention context
|
|
3127
|
+
else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
|
|
3128
|
+
this.#tryTriggerAutocomplete();
|
|
3129
|
+
}
|
|
3104
3130
|
// # prompt action context
|
|
3105
3131
|
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
3106
3132
|
this.#tryTriggerAutocomplete();
|
|
@@ -3264,6 +3290,8 @@ export class Editor implements Component, Focusable {
|
|
|
3264
3290
|
this.#tryTriggerAutocomplete();
|
|
3265
3291
|
} else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
3266
3292
|
this.#tryTriggerAutocomplete();
|
|
3293
|
+
} else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
|
|
3294
|
+
this.#tryTriggerAutocomplete();
|
|
3267
3295
|
} else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
3268
3296
|
this.#tryTriggerAutocomplete();
|
|
3269
3297
|
} else if (this.#textTriggersUrlAutocomplete(textBeforeCursor)) {
|
|
@@ -3600,6 +3628,10 @@ export class Editor implements Component, Focusable {
|
|
|
3600
3628
|
else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
|
|
3601
3629
|
this.#tryTriggerAutocomplete();
|
|
3602
3630
|
}
|
|
3631
|
+
// model mention context
|
|
3632
|
+
else if (MENTION_CONTEXT_RE.test(textBeforeCursor)) {
|
|
3633
|
+
this.#tryTriggerAutocomplete();
|
|
3634
|
+
}
|
|
3603
3635
|
// # prompt action context
|
|
3604
3636
|
else if (textBeforeCursor.match(/#[^\s#]*$/)) {
|
|
3605
3637
|
this.#tryTriggerAutocomplete();
|
|
@@ -3893,6 +3925,10 @@ export class Editor implements Component, Focusable {
|
|
|
3893
3925
|
return /(?:^|\s)@[^\s]*$/.test(currentTextBeforeCursor);
|
|
3894
3926
|
}
|
|
3895
3927
|
|
|
3928
|
+
if (this.#autocompletePrefix.startsWith("^")) {
|
|
3929
|
+
return MENTION_CONTEXT_RE.test(currentTextBeforeCursor);
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3896
3932
|
return currentTextBeforeCursor.endsWith(this.#autocompletePrefix);
|
|
3897
3933
|
}
|
|
3898
3934
|
|
package/src/components/image.ts
CHANGED
|
@@ -108,10 +108,10 @@ function nextImageIdSeed(): number {
|
|
|
108
108
|
* Bounds how many inline images render as live terminal graphics at once.
|
|
109
109
|
*
|
|
110
110
|
* Terminal graphics protocols — Kitty especially — keep every transmitted image
|
|
111
|
-
* in a per-
|
|
112
|
-
*
|
|
113
|
-
* shows many images piles up placements plus store memory and
|
|
114
|
-
* scrollback.
|
|
111
|
+
* in a per-screen store and re-draw placements as content scrolls; placements
|
|
112
|
+
* that left the viewport survive text-clear escapes (`CSI 2 J`). Unbounded, a
|
|
113
|
+
* session that shows many images piles up placements plus store memory and
|
|
114
|
+
* leaves ghosts in scrollback.
|
|
115
115
|
*
|
|
116
116
|
* The budget keeps the most recent `cap` images live and demotes older ones to
|
|
117
117
|
* their text fallback. Demotion needs a full redraw (so off-screen rows are
|
|
@@ -165,10 +165,16 @@ export class ImageBudget {
|
|
|
165
165
|
* {@link forgetTransmitted}.
|
|
166
166
|
*/
|
|
167
167
|
#resetPurgeIds: number[] = [];
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Image ids whose data is believed to be loaded in each screen's store.
|
|
170
|
+
* kitty and Ghostty keep the normal and alternate buffers' graphics apart
|
|
171
|
+
* and wipe the alternate store on every `?1049h`, so data sent while one
|
|
172
|
+
* buffer was active is unknown to the other: a placement there resolves
|
|
173
|
+
* only after its own transmit.
|
|
174
|
+
*/
|
|
175
|
+
#transmitted: Record<Surface, Set<number>> = { screen: new Set(), alt: new Set() };
|
|
176
|
+
/** Transmit sequences (full base64) to write once per surface, before that frame's placements. */
|
|
177
|
+
#pendingTransmits: Record<Surface, Map<number, string>> = { screen: new Map(), alt: new Map() };
|
|
172
178
|
// True while the in-flight pass is a partial/throwaway pass (the
|
|
173
179
|
// non-multiplexer resize viewport fast path) that walks only the visible
|
|
174
180
|
// tail, bottom-up. Such a pass cannot derive display order from observe()
|
|
@@ -258,6 +264,8 @@ export class ImageBudget {
|
|
|
258
264
|
beginAltScreenLifecycle(): void {
|
|
259
265
|
resetSurfaceSplit(this.#altSplit);
|
|
260
266
|
this.#liveIds.alt.clear();
|
|
267
|
+
// `?1049h` hands over an empty graphics store as well as a cleared grid.
|
|
268
|
+
this.#transmitted.alt.clear();
|
|
261
269
|
}
|
|
262
270
|
|
|
263
271
|
/**
|
|
@@ -354,9 +362,10 @@ export class ImageBudget {
|
|
|
354
362
|
*/
|
|
355
363
|
limitResidentImages(): void {
|
|
356
364
|
this.#liveIds[this.#surface] = new Set(this.#passIds.filter(id => this.#passShowsLive(id)));
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
365
|
+
const transmitted = this.#transmitted[this.#surface];
|
|
366
|
+
if (this.#cap <= 0 || transmitted.size <= this.#cap) return;
|
|
367
|
+
for (const id of transmitted) {
|
|
368
|
+
if (transmitted.size <= this.#cap) break;
|
|
360
369
|
this.#retire(id);
|
|
361
370
|
}
|
|
362
371
|
}
|
|
@@ -384,15 +393,17 @@ export class ImageBudget {
|
|
|
384
393
|
}
|
|
385
394
|
|
|
386
395
|
/**
|
|
387
|
-
* Drop `imageId` from the
|
|
388
|
-
* a transmit that never went out) and
|
|
396
|
+
* Drop `imageId` from the painted surface's image store: queue its `d=I` (or
|
|
397
|
+
* cancel a transmit that never went out) and, once no surface holds its data,
|
|
398
|
+
* forget its placement ledger and key.
|
|
389
399
|
*
|
|
390
400
|
* The single gate on every destruction path. `d=I` removes an image's
|
|
391
|
-
* placements everywhere, scrollback included, and a frame diff
|
|
392
|
-
* rows whose text changed — so a graphic some standing frame
|
|
393
|
-
* cannot be repaired once deleted, and must never be a
|
|
394
|
-
* when the in-flight pass renders the image live, or when
|
|
395
|
-
* surface this pass is not repainting does. Returns whether
|
|
401
|
+
* placements everywhere on its screen, scrollback included, and a frame diff
|
|
402
|
+
* only rewrites rows whose text changed — so a graphic some standing frame
|
|
403
|
+
* still shows cannot be repaired once deleted, and must never be a
|
|
404
|
+
* candidate. Refuses when the in-flight pass renders the image live, or when
|
|
405
|
+
* the frame on any surface this pass is not repainting does. Returns whether
|
|
406
|
+
* it was retired.
|
|
396
407
|
*/
|
|
397
408
|
#retire(imageId: number): boolean {
|
|
398
409
|
if (this.#passShowsLive(imageId)) return false;
|
|
@@ -401,13 +412,19 @@ export class ImageBudget {
|
|
|
401
412
|
}
|
|
402
413
|
// A transmit queued by a discarded discovery pass never reached the
|
|
403
414
|
// terminal, so cancel it instead of transmitting then purging.
|
|
404
|
-
if (!this.#pendingTransmits.delete(imageId)) this.#purgeIds.push(imageId);
|
|
405
|
-
this.#transmitted.delete(imageId);
|
|
406
|
-
this.#deletePlacementState(imageId);
|
|
415
|
+
if (!this.#pendingTransmits[this.#surface].delete(imageId)) this.#purgeIds.push(imageId);
|
|
416
|
+
this.#transmitted[this.#surface].delete(imageId);
|
|
417
|
+
if (!this.#isTransmitted(imageId)) this.#deletePlacementState(imageId);
|
|
407
418
|
this.#forgetKeyForId(imageId);
|
|
408
419
|
return true;
|
|
409
420
|
}
|
|
410
421
|
|
|
422
|
+
/** Whether any screen's store is believed to hold `imageId`'s data. */
|
|
423
|
+
#isTransmitted(imageId: number): boolean {
|
|
424
|
+
for (const surface of SURFACES) if (this.#transmitted[surface].has(imageId)) return true;
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
|
|
411
428
|
/**
|
|
412
429
|
* Image ids a destructive reset must delete explicitly, alongside its `d=A`.
|
|
413
430
|
* Emit only from that reset's repaint; clears the queue.
|
|
@@ -427,25 +444,28 @@ export class ImageBudget {
|
|
|
427
444
|
return ids;
|
|
428
445
|
}
|
|
429
446
|
|
|
430
|
-
/** All image ids believed to be loaded in
|
|
447
|
+
/** All image ids believed to be loaded in any screen's store; clears tracking. */
|
|
431
448
|
takeAllTransmittedIds(): readonly number[] {
|
|
432
|
-
|
|
433
|
-
const
|
|
434
|
-
|
|
449
|
+
const ids = new Set<number>();
|
|
450
|
+
for (const surface of SURFACES) {
|
|
451
|
+
for (const id of this.#transmitted[surface]) ids.add(id);
|
|
452
|
+
this.#transmitted[surface].clear();
|
|
453
|
+
this.#pendingTransmits[surface].clear();
|
|
454
|
+
}
|
|
455
|
+
if (ids.size === 0) return EMPTY_IDS;
|
|
435
456
|
this.#purgeIds = [];
|
|
436
457
|
this.#resetPurgeIds = [];
|
|
437
|
-
this.#pendingTransmits.clear();
|
|
438
458
|
this.#keyToId.clear();
|
|
439
459
|
this.#idToKey.clear();
|
|
440
460
|
this.#placementState.clear();
|
|
441
461
|
this.#watchedPlacements.clear();
|
|
442
462
|
for (const surface of SURFACES) this.#liveIds[surface].clear();
|
|
443
|
-
return ids;
|
|
463
|
+
return [...ids];
|
|
444
464
|
}
|
|
445
465
|
|
|
446
|
-
/** Whether `imageId`'s data still needs to be transmitted to the
|
|
466
|
+
/** Whether `imageId`'s data still needs to be transmitted to the surface the in-flight pass paints. */
|
|
447
467
|
shouldTransmit(imageId: number): boolean {
|
|
448
|
-
return !this.#transmitted.has(imageId);
|
|
468
|
+
return !this.#transmitted[this.#surface].has(imageId);
|
|
449
469
|
}
|
|
450
470
|
|
|
451
471
|
/**
|
|
@@ -566,18 +586,20 @@ export class ImageBudget {
|
|
|
566
586
|
}
|
|
567
587
|
|
|
568
588
|
/**
|
|
569
|
-
* Queue a one-time transmit for `imageId
|
|
570
|
-
*
|
|
589
|
+
* Queue a one-time transmit for `imageId` on the surface the in-flight pass
|
|
590
|
+
* paints. No-op if that surface already holds the data, so a repeated call
|
|
591
|
+
* (e.g. a width-change re-render) never re-sends it.
|
|
571
592
|
*/
|
|
572
593
|
enqueueTransmit(imageId: number, sequence: string): void {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
594
|
+
const transmitted = this.#transmitted[this.#surface];
|
|
595
|
+
if (transmitted.has(imageId)) return;
|
|
596
|
+
transmitted.add(imageId);
|
|
597
|
+
this.#pendingTransmits[this.#surface].set(imageId, sequence);
|
|
576
598
|
}
|
|
577
599
|
|
|
578
|
-
/** Whether
|
|
600
|
+
/** Whether the in-flight pass's surface has image data queued but not yet written. */
|
|
579
601
|
hasPendingTransmits(): boolean {
|
|
580
|
-
return this.#pendingTransmits.size > 0;
|
|
602
|
+
return this.#pendingTransmits[this.#surface].size > 0;
|
|
581
603
|
}
|
|
582
604
|
|
|
583
605
|
/**
|
|
@@ -587,33 +609,38 @@ export class ImageBudget {
|
|
|
587
609
|
* observe pass only then — a partial tree walk would under-count display order.
|
|
588
610
|
*/
|
|
589
611
|
get quiescent(): boolean {
|
|
590
|
-
if (this.#
|
|
612
|
+
if (this.#purgeIds.length > 0) return false;
|
|
613
|
+
for (const surface of SURFACES) if (this.#pendingTransmits[surface].size > 0) return false;
|
|
591
614
|
for (const split of [this.#screenSplit, this.#altSplit]) {
|
|
592
615
|
if (split.lastTotal !== 0 || split.planned !== split.onTerminal) return false;
|
|
593
616
|
}
|
|
594
617
|
return true;
|
|
595
618
|
}
|
|
596
619
|
|
|
597
|
-
/** Transmit sequences to write before this frame's placements; clears
|
|
620
|
+
/** Transmit sequences to write before this frame's placements on its surface; clears that queue. */
|
|
598
621
|
takeTransmits(): readonly string[] {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
622
|
+
const pending = this.#pendingTransmits[this.#surface];
|
|
623
|
+
if (pending.size === 0) return EMPTY_TRANSMITS;
|
|
624
|
+
const sequences = [...pending.values()];
|
|
625
|
+
pending.clear();
|
|
602
626
|
return sequences;
|
|
603
627
|
}
|
|
604
628
|
|
|
605
629
|
/**
|
|
606
|
-
* Drop transmit tracking so every still-live image
|
|
607
|
-
* (`a=t`) on the next render. Recovers when the terminal
|
|
608
|
-
* transmit — e.g. Ghostty discarding graphics sent during
|
|
609
|
-
* window — where a placement-only replay can never bind a
|
|
610
|
-
* Pair with a component invalidate + forced repaint so
|
|
611
|
-
* re-emit together; keeps no base64 in budget state
|
|
630
|
+
* Drop the normal screen's transmit tracking so every still-live image
|
|
631
|
+
* re-enqueues its data (`a=t`) on the next render. Recovers when the terminal
|
|
632
|
+
* dropped the original transmit — e.g. Ghostty discarding graphics sent during
|
|
633
|
+
* its post-startup window — where a placement-only replay can never bind a
|
|
634
|
+
* Unicode placeholder. Pair with a component invalidate + forced repaint so
|
|
635
|
+
* the data and placement re-emit together; keeps no base64 in budget state
|
|
636
|
+
* (the transmit-once design).
|
|
612
637
|
*/
|
|
613
638
|
forgetTransmitted(): void {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
639
|
+
const transmitted = this.#transmitted.screen;
|
|
640
|
+
const pending = this.#pendingTransmits.screen;
|
|
641
|
+
if (transmitted.size === 0 && pending.size === 0) return;
|
|
642
|
+
for (const id of transmitted) {
|
|
643
|
+
if (!pending.has(id)) this.#resetPurgeIds.push(id);
|
|
617
644
|
}
|
|
618
645
|
// The ids go to #resetPurgeIds, drained only by the destructive repaint
|
|
619
646
|
// itself — never to #purgeIds, which any frame drains. That is how a
|
|
@@ -624,8 +651,8 @@ export class ImageBudget {
|
|
|
624
651
|
// placements from it, and erasing placeholder text does not remove the
|
|
625
652
|
// prototype either. Forgetting drops the id from tracking, so without an
|
|
626
653
|
// explicit `d=I` no later sweep can ever find that placement again.
|
|
627
|
-
|
|
628
|
-
|
|
654
|
+
transmitted.clear();
|
|
655
|
+
pending.clear();
|
|
629
656
|
}
|
|
630
657
|
|
|
631
658
|
/**
|
|
@@ -641,7 +668,7 @@ export class ImageBudget {
|
|
|
641
668
|
* deleting every placement it ever made including scrollback copies.
|
|
642
669
|
*/
|
|
643
670
|
#forgetKeyForId(id: number): void {
|
|
644
|
-
if (this.#
|
|
671
|
+
if (this.#isTransmitted(id)) return;
|
|
645
672
|
const key = this.#idToKey.get(id);
|
|
646
673
|
if (key === undefined) return;
|
|
647
674
|
this.#idToKey.delete(id);
|
package/src/components/input.ts
CHANGED
|
@@ -54,7 +54,7 @@ export class Input implements Component, Focusable {
|
|
|
54
54
|
/** Return bounded input content and cursor state for debug inspection. */
|
|
55
55
|
debugState(): Record<string, unknown> {
|
|
56
56
|
return {
|
|
57
|
-
textPreview: this.#value.slice(0, 120),
|
|
57
|
+
textPreview: this.mask ? "********" : this.#value.slice(0, 120),
|
|
58
58
|
textLength: this.#value.length,
|
|
59
59
|
previewTruncated: this.#value.length > 120,
|
|
60
60
|
cursor: this.#cursor,
|
|
@@ -988,14 +988,6 @@ const FAST_LINE_START_HAZARD_RE =
|
|
|
988
988
|
// chars are in ASCENDING code-point order (no reversed ranges that
|
|
989
989
|
// rely on engine leniency): * + = – — ─ ━ ═ then the literal `-`.
|
|
990
990
|
/^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|\d{1,9}[.)](?:[ \t]|$)|[*+=–—─━═-](?:[ \t]|$)|(?:[*+=–—─━═-][ \t]*){2,}[ \t]*$)/;
|
|
991
|
-
/** @internal exported for tests — counts fast-tail splice frames. A future
|
|
992
|
-
* regression that silently disarms the fast path (e.g. an over-broad gate)
|
|
993
|
-
* leaves byte-identity intact but drops the counter to zero. */
|
|
994
|
-
export let fastTailSplices = 0;
|
|
995
|
-
/** @internal exported for tests — resets the splice counter. */
|
|
996
|
-
export function resetFastTailSplices(): void {
|
|
997
|
-
fastTailSplices = 0;
|
|
998
|
-
}
|
|
999
991
|
|
|
1000
992
|
/** @internal exported for tests — the grown-line-start block-kind gate. */
|
|
1001
993
|
export function fastLineStartHazard(grownLine: string): boolean {
|
|
@@ -2199,7 +2191,6 @@ export class Markdown implements Component {
|
|
|
2199
2191
|
rowEnd: recipe.rowStart + wrapped.length,
|
|
2200
2192
|
signature: recipe.signature,
|
|
2201
2193
|
};
|
|
2202
|
-
fastTailSplices++;
|
|
2203
2194
|
return fastResult;
|
|
2204
2195
|
}
|
|
2205
2196
|
}
|
package/src/fuzzy.ts
CHANGED
|
@@ -403,7 +403,7 @@ export class FuzzyText {
|
|
|
403
403
|
* Filter and sort items by fuzzy match quality (best matches first).
|
|
404
404
|
* Supports space-separated tokens: all tokens must match.
|
|
405
405
|
*/
|
|
406
|
-
export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[] {
|
|
406
|
+
export function fuzzyRank<T>(items: readonly T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[] {
|
|
407
407
|
if (!query.trim()) {
|
|
408
408
|
return items.map(item => ({ item, score: 0 }));
|
|
409
409
|
}
|
package/src/tui.ts
CHANGED
|
@@ -2773,9 +2773,10 @@ export class TUI extends Container {
|
|
|
2773
2773
|
const pendingAltExit = this.#pendingAltExit;
|
|
2774
2774
|
let buffer = this.#paintBeginSequence + pendingAltExit;
|
|
2775
2775
|
if (destructiveReset && TERMINAL.imageProtocol === ImageProtocol.Kitty) {
|
|
2776
|
-
//
|
|
2777
|
-
//
|
|
2778
|
-
//
|
|
2776
|
+
// A reset is explicitly destructive, so remove every placement—not only
|
|
2777
|
+
// the ones this TUI tracked—then resend images composed for the clean
|
|
2778
|
+
// replay. ED2 below reclaims the rest, but only on terminals that treat
|
|
2779
|
+
// an erase as a graphics clear; the explicit delete covers the others.
|
|
2779
2780
|
buffer += encodeKittyDeleteAllImages();
|
|
2780
2781
|
// `d=A` spares virtual placements, and erasing the placeholder text it
|
|
2781
2782
|
// leaves behind does not remove the prototype either. The ids this
|
|
@@ -2789,7 +2790,6 @@ export class TUI extends Container {
|
|
|
2789
2790
|
} else {
|
|
2790
2791
|
this.#imageBudget.takePurgeIds();
|
|
2791
2792
|
}
|
|
2792
|
-
for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
|
|
2793
2793
|
// ED2 MUST precede ED3: tmux implements ED2 by scrolling the live screen
|
|
2794
2794
|
// into pane history (so cleared content stays reachable), so erasing
|
|
2795
2795
|
// history first would let ED2 refill it with a copy of the old screen —
|
|
@@ -2797,7 +2797,16 @@ export class TUI extends Container {
|
|
|
2797
2797
|
// ED2-then-ED3 clears the screen, then wipes history including that
|
|
2798
2798
|
// push. On xterm-family terminals the two erases are independent and
|
|
2799
2799
|
// the order is irrelevant.
|
|
2800
|
+
//
|
|
2801
|
+
// Both erases MUST precede the image transmits. kitty and Ghostty treat
|
|
2802
|
+
// ED2 as a graphics clear that also frees every image left without a
|
|
2803
|
+
// placement — which is exactly what freshly transmitted data is until
|
|
2804
|
+
// the row carrying its placement is written. Transmitting first let the
|
|
2805
|
+
// erase reclaim the data, so the replay's placements then referenced an
|
|
2806
|
+
// image the terminal no longer had and every inline image vanished
|
|
2807
|
+
// after a settled width resize.
|
|
2800
2808
|
if (destructiveReset) buffer += "\x1b[H\x1b[2J\x1b[3J";
|
|
2809
|
+
for (const sequence of this.#imageBudget.takeTransmits()) buffer += sequence;
|
|
2801
2810
|
const diffable =
|
|
2802
2811
|
geometryStable &&
|
|
2803
2812
|
historyRows.length === 0 &&
|