/ extensions / code-actions / actions.ts
actions.ts
  1  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
  2  import { Container, Text, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
  3  import { DynamicBorder } from "@mariozechner/pi-coding-agent";
  4  import * as fs from "node:fs";
  5  import * as os from "node:os";
  6  import * as path from "node:path";
  7  let justBash: { Bash?: any; OverlayFs?: any } | null = null;
  8  let justBashLoadPromise: Promise<void> | null = null;
  9  let justBashLoadDone = false;
 10  
 11  async function ensureJustBashLoaded(): Promise<void> {
 12  	if (justBashLoadDone) return;
 13  
 14  	if (!justBashLoadPromise) {
 15  		justBashLoadPromise = import("just-bash")
 16  			.then((mod: any) => {
 17  				justBash = mod;
 18  			})
 19  			.catch(() => {
 20  				justBash = null;
 21  			})
 22  			.finally(() => {
 23  				justBashLoadDone = true;
 24  			});
 25  	}
 26  
 27  	await justBashLoadPromise;
 28  }
 29  
 30  export async function copyToClipboard(pi: ExtensionAPI, content: string): Promise<boolean> {
 31  	const tmpPath = path.join(os.tmpdir(), `pi-code-${Date.now()}.txt`);
 32  	fs.writeFileSync(tmpPath, content, "utf8");
 33  
 34  	const commands: Array<{ command: string; args: string[] }> = [];
 35  	if (process.platform === "darwin") {
 36  		commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | pbcopy`] });
 37  	} else if (process.platform === "win32") {
 38  		commands.push({ command: "powershell", args: ["-NoProfile", "-Command", `Get-Content -Raw "${tmpPath}" | Set-Clipboard`] });
 39  	} else {
 40  		commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | wl-copy`] });
 41  		commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | xclip -selection clipboard`] });
 42  		commands.push({ command: "sh", args: ["-c", `cat "${tmpPath}" | xsel --clipboard --input`] });
 43  	}
 44  
 45  	let success = false;
 46  	for (const cmd of commands) {
 47  		try {
 48  			const result = await pi.exec(cmd.command, cmd.args);
 49  			if (result.code === 0) {
 50  				success = true;
 51  				break;
 52  			}
 53  		} catch {
 54  			// Try next command
 55  		}
 56  	}
 57  
 58  	try {
 59  		fs.unlinkSync(tmpPath);
 60  	} catch {
 61  		// Ignore cleanup errors
 62  	}
 63  
 64  	return success;
 65  }
 66  
 67  export function insertIntoEditor(ctx: ExtensionCommandContext, content: string): void {
 68  	const existing = ctx.ui.getEditorText();
 69  	const next = existing ? `${existing}\n${content}` : content;
 70  	ctx.ui.setEditorText(next);
 71  }
 72  
 73  function formatOutput(command: string, result: { stdout: string; stderr: string; code: number }): string {
 74  	const lines: string[] = [];
 75  	lines.push(`Command: ${command}`);
 76  	lines.push(`Exit code: ${result.code}`);
 77  
 78  	if (result.stdout.trim().length > 0) {
 79  		lines.push("");
 80  		lines.push("STDOUT:");
 81  		lines.push(result.stdout.trimEnd());
 82  	}
 83  
 84  	if (result.stderr.trim().length > 0) {
 85  		lines.push("");
 86  		lines.push("STDERR:");
 87  		lines.push(result.stderr.trimEnd());
 88  	}
 89  
 90  	return lines.join("\n");
 91  }
 92  
 93  function truncateLines(text: string, maxLines: number): string {
 94  	const lines = text.split(/\r?\n/);
 95  	if (lines.length <= maxLines) return text;
 96  	const truncated = lines.slice(0, maxLines).join("\n");
 97  	return `${truncated}\n\n[Output truncated to ${maxLines} lines]`;
 98  }
 99  
100  type CommandRunResult = {
101  	stdout: string;
102  	stderr: string;
103  	code: number;
104  	commandLabel: string;
105  };
106  
107  function looksLikeMissingCommand(stderr: string): boolean {
108  	const normalized = stderr.toLowerCase();
109  	return normalized.includes("command not found") || normalized.includes("unknown command") || normalized.includes("not recognized");
110  }
111  
112  function normalizeShellSnippetForExecution(snippet: string): string {
113  	const trimmed = snippet.trim();
114  
115  	// If the snippet is a tool-call style JSON object, extract the command field
116  	if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
117  		try {
118  			const parsed = JSON.parse(trimmed) as any;
119  			const cmd = typeof parsed?.command === "string"
120  				? parsed.command
121  				: typeof parsed?.cmd === "string"
122  					? parsed.cmd
123  					: null;
124  			if (cmd && cmd.trim().length > 0) return cmd.trim();
125  		} catch {
126  			// ignore
127  		}
128  	}
129  
130  	const lines = snippet.split(/\r?\n/);
131  	const hasPromptLines = lines.some(
132  		(line) => /^\s*\$\s+/.test(line) || /^\s*>\s+/.test(line) || /^\s*!\s*/.test(line),
133  	);
134  
135  	// Common Pi convention: snippets sometimes include a leading `!` to indicate “run in shell”
136  	// If there are no prompt-like transcript lines, just strip a single leading `!`
137  	if (!hasPromptLines) {
138  		return trimmed.startsWith("!")
139  			? trimmed.replace(/^!\s*/, "")
140  			: trimmed;
141  	}
142  
143  	const extracted = lines
144  		.map((line) => {
145  			if (/^\s*\$\s+/.test(line)) return line.replace(/^\s*\$\s+/, "");
146  			if (/^\s*>\s+/.test(line)) return line.replace(/^\s*>\s+/, "");
147  			if (/^\s*!\s*/.test(line)) return line.replace(/^\s*!\s*/, "");
148  			return "";
149  		})
150  		.filter((line) => line.trim().length > 0)
151  		.join("\n")
152  		.trim();
153  
154  	return extracted.length > 0 ? extracted : trimmed;
155  }
156  
157  async function runSnippetInSystemShell(pi: ExtensionAPI, ctx: ExtensionCommandContext, snippet: string): Promise<CommandRunResult> {
158  	const isWindows = process.platform === "win32";
159  	const command = isWindows ? "powershell" : "bash";
160  	const args = isWindows ? ["-NoProfile", "-Command", snippet] : ["-lc", snippet];
161  	const result = await pi.exec(command, args, { cwd: ctx.cwd });
162  
163  	return {
164  		stdout: result.stdout,
165  		stderr: result.stderr,
166  		code: result.code,
167  		commandLabel: `${command} ${args.join(" ")}`,
168  	};
169  }
170  
171  async function runSnippetInSandbox(snippet: string, cwd: string): Promise<CommandRunResult> {
172  	await ensureJustBashLoaded();
173  	const OverlayFsCtor = justBash?.OverlayFs;
174  	const BashCtor = justBash?.Bash;
175  	if (typeof OverlayFsCtor !== "function" || typeof BashCtor !== "function") {
176  		throw new Error("just-bash is not available");
177  	}
178  
179  	const overlay = new OverlayFsCtor({ root: cwd });
180  	const bash = new BashCtor({
181  		fs: overlay,
182  		cwd: overlay.getMountPoint(),
183  		executionLimits: {
184  			maxCallDepth: 32,
185  			maxCommandCount: 1000,
186  			maxLoopIterations: 3000,
187  			maxAwkIterations: 8000,
188  			maxSedIterations: 8000,
189  		},
190  	});
191  
192  	const result = await bash.exec(snippet);
193  	return {
194  		stdout: result.stdout,
195  		stderr: result.stderr,
196  		code: result.exitCode,
197  		commandLabel: `just-bash (overlayfs, read-only) -c ${JSON.stringify(snippet)}`,
198  	};
199  }
200  
201  export async function runSnippet(pi: ExtensionAPI, ctx: ExtensionCommandContext, snippet: string): Promise<void> {
202  	let runResult: CommandRunResult;
203  
204  	const normalizedSnippet = normalizeShellSnippetForExecution(snippet);
205  
206  	if (process.platform === "win32") {
207  		runResult = await runSnippetInSystemShell(pi, ctx, normalizedSnippet);
208  	} else {
209  		let sandboxResult: CommandRunResult | null = null;
210  		try {
211  			sandboxResult = await runSnippetInSandbox(normalizedSnippet, ctx.cwd);
212  		} catch {
213  			sandboxResult = null;
214  		}
215  
216  		if (!sandboxResult) {
217  			runResult = await runSnippetInSystemShell(pi, ctx, normalizedSnippet);
218  		} else if (sandboxResult.code !== 0 && looksLikeMissingCommand(sandboxResult.stderr)) {
219  			const stderrPreview = (sandboxResult.stderr ?? "").trim().slice(0, 500);
220  			const proceed = await ctx.ui.confirm(
221  				"Sandbox missing command",
222  				"The just-bash sandbox could not run this snippet because one or more commands are unsupported.\n\n" +
223  					`Snippet:\n${normalizedSnippet}\n\n` +
224  					(stderrPreview.length > 0 ? `Sandbox error:\n${stderrPreview}\n\n` : "") +
225  					"Run it in your real shell instead?",
226  			);
227  
228  			if (proceed) {
229  				runResult = await runSnippetInSystemShell(pi, ctx, normalizedSnippet);
230  			} else {
231  				runResult = sandboxResult;
232  			}
233  		} else {
234  			runResult = sandboxResult;
235  		}
236  	}
237  
238  	const output = truncateLines(
239  		formatOutput(runResult.commandLabel, {
240  			stdout: runResult.stdout,
241  			stderr: runResult.stderr,
242  			code: runResult.code,
243  		}),
244  		200,
245  	);
246  
247  	await ctx.ui.custom<void>((tui, theme, _kb, done) => {
248  		const container = new Container();
249  		container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
250  		container.addChild(new Text(theme.fg("accent", theme.bold("Command Output")), 1, 0));
251  
252  		const text = new Text(output, 1, 0);
253  		container.addChild(text);
254  
255  		container.addChild(new Text(theme.fg("dim", "Enter/Esc to close"), 1, 0));
256  		container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
257  
258  		return {
259  			render: (width: number) => container.render(width).map((line) => truncateToWidth(line, width)),
260  			invalidate: () => container.invalidate(),
261  			handleInput: (data: string) => {
262  				if (matchesKey(data, "escape") || matchesKey(data, "enter")) {
263  					done();
264  				}
265  			},
266  		};
267  	});
268  }