Commit 54f55132b54d

Vincent Demeester <vincent@sbr.pm>
2026-02-06 15:37:39
feat(pi): add files.ts extension and Emacs-style keybindings
Added interactive file browser extension: - Ctrl+X: Browse files (fuzzy search) - Alt+O: Reveal latest file reference - Alt+Q: Quick Look latest file (macOS) - /files command: List git tree + session files - Quick actions: reveal, open, edit, diff, add to prompt Configured Emacs-style keybindings: - Cursor movement: Ctrl+P/N/B/F - Word movement: Alt+B/F - Delete: Ctrl+D/H - New line: Ctrl+J - Model cycling: Alt+M/Alt+Shift+M (moved from Ctrl+P to free it) Includes KEYBINDINGS.md documentation. Downloaded from mitsuhiko/agent-stuff and adapted shortcuts to avoid conflicts with Kitty terminal (Ctrl+Shift+F) and Emacs navigation.
1 parent db5c3fb
Changed files (4)
dots/pi/agent/extensions/files.ts
@@ -0,0 +1,1114 @@
+/**
+ * Files Extension
+ *
+ * /files command lists files in the current git tree (plus session-referenced files)
+ * and offers quick actions like reveal, open, edit, or diff.
+ * /diff is kept as an alias to the same picker.
+ */
+
+import { spawnSync } from "node:child_process";
+import {
+	existsSync,
+	mkdtempSync,
+	readFileSync,
+	realpathSync,
+	statSync,
+	unlinkSync,
+	writeFileSync,
+} from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent";
+import { DynamicBorder } from "@mariozechner/pi-coding-agent";
+import {
+	Container,
+	fuzzyFilter,
+	getEditorKeybindings,
+	Input,
+	matchesKey,
+	type SelectItem,
+	SelectList,
+	Spacer,
+	Text,
+	type TUI,
+} from "@mariozechner/pi-tui";
+
+type ContentBlock = {
+	type?: string;
+	text?: string;
+	arguments?: Record<string, unknown>;
+};
+
+type FileReference = {
+	path: string;
+	display: string;
+	exists: boolean;
+	isDirectory: boolean;
+};
+
+type FileEntry = {
+	canonicalPath: string;
+	resolvedPath: string;
+	displayPath: string;
+	exists: boolean;
+	isDirectory: boolean;
+	status?: string;
+	inRepo: boolean;
+	isTracked: boolean;
+	isReferenced: boolean;
+	hasSessionChange: boolean;
+	lastTimestamp: number;
+};
+
+type GitStatusEntry = {
+	status: string;
+	exists: boolean;
+	isDirectory: boolean;
+};
+
+type FileToolName = "write" | "edit";
+
+type SessionFileChange = {
+	operations: Set<FileToolName>;
+	lastTimestamp: number;
+};
+
+const FILE_TAG_REGEX = /<file\s+name=["']([^"']+)["']>/g;
+const FILE_URL_REGEX = /file:\/\/[^\s"'<>]+/g;
+const PATH_REGEX = /(?:^|[\s"'`([{<])((?:~|\/)[^\s"'`<>)}\]]+)/g;
+
+const MAX_EDIT_BYTES = 40 * 1024 * 1024;
+
+const extractFileReferencesFromText = (text: string): string[] => {
+	const refs: string[] = [];
+
+	for (const match of text.matchAll(FILE_TAG_REGEX)) {
+		refs.push(match[1]);
+	}
+
+	for (const match of text.matchAll(FILE_URL_REGEX)) {
+		refs.push(match[0]);
+	}
+
+	for (const match of text.matchAll(PATH_REGEX)) {
+		refs.push(match[1]);
+	}
+
+	return refs;
+};
+
+const extractPathsFromToolArgs = (args: unknown): string[] => {
+	if (!args || typeof args !== "object") {
+		return [];
+	}
+
+	const refs: string[] = [];
+	const record = args as Record<string, unknown>;
+	const directKeys = ["path", "file", "filePath", "filepath", "fileName", "filename"] as const;
+	const listKeys = ["paths", "files", "filePaths"] as const;
+
+	for (const key of directKeys) {
+		const value = record[key];
+		if (typeof value === "string") {
+			refs.push(value);
+		}
+	}
+
+	for (const key of listKeys) {
+		const value = record[key];
+		if (Array.isArray(value)) {
+			for (const item of value) {
+				if (typeof item === "string") {
+					refs.push(item);
+				}
+			}
+		}
+	}
+
+	return refs;
+};
+
+const extractFileReferencesFromContent = (content: unknown): string[] => {
+	if (typeof content === "string") {
+		return extractFileReferencesFromText(content);
+	}
+
+	if (!Array.isArray(content)) {
+		return [];
+	}
+
+	const refs: string[] = [];
+	for (const part of content) {
+		if (!part || typeof part !== "object") {
+			continue;
+		}
+
+		const block = part as ContentBlock;
+
+		if (block.type === "text" && typeof block.text === "string") {
+			refs.push(...extractFileReferencesFromText(block.text));
+		}
+
+		if (block.type === "toolCall") {
+			refs.push(...extractPathsFromToolArgs(block.arguments));
+		}
+	}
+
+	return refs;
+};
+
+const extractFileReferencesFromEntry = (entry: SessionEntry): string[] => {
+	if (entry.type === "message") {
+		return extractFileReferencesFromContent(entry.message.content);
+	}
+
+	if (entry.type === "custom_message") {
+		return extractFileReferencesFromContent(entry.content);
+	}
+
+	return [];
+};
+
+const sanitizeReference = (raw: string): string => {
+	let value = raw.trim();
+	value = value.replace(/^["'`(<\[]+/, "");
+	value = value.replace(/[>"'`,;).\]]+$/, "");
+	value = value.replace(/[.,;:]+$/, "");
+	return value;
+};
+
+const isCommentLikeReference = (value: string): boolean => value.startsWith("//");
+
+const stripLineSuffix = (value: string): string => {
+	let result = value.replace(/#L\d+(C\d+)?$/i, "");
+	const lastSeparator = Math.max(result.lastIndexOf("/"), result.lastIndexOf("\\"));
+	const segmentStart = lastSeparator >= 0 ? lastSeparator + 1 : 0;
+	const segment = result.slice(segmentStart);
+	const colonIndex = segment.indexOf(":");
+	if (colonIndex >= 0 && /\d/.test(segment[colonIndex + 1] ?? "")) {
+		result = result.slice(0, segmentStart + colonIndex);
+		return result;
+	}
+
+	const lastColon = result.lastIndexOf(":");
+	if (lastColon > lastSeparator) {
+		const suffix = result.slice(lastColon + 1);
+		if (/^\d+(?::\d+)?$/.test(suffix)) {
+			result = result.slice(0, lastColon);
+		}
+	}
+	return result;
+};
+
+const normalizeReferencePath = (raw: string, cwd: string): string | null => {
+	let candidate = sanitizeReference(raw);
+	if (!candidate || isCommentLikeReference(candidate)) {
+		return null;
+	}
+
+	if (candidate.startsWith("file://")) {
+		try {
+			candidate = fileURLToPath(candidate);
+		} catch {
+			return null;
+		}
+	}
+
+	candidate = stripLineSuffix(candidate);
+	if (!candidate || isCommentLikeReference(candidate)) {
+		return null;
+	}
+
+	if (candidate.startsWith("~")) {
+		candidate = path.join(os.homedir(), candidate.slice(1));
+	}
+
+	if (!path.isAbsolute(candidate)) {
+		candidate = path.resolve(cwd, candidate);
+	}
+
+	candidate = path.normalize(candidate);
+	const root = path.parse(candidate).root;
+	if (candidate.length > root.length) {
+		candidate = candidate.replace(/[\\/]+$/, "");
+	}
+
+	return candidate;
+};
+
+const formatDisplayPath = (absolutePath: string, cwd: string): string => {
+	const normalizedCwd = path.resolve(cwd);
+	if (absolutePath.startsWith(normalizedCwd + path.sep)) {
+		return path.relative(normalizedCwd, absolutePath);
+	}
+
+	return absolutePath;
+};
+
+const collectRecentFileReferences = (entries: SessionEntry[], cwd: string, limit: number): FileReference[] => {
+	const results: FileReference[] = [];
+	const seen = new Set<string>();
+
+	for (let i = entries.length - 1; i >= 0 && results.length < limit; i -= 1) {
+		const refs = extractFileReferencesFromEntry(entries[i]);
+		for (let j = refs.length - 1; j >= 0 && results.length < limit; j -= 1) {
+			const normalized = normalizeReferencePath(refs[j], cwd);
+			if (!normalized || seen.has(normalized)) {
+				continue;
+			}
+
+			seen.add(normalized);
+
+			let exists = false;
+			let isDirectory = false;
+			if (existsSync(normalized)) {
+				exists = true;
+				const stats = statSync(normalized);
+				isDirectory = stats.isDirectory();
+			}
+
+			results.push({
+				path: normalized,
+				display: formatDisplayPath(normalized, cwd),
+				exists,
+				isDirectory,
+			});
+		}
+	}
+
+	return results;
+};
+
+const findLatestFileReference = (entries: SessionEntry[], cwd: string): FileReference | null => {
+	const refs = collectRecentFileReferences(entries, cwd, 100);
+	return refs.find((ref) => ref.exists) ?? null;
+};
+
+const toCanonicalPath = (inputPath: string): { canonicalPath: string; isDirectory: boolean } | null => {
+	if (!existsSync(inputPath)) {
+		return null;
+	}
+
+	try {
+		const canonicalPath = realpathSync(inputPath);
+		const stats = statSync(canonicalPath);
+		return { canonicalPath, isDirectory: stats.isDirectory() };
+	} catch {
+		return null;
+	}
+};
+
+const toCanonicalPathMaybeMissing = (
+	inputPath: string,
+): { canonicalPath: string; isDirectory: boolean; exists: boolean } | null => {
+	const resolvedPath = path.resolve(inputPath);
+	if (!existsSync(resolvedPath)) {
+		return { canonicalPath: path.normalize(resolvedPath), isDirectory: false, exists: false };
+	}
+
+	try {
+		const canonicalPath = realpathSync(resolvedPath);
+		const stats = statSync(canonicalPath);
+		return { canonicalPath, isDirectory: stats.isDirectory(), exists: true };
+	} catch {
+		return { canonicalPath: path.normalize(resolvedPath), isDirectory: false, exists: true };
+	}
+};
+
+const collectSessionFileChanges = (entries: SessionEntry[], cwd: string): Map<string, SessionFileChange> => {
+	const toolCalls = new Map<string, { path: string; name: FileToolName }>();
+
+	for (const entry of entries) {
+		if (entry.type !== "message") continue;
+		const msg = entry.message;
+
+		if (msg.role === "assistant" && Array.isArray(msg.content)) {
+			for (const block of msg.content) {
+				if (block.type === "toolCall") {
+					const name = block.name as FileToolName;
+					if (name === "write" || name === "edit") {
+						const filePath = block.arguments?.path;
+						if (filePath && typeof filePath === "string") {
+							toolCalls.set(block.id, { path: filePath, name });
+						}
+					}
+				}
+			}
+		}
+	}
+
+	const fileMap = new Map<string, SessionFileChange>();
+
+	for (const entry of entries) {
+		if (entry.type !== "message") continue;
+		const msg = entry.message;
+
+		if (msg.role === "toolResult") {
+			const toolCall = toolCalls.get(msg.toolCallId);
+			if (!toolCall) continue;
+
+			const resolvedPath = path.isAbsolute(toolCall.path)
+				? toolCall.path
+				: path.resolve(cwd, toolCall.path);
+			const canonical = toCanonicalPath(resolvedPath);
+			if (!canonical) {
+				continue;
+			}
+
+			const existing = fileMap.get(canonical.canonicalPath);
+			if (existing) {
+				existing.operations.add(toolCall.name);
+				if (msg.timestamp > existing.lastTimestamp) {
+					existing.lastTimestamp = msg.timestamp;
+				}
+			} else {
+				fileMap.set(canonical.canonicalPath, {
+					operations: new Set([toolCall.name]),
+					lastTimestamp: msg.timestamp,
+				});
+			}
+		}
+	}
+
+	return fileMap;
+};
+
+const splitNullSeparated = (value: string): string[] => value.split("\0").filter(Boolean);
+
+const getGitRoot = async (pi: ExtensionAPI, cwd: string): Promise<string | null> => {
+	const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd });
+	if (result.code !== 0) {
+		return null;
+	}
+
+	const root = result.stdout.trim();
+	return root ? root : null;
+};
+
+const getGitStatusMap = async (pi: ExtensionAPI, cwd: string): Promise<Map<string, GitStatusEntry>> => {
+	const statusMap = new Map<string, GitStatusEntry>();
+	const statusResult = await pi.exec("git", ["status", "--porcelain=1", "-z"], { cwd });
+	if (statusResult.code !== 0 || !statusResult.stdout) {
+		return statusMap;
+	}
+
+	const entries = splitNullSeparated(statusResult.stdout);
+	for (let i = 0; i < entries.length; i += 1) {
+		const entry = entries[i];
+		if (!entry || entry.length < 4) continue;
+		const status = entry.slice(0, 2);
+		const statusLabel = status.replace(/\s/g, "") || status.trim();
+		let filePath = entry.slice(3);
+		if ((status.startsWith("R") || status.startsWith("C")) && entries[i + 1]) {
+			filePath = entries[i + 1];
+			i += 1;
+		}
+		if (!filePath) continue;
+
+		const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
+		const canonical = toCanonicalPathMaybeMissing(resolved);
+		if (!canonical) continue;
+		statusMap.set(canonical.canonicalPath, {
+			status: statusLabel,
+			exists: canonical.exists,
+			isDirectory: canonical.isDirectory,
+		});
+	}
+
+	return statusMap;
+};
+
+const getGitFiles = async (
+	pi: ExtensionAPI,
+	gitRoot: string,
+): Promise<{ tracked: Set<string>; files: Array<{ canonicalPath: string; isDirectory: boolean }> }> => {
+	const tracked = new Set<string>();
+	const files: Array<{ canonicalPath: string; isDirectory: boolean }> = [];
+
+	const trackedResult = await pi.exec("git", ["ls-files", "-z"], { cwd: gitRoot });
+	if (trackedResult.code === 0 && trackedResult.stdout) {
+		for (const relativePath of splitNullSeparated(trackedResult.stdout)) {
+			const resolvedPath = path.resolve(gitRoot, relativePath);
+			const canonical = toCanonicalPath(resolvedPath);
+			if (!canonical) continue;
+			tracked.add(canonical.canonicalPath);
+			files.push(canonical);
+		}
+	}
+
+	const untrackedResult = await pi.exec("git", ["ls-files", "-z", "--others", "--exclude-standard"], { cwd: gitRoot });
+	if (untrackedResult.code === 0 && untrackedResult.stdout) {
+		for (const relativePath of splitNullSeparated(untrackedResult.stdout)) {
+			const resolvedPath = path.resolve(gitRoot, relativePath);
+			const canonical = toCanonicalPath(resolvedPath);
+			if (!canonical) continue;
+			files.push(canonical);
+		}
+	}
+
+	return { tracked, files };
+};
+
+const buildFileEntries = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<{ files: FileEntry[]; gitRoot: string | null }> => {
+	const entries = ctx.sessionManager.getBranch();
+	const sessionChanges = collectSessionFileChanges(entries, ctx.cwd);
+	const gitRoot = await getGitRoot(pi, ctx.cwd);
+	const statusMap = gitRoot ? await getGitStatusMap(pi, gitRoot) : new Map<string, GitStatusEntry>();
+
+	let trackedSet = new Set<string>();
+	let gitFiles: Array<{ canonicalPath: string; isDirectory: boolean }> = [];
+	if (gitRoot) {
+		const gitListing = await getGitFiles(pi, gitRoot);
+		trackedSet = gitListing.tracked;
+		gitFiles = gitListing.files;
+	}
+
+	const fileMap = new Map<string, FileEntry>();
+
+	const upsertFile = (data: Partial<FileEntry> & { canonicalPath: string; isDirectory: boolean }) => {
+		const existing = fileMap.get(data.canonicalPath);
+		const displayPath = data.displayPath ?? formatDisplayPath(data.canonicalPath, ctx.cwd);
+
+		if (existing) {
+			fileMap.set(data.canonicalPath, {
+				...existing,
+				...data,
+				displayPath,
+				exists: data.exists ?? existing.exists,
+				isDirectory: data.isDirectory ?? existing.isDirectory,
+				isReferenced: existing.isReferenced || data.isReferenced === true,
+				inRepo: existing.inRepo || data.inRepo === true,
+				isTracked: existing.isTracked || data.isTracked === true,
+				hasSessionChange: existing.hasSessionChange || data.hasSessionChange === true,
+				lastTimestamp: Math.max(existing.lastTimestamp, data.lastTimestamp ?? 0),
+			});
+			return;
+		}
+
+		fileMap.set(data.canonicalPath, {
+			canonicalPath: data.canonicalPath,
+			resolvedPath: data.resolvedPath ?? data.canonicalPath,
+			displayPath,
+			exists: data.exists ?? true,
+			isDirectory: data.isDirectory,
+			status: data.status,
+			inRepo: data.inRepo ?? false,
+			isTracked: data.isTracked ?? false,
+			isReferenced: data.isReferenced ?? false,
+			hasSessionChange: data.hasSessionChange ?? false,
+			lastTimestamp: data.lastTimestamp ?? 0,
+		});
+	};
+
+	for (const file of gitFiles) {
+		upsertFile({
+			canonicalPath: file.canonicalPath,
+			resolvedPath: file.canonicalPath,
+			isDirectory: file.isDirectory,
+			exists: true,
+			status: statusMap.get(file.canonicalPath)?.status,
+			inRepo: true,
+			isTracked: trackedSet.has(file.canonicalPath),
+		});
+	}
+
+	for (const [canonicalPath, statusEntry] of statusMap.entries()) {
+		if (fileMap.has(canonicalPath)) {
+			continue;
+		}
+
+		const inRepo =
+			gitRoot !== null &&
+			!path.relative(gitRoot, canonicalPath).startsWith("..") &&
+			!path.isAbsolute(path.relative(gitRoot, canonicalPath));
+
+		upsertFile({
+			canonicalPath,
+			resolvedPath: canonicalPath,
+			isDirectory: statusEntry.isDirectory,
+			exists: statusEntry.exists,
+			status: statusEntry.status,
+			inRepo,
+			isTracked: trackedSet.has(canonicalPath) || statusEntry.status !== "??",
+		});
+	}
+
+	const references = collectRecentFileReferences(entries, ctx.cwd, 200).filter((ref) => ref.exists);
+	for (const ref of references) {
+		const canonical = toCanonicalPath(ref.path);
+		if (!canonical) continue;
+
+		const inRepo =
+			gitRoot !== null &&
+			!path.relative(gitRoot, canonical.canonicalPath).startsWith("..") &&
+			!path.isAbsolute(path.relative(gitRoot, canonical.canonicalPath));
+
+		upsertFile({
+			canonicalPath: canonical.canonicalPath,
+			resolvedPath: canonical.canonicalPath,
+			isDirectory: canonical.isDirectory,
+			exists: true,
+			status: statusMap.get(canonical.canonicalPath)?.status,
+			inRepo,
+			isTracked: trackedSet.has(canonical.canonicalPath),
+			isReferenced: true,
+		});
+	}
+
+	for (const [canonicalPath, change] of sessionChanges.entries()) {
+		const canonical = toCanonicalPath(canonicalPath);
+		if (!canonical) continue;
+
+		const inRepo =
+			gitRoot !== null &&
+			!path.relative(gitRoot, canonical.canonicalPath).startsWith("..") &&
+			!path.isAbsolute(path.relative(gitRoot, canonical.canonicalPath));
+
+		upsertFile({
+			canonicalPath: canonical.canonicalPath,
+			resolvedPath: canonical.canonicalPath,
+			isDirectory: canonical.isDirectory,
+			exists: true,
+			status: statusMap.get(canonical.canonicalPath)?.status,
+			inRepo,
+			isTracked: trackedSet.has(canonical.canonicalPath),
+			hasSessionChange: true,
+			lastTimestamp: change.lastTimestamp,
+		});
+	}
+
+	const files = Array.from(fileMap.values()).sort((a, b) => {
+		const aDirty = Boolean(a.status);
+		const bDirty = Boolean(b.status);
+		if (aDirty !== bDirty) {
+			return aDirty ? -1 : 1;
+		}
+		if (a.inRepo !== b.inRepo) {
+			return a.inRepo ? -1 : 1;
+		}
+		if (a.hasSessionChange !== b.hasSessionChange) {
+			return a.hasSessionChange ? -1 : 1;
+		}
+		if (a.lastTimestamp !== b.lastTimestamp) {
+			return b.lastTimestamp - a.lastTimestamp;
+		}
+		if (a.isReferenced !== b.isReferenced) {
+			return a.isReferenced ? -1 : 1;
+		}
+		return a.displayPath.localeCompare(b.displayPath);
+	});
+
+	return { files, gitRoot };
+};
+
+type EditCheckResult = {
+	allowed: boolean;
+	reason?: string;
+	content?: string;
+};
+
+const getEditableContent = (target: FileEntry): EditCheckResult => {
+	if (!existsSync(target.resolvedPath)) {
+		return { allowed: false, reason: "File not found" };
+	}
+
+	const stats = statSync(target.resolvedPath);
+	if (stats.isDirectory()) {
+		return { allowed: false, reason: "Directories cannot be edited" };
+	}
+
+	if (stats.size >= MAX_EDIT_BYTES) {
+		return { allowed: false, reason: "File is too large" };
+	}
+
+	const buffer = readFileSync(target.resolvedPath);
+	if (buffer.includes(0)) {
+		return { allowed: false, reason: "File contains null bytes" };
+	}
+
+	return { allowed: true, content: buffer.toString("utf8") };
+};
+
+const showActionSelector = async (
+	ctx: ExtensionContext,
+	options: { canQuickLook: boolean; canEdit: boolean; canDiff: boolean },
+): Promise<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff" | null> => {
+	const actions: SelectItem[] = [
+		...(options.canDiff ? [{ value: "diff", label: "Diff in VS Code" }] : []),
+		{ value: "reveal", label: "Reveal in Finder" },
+		{ value: "open", label: "Open" },
+		{ value: "addToPrompt", label: "Add to prompt" },
+		...(options.canQuickLook ? [{ value: "quicklook", label: "Open in Quick Look" }] : []),
+		...(options.canEdit ? [{ value: "edit", label: "Edit" }] : []),
+	];
+
+	return ctx.ui.custom<"reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff" | null>((tui, theme, _kb, done) => {
+		const container = new Container();
+		container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
+		container.addChild(new Text(theme.fg("accent", theme.bold("Choose action"))));
+
+		const selectList = new SelectList(actions, actions.length, {
+			selectedPrefix: (text) => theme.fg("accent", text),
+			selectedText: (text) => theme.fg("accent", text),
+			description: (text) => theme.fg("muted", text),
+			scrollInfo: (text) => theme.fg("dim", text),
+			noMatch: (text) => theme.fg("warning", text),
+		});
+
+		selectList.onSelect = (item) => done(item.value as "reveal" | "quicklook" | "open" | "edit" | "addToPrompt" | "diff");
+		selectList.onCancel = () => done(null);
+
+		container.addChild(selectList);
+		container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to cancel")));
+		container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
+
+		return {
+			render(width: number) {
+				return container.render(width);
+			},
+			invalidate() {
+				container.invalidate();
+			},
+			handleInput(data: string) {
+				selectList.handleInput(data);
+				tui.requestRender();
+			},
+		};
+	});
+};
+
+const openPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
+	if (!existsSync(target.resolvedPath)) {
+		ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
+		return;
+	}
+
+	const command = process.platform === "darwin" ? "open" : "xdg-open";
+	const result = await pi.exec(command, [target.resolvedPath]);
+	if (result.code !== 0) {
+		const errorMessage = result.stderr?.trim() || `Failed to open ${target.displayPath}`;
+		ctx.ui.notify(errorMessage, "error");
+	}
+};
+
+const openExternalEditor = (tui: TUI, editorCmd: string, content: string): string | null => {
+	const tmpFile = path.join(os.tmpdir(), `pi-files-edit-${Date.now()}.txt`);
+
+	try {
+		writeFileSync(tmpFile, content, "utf8");
+		tui.stop();
+
+		const [editor, ...editorArgs] = editorCmd.split(" ");
+		const result = spawnSync(editor, [...editorArgs, tmpFile], { stdio: "inherit" });
+
+		if (result.status === 0) {
+			return readFileSync(tmpFile, "utf8").replace(/\n$/, "");
+		}
+
+		return null;
+	} finally {
+		try {
+			unlinkSync(tmpFile);
+		} catch {
+		}
+		tui.start();
+		tui.requestRender(true);
+	}
+};
+
+const editPath = async (ctx: ExtensionContext, target: FileEntry, content: string): Promise<void> => {
+	const editorCmd = process.env.VISUAL || process.env.EDITOR;
+	if (!editorCmd) {
+		ctx.ui.notify("No editor configured. Set $VISUAL or $EDITOR.", "warning");
+		return;
+	}
+
+	const updated = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+		const status = new Text(theme.fg("dim", `Opening ${editorCmd}...`));
+
+		queueMicrotask(() => {
+			const result = openExternalEditor(tui, editorCmd, content);
+			done(result);
+		});
+
+		return status;
+	});
+
+	if (updated === null) {
+		ctx.ui.notify("Edit cancelled", "info");
+		return;
+	}
+
+	try {
+		writeFileSync(target.resolvedPath, updated, "utf8");
+	} catch {
+		ctx.ui.notify(`Failed to save ${target.displayPath}`, "error");
+	}
+};
+
+const revealPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
+	if (!existsSync(target.resolvedPath)) {
+		ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
+		return;
+	}
+
+	const isDirectory = target.isDirectory || statSync(target.resolvedPath).isDirectory();
+	let command = "open";
+	let args: string[] = [];
+
+	if (process.platform === "darwin") {
+		args = isDirectory ? [target.resolvedPath] : ["-R", target.resolvedPath];
+	} else {
+		command = "xdg-open";
+		args = [isDirectory ? target.resolvedPath : path.dirname(target.resolvedPath)];
+	}
+
+	const result = await pi.exec(command, args);
+	if (result.code !== 0) {
+		const errorMessage = result.stderr?.trim() || `Failed to reveal ${target.displayPath}`;
+		ctx.ui.notify(errorMessage, "error");
+	}
+};
+
+const quickLookPath = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry): Promise<void> => {
+	if (process.platform !== "darwin") {
+		ctx.ui.notify("Quick Look is only available on macOS", "warning");
+		return;
+	}
+
+	if (!existsSync(target.resolvedPath)) {
+		ctx.ui.notify(`File not found: ${target.displayPath}`, "error");
+		return;
+	}
+
+	const isDirectory = target.isDirectory || statSync(target.resolvedPath).isDirectory();
+	if (isDirectory) {
+		ctx.ui.notify("Quick Look only works on files", "warning");
+		return;
+	}
+
+	const result = await pi.exec("qlmanage", ["-p", target.resolvedPath]);
+	if (result.code !== 0) {
+		const errorMessage = result.stderr?.trim() || `Failed to Quick Look ${target.displayPath}`;
+		ctx.ui.notify(errorMessage, "error");
+	}
+};
+
+const openDiff = async (pi: ExtensionAPI, ctx: ExtensionContext, target: FileEntry, gitRoot: string | null): Promise<void> => {
+	if (!gitRoot) {
+		ctx.ui.notify("Git repository not found", "warning");
+		return;
+	}
+
+	const relativePath = path.relative(gitRoot, target.resolvedPath).split(path.sep).join("/");
+	const tmpDir = mkdtempSync(path.join(os.tmpdir(), "pi-files-"));
+	const tmpFile = path.join(tmpDir, path.basename(target.displayPath));
+
+	const existsInHead = await pi.exec("git", ["cat-file", "-e", `HEAD:${relativePath}`], { cwd: gitRoot });
+	if (existsInHead.code === 0) {
+		const result = await pi.exec("git", ["show", `HEAD:${relativePath}`], { cwd: gitRoot });
+		if (result.code !== 0) {
+			const errorMessage = result.stderr?.trim() || `Failed to diff ${target.displayPath}`;
+			ctx.ui.notify(errorMessage, "error");
+			return;
+		}
+		writeFileSync(tmpFile, result.stdout ?? "", "utf8");
+	} else {
+		writeFileSync(tmpFile, "", "utf8");
+	}
+
+	let workingPath = target.resolvedPath;
+	if (!existsSync(target.resolvedPath)) {
+		workingPath = path.join(tmpDir, `pi-files-working-${path.basename(target.displayPath)}`);
+		writeFileSync(workingPath, "", "utf8");
+	}
+
+	const openResult = await pi.exec("code", ["--diff", tmpFile, workingPath], { cwd: gitRoot });
+	if (openResult.code !== 0) {
+		const errorMessage = openResult.stderr?.trim() || `Failed to open diff for ${target.displayPath}`;
+		ctx.ui.notify(errorMessage, "error");
+	}
+};
+
+const addFileToPrompt = (ctx: ExtensionContext, target: FileEntry): void => {
+	const mentionTarget = target.displayPath || target.resolvedPath;
+	const mention = `@${mentionTarget}`;
+	const current = ctx.ui.getEditorText();
+	const separator = current && !current.endsWith(" ") ? " " : "";
+	ctx.ui.setEditorText(`${current}${separator}${mention}`);
+	ctx.ui.notify(`Added ${mention} to prompt`, "info");
+};
+
+const showFileSelector = async (
+	ctx: ExtensionContext,
+	files: FileEntry[],
+	selectedPath?: string | null,
+	gitRoot?: string | null,
+): Promise<{ selected: FileEntry | null; quickAction: "diff" | null }> => {
+	const items: SelectItem[] = files.map((file) => {
+		const directoryLabel = file.isDirectory ? " [directory]" : "";
+		return {
+			value: file.canonicalPath,
+			label: `${file.displayPath}${directoryLabel}`,
+			description: file.status ? `[${file.status}]` : undefined,
+		};
+	});
+
+	let quickAction: "diff" | null = null;
+	const selection = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
+		const container = new Container();
+		container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
+		container.addChild(new Text(theme.fg("accent", theme.bold(" Select file")), 0, 0));
+
+		const searchInput = new Input();
+		container.addChild(searchInput);
+		container.addChild(new Spacer(1));
+
+		const listContainer = new Container();
+		container.addChild(listContainer);
+		container.addChild(
+			new Text(theme.fg("dim", "Type to filter • enter to select • ctrl+shift+d diff • esc to cancel"), 0, 0),
+		);
+		container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
+
+		let filteredItems = items;
+		let selectList: SelectList | null = null;
+
+		const updateList = () => {
+			listContainer.clear();
+			if (filteredItems.length === 0) {
+				listContainer.addChild(new Text(theme.fg("warning", "  No matching files"), 0, 0));
+				selectList = null;
+				return;
+			}
+
+			selectList = new SelectList(filteredItems, Math.min(filteredItems.length, 12), {
+				selectedPrefix: (text) => theme.fg("accent", text),
+				selectedText: (text) => theme.fg("accent", text),
+				description: (text) => theme.fg("muted", text),
+				scrollInfo: (text) => theme.fg("dim", text),
+				noMatch: (text) => theme.fg("warning", text),
+			});
+
+			if (selectedPath) {
+				const index = filteredItems.findIndex((item) => item.value === selectedPath);
+				if (index >= 0) {
+					selectList.setSelectedIndex(index);
+				}
+			}
+
+			selectList.onSelect = (item) => done(item.value as string);
+			selectList.onCancel = () => done(null);
+
+			listContainer.addChild(selectList);
+		};
+
+		const applyFilter = () => {
+			const query = searchInput.getValue();
+			filteredItems = query
+				? fuzzyFilter(items, query, (item) => `${item.label} ${item.value} ${item.description ?? ""}`)
+				: items;
+			updateList();
+		};
+
+		applyFilter();
+
+		return {
+			render(width: number) {
+				return container.render(width);
+			},
+			invalidate() {
+				container.invalidate();
+			},
+			handleInput(data: string) {
+				if (matchesKey(data, "ctrl+shift+d")) {
+					const selected = selectList?.getSelectedItem();
+					if (selected) {
+						const file = files.find((entry) => entry.canonicalPath === selected.value);
+						const canDiff = file?.isTracked && !file.isDirectory && Boolean(gitRoot);
+						if (!canDiff) {
+							ctx.ui.notify("Diff is only available for tracked files", "warning");
+							return;
+						}
+						quickAction = "diff";
+						done(selected.value as string);
+						return;
+					}
+				}
+
+				const kb = getEditorKeybindings();
+				if (
+					kb.matches(data, "selectUp") ||
+					kb.matches(data, "selectDown") ||
+					kb.matches(data, "selectConfirm") ||
+					kb.matches(data, "selectCancel")
+				) {
+					if (selectList) {
+						selectList.handleInput(data);
+					} else if (kb.matches(data, "selectCancel")) {
+						done(null);
+					}
+					tui.requestRender();
+					return;
+				}
+
+				searchInput.handleInput(data);
+				applyFilter();
+				tui.requestRender();
+			},
+		};
+	});
+
+	const selected = selection ? files.find((file) => file.canonicalPath === selection) ?? null : null;
+	return { selected, quickAction };
+};
+
+const runFileBrowser = async (pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> => {
+	if (!ctx.hasUI) {
+		ctx.ui.notify("Files requires interactive mode", "error");
+		return;
+	}
+
+	const { files, gitRoot } = await buildFileEntries(pi, ctx);
+	if (files.length === 0) {
+		ctx.ui.notify("No files found", "info");
+		return;
+	}
+
+	let lastSelectedPath: string | null = null;
+	while (true) {
+		const { selected, quickAction } = await showFileSelector(ctx, files, lastSelectedPath, gitRoot);
+		if (!selected) {
+			ctx.ui.notify("Files cancelled", "info");
+			return;
+		}
+
+		lastSelectedPath = selected.canonicalPath;
+
+		const canQuickLook = process.platform === "darwin" && !selected.isDirectory;
+		const editCheck = getEditableContent(selected);
+		const canDiff = selected.isTracked && !selected.isDirectory && Boolean(gitRoot);
+
+		if (quickAction === "diff") {
+			await openDiff(pi, ctx, selected, gitRoot);
+			continue;
+		}
+
+		const action = await showActionSelector(ctx, {
+			canQuickLook,
+			canEdit: editCheck.allowed,
+			canDiff,
+		});
+		if (!action) {
+			continue;
+		}
+
+		switch (action) {
+			case "quicklook":
+				await quickLookPath(pi, ctx, selected);
+				break;
+			case "open":
+				await openPath(pi, ctx, selected);
+				break;
+			case "edit":
+				if (!editCheck.allowed || editCheck.content === undefined) {
+					ctx.ui.notify(editCheck.reason ?? "File cannot be edited", "warning");
+					break;
+				}
+				await editPath(ctx, selected, editCheck.content);
+				break;
+			case "addToPrompt":
+				addFileToPrompt(ctx, selected);
+				break;
+			case "diff":
+				await openDiff(pi, ctx, selected, gitRoot);
+				break;
+			default:
+				await revealPath(pi, ctx, selected);
+				break;
+		}
+	}
+};
+
+export default function (pi: ExtensionAPI): void {
+	pi.registerCommand("files", {
+		description: "Browse files with git status and session references",
+		handler: async (_args, ctx) => {
+			await runFileBrowser(pi, ctx);
+		},
+	});
+
+	pi.registerShortcut("ctrl+x", {
+		description: "Browse files mentioned in the session",
+		handler: async (ctx) => {
+			await runFileBrowser(pi, ctx);
+		},
+	});
+
+	pi.registerShortcut("alt+o", {
+		description: "Reveal the latest file reference in Finder",
+		handler: async (ctx) => {
+			const entries = ctx.sessionManager.getBranch();
+			const latest = findLatestFileReference(entries, ctx.cwd);
+
+			if (!latest) {
+				ctx.ui.notify("No file reference found in the session", "warning");
+				return;
+			}
+
+			const canonical = toCanonicalPath(latest.path);
+			if (!canonical) {
+				ctx.ui.notify(`File not found: ${latest.display}`, "error");
+				return;
+			}
+
+			await revealPath(pi, ctx, {
+				canonicalPath: canonical.canonicalPath,
+				resolvedPath: canonical.canonicalPath,
+				displayPath: latest.display,
+				exists: true,
+				isDirectory: canonical.isDirectory,
+				status: undefined,
+				inRepo: false,
+				isTracked: false,
+				isReferenced: true,
+				hasSessionChange: false,
+				lastTimestamp: 0,
+			});
+		},
+	});
+
+	pi.registerShortcut("alt+q", {
+		description: "Quick Look the latest file reference",
+		handler: async (ctx) => {
+			const entries = ctx.sessionManager.getBranch();
+			const latest = findLatestFileReference(entries, ctx.cwd);
+
+			if (!latest) {
+				ctx.ui.notify("No file reference found in the session", "warning");
+				return;
+			}
+
+			const canonical = toCanonicalPath(latest.path);
+			if (!canonical) {
+				ctx.ui.notify(`File not found: ${latest.display}`, "error");
+				return;
+			}
+
+			await quickLookPath(pi, ctx, {
+				canonicalPath: canonical.canonicalPath,
+				resolvedPath: canonical.canonicalPath,
+				displayPath: latest.display,
+				exists: true,
+				isDirectory: canonical.isDirectory,
+				status: undefined,
+				inRepo: false,
+				isTracked: false,
+				isReferenced: true,
+				hasSessionChange: false,
+				lastTimestamp: 0,
+			});
+		},
+	});
+}
dots/pi/agent/keybindings.json
@@ -0,0 +1,13 @@
+{
+  "cursorUp": ["up", "ctrl+p"],
+  "cursorDown": ["down", "ctrl+n"],
+  "cursorLeft": ["left", "ctrl+b"],
+  "cursorRight": ["right", "ctrl+f"],
+  "cursorWordLeft": ["alt+left", "alt+b"],
+  "cursorWordRight": ["alt+right", "alt+f"],
+  "deleteCharForward": ["delete", "ctrl+d"],
+  "deleteCharBackward": ["backspace", "ctrl+h"],
+  "newLine": ["shift+enter", "ctrl+j"],
+  "cycleModelForward": ["alt+m"],
+  "cycleModelBackward": ["alt+shift+m"]
+}
dots/pi/agent/KEYBINDINGS.md
@@ -0,0 +1,78 @@
+# Pi Keybindings Reference
+
+## Custom Extension Shortcuts
+
+### Files Extension
+
+| Shortcut | Action | Mnemonic |
+|----------|--------|----------|
+| `Ctrl+X` | Browse files | "X" like Emacs C-x prefix |
+| `Alt+O` | Reveal latest file | "O" for Open/Reveal |
+| `Alt+Q` | Quick Look latest file | "Q" for Quick look |
+| `/files` | Browse files (command) | - |
+
+### Prompt History (cwd-history)
+
+| Shortcut | Action |
+|----------|--------|
+| `Up`/`Down` | Navigate prompt history (auto-seeded from sessions) |
+
+## Emacs-Style Navigation (keybindings.json)
+
+### Cursor Movement
+- `Ctrl+P` / `Ctrl+N` - Up / Down
+- `Ctrl+B` / `Ctrl+F` - Left / Right
+- `Alt+B` / `Alt+F` - Word left / right
+
+### Editing
+- `Ctrl+D` - Delete forward
+- `Ctrl+H` - Delete backward
+- `Ctrl+J` - New line
+
+### Model Selection
+- `Ctrl+L` - Open model selector (built-in)
+- `Alt+M` - Cycle to next model (custom)
+- `Alt+Shift+M` - Cycle to previous model (custom)
+
+## Reserved Keys (Built-in Pi)
+
+### Navigation
+- `alt+b` - cursorWordLeft
+- `alt+f` - cursorWordRight ⚠️
+- `alt+d` - deleteWordForward
+- `alt+y` - yankPop
+
+### Application
+- `ctrl+c` - copy/clear
+- `ctrl+d` - deleteCharForward/exit (overridden in keybindings.json)
+- `ctrl+g` - external editor
+- `ctrl+l` - select model
+- `ctrl+o` - expand tools
+- `ctrl+p` - cycle model (overridden to cursorUp in keybindings.json)
+- `ctrl+t` - toggle thinking
+- `ctrl+z` - suspend
+
+### Message Queue
+- `alt+enter` - followUp
+- `alt+up` - dequeue
+
+## Available for Custom Extensions
+
+### Safe Alt + Letters
+`alt+h`, `alt+i`, `alt+j`, `alt+k`, `alt+l`, `alt+m`, `alt+n`, `alt+o`, `alt+q`, `alt+s`, `alt+u`, `alt+v`, `alt+w`, `alt+x`, `alt+z`
+
+### Safe Ctrl + Letters
+`ctrl+i`, `ctrl+j`, `ctrl+m`, `ctrl+q`, `ctrl+r`, `ctrl+s`, `ctrl+x`
+
+### Function Keys
+`F1` through `F12` (all available)
+
+### Ctrl+Alt Combinations
+Most `ctrl+alt+letter` combinations are available
+
+## Configuration
+
+- **Global keybindings**: `~/.pi/agent/keybindings.json`
+- **Extension shortcuts**: Defined in extension files (e.g., `files.ts`)
+
+See [pi docs](https://github.com/badlogic/pi-mono) for full keybindings documentation.
dots/Makefile
@@ -58,7 +58,7 @@ all += git-template copilot-hooks opencode-plugin pi-agent agent-skills agent-sk
 git-template : ~/.config/git/template
 copilot-hooks : ~/.config/copilot-hooks
 opencode-plugin : ~/.config/opencode/plugin
-pi-agent : ~/.pi/agent/extensions ~/.pi/agent/AGENTS.md ~/.pi/agent/README.md
+pi-agent : ~/.pi/agent/extensions ~/.pi/agent/AGENTS.md ~/.pi/agent/README.md ~/.pi/agent/keybindings.json
 agent-skills : ~/.config/agent-skills
 agent-skill-manager-bin : ~/bin/agent-skill-manager
 ai-config : ~/.config/ai/skills ~/.config/ai/path-policies.json
@@ -122,3 +122,8 @@ all : $(all)
 .PHONY: all $(all)
 .DEFAULT_GOAL := all
 
+
+~/.pi/agent/keybindings.json : force
+	@echo "🔗 Linking $(dotfiles)/pi/agent/keybindings.json -> ~/.pi/agent/keybindings.json"
+	@mkdir -p ~/.pi/agent
+	@ln -snf $(dotfiles)/pi/agent/keybindings.json ~/.pi/agent/keybindings.json