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.
- package/README.md +29 -11
- package/dist/autocomplete.d.ts +18 -14
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +151 -112
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +6 -1
- package/dist/components/box.js.map +1 -1
- package/dist/components/cancellable-loader.d.ts.map +1 -1
- package/dist/components/cancellable-loader.js +6 -7
- package/dist/components/cancellable-loader.js.map +1 -1
- package/dist/components/editor.d.ts +45 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +505 -221
- package/dist/components/editor.js.map +1 -1
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +22 -7
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +57 -74
- package/dist/components/input.js.map +1 -1
- package/dist/components/loader.d.ts +12 -2
- package/dist/components/loader.d.ts.map +1 -1
- package/dist/components/loader.js +36 -13
- package/dist/components/loader.js.map +1 -1
- package/dist/components/markdown.d.ts +0 -5
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +101 -114
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/select-list.d.ts +19 -1
- package/dist/components/select-list.d.ts.map +1 -1
- package/dist/components/select-list.js +82 -71
- package/dist/components/select-list.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +18 -10
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/spacer.d.ts.map +1 -1
- package/dist/components/spacer.js +1 -0
- package/dist/components/spacer.js.map +1 -1
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js +8 -0
- package/dist/components/text.js.map +1 -1
- package/dist/components/truncated-text.d.ts.map +1 -1
- package/dist/components/truncated-text.js +3 -0
- package/dist/components/truncated-text.js.map +1 -1
- package/dist/editor-component.d.ts.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +3 -0
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/keybindings.d.ts +187 -33
- package/dist/keybindings.d.ts.map +1 -1
- package/dist/keybindings.js +156 -95
- package/dist/keybindings.js.map +1 -1
- package/dist/keys.d.ts +21 -12
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +270 -112
- package/dist/keys.js.map +1 -1
- package/dist/kill-ring.d.ts.map +1 -1
- package/dist/kill-ring.js +1 -3
- package/dist/kill-ring.js.map +1 -1
- package/dist/stdin-buffer.d.ts +2 -0
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +31 -8
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +17 -0
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +41 -5
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +4 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +56 -8
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +21 -5
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +234 -118
- package/dist/tui.js.map +1 -1
- package/dist/undo-stack.d.ts.map +1 -1
- package/dist/undo-stack.js +1 -3
- package/dist/undo-stack.js.map +1 -1
- package/dist/utils.d.ts +1 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +281 -81
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
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 "@
|
|
624
|
-
import type { Component } from "@
|
|
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 "@
|
|
660
|
-
import type { Component } from "@
|
|
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;
|
package/dist/autocomplete.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
27
|
-
getSuggestions(lines: string[], cursorLine: number, cursorCol: number
|
|
28
|
-
|
|
29
|
-
|
|
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"]}
|
package/dist/autocomplete.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
|
-
import {
|
|
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
|
-
"--
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
args.push(query);
|
|
119
|
+
if (toDisplayPath(query).includes("/")) {
|
|
120
|
+
args.push("--full-path");
|
|
92
121
|
}
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
results.push({
|
|
111
|
-
path: line,
|
|
112
|
-
isDirectory,
|
|
130
|
+
const child = spawn(fdPath, args, {
|
|
131
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
113
132
|
});
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
140
|
-
if (textBeforeCursor.startsWith("/")) {
|
|
205
|
+
if (!options.force && textBeforeCursor.startsWith("/")) {
|
|
141
206
|
const spaceIndex = textBeforeCursor.indexOf(" ");
|
|
142
207
|
if (spaceIndex === -1) {
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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:
|
|
202
|
-
prefix:
|
|
246
|
+
items: argumentSuggestions,
|
|
247
|
+
prefix: argumentText,
|
|
203
248
|
};
|
|
204
249
|
}
|
|
205
|
-
|
|
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
|
|
380
|
+
const normalizedQuery = toDisplayPath(rawQuery);
|
|
381
|
+
const slashIndex = normalizedQuery.lastIndexOf("/");
|
|
326
382
|
if (slashIndex === -1) {
|
|
327
383
|
return null;
|
|
328
384
|
}
|
|
329
|
-
const displayBase =
|
|
330
|
-
const query =
|
|
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 `/${
|
|
410
|
+
return `/${normalizedRelativePath}`;
|
|
354
411
|
}
|
|
355
|
-
return `${displayBase}${
|
|
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
|
-
|
|
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] || "";
|