@xynogen/pix-search 0.1.1 → 0.2.2

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 CHANGED
@@ -1,12 +1,30 @@
1
1
  # pix-search
2
2
 
3
- Smarter `@` file search for [Pi Coding Agent](https://github.com/earendil-works/pi).
3
+ `@` file picker overlay for [Pi Coding Agent](https://github.com/earendil-works/pi).
4
4
 
5
- Wraps the built-in autocomplete provider to improve file ranking when you type `@` in the prompt:
5
+ Typing `@` at a token boundary (line start or after whitespace) opens a modal
6
+ file picker with its own query input:
6
7
 
7
- - **Fuzzy scoring** — proper character-order matching with gap penalties and word-boundary bonuses (uses pi-tui's `fuzzyMatch`)
8
- - **Git recency** — recently modified files (from `git log`) rank higher
9
- - **Depth penalty** — shallower files win ties
8
+ - **Files and folders** — pick a file to point the model at it, or a folder to
9
+ scope work to a whole directory. Folders appear with a trailing `/`.
10
+ - **Live preview** — on wide terminals a side pane shows the selection: a file's
11
+ first lines with syntax highlighting, or a folder's immediate file list.
12
+ - **File-type icons & colors** — results show Nerd Font file/folder icons and
13
+ type-colored names (respects `PRETTY_ICONS`).
14
+ - **Spaces in the query** — the picker owns keyboard focus, so `@` then `my file`
15
+ filters on the full phrase with no `@"…"` quoting.
16
+ - **Name-based** — matches file/folder *names and paths*, not file contents.
17
+ - **Fuzzy scoring** — character-order matching with word-boundary bonuses.
18
+ - **Git recency** — recently modified files (from `git log`) rank higher.
19
+ - **Depth penalty** — shallower entries win ties.
20
+
21
+ Picking a result inserts `<path>…</path>` into the prompt; the model receives
22
+ that span verbatim, and `@xynogen/pix-display` renders it as an `@name` chip
23
+ when installed. Keys: `↑`/`↓` move, `⏎` insert, `Esc` cancel (a cancelled
24
+ pick inserts a literal `@`, so the keystroke is never swallowed).
25
+
26
+ `@` mid-token (e.g. an email `user@host`) is left as a literal `@` — only a
27
+ boundary `@` opens the picker.
10
28
 
11
29
  ## Install
12
30
 
@@ -14,4 +32,6 @@ Wraps the built-in autocomplete provider to improve file ranking when you type `
14
32
  pi install npm:@xynogen/pix-search
15
33
  ```
16
34
 
17
- Standalone — **not** bundled by `@xynogen/pix-core`.
35
+ Standalone — **not** bundled by `@xynogen/pix-core`. It wraps whatever editor
36
+ is already installed (via `getEditorComponent`/`setEditorComponent`), so it
37
+ composes with `pix-display` in either load order.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xynogen/pix-search",
3
- "version": "0.1.1",
4
- "description": "Pi extension — smarter @ file search with fuzzy + git recency ranking",
3
+ "version": "0.2.2",
4
+ "description": "Pi extension — @ file picker overlay with fuzzy + git recency ranking",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
7
7
  "exports": {
@@ -48,6 +48,7 @@
48
48
  "@earendil-works/pi-tui": "*"
49
49
  },
50
50
  "dependencies": {
51
- "@vscode/ripgrep": "^1.18.0"
51
+ "@vscode/ripgrep": "^1.18.0",
52
+ "@xynogen/pix-pretty": "^1.21.0"
52
53
  }
53
54
  }
package/src/editor.ts ADDED
@@ -0,0 +1,51 @@
1
+ /** Add the @ picker to an editor without replacing its paste state or rendering. */
2
+ import type { CustomEditor } from "@earendil-works/pi-coding-agent";
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+
5
+ /** Picked paths are marked as `<path>…</path>`; pix-display renders the span as a chip. */
6
+ export function pathToken(path: string): string {
7
+ return `<path>${path}</path> `;
8
+ }
9
+
10
+ export type OpenPicker = () => Promise<string | null>;
11
+
12
+ /** A mention starts at the beginning of a line or after whitespace. */
13
+ export function atStartsMention(before: string): boolean {
14
+ return before === "" || /\s$/.test(before);
15
+ }
16
+
17
+ export function attachPicker(
18
+ editor: CustomEditor,
19
+ tui: TUI,
20
+ openPicker: OpenPicker,
21
+ onError: (message: string) => void,
22
+ ): void {
23
+ const handleInput = editor.handleInput.bind(editor);
24
+ let inPaste = false;
25
+ const runPicker = async () => {
26
+ try {
27
+ const path = await openPicker();
28
+ editor.insertTextAtCursor(path ? pathToken(path) : "@");
29
+ } catch (error) {
30
+ editor.insertTextAtCursor("@");
31
+ onError(`File picker failed: ${error instanceof Error ? error.message : String(error)}`);
32
+ } finally {
33
+ tui.requestRender();
34
+ }
35
+ };
36
+ editor.handleInput = (data: string) => {
37
+ // Bracketed paste can arrive in separate chunks; a pasted @ is not a shortcut.
38
+ for (const marker of data.matchAll(/\x1b\[(200|201)~/g)) {
39
+ inPaste = marker[1] === "200";
40
+ }
41
+ if (!inPaste && data === "@") {
42
+ const cursor = editor.getCursor();
43
+ const line = editor.getLines()[cursor.line] ?? "";
44
+ if (atStartsMention(line.slice(0, cursor.col))) {
45
+ void runPicker();
46
+ return;
47
+ }
48
+ }
49
+ handleInput(data);
50
+ };
51
+ }
package/src/extension.ts CHANGED
@@ -1,19 +1,71 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { createSearchProvider } from "./provider.ts";
1
+ /**
2
+ * pix-search — adds input handling that opens a file-picker
3
+ * overlay when you type `@` at a token boundary. The picker owns its own query
4
+ * input, so spaces in the query work natively (no `@"…"` quoting). It lists
5
+ * both files and their containing folders (so you can @-mention a directory),
6
+ * ranked by fuzzy score + git recency, with a live preview of the selection.
7
+ */
8
+
9
+ import { CustomEditor, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { attachPicker } from "./editor.ts";
11
+ import { FilePicker } from "./picker.ts";
12
+ import { withDirectories } from "./rank.ts";
3
13
  import { loadRecency, type RecencyMap } from "./recency.ts";
14
+ import { rgFiles } from "./rg.ts";
4
15
 
5
16
  export default function (pi: ExtensionAPI): void {
6
17
  pi.on("session_start", async (_event, ctx) => {
18
+ if (ctx.mode !== "tui") return;
7
19
  const cwd = ctx.cwd;
8
20
 
9
- // Warm recency cache in background — non-blocking
21
+ // Warm the git-recency cache in the background; picker reads whatever is
22
+ // ready when it opens (empty until the first git log returns).
10
23
  let recency: RecencyMap = new Map();
11
- const recencyReady = loadRecency(cwd).then((map) => {
24
+ void loadRecency(cwd).then((map) => {
12
25
  recency = map;
13
26
  });
14
- // Fire-and-forget; if git isn't available, recency stays empty
15
- void recencyReady;
16
27
 
17
- ctx.ui.addAutocompleteProvider((current) => createSearchProvider(current, cwd, () => recency));
28
+ // Open the picker overlay and resolve with the chosen path (or null).
29
+ const openPicker = () =>
30
+ ctx.ui.custom<string | null>(
31
+ (tui, theme, _kb, done) => {
32
+ // List files fresh each open so new/removed files are reflected.
33
+ const controller = new AbortController();
34
+ const picker = new FilePicker({
35
+ files: [],
36
+ recency,
37
+ theme: theme as never,
38
+ cwd,
39
+ done,
40
+ onChange: () => tui.requestRender(),
41
+ });
42
+ void rgFiles(cwd, controller.signal).then((files) =>
43
+ picker.setFiles(withDirectories(files)),
44
+ );
45
+ return {
46
+ render: (width: number) => picker.render(width),
47
+ handleInput: (data: string) => picker.handleInput(data),
48
+ invalidate: () => picker.invalidate(),
49
+ dispose: () => controller.abort(),
50
+ };
51
+ },
52
+ { overlay: true, overlayOptions: { anchor: "center", width: "60%", maxHeight: "60%" } },
53
+ );
54
+
55
+ const previous = ctx.ui.getEditorComponent();
56
+ ctx.ui.setEditorComponent((tui, theme, kb) => {
57
+ const editor = previous?.(tui, theme, kb) ?? new CustomEditor(tui, theme, kb);
58
+ // ponytail: cursor access requires CustomEditor. Keep other editors intact;
59
+ // add a public cursor adapter if a non-CustomEditor integration is needed.
60
+ if (editor instanceof CustomEditor) {
61
+ attachPicker(editor, tui, openPicker, (message) => ctx.ui.notify(message, "error"));
62
+ } else {
63
+ ctx.ui.notify(
64
+ "pix-search: @ picker requires a CustomEditor; existing editor kept.",
65
+ "warning",
66
+ );
67
+ }
68
+ return editor;
69
+ });
18
70
  });
19
71
  }
package/src/picker.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * File picker overlay — a self-contained modal with its own query input and
3
+ * result list. Unlike the inline `@` dropdown, this owns keyboard focus, so
4
+ * spaces in the query are native (no quoting hack) and the host editor's
5
+ * space-stops-autocomplete behavior is irrelevant.
6
+ *
7
+ * The component is a plain pi-tui `Component` (render + handleInput). It is
8
+ * pure w.r.t. file discovery: it receives the already-listed files and a
9
+ * recency map, ranks them with `rankFiles`, and reports the chosen path (or
10
+ * null on cancel) through the `done` callback.
11
+ */
12
+
13
+ import { readFileSync } from "node:fs";
14
+ import { isAbsolute, join } from "node:path";
15
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
16
+ import { hlBlock } from "@xynogen/pix-pretty/highlight";
17
+ import { dirIcon, fileColor, fileIcon } from "@xynogen/pix-pretty/icons";
18
+ import { lang } from "@xynogen/pix-pretty/lang";
19
+ import { frameLines, joinColumns, modalWidth } from "@xynogen/pix-pretty/modal-frame";
20
+ import type { ThemeLike } from "@xynogen/pix-pretty/types";
21
+ import { rankFiles } from "./rank.ts";
22
+ import type { RecencyMap } from "./recency.ts";
23
+
24
+ const MAX_VISIBLE = 10;
25
+ /** Show the preview pane only when the modal is at least this wide. */
26
+ const PREVIEW_MIN_WIDTH = 72;
27
+ /** How many lines of the selected file to preview. */
28
+ const PREVIEW_LINES = 14;
29
+ /** Cap the bytes we read for a preview — enough for PREVIEW_LINES of code. */
30
+ const PREVIEW_MAX_BYTES = 8 * 1024;
31
+
32
+ export interface FilePickerOptions {
33
+ files: string[];
34
+ recency: RecencyMap;
35
+ theme: ThemeLike;
36
+ /** Root for resolving relative result paths when reading previews. */
37
+ cwd: string;
38
+ /** Called with the chosen path, or null on cancel. */
39
+ done: (path: string | null) => void;
40
+ /** Ask the host to re-render (e.g. after an async syntax-highlight lands). */
41
+ onChange?: () => void;
42
+ /** Seed query (e.g. characters typed after @ before the picker opened). */
43
+ initialQuery?: string;
44
+ }
45
+
46
+ export class FilePicker {
47
+ private query: string;
48
+ private selected = 0;
49
+ private cachedWidth?: number;
50
+ private cachedLines?: string[];
51
+
52
+ private files: string[];
53
+ /** Syntax-highlighted preview lines, keyed by file path. Filled async by
54
+ * hlBlock; a miss shows plain text until the highlight lands. */
55
+ private hlCache = new Map<string, string[]>();
56
+
57
+ constructor(private readonly opts: FilePickerOptions) {
58
+ this.query = opts.initialQuery ?? "";
59
+ this.files = opts.files;
60
+ }
61
+
62
+ /** Replace the candidate file list (e.g. once async `rg --files` returns). */
63
+ setFiles(files: string[]): void {
64
+ this.files = files;
65
+ this.invalidate();
66
+ }
67
+
68
+ private results() {
69
+ return rankFiles(this.files, this.query, this.opts.recency, 50);
70
+ }
71
+
72
+ /** Color a result label: dirs use accent/dim, files use their type hue when
73
+ * selected and dim otherwise (so the selection pops without noise). */
74
+ private styleName(path: string, label: string, isDir: boolean, isSel: boolean): string {
75
+ const theme = this.opts.theme;
76
+ if (isDir) return theme.fg(isSel ? "accent" : "dim", label);
77
+ return isSel ? fileColor(path, label, theme) : theme.fg("dim", label);
78
+ }
79
+
80
+ /** Preview lines for the selected result: a folder shows its immediate
81
+ * file/dir list (icon-prefixed); a file shows its first lines, syntax-
82
+ * highlighted once hlBlock resolves. Returns [] on any error (unreadable,
83
+ * binary, missing) so the pane just stays empty. */
84
+ private preview(path: string): string[] {
85
+ const theme = this.opts.theme;
86
+ if (path.endsWith("/")) {
87
+ const children = new Set<string>();
88
+ for (const f of this.files) {
89
+ if (!f.startsWith(path)) continue;
90
+ const rest = f.slice(path.length).split("/");
91
+ children.add(rest.length > 1 ? `${rest[0]}/` : (rest[0] ?? ""));
92
+ }
93
+ return [...children]
94
+ .filter(Boolean)
95
+ .sort((a, b) => a.localeCompare(b))
96
+ .slice(0, PREVIEW_LINES)
97
+ .map((child) => {
98
+ const isDir = child.endsWith("/");
99
+ const icon = isDir ? dirIcon(theme) : fileIcon(child, theme);
100
+ const name = isDir ? theme.fg("accent", child) : fileColor(child, child, theme);
101
+ return `${icon}${name}`;
102
+ });
103
+ }
104
+ const cached = this.hlCache.get(path);
105
+ if (cached) return cached;
106
+ try {
107
+ const abs = isAbsolute(path) ? path : join(this.opts.cwd, path);
108
+ const buf = readFileSync(abs).subarray(0, PREVIEW_MAX_BYTES).toString("utf8");
109
+ if (buf.includes("\u0000")) return []; // binary
110
+ const plain = buf.split("\n").slice(0, PREVIEW_LINES);
111
+ // Kick off async highlight; cache + re-render when it lands.
112
+ void hlBlock(plain.join("\n"), lang(path), theme).then((hl) => {
113
+ this.hlCache.set(path, hl);
114
+ this.invalidate();
115
+ this.opts.onChange?.();
116
+ });
117
+ return plain; // plain until the highlight resolves
118
+ } catch {
119
+ return [];
120
+ }
121
+ }
122
+
123
+ handleInput(data: string): void {
124
+ if (matchesKey(data, "escape")) {
125
+ this.opts.done(null);
126
+ return;
127
+ }
128
+ if (matchesKey(data, "enter")) {
129
+ const hit = this.results()[this.selected];
130
+ this.opts.done(hit ? hit.path : null);
131
+ return;
132
+ }
133
+ if (matchesKey(data, "up")) {
134
+ this.selected = Math.max(0, this.selected - 1);
135
+ this.invalidate();
136
+ return;
137
+ }
138
+ if (matchesKey(data, "down")) {
139
+ this.selected = Math.min(this.results().length - 1, this.selected + 1);
140
+ this.invalidate();
141
+ return;
142
+ }
143
+ if (matchesKey(data, "backspace")) {
144
+ this.query = this.query.slice(0, -1);
145
+ this.selected = 0;
146
+ this.invalidate();
147
+ return;
148
+ }
149
+ // Printable characters (including space) extend the query.
150
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
151
+ this.query += data;
152
+ this.selected = 0;
153
+ this.invalidate();
154
+ }
155
+ }
156
+
157
+ render(width: number): string[] {
158
+ if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
159
+
160
+ const theme = this.opts.theme;
161
+ const inner = modalWidth(width) - 4; // frame chrome
162
+ const results = this.results();
163
+ if (this.selected >= results.length) this.selected = Math.max(0, results.length - 1);
164
+
165
+ // Scroll window around the selection.
166
+ const start = Math.max(
167
+ 0,
168
+ Math.min(this.selected - MAX_VISIBLE + 1, results.length - MAX_VISIBLE),
169
+ );
170
+ const visible = results.slice(start, start + MAX_VISIBLE);
171
+
172
+ // Header: bold title + live result count, then the query line.
173
+ const count = theme.fg(
174
+ "muted",
175
+ `${results.length} ${results.length === 1 ? "match" : "matches"}`,
176
+ );
177
+ const title = `${theme.bold(theme.fg("accent", " Find files & folders"))} ${count}`;
178
+ const queryLine = `${theme.fg("accent", "❯")} ${
179
+ this.query ? theme.fg("text", this.query) : theme.fg("muted", "type to filter…")
180
+ }`;
181
+
182
+ // Two-pane layout when the modal is wide: results on the left, a preview
183
+ // of the selected entry on the right. Narrow terminals fall back to a
184
+ // single result column.
185
+ const showPreview = inner >= PREVIEW_MIN_WIDTH && results.length > 0;
186
+ const listW = showPreview ? Math.floor(inner * 0.45) : inner;
187
+ const gap = 2;
188
+ const previewW = inner - listW - gap;
189
+
190
+ const listRows: string[] = [];
191
+ if (results.length === 0) {
192
+ listRows.push(theme.fg("muted", " no matching files"));
193
+ } else {
194
+ for (const [i, entry] of visible.entries()) {
195
+ const isSel = start + i === this.selected;
196
+ const isDir = entry.path.endsWith("/");
197
+ const marker = isSel ? theme.fg("accent", "›") : " ";
198
+ const icon = isDir ? dirIcon(theme) : fileIcon(entry.path, theme);
199
+ const name = this.styleName(entry.path, entry.label, isDir, isSel);
200
+ const dir = entry.path.slice(0, entry.path.length - entry.label.length);
201
+ const dirText = dir ? theme.fg("muted", dir) : "";
202
+ listRows.push(truncateToWidth(`${marker} ${icon}${name} ${dirText}`, listW));
203
+ }
204
+ }
205
+
206
+ const rows: string[] = [title, "", queryLine, ""];
207
+ if (showPreview) {
208
+ const sel = results[this.selected];
209
+ const previewRows = sel ? this.preview(sel.path) : [];
210
+ const head = sel
211
+ ? theme.fg(
212
+ "muted",
213
+ truncateToWidth(sel.path.endsWith("/") ? `${sel.path} · contents` : sel.path, previewW),
214
+ )
215
+ : "";
216
+ const previewLines = [
217
+ head,
218
+ theme.fg("border", "─".repeat(previewW)),
219
+ ...previewRows.map((l) => truncateToWidth(l, previewW)),
220
+ ];
221
+ rows.push(
222
+ ...joinColumns(listRows, previewLines, { leftWidth: listW, rightWidth: previewW, gap }),
223
+ );
224
+ } else {
225
+ rows.push(...listRows);
226
+ }
227
+
228
+ const hint = theme.fg("muted", "↑↓ move · ⏎ insert · esc cancel");
229
+ rows.push("", hint);
230
+
231
+ this.cachedLines = frameLines({
232
+ width: modalWidth(width),
233
+ lines: rows,
234
+ color: (s) => theme.fg("accent", s),
235
+ fg: (s) => theme.fg("text", s),
236
+ });
237
+ this.cachedWidth = width;
238
+ return this.cachedLines;
239
+ }
240
+
241
+ invalidate(): void {
242
+ this.cachedWidth = undefined;
243
+ this.cachedLines = undefined;
244
+ }
245
+ }
package/src/rank.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure file ranking — filename fuzzy score + git recency + depth penalty.
3
+ * No TUI, no I/O: takes a file list and returns the best matches, so the
4
+ * scoring is unit-testable independently of the picker UI.
5
+ */
6
+
7
+ import { basename } from "node:path";
8
+ import type { RecencyMap } from "./recency.ts";
9
+ import { scoreFilename } from "./rg.ts";
10
+
11
+ const W_FILENAME = 1.0;
12
+ const W_RECENCY = 30;
13
+ const W_DEPTH = -0.5; // penalty per path depth level
14
+
15
+ export interface RankedFile {
16
+ /** Path relative to the search root, with a trailing "/" for directories. */
17
+ path: string;
18
+ /** basename (with trailing "/" for dirs) for compact display. */
19
+ label: string;
20
+ score: number;
21
+ }
22
+
23
+ function depthOf(path: string): number {
24
+ return path.split("/").length - 1;
25
+ }
26
+
27
+ /**
28
+ * Rank `files` against `query`. Empty query returns all files ordered by
29
+ * recency then depth (so the picker shows something useful before typing).
30
+ * Files with no filename match are dropped once the query is non-empty.
31
+ */
32
+ export function rankFiles(
33
+ files: string[],
34
+ query: string,
35
+ recency: RecencyMap,
36
+ limit = 20,
37
+ ): RankedFile[] {
38
+ const q = query.trim();
39
+ const ranked: RankedFile[] = [];
40
+ for (const path of files) {
41
+ const fnScore = scoreFilename(path, q);
42
+ if (q && fnScore === 0) continue;
43
+ const isDir = path.endsWith("/");
44
+ const clean = isDir ? path.slice(0, -1) : path;
45
+ const score =
46
+ fnScore * W_FILENAME + (recency.get(path) ?? 0) * W_RECENCY + depthOf(path) * W_DEPTH;
47
+ ranked.push({ path, label: basename(clean) + (isDir ? "/" : ""), score });
48
+ }
49
+ ranked.sort((a, b) => b.score - a.score);
50
+ return ranked.slice(0, limit);
51
+ }
52
+
53
+ /**
54
+ * Expand a flat file list into files + the directories that contain them, so
55
+ * the picker can offer folders as selectable targets. Directories keep a
56
+ * trailing "/" (rankFiles already treats that as a dir label). Order is
57
+ * files-as-given with derived dirs appended; rankFiles re-sorts by score.
58
+ */
59
+ export function withDirectories(files: string[]): string[] {
60
+ const dirs = new Set<string>();
61
+ for (const path of files) {
62
+ const parts = path.split("/");
63
+ for (let i = 1; i < parts.length; i++) {
64
+ dirs.add(`${parts.slice(0, i).join("/")}/`);
65
+ }
66
+ }
67
+ // Drop any dir that already appears verbatim in files (rare) to avoid dupes.
68
+ const seen = new Set(files);
69
+ return [...files, ...[...dirs].filter((d) => !seen.has(d))];
70
+ }
package/src/rg.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Ripgrep-based file discovery.
3
- * - `rgFiles(query)` — filename matches via `rg --files` piped through fuzzy
4
- * - `rgContent(query)` — files containing query text via `rg -l`
3
+ * - `rgFiles()` — enumerate files via `rg --files`, scored by filename fuzzy
5
4
  *
6
- * Both respect .gitignore, hidden-file exclusions, and abort signals.
5
+ * Respects .gitignore, hidden-file exclusions, and abort signals.
7
6
  */
8
7
 
9
8
  import { spawn } from "node:child_process";
@@ -20,13 +19,6 @@ try {
20
19
  /* bundled binary unavailable — fall back to PATH `rg` */
21
20
  }
22
21
 
23
- export interface RgEntry {
24
- /** Relative path from basePath */
25
- path: string;
26
- /** Whether this matched by content (true) or filename (false) */
27
- contentMatch: boolean;
28
- }
29
-
30
22
  const TIMEOUT_MS = 3_000;
31
23
 
32
24
  function spawnRg(args: string[], cwd: string, signal: AbortSignal): Promise<string[]> {
@@ -85,71 +77,6 @@ export async function rgFiles(cwd: string, signal: AbortSignal): Promise<string[
85
77
  return spawnRg(["--files", "--hidden", "--glob", "!.git"], cwd, signal);
86
78
  }
87
79
 
88
- /**
89
- * Search file contents for `query`, return matching file paths.
90
- * Uses fixed-string search (no regex) for speed + safety.
91
- */
92
- export async function rgContent(
93
- query: string,
94
- cwd: string,
95
- signal: AbortSignal,
96
- ): Promise<string[]> {
97
- if (!query || query.length < 2) return [];
98
- return spawnRg(
99
- [
100
- "--files-with-matches",
101
- "--fixed-strings",
102
- "--ignore-case",
103
- "--hidden",
104
- "--glob",
105
- "!.git",
106
- "--max-count",
107
- "1", // stop after first match per file
108
- "--",
109
- query,
110
- ],
111
- cwd,
112
- signal,
113
- );
114
- }
115
-
116
- /**
117
- * Combined file search: filename fuzzy + content grep.
118
- * Returns deduplicated entries, filename matches first.
119
- */
120
- export async function rgSearch(
121
- query: string,
122
- cwd: string,
123
- signal: AbortSignal,
124
- ): Promise<RgEntry[]> {
125
- // Run both in parallel
126
- const [allFiles, contentFiles] = await Promise.all([
127
- rgFiles(cwd, signal),
128
- rgContent(query, cwd, signal),
129
- ]);
130
-
131
- if (signal.aborted) return [];
132
-
133
- const seen = new Set<string>();
134
- const results: RgEntry[] = [];
135
-
136
- // Filename matches (all files, will be fuzzy-scored by caller)
137
- for (const path of allFiles) {
138
- if (seen.has(path)) continue;
139
- seen.add(path);
140
- results.push({ path, contentMatch: false });
141
- }
142
-
143
- // Content-only matches (files not already in filename results)
144
- for (const path of contentFiles) {
145
- if (seen.has(path)) continue;
146
- seen.add(path);
147
- results.push({ path, contentMatch: true });
148
- }
149
-
150
- return results;
151
- }
152
-
153
80
  /**
154
81
  * Score a file path against a query for filename relevance.
155
82
  * Returns 0 for no match. Higher = better.
package/src/provider.ts DELETED
@@ -1,165 +0,0 @@
1
- /**
2
- * Autocomplete provider wrapper — intercepts @ suggestions and re-ranks
3
- * using rg file discovery, fuzzy scoring, and git recency.
4
- *
5
- * Non-@ completions (slash commands, path completions) pass through to the
6
- * built-in provider unchanged.
7
- */
8
-
9
- import { basename } from "node:path";
10
- import type {
11
- AutocompleteItem,
12
- AutocompleteProvider,
13
- AutocompleteSuggestions,
14
- } from "@earendil-works/pi-tui";
15
- import type { RecencyMap } from "./recency.ts";
16
- import { rgContent, rgFiles, scoreFilename } from "./rg.ts";
17
-
18
- const MAX_SUGGESTIONS = 20;
19
- /** Minimum query length before we also search file contents */
20
- const CONTENT_SEARCH_MIN = 2;
21
-
22
- /** Weight factors for the combined score */
23
- const W_FILENAME = 1.0;
24
- const W_RECENCY = 30;
25
- const W_CONTENT = 25;
26
- const W_DEPTH = -0.5; // penalty per path depth level
27
-
28
- interface ScoredEntry {
29
- path: string;
30
- filenameScore: number;
31
- recencyScore: number;
32
- contentMatch: boolean;
33
- depth: number;
34
- }
35
-
36
- function depthOf(path: string): number {
37
- return path.split("/").length - 1;
38
- }
39
-
40
- function combinedScore(entry: ScoredEntry): number {
41
- return (
42
- entry.filenameScore * W_FILENAME +
43
- entry.recencyScore * W_RECENCY +
44
- (entry.contentMatch ? W_CONTENT : 0) +
45
- entry.depth * W_DEPTH
46
- );
47
- }
48
-
49
- function isAtPrefix(prefix: string): boolean {
50
- return prefix.startsWith("@") || prefix.startsWith('@"');
51
- }
52
-
53
- function extractRawQuery(prefix: string): string {
54
- let raw = prefix;
55
- if (raw.startsWith('@"')) raw = raw.slice(2);
56
- else if (raw.startsWith("@")) raw = raw.slice(1);
57
- if (raw.endsWith('"')) raw = raw.slice(0, -1);
58
- return raw;
59
- }
60
-
61
- function buildValue(path: string, isDir: boolean, quoted: boolean): string {
62
- const displayPath = isDir ? `${path}/` : path;
63
- const needsQuotes = quoted || path.includes(" ");
64
- if (!needsQuotes) return `@${displayPath}`;
65
- return `@"${displayPath}"`;
66
- }
67
-
68
- export function createSearchProvider(
69
- inner: AutocompleteProvider,
70
- cwd: string,
71
- getRecency: () => RecencyMap,
72
- ): AutocompleteProvider {
73
- return {
74
- triggerCharacters: inner.triggerCharacters,
75
-
76
- async getSuggestions(
77
- lines,
78
- cursorLine,
79
- cursorCol,
80
- options,
81
- ): Promise<AutocompleteSuggestions | null> {
82
- // Let inner figure out if this is an @ prefix
83
- const result = await inner.getSuggestions(lines, cursorLine, cursorCol, options);
84
-
85
- // Not an @ prefix, or inner returned nothing — pass through
86
- if (!result || !isAtPrefix(result.prefix)) return result;
87
-
88
- const query = extractRawQuery(result.prefix);
89
- const isQuoted = result.prefix.includes('"');
90
- const { signal } = options;
91
-
92
- if (signal.aborted) return result;
93
-
94
- // Run rg in parallel: file listing + content search
95
- const [allFiles, contentFiles] = await Promise.all([
96
- rgFiles(cwd, signal),
97
- query.length >= CONTENT_SEARCH_MIN ? rgContent(query, cwd, signal) : Promise.resolve([]),
98
- ]);
99
-
100
- if (signal.aborted) return result;
101
-
102
- const contentSet = new Set(contentFiles);
103
- const recency = getRecency();
104
-
105
- // Score every file
106
- const scored: ScoredEntry[] = [];
107
- for (const path of allFiles) {
108
- const fnScore = scoreFilename(path, query);
109
- const isContent = contentSet.has(path);
110
- // Skip if neither filename nor content match
111
- if (fnScore === 0 && !isContent) continue;
112
-
113
- scored.push({
114
- path,
115
- filenameScore: fnScore,
116
- recencyScore: recency.get(path) ?? 0,
117
- contentMatch: isContent,
118
- depth: depthOf(path),
119
- });
120
- }
121
-
122
- // Add content-only matches not in allFiles
123
- for (const path of contentFiles) {
124
- if (scored.some((e) => e.path === path)) continue;
125
- scored.push({
126
- path,
127
- filenameScore: 0,
128
- recencyScore: recency.get(path) ?? 0,
129
- contentMatch: true,
130
- depth: depthOf(path),
131
- });
132
- }
133
-
134
- // Sort by combined score descending
135
- scored.sort((a, b) => combinedScore(b) - combinedScore(a));
136
-
137
- const items: AutocompleteItem[] = scored.slice(0, MAX_SUGGESTIONS).map((entry) => {
138
- const isDir = entry.path.endsWith("/");
139
- const cleanPath = isDir ? entry.path.slice(0, -1) : entry.path;
140
- const label = basename(cleanPath) + (isDir ? "/" : "");
141
- const desc =
142
- entry.contentMatch && entry.filenameScore === 0
143
- ? `${entry.path} (content match)`
144
- : entry.path;
145
- return {
146
- value: buildValue(cleanPath, isDir, isQuoted),
147
- label,
148
- description: desc,
149
- };
150
- });
151
-
152
- if (items.length === 0) return result; // fall back to built-in
153
-
154
- return { items, prefix: result.prefix };
155
- },
156
-
157
- applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
158
- return inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
159
- },
160
-
161
- shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
162
- return inner.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
163
- },
164
- };
165
- }