@k3_2o/pi-move 0.1.0

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.
@@ -0,0 +1,8 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": false,
4
+ "trailingComma": "all",
5
+ "printWidth": 100,
6
+ "tabWidth": 2,
7
+ "arrowParens": "always"
8
+ }
package/Makefile ADDED
@@ -0,0 +1,53 @@
1
+ .PHONY: fmt fmt-check lint typecheck check security smoke install clean
2
+
3
+ # --- Formatting ---
4
+
5
+ fmt:
6
+ @echo "=== Formatting with Prettier ==="
7
+ npx prettier --write '*.ts'
8
+
9
+ fmt-check:
10
+ @echo "=== Checking format with Prettier ==="
11
+ npx prettier --check '*.ts'
12
+
13
+ # --- Linting ---
14
+
15
+ lint:
16
+ @echo "=== Linting with ESLint ==="
17
+ npx eslint '*.ts'
18
+
19
+ # --- Type Checking ---
20
+
21
+ typecheck:
22
+ @echo "=== Type checking with tsc ==="
23
+ npx tsc --noEmit
24
+
25
+ # --- Combined Check ---
26
+
27
+ check: fmt-check lint typecheck
28
+ @echo "=== All checks passed ==="
29
+
30
+ # --- Security Audit ---
31
+
32
+ security:
33
+ @echo "=== Security audit ==="
34
+ npm audit --audit-level=high
35
+
36
+ # --- Smoke Test ---
37
+
38
+ smoke:
39
+ @echo "=== Smoke test ==="
40
+ bun -e "import('./src/index.ts').then(() => console.log('SMOKE OK')).catch(e => { console.error(e); process.exit(1); })"
41
+
42
+ # --- Install ---
43
+
44
+ install:
45
+ @echo "=== Installing dependencies ==="
46
+ npm install
47
+
48
+ # --- Clean ---
49
+
50
+ clean:
51
+ @echo "=== Cleaning ==="
52
+ rm -rf node_modules/ dist/
53
+ rm -f package-lock.json
package/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # pi-move
2
+
3
+ A Pi extension that adds `/move` — switch to a fresh Pi session in any directory
4
+ from inside Pi. No quitting.
5
+
6
+ ## Why
7
+
8
+ Pi works in one directory at a time. To switch projects you normally have to
9
+ quit, `cd`, and restart. `/move` does that in one step.
10
+
11
+ ## How
12
+
13
+ Type `/move`, an overlay pops up with a path input. Start typing, tab to autocomplete directories
14
+ as you go. Press Enter and Pi creates a new empty session in that
15
+ directory and switches to it.
16
+
17
+ If the directory doesn't exist, Pi asks if you want to create it.
18
+ no need to exit to make a new directory for your project
19
+
20
+ ## Requirements
21
+
22
+ - Pi 0.79+
23
+ - `fd` (pi usually demands you have this on start-up, as a pi user you probably already have it)
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pi install @k3_2o/pi-move
29
+ # or
30
+ pi install git:github.com/k3-2o/pi-move
31
+ ```
32
+
33
+ Or clone manually:
34
+
35
+ ```bash
36
+ git clone https://github.com/k3-2o/pi-move ~/.pi/agent/extensions/pi-move
37
+ ```
38
+
39
+ ## License
40
+
41
+ MIT
@@ -0,0 +1,30 @@
1
+ // @ts-check
2
+ import tseslint from "typescript-eslint";
3
+ import eslint from "@eslint/js";
4
+
5
+ export default tseslint.config(
6
+ eslint.configs.recommended,
7
+ ...tseslint.configs.recommendedTypeChecked,
8
+ {
9
+ languageOptions: {
10
+ parserOptions: {
11
+ projectService: true,
12
+ tsconfigRootDir: import.meta.dirname,
13
+ },
14
+ },
15
+ },
16
+ {
17
+ rules: {
18
+ "@typescript-eslint/no-unused-vars": [
19
+ "error",
20
+ { argsIgnorePattern: "^_" },
21
+ ],
22
+ "@typescript-eslint/no-non-null-assertion": "warn",
23
+ "@typescript-eslint/no-confusing-void-expression": "off",
24
+ "no-console": ["warn", { allow: ["warn", "error"] }],
25
+ },
26
+ },
27
+ {
28
+ ignores: ["dist/", "node_modules/", "tests/", "scripts/"],
29
+ },
30
+ );
package/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ // --- pi-move: Directory Switcher for Pi ---
2
+
3
+ import * as fs from "node:fs";
4
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
+ import { handleMoveCommand } from "./move.js";
6
+
7
+ export default function piMoveExtension(pi: ExtensionAPI): void {
8
+ // --- Lifecycle: session_shutdown ---
9
+ // When leaving an empty session (user moved here via /move but sent
10
+ // no messages), delete the file so it doesn't clutter /resume.
11
+ pi.on("session_shutdown", (_event, ctx) => {
12
+ const entries = ctx.sessionManager.getEntries();
13
+ // Check for actual user/assistant messages, not just metadata entries
14
+ // like model_change and thinking_level_change that pi auto-appends.
15
+ const hasRealMessages = entries.some(
16
+ (e) => e.type === "message" && (e.message.role === "user" || e.message.role === "assistant"),
17
+ );
18
+ if (hasRealMessages) return; // Has real messages — keep it
19
+
20
+ const sessionFile = ctx.sessionManager.getSessionFile();
21
+ if (!sessionFile) return;
22
+
23
+ try {
24
+ fs.unlinkSync(sessionFile);
25
+ } catch {
26
+ // File already gone — skip
27
+ }
28
+ });
29
+
30
+ // --- Command: /move ---
31
+
32
+ pi.registerCommand("move", {
33
+ description:
34
+ "Move to a different directory — starts a fresh Pi session in the target directory",
35
+ getArgumentCompletions: (_argumentPrefix: string) => {
36
+ // We don't use inline arg completion — the overlay handles it
37
+ return null;
38
+ },
39
+ handler: async (args, ctx) => {
40
+ await handleMoveCommand(args, ctx);
41
+ },
42
+ });
43
+ }
package/move.ts ADDED
@@ -0,0 +1,144 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
5
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
6
+ import { MoveOverlay, type MoveOverlayResult } from "./overlay.js";
7
+ import { resolveDirectory } from "./utils.js";
8
+
9
+ /**
10
+ * Handle the /move command.
11
+ */
12
+ export async function handleMoveCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
13
+ const trimmedArg = args.trim();
14
+
15
+ if (trimmedArg.length > 0) {
16
+ const target = await resolveOrCreateDirectory(trimmedArg, ctx);
17
+ if (target !== null) {
18
+ await switchToNewSession(target, ctx);
19
+ }
20
+ return;
21
+ }
22
+
23
+ if (!ctx.hasUI || ctx.mode !== "tui") {
24
+ ctx.ui.notify("/move requires interactive TUI mode", "error");
25
+ return;
26
+ }
27
+
28
+ const result = await ctx.ui.custom<MoveOverlayResult | undefined>(
29
+ (_tui, theme, _keybindings, done) => new MoveOverlay(theme, ctx.cwd, done),
30
+ { overlay: true },
31
+ );
32
+
33
+ if (result === undefined) return;
34
+
35
+ const target = await resolveOrCreateDirectory(result.directory, ctx);
36
+ if (target !== null) {
37
+ await switchToNewSession(target, ctx);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Resolve a directory path. If it doesn't exist, prompt the user to create it.
43
+ * Returns the resolved path, or null if the user cancels or the path is invalid.
44
+ */
45
+ async function resolveOrCreateDirectory(
46
+ input: string,
47
+ ctx: ExtensionCommandContext,
48
+ ): Promise<string | null> {
49
+ // Try normal resolution first
50
+ const resolved = resolveDirectory(input, ctx.cwd);
51
+ if (resolved !== null) return resolved;
52
+
53
+ // Directory doesn't exist. Check if we can create it.
54
+ // Resolve the path to get the intended full path.
55
+ let targetPath: string;
56
+ if (input.startsWith("~/")) {
57
+ targetPath = path.join(os.homedir(), input.slice(2));
58
+ } else if (path.isAbsolute(input)) {
59
+ targetPath = path.normalize(input);
60
+ } else {
61
+ targetPath = path.resolve(ctx.cwd, input);
62
+ }
63
+
64
+ // Check if parent directory exists
65
+ const parentDir = path.dirname(targetPath);
66
+ if (!fs.existsSync(parentDir)) {
67
+ ctx.ui.notify(
68
+ `Cannot create "${path.basename(targetPath)}": parent directory does not exist`,
69
+ "error",
70
+ );
71
+ return null;
72
+ }
73
+
74
+ // Prompt user to create it
75
+ const basename = path.basename(targetPath);
76
+ const confirmed = await ctx.ui.confirm(
77
+ "Create directory?",
78
+ `"${basename}" does not exist. Create it?`,
79
+ );
80
+
81
+ if (!confirmed) return null;
82
+
83
+ try {
84
+ fs.mkdirSync(targetPath, { recursive: true });
85
+ return targetPath;
86
+ } catch (err) {
87
+ const message = err instanceof Error ? err.message : String(err);
88
+ ctx.ui.notify(`Failed to create directory: ${message}`, "error");
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Switch to a fresh Pi session in the target directory.
95
+ *
96
+ * Strategy: Create a new session file in the CURRENT session directory
97
+ * (so /resume still scans the same folder) but with the target directory
98
+ * as the session's CWD. Then switch to that session.
99
+ *
100
+ * IMPORTANT: SessionManager.create() does NOT write the file to disk.
101
+ * We must write the session header ourselves before switchSession reads it.
102
+ */
103
+ async function switchToNewSession(targetDir: string, ctx: ExtensionCommandContext): Promise<void> {
104
+ ctx.ui.notify(`Moving to ${targetDir}...`, "info");
105
+
106
+ try {
107
+ // Create the session manager — this generates a file path but does NOT
108
+ // write the file. We need the file on disk so switchSession can open it.
109
+ const currentSessionDir = ctx.sessionManager.getSessionDir();
110
+ const newSession = SessionManager.create(targetDir, currentSessionDir);
111
+ const sessionFile = newSession.getSessionFile();
112
+
113
+ if (!sessionFile) {
114
+ ctx.ui.notify("Failed to create new session", "error");
115
+ return;
116
+ }
117
+
118
+ // Write the session header to disk. Without this, SessionManager.open()
119
+ // in switchSession cannot read the CWD from the header and falls back
120
+ // to process.cwd() — which is the OLD directory.
121
+ const sessionId = newSession.getSessionId();
122
+ const header = {
123
+ type: "session",
124
+ version: 3,
125
+ id: sessionId,
126
+ timestamp: new Date().toISOString(),
127
+ cwd: targetDir,
128
+ };
129
+
130
+ // Ensure the session directory exists
131
+ if (!fs.existsSync(currentSessionDir)) {
132
+ fs.mkdirSync(currentSessionDir, { recursive: true });
133
+ }
134
+
135
+ fs.writeFileSync(sessionFile, JSON.stringify(header) + "\n", "utf-8");
136
+
137
+ // Switch to the new session. Pi opens the file, reads cwd: targetDir
138
+ // from the header, and creates a fresh runtime with the target CWD.
139
+ await ctx.switchSession(sessionFile);
140
+ } catch (err) {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ ctx.ui.notify(`Failed to move: ${message}`, "error");
143
+ }
144
+ }
package/overlay.ts ADDED
@@ -0,0 +1,216 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { findDirectories } from "./utils.js";
4
+
5
+ /**
6
+ * Result emitted by the move overlay when the user confirms a selection.
7
+ */
8
+ export interface MoveOverlayResult {
9
+ /** The selected directory path (resolved, absolute). */
10
+ directory: string;
11
+ }
12
+
13
+ /**
14
+ * Overlay component for the /move command.
15
+ *
16
+ * Renders a floating modal with:
17
+ * - A text input field for typing a directory path
18
+ * - A list of matching directories below (updated on each keystroke)
19
+ * - Keyboard navigation: type to filter, ↑↓ to select, Enter to confirm, Esc to cancel
20
+ */
21
+ export class MoveOverlay implements Focusable {
22
+ readonly width = 68;
23
+ readonly maxResults = 15;
24
+
25
+ /** Focusable interface — set by TUI when focus changes */
26
+ focused = false;
27
+
28
+ private input = "";
29
+ private cursor = 0;
30
+ private selectedIndex = 0;
31
+ private results: Array<{ value: string; label: string; description?: string }> = [];
32
+ private theme: Theme;
33
+ private done: (result: MoveOverlayResult | undefined) => void;
34
+ private cwd: string;
35
+
36
+ constructor(theme: Theme, cwd: string, done: (result: MoveOverlayResult | undefined) => void) {
37
+ this.theme = theme;
38
+ this.cwd = cwd;
39
+ this.done = done;
40
+ this.updateResults();
41
+ }
42
+
43
+ handleInput(data: string): void {
44
+ if (matchesKey(data, "escape")) {
45
+ this.done(undefined);
46
+ return;
47
+ }
48
+
49
+ if (matchesKey(data, "return")) {
50
+ this.confirmSelection();
51
+ return;
52
+ }
53
+
54
+ if (matchesKey(data, "up")) {
55
+ if (this.results.length > 0) {
56
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
57
+ }
58
+ return;
59
+ }
60
+
61
+ if (matchesKey(data, "down")) {
62
+ if (this.results.length > 0) {
63
+ this.selectedIndex = Math.min(this.results.length - 1, this.selectedIndex + 1);
64
+ }
65
+ return;
66
+ }
67
+
68
+ if (matchesKey(data, "tab")) {
69
+ // Tab auto-completes the selected item into the input
70
+ const selected = this.results[this.selectedIndex];
71
+ if (selected) {
72
+ this.input = selected.value;
73
+ this.cursor = this.input.length;
74
+ this.updateResults();
75
+ }
76
+ return;
77
+ }
78
+
79
+ if (matchesKey(data, "backspace")) {
80
+ if (this.cursor > 0) {
81
+ this.input = this.input.slice(0, this.cursor - 1) + this.input.slice(this.cursor);
82
+ this.cursor--;
83
+ this.selectedIndex = 0;
84
+ this.updateResults();
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (matchesKey(data, "left")) {
90
+ this.cursor = Math.max(0, this.cursor - 1);
91
+ return;
92
+ }
93
+
94
+ if (matchesKey(data, "right")) {
95
+ this.cursor = Math.min(this.input.length, this.cursor + 1);
96
+ return;
97
+ }
98
+
99
+ // Regular character input
100
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
101
+ this.input = this.input.slice(0, this.cursor) + data + this.input.slice(this.cursor);
102
+ this.cursor++;
103
+ this.selectedIndex = 0;
104
+ this.updateResults();
105
+ }
106
+ }
107
+
108
+ render(_width: number): string[] {
109
+ const w = this.width;
110
+ const th = this.theme;
111
+ const innerW = w - 2;
112
+ const lines: string[] = [];
113
+
114
+ const pad = (s: string, len: number) => {
115
+ const vis = visibleWidth(s);
116
+ return s + " ".repeat(Math.max(0, len - vis));
117
+ };
118
+
119
+ const row = (content: string) => {
120
+ const vis = visibleWidth(content);
121
+ return (
122
+ th.fg("border", "│") +
123
+ (vis > innerW ? content.slice(0, innerW) : pad(content, innerW)) +
124
+ th.fg("border", "│")
125
+ );
126
+ };
127
+
128
+ // ── Top border ──
129
+ lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
130
+
131
+ // ── Title ──
132
+ lines.push(row(` ${th.fg("accent", "📂 Move to directory")}`));
133
+ lines.push(row(""));
134
+
135
+ // ── Input field ──
136
+ const inputPrompt = th.fg("text", " Path: ");
137
+ let inputDisplay = this.input;
138
+ if (this.input.length > 0) {
139
+ const before = inputDisplay.slice(0, this.cursor);
140
+ const cursorChar = this.cursor < inputDisplay.length ? inputDisplay[this.cursor] : " ";
141
+ const after = inputDisplay.slice(this.cursor + 1);
142
+ const marker = this.focused ? CURSOR_MARKER : "";
143
+ inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
144
+ } else {
145
+ const placeholder = th.fg("dim", "Type a directory path...");
146
+ const marker = this.focused ? CURSOR_MARKER : "";
147
+ inputDisplay = `${placeholder}${marker}\x1b[7m \x1b[27m`;
148
+ }
149
+ lines.push(row(`${inputPrompt}${inputDisplay}`));
150
+ lines.push(row(""));
151
+
152
+ // ── Results list ──
153
+ if (this.results.length === 0 && this.input.length > 0) {
154
+ lines.push(row(` ${th.fg("dim", "No matching directories")}`));
155
+ } else {
156
+ const visibleResults = this.results.slice(0, this.maxResults);
157
+
158
+ for (let i = 0; i < visibleResults.length; i++) {
159
+ const item = visibleResults[i];
160
+ if (!item) continue;
161
+ const isSelected = i === this.selectedIndex;
162
+
163
+ const prefix = isSelected ? th.fg("accent", " ▶") : " ";
164
+ const label = isSelected ? th.fg("accent", item.label) : th.fg("text", item.label);
165
+
166
+ const desc = item.description ? th.fg("dim", ` ${item.description}`) : "";
167
+
168
+ lines.push(row(`${prefix} ${label}${desc}`));
169
+ }
170
+ }
171
+
172
+ lines.push(row(""));
173
+
174
+ // ── Footer ──
175
+ lines.push(row(` ${th.fg("dim", "Type to filter · ↑↓·Tab·Enter·Esc")}`));
176
+
177
+ // ── Bottom border ──
178
+ lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
179
+
180
+ return lines;
181
+ }
182
+
183
+ invalidate(): void {
184
+ // No external state to invalidate
185
+ }
186
+
187
+ dispose(): void {
188
+ // No resources to clean up
189
+ }
190
+
191
+ private updateResults(): void {
192
+ this.results = findDirectories(this.input, this.cwd, this.maxResults + 5);
193
+ if (this.selectedIndex >= this.results.length) {
194
+ this.selectedIndex = Math.max(0, this.results.length - 1);
195
+ }
196
+ }
197
+
198
+ private confirmSelection(): void {
199
+ // If there's a selection, use it
200
+ const selectedItem = this.results[this.selectedIndex];
201
+ if (selectedItem) {
202
+ this.done({ directory: selectedItem.value });
203
+ return;
204
+ }
205
+
206
+ // If input is non-empty, try resolving it directly
207
+ if (this.input.trim().length > 0) {
208
+ // Resolve will be done by the handler
209
+ this.done({ directory: this.input.trim() });
210
+ return;
211
+ }
212
+
213
+ // Nothing to select
214
+ this.done(undefined);
215
+ }
216
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@k3_2o/pi-move",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension — /move command to switch directories from inside Pi",
5
+ "keywords": ["pi-package", "extension", "directory", "navigation"],
6
+ "author": "k3_2o",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "scripts": {
10
+ "fmt": "prettier --write '*.ts'",
11
+ "fmt:check": "prettier --check '*.ts'",
12
+ "lint": "eslint '*.ts'",
13
+ "typecheck": "tsc --noEmit",
14
+ "check": "npm run fmt:check && npm run lint && npm run typecheck",
15
+ "security": "npm audit --audit-level=high"
16
+ },
17
+ "devDependencies": {
18
+ "eslint": "^9.0.0",
19
+ "prettier": "^3.5.3",
20
+ "typescript": "^5.7.0",
21
+ "typescript-eslint": "^8.0.0"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/k3-2o/pi-move.git"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "pi": {
31
+ "extensions": ["./index.ts"]
32
+ }
33
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noUncheckedIndexedAccess": true,
8
+ "noUnusedLocals": true,
9
+ "noUnusedParameters": true,
10
+ "exactOptionalPropertyTypes": false,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true,
13
+ "declaration": false,
14
+ "outDir": "dist",
15
+ "baseUrl": ".",
16
+ "paths": {
17
+ "@earendil-works/pi-coding-agent": [
18
+ "/home/k2/node_modules/@earendil-works/pi-coding-agent/dist/index.d.ts"
19
+ ],
20
+ "@earendil-works/pi-coding-agent/*": [
21
+ "/home/k2/node_modules/@earendil-works/pi-coding-agent/dist/*.d.ts"
22
+ ],
23
+ "@earendil-works/pi-tui": [
24
+ "/home/k2/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui/dist/index.d.ts"
25
+ ],
26
+ "@earendil-works/pi-tui/*": [
27
+ "/home/k2/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui/dist/*.d.ts"
28
+ ]
29
+ }
30
+ },
31
+ "include": ["*.ts", "scripts/**/*.ts"],
32
+ "exclude": ["node_modules", "dist", "tests"]
33
+ }
package/utils.ts ADDED
@@ -0,0 +1,264 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { spawn } from "node:child_process";
5
+
6
+ /**
7
+ * Resolve a user-provided directory path against the given CWD.
8
+ * Handles ~ expansion, relative paths, . and .. segments.
9
+ *
10
+ * Returns null if the resolved path doesn't exist or isn't a directory.
11
+ */
12
+ export function resolveDirectory(input: string, cwd: string): string | null {
13
+ let resolved: string;
14
+
15
+ if (input.startsWith("~/") || input === "~") {
16
+ resolved = path.join(os.homedir(), input.slice(1));
17
+ } else if (path.isAbsolute(input)) {
18
+ resolved = input;
19
+ } else {
20
+ resolved = path.resolve(cwd, input);
21
+ }
22
+
23
+ resolved = path.normalize(resolved);
24
+
25
+ try {
26
+ if (fs.statSync(resolved).isDirectory()) {
27
+ return resolved;
28
+ }
29
+ } catch {
30
+ // Does not exist or permission denied
31
+ }
32
+
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * Get a sorted list of directories matching the given prefix.
38
+ *
39
+ * Uses `fd` for fast, recursive, .gitignore-respecting search when available.
40
+ * Falls back to a basic readdirSync scan if fd isn't installed.
41
+ */
42
+ export function findDirectories(
43
+ prefix: string,
44
+ cwd: string,
45
+ maxResults = 30,
46
+ ): Array<{ value: string; label: string; description?: string }> {
47
+ const resolved = resolveDirectory(prefix, cwd);
48
+ if (resolved !== null && fs.statSync(resolved).isDirectory()) {
49
+ // If prefix is already a valid directory, list its contents
50
+ return listDirectoryContents(resolved, maxResults);
51
+ }
52
+
53
+ // Fuzzy search from cwd (or from the partial path)
54
+ const searchDir = getSearchBase(prefix, cwd);
55
+ const query = getQuery(prefix);
56
+
57
+ return searchDirectories(searchDir, query, maxResults);
58
+ }
59
+
60
+ /**
61
+ * Search for directories using fd (fast) or readdirSync (fallback).
62
+ */
63
+ async function searchDirectoriesFd(
64
+ baseDir: string,
65
+ query: string,
66
+ maxResults: number,
67
+ ): Promise<Array<{ path: string; isDirectory: boolean }>> {
68
+ return new Promise((resolve) => {
69
+ const args = [
70
+ "--base-directory",
71
+ baseDir,
72
+ "--max-results",
73
+ String(maxResults),
74
+ "--type",
75
+ "d",
76
+ "--follow",
77
+ "--hidden",
78
+ "--exclude",
79
+ ".git",
80
+ "--exclude",
81
+ "node_modules",
82
+ ];
83
+
84
+ if (query) {
85
+ args.push(query);
86
+ }
87
+
88
+ const child = spawn("fd", args, {
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ });
91
+
92
+ let stdout = "";
93
+
94
+ child.stdout.setEncoding("utf-8");
95
+ child.stdout.on("data", (chunk: string) => {
96
+ stdout += chunk;
97
+ });
98
+
99
+ child.on("error", () => {
100
+ resolve([]);
101
+ });
102
+
103
+ child.on("close", (code) => {
104
+ if (code !== 0 || !stdout) {
105
+ resolve([]);
106
+ return;
107
+ }
108
+
109
+ const lines = stdout.trim().split("\n").filter(Boolean);
110
+ const results = lines.map((line) => {
111
+ const normalized = line.replace(/\\/g, "/");
112
+ const isDir = normalized.endsWith("/");
113
+ return {
114
+ path: isDir ? normalized.slice(0, -1) : normalized,
115
+ isDirectory: true,
116
+ };
117
+ });
118
+
119
+ resolve(results);
120
+ });
121
+ });
122
+ }
123
+
124
+ /**
125
+ * List directory contents (all subdirectories).
126
+ */
127
+ function listDirectoryContents(
128
+ dirPath: string,
129
+ maxResults: number,
130
+ ): Array<{ value: string; label: string; description?: string }> {
131
+ try {
132
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
133
+ const results: Array<{ value: string; label: string; description?: string }> = [];
134
+
135
+ for (const entry of entries) {
136
+ if (results.length >= maxResults) break;
137
+ if (entry.name.startsWith(".")) continue; // skip hidden
138
+
139
+ let isDir: boolean;
140
+ try {
141
+ isDir = entry.isDirectory();
142
+ if (!isDir && entry.isSymbolicLink()) {
143
+ isDir = fs.statSync(path.join(dirPath, entry.name)).isDirectory();
144
+ }
145
+ } catch {
146
+ continue;
147
+ }
148
+
149
+ if (isDir) {
150
+ const fullPath = path.join(dirPath, entry.name);
151
+ results.push({
152
+ value: fullPath,
153
+ label: entry.name + "/",
154
+ description: fullPath,
155
+ });
156
+ }
157
+ }
158
+
159
+ // Alphabetical sort
160
+ results.sort((a, b) => a.label.localeCompare(b.label));
161
+ return results;
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Search directories using readdirSync recursively (fallback when fd is unavailable).
169
+ */
170
+ function searchDirectories(
171
+ baseDir: string,
172
+ query: string,
173
+ maxResults: number,
174
+ ): Array<{ value: string; label: string; description?: string }> {
175
+ // First, try fd for speed
176
+ searchDirectoriesFd(baseDir, query, maxResults)
177
+ .then((fdResults) => {
178
+ if (fdResults.length > 0) {
179
+ return fdResults.map((r) => ({
180
+ value: path.resolve(baseDir, r.path),
181
+ label: r.path + "/",
182
+ description: path.resolve(baseDir, r.path),
183
+ }));
184
+ }
185
+ return null;
186
+ })
187
+ .catch(() => null);
188
+
189
+ // Synchronous fallback: iterate parent directories
190
+ // This is simpler and immediate
191
+ const results: Array<{ value: string; label: string; description?: string }> = [];
192
+
193
+ try {
194
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
195
+ const lowerQuery = query.toLowerCase();
196
+
197
+ for (const entry of entries) {
198
+ if (results.length >= maxResults) break;
199
+
200
+ let isDir: boolean;
201
+ try {
202
+ isDir = entry.isDirectory();
203
+ if (!isDir && entry.isSymbolicLink()) {
204
+ isDir = fs.statSync(path.join(baseDir, entry.name)).isDirectory();
205
+ }
206
+ } catch {
207
+ continue;
208
+ }
209
+
210
+ if (!isDir) continue;
211
+
212
+ if (query === "" || entry.name.toLowerCase().includes(lowerQuery)) {
213
+ const fullPath = path.join(baseDir, entry.name);
214
+ results.push({
215
+ value: fullPath,
216
+ label: entry.name + "/",
217
+ description: fullPath,
218
+ });
219
+ }
220
+ }
221
+ } catch {
222
+ // Permission denied or non-existent
223
+ }
224
+
225
+ return results;
226
+ }
227
+
228
+ /**
229
+ * Extract the search base directory and the query part from a path prefix.
230
+ * E.g., "~/projects/my" → base: "~/projects", query: "my"
231
+ * "foo/bar" → base: "cwd/foo", query: "bar"
232
+ */
233
+ function getSearchBase(prefix: string, cwd: string): string {
234
+ if (prefix === "" || prefix === "~") {
235
+ return cwd;
236
+ }
237
+
238
+ const normalized = prefix.replace(/\\/g, "/");
239
+ const lastSlash = normalized.lastIndexOf("/");
240
+
241
+ if (lastSlash === -1) {
242
+ return cwd;
243
+ }
244
+
245
+ const basePart = normalized.slice(0, lastSlash + 1);
246
+
247
+ if (basePart.startsWith("~")) {
248
+ return path.join(os.homedir(), basePart.slice(1));
249
+ }
250
+
251
+ if (path.isAbsolute(basePart)) {
252
+ return basePart;
253
+ }
254
+
255
+ return path.resolve(cwd, basePart);
256
+ }
257
+
258
+ function getQuery(prefix: string): string {
259
+ if (prefix === "" || prefix === "~") return "";
260
+ const normalized = prefix.replace(/\\/g, "/");
261
+ const lastSlash = normalized.lastIndexOf("/");
262
+ if (lastSlash === -1) return normalized;
263
+ return normalized.slice(lastSlash + 1);
264
+ }