mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 09:45:24 -04:00
fix(code): preserve Ctrl+D deletion in non-empty input (#4626)
Ctrl+D now deletes the character to the right in a non-empty prompt and only exits from an empty prompt. --- A priority app binding intercepted Ctrl+D before the focused chat text area could apply its standard delete-right behavior. Delegate Ctrl+D to the focused text area while a draft exists, and retain quit behavior for an empty prompt and existing modal flows. Made by [Open SWE](https://openswe.vercel.app/agents/9c524bf4-b1c3-5e11-d131-8d5d44be990b) --------- Co-authored-by: Will Bradley <3030224+wbbradley@users.noreply.github.com> Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> Co-authored-by: Mason Daugherty <mason@langchain.dev> Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -12671,7 +12671,13 @@ class DeepAgentsApp(App):
|
||||
self.set_timer(timeout, lambda: setattr(self, "_clear_input_pending", False))
|
||||
|
||||
def action_quit_app(self) -> None:
|
||||
"""Handle quit action (Ctrl+D)."""
|
||||
"""Handle the Ctrl+D binding.
|
||||
|
||||
Delete-confirm screens and the auth/thread selectors keep their own
|
||||
Ctrl+D behavior. Otherwise, when the chat input is focused and holds a
|
||||
draft, Ctrl+D deletes right of the cursor instead of quitting; the app
|
||||
only exits from an empty (or unfocused) prompt.
|
||||
"""
|
||||
from deepagents_code.tui.widgets.auth import (
|
||||
AuthPromptScreen,
|
||||
DeleteCredentialConfirmScreen,
|
||||
@@ -12696,6 +12702,18 @@ class DeepAgentsApp(App):
|
||||
return
|
||||
self._arm_quit_pending("Ctrl+D")
|
||||
return
|
||||
|
||||
# Delegate Ctrl+D to the chat input's delete-right when it holds a
|
||||
# draft. Check `self.focused` (the active screen's focused widget), not
|
||||
# `text_area.has_focus`: a draft hidden behind a modal keeps focus but
|
||||
# must not be edited from under it, so Ctrl+D quits in that case.
|
||||
chat_input = self._chat_input
|
||||
if chat_input is not None:
|
||||
text_area = chat_input.input_widget
|
||||
if text_area is not None and self.focused is text_area and chat_input.value:
|
||||
text_area.action_delete_right()
|
||||
return
|
||||
|
||||
self.exit()
|
||||
|
||||
def exit(
|
||||
|
||||
@@ -1254,11 +1254,6 @@ class ChatTextArea(TextArea):
|
||||
event.stop()
|
||||
return
|
||||
|
||||
if event.key == "delete" and self._delete_placeholder_token(backwards=False):
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
return
|
||||
|
||||
# If completion is active, let parent handle navigation keys.
|
||||
# Space is included so that slash-command completion can accept the
|
||||
# selected suggestion via the same code path as Tab (avoiding a
|
||||
@@ -1304,6 +1299,11 @@ class ChatTextArea(TextArea):
|
||||
|
||||
await super()._on_key(event)
|
||||
|
||||
def action_delete_right(self) -> None:
|
||||
"""Delete a bound placeholder atomically or the next character."""
|
||||
if not self._delete_placeholder_token(backwards=False):
|
||||
super().action_delete_right()
|
||||
|
||||
def _delete_placeholder_token(self, *, backwards: bool) -> bool:
|
||||
"""Delete a full placeholder token (image, video, or paste) in one keypress.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ from deepagents_code.app import (
|
||||
_warn_discarded_goal_channels,
|
||||
)
|
||||
from deepagents_code.event_bus import ExternalEvent
|
||||
from deepagents_code.media_utils import ImageData
|
||||
from deepagents_code.media_utils import ImageData, VideoData
|
||||
from deepagents_code.tui.widgets.ask_user import AskUserTextArea
|
||||
from deepagents_code.tui.widgets.chat_input import ChatInput
|
||||
from deepagents_code.tui.widgets.goal_review import GoalReviewMenu, GoalReviewResult
|
||||
@@ -1953,6 +1953,140 @@ class TestAppBindings:
|
||||
assert "ctrl+e" not in bindings_by_key
|
||||
|
||||
|
||||
class TestCtrlDChatInput:
|
||||
"""Test Ctrl+D deletion and quit behavior in the main chat input."""
|
||||
|
||||
async def test_ctrl_d_deletes_right_when_input_has_text(self) -> None:
|
||||
"""Ctrl+D should delete right of the cursor without quitting."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
text_area.focus()
|
||||
await pilot.press("h", "e", "l", "l", "o")
|
||||
# ctrl+a moves the cursor to the start of the line (not select-all),
|
||||
# so delete-right removes the leading "h".
|
||||
await pilot.press("ctrl+a")
|
||||
|
||||
with patch.object(app, "exit") as exit_mock:
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == "ello"
|
||||
exit_mock.assert_not_called()
|
||||
# A draft swallows the quit rather than half-arming the double-tap.
|
||||
assert app._quit_pending is False
|
||||
|
||||
async def test_ctrl_d_does_not_quit_at_end_of_non_empty_input(self) -> None:
|
||||
"""Ctrl+D should be a no-op at the end of a non-empty draft."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
text_area.focus()
|
||||
await pilot.press("h", "i")
|
||||
|
||||
with patch.object(app, "exit") as exit_mock:
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == "hi"
|
||||
exit_mock.assert_not_called()
|
||||
assert app._quit_pending is False
|
||||
|
||||
@pytest.mark.parametrize("kind", ["image", "video"])
|
||||
async def test_ctrl_d_deletes_bound_media_placeholder_atomically(
|
||||
self, kind: str
|
||||
) -> None:
|
||||
"""Ctrl+D should delete a bound media placeholder as one token."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
if kind == "image":
|
||||
placeholder = app._image_tracker.add_image(
|
||||
ImageData(base64_data="abc", format="png", placeholder="")
|
||||
)
|
||||
else:
|
||||
placeholder = app._image_tracker.add_video(
|
||||
VideoData(base64_data="abc", format="mp4", placeholder="")
|
||||
)
|
||||
chat_input.value = placeholder
|
||||
text_area.move_cursor((0, 0))
|
||||
text_area.focus()
|
||||
|
||||
with patch.object(app, "exit") as exit_mock:
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == ""
|
||||
assert app._image_tracker.get_images() == []
|
||||
assert app._image_tracker.get_videos() == []
|
||||
exit_mock.assert_not_called()
|
||||
|
||||
async def test_ctrl_d_deletes_collapsed_paste_placeholder_atomically(self) -> None:
|
||||
"""Ctrl+D should preserve collapsed-paste integrity when deleting it."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
chat_input.handle_external_paste("p" * 900)
|
||||
text_area.move_cursor((0, 0))
|
||||
assert chat_input.value == "[Pasted text #1]"
|
||||
|
||||
with patch.object(app, "exit") as exit_mock:
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == ""
|
||||
assert 1 in chat_input._pasted_contents
|
||||
exit_mock.assert_not_called()
|
||||
|
||||
async def test_ctrl_d_quits_when_input_is_empty(self) -> None:
|
||||
"""Ctrl+D should still quit when the focused chat input is empty."""
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
assert chat_input.value == ""
|
||||
text_area.focus()
|
||||
|
||||
with patch.object(app, "exit") as exit_mock:
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
|
||||
exit_mock.assert_called_once()
|
||||
|
||||
async def test_ctrl_d_quits_with_non_empty_draft_hidden_by_modal(self) -> None:
|
||||
"""Ctrl+D should quit instead of editing a draft hidden behind a modal."""
|
||||
from deepagents_code.tui.widgets.update_available import UpdateAvailableScreen
|
||||
|
||||
app = DeepAgentsApp()
|
||||
async with app.run_test() as pilot:
|
||||
chat_input = app.query_one(ChatInput)
|
||||
text_area = chat_input.input_widget
|
||||
assert text_area is not None
|
||||
text_area.focus()
|
||||
await pilot.press("d", "r", "a", "f", "t", "ctrl+a")
|
||||
|
||||
app.push_screen(UpdateAvailableScreen(_update_entry()))
|
||||
await pilot.pause()
|
||||
|
||||
assert text_area.has_focus
|
||||
assert app.focused is not text_area
|
||||
with patch.object(app, "exit") as exit_mock:
|
||||
await pilot.press("ctrl+d")
|
||||
await pilot.pause()
|
||||
|
||||
assert chat_input.value == "draft"
|
||||
exit_mock.assert_called_once()
|
||||
|
||||
|
||||
class TestCtrlCCopySelection:
|
||||
"""Test Ctrl+C copying a focused input's selection instead of quitting."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user