flake-update-20260505
1/**
2 * Shell Completions Extension for Pi
3 *
4 * Adds native shell completions (fish/zsh/bash) to pi's `!` and `!!` bash mode.
5 * Uses the user's actual shell completion configuration - if they haven't
6 * set up completions, we don't provide them (no magic).
7 *
8 * Usage: Place in ~/.pi/agent/extensions/shell-completions/index.ts
9 *
10 * Shell priority:
11 * 1. User's $SHELL (if fish/zsh/bash) - uses their configured completions
12 * 2. Fish (if available) - always has completions (core feature)
13 * 3. Zsh (if compinit is configured)
14 * 4. Bash (if bash-completion is installed)
15 *
16 * Philosophy: Don't magically provide completions the user hasn't configured.
17 * This means completions respect the user's shell setup, aliases, and customizations.
18 */
19
20import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
21import type { AutocompleteItem, AutocompleteProvider } from "@mariozechner/pi-tui";
22import * as fs from "node:fs";
23import * as path from "node:path";
24
25import type { ShellInfo, ShellType, CompletionResult } from "./types.js";
26import { getFishCompletions } from "./fish.js";
27import { getBashCompletions } from "./bash.js";
28import { getZshCompletions } from "./zsh.js";
29
30// ============================================================================
31// Shell Detection
32// ============================================================================
33
34/**
35 * Detect shell type from path.
36 */
37function detectShellType(shellPath: string): ShellType {
38 const name = path.basename(shellPath);
39 if (name === "fish" || name.startsWith("fish")) return "fish";
40 if (name === "zsh" || name.startsWith("zsh")) return "zsh";
41 return "bash";
42}
43
44/**
45 * Find a shell suitable for running completion scripts.
46 *
47 * Priority:
48 * 1. User's $SHELL if it's fish/zsh/bash (respects user's configured completions)
49 * 2. Fish if available (best completion UX)
50 * 3. Zsh if available
51 * 4. Bash as fallback
52 */
53function findCompletionShell(): ShellInfo {
54 // First, try user's $SHELL - they've configured their completions there
55 const userShell = process.env.SHELL;
56 if (userShell && fs.existsSync(userShell)) {
57 const shellType = detectShellType(userShell);
58 // Only use it if it's a shell we support (fish/zsh/bash)
59 if (shellType === "fish" || shellType === "zsh" || shellType === "bash") {
60 return { path: userShell, type: shellType };
61 }
62 }
63
64 // If user's shell isn't suitable, prefer fish for best completions
65 const fishPaths = [
66 "/opt/homebrew/bin/fish",
67 "/usr/local/bin/fish",
68 "/usr/bin/fish",
69 "/bin/fish",
70 ];
71 for (const fishPath of fishPaths) {
72 if (fs.existsSync(fishPath)) {
73 return { path: fishPath, type: "fish" };
74 }
75 }
76
77 // Then zsh
78 const zshPaths = [
79 "/bin/zsh",
80 "/usr/bin/zsh",
81 "/usr/local/bin/zsh",
82 "/opt/homebrew/bin/zsh",
83 ];
84 for (const zshPath of zshPaths) {
85 if (fs.existsSync(zshPath)) {
86 return { path: zshPath, type: "zsh" };
87 }
88 }
89
90 // Bash fallback
91 const bashPaths = [
92 "/bin/bash",
93 "/usr/bin/bash",
94 "/usr/local/bin/bash",
95 "/opt/homebrew/bin/bash",
96 ];
97 for (const bashPath of bashPaths) {
98 if (fs.existsSync(bashPath)) {
99 return { path: bashPath, type: "bash" };
100 }
101 }
102
103 // Try resolving from PATH (NixOS compat)
104 try {
105 const { execSync } = require("node:child_process");
106 const which = execSync("which bash", { encoding: "utf-8", timeout: 5000 }).trim();
107 if (which) return { path: which, type: "bash" };
108 } catch { /* ignore */ }
109
110 return { path: "/bin/bash", type: "bash" };
111}
112
113// ============================================================================
114// Completion Context Extraction
115// ============================================================================
116
117/**
118 * Extract the command line and completion prefix from editor text.
119 */
120function extractCompletionContext(text: string): {
121 commandLine: string;
122 prefix: string;
123} {
124 // Remove ! or !! prefix
125 let commandLine = text.trimStart();
126 if (commandLine.startsWith("!!")) {
127 commandLine = commandLine.slice(2);
128 } else if (commandLine.startsWith("!")) {
129 commandLine = commandLine.slice(1);
130 }
131
132 const trimmed = commandLine.trimStart();
133
134 // If ends with space, completing a new word
135 if (trimmed.endsWith(" ")) {
136 return { commandLine: trimmed, prefix: "" };
137 }
138
139 // Last word is the prefix
140 const words = trimmed.split(/\s+/);
141 const prefix = words[words.length - 1] || "";
142
143 return { commandLine: trimmed, prefix };
144}
145
146// ============================================================================
147// Shell Completion Dispatcher
148// ============================================================================
149
150/**
151 * Get shell completions for a command line.
152 * Returns null if the user hasn't configured completions for their shell.
153 */
154function getShellCompletions(
155 text: string,
156 cwd: string,
157 shell: ShellInfo
158): CompletionResult | null {
159 const { commandLine } = extractCompletionContext(text);
160
161 if (!commandLine.trim()) {
162 return null;
163 }
164
165 // Each shell provider checks if user has completions configured
166 // and returns null if not
167 switch (shell.type) {
168 case "fish":
169 // Fish always has completions (it's a core feature)
170 return getFishCompletions(commandLine, cwd, shell.path);
171 case "bash":
172 // Bash: only works if bash-completion is available
173 return getBashCompletions(commandLine, cwd, shell.path);
174 case "zsh":
175 // Zsh: only works if user has compinit in their .zshrc
176 return getZshCompletions(commandLine, cwd, shell.path);
177 default:
178 return null;
179 }
180}
181
182// ============================================================================
183// Shell-Aware Autocomplete Provider Wrapper
184// ============================================================================
185
186/**
187 * Wraps an existing autocomplete provider to add shell completion support
188 * when in bash mode (text starts with ! or !!).
189 */
190function wrapWithShellCompletion(
191 baseProvider: AutocompleteProvider,
192 shell: ShellInfo
193): AutocompleteProvider {
194 const isBashMode = (lines: string[]): boolean => {
195 const text = lines.join("\n").trimStart();
196 return text.startsWith("!") || text.startsWith("!!");
197 };
198
199 const getTextUpToCursor = (
200 lines: string[],
201 cursorLine: number,
202 cursorCol: number
203 ): string => {
204 const textLines = lines.slice(0, cursorLine + 1);
205 if (textLines.length > 0) {
206 textLines[textLines.length - 1] = textLines[textLines.length - 1].slice(0, cursorCol);
207 }
208 return textLines.join("\n");
209 };
210
211 return {
212 getSuggestions(
213 lines: string[],
214 cursorLine: number,
215 cursorCol: number,
216 options?: any
217 ): { items: AutocompleteItem[]; prefix: string } | null {
218 if (isBashMode(lines)) {
219 const text = getTextUpToCursor(lines, cursorLine, cursorCol);
220 const result = getShellCompletions(text, process.cwd(), shell);
221 if (result && result.items.length > 0) {
222 return result;
223 }
224 }
225 return baseProvider.getSuggestions(lines, cursorLine, cursorCol, options);
226 },
227
228 applyCompletion(
229 lines: string[],
230 cursorLine: number,
231 cursorCol: number,
232 item: AutocompleteItem,
233 prefix: string
234 ): { lines: string[]; cursorLine: number; cursorCol: number } {
235 if (isBashMode(lines)) {
236 const currentLine = lines[cursorLine] || "";
237 const prefixStart = cursorCol - prefix.length;
238 const beforePrefix = currentLine.slice(0, prefixStart);
239 const afterCursor = currentLine.slice(cursorCol);
240
241 // Don't add space after directories
242 const isDirectory = item.value.endsWith("/");
243 const suffix = isDirectory ? "" : " ";
244
245 const newLine = beforePrefix + item.value + suffix + afterCursor;
246 const newLines = [...lines];
247 newLines[cursorLine] = newLine;
248
249 return {
250 lines: newLines,
251 cursorLine,
252 cursorCol: prefixStart + item.value.length + suffix.length,
253 };
254 }
255
256 return baseProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
257 },
258
259 // Forward optional methods
260 getForceFileSuggestions(
261 lines: string[],
262 cursorLine: number,
263 cursorCol: number
264 ): { items: AutocompleteItem[]; prefix: string } | null {
265 if (isBashMode(lines)) {
266 const text = getTextUpToCursor(lines, cursorLine, cursorCol);
267 return getShellCompletions(text, process.cwd(), shell);
268 }
269 if ("getForceFileSuggestions" in baseProvider) {
270 return (baseProvider as any).getForceFileSuggestions(lines, cursorLine, cursorCol);
271 }
272 return this.getSuggestions(lines, cursorLine, cursorCol);
273 },
274
275 shouldTriggerFileCompletion(
276 lines: string[],
277 cursorLine: number,
278 cursorCol: number
279 ): boolean {
280 if (isBashMode(lines)) {
281 return true;
282 }
283 if ("shouldTriggerFileCompletion" in baseProvider) {
284 return (baseProvider as any).shouldTriggerFileCompletion(lines, cursorLine, cursorCol);
285 }
286 return true;
287 },
288 };
289}
290
291// ============================================================================
292// Custom Editor with Shell Completion
293// ============================================================================
294
295/**
296 * Custom editor that intercepts setAutocompleteProvider to wrap with shell completion.
297 */
298class ShellCompletionEditor extends CustomEditor {
299 private shell: ShellInfo;
300 private wrappedProvider = false;
301
302 constructor(tui: any, theme: any, keybindings: any, shell: ShellInfo) {
303 super(tui, theme, keybindings);
304 this.shell = shell;
305 }
306
307 // Override setAutocompleteProvider to wrap the base provider
308 setAutocompleteProvider(provider: AutocompleteProvider): void {
309 if (!this.wrappedProvider && provider) {
310 // Wrap the provider with shell completion support
311 const wrapped = wrapWithShellCompletion(provider, this.shell);
312 super.setAutocompleteProvider(wrapped);
313 this.wrappedProvider = true;
314 } else {
315 super.setAutocompleteProvider(provider);
316 }
317 }
318}
319
320// ============================================================================
321// Extension Entry Point
322// ============================================================================
323
324export default function (pi: ExtensionAPI) {
325 const shell = findCompletionShell();
326 const shellName = path.basename(shell.path);
327
328 pi.on("session_start", (_event, ctx) => {
329 ctx.ui.setEditorComponent((tui, theme, keybindings) => {
330 return new ShellCompletionEditor(tui, theme, keybindings, shell);
331 });
332
333 ctx.ui.notify(`Shell completions enabled (${shellName})`, "info");
334 });
335}
336
337// Re-export types for potential external use
338export type { ShellInfo, ShellType, CompletionResult } from "./types.js";