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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user