From 4f6fbe4810e3a2f2b5b297399c111fc98326f4c1 Mon Sep 17 00:00:00 2001 From: hzhaoy Date: Mon, 6 Jul 2026 23:52:57 +0800 Subject: [PATCH] fix(tui): keep prompt flow recoverable Add in-memory prompt history for the TUI composer so arrow navigation recalls recent prompts without breaking slash-command completion. Guard transcript clearing while an inline approval card is still pending so the callback that resumes an interrupted stream remains reachable. Constraint: Inline HITL approvals are mounted inside the chat transcript. Rejected: Preserve only approval widgets during clear | leaves users with detached approval context after erasing the surrounding transcript. Confidence: high Scope-risk: narrow Reversibility: clean Directive: Do not clear pending approval widgets without preserving a usable resume decision path. Tested: uv run pytest Tested: git diff --check --- .../tui/screens/main_screen.py | 3 + .../tui/widgets/approval_request.py | 4 + src/deep_code_agent/tui/widgets/chat_log.py | 6 + src/deep_code_agent/tui/widgets/input_box.py | 78 +++++++++-- tests/tui/test_main_screen_commands.py | 60 ++++++++ tests/tui/test_widgets.py | 131 ++++++++++++++++++ 6 files changed, 273 insertions(+), 9 deletions(-) diff --git a/src/deep_code_agent/tui/screens/main_screen.py b/src/deep_code_agent/tui/screens/main_screen.py index 9d7a8dc..11cb704 100644 --- a/src/deep_code_agent/tui/screens/main_screen.py +++ b/src/deep_code_agent/tui/screens/main_screen.py @@ -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.") diff --git a/src/deep_code_agent/tui/widgets/approval_request.py b/src/deep_code_agent/tui/widgets/approval_request.py index bc8a4af..b7f969d 100644 --- a/src/deep_code_agent/tui/widgets/approval_request.py +++ b/src/deep_code_agent/tui/widgets/approval_request.py @@ -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) diff --git a/src/deep_code_agent/tui/widgets/chat_log.py b/src/deep_code_agent/tui/widgets/chat_log.py index 0d89441..64f79bb 100644 --- a/src/deep_code_agent/tui/widgets/chat_log.py +++ b/src/deep_code_agent/tui/widgets/chat_log.py @@ -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. diff --git a/src/deep_code_agent/tui/widgets/input_box.py b/src/deep_code_agent/tui/widgets/input_box.py index 0fd697f..f096649 100644 --- a/src/deep_code_agent/tui/widgets/input_box.py +++ b/src/deep_code_agent/tui/widgets/input_box.py @@ -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() @@ -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. @@ -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.""" @@ -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() @@ -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: @@ -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: @@ -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: diff --git a/tests/tui/test_main_screen_commands.py b/tests/tui/test_main_screen_commands.py index 119051f..f20f173 100644 --- a/tests/tui/test_main_screen_commands.py +++ b/tests/tui/test_main_screen_commands.py @@ -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()) diff --git a/tests/tui/test_widgets.py b/tests/tui/test_widgets.py index 4d19e62..c0c97eb 100644 --- a/tests/tui/test_widgets.py +++ b/tests/tui/test_widgets.py @@ -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 @@ -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