@oh-my-pi/pi-tui 17.4.2 → 18.0.0
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 +41 -0
- package/README.md +1 -1
- package/dist/types/autocomplete.d.ts +10 -1
- package/dist/types/components/editor.d.ts +40 -1
- package/dist/types/components/markdown.d.ts +17 -0
- package/dist/types/components/select-list.d.ts +9 -0
- package/dist/types/keybindings.d.ts +5 -0
- package/dist/types/terminal.d.ts +32 -2
- package/dist/types/tui.d.ts +43 -0
- package/dist/types/utils.d.ts +3 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +84 -54
- package/src/components/editor.ts +273 -26
- package/src/components/markdown.ts +111 -36
- package/src/components/select-list.ts +67 -15
- package/src/keybindings.ts +5 -0
- package/src/terminal-capabilities.ts +7 -1
- package/src/terminal.ts +133 -22
- package/src/tui.ts +177 -23
- package/src/utils.ts +12 -3
package/src/components/editor.ts
CHANGED
|
@@ -46,6 +46,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
46
46
|
minPrimaryColumnWidth: 12,
|
|
47
47
|
maxPrimaryColumnWidth: 32,
|
|
48
48
|
wrapDescription: true,
|
|
49
|
+
maxDescriptionRows: 2,
|
|
49
50
|
overflowSearch: false,
|
|
50
51
|
};
|
|
51
52
|
|
|
@@ -379,6 +380,8 @@ interface LayoutLine {
|
|
|
379
380
|
text: string;
|
|
380
381
|
/** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */
|
|
381
382
|
width: number;
|
|
383
|
+
sourceLine: number;
|
|
384
|
+
sourceStartCol: number;
|
|
382
385
|
hasCursor: boolean;
|
|
383
386
|
cursorPos?: number;
|
|
384
387
|
}
|
|
@@ -412,6 +415,50 @@ interface HistoryStorage {
|
|
|
412
415
|
getRecent(limit: number): HistoryEntry[];
|
|
413
416
|
}
|
|
414
417
|
|
|
418
|
+
/** A synchronous replacement immediately before the editor cursor. */
|
|
419
|
+
export interface EditorInlineReplacement {
|
|
420
|
+
/** UTF-16 code units to remove immediately before the cursor. */
|
|
421
|
+
replaceLen: number;
|
|
422
|
+
/** Literal text inserted where the removed suffix started. */
|
|
423
|
+
insert: string;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Replacement candidates and the current-line span they replace. */
|
|
427
|
+
export interface EditorWordReplacements {
|
|
428
|
+
line: number;
|
|
429
|
+
startCol: number;
|
|
430
|
+
endCol: number;
|
|
431
|
+
items: readonly string[];
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Source location for one visual text segment passed to `decorateText`. */
|
|
435
|
+
export interface EditorTextDecorationContext {
|
|
436
|
+
line: number;
|
|
437
|
+
startCol: number;
|
|
438
|
+
endCol: number;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Optional prose assistance kept separate from command/file autocomplete.
|
|
443
|
+
* Hosts independently decide whether word completion and autocorrection are enabled.
|
|
444
|
+
*/
|
|
445
|
+
export interface EditorTextAssistProvider {
|
|
446
|
+
/** Return ghost-text suffix for the partial word at the cursor, or `null`. */
|
|
447
|
+
getWordCompletion?(lines: string[], cursorLine: number, cursorCol: number): string | null;
|
|
448
|
+
/** Return a correction after one single-character insertion, or `null`. */
|
|
449
|
+
tryAutocorrect?(
|
|
450
|
+
lines: string[],
|
|
451
|
+
cursorLine: number,
|
|
452
|
+
cursorCol: number,
|
|
453
|
+
): EditorInlineReplacement | null | Promise<EditorInlineReplacement | null>;
|
|
454
|
+
/** Return replacement candidates for the misspelled word at the cursor. */
|
|
455
|
+
getWordReplacements?(
|
|
456
|
+
lines: string[],
|
|
457
|
+
cursorLine: number,
|
|
458
|
+
cursorCol: number,
|
|
459
|
+
): EditorWordReplacements | null | Promise<EditorWordReplacements | null>;
|
|
460
|
+
}
|
|
461
|
+
|
|
415
462
|
type HistoryCursorAnchor = "start" | "end";
|
|
416
463
|
|
|
417
464
|
export class Editor implements Component, Focusable {
|
|
@@ -437,7 +484,7 @@ export class Editor implements Component, Focusable {
|
|
|
437
484
|
/** Optional hook that decorates displayed user text after source-text layout.
|
|
438
485
|
* Width-changing output is allowed on lines without the cursor; it is truncated
|
|
439
486
|
* to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
|
|
440
|
-
decorateText: ((text: string) => string) | undefined;
|
|
487
|
+
decorateText: ((text: string, context: EditorTextDecorationContext) => string) | undefined;
|
|
441
488
|
#promptGutter: string | undefined;
|
|
442
489
|
|
|
443
490
|
// Store last layout width for cursor navigation
|
|
@@ -473,12 +520,20 @@ export class Editor implements Component, Focusable {
|
|
|
473
520
|
|
|
474
521
|
// Autocomplete support
|
|
475
522
|
#autocompleteProvider?: AutocompleteProvider;
|
|
523
|
+
#textAssistProvider?: EditorTextAssistProvider;
|
|
476
524
|
#autocompleteList?: SelectList;
|
|
477
|
-
#autocompleteState: "regular" | "force" | null = null;
|
|
525
|
+
#autocompleteState: "regular" | "force" | "assist" | null = null;
|
|
526
|
+
#textAssistReplacement:
|
|
527
|
+
| { line: number; startCol: number; endCol: number; original: string; cursorOffset: number }
|
|
528
|
+
| undefined;
|
|
478
529
|
#autocompletePrefix: string = "";
|
|
479
530
|
#autocompleteRequestId: number = 0;
|
|
480
|
-
#autocompleteMaxVisible: number =
|
|
531
|
+
#autocompleteMaxVisible: number = 10;
|
|
481
532
|
onAutocompleteUpdate?: () => void;
|
|
533
|
+
/** Called after an async text-assist result mutates the document outside an input event, so hosts can schedule a repaint. */
|
|
534
|
+
onTextAssistApplied?: () => void;
|
|
535
|
+
/** Terminal height source for clamping the autocomplete dropdown. Hosts wire this to their Terminal's rows. */
|
|
536
|
+
viewportRowsProvider?: () => number;
|
|
482
537
|
|
|
483
538
|
// Paste tracking for large pastes
|
|
484
539
|
#pastes: Map<number, string> = new Map();
|
|
@@ -540,10 +595,21 @@ export class Editor implements Component, Focusable {
|
|
|
540
595
|
this.borderColor = theme.borderColor;
|
|
541
596
|
}
|
|
542
597
|
|
|
598
|
+
setTheme(theme: EditorTheme): void {
|
|
599
|
+
this.#theme = theme;
|
|
600
|
+
this.borderColor = theme.borderColor;
|
|
601
|
+
}
|
|
602
|
+
|
|
543
603
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
544
604
|
this.#autocompleteProvider = provider;
|
|
545
605
|
}
|
|
546
606
|
|
|
607
|
+
/** Install prose assistance without changing command/file autocomplete. */
|
|
608
|
+
setTextAssistProvider(provider: EditorTextAssistProvider | undefined): void {
|
|
609
|
+
this.#textAssistProvider = provider;
|
|
610
|
+
this.#widthEpochRevision++;
|
|
611
|
+
}
|
|
612
|
+
|
|
547
613
|
/**
|
|
548
614
|
* Set custom content for the top border (e.g., status line).
|
|
549
615
|
* Pass undefined to use the default plain border.
|
|
@@ -658,7 +724,7 @@ export class Editor implements Component, Focusable {
|
|
|
658
724
|
}
|
|
659
725
|
|
|
660
726
|
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
661
|
-
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) :
|
|
727
|
+
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 10;
|
|
662
728
|
if (this.#autocompleteMaxVisible !== newMaxVisible) {
|
|
663
729
|
this.#autocompleteMaxVisible = newMaxVisible;
|
|
664
730
|
if (this.#autocompleteState !== null) {
|
|
@@ -821,14 +887,38 @@ export class Editor implements Component, Focusable {
|
|
|
821
887
|
* the right boundary with `(?!\S)` would otherwise reject an otherwise-
|
|
822
888
|
* valid match at the cursor seam (e.g. `ultrathink` immediately followed
|
|
823
889
|
* by the marker stops glowing until a trailing character is typed). */
|
|
824
|
-
#decorate(text: string): string {
|
|
890
|
+
#decorate(text: string, context: EditorTextDecorationContext): string {
|
|
825
891
|
const decorate = this.decorateText;
|
|
826
892
|
if (decorate === undefined || text.length === 0) return text;
|
|
827
893
|
const idx = text.indexOf(CURSOR_MARKER);
|
|
828
|
-
|
|
894
|
+
const sourceLength = Math.max(0, context.endCol - context.startCol);
|
|
895
|
+
if (idx === -1) {
|
|
896
|
+
const decoratedLength = Math.min(text.length, sourceLength);
|
|
897
|
+
return (
|
|
898
|
+
decorate(text.slice(0, decoratedLength), {
|
|
899
|
+
...context,
|
|
900
|
+
endCol: context.startCol + decoratedLength,
|
|
901
|
+
}) + text.slice(decoratedLength)
|
|
902
|
+
);
|
|
903
|
+
}
|
|
829
904
|
const before = text.slice(0, idx);
|
|
830
905
|
const after = text.slice(idx + CURSOR_MARKER.length);
|
|
831
|
-
|
|
906
|
+
const beforeLength = Math.min(before.length, sourceLength);
|
|
907
|
+
const afterLength = Math.min(after.length, sourceLength - beforeLength);
|
|
908
|
+
const cursorCol = context.startCol + beforeLength;
|
|
909
|
+
return (
|
|
910
|
+
(beforeLength > 0 ? decorate(before.slice(0, beforeLength), { ...context, endCol: cursorCol }) : "") +
|
|
911
|
+
before.slice(beforeLength) +
|
|
912
|
+
CURSOR_MARKER +
|
|
913
|
+
(afterLength > 0
|
|
914
|
+
? decorate(after.slice(0, afterLength), {
|
|
915
|
+
...context,
|
|
916
|
+
startCol: cursorCol,
|
|
917
|
+
endCol: cursorCol + afterLength,
|
|
918
|
+
})
|
|
919
|
+
: "") +
|
|
920
|
+
after.slice(afterLength)
|
|
921
|
+
);
|
|
832
922
|
}
|
|
833
923
|
|
|
834
924
|
#getStyledInputCursor(): { text: string; width: number } {
|
|
@@ -1014,6 +1104,11 @@ export class Editor implements Component, Focusable {
|
|
|
1014
1104
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
1015
1105
|
let displayText = layoutLine.text;
|
|
1016
1106
|
let displayWidth = layoutLine.width;
|
|
1107
|
+
const decorationContext: EditorTextDecorationContext = {
|
|
1108
|
+
line: layoutLine.sourceLine,
|
|
1109
|
+
startCol: layoutLine.sourceStartCol,
|
|
1110
|
+
endCol: layoutLine.sourceStartCol + layoutLine.text.length,
|
|
1111
|
+
};
|
|
1017
1112
|
let cursorPaddingOverflow = 0;
|
|
1018
1113
|
let decorated = false;
|
|
1019
1114
|
let imeSafeCursorTail = false;
|
|
@@ -1104,7 +1199,14 @@ export class Editor implements Component, Focusable {
|
|
|
1104
1199
|
// Decorate the plain text on each side of the cursor glyph. The reverse-video
|
|
1105
1200
|
// reset (\x1b[0m) ends in "m" (a word char), so a boundary match on restAfter
|
|
1106
1201
|
// would fail in the whole-line fallback below — decorate the segments here.
|
|
1107
|
-
displayText =
|
|
1202
|
+
displayText =
|
|
1203
|
+
this.#decorate(before, { ...decorationContext, endCol: decorationContext.startCol + before.length }) +
|
|
1204
|
+
marker +
|
|
1205
|
+
cursor +
|
|
1206
|
+
this.#decorate(restAfter, {
|
|
1207
|
+
...decorationContext,
|
|
1208
|
+
startCol: decorationContext.startCol + before.length + firstGrapheme.length,
|
|
1209
|
+
});
|
|
1108
1210
|
decorated = true;
|
|
1109
1211
|
// displayWidth stays the same - we're replacing, not adding
|
|
1110
1212
|
} else if (this.cursorOverride) {
|
|
@@ -1156,7 +1258,7 @@ export class Editor implements Component, Focusable {
|
|
|
1156
1258
|
// the whole line. `#decorate` splits around CURSOR_MARKER so a keyword glued to
|
|
1157
1259
|
// the cursor still satisfies its right-boundary lookahead.
|
|
1158
1260
|
if (!decorated) {
|
|
1159
|
-
displayText = this.#decorate(displayText);
|
|
1261
|
+
displayText = this.#decorate(displayText, decorationContext);
|
|
1160
1262
|
}
|
|
1161
1263
|
if (!hasCursor) {
|
|
1162
1264
|
// Undecorated, unsliced lines keep their carried width; any
|
|
@@ -1190,6 +1292,12 @@ export class Editor implements Component, Focusable {
|
|
|
1190
1292
|
|
|
1191
1293
|
// Add autocomplete list if active
|
|
1192
1294
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1295
|
+
// Clamp the dropdown to the terminal viewport: the editor rows already
|
|
1296
|
+
// rendered above plus a small reserve must stay visible.
|
|
1297
|
+
const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24;
|
|
1298
|
+
this.#autocompleteList.setMaxVisible(
|
|
1299
|
+
Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)),
|
|
1300
|
+
);
|
|
1193
1301
|
const autocompleteResult = this.#autocompleteList.render(width);
|
|
1194
1302
|
result.push(...autocompleteResult);
|
|
1195
1303
|
}
|
|
@@ -1276,6 +1384,11 @@ export class Editor implements Component, Focusable {
|
|
|
1276
1384
|
return;
|
|
1277
1385
|
}
|
|
1278
1386
|
|
|
1387
|
+
if (kb.matchesCanonical(canonical, "tui.editor.spellingSuggestions")) {
|
|
1388
|
+
void this.#showSpellingSuggestions();
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1279
1392
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1280
1393
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1281
1394
|
// Escape - cancel autocomplete
|
|
@@ -1283,6 +1396,15 @@ export class Editor implements Component, Focusable {
|
|
|
1283
1396
|
this.#cancelAutocomplete(true);
|
|
1284
1397
|
return;
|
|
1285
1398
|
}
|
|
1399
|
+
if (
|
|
1400
|
+
this.#autocompleteState === "assist" &&
|
|
1401
|
+
(kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1402
|
+
data === "\n" ||
|
|
1403
|
+
kb.matchesCanonical(canonical, "tui.input.tab"))
|
|
1404
|
+
) {
|
|
1405
|
+
this.#applySpellingSuggestion();
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1286
1408
|
// Let the autocomplete list handle navigation and selection
|
|
1287
1409
|
else if (
|
|
1288
1410
|
kb.matchesCanonical(canonical, "tui.select.up") ||
|
|
@@ -1422,9 +1544,14 @@ export class Editor implements Component, Focusable {
|
|
|
1422
1544
|
// Let them fall through to normal character handling
|
|
1423
1545
|
}
|
|
1424
1546
|
|
|
1547
|
+
if (this.#autocompleteState === "assist") {
|
|
1548
|
+
this.#cancelAutocomplete();
|
|
1549
|
+
this.onAutocompleteUpdate?.();
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1425
1552
|
// Tab key - context-aware completion (but not when already autocompleting)
|
|
1426
1553
|
if (kb.matchesCanonical(canonical, "tui.input.tab") && !this.#autocompleteState) {
|
|
1427
|
-
this.#handleTabCompletion();
|
|
1554
|
+
void this.#handleTabCompletion();
|
|
1428
1555
|
return;
|
|
1429
1556
|
}
|
|
1430
1557
|
|
|
@@ -1650,6 +1777,8 @@ export class Editor implements Component, Focusable {
|
|
|
1650
1777
|
layoutLines.push({
|
|
1651
1778
|
text: "",
|
|
1652
1779
|
width: 0,
|
|
1780
|
+
sourceLine: 0,
|
|
1781
|
+
sourceStartCol: 0,
|
|
1653
1782
|
hasCursor: true,
|
|
1654
1783
|
cursorPos: 0,
|
|
1655
1784
|
});
|
|
@@ -1668,6 +1797,8 @@ export class Editor implements Component, Focusable {
|
|
|
1668
1797
|
layoutLines.push({
|
|
1669
1798
|
text: line,
|
|
1670
1799
|
width: lineVisibleWidth,
|
|
1800
|
+
sourceLine: i,
|
|
1801
|
+
sourceStartCol: 0,
|
|
1671
1802
|
hasCursor: true,
|
|
1672
1803
|
cursorPos: this.#state.cursorCol,
|
|
1673
1804
|
});
|
|
@@ -1675,6 +1806,8 @@ export class Editor implements Component, Focusable {
|
|
|
1675
1806
|
layoutLines.push({
|
|
1676
1807
|
text: line,
|
|
1677
1808
|
width: lineVisibleWidth,
|
|
1809
|
+
sourceLine: i,
|
|
1810
|
+
sourceStartCol: 0,
|
|
1678
1811
|
hasCursor: false,
|
|
1679
1812
|
});
|
|
1680
1813
|
}
|
|
@@ -1716,6 +1849,8 @@ export class Editor implements Component, Focusable {
|
|
|
1716
1849
|
layoutLines.push({
|
|
1717
1850
|
text: chunk.text,
|
|
1718
1851
|
width: chunk.width,
|
|
1852
|
+
sourceLine: i,
|
|
1853
|
+
sourceStartCol: chunk.startIndex,
|
|
1719
1854
|
hasCursor: true,
|
|
1720
1855
|
cursorPos: adjustedCursorPos,
|
|
1721
1856
|
});
|
|
@@ -1723,6 +1858,8 @@ export class Editor implements Component, Focusable {
|
|
|
1723
1858
|
layoutLines.push({
|
|
1724
1859
|
text: chunk.text,
|
|
1725
1860
|
width: chunk.width,
|
|
1861
|
+
sourceLine: i,
|
|
1862
|
+
sourceStartCol: chunk.startIndex,
|
|
1726
1863
|
hasCursor: false,
|
|
1727
1864
|
});
|
|
1728
1865
|
}
|
|
@@ -2006,6 +2143,27 @@ export class Editor implements Component, Focusable {
|
|
|
2006
2143
|
}
|
|
2007
2144
|
|
|
2008
2145
|
// All the editor methods from before...
|
|
2146
|
+
#applyInlineReplacement(replacement: EditorInlineReplacement): boolean {
|
|
2147
|
+
if (
|
|
2148
|
+
!Number.isInteger(replacement.replaceLen) ||
|
|
2149
|
+
replacement.replaceLen < 0 ||
|
|
2150
|
+
replacement.replaceLen > this.#state.cursorCol
|
|
2151
|
+
) {
|
|
2152
|
+
return false;
|
|
2153
|
+
}
|
|
2154
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
2155
|
+
const before = line.slice(0, this.#state.cursorCol - replacement.replaceLen);
|
|
2156
|
+
const after = line.slice(this.#state.cursorCol);
|
|
2157
|
+
this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
|
|
2158
|
+
this.#setCursorCol(before.length + replacement.insert.length);
|
|
2159
|
+
this.onChange?.(this.getText());
|
|
2160
|
+
if (this.#autocompleteState) {
|
|
2161
|
+
this.#cancelAutocomplete();
|
|
2162
|
+
this.onAutocompleteUpdate?.();
|
|
2163
|
+
}
|
|
2164
|
+
return true;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2009
2167
|
#insertCharacter(char: string): void {
|
|
2010
2168
|
this.#exitHistoryForEditing();
|
|
2011
2169
|
// Undo coalescing: consecutive word typing collapses into one undo unit
|
|
@@ -2031,22 +2189,30 @@ export class Editor implements Component, Focusable {
|
|
|
2031
2189
|
// Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
|
|
2032
2190
|
// Runs before autocomplete trigger so the popup doesn't briefly chase a
|
|
2033
2191
|
// prefix that's about to be rewritten.
|
|
2034
|
-
if (char.length === 1
|
|
2192
|
+
if (char.length === 1) {
|
|
2035
2193
|
const replaceLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2036
2194
|
const textBeforeCursor = replaceLine.slice(0, this.#state.cursorCol);
|
|
2037
|
-
const
|
|
2038
|
-
if (
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2195
|
+
const inlineReplacement = this.#autocompleteProvider?.trySyncInlineReplace?.(textBeforeCursor);
|
|
2196
|
+
if (inlineReplacement && this.#applyInlineReplacement(inlineReplacement)) return;
|
|
2197
|
+
const cursorLine = this.#state.cursorLine;
|
|
2198
|
+
const cursorCol = this.#state.cursorCol;
|
|
2199
|
+
const currentLine = this.#state.lines[cursorLine] ?? "";
|
|
2200
|
+
const autocorrection = this.#textAssistProvider?.tryAutocorrect?.(this.#state.lines, cursorLine, cursorCol);
|
|
2201
|
+
if (autocorrection instanceof Promise) {
|
|
2202
|
+
autocorrection
|
|
2203
|
+
.then(replacement => {
|
|
2204
|
+
if (
|
|
2205
|
+
replacement &&
|
|
2206
|
+
this.#state.cursorLine === cursorLine &&
|
|
2207
|
+
this.#state.cursorCol === cursorCol &&
|
|
2208
|
+
this.#state.lines[cursorLine] === currentLine &&
|
|
2209
|
+
this.#applyInlineReplacement(replacement)
|
|
2210
|
+
) {
|
|
2211
|
+
this.onTextAssistApplied?.();
|
|
2212
|
+
}
|
|
2213
|
+
})
|
|
2214
|
+
.catch(() => {});
|
|
2215
|
+
} else if (autocorrection && this.#applyInlineReplacement(autocorrection)) {
|
|
2050
2216
|
return;
|
|
2051
2217
|
}
|
|
2052
2218
|
}
|
|
@@ -3277,6 +3443,13 @@ export class Editor implements Component, Focusable {
|
|
|
3277
3443
|
}
|
|
3278
3444
|
|
|
3279
3445
|
async #handleTabCompletion(): Promise<void> {
|
|
3446
|
+
const wordCompletion = this.#getWordCompletion();
|
|
3447
|
+
if (wordCompletion) {
|
|
3448
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3449
|
+
const after = currentLine.slice(this.#state.cursorCol);
|
|
3450
|
+
this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
|
|
3451
|
+
return;
|
|
3452
|
+
}
|
|
3280
3453
|
if (!this.#autocompleteProvider) return;
|
|
3281
3454
|
|
|
3282
3455
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3293,6 +3466,68 @@ export class Editor implements Component, Focusable {
|
|
|
3293
3466
|
await this.#forceFileAutocomplete();
|
|
3294
3467
|
}
|
|
3295
3468
|
}
|
|
3469
|
+
async #showSpellingSuggestions(): Promise<void> {
|
|
3470
|
+
const cursorLine = this.#state.cursorLine;
|
|
3471
|
+
const cursorCol = this.#state.cursorCol;
|
|
3472
|
+
const lines = [...this.#state.lines];
|
|
3473
|
+
const result = this.#textAssistProvider?.getWordReplacements?.(lines, cursorLine, cursorCol);
|
|
3474
|
+
const replacements = result instanceof Promise ? await result.catch(() => null) : result;
|
|
3475
|
+
if (
|
|
3476
|
+
!replacements ||
|
|
3477
|
+
this.#state.cursorLine !== cursorLine ||
|
|
3478
|
+
this.#state.cursorCol !== cursorCol ||
|
|
3479
|
+
this.#state.lines[replacements.line] !== lines[replacements.line] ||
|
|
3480
|
+
replacements.line < 0 ||
|
|
3481
|
+
replacements.line >= this.#state.lines.length ||
|
|
3482
|
+
replacements.startCol < 0 ||
|
|
3483
|
+
replacements.endCol <= replacements.startCol ||
|
|
3484
|
+
replacements.items.length === 0
|
|
3485
|
+
) {
|
|
3486
|
+
return;
|
|
3487
|
+
}
|
|
3488
|
+
const line = this.#state.lines[replacements.line] ?? "";
|
|
3489
|
+
if (replacements.endCol > line.length) return;
|
|
3490
|
+
const original = line.slice(replacements.startCol, replacements.endCol);
|
|
3491
|
+
this.#autocompletePrefix = original;
|
|
3492
|
+
this.#autocompleteList = this.#createAutocompleteList(
|
|
3493
|
+
original,
|
|
3494
|
+
replacements.items.map(value => ({ value, label: value })),
|
|
3495
|
+
);
|
|
3496
|
+
this.#autocompleteState = "assist";
|
|
3497
|
+
this.#textAssistReplacement = {
|
|
3498
|
+
line: replacements.line,
|
|
3499
|
+
startCol: replacements.startCol,
|
|
3500
|
+
endCol: replacements.endCol,
|
|
3501
|
+
original,
|
|
3502
|
+
cursorOffset:
|
|
3503
|
+
replacements.line === this.#state.cursorLine ? Math.max(0, this.#state.cursorCol - replacements.endCol) : 0,
|
|
3504
|
+
};
|
|
3505
|
+
this.#widthEpochRevision++;
|
|
3506
|
+
this.onAutocompleteUpdate?.();
|
|
3507
|
+
}
|
|
3508
|
+
|
|
3509
|
+
#applySpellingSuggestion(): void {
|
|
3510
|
+
const replacement = this.#textAssistReplacement;
|
|
3511
|
+
const selected = this.#autocompleteList?.getSelectedItem();
|
|
3512
|
+
if (!replacement || !selected) {
|
|
3513
|
+
this.#cancelAutocomplete();
|
|
3514
|
+
return;
|
|
3515
|
+
}
|
|
3516
|
+
const line = this.#state.lines[replacement.line] ?? "";
|
|
3517
|
+
if (line.slice(replacement.startCol, replacement.endCol) !== replacement.original) {
|
|
3518
|
+
this.#cancelAutocomplete();
|
|
3519
|
+
return;
|
|
3520
|
+
}
|
|
3521
|
+
this.#recordUndoState();
|
|
3522
|
+
this.#state.lines[replacement.line] =
|
|
3523
|
+
line.slice(0, replacement.startCol) + selected.value + line.slice(replacement.endCol);
|
|
3524
|
+
this.#state.cursorLine = replacement.line;
|
|
3525
|
+
this.#setCursorCol(replacement.startCol + selected.value.length + replacement.cursorOffset);
|
|
3526
|
+
this.#lastAction = null;
|
|
3527
|
+
this.#cancelAutocomplete();
|
|
3528
|
+
this.onAutocompleteUpdate?.();
|
|
3529
|
+
this.onChange?.(this.getText());
|
|
3530
|
+
}
|
|
3296
3531
|
async #handleSlashCommandCompletion(): Promise<void> {
|
|
3297
3532
|
await this.#tryTriggerAutocomplete();
|
|
3298
3533
|
}
|
|
@@ -3334,6 +3569,7 @@ export class Editor implements Component, Focusable {
|
|
|
3334
3569
|
this.#autocompleteRequestId += 1;
|
|
3335
3570
|
this.#autocompleteState = null;
|
|
3336
3571
|
this.#autocompleteList = undefined;
|
|
3572
|
+
this.#textAssistReplacement = undefined;
|
|
3337
3573
|
this.#autocompletePrefix = "";
|
|
3338
3574
|
if (wasAutocompleting) this.#widthEpochRevision++;
|
|
3339
3575
|
if (notifyCancel && wasAutocompleting) {
|
|
@@ -3347,6 +3583,7 @@ export class Editor implements Component, Focusable {
|
|
|
3347
3583
|
|
|
3348
3584
|
async #updateAutocomplete(): Promise<void> {
|
|
3349
3585
|
if (!this.#autocompleteState || !this.#autocompleteProvider) return;
|
|
3586
|
+
if (this.#autocompleteState === "assist") return;
|
|
3350
3587
|
|
|
3351
3588
|
// In force mode, use forceFileAutocomplete to get suggestions
|
|
3352
3589
|
if (this.#autocompleteState === "force") {
|
|
@@ -3405,13 +3642,23 @@ export class Editor implements Component, Focusable {
|
|
|
3405
3642
|
|
|
3406
3643
|
// Fall back to provider's getInlineHint
|
|
3407
3644
|
if (this.#autocompleteProvider?.getInlineHint) {
|
|
3408
|
-
|
|
3645
|
+
const hint = this.#autocompleteProvider.getInlineHint(
|
|
3409
3646
|
this.#state.lines,
|
|
3410
3647
|
this.#state.cursorLine,
|
|
3411
3648
|
this.#state.cursorCol,
|
|
3412
3649
|
);
|
|
3650
|
+
if (hint) return hint;
|
|
3413
3651
|
}
|
|
3414
3652
|
|
|
3415
|
-
return
|
|
3653
|
+
return this.#getWordCompletion();
|
|
3654
|
+
}
|
|
3655
|
+
#getWordCompletion(): string | null {
|
|
3656
|
+
return (
|
|
3657
|
+
this.#textAssistProvider?.getWordCompletion?.(
|
|
3658
|
+
this.#state.lines,
|
|
3659
|
+
this.#state.cursorLine,
|
|
3660
|
+
this.#state.cursorCol,
|
|
3661
|
+
) ?? null
|
|
3662
|
+
);
|
|
3416
3663
|
}
|
|
3417
3664
|
}
|