diff --git a/libs/code/deepagents_code/app.py b/libs/code/deepagents_code/app.py index c0b1c92ce02..a781a623aea 100644 --- a/libs/code/deepagents_code/app.py +++ b/libs/code/deepagents_code/app.py @@ -12654,7 +12654,13 @@ def _arm_clear_input_pending(self) -> None: 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, @@ -12679,6 +12685,18 @@ def action_quit_app(self) -> None: 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( diff --git a/libs/code/deepagents_code/tui/widgets/chat_input.py b/libs/code/deepagents_code/tui/widgets/chat_input.py index f72b74331e0..75012f1edcf 100644 --- a/libs/code/deepagents_code/tui/widgets/chat_input.py +++ b/libs/code/deepagents_code/tui/widgets/chat_input.py @@ -1254,11 +1254,6 @@ async def _on_key(self, event: events.Key) -> None: 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 @@ async def _on_key(self, event: events.Key) -> None: 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. diff --git a/libs/code/tests/unit_tests/test_app.py b/libs/code/tests/unit_tests/test_app.py index 4fd4a36e6bb..fe7262b7bf5 100644 --- a/libs/code/tests/unit_tests/test_app.py +++ b/libs/code/tests/unit_tests/test_app.py @@ -58,7 +58,7 @@ _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 @@ def test_ctrl_e_not_bound(self) -> None: 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."""