diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index db67a54b4..8b20767c6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Added + +- Added `openInEditor()` utility function to centralize external editor handling with support for custom file extensions and stdio configuration +- Added `getEditorCommand()` utility function to retrieve the user's preferred editor from $VISUAL or $EDITOR environment variables + +### Changed + +- Refactored hook editor and input controller to use shared external editor utilities, reducing code duplication ## [11.0.3] - 2026-02-05 ### Added diff --git a/packages/coding-agent/src/modes/components/hook-editor.ts b/packages/coding-agent/src/modes/components/hook-editor.ts index ff07faef0..c7eb71805 100644 --- a/packages/coding-agent/src/modes/components/hook-editor.ts +++ b/packages/coding-agent/src/modes/components/hook-editor.ts @@ -2,12 +2,9 @@ * Multi-line editor component for hooks. * Supports Ctrl+G for external editor. */ -import * as fs from "node:fs/promises"; -import * as os from "node:os"; -import * as path from "node:path"; import { Container, Editor, matchesKey, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui"; -import { $env, Snowflake } from "@oh-my-pi/pi-utils"; import { getEditorTheme, theme } from "../../modes/theme/theme"; +import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { DynamicBorder } from "./dynamic-border"; export class HookEditorComponent extends Container { @@ -47,10 +44,7 @@ export class HookEditorComponent extends Container { this.addChild(new Spacer(1)); // Add hint - const hasExternalEditor = !!($env.VISUAL || $env.EDITOR); - const hint = hasExternalEditor - ? "ctrl+enter submit esc cancel ctrl+g external editor" - : "ctrl+enter submit esc cancel"; + const hint = "ctrl+enter submit esc cancel ctrl+g external editor"; this.addChild(new Text(theme.fg("dim", hint), 1, 0)); this.addChild(new Spacer(1)); @@ -83,36 +77,17 @@ export class HookEditorComponent extends Container { } private async openExternalEditor(): Promise { - const editorCmd = $env.VISUAL || $env.EDITOR; - if (!editorCmd) { - return; - } + const editorCmd = getEditorCommand(); + if (!editorCmd) return; const currentText = this.editor.getText(); - const tmpFile = path.join(os.tmpdir(), `omp-hook-editor-${Snowflake.next()}.md`); - try { - await Bun.write(tmpFile, currentText); this.tui.stop(); - - const [editor, ...editorArgs] = editorCmd.split(" "); - const child = Bun.spawn([editor, ...editorArgs, tmpFile], { - stdin: "inherit", - stdout: "inherit", - stderr: "inherit", - }); - const exitCode = await child.exited; - - if (exitCode === 0) { - const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, ""); - this.editor.setText(newContent); + const result = await openInEditor(editorCmd, currentText); + if (result !== null) { + this.editor.setText(result); } } finally { - try { - await fs.rm(tmpFile, { force: true }); - } catch { - // Ignore cleanup errors - } this.tui.start(); this.tui.requestRender(true); } diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 6259da0a9..757f504a6 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1,16 +1,14 @@ -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 type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { readImageFromClipboard } from "@oh-my-pi/pi-natives"; -import { $env, Snowflake } from "@oh-my-pi/pi-utils"; +import { $env } from "@oh-my-pi/pi-utils"; import type { SettingPath, SettingValue } from "../../config/settings"; import { settings } from "../../config/settings"; import { theme } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import type { AgentSessionEvent } from "../../session/agent-session"; import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages"; +import { getEditorCommand, openInEditor } from "../../utils/external-editor"; import { resizeImage } from "../../utils/image-resize"; import { generateSessionTitle, setTerminalTitle } from "../../utils/title-generator"; @@ -730,61 +728,36 @@ export class InputController { } async openExternalEditor(): Promise { - // Determine editor (respect $VISUAL, then $EDITOR) - const editorCmd = $env.VISUAL || $env.EDITOR; + const editorCmd = getEditorCommand(); if (!editorCmd) { this.ctx.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); return; } const currentText = this.ctx.editor.getText(); - const tmpFile = path.join(os.tmpdir(), `omp-editor-${Snowflake.next()}.omp.md`); let ttyHandle: fs.FileHandle | null = null; try { - // Write current content to temp file - await Bun.write(tmpFile, currentText); - - // Stop TUI to release terminal ttyHandle = await this.openEditorTerminalHandle(); this.ctx.ui.stop(); - // Split by space to support editor arguments (e.g., "code --wait") - const [editor, ...editorArgs] = editorCmd.split(" "); - const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle ? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd] : ["inherit", "inherit", "inherit"]; - const child = spawn(editor, [...editorArgs, tmpFile], { stdio }); - const exitCode = await new Promise((resolve, reject) => { - child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0))); - child.once("error", error => reject(error)); - }); - - // On successful exit (exitCode 0), replace editor content - if (exitCode === 0) { - const newContent = (await Bun.file(tmpFile).text()).replace(/\n$/, ""); - this.ctx.editor.setText(newContent); + const result = await openInEditor(editorCmd, currentText, { extension: ".omp.md", stdio }); + if (result !== null) { + this.ctx.editor.setText(result); } - // On non-zero exit, keep original text (no action needed) } catch (error) { this.ctx.showWarning( `Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`, ); } finally { - // Clean up temp file - try { - await fs.rm(tmpFile, { force: true }); - } catch { - // Ignore cleanup errors - } - if (ttyHandle) { await ttyHandle.close(); } - // Restart TUI this.ctx.ui.start(); this.ctx.ui.requestRender(); } diff --git a/packages/coding-agent/src/utils/external-editor.ts b/packages/coding-agent/src/utils/external-editor.ts new file mode 100644 index 000000000..47a15e66f --- /dev/null +++ b/packages/coding-agent/src/utils/external-editor.ts @@ -0,0 +1,59 @@ +/** + * 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"]; +} + +/** + * 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 { + 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 }); + const exitCode = await new Promise((resolve, reject) => { + child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0))); + child.once("error", error => reject(error)); + }); + + if (exitCode === 0) { + return (await Bun.file(tmpFile).text()).replace(/\n$/, ""); + } + return null; + } finally { + try { + await fs.rm(tmpFile, { force: true }); + } catch { + // Ignore cleanup errors + } + } +}