@oh-my-pi/pi-tui 17.4.2 → 18.0.0

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