@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +1 -2268
  2. package/LICENSE +23 -0
  3. package/README.md +1 -1
  4. package/package.json +68 -69
  5. package/src/autocomplete.ts +147 -61
  6. package/src/components/composer/borderless.ts +37 -0
  7. package/src/components/composer/box.ts +85 -0
  8. package/src/components/composer/claude.ts +39 -0
  9. package/src/components/composer/field.ts +47 -0
  10. package/src/components/composer/index.ts +9 -0
  11. package/src/components/composer/pi.ts +37 -0
  12. package/src/components/composer/rail.ts +48 -0
  13. package/src/components/composer/registry.ts +46 -0
  14. package/src/components/composer/rule.ts +55 -0
  15. package/src/components/composer/types.ts +98 -0
  16. package/src/components/editor.ts +564 -259
  17. package/src/components/image.ts +4 -6
  18. package/src/components/loader.ts +1 -9
  19. package/src/components/markdown.ts +487 -423
  20. package/src/components/select-list.ts +67 -15
  21. package/src/components/settings-list.ts +43 -11
  22. package/src/components/text.ts +8 -18
  23. package/src/desktop-notify.ts +1 -1
  24. package/src/fuzzy.ts +63 -6
  25. package/src/index.ts +1 -0
  26. package/src/keybindings.ts +5 -0
  27. package/src/keys.ts +1 -1
  28. package/src/stdin-buffer.ts +130 -20
  29. package/src/terminal-capabilities.ts +36 -6
  30. package/src/terminal.ts +148 -23
  31. package/src/tui.ts +655 -3458
  32. package/src/utils.ts +65 -61
  33. package/dist/types/autocomplete.d.ts +0 -116
  34. package/dist/types/bracketed-paste.d.ts +0 -51
  35. package/dist/types/components/box.d.ts +0 -31
  36. package/dist/types/components/cancellable-loader.d.ts +0 -21
  37. package/dist/types/components/editor.d.ts +0 -166
  38. package/dist/types/components/image.d.ts +0 -165
  39. package/dist/types/components/input.d.ts +0 -25
  40. package/dist/types/components/loader.d.ts +0 -25
  41. package/dist/types/components/markdown.d.ts +0 -92
  42. package/dist/types/components/scroll-view.d.ts +0 -62
  43. package/dist/types/components/select-list.d.ts +0 -69
  44. package/dist/types/components/settings-list.d.ts +0 -123
  45. package/dist/types/components/spacer.d.ts +0 -11
  46. package/dist/types/components/tab-bar.d.ts +0 -89
  47. package/dist/types/components/text.d.ts +0 -28
  48. package/dist/types/components/truncated-text.d.ts +0 -10
  49. package/dist/types/deccara.d.ts +0 -49
  50. package/dist/types/desktop-notify.d.ts +0 -52
  51. package/dist/types/editor-component.d.ts +0 -38
  52. package/dist/types/fuzzy.d.ts +0 -48
  53. package/dist/types/index.d.ts +0 -32
  54. package/dist/types/keybindings.d.ts +0 -197
  55. package/dist/types/keys.d.ts +0 -210
  56. package/dist/types/kill-ring.d.ts +0 -20
  57. package/dist/types/kitty-graphics.d.ts +0 -76
  58. package/dist/types/latex-block.d.ts +0 -8
  59. package/dist/types/latex-to-unicode.d.ts +0 -50
  60. package/dist/types/loop-watchdog.d.ts +0 -49
  61. package/dist/types/mouse.d.ts +0 -67
  62. package/dist/types/stdin-buffer.d.ts +0 -60
  63. package/dist/types/symbols.d.ts +0 -25
  64. package/dist/types/terminal-capabilities.d.ts +0 -324
  65. package/dist/types/terminal.d.ts +0 -175
  66. package/dist/types/tmux.d.ts +0 -6
  67. package/dist/types/ttyid.d.ts +0 -9
  68. package/dist/types/tui.d.ts +0 -480
  69. package/dist/types/utils.d.ts +0 -112
@@ -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
- #autocompleteMaxVisible: number = 5;
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. Return a logical `revision` to
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))) : 5;
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
- const padding = this.#paddingXOverride ?? this.#theme.editorPaddingX ?? 2;
729
- return Math.max(0, padding);
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.#borderVisible ? paddingX + 1 : 0;
830
+ return this.#effectiveStyle().sideChromeWidth(paddingX);
734
831
  }
735
832
 
736
833
  #getPromptGutterWidth(width: number, paddingX: number): number {
