main
  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";