@oh-my-pi/pi-tui 18.0.0 → 18.0.3
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 +37 -0
- package/dist/types/autocomplete.d.ts +7 -5
- package/dist/types/components/editor.d.ts +2 -4
- package/dist/types/components/image.d.ts +2 -5
- package/dist/types/components/markdown.d.ts +2 -25
- package/dist/types/components/text.d.ts +0 -1
- package/dist/types/terminal-capabilities.d.ts +10 -3
- package/dist/types/terminal.d.ts +10 -0
- package/dist/types/tui.d.ts +47 -245
- package/package.json +3 -3
- package/src/autocomplete.ts +66 -10
- package/src/components/editor.ts +137 -134
- package/src/components/image.ts +4 -6
- package/src/components/loader.ts +1 -9
- package/src/components/markdown.ts +48 -407
- package/src/components/text.ts +8 -18
- package/src/fuzzy.ts +63 -6
- package/src/stdin-buffer.ts +130 -20
- package/src/terminal-capabilities.ts +13 -2
- package/src/terminal.ts +14 -0
- package/src/tui.ts +567 -3599
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";
|
|
@@ -460,6 +462,7 @@ export interface EditorTextAssistProvider {
|
|
|
460
462
|
}
|
|
461
463
|
|
|
462
464
|
type HistoryCursorAnchor = "start" | "end";
|
|
465
|
+
type AutocompleteRequest = { kind: "regular"; explicitTab: boolean } | { kind: "force" };
|
|
463
466
|
|
|
464
467
|
export class Editor implements Component, Focusable {
|
|
465
468
|
#state: EditorState = {
|
|
@@ -467,9 +470,6 @@ export class Editor implements Component, Focusable {
|
|
|
467
470
|
cursorLine: 0,
|
|
468
471
|
cursorCol: 0,
|
|
469
472
|
};
|
|
470
|
-
#widthEpochText = "";
|
|
471
|
-
#widthEpochRevision = 0;
|
|
472
|
-
|
|
473
473
|
/** Focusable interface - set by TUI when focus changes */
|
|
474
474
|
focused: boolean = false;
|
|
475
475
|
|
|
@@ -528,6 +528,10 @@ export class Editor implements Component, Focusable {
|
|
|
528
528
|
| undefined;
|
|
529
529
|
#autocompletePrefix: string = "";
|
|
530
530
|
#autocompleteRequestId: number = 0;
|
|
531
|
+
#autocompletePendingRequest: AutocompleteRequest | undefined;
|
|
532
|
+
#autocompleteRequestRunning = false;
|
|
533
|
+
#autocompleteAbortController: AbortController | undefined;
|
|
534
|
+
#autocompleteWaiters: Array<() => void> = [];
|
|
531
535
|
#autocompleteMaxVisible: number = 10;
|
|
532
536
|
onAutocompleteUpdate?: () => void;
|
|
533
537
|
/** Called after an async text-assist result mutates the document outside an input event, so hosts can schedule a repaint. */
|
|
@@ -585,9 +589,6 @@ export class Editor implements Component, Focusable {
|
|
|
585
589
|
// per-event rebuilds down to one per rendered frame (see #4145).
|
|
586
590
|
#topBorderContent?: EditorTopBorder;
|
|
587
591
|
#topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
|
|
588
|
-
#topBorderProviderWidth: number | undefined;
|
|
589
|
-
#topBorderProviderSignature: string | undefined;
|
|
590
|
-
#topBorderProviderRevision: number | undefined;
|
|
591
592
|
#borderVisible = true;
|
|
592
593
|
#borderStyle: EditorBorderStyle = "box";
|
|
593
594
|
constructor(theme: EditorTheme) {
|
|
@@ -607,7 +608,6 @@ export class Editor implements Component, Focusable {
|
|
|
607
608
|
/** Install prose assistance without changing command/file autocomplete. */
|
|
608
609
|
setTextAssistProvider(provider: EditorTextAssistProvider | undefined): void {
|
|
609
610
|
this.#textAssistProvider = provider;
|
|
610
|
-
this.#widthEpochRevision++;
|
|
611
611
|
}
|
|
612
612
|
|
|
613
613
|
/**
|
|
@@ -623,7 +623,6 @@ export class Editor implements Component, Focusable {
|
|
|
623
623
|
if (this.#topBorderContent?.content === content?.content && this.#topBorderContent?.width === content?.width)
|
|
624
624
|
return;
|
|
625
625
|
this.#topBorderContent = content;
|
|
626
|
-
this.#widthEpochRevision++;
|
|
627
626
|
}
|
|
628
627
|
|
|
629
628
|
/**
|
|
@@ -634,16 +633,10 @@ export class Editor implements Component, Focusable {
|
|
|
634
633
|
* Use this when the top border derives from state that mutates far faster
|
|
635
634
|
* than the render cadence (session events, streaming, subagent updates).
|
|
636
635
|
* The TUI already throttles renders, so a provider is invoked exactly once
|
|
637
|
-
* per frame and does no work between paints.
|
|
638
|
-
* distinguish concurrent status mutations from pure width reflow.
|
|
639
|
-
*/
|
|
636
|
+
* per frame and does no work between paints. */
|
|
640
637
|
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
|
|
641
638
|
if (this.#topBorderProvider === provider) return;
|
|
642
639
|
this.#topBorderProvider = provider;
|
|
643
|
-
this.#topBorderProviderWidth = undefined;
|
|
644
|
-
this.#topBorderProviderSignature = undefined;
|
|
645
|
-
this.#topBorderProviderRevision = undefined;
|
|
646
|
-
this.#widthEpochRevision++;
|
|
647
640
|
}
|
|
648
641
|
|
|
649
642
|
/**
|
|
@@ -652,7 +645,6 @@ export class Editor implements Component, Focusable {
|
|
|
652
645
|
setBorderVisible(borderVisible: boolean): void {
|
|
653
646
|
if (this.#borderVisible === borderVisible) return;
|
|
654
647
|
this.#borderVisible = borderVisible;
|
|
655
|
-
this.#widthEpochRevision++;
|
|
656
648
|
}
|
|
657
649
|
|
|
658
650
|
setPromptGutter(promptGutter: string | undefined): void {
|
|
@@ -665,7 +657,6 @@ export class Editor implements Component, Focusable {
|
|
|
665
657
|
setBorderStyle(style: EditorBorderStyle): void {
|
|
666
658
|
if (this.#borderStyle === style) return;
|
|
667
659
|
this.#borderStyle = style;
|
|
668
|
-
this.#widthEpochRevision++;
|
|
669
660
|
}
|
|
670
661
|
|
|
671
662
|
/** True while the autocomplete/slash-command menu is open below the editor. */
|
|
@@ -689,14 +680,12 @@ export class Editor implements Component, Focusable {
|
|
|
689
680
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
690
681
|
if (this.#useTerminalCursor === useTerminalCursor) return;
|
|
691
682
|
this.#useTerminalCursor = useTerminalCursor;
|
|
692
|
-
this.#widthEpochRevision++;
|
|
693
683
|
}
|
|
694
684
|
|
|
695
685
|
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
696
686
|
setImeSafeCursorLayout(enabled: boolean): void {
|
|
697
687
|
if (this.#imeSafeCursorLayout === enabled) return;
|
|
698
688
|
this.#imeSafeCursorLayout = enabled;
|
|
699
|
-
this.#widthEpochRevision++;
|
|
700
689
|
}
|
|
701
690
|
|
|
702
691
|
getUseTerminalCursor(): boolean {
|
|
@@ -706,7 +695,6 @@ export class Editor implements Component, Focusable {
|
|
|
706
695
|
setMaxHeight(maxHeight: number | undefined): void {
|
|
707
696
|
if (this.#maxHeight === maxHeight) return;
|
|
708
697
|
this.#maxHeight = maxHeight;
|
|
709
|
-
this.#widthEpochRevision++;
|
|
710
698
|
// Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
|
|
711
699
|
}
|
|
712
700
|
|
|
@@ -729,11 +717,11 @@ export class Editor implements Component, Focusable {
|
|
|
729
717
|
this.#autocompleteMaxVisible = newMaxVisible;
|
|
730
718
|
if (this.#autocompleteState !== null) {
|
|
731
719
|
this.#autocompleteList?.setMaxVisible(newMaxVisible);
|
|
732
|
-
this.#widthEpochRevision++;
|
|
733
720
|
}
|
|
734
721
|
}
|
|
735
722
|
}
|
|
736
723
|
|
|
724
|
+
/** Loads persistent prompts for navigation and enables future persistence. */
|
|
737
725
|
setHistoryStorage(storage: HistoryStorage): void {
|
|
738
726
|
this.#historyStorage = storage;
|
|
739
727
|
const recent = storage.getRecent(100);
|
|
@@ -748,13 +736,6 @@ export class Editor implements Component, Focusable {
|
|
|
748
736
|
addToHistory(text: string): void {
|
|
749
737
|
const trimmed = text.trim();
|
|
750
738
|
if (!trimmed) return;
|
|
751
|
-
// Don't add consecutive duplicates
|
|
752
|
-
if (this.#history.length > 0 && this.#history[0] === trimmed) return;
|
|
753
|
-
this.#history.unshift(trimmed);
|
|
754
|
-
// Limit history size
|
|
755
|
-
if (this.#history.length > 100) {
|
|
756
|
-
this.#history.pop();
|
|
757
|
-
}
|
|
758
739
|
|
|
759
740
|
const stor = this.#historyStorage;
|
|
760
741
|
if (stor) {
|
|
@@ -762,6 +743,14 @@ export class Editor implements Component, Focusable {
|
|
|
762
743
|
logger.error("HistoryStorage add failed", { error: String(error) });
|
|
763
744
|
});
|
|
764
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
|
+
}
|
|
765
754
|
}
|
|
766
755
|
|
|
767
756
|
#isEditorEmpty(): boolean {
|
|
@@ -1050,28 +1039,13 @@ export class Editor implements Component, Focusable {
|
|
|
1050
1039
|
}
|
|
1051
1040
|
|
|
1052
1041
|
// Resolve the custom top-border content once per frame; the style decides
|
|
1053
|
-
// how (and whether) to draw it. Provider
|
|
1054
|
-
// 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.
|
|
1055
1044
|
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
|
1056
1045
|
let topBorder: EditorTopBorder | undefined;
|
|
1057
1046
|
if (style.statusAttachment !== "none") {
|
|
1058
1047
|
if (this.#topBorderProvider) {
|
|
1059
|
-
const previousWidth = this.#topBorderProviderWidth;
|
|
1060
1048
|
topBorder = this.#topBorderProvider(topFillWidth);
|
|
1061
|
-
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
|
|
1062
|
-
const revision = topBorder?.revision;
|
|
1063
|
-
if (
|
|
1064
|
-
(previousWidth !== undefined &&
|
|
1065
|
-
revision !== undefined &&
|
|
1066
|
-
this.#topBorderProviderRevision !== undefined &&
|
|
1067
|
-
revision !== this.#topBorderProviderRevision) ||
|
|
1068
|
-
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
|
|
1069
|
-
) {
|
|
1070
|
-
this.#widthEpochRevision++;
|
|
1071
|
-
}
|
|
1072
|
-
this.#topBorderProviderWidth = topFillWidth;
|
|
1073
|
-
this.#topBorderProviderSignature = signature;
|
|
1074
|
-
this.#topBorderProviderRevision = revision;
|
|
1075
1049
|
} else {
|
|
1076
1050
|
topBorder = this.#topBorderContent;
|
|
1077
1051
|
}
|
|
@@ -1322,6 +1296,11 @@ export class Editor implements Component, Focusable {
|
|
|
1322
1296
|
// lookup instead of re-parsing `data` per probe (~35 probes per key).
|
|
1323
1297
|
const parsedKey = parseKey(data);
|
|
1324
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
|
+
}
|
|
1325
1304
|
|
|
1326
1305
|
// Handle character jump mode (awaiting next character to jump to)
|
|
1327
1306
|
if (this.#jumpMode !== null) {
|
|
@@ -1423,7 +1402,6 @@ export class Editor implements Component, Focusable {
|
|
|
1423
1402
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1424
1403
|
) {
|
|
1425
1404
|
this.#autocompleteList.handleInput(data);
|
|
1426
|
-
this.#widthEpochRevision++;
|
|
1427
1405
|
this.onAutocompleteUpdate?.();
|
|
1428
1406
|
return;
|
|
1429
1407
|
}
|
|
@@ -1476,7 +1454,8 @@ export class Editor implements Component, Focusable {
|
|
|
1476
1454
|
(kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") &&
|
|
1477
1455
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1478
1456
|
this.#isInSubmittedSlashCommandContext() &&
|
|
1479
|
-
!this.#selectedCompletionIsPath()
|
|
1457
|
+
!this.#selectedCompletionIsPath() &&
|
|
1458
|
+
!this.#selectedCompletionIsSkillNamespace()
|
|
1480
1459
|
) {
|
|
1481
1460
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1482
1461
|
// Check for stale autocomplete state due to debounce
|
|
@@ -1515,6 +1494,7 @@ export class Editor implements Component, Focusable {
|
|
|
1515
1494
|
this.#cancelAutocomplete();
|
|
1516
1495
|
} else {
|
|
1517
1496
|
if (selected && this.#autocompleteProvider) {
|
|
1497
|
+
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
1518
1498
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
1519
1499
|
this.#state.lines,
|
|
1520
1500
|
this.#state.cursorLine,
|
|
@@ -1535,6 +1515,9 @@ export class Editor implements Component, Focusable {
|
|
|
1535
1515
|
}
|
|
1536
1516
|
|
|
1537
1517
|
result.onApplied?.();
|
|
1518
|
+
if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) {
|
|
1519
|
+
void this.#tryTriggerAutocomplete();
|
|
1520
|
+
}
|
|
1538
1521
|
}
|
|
1539
1522
|
return;
|
|
1540
1523
|
}
|
|
@@ -1874,15 +1857,6 @@ export class Editor implements Component, Focusable {
|
|
|
1874
1857
|
return this.#state.lines.join("\n");
|
|
1875
1858
|
}
|
|
1876
1859
|
|
|
1877
|
-
getNativeScrollbackWidthEpochRevision(): number {
|
|
1878
|
-
const text = this.getText();
|
|
1879
|
-
if (text !== this.#widthEpochText) {
|
|
1880
|
-
this.#widthEpochText = text;
|
|
1881
|
-
this.#widthEpochRevision++;
|
|
1882
|
-
}
|
|
1883
|
-
return this.#widthEpochRevision;
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
1860
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
1887
1861
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
1888
1862
|
textEquals(value: string): boolean {
|
|
@@ -3363,6 +3337,14 @@ export class Editor implements Component, Focusable {
|
|
|
3363
3337
|
if (!selected) return false;
|
|
3364
3338
|
return selected.value.startsWith("/") || selected.value.startsWith('"');
|
|
3365
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
|
+
}
|
|
3366
3348
|
|
|
3367
3349
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
3368
3350
|
if (this.#autocompleteState !== "regular") {
|
|
@@ -3383,7 +3365,10 @@ export class Editor implements Component, Focusable {
|
|
|
3383
3365
|
}
|
|
3384
3366
|
|
|
3385
3367
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
|
|
3386
|
-
return
|
|
3368
|
+
return (
|
|
3369
|
+
this.#isInSubmittedSlashCommandContext() &&
|
|
3370
|
+
(/^\/\S+ $/.test(textBeforeCursor) || textBeforeCursor === `/${SKILL_NAMESPACE}`)
|
|
3371
|
+
);
|
|
3387
3372
|
}
|
|
3388
3373
|
|
|
3389
3374
|
// Autocomplete methods
|
|
@@ -3400,39 +3385,18 @@ export class Editor implements Component, Focusable {
|
|
|
3400
3385
|
|
|
3401
3386
|
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
3402
3387
|
if (!this.#autocompleteProvider) return;
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
this.#
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
return;
|
|
3414
|
-
}
|
|
3415
|
-
}
|
|
3416
|
-
|
|
3417
|
-
const requestId = ++this.#autocompleteRequestId;
|
|
3418
|
-
|
|
3419
|
-
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
3420
|
-
this.#state.lines,
|
|
3421
|
-
this.#state.cursorLine,
|
|
3422
|
-
this.#state.cursorCol,
|
|
3423
|
-
);
|
|
3424
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3425
|
-
|
|
3426
|
-
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3427
|
-
this.#autocompletePrefix = suggestions.prefix;
|
|
3428
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3429
|
-
this.#autocompleteState = "regular";
|
|
3430
|
-
this.#widthEpochRevision++;
|
|
3431
|
-
this.onAutocompleteUpdate?.();
|
|
3432
|
-
} else {
|
|
3433
|
-
this.#cancelAutocomplete();
|
|
3434
|
-
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;
|
|
3435
3398
|
}
|
|
3399
|
+
await this.#queueAutocompleteRequest({ kind: "regular", explicitTab });
|
|
3436
3400
|
}
|
|
3437
3401
|
#createAutocompleteList(
|
|
3438
3402
|
prefix: string,
|
|
@@ -3502,7 +3466,6 @@ export class Editor implements Component, Focusable {
|
|
|
3502
3466
|
cursorOffset:
|
|
3503
3467
|
replacements.line === this.#state.cursorLine ? Math.max(0, this.#state.cursorCol - replacements.endCol) : 0,
|
|
3504
3468
|
};
|
|
3505
|
-
this.#widthEpochRevision++;
|
|
3506
3469
|
this.onAutocompleteUpdate?.();
|
|
3507
3470
|
}
|
|
3508
3471
|
|
|
@@ -3534,44 +3497,21 @@ export class Editor implements Component, Focusable {
|
|
|
3534
3497
|
|
|
3535
3498
|
async #forceFileAutocomplete(): Promise<void> {
|
|
3536
3499
|
if (!this.#autocompleteProvider) return;
|
|
3537
|
-
|
|
3538
|
-
// File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
|
|
3539
|
-
const getForceFileSuggestions = this.#autocompleteProvider.getForceFileSuggestions;
|
|
3540
|
-
if (typeof getForceFileSuggestions !== "function") {
|
|
3500
|
+
if (typeof this.#autocompleteProvider.getForceFileSuggestions !== "function") {
|
|
3541
3501
|
await this.#tryTriggerAutocomplete(true);
|
|
3542
3502
|
return;
|
|
3543
3503
|
}
|
|
3544
|
-
|
|
3545
|
-
const requestId = ++this.#autocompleteRequestId;
|
|
3546
|
-
const suggestions = await getForceFileSuggestions.call(
|
|
3547
|
-
this.#autocompleteProvider,
|
|
3548
|
-
this.#state.lines,
|
|
3549
|
-
this.#state.cursorLine,
|
|
3550
|
-
this.#state.cursorCol,
|
|
3551
|
-
);
|
|
3552
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3553
|
-
|
|
3554
|
-
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3555
|
-
this.#autocompletePrefix = suggestions.prefix;
|
|
3556
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3557
|
-
this.#autocompleteState = "force";
|
|
3558
|
-
this.#widthEpochRevision++;
|
|
3559
|
-
this.onAutocompleteUpdate?.();
|
|
3560
|
-
} else {
|
|
3561
|
-
this.#cancelAutocomplete();
|
|
3562
|
-
this.onAutocompleteUpdate?.();
|
|
3563
|
-
}
|
|
3504
|
+
await this.#queueAutocompleteRequest({ kind: "force" });
|
|
3564
3505
|
}
|
|
3565
3506
|
|
|
3566
3507
|
#cancelAutocomplete(notifyCancel: boolean = false): void {
|
|
3567
3508
|
const wasAutocompleting = this.#autocompleteState !== null;
|
|
3568
3509
|
this.#clearAutocompleteTimeout();
|
|
3569
|
-
this.#
|
|
3510
|
+
this.#invalidateAutocompleteRequests();
|
|
3570
3511
|
this.#autocompleteState = null;
|
|
3571
3512
|
this.#autocompleteList = undefined;
|
|
3572
3513
|
this.#textAssistReplacement = undefined;
|
|
3573
3514
|
this.#autocompletePrefix = "";
|
|
3574
|
-
if (wasAutocompleting) this.#widthEpochRevision++;
|
|
3575
3515
|
if (notifyCancel && wasAutocompleting) {
|
|
3576
3516
|
this.onAutocompleteCancel?.();
|
|
3577
3517
|
}
|
|
@@ -3582,34 +3522,97 @@ export class Editor implements Component, Focusable {
|
|
|
3582
3522
|
}
|
|
3583
3523
|
|
|
3584
3524
|
async #updateAutocomplete(): Promise<void> {
|
|
3585
|
-
if (!this.#autocompleteState || !this.#autocompleteProvider) return;
|
|
3586
|
-
if (this.#autocompleteState === "assist") return;
|
|
3587
|
-
|
|
3588
|
-
// In force mode, use forceFileAutocomplete to get suggestions
|
|
3525
|
+
if (!this.#autocompleteState || !this.#autocompleteProvider || this.#autocompleteState === "assist") return;
|
|
3589
3526
|
if (this.#autocompleteState === "force") {
|
|
3590
|
-
this.#forceFileAutocomplete();
|
|
3527
|
+
await this.#forceFileAutocomplete();
|
|
3591
3528
|
return;
|
|
3592
3529
|
}
|
|
3530
|
+
await this.#queueAutocompleteRequest({ kind: "regular", explicitTab: false });
|
|
3531
|
+
}
|
|
3593
3532
|
|
|
3594
|
-
|
|
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
|
+
}
|
|
3595
3542
|
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
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
|
+
}
|
|
3602
3598
|
|
|
3603
3599
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3604
3600
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3605
|
-
// Always create new SelectList to ensure update
|
|
3606
3601
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3607
|
-
this.#
|
|
3608
|
-
this.onAutocompleteUpdate?.();
|
|
3609
|
-
} else {
|
|
3610
|
-
this.#cancelAutocomplete();
|
|
3602
|
+
this.#autocompleteState = request.kind === "force" ? "force" : "regular";
|
|
3611
3603
|
this.onAutocompleteUpdate?.();
|
|
3604
|
+
return;
|
|
3612
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();
|
|
3613
3616
|
}
|
|
3614
3617
|
|
|
3615
3618
|
#debouncedUpdateAutocomplete(): void {
|
|
@@ -3617,7 +3620,7 @@ export class Editor implements Component, Focusable {
|
|
|
3617
3620
|
clearTimeout(this.#autocompleteTimeout);
|
|
3618
3621
|
}
|
|
3619
3622
|
this.#autocompleteTimeout = setTimeout(() => {
|
|
3620
|
-
this.#updateAutocomplete();
|
|
3623
|
+
void this.#updateAutocomplete();
|
|
3621
3624
|
this.#autocompleteTimeout = undefined;
|
|
3622
3625
|
}, 100);
|
|
3623
3626
|
}
|
package/src/components/image.ts
CHANGED
|
@@ -363,11 +363,8 @@ export class ImageBudget {
|
|
|
363
363
|
* Restart every placement epoch after a destructive history clear (`CSI 3 J`
|
|
364
364
|
* full paint). The clear destroys all placement cells — scrollback rows are
|
|
365
365
|
* gone and the replay rewrites the viewport — so no archive remains to
|
|
366
|
-
* protect. Reverting to epoch 1 lets the replay
|
|
367
|
-
*
|
|
368
|
-
* and the highest epoch it reached so the caller can delete all of its
|
|
369
|
-
* registry entries explicitly (`d=i` keeps the transmitted data) — an image
|
|
370
|
-
* absent from the replay never re-places, so even its epoch-1 entry must go.
|
|
366
|
+
* protect. Reverting to epoch 1 lets the replay recreate every visible
|
|
367
|
+
* placement after the terminal-wide cleanup.
|
|
371
368
|
*/
|
|
372
369
|
resetPlacementEpochs(): ReadonlyArray<{ imageId: number; lastEpoch: number }> {
|
|
373
370
|
let stale: Array<{ imageId: number; lastEpoch: number }> | undefined;
|
|
@@ -532,7 +529,8 @@ export class Image implements Component {
|
|
|
532
529
|
this.#cachedImageProtocol === imageProtocol &&
|
|
533
530
|
this.#cachedCellWidthPx === cellDimensions.widthPx &&
|
|
534
531
|
this.#cachedCellHeightPx === cellDimensions.heightPx &&
|
|
535
|
-
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders
|
|
532
|
+
this.#cachedKittyUnicodePlaceholders === kittyUnicodePlaceholders &&
|
|
533
|
+
(this.#imageId == null || this.#budget?.shouldTransmit(this.#imageId) !== true)
|
|
536
534
|
) {
|
|
537
535
|
return this.#cachedLines;
|
|
538
536
|
}
|
package/src/components/loader.ts
CHANGED
|
@@ -160,14 +160,6 @@ export class Loader extends Text {
|
|
|
160
160
|
if (!this.#ui) {
|
|
161
161
|
return;
|
|
162
162
|
}
|
|
163
|
-
|
|
164
|
-
// update the already-positioned rows without driving the full
|
|
165
|
-
// compose/prepare/diff pipeline. Lightweight test stubs may not carry the
|
|
166
|
-
// newer API; keep their legacy component-scoped path working.
|
|
167
|
-
if (typeof this.#ui.requestDirectWrite === "function") {
|
|
168
|
-
this.#ui.requestDirectWrite(this);
|
|
169
|
-
} else {
|
|
170
|
-
this.#ui.requestComponentRender(this);
|
|
171
|
-
}
|
|
163
|
+
this.#ui.requestComponentRender(this);
|
|
172
164
|
}
|
|
173
165
|
}
|