import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { spawn, spawnSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; const COMMAND_NAMES = ["open-last", "open-last-response", "olast"] as const; const OUTPUT_DIR_ENV = "PI_OPEN_LAST_DIR"; const EDITOR_ENV = "PI_OPEN_LAST_EDITOR"; type ContentBlock = { type?: string; text?: string; }; type AssistantMessage = { role?: string; content?: unknown; provider?: string; model?: string; timestamp?: number; }; type MessageEntry = { type?: string; id?: string; timestamp?: string; message?: AssistantMessage; }; type ParsedArgs = { help: boolean; pathOnly: boolean; editorCommand?: string; }; type LatestAssistantResponse = { entryId: string; text: string; model?: string; provider?: string; timestamp?: string; }; const parseArgs = (args: string): ParsedArgs => { const trimmed = args.trim(); if (trimmed === "help" || trimmed === "--help" || trimmed === "-h") { return { help: true, pathOnly: false }; } const tokens = trimmed.length > 0 ? trimmed.split(/\s+/) : []; let pathOnly = false; const editorTokens: string[] = []; for (const token of tokens) { if (token === "--path-only" || token === "--no-open") { pathOnly = true; continue; } editorTokens.push(token); } const editorCommand = editorTokens.join(" ").trim(); return { help: false, pathOnly, editorCommand: editorCommand.length > 0 ? editorCommand : undefined, }; }; const extractText = (content: unknown): string => { if (typeof content === "string") { return content; } if (!Array.isArray(content)) { return ""; } return content .map((block) => { if (!block || typeof block !== "object") { return ""; } const contentBlock = block as ContentBlock; return contentBlock.type === "text" && typeof contentBlock.text === "string" ? contentBlock.text : ""; }) .filter((text) => text.trim().length > 0) .join("\n\n"); }; const findLatestAssistantResponse = ( ctx: ExtensionCommandContext, ): LatestAssistantResponse | undefined => { const branch = ctx.sessionManager.getBranch() as MessageEntry[]; for (let index = branch.length - 1; index >= 0; index -= 1) { const entry = branch[index]; if (entry?.type !== "message" || entry.message?.role !== "assistant") { continue; } const text = extractText(entry.message.content).trim(); if (text.length === 0) { continue; } return { entryId: entry.id ?? "unknown-entry", text, model: entry.message.model, provider: entry.message.provider, timestamp: entry.timestamp, }; } return undefined; }; const sanitizeFilePart = (value: string): string => { const sanitized = value .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); return sanitized || "session"; }; const buildOutputPath = async (ctx: ExtensionCommandContext, response: LatestAssistantResponse) => { const outputDir = process.env[OUTPUT_DIR_ENV]?.trim() || join(tmpdir(), "pi-open-last-response"); await mkdir(outputDir, { recursive: true }); const sessionName = ctx.sessionManager.getSessionName() || ctx.sessionManager.getSessionId() || "session"; const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = [ "pi-last-response", sanitizeFilePart(sessionName), response.entryId, timestamp, ] .filter(Boolean) .join("-"); return join(outputDir, `${fileName}.md`); }; const shellQuote = (value: string): string => { if (process.platform === "win32") { return `"${value.replace(/"/g, '\\"')}"`; } return `'${value.replace(/'/g, `'\\''`)}'`; }; const firstCommandToken = (command: string): string => { const match = command.trim().match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/); return match?.[1] ?? match?.[2] ?? match?.[3] ?? ""; }; const isTerminalEditor = (command: string): boolean => { const token = basename(firstCommandToken(command)); return new Set(["vi", "vim", "nvim", "nano", "emacs", "less", "more", "view"]).has(token); }; const commandExists = (command: string): boolean => { if (process.platform === "win32") { return spawnSync("where", [command], { stdio: "ignore" }).status === 0; } return spawnSync("sh", ["-lc", `command -v ${shellQuote(command)} >/dev/null 2>&1`], { stdio: "ignore", }).status === 0; }; const findDefaultEditor = (): { command?: string; source?: string } => { const configured = process.env[EDITOR_ENV]?.trim(); if (configured) { return { command: configured, source: EDITOR_ENV }; } for (const [name, value] of [ ["VISUAL", process.env.VISUAL?.trim()], ["EDITOR", process.env.EDITOR?.trim()], ] as const) { if (value && !isTerminalEditor(value)) { return { command: value, source: name }; } } const candidates = process.platform === "darwin" ? ["cursor", "code", "zed", "subl", "open"] : process.platform === "win32" ? ["cursor.cmd", "code.cmd", "notepad.exe"] : ["cursor", "code", "zed", "subl", "xdg-open"]; for (const candidate of candidates) { if (commandExists(candidate)) { return { command: candidate, source: "auto-detect" }; } } const fallback = process.env.VISUAL?.trim() || process.env.EDITOR?.trim(); return fallback ? { command: fallback, source: "VISUAL/EDITOR" } : {}; }; const openWithEditor = (editorCommand: string, filePath: string, cwd: string) => { const command = `${editorCommand} ${shellQuote(filePath)}`; const child = spawn(command, { cwd, detached: true, env: process.env, shell: true, stdio: "ignore", }); child.unref(); }; const showHelp = (ctx: ExtensionCommandContext) => { const text = [ "Usage:", " /open-last [editor-command]", " /open-last --path-only", " /open-last code --reuse-window", " /open-last cursor", "", "Environment:", ` ${EDITOR_ENV}=\"code --reuse-window\" Override editor command`, ` ${OUTPUT_DIR_ENV}=\"/path/to/dir\" Override output directory`, ].join("\n"); if (ctx.hasUI) { ctx.ui.notify(text, "info"); } else { console.log(text); } }; const notify = ( ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error" = "info", ) => { if (ctx.hasUI) { ctx.ui.notify(message, level); } else { const stream = level === "error" ? process.stderr : process.stdout; stream.write(`${message}\n`); } }; const handleOpenLast = async (args: string, ctx: ExtensionCommandContext) => { const parsed = parseArgs(args); if (parsed.help) { showHelp(ctx); return; } await ctx.waitForIdle(); const response = findLatestAssistantResponse(ctx); if (!response) { notify(ctx, "No completed assistant response with text found on the current branch.", "warning"); return; } const outputPath = await buildOutputPath(ctx, response); await writeFile(outputPath, `${response.text}\n`, { encoding: "utf8", mode: 0o600 }); if (parsed.pathOnly) { notify(ctx, `Latest assistant response written to: ${outputPath}`, "info"); return; } const editor = parsed.editorCommand ? { command: parsed.editorCommand, source: "argument" } : findDefaultEditor(); if (!editor.command) { notify( ctx, `Wrote latest assistant response to ${outputPath}, but no editor was found. Set ${EDITOR_ENV}, VISUAL, or EDITOR.`, "warning", ); return; } if (isTerminalEditor(editor.command) && ctx.mode === "tui") { notify( ctx, `Editor command '${editor.command}' looks terminal-based and may not attach correctly from the Pi TUI. Prefer '${EDITOR_ENV}=code' or '${EDITOR_ENV}=cursor'. File: ${outputPath}`, "warning", ); } try { openWithEditor(editor.command, outputPath, ctx.cwd); notify( ctx, `Opened latest assistant response with ${editor.command} (${editor.source ?? "configured"}): ${outputPath}`, "info", ); } catch (error) { const message = error instanceof Error ? error.message : String(error); notify(ctx, `Failed to open editor. File written to ${outputPath}. Error: ${message}`, "error"); } }; export default function (pi: ExtensionAPI) { for (const commandName of COMMAND_NAMES) { pi.registerCommand(commandName, { description: "Open the latest assistant response in an external editor", handler: handleOpenLast, }); } }