From 664f2712cd79bf194b5a96e2c4126c030df2ceac Mon Sep 17 00:00:00 2001 From: Alnoman Kamil Date: Sat, 1 Aug 2026 17:35:17 +0300 Subject: [PATCH] Add external prompt editor shortcut --- .../architecture/external-prompt-editor.md | 38 ++++++++++ src/tau_coding/commands.py | 1 + src/tau_coding/tui/app.py | 36 ++++++++- src/tau_coding/tui/config.py | 2 + src/tau_coding/tui/external_editor.py | 75 +++++++++++++++++++ tests/test_tui_app.py | 53 +++++++++++++ tests/test_tui_config.py | 4 + tests/test_tui_external_editor.py | 62 +++++++++++++++ website/content/guides/tui.md | 7 +- website/content/reference/configuration.md | 1 + website/content/reference/keybindings.md | 5 ++ 11 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 dev-notes/architecture/external-prompt-editor.md create mode 100644 src/tau_coding/tui/external_editor.py create mode 100644 tests/test_tui_external_editor.py diff --git a/dev-notes/architecture/external-prompt-editor.md b/dev-notes/architecture/external-prompt-editor.md new file mode 100644 index 0000000000..a015221aab --- /dev/null +++ b/dev-notes/architecture/external-prompt-editor.md @@ -0,0 +1,38 @@ +--- +title: "External prompt editor" +--- + +Tau's Textual prompt composer supports a Pi-style external-editor action. The +feature remains in `tau_coding.tui`: the reusable `tau_agent` harness does not +know about terminal suspension, environment variables, temporary files, or key +bindings. + +## Behavior + +`Alt+E` is the default `external_editor` keybinding in `~/.tau/tui.json`. The +action expands any compact large-paste placeholders, writes the current prompt +to a temporary `*.tau.md` file, suspends Textual's terminal application mode, +and runs the first configured editor from: + +1. `$VISUAL` +2. `$EDITOR` +3. Notepad on Windows or `nano` elsewhere + +The temporary path is appended to editor arguments, so commands such as +`EDITOR="code --wait"` work. A zero exit status reloads the saved file into the +prompt and places the cursor at the end. Launch errors and non-zero exits leave +the original prompt untouched and surface a warning. Temporary files are always +removed. + +This maps to Pi's built-in `app.editor.external` behavior while using Tau's +existing named-keybinding configuration rather than Pi's action-id map. + +## Verification + +```bash +uv run pytest -q tests/test_tui_external_editor.py tests/test_tui_config.py \ + tests/test_tui_app.py -k external_editor +uv run ruff check src/tau_coding/tui tests/test_tui_external_editor.py \ + tests/test_tui_config.py tests/test_tui_app.py +uv run mypy src +``` diff --git a/src/tau_coding/commands.py b/src/tau_coding/commands.py index e297c837b2..8e932eaadf 100644 --- a/src/tau_coding/commands.py +++ b/src/tau_coding/commands.py @@ -467,6 +467,7 @@ def _hotkeys_command(context: CommandContext) -> CommandResult: "Common keyboard shortcuts:", "- Enter: submit prompt", "- Shift+Enter: insert newline", + "- Alt+E: edit the current prompt in an external editor", "- Alt+Enter: queue follow-up while running", "- Esc: cancel active run", "- Ctrl+K: open slash-command completions", diff --git a/src/tau_coding/tui/app.py b/src/tau_coding/tui/app.py index c4a7f5c647..0cbd4f2e92 100644 --- a/src/tau_coding/tui/app.py +++ b/src/tau_coding/tui/app.py @@ -18,7 +18,7 @@ from rich.style import Style from rich.text import Text from textual import events, on -from textual.app import App, ComposeResult +from textual.app import App, ComposeResult, SuspendNotSupported from textual.binding import Binding, BindingsMap from textual.containers import Container, Horizontal, Vertical, VerticalScroll from textual.css.query import NoMatches @@ -147,6 +147,7 @@ load_tui_settings, save_tui_settings, ) +from tau_coding.tui.external_editor import ExternalEditorError, edit_prompt_in_external_editor from tau_coding.tui.file_drop import normalize_dropped_paths from tau_coding.tui.state import TuiState, format_terminal_command_result_block from tau_coding.tui.terminal_notification import TerminalNotificationController @@ -455,6 +456,8 @@ def action_toggle_thinking(self) -> None: ... def action_edit_queued_message(self) -> bool: ... + async def action_external_editor(self) -> None: ... + async def action_submit_prompt(self) -> None: ... async def action_submit_follow_up(self) -> None: ... @@ -614,6 +617,10 @@ def action_insert_newline(self) -> None: """Insert a newline in the prompt.""" self.insert("\n") + async def action_external_editor(self) -> None: + """Edit the current prompt through the app's external editor action.""" + await self._completion_target().action_external_editor() + async def action_quit(self) -> None: """Quit the app through the app-level action.""" await self.app.action_quit() @@ -720,7 +727,11 @@ async def on_key(self, event: Key) -> None: bindings), so there is no interceptor splice here. """ keybindings = self.tui_keybindings - if event.key == keybindings.queue_follow_up: + if event.key == keybindings.external_editor: + event.stop() + event.prevent_default() + await self.action_external_editor() + elif event.key == keybindings.queue_follow_up: event.stop() event.prevent_default() await self._completion_target().action_submit_follow_up() @@ -3698,6 +3709,25 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None: self._completion_state = self._build_completion_state(event.text_area.text) self._refresh_completions() + async def action_external_editor(self) -> None: + """Suspend Tau while an external editor modifies the current prompt.""" + prompt = self.query_one("#prompt", PromptInput) + original = prompt.text_for_submission() + try: + with self.suspend(): + edited = await asyncio.to_thread(edit_prompt_in_external_editor, original) + except (ExternalEditorError, SuspendNotSupported) as exc: + self._notify(str(exc), severity="warning") + prompt.focus() + return + + prompt.text = edited + prompt._clear_pending_paste() + prompt.move_cursor(_text_end_location(edited)) + self._completion_state = self._build_completion_state(edited) + self._refresh_completions() + prompt.focus() + async def action_submit_prompt(self) -> None: """Submit the current prompt text or slash command.""" await self._submit_prompt_from_editor(streaming_behavior="steer") @@ -6425,6 +6455,7 @@ def _app_bindings(keybindings: TuiKeybindings) -> list[Binding]: ), Binding(keybindings.toggle_tool_results, "toggle_tool_results", "Tool results"), Binding(keybindings.toggle_thinking, "toggle_thinking", "Thinking tokens"), + Binding(keybindings.external_editor, "external_editor", "External editor", show=False), Binding(keybindings.copy_message, "clear_prompt", "Clear input"), Binding(keybindings.quit, "quit", "Quit"), ] @@ -6508,6 +6539,7 @@ def _hidden_prompt_bindings( (keybindings.model_cycle, "cycle_model"), (keybindings.toggle_tool_results, "toggle_tool_results"), (keybindings.toggle_thinking, "toggle_thinking"), + (keybindings.external_editor, "external_editor"), (keybindings.copy_message, "clear_prompt"), (keybindings.accept_completion, "accept_completion"), (keybindings.completion_next, "completion_next"), diff --git a/src/tau_coding/tui/config.py b/src/tau_coding/tui/config.py index dfe54fbf8d..50b862a457 100644 --- a/src/tau_coding/tui/config.py +++ b/src/tau_coding/tui/config.py @@ -61,6 +61,7 @@ class TuiKeybindings: model_cycle: str = "ctrl+p" toggle_thinking: str = "ctrl+t" toggle_tool_results: str = "ctrl+o" + external_editor: str = "alt+e" copy_message: str = "ctrl+c" quit: str = "ctrl+d" @@ -78,6 +79,7 @@ def to_json(self) -> dict[str, str]: "model_cycle": self.model_cycle, "toggle_thinking": self.toggle_thinking, "toggle_tool_results": self.toggle_tool_results, + "external_editor": self.external_editor, "copy_message": self.copy_message, "quit": self.quit, } diff --git a/src/tau_coding/tui/external_editor.py b/src/tau_coding/tui/external_editor.py new file mode 100644 index 0000000000..a48192f059 --- /dev/null +++ b/src/tau_coding/tui/external_editor.py @@ -0,0 +1,75 @@ +"""External-editor integration for the Tau prompt composer.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import tempfile +from collections.abc import Mapping +from pathlib import Path + + +class ExternalEditorError(RuntimeError): + """Raised when the configured external editor cannot update the prompt.""" + + +def resolve_external_editor_command(environ: Mapping[str, str] | None = None) -> str: + """Resolve the editor using Pi-compatible environment fallbacks.""" + environment = os.environ if environ is None else environ + for variable in ("VISUAL", "EDITOR"): + configured = environment.get(variable) + if configured and configured.strip(): + return configured.strip() + return "notepad" if os.name == "nt" else "nano" + + +def edit_prompt_in_external_editor( + text: str, + *, + editor_command: str | None = None, +) -> str: + """Edit *text* in a temporary Markdown file and return the saved content. + + The editor receives the temporary path as its final argument. A non-zero + exit or launch failure raises :class:`ExternalEditorError`, allowing the + caller to preserve the original prompt. + """ + command = editor_command or resolve_external_editor_command() + try: + arguments = shlex.split(command, posix=os.name != "nt") + except ValueError as exc: + raise ExternalEditorError(f"Invalid external editor command: {exc}") from exc + if not arguments: + raise ExternalEditorError("External editor command is empty") + + path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix="tau-editor-", + suffix=".tau.md", + delete=False, + ) as temporary: + temporary.write(text) + path = Path(temporary.name) + + try: + result = subprocess.run([*arguments, str(path)], check=False) + except OSError as exc: + raise ExternalEditorError( + f"Could not launch external editor {arguments[0]!r}: {exc}" + ) from exc + if result.returncode != 0: + raise ExternalEditorError(f"External editor exited with code {result.returncode}") + + edited = path.read_text(encoding="utf-8") + if edited.endswith("\r\n"): + return edited[:-2] + if edited.endswith("\n"): + return edited[:-1] + return edited + finally: + if path is not None: + path.unlink(missing_ok=True) diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 403dddd252..44a9e4a61f 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -1,6 +1,7 @@ import asyncio import re from collections.abc import AsyncIterator +from contextlib import nullcontext from datetime import datetime from io import StringIO from pathlib import Path @@ -5970,6 +5971,58 @@ async def test_tui_app_uses_configured_command_palette_keybinding() -> None: assert any(item.display == "/session" for item in app._completion_state.items) +@pytest.mark.anyio +async def test_tui_app_opens_current_prompt_in_external_editor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed: list[str] = [] + + def fake_editor(text: str) -> str: + observed.append(text) + return "edited in external editor" + + monkeypatch.setattr(tui_app, "edit_prompt_in_external_editor", fake_editor) + app = TauTuiApp(FakeSession()) + app.suspend = nullcontext # type: ignore[method-assign] + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt", PromptInput) + prompt.value = "current prompt" + + await pilot.press("alt+e") + await pilot.pause() + + assert observed == ["current prompt"] + assert prompt.value == "edited in external editor" + assert prompt.cursor_position == len(prompt.value) + assert prompt.has_focus + + +@pytest.mark.anyio +async def test_tui_app_uses_configured_external_editor_keybinding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + tui_app, + "edit_prompt_in_external_editor", + lambda text: f"{text} edited", + ) + app = TauTuiApp( + FakeSession(), + tui_settings=TuiSettings(keybindings=TuiKeybindings(external_editor="f8")), + ) + app.suspend = nullcontext # type: ignore[method-assign] + + async with app.run_test() as pilot: + prompt = app.query_one("#prompt", PromptInput) + prompt.value = "custom key" + + await pilot.press("f8") + await pilot.pause() + + assert prompt.value == "custom key edited" + + @pytest.mark.anyio async def test_tui_app_quits_from_focused_prompt_with_default_keybinding() -> None: app = TauTuiApp(FakeSession()) diff --git a/tests/test_tui_config.py b/tests/test_tui_config.py index 54bb3849da..56e5547510 100644 --- a/tests/test_tui_config.py +++ b/tests/test_tui_config.py @@ -44,6 +44,7 @@ def test_load_tui_settings_reads_keybindings(tmp_path: Path) -> None: "thinking_cycle": "f3", "model_cycle": "f6", "toggle_thinking": "f4", + "external_editor": "f8", "copy_message": "ctrl+b" }, "theme": "high-contrast" @@ -59,6 +60,7 @@ def test_load_tui_settings_reads_keybindings(tmp_path: Path) -> None: assert settings.keybindings.queue_follow_up == "f5" assert settings.keybindings.toggle_tool_results == "ctrl+o" assert settings.keybindings.toggle_thinking == "f4" + assert settings.keybindings.external_editor == "f8" assert settings.keybindings.accept_completion == "f2" assert settings.keybindings.thinking_cycle == "f3" assert settings.keybindings.model_cycle == "f6" @@ -170,6 +172,7 @@ def test_tui_keybindings_serialize_to_json() -> None: thinking_cycle="f3", model_cycle="f6", toggle_thinking="f4", + external_editor="f8", copy_message="ctrl+b", ), theme="high-contrast", @@ -180,6 +183,7 @@ def test_tui_keybindings_serialize_to_json() -> None: assert settings.to_json()["keybindings"]["queue_follow_up"] == "f5" assert settings.to_json()["keybindings"]["toggle_tool_results"] == "ctrl+o" assert settings.to_json()["keybindings"]["toggle_thinking"] == "f4" + assert settings.to_json()["keybindings"]["external_editor"] == "f8" assert settings.to_json()["keybindings"]["accept_completion"] == "f2" assert settings.to_json()["keybindings"]["thinking_cycle"] == "f3" assert settings.to_json()["keybindings"]["model_cycle"] == "f6" diff --git a/tests/test_tui_external_editor.py b/tests/test_tui_external_editor.py new file mode 100644 index 0000000000..4c01cfb0e0 --- /dev/null +++ b/tests/test_tui_external_editor.py @@ -0,0 +1,62 @@ +from pathlib import Path +from subprocess import CompletedProcess + +import pytest + +from tau_coding.tui.external_editor import ( + ExternalEditorError, + edit_prompt_in_external_editor, + resolve_external_editor_command, +) + + +def test_external_editor_command_prefers_visual_then_editor() -> None: + assert resolve_external_editor_command({"VISUAL": "nvim", "EDITOR": "vim"}) == "nvim" + assert resolve_external_editor_command({"VISUAL": " ", "EDITOR": "vim"}) == "vim" + assert resolve_external_editor_command({"EDITOR": "vim"}) == "vim" + + +def test_external_editor_updates_prompt_and_removes_one_final_newline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed_path: Path | None = None + + def fake_run(arguments: list[str], *, check: bool) -> CompletedProcess[str]: + nonlocal observed_path + assert arguments[:2] == ["code", "--wait"] + assert check is False + observed_path = Path(arguments[-1]) + assert observed_path.read_text(encoding="utf-8") == "original prompt" + observed_path.write_text("edited prompt\n", encoding="utf-8") + return CompletedProcess(arguments, 0) + + monkeypatch.setattr("tau_coding.tui.external_editor.subprocess.run", fake_run) + + edited = edit_prompt_in_external_editor( + "original prompt", + editor_command="code --wait", + ) + + assert edited == "edited prompt" + assert observed_path is not None + assert not observed_path.exists() + + +def test_external_editor_failure_preserves_temp_file_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + observed_path: Path | None = None + + def fake_run(arguments: list[str], *, check: bool) -> CompletedProcess[str]: + nonlocal observed_path + del check + observed_path = Path(arguments[-1]) + return CompletedProcess(arguments, 7) + + monkeypatch.setattr("tau_coding.tui.external_editor.subprocess.run", fake_run) + + with pytest.raises(ExternalEditorError, match="exited with code 7"): + edit_prompt_in_external_editor("unchanged", editor_command="vim") + + assert observed_path is not None + assert not observed_path.exists() diff --git a/website/content/guides/tui.md b/website/content/guides/tui.md index 24007a68df..8d6fe2f472 100644 --- a/website/content/guides/tui.md +++ b/website/content/guides/tui.md @@ -12,8 +12,11 @@ see [Keyboard shortcuts]({{< relref "../reference/keybindings.md" >}}). Type into the prompt box at the bottom and press **Enter** to submit. The editor keeps its padded block size and background, while a single left border changes color to reflect focus, shell mode, and active runs without boxing it in. -**Shift+Enter** inserts a newline for multi-line prompts. Tau streams the -assistant's reply above the prompt, showing tool calls as they run. In supported +**Shift+Enter** inserts a newline for multi-line prompts. Press **Alt+E** to +suspend Tau and edit the current prompt in `$VISUAL` or `$EDITOR` (falling back +to Notepad on Windows or `nano` elsewhere); saving and exiting successfully +returns the edited text to the prompt. Tau streams the assistant's reply above +the prompt, showing tool calls as they run. In supported terminal emulators, Tau also updates the tab title: named sessions show as `τ | `, and active runs add an animated running indicator so you can see work continuing from another tab. When a run fully settles while Tau's terminal diff --git a/website/content/reference/configuration.md b/website/content/reference/configuration.md index 34f109abfb..b6ca1c63d7 100644 --- a/website/content/reference/configuration.md +++ b/website/content/reference/configuration.md @@ -272,6 +272,7 @@ The built-in frontend reads optional settings from `~/.tau/tui.json`: "model_cycle": "ctrl+p", "toggle_thinking": "ctrl+t", "toggle_tool_results": "ctrl+o", + "external_editor": "alt+e", "copy_message": "ctrl+c", "quit": "ctrl+d" } diff --git a/website/content/reference/keybindings.md b/website/content/reference/keybindings.md index d2abfd3b61..766c28d597 100644 --- a/website/content/reference/keybindings.md +++ b/website/content/reference/keybindings.md @@ -13,6 +13,7 @@ These are the default keys in the interactive [TUI]({{< relref "../guides/tui.md | --- | --- | | `Enter` | Submit the prompt (or apply a highlighted completion) | | `Shift+Enter` | Insert a newline | +| `Alt+E` | Edit the current prompt in an external editor | | `Esc` | Cancel the active run | | `Enter` (while running) | Queue text as steering for the current run | | `Alt+Enter` | Queue a follow-up that waits until the run would stop | @@ -44,6 +45,10 @@ These are the default keys in the interactive [TUI]({{< relref "../guides/tui.md | `Ctrl+D` | Quit | {{% note title="Remapping" %}} +The external-editor command resolves `$VISUAL`, then `$EDITOR`, and falls back to +Notepad on Windows or `nano` elsewhere. Tau suspends its terminal UI until the +editor exits; only a successful editor exit replaces the current prompt. + Keys use Textual's syntax (`ctrl+k`, `shift+tab`, `down`, `f2`, …). Tau rejects unknown names, empty keys, and duplicate assignments so mistakes fail early. Any key you don't set keeps its default.