Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/nu-bang/index.ts

Raw
import {
	type ChildProcessWithoutNullStreams,
	spawn,
	spawnSync,
} from "node:child_process";
import { constants } from "node:fs";
import { access, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type {
	BashOperations,
	ExtensionAPI,
	ExtensionContext,
	UserBashEvent,
	UserBashEventResult,
} from "@earendil-works/pi-coding-agent";
import type {
	AutocompleteItem,
	AutocompleteProvider,
} from "@earendil-works/pi-tui";
import { closeDebug, dbg, span } from "./src/debug.ts";
import {
	resolveSetting,
	type SettingDeclaration,
} from "./src/pi-ext-settings.ts";

interface ProcessResult {
	stdout: string;
	stderr: string;
	output: string;
	code: number | undefined;
	signal: NodeJS.Signals | null;
}

interface BangCommand {
	command: string;
	prefixLength: number;
}

/** How a `!` command should run: streamed through Pi, or interactively with the terminal handed over. */
export interface BangDispatch {
	command: string;
	interactive: boolean;
}

const DEFAULT_NU_COMMAND = "nu";
const COMMAND_SETTING: SettingDeclaration<string> = {
	key: "nu-bang.command",
	env: "PI_NU_BANG_COMMAND",
	default: DEFAULT_NU_COMMAND,
	parse: (raw) => (typeof raw === "string" && raw.trim() ? raw : undefined),
};
// Non-login Nushell avoids sourcing user login startup for every bang command.
// That keeps execution deterministic and removes the dominant per-test/process cost.
const NU_BASE_ARGS = ["--no-history", "--error-style", "fancy"] as const;
const NU_TABLE_MODE = "none";
const TEMP_PREFIX = "pi-nu-bang-";
const TEMP_FILE_NAME = "command.nu";
const EXIT_STDIO_GRACE_MS = 100;
const INTERACTIVE_PREFIX = /^i[ \t]+/;
// Conservative allowlist: programs that need the terminal and never produce useful captured output.
const INTERACTIVE_PROGRAMS = new Set([
	// Editors
	"vim",
	"nvim",
	"vi",
	"nano",
	"emacs",
	"micro",
	"helix",
	"hx",
	"kak",
	// Pagers
	"less",
	"more",
	"most",
	// Terminal UIs
	"lazygit",
	"tig",
	"gitui",
	"htop",
	"btop",
	"top",
	"ranger",
	"yazi",
	"nnn",
	"lf",
	"fzf",
	"tmux",
	"ssh",
]);
const GIT_PATCH_FLAGS = new Set(["-p", "--patch", "-i", "--interactive"]);
const GIT_PATCH_SUBCOMMANDS = new Set([
	"add",
	"stash",
	"reset",
	"checkout",
	"restore",
]);
const GIT_COMMIT_NO_EDITOR_FLAG =
	/^(?:-[a-zA-Z]*[mFC][a-zA-Z]*|--message(?:=.*)?|--file(?:=.*)?|--reuse-message(?:=.*)?|--no-edit)$/;

const tempDirs = new Set<string>();

function nuBaseArgs(): string[] {
	return [...NU_BASE_ARGS];
}

function nuExecArgs(command: string): string[] {
	return [...nuBaseArgs(), "--table-mode", NU_TABLE_MODE, "-c", command];
}

function escapeControlChars(text: string): string {
	return text.replace(
		/[\x00-\x1F\x7F-\x9F]/g,
		(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`,
	);
}

function segmentTokens(segment: string): string[] {
	const tokens = segment.trim().split(/\s+/).filter(Boolean);
	if (tokens[0]?.startsWith("^")) tokens[0] = tokens[0].slice(1);
	return tokens;
}

function gitSubcommandIsInteractive(args: string[]): boolean {
	const [subcommand, ...rest] = args;
	if (subcommand === undefined) return false;
	if (subcommand === "commit")
		return !rest.some((arg) => GIT_COMMIT_NO_EDITOR_FLAG.test(arg));
	if (subcommand === "rebase")
		return rest.includes("-i") || rest.includes("--interactive");
	if (GIT_PATCH_SUBCOMMANDS.has(subcommand))
		return rest.some((arg) => GIT_PATCH_FLAGS.has(arg));
	return subcommand === "difftool" || subcommand === "mergetool";
}

function segmentIsInteractive(segment: string): boolean {
	const [program, ...args] = segmentTokens(segment);
	if (program === undefined) return false;
	if (INTERACTIVE_PROGRAMS.has(program)) return true;
	return program === "git" && gitSubcommandIsInteractive(args);
}

/**
 * Decide whether Pi's `!` command needs the terminal.
 * `!i <command>` forces interactive mode; otherwise the first pipeline stage and the
 * stage after the last `|` are matched against a conservative allowlist.
 */
export function classifyBangCommand(command: string): BangDispatch {
	const forced = INTERACTIVE_PREFIX.exec(command);
	if (forced)
		return { command: command.slice(forced[0].length), interactive: true };

	const firstPipe = command.indexOf("|");
	const lastPipe = command.lastIndexOf("|");
	const first = firstPipe < 0 ? command : command.slice(0, firstPipe);
	const last = lastPipe < 0 ? command : command.slice(lastPipe + 1);
	return {
		command,
		interactive: segmentIsInteractive(first) || segmentIsInteractive(last),
	};
}

function nodeErrorCode(error: unknown): string | undefined {
	return error instanceof Error &&
		"code" in error &&
		typeof error.code === "string"
		? error.code
		: undefined;
}

async function workingDirectoryError(cwd: string): Promise<string | undefined> {
	const safeCwd = escapeControlChars(cwd);
	try {
		const info = await stat(cwd);
		if (!info.isDirectory())
			return `Working directory is not a directory: ${safeCwd}`;
		try {
			await access(cwd, constants.X_OK);
			return undefined;
		} catch (error) {
			const code = nodeErrorCode(error);
			if (code === "EACCES") {
				return `Working directory is not accessible: ${safeCwd}`;
			}
			if (code)
				return `Working directory cannot be checked: ${safeCwd} (${escapeControlChars(code)})`;
			throw error;
		}
	} catch (error) {
		const code = nodeErrorCode(error);
		if (code === "ENOENT") {
			return `Working directory does not exist: ${safeCwd}`;
		}
		if (code)
			return `Working directory cannot be checked: ${safeCwd} (${escapeControlChars(code)})`;
		throw error;
	}
}

function waitForForegroundProcess(
	child: ChildProcessWithoutNullStreams,
): Promise<{ code: number | null; signal: NodeJS.Signals | null }> {
	return new Promise((resolve, reject) => {
		let settled = false;
		let exited = false;
		let exitCode: number | null = null;
		let exitSignal: NodeJS.Signals | null = null;
		let postExitTimer: ReturnType<typeof setTimeout> | undefined;
		let stdoutEnded = false;
		let stderrEnded = false;

		function cleanup(): void {
			if (postExitTimer) clearTimeout(postExitTimer);
			child.removeListener("error", onError);
			child.removeListener("exit", onExit);
			child.removeListener("close", onClose);
			child.stdout.removeListener("end", onStdoutEnd);
			child.stderr.removeListener("end", onStderrEnd);
		}

		function finalize(
			code: number | null,
			processSignal: NodeJS.Signals | null,
		): void {
			if (settled) return;
			settled = true;
			cleanup();
			child.stdout.destroy();
			child.stderr.destroy();
			resolve({ code, signal: processSignal });
		}

		function maybeFinalizeAfterExit(): void {
			if (exited && stdoutEnded && stderrEnded) finalize(exitCode, exitSignal);
		}

		function onStdoutEnd(): void {
			stdoutEnded = true;
			maybeFinalizeAfterExit();
		}

		function onStderrEnd(): void {
			stderrEnded = true;
			maybeFinalizeAfterExit();
		}

		function onError(error: Error): void {
			if (settled) return;
			settled = true;
			cleanup();
			reject(error);
		}

		function onExit(
			code: number | null,
			processSignal: NodeJS.Signals | null,
		): void {
			exited = true;
			exitCode = code;
			exitSignal = processSignal;
			maybeFinalizeAfterExit();
			if (!settled)
				postExitTimer ??= setTimeout(
					() => finalize(code, processSignal),
					EXIT_STDIO_GRACE_MS,
				);
		}

		function onClose(
			code: number | null,
			processSignal: NodeJS.Signals | null,
		): void {
			finalize(code, processSignal);
		}

		child.stdout.once("end", onStdoutEnd);
		child.stderr.once("end", onStderrEnd);
		child.once("error", onError);
		child.once("exit", onExit);
		child.once("close", onClose);
	});
}

function runProcess(
	nuCommand: string,
	args: string[],
	cwd: string,
	signal?: AbortSignal,
): Promise<ProcessResult> {
	return new Promise((resolve, reject) => {
		let stdout = "";
		let stderr = "";
		let output = "";

		runStreamingProcess(nuCommand, args, cwd, {
			onStdout(chunk) {
				const text = chunk.toString("utf8");
				stdout += text;
				output += text;
			},
			onStderr(chunk) {
				const text = chunk.toString("utf8");
				stderr += text;
				output += text;
			},
			signal,
		})
			.then((result) =>
				resolve({
					stdout,
					stderr,
					output,
					code: result.exitCode ?? undefined,
					signal: result.signal,
				}),
			)
			.catch(reject);
	});
}

function runStreamingProcess(
	nuCommand: string,
	args: string[],
	cwd: string,
	options: {
		onStdout?: (chunk: Buffer) => void;
		onStderr?: (chunk: Buffer) => void;
		signal?: AbortSignal | undefined;
		timeout?: number | undefined;
		env?: NodeJS.ProcessEnv | undefined;
	},
): Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }> {
	return new Promise((resolve, reject) => {
		let aborted = options.signal?.aborted ?? false;
		let timedOut = false;
		let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
		let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
		const detached = process.platform !== "win32";
		const child = spawn(nuCommand, args, {
			cwd,
			detached,
			env: options.env ?? process.env,
			stdio: ["ignore", "pipe", "pipe"],
		});

		function killProcessGroup(kind: NodeJS.Signals): void {
			if (child.pid === undefined) return;
			try {
				if (detached) process.kill(-child.pid, kind);
				else child.kill(kind);
			} catch {
				try {
					child.kill(kind);
				} catch {}
			}
		}

		function abortProcess(): void {
			aborted = true;
			killProcessGroup("SIGTERM");
			forceKillTimer ??= setTimeout(() => killProcessGroup("SIGKILL"), 250);
		}

		function cleanup(): void {
			if (forceKillTimer) clearTimeout(forceKillTimer);
			if (timeoutTimer) clearTimeout(timeoutTimer);
			options.signal?.removeEventListener("abort", abortProcess);
		}

		if (options.timeout !== undefined && options.timeout > 0) {
			timeoutTimer = setTimeout(() => {
				timedOut = true;
				killProcessGroup("SIGTERM");
				forceKillTimer ??= setTimeout(() => killProcessGroup("SIGKILL"), 250);
			}, options.timeout * 1000);
		}

		child.stdout.on("data", (chunk: Buffer) => options.onStdout?.(chunk));
		child.stderr.on("data", (chunk: Buffer) => options.onStderr?.(chunk));

		waitForForegroundProcess(child as unknown as ChildProcessWithoutNullStreams)
			.then((result) => {
				cleanup();
				if (timedOut) {
					reject(new Error(`timeout:${options.timeout}`));
					return;
				}
				resolve({
					exitCode: result.code,
					signal: aborted ? "SIGTERM" : result.signal,
				});
			})
			.catch((error: unknown) => {
				cleanup();
				if (aborted) {
					resolve({ exitCode: null, signal: "SIGTERM" });
					return;
				}
				reject(error);
			});

		if (options.signal) {
			options.signal.addEventListener("abort", abortProcess, { once: true });
			if (options.signal.aborted) abortProcess();
		}
	});
}

async function withTempNuFile<T>(
	code: string,
	fn: (file: string) => Promise<T>,
): Promise<T> {
	const dir = await mkdtemp(join(tmpdir(), TEMP_PREFIX));
	tempDirs.add(dir);
	try {
		const file = join(dir, TEMP_FILE_NAME);
		await writeFile(file, code, "utf8");
		return await fn(file);
	} finally {
		tempDirs.delete(dir);
		await rm(dir, { recursive: true, force: true }).catch(() => {});
	}
}

/** Return Nu source and prefix width for Pi's ! / !! input, or undefined for normal input. */
export function stripBangPrefix(text: string): BangCommand | undefined {
	if (text.startsWith("!!")) return { command: text.slice(2), prefixLength: 2 };
	if (text.startsWith("!")) return { command: text.slice(1), prefixLength: 1 };
	return undefined;
}

export function cursorOffset(
	lines: string[],
	cursorLine: number,
	cursorCol: number,
	prefixLength: number,
): number | undefined {
	if (cursorLine === 0 && cursorCol <= prefixLength) return undefined;
	let offset = cursorCol;
	for (let i = 0; i < cursorLine; i++) offset += (lines[i] ?? "").length + 1;
	return Math.max(0, offset - prefixLength);
}

function completionToken(before: string): string {
	let tokenStart = 0;
	let quote: string | undefined;
	let escaped = false;

	for (let i = 0; i < before.length; i++) {
		const char = before[i] ?? "";
		if (quote) {
			if (escaped) {
				escaped = false;
				continue;
			}
			if (char === "\\" && quote !== "`") {
				escaped = true;
				continue;
			}
			if (char === quote) quote = undefined;
			continue;
		}

		if (escaped) {
			escaped = false;
			continue;
		}
		if (char === "\\") {
			escaped = true;
			continue;
		}
		if (char === '"' || char === "'" || char === "`") {
			quote = char;
			continue;
		}
		if (/[\s|;(){}[\]]/.test(char)) tokenStart = i + 1;
	}

	return before.slice(tokenStart);
}

