fix(code): quit with Ctrl+D at end of prompt (#4678)

Follow-up to #4626.

`Ctrl+D` now quits Deep Agents Code when the cursor is at the end of the
prompt, including when the prompt contains text. It continues to delete
selected text or content to the right of the cursor.

---

After #4626, `Ctrl+D` preserves its delete-right behavior whenever the
prompt contains a draft. At the end of a non-empty prompt, however,
there is nothing to delete and the key does nothing.

Treat the end of the prompt like the empty-prompt case: `Ctrl+D` quits
when there is no selection and no content to the right. It continues to
delete selected text, delete the next character, or join multiline input
when the cursor is not at the true end of the prompt.
This commit is contained in:
Mason Daugherty
2026-07-13 03:14:30 -04:00
committed by GitHub
parent b1600a8da7
commit 2f8c11176f
2 changed files with 90 additions and 11 deletions
+17 -8
View File
@@ -12877,9 +12877,10 @@ class DeepAgentsApp(App):
"""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.
Ctrl+D behavior. Otherwise, when the chat input is focused, Ctrl+D
deletes a non-empty selection or the character right of the cursor.
Only at the end of the prompt with no active selection does it exit
the app.
"""
from deepagents_code.tui.widgets.auth import (
AuthPromptScreen,
@@ -12906,14 +12907,22 @@ class DeepAgentsApp(App):
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.
# Delegate Ctrl+D when delete-right can remove selected text or content
# after the cursor. 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:
if (
text_area is not None
and self.focused is text_area
and chat_input.value
and (
not text_area.selection.is_empty
or text_area.cursor_location != text_area.document.end
)
):
text_area.action_delete_right()
return
+73 -3
View File
@@ -1978,8 +1978,8 @@ class TestCtrlDChatInput:
# 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."""
async def test_ctrl_d_quits_at_end_of_non_empty_input(self) -> None:
"""Ctrl+D should quit at the end of a non-empty draft."""
app = DeepAgentsApp()
async with app.run_test() as pilot:
chat_input = app.query_one(ChatInput)
@@ -1988,14 +1988,84 @@ class TestCtrlDChatInput:
text_area.focus()
await pilot.press("h", "i")
# Precondition for the quit path: cursor at document end, no
# selection. Asserted explicitly so the test documents why this
# configuration quits and survives any future default that would
# pre-select or reposition the input.
assert text_area.selection.is_empty
assert text_area.cursor_location == text_area.document.end
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()
exit_mock.assert_called_once()
assert app._quit_pending is False
async def test_ctrl_d_deletes_selection_at_end_of_input(self) -> None:
"""Ctrl+D should delete a selection reaching document end, not quit."""
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")
# select_all leaves a non-empty selection with the cursor at
# document end, so only the `selection.is_empty` branch — not the
# end-of-document check — keeps Ctrl+D from quitting here.
text_area.select_all()
assert not text_area.selection.is_empty
assert text_area.cursor_location == text_area.document.end
with patch.object(app, "exit") as exit_mock:
await pilot.press("ctrl+d")
await pilot.pause()
assert chat_input.value == ""
exit_mock.assert_not_called()
async def test_ctrl_d_deletes_right_at_end_of_non_final_line(self) -> None:
"""Ctrl+D mid-draft (end of a non-final line) should join lines, not quit."""
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.text = "ab\ncd"
text_area.focus()
# End of the first line: not document end, so delete-right removes
# the newline and joins the two lines instead of quitting.
text_area.move_cursor((0, 2))
assert text_area.cursor_location != text_area.document.end
with patch.object(app, "exit") as exit_mock:
await pilot.press("ctrl+d")
await pilot.pause()
assert chat_input.value == "abcd"
exit_mock.assert_not_called()
async def test_ctrl_d_quits_at_document_end_of_multiline_input(self) -> None:
"""Ctrl+D at the true end of a multi-line draft should quit."""
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.text = "ab\ncd"
text_area.focus()
text_area.move_cursor(text_area.document.end)
assert text_area.selection.is_empty
with patch.object(app, "exit") as exit_mock:
await pilot.press("ctrl+d")
await pilot.pause()
assert chat_input.value == "ab\ncd"
exit_mock.assert_called_once()
@pytest.mark.parametrize("kind", ["image", "video"])
async def test_ctrl_d_deletes_bound_media_placeholder_atomically(
self, kind: str