Files
oh-my-pi/packages/coding-agent/src/utils/external-editor.ts
T
can1357 6bb10ca40e feat(modes): enabled external editor integration for plan review and editing workflows
- Added external editor callback support to extension UI dialogs and hook selector for opening plans in external editor.
- Added `matchesAppExternalEditor()` keybinding matcher to detect external editor shortcuts (ctrl+g) across components.
- Added `trimTrailingNewline` option to `openInEditor()` function to preserve trailing newlines when editing externally.
- Modified plan review flow to re-read plan content from disk before approval to reflect external editor changes.
- Refactored plan preview rendering to cache and reuse UI container instead of recreating children on each render.
- Refactored Promise creation in external editor utility to use `Promise.withResolvers()` for cleaner async handling.
2026-04-02 07:43:01 +02:00

66 lines
2.1 KiB
TypeScript

/**
* Utilities for launching an external text editor ($VISUAL / $EDITOR).
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $env, Snowflake } from "@oh-my-pi/pi-utils";
/** Returns the user's preferred editor command, or undefined if not configured. */
export function getEditorCommand(): string | undefined {
return $env.VISUAL || $env.EDITOR || undefined;
}
export interface OpenInEditorOptions {
/** File extension for the temp file (default: ".md"). */
extension?: string;
/** Custom stdio configuration (default: all "inherit"). */
stdio?: [number | "inherit", number | "inherit", number | "inherit"];
/** Keep the file's trailing newline instead of trimming it from the returned text. */
trimTrailingNewline?: boolean;
}
/**
* Opens `content` in the user's external editor and returns the edited text.
* Returns `null` if the editor exits with a non-zero code.
*
* The caller is responsible for stopping/starting the TUI around this call.
*/
export async function openInEditor(
editorCmd: string,
content: string,
options?: OpenInEditorOptions,
): Promise<string | null> {
const ext = options?.extension ?? ".md";
const tmpFile = path.join(os.tmpdir(), `omp-editor-${Snowflake.next()}${ext}`);
try {
await Bun.write(tmpFile, content);
const [editor, ...editorArgs] = editorCmd.split(" ");
const stdio = options?.stdio ?? ["inherit", "inherit", "inherit"];
const child = spawn(editor, [...editorArgs, tmpFile], { stdio, shell: process.platform === "win32" });
const { promise, reject, resolve } = Promise.withResolvers<number>();
child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0)));
child.once("error", error => reject(error));
const exitCode = await promise;
if (exitCode === 0) {
const text = await Bun.file(tmpFile).text();
if (options?.trimTrailingNewline === false) {
return text;
}
return text.replace(/\n$/, "");
}
return null;
} finally {
try {
await fs.rm(tmpFile, { force: true });
} catch {
// Ignore cleanup errors
}
}
}