refactor(coding-agent): extracted external editor handling into shared utility module

- Extracted external editor handling logic into shared utility module to reduce code duplication across hook-editor and input-controller components.
- Created new external-editor utility module with getEditorCommand() and openInEditor() functions for centralizing editor process management and temporary file handling.
- Simplified hook-editor component by replacing manual editor spawning and file operations with openInEditor() utility function call.
- Simplified input-controller component by replacing manual editor spawning and file operations with openInEditor() utility function call.
This commit is contained in:
can1357
2026-02-05 06:13:58 +01:00
parent f0b97f44fe
commit e131000ed2
4 changed files with 80 additions and 65 deletions
+8
View File
@@ -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
@@ -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<void> {
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);
}
@@ -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<void> {
// 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<number>((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();
}
@@ -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<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 });
const exitCode = await new Promise<number>((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
}
}
}