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
20 changes: 19 additions & 1 deletion libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Comment thread
wbbradley marked this conversation as resolved.
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
Comment thread
wbbradley marked this conversation as resolved.

self.exit()

def exit(
Expand Down
10 changes: 5 additions & 5 deletions libs/code/deepagents_code/tui/widgets/chat_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
136 changes: 135 additions & 1 deletion libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""

Expand Down
Loading