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
3 changes: 3 additions & 0 deletions src/deep_code_agent/tui/screens/main_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ def action_help(self) -> None:
def action_clear_chat(self) -> None:
"""Clear the conversation stream."""
chat_log = self.get_chat_log()
if chat_log.has_pending_approval_request():
self.get_status_bar().set_waiting_approval("Resolve approval before clearing")
return
chat_log.clear_messages()
chat_log.add_session_header(self.session_info)
chat_log.add_system_message("Conversation cleared.")
Expand Down
4 changes: 4 additions & 0 deletions src/deep_code_agent/tui/widgets/approval_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ def __init__(self, interrupt_data: object, callback: Callable[[dict], None], **k
self._debug_data = tool_call.debug_data
self._resolved = False

@property
def is_pending(self) -> bool:
return not self._resolved

def compose(self) -> ComposeResult:
yield Static("Tool approval required", id="approval-title", markup=False)
yield Static(self._summary_text(), id="approval-summary", markup=False)
Expand Down
6 changes: 6 additions & 0 deletions src/deep_code_agent/tui/widgets/chat_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,12 @@ def add_approval_request(self, interrupt_data, callback, focus: bool = True):
self.call_after_refresh(widget.focus)
return widget

def has_pending_approval_request(self) -> bool:
"""Return whether the transcript contains an unresolved approval card."""
from deep_code_agent.tui.widgets.approval_request import ApprovalRequest

return any(widget.is_pending for widget in self.query(ApprovalRequest))

def upsert_todos_card(self, todos: list[dict[str, str]]) -> TodosProgressCard:
"""Create or update the singleton todos progress card.

Expand Down
78 changes: 69 additions & 9 deletions src/deep_code_agent/tui/widgets/input_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@


class ComposerInput(Input):
"""Prompt input with slash-command navigation keys."""
"""Prompt input with navigation keys delegated to the composer."""

BINDINGS = [
Binding("up", "slash_previous", "Previous command", show=False, priority=True),
Binding("down", "slash_next", "Next command", show=False, priority=True),
Binding("up", "slash_previous", "Previous prompt", show=False, priority=True),
Binding("down", "slash_next", "Next prompt", show=False, priority=True),
Binding("tab", "slash_complete", "Complete command", show=False, priority=True),
]

def action_slash_previous(self) -> None:
handler = getattr(self.parent, "select_previous_slash_command", None)
handler = getattr(self.parent, "select_previous_prompt_item", None)
if callable(handler):
handler()

def action_slash_next(self) -> None:
handler = getattr(self.parent, "select_next_slash_command", None)
handler = getattr(self.parent, "select_next_prompt_item", None)
if callable(handler):
handler()

Expand Down Expand Up @@ -140,6 +140,9 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._slash_commands: list[SlashCommand] = []
self._slash_index = 0
self._input_history: list[str] = []
self._history_index: int | None = None
self._history_draft = ""

class UserInput(Message):
"""Message sent when user submits input.
Expand Down Expand Up @@ -180,12 +183,12 @@ def action_submit(self) -> None:
self._submit_input()

def action_slash_previous(self) -> None:
"""Select the previous visible slash command."""
self.select_previous_slash_command()
"""Select the previous slash command or history item."""
self.select_previous_prompt_item()

def action_slash_next(self) -> None:
"""Select the next visible slash command."""
self.select_next_slash_command()
"""Select the next slash command or history item."""
self.select_next_prompt_item()

def action_slash_complete(self) -> None:
"""Complete the selected slash command, or fall back to focus navigation."""
Expand All @@ -211,6 +214,9 @@ def _submit_input(self) -> None:
content = input_widget.value.strip()

if content:
self._input_history.append(content)
self._history_index = None
self._history_draft = ""
self.post_message(self.UserInput(content))
input_widget.value = ""
self._hide_slash_command_menu()
Expand Down Expand Up @@ -241,6 +247,20 @@ def focus_input(self) -> None:
"""Focus the input field."""
self.query_one("#user-input", Input).focus()

def select_previous_prompt_item(self) -> bool:
"""Move up through slash suggestions or submitted input history."""
if self._is_slash_command_input():
self.select_previous_slash_command()
return True
return self.select_previous_history_item()

def select_next_prompt_item(self) -> bool:
"""Move down through slash suggestions or submitted input history."""
if self._is_slash_command_input():
self.select_next_slash_command()
return True
return self.select_next_history_item()

def select_previous_slash_command(self) -> bool:
"""Move the slash-command selection up."""
if not self._slash_commands:
Expand All @@ -257,6 +277,37 @@ def select_next_slash_command(self) -> bool:
self._render_slash_command_menu()
return True

def select_previous_history_item(self) -> bool:
"""Move to an older submitted prompt."""
if not self._input_history:
return False

input_widget = self.query_one("#user-input", Input)
if self._history_index is None:
self._history_draft = input_widget.value
self._history_index = len(self._input_history) - 1
elif self._history_index > 0:
self._history_index -= 1

self._set_input_value(self._input_history[self._history_index])
return True

def select_next_history_item(self) -> bool:
"""Move to a newer submitted prompt, or restore the draft."""
if self._history_index is None:
return False

if self._history_index < len(self._input_history) - 1:
self._history_index += 1
value = self._input_history[self._history_index]
else:
self._history_index = None
value = self._history_draft
self._history_draft = ""

self._set_input_value(value)
return True

def complete_selected_slash_command(self) -> bool:
"""Complete the currently selected slash command into the prompt."""
if not self._slash_commands:
Expand All @@ -268,6 +319,15 @@ def complete_selected_slash_command(self) -> bool:
self._hide_slash_command_menu()
return True

def _set_input_value(self, value: str) -> None:
input_widget = self.query_one("#user-input", Input)
input_widget.value = value
input_widget.cursor_position = len(value)

def _is_slash_command_input(self) -> bool:
input_widget = self.query_one("#user-input", Input)
return self._history_index is None and command_token(input_widget.value) is not None

def _refresh_slash_command_menu(self, value: str) -> None:
menu = self.query_one("#slash-command-menu", Static)
if command_token(value) is None:
Expand Down
60 changes: 60 additions & 0 deletions tests/tui/test_main_screen_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,63 @@ def capture_notify(*args, **kwargs):
assert notifications == []

asyncio.run(run_test())


def test_tui_clear_is_ignored_while_approval_is_pending():
"""Clearing the transcript must not remove the active approval callback holder."""
from deep_code_agent.tui.app import DeepCodeAgentApp

async def run_test():
app = DeepCodeAgentApp(agent=object())
async with app.run_test() as pilot:
await pilot.pause()
screen = app._main_screen
assert screen is not None

chat_log = screen.get_chat_log()
chat_log.add_user_message("before approval")
approval = chat_log.add_approval_request(
{"action_requests": [{"action": {"name": "terminal", "args": {"cmd": "pwd"}}}]},
callback=lambda decision: None,
)
await pilot.pause()

children_before_clear = list(chat_log.children)
screen.action_clear_chat()
await pilot.pause()

assert list(chat_log.children) == children_before_clear
assert approval in chat_log.children
assert screen.get_status_bar().status == "waiting_approval"

asyncio.run(run_test())


def test_tui_clear_removes_resolved_approval_requests():
"""Resolved approval cards are normal transcript content and can be cleared."""
from deep_code_agent.tui.app import DeepCodeAgentApp

async def run_test():
app = DeepCodeAgentApp(agent=object())
async with app.run_test() as pilot:
await pilot.pause()
screen = app._main_screen
assert screen is not None

chat_log = screen.get_chat_log()
approval = chat_log.add_approval_request(
{"action_requests": [{"action": {"name": "terminal", "args": {"cmd": "pwd"}}}]},
callback=lambda decision: None,
)
await pilot.pause()

approval.action_confirm_selection()
await pilot.pause()
screen.action_clear_chat()
await pilot.pause()

assert approval not in chat_log.children
chat_text = "\n".join(str(getattr(child, "content", "")) for child in chat_log.children)
assert "Conversation cleared." in chat_text

asyncio.run(run_test())
131 changes: 131 additions & 0 deletions tests/tui/test_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,111 @@ async def run_test():
asyncio.run(run_test())


def test_input_box_arrow_navigation_recalls_input_history():
"""Up and Down should navigate submitted prompts when not completing slash commands."""
import asyncio

from deep_code_agent.tui.widgets.input_box import InputBox

async def run_test():
async with App().run_test() as pilot:
widget = InputBox()
await pilot.app.mount(widget)
await pilot.pause()

input_widget = widget.query_one("#user-input", Input)
input_widget.focus()

input_widget.value = "first prompt"
widget._submit_input()
input_widget.value = "second prompt"
widget._submit_input()
await pilot.pause()

await pilot.press("up")
await pilot.pause()
assert input_widget.value == "second prompt"
assert input_widget.cursor_position == len("second prompt")

await pilot.press("up")
await pilot.pause()
assert input_widget.value == "first prompt"

await pilot.press("down")
await pilot.pause()
assert input_widget.value == "second prompt"

await pilot.press("down")
await pilot.pause()
assert input_widget.value == ""

asyncio.run(run_test())


def test_input_box_history_navigation_restores_current_draft():
"""Returning past the newest history item should restore the unsent draft."""
import asyncio

from deep_code_agent.tui.widgets.input_box import InputBox

async def run_test():
async with App().run_test() as pilot:
widget = InputBox()
await pilot.app.mount(widget)
await pilot.pause()

input_widget = widget.query_one("#user-input", Input)
input_widget.focus()

input_widget.value = "sent prompt"
widget._submit_input()
input_widget.value = "unsent draft"
await pilot.pause()

await pilot.press("up")
await pilot.pause()
assert input_widget.value == "sent prompt"

await pilot.press("down")
await pilot.pause()
assert input_widget.value == "unsent draft"
assert input_widget.cursor_position == len("unsent draft")

asyncio.run(run_test())


def test_input_box_history_navigation_continues_past_slash_command_entries():
"""History navigation should not switch to slash completion after recalling a command."""
import asyncio

from deep_code_agent.tui.widgets.input_box import InputBox

async def run_test():
async with App().run_test() as pilot:
widget = InputBox()
await pilot.app.mount(widget)
await pilot.pause()

input_widget = widget.query_one("#user-input", Input)
input_widget.focus()

input_widget.value = "older prompt"
widget._submit_input()
input_widget.value = "/help"
widget._submit_input()
await pilot.pause()

await pilot.press("up")
await pilot.pause()
assert input_widget.value == "/help"

await pilot.press("up")
await pilot.pause()
assert input_widget.value == "older prompt"

asyncio.run(run_test())


def test_side_panel_compacts_long_codebase_path():
"""Long paths should not overwhelm the sidebar."""
from deep_code_agent.tui.widgets.side_panel import SidePanel
Expand Down Expand Up @@ -279,6 +384,32 @@ async def run_test():
asyncio.run(run_test())


def test_chat_log_tracks_pending_approval_requests():
"""Only unresolved approval cards should block transcript clearing."""
import asyncio

from deep_code_agent.tui.widgets.chat_log import ChatLog

async def run_test():
async with App().run_test() as pilot:
chat_log = ChatLog()
await pilot.app.mount(chat_log)
await pilot.pause()

widget = chat_log.add_approval_request(
{"action_requests": [{"action": {"name": "terminal", "args": {"cmd": "pwd"}}}]},
callback=lambda decision: None,
)
await pilot.pause()

assert chat_log.has_pending_approval_request()
widget.action_confirm_selection()
await pilot.pause()
assert not chat_log.has_pending_approval_request()

asyncio.run(run_test())


def test_chat_log_focuses_inline_approval_request():
"""Keyboard navigation should work without clicking the approval card first."""
import asyncio
Expand Down
Loading