diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8e85e09c..4a4de8b6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an `externalEditor` settings.json override for Ctrl+G external editor commands, with default fallbacks to Notepad on Windows and `nano` elsewhere ([#6122](https://github.com/earendil-works/pi/issues/6122)). + ### Fixed - Fixed `--session` and `SessionManager.open()` to reject non-empty invalid session files without overwriting them ([#6002](https://github.com/earendil-works/pi/issues/6002)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 765c7a2f..90efe133 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -161,6 +161,7 @@ The editor can be temporarily replaced by other UI, like built-in `/settings` or | File reference | Type `@` to fuzzy-search project files | | Path completion | Tab to complete paths | | Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) | +| External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere | | Images | Ctrl+V to paste (Alt+V on Windows), or drag onto terminal | | Bash commands | `!command` runs and sends output to LLM, `!!command` runs without sending | @@ -663,7 +664,7 @@ pi --thinking high "Solve this complex problem" | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) | -| `VISUAL`, `EDITOR` | External editor for Ctrl+G | +| `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | --- diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index a15a25a0..0bb0f492 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -86,7 +86,7 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | `app.clear` | `ctrl+c` | Clear editor | | `app.exit` | `ctrl+d` | Exit (when editor empty) | | `app.suspend` | `ctrl+z` (none on Windows) | Suspend to background | -| `app.editor.external` | `ctrl+g` | Open in external editor (`$VISUAL` or `$EDITOR`) | +| `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) | | `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows) | Paste image from clipboard | ### Sessions diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 18c9ad97..3a066057 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -51,6 +51,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | Setting | Type | Default | Description | |---------|------|---------|-------------| | `theme` | string | `"dark"` | Theme name (`"dark"`, `"light"`, or custom) | +| `externalEditor` | string | `$VISUAL`, then `$EDITOR`, then Notepad on Windows or `nano` elsewhere | Command for Ctrl+G external editor; takes precedence over environment variables | | `quietStartup` | boolean | `false` | Hide startup header | | `defaultProjectTrust` | string | `"ask"` | Fallback project trust behavior: `"ask"`, `"always"`, or `"never"`. Global setting only | | `collapseChangelog` | boolean | `false` | Show condensed changelog after updates | @@ -63,6 +64,14 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | +For VS Code, include `--wait` so pi resumes after the editor exits: + +```json +{ + "externalEditor": "code --wait" +} +``` + ### Telemetry and update checks `enableInstallTelemetry` only controls the anonymous install/update ping to `https://pi.dev/api/report-install`. Opting out of telemetry does not disable update checks; Pi can still fetch `https://pi.dev/api/latest-version` to look for the latest version. diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ccd3ee41..7a39eecf 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -25,7 +25,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by | Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal | | Shell command | `!command` runs and sends output to the model | | Hidden shell command | `!!command` runs without sending output to the model | -| External editor | Ctrl+G opens `$VISUAL` or `$EDITOR` | +| External editor | Ctrl+G opens `externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere | See [Keybindings](keybindings.md) for all shortcuts and customization. @@ -297,7 +297,7 @@ pi --exclude-tools ask_question | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported | -| `VISUAL`, `EDITOR` | External editor for Ctrl+G | +| `VISUAL`, `EDITOR` | Fallback external editor for Ctrl+G when `externalEditor` is unset; defaults to Notepad on Windows and `nano` elsewhere | ## Design Principles diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 99ae71a4..0778f938 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -90,6 +90,7 @@ export interface Settings { branchSummary?: BranchSummarySettings; retry?: RetrySettings; hideThinkingBlock?: boolean; + externalEditor?: string; // Command for Ctrl+G external editor; takes precedence over VISUAL/EDITOR shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) quietStartup?: boolean; defaultProjectTrust?: DefaultProjectTrust; // default: "ask"; global setting only @@ -841,6 +842,18 @@ export class SettingsManager { return this.settings.hideThinkingBlock ?? false; } + getExternalEditorCommand(): string | undefined { + const configuredEditor = this.settings.externalEditor; + if (typeof configuredEditor === "string" && configuredEditor.trim() !== "") { + return configuredEditor; + } + const environmentEditor = process.env.VISUAL || process.env.EDITOR; + if (environmentEditor) { + return environmentEditor; + } + return process.platform === "win32" ? "notepad" : "nano"; + } + setHideThinkingBlock(hide: boolean): void { this.globalSettings.hideThinkingBlock = hide; this.markModified("hideThinkingBlock"); diff --git a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts index e000fff4..a5ed3fba 100644 --- a/packages/coding-agent/src/modes/interactive/components/extension-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/extension-editor.ts @@ -28,6 +28,7 @@ export class ExtensionEditorComponent extends Container implements Focusable { private onCancelCallback: () => void; private tui: TUI; private keybindings: KeybindingsManager; + private externalEditorCommand: string | undefined; private _focused = false; get focused(): boolean { @@ -46,11 +47,13 @@ export class ExtensionEditorComponent extends Container implements Focusable { onSubmit: (value: string) => void, onCancel: () => void, options?: EditorOptions, + externalEditorCommand?: string, ) { super(); this.tui = tui; this.keybindings = keybindings; + this.externalEditorCommand = externalEditorCommand; this.onSubmitCallback = onSubmit; this.onCancelCallback = onCancel; @@ -76,7 +79,7 @@ export class ExtensionEditorComponent extends Container implements Focusable { this.addChild(new Spacer(1)); // Add hint - const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); + const hasExternalEditor = !!this.getExternalEditorCommand(); const hint = keyHint("tui.select.confirm", "submit") + " " + @@ -110,8 +113,16 @@ export class ExtensionEditorComponent extends Container implements Focusable { this.editor.handleInput(keyData); } + private getExternalEditorCommand(): string | undefined { + const editorCmd = this.externalEditorCommand || process.env.VISUAL || process.env.EDITOR; + if (editorCmd) { + return editorCmd; + } + return process.platform === "win32" ? "notepad" : "nano"; + } + private async openExternalEditor(): Promise { - const editorCmd = process.env.VISUAL || process.env.EDITOR; + const editorCmd = this.getExternalEditorCommand(); if (!editorCmd) { return; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 7b6dbbe8..4d7ba928 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2239,6 +2239,8 @@ export class InteractiveMode { this.hideExtensionEditor(); resolve(undefined); }, + undefined, + this.settingsManager.getExternalEditorCommand(), ); this.editorContainer.clear(); @@ -3647,10 +3649,9 @@ export class InteractiveMode { } private async openExternalEditor(): Promise { - // Determine editor (respect $VISUAL, then $EDITOR) - const editorCmd = process.env.VISUAL || process.env.EDITOR; + const editorCmd = this.settingsManager.getExternalEditorCommand(); if (!editorCmd) { - this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable."); + this.showWarning("No editor configured. Set externalEditor in settings.json or $VISUAL/$EDITOR."); return; } diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index d86c3f91..7e592874 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -354,6 +354,49 @@ describe("SettingsManager", () => { }); }); + describe("externalEditor", () => { + const originalVisual = process.env.VISUAL; + const originalEditor = process.env.EDITOR; + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + + function setEditorEnv(visual?: string, editor?: string): void { + if (visual === undefined) delete process.env.VISUAL; + else process.env.VISUAL = visual; + if (editor === undefined) delete process.env.EDITOR; + else process.env.EDITOR = editor; + } + + afterEach(() => { + setEditorEnv(originalVisual, originalEditor); + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + }); + + it("should resolve editor commands by precedence", () => { + setEditorEnv("vim", "nano"); + expect(SettingsManager.inMemory({ externalEditor: "code --wait" }).getExternalEditorCommand()).toBe( + "code --wait", + ); + expect(SettingsManager.inMemory().getExternalEditorCommand()).toBe("vim"); + + setEditorEnv(undefined, "emacs"); + expect(SettingsManager.inMemory().getExternalEditorCommand()).toBe("emacs"); + }); + + it("should fall back to platform defaults", () => { + setEditorEnv(); + Object.defineProperty(process, "platform", { value: "win32" }); + expect(SettingsManager.inMemory().getExternalEditorCommand()).toBe("notepad"); + + Object.defineProperty(process, "platform", { value: "darwin" }); + expect(SettingsManager.inMemory().getExternalEditorCommand()).toBe("nano"); + + Object.defineProperty(process, "platform", { value: "linux" }); + expect(SettingsManager.inMemory().getExternalEditorCommand()).toBe("nano"); + }); + }); + describe("shellCommandPrefix", () => { it("should load shellCommandPrefix from settings", () => { const settingsPath = join(agentDir, "settings.json");