diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 47b461ea..c62f03e8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **External editor support**: Press `Ctrl+G` to edit your message in an external editor. Uses `$VISUAL` or `$EDITOR` environment variable. On successful save, the message is replaced; on cancel, the original is kept. ([#266](https://github.com/badlogic/pi-mono/pull/266) by [@aliou](https://github.com/aliou)) + ## [0.25.2] - 2025-12-21 ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index db3f4c25..69c6a717 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -230,6 +230,7 @@ The agent reads, writes, and edits files, and executes commands via bash. | Ctrl+P | Cycle models (scoped by `--models`) | | Ctrl+O | Toggle tool output expansion | | Ctrl+T | Toggle thinking block visibility | +| Ctrl+G | Edit message in external editor (`$VISUAL` or `$EDITOR`) | ### Bash Mode diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 98bbfd19..62171ca1 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,4 +1,14 @@ -import { Editor, isCtrlC, isCtrlD, isCtrlO, isCtrlP, isCtrlT, isEscape, isShiftTab } from "@mariozechner/pi-tui"; +import { + Editor, + isCtrlC, + isCtrlD, + isCtrlG, + isCtrlO, + isCtrlP, + isCtrlT, + isEscape, + isShiftTab, +} from "@mariozechner/pi-tui"; /** * Custom editor that handles Escape and Ctrl+C keys for coding-agent @@ -11,8 +21,15 @@ export class CustomEditor extends Editor { public onCtrlP?: () => void; public onCtrlO?: () => void; public onCtrlT?: () => void; + public onCtrlG?: () => void; handleInput(data: string): void { + // Intercept Ctrl+G for external editor + if (isCtrlG(data) && this.onCtrlG) { + this.onCtrlG(); + return; + } + // Intercept Ctrl+T for thinking block visibility toggle if (isCtrlT(data) && this.onCtrlT) { this.onCtrlT(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9675adc8..0c5ebf9d 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4,6 +4,7 @@ */ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import type { AgentState, AppMessage, Attachment } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; @@ -23,7 +24,7 @@ import { TUI, visibleWidth, } from "@mariozechner/pi-tui"; -import { exec } from "child_process"; +import { exec, spawnSync } from "child_process"; import { APP_NAME, getDebugLogPath, getOAuthPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; @@ -579,6 +580,7 @@ export class InteractiveMode { this.editor.onCtrlP = () => this.cycleModel(); this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); + this.editor.onCtrlG = () => this.openExternalEditor(); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -1225,6 +1227,52 @@ export class InteractiveMode { this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); } + private openExternalEditor(): void { + // Determine editor (respect $VISUAL, then $EDITOR) + const editorCmd = process.env.VISUAL || process.env.EDITOR; + if (!editorCmd) { + this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); + return; + } + + const currentText = this.editor.getText(); + const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`); + + try { + // Write current content to temp file + fs.writeFileSync(tmpFile, currentText, "utf-8"); + + // Stop TUI to release terminal + this.ui.stop(); + + // Split by space to support editor arguments (e.g., "code --wait") + const [editor, ...editorArgs] = editorCmd.split(" "); + + // Spawn editor synchronously with inherited stdio for interactive editing + const result = spawnSync(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + }); + + // On successful exit (status 0), replace editor content + if (result.status === 0) { + const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + this.editor.setText(newContent); + } + // On non-zero exit, keep original text (no action needed) + } finally { + // Clean up temp file + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + + // Restart TUI + this.ui.start(); + this.ui.requestRender(); + } + } + // ========================================================================= // UI helpers // ========================================================================= @@ -1703,6 +1751,7 @@ export class InteractiveMode { | \`Ctrl+P\` | Cycle models | | \`Ctrl+O\` | Toggle tool output expansion | | \`Ctrl+T\` | Toggle thinking block visibility | +| \`Ctrl+G\` | Edit message in external editor | | \`/\` | Slash commands | | \`!\` | Run bash command | `; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index fdf04af8..e09b5255 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -33,6 +33,7 @@ export { isCtrlC, isCtrlD, isCtrlE, + isCtrlG, isCtrlK, isCtrlLeft, isCtrlO, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index d82a01ca..faa5e4d7 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -28,6 +28,7 @@ const CODEPOINTS = { c: 99, d: 100, e: 101, + g: 103, k: 107, o: 111, p: 112, @@ -160,6 +161,7 @@ export const Keys = { CTRL_C: kittySequence(CODEPOINTS.c, MODIFIERS.ctrl), CTRL_D: kittySequence(CODEPOINTS.d, MODIFIERS.ctrl), CTRL_E: kittySequence(CODEPOINTS.e, MODIFIERS.ctrl), + CTRL_G: kittySequence(CODEPOINTS.g, MODIFIERS.ctrl), CTRL_K: kittySequence(CODEPOINTS.k, MODIFIERS.ctrl), CTRL_O: kittySequence(CODEPOINTS.o, MODIFIERS.ctrl), CTRL_P: kittySequence(CODEPOINTS.p, MODIFIERS.ctrl), @@ -214,6 +216,7 @@ const RAW = { CTRL_C: "\x03", CTRL_D: "\x04", CTRL_E: "\x05", + CTRL_G: "\x07", CTRL_K: "\x0b", CTRL_O: "\x0f", CTRL_P: "\x10", @@ -256,6 +259,14 @@ export function isCtrlE(data: string): boolean { return data === RAW.CTRL_E || data === Keys.CTRL_E || matchesKittySequence(data, CODEPOINTS.e, MODIFIERS.ctrl); } +/** + * Check if input matches Ctrl+G (raw byte or Kitty protocol). + * Ignores lock key bits. + */ +export function isCtrlG(data: string): boolean { + return data === RAW.CTRL_G || data === Keys.CTRL_G || matchesKittySequence(data, CODEPOINTS.g, MODIFIERS.ctrl); +} + /** * Check if input matches Ctrl+K (raw byte or Kitty protocol). * Ignores lock key bits.