import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem } from "@earendil-works/pi-tui"; import { Box, Text } from "@earendil-works/pi-tui"; import { existsSync, statSync, readdirSync } from "node:fs"; import { homedir } from "node:os"; import { resolve } from "node:path"; // /show-cwd — deterministically prints the harness's current working // directory directly into the chat transcript (no LLM involvement). // // /change-cwd — switches the harness process's working directory // (process.chdir). The argument is taken as a single path: the whole // remaining text after the command name. '~' is expanded manually since // pi does no shell expansion on command args. '/change-cwd -' jumps back // to the previous directory. // // Legacy aliases: /show-pwd and /change-pwd are still registered (they share // the exact handlers and argument completion) so typing "/pwd" out of // habit still surfaces them in the command dropdown. let previousCwd: string | null = null; // Split the raw typed argument into the directory part (preserved verbatim // in completion values so '~/', '../', absolute prefixes survive) and the // partial last segment being typed. function splitArgPrefix(text: string): { dirPart: string; partial: string } { const lastSlash = text.lastIndexOf("/"); if (lastSlash === -1) return { dirPart: "", partial: text }; return { dirPart: text.slice(0, lastSlash + 1), partial: text.slice(lastSlash + 1) }; } function completePathArgument(raw: string): AutocompleteItem[] | null { const text = raw ?? ""; const { dirPart, partial } = splitArgPrefix(text); // '.' / '..' segments: offer the segment itself so you can drill upward if (partial === "." || partial === "..") { return [{ value: dirPart + partial + "/", label: partial + "/" }]; } let baseDir: string; try { baseDir = expandPath(dirPart === "" ? "." : dirPart); if (!existsSync(baseDir) || !statSync(baseDir).isDirectory()) return null; } catch { return null; } const showHidden = partial.startsWith("."); const dirs: AutocompleteItem[] = []; const files: AutocompleteItem[] = []; let entries: string[]; try { entries = readdirSync(baseDir); } catch { return null; } for (const name of entries) { if (!name.startsWith(partial)) continue; if (!showHidden && name.startsWith(".")) continue; let isDir = false; try { isDir = statSync(resolve(baseDir, name)).isDirectory(); } catch { continue; // broken symlink etc. } const item: AutocompleteItem = { value: dirPart + name + (isDir ? "/" : ""), label: name + (isDir ? "/" : ""), description: resolve(baseDir, name), }; (isDir ? dirs : files).push(item); if (dirs.length + files.length >= 30) break; } const items = [...dirs, ...files]; return items.length > 0 ? items : null; } function expandPath(input: string): string { const trimmed = input.trim(); if (trimmed === "~") return homedir(); if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) { return resolve(homedir(), trimmed.slice(2)); } // Resolve relatives against the *current* cwd before it changes return resolve(process.cwd(), trimmed); } export default function (pi: ExtensionAPI) { pi.registerEntryRenderer("show-cwd", (entry, _meta, theme) => { const { cwd } = entry.data as { cwd: string }; const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); box.addChild(new Text(`${theme.bold("cwd")}: ${theme.fg("dim", cwd)}`)); return box; }); pi.registerEntryRenderer("change-cwd", (entry, _meta, theme) => { const { from, to, error, warning } = entry.data as { from?: string; to?: string; error?: string; warning?: string; }; const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text)); if (error) { box.addChild( new Text( `${theme.bold("cwd change failed")}: ${theme.fg("error", error)}`, ), ); } else { box.addChild( new Text( `${theme.bold("cwd")}: ${theme.fg("dim", from ?? "")} ${theme.fg("accent", "→")} ${to}`, ), ); if (warning) { box.addChild(new Text(theme.fg("warning", warning), 1, 0)); } } return box; }); const showCwdCommand = { description: "Print the current working directory in chat", handler: async () => { pi.appendEntry("show-cwd", { cwd: process.cwd() }); }, }; pi.registerCommand("show-cwd", showCwdCommand); // Legacy alias so "/pwd" muscle memory still finds it in the dropdown pi.registerCommand("show-pwd", { ...showCwdCommand, description: "Print the current working directory in chat (alias of /show-cwd)", }); const changeCwdDescription = "Change the harness working directory (usage: /change-cwd , or '-' for previous)"; const changeCwdCompletions = (prefix: string) => completePathArgument(prefix); const handleChangeCwd = async (args: string, ctx: ExtensionCommandContext) => { const raw = (args ?? "").trim(); // --- Empty: show usage instead of silently doing nothing --- if (!raw) { pi.appendEntry("change-cwd", { error: "no argument given — usage: /change-cwd ('-' = previous)", }); return; } // --- '-' fallback: jump back to where we came from --- const target = raw === "-" && previousCwd ? previousCwd : expandPath(raw); if (raw === "-" && !previousCwd) { pi.appendEntry("change-cwd", { error: "no previous directory recorded yet" }); return; } // --- Validate before chdir: process.chdir() throws on bad paths, // and we want a friendly message, not a crash/stack in the UI --- try { if (!existsSync(target)) { pi.appendEntry("change-cwd", { error: `path does not exist: ${target}` }); return; } if (!statSync(target).isDirectory()) { pi.appendEntry("change-cwd", { error: `not a directory: ${target}` }); return; } } catch (e) { pi.appendEntry("change-cwd", { error: `cannot inspect path: ${target} (${e instanceof Error ? e.message : String(e)})`, }); return; } const from = process.cwd(); try { process.chdir(target); } catch (e) { // e.g. EACCES / permission-denied on an existing dir pi.appendEntry("change-cwd", { error: `chdir failed: ${e instanceof Error ? e.message : String(e)}`, }); return; } previousCwd = from; pi.appendEntry("change-cwd", { from, to: process.cwd(), warning: "pi pins its session: agent bash/read tools and @ completion stay on the startup dir above until restart — " + "restart pi in the new dir for a full switch", }); ctx.ui.notify( `cwd → ${process.cwd()} (agent tools + @ completion still use ${from} until pi restarts)`, "warning", ); }; pi.registerCommand("change-cwd", { description: changeCwdDescription, getArgumentCompletions: changeCwdCompletions, handler: handleChangeCwd, }); // Legacy alias so "/pwd" muscle memory still finds it in the dropdown pi.registerCommand("change-pwd", { description: "Change the harness working directory (alias of /change-cwd)", getArgumentCompletions: changeCwdCompletions, handler: handleChangeCwd, }); }