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:
Will Bradley
2026-07-13 00:19:12 -06:00
committed by GitHub
parent e6f10a1490
commit 306bd893f2
3 changed files with 159 additions and 7 deletions
+19 -1
View File
@@ -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.
+135 -1
View File
@@ -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."""