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

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