Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/open/index.ts

Raw
import { spawn } from "node:child_process";
import { statSync } from "node:fs";
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
import type {
	ExtensionAPI,
	ExtensionContext,
	SessionEntry,
} from "@earendil-works/pi-coding-agent";
import {
	Container,
	fuzzyFilter,
	Input,
	SelectList,
} from "@earendil-works/pi-tui";
import { closeDebug, dbg, span } from "./src/debug.ts";
import { findExecutable, findExecutableFile } from "./src/pi-ext-executable.ts";

const MAX_CANDIDATES = 20;
const SOURCE_ORDER = ["user", "assistant", "edit", "write"] as const;
const SPLIT_PERCENT = "69";

type CandidateSource = (typeof SOURCE_ORDER)[number];

export interface FileCandidate {
	path: string;
	displayPath: string;
	source: CandidateSource;
}

function envValue(
	env: NodeJS.ProcessEnv,
	key: string,
	platform: NodeJS.Platform,
): string | undefined {
	if (platform !== "win32") return env[key];
	const actual = Object.keys(env).find(
		(candidate) => candidate.toLowerCase() === key.toLowerCase(),
	);
	return actual ? env[actual] : undefined;
}

function executableNames(command: string, platform: NodeJS.Platform): string[] {
	if (platform !== "win32" || extname(command)) return [command];
	return [command, `${command}.exe`, `${command}.com`];
}

function executableOnPath(
	command: string,
	env: NodeJS.ProcessEnv = process.env,
	platform: NodeJS.Platform = process.platform,
): string | undefined {
	const names = executableNames(command, platform);
	if (command.includes("/") || command.includes("\\")) {
		for (const name of names) {
			const candidate = findExecutableFile(resolve(name), { platform });
			if (candidate) return candidate;
		}
		return undefined;
	}
	return findExecutable(names, {
		path: envValue(env, "PATH", platform) ?? "",
		platform,
	});
}

