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

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