Skip to content
Merged
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
104 changes: 88 additions & 16 deletions libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -568,7 +568,11 @@ class _ConfigWriteResult:
from deepagents_code.tui.widgets.auth import AuthManagerScreen
from deepagents_code.tui.widgets.cwd_switch import CwdSwitchAbortMode
from deepagents_code.tui.widgets.debug_console import SnapshotField
from deepagents_code.tui.widgets.goal_review import GoalReviewMenu, GoalReviewResult
from deepagents_code.tui.widgets.goal_review import (
GoalReviewMenu,
GoalReviewResult,
GoalReviewTextArea,
)
from deepagents_code.tui.widgets.model_selector import ModelSelectorScreen
from deepagents_code.tui.widgets.notification_center import (
NotificationActionRequested,
Expand Down Expand Up @@ -15099,35 +15103,103 @@ def action_approval_escape(self) -> None:
if self._pending_approval_widget:
self._pending_approval_widget.action_select_reject()

async def action_open_editor(self) -> None:
"""Open the current prompt text in an external editor ($VISUAL/$EDITOR)."""
from deepagents_code.editor import open_in_editor
def _focused_goal_review_editor(self) -> GoalReviewTextArea | None:
"""Return the active focused goal-review editor, if any."""
menu = self._pending_goal_review_widget
if (
menu is None
or menu._input_mode is None
or not menu.is_attached
or not menu.display
or not menu.visible
):
return None

chat_input = self._chat_input
if not chat_input or not chat_input._text_area:
return
from deepagents_code.tui.widgets.goal_review import GoalReviewTextArea

focused = self.focused
if (
not isinstance(focused, GoalReviewTextArea)
or focused is not menu._edit_input
or not focused.is_attached
or not focused.display
or not focused.visible
):
return None
return focused

async def _open_text_area_in_editor(
self,
text_area: TextArea,
current_text: str,
*,
allow_empty: bool,
raise_editor_errors: bool,
restore_focus: Callable[[], object],
reset_after_edit: Callable[[], None] | None = None,
) -> None:
"""Edit text externally, then restore the originating field's focus.

current_text = chat_input._text_area.text or ""
Args:
text_area: Field to replace when the editor returns a result.
current_text: Complete value to pre-populate in the editor.
allow_empty: Whether a blank edited result should replace the field.
raise_editor_errors: Whether launch and file errors should reach the
notification handler instead of looking like cancellation.
restore_focus: Callback that restores the originating editable surface.
reset_after_edit: Optional state reset after replacing the field text.
"""
from deepagents_code.editor import open_in_editor

edited: str | None = None
try:
with self.suspend():
edited = open_in_editor(current_text)
edited = open_in_editor(
current_text,
allow_empty=allow_empty,
raise_on_error=raise_editor_errors,
)
except Exception:
logger.warning("External editor failed", exc_info=True)
self.notify(
"External editor failed. Check $VISUAL/$EDITOR.",
severity="error",
timeout=5,
)
chat_input.focus_input()
else:
if edited is not None:
text_area.text = edited
if reset_after_edit is not None:
reset_after_edit()
lines = edited.split("\n")
text_area.move_cursor((len(lines) - 1, len(lines[-1])))
finally:
restore_focus()

async def action_open_editor(self) -> None:
"""Open the focused editable surface in $VISUAL/$EDITOR."""
goal_editor = self._focused_goal_review_editor()
if goal_editor is not None:
await self._open_text_area_in_editor(
goal_editor,
goal_editor.submitted_value,
allow_empty=True,
raise_editor_errors=True,
restore_focus=goal_editor.focus,
reset_after_edit=goal_editor.reset_paste_state,
)
return

chat_input = self._chat_input
if not chat_input or not chat_input._text_area:
return

if edited is not None:
chat_input._text_area.text = edited
lines = edited.split("\n")
chat_input._text_area.move_cursor((len(lines) - 1, len(lines[-1])))
chat_input.focus_input()
await self._open_text_area_in_editor(
chat_input._text_area,
chat_input._text_area.text or "",
allow_empty=False,
raise_editor_errors=False,
restore_focus=chat_input.focus_input,
)

def on_paste(self, event: Paste) -> None:
"""Route unfocused paste events to chat input for drag/drop reliability."""
Expand Down
41 changes: 34 additions & 7 deletions libs/code/deepagents_code/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"""Set of vim-family editor base names that receive the `-i NONE` flag."""


class ExternalEditorError(RuntimeError):
"""Raised when an external editor cannot be opened or read."""


def resolve_editor() -> list[str] | None:
"""Resolve editor command from environment.

Expand Down Expand Up @@ -70,21 +74,37 @@ def _prepare_command(cmd: list[str], filepath: str) -> list[str]:
return cmd


def open_in_editor(current_text: str) -> str | None:
def open_in_editor(
current_text: str,
*,
allow_empty: bool = False,
raise_on_error: bool = False,
) -> str | None:
"""Open current_text in an external editor.

Creates a temp .md file, launches the editor, and reads back the result.

Args:
current_text: The text to pre-populate in the editor.
allow_empty: Return an empty or whitespace-only edited result instead of
treating it as cancellation.
raise_on_error: Re-raise editor launch and file errors instead of treating
them as cancellation.

Returns:
The edited text with normalized line endings, or `None` if the editor
exited with a non-zero status, was not found, or the result was
empty/whitespace-only.
exited with a non-zero status, returned blank text while `allow_empty`
is false, or failed while `raise_on_error` is false.

Raises:
ExternalEditorError: If opening or reading the editor file fails while
`raise_on_error` is true.
"""
cmd = resolve_editor()
if cmd is None:
if raise_on_error:
msg = "Editor command resolved to no arguments"
raise ExternalEditorError(msg)
return None

tmp_path: str | None = None
Expand Down Expand Up @@ -125,14 +145,21 @@ def open_in_editor(current_text: str) -> str | None:
# while preserving any intentional trailing newlines the user added.
edited = edited.removesuffix("\n")

# Treat empty result as cancellation
if not edited.strip():
# Chat composition historically treats a blank result as cancellation;
# callers with their own submit-time validation may opt in to preserving it.
if not allow_empty and not edited.strip():
return None

except FileNotFoundError:
except FileNotFoundError as exc:
if raise_on_error:
msg = "External editor executable or temporary file was not found"
raise ExternalEditorError(msg) from exc
return None
except Exception:
except Exception as exc:
logger.warning("Editor failed", exc_info=True)
if raise_on_error:
msg = "External editor failed"
raise ExternalEditorError(msg) from exc
return None
else:
return edited
Expand Down
8 changes: 5 additions & 3 deletions libs/code/deepagents_code/tui/widgets/goal_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def _hint_empty_submission(self, what: str) -> None:
glyphs = get_glyphs()
self._help_widget.update(
f"Enter some {what}, or press Esc to go back {glyphs.bullet} "
"Shift+Enter newline"
f"Shift+Enter newline {glyphs.bullet} Ctrl+X external editor"
)

def _submit(self, result: GoalReviewResult) -> None:
Expand All @@ -388,13 +388,15 @@ def _update_options(self) -> None:
if self._input_mode == "edit":
self._help_widget.update(
f"Enter save edits {glyphs.bullet} "
f"Shift+Enter newline {glyphs.bullet} Esc back"
f"Shift+Enter newline {glyphs.bullet} "
f"Ctrl+X external editor {glyphs.bullet} Esc back"
)
return
if self._input_mode == "reject":
self._help_widget.update(
f"Enter regenerate {glyphs.bullet} "
f"Shift+Enter newline {glyphs.bullet} Esc back"
f"Shift+Enter newline {glyphs.bullet} "
f"Ctrl+X external editor {glyphs.bullet} Esc back"
)
return
self._help_widget.update(
Expand Down
Loading