Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions dev-notes/architecture/external-prompt-editor.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions src/tau_coding/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 34 additions & 2 deletions src/tau_coding/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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: ...
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand 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")
Expand Down Expand Up @@ -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"),
]
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 2 additions & 0 deletions src/tau_coding/tui/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
}
Expand Down
75 changes: 75 additions & 0 deletions src/tau_coding/tui/external_editor.py
Original file line number Diff line number Diff line change
@@ -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)
53 changes: 53 additions & 0 deletions tests/test_tui_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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())
Expand Down
4 changes: 4 additions & 0 deletions tests/test_tui_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
62 changes: 62 additions & 0 deletions tests/test_tui_external_editor.py
Original file line number Diff line number Diff line change
@@ -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()
Loading