phi-code-tui 0.56.3 → 0.74.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.
Files changed (88) hide show
  1. package/README.md +29 -11
  2. package/dist/autocomplete.d.ts +18 -14
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +151 -112
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts.map +1 -1
  7. package/dist/components/box.js +6 -1
  8. package/dist/components/box.js.map +1 -1
  9. package/dist/components/cancellable-loader.d.ts.map +1 -1
  10. package/dist/components/cancellable-loader.js +6 -7
  11. package/dist/components/cancellable-loader.js.map +1 -1
  12. package/dist/components/editor.d.ts +45 -1
  13. package/dist/components/editor.d.ts.map +1 -1
  14. package/dist/components/editor.js +505 -221
  15. package/dist/components/editor.js.map +1 -1
  16. package/dist/components/image.d.ts.map +1 -1
  17. package/dist/components/image.js +22 -7
  18. package/dist/components/image.js.map +1 -1
  19. package/dist/components/input.d.ts.map +1 -1
  20. package/dist/components/input.js +57 -74
  21. package/dist/components/input.js.map +1 -1
  22. package/dist/components/loader.d.ts +12 -2
  23. package/dist/components/loader.d.ts.map +1 -1
  24. package/dist/components/loader.js +36 -13
  25. package/dist/components/loader.js.map +1 -1
  26. package/dist/components/markdown.d.ts +0 -5
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +101 -114
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/select-list.d.ts +19 -1
  31. package/dist/components/select-list.d.ts.map +1 -1
  32. package/dist/components/select-list.js +82 -71
  33. package/dist/components/select-list.js.map +1 -1
  34. package/dist/components/settings-list.d.ts.map +1 -1
  35. package/dist/components/settings-list.js +18 -10
  36. package/dist/components/settings-list.js.map +1 -1
  37. package/dist/components/spacer.d.ts.map +1 -1
  38. package/dist/components/spacer.js +1 -0
  39. package/dist/components/spacer.js.map +1 -1
  40. package/dist/components/text.d.ts.map +1 -1
  41. package/dist/components/text.js +8 -0
  42. package/dist/components/text.js.map +1 -1
  43. package/dist/components/truncated-text.d.ts.map +1 -1
  44. package/dist/components/truncated-text.js +3 -0
  45. package/dist/components/truncated-text.js.map +1 -1
  46. package/dist/editor-component.d.ts.map +1 -1
  47. package/dist/fuzzy.d.ts.map +1 -1
  48. package/dist/fuzzy.js +3 -0
  49. package/dist/fuzzy.js.map +1 -1
  50. package/dist/index.d.ts +5 -5
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +3 -3
  53. package/dist/index.js.map +1 -1
  54. package/dist/keybindings.d.ts +187 -33
  55. package/dist/keybindings.d.ts.map +1 -1
  56. package/dist/keybindings.js +156 -95
  57. package/dist/keybindings.js.map +1 -1
  58. package/dist/keys.d.ts +21 -12
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +270 -112
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts.map +1 -1
  63. package/dist/kill-ring.js +1 -3
  64. package/dist/kill-ring.js.map +1 -1
  65. package/dist/stdin-buffer.d.ts +2 -0
  66. package/dist/stdin-buffer.d.ts.map +1 -1
  67. package/dist/stdin-buffer.js +31 -8
  68. package/dist/stdin-buffer.js.map +1 -1
  69. package/dist/terminal-image.d.ts +17 -0
  70. package/dist/terminal-image.d.ts.map +1 -1
  71. package/dist/terminal-image.js +41 -5
  72. package/dist/terminal-image.js.map +1 -1
  73. package/dist/terminal.d.ts +4 -0
  74. package/dist/terminal.d.ts.map +1 -1
  75. package/dist/terminal.js +56 -8
  76. package/dist/terminal.js.map +1 -1
  77. package/dist/tui.d.ts +21 -5
  78. package/dist/tui.d.ts.map +1 -1
  79. package/dist/tui.js +234 -118
  80. package/dist/tui.js.map +1 -1
  81. package/dist/undo-stack.d.ts.map +1 -1
  82. package/dist/undo-stack.js +1 -3
  83. package/dist/undo-stack.js.map +1 -1
  84. package/dist/utils.d.ts +1 -0
  85. package/dist/utils.d.ts.map +1 -1
  86. package/dist/utils.js +281 -81
  87. package/dist/utils.js.map +1 -1
  88. package/package.json +3 -3
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @mariozechner/pi-tui
1
+ # @earendil-works/pi-tui
2
2
 
3
3
  Minimal terminal UI framework with differential rendering and synchronized output for flicker-free interactive CLI applications.
4
4
 
@@ -16,7 +16,7 @@ Minimal terminal UI framework with differential rendering and synchronized outpu
16
16
  ## Quick Start
17
17
 
18
18
  ```typescript
19
- import { TUI, Text, Editor, ProcessTerminal } from "@mariozechner/pi-tui";
19
+ import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@earendil-works/pi-tui";
20
20
 
21
21
  // Create terminal
22
22
  const terminal = new ProcessTerminal();
@@ -27,6 +27,7 @@ const tui = new TUI(terminal);
27
27
  // Add components
28
28
  tui.addChild(new Text("Welcome to my app!"));
29
29
 
30
+ import { defaultEditorTheme as editorTheme } from './test/test-themes.ts';
30
31
  const editor = new Editor(tui, editorTheme);
31
32
  editor.onSubmit = (text) => {
32
33
  console.log("Submitted:", text);
@@ -34,6 +35,17 @@ editor.onSubmit = (text) => {
34
35
  };
35
36
  tui.addChild(editor);
36
37
 
38
+ // Focus the editor so it receives keyboard input
39
+ tui.setFocus(editor);
40
+
41
+ // In raw mode Ctrl+C doesn't send SIGINT — intercept it here to allow exit
42
+ tui.addInputListener((data) => {
43
+ if (matchesKey(data, 'ctrl+c')) {
44
+ tui.stop();
45
+ process.exit(0);
46
+ }
47
+ });
48
+
37
49
  // Start
38
50
  tui.start();
39
51
  ```
@@ -93,6 +105,9 @@ const handle = tui.showOverlay(component, {
93
105
 
94
106
  // Responsive visibility
95
107
  visible: (termWidth, termHeight) => termWidth >= 100 // Hide on narrow terminals
108
+
109
+ // Focus behavior
110
+ nonCapturing: true // Don't auto-focus when shown
96
111
  });
97
112
 
98
113
  // OverlayHandle methods
@@ -100,6 +115,9 @@ handle.hide(); // Permanently remove the overlay
100
115
  handle.setHidden(true); // Temporarily hide (can show again)
101
116
  handle.setHidden(false); // Show again after hiding
102
117
  handle.isHidden(); // Check if temporarily hidden
118
+ handle.focus(); // Focus and bring to visual front
119
+ handle.unfocus(); // Release focus to previous target
120
+ handle.isFocused(); // Check if overlay has focus
103
121
 
104
122
  // Hide topmost overlay
105
123
  tui.hideOverlay();
@@ -141,7 +159,7 @@ The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered lin
141
159
  Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface:
142
160
 
143
161
  ```typescript
144
- import { CURSOR_MARKER, type Component, type Focusable } from "@mariozechner/pi-tui";
162
+ import { CURSOR_MARKER, type Component, type Focusable } from "@earendil-works/pi-tui";
145
163
 
146
164
  class MyInput implements Component, Focusable {
147
165
  focused: boolean = false; // Set by TUI when focus changes
@@ -165,7 +183,7 @@ This enables IME candidate windows to appear at the correct position for CJK inp
165
183
  **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child:
166
184
 
167
185
  ```typescript
