@linxiraos/pi-tui 1.0.9 → 1.1.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 +1 -2268
- package/LICENSE +23 -0
- package/README.md +1 -1
- package/package.json +68 -69
- package/src/autocomplete.ts +147 -61
- package/src/components/composer/borderless.ts +37 -0
- package/src/components/composer/box.ts +85 -0
- package/src/components/composer/claude.ts +39 -0
- package/src/components/composer/field.ts +47 -0
- package/src/components/composer/index.ts +9 -0
- package/src/components/composer/pi.ts +37 -0
- package/src/components/composer/rail.ts +48 -0
- package/src/components/composer/registry.ts +46 -0
- package/src/components/composer/rule.ts +55 -0
- package/src/components/composer/types.ts +98 -0
- package/src/components/editor.ts +564 -259
- package/src/components/image.ts +4 -6
- package/src/components/loader.ts +1 -9
- package/src/components/markdown.ts +487 -423
- package/src/components/select-list.ts +67 -15
- package/src/components/settings-list.ts +43 -11
- package/src/components/text.ts +8 -18
- package/src/desktop-notify.ts +1 -1
- package/src/fuzzy.ts +63 -6
- package/src/index.ts +1 -0
- package/src/keybindings.ts +5 -0
- package/src/keys.ts +1 -1
- package/src/stdin-buffer.ts +130 -20
- package/src/terminal-capabilities.ts +36 -6
- package/src/terminal.ts +148 -23
- package/src/tui.ts +655 -3458
- package/src/utils.ts +65 -61
- package/dist/types/autocomplete.d.ts +0 -116
- package/dist/types/bracketed-paste.d.ts +0 -51
- package/dist/types/components/box.d.ts +0 -31
- package/dist/types/components/cancellable-loader.d.ts +0 -21
- package/dist/types/components/editor.d.ts +0 -166
- package/dist/types/components/image.d.ts +0 -165
- package/dist/types/components/input.d.ts +0 -25
- package/dist/types/components/loader.d.ts +0 -25
- package/dist/types/components/markdown.d.ts +0 -92
- package/dist/types/components/scroll-view.d.ts +0 -62
- package/dist/types/components/select-list.d.ts +0 -69
- package/dist/types/components/settings-list.d.ts +0 -123
- package/dist/types/components/spacer.d.ts +0 -11
- package/dist/types/components/tab-bar.d.ts +0 -89
- package/dist/types/components/text.d.ts +0 -28
- package/dist/types/components/truncated-text.d.ts +0 -10
- package/dist/types/deccara.d.ts +0 -49
- package/dist/types/desktop-notify.d.ts +0 -52
- package/dist/types/editor-component.d.ts +0 -38
- package/dist/types/fuzzy.d.ts +0 -48
- package/dist/types/index.d.ts +0 -32
- package/dist/types/keybindings.d.ts +0 -197
- package/dist/types/keys.d.ts +0 -210
- package/dist/types/kill-ring.d.ts +0 -20
- package/dist/types/kitty-graphics.d.ts +0 -76
- package/dist/types/latex-block.d.ts +0 -8
- package/dist/types/latex-to-unicode.d.ts +0 -50
- package/dist/types/loop-watchdog.d.ts +0 -49
- package/dist/types/mouse.d.ts +0 -67
- package/dist/types/stdin-buffer.d.ts +0 -60
- package/dist/types/symbols.d.ts +0 -25
- package/dist/types/terminal-capabilities.d.ts +0 -324
- package/dist/types/terminal.d.ts +0 -175
- package/dist/types/tmux.d.ts +0 -6
- package/dist/types/ttyid.d.ts +0 -9
- package/dist/types/tui.d.ts +0 -480
- package/dist/types/utils.d.ts +0 -112
package/src/components/editor.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { getProjectDir, logger } from "@linxiraos/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";
|
|
@@ -23,8 +25,21 @@ import {
|
|
|
23
25
|
truncateToWidth,
|
|
24
26
|
visibleWidth,
|
|
25
27
|
} from "../utils";
|
|
28
|
+
import {
|
|
29
|
+
borderlessComposerStyle,
|
|
30
|
+
type ComposerChromeContext,
|
|
31
|
+
type ComposerStyle,
|
|
32
|
+
type EditorBorderStyle,
|
|
33
|
+
type EditorTopBorder,
|
|
34
|
+
getComposerStyle,
|
|
35
|
+
} from "./composer";
|
|
36
|
+
|
|
37
|
+
export type { EditorBorderStyle, EditorTopBorder };
|
|
38
|
+
|
|
26
39
|
import { type SelectItem, SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
|
|
27
40
|
|
|
41
|
+
const PASSTHROUGH_COLOR = (text: string): string => text;
|
|
42
|
+
|
|
28
43
|
const AUTOCOMPLETE_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
29
44
|
overflowSearch: false,
|
|
30
45
|
};
|
|
@@ -33,6 +48,7 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
|
|
33
48
|
minPrimaryColumnWidth: 12,
|
|
34
49
|
maxPrimaryColumnWidth: 32,
|
|
35
50
|
wrapDescription: true,
|
|
51
|
+
maxDescriptionRows: 2,
|
|
36
52
|
overflowSearch: false,
|
|
37
53
|
};
|
|
38
54
|
|
|
@@ -366,6 +382,8 @@ interface LayoutLine {
|
|
|
366
382
|
text: string;
|
|
367
383
|
/** Exact `visibleWidth(text)` carried from wrap/layout, never re-derived. */
|
|
368
384
|
width: number;
|
|
385
|
+
sourceLine: number;
|
|
386
|
+
sourceStartCol: number;
|
|
369
387
|
hasCursor: boolean;
|
|
370
388
|
cursorPos?: number;
|
|
371
389
|
}
|
|
@@ -379,6 +397,10 @@ interface WrapEntry {
|
|
|
379
397
|
|
|
380
398
|
export interface EditorTheme {
|
|
381
399
|
borderColor: (str: string) => string;
|
|
400
|
+
/** Stable accent for composer chrome that should not follow the mutable border state. */
|
|
401
|
+
accentColor?: (str: string) => string;
|
|
402
|
+
/** Background fill used by filled composer styles. */
|
|
403
|
+
surfaceColor?: (str: string) => string;
|
|
382
404
|
selectList: SelectListTheme;
|
|
383
405
|
symbols: SymbolTheme;
|
|
384
406
|
editorPaddingX?: number;
|
|
@@ -386,15 +408,6 @@ export interface EditorTheme {
|
|
|
386
408
|
hintStyle?: (text: string) => string;
|
|
387
409
|
}
|
|
388
410
|
|
|
389
|
-
export interface EditorTopBorder {
|
|
390
|
-
/** The status content (already styled) */
|
|
391
|
-
content: string;
|
|
392
|
-
/** Visible width of the content */
|
|
393
|
-
width: number;
|
|
394
|
-
/** Optional logical revision that changes independently of available width. */
|
|
395
|
-
revision?: number;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
411
|
interface HistoryEntry {
|
|
399
412
|
prompt: string;
|
|
400
413
|
}
|
|
@@ -404,7 +417,52 @@ interface HistoryStorage {
|
|
|
404
417
|
getRecent(limit: number): HistoryEntry[];
|
|
405
418
|
}
|
|
406
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
|
+
|
|
407
464
|
type HistoryCursorAnchor = "start" | "end";
|
|
465
|
+
type AutocompleteRequest = { kind: "regular"; explicitTab: boolean } | { kind: "force" };
|
|
408
466
|
|
|
409
467
|
export class Editor implements Component, Focusable {
|
|
410
468
|
#state: EditorState = {
|
|
@@ -412,9 +470,6 @@ export class Editor implements Component, Focusable {
|
|
|
412
470
|
cursorLine: 0,
|
|
413
471
|
cursorCol: 0,
|
|
414
472
|
};
|
|
415
|
-
#widthEpochText = "";
|
|
416
|
-
#widthEpochRevision = 0;
|
|
417
|
-
|
|
418
473
|
/** Focusable interface - set by TUI when focus changes */
|
|
419
474
|
focused: boolean = false;
|
|
420
475
|
|
|
@@ -429,7 +484,7 @@ export class Editor implements Component, Focusable {
|
|
|
429
484
|
/** Optional hook that decorates displayed user text after source-text layout.
|
|
430
485
|
* Width-changing output is allowed on lines without the cursor; it is truncated
|
|
431
486
|
* to the content width rather than reflowed. Cursor glyphs and inline hints are excluded. */
|
|
432
|
-
decorateText: ((text: string) => string) | undefined;
|
|
487
|
+
decorateText: ((text: string, context: EditorTextDecorationContext) => string) | undefined;
|
|
433
488
|
#promptGutter: string | undefined;
|
|
434
489
|
|
|
435
490
|
// Store last layout width for cursor navigation
|
|
@@ -465,17 +520,32 @@ export class Editor implements Component, Focusable {
|
|
|
465
520
|
|
|
466
521
|
// Autocomplete support
|
|
467
522
|
#autocompleteProvider?: AutocompleteProvider;
|
|
523
|
+
#textAssistProvider?: EditorTextAssistProvider;
|
|
468
524
|
#autocompleteList?: SelectList;
|
|
469
|
-
#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;
|
|
470
529
|
#autocompletePrefix: string = "";
|
|
471
530
|
#autocompleteRequestId: number = 0;
|
|
472
|
-
#
|
|
531
|
+
#autocompletePendingRequest: AutocompleteRequest | undefined;
|
|
532
|
+
#autocompleteRequestRunning = false;
|
|
533
|
+
#autocompleteAbortController: AbortController | undefined;
|
|
534
|
+
#autocompleteWaiters: Array<() => void> = [];
|
|
535
|
+
#autocompleteMaxVisible: number = 10;
|
|
473
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;
|
|
474
541
|
|
|
475
542
|
// Paste tracking for large pastes
|
|
476
543
|
#pastes: Map<number, string> = new Map();
|
|
477
544
|
#pasteCounter: number = 0;
|
|
478
545
|
|
|
546
|
+
// Host-registered atomic chip tokens: exact buffer label → expansion emitted on submit.
|
|
547
|
+
#atoms: Map<string, string> = new Map();
|
|
548
|
+
|
|
479
549
|
/** Optional pattern matching atomic placeholder tokens (e.g. `[Image #1, 800x600]` or
|
|
480
550
|
* `[Paste #2, +30 lines]`) that the editor treats as indivisible: a backspace or forward-delete
|
|
481
551
|
* landing on any character of a token removes the whole token instead of corrupting it into
|
|
@@ -519,20 +589,27 @@ export class Editor implements Component, Focusable {
|
|
|
519
589
|
// per-event rebuilds down to one per rendered frame (see #4145).
|
|
520
590
|
#topBorderContent?: EditorTopBorder;
|
|
521
591
|
#topBorderProvider?: (availableWidth: number) => EditorTopBorder | undefined;
|
|
522
|
-
#topBorderProviderWidth: number | undefined;
|
|
523
|
-
#topBorderProviderSignature: string | undefined;
|
|
524
|
-
#topBorderProviderRevision: number | undefined;
|
|
525
592
|
#borderVisible = true;
|
|
526
|
-
|
|
593
|
+
#borderStyle: EditorBorderStyle = "box";
|
|
527
594
|
constructor(theme: EditorTheme) {
|
|
528
595
|
this.#theme = theme;
|
|
529
596
|
this.borderColor = theme.borderColor;
|
|
530
597
|
}
|
|
531
598
|
|
|
599
|
+
setTheme(theme: EditorTheme): void {
|
|
600
|
+
this.#theme = theme;
|
|
601
|
+
this.borderColor = theme.borderColor;
|
|
602
|
+
}
|
|
603
|
+
|
|
532
604
|
setAutocompleteProvider(provider: AutocompleteProvider): void {
|
|
533
605
|
this.#autocompleteProvider = provider;
|
|
534
606
|
}
|
|
535
607
|
|
|
608
|
+
/** Install prose assistance without changing command/file autocomplete. */
|
|
609
|
+
setTextAssistProvider(provider: EditorTextAssistProvider | undefined): void {
|
|
610
|
+
this.#textAssistProvider = provider;
|
|
611
|
+
}
|
|
612
|
+
|
|
536
613
|
/**
|
|
537
614
|
* Set custom content for the top border (e.g., status line).
|
|
538
615
|
* Pass undefined to use the default plain border.
|
|
@@ -546,7 +623,6 @@ export class Editor implements Component, Focusable {
|
|
|
546
623
|
if (this.#topBorderContent?.content === content?.content && this.#topBorderContent?.width === content?.width)
|
|
547
624
|
return;
|
|
548
625
|
this.#topBorderContent = content;
|
|
549
|
-
this.#widthEpochRevision++;
|
|
550
626
|
}
|
|
551
627
|
|
|
552
628
|
/**
|
|
@@ -557,16 +633,10 @@ export class Editor implements Component, Focusable {
|
|
|
557
633
|
* Use this when the top border derives from state that mutates far faster
|
|
558
634
|
* than the render cadence (session events, streaming, subagent updates).
|
|
559
635
|
* The TUI already throttles renders, so a provider is invoked exactly once
|
|
560
|
-
* per frame and does no work between paints.
|
|
561
|
-
* distinguish concurrent status mutations from pure width reflow.
|
|
562
|
-
*/
|
|
636
|
+
* per frame and does no work between paints. */
|
|
563
637
|
setTopBorderProvider(provider: ((availableWidth: number) => EditorTopBorder | undefined) | undefined): void {
|
|
564
638
|
if (this.#topBorderProvider === provider) return;
|
|
565
639
|
this.#topBorderProvider = provider;
|
|
566
|
-
this.#topBorderProviderWidth = undefined;
|
|
567
|
-
this.#topBorderProviderSignature = undefined;
|
|
568
|
-
this.#topBorderProviderRevision = undefined;
|
|
569
|
-
this.#widthEpochRevision++;
|
|
570
640
|
}
|
|
571
641
|
|
|
572
642
|
/**
|
|
@@ -575,12 +645,24 @@ export class Editor implements Component, Focusable {
|
|
|
575
645
|
setBorderVisible(borderVisible: boolean): void {
|
|
576
646
|
if (this.#borderVisible === borderVisible) return;
|
|
577
647
|
this.#borderVisible = borderVisible;
|
|
578
|
-
this.#widthEpochRevision++;
|
|
579
648
|
}
|
|
580
649
|
|
|
581
650
|
setPromptGutter(promptGutter: string | undefined): void {
|
|
582
651
|
this.#promptGutter = promptGutter;
|
|
583
652
|
}
|
|
653
|
+
getBorderStyle(): EditorBorderStyle {
|
|
654
|
+
return this.#borderStyle;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
setBorderStyle(style: EditorBorderStyle): void {
|
|
658
|
+
if (this.#borderStyle === style) return;
|
|
659
|
+
this.#borderStyle = style;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** True while the autocomplete/slash-command menu is open below the editor. */
|
|
663
|
+
isAutocompleteActive(): boolean {
|
|
664
|
+
return this.#autocompleteState !== null;
|
|
665
|
+
}
|
|
584
666
|
|
|
585
667
|
/**
|
|
586
668
|
* Get the available width for top border content given a total terminal width.
|
|
@@ -598,14 +680,12 @@ export class Editor implements Component, Focusable {
|
|
|
598
680
|
setUseTerminalCursor(useTerminalCursor: boolean): void {
|
|
599
681
|
if (this.#useTerminalCursor === useTerminalCursor) return;
|
|
600
682
|
this.#useTerminalCursor = useTerminalCursor;
|
|
601
|
-
this.#widthEpochRevision++;
|
|
602
683
|
}
|
|
603
684
|
|
|
604
685
|
/** Render a dedicated bottom border so terminal-local IME preedit cannot shift editor chrome. */
|
|
605
686
|
setImeSafeCursorLayout(enabled: boolean): void {
|
|
606
687
|
if (this.#imeSafeCursorLayout === enabled) return;
|
|
607
688
|
this.#imeSafeCursorLayout = enabled;
|
|
608
|
-
this.#widthEpochRevision++;
|
|
609
689
|
}
|
|
610
690
|
|
|
611
691
|
getUseTerminalCursor(): boolean {
|
|
@@ -615,7 +695,6 @@ export class Editor implements Component, Focusable {
|
|
|
615
695
|
setMaxHeight(maxHeight: number | undefined): void {
|
|
616
696
|
if (this.#maxHeight === maxHeight) return;
|
|
617
697
|
this.#maxHeight = maxHeight;
|
|
618
|
-
this.#widthEpochRevision++;
|
|
619
698
|
// Don't reset scrollOffset — #updateScrollOffset will clamp it on next render
|
|
620
699
|
}
|
|
621
700
|
|
|
@@ -633,16 +712,16 @@ export class Editor implements Component, Focusable {
|
|
|
633
712
|
}
|
|
634
713
|
|
|
635
714
|
setAutocompleteMaxVisible(maxVisible: number): void {
|
|
636
|
-
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;
|
|
637
716
|
if (this.#autocompleteMaxVisible !== newMaxVisible) {
|
|
638
717
|
this.#autocompleteMaxVisible = newMaxVisible;
|
|
639
718
|
if (this.#autocompleteState !== null) {
|
|
640
719
|
this.#autocompleteList?.setMaxVisible(newMaxVisible);
|
|
641
|
-
this.#widthEpochRevision++;
|
|
642
720
|
}
|
|
643
721
|
}
|
|
644
722
|
}
|
|
645
723
|
|
|
724
|
+
/** Loads persistent prompts for navigation and enables future persistence. */
|
|
646
725
|
setHistoryStorage(storage: HistoryStorage): void {
|
|
647
726
|
this.#historyStorage = storage;
|
|
648
727
|
const recent = storage.getRecent(100);
|
|
@@ -657,13 +736,6 @@ export class Editor implements Component, Focusable {
|
|
|
657
736
|
addToHistory(text: string): void {
|
|
658
737
|
const trimmed = text.trim();
|
|
659
738
|
if (!trimmed) return;
|
|
660
|
-
// Don't add consecutive duplicates
|
|
661
|
-
if (this.#history.length > 0 && this.#history[0] === trimmed) return;
|
|
662
|
-
this.#history.unshift(trimmed);
|
|
663
|
-
// Limit history size
|
|
664
|
-
if (this.#history.length > 100) {
|
|
665
|
-
this.#history.pop();
|
|
666
|
-
}
|
|
667
739
|
|
|
668
740
|
const stor = this.#historyStorage;
|
|
669
741
|
if (stor) {
|
|
@@ -671,6 +743,14 @@ export class Editor implements Component, Focusable {
|
|
|
671
743
|
logger.error("HistoryStorage add failed", { error: String(error) });
|
|
672
744
|
});
|
|
673
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
|
+
}
|
|
674
754
|
}
|
|
675
755
|
|
|
676
756
|
#isEditorEmpty(): boolean {
|
|
@@ -724,31 +804,50 @@ export class Editor implements Component, Focusable {
|
|
|
724
804
|
// No cached state to invalidate currently
|
|
725
805
|
}
|
|
726
806
|
|
|
807
|
+
/** Active chrome style; a hidden border collapses every shape to borderless. */
|
|
808
|
+
#effectiveStyle(): ComposerStyle {
|
|
809
|
+
return this.#borderVisible ? getComposerStyle(this.#borderStyle) : borderlessComposerStyle;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
#getEffectivePromptGutter(): string | undefined {
|
|
813
|
+
const style = this.#effectiveStyle();
|
|
814
|
+
// The box frame never renders a gutter; hosts that set one expect it only
|
|
815
|
+
// in borderless contexts (hook editors, agents hub).
|
|
816
|
+
if (style.sideBorders) return undefined;
|
|
817
|
+
if (this.#promptGutter !== undefined) return this.#promptGutter;
|
|
818
|
+
// Legacy `setBorderVisible(false)` callers control the gutter themselves;
|
|
819
|
+
// only an explicitly selected composer shape gets the style default.
|
|
820
|
+
if (!this.#borderVisible) return undefined;
|
|
821
|
+
return style.defaultPromptGutter;
|
|
822
|
+
}
|
|
823
|
+
|
|
727
824
|
#getEditorPaddingX(): number {
|
|
728
|
-
|
|
729
|
-
return
|
|
825
|
+
if (this.#paddingXOverride !== undefined) return Math.max(0, this.#paddingXOverride);
|
|
826
|
+
return this.#effectiveStyle().defaultPaddingX(this.#theme.editorPaddingX);
|
|
730
827
|
}
|
|
731
828
|
|
|
732
829
|
#getHorizontalChromeWidth(paddingX: number): number {
|
|
733
|
-
return this.#
|
|
830
|
+
return this.#effectiveStyle().sideChromeWidth(paddingX);
|
|
734
831
|
}
|
|
735
832
|
|
|
736
833
|
#getPromptGutterWidth(width: number, paddingX: number): number {
|
|
737
|
-
|
|
834
|
+
const gutter = this.#getEffectivePromptGutter();
|
|
835
|
+
if (!gutter) return 0;
|
|
738
836
|
const chromeWidth = 2 * this.#getHorizontalChromeWidth(paddingX);
|
|
739
837
|
const availableWidth = Math.max(0, width - chromeWidth);
|
|
740
|
-
return Math.min(visibleWidth(
|
|
838
|
+
return Math.min(visibleWidth(gutter), availableWidth);
|
|
741
839
|
}
|
|
742
840
|
|
|
743
841
|
#getPromptGutter(
|
|
744
842
|
width: number,
|
|
745
843
|
paddingX: number,
|
|
746
844
|
): { firstLine: string; continuation: string; width: number } | undefined {
|
|
747
|
-
|
|
845
|
+
const gutter = this.#getEffectivePromptGutter();
|
|
846
|
+
if (!gutter) return undefined;
|
|
748
847
|
const gutterWidth = this.#getPromptGutterWidth(width, paddingX);
|
|
749
848
|
if (gutterWidth === 0) return undefined;
|
|
750
849
|
return {
|
|
751
|
-
firstLine: sliceByColumn(
|
|
850
|
+
firstLine: sliceByColumn(gutter, 0, gutterWidth, true),
|
|
752
851
|
continuation: padding(gutterWidth),
|
|
753
852
|
width: gutterWidth,
|
|
754
853
|
};
|
|
@@ -761,17 +860,15 @@ export class Editor implements Component, Focusable {
|
|
|
761
860
|
|
|
762
861
|
#getLayoutWidth(width: number, paddingX: number): number {
|
|
763
862
|
const contentWidth = this.#getContentWidth(width, paddingX);
|
|
764
|
-
const cursorReserve = this.#
|
|
863
|
+
const cursorReserve = this.#effectiveStyle().sideBorders && paddingX === 0 ? 1 : 0;
|
|
765
864
|
// Keep cursor/scroll layout addressable even when a borderless prompt gutter consumes every visible column.
|
|
766
865
|
return Math.max(1, contentWidth - cursorReserve);
|
|
767
866
|
}
|
|
768
867
|
|
|
769
868
|
#getVisibleContentHeight(contentLines: number): number {
|
|
770
869
|
if (this.#maxHeight === undefined) return contentLines;
|
|
771
|
-
|
|
772
|
-
return Math.max(1, this.#maxHeight - verticalChrome);
|
|
870
|
+
return Math.max(1, this.#maxHeight - this.#effectiveStyle().verticalChrome);
|
|
773
871
|
}
|
|
774
|
-
|
|
775
872
|
/** Apply the optional input decorator to a plain (ANSI-free) text segment.
|
|
776
873
|
* Decoration only adds zero-width SGR codes, so visible width is unchanged.
|
|
777
874
|
* Splits around CURSOR_MARKER so each user-text segment is decorated in
|
|
@@ -779,14 +876,38 @@ export class Editor implements Component, Focusable {
|
|
|
779
876
|
* the right boundary with `(?!\S)` would otherwise reject an otherwise-
|
|
780
877
|
* valid match at the cursor seam (e.g. `ultrathink` immediately followed
|
|
781
878
|
* by the marker stops glowing until a trailing character is typed). */
|
|
782
|
-
#decorate(text: string): string {
|
|
879
|
+
#decorate(text: string, context: EditorTextDecorationContext): string {
|
|
783
880
|
const decorate = this.decorateText;
|
|
784
881
|
if (decorate === undefined || text.length === 0) return text;
|
|
785
882
|
const idx = text.indexOf(CURSOR_MARKER);
|
|
786
|
-
|
|
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
|
+
}
|
|
787
893
|
const before = text.slice(0, idx);
|
|
788
894
|
const after = text.slice(idx + CURSOR_MARKER.length);
|
|
789
|
-
|
|
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
|
+
);
|
|
790
911
|
}
|
|
791
912
|
|
|
792
913
|
#getStyledInputCursor(): { text: string; width: number } {
|
|
@@ -882,20 +1003,16 @@ export class Editor implements Component, Focusable {
|
|
|
882
1003
|
}
|
|
883
1004
|
|
|
884
1005
|
render(width: number): readonly string[] {
|
|
1006
|
+
const style = this.#effectiveStyle();
|
|
885
1007
|
const paddingX = this.#getEditorPaddingX();
|
|
886
|
-
const
|
|
1008
|
+
const isSideBordered = style.sideBorders;
|
|
887
1009
|
const promptGutter = this.#getPromptGutter(width, paddingX);
|
|
888
1010
|
const contentAreaWidth = this.#getContentWidth(width, paddingX);
|
|
889
1011
|
const layoutWidth = this.#getLayoutWidth(width, paddingX);
|
|
890
1012
|
this.#lastLayoutWidth = layoutWidth;
|
|
891
1013
|
|
|
892
|
-
// Box-drawing characters for rounded corners
|
|
893
1014
|
const box = this.#theme.symbols.boxRound;
|
|
894
1015
|
const borderWidth = this.#getHorizontalChromeWidth(paddingX);
|
|
895
|
-
const topLeft = this.borderColor(`${box.topLeft}${box.horizontal.repeat(paddingX)}`);
|
|
896
|
-
const topRight = this.borderColor(`${box.horizontal.repeat(paddingX)}${box.topRight}`);
|
|
897
|
-
const bottomLeft = this.borderColor(`${box.bottomLeft}${box.horizontal}${padding(Math.max(0, paddingX - 1))}`);
|
|
898
|
-
const horizontal = this.borderColor(box.horizontal);
|
|
899
1016
|
|
|
900
1017
|
// Layout the text
|
|
901
1018
|
const layoutLines = this.#layoutText(layoutWidth);
|
|
@@ -921,51 +1038,32 @@ export class Editor implements Component, Focusable {
|
|
|
921
1038
|
scrollbarThumb = { start, end: start + thumbSize };
|
|
922
1039
|
}
|
|
923
1040
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
let topBorder: EditorTopBorder | undefined;
|
|
1041
|
+
// Resolve the custom top-border content once per frame; the style decides
|
|
1042
|
+
// how (and whether) to draw it. Provider evaluation stays editor-owned,
|
|
1043
|
+
// coalescing per-event rebuilds to one per painted frame.
|
|
1044
|
+
const topFillWidth = Math.max(0, width - borderWidth * 2);
|
|
1045
|
+
let topBorder: EditorTopBorder | undefined;
|
|
1046
|
+
if (style.statusAttachment !== "none") {
|
|
931
1047
|
if (this.#topBorderProvider) {
|
|
932
|
-
const previousWidth = this.#topBorderProviderWidth;
|
|
933
1048
|
topBorder = this.#topBorderProvider(topFillWidth);
|
|
934
|
-
const signature = topBorder ? `${topBorder.width}\0${topBorder.content}` : "";
|
|
935
|
-
const revision = topBorder?.revision;
|
|
936
|
-
if (
|
|
937
|
-
(previousWidth !== undefined &&
|
|
938
|
-
revision !== undefined &&
|
|
939
|
-
this.#topBorderProviderRevision !== undefined &&
|
|
940
|
-
revision !== this.#topBorderProviderRevision) ||
|
|
941
|
-
(previousWidth === topFillWidth && signature !== this.#topBorderProviderSignature)
|
|
942
|
-
) {
|
|
943
|
-
this.#widthEpochRevision++;
|
|
944
|
-
}
|
|
945
|
-
this.#topBorderProviderWidth = topFillWidth;
|
|
946
|
-
this.#topBorderProviderSignature = signature;
|
|
947
|
-
this.#topBorderProviderRevision = revision;
|
|
948
1049
|
} else {
|
|
949
1050
|
topBorder = this.#topBorderContent;
|
|
950
1051
|
}
|
|
951
|
-
if (topBorder) {
|
|
952
|
-
const { content, width: statusWidth } = topBorder;
|
|
953
|
-
if (statusWidth <= topFillWidth) {
|
|
954
|
-
// Status fits - add fill after it
|
|
955
|
-
const fillWidth = topFillWidth - statusWidth;
|
|
956
|
-
result.push(topLeft + content + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
|
957
|
-
} else {
|
|
958
|
-
// Status too long - truncate it
|
|
959
|
-
const truncated = truncateToWidth(content, Math.max(0, topFillWidth - 1));
|
|
960
|
-
const truncatedWidth = visibleWidth(truncated);
|
|
961
|
-
const fillWidth = Math.max(0, topFillWidth - truncatedWidth);
|
|
962
|
-
result.push(topLeft + truncated + this.borderColor(box.horizontal.repeat(fillWidth)) + topRight);
|
|
963
|
-
}
|
|
964
|
-
} else {
|
|
965
|
-
result.push(topLeft + horizontal.repeat(topFillWidth) + topRight);
|
|
966
|
-
}
|
|
967
1052
|
}
|
|
968
1053
|
|
|
1054
|
+
const chromeCtx: ComposerChromeContext = {
|
|
1055
|
+
width,
|
|
1056
|
+
paddingX,
|
|
1057
|
+
borderColor: (str: string) => this.borderColor(str),
|
|
1058
|
+
accentColor: this.#theme.accentColor ?? this.borderColor,
|
|
1059
|
+
surfaceColor: this.#theme.surfaceColor ?? PASSTHROUGH_COLOR,
|
|
1060
|
+
box,
|
|
1061
|
+
topBorder,
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
const topRow = style.renderTop(chromeCtx);
|
|
1065
|
+
if (topRow !== undefined) result.push(topRow);
|
|
1066
|
+
|
|
969
1067
|
// Render each layout line
|
|
970
1068
|
// Keep the hardware cursor at the text insertion point while autocomplete
|
|
971
1069
|
// rows render below it; terminals use that position to anchor IME candidates.
|
|
@@ -980,6 +1078,11 @@ export class Editor implements Component, Focusable {
|
|
|
980
1078
|
const layoutLine = visibleLayoutLines[visibleIndex]!;
|
|
981
1079
|
let displayText = layoutLine.text;
|
|
982
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
|
+
};
|
|
983
1086
|
let cursorPaddingOverflow = 0;
|
|
984
1087
|
let decorated = false;
|
|
985
1088
|
let imeSafeCursorTail = false;
|
|
@@ -991,12 +1094,12 @@ export class Editor implements Component, Focusable {
|
|
|
991
1094
|
const hasCursor = layoutLine.hasCursor && layoutLine.cursorPos !== undefined;
|
|
992
1095
|
const marker = emitCursorMarker ? CURSOR_MARKER : "";
|
|
993
1096
|
|
|
994
|
-
if (!
|
|
1097
|
+
if (!isSideBordered && displayWidth > lineContentWidth) {
|
|
995
1098
|
displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
|
|
996
1099
|
displayWidth = visibleWidth(displayText);
|
|
997
1100
|
}
|
|
998
1101
|
|
|
999
|
-
if (!
|
|
1102
|
+
if (!isSideBordered && lineContentWidth === 0) {
|
|
1000
1103
|
if (hasCursor && !this.#useTerminalCursor) {
|
|
1001
1104
|
const zeroWidthCursorBudget = visibleWidth(gutterText);
|
|
1002
1105
|
const zeroWidthCursorReplacement = this.cursorOverride
|
|
@@ -1039,7 +1142,7 @@ export class Editor implements Component, Focusable {
|
|
|
1039
1142
|
if (marker) {
|
|
1040
1143
|
const before = displayText.slice(0, layoutLine.cursorPos);
|
|
1041
1144
|
const after = displayText.slice(layoutLine.cursorPos);
|
|
1042
|
-
if (this.#imeSafeCursorLayout && after.length === 0 &&
|
|
1145
|
+
if (this.#imeSafeCursorLayout && after.length === 0 && isSideBordered) {
|
|
1043
1146
|
// Terminal frontends render IME marked text locally before committed bytes
|
|
1044
1147
|
// reach the application. Keep the end-of-input cursor row empty to its
|
|
1045
1148
|
// right so that insertion cannot shift box chrome onto the next row.
|
|
@@ -1050,7 +1153,7 @@ export class Editor implements Component, Focusable {
|
|
|
1050
1153
|
const hintText = hintStyle(truncateToWidth(inlineHint, availWidth));
|
|
1051
1154
|
displayText = before + marker + hintText;
|
|
1052
1155
|
displayWidth += Math.min(visibleWidth(inlineHint), availWidth);
|
|
1053
|
-
} else if (after.length === 0 && !
|
|
1156
|
+
} else if (after.length === 0 && !isSideBordered && displayWidth >= lineContentWidth) {
|
|
1054
1157
|
displayText = this.#renderTerminalCursorMarker(before, marker, lineContentWidth);
|
|
1055
1158
|
} else {
|
|
1056
1159
|
displayText = before + marker + after;
|
|
@@ -1070,13 +1173,20 @@ export class Editor implements Component, Focusable {
|
|
|
1070
1173
|
// Decorate the plain text on each side of the cursor glyph. The reverse-video
|
|
1071
1174
|
// reset (\x1b[0m) ends in "m" (a word char), so a boundary match on restAfter
|
|
1072
1175
|
// would fail in the whole-line fallback below — decorate the segments here.
|
|
1073
|
-
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
|
+
});
|
|
1074
1184
|
decorated = true;
|
|
1075
1185
|
// displayWidth stays the same - we're replacing, not adding
|
|
1076
1186
|
} else if (this.cursorOverride) {
|
|
1077
1187
|
// Cursor override replaces the normal end-of-text cursor glyph
|
|
1078
1188
|
const overrideWidth = this.cursorOverrideWidth ?? 1;
|
|
1079
|
-
if (!
|
|
1189
|
+
if (!isSideBordered && displayWidth + overrideWidth > lineContentWidth) {
|
|
1080
1190
|
// Borderless editors have no spare padding cell for an end-of-line cursor glyph.
|
|
1081
1191
|
// Preserve cursorOverride by replacing the tail of the line with it.
|
|
1082
1192
|
const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth, {
|
|
@@ -1097,7 +1207,7 @@ export class Editor implements Component, Focusable {
|
|
|
1097
1207
|
} else {
|
|
1098
1208
|
// Cursor is at the end - add thin cursor glyph
|
|
1099
1209
|
const { text: cursor, width: cursorWidth } = this.#getStyledInputCursor();
|
|
1100
|
-
if (!
|
|
1210
|
+
if (!isSideBordered && displayWidth + cursorWidth > lineContentWidth) {
|
|
1101
1211
|
// Borderless editors have no spare padding cell for an end-of-line cursor glyph.
|
|
1102
1212
|
// Highlight the last grapheme so the cursor stays visible without consuming width.
|
|
1103
1213
|
const widthLimitedCursor = this.#renderEndOfLineCursorAtWidthLimit(before, marker, lineContentWidth);
|
|
@@ -1122,7 +1232,7 @@ export class Editor implements Component, Focusable {
|
|
|
1122
1232
|
// the whole line. `#decorate` splits around CURSOR_MARKER so a keyword glued to
|
|
1123
1233
|
// the cursor still satisfies its right-boundary lookahead.
|
|
1124
1234
|
if (!decorated) {
|
|
1125
|
-
displayText = this.#decorate(displayText);
|
|
1235
|
+
displayText = this.#decorate(displayText, decorationContext);
|
|
1126
1236
|
}
|
|
1127
1237
|
if (!hasCursor) {
|
|
1128
1238
|
// Undecorated, unsliced lines keep their carried width; any
|
|
@@ -1136,46 +1246,32 @@ export class Editor implements Component, Focusable {
|
|
|
1136
1246
|
|
|
1137
1247
|
const linePad = padding(Math.max(0, lineContentWidth - displayWidth));
|
|
1138
1248
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
const bottomBorder = this.borderColor(
|
|
1153
|
-
`${box.bottomLeft}${box.horizontal.repeat(Math.max(0, width - 2))}${box.bottomRight}`,
|
|
1154
|
-
);
|
|
1155
|
-
result.push(leftBorder + displayText);
|
|
1156
|
-
result.push(bottomBorder);
|
|
1157
|
-
continue;
|
|
1158
|
-
}
|
|
1159
|
-
if (isLastLine) {
|
|
1160
|
-
const rightPad = Math.max(0, rightChromeCells - 2);
|
|
1161
|
-
const includeHorizontal = rightChromeCells >= 2;
|
|
1162
|
-
const bottomRightAdjusted = this.borderColor(
|
|
1163
|
-
`${padding(rightPad)}${includeHorizontal ? box.horizontal : ""}${box.bottomRight}`,
|
|
1164
|
-
);
|
|
1165
|
-
result.push(`${bottomLeft}${displayText}${linePad}${bottomRightAdjusted}`);
|
|
1166
|
-
} else {
|
|
1167
|
-
const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
|
|
1168
|
-
// When scrollbar is active, replace the right border vertical with a
|
|
1169
|
-
// thumb glyph (█) on lines inside the thumb range, keeping the track (│) elsewhere.
|
|
1170
|
-
const inThumb = scrollbarThumb && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end;
|
|
1171
|
-
const rightGlyph = inThumb ? "█" : box.vertical;
|
|
1172
|
-
const rightBorder = this.borderColor(`${padding(Math.max(0, rightChromeCells - 1))}${rightGlyph}`);
|
|
1173
|
-
result.push(leftBorder + displayText + linePad + rightBorder);
|
|
1174
|
-
}
|
|
1249
|
+
result.push(
|
|
1250
|
+
...style.renderRow({
|
|
1251
|
+
...chromeCtx,
|
|
1252
|
+
text: displayText,
|
|
1253
|
+
pad: linePad,
|
|
1254
|
+
gutter: gutterText,
|
|
1255
|
+
isLastRow: visibleIndex === visibleLayoutLines.length - 1,
|
|
1256
|
+
cursorOverflow: cursorPaddingOverflow,
|
|
1257
|
+
imeSafeCursorTail,
|
|
1258
|
+
scrollbarThumb:
|
|
1259
|
+
scrollbarThumb !== null && visibleIndex >= scrollbarThumb.start && visibleIndex < scrollbarThumb.end,
|
|
1260
|
+
}),
|
|
1261
|
+
);
|
|
1175
1262
|
}
|
|
1176
1263
|
|
|
1264
|
+
const bottomRow = style.renderBottom(chromeCtx);
|
|
1265
|
+
if (bottomRow !== undefined) result.push(bottomRow);
|
|
1266
|
+
|
|
1177
1267
|
// Add autocomplete list if active
|
|
1178
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
|
+
);
|
|
1179
1275
|
const autocompleteResult = this.#autocompleteList.render(width);
|
|
1180
1276
|
result.push(...autocompleteResult);
|
|
1181
1277
|
}
|
|
@@ -1200,6 +1296,11 @@ export class Editor implements Component, Focusable {
|
|
|
1200
1296
|
// lookup instead of re-parsing `data` per probe (~35 probes per key).
|
|
1201
1297
|
const parsedKey = parseKey(data);
|
|
1202
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
|
+
}
|
|
1203
1304
|
|
|
1204
1305
|
// Handle character jump mode (awaiting next character to jump to)
|
|
1205
1306
|
if (this.#jumpMode !== null) {
|
|
@@ -1262,6 +1363,11 @@ export class Editor implements Component, Focusable {
|
|
|
1262
1363
|
return;
|
|
1263
1364
|
}
|
|
1264
1365
|
|
|
1366
|
+
if (kb.matchesCanonical(canonical, "tui.editor.spellingSuggestions")) {
|
|
1367
|
+
void this.#showSpellingSuggestions();
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1265
1371
|
// Handle autocomplete special keys first (but don't block other input)
|
|
1266
1372
|
if (this.#autocompleteState && this.#autocompleteList) {
|
|
1267
1373
|
// Escape - cancel autocomplete
|
|
@@ -1269,6 +1375,15 @@ export class Editor implements Component, Focusable {
|
|
|
1269
1375
|
this.#cancelAutocomplete(true);
|
|
1270
1376
|
return;
|
|
1271
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
|
+
}
|
|
1272
1387
|
// Let the autocomplete list handle navigation and selection
|
|
1273
1388
|
else if (
|
|
1274
1389
|
kb.matchesCanonical(canonical, "tui.select.up") ||
|
|
@@ -1287,7 +1402,6 @@ export class Editor implements Component, Focusable {
|
|
|
1287
1402
|
kb.matchesCanonical(canonical, "tui.select.pageDown")
|
|
1288
1403
|
) {
|
|
1289
1404
|
this.#autocompleteList.handleInput(data);
|
|
1290
|
-
this.#widthEpochRevision++;
|
|
1291
1405
|
this.onAutocompleteUpdate?.();
|
|
1292
1406
|
return;
|
|
1293
1407
|
}
|
|
@@ -1340,7 +1454,8 @@ export class Editor implements Component, Focusable {
|
|
|
1340
1454
|
(kb.matchesCanonical(canonical, "tui.input.submit") || data === "\n") &&
|
|
1341
1455
|
findLeadingSlashCommandStart(this.#autocompletePrefix) !== null &&
|
|
1342
1456
|
this.#isInSubmittedSlashCommandContext() &&
|
|
1343
|
-
!this.#selectedCompletionIsPath()
|
|
1457
|
+
!this.#selectedCompletionIsPath() &&
|
|
1458
|
+
!this.#selectedCompletionIsSkillNamespace()
|
|
1344
1459
|
) {
|
|
1345
1460
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1346
1461
|
// Check for stale autocomplete state due to debounce
|
|
@@ -1379,6 +1494,7 @@ export class Editor implements Component, Focusable {
|
|
|
1379
1494
|
this.#cancelAutocomplete();
|
|
1380
1495
|
} else {
|
|
1381
1496
|
if (selected && this.#autocompleteProvider) {
|
|
1497
|
+
const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
|
|
1382
1498
|
const result = this.#autocompleteProvider.applyCompletion(
|
|
1383
1499
|
this.#state.lines,
|
|
1384
1500
|
this.#state.cursorLine,
|
|
@@ -1399,6 +1515,9 @@ export class Editor implements Component, Focusable {
|
|
|
1399
1515
|
}
|
|
1400
1516
|
|
|
1401
1517
|
result.onApplied?.();
|
|
1518
|
+
if (shouldChainSlashCommandAutocomplete && this.#isCompletedSlashCommandAtCursor()) {
|
|
1519
|
+
void this.#tryTriggerAutocomplete();
|
|
1520
|
+
}
|
|
1402
1521
|
}
|
|
1403
1522
|
return;
|
|
1404
1523
|
}
|
|
@@ -1408,9 +1527,14 @@ export class Editor implements Component, Focusable {
|
|
|
1408
1527
|
// Let them fall through to normal character handling
|
|
1409
1528
|
}
|
|
1410
1529
|
|
|
1530
|
+
if (this.#autocompleteState === "assist") {
|
|
1531
|
+
this.#cancelAutocomplete();
|
|
1532
|
+
this.onAutocompleteUpdate?.();
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1411
1535
|
// Tab key - context-aware completion (but not when already autocompleting)
|
|
1412
1536
|
if (kb.matchesCanonical(canonical, "tui.input.tab") && !this.#autocompleteState) {
|
|
1413
|
-
this.#handleTabCompletion();
|
|
1537
|
+
void this.#handleTabCompletion();
|
|
1414
1538
|
return;
|
|
1415
1539
|
}
|
|
1416
1540
|
|
|
@@ -1458,14 +1582,19 @@ export class Editor implements Component, Focusable {
|
|
|
1458
1582
|
this.#addNewLine();
|
|
1459
1583
|
}
|
|
1460
1584
|
}
|
|
1461
|
-
// New line
|
|
1585
|
+
// New line. A key the user explicitly bound to `tui.input.submit` wins
|
|
1586
|
+
// over these hardcoded newline fallbacks, so Ctrl/Shift+Enter can be
|
|
1587
|
+
// remapped to submit (#8906). The bare-LF case is exempt: its canonical
|
|
1588
|
+
// form is "enter" (indistinguishable from plain Enter), so gating it
|
|
1589
|
+
// would hijack the default Enter=submit binding.
|
|
1462
1590
|
else if (
|
|
1463
|
-
(
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1591
|
+
(!kb.matchesCanonical(canonical, "tui.input.submit") &&
|
|
1592
|
+
((data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
|
|
1593
|
+
matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
|
|
1594
|
+
data === "\x1b\r" || // Option+Enter in some terminals (legacy)
|
|
1595
|
+
data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
|
|
1596
|
+
kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
|
|
1597
|
+
(data.length > 1 && data.includes("\x1b") && data.includes("\r")))) ||
|
|
1469
1598
|
(data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
|
|
1470
1599
|
) {
|
|
1471
1600
|
if (this.#shouldSubmitOnBackslashEnter(data, kb)) {
|
|
@@ -1631,6 +1760,8 @@ export class Editor implements Component, Focusable {
|
|
|
1631
1760
|
layoutLines.push({
|
|
1632
1761
|
text: "",
|
|
1633
1762
|
width: 0,
|
|
1763
|
+
sourceLine: 0,
|
|
1764
|
+
sourceStartCol: 0,
|
|
1634
1765
|
hasCursor: true,
|
|
1635
1766
|
cursorPos: 0,
|
|
1636
1767
|
});
|
|
@@ -1649,6 +1780,8 @@ export class Editor implements Component, Focusable {
|
|
|
1649
1780
|
layoutLines.push({
|
|
1650
1781
|
text: line,
|
|
1651
1782
|
width: lineVisibleWidth,
|
|
1783
|
+
sourceLine: i,
|
|
1784
|
+
sourceStartCol: 0,
|
|
1652
1785
|
hasCursor: true,
|
|
1653
1786
|
cursorPos: this.#state.cursorCol,
|
|
1654
1787
|
});
|
|
@@ -1656,6 +1789,8 @@ export class Editor implements Component, Focusable {
|
|
|
1656
1789
|
layoutLines.push({
|
|
1657
1790
|
text: line,
|
|
1658
1791
|
width: lineVisibleWidth,
|
|
1792
|
+
sourceLine: i,
|
|
1793
|
+
sourceStartCol: 0,
|
|
1659
1794
|
hasCursor: false,
|
|
1660
1795
|
});
|
|
1661
1796
|
}
|
|
@@ -1697,6 +1832,8 @@ export class Editor implements Component, Focusable {
|
|
|
1697
1832
|
layoutLines.push({
|
|
1698
1833
|
text: chunk.text,
|
|
1699
1834
|
width: chunk.width,
|
|
1835
|
+
sourceLine: i,
|
|
1836
|
+
sourceStartCol: chunk.startIndex,
|
|
1700
1837
|
hasCursor: true,
|
|
1701
1838
|
cursorPos: adjustedCursorPos,
|
|
1702
1839
|
});
|
|
@@ -1704,6 +1841,8 @@ export class Editor implements Component, Focusable {
|
|
|
1704
1841
|
layoutLines.push({
|
|
1705
1842
|
text: chunk.text,
|
|
1706
1843
|
width: chunk.width,
|
|
1844
|
+
sourceLine: i,
|
|
1845
|
+
sourceStartCol: chunk.startIndex,
|
|
1707
1846
|
hasCursor: false,
|
|
1708
1847
|
});
|
|
1709
1848
|
}
|
|
@@ -1718,15 +1857,6 @@ export class Editor implements Component, Focusable {
|
|
|
1718
1857
|
return this.#state.lines.join("\n");
|
|
1719
1858
|
}
|
|
1720
1859
|
|
|
1721
|
-
getNativeScrollbackWidthEpochRevision(): number {
|
|
1722
|
-
const text = this.getText();
|
|
1723
|
-
if (text !== this.#widthEpochText) {
|
|
1724
|
-
this.#widthEpochText = text;
|
|
1725
|
-
this.#widthEpochRevision++;
|
|
1726
|
-
}
|
|
1727
|
-
return this.#widthEpochRevision;
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
1860
|
/** Whether the buffer text equals `value`, without `getText()`'s full join —
|
|
1731
1861
|
* O(1) for the hot per-keystroke probes against short single-line values. */
|
|
1732
1862
|
textEquals(value: string): boolean {
|
|
@@ -1736,13 +1866,48 @@ export class Editor implements Component, Focusable {
|
|
|
1736
1866
|
return this.getText() === value;
|
|
1737
1867
|
}
|
|
1738
1868
|
|
|
1869
|
+
/** Expand collapsed markers — `[Paste #N]` tokens and registered atom labels — into their
|
|
1870
|
+
* stored content. Single pass so replaced content is never rescanned (a pasted body that
|
|
1871
|
+
* happens to contain another token's label must survive verbatim). Longer atom labels are
|
|
1872
|
+
* tried first so `#1` never shadows `#10`. */
|
|
1739
1873
|
#expandPasteMarkers(text: string): string {
|
|
1740
|
-
|
|
1741
|
-
for (const
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1874
|
+
const sources: string[] = [];
|
|
1875
|
+
for (const pasteId of this.#pastes.keys()) {
|
|
1876
|
+
sources.push(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`);
|
|
1877
|
+
}
|
|
1878
|
+
const labels = [...this.#atoms.keys()].sort((a, b) => b.length - a.length);
|
|
1879
|
+
for (const label of labels) sources.push(RegExp.escape(label));
|
|
1880
|
+
if (sources.length === 0) return text;
|
|
1881
|
+
const markerRegex = new RegExp(sources.join("|"), "g");
|
|
1882
|
+
return text.replace(markerRegex, match => {
|
|
1883
|
+
const paste = /^\[Paste #(\d+)/.exec(match);
|
|
1884
|
+
if (paste) return this.#pastes.get(Number(paste[1])) ?? match;
|
|
1885
|
+
return this.#atoms.get(match) ?? match;
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
/** Register `label` as a collapsed atom expanding to `expansion` on submit, without inserting
|
|
1890
|
+
* it — for hosts that re-collapse restored draft text via {@link setText}. */
|
|
1891
|
+
registerAtom(label: string, expansion: string): void {
|
|
1892
|
+
this.#atoms.set(label, expansion);
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
/** Insert `label` (plus a trailing space) at the cursor and register it as an atom expanding
|
|
1896
|
+
* to `expansion` on submit. Pair with {@link atomicTokenPattern} so the label deletes as a
|
|
1897
|
+
* unit. */
|
|
1898
|
+
insertAtom(label: string, expansion: string): void {
|
|
1899
|
+
this.#historyIndex = -1;
|
|
1900
|
+
this.#resetKillSequence();
|
|
1901
|
+
this.#recordUndoState();
|
|
1902
|
+
this.registerAtom(label, expansion);
|
|
1903
|
+
this.#withUndoSuspended(() => {
|
|
1904
|
+
this.#insertTextAtCursor(`${label} `);
|
|
1905
|
+
});
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/** Drop every registered atom expansion (draft cleared or replaced by the host). */
|
|
1909
|
+
clearAtoms(): void {
|
|
1910
|
+
this.#atoms.clear();
|
|
1746
1911
|
}
|
|
1747
1912
|
|
|
1748
1913
|
/**
|
|
@@ -1952,6 +2117,27 @@ export class Editor implements Component, Focusable {
|
|
|
1952
2117
|
}
|
|
1953
2118
|
|
|
1954
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
|
+
|
|
1955
2141
|
#insertCharacter(char: string): void {
|
|
1956
2142
|
this.#exitHistoryForEditing();
|
|
1957
2143
|
// Undo coalescing: consecutive word typing collapses into one undo unit
|
|
@@ -1977,22 +2163,30 @@ export class Editor implements Component, Focusable {
|
|
|
1977
2163
|
// Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
|
|
1978
2164
|
// Runs before autocomplete trigger so the popup doesn't briefly chase a
|
|
1979
2165
|
// prefix that's about to be rewritten.
|
|
1980
|
-
if (char.length === 1
|
|
2166
|
+
if (char.length === 1) {
|
|
1981
2167
|
const replaceLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
1982
2168
|
const textBeforeCursor = replaceLine.slice(0, this.#state.cursorCol);
|
|
1983
|
-
const
|
|
1984
|
-
if (
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
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)) {
|
|
1996
2190
|
return;
|
|
1997
2191
|
}
|
|
1998
2192
|
}
|
|
@@ -2197,6 +2391,7 @@ export class Editor implements Component, Focusable {
|
|
|
2197
2391
|
this.#state = { lines: [""], cursorLine: 0, cursorCol: 0 };
|
|
2198
2392
|
this.#pastes.clear();
|
|
2199
2393
|
this.#pasteCounter = 0;
|
|
2394
|
+
this.#atoms.clear();
|
|
2200
2395
|
this.#historyIndex = -1;
|
|
2201
2396
|
this.#scrollOffset = 0;
|
|
2202
2397
|
this.#undoStack.length = 0;
|
|
@@ -3142,6 +3337,14 @@ export class Editor implements Component, Focusable {
|
|
|
3142
3337
|
if (!selected) return false;
|
|
3143
3338
|
return selected.value.startsWith("/") || selected.value.startsWith('"');
|
|
3144
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
|
+
}
|
|
3145
3348
|
|
|
3146
3349
|
#isSlashCommandNameAutocompleteSelection(): boolean {
|
|
3147
3350
|
if (this.#autocompleteState !== "regular") {
|
|
@@ -3162,7 +3365,10 @@ export class Editor implements Component, Focusable {
|
|
|
3162
3365
|
}
|
|
3163
3366
|
|
|
3164
3367
|
const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol).trimStart();
|
|
3165
|
-
return
|
|
3368
|
+
return (
|
|
3369
|
+
this.#isInSubmittedSlashCommandContext() &&
|
|
3370
|
+
(/^\/\S+ $/.test(textBeforeCursor) || textBeforeCursor === `/${SKILL_NAMESPACE}`)
|
|
3371
|
+
);
|
|
3166
3372
|
}
|
|
3167
3373
|
|
|
3168
3374
|
// Autocomplete methods
|
|
@@ -3179,39 +3385,18 @@ export class Editor implements Component, Focusable {
|
|
|
3179
3385
|
|
|
3180
3386
|
async #tryTriggerAutocomplete(explicitTab: boolean = false): Promise<void> {
|
|
3181
3387
|
if (!this.#autocompleteProvider) return;
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
this.#
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
return;
|
|
3193
|
-
}
|
|
3194
|
-
}
|
|
3195
|
-
|
|
3196
|
-
const requestId = ++this.#autocompleteRequestId;
|
|
3197
|
-
|
|
3198
|
-
const suggestions = await this.#autocompleteProvider.getSuggestions(
|
|
3199
|
-
this.#state.lines,
|
|
3200
|
-
this.#state.cursorLine,
|
|
3201
|
-
this.#state.cursorCol,
|
|
3202
|
-
);
|
|
3203
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3204
|
-
|
|
3205
|
-
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3206
|
-
this.#autocompletePrefix = suggestions.prefix;
|
|
3207
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3208
|
-
this.#autocompleteState = "regular";
|
|
3209
|
-
this.#widthEpochRevision++;
|
|
3210
|
-
this.onAutocompleteUpdate?.();
|
|
3211
|
-
} else {
|
|
3212
|
-
this.#cancelAutocomplete();
|
|
3213
|
-
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;
|
|
3214
3398
|
}
|
|
3399
|
+
await this.#queueAutocompleteRequest({ kind: "regular", explicitTab });
|
|
3215
3400
|
}
|
|
3216
3401
|
#createAutocompleteList(
|
|
3217
3402
|
prefix: string,
|
|
@@ -3222,6 +3407,13 @@ export class Editor implements Component, Focusable {
|
|
|
3222
3407
|
}
|
|
3223
3408
|
|
|
3224
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
|
+
}
|
|
3225
3417
|
if (!this.#autocompleteProvider) return;
|
|
3226
3418
|
|
|
3227
3419
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3238,49 +3430,88 @@ export class Editor implements Component, Focusable {
|
|
|
3238
3430
|
await this.#forceFileAutocomplete();
|
|
3239
3431
|
}
|
|
3240
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
|
+
}
|
|
3241
3494
|
async #handleSlashCommandCompletion(): Promise<void> {
|
|
3242
3495
|
await this.#tryTriggerAutocomplete();
|
|
3243
3496
|
}
|
|
3244
3497
|
|
|
3245
3498
|
async #forceFileAutocomplete(): Promise<void> {
|
|
3246
3499
|
if (!this.#autocompleteProvider) return;
|
|
3247
|
-
|
|
3248
|
-
// File-aware providers expose getForceFileSuggestions; slash-only ones fall back to regular completion.
|
|
3249
|
-
const getForceFileSuggestions = this.#autocompleteProvider.getForceFileSuggestions;
|
|
3250
|
-
if (typeof getForceFileSuggestions !== "function") {
|
|
3500
|
+
if (typeof this.#autocompleteProvider.getForceFileSuggestions !== "function") {
|
|
3251
3501
|
await this.#tryTriggerAutocomplete(true);
|
|
3252
3502
|
return;
|
|
3253
3503
|
}
|
|
3254
|
-
|
|
3255
|
-
const requestId = ++this.#autocompleteRequestId;
|
|
3256
|
-
const suggestions = await getForceFileSuggestions.call(
|
|
3257
|
-
this.#autocompleteProvider,
|
|
3258
|
-
this.#state.lines,
|
|
3259
|
-
this.#state.cursorLine,
|
|
3260
|
-
this.#state.cursorCol,
|
|
3261
|
-
);
|
|
3262
|
-
if (requestId !== this.#autocompleteRequestId) return;
|
|
3263
|
-
|
|
3264
|
-
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3265
|
-
this.#autocompletePrefix = suggestions.prefix;
|
|
3266
|
-
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3267
|
-
this.#autocompleteState = "force";
|
|
3268
|
-
this.#widthEpochRevision++;
|
|
3269
|
-
this.onAutocompleteUpdate?.();
|
|
3270
|
-
} else {
|
|
3271
|
-
this.#cancelAutocomplete();
|
|
3272
|
-
this.onAutocompleteUpdate?.();
|
|
3273
|
-
}
|
|
3504
|
+
await this.#queueAutocompleteRequest({ kind: "force" });
|
|
3274
3505
|
}
|
|
3275
3506
|
|
|
3276
3507
|
#cancelAutocomplete(notifyCancel: boolean = false): void {
|
|
3277
3508
|
const wasAutocompleting = this.#autocompleteState !== null;
|
|
3278
3509
|
this.#clearAutocompleteTimeout();
|
|
3279
|
-
this.#
|
|
3510
|
+
this.#invalidateAutocompleteRequests();
|
|
3280
3511
|
this.#autocompleteState = null;
|
|
3281
3512
|
this.#autocompleteList = undefined;
|
|
3513
|
+
this.#textAssistReplacement = undefined;
|
|
3282
3514
|
this.#autocompletePrefix = "";
|
|
3283
|
-
if (wasAutocompleting) this.#widthEpochRevision++;
|
|
3284
3515
|
if (notifyCancel && wasAutocompleting) {
|
|
3285
3516
|
this.onAutocompleteCancel?.();
|
|
3286
3517
|
}
|
|
@@ -3291,33 +3522,97 @@ export class Editor implements Component, Focusable {
|
|
|
3291
3522
|
}
|
|
3292
3523
|
|
|
3293
3524
|
async #updateAutocomplete(): Promise<void> {
|
|
3294
|
-
if (!this.#autocompleteState || !this.#autocompleteProvider) return;
|
|
3295
|
-
|
|
3296
|
-
// In force mode, use forceFileAutocomplete to get suggestions
|
|
3525
|
+
if (!this.#autocompleteState || !this.#autocompleteProvider || this.#autocompleteState === "assist") return;
|
|
3297
3526
|
if (this.#autocompleteState === "force") {
|
|
3298
|
-
this.#forceFileAutocomplete();
|
|
3527
|
+
await this.#forceFileAutocomplete();
|
|
3299
3528
|
return;
|
|
3300
3529
|
}
|
|
3530
|
+
await this.#queueAutocompleteRequest({ kind: "regular", explicitTab: false });
|
|
3531
|
+
}
|
|
3301
3532
|
|
|
3302
|
-
|
|
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
|
+
}
|
|
3303
3542
|
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
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
|
+
}
|
|
3310
3598
|
|
|
3311
3599
|
if (suggestions && Array.isArray(suggestions.items) && suggestions.items.length > 0) {
|
|
3312
3600
|
this.#autocompletePrefix = suggestions.prefix;
|
|
3313
|
-
// Always create new SelectList to ensure update
|
|
3314
3601
|
this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
|
|
3315
|
-
this.#
|
|
3316
|
-
this.onAutocompleteUpdate?.();
|
|
3317
|
-
} else {
|
|
3318
|
-
this.#cancelAutocomplete();
|
|
3602
|
+
this.#autocompleteState = request.kind === "force" ? "force" : "regular";
|
|
3319
3603
|
this.onAutocompleteUpdate?.();
|
|
3604
|
+
return;
|
|
3320
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();
|
|
3321
3616
|
}
|
|
3322
3617
|
|
|
3323
3618
|
#debouncedUpdateAutocomplete(): void {
|
|
@@ -3325,7 +3620,7 @@ export class Editor implements Component, Focusable {
|
|
|
3325
3620
|
clearTimeout(this.#autocompleteTimeout);
|
|
3326
3621
|
}
|
|
3327
3622
|
this.#autocompleteTimeout = setTimeout(() => {
|
|
3328
|
-
this.#updateAutocomplete();
|
|
3623
|
+
void this.#updateAutocomplete();
|
|
3329
3624
|
this.#autocompleteTimeout = undefined;
|
|
3330
3625
|
}, 100);
|
|
3331
3626
|
}
|
|
@@ -3350,13 +3645,23 @@ export class Editor implements Component, Focusable {
|
|
|
3350
3645
|
|
|
3351
3646
|
// Fall back to provider's getInlineHint
|
|
3352
3647
|
if (this.#autocompleteProvider?.getInlineHint) {
|
|
3353
|
-
|
|
3648
|
+
const hint = this.#autocompleteProvider.getInlineHint(
|
|
3354
3649
|
this.#state.lines,
|
|
3355
3650
|
this.#state.cursorLine,
|
|
3356
3651
|
this.#state.cursorCol,
|
|
3357
3652
|
);
|
|
3653
|
+
if (hint) return hint;
|
|
3358
3654
|
}
|
|
3359
3655
|
|
|
3360
|
-
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
|
+
);
|
|
3361
3666
|
}
|
|
3362
3667
|
}
|