@oh-my-pi/pi-tui 17.4.2 → 18.0.1
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 +67 -0
- package/README.md +1 -1
- package/dist/types/autocomplete.d.ts +17 -6
- package/dist/types/components/editor.d.ts +42 -5
- package/dist/types/components/image.d.ts +2 -5
- package/dist/types/components/markdown.d.ts +18 -24
- package/dist/types/components/select-list.d.ts +9 -0
- package/dist/types/components/text.d.ts +0 -1
- package/dist/types/keybindings.d.ts +5 -0
- package/dist/types/terminal-capabilities.d.ts +10 -3
- package/dist/types/terminal.d.ts +42 -2
- package/dist/types/tui.d.ts +63 -218
- package/dist/types/utils.d.ts +3 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +147 -61
- package/src/components/editor.ts +407 -157
- package/src/components/image.ts +4 -6
- package/src/components/loader.ts +1 -9
- package/src/components/markdown.ts +133 -417
- package/src/components/select-list.ts +67 -15
- package/src/components/text.ts +8 -18
- package/src/fuzzy.ts +63 -6
- package/src/keybindings.ts +5 -0
- package/src/stdin-buffer.ts +130 -20
- package/src/terminal-capabilities.ts +20 -3
- package/src/terminal.ts +147 -22
- package/src/tui.ts +628 -3510
- package/src/utils.ts +12 -3
|
@@ -27,6 +27,8 @@ export interface SelectItem {
|
|
|
27
27
|
value: string;
|
|
28
28
|
label: string;
|
|
29
29
|
description?: string;
|
|
30
|
+
/** Optional type-indicator glyph rendered in an aligned column before the label */
|
|
31
|
+
icon?: string;
|
|
30
32
|
/** Dim hint text shown inline after cursor when this item is selected */
|
|
31
33
|
hint?: string;
|
|
32
34
|
}
|
|
@@ -38,6 +40,8 @@ export interface SelectListTheme {
|
|
|
38
40
|
scrollInfo: (text: string) => string;
|
|
39
41
|
noMatch: (text: string) => string;
|
|
40
42
|
symbols: SymbolTheme;
|
|
43
|
+
/** Style for the type-icon column on unselected rows. Defaults to plain text. */
|
|
44
|
+
icon?: (text: string) => string;
|
|
41
45
|
/** Hover band applied to the full row under the mouse pointer. */
|
|
42
46
|
hovered?: (text: string) => string;
|
|
43
47
|
}
|
|
@@ -64,12 +68,18 @@ export interface SelectListLayoutOptions {
|
|
|
64
68
|
* wrap unevenly.
|
|
65
69
|
*/
|
|
66
70
|
wrapDescription?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Cap wrapped descriptions at this many visual rows; the last kept row is
|
|
73
|
+
* ellipsized. Only meaningful with `wrapDescription`.
|
|
74
|
+
*/
|
|
75
|
+
maxDescriptionRows?: number;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
type SelectItemLayout =
|
|
70
79
|
| {
|
|
71
80
|
kind: "description";
|
|
72
81
|
prefix: string;
|
|
82
|
+
iconCell: string;
|
|
73
83
|
truncatedValue: string;
|
|
74
84
|
spacing: string;
|
|
75
85
|
descriptionSingleLine: string;
|
|
@@ -79,6 +89,7 @@ type SelectItemLayout =
|
|
|
79
89
|
| {
|
|
80
90
|
kind: "primary";
|
|
81
91
|
prefix: string;
|
|
92
|
+
iconCell: string;
|
|
82
93
|
truncatedValue: string;
|
|
83
94
|
spacing: "";
|
|
84
95
|
};
|
|
@@ -172,6 +183,7 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
const primaryColumnWidth = this.#getPrimaryColumnWidth();
|
|
186
|
+
const iconColumnWidth = this.#getIconColumnWidth();
|
|
175
187
|
const wrapEnabled = this.layout.wrapDescription === true;
|
|
176
188
|
// `maxVisible` is the picker's visual row budget. For non-wrap layouts
|
|
177
189
|
// every item is one row, so the budget matches the original item count.
|
|
@@ -190,7 +202,9 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
190
202
|
rowCounts[i] = 0;
|
|
191
203
|
continue;
|
|
192
204
|
}
|
|
193
|
-
rowCounts[i] = wrapEnabled
|
|
205
|
+
rowCounts[i] = wrapEnabled
|
|
206
|
+
? this.#computeItemRowCount(item, conservativeRowWidth, primaryColumnWidth, iconColumnWidth)
|
|
207
|
+
: 1;
|
|
194
208
|
visualTotal += rowCounts[i];
|
|
195
209
|
}
|
|
196
210
|
|
|
@@ -211,7 +225,13 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
211
225
|
const item = this.#filteredItems[i];
|
|
212
226
|
if (!item) continue;
|
|
213
227
|
const hovered = this.theme.hovered !== undefined && i === this.#hoveredIndex && i !== this.#selectedIndex;
|
|
214
|
-
const itemRows = this.#renderItem(
|
|
228
|
+
const itemRows = this.#renderItem(
|
|
229
|
+
item,
|
|
230
|
+
i === this.#selectedIndex,
|
|
231
|
+
rowWidth,
|
|
232
|
+
primaryColumnWidth,
|
|
233
|
+
iconColumnWidth,
|
|
234
|
+
);
|
|
215
235
|
for (const row of itemRows) {
|
|
216
236
|
if (rows.length >= visualBudget) break;
|
|
217
237
|
this.#hitRows[rows.length] = i;
|
|
@@ -280,25 +300,33 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
280
300
|
}
|
|
281
301
|
}
|
|
282
302
|
|
|
283
|
-
#renderItem(
|
|
284
|
-
|
|
303
|
+
#renderItem(
|
|
304
|
+
item: SelectItem,
|
|
305
|
+
isSelected: boolean,
|
|
306
|
+
width: number,
|
|
307
|
+
primaryColumnWidth: number,
|
|
308
|
+
iconColumnWidth: number,
|
|
309
|
+
): string[] {
|
|
310
|
+
const layout = this.#computeItemLayout(item, isSelected, width, primaryColumnWidth, iconColumnWidth);
|
|
285
311
|
const { prefix, truncatedValue, spacing } = layout;
|
|
312
|
+
const iconCell =
|
|
313
|
+
layout.iconCell && !isSelected && this.theme.icon ? this.theme.icon(layout.iconCell) : layout.iconCell;
|
|
286
314
|
|
|
287
315
|
if (layout.kind === "description") {
|
|
288
316
|
const { descriptionSingleLine, descriptionStart, remainingWidth } = layout;
|
|
289
317
|
if (this.layout.wrapDescription) {
|
|
290
|
-
const wrapped =
|
|
318
|
+
const wrapped = this.#wrapDescription(descriptionSingleLine, remainingWidth);
|
|
291
319
|
if (wrapped.length === 0) wrapped.push("");
|
|
292
320
|
const indent = padding(descriptionStart);
|
|
293
321
|
const first = wrapped[0] ?? "";
|
|
294
322
|
if (isSelected) {
|
|
295
|
-
const rows = [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${first}`)];
|
|
323
|
+
const rows = [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}${spacing}${first}`)];
|
|
296
324
|
for (let i = 1; i < wrapped.length; i++) {
|
|
297
325
|
rows.push(this.theme.selectedText(`${indent}${wrapped[i]}`));
|
|
298
326
|
}
|
|
299
327
|
return rows;
|
|
300
328
|
}
|
|
301
|
-
const rows = [prefix + truncatedValue + this.theme.description(spacing + first)];
|
|
329
|
+
const rows = [prefix + iconCell + truncatedValue + this.theme.description(spacing + first)];
|
|
302
330
|
for (let i = 1; i < wrapped.length; i++) {
|
|
303
331
|
rows.push(this.theme.description(`${indent}${wrapped[i]}`));
|
|
304
332
|
}
|
|
@@ -307,25 +335,34 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
307
335
|
|
|
308
336
|
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
|
|
309
337
|
if (isSelected) {
|
|
310
|
-
return [this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`)];
|
|
338
|
+
return [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}${spacing}${truncatedDesc}`)];
|
|
311
339
|
}
|
|
312
|
-
return [prefix + truncatedValue + this.theme.description(spacing + truncatedDesc)];
|
|
340
|
+
return [prefix + iconCell + truncatedValue + this.theme.description(spacing + truncatedDesc)];
|
|
313
341
|
}
|
|
314
342
|
|
|
315
343
|
if (isSelected) {
|
|
316
|
-
return [this.theme.selectedText(`${prefix}${truncatedValue}`)];
|
|
344
|
+
return [this.theme.selectedText(`${prefix}${iconCell}${truncatedValue}`)];
|
|
317
345
|
}
|
|
318
|
-
return [prefix + truncatedValue];
|
|
346
|
+
return [prefix + iconCell + truncatedValue];
|
|
319
347
|
}
|
|
320
348
|
|
|
321
|
-
#computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number): number {
|
|
349
|
+
#computeItemRowCount(item: SelectItem, width: number, primaryColumnWidth: number, iconColumnWidth: number): number {
|
|
322
350
|
// Selection style does not change row count; pass isSelected=false to
|
|
323
351
|
// keep the cheap path uniform for items outside the visible window.
|
|
324
|
-
const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth);
|
|
352
|
+
const layout = this.#computeItemLayout(item, false, width, primaryColumnWidth, iconColumnWidth);
|
|
325
353
|
if (layout.kind !== "description") return 1;
|
|
326
|
-
const wrapped =
|
|
354
|
+
const wrapped = this.#wrapDescription(layout.descriptionSingleLine, layout.remainingWidth);
|
|
327
355
|
return Math.max(1, wrapped.length);
|
|
328
356
|
}
|
|
357
|
+
/** Wrap a description, capping it at `maxDescriptionRows` with a trailing ellipsis. */
|
|
358
|
+
#wrapDescription(description: string, width: number): string[] {
|
|
359
|
+
const wrapped = wrapTextWithAnsi(description, width);
|
|
360
|
+
const cap = this.layout.maxDescriptionRows;
|
|
361
|
+
if (cap === undefined || cap < 1 || wrapped.length <= cap) return wrapped;
|
|
362
|
+
const kept = wrapped.slice(0, cap);
|
|
363
|
+
kept[cap - 1] = truncateToWidth(`${kept[cap - 1]} …`, width, Ellipsis.Unicode);
|
|
364
|
+
return kept;
|
|
365
|
+
}
|
|
329
366
|
|
|
330
367
|
/**
|
|
331
368
|
* Pick a contiguous window of items containing `selectedIndex` such that
|
|
@@ -380,10 +417,15 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
380
417
|
isSelected: boolean,
|
|
381
418
|
width: number,
|
|
382
419
|
primaryColumnWidth: number,
|
|
420
|
+
iconColumnWidth: number,
|
|
383
421
|
): SelectItemLayout {
|
|
384
422
|
const cursor = this.theme.symbols?.cursor ?? DEFAULT_CURSOR_SYMBOL;
|
|
385
423
|
const prefix = isSelected ? `${cursor} ` : padding(visibleWidth(cursor) + 1);
|
|
386
|
-
|
|
424
|
+
// Icon column: every row reserves the same width so labels stay aligned
|
|
425
|
+
// whether or not an individual item carries an icon.
|
|
426
|
+
const iconWidth = item.icon ? visibleWidth(item.icon) : 0;
|
|
427
|
+
const iconCell = iconColumnWidth > 0 ? (item.icon ?? "") + padding(iconColumnWidth - iconWidth + 1) : "";
|
|
428
|
+
const prefixWidth = visibleWidth(prefix) + (iconColumnWidth > 0 ? iconColumnWidth + 1 : 0);
|
|
387
429
|
const descriptionSingleLine = item.description ? sanitizeSingleLine(item.description) : undefined;
|
|
388
430
|
|
|
389
431
|
if (descriptionSingleLine && width > 40) {
|
|
@@ -399,6 +441,7 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
399
441
|
return {
|
|
400
442
|
kind: "description",
|
|
401
443
|
prefix,
|
|
444
|
+
iconCell,
|
|
402
445
|
truncatedValue,
|
|
403
446
|
spacing,
|
|
404
447
|
descriptionSingleLine,
|
|
@@ -413,11 +456,20 @@ export class SelectList implements Component, MouseRoutable {
|
|
|
413
456
|
return {
|
|
414
457
|
kind: "primary",
|
|
415
458
|
prefix,
|
|
459
|
+
iconCell,
|
|
416
460
|
truncatedValue,
|
|
417
461
|
spacing: "",
|
|
418
462
|
};
|
|
419
463
|
}
|
|
420
464
|
|
|
465
|
+
#getIconColumnWidth(): number {
|
|
466
|
+
let widest = 0;
|
|
467
|
+
for (const item of this.#filteredItems) {
|
|
468
|
+
if (item.icon) widest = Math.max(widest, visibleWidth(item.icon));
|
|
469
|
+
}
|
|
470
|
+
return widest;
|
|
471
|
+
}
|
|
472
|
+
|
|
421
473
|
#getPrimaryColumnWidth(): number {
|
|
422
474
|
const { min, max } = this.#getPrimaryColumnBounds();
|
|
423
475
|
const widestPrimary = this.#filteredItems.reduce((widest, item) => {
|
package/src/components/text.ts
CHANGED
|
@@ -25,14 +25,11 @@ export class Text implements Component {
|
|
|
25
25
|
#paddingY: number; // Top/bottom padding
|
|
26
26
|
#customBgFn?: (text: string) => string;
|
|
27
27
|
#styleFn?: (text: string) => string;
|
|
28
|
-
#widthEpochRevision = 0;
|
|
29
|
-
|
|
30
28
|
#ignoreTight = false;
|
|
31
29
|
|
|
32
30
|
setIgnoreTight(ignore: boolean): this {
|
|
33
31
|
if (this.#ignoreTight === ignore) return this;
|
|
34
32
|
this.#ignoreTight = ignore;
|
|
35
|
-
this.#widthEpochRevision++;
|
|
36
33
|
this.invalidate();
|
|
37
34
|
return this;
|
|
38
35
|
}
|
|
@@ -40,7 +37,7 @@ export class Text implements Component {
|
|
|
40
37
|
// Cache for rendered output
|
|
41
38
|
#cachedText?: string;
|
|
42
39
|
#cachedWidth?: number;
|
|
43
|
-
#
|
|
40
|
+
#cachedWidthConfigEpoch?: number;
|
|
44
41
|
#cachedLines?: string[];
|
|
45
42
|
|
|
46
43
|
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
|
|
@@ -61,23 +58,17 @@ export class Text implements Component {
|
|
|
61
58
|
this.#text = text;
|
|
62
59
|
this.#cachedText = undefined;
|
|
63
60
|
this.#cachedWidth = undefined;
|
|
64
|
-
this.#
|
|
61
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
65
62
|
this.#cachedLines = undefined;
|
|
66
|
-
this.#widthEpochRevision++;
|
|
67
63
|
return true;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
getNativeScrollbackWidthEpochRevision(): number {
|
|
71
|
-
return this.#widthEpochRevision;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
66
|
setCustomBgFn(customBgFn?: (text: string) => string): void {
|
|
75
67
|
this.#customBgFn = customBgFn;
|
|
76
68
|
this.#cachedText = undefined;
|
|
77
69
|
this.#cachedWidth = undefined;
|
|
78
|
-
this.#
|
|
70
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
79
71
|
this.#cachedLines = undefined;
|
|
80
|
-
this.#widthEpochRevision++;
|
|
81
72
|
}
|
|
82
73
|
|
|
83
74
|
/**
|
|
@@ -90,16 +81,15 @@ export class Text implements Component {
|
|
|
90
81
|
this.#styleFn = styleFn;
|
|
91
82
|
this.#cachedText = undefined;
|
|
92
83
|
this.#cachedWidth = undefined;
|
|
93
|
-
this.#
|
|
84
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
94
85
|
this.#cachedLines = undefined;
|
|
95
|
-
this.#widthEpochRevision++;
|
|
96
86
|
return this;
|
|
97
87
|
}
|
|
98
88
|
|
|
99
89
|
invalidate(): void {
|
|
100
90
|
this.#cachedText = undefined;
|
|
101
91
|
this.#cachedWidth = undefined;
|
|
102
|
-
this.#
|
|
92
|
+
this.#cachedWidthConfigEpoch = undefined;
|
|
103
93
|
this.#cachedLines = undefined;
|
|
104
94
|
}
|
|
105
95
|
|
|
@@ -109,7 +99,7 @@ export class Text implements Component {
|
|
|
109
99
|
this.#cachedLines &&
|
|
110
100
|
this.#cachedText === this.#text &&
|
|
111
101
|
this.#cachedWidth === width &&
|
|
112
|
-
this.#
|
|
102
|
+
this.#cachedWidthConfigEpoch === getWidthConfigEpoch()
|
|
113
103
|
) {
|
|
114
104
|
return this.#cachedLines;
|
|
115
105
|
}
|
|
@@ -119,7 +109,7 @@ export class Text implements Component {
|
|
|
119
109
|
const result: string[] = [];
|
|
120
110
|
this.#cachedText = this.#text;
|
|
121
111
|
this.#cachedWidth = width;
|
|
122
|
-
this.#
|
|
112
|
+
this.#cachedWidthConfigEpoch = getWidthConfigEpoch();
|
|
123
113
|
this.#cachedLines = result;
|
|
124
114
|
return result;
|
|
125
115
|
}
|
|
@@ -175,7 +165,7 @@ export class Text implements Component {
|
|
|
175
165
|
// Update cache
|
|
176
166
|
this.#cachedText = this.#text;
|
|
177
167
|
this.#cachedWidth = width;
|
|
178
|
-
this.#
|
|
168
|
+
this.#cachedWidthConfigEpoch = getWidthConfigEpoch();
|
|
179
169
|
this.#cachedLines = result;
|
|
180
170
|
|
|
181
171
|
return result.length > 0 ? result : [""];
|
package/src/fuzzy.ts
CHANGED
|
@@ -42,6 +42,16 @@ const ALPHANUMERIC_SWAP_PENALTY = 5;
|
|
|
42
42
|
const COMPACT_PHRASE_BONUS = 1200;
|
|
43
43
|
const PHRASE_BONUS = 1000;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Shortest needle worth scanning past its leading occurrence for.
|
|
47
|
+
*
|
|
48
|
+
* One or two characters sit mid-word in nearly every candidate — "im" is inside
|
|
49
|
+
* "experimental" — so rescanning that short would hand the word-start bonus to
|
|
50
|
+
* the whole corpus at once and reshuffle the list on the opening keystrokes of a
|
|
51
|
+
* search instead of narrowing it. Shadowing only misleads for real phrases.
|
|
52
|
+
*/
|
|
53
|
+
const MIN_SHADOW_RESCAN_LENGTH = 3;
|
|
54
|
+
|
|
45
55
|
function normalizeForSearch(value: string): string {
|
|
46
56
|
return value
|
|
47
57
|
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
@@ -172,6 +182,53 @@ function isWordBoundaryPhrase(normalized: string, index: number, length: number)
|
|
|
172
182
|
return before && after;
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Offset of the first whole-word occurrence of `phrase` in `normalized`, or -1.
|
|
187
|
+
*
|
|
188
|
+
* A bare `indexOf` reports only the leading occurrence, so a qualifying match is
|
|
189
|
+
* dropped whenever an earlier non-qualifying one shadows it — the whole word
|
|
190
|
+
* "image" in "reimage image provider" loses to the "image" inside "reimage".
|
|
191
|
+
* Occurrences are scanned left to right, so the first qualifying hit is also the
|
|
192
|
+
* best-scoring one: the caller's position tiebreak grows with the offset.
|
|
193
|
+
*
|
|
194
|
+
* Only a hit buried inside a word can shadow. A leading hit that already starts
|
|
195
|
+
* a word is an ordinary prefix match — the query is "image" and the text says
|
|
196
|
+
* "images" — and it is scored exactly as before rather than borrowing a
|
|
197
|
+
* whole-word bonus from some later occurrence; `findCompactWordStart` treats its
|
|
198
|
+
* leading word-start hit the same way. The rescan is additionally limited to
|
|
199
|
+
* {@link MIN_SHADOW_RESCAN_LENGTH} and longer needles.
|
|
200
|
+
*/
|
|
201
|
+
function findWordBoundaryPhrase(normalized: string, phrase: string): number {
|
|
202
|
+
if (phrase.length === 0) return -1;
|
|
203
|
+
const first = normalized.indexOf(phrase);
|
|
204
|
+
if (first < 0) return -1;
|
|
205
|
+
if (isWordBoundaryPhrase(normalized, first, phrase.length)) return first;
|
|
206
|
+
if (first === 0 || normalized[first - 1] === " ") return -1;
|
|
207
|
+
if (phrase.length < MIN_SHADOW_RESCAN_LENGTH) return -1;
|
|
208
|
+
for (let at = normalized.indexOf(phrase, first + 1); at >= 0; at = normalized.indexOf(phrase, at + 1)) {
|
|
209
|
+
if (isWordBoundaryPhrase(normalized, at, phrase.length)) return at;
|
|
210
|
+
}
|
|
211
|
+
return -1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Offset of the first occurrence of `needle` that starts a word in `index.compact`,
|
|
216
|
+
* or -1. Same shadowing hazard, and the same length floor, as
|
|
217
|
+
* {@link findWordBoundaryPhrase}.
|
|
218
|
+
*/
|
|
219
|
+
function findCompactWordStart(index: SearchIndex, needle: string): number {
|
|
220
|
+
if (needle.length === 0) return -1;
|
|
221
|
+
const { compact, compactWordStarts } = index;
|
|
222
|
+
const first = compact.indexOf(needle);
|
|
223
|
+
if (first < 0) return -1;
|
|
224
|
+
if (compactWordStarts.has(first)) return first;
|
|
225
|
+
if (needle.length < MIN_SHADOW_RESCAN_LENGTH) return -1;
|
|
226
|
+
for (let at = compact.indexOf(needle, first + 1); at >= 0; at = compact.indexOf(needle, at + 1)) {
|
|
227
|
+
if (compactWordStarts.has(at)) return at;
|
|
228
|
+
}
|
|
229
|
+
return -1;
|
|
230
|
+
}
|
|
231
|
+
|
|
175
232
|
function scoreTokenAgainstWord(token: string, word: SearchWord): FuzzyMatch | null {
|
|
176
233
|
if (word.text === token) {
|
|
177
234
|
return { matches: true, score: withPosition(-200, word.index) };
|
|
@@ -230,8 +287,8 @@ function scoreTokenDirect(token: string, index: SearchIndex): FuzzyMatch {
|
|
|
230
287
|
if (token.length === 0) return { matches: true, score: 0 };
|
|
231
288
|
|
|
232
289
|
let best: FuzzyMatch | null = null;
|
|
233
|
-
const compactIndex = index
|
|
234
|
-
if (compactIndex >= 0
|
|
290
|
+
const compactIndex = findCompactWordStart(index, token);
|
|
291
|
+
if (compactIndex >= 0) {
|
|
235
292
|
best = { matches: true, score: withPosition(-140, compactIndex) };
|
|
236
293
|
}
|
|
237
294
|
|
|
@@ -290,14 +347,14 @@ function fuzzyMatchCore(pq: PreparedQuery | null, index: SearchIndex): FuzzyMatc
|
|
|
290
347
|
}
|
|
291
348
|
|
|
292
349
|
let totalScore = 0;
|
|
293
|
-
const phraseIndex = index.normalized
|
|
294
|
-
if (phraseIndex >= 0
|
|
350
|
+
const phraseIndex = findWordBoundaryPhrase(index.normalized, pq.normalized);
|
|
351
|
+
if (phraseIndex >= 0) {
|
|
295
352
|
totalScore -= PHRASE_BONUS;
|
|
296
353
|
totalScore += phraseIndex * 0.01;
|
|
297
354
|
}
|
|
298
355
|
|
|
299
|
-
const compactPhraseIndex = index
|
|
300
|
-
if (compactPhraseIndex >= 0
|
|
356
|
+
const compactPhraseIndex = findCompactWordStart(index, pq.compact);
|
|
357
|
+
if (compactPhraseIndex >= 0) {
|
|
301
358
|
totalScore -= COMPACT_PHRASE_BONUS;
|
|
302
359
|
totalScore += compactPhraseIndex * 0.01;
|
|
303
360
|
}
|
package/src/keybindings.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface Keybindings {
|
|
|
27
27
|
"tui.editor.yank": true;
|
|
28
28
|
"tui.editor.yankPop": true;
|
|
29
29
|
"tui.editor.undo": true;
|
|
30
|
+
"tui.editor.spellingSuggestions": true;
|
|
30
31
|
// Generic input actions
|
|
31
32
|
"tui.input.newLine": true;
|
|
32
33
|
"tui.input.submit": true;
|
|
@@ -118,6 +119,10 @@ export const TUI_KEYBINDINGS = {
|
|
|
118
119
|
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
|
|
119
120
|
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
|
|
120
121
|
"tui.editor.undo": { defaultKeys: ["ctrl+-", "ctrl+_"], description: "Undo" },
|
|
122
|
+
"tui.editor.spellingSuggestions": {
|
|
123
|
+
defaultKeys: "ctrl+.",
|
|
124
|
+
description: "Show spelling replacements",
|
|
125
|
+
},
|
|
121
126
|
"tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" },
|
|
122
127
|
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
|
|
123
128
|
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
|
package/src/stdin-buffer.ts
CHANGED
|
@@ -56,6 +56,17 @@ const PARTIAL_HOLD_MAX_MS = 150;
|
|
|
56
56
|
// so the string-terminator cap is generous.
|
|
57
57
|
const MAX_CSI_BYTES = 4096;
|
|
58
58
|
const MAX_STRING_SEQ_BYTES = 16 * 1024 * 1024;
|
|
59
|
+
// Torn-string discard bounds. When a capped or timeout-expired OSC/DCS/APC
|
|
60
|
+
// partial is dropped, the bytes that follow are mid-string payload (base64
|
|
61
|
+
// clipboard data, Sixel, …) up to the next ST/BEL. Replaying them as
|
|
62
|
+
// keystrokes types kilobytes of garbage into the focused component (kitty
|
|
63
|
+
// OSC 5522 image paste spam), so discard mode swallows the tail until a
|
|
64
|
+
// terminator — bounded by a byte budget for a terminator-less stream and an
|
|
65
|
+
// inactivity watchdog for a stream that simply stops.
|
|
66
|
+
const STRING_DISCARD_MAX_BYTES = 2 * MAX_STRING_SEQ_BYTES;
|
|
67
|
+
const STRING_DISCARD_INACTIVITY_MS = 1000;
|
|
68
|
+
// Partial that can only be the head of an OSC/DCS/APC string sequence.
|
|
69
|
+
const STRING_SEQ_PARTIAL = /^\x1b[\]P_]/;
|
|
59
70
|
|
|
60
71
|
// SGR mouse report bodies live between `<` and the terminating `M`/`m`.
|
|
61
72
|
// Matched only when the trailing byte is a valid terminator, so the regex
|
|
@@ -110,7 +121,8 @@ function isRawMultilineBurst(text: string): boolean {
|
|
|
110
121
|
* `end > pos` — complete sequence, exclusive end index.
|
|
111
122
|
* `-1` — incomplete, still under the per-type cap; buffer for more.
|
|
112
123
|
* `-2` — incomplete and the prefix already spans the per-type cap;
|
|
113
|
-
* the caller flushes
|
|
124
|
+
* the caller cap-flushes CSI as raw bytes; string types
|
|
125
|
+
* (OSC/DCS/APC) are dropped and their tail discarded.
|
|
114
126
|
*/
|
|
115
127
|
function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSearchFrom: number): number {
|
|
116
128
|
if (pos + 1 >= length) return -1;
|
|
@@ -211,17 +223,6 @@ function resolveEscapeEnd(buffer: string, pos: number, length: number, resumeSea
|
|
|
211
223
|
}
|
|
212
224
|
}
|
|
213
225
|
|
|
214
|
-
/**
|
|
215
|
-
* Per-type cap used to flush the incomplete prefix when `resolveEscapeEnd`
|
|
216
|
-
* returns -2. The cap keeps issue-4073's malformed streamed CSI/OSC/…
|
|
217
|
-
* bounded in both work and memory.
|
|
218
|
-
*/
|
|
219
|
-
function escapeCapFor(next: number): number {
|
|
220
|
-
// OSC/DCS/APC carry the large payloads (image paste, Sixel); CSI stays
|
|
221
|
-
// tight because real CSI keys/mouse/responses fit comfortably below 4 KiB.
|
|
222
|
-
return next === 0x5d || next === 0x50 || next === 0x5f ? MAX_STRING_SEQ_BYTES : MAX_CSI_BYTES;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
226
|
/**
|
|
226
227
|
* Split accumulated buffer into complete sequences
|
|
227
228
|
*/
|
|
@@ -236,7 +237,7 @@ function parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | unde
|
|
|
236
237
|
function extractCompleteSequences(
|
|
237
238
|
buffer: string,
|
|
238
239
|
resumeSearchFrom: number,
|
|
239
|
-
): { sequences: string[]; remainder: string; resumeSearchFrom: number } {
|
|
240
|
+
): { sequences: string[]; remainder: string; resumeSearchFrom: number; discardFrom?: number } {
|
|
240
241
|
const sequences: string[] = [];
|
|
241
242
|
const length = buffer.length;
|
|
242
243
|
let pos = 0;
|
|
@@ -295,8 +296,8 @@ function extractCompleteSequences(
|
|
|
295
296
|
return { sequences, remainder: buffer.slice(pos), resumeSearchFrom: 0 };
|
|
296
297
|
}
|
|
297
298
|
if (innerEnd === -2) {
|
|
298
|
-
|
|
299
|
-
const flushEnd = Math.min(length, pos +
|
|
299
|
+
// `third` is CSI/SS3 here, never a string type.
|
|
300
|
+
const flushEnd = Math.min(length, pos + MAX_CSI_BYTES);
|
|
300
301
|
sequences.push(buffer.slice(pos, flushEnd));
|
|
301
302
|
pos = flushEnd;
|
|
302
303
|
hint = 0;
|
|
@@ -331,8 +332,14 @@ function extractCompleteSequences(
|
|
|
331
332
|
}
|
|
332
333
|
if (end === -2) {
|
|
333
334
|
const next = buffer.charCodeAt(pos + 1);
|
|
334
|
-
|
|
335
|
-
|
|
335
|
+
if (next === 0x5d || next === 0x50 || next === 0x5f) {
|
|
336
|
+
// Unterminated string sequence at the cap (issue #4073 case A):
|
|
337
|
+
// the head is junk and everything after it is mid-string
|
|
338
|
+
// payload. Hand both to the caller's discard mode instead of
|
|
339
|
+
// flushing the payload as per-character typed text.
|
|
340
|
+
return { sequences, remainder: "", resumeSearchFrom: 0, discardFrom: pos };
|
|
341
|
+
}
|
|
342
|
+
const flushEnd = Math.min(length, pos + MAX_CSI_BYTES);
|
|
336
343
|
sequences.push(buffer.slice(pos, flushEnd));
|
|
337
344
|
pos = flushEnd;
|
|
338
345
|
hint = 0;
|
|
@@ -399,6 +406,10 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
399
406
|
#escapeSearchOffset = 0;
|
|
400
407
|
#rawPasteCandidate = "";
|
|
401
408
|
#rawPasteTimer?: NodeJS.Timeout;
|
|
409
|
+
#stringDiscardActive = false;
|
|
410
|
+
#stringDiscardBytes = 0;
|
|
411
|
+
#stringDiscardEscHeld = false;
|
|
412
|
+
#stringDiscardWatchdog?: NodeJS.Timeout;
|
|
402
413
|
|
|
403
414
|
constructor(options: StdinBufferOptions = {}) {
|
|
404
415
|
super();
|
|
@@ -422,6 +433,12 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
422
433
|
} else {
|
|
423
434
|
str = data;
|
|
424
435
|
}
|
|
436
|
+
if (this.#stringDiscardActive) {
|
|
437
|
+
// Mid torn-string: swallow payload up to the terminator; only what
|
|
438
|
+
// follows the terminator re-enters normal parsing.
|
|
439
|
+
str = this.#consumeStringDiscard(str);
|
|
440
|
+
if (str.length === 0) return;
|
|
441
|
+
}
|
|
425
442
|
|
|
426
443
|
if (this.#flushDeferral && this.#isFreshEscapeAfterDeferredFlush(str)) {
|
|
427
444
|
// The buffered partial already hit its flush timeout. A new escape is
|
|
@@ -500,6 +517,18 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
500
517
|
}
|
|
501
518
|
|
|
502
519
|
const result = extractCompleteSequences(this.#buffer, this.#escapeSearchOffset);
|
|
520
|
+
if (result.discardFrom !== undefined) {
|
|
521
|
+
const junk = this.#buffer.slice(result.discardFrom);
|
|
522
|
+
this.#buffer = "";
|
|
523
|
+
this.#escapeSearchOffset = 0;
|
|
524
|
+
for (const sequence of result.sequences) {
|
|
525
|
+
this.#emitDataSequence(sequence);
|
|
526
|
+
}
|
|
527
|
+
this.#enterStringDiscard();
|
|
528
|
+
const after = this.#consumeStringDiscard(junk);
|
|
529
|
+
if (after.length > 0) this.process(after);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
503
532
|
this.#buffer = result.remainder;
|
|
504
533
|
this.#escapeSearchOffset = result.resumeSearchFrom;
|
|
505
534
|
|
|
@@ -662,7 +691,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
662
691
|
this.#timeout = undefined;
|
|
663
692
|
this.#flushDeferral = setTimeout(() => {
|
|
664
693
|
this.#flushDeferral = undefined;
|
|
665
|
-
this.#flushExpired();
|
|
694
|
+
this.#flushExpired(true);
|
|
666
695
|
});
|
|
667
696
|
}, this.#timeoutMs);
|
|
668
697
|
}
|
|
@@ -710,7 +739,7 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
710
739
|
}
|
|
711
740
|
|
|
712
741
|
/** Timeout-driven flush: hold unambiguous partials (bounded), else deliver. */
|
|
713
|
-
#flushExpired(): void {
|
|
742
|
+
#flushExpired(fromTimer = false): void {
|
|
714
743
|
if (this.#buffer.length === 0) {
|
|
715
744
|
this.#partialHoldStartMs = 0;
|
|
716
745
|
return;
|
|
@@ -723,12 +752,16 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
723
752
|
}
|
|
724
753
|
}
|
|
725
754
|
this.#partialHoldStartMs = 0;
|
|
726
|
-
for (const sequence of this
|
|
755
|
+
for (const sequence of this.#drainBuffered(fromTimer)) {
|
|
727
756
|
this.#emitDataSequence(sequence);
|
|
728
757
|
}
|
|
729
758
|
}
|
|
730
759
|
|
|
731
760
|
flush(): string[] {
|
|
761
|
+
return this.#drainBuffered(false);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
#drainBuffered(discardTornString: boolean): string[] {
|
|
732
765
|
this.#clearFlushTimer();
|
|
733
766
|
|
|
734
767
|
const rawCandidate = this.#takeRawPasteCandidate();
|
|
@@ -750,16 +783,93 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
|
|
750
783
|
// split in `extractCompleteSequences` and deliver two ESC events.
|
|
751
784
|
if (buffered === `${ESC}${ESC}`) {
|
|
752
785
|
sequences.push(ESC, ESC);
|
|
786
|
+
} else if (isKittyProtocolActive() && STRING_SEQ_PARTIAL.test(buffered)) {
|
|
787
|
+
// Torn OSC/DCS/APC head. Under the kitty keyboard protocol no key
|
|
788
|
+
// ever arrives as a bare `\x1b]`/`\x1bP`/`\x1b_`, so this is always
|
|
789
|
+
// terminal protocol data (kitty OSC 5522 clipboard read, graphics
|
|
790
|
+
// reply) whose tail is still in flight. Never deliver the head as a
|
|
791
|
+
// key; on a timeout flush also swallow the tail up to its
|
|
792
|
+
// terminator so kilobytes of base64 are not typed into the focused
|
|
793
|
+
// component (image paste spam).
|
|
794
|
+
if (discardTornString) this.#enterStringDiscard();
|
|
753
795
|
} else {
|
|
754
796
|
sequences.push(buffered);
|
|
755
797
|
}
|
|
756
798
|
return sequences;
|
|
757
799
|
}
|
|
758
800
|
|
|
801
|
+
/** Enter torn-string discard: swallow mid-string bytes until ST/BEL. */
|
|
802
|
+
#enterStringDiscard(): void {
|
|
803
|
+
this.#stringDiscardActive = true;
|
|
804
|
+
this.#stringDiscardBytes = 0;
|
|
805
|
+
this.#stringDiscardEscHeld = false;
|
|
806
|
+
this.#armStringDiscardWatchdog();
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
#exitStringDiscard(): void {
|
|
810
|
+
this.#stringDiscardActive = false;
|
|
811
|
+
this.#stringDiscardBytes = 0;
|
|
812
|
+
this.#stringDiscardEscHeld = false;
|
|
813
|
+
if (this.#stringDiscardWatchdog) {
|
|
814
|
+
clearTimeout(this.#stringDiscardWatchdog);
|
|
815
|
+
this.#stringDiscardWatchdog = undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Swallow mid-string bytes; returns the input remaining after the string
|
|
821
|
+
* terminator (empty while still inside the torn string). A trailing ESC is
|
|
822
|
+
* held across calls so an `ESC \` split between reads is still detected.
|
|
823
|
+
* The byte budget bounds a terminator-less stream; the inactivity watchdog
|
|
824
|
+
* recovers a stream that stops without ever terminating.
|
|
825
|
+
*/
|
|
826
|
+
#consumeStringDiscard(str: string): string {
|
|
827
|
+
if (this.#stringDiscardEscHeld) {
|
|
828
|
+
this.#stringDiscardEscHeld = false;
|
|
829
|
+
if (str.charCodeAt(0) === 0x5c /* \ */) {
|
|
830
|
+
this.#exitStringDiscard();
|
|
831
|
+
return str.slice(1);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
for (let i = 0; i < str.length; i++) {
|
|
835
|
+
const code = str.charCodeAt(i);
|
|
836
|
+
if (code === 0x07 /* BEL */) {
|
|
837
|
+
this.#exitStringDiscard();
|
|
838
|
+
return str.slice(i + 1);
|
|
839
|
+
}
|
|
840
|
+
if (code === 0x1b /* ESC */) {
|
|
841
|
+
if (i + 1 === str.length) {
|
|
842
|
+
this.#stringDiscardEscHeld = true;
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
if (str.charCodeAt(i + 1) === 0x5c /* \ */) {
|
|
846
|
+
this.#exitStringDiscard();
|
|
847
|
+
return str.slice(i + 2);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
this.#stringDiscardBytes += str.length;
|
|
852
|
+
if (this.#stringDiscardBytes > STRING_DISCARD_MAX_BYTES) {
|
|
853
|
+
this.#exitStringDiscard();
|
|
854
|
+
return "";
|
|
855
|
+
}
|
|
856
|
+
this.#armStringDiscardWatchdog();
|
|
857
|
+
return "";
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#armStringDiscardWatchdog(): void {
|
|
861
|
+
if (this.#stringDiscardWatchdog) clearTimeout(this.#stringDiscardWatchdog);
|
|
862
|
+
this.#stringDiscardWatchdog = setTimeout(() => {
|
|
863
|
+
this.#stringDiscardWatchdog = undefined;
|
|
864
|
+
this.#exitStringDiscard();
|
|
865
|
+
}, STRING_DISCARD_INACTIVITY_MS);
|
|
866
|
+
}
|
|
867
|
+
|
|
759
868
|
clear(): void {
|
|
760
869
|
this.#clearFlushTimer();
|
|
761
870
|
this.#clearPasteWatchdog();
|
|
762
871
|
this.#clearRawPasteTimer();
|
|
872
|
+
this.#exitStringDiscard();
|
|
763
873
|
this.#buffer = "";
|
|
764
874
|
this.#rawPasteCandidate = "";
|
|
765
875
|
this.#pasteMode = false;
|