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,
});
}