@oh-my-pi/pi-tui 15.11.4 → 15.11.7
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 +15 -0
- package/dist/types/components/input.d.ts +2 -0
- package/dist/types/fuzzy.d.ts +10 -1
- package/package.json +3 -3
- package/src/components/input.ts +6 -3
- package/src/fuzzy.ts +214 -59
- package/src/tui.ts +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.5] - 2026-06-12
|
|
6
|
+
### Added
|
|
7
|
+
|
|
8
|
+
- Added `fuzzyRank` to return sorted matches together with a fuzzy score
|
|
9
|
+
- Added a configurable `Input.prompt` field (defaults to `"> "`; set to `""` for chrome-less embedding inside custom banners)
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Changed fuzzy matching to normalize queries and text into words, including camelCase and punctuation separators, before scoring
|
|
14
|
+
- Changed `Input.setValue` to place the cursor at the end of the new value instead of clamping it to its previous position, so typing after seeding a prefilled value appends rather than prepends
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Fixed multi-word searches so `fuzzyMatch` no longer matches when query letters are only scattered across unrelated words
|
|
19
|
+
|
|
5
20
|
## [15.11.4] - 2026-06-12
|
|
6
21
|
### Added
|
|
7
22
|
|
|
@@ -4,6 +4,8 @@ import { type Component, type Focusable } from "../tui";
|
|
|
4
4
|
*/
|
|
5
5
|
export declare class Input implements Component, Focusable {
|
|
6
6
|
#private;
|
|
7
|
+
/** Rendered before the editable area; set to "" for chrome-less embedding. */
|
|
8
|
+
prompt: string;
|
|
7
9
|
onSubmit?: (value: string) => void;
|
|
8
10
|
onEscape?: () => void;
|
|
9
11
|
/** Focusable interface - set by TUI when focus changes */
|
package/dist/types/fuzzy.d.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fuzzy matching utilities.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Matching is deliberately word-local for normal words. This keeps a query like
|
|
5
|
+
* "image provider" from matching a long setting description only because the
|
|
6
|
+
* letters i-m-a-g-e appear somewhere in order across unrelated words.
|
|
7
|
+
*
|
|
4
8
|
* Lower score = better match.
|
|
5
9
|
*/
|
|
6
10
|
export interface FuzzyMatch {
|
|
7
11
|
matches: boolean;
|
|
8
12
|
score: number;
|
|
9
13
|
}
|
|
14
|
+
export interface FuzzyFilterResult<T> {
|
|
15
|
+
item: T;
|
|
16
|
+
score: number;
|
|
17
|
+
}
|
|
10
18
|
export declare function fuzzyMatch(query: string, text: string): FuzzyMatch;
|
|
11
19
|
/**
|
|
12
20
|
* Filter and sort items by fuzzy match quality (best matches first).
|
|
13
21
|
* Supports space-separated tokens: all tokens must match.
|
|
14
22
|
*/
|
|
23
|
+
export declare function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[];
|
|
15
24
|
export declare function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "15.11.
|
|
4
|
+
"version": "15.11.7",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "15.11.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.11.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.11.7",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.11.7",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/input.ts
CHANGED
|
@@ -28,6 +28,8 @@ export class Input implements Component, Focusable {
|
|
|
28
28
|
#value: string = "";
|
|
29
29
|
#cursor: number = 0; // Cursor position in the value
|
|
30
30
|
#useTerminalCursor = false;
|
|
31
|
+
/** Rendered before the editable area; set to "" for chrome-less embedding. */
|
|
32
|
+
prompt = "> ";
|
|
31
33
|
onSubmit?: (value: string) => void;
|
|
32
34
|
onEscape?: () => void;
|
|
33
35
|
|
|
@@ -50,7 +52,8 @@ export class Input implements Component, Focusable {
|
|
|
50
52
|
|
|
51
53
|
setValue(value: string): void {
|
|
52
54
|
this.#value = value;
|
|
53
|
-
|
|
55
|
+
// Callers seed or replace the value wholesale; typing continues at the end.
|
|
56
|
+
this.#cursor = value.length;
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
@@ -399,8 +402,8 @@ export class Input implements Component, Focusable {
|
|
|
399
402
|
|
|
400
403
|
render(width: number): readonly string[] {
|
|
401
404
|
// Calculate visible window
|
|
402
|
-
const prompt =
|
|
403
|
-
const availableWidth = width - prompt
|
|
405
|
+
const prompt = this.prompt;
|
|
406
|
+
const availableWidth = width - visibleWidth(prompt);
|
|
404
407
|
|
|
405
408
|
if (availableWidth <= 0) {
|
|
406
409
|
return [prompt];
|
package/src/fuzzy.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fuzzy matching utilities.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Matching is deliberately word-local for normal words. This keeps a query like
|
|
5
|
+
* "image provider" from matching a long setting description only because the
|
|
6
|
+
* letters i-m-a-g-e appear somewhere in order across unrelated words.
|
|
7
|
+
*
|
|
4
8
|
* Lower score = better match.
|
|
5
9
|
*/
|
|
6
10
|
|
|
@@ -9,56 +13,107 @@ export interface FuzzyMatch {
|
|
|
9
13
|
score: number;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
export interface FuzzyFilterResult<T> {
|
|
17
|
+
item: T;
|
|
18
|
+
score: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface CharacterMatch {
|
|
22
|
+
matches: boolean;
|
|
23
|
+
score: number;
|
|
24
|
+
span: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SearchWord {
|
|
28
|
+
text: string;
|
|
29
|
+
index: number;
|
|
30
|
+
ordinal: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SearchIndex {
|
|
34
|
+
normalized: string;
|
|
35
|
+
compact: string;
|
|
36
|
+
/** Start offsets of each word within `compact` (cumulative word lengths). */
|
|
37
|
+
compactWordStarts: Set<number>;
|
|
38
|
+
words: SearchWord[];
|
|
39
|
+
}
|
|
40
|
+
|
|
12
41
|
const ALPHANUMERIC_SWAP_PENALTY = 5;
|
|
42
|
+
const COMPACT_PHRASE_BONUS = 1200;
|
|
43
|
+
const PHRASE_BONUS = 1000;
|
|
13
44
|
|
|
14
|
-
function
|
|
45
|
+
function normalizeForSearch(value: string): string {
|
|
46
|
+
return value
|
|
47
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
48
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
51
|
+
.trim()
|
|
52
|
+
.replace(/\s+/g, " ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildSearchIndex(text: string): SearchIndex {
|
|
56
|
+
const normalized = normalizeForSearch(text);
|
|
57
|
+
if (normalized.length === 0) {
|
|
58
|
+
return { normalized, compact: "", compactWordStarts: new Set(), words: [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const words: SearchWord[] = [];
|
|
62
|
+
const compactWordStarts = new Set<number>();
|
|
63
|
+
let index = 0;
|
|
64
|
+
let compactIndex = 0;
|
|
65
|
+
let ordinal = 0;
|
|
66
|
+
for (const word of normalized.split(" ")) {
|
|
67
|
+
words.push({ text: word, index, ordinal });
|
|
68
|
+
compactWordStarts.add(compactIndex);
|
|
69
|
+
index += word.length + 1;
|
|
70
|
+
compactIndex += word.length;
|
|
71
|
+
ordinal++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { normalized, compact: normalized.replaceAll(" ", ""), compactWordStarts, words };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function scoreCharacters(queryLower: string, textLower: string): CharacterMatch {
|
|
15
78
|
if (queryLower.length === 0) {
|
|
16
|
-
return { matches: true, score: 0 };
|
|
79
|
+
return { matches: true, score: 0, span: 0 };
|
|
17
80
|
}
|
|
18
81
|
|
|
19
82
|
if (queryLower.length > textLower.length) {
|
|
20
|
-
return { matches: false, score: 0 };
|
|
83
|
+
return { matches: false, score: 0, span: 0 };
|
|
21
84
|
}
|
|
22
85
|
|
|
23
86
|
let queryIndex = 0;
|
|
24
87
|
let score = 0;
|
|
88
|
+
let firstMatchIndex = -1;
|
|
25
89
|
let lastMatchIndex = -1;
|
|
26
90
|
let consecutiveMatches = 0;
|
|
27
91
|
|
|
28
92
|
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
|
|
29
93
|
if (textLower[i] === queryLower[queryIndex]) {
|
|
30
|
-
|
|
94
|
+
if (firstMatchIndex < 0) firstMatchIndex = i;
|
|
31
95
|
|
|
32
|
-
// Reward consecutive matches
|
|
33
96
|
if (lastMatchIndex === i - 1) {
|
|
34
97
|
consecutiveMatches++;
|
|
35
98
|
score -= consecutiveMatches * 5;
|
|
36
99
|
} else {
|
|
37
100
|
consecutiveMatches = 0;
|
|
38
|
-
// Penalize gaps
|
|
39
101
|
if (lastMatchIndex >= 0) {
|
|
40
102
|
score += (i - lastMatchIndex - 1) * 2;
|
|
41
103
|
}
|
|
42
104
|
}
|
|
43
105
|
|
|
44
|
-
// Reward word boundary matches
|
|
45
|
-
if (isWordBoundary) {
|
|
46
|
-
score -= 10;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Slight penalty for later matches
|
|
50
106
|
score += i * 0.1;
|
|
51
|
-
|
|
52
107
|
lastMatchIndex = i;
|
|
53
108
|
queryIndex++;
|
|
54
109
|
}
|
|
55
110
|
}
|
|
56
111
|
|
|
57
112
|
if (queryIndex < queryLower.length) {
|
|
58
|
-
return { matches: false, score: 0 };
|
|
113
|
+
return { matches: false, score: 0, span: 0 };
|
|
59
114
|
}
|
|
60
115
|
|
|
61
|
-
return { matches: true, score };
|
|
116
|
+
return { matches: true, score, span: lastMatchIndex - firstMatchIndex + 1 };
|
|
62
117
|
}
|
|
63
118
|
|
|
64
119
|
function buildAlphanumericSwapQueries(queryLower: string): string[] {
|
|
@@ -76,68 +131,168 @@ function buildAlphanumericSwapQueries(queryLower: string): string[] {
|
|
|
76
131
|
return [...variants];
|
|
77
132
|
}
|
|
78
133
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
134
|
+
function withPosition(score: number, index: number): number {
|
|
135
|
+
return score + index * 0.01;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isWordBoundaryPhrase(normalized: string, index: number, length: number): boolean {
|
|
139
|
+
const before = index === 0 || normalized[index - 1] === " ";
|
|
140
|
+
const afterIndex = index + length;
|
|
141
|
+
const after = afterIndex === normalized.length || normalized[afterIndex] === " ";
|
|
142
|
+
return before && after;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
|
|
146
|
+
if (word.text === token) {
|
|
147
|
+
return { matches: true, score: withPosition(-200, word.index) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (word.text.startsWith(token)) {
|
|
151
|
+
return { matches: true, score: withPosition(-170 + (word.text.length - token.length) * 0.5, word.index) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (token.startsWith(word.text) && token.length - word.text.length <= 2) {
|
|
155
|
+
return { matches: true, score: withPosition(-150 + token.length - word.text.length, word.index) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const substringIndex = word.text.indexOf(token);
|
|
159
|
+
if (substringIndex >= 0) {
|
|
160
|
+
return { matches: true, score: withPosition(-20 + substringIndex, word.index) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const characterMatch = scoreCharacters(token, word.text);
|
|
164
|
+
if (!characterMatch.matches) return null;
|
|
165
|
+
|
|
166
|
+
const maxSpan = Math.max(token.length + 2, Math.ceil(token.length * 1.8));
|
|
167
|
+
if (characterMatch.span > maxSpan) return null;
|
|
168
|
+
|
|
169
|
+
return { matches: true, score: withPosition(-40 + characterMatch.score, word.index) };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function scoreAcronym(token: string, index: SearchIndex): FuzzyMatch | null {
|
|
173
|
+
if (token.length < 2 || token.length > 4 || index.words.length === 0) return null;
|
|
174
|
+
|
|
175
|
+
let queryIndex = 0;
|
|
176
|
+
let firstOrdinal = -1;
|
|
177
|
+
let lastOrdinal = -1;
|
|
178
|
+
let firstTextIndex = 0;
|
|
82
179
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
180
|
+
for (const word of index.words) {
|
|
181
|
+
if (word.text[0] !== token[queryIndex]) continue;
|
|
182
|
+
if (firstOrdinal < 0) {
|
|
183
|
+
firstOrdinal = word.ordinal;
|
|
184
|
+
firstTextIndex = word.index;
|
|
185
|
+
}
|
|
186
|
+
lastOrdinal = word.ordinal;
|
|
187
|
+
queryIndex++;
|
|
188
|
+
if (queryIndex === token.length) break;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (queryIndex < token.length || firstOrdinal < 0 || lastOrdinal < 0) return null;
|
|
192
|
+
|
|
193
|
+
const wordSpan = lastOrdinal - firstOrdinal + 1;
|
|
194
|
+
if (wordSpan > token.length + 2) return null;
|
|
195
|
+
|
|
196
|
+
return { matches: true, score: withPosition(-30 + wordSpan * 4 - token.length * 2, firstTextIndex) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
|
|
200
|
+
if (token.length === 0) return { matches: true, score: 0 };
|
|
201
|
+
|
|
202
|
+
let best: FuzzyMatch | null = null;
|
|
203
|
+
const compactIndex = index.compact.indexOf(token);
|
|
204
|
+
if (compactIndex >= 0 && index.compactWordStarts.has(compactIndex)) {
|
|
205
|
+
best = { matches: true, score: withPosition(-140, compactIndex) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
for (const word of index.words) {
|
|
209
|
+
const match = scoreTokenAgainstWord(token, word);
|
|
210
|
+
if (match && (!best || match.score < best.score)) {
|
|
211
|
+
best = match;
|
|
212
|
+
}
|
|
86
213
|
}
|
|
87
214
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
215
|
+
const acronym = scoreAcronym(token, index);
|
|
216
|
+
if (acronym && (!best || acronym.score < best.score)) {
|
|
217
|
+
best = acronym;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return best ?? { matches: false, score: 0 };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function scoreToken(token: string, index: SearchIndex): FuzzyMatch {
|
|
224
|
+
let best = scoreTokenDirect(token, index);
|
|
225
|
+
if (best.matches) return best;
|
|
226
|
+
|
|
227
|
+
for (const variant of buildAlphanumericSwapQueries(token)) {
|
|
228
|
+
const match = scoreTokenDirect(variant, index);
|
|
91
229
|
if (!match.matches) continue;
|
|
92
230
|
const score = match.score + ALPHANUMERIC_SWAP_PENALTY;
|
|
93
|
-
if (!
|
|
94
|
-
|
|
231
|
+
if (!best.matches || score < best.score) {
|
|
232
|
+
best = { matches: true, score };
|
|
95
233
|
}
|
|
96
234
|
}
|
|
97
235
|
|
|
98
|
-
return
|
|
236
|
+
return best;
|
|
99
237
|
}
|
|
100
238
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
|
106
|
-
if (!query.trim()) {
|
|
107
|
-
return items;
|
|
239
|
+
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
|
240
|
+
const normalizedQuery = normalizeForSearch(query);
|
|
241
|
+
if (normalizedQuery.length === 0) {
|
|
242
|
+
return { matches: true, score: 0 };
|
|
108
243
|
}
|
|
109
244
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
245
|
+
const index = buildSearchIndex(text);
|
|
246
|
+
if (index.words.length === 0) {
|
|
247
|
+
return { matches: false, score: 0 };
|
|
248
|
+
}
|
|
114
249
|
|
|
115
|
-
|
|
116
|
-
|
|
250
|
+
let totalScore = 0;
|
|
251
|
+
const phraseIndex = index.normalized.indexOf(normalizedQuery);
|
|
252
|
+
if (phraseIndex >= 0 && isWordBoundaryPhrase(index.normalized, phraseIndex, normalizedQuery.length)) {
|
|
253
|
+
totalScore -= PHRASE_BONUS;
|
|
254
|
+
totalScore += phraseIndex * 0.01;
|
|
117
255
|
}
|
|
118
256
|
|
|
119
|
-
const
|
|
257
|
+
const compactQuery = normalizedQuery.replaceAll(" ", "");
|
|
258
|
+
const compactPhraseIndex = index.compact.indexOf(compactQuery);
|
|
259
|
+
if (compactPhraseIndex >= 0 && index.compactWordStarts.has(compactPhraseIndex)) {
|
|
260
|
+
totalScore -= COMPACT_PHRASE_BONUS;
|
|
261
|
+
totalScore += compactPhraseIndex * 0.01;
|
|
262
|
+
}
|
|
120
263
|
|
|
121
|
-
for (const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
for (const token of tokens) {
|
|
127
|
-
const match = fuzzyMatch(token, text);
|
|
128
|
-
if (match.matches) {
|
|
129
|
-
totalScore += match.score;
|
|
130
|
-
} else {
|
|
131
|
-
allMatch = false;
|
|
132
|
-
break;
|
|
133
|
-
}
|
|
264
|
+
for (const token of normalizedQuery.split(" ")) {
|
|
265
|
+
const match = scoreToken(token, index);
|
|
266
|
+
if (!match.matches) {
|
|
267
|
+
return { matches: false, score: 0 };
|
|
134
268
|
}
|
|
269
|
+
totalScore += match.score;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { matches: true, score: totalScore };
|
|
273
|
+
}
|
|
135
274
|
|
|
136
|
-
|
|
137
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Filter and sort items by fuzzy match quality (best matches first).
|
|
277
|
+
* Supports space-separated tokens: all tokens must match.
|
|
278
|
+
*/
|
|
279
|
+
export function fuzzyRank<T>(items: T[], query: string, getText: (item: T) => string): FuzzyFilterResult<T>[] {
|
|
280
|
+
if (!query.trim()) {
|
|
281
|
+
return items.map(item => ({ item, score: 0 }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const results: FuzzyFilterResult<T>[] = [];
|
|
285
|
+
for (const item of items) {
|
|
286
|
+
const match = fuzzyMatch(query, getText(item));
|
|
287
|
+
if (match.matches) {
|
|
288
|
+
results.push({ item, score: match.score });
|
|
138
289
|
}
|
|
139
290
|
}
|
|
140
291
|
|
|
141
|
-
results.sort((a, b) => a.
|
|
142
|
-
return results
|
|
292
|
+
results.sort((a, b) => a.score - b.score);
|
|
293
|
+
return results;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
|
|
297
|
+
return fuzzyRank(items, query, getText).map(result => result.item);
|
|
143
298
|
}
|
package/src/tui.ts
CHANGED
|
@@ -2712,6 +2712,18 @@ export class TUI extends Container {
|
|
|
2712
2712
|
#emitAltFrame(lines: string[], width: number, height: number): void {
|
|
2713
2713
|
const fitted: string[] = new Array(height);
|
|
2714
2714
|
for (let r = 0; r < height; r++) fitted[r] = lines[r] ?? "";
|
|
2715
|
+
// Flush queued image-data transmits (`a=t`, no visible output) before the
|
|
2716
|
+
// paint so id-keyed placements and placeholder cells composed into this
|
|
2717
|
+
// frame resolve against loaded data. The normal-screen path flushes these
|
|
2718
|
+
// ahead of its paint; without this, an image first shown inside a
|
|
2719
|
+
// fullscreen overlay (e.g. the settings shape preview) would render as
|
|
2720
|
+
// blank placeholder cells until the overlay closed.
|
|
2721
|
+
const imageTransmits = this.#imageBudget.takeTransmits();
|
|
2722
|
+
if (imageTransmits.length > 0) {
|
|
2723
|
+
let transmitBuffer = "";
|
|
2724
|
+
for (const seq of imageTransmits) transmitBuffer += seq;
|
|
2725
|
+
this.terminal.write(transmitBuffer);
|
|
2726
|
+
}
|
|
2715
2727
|
// Skip an identical repaint (the modal is mostly static between
|
|
2716
2728
|
// keystrokes) — unless a forced repaint (resetDisplay,
|
|
2717
2729
|
// requestRender(true)) is pending: the redraw gesture must repair a
|