@oh-my-pi/pi-tui 18.1.22 → 18.2.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,4 +1,5 @@
1
- import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
1
+ import { getProjectDir } from "@oh-my-pi/pi-utils/dirs";
2
+ import * as logger from "@oh-my-pi/pi-utils/logger";
2
3
  import {
3
4
  type AutocompleteItem,
4
5
  type AutocompleteProvider,
@@ -630,6 +631,7 @@ export class Editor implements Component, Focusable {
630
631
  onSubmit?: (text: string) => void | Promise<void>;
631
632
  onAltEnter?: (text: string) => void;
632
633
  onChange?: (text: string) => void;
634
+ #textRevision = 0;
633
635
  /** Called for a "marker-sized" paste — the point where the editor would otherwise collapse it
634
636
  * into a `[Paste #N]` token (> 10 lines or > 1000 characters). Return `true` to intercept:
635
637
  * the editor inserts nothing and records no undo state, leaving insertion to the host (e.g. a
@@ -962,9 +964,7 @@ export class Editor implements Component, Focusable {
962
964
  this.#state.cursorLine = this.#state.lines.length - 1;
963
965
  this.#setCursorCol(this.#state.lines[this.#state.cursorLine]?.length || 0);
964
966
  }
965
- if (this.onChange) {
966
- this.onChange(this.getText());
967
- }
967
+ this.#notifyChange();
968
968
  }
969
969
 
970
970
  invalidate(): void {
@@ -1661,9 +1661,7 @@ export class Editor implements Component, Focusable {
1661
1661
  this.#cancelAutocomplete();
1662
1662
  this.onAutocompleteUpdate?.();
1663
1663
 
1664
- if (this.onChange) {
1665
- this.onChange(this.getText());
1666
- }
1664
+ this.#notifyChange();
1667
1665
 
1668
1666
  result.onApplied?.();
1669
1667
 
@@ -1721,7 +1719,14 @@ export class Editor implements Component, Focusable {
1721
1719
  } else {
1722
1720
  if (selected && this.#autocompleteProvider) {
1723
1721
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
1724
- const shouldChainDirectoryCompletion = isDirectoryCompletionValue(selected.value);
1722
+ // Directory chaining exists so an @ mention can be browsed deeper
1723
+ // without retyping the path. It must not apply to a slash
1724
+ // command's directory argument: there the accepted value is the
1725
+ // whole argument, so chaining reopens the popup on the directory's
1726
+ // children, the command never submits, and every further Enter
1727
+ // descends another level (#12107).
1728
+ const shouldChainDirectoryCompletion =
1729
+ this.#autocompletePrefix.startsWith("@") && isDirectoryCompletionValue(selected.value);
1725
1730
  const result = this.#autocompleteProvider.applyCompletion(
1726
1731
  this.#state.lines,
1727
1732
  this.#state.cursorLine,
@@ -1737,9 +1742,7 @@ export class Editor implements Component, Focusable {
1737
1742
  this.#cancelAutocomplete();
1738
1743
  this.onAutocompleteUpdate?.();
1739
1744
 
1740
- if (this.onChange) {
1741
- this.onChange(this.getText());
1742
- }
1745
+ this.#notifyChange();
1743
1746
 
1744
1747
  result.onApplied?.();
1745
1748
  if (shouldChainDirectoryCompletion) {
@@ -2168,7 +2171,7 @@ export class Editor implements Component, Focusable {
2168
2171
  #afterVimEdit(): void {
2169
2172
  this.#historyIndex = -1;
2170
2173
  this.#resetKillSequence();
2171
- this.onChange?.(this.getText());
2174
+ this.#notifyChange();
2172
2175
  }
2173
2176
 
2174
2177
  /**
@@ -2389,6 +2392,16 @@ export class Editor implements Component, Focusable {
2389
2392
  return this.#state.lines.join("\n");
2390
2393
  }
2391
2394
 
2395
+ /** Monotonic buffer-content revision for render caches. Cursor-only movement does not advance it. */
2396
+ get textRevision(): number {
2397
+ return this.#textRevision;
2398
+ }
2399
+
2400
+ #notifyChange(text?: string): void {
2401
+ this.#textRevision++;
2402
+ this.onChange?.(text ?? this.getText());
2403
+ }
2404
+
2392
2405
  /** Whether the buffer text equals `value`, without `getText()`'s full join —
2393
2406
  * O(1) for the hot per-keystroke probes against short single-line values. */
2394
2407
  textEquals(value: string): boolean {
@@ -2437,6 +2450,24 @@ export class Editor implements Component, Focusable {
2437
2450
  });
2438
2451
  }