737
- if (this.#borderVisible || !this.#promptGutter) return 0;
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(this.#promptGutter), availableWidth);
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
- if (this.#borderVisible || !this.#promptGutter) return undefined;
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(this.#promptGutter, 0, gutterWidth, true),
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.#borderVisible && paddingX === 0 ? 1 : 0;
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
- const verticalChrome = this.#borderVisible ? 2 : 0;
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
- if (idx === -1) return decorate(text);
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
- return (before.length > 0 ? decorate(before) : "") + CURSOR_MARKER + (after.length > 0 ? decorate(after) : "");
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 borderVisible = this.#borderVisible;
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
- if (borderVisible) {
925
- // Render top border: ╭─ [status content] ────────────────╮
926
- const topFillWidth = Math.max(0, width - borderWidth * 2);
927
- // Provider (lazy) wins over eager content — a host that installs both
928
- // wants the coalesced path; falling back to eager keeps existing
929
- // setTopBorder callers working unchanged.
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 (!borderVisible && displayWidth > lineContentWidth) {
1097
+ if (!isSideBordered && displayWidth > lineContentWidth) {
995
1098
  displayText = sliceByColumn(displayText, 0, lineContentWidth, true);
996
1099
  displayWidth = visibleWidth(displayText);
997
1100
  }
998
1101
 
999
- if (!borderVisible && lineContentWidth === 0) {
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 && borderVisible) {
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 && !borderVisible && displayWidth >= lineContentWidth) {
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 = this.#decorate(before) + marker + cursor + this.#decorate(restAfter);
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 (!borderVisible && displayWidth + overrideWidth > lineContentWidth) {
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 (!borderVisible && displayWidth + cursorWidth > lineContentWidth) {
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
- if (!borderVisible) {
1140
- result.push(gutterText + displayText + linePad);
1141
- continue;
1142
- }
1143
-
1144
- // All lines have consistent borders based on padding. When the end-of-line cursor
1145
- // glyph (or a wide trailing grapheme) extends past `lineContentWidth`, shrink the
1146
- // right chrome by the exact overflow count: drop padding spaces first, then the
1147
- // trailing `─`, but never the corner/vertical bar itself.
1148
- const isLastLine = visibleIndex === visibleLayoutLines.length - 1;
1149
- const rightChromeCells = Math.max(1, paddingX + 1 - cursorPaddingOverflow);
1150
- if (isLastLine && imeSafeCursorTail) {
1151
- const leftBorder = this.borderColor(`${box.vertical}${padding(paddingX)}`);
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
- (data.charCodeAt(0) === 10 && data.length > 1) || // Ctrl+Enter with modifiers
1464
- matchesKey(data, "ctrl+enter") || // Ctrl+Enter (Kitty/modifyOtherKeys, including lock bits/keypad Enter)
1465
- data === "\x1b\r" || // Option+Enter in some terminals (legacy)
1466
- data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
1467
- kb.matchesCanonical(canonical, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
1468
- (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
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
- let result = text;
1741
- for (const [pasteId, pasteContent] of this.#pastes) {
1742
- const markerRegex = new RegExp(`\\[Paste #${pasteId}(?:, (?:\\+\\d+ lines|\\d+ chars))?\\]`, "g");
1743
- result = result.replace(markerRegex, () => pasteContent);
1744
- }
1745
- return result;
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 && this.#autocompleteProvider?.trySyncInlineReplace) {
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 replacement = this.#autocompleteProvider.trySyncInlineReplace(textBeforeCursor);
1984
- if (replacement) {
1985
- const before = replaceLine.slice(0, this.#state.cursorCol - replacement.replaceLen);
1986
- const after = replaceLine.slice(this.#state.cursorCol);
1987
- this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
1988
- this.#setCursorCol(before.length + replacement.insert.length);
1989
- if (this.onChange) {
1990
- this.onChange(this.getText());
1991
- }
1992
- if (this.#autocompleteState) {
1993
- this.#cancelAutocomplete();
1994
- this.onAutocompleteUpdate?.();
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 this.#isInSubmittedSlashCommandContext() && /^\/\S+ $/.test(textBeforeCursor);
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
- // Check if we should trigger file completion on Tab
3183
- if (explicitTab) {
3184
- const shouldTrigger =
3185
- !this.#autocompleteProvider.shouldTriggerFileCompletion ||
3186
- this.#autocompleteProvider.shouldTriggerFileCompletion(
3187
- this.#state.lines,
3188
- this.#state.cursorLine,
3189
- this.#state.cursorCol,
3190
- );
3191
- if (!shouldTrigger) {
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.#autocompleteRequestId += 1;
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
- const requestId = ++this.#autocompleteRequestId;
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
- const suggestions = await this.#autocompleteProvider.getSuggestions(
3305
- this.#state.lines,
3306
- this.#state.cursorLine,
3307
- this.#state.cursorCol,
3308
- );
3309
- if (requestId !== this.#autocompleteRequestId) return;
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.#widthEpochRevision++;
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
- return this.#autocompleteProvider.getInlineHint(
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 null;
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
  }