168
- import { Container, type Focusable, Input } from "@mariozechner/pi-tui";
186
+ import { Container, type Focusable, Input } from "@earendil-works/pi-tui";
169
187
 
170
188
  class SearchDialog extends Container implements Focusable {
171
189
  private searchInput: Input;
@@ -512,7 +530,7 @@ Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image he
512
530
  Supports both slash commands and file paths.
513
531
 
514
532
  ```typescript
515
- import { CombinedAutocompleteProvider } from "@mariozechner/pi-tui";
533
+ import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
516
534
 
517
535
  const provider = new CombinedAutocompleteProvider(
518
536
  [
@@ -537,7 +555,7 @@ editor.setAutocompleteProvider(provider);
537
555
  Use `matchesKey()` with the `Key` helper for detecting keyboard input (supports Kitty keyboard protocol):
538
556
 
539
557
  ```typescript
540
- import { matchesKey, Key } from "@mariozechner/pi-tui";
558
+ import { matchesKey, Key } from "@earendil-works/pi-tui";
541
559
 
542
560
  if (matchesKey(data, Key.ctrl("c"))) {
543
561
  process.exit(0);
@@ -595,7 +613,7 @@ interface Terminal {
595
613
  ## Utilities
596
614
 
597
615
  ```typescript
598
- import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
616
+ import { visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
599
617
 
600
618
  // Get visible width of string (ignoring ANSI codes)
601
619
  const width = visibleWidth("\x1b[31mHello\x1b[0m"); // 5
@@ -620,8 +638,8 @@ When creating custom components, **each line returned by `render()` must not exc
620
638
  Use `matchesKey()` with the `Key` helper for keyboard input:
621
639
 
622
640
  ```typescript
623
- import { matchesKey, Key, truncateToWidth } from "@mariozechner/pi-tui";
624
- import type { Component } from "@mariozechner/pi-tui";
641
+ import { matchesKey, Key, truncateToWidth } from "@earendil-works/pi-tui";
642
+ import type { Component } from "@earendil-works/pi-tui";
625
643
 
626
644
  class MyInteractiveComponent implements Component {
627
645
  private selectedIndex = 0;
@@ -656,8 +674,8 @@ class MyInteractiveComponent implements Component {
656
674
  Use the provided utilities to ensure lines fit:
657
675
 
658
676
  ```typescript
659
- import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
660
- import type { Component } from "@mariozechner/pi-tui";
677
+ import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
678
+ import type { Component } from "@earendil-works/pi-tui";
661
679
 
662
680
  class MyComponent implements Component {
663
681
  private text: string;
@@ -3,31 +3,38 @@ export interface AutocompleteItem {
3
3
  label: string;
4
4
  description?: string;
5
5
  }
6
+ type Awaitable<T> = T | Promise<T>;
6
7
  export interface SlashCommand {
7
8
  name: string;
8
9
  description?: string;
9
- getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
10
+ argumentHint?: string;
11
+ getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
12
+ }
13
+ export interface AutocompleteSuggestions {
14
+ items: AutocompleteItem[];
15
+ prefix: string;
10
16
  }
11
17
  export interface AutocompleteProvider {
12
- getSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
13
- items: AutocompleteItem[];
14
- prefix: string;
15
- } | null;
18
+ getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: {
19
+ signal: AbortSignal;
20
+ force?: boolean;
21
+ }): Promise<AutocompleteSuggestions | null>;
16
22
  applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
17
23
  lines: string[];
18
24
  cursorLine: number;
19
25
  cursorCol: number;
20
26
  };
27
+ shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
21
28
  }
22
29
  export declare class CombinedAutocompleteProvider implements AutocompleteProvider {
23
30
  private commands;
24
31
  private basePath;
25
32
  private fdPath;
26
- constructor(commands?: (SlashCommand | AutocompleteItem)[], basePath?: string, fdPath?: string | null);
27
- getSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
28
- items: AutocompleteItem[];
29
- prefix: string;
30
- } | null;
33
+ constructor(commands: (AutocompleteItem | SlashCommand)[] | undefined, basePath: string, fdPath?: string | null);
34
+ getSuggestions(lines: string[], cursorLine: number, cursorCol: number, options: {
35
+ signal: AbortSignal;
36
+ force?: boolean;
37
+ }): Promise<AutocompleteSuggestions | null>;
31
38
  applyCompletion(lines: string[], cursorLine: number, cursorCol: number, item: AutocompleteItem, prefix: string): {
32
39
  lines: string[];
33
40
  cursorLine: number;
@@ -41,10 +48,7 @@ export declare class CombinedAutocompleteProvider implements AutocompleteProvide
41
48
  private getFileSuggestions;
42
49
  private scoreEntry;
43
50
  private getFuzzyFileSuggestions;
44
- getForceFileSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
45
- items: AutocompleteItem[];
46
- prefix: string;
47
- } | null;
48
51
  shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean;
49
52
  }
53
+ export {};
50
54
  //# sourceMappingURL=autocomplete.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AAmJA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IAGrB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,gBAAgB,EAAE,GAAG,IAAI,CAAC;CAC3E;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QACF,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAC1B,MAAM,EAAE,MAAM,CAAC;KACf,GAAG,IAAI,CAAC;IAIT,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QACF,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KAClB,CAAC;CACF;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAgB;gBAG7B,QAAQ,GAAE,CAAC,YAAY,GAAG,gBAAgB,CAAC,EAAO,EAClD,QAAQ,GAAE,MAAsB,EAChC,MAAM,GAAE,MAAM,GAAG,IAAW;IAO7B,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IA+FvD,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE;IAkF7D,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,uBAAuB;IA6B/B,OAAO,CAAC,oBAAoB;IAQ5B,OAAO,CAAC,kBAAkB;IAoI1B,OAAO,CAAC,UAAU;IAuBlB,OAAO,CAAC,uBAAuB;IAsD/B,uBAAuB,CACtB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,GACf;QAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IAyBvD,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO;CAW5F"}
1
+ {"version":3,"file":"autocomplete.d.ts","sourceRoot":"","sources":["../src/autocomplete.ts"],"names":[],"mappings":"AA0NA,MAAM,WAAW,gBAAgB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,KAAK,SAAS,CAAC,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;AAEnC,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;IAGtB,sBAAsB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,SAAS,CAAC,gBAAgB,EAAE,GAAG,IAAI,CAAC,CAAC;CACtF;AAED,MAAM,WAAW,uBAAuB;IACvC,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB;IAGpC,cAAc,CACb,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/C,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IAI3C,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QACF,KAAK,EAAE,MAAM,EAAE,CAAC;QAChB,UAAU,EAAE,MAAM,CAAC;QACnB,SAAS,EAAE,MAAM,CAAC;KAClB,CAAC;IAGF,2BAA2B,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;CAC9F;AAGD,qBAAa,4BAA6B,YAAW,oBAAoB;IACxE,OAAO,CAAC,QAAQ,CAAsC;IACtD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAgB;IAE9B,YAAY,QAAQ,iDAA0C,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,GAAE,MAAM,GAAG,IAAW,EAI7G;IAEK,cAAc,CACnB,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE;QAAE,MAAM,EAAE,WAAW,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC/C,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAoFzC;IAED,eAAe,CACd,KAAK,EAAE,MAAM,EAAE,EACf,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,gBAAgB,EACtB,MAAM,EAAE,MAAM,GACZ;QAAE,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CA+E5D;IAGD,OAAO,CAAC,eAAe;IAiBvB,OAAO,CAAC,iBAAiB;IA8BzB,OAAO,CAAC,cAAc;IAWtB,OAAO,CAAC,uBAAuB;IA8B/B,OAAO,CAAC,oBAAoB;IAS5B,OAAO,CAAC,kBAAkB;IAyI1B,OAAO,CAAC,UAAU;YAuBJ,uBAAuB;IAuDrC,2BAA2B,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAU3F;CACD","sourcesContent":["import { spawn } from \"child_process\";\nimport { readdirSync, statSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { basename, dirname, join } from \"path\";\nimport { fuzzyFilter } from \"./fuzzy.js\";\n\nconst PATH_DELIMITERS = new Set([\" \", \"\\t\", '\"', \"'\", \"=\"]);\n\nfunction toDisplayPath(value: string): string {\n\treturn value.replace(/\\\\/g, \"/\");\n}\n\nfunction escapeRegex(value: string): string {\n\treturn value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\nfunction buildFdPathQuery(query: string): string {\n\tconst normalized = toDisplayPath(query);\n\tif (!normalized.includes(\"/\")) {\n\t\treturn normalized;\n\t}\n\n\tconst hasTrailingSeparator = normalized.endsWith(\"/\");\n\tconst trimmed = normalized.replace(/^\\/+|\\/+$/g, \"\");\n\tif (!trimmed) {\n\t\treturn normalized;\n\t}\n\n\tconst separatorPattern = \"[\\\\\\\\/]\";\n\tconst segments = trimmed\n\t\t.split(\"/\")\n\t\t.filter(Boolean)\n\t\t.map((segment) => escapeRegex(segment));\n\tif (segments.length === 0) {\n\t\treturn normalized;\n\t}\n\n\tlet pattern = segments.join(separatorPattern);\n\tif (hasTrailingSeparator) {\n\t\tpattern += separatorPattern;\n\t}\n\treturn pattern;\n}\n\nfunction findLastDelimiter(text: string): number {\n\tfor (let i = text.length - 1; i >= 0; i -= 1) {\n\t\tif (PATH_DELIMITERS.has(text[i] ?? \"\")) {\n\t\t\treturn i;\n\t\t}\n\t}\n\treturn -1;\n}\n\nfunction findUnclosedQuoteStart(text: string): number | null {\n\tlet inQuotes = false;\n\tlet quoteStart = -1;\n\n\tfor (let i = 0; i < text.length; i += 1) {\n\t\tif (text[i] === '\"') {\n\t\t\tinQuotes = !inQuotes;\n\t\t\tif (inQuotes) {\n\t\t\t\tquoteStart = i;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn inQuotes ? quoteStart : null;\n}\n\nfunction isTokenStart(text: string, index: number): boolean {\n\treturn index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? \"\");\n}\n\nfunction extractQuotedPrefix(text: string): string | null {\n\tconst quoteStart = findUnclosedQuoteStart(text);\n\tif (quoteStart === null) {\n\t\treturn null;\n\t}\n\n\tif (quoteStart > 0 && text[quoteStart - 1] === \"@\") {\n\t\tif (!isTokenStart(text, quoteStart - 1)) {\n\t\t\treturn null;\n\t\t}\n\t\treturn text.slice(quoteStart - 1);\n\t}\n\n\tif (!isTokenStart(text, quoteStart)) {\n\t\treturn null;\n\t}\n\n\treturn text.slice(quoteStart);\n}\n\nfunction parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {\n\tif (prefix.startsWith('@\"')) {\n\t\treturn { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };\n\t}\n\tif (prefix.startsWith('\"')) {\n\t\treturn { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };\n\t}\n\tif (prefix.startsWith(\"@\")) {\n\t\treturn { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };\n\t}\n\treturn { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };\n}\n\nfunction buildCompletionValue(\n\tpath: string,\n\toptions: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },\n): string {\n\tconst needsQuotes = options.isQuotedPrefix || path.includes(\" \");\n\tconst prefix = options.isAtPrefix ? \"@\" : \"\";\n\n\tif (!needsQuotes) {\n\t\treturn `${prefix}${path}`;\n\t}\n\n\tconst openQuote = `${prefix}\"`;\n\tconst closeQuote = '\"';\n\treturn `${openQuote}${path}${closeQuote}`;\n}\n\n// Use fd to walk directory tree (fast, respects .gitignore)\nasync function walkDirectoryWithFd(\n\tbaseDir: string,\n\tfdPath: string,\n\tquery: string,\n\tmaxResults: number,\n\tsignal: AbortSignal,\n): Promise<Array<{ path: string; isDirectory: boolean }>> {\n\tconst args = [\n\t\t\"--base-directory\",\n\t\tbaseDir,\n\t\t\"--max-results\",\n\t\tString(maxResults),\n\t\t\"--type\",\n\t\t\"f\",\n\t\t\"--type\",\n\t\t\"d\",\n\t\t\"--follow\",\n\t\t\"--hidden\",\n\t\t\"--exclude\",\n\t\t\".git\",\n\t\t\"--exclude\",\n\t\t\".git/*\",\n\t\t\"--exclude\",\n\t\t\".git/**\",\n\t];\n\n\tif (toDisplayPath(query).includes(\"/\")) {\n\t\targs.push(\"--full-path\");\n\t}\n\n\tif (query) {\n\t\targs.push(buildFdPathQuery(query));\n\t}\n\n\treturn await new Promise((resolve) => {\n\t\tif (signal.aborted) {\n\t\t\tresolve([]);\n\t\t\treturn;\n\t\t}\n\n\t\tconst child = spawn(fdPath, args, {\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\t\tlet stdout = \"\";\n\t\tlet resolved = false;\n\n\t\tconst finish = (results: Array<{ path: string; isDirectory: boolean }>) => {\n\t\t\tif (resolved) return;\n\t\t\tresolved = true;\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve(results);\n\t\t};\n\n\t\tconst onAbort = () => {\n\t\t\tif (child.exitCode === null) {\n\t\t\t\tchild.kill(\"SIGKILL\");\n\t\t\t}\n\t\t};\n\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t\tchild.stdout.setEncoding(\"utf-8\");\n\t\tchild.stdout.on(\"data\", (chunk: string) => {\n\t\t\tstdout += chunk;\n\t\t});\n\t\tchild.on(\"error\", () => {\n\t\t\tfinish([]);\n\t\t});\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (signal.aborted || code !== 0 || !stdout) {\n\t\t\t\tfinish([]);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst lines = stdout.trim().split(\"\\n\").filter(Boolean);\n\t\t\tconst results: Array<{ path: string; isDirectory: boolean }> = [];\n\n\t\t\tfor (const line of lines) {\n\t\t\t\tconst displayLine = toDisplayPath(line);\n\t\t\t\tconst hasTrailingSeparator = displayLine.endsWith(\"/\");\n\t\t\t\tconst normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;\n\t\t\t\tif (normalizedPath === \".git\" || normalizedPath.startsWith(\".git/\") || normalizedPath.includes(\"/.git/\")) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tresults.push({\n\t\t\t\t\tpath: displayLine,\n\t\t\t\t\tisDirectory: hasTrailingSeparator,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tfinish(results);\n\t\t});\n\t});\n}\n\nexport interface AutocompleteItem {\n\tvalue: string;\n\tlabel: string;\n\tdescription?: string;\n}\n\ntype Awaitable<T> = T | Promise<T>;\n\nexport interface SlashCommand {\n\tname: string;\n\tdescription?: string;\n\targumentHint?: string;\n\t// Function to get argument completions for this command\n\t// Returns null if no argument completion is available\n\tgetArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;\n}\n\nexport interface AutocompleteSuggestions {\n\titems: AutocompleteItem[];\n\tprefix: string; // What we're matching against (e.g., \"/\" or \"src/\")\n}\n\nexport interface AutocompleteProvider {\n\t// Get autocomplete suggestions for current text/cursor position\n\t// Returns null if no suggestions available\n\tgetSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\toptions: { signal: AbortSignal; force?: boolean },\n\t): Promise<AutocompleteSuggestions | null>;\n\n\t// Apply the selected item\n\t// Returns the new text and cursor position\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): {\n\t\tlines: string[];\n\t\tcursorLine: number;\n\t\tcursorCol: number;\n\t};\n\n\t// Check if file completion should trigger for explicit Tab completion\n\tshouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;\n}\n\n// Combined provider that handles both slash commands and file paths\nexport class CombinedAutocompleteProvider implements AutocompleteProvider {\n\tprivate commands: (SlashCommand | AutocompleteItem)[];\n\tprivate basePath: string;\n\tprivate fdPath: string | null;\n\n\tconstructor(commands: (SlashCommand | AutocompleteItem)[] = [], basePath: string, fdPath: string | null = null) {\n\t\tthis.commands = commands;\n\t\tthis.basePath = basePath;\n\t\tthis.fdPath = fdPath;\n\t}\n\n\tasync getSuggestions(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\toptions: { signal: AbortSignal; force?: boolean },\n\t): Promise<AutocompleteSuggestions | null> {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\tconst atPrefix = this.extractAtPrefix(textBeforeCursor);\n\t\tif (atPrefix) {\n\t\t\tconst { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);\n\t\t\tconst suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {\n\t\t\t\tisQuotedPrefix,\n\t\t\t\tsignal: options.signal,\n\t\t\t});\n\t\t\tif (suggestions.length === 0) return null;\n\n\t\t\treturn {\n\t\t\t\titems: suggestions,\n\t\t\t\tprefix: atPrefix,\n\t\t\t};\n\t\t}\n\n\t\tif (!options.force && textBeforeCursor.startsWith(\"/\")) {\n\t\t\tconst spaceIndex = textBeforeCursor.indexOf(\" \");\n\n\t\t\tif (spaceIndex === -1) {\n\t\t\t\tconst prefix = textBeforeCursor.slice(1);\n\t\t\t\tconst commandItems = this.commands.map((cmd) => {\n\t\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\t\tconst hint = \"argumentHint\" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;\n\t\t\t\t\tconst desc = cmd.description ?? \"\";\n\t\t\t\t\tconst fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;\n\t\t\t\t\treturn {\n\t\t\t\t\t\tname,\n\t\t\t\t\t\tlabel: name,\n\t\t\t\t\t\tdescription: fullDesc || undefined,\n\t\t\t\t\t};\n\t\t\t\t});\n\n\t\t\t\tconst filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({\n\t\t\t\t\tvalue: item.name,\n\t\t\t\t\tlabel: item.label,\n\t\t\t\t\t...(item.description && { description: item.description }),\n\t\t\t\t}));\n\n\t\t\t\tif (filtered.length === 0) return null;\n\n\t\t\t\treturn {\n\t\t\t\t\titems: filtered,\n\t\t\t\t\tprefix: textBeforeCursor,\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tconst commandName = textBeforeCursor.slice(1, spaceIndex);\n\t\t\tconst argumentText = textBeforeCursor.slice(spaceIndex + 1);\n\n\t\t\tconst command = this.commands.find((cmd) => {\n\t\t\t\tconst name = \"name\" in cmd ? cmd.name : cmd.value;\n\t\t\t\treturn name === commandName;\n\t\t\t});\n\t\t\tif (!command || !(\"getArgumentCompletions\" in command) || !command.getArgumentCompletions) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\tconst argumentSuggestions = await command.getArgumentCompletions(argumentText);\n\t\t\tif (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\treturn {\n\t\t\t\titems: argumentSuggestions,\n\t\t\t\tprefix: argumentText,\n\t\t\t};\n\t\t}\n\n\t\tconst pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);\n\t\tif (pathMatch === null) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst suggestions = this.getFileSuggestions(pathMatch);\n\t\tif (suggestions.length === 0) return null;\n\n\t\treturn {\n\t\t\titems: suggestions,\n\t\t\tprefix: pathMatch,\n\t\t};\n\t}\n\n\tapplyCompletion(\n\t\tlines: string[],\n\t\tcursorLine: number,\n\t\tcursorCol: number,\n\t\titem: AutocompleteItem,\n\t\tprefix: string,\n\t): { lines: string[]; cursorLine: number; cursorCol: number } {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst beforePrefix = currentLine.slice(0, cursorCol - prefix.length);\n\t\tconst afterCursor = currentLine.slice(cursorCol);\n\t\tconst isQuotedPrefix = prefix.startsWith('\"') || prefix.startsWith('@\"');\n\t\tconst hasLeadingQuoteAfterCursor = afterCursor.startsWith('\"');\n\t\tconst hasTrailingQuoteInItem = item.value.endsWith('\"');\n\t\tconst adjustedAfterCursor =\n\t\t\tisQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor;\n\n\t\t// Check if we're completing a slash command (prefix starts with \"/\" but NOT a file path)\n\t\t// Slash commands are at the start of the line and don't contain path separators after the first /\n\t\tconst isSlashCommand = prefix.startsWith(\"/\") && beforePrefix.trim() === \"\" && !prefix.slice(1).includes(\"/\");\n\t\tif (isSlashCommand) {\n\t\t\t// This is a command name completion\n\t\t\tconst newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + item.value.length + 2, // +2 for \"/\" and space\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're completing a file attachment (prefix starts with \"@\")\n\t\tif (prefix.startsWith(\"@\")) {\n\t\t\t// This is a file attachment completion\n\t\t\t// Don't add space after directories so user can continue autocompleting\n\t\t\tconst isDirectory = item.label.endsWith(\"/\");\n\t\t\tconst suffix = isDirectory ? \"\" : \" \";\n\t\t\tconst newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\tconst hasTrailingQuote = item.value.endsWith('\"');\n\t\t\tconst cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + cursorOffset + suffix.length,\n\t\t\t};\n\t\t}\n\n\t\t// Check if we're in a slash command context (beforePrefix contains \"/command \")\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\t\tif (textBeforeCursor.includes(\"/\") && textBeforeCursor.includes(\" \")) {\n\t\t\t// This is likely a command argument completion\n\t\t\tconst newLine = beforePrefix + item.value + adjustedAfterCursor;\n\t\t\tconst newLines = [...lines];\n\t\t\tnewLines[cursorLine] = newLine;\n\n\t\t\tconst isDirectory = item.label.endsWith(\"/\");\n\t\t\tconst hasTrailingQuote = item.value.endsWith('\"');\n\t\t\tconst cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;\n\n\t\t\treturn {\n\t\t\t\tlines: newLines,\n\t\t\t\tcursorLine,\n\t\t\t\tcursorCol: beforePrefix.length + cursorOffset,\n\t\t\t};\n\t\t}\n\n\t\t// For file paths, complete the path\n\t\tconst newLine = beforePrefix + item.value + adjustedAfterCursor;\n\t\tconst newLines = [...lines];\n\t\tnewLines[cursorLine] = newLine;\n\n\t\tconst isDirectory = item.label.endsWith(\"/\");\n\t\tconst hasTrailingQuote = item.value.endsWith('\"');\n\t\tconst cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;\n\n\t\treturn {\n\t\t\tlines: newLines,\n\t\t\tcursorLine,\n\t\t\tcursorCol: beforePrefix.length + cursorOffset,\n\t\t};\n\t}\n\n\t// Extract @ prefix for fuzzy file suggestions\n\tprivate extractAtPrefix(text: string): string | null {\n\t\tconst quotedPrefix = extractQuotedPrefix(text);\n\t\tif (quotedPrefix?.startsWith('@\"')) {\n\t\t\treturn quotedPrefix;\n\t\t}\n\n\t\tconst lastDelimiterIndex = findLastDelimiter(text);\n\t\tconst tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;\n\n\t\tif (text[tokenStart] === \"@\") {\n\t\t\treturn text.slice(tokenStart);\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Extract a path-like prefix from the text before cursor\n\tprivate extractPathPrefix(text: string, forceExtract: boolean = false): string | null {\n\t\tconst quotedPrefix = extractQuotedPrefix(text);\n\t\tif (quotedPrefix) {\n\t\t\treturn quotedPrefix;\n\t\t}\n\n\t\tconst lastDelimiterIndex = findLastDelimiter(text);\n\t\tconst pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);\n\n\t\t// For forced extraction (Tab key), always return something\n\t\tif (forceExtract) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .\n\t\t// Only return empty string if the text looks like it's starting a path context\n\t\tif (pathPrefix.includes(\"/\") || pathPrefix.startsWith(\".\") || pathPrefix.startsWith(\"~/\")) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\t// Return empty string only after a space (not for completely empty text)\n\t\t// Empty text should not trigger file suggestions - that's for forced Tab completion\n\t\tif (pathPrefix === \"\" && text.endsWith(\" \")) {\n\t\t\treturn pathPrefix;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t// Expand home directory (~/) to actual home path\n\tprivate expandHomePath(path: string): string {\n\t\tif (path.startsWith(\"~/\")) {\n\t\t\tconst expandedPath = join(homedir(), path.slice(2));\n\t\t\t// Preserve trailing slash if original path had one\n\t\t\treturn path.endsWith(\"/\") && !expandedPath.endsWith(\"/\") ? `${expandedPath}/` : expandedPath;\n\t\t} else if (path === \"~\") {\n\t\t\treturn homedir();\n\t\t}\n\t\treturn path;\n\t}\n\n\tprivate resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {\n\t\tconst normalizedQuery = toDisplayPath(rawQuery);\n\t\tconst slashIndex = normalizedQuery.lastIndexOf(\"/\");\n\t\tif (slashIndex === -1) {\n\t\t\treturn null;\n\t\t}\n\n\t\tconst displayBase = normalizedQuery.slice(0, slashIndex + 1);\n\t\tconst query = normalizedQuery.slice(slashIndex + 1);\n\n\t\tlet baseDir: string;\n\t\tif (displayBase.startsWith(\"~/\")) {\n\t\t\tbaseDir = this.expandHomePath(displayBase);\n\t\t} else if (displayBase.startsWith(\"/\")) {\n\t\t\tbaseDir = displayBase;\n\t\t} else {\n\t\t\tbaseDir = join(this.basePath, displayBase);\n\t\t}\n\n\t\ttry {\n\t\t\tif (!statSync(baseDir).isDirectory()) {\n\t\t\t\treturn null;\n\t\t\t}\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn { baseDir, query, displayBase };\n\t}\n\n\tprivate scopedPathForDisplay(displayBase: string, relativePath: string): string {\n\t\tconst normalizedRelativePath = toDisplayPath(relativePath);\n\t\tif (displayBase === \"/\") {\n\t\t\treturn `/${normalizedRelativePath}`;\n\t\t}\n\t\treturn `${toDisplayPath(displayBase)}${normalizedRelativePath}`;\n\t}\n\n\t// Get file/directory suggestions for a given path prefix\n\tprivate getFileSuggestions(prefix: string): AutocompleteItem[] {\n\t\ttry {\n\t\t\tlet searchDir: string;\n\t\t\tlet searchPrefix: string;\n\t\t\tconst { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);\n\t\t\tlet expandedPrefix = rawPrefix;\n\n\t\t\t// Handle home directory expansion\n\t\t\tif (expandedPrefix.startsWith(\"~\")) {\n\t\t\t\texpandedPrefix = this.expandHomePath(expandedPrefix);\n\t\t\t}\n\n\t\t\tconst isRootPrefix =\n\t\t\t\trawPrefix === \"\" ||\n\t\t\t\trawPrefix === \"./\" ||\n\t\t\t\trawPrefix === \"../\" ||\n\t\t\t\trawPrefix === \"~\" ||\n\t\t\t\trawPrefix === \"~/\" ||\n\t\t\t\trawPrefix === \"/\" ||\n\t\t\t\t(isAtPrefix && rawPrefix === \"\");\n\n\t\t\tif (isRootPrefix) {\n\t\t\t\t// Complete from specified position\n\t\t\t\tif (rawPrefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else if (rawPrefix.endsWith(\"/\")) {\n\t\t\t\t// If prefix ends with /, show contents of that directory\n\t\t\t\tif (rawPrefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = expandedPrefix;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, expandedPrefix);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = \"\";\n\t\t\t} else {\n\t\t\t\t// Split into directory and file prefix\n\t\t\t\tconst dir = dirname(expandedPrefix);\n\t\t\t\tconst file = basename(expandedPrefix);\n\t\t\t\tif (rawPrefix.startsWith(\"~\") || expandedPrefix.startsWith(\"/\")) {\n\t\t\t\t\tsearchDir = dir;\n\t\t\t\t} else {\n\t\t\t\t\tsearchDir = join(this.basePath, dir);\n\t\t\t\t}\n\t\t\t\tsearchPrefix = file;\n\t\t\t}\n\n\t\t\tconst entries = readdirSync(searchDir, { withFileTypes: true });\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\n\t\t\tfor (const entry of entries) {\n\t\t\t\tif (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Check if entry is a directory (or a symlink pointing to a directory)\n\t\t\t\tlet isDirectory = entry.isDirectory();\n\t\t\t\tif (!isDirectory && entry.isSymbolicLink()) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst fullPath = join(searchDir, entry.name);\n\t\t\t\t\t\tisDirectory = statSync(fullPath).isDirectory();\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Broken symlink or permission error - treat as file\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tlet relativePath: string;\n\t\t\t\tconst name = entry.name;\n\t\t\t\tconst displayPrefix = rawPrefix;\n\n\t\t\t\tif (displayPrefix.endsWith(\"/\")) {\n\t\t\t\t\t// If prefix ends with /, append entry to the prefix\n\t\t\t\t\trelativePath = displayPrefix + name;\n\t\t\t\t} else if (displayPrefix.includes(\"/\") || displayPrefix.includes(\"\\\\\")) {\n\t\t\t\t\t// Preserve ~/ format for home directory paths\n\t\t\t\t\tif (displayPrefix.startsWith(\"~/\")) {\n\t\t\t\t\t\tconst homeRelativeDir = displayPrefix.slice(2); // Remove ~/\n\t\t\t\t\t\tconst dir = dirname(homeRelativeDir);\n\t\t\t\t\t\trelativePath = `~/${dir === \".\" ? name : join(dir, name)}`;\n\t\t\t\t\t} else if (displayPrefix.startsWith(\"/\")) {\n\t\t\t\t\t\t// Absolute path - construct properly\n\t\t\t\t\t\tconst dir = dirname(displayPrefix);\n\t\t\t\t\t\tif (dir === \"/\") {\n\t\t\t\t\t\t\trelativePath = `/${name}`;\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\trelativePath = `${dir}/${name}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = join(dirname(displayPrefix), name);\n\t\t\t\t\t\t// path.join normalizes away ./ prefix, preserve it\n\t\t\t\t\t\tif (displayPrefix.startsWith(\"./\") && !relativePath.startsWith(\"./\")) {\n\t\t\t\t\t\t\trelativePath = `./${relativePath}`;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// For standalone entries, preserve ~/ if original prefix was ~/\n\t\t\t\t\tif (displayPrefix.startsWith(\"~\")) {\n\t\t\t\t\t\trelativePath = `~/${name}`;\n\t\t\t\t\t} else {\n\t\t\t\t\t\trelativePath = name;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\trelativePath = toDisplayPath(relativePath);\n\t\t\t\tconst pathValue = isDirectory ? `${relativePath}/` : relativePath;\n\t\t\t\tconst value = buildCompletionValue(pathValue, {\n\t\t\t\t\tisDirectory,\n\t\t\t\t\tisAtPrefix,\n\t\t\t\t\tisQuotedPrefix,\n\t\t\t\t});\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue,\n\t\t\t\t\tlabel: name + (isDirectory ? \"/\" : \"\"),\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Sort directories first, then alphabetically\n\t\t\tsuggestions.sort((a, b) => {\n\t\t\t\tconst aIsDir = a.value.endsWith(\"/\");\n\t\t\t\tconst bIsDir = b.value.endsWith(\"/\");\n\t\t\t\tif (aIsDir && !bIsDir) return -1;\n\t\t\t\tif (!aIsDir && bIsDir) return 1;\n\t\t\t\treturn a.label.localeCompare(b.label);\n\t\t\t});\n\n\t\t\treturn suggestions;\n\t\t} catch (_e) {\n\t\t\t// Directory doesn't exist or not accessible\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Score an entry against the query (higher = better match)\n\t// isDirectory adds bonus to prioritize folders\n\tprivate scoreEntry(filePath: string, query: string, isDirectory: boolean): number {\n\t\tconst fileName = basename(filePath);\n\t\tconst lowerFileName = fileName.toLowerCase();\n\t\tconst lowerQuery = query.toLowerCase();\n\n\t\tlet score = 0;\n\n\t\t// Exact filename match (highest)\n\t\tif (lowerFileName === lowerQuery) score = 100;\n\t\t// Filename starts with query\n\t\telse if (lowerFileName.startsWith(lowerQuery)) score = 80;\n\t\t// Substring match in filename\n\t\telse if (lowerFileName.includes(lowerQuery)) score = 50;\n\t\t// Substring match in full path\n\t\telse if (filePath.toLowerCase().includes(lowerQuery)) score = 30;\n\n\t\t// Directories get a bonus to appear first\n\t\tif (isDirectory && score > 0) score += 10;\n\n\t\treturn score;\n\t}\n\n\t// Fuzzy file search using fd (fast, respects .gitignore)\n\tprivate async getFuzzyFileSuggestions(\n\t\tquery: string,\n\t\toptions: { isQuotedPrefix: boolean; signal: AbortSignal },\n\t): Promise<AutocompleteItem[]> {\n\t\tif (!this.fdPath || options.signal.aborted) {\n\t\t\treturn [];\n\t\t}\n\n\t\ttry {\n\t\t\tconst scopedQuery = this.resolveScopedFuzzyQuery(query);\n\t\t\tconst fdBaseDir = scopedQuery?.baseDir ?? this.basePath;\n\t\t\tconst fdQuery = scopedQuery?.query ?? query;\n\t\t\tconst entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);\n\t\t\tif (options.signal.aborted) {\n\t\t\t\treturn [];\n\t\t\t}\n\n\t\t\tconst scoredEntries = entries\n\t\t\t\t.map((entry) => ({\n\t\t\t\t\t...entry,\n\t\t\t\t\tscore: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,\n\t\t\t\t}))\n\t\t\t\t.filter((entry) => entry.score > 0);\n\n\t\t\tscoredEntries.sort((a, b) => b.score - a.score);\n\t\t\tconst topEntries = scoredEntries.slice(0, 20);\n\n\t\t\tconst suggestions: AutocompleteItem[] = [];\n\t\t\tfor (const { path: entryPath, isDirectory } of topEntries) {\n\t\t\t\tconst pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;\n\t\t\t\tconst displayPath = scopedQuery\n\t\t\t\t\t? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)\n\t\t\t\t\t: pathWithoutSlash;\n\t\t\t\tconst entryName = basename(pathWithoutSlash);\n\t\t\t\tconst completionPath = isDirectory ? `${displayPath}/` : displayPath;\n\t\t\t\tconst value = buildCompletionValue(completionPath, {\n\t\t\t\t\tisDirectory,\n\t\t\t\t\tisAtPrefix: true,\n\t\t\t\t\tisQuotedPrefix: options.isQuotedPrefix,\n\t\t\t\t});\n\n\t\t\t\tsuggestions.push({\n\t\t\t\t\tvalue,\n\t\t\t\t\tlabel: entryName + (isDirectory ? \"/\" : \"\"),\n\t\t\t\t\tdescription: displayPath,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\treturn suggestions;\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\t// Check if we should trigger file completion (called on Tab key)\n\tshouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {\n\t\tconst currentLine = lines[cursorLine] || \"\";\n\t\tconst textBeforeCursor = currentLine.slice(0, cursorCol);\n\n\t\t// Don't trigger if we're typing a slash command at the start of the line\n\t\tif (textBeforeCursor.trim().startsWith(\"/\") && !textBeforeCursor.trim().includes(\" \")) {\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"]}
@@ -1,9 +1,39 @@
1
- import { spawnSync } from "child_process";
1
+ import { spawn } from "child_process";
2
2
  import { readdirSync, statSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { basename, dirname, join } from "path";
5
5
  import { fuzzyFilter } from "./fuzzy.js";
6
6
  const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
7
+ function toDisplayPath(value) {
8
+ return value.replace(/\\/g, "/");
9
+ }
10
+ function escapeRegex(value) {
11
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ }
13
+ function buildFdPathQuery(query) {
14
+ const normalized = toDisplayPath(query);
15
+ if (!normalized.includes("/")) {
16
+ return normalized;
17
+ }
18
+ const hasTrailingSeparator = normalized.endsWith("/");
19
+ const trimmed = normalized.replace(/^\/+|\/+$/g, "");
20
+ if (!trimmed) {
21
+ return normalized;
22
+ }
23
+ const separatorPattern = "[\\\\/]";
24
+ const segments = trimmed
25
+ .split("/")
26
+ .filter(Boolean)
27
+ .map((segment) => escapeRegex(segment));
28
+ if (segments.length === 0) {
29
+ return normalized;
30
+ }
31
+ let pattern = segments.join(separatorPattern);
32
+ if (hasTrailingSeparator) {
33
+ pattern += separatorPattern;
34
+ }
35
+ return pattern;
36
+ }
7
37
  function findLastDelimiter(text) {
8
38
  for (let i = text.length - 1; i >= 0; i -= 1) {
9
39
  if (PATH_DELIMITERS.has(text[i] ?? "")) {
@@ -67,7 +97,7 @@ function buildCompletionValue(path, options) {
67
97
  return `${openQuote}${path}${closeQuote}`;
68
98
  }
69
99
  // Use fd to walk directory tree (fast, respects .gitignore)
70
- function walkDirectoryWithFd(baseDir, fdPath, query, maxResults) {
100
+ async function walkDirectoryWithFd(baseDir, fdPath, query, maxResults, signal) {
71
101
  const args = [
72
102
  "--base-directory",
73
103
  baseDir,
@@ -77,7 +107,7 @@ function walkDirectoryWithFd(baseDir, fdPath, query, maxResults) {
77
107
  "f",
78
108
  "--type",
79
109
  "d",
80
- "--full-path",
110
+ "--follow",
81
111
  "--hidden",
82
112
  "--exclude",
83
113
  ".git",
@@ -86,49 +116,85 @@ function walkDirectoryWithFd(baseDir, fdPath, query, maxResults) {
86
116
  "--exclude",
87
117
  ".git/**",
88
118
  ];
89
- // Add query as pattern if provided
90
- if (query) {
91
- args.push(query);
119
+ if (toDisplayPath(query).includes("/")) {
120
+ args.push("--full-path");
92
121
  }
93
- const result = spawnSync(fdPath, args, {
94
- encoding: "utf-8",
95
- stdio: ["pipe", "pipe", "pipe"],
96
- maxBuffer: 10 * 1024 * 1024,
97
- });
98
- if (result.status !== 0 || !result.stdout) {
99
- return [];
122
+ if (query) {
123
+ args.push(buildFdPathQuery(query));
100
124
  }
101
- const lines = result.stdout.trim().split("\n").filter(Boolean);
102
- const results = [];
103
- for (const line of lines) {
104
- const normalizedPath = line.endsWith("/") ? line.slice(0, -1) : line;
105
- if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
106
- continue;
125
+ return await new Promise((resolve) => {
126
+ if (signal.aborted) {
127
+ resolve([]);
128
+ return;
107
129
  }
108
- // fd outputs directories with trailing /
109
- const isDirectory = line.endsWith("/");
110
- results.push({
111
- path: line,
112
- isDirectory,
130
+ const child = spawn(fdPath, args, {
131
+ stdio: ["ignore", "pipe", "pipe"],
113
132
  });
114
- }
115
- return results;
133
+ let stdout = "";
134
+ let resolved = false;
135
+ const finish = (results) => {
136
+ if (resolved)
137
+ return;
138
+ resolved = true;
139
+ signal.removeEventListener("abort", onAbort);
140
+ resolve(results);
141
+ };
142
+ const onAbort = () => {
143
+ if (child.exitCode === null) {
144
+ child.kill("SIGKILL");
145
+ }
146
+ };
147
+ signal.addEventListener("abort", onAbort, { once: true });
148
+ child.stdout.setEncoding("utf-8");
149
+ child.stdout.on("data", (chunk) => {
150
+ stdout += chunk;
151
+ });
152
+ child.on("error", () => {
153
+ finish([]);
154
+ });
155
+ child.on("close", (code) => {
156
+ if (signal.aborted || code !== 0 || !stdout) {
157
+ finish([]);
158
+ return;
159
+ }
160
+ const lines = stdout.trim().split("\n").filter(Boolean);
161
+ const results = [];
162
+ for (const line of lines) {
163
+ const displayLine = toDisplayPath(line);
164
+ const hasTrailingSeparator = displayLine.endsWith("/");
165
+ const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
166
+ if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
167
+ continue;
168
+ }
169
+ results.push({
170
+ path: displayLine,
171
+ isDirectory: hasTrailingSeparator,
172
+ });
173
+ }
174
+ finish(results);
175
+ });
176
+ });
116
177
  }
117
178
  // Combined provider that handles both slash commands and file paths
118
179
  export class CombinedAutocompleteProvider {
119
- constructor(commands = [], basePath = process.cwd(), fdPath = null) {
180
+ commands;
181
+ basePath;
182
+ fdPath;
183
+ constructor(commands = [], basePath, fdPath = null) {
120
184
  this.commands = commands;
121
185
  this.basePath = basePath;
122
186
  this.fdPath = fdPath;
123
187
  }
124
- getSuggestions(lines, cursorLine, cursorCol) {
188
+ async getSuggestions(lines, cursorLine, cursorCol, options) {
125
189
  const currentLine = lines[cursorLine] || "";
126
190
  const textBeforeCursor = currentLine.slice(0, cursorCol);
127
- // Check for @ file reference (fuzzy search) - must be after a delimiter or at start
128
191
  const atPrefix = this.extractAtPrefix(textBeforeCursor);
129
192
  if (atPrefix) {
130
193
  const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
131
- const suggestions = this.getFuzzyFileSuggestions(rawPrefix, { isQuotedPrefix: isQuotedPrefix });
194
+ const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
195
+ isQuotedPrefix,
196
+ signal: options.signal,
197
+ });
132
198
  if (suggestions.length === 0)
133
199
  return null;
134
200
  return {
@@ -136,17 +202,21 @@ export class CombinedAutocompleteProvider {
136
202
  prefix: atPrefix,
137
203
  };
138
204
  }
139
- // Check for slash commands
140
- if (textBeforeCursor.startsWith("/")) {
205
+ if (!options.force && textBeforeCursor.startsWith("/")) {
141
206
  const spaceIndex = textBeforeCursor.indexOf(" ");
142
207
  if (spaceIndex === -1) {
143
- // No space yet - complete command names with fuzzy matching
144
- const prefix = textBeforeCursor.slice(1); // Remove the "/"
145
- const commandItems = this.commands.map((cmd) => ({
146
- name: "name" in cmd ? cmd.name : cmd.value,
147
- label: "name" in cmd ? cmd.name : cmd.label,
148
- description: cmd.description,
149
- }));
208
+ const prefix = textBeforeCursor.slice(1);
209
+ const commandItems = this.commands.map((cmd) => {
210
+ const name = "name" in cmd ? cmd.name : cmd.value;
211
+ const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
212
+ const desc = cmd.description ?? "";
213
+ const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
214
+ return {
215
+ name,
216
+ label: name,
217
+ description: fullDesc || undefined,
218
+ };
219
+ });
150
220
  const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
151
221
  value: item.name,
152
222
  label: item.label,
@@ -159,50 +229,35 @@ export class CombinedAutocompleteProvider {
159
229
  prefix: textBeforeCursor,
160
230
  };
161
231
  }
162
- else {
163
- // Space found - complete command arguments
164
- const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
165
- const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
166
- const command = this.commands.find((cmd) => {
167
- const name = "name" in cmd ? cmd.name : cmd.value;
168
- return name === commandName;
169
- });
170
- if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
171
- return null; // No argument completion for this command
172
- }
173
- const argumentSuggestions = command.getArgumentCompletions(argumentText);
174
- if (!argumentSuggestions || argumentSuggestions.length === 0) {
175
- return null;
176
- }
177
- return {
178
- items: argumentSuggestions,
179
- prefix: argumentText,
180
- };
232
+ const commandName = textBeforeCursor.slice(1, spaceIndex);
233
+ const argumentText = textBeforeCursor.slice(spaceIndex + 1);
234
+ const command = this.commands.find((cmd) => {
235
+ const name = "name" in cmd ? cmd.name : cmd.value;
236
+ return name === commandName;
237
+ });
238
+ if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
239
+ return null;
181
240
  }
182
- }
183
- // Check for file paths - triggered by Tab or if we detect a path pattern
184
- const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
185
- if (pathMatch !== null) {
186
- const suggestions = this.getFileSuggestions(pathMatch);
187
- if (suggestions.length === 0)
241
+ const argumentSuggestions = await command.getArgumentCompletions(argumentText);
242
+ if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
188
243
  return null;
189
- // Check if we have an exact match that is a directory
190
- // In that case, we might want to return suggestions for the directory content instead
191
- // But only if the prefix ends with /
192
- if (suggestions.length === 1 && suggestions[0]?.value === pathMatch && !pathMatch.endsWith("/")) {
193
- // Exact match found (e.g. user typed "src" and "src/" is the only match)
194
- // We still return it so user can select it and add /
195
- return {
196
- items: suggestions,
197
- prefix: pathMatch,
198
- };
199
244
  }
200
245
  return {
201
- items: suggestions,
202
- prefix: pathMatch,
246
+ items: argumentSuggestions,
247
+ prefix: argumentText,
203
248
  };
204
249
  }
205
- return null;
250
+ const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);
251
+ if (pathMatch === null) {
252
+ return null;
253
+ }
254
+ const suggestions = this.getFileSuggestions(pathMatch);
255
+ if (suggestions.length === 0)
256
+ return null;
257
+ return {
258
+ items: suggestions,
259
+ prefix: pathMatch,
260
+ };
206
261
  }
207
262
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
208
263
  const currentLine = lines[cursorLine] || "";
@@ -322,12 +377,13 @@ export class CombinedAutocompleteProvider {
322
377
  return path;
323
378
  }
324
379
  resolveScopedFuzzyQuery(rawQuery) {
325
- const slashIndex = rawQuery.lastIndexOf("/");
380
+ const normalizedQuery = toDisplayPath(rawQuery);
381
+ const slashIndex = normalizedQuery.lastIndexOf("/");
326
382
  if (slashIndex === -1) {
327
383
  return null;
328
384
  }
329
- const displayBase = rawQuery.slice(0, slashIndex + 1);
330
- const query = rawQuery.slice(slashIndex + 1);
385
+ const displayBase = normalizedQuery.slice(0, slashIndex + 1);
386
+ const query = normalizedQuery.slice(slashIndex + 1);
331
387
  let baseDir;
332
388
  if (displayBase.startsWith("~/")) {
333
389
  baseDir = this.expandHomePath(displayBase);
@@ -349,10 +405,11 @@ export class CombinedAutocompleteProvider {
349
405
  return { baseDir, query, displayBase };
350
406
  }
351
407
  scopedPathForDisplay(displayBase, relativePath) {
408
+ const normalizedRelativePath = toDisplayPath(relativePath);
352
409
  if (displayBase === "/") {
353
- return `/${relativePath}`;
410
+ return `/${normalizedRelativePath}`;
354
411
  }
355
- return `${displayBase}${relativePath}`;
412
+ return `${toDisplayPath(displayBase)}${normalizedRelativePath}`;
356
413
  }
357
414
  // Get file/directory suggestions for a given path prefix
358
415
  getFileSuggestions(prefix) {
@@ -428,7 +485,7 @@ export class CombinedAutocompleteProvider {
428
485
  // If prefix ends with /, append entry to the prefix
429
486
  relativePath = displayPrefix + name;
430
487
  }
431
- else if (displayPrefix.includes("/")) {
488
+ else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) {
432
489
  // Preserve ~/ format for home directory paths
433
490
  if (displayPrefix.startsWith("~/")) {
434
491
  const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
@@ -447,6 +504,10 @@ export class CombinedAutocompleteProvider {
447
504
  }
448
505
  else {
449
506
  relativePath = join(dirname(displayPrefix), name);
507
+ // path.join normalizes away ./ prefix, preserve it
508
+ if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
509
+ relativePath = `./${relativePath}`;
510
+ }
450
511
  }
451
512
  }
452
513
  else {
@@ -458,6 +519,7 @@ export class CombinedAutocompleteProvider {
458
519
  relativePath = name;
459
520
  }
460
521
  }
522
+ relativePath = toDisplayPath(relativePath);
461
523
  const pathValue = isDirectory ? `${relativePath}/` : relativePath;
462
524
  const value = buildCompletionValue(pathValue, {
463
525
  isDirectory,
@@ -511,30 +573,28 @@ export class CombinedAutocompleteProvider {
511
573
  return score;
512
574
  }
513
575
  // Fuzzy file search using fd (fast, respects .gitignore)
514
- getFuzzyFileSuggestions(query, options) {
515
- if (!this.fdPath) {
516
- // fd not available, return empty results
576
+ async getFuzzyFileSuggestions(query, options) {
577
+ if (!this.fdPath || options.signal.aborted) {
517
578
  return [];
518
579
  }
519
580
  try {
520
581
  const scopedQuery = this.resolveScopedFuzzyQuery(query);
521
582
  const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
522
583
  const fdQuery = scopedQuery?.query ?? query;
523
- const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100);
524
- // Score entries
584
+ const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
585
+ if (options.signal.aborted) {
586
+ return [];
587
+ }
525
588
  const scoredEntries = entries
526
589
  .map((entry) => ({
527
590
  ...entry,
528
591
  score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
529
592
  }))
530
593
  .filter((entry) => entry.score > 0);
531
- // Sort by score (descending) and take top 20
532
594
  scoredEntries.sort((a, b) => b.score - a.score);
533
595
  const topEntries = scoredEntries.slice(0, 20);
534
- // Build suggestions
535
596
  const suggestions = [];
536
597
  for (const { path: entryPath, isDirectory } of topEntries) {
537
- // fd already includes trailing / for directories
538
598
  const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
539
599
  const displayPath = scopedQuery
540
600
  ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
@@ -558,27 +618,6 @@ export class CombinedAutocompleteProvider {
558
618
  return [];
559
619
  }
560
620
  }
561
- // Force file completion (called on Tab key) - always returns suggestions
562
- getForceFileSuggestions(lines, cursorLine, cursorCol) {
563
- const currentLine = lines[cursorLine] || "";
564
- const textBeforeCursor = currentLine.slice(0, cursorCol);
565
- // Don't trigger if we're typing a slash command at the start of the line
566
- if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
567
- return null;
568
- }
569
- // Force extract path prefix - this will always return something
570
- const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
571
- if (pathMatch !== null) {
572
- const suggestions = this.getFileSuggestions(pathMatch);
573
- if (suggestions.length === 0)
574
- return null;
575
- return {
576
- items: suggestions,
577
- prefix: pathMatch,
578
- };
579
- }
580
- return null;
581
- }
582
621
  // Check if we should trigger file completion (called on Tab key)
583
622
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
584
623
  const currentLine = lines[cursorLine] || "";