2439
2452
 
2453
+ /** Collapse the typed span `[start, end)` on `line` into the atom `label` (expanding back
2454
+ * to `expansion` on submit). A cursor at or past the span keeps its position relative to
2455
+ * the span end; a cursor inside it lands after the label. */
2456
+ collapseToAtom(line: number, start: number, end: number, label: string, expansion: string): void {
2457
+ const text = this.#state.lines[line];
2458
+ if (text === undefined || start < 0 || end > text.length || start >= end) return;
2459
+ this.#resetKillSequence();
2460
+ this.#recordUndoState();
2461
+ this.registerAtom(label, expansion);
2462
+ this.#state.lines[line] = text.slice(0, start) + label + text.slice(end);
2463
+ if (this.#state.cursorLine === line && this.#state.cursorCol > start) {
2464
+ const col = this.#state.cursorCol;
2465
+ this.#setCursorCol(col >= end ? col - (end - start) + label.length : start + label.length);
2466
+ }
2467
+ this.#lastAction = null;
2468
+ this.#notifyChange();
2469
+ }
2470
+
2440
2471
  /** Drop every registered atom expansion (draft cleared or replaced by the host). */
2441
2472
  clearAtoms(): void {
2442
2473
  this.#atoms.clear();
@@ -2474,6 +2505,29 @@ export class Editor implements Component, Focusable {
2474
2505
  this.#moveToMessageEnd();
2475
2506
  }
2476
2507
 
2508
+ /** The `tui.editor.deleteCharForward` operation, callable by hosts that resolve the chord
2509
+ * themselves rather than redispatching the raw key (see CustomEditor's exit-chord overlap).
2510
+ * Mirrors the transient state the key dispatch tears down before this action so the two
2511
+ * cannot diverge: a pending character jump is cancelled by any other key, and an open
2512
+ * spelling-assist popup is dismissed by anything that is not one of its accept keys (its
2513
+ * debounced refresh skips assist mode, so a surviving list would hang around forever).
2514
+ * While Vim owns the buffer (Normal or Visual) the operation is Vim's `x` — deleting the
2515
+ * selection and returning to Normal in Visual mode, the grapheme under the cursor
2516
+ * otherwise. Only Insert mode and Vim-off editors delete straight through. */
2517
+ deleteCharForward(): void {
2518
+ this.#jumpMode = null;
2519
+ if (this.#autocompleteState === "assist") {
2520
+ this.#cancelAutocomplete();
2521
+ this.onAutocompleteUpdate?.();
2522
+ }
2523
+ const vim = this.#vim;
2524
+ if (vim !== null && vim.mode !== "insert") {
2525
+ this.#runVimKey("x", vim);
2526
+ return;
2527
+ }
2528
+ this.#handleForwardDelete();
2529
+ }
2530
+
2477
2531
  /**
2478
2532
  * Undo the last meaningful edit while ignoring transient text that is still present at the cursor.
2479
2533
  * Used for command-like autocomplete actions whose typed trigger should not count as the edit being undone.
@@ -2517,9 +2571,7 @@ export class Editor implements Component, Focusable {
2517
2571
  }
2518
2572
 
2519
2573
  if (this.#undoStack.length === 0) {
2520
- if (this.onChange) {
2521
- this.onChange(this.getText());
2522
- }
2574
+ this.#notifyChange();
2523
2575
  return;
2524
2576
  }
2525
2577
 
@@ -2566,9 +2618,7 @@ export class Editor implements Component, Focusable {
2566
2618
  line.slice(0, this.#state.cursorCol - removable) + line.slice(this.#state.cursorCol);
2567
2619
  this.#setCursorCol(this.#state.cursorCol - removable);
2568
2620
  this.#lastAction = null;
2569
- if (this.onChange) {
2570
- this.onChange(this.getText());
2571
- }
2621
+ this.#notifyChange();
2572
2622
  }
2573
2623
 
2574
2624
  /** Code units of the current volatile speech-to-text preview (see {@link setVolatileText}). */
@@ -2585,7 +2635,7 @@ export class Editor implements Component, Focusable {
2585
2635
  if (text) this.#insertTextAtCursor(text);
2586
2636
  });
2587
2637
  this.#volatileTextLen = text.length;
2588
- if (!text && this.onChange) this.onChange(this.getText());
2638
+ if (!text) this.#notifyChange();
2589
2639
  }
2590
2640
 
2591
2641
  /** Remove the current volatile preview without committing it. */
@@ -2593,7 +2643,7 @@ export class Editor implements Component, Focusable {
2593
2643
  if (this.#volatileTextLen === 0) return;
2594
2644
  this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
2595
2645
  this.#volatileTextLen = 0;
2596
- if (this.onChange) this.onChange(this.getText());
2646
+ this.#notifyChange();
2597
2647
  }
2598
2648
 
2599
2649
  /** Drop any volatile preview, then insert `text` as a single undoable edit. */
@@ -2602,7 +2652,7 @@ export class Editor implements Component, Focusable {
2602
2652
  this.#withUndoSuspended(() => this.#deleteCharsBeforeCursor(this.#volatileTextLen));
2603
2653
  this.#volatileTextLen = 0;
2604
2654
  if (text) this.#insertTextAtCursor(text);
2605
- else if (this.onChange) this.onChange(this.getText());
2655
+ else this.#notifyChange();
2606
2656
  }
2607
2657
 
2608
2658
  /** Delete `count` UTF-16 code units immediately before the cursor, crossing line
@@ -2662,7 +2712,7 @@ export class Editor implements Component, Focusable {
2662
2712
  const after = line.slice(this.#state.cursorCol);
2663
2713
  this.#state.lines[this.#state.cursorLine] = before + replacement.insert + after;
2664
2714
  this.#setCursorCol(before.length + replacement.insert.length);
2665
- this.onChange?.(this.getText());
2715
+ this.#notifyChange();
2666
2716
  if (this.#autocompleteState) {
2667
2717
  this.#cancelAutocomplete();
2668
2718
  this.onAutocompleteUpdate?.();
@@ -2688,9 +2738,7 @@ export class Editor implements Component, Focusable {
2688
2738
  this.#state.lines[this.#state.cursorLine] = before + char + after;
2689
2739
  this.#setCursorCol(this.#state.cursorCol + char.length);
2690
2740
 
2691
- if (this.onChange) {
2692
- this.onChange(this.getText());
2693
- }
2741
+ this.#notifyChange();
2694
2742
 
2695
2743
  // Synchronous inline replacement (e.g. emoji shortcodes `:joy:` → 😂).
2696
2744
  // Runs before autocomplete trigger so the popup doesn't briefly chase a
@@ -2743,6 +2791,10 @@ export class Editor implements Component, Focusable {
2743
2791
  else if (char === "#") {
2744
2792
  this.#tryTriggerAutocomplete();
2745
2793
  }
2794
+ // Argument providers may expose candidates only after a separator.
2795
+ else if (char === " " && this.#isInSubmittedSlashCommandContext()) {
2796
+ this.#tryTriggerAutocomplete();
2797
+ }
2746
2798
  // Also auto-trigger when typing letters/path chars in a completable context
2747
2799
  else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
2748
2800
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
@@ -2899,9 +2951,7 @@ export class Editor implements Component, Focusable {
2899
2951
  this.#state.cursorLine++;
2900
2952
  this.#setCursorCol(0);
2901
2953
 
2902
- if (this.onChange) {
2903
- this.onChange(this.getText());
2904
- }
2954
+ this.#notifyChange();
2905
2955
  }
2906
2956
 
2907
2957
  #shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
@@ -2926,7 +2976,7 @@ export class Editor implements Component, Focusable {
2926
2976
  this.#scrollOffset = 0;
2927
2977
  this.#undoStack.length = 0;
2928
2978
 
2929
- if (this.onChange) this.onChange("");
2979
+ this.#notifyChange("");
2930
2980
  if (this.onSubmit) this.onSubmit(result);
2931
2981
  }
2932
2982
 
@@ -3029,9 +3079,7 @@ export class Editor implements Component, Focusable {
3029
3079
  this.#setCursorCol(previousLine.length);
3030
3080
  }
3031
3081
 
3032
- if (this.onChange) {
3033
- this.onChange(this.getText());
3034
- }
3082
+ this.#notifyChange();
3035
3083
 
3036
3084
  // Update or re-trigger autocomplete after backspace
3037
3085
  if (this.#autocompleteState) {
@@ -3205,9 +3253,7 @@ export class Editor implements Component, Focusable {
3205
3253
  this.#preferredVisualCol = null;
3206
3254
  Object.assign(this.#state, snapshot);
3207
3255
 
3208
- if (this.onChange) {
3209
- this.onChange(this.getText());
3210
- }
3256
+ this.#notifyChange();
3211
3257
 
3212
3258
  if (this.#autocompleteState) {
3213
3259
  this.#debouncedUpdateAutocomplete();
@@ -3295,9 +3341,7 @@ export class Editor implements Component, Focusable {
3295
3341
  this.#setCursorCol((lines[lines.length - 1] || "").length);
3296
3342
  }
3297
3343
 
3298
- if (this.onChange) {
3299
- this.onChange(this.getText());
3300
- }
3344
+ this.#notifyChange();
3301
3345
  this.#retriggerAutocompleteAtCursor();
3302
3346
  }
3303
3347
 
@@ -3402,9 +3446,7 @@ export class Editor implements Component, Focusable {
3402
3446
 
3403
3447
  this.#recordKill(deletedText, "backward");
3404
3448
 
3405
- if (this.onChange) {
3406
- this.onChange(this.getText());
3407
- }
3449
+ this.#notifyChange();
3408
3450
  this.#retriggerAutocompleteAtCursor();
3409
3451
  }
3410
3452
 
@@ -3434,9 +3476,7 @@ export class Editor implements Component, Focusable {
3434
3476
 
3435
3477
  this.#recordKill(deletedText, "forward");
3436
3478
 
3437
- if (this.onChange) {
3438
- this.onChange(this.getText());
3439
- }
3479
+ this.#notifyChange();
3440
3480
  this.#retriggerAutocompleteAtCursor();
3441
3481
  }
3442
3482
 
@@ -3469,9 +3509,7 @@ export class Editor implements Component, Focusable {
3469
3509
  this.#recordKill(deletedText, "backward");
3470
3510
  }
3471
3511
 
3472
- if (this.onChange) {
3473
- this.onChange(this.getText());
3474
- }
3512
+ this.#notifyChange();
3475
3513
  this.#retriggerAutocompleteAtCursor();
3476
3514
  }
3477
3515
 
@@ -3501,9 +3539,7 @@ export class Editor implements Component, Focusable {
3501
3539
  this.#recordKill(deletedText, "forward");
3502
3540
  }
3503
3541
 
3504
- if (this.onChange) {
3505
- this.onChange(this.getText());
3506
- }
3542
+ this.#notifyChange();
3507
3543
  this.#retriggerAutocompleteAtCursor();
3508
3544
  }
3509
3545
 
@@ -3541,9 +3577,14 @@ export class Editor implements Component, Focusable {
3541
3577
  this.#state.lines.splice(this.#state.cursorLine + 1, 1);
3542
3578
  }
3543
3579
 
3544
- if (this.onChange) {
3545
- this.onChange(this.getText());
3546
- }
3580
+ // Deleting the final grapheme can leave the cursor one past the end of the line, which
3581
+ // Normal mode never allows (it rests *on* a grapheme). Vim's own `x` clamps via
3582
+ // #applyVimCommands; callers that invoke this operation directly — hosts resolving a
3583
+ // chord themselves, or a key bound to deleteCharForward that Vim does not map — get the
3584
+ // same treatment here so the cursor can't sit off the buffer.
3585
+ this.#clampVimCursor();
3586
+
3587
+ this.#notifyChange();
3547
3588
 
3548
3589
  // Update or re-trigger autocomplete after forward delete
3549
3590
  if (this.#autocompleteState) {
@@ -4023,7 +4064,7 @@ export class Editor implements Component, Focusable {
4023
4064
  this.#lastAction = null;
4024
4065
  this.#cancelAutocomplete();
4025
4066
  this.onAutocompleteUpdate?.();
4026
- this.onChange?.(this.getText());
4067
+ this.#notifyChange();
4027
4068
  }
4028
4069
  async #handleSlashCommandCompletion(): Promise<void> {
4029
4070
  await this.#tryTriggerAutocomplete();