pi-shell-completions 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.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # pi-shell-completions
2
+
3
+ Adds native shell completions to pi's `!` and `!!` bash mode commands.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pi install npm:pi-shell-completions
9
+ ```
10
+
11
+ Or for local development, place in `~/.pi/agent/extensions/shell-completions/`
12
+
13
+ ## How it works
14
+
15
+ When you type `!git checkout ` in pi's prompt, this extension queries your shell's completion system and shows suggestions.
16
+
17
+ ### Shell support
18
+
19
+ | Shell | How it works | Quality |
20
+ |-------|--------------|---------|
21
+ | **Fish** | Native `complete -C` command | ⭐⭐⭐ Excellent - all completions work |
22
+ | **Bash** | Sources bash-completion scripts | ⭐⭐ Good - if bash-completion is installed |
23
+ | **Zsh** | Fallback script for common tools | ⭐ Basic - see limitations |
24
+
25
+ ### Fish (recommended)
26
+
27
+ Fish's completion system is designed to be queried programmatically via `complete -C "command "`. This means:
28
+
29
+ - All your fish completions work automatically
30
+ - Git branches, docker containers, ssh hosts, npm scripts — everything
31
+ - Descriptions are included
32
+ - Fast (10-30ms)
33
+
34
+ Even if fish isn't your primary shell, installing it gives you great completions in pi.
35
+
36
+ ### Bash
37
+
38
+ Bash-completion can be queried by setting up `COMP_*` environment variables and calling completion functions. This extension:
39
+
40
+ - Sources completion scripts from standard locations (`/opt/homebrew/etc/bash_completion.d/`, `/usr/share/bash-completion/completions/`, etc.)
41
+ - Calls the registered completion function for each command
42
+ - Works if you have bash-completion installed
43
+
44
+ ### Zsh (limited)
45
+
46
+ Zsh's completion system is tightly coupled to its line editor (ZLE) and cannot be easily queried programmatically. The `zpty` pseudo-terminal approach is complex and unreliable.
47
+
48
+ **Current limitations:**
49
+ - Does NOT use your full zsh completion config
50
+ - Only handles common tools: git, ssh, make, npm/yarn/pnpm, docker
51
+ - Falls back to file completion for other commands
52
+
53
+ **Recommendation:** If you use zsh and want good completions in pi, install fish as a secondary shell. The extension will automatically prefer fish when available.
54
+
55
+ ## Shell priority
56
+
57
+ 1. Your `$SHELL` (if fish/zsh/bash)
58
+ 2. Fish (if available) — even if not your primary shell
59
+ 3. Zsh
60
+ 4. Bash
61
+
62
+ ## Requirements
63
+
64
+ - One of: fish, zsh, or bash
65
+ - For bash: bash-completion package installed
66
+ - For best experience: fish
67
+
68
+ ## License
69
+
70
+ MIT
package/bash.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Bash shell completion provider.
3
+ *
4
+ * Uses bash's native completion system by running a script that sets up
5
+ * COMP_* environment variables and calls the registered completion function.
6
+ *
7
+ * Philosophy: Only provide completions if the user has bash-completion available.
8
+ */
9
+
10
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
11
+ import { spawnSync } from "node:child_process";
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { CompletionResult, ShellCompletionProvider } from "./types.js";
16
+
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ const COMPLETE_SCRIPT = path.join(__dirname, "scripts", "bash-complete.bash");
19
+
20
+ /**
21
+ * Check if bash-completion is available.
22
+ * We check for the presence of completion scripts in standard locations.
23
+ */
24
+ let completionCheckCache: boolean | null = null;
25
+
26
+ function userHasBashCompletions(bashPath: string): boolean {
27
+ if (completionCheckCache !== null) {
28
+ return completionCheckCache;
29
+ }
30
+
31
+ try {
32
+ // Check if bash-completion framework or git completion exists
33
+ const result = spawnSync(
34
+ bashPath,
35
+ [
36
+ "-c",
37
+ `
38
+ for f in /usr/share/bash-completion/bash_completion /etc/bash_completion /opt/homebrew/etc/bash_completion /opt/homebrew/share/bash-completion/bash_completion; do
39
+ [[ -f "$f" ]] && { echo yes; exit 0; }
40
+ done
41
+ # Also check for individual completion files
42
+ for f in /opt/homebrew/etc/bash_completion.d/git* /usr/share/bash-completion/completions/git; do
43
+ [[ -f "$f" ]] && { echo yes; exit 0; }
44
+ done
45
+ echo no
46
+ `,
47
+ ],
48
+ {
49
+ encoding: "utf-8",
50
+ timeout: 500,
51
+ }
52
+ );
53
+
54
+ completionCheckCache = result.stdout?.trim() === "yes";
55
+ return completionCheckCache;
56
+ } catch {
57
+ completionCheckCache = false;
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Get completions using bash's native completion system.
64
+ */
65
+ export function getBashCompletions(
66
+ commandLine: string,
67
+ cwd: string,
68
+ bashPath: string
69
+ ): CompletionResult | null {
70
+ // Check if bash completions are available
71
+ if (!userHasBashCompletions(bashPath)) {
72
+ return null;
73
+ }
74
+
75
+ // Check if completion script exists
76
+ if (!fs.existsSync(COMPLETE_SCRIPT)) {
77
+ return null;
78
+ }
79
+
80
+ // Extract prefix
81
+ const trimmed = commandLine.trimStart();
82
+ let prefix = "";
83
+ if (!trimmed.endsWith(" ")) {
84
+ const words = trimmed.split(/\s+/);
85
+ prefix = words[words.length - 1] || "";
86
+ }
87
+
88
+ try {
89
+ const result = spawnSync(bashPath, [COMPLETE_SCRIPT, commandLine, cwd], {
90
+ encoding: "utf-8",
91
+ timeout: 500,
92
+ maxBuffer: 1024 * 100,
93
+ cwd,
94
+ });
95
+
96
+ if (result.error || !result.stdout) {
97
+ return null;
98
+ }
99
+
100
+ const items: AutocompleteItem[] = result.stdout
101
+ .trim()
102
+ .split("\n")
103
+ .filter(Boolean)
104
+ .map((line) => {
105
+ // Remove trailing space that bash completion adds
106
+ const value = line.trimEnd();
107
+ return { value, label: value };
108
+ });
109
+
110
+ if (items.length === 0) {
111
+ return null;
112
+ }
113
+
114
+ return {
115
+ items: items.slice(0, 30),
116
+ prefix,
117
+ };
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ export const bashCompletionProvider: ShellCompletionProvider = {
124
+ getCompletions: getBashCompletions,
125
+ };
package/fish.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Fish shell completion provider.
3
+ *
4
+ * Uses fish's native `complete -C` command which provides excellent completions
5
+ * for most tools automatically.
6
+ *
7
+ * Fish always has completions available (it's a core feature), so this never
8
+ * returns null for "user hasn't configured completions".
9
+ */
10
+
11
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
12
+ import { spawnSync } from "node:child_process";
13
+ import type { CompletionResult, ShellCompletionProvider } from "./types.js";
14
+
15
+ /**
16
+ * Get completions using fish's native `complete -C` command.
17
+ * Fish completions are excellent and cover most tools automatically.
18
+ */
19
+ export function getFishCompletions(
20
+ commandLine: string,
21
+ cwd: string,
22
+ fishPath: string
23
+ ): CompletionResult | null {
24
+ // Extract prefix
25
+ const trimmed = commandLine.trimStart();
26
+ let prefix = "";
27
+ if (!trimmed.endsWith(" ")) {
28
+ const words = trimmed.split(/\s+/);
29
+ prefix = words[words.length - 1] || "";
30
+ }
31
+
32
+ try {
33
+ // Fish's complete -C gives us completions directly
34
+ const result = spawnSync(
35
+ fishPath,
36
+ ["-c", `complete -C ${JSON.stringify(commandLine)}`],
37
+ {
38
+ encoding: "utf-8",
39
+ timeout: 500,
40
+ maxBuffer: 1024 * 100,
41
+ cwd,
42
+ }
43
+ );
44
+
45
+ if (result.error || !result.stdout) {
46
+ return null;
47
+ }
48
+
49
+ // Fish output format: "completion\tdescription" (tab-separated)
50
+ const lines = result.stdout.trim().split("\n").filter(Boolean);
51
+ const items: AutocompleteItem[] = [];
52
+
53
+ for (const line of lines) {
54
+ const tabIndex = line.indexOf("\t");
55
+ if (tabIndex >= 0) {
56
+ const value = line.slice(0, tabIndex).trim();
57
+ const description = line.slice(tabIndex + 1).trim();
58
+ if (value) {
59
+ items.push({ value, label: value, description });
60
+ }
61
+ } else {
62
+ const value = line.trim();
63
+ if (value) {
64
+ items.push({ value, label: value });
65
+ }
66
+ }
67
+ }
68
+
69
+ if (items.length === 0) {
70
+ return null;
71
+ }
72
+
73
+ return {
74
+ items: items.slice(0, 30),
75
+ prefix,
76
+ };
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export const fishCompletionProvider: ShellCompletionProvider = {
83
+ getCompletions: getFishCompletions,
84
+ };
package/index.ts ADDED
@@ -0,0 +1,330 @@
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
+
20
+ import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
21
+ import type { AutocompleteItem, AutocompleteProvider } from "@mariozechner/pi-tui";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ import type { ShellInfo, ShellType, CompletionResult } from "./types.js";
26
+ import { getFishCompletions } from "./fish.js";
27
+ import { getBashCompletions } from "./bash.js";
28
+ import { getZshCompletions } from "./zsh.js";
29
+
30
+ // ============================================================================
31
+ // Shell Detection
32
+ // ============================================================================
33
+
34
+ /**
35
+ * Detect shell type from path.
36
+ */
37
+ function 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
+ */
53
+ function 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
+ return { path: "/bin/bash", type: "bash" };
104
+ }
105
+
106
+ // ============================================================================
107
+ // Completion Context Extraction
108
+ // ============================================================================
109
+
110
+ /**
111
+ * Extract the command line and completion prefix from editor text.
112
+ */
113
+ function extractCompletionContext(text: string): {
114
+ commandLine: string;
115
+ prefix: string;
116
+ } {
117
+ // Remove ! or !! prefix
118
+ let commandLine = text.trimStart();
119
+ if (commandLine.startsWith("!!")) {
120
+ commandLine = commandLine.slice(2);
121
+ } else if (commandLine.startsWith("!")) {
122
+ commandLine = commandLine.slice(1);
123
+ }
124
+
125
+ const trimmed = commandLine.trimStart();
126
+
127
+ // If ends with space, completing a new word
128
+ if (trimmed.endsWith(" ")) {
129
+ return { commandLine: trimmed, prefix: "" };
130
+ }
131
+
132
+ // Last word is the prefix
133
+ const words = trimmed.split(/\s+/);
134
+ const prefix = words[words.length - 1] || "";
135
+
136
+ return { commandLine: trimmed, prefix };
137
+ }
138
+
139
+ // ============================================================================
140
+ // Shell Completion Dispatcher
141
+ // ============================================================================
142
+
143
+ /**
144
+ * Get shell completions for a command line.
145
+ * Returns null if the user hasn't configured completions for their shell.
146
+ */
147
+ function getShellCompletions(
148
+ text: string,
149
+ cwd: string,
150
+ shell: ShellInfo
151
+ ): CompletionResult | null {
152
+ const { commandLine } = extractCompletionContext(text);
153
+
154
+ if (!commandLine.trim()) {
155
+ return null;
156
+ }
157
+
158
+ // Each shell provider checks if user has completions configured
159
+ // and returns null if not
160
+ switch (shell.type) {
161
+ case "fish":
162
+ // Fish always has completions (it's a core feature)
163
+ return getFishCompletions(commandLine, cwd, shell.path);
164
+ case "bash":
165
+ // Bash: only works if bash-completion is available
166
+ return getBashCompletions(commandLine, cwd, shell.path);
167
+ case "zsh":
168
+ // Zsh: only works if user has compinit in their .zshrc
169
+ return getZshCompletions(commandLine, cwd, shell.path);
170
+ default:
171
+ return null;
172
+ }
173
+ }
174
+
175
+ // ============================================================================
176
+ // Shell-Aware Autocomplete Provider Wrapper
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Wraps an existing autocomplete provider to add shell completion support
181
+ * when in bash mode (text starts with ! or !!).
182
+ */
183
+ function wrapWithShellCompletion(
184
+ baseProvider: AutocompleteProvider,
185
+ shell: ShellInfo
186
+ ): AutocompleteProvider {
187
+ const isBashMode = (lines: string[]): boolean => {
188
+ const text = lines.join("\n").trimStart();
189
+ return text.startsWith("!") || text.startsWith("!!");
190
+ };
191
+
192
+ const getTextUpToCursor = (
193
+ lines: string[],
194
+ cursorLine: number,
195
+ cursorCol: number
196
+ ): string => {
197
+ const textLines = lines.slice(0, cursorLine + 1);
198
+ if (textLines.length > 0) {
199
+ textLines[textLines.length - 1] = textLines[textLines.length - 1].slice(0, cursorCol);
200
+ }
201
+ return textLines.join("\n");
202
+ };
203
+
204
+ return {
205
+ getSuggestions(
206
+ lines: string[],
207
+ cursorLine: number,
208
+ cursorCol: number
209
+ ): { items: AutocompleteItem[]; prefix: string } | null {
210
+ if (isBashMode(lines)) {
211
+ const text = getTextUpToCursor(lines, cursorLine, cursorCol);
212
+ const result = getShellCompletions(text, process.cwd(), shell);
213
+ if (result && result.items.length > 0) {
214
+ return result;
215
+ }
216
+ }
217
+ return baseProvider.getSuggestions(lines, cursorLine, cursorCol);
218
+ },
219
+
220
+ applyCompletion(
221
+ lines: string[],
222
+ cursorLine: number,
223
+ cursorCol: number,
224
+ item: AutocompleteItem,
225
+ prefix: string
226
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
227
+ if (isBashMode(lines)) {
228
+ const currentLine = lines[cursorLine] || "";
229
+ const prefixStart = cursorCol - prefix.length;
230
+ const beforePrefix = currentLine.slice(0, prefixStart);
231
+ const afterCursor = currentLine.slice(cursorCol);
232
+
233
+ // Don't add space after directories
234
+ const isDirectory = item.value.endsWith("/");
235
+ const suffix = isDirectory ? "" : " ";
236
+
237
+ const newLine = beforePrefix + item.value + suffix + afterCursor;
238
+ const newLines = [...lines];
239
+ newLines[cursorLine] = newLine;
240
+
241
+ return {
242
+ lines: newLines,
243
+ cursorLine,
244
+ cursorCol: prefixStart + item.value.length + suffix.length,
245
+ };
246
+ }
247
+
248
+ return baseProvider.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
249
+ },
250
+
251
+ // Forward optional methods
252
+ getForceFileSuggestions(
253
+ lines: string[],
254
+ cursorLine: number,
255
+ cursorCol: number
256
+ ): { items: AutocompleteItem[]; prefix: string } | null {
257
+ if (isBashMode(lines)) {
258
+ const text = getTextUpToCursor(lines, cursorLine, cursorCol);
259
+ return getShellCompletions(text, process.cwd(), shell);
260
+ }
261
+ if ("getForceFileSuggestions" in baseProvider) {
262
+ return (baseProvider as any).getForceFileSuggestions(lines, cursorLine, cursorCol);
263
+ }
264
+ return this.getSuggestions(lines, cursorLine, cursorCol);
265
+ },
266
+
267
+ shouldTriggerFileCompletion(
268
+ lines: string[],
269
+ cursorLine: number,
270
+ cursorCol: number
271
+ ): boolean {
272
+ if (isBashMode(lines)) {
273
+ return true;
274
+ }
275
+ if ("shouldTriggerFileCompletion" in baseProvider) {
276
+ return (baseProvider as any).shouldTriggerFileCompletion(lines, cursorLine, cursorCol);
277
+ }
278
+ return true;
279
+ },
280
+ };
281
+ }
282
+
283
+ // ============================================================================
284
+ // Custom Editor with Shell Completion
285
+ // ============================================================================
286
+
287
+ /**
288
+ * Custom editor that intercepts setAutocompleteProvider to wrap with shell completion.
289
+ */
290
+ class ShellCompletionEditor extends CustomEditor {
291
+ private shell: ShellInfo;
292
+ private wrappedProvider = false;
293
+
294
+ constructor(tui: any, theme: any, keybindings: any, shell: ShellInfo) {
295
+ super(tui, theme, keybindings);
296
+ this.shell = shell;
297
+ }
298
+
299
+ // Override setAutocompleteProvider to wrap the base provider
300
+ setAutocompleteProvider(provider: AutocompleteProvider): void {
301
+ if (!this.wrappedProvider && provider) {
302
+ // Wrap the provider with shell completion support
303
+ const wrapped = wrapWithShellCompletion(provider, this.shell);
304
+ super.setAutocompleteProvider(wrapped);
305
+ this.wrappedProvider = true;
306
+ } else {
307
+ super.setAutocompleteProvider(provider);
308
+ }
309
+ }
310
+ }
311
+
312
+ // ============================================================================
313
+ // Extension Entry Point
314
+ // ============================================================================
315
+
316
+ export default function (pi: ExtensionAPI) {
317
+ const shell = findCompletionShell();
318
+ const shellName = path.basename(shell.path);
319
+
320
+ pi.on("session_start", (_event, ctx) => {
321
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
322
+ return new ShellCompletionEditor(tui, theme, keybindings, shell);
323
+ });
324
+
325
+ ctx.ui.notify(`Shell completions enabled (${shellName})`, "info");
326
+ });
327
+ }
328
+
329
+ // Re-export types for potential external use
330
+ export type { ShellInfo, ShellType, CompletionResult } from "./types.js";
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "pi-shell-completions",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that adds native shell completions (fish/zsh/bash) to ! and !! bash mode commands",
5
+ "type": "module",
6
+ "keywords": ["pi-package"],
7
+ "license": "MIT",
8
+ "author": "laulauland",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/laulauland/dotfiles"
12
+ },
13
+ "pi": {
14
+ "extensions": ["./index.ts"]
15
+ },
16
+ "peerDependencies": {
17
+ "@mariozechner/pi-coding-agent": "*",
18
+ "@mariozechner/pi-tui": "*"
19
+ }
20
+ }
@@ -0,0 +1,47 @@
1
+ #!/bin/bash
2
+ # Gets completions using bash's native completion system
3
+ # Usage: bash-complete.bash "command line" "/path/to/cwd"
4
+
5
+ __cmdline="$1"
6
+ __cwd="$2"
7
+
8
+ cd "$__cwd" 2>/dev/null || exit 1
9
+
10
+ # Extract command name
11
+ __cmd=${__cmdline%% *}
12
+
13
+ # Source bash-completion framework if available
14
+ for f in /usr/share/bash-completion/bash_completion /etc/bash_completion /opt/homebrew/etc/bash_completion /opt/homebrew/share/bash-completion/bash_completion; do
15
+ [[ -f "$f" ]] && { source "$f" 2>/dev/null; break; }
16
+ done
17
+
18
+ # Also try to source command-specific completions directly (macOS/Homebrew)
19
+ for dir in /opt/homebrew/etc/bash_completion.d /usr/share/bash-completion/completions /etc/bash_completion.d; do
20
+ for f in "$dir/$__cmd" "$dir/$__cmd.bash" "$dir/${__cmd}-completion.bash"; do
21
+ [[ -f "$f" ]] && source "$f" 2>/dev/null
22
+ done
23
+ done
24
+
25
+ # Set up completion environment
26
+ COMP_LINE="$__cmdline"
27
+ COMP_POINT=${#COMP_LINE}
28
+ eval set -- "$COMP_LINE"
29
+ COMP_WORDS=("$@")
30
+
31
+ # Add empty word if line ends with space (completing new word)
32
+ [[ "${COMP_LINE: -1}" = ' ' ]] && COMP_WORDS+=('')
33
+
34
+ COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))
35
+
36
+ # Load completion for the command if available
37
+ declare -F _completion_loader &>/dev/null && _completion_loader "$__cmd" 2>/dev/null
38
+
39
+ # Get the completion function
40
+ completion=$(complete -p "$__cmd" 2>/dev/null | awk '{print $(NF-1)}')
41
+
42
+ if [[ -n "$completion" ]] && declare -F "$completion" &>/dev/null; then
43
+ # Call the completion function
44
+ "$completion" 2>/dev/null
45
+ # Output unique results
46
+ printf '%s\n' "${COMPREPLY[@]}" | sort -u | head -30
47
+ fi
@@ -0,0 +1,148 @@
1
+ #!/bin/zsh
2
+ # Simple zsh completion capture using _complete_help
3
+ # Usage: zsh-capture.zsh "command line" "/path/to/cwd"
4
+
5
+ emulate -L zsh
6
+ setopt no_beep
7
+
8
+ local cmdline="$1"
9
+ local cwd="$2"
10
+
11
+ cd "$cwd" 2>/dev/null || exit 1
12
+
13
+ # Initialize completion system (use user's zcompdump)
14
+ autoload -Uz compinit
15
+ compinit -C 2>/dev/null
16
+
17
+ # Parse command line
18
+ local -a words
19
+ words=("${(@Q)${(z)cmdline}}")
20
+
21
+ # If line ends with space, we're completing a new word
22
+ if [[ "$cmdline" == *" " ]]; then
23
+ words+=("")
24
+ fi
25
+
26
+ local cmd="${words[1]}"
27
+ local current="${words[-1]}"
28
+
29
+ # Helper to output completions
30
+ output() {
31
+ local val="$1" desc="$2"
32
+ if [[ -n "$desc" ]]; then
33
+ print -r -- "${val}"$'\t'"${desc}"
34
+ else
35
+ print -r -- "${val}"
36
+ fi
37
+ }
38
+
39
+ # Git completions
40
+ if [[ "$cmd" == "git" ]]; then
41
+ if (( ${#words} == 2 )); then
42
+ # Git subcommands
43
+ git --list-cmds=main,others 2>/dev/null | while read -r subcmd; do
44
+ [[ -z "$current" || "$subcmd" == "$current"* ]] && output "$subcmd"
45
+ done
46
+ else
47
+ local subcmd="${words[2]}"
48
+ case "$subcmd" in
49
+ checkout|switch|merge|rebase|branch|log)
50
+ # Branches
51
+ git for-each-ref --format='%(refname:short)' refs/heads 2>/dev/null | while read -r b; do
52
+ [[ -z "$current" || "$b" == "$current"* ]] && output "$b" "branch"
53
+ done
54
+ git for-each-ref --format='%(refname:short)' refs/remotes 2>/dev/null | while read -r b; do
55
+ [[ "$b" == */HEAD ]] && continue
56
+ local short="${b#*/}"
57
+ [[ -z "$current" || "$short" == "$current"* ]] && output "$short" "remote"
58
+ done
59
+ ;;
60
+ add|diff|restore|reset)
61
+ # Modified files
62
+ git diff --name-only 2>/dev/null | while read -r f; do
63
+ [[ -z "$current" || "$f" == "$current"* ]] && output "$f" "modified"
64
+ done
65
+ git diff --cached --name-only 2>/dev/null | while read -r f; do
66
+ [[ -z "$current" || "$f" == "$current"* ]] && output "$f" "staged"
67
+ done
68
+ ;;
69
+ push|pull|fetch)
70
+ if (( ${#words} == 3 )); then
71
+ git remote 2>/dev/null | while read -r r; do
72
+ [[ -z "$current" || "$r" == "$current"* ]] && output "$r" "remote"
73
+ done
74
+ fi
75
+ ;;
76
+ stash)
77
+ for sub in apply drop list pop show push; do
78
+ [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
79
+ done
80
+ ;;
81
+ esac
82
+ fi
83
+ exit 0
84
+ fi
85
+
86
+ # SSH/SCP completions - hosts
87
+ if [[ "$cmd" == "ssh" || "$cmd" == "scp" || "$cmd" == "sftp" ]]; then
88
+ {
89
+ [[ -f ~/.ssh/config ]] && awk '/^Host / && !/\*/{for(i=2;i<=NF;i++)print $i}' ~/.ssh/config
90
+ [[ -f ~/.ssh/known_hosts ]] && awk -F'[, ]' '{print $1}' ~/.ssh/known_hosts
91
+ } 2>/dev/null | sort -u | while read -r h; do
92
+ [[ -z "$current" || "$h" == "$current"* ]] && output "$h" "host"
93
+ done
94
+ exit 0
95
+ fi
96
+
97
+ # Make completions
98
+ if [[ "$cmd" == "make" ]]; then
99
+ local mf
100
+ for f in GNUmakefile Makefile makefile; do
101
+ [[ -f "$f" ]] && mf="$f" && break
102
+ done
103
+ if [[ -n "$mf" ]]; then
104
+ awk -F: '/^[a-zA-Z_][a-zA-Z0-9_-]*:/ && !/^\./{print $1}' "$mf" 2>/dev/null | while read -r t; do
105
+ [[ -z "$current" || "$t" == "$current"* ]] && output "$t" "target"
106
+ done
107
+ fi
108
+ exit 0
109
+ fi
110
+
111
+ # NPM/Yarn/PNPM completions
112
+ if [[ "$cmd" == "npm" || "$cmd" == "yarn" || "$cmd" == "pnpm" ]]; then
113
+ if (( ${#words} == 2 )); then
114
+ for sub in install add remove run build test start dev publish; do
115
+ [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
116
+ done
117
+ elif [[ "${words[2]}" == "run" && -f package.json ]]; then
118
+ jq -r '.scripts // {} | keys[]' package.json 2>/dev/null | while read -r s; do
119
+ [[ -z "$current" || "$s" == "$current"* ]] && output "$s" "script"
120
+ done
121
+ fi
122
+ exit 0
123
+ fi
124
+
125
+ # Docker completions
126
+ if [[ "$cmd" == "docker" ]]; then
127
+ if (( ${#words} == 2 )); then
128
+ for sub in build compose exec images logs ps pull push rm rmi run start stop; do
129
+ [[ -z "$current" || "$sub" == "$current"* ]] && output "$sub"
130
+ done
131
+ fi
132
+ exit 0
133
+ fi
134
+
135
+ # Fallback: file completion
136
+ if [[ -n "$current" ]]; then
137
+ local -a matches
138
+ matches=( ${current}*(N) )
139
+ for f in "${matches[@]:0:20}"; do
140
+ [[ -d "$f" ]] && output "${f}/" "directory" || output "$f" "file"
141
+ done
142
+ else
143
+ local -a matches
144
+ matches=( *(N) )
145
+ for f in "${matches[@]:0:20}"; do
146
+ [[ -d "$f" ]] && output "${f}/" "directory" || output "$f" "file"
147
+ done
148
+ fi
package/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared types for shell completions extension.
3
+ */
4
+
5
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
6
+
7
+ export type ShellType = "fish" | "zsh" | "bash";
8
+
9
+ export interface ShellInfo {
10
+ path: string;
11
+ type: ShellType;
12
+ }
13
+
14
+ export interface CompletionContext {
15
+ commandLine: string;
16
+ prefix: string;
17
+ }
18
+
19
+ export interface CompletionResult {
20
+ items: AutocompleteItem[];
21
+ prefix: string;
22
+ }
23
+
24
+ /**
25
+ * Interface for shell-specific completion providers.
26
+ * Returns null if the user hasn't configured completions for this shell.
27
+ */
28
+ export interface ShellCompletionProvider {
29
+ getCompletions(commandLine: string, cwd: string, shellPath: string): CompletionResult | null;
30
+ }
package/zsh.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Zsh completion support
3
+ *
4
+ * Unfortunately, zsh's completion system is tightly coupled to its line editor
5
+ * (ZLE) and cannot be easily queried programmatically without a pseudo-terminal.
6
+ * The zpty approach is complex and fragile.
7
+ *
8
+ * This implementation uses a simple fallback script that handles common cases
9
+ * (git, ssh, make, npm, docker) but does NOT tap into the user's full zsh
10
+ * completion configuration.
11
+ *
12
+ * For the best experience, install fish (even as a secondary shell) - its
13
+ * `complete -C` command provides excellent completions without complexity.
14
+ */
15
+
16
+ import { spawnSync } from "node:child_process";
17
+ import * as fs from "node:fs";
18
+ import * as path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import type { CompletionResult } from "./types.js";
21
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
22
+
23
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
+ const CAPTURE_SCRIPT = path.join(__dirname, "scripts", "zsh-capture.zsh");
25
+
26
+ /**
27
+ * Parse completion output (tab-separated: value\tdescription)
28
+ */
29
+ function parseOutput(output: string): AutocompleteItem[] {
30
+ const lines = output.trim().split("\n").filter(Boolean);
31
+ const items: AutocompleteItem[] = [];
32
+ const seen = new Set<string>();
33
+
34
+ for (const line of lines) {
35
+ const tabIndex = line.indexOf("\t");
36
+ let value: string;
37
+ let description: string | undefined;
38
+
39
+ if (tabIndex >= 0) {
40
+ value = line.slice(0, tabIndex).trim();
41
+ description = line.slice(tabIndex + 1).trim() || undefined;
42
+ } else {
43
+ value = line.trim();
44
+ }
45
+
46
+ if (!value || seen.has(value)) continue;
47
+ seen.add(value);
48
+
49
+ // Skip internal refs
50
+ if (value.startsWith("refs/jj/keep/")) continue;
51
+
52
+ items.push({ value, label: value, description });
53
+ }
54
+
55
+ return items;
56
+ }
57
+
58
+ /**
59
+ * Get completions using zsh fallback script.
60
+ * Note: This does NOT use the user's full zsh completion config.
61
+ */
62
+ export function getZshCompletions(
63
+ commandLine: string,
64
+ cwd: string,
65
+ zshPath: string
66
+ ): CompletionResult | null {
67
+ // Check if capture script exists
68
+ if (!fs.existsSync(CAPTURE_SCRIPT)) {
69
+ return null;
70
+ }
71
+
72
+ // Extract prefix
73
+ const trimmed = commandLine.trimStart();
74
+ let prefix = "";
75
+ if (!trimmed.endsWith(" ")) {
76
+ const words = trimmed.split(/\s+/);
77
+ prefix = words[words.length - 1] || "";
78
+ }
79
+
80
+ try {
81
+ const result = spawnSync(zshPath, [CAPTURE_SCRIPT, commandLine, cwd], {
82
+ encoding: "utf-8",
83
+ timeout: 500,
84
+ maxBuffer: 1024 * 100,
85
+ cwd,
86
+ });
87
+
88
+ if (result.error || !result.stdout) {
89
+ return null;
90
+ }
91
+
92
+ const items = parseOutput(result.stdout);
93
+
94
+ if (items.length === 0) {
95
+ return null;
96
+ }
97
+
98
+ return {
99
+ items: items.slice(0, 30),
100
+ prefix,
101
+ };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export const zshCompletionProvider = {
108
+ name: "zsh" as const,
109
+ getCompletions: getZshCompletions,
110
+ };