@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
package/src/components/editor.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import {
|
|
3
|
+
type AutocompleteItem,
|
|
3
4
|
type AutocompleteProvider,
|
|
4
5
|
findLeadingSlashCommandStart,
|
|
5
6
|
findTrailingSlashCommandStart,
|
|
6
7
|
midPromptSkillTokenMatches,
|
|
8
|
+
SKILL_NAMESPACE,
|
|
7
9
|
} from "../autocomplete";
|
|
8
10
|
import { BracketedPasteHandler, decodeReencodedPasteControls } from "../bracketed-paste";
|
|
9
11
|
import { canonicalKeyId, getKeybindings, type KeybindingsManager } from "../keybindings";
|
|
@@ -46,6 +48,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
46
48
|
minPrimaryColumnWidth: 12,
|
|
47
49
|
maxPrimaryColumnWidth: 32,
|
|
48
50
|
wrapDescription: true,
|
|
51
|
+
maxDescriptionRows: 2,
|
|
49
52
|
overflowSearch: false,
|
|
50
53
|
};
|
|
51
54
|
|
|
@@ -379,6 +382,8 @@ interface LayoutLine {
|
|
|
379
382
|
text: string;
|
|
380
383
|
/** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */
|
|
381
384
|
width: number;
|
|
385
|
+
sourceLine: number;
|
|
386
|
+
sourceStartCol: number;
|
|
382
387
|
hasCursor: boolean;
|
|
383
388
|
cursorPos?: number;
|
|
384
389
|
}
|
|
@@ -412,7 +417,52 @@ interface HistoryStorage {
|
|
|
412
417
|
getRecent(limit: number): HistoryEntry[];
|
|
413
418
|
}
|
|
414
419
|
|
|
420
|
+
/** A synchronous replacement immediately before the editor cursor. */
|
|
421
|
+
export interface EditorInlineReplacement {
|
|
422
|
+
/** UTF-16 code units to remove immediately before the cursor. */
|
|
423
|
+
replaceLen: number;
|
|
424
|
+
/** Literal text inserted where the removed suffix started. */
|
|
425
|
+
insert: string;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Replacement candidates and the current-line span they replace. */
|
|
429
|
+
export interface EditorWordReplacements {
|
|
430
|
+
line: number;
|
|
431
|
+
startCol: number;
|
|
432
|
+
endCol: number;
|
|
433
|
+
items: readonly string[];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Source location for one visual text segment passed to `decorateText`. */
|
|
437
|
+
export interface EditorTextDecorationContext {
|
|
438
|
+
line: number;
|
|
439
|
+
startCol: number;
|
|
440
|
+
endCol: number;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Optional prose assistance kept separate from command/file autocomplete.
|
|
445
|
+
* Hosts independently decide whether word completion and autocorrection are enabled.
|
|
446
|
+
*/
|
|
447
|
+
export interface EditorTextAssistProvider {
|
|
448
|
+
/** Return ghost-text suffix for the partial word at the cursor, or `null`. */
|
|
449
|
+
getWordCompletion?(lines: string[], cursorLine: number, cursorCol: number): string | null;
|
|
450
|
+
/** Return a correction after one single-character insertion, or `null`. */
|
|
451
|
+
tryAutocorrect?(
|
|
452
|
+
lines: string[],
|
|
453
|
+
cursorLine: number,
|
|
454
|
+
cursorCol: number,
|
|
455
|
+
): EditorInlineReplacement | null | Promise<EditorInlineReplacement | null>;
|
|
456
|
+
/** Return replacement candidates for the misspelled word at the cursor. */
|
|
457
|
+
getWordReplacements?(
|
|
458
|
+
lines: string[],
|
|
459
|
+
cursorLine: number,
|
|
460
|
+
cursorCol: number,
|
|
461
|
+
): EditorWordReplacements | null | Promise<EditorWordReplacements | null>;
|
|
462
|
+
}
|
|
463
|
+
|
|
415
464
|
type HistoryCursorAnchor = "start" | "end";
|
|
465
|
+
type AutocompleteRequest = { kind: "regular"; explicitTab: boolean } | { kind: "force" };
|
|
416
466
|
|
|
417
467
|
export class Editor implements Component, Focusable {
|
|
418
468
|
#state: EditorState = {
|
|
@@ -420,9 +470,6 @@ export class Editor implements Component, Focusable {
|
|
|
420
470
|
cursorLine: 0,
|
|
421
471
|
cursorCol: 0,
|
|
422
472
|
};
|
|
423
|
-
#widthEpochText = "";
|
|
424
|
-
#widthEpochRevision = 0;
|
|
425
|
-
|
|
426
473
|
/** Focusable interface - set by TUI when focus changes */
|
|
427
474
|
focused: boolean = false;
|
|
428
475
|
|
|
@@ -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,24 @@ 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
|
-
#
|
|
531
|
+
#autocompletePendingRequest: AutocompleteRequest | undefined;
|
|
532
|
+
#autocompleteRequestRunning = false;
|
|
533
|
+
#autocompleteAbortController: AbortController | undefined;
|
|
534
|
+
#autocompleteWaiters: Array<() => void> = [];
|
|
535
|
+
#autocompleteMaxVisible: number = 10;
|
|
481
536
|
onAutocompleteUpdate?: () => void;
|
|
537
|
+
/** Called after an async text-assist result mutates the document outside an input event, so hosts can schedule a repaint. */
|
|
538
|
+
onTextAssistApplied?: () => void;
|
|
539
|
+
/** Terminal height source for clamping the autocomplete dropdown. Hosts wire this to their Terminal's rows. */
|
|
540
|
+
viewportRowsProvider?: () => number;
|
|
482
541
|
|
|
483
542
|
// Paste tracking for large pastes
|
|
484
543
|
#pastes: Map<number, string> = new Map();
|
|
@@ -530,9 +589,6 @@ export class Editor implements Component, Focusable {
|
|
|
530
589
|
// per-event rebuilds down to one per rendered frame (see #4145).
|
|
531
590
|
#topBorderContent?: EditorTopBorder;
|
|
532
591
|
#topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
|
|
533
|
-
#topBorderProviderWidth: number | undefined;
|
|
534
|
-
#topBorderProviderSignature: string | undefined;
|
|
535
|
-
#topBorderProviderRevision: number | undefined;
|
|
536
592
|
#borderVisible = true;
|
|
537
593
|
#borderStyle: EditorBorderStyle = "box";
|
|
538
594
|
constructor(theme: EditorTheme) {
|
|
@@ -540,10 +596,20 @@ export class Editor implements Component, Focusable {
|
|
|
540
596
|
this.borderColor = theme.borderColor;
|
|
541
597
|
}
|
|
542
598
|
|
|
599
|
+
setTheme(theme: EditorTheme): void {
|
|
600
|
+
this.#theme = theme;
|
|
601
|
+
this.borderColor = theme.borderColor;
|
|
602
|
+
}
|
|
603
|
+
|
|
543
604
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
544
605
|
this.#autocompleteProvider = provider;
|
|
545
606
|
}
|
|
546
607
|
|
|
608
|
+
/** Install prose assistance without changing command/file autocomplete. */
|
|
609
|
+
setTextAssistProvider(provider: EditorTextAssistProvider | undefined): void {
|
|
610
|
+
this.#textAssistProvider = provider;
|
|
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.
|
|
@@ -557,7 +623,6 @@ export class Editor implements Component, Focusable {
|
|
|
557
623
|
if (this.#topBorderContent?.content === content?.content && this.#topBorderContent?.width === content?.width)
|
|
558
624
|
return;
|
|
559
625
|
this.#topBorderContent = content;
|
|
560
|
-
this.#widthEpochRevision++;
|
|
561
626
|
}
|
|
562
627
|
|
|
563
628
|
/**
|
|
@@ -568,16 +633,10 @@ export class Editor implements Component, Focusable {
|
|
|
568
633
|
* Use this when the top border derives from state that mutates far faster
|
|
569
634
|
* than the render cadence (session events, streaming, subagent updates).
|
|
570
635
|
* The TUI already throttles renders, so a provider is invoked exactly once
|
|
571
|
-
* per frame and does no work between paints.
|
|
572
|
-
* distinguish concurrent status mutations from pure width reflow.
|
|
573
|
-
*/
|
|
636
|
+
* per frame and does no work between paints. */
|
|
574
637
|
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
|
|
575
638
|
if (this.#topBorderProvider === provider) return;
|
|
576
639
|
this.#topBorderProvider = provider;
|
|
577
|
-
this.#topBorderProviderWidth = undefined;
|
|
578
|
-
this.#topBorderProviderSignature = undefined;
|
|
579
|
-
this.#topBorderProviderRevision = undefined;
|
|
580
|
-
this.#widthEpochRevision++;
|
|
581
640
|
}
|
|
582
641
|
|
|
583
642
|
/**
|
|
@@ -586,7 +645,6 @@ export class Editor implements Component, Focusable {
|
|
|
586
645
|
setBorderVisible(borderVisible: boolean): void {
|
|
587
646
|
if (this.#borderVisible === borderVisible) return;
|
|
588
647
|
this.#borderVisible = borderVisible;
|
|
589
|
-
this.#widthEpochRevision++;
|
|
590
648
|
}
|
|
591
649
|
|
|
592
650
|
setPromptGutter(promptGutter: string | undefined): void {
|
|
@@ -599,7 +657,6 @@ export class Editor implements Component, Focusable {
|
|
|
599
657
|
setBorderStyle(style: EditorBorderStyle): void {
|
|
600
658
|
if (this.#borderStyle === style) return;
|
|
601
659
|
this.#borderStyle = style;
|
|
602
|
-
this.#widthEpochRevision++;
|
|
603
660
|
}
|
|
604
661
|
|
|
605
662
|
/** True while the autocomplete/slash-command menu is open below the editor. */
|
|
@@ -623,14 +680,12 @@ export class Editor implements Component, Focusable {
|
|
|
623
680
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
624
681
|
if (this.#useTerminalCursor === useTerminalCursor) return;
|
|
625
682
|
this.#useTerminalCursor = useTerminalCursor;
|
|
626
|
-
this.#widthEpochRevision++;
|
|
627
683
|
}
|
|
628
684
|
|
|
629
685
|
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
630
686
|
setImeSafeCursorLayout(enabled: boolean): void {
|
|
631
687
|
if (this.#imeSafeCursorLayout === enabled) return;
|
|
632
688
|
this.#imeSafeCursorLayout = enabled;
|
|
633
|
-
this.#widthEpochRevision++;
|
|
634
689
|
}
|
|
635
690
|
|
|
636
691
|
getUseTerminalCursor(): boolean {
|
|
@@ -640,7 +695,6 @@ export class Editor implements Component, Focusable {
|
|
|
640
695
|
setMaxHeight(maxHeight: number | undefined): void {
|
|
641
696
|
if (this.#maxHeight === maxHeight) return;
|
|
642
697
|
this.#maxHeight = maxHeight;
|
|
643
|
-
this.#widthEpochRevision++;
|
|
644
698
|
// Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
|
|
645
699
|
}
|
|
646
700
|
|
|
@@ -658,16 +712,16 @@ export class Editor implements Component, Focusable {
|
|
|
658
712
|
}
|
|
659
713
|
|
|
660
714
|
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
661
|
-
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) :
|
|
715
|
+
const newMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 10;
|
|
662
716
|
if (this.#autocompleteMaxVisible !== newMaxVisible) {
|
|
663
717
|
this.#autocompleteMaxVisible = newMaxVisible;
|
|
664
718
|
if (this.#autocompleteState !== null) {
|
|
665
719
|
this.#autocompleteList?.setMaxVisible(newMaxVisible);
|
|
666
|
-
this.#widthEpochRevision++;
|
|
667
720
|
}
|
|
668
721
|
}
|
|
669
722
|
}
|
|
670
723
|
|
|
724
|
+
/** Loads persistent prompts for navigation and enables future persistence. */
|
|
671
725
|
setHistoryStorage(storage: HistoryStorage): void {
|
|
672
726
|
this.#historyStorage = storage;
|
|
673
727
|
const recent = storage.getRecent(100);
|
|
@@ -682,13 +736,6 @@ export class Editor implements Component, Focusable {
|
|
|
682
736
|
addToHistory(text: string): void {
|
|
683
737
|
const trimmed = text.trim();
|
|
684
738
|
if (!trimmed) return;
|
|
685
|
-
// Don't add consecutive duplicates
|
|
686
|
-
if (this.#history.length > 0 && this.#history[0] === trimmed) return;
|
|
687
|
-
this.#history.unshift(trimmed);
|
|
688
|
-
// Limit history size
|
|
689
|
-
if (this.#history.length > 100) {
|
|
690
|
-
this.#history.pop();
|
|
691
|
-
}
|
|
692
739
|
|
|
693
740
|
const stor = this.#historyStorage;
|
|
694
741
|
if (stor) {
|
|
@@ -696,6 +743,14 @@ export class Editor implements Component, Focusable {
|
|
|
696
743
|
logger.error("HistoryStorage add failed", { error: String(error) });
|
|
697
744
|
});
|
|
698
745
|
}
|
|
746
|
+
|
|
747
|
+
// Don't add consecutive duplicates
|
|
748
|
+
if (this.#history.length > 0 && this.#history[0] === trimmed) return;
|
|
749
|
+
this.#history.unshift(trimmed);
|
|
750
|
+
// Limit history size
|
|
751
|
+
if (this.#history.length > 100) {
|
|
752
|
+
this.#history.pop();
|
|
753
|
+
}
|
|
699
754
|
}
|
|
700
755
|
|
|
701
756
|
#isEditorEmpty(): boolean {
|
|
@@ -821,14 +876,38 @@ export class Editor implements Component, Focusable {
|
|
|
821
876
|
* the right boundary with `(?!\S)` would otherwise reject an otherwise-
|
|
822
877
|
* valid match at the cursor seam (e.g. `ultrathink` immediately followed
|
|
823
878
|
* by the marker stops glowing until a trailing character is typed). */
|
|
824
|
-
#decorate(text: string): string {
|
|
879
|
+
#decorate(text: string, context: EditorTextDecorationContext): string {
|
|
825
880
|
const decorate = this.decorateText;
|
|
826
881
|
if (decorate === undefined || text.length === 0) return text;
|
|
827
882
|
const idx = text.indexOf(CURSOR_MARKER);
|
|
828
|
-
|
|
883
|
+
const sourceLength = Math.max(0, context.endCol - context.startCol);
|
|
884
|
+
if (idx === -1) {
|
|
885
|
+
const decoratedLength = Math.min(text.length, sourceLength);
|
|
886
|
+
return (
|
|
887
|
+
decorate(text.slice(0, decoratedLength), {
|
|
888
|
+
...context,
|
|
889
|
+
endCol: context.startCol + decoratedLength,
|
|
890
|
+
}) + text.slice(decoratedLength)
|
|
891
|
+
);
|
|
892
|
+
}
|
|
829
893
|
const before = text.slice(0, idx);
|
|
830
894
|
const after = text.slice(idx + CURSOR_MARKER.length);
|
|
831
|
-
|
|
895
|
+
const beforeLength = Math.min(before.length, sourceLength);
|
|
896
|
+
const afterLength = Math.min(after.length, sourceLength - beforeLength);
|
|
897
|
+
const cursorCol = context.startCol + beforeLength;
|
|
898
|
+
return (
|
|
899
|
+
(beforeLength > 0 ? decorate(before.slice(0, beforeLength), { ...context, endCol: cursorCol }) : "") +
|
|
900
|
+
before.slice(beforeLength) +
|
|
901
|
+
CURSOR_MARKER +
|
|
902
|
+
(afterLength > 0
|
|
903
|
+
? decorate(after.slice(0, afterLength), {
|
|
904
|
+
...context,
|
|
905
|
+
startCol: cursorCol,
|
|
906
|
+
endCol: cursorCol + afterLength,
|
|
907
|
+
})
|
|
908
|
+
: "") +
|
|
909
|
+
after.slice(afterLength)
|
|
910
|
+
);
|
|
832
911
|
}
|
|
833
912
|
|
|
834
913
|
#getStyledInputCursor(): { text: string; width: number } {
|
|
@@ -960,28 +1039,13 @@ export class Editor implements Component, Focusable {
|
|
|
960
1039
|
}
|
|
961
1040
|
|
|
962
1041
|
// Resolve the custom top-border content once per frame; the style decides
|
|
963
|
-
// how (and whether) to draw it. Provider
|
|
964
|
-
// per-event rebuilds
|
|
1042
|
+
// how (and whether) to draw it. Provider evaluation stays editor-owned,
|
|
1043
|
+
// coalescing per-event rebuilds to one per painted frame.
|
|
965
1044
|
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
|
966
1045
|
let topBorder: EditorTopBorder | undefined;
|
|
967
1046
|
if (style.statusAttachment !== "none") {
|
|
968
1047
|
if (this.#topBorderProvider) {
|
|
969
|
-
const previousWidth = this.#topBorderProviderWidth;
|
|
970
1048
|
topBorder = this.#topBorderProvider(topFillWidth);
|
|
971
|
-
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
|
|
972
|
-
const revision = topBorder?.revision;
|
|
973
|
-
if (
|
|
974
|
-
(previousWidth !== undefined &&
|
|
975
|
-
revision !== undefined &&
|
|
976
|
-
this.#topBorderProviderRevision !== undefined &&
|
|
977
|
-
revision !== this.#topBorderProviderRevision) ||
|
|
978
|
-
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
|
|
979
|
-
) {
|
|
980
|
-
this.#widthEpochRevision++;
|
|
981
|
-
}
|
|
982
|
-
this.#topBorderProviderWidth = topFillWidth;
|
|
983
|
-
this.#topBorderProviderSignature = signature;
|
|
984
|
-
this.#topBorderProviderRevision = revision;
|
|
985
1049
|
} else {
|
|
986
1050
|
topBorder = this.#topBorderContent;
|
|
987
1051
|
}
|
|
@@ -1014,6 +1078,11 @@ export class Editor implements Component, Focusable {
|
|
|
1014
1078
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
1015
1079
|
let displayText = layoutLine.text;
|
|
1016
1080
|
let displayWidth = layoutLine.width;
|
|
1081
|
+
const decorationContext: EditorTextDecorationContext = {
|
|
1082
|
+
line: layoutLine.sourceLine,
|
|
1083
|
+
startCol: layoutLine.sourceStartCol,
|
|
1084
|
+
endCol: layoutLine.sourceStartCol + layoutLine.text.length,
|
|
1085
|
+
};
|
|
1017
1086
|
let cursorPaddingOverflow = 0;
|
|
1018
1087
|
let decorated = false;
|
|
1019
1088
|
let imeSafeCursorTail = false;
|
|
@@ -1104,7 +1173,14 @@ export class Editor implements Component, Focusable {
|
|
|
1104
1173
|
// Decorate the plain text on each side of the cursor glyph. The reverse-video
|
|
1105
1174
|
// reset (\x1b[0m) ends in "m" (a word char), so a boundary match on restAfter
|
|
1106
1175
|
// would fail in the whole-line fallback below — decorate the segments here.
|
|
1107
|
-
displayText =
|
|
1176
|
+
displayText =
|
|
1177
|
+
this.#decorate(before, { ...decorationContext, endCol: decorationContext.startCol + before.length }) +
|
|
1178
|
+
marker +
|
|
1179
|
+
cursor +
|
|
1180
|
+
this.#decorate(restAfter, {
|
|
1181
|
+
...decorationContext,
|
|
1182
|
+
startCol: decorationContext.startCol + before.length + firstGrapheme.length,
|
|
1183
|
+
});
|
|
1108
1184
|
decorated = true;
|
|
1109
1185
|
// displayWidth stays the same - we're replacing, not adding
|
|
1110
1186
|
} else if (this.cursorOverride) {
|
|
@@ -1156,7 +1232,7 @@ export class Editor implements Component, Focusable {
|
|
|
1156
1232
|
// the whole line. `#decorate` splits around CURSOR_MARKER so a keyword glued to
|
|
1157
1233
|
// the cursor still satisfies its right-boundary lookahead.
|
|
1158
1234
|
if (!decorated) {
|
|
1159
|
-
displayText = this.#decorate(displayText);
|
|
1235
|
+
displayText = this.#decorate(displayText, decorationContext);
|
|
1160
1236
|
}
|
|
1161
1237
|
if (!hasCursor) {
|
|
1162
1238
|
// Undecorated, unsliced lines keep their carried width; any
|
|
@@ -1190,6 +1266,12 @@ export class Editor implements Component, Focusable {
|
|
|
1190
1266
|
|
|
1191
1267
|
// Add autocomplete list if active
|
|
1192
1268
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1269
|
+
// Clamp the dropdown to the terminal viewport: the editor rows already
|
|
1270
|
+
// rendered above plus a small reserve must stay visible.
|
|
1271
|
+
const viewportRows = this.viewportRowsProvider?.() || process.stdout.rows || Number(Bun.env.LINES) || 24;
|
|
1272
|
+
this.#autocompleteList.setMaxVisible(
|
|
1273
|
+
Math.max(3, Math.min(this.#autocompleteMaxVisible, viewportRows - result.length - 2)),
|
|
1274
|
+
);
|
|
1193
1275
|
const autocompleteResult = this.#autocompleteList.render(width);
|
|
1194
1276
|
result.push(...autocompleteResult);
|
|
1195
1277
|
}
|
|
@@ -1214,6 +1296,11 @@ export class Editor implements Component, Focusable {
|
|
|
1214
1296
|
// lookup instead of re-parsing `data` per probe (~35 probes per key).
|
|
1215
1297
|
const parsedKey = parseKey(data);
|
|
1216
1298
|
const canonical = parsedKey === undefined ? undefined : canonicalKeyId(parsedKey);
|
|
1299
|
+
// Input wins over a pending provider lookup. The next completable edit
|
|
1300
|
+
// queues one fresh request after the stale request acknowledges abort.
|
|
1301
|
+
if (this.#autocompleteRequestRunning && this.#autocompleteState === null) {
|
|
1302
|
+
this.#invalidateAutocompleteRequests();
|
|
1303
|
+
}
|
|
1217
1304
|
|
|
1218
1305
|
// Handle character jump mode (awaiting next character to jump to)
|
|
1219
1306
|
if (this.#jumpMode !== null) {
|
|
@@ -1276,6 +1363,11 @@ export class Editor implements Component, Focusable {
|
|
|
1276
1363
|
return;
|
|
1277
1364
|
}
|
|
1278
1365
|
|
|
1366
|
+
if (kb.matchesCanonical(canonical, "tui.editor.spellingSuggestions")) {
|
|
1367
|
+
void this.#showSpellingSuggestions();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1279
1371
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1280
1372
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1281
1373
|
// Escape - cancel autocomplete
|
|
@@ -1283,6 +1375,15 @@ export class Editor implements Component, Focusable {
|
|
|
1283
1375
|
this.#cancelAutocomplete(true);
|
|
1284
1376
|
return;
|
|
1285
1377
|
}
|
|
1378
|
+
if (
|
|
1379
|
+
this.#autocompleteState === "assist" &&
|
|
1380
|
+
(kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1381
|
+
data === "\n" ||
|
|
1382
|
+
kb.matchesCanonical(canonical, "tui.input.tab"))
|
|
1383
|
+
) {
|
|
1384
|
+
this.#applySpellingSuggestion();
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1286
1387
|
// Let the autocomplete list handle navigation and selection
|
|
1287
1388
|
else if (
|
|
1288
1389
|
kb.matchesCanonical(canonical, "tui.select.up") ||
|
|
@@ -1301,7 +1402,6 @@ export class Editor implements Component, Focusable {
|
|
|
1301
1402
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1302
1403
|
) {
|
|
1303
1404
|
this.#autocompleteList.handleInput(data);
|
|
1304
|
-
this.#widthEpochRevision++;
|
|
1305
1405
|
this.onAutocompleteUpdate?.();
|
|
1306
1406
|
return;
|
|
1307
1407
|
}
|
|
@@ -1354,7 +1454,8 @@ export class Editor implements Component, Focusable {
|
|
|
1354
1454
|
(kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") &&
|
|
1355
1455
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1356
1456
|
this.#isInSubmittedSlashCommandContext() &&
|
|
1357
|
-
!this.#selectedCompletionIsPath()
|
|
1457
|
+
!this.#selectedCompletionIsPath() &&
|
|
1458
|
+
!this.#selectedCompletionIsSkillNamespace()
|
|
1358
1459
|
) {
|
|
1359
1460
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1360
1461
|
// Check for stale autocomplete state due to debounce
|
|
@@ -1393,6 +1494,7 @@ export class Editor implements Component, Focusable {
|
|
|
1393
1494
|
this.#cancelAutocomplete();
|
|
1394
1495
|
} else {
|
|
1395
1496
|
if (selected && this.#autocompleteProvider) {
|
|
1497
|
+
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
1396
1498
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
1397
1499
|
this.#state.lines,
|
|
1398
1500
|
this.#state.cursorLine,
|
|
@@ -1413,6 +1515,9 @@ export class Editor implements Component, Focusable {
|
|
|
1413
1515
|
}
|
|
1414
1516
|
|
|
1415
1517
|
result.onApplied?.();
|
|
1518
|
+
if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) {
|
|
1519
|
+
void this.#tryTriggerAutocomplete();
|
|
1520
|
+
}
|
|
1416
1521
|
}
|
|
1417
1522
|
return;
|
|
1418
1523
|
}
|
|
@@ -1422,9 +1527,14 @@ export class Editor implements Component, Focusable {
|
|
|
1422
1527
|
// Let them fall through to normal character handling
|
|
1423
1528
|
}
|
|
1424
1529
|
|
|
1530
|
+
if (this.#autocompleteState === "assist") {
|
|
1531
|
+
this.#cancelAutocomplete();
|
|
1532
|
+
this.onAutocompleteUpdate?.();
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1425
1535
|
// Tab key - context-aware completion (but not when already autocompleting)
|
|
1426
1536
|
if (kb.matchesCanonical(canonical, "tui.input.tab") && !this.#autocompleteState) {
|
|
1427
|
-
this.#handleTabCompletion();
|
|
1537
|
+
void this.#handleTabCompletion();
|
|
1428
1538
|
return;
|
|
1429
1539
|
}
|
|
1430
1540
|
|
|
@@ -1650,6 +1760,8 @@ export class Editor implements Component, Focusable {
|
|
|
1650
1760
|
layoutLines.push({
|
|
1651
1761
|
text: "",
|
|
1652
1762
|
width: 0,
|
|
1763
|
+
sourceLine: 0,
|
|
1764
|
+
sourceStartCol: 0,
|
|
1653
1765
|
hasCursor: true,
|
|
1654
1766
|
cursorPos: 0,
|
|
1655
1767
|
});
|
|
@@ -1668,6 +1780,8 @@ export class Editor implements Component, Focusable {
|
|
|
1668
1780
|
layoutLines.push({
|
|
1669
1781
|
text: line,
|
|
1670
1782
|
width: lineVisibleWidth,
|
|
1783
|
+
sourceLine: i,
|
|
1784
|
+
sourceStartCol: 0,
|
|
1671
1785
|
hasCursor: true,
|
|
1672
1786
|
cursorPos: this.#state.cursorCol,
|
|
1673
1787
|
});
|
|
@@ -1675,6 +1789,8 @@ export class Editor implements Component, Focusable {
|
|
|
1675
1789
|
layoutLines.push({
|
|
1676
1790
|
text: line,
|
|
1677
1791
|
width: lineVisibleWidth,
|
|
1792
|
+
sourceLine: i,
|
|
1793
|
+
sourceStartCol: 0,
|
|
1678
1794
|
hasCursor: false,
|
|
1679
1795
|
});
|
|
1680
1796
|
}
|
|
@@ -1716,6 +1832,8 @@ export class Editor implements Component, Focusable {
|
|
|
1716
1832
|
layoutLines.push({
|
|
1717
1833
|
text: chunk.text,
|
|
1718
1834
|
width: chunk.width,
|
|
1835
|
+
sourceLine: i,
|
|
1836
|
+
sourceStartCol: chunk.startIndex,
|
|
1719
1837
|
hasCursor: true,
|
|
1720
1838
|
cursorPos: adjustedCursorPos,
|
|
1721
1839
|
});
|
|
@@ -1723,6 +1841,8 @@ export class Editor implements Component, Focusable {
|
|
|
1723
1841
|
layoutLines.push({
|
|
1724
1842
|
text: chunk.text,
|
|
1725
1843
|
width: chunk.width,
|
|
1844
|
+
sourceLine: i,
|
|
1845
|
+
sourceStartCol: chunk.startIndex,
|
|
1726
1846
|
hasCursor: false,
|
|
1727
1847
|
});
|
|
1728
1848
|
}
|
|
@@ -1737,15 +1857,6 @@ export class Editor implements Component, Focusable {
|
|
|
1737
1857
|
return this.#state.lines.join("\n");
|
|
1738
1858
|
}
|
|
1739
1859
|
|
|
1740
|
-
getNativeScrollbackWidthEpochRevision(): number {
|
|
1741
|
-
const text = this.getText();
|
|
1742
|
-
if (text !== this.#widthEpochText) {
|
|
1743
|
-
this.#widthEpochText = text;
|
|
1744
|
-
this.#widthEpochRevision++;
|
|
1745
|
-
}
|
|
1746
|
-
return this.#widthEpochRevision;
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
1860
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
1750
1861
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
1751
1862
|
textEquals(value: string): boolean {
|
|
@@ -2006,6 +2117,27 @@ export class Editor implements Component, Focusable {
|
|
|
2006
2117
|
}
|
|
2007
2118
|
|
|
2008
2119
|
// All the editor methods from before...
|
|
2120
|
+
#applyInlineReplacement(replacement: EditorInlineReplacement): boolean {
|
|
2121
|
+
if (
|
|
2122
|
+
!Number.isInteger(replacement.replaceLen) ||
|
|
2123
|
+
replacement.replaceLen < 0 ||
|
|
2124
|
+
replacement.replaceLen > this.#state.cursorCol
|
|
2125
|
+
) {
|
|
2126
|
+
return false;
|
|
2127
|
+
}
|
|
2128
|
+
const line = this.#state.lines[this.#state.cursorLine] || "";
|
|
2129
|
+
const before = line.slice(0, this.#state.cursorCol - replacement.replaceLen);
|
|
2130
|
+
const after = line.slice(this.#state.cursorCol);
|
|
2131
|
+
this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
|
|
2132
|
+
this.#setCursorCol(before.length + replacement.insert.length);
|
|
2133
|
+
this.onChange?.(this.getText());
|
|
2134
|
+
if (this.#autocompleteState) {
|
|
2135
|
+
this.#cancelAutocomplete();
|
|
2136
|
+
this.onAutocompleteUpdate?.();
|
|
2137
|
+
}
|
|
2138
|
+
return true;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2009
2141
|
#insertCharacter(char: string): void {
|
|
2010
2142
|
this.#exitHistoryForEditing();
|
|
2011
2143
|
// Undo coalescing: consecutive word typing collapses into one undo unit
|
|
@@ -2031,22 +2163,30 @@ export class Editor implements Component, Focusable {
|
|
|
2031
2163
|
// Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
|
|
2032
2164
|
// Runs before autocomplete trigger so the popup doesn't briefly chase a
|
|
2033
2165
|
// prefix that's about to be rewritten.
|
|
2034
|
-
if (char.length === 1
|
|
2166
|
+
if (char.length === 1) {
|
|
2035
2167
|
const replaceLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
2036
2168
|
const textBeforeCursor = replaceLine.slice(0, this.#state.cursorCol);
|
|
2037
|
-
const
|
|
2038
|
-
if (
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2169
|
+
const inlineReplacement = this.#autocompleteProvider?.trySyncInlineReplace?.(textBeforeCursor);
|
|
2170
|
+
if (inlineReplacement && this.#applyInlineReplacement(inlineReplacement)) return;
|
|
2171
|
+
const cursorLine = this.#state.cursorLine;
|
|
2172
|
+
const cursorCol = this.#state.cursorCol;
|
|
2173
|
+
const currentLine = this.#state.lines[cursorLine] ?? "";
|
|
2174
|
+
const autocorrection = this.#textAssistProvider?.tryAutocorrect?.(this.#state.lines, cursorLine, cursorCol);
|
|
2175
|
+
if (autocorrection instanceof Promise) {
|
|
2176
|
+
autocorrection
|
|
2177
|
+
.then(replacement => {
|
|
2178
|
+
if (
|
|
2179
|
+
replacement &&
|
|
2180
|
+
this.#state.cursorLine === cursorLine &&
|
|
2181
|
+
this.#state.cursorCol === cursorCol &&
|
|
2182
|
+
this.#state.lines[cursorLine] === currentLine &&
|
|
2183
|
+
this.#applyInlineReplacement(replacement)
|
|
2184
|
+
) {
|
|
2185
|
+
this.onTextAssistApplied?.();
|
|
2186
|
+
}
|
|
2187
|
+
})
|
|
2188
|
+
.catch(() => {});
|
|
2189
|
+
} else if (autocorrection && this.#applyInlineReplacement(autocorrection)) {
|
|
2050
2190
|
return;
|
|
2051
2191
|
}
|
|
2052
2192
|
}
|
|
@@ -3197,6 +3337,14 @@ export class Editor implements Component, Focusable {
|
|
|
3197
3337
|
if (!selected) return false;
|
|
3198
3338
|
return selected.value.startsWith("/") || selected.value.startsWith('"');
|
|
3199
3339
|
}
|
|
3340
|
+
/**
|
|
3341
|
+
* Whether the current popup selection is the collapsed `/skill:` namespace
|
|
3342
|
+
* row. Accepting it expands the namespace (insert `/skill:`, reopen the
|
|
3343
|
+
* popup) instead of submitting, since the bare namespace is not a command.
|
|
3344
|
+
*/
|
|
3345
|
+
#selectedCompletionIsSkillNamespace(): boolean {
|
|
3346
|
+
return this.#autocompleteList?.getSelectedItem()?.value === SKILL_NAMESPACE;
|
|
3347
|
+
}
|
|
3200
3348
|
|
|
3201
3349
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
3202
3350
|
if (this.#autocompleteState !== "regular") {
|
|
@@ -3217,7 +3365,10 @@ export class Editor implements Component, Focusable {
|
|
|
3217
3365
|
}
|
|
3218
3366
|
|
|
3219
3367
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
|
|
3220
|
-
return
|
|
3368
|
+
return (
|
|
3369
|
+
this.#isInSubmittedSlashCommandContext() &&
|
|
3370
|
+
(/^\/\S+ $/.test(textBeforeCursor) || textBeforeCursor === `/${SKILL_NAMESPACE}`)
|
|
3371
|
+
);
|
|
3221
3372
|
}
|
|
3222
3373
|
|
|
3223
3374
|
// Autocomplete methods
|
|
@@ -3234,39 +3385,18 @@ export class Editor implements Component, Focusable {
|
|
|
3234
3385
|
|
|
3235
3386
|
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
3236
3387
|
if (!this.#autocompleteProvider) return;
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
this.#
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
return;
|
|
3248
|
-
}
|
|
3249
|
-
}
|
|
3250
|
-
|
|
3251
|
-
const requestId = ++this.#autocompleteRequestId;
|
|
3252
|
-
|
|
3253
|
-
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
3254
|
-
this.#state.lines,
|
|
3255
|
-
this.#state.cursorLine,
|
|
3256
|
-
this.#state.cursorCol,
|
|
3257
|
-
);
|
|
3258
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3259
|
-
|
|
3260
|
-
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3261
|
-
this.#autocompletePrefix = suggestions.prefix;
|
|
3262
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3263
|
-
this.#autocompleteState = "regular";
|
|
3264
|
-
this.#widthEpochRevision++;
|
|
3265
|
-
this.onAutocompleteUpdate?.();
|
|
3266
|
-
} else {
|
|
3267
|
-
this.#cancelAutocomplete();
|
|
3268
|
-
this.onAutocompleteUpdate?.();
|
|
3388
|
+
if (
|
|
3389
|
+
explicitTab &&
|
|
3390
|
+
this.#autocompleteProvider.shouldTriggerFileCompletion &&
|
|
3391
|
+
!this.#autocompleteProvider.shouldTriggerFileCompletion(
|
|
3392
|
+
this.#state.lines,
|
|
3393
|
+
this.#state.cursorLine,
|
|
3394
|
+
this.#state.cursorCol,
|
|
3395
|
+
)
|
|
3396
|
+
) {
|
|
3397
|
+
return;
|
|
3269
3398
|
}
|
|
3399
|
+
await this.#queueAutocompleteRequest({ kind: "regular", explicitTab });
|
|
3270
3400
|
}
|
|
3271
3401
|
#createAutocompleteList(
|
|
3272
3402
|
prefix: string,
|
|
@@ -3277,6 +3407,13 @@ export class Editor implements Component, Focusable {
|
|
|
3277
3407
|
}
|
|
3278
3408
|
|
|
3279
3409
|
async #handleTabCompletion(): Promise<void> {
|
|
3410
|
+
const wordCompletion = this.#getWordCompletion();
|
|
3411
|
+
if (wordCompletion) {
|
|
3412
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3413
|
+
const after = currentLine.slice(this.#state.cursorCol);
|
|
3414
|
+
this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
|
|
3415
|
+
return;
|
|
3416
|
+
}
|
|
3280
3417
|
if (!this.#autocompleteProvider) return;
|
|
3281
3418
|
|
|
3282
3419
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3293,49 +3430,88 @@ export class Editor implements Component, Focusable {
|
|
|
3293
3430
|
await this.#forceFileAutocomplete();
|
|
3294
3431
|
}
|
|
3295
3432
|
}
|
|
3433
|
+
async #showSpellingSuggestions(): Promise<void> {
|
|
3434
|
+
const cursorLine = this.#state.cursorLine;
|
|
3435
|
+
const cursorCol = this.#state.cursorCol;
|
|
3436
|
+
const lines = [...this.#state.lines];
|
|
3437
|
+
const result = this.#textAssistProvider?.getWordReplacements?.(lines, cursorLine, cursorCol);
|
|
3438
|
+
const replacements = result instanceof Promise ? await result.catch(() => null) : result;
|
|
3439
|
+
if (
|
|
3440
|
+
!replacements ||
|
|
3441
|
+
this.#state.cursorLine !== cursorLine ||
|
|
3442
|
+
this.#state.cursorCol !== cursorCol ||
|
|
3443
|
+
this.#state.lines[replacements.line] !== lines[replacements.line] ||
|
|
3444
|
+
replacements.line < 0 ||
|
|
3445
|
+
replacements.line >= this.#state.lines.length ||
|
|
3446
|
+
replacements.startCol < 0 ||
|
|
3447
|
+
replacements.endCol <= replacements.startCol ||
|
|
3448
|
+
replacements.items.length === 0
|
|
3449
|
+
) {
|
|
3450
|
+
return;
|
|
3451
|
+
}
|
|
3452
|
+
const line = this.#state.lines[replacements.line] ?? "";
|
|
3453
|
+
if (replacements.endCol > line.length) return;
|
|
3454
|
+
const original = line.slice(replacements.startCol, replacements.endCol);
|
|
3455
|
+
this.#autocompletePrefix = original;
|
|
3456
|
+
this.#autocompleteList = this.#createAutocompleteList(
|
|
3457
|
+
original,
|
|
3458
|
+
replacements.items.map(value => ({ value, label: value })),
|
|
3459
|
+
);
|
|
3460
|
+
this.#autocompleteState = "assist";
|
|
3461
|
+
this.#textAssistReplacement = {
|
|
3462
|
+
line: replacements.line,
|
|
3463
|
+
startCol: replacements.startCol,
|
|
3464
|
+
endCol: replacements.endCol,
|
|
3465
|
+
original,
|
|
3466
|
+
cursorOffset:
|
|
3467
|
+
replacements.line === this.#state.cursorLine ? Math.max(0, this.#state.cursorCol - replacements.endCol) : 0,
|
|
3468
|
+
};
|
|
3469
|
+
this.onAutocompleteUpdate?.();
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
#applySpellingSuggestion(): void {
|
|
3473
|
+
const replacement = this.#textAssistReplacement;
|
|
3474
|
+
const selected = this.#autocompleteList?.getSelectedItem();
|
|
3475
|
+
if (!replacement || !selected) {
|
|
3476
|
+
this.#cancelAutocomplete();
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
const line = this.#state.lines[replacement.line] ?? "";
|
|
3480
|
+
if (line.slice(replacement.startCol, replacement.endCol) !== replacement.original) {
|
|
3481
|
+
this.#cancelAutocomplete();
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
this.#recordUndoState();
|
|
3485
|
+
this.#state.lines[replacement.line] =
|
|
3486
|
+
line.slice(0, replacement.startCol) + selected.value + line.slice(replacement.endCol);
|
|
3487
|
+
this.#state.cursorLine = replacement.line;
|
|
3488
|
+
this.#setCursorCol(replacement.startCol + selected.value.length + replacement.cursorOffset);
|
|
3489
|
+
this.#lastAction = null;
|
|
3490
|
+
this.#cancelAutocomplete();
|
|
3491
|
+
this.onAutocompleteUpdate?.();
|
|
3492
|
+
this.onChange?.(this.getText());
|
|
3493
|
+
}
|
|
3296
3494
|
async #handleSlashCommandCompletion(): Promise<void> {
|
|
3297
3495
|
await this.#tryTriggerAutocomplete();
|
|
3298
3496
|
}
|
|
3299
3497
|
|
|
3300
3498
|
async #forceFileAutocomplete(): Promise<void> {
|
|
3301
3499
|
if (!this.#autocompleteProvider) return;
|
|
3302
|
-
|
|
3303
|
-
// File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
|
|
3304
|
-
const getForceFileSuggestions = this.#autocompleteProvider.getForceFileSuggestions;
|
|
3305
|
-
if (typeof getForceFileSuggestions !== "function") {
|
|
3500
|
+
if (typeof this.#autocompleteProvider.getForceFileSuggestions !== "function") {
|
|
3306
3501
|
await this.#tryTriggerAutocomplete(true);
|
|
3307
3502
|
return;
|
|
3308
3503
|
}
|
|
3309
|
-
|
|
3310
|
-
const requestId = ++this.#autocompleteRequestId;
|
|
3311
|
-
const suggestions = await getForceFileSuggestions.call(
|
|
3312
|
-
this.#autocompleteProvider,
|
|
3313
|
-
this.#state.lines,
|
|
3314
|
-
this.#state.cursorLine,
|
|
3315
|
-
this.#state.cursorCol,
|
|
3316
|
-
);
|
|
3317
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3318
|
-
|
|
3319
|
-
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3320
|
-
this.#autocompletePrefix = suggestions.prefix;
|
|
3321
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3322
|
-
this.#autocompleteState = "force";
|
|
3323
|
-
this.#widthEpochRevision++;
|
|
3324
|
-
this.onAutocompleteUpdate?.();
|
|
3325
|
-
} else {
|
|
3326
|
-
this.#cancelAutocomplete();
|
|
3327
|
-
this.onAutocompleteUpdate?.();
|
|
3328
|
-
}
|
|
3504
|
+
await this.#queueAutocompleteRequest({ kind: "force" });
|
|
3329
3505
|
}
|
|
3330
3506
|
|
|
3331
3507
|
#cancelAutocomplete(notifyCancel: boolean = false): void {
|
|
3332
3508
|
const wasAutocompleting = this.#autocompleteState !== null;
|
|
3333
3509
|
this.#clearAutocompleteTimeout();
|
|
3334
|
-
this.#
|
|
3510
|
+
this.#invalidateAutocompleteRequests();
|
|
3335
3511
|
this.#autocompleteState = null;
|
|
3336
3512
|
this.#autocompleteList = undefined;
|
|
3513
|
+
this.#textAssistReplacement = undefined;
|
|
3337
3514
|
this.#autocompletePrefix = "";
|
|
3338
|
-
if (wasAutocompleting) this.#widthEpochRevision++;
|
|
3339
3515
|
if (notifyCancel && wasAutocompleting) {
|
|
3340
3516
|
this.onAutocompleteCancel?.();
|
|
3341
3517
|
}
|
|
@@ -3346,33 +3522,97 @@ export class Editor implements Component, Focusable {
|
|
|
3346
3522
|
}
|
|
3347
3523
|
|
|
3348
3524
|
async #updateAutocomplete(): Promise<void> {
|
|
3349
|
-
if (!this.#autocompleteState || !this.#autocompleteProvider) return;
|
|
3350
|
-
|
|
3351
|
-
// In force mode, use forceFileAutocomplete to get suggestions
|
|
3525
|
+
if (!this.#autocompleteState || !this.#autocompleteProvider || this.#autocompleteState === "assist") return;
|
|
3352
3526
|
if (this.#autocompleteState === "force") {
|
|
3353
|
-
this.#forceFileAutocomplete();
|
|
3527
|
+
await this.#forceFileAutocomplete();
|
|
3354
3528
|
return;
|
|
3355
3529
|
}
|
|
3530
|
+
await this.#queueAutocompleteRequest({ kind: "regular", explicitTab: false });
|
|
3531
|
+
}
|
|
3356
3532
|
|
|
3357
|
-
|
|
3533
|
+
#queueAutocompleteRequest(request: AutocompleteRequest): Promise<void> {
|
|
3534
|
+
const waiter = Promise.withResolvers<void>();
|
|
3535
|
+
this.#autocompleteWaiters.push(waiter.resolve);
|
|
3536
|
+
this.#autocompletePendingRequest = request;
|
|
3537
|
+
this.#autocompleteRequestId++;
|
|
3538
|
+
this.#autocompleteAbortController?.abort();
|
|
3539
|
+
if (!this.#autocompleteRequestRunning) void this.#drainAutocompleteRequests();
|
|
3540
|
+
return waiter.promise;
|
|
3541
|
+
}
|
|
3358
3542
|
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3543
|
+
async #drainAutocompleteRequests(): Promise<void> {
|
|
3544
|
+
if (this.#autocompleteRequestRunning) return;
|
|
3545
|
+
this.#autocompleteRequestRunning = true;
|
|
3546
|
+
try {
|
|
3547
|
+
while (this.#autocompletePendingRequest) {
|
|
3548
|
+
const request = this.#autocompletePendingRequest;
|
|
3549
|
+
this.#autocompletePendingRequest = undefined;
|
|
3550
|
+
const requestId = this.#autocompleteRequestId;
|
|
3551
|
+
const controller = new AbortController();
|
|
3552
|
+
this.#autocompleteAbortController = controller;
|
|
3553
|
+
await this.#runAutocompleteRequest(request, requestId, controller.signal);
|
|
3554
|
+
if (this.#autocompleteAbortController === controller) {
|
|
3555
|
+
this.#autocompleteAbortController = undefined;
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
} finally {
|
|
3559
|
+
this.#autocompleteRequestRunning = false;
|
|
3560
|
+
const waiters = this.#autocompleteWaiters.splice(0);
|
|
3561
|
+
for (const resolve of waiters) resolve();
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
async #runAutocompleteRequest(request: AutocompleteRequest, requestId: number, signal: AbortSignal): Promise<void> {
|
|
3566
|
+
const provider = this.#autocompleteProvider;
|
|
3567
|
+
if (!provider) return;
|
|
3568
|
+
const lines = [...this.#state.lines];
|
|
3569
|
+
const cursorLine = this.#state.cursorLine;
|
|
3570
|
+
const cursorCol = this.#state.cursorCol;
|
|
3571
|
+
let suggestions: { items: AutocompleteItem[]; prefix: string } | null;
|
|
3572
|
+
try {
|
|
3573
|
+
if (request.kind === "force") {
|
|
3574
|
+
const getForceFileSuggestions = provider.getForceFileSuggestions;
|
|
3575
|
+
if (!getForceFileSuggestions) return;
|
|
3576
|
+
suggestions = await getForceFileSuggestions.call(provider, lines, cursorLine, cursorCol, signal);
|
|
3577
|
+
} else {
|
|
3578
|
+
suggestions = await provider.getSuggestions(lines, cursorLine, cursorCol, signal);
|
|
3579
|
+
}
|
|
3580
|
+
} catch (error) {
|
|
3581
|
+
if (!signal.aborted && requestId === this.#autocompleteRequestId) {
|
|
3582
|
+
logger.debug("Autocomplete provider failed", { error: String(error) });
|
|
3583
|
+
this.#cancelAutocomplete();
|
|
3584
|
+
this.onAutocompleteUpdate?.();
|
|
3585
|
+
}
|
|
3586
|
+
return;
|
|
3587
|
+
}
|
|
3588
|
+
if (
|
|
3589
|
+
signal.aborted ||
|
|
3590
|
+
requestId !== this.#autocompleteRequestId ||
|
|
3591
|
+
cursorLine !== this.#state.cursorLine ||
|
|
3592
|
+
cursorCol !== this.#state.cursorCol ||
|
|
3593
|
+
lines.length !== this.#state.lines.length ||
|
|
3594
|
+
lines.some((line, index) => line !== this.#state.lines[index])
|
|
3595
|
+
) {
|
|
3596
|
+
return;
|
|
3597
|
+
}
|
|
3365
3598
|
|
|
3366
3599
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3367
3600
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3368
|
-
// Always create new SelectList to ensure update
|
|
3369
3601
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3370
|
-
this.#
|
|
3371
|
-
this.onAutocompleteUpdate?.();
|
|
3372
|
-
} else {
|
|
3373
|
-
this.#cancelAutocomplete();
|
|
3602
|
+
this.#autocompleteState = request.kind === "force" ? "force" : "regular";
|
|
3374
3603
|
this.onAutocompleteUpdate?.();
|
|
3604
|
+
return;
|
|
3375
3605
|
}
|
|
3606
|
+
this.#cancelAutocomplete();
|
|
3607
|
+
this.onAutocompleteUpdate?.();
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
#invalidateAutocompleteRequests(): void {
|
|
3611
|
+
this.#autocompletePendingRequest = undefined;
|
|
3612
|
+
this.#autocompleteRequestId++;
|
|
3613
|
+
this.#autocompleteAbortController?.abort();
|
|
3614
|
+
const waiters = this.#autocompleteWaiters.splice(0);
|
|
3615
|
+
for (const resolve of waiters) resolve();
|
|
3376
3616
|
}
|
|
3377
3617
|
|
|
3378
3618
|
#debouncedUpdateAutocomplete(): void {
|
|
@@ -3380,7 +3620,7 @@ export class Editor implements Component, Focusable {
|
|
|
3380
3620
|
clearTimeout(this.#autocompleteTimeout);
|
|
3381
3621
|
}
|
|
3382
3622
|
this.#autocompleteTimeout = setTimeout(() => {
|
|
3383
|
-
this.#updateAutocomplete();
|
|
3623
|
+
void this.#updateAutocomplete();
|
|
3384
3624
|
this.#autocompleteTimeout = undefined;
|
|
3385
3625
|
}, 100);
|
|
3386
3626
|
}
|
|
@@ -3405,13 +3645,23 @@ export class Editor implements Component, Focusable {
|
|
|
3405
3645
|
|
|
3406
3646
|
// Fall back to provider's getInlineHint
|
|
3407
3647
|
if (this.#autocompleteProvider?.getInlineHint) {
|
|
3408
|
-
|
|
3648
|
+
const hint = this.#autocompleteProvider.getInlineHint(
|
|
3409
3649
|
this.#state.lines,
|
|
3410
3650
|
this.#state.cursorLine,
|
|
3411
3651
|
this.#state.cursorCol,
|
|
3412
3652
|
);
|
|
3653
|
+
if (hint) return hint;
|
|
3413
3654
|
}
|
|
3414
3655
|
|
|
3415
|
-
return
|
|
3656
|
+
return this.#getWordCompletion();
|
|
3657
|
+
}
|
|
3658
|
+
#getWordCompletion(): string | null {
|
|
3659
|
+
return (
|
|
3660
|
+
this.#textAssistProvider?.getWordCompletion?.(
|
|
3661
|
+
this.#state.lines,
|
|
3662
|
+
this.#state.cursorLine,
|
|
3663
|
+
this.#state.cursorCol,
|
|
3664
|
+
) ?? null
|
|
3665
|
+
);
|
|
3416
3666
|
}
|
|
3417
3667
|
}
|