function existingFile(rawPath: string, cwd: string): string | undefined {
	const trimmed = rawPath.trim().replace(/^(["'])(.*)\1$/u, "$2");
	if (!trimmed) return undefined;
	const path = resolve(cwd, trimmed);
	try {
		return statSync(path).isFile() ? path : undefined;
	} catch {
		return undefined;
	}
}

function displayPath(path: string, cwd: string): string {
	const local = relative(cwd, path);
	return local && !local.startsWith(`..${sep}`) && !isAbsolute(local)
		? local
		: path;
}

function pathLike(token: string): boolean {
	return (
		token.includes("/") ||
		token.includes("\\") ||
		token.startsWith(".") ||
		/^[^\s.][^/\\\s]*\.[A-Za-z0-9_-]+$/u.test(token)
	);
}

function trimToken(token: string): string {
	return token
		.trim()
		.replace(/^[@`'"<([{]+/u, "")
		.replace(/[`'">)\]},;!?]+$/u, "")
		.replace(/[.:]+$/u, "");
}

function extractTextPaths(text: string, cwd: string): string[] {
	const raw: Array<{ value: string; structured: boolean; index: number }> = [];
	for (const match of text.matchAll(/\]\(([^)\n]+)\)/gu)) {
		raw.push({ value: match[1], structured: true, index: match.index });
	}
	for (const match of text.matchAll(/`([^`\n]+)`/gu)) {
		raw.push({ value: match[1], structured: true, index: match.index });
	}
	for (const match of text.matchAll(/\S+/gu)) {
		raw.push({ value: match[0], structured: false, index: match.index });
	}
	raw.sort(
		(left, right) =>
			left.index - right.index ||
			Number(right.structured) - Number(left.structured),
	);

	const paths: string[] = [];
	const seen = new Set<string>();
	for (const item of raw) {
		const token = trimToken(item.value);
		if (!token || (!item.structured && !pathLike(token))) continue;
		const path = existingFile(token, cwd);
		if (!path || seen.has(path)) continue;
		seen.add(path);
		paths.push(path);
	}
	return paths;
}

function textFromContent(content: unknown): string[] {
	if (typeof content === "string") return [content];
	if (!Array.isArray(content)) return [];
	return content.flatMap((part) =>
		part &&
		typeof part === "object" &&
		"type" in part &&
		part.type === "text" &&
		"text" in part &&
		typeof part.text === "string"
			? [part.text]
			: [],
	);
}

function toolPath(
	block: unknown,
): { source: "edit" | "write"; path: string } | undefined {
	if (!block || typeof block !== "object" || !("type" in block))
		return undefined;
	if (block.type !== "toolCall" || !("name" in block)) return undefined;
	if (block.name !== "edit" && block.name !== "write") return undefined;
	if (
		!("arguments" in block) ||
		!block.arguments ||
		typeof block.arguments !== "object"
	)
		return undefined;
	if (!("path" in block.arguments) || typeof block.arguments.path !== "string")
		return undefined;
	return { source: block.name, path: block.arguments.path };
}

function collectCandidates(
	entries: readonly SessionEntry[] | readonly unknown[],
	cwd: string,
): FileCandidate[] {
	const buckets = new Map(
		SOURCE_ORDER.map((source) => [source, new Map<string, FileCandidate>()]),
	);
	const found = new Set<string>();
	const add = (path: string, source: CandidateSource): void => {
		const bucket = buckets.get(source);
		if (!bucket || bucket.has(path)) return;
		bucket.set(path, { path, displayPath: displayPath(path, cwd), source });
		found.add(path);
	};

	for (let index = entries.length - 1; index >= 0; index--) {
		const entry = entries[index];
		if (!entry || typeof entry !== "object" || !("type" in entry)) continue;
		if (entry.type !== "message" || !("message" in entry)) continue;
		const message = entry.message;
		if (!message || typeof message !== "object" || !("role" in message))
			continue;
		if (message.role === "user" && "content" in message) {
			for (const text of textFromContent(message.content).toReversed()) {
				for (const path of extractTextPaths(text, cwd).toReversed())
					add(path, "user");
			}
			if (found.size >= MAX_CANDIDATES) break;
			continue;
		}
		if (message.role !== "assistant" || !("content" in message)) continue;
		for (const block of Array.isArray(message.content)
			? message.content.toReversed()
			: []) {
			const touched = toolPath(block);
			if (touched) {
				const path = existingFile(touched.path, cwd);
				if (path) add(path, touched.source);
				continue;
			}
			for (const text of textFromContent([block])) {
				for (const path of extractTextPaths(text, cwd).toReversed())
					add(path, "assistant");
			}
		}
		if (found.size >= MAX_CANDIDATES) break;
	}

	const candidates: FileCandidate[] = [];
	const seen = new Set<string>();
	for (const source of SOURCE_ORDER) {
		for (const candidate of buckets.get(source)?.values() ?? []) {
			if (seen.has(candidate.path)) continue;
			seen.add(candidate.path);
			candidates.push(candidate);
			if (candidates.length === MAX_CANDIDATES) return candidates;
		}
	}
	return candidates;
}

function fuzzyCandidates(
	candidates: readonly FileCandidate[],
	query: string,
): FileCandidate[] {
	return fuzzyFilter(
		[...candidates],
		query,
		(candidate) => candidate.displayPath,
	);
}

async function chooseCandidate(
	candidates: readonly FileCandidate[],
	ctx: Pick<ExtensionContext, "mode" | "ui">,
): Promise<FileCandidate | undefined> {
	if (ctx.mode !== "tui") {
		const selected = await ctx.ui.select(
			"Open",
			candidates.map((candidate) => candidate.displayPath),
		);
		return candidates.find((candidate) => candidate.displayPath === selected);
	}
	const selectedPath = await ctx.ui.custom<string | undefined>(
		(tui, theme, keybindings, done) => {
			const container = new Container();
			const input = new Input();
			input.focused = true;
			let list: SelectList;

			function rebuild(): void {
				const filtered = fuzzyCandidates(candidates, input.getValue());
				list = new SelectList(
					filtered.map((candidate) => ({
						value: candidate.path,
						label: candidate.displayPath,
						description: candidate.source,
					})),
					Math.min(Math.max(filtered.length, 1), 8),
					{
						selectedPrefix: (text) => theme.fg("accent", text),
						selectedText: (text) => theme.fg("accent", text),
						description: (text) => theme.fg("dim", text),
						scrollInfo: (text) => theme.fg("dim", text),
						noMatch: (text) => theme.fg("warning", text),
					},
				);
				list.onSelect = (item) => done(item.value);
				list.onCancel = () => done(undefined);
				container.clear();
				container.addChild(input);
				container.addChild(list);
			}

			rebuild();
			let focused = true;
			return {
				get focused() {
					return focused;
				},
				set focused(value: boolean) {
					focused = value;
					input.focused = value;
				},
				render: (width: number) => container.render(width),
				invalidate: () => container.invalidate(),
				handleInput(data: string) {
					if (
						keybindings.matches(data, "tui.select.up") ||
						keybindings.matches(data, "tui.select.down")
					) {
						list.handleInput(data);
						tui.requestRender();
						return;
					}
					if (keybindings.matches(data, "tui.select.confirm")) {
						const selected = list.getSelectedItem();
						if (selected) done(selected.value);
						return;
					}
					if (keybindings.matches(data, "tui.select.cancel")) {
						done(undefined);
						return;
					}
					const previous = input.getValue();
					input.handleInput(data);
					if (input.getValue() !== previous) rebuild();
					tui.requestRender();
				},
			};
		},
	);
	return candidates.find((candidate) => candidate.path === selectedPath);
}

function splitArguments(
	paneId: string,
	cwd: string,
	editor: string,
	path: string,
): string[] {
	return [
		"cli",
		"split-pane",
		"--pane-id",
		paneId,
		"--right",
		"--percent",
		SPLIT_PERCENT,
		"--cwd",
		cwd,
		"--",
		editor,
		path,
	];
}

function termuxArguments(
	userId: string | undefined,
	cwd: string,
	editor: string,
	path: string,
): string[] {
	const androidUser = /^(?:0|[1-9][0-9]*)$/u.test(userId ?? "")
		? (userId as string)
		: "0";
	return [
		"startservice",
		"--user",
		androidUser,
		"-n",
		"com.termux/com.termux.app.RunCommandService",
		"-a",
		"com.termux.RUN_COMMAND",
		"--es",
		"com.termux.RUN_COMMAND_PATH",
		editor,
		"--esa",
		"com.termux.RUN_COMMAND_ARGUMENTS",
		path,
		"--es",
		"com.termux.RUN_COMMAND_WORKDIR",
		cwd,
		"--ez",
		"com.termux.RUN_COMMAND_BACKGROUND",
		"false",
		"--es",
		"com.termux.RUN_COMMAND_SESSION_ACTION",
		"0",
		"--es",
		"com.termux.RUN_COMMAND_LABEL",
		"Pi open",
	];
}

async function launchDetached(
	command: string,
	args: string[],
	cwd: string,
): Promise<void> {
	await new Promise<void>((resolveLaunch, reject) => {
		const child = spawn(command, args, {
			cwd,
			detached: true,
			stdio: "ignore",
			windowsHide: true,
		});
		child.once("error", reject);
		child.once("spawn", () => {
			child.unref();
			resolveLaunch();
		});
	});
}

export default function openExtension(pi: ExtensionAPI): void {
	const wezterm = executableOnPath("wezterm");
	const am = executableOnPath("am");
	const editorName = process.env.EDITOR?.trim();
	const editor = editorName ? executableOnPath(editorName) : undefined;
	const paneId = process.env.WEZTERM_PANE;
	const inTermux = Boolean(process.env.TERMUX_VERSION);
	const weztermReady = Boolean(wezterm && paneId);
	const termuxReady = Boolean(inTermux && am);

	pi.on("session_start", (_event, ctx) => {
		dbg?.("session.start", { mode: ctx.mode });
		if (!editorName) ctx.ui.notify("/open: EDITOR is not set", "warning");
		else if (!editor)
			ctx.ui.notify(`/open: EDITOR '${editorName}' is not on PATH`, "warning");
		if (!weztermReady && !termuxReady) {
			if (!wezterm) ctx.ui.notify("/open: wezterm is not on PATH", "warning");
			if (!paneId) ctx.ui.notify("/open: WEZTERM_PANE is not set", "warning");
			if (inTermux && !am) ctx.ui.notify("/open: am is not on PATH", "warning");
		}
	});

	pi.registerCommand("open", {
		description: "Open a file beside Pi",
		async handler(args, ctx) {
			const finish = span?.("open.launch", { platform: process.platform });
			try {
				if (!editor || (!weztermReady && !termuxReady)) {
					ctx.ui.notify("/open unavailable; see startup warnings", "error");
					finish?.("finish", { outcome: "unavailable" });
					return;
				}

				let path: string | undefined;
				if (args.trim()) {
					path = existingFile(args, ctx.cwd);
					if (!path) {
						ctx.ui.notify(`Not a file: ${args.trim()}`, "error");
						finish?.("finish", { outcome: "invalid" });
						return;
					}
				} else {
					const candidates = collectCandidates(
						ctx.sessionManager.getBranch(),
						ctx.cwd,
					);
					if (candidates.length === 0) {
						ctx.ui.notify("No mentioned files", "info");
						finish?.("finish", { outcome: "no_candidates" });
						return;
					}
					path =
						candidates.length === 1
							? candidates[0].path
							: (await chooseCandidate(candidates, ctx))?.path;
				}
				if (!path) {
					finish?.("finish", { outcome: "cancelled" });
					return;
				}

				try {
					if (wezterm && paneId) {
						await launchDetached(
							wezterm,
							splitArguments(paneId, ctx.cwd, editor, path),
							ctx.cwd,
						);
					} else if (am) {
						await launchDetached(
							am,
							termuxArguments(
								process.env.TERMUX__USER_ID,
								ctx.cwd,
								editor,
								path,
							),
							ctx.cwd,
						);
					}
					finish?.("finish", { kind: "launch", outcome: "launched" });
				} catch (error) {
					finish?.("error", { kind: "launch", outcome: "failed" });
					ctx.ui.notify(
						`/open failed: ${error instanceof Error ? error.message : String(error)}`,
						"error",
					);
				}
			} catch (error) {
				finish?.("error", { outcome: "failed" });
				throw error;
			}
		},
	});

	pi.on("session_shutdown", () => {
		dbg?.("session.shutdown");
		closeDebug();
	});
}

export const __test = {
	chooseCandidate,
	collectCandidates,
	executableOnPath,
	extractTextPaths,
	fuzzyCandidates,
	splitArguments,
	termuxArguments,
};