export function completionPrefix(code: string, offset: number): string {
	const token = completionToken(code.slice(0, offset));
	const dot = token.lastIndexOf(".");
	const isVariableCellPath = /^\$[^\s|;(){}[\]"'`]*$/.test(token);
	return isVariableCellPath && dot >= 0 ? token.slice(dot + 1) : token;
}

function unescapeBackslashToken(value: string): string {
	return value.replace(/\\(.)/g, "$1");
}

function escapedPathCompletionInput(
	code: string,
	offset: number,
	prefix: string,
): { code: string; offset: number } | undefined {
	if (!/\\./.test(prefix)) return undefined;
	const start = offset - prefix.length;
	if (start < 0) return undefined;

	const quotedPrefix = "`" + unescapeBackslashToken(prefix);
	return {
		code: `${code.slice(0, start)}${quotedPrefix}${code.slice(offset)}`,
		offset: start + quotedPrefix.length,
	};
}

function unquoteCompletionValue(value: string): string {
	const quote = value[0];
	return (quote === "`" || quote === "'" || quote === '"') &&
		value.endsWith(quote)
		? value.slice(1, -1)
		: value;
}

function filterEscapedPathCompletions(
	values: string[],
	prefix: string,
): string[] {
	const unescapedPrefix = unescapeBackslashToken(prefix);
	return values.filter((value) =>
		unescapeBackslashToken(unquoteCompletionValue(value)).startsWith(
			unescapedPrefix,
		),
	);
}

function stringArray(value: unknown): string[] {
	return Array.isArray(value)
		? value.filter((item): item is string => typeof item === "string")
		: [];
}

function parseCompletionJson(text: string): string[] | undefined {
	try {
		const parsed = JSON.parse(text) as { completions?: unknown };
		return stringArray(parsed.completions);
	} catch {
		return undefined;
	}
}

function readQuotedItem(
	body: string,
	index: number,
): { value: string; next: number } {
	let i = index;
	while (body[i] === '"') i++;

	let value = "";
	while (i < body.length) {
		const char = body[i];
		if (char === "\\" && i + 1 < body.length) {
			value += body[i + 1];
			i += 2;
			continue;
		}
		if (char === '"') {
			let afterQuotes = i;
			while (body[afterQuotes] === '"') afterQuotes++;
			let next = afterQuotes;
			while (/\s/.test(body[next] ?? "")) next++;
			if (next >= body.length || body[next] === ",")
				return { value, next: next + 1 };
			value += body.slice(i, afterQuotes);
			i = afterQuotes;
			continue;
		}
		value += char;
		i++;
	}
	return { value: value.trim(), next: i };
}

function parseMalformedCompletionList(text: string): string[] {
	const start = text.indexOf("[");
	const end = text.lastIndexOf("]");
	if (start < 0 || end <= start) return [];

	const body = text.slice(start + 1, end);
	const completions: string[] = [];
	let i = 0;
	while (i < body.length) {
		while (/[,\s]/.test(body[i] ?? "")) i++;
		if (i >= body.length) break;

		if (body[i] === '"') {
			const item = readQuotedItem(body, i);
			if (item.value) completions.push(item.value);
			i = item.next;
			continue;
		}

		const next = body.indexOf(",", i);
		const endOfItem = next < 0 ? body.length : next;
		const value = body.slice(i, endOfItem).trim();
		if (value) completions.push(value);
		i = endOfItem + 1;
	}
	return completions;
}

export function parseCompletionsOutput(stdout: string): string[] {
	const text = stdout.trim();
	if (!text) return [];
	return (parseCompletionJson(text) ?? parseMalformedCompletionList(text)).map(
		escapeControlChars,
	);
}

export async function nuCompletions(
	code: string,
	offset: number,
	cwd: string,
	signal?: AbortSignal,
	nuCommand: string = DEFAULT_NU_COMMAND,
): Promise<string[]> {
	const cwdProblem = await workingDirectoryError(cwd);
	if (cwdProblem) throw new Error(cwdProblem);

	return withTempNuFile(code, async (file) => {
		const result = await runProcess(
			nuCommand,
			[...nuBaseArgs(), "-I", cwd, "--ide-complete", String(offset), file],
			cwd,
			signal,
		);
		return parseCompletionsOutput(result.stdout);
	});
}

export function createNuBashOperations(
	fixedCommand?: string,
	nuCommand: string = DEFAULT_NU_COMMAND,
): BashOperations {
	return {
		async exec(command, cwd, options) {
			const finish = span?.("nu.execute", { kind: "process" });
			try {
				const cwdProblem = await workingDirectoryError(cwd);
				if (cwdProblem) throw new Error(cwdProblem);
				const result = await runStreamingProcess(
					nuCommand,
					nuExecArgs(fixedCommand ?? command),
					cwd,
					{
						onStdout: options.onData,
						onStderr: options.onData,
						signal: options.signal,
						timeout: options.timeout,
						env: options.env,
					},
				);
				finish?.("finish", {
					kind: "process",
					outcome:
						result.signal !== null
							? "cancelled"
							: result.exitCode === 0
								? "ok"
								: "nonzero",
				});
				return { exitCode: result.exitCode };
			} catch (error) {
				finish?.("error", { kind: "process", outcome: "error" });
				throw error;
			}
		},
	};
}

function bashResult(output: string, exitCode: number | undefined) {
	return { output, exitCode, cancelled: false, truncated: false };
}

async function runInteractive(
	command: string,
	cwd: string,
	nuCommand: string,
	ctx: ExtensionContext,
): Promise<UserBashEventResult> {
	const cwdProblem = await workingDirectoryError(cwd);
	if (cwdProblem)
		return { result: bashResult(`Nu execution failed: ${cwdProblem}`, 1) };

	const finish = span?.("nu.interactive", { kind: "process" });
	const outcome = await ctx.ui.custom<{
		status: number | null;
		error: Error | undefined;
	}>((tui, _theme, _keybindings, done) => {
		tui.stop();
		const spawned = spawnSync(nuCommand, [...nuBaseArgs(), "-c", command], {
			cwd,
			stdio: "inherit",
			env: process.env,
		});
		tui.start();
		tui.requestRender(true);
		done({ status: spawned.status, error: spawned.error });
		return { render: () => [], invalidate: () => {} };
	});

	if (outcome.error) {
		finish?.("error", { kind: "process", outcome: "error" });
		return {
			result: bashResult(
				`Nu execution failed: ${escapeControlChars(outcome.error.message)}`,
				1,
			),
		};
	}
	finish?.("finish", {
		kind: "process",
		outcome: outcome.status === 0 ? "ok" : "nonzero",
	});
	const output =
		outcome.status === 0
			? "(interactive command completed)"
			: `(interactive command exited with code ${outcome.status ?? "unknown"})`;
	return { result: bashResult(output, outcome.status ?? 1) };
}

export function handleUserBash(
	event: UserBashEvent,
	ctx: ExtensionContext,
	nuCommand: string,
): UserBashEventResult | Promise<UserBashEventResult> {
	const dispatch = classifyBangCommand(event.command);
	if (!dispatch.interactive)
		return { operations: createNuBashOperations(event.command, nuCommand) };
	if (ctx.mode !== "tui")
		return {
			result: bashResult(
				"(interactive commands require Pi's TUI; run this command in a terminal instead)",
				1,
			),
		};
	return runInteractive(dispatch.command, event.cwd, nuCommand, ctx);
}

function fullText(lines: string[]): string {
	return lines.join("\n");
}

function completionItems(values: string[]): AutocompleteItem[] {
	return values.map((value) => ({ value, label: value }));
}

function matchingClosingQuote(prefix: string): string | undefined {
	const quote = prefix[0];
	return quote === '"' || quote === "'" || quote === "`" ? quote : undefined;
}

function replacePrefix(
	lines: string[],
	cursorLine: number,
	cursorCol: number,
	prefix: string,
	value: string,
) {
	const next = [...lines];
	const line = next[cursorLine] ?? "";
	const before = line.slice(0, cursorCol);
	const after = line.slice(cursorCol);
	const closingQuote = matchingClosingQuote(prefix);
	const adjustedAfter =
		closingQuote && after.startsWith(closingQuote) ? after.slice(1) : after;
	next[cursorLine] =
		before.slice(0, before.length - prefix.length) + value + adjustedAfter;
	return {
		lines: next,
		cursorLine,
		cursorCol: cursorCol - prefix.length + value.length,
	};
}

export function wrapNuAutocompleteProvider(
	current: AutocompleteProvider,
	cwd: string,
	nuCommand: string = DEFAULT_NU_COMMAND,
): AutocompleteProvider {
	const fallbackItems = new WeakSet<AutocompleteItem>();

	return {
		async getSuggestions(lines, cursorLine, cursorCol, options) {
			const bang = stripBangPrefix(fullText(lines));
			if (!bang)
				return current.getSuggestions(lines, cursorLine, cursorCol, options);

			const offset = cursorOffset(
				lines,
				cursorLine,
				cursorCol,
				bang.prefixLength,
			);
			if (offset === undefined) return null;

			try {
				const prefix = completionPrefix(bang.command, offset);
				const retry = escapedPathCompletionInput(bang.command, offset, prefix);
				let completions: string[];
				if (retry) {
					// Escaped path prefixes need a quoted Nu retry.
					// Run the retry first so the common path pays one Nu spawn, not two.
					const retryCompletions = await nuCompletions(
						retry.code,
						retry.offset,
						cwd,
						options.signal,
						nuCommand,
					);
					const source = retryCompletions.length
						? retryCompletions
						: await nuCompletions(
								bang.command,
								offset,
								cwd,
								options.signal,
								nuCommand,
							);
					completions = filterEscapedPathCompletions(source, prefix);
				} else {
					completions = await nuCompletions(
						bang.command,
						offset,
						cwd,
						options.signal,
						nuCommand,
					);
				}
				const items = completionItems(completions);
				return items.length === 0 ? null : { prefix, items };
			} catch {
				if (options.signal.aborted) return null;
				const fallback = await current.getSuggestions(
					lines,
					cursorLine,
					cursorCol,
					options,
				);
				for (const item of fallback?.items ?? []) fallbackItems.add(item);
				return fallback;
			}
		},
		applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
			if (!stripBangPrefix(fullText(lines)) || fallbackItems.has(item))
				return current.applyCompletion(
					lines,
					cursorLine,
					cursorCol,
					item,
					prefix,
				);
			return replacePrefix(lines, cursorLine, cursorCol, prefix, item.value);
		},
		shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
			if (stripBangPrefix(fullText(lines))) return true;
			return (
				current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ??
				true
			);
		},
	};
}

async function cleanupTempDirs(): Promise<void> {
	for (const dir of [...tempDirs]) {
		await rm(dir, { recursive: true, force: true }).catch(() => {});
		tempDirs.delete(dir);
	}
}

export default function (pi: ExtensionAPI): void {
	let nuCommand = DEFAULT_NU_COMMAND;

	pi.on("session_shutdown", async () => {
		dbg?.("session.shutdown");
		try {
			await cleanupTempDirs();
		} finally {
			closeDebug();
		}
	});

	pi.on("session_start", (_event, ctx) => {
		dbg?.("session.start", { mode: ctx.mode });
		const setting = resolveSetting(pi, ctx, COMMAND_SETTING);
		if (!setting.ok)
			ctx.ui.notify(`${setting.error}; using ${DEFAULT_NU_COMMAND}`, "warning");
		nuCommand = setting.ok ? setting.value : DEFAULT_NU_COMMAND;
		ctx.ui.addAutocompleteProvider?.((current) =>
			wrapNuAutocompleteProvider(current, ctx.cwd, nuCommand),
		);
	});

	pi.on("user_bash", (event, ctx) => handleUserBash(event, ctx, nuCommand));
}