main
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
16import { spawnSync } from "node:child_process";
17import * as fs from "node:fs";
18import * as path from "node:path";
19import { fileURLToPath } from "node:url";
20import type { CompletionResult } from "./types.js";
21import type { AutocompleteItem } from "@mariozechner/pi-tui";
22
23const __dirname = path.dirname(fileURLToPath(import.meta.url));
24const CAPTURE_SCRIPT = path.join(__dirname, "scripts", "zsh-capture.zsh");
25
26/**
27 * Parse completion output (tab-separated: value\tdescription)
28 */
29function 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 */
62export 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
107export const zshCompletionProvider = {
108 name: "zsh" as const,
109 getCompletions: getZshCompletions,
110};