diff --git a/libs/code/deepagents_code/tui/widgets/ask_user.py b/libs/code/deepagents_code/tui/widgets/ask_user.py index 4162eb052d5..793dc7432d9 100644 --- a/libs/code/deepagents_code/tui/widgets/ask_user.py +++ b/libs/code/deepagents_code/tui/widgets/ask_user.py @@ -264,12 +264,16 @@ def confirm_and_advance(self, index: int) -> None: def _set_active_question(self, index: int) -> None: """Update the visual indicator and focus for the active question.""" + self._highlight_question(index) + self._question_widgets[index].focus_input() + + def _highlight_question(self, index: int) -> None: + """Highlight `index` and dim the rest without changing focus.""" self._current_question = index for i, qw in enumerate(self._question_widgets): if i == index: qw.add_class("ask-user-question-active") qw.remove_class("ask-user-question-inactive") - qw.focus_input() else: qw.remove_class("ask-user-question-active") qw.add_class("ask-user-question-inactive") @@ -300,6 +304,23 @@ def action_cancel(self) -> None: # noqa: D102 self._future.set_result({"type": "cancelled"}) self.post_message(self.Cancelled()) + def on_descendant_focus(self, event: events.DescendantFocus) -> None: + """Keep the active-question highlight in sync with focus. + + A mouse click moves focus into another question's text input, or onto + the question container itself for multiple-choice (whose choices are + not individually focusable), without going through + `_set_active_question`, which would otherwise leave the highlight on + the previously active question. Sync the highlight to the focused + question so exactly one question is ever active. Focus is not moved + here, so the widget the user clicked keeps focus. + """ + node: Any = event.widget + while node is not None and not isinstance(node, _QuestionWidget): + node = node.parent + if node is not None and node._index != self._current_question: + self._highlight_question(node._index) + def on_blur(self, event: events.Blur) -> None: # noqa: PLR6301 # Textual event handler """Prevent blur from propagating and dismissing the menu.""" event.stop() diff --git a/libs/code/tests/unit_tests/tui/widgets/test_ask_user.py b/libs/code/tests/unit_tests/tui/widgets/test_ask_user.py index 45bd30d3d71..bb52b0ddd7c 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_ask_user.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_ask_user.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING +from textual import events from textual.app import App, ComposeResult from textual.binding import Binding from textual.widgets import Markdown, Static @@ -531,6 +532,263 @@ async def test_previous_question_navigates_backward(self) -> None: assert qw0.has_class("ask-user-question-active") assert qw1.has_class("ask-user-question-inactive") + async def test_clicking_text_question_moves_active_highlight(self) -> None: + """Clicking a dimmed text question makes it the active/highlighted one.""" + app = _AskUserTestApp( + [ + {"question": "Q1?", "type": "text"}, + {"question": "Q2?", "type": "text"}, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + qw0 = menu._question_widgets[0] + qw1 = menu._question_widgets[1] + assert qw0.has_class("ask-user-question-active") + + second_input = qw1.query_one(".ask-user-text-input", AskUserTextArea) + await pilot.click(second_input) + await pilot.pause() + + assert menu._current_question == 1 + assert qw1.has_class("ask-user-question-active") + assert qw0.has_class("ask-user-question-inactive") + assert not qw0.has_class("ask-user-question-active") + assert second_input.has_focus + + async def test_clicking_multiple_choice_question_moves_active_highlight( + self, + ) -> None: + """Clicking a dimmed multiple-choice question highlights it.""" + app = _AskUserTestApp( + [ + { + "question": "Color?", + "type": "multiple_choice", + "choices": [{"value": "red"}, {"value": "blue"}], + }, + { + "question": "Size?", + "type": "multiple_choice", + "choices": [{"value": "S"}, {"value": "M"}], + }, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + qw0 = menu._question_widgets[0] + qw1 = menu._question_widgets[1] + assert qw0.has_class("ask-user-question-active") + + await pilot.click(qw1) + await pilot.pause() + + assert menu._current_question == 1 + assert qw1.has_focus + assert qw1.has_class("ask-user-question-active") + assert qw0.has_class("ask-user-question-inactive") + + async def test_focus_sync_does_not_steal_focus_from_clicked_widget(self) -> None: + """Syncing the highlight must not move focus off the clicked question.""" + app = _AskUserTestApp( + [ + {"question": "Q1?", "type": "text"}, + {"question": "Q2?", "type": "text"}, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + qw1 = menu._question_widgets[1] + second_input = qw1.query_one(".ask-user-text-input", AskUserTextArea) + + await pilot.click(second_input) + await pilot.pause() + + # Typing lands in the clicked question's input, not the first one. + await pilot.press("h", "i") + await pilot.pause() + assert second_input.text == "hi" + first_input = menu._question_widgets[0].query_one( + ".ask-user-text-input", AskUserTextArea + ) + assert first_input.text == "" + + async def test_focus_sync_does_not_steal_focus_from_clicked_mc_question( + self, + ) -> None: + """Clicking a dimmed MC question keeps focus there for choice nav. + + Multiple-choice questions take focus at the container level (their + `_ChoiceOption`s are not focusable), so this exercises the widget-level + focus path that the text-input focus-steal test does not. + """ + app = _AskUserTestApp( + [ + { + "question": "Color?", + "type": "multiple_choice", + "choices": [{"value": "red"}, {"value": "blue"}], + }, + { + "question": "Size?", + "type": "multiple_choice", + "choices": [{"value": "S"}, {"value": "M"}], + }, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + qw1 = menu._question_widgets[1] + + await pilot.click(qw1) + await pilot.pause() + assert qw1.has_focus + + # Arrow keys drive the clicked question's choice list, not Q1's. + await pilot.press("down") + await pilot.pause() + assert qw1._selected_choice == 1 + assert menu._question_widgets[0]._selected_choice == 0 + + async def test_clicking_already_active_question_is_noop(self) -> None: + """Clicking the currently active question leaves it active and focused.""" + app = _AskUserTestApp( + [ + {"question": "Q1?", "type": "text"}, + {"question": "Q2?", "type": "text"}, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + qw0 = menu._question_widgets[0] + first_input = qw0.query_one(".ask-user-text-input", AskUserTextArea) + assert qw0.has_class("ask-user-question-active") + + # `node._index == _current_question`, so the handler short-circuits. + await pilot.click(first_input) + await pilot.pause() + + assert menu._current_question == 0 + assert qw0.has_class("ask-user-question-active") + assert not qw0.has_class("ask-user-question-inactive") + assert first_input.has_focus + + async def test_click_highlight_preserves_confirmed_and_answers(self) -> None: + """Following focus on click must not confirm or clear existing answers.""" + app = _AskUserTestApp( + [ + {"question": "Q1?", "type": "text"}, + {"question": "Q2?", "type": "text"}, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + first_input = menu._question_widgets[0].query_one( + ".ask-user-text-input", AskUserTextArea + ) + await pilot.press("t", "y", "p", "e", "d") + await pilot.pause() + assert first_input.text == "typed" + + second_input = menu._question_widgets[1].query_one( + ".ask-user-text-input", AskUserTextArea + ) + await pilot.click(second_input) + await pilot.pause() + + # The click only moved the highlight: no confirmation, no lost answer. + assert menu._current_question == 1 + assert menu._confirmed == [False, False] + assert first_input.text == "typed" + + async def test_other_input_focus_syncs_highlight(self) -> None: + """Focusing a question's Other input syncs the highlight to that question. + + Covers the free-text `_other_input` focus path (a distinct descendant + from the plain text input) both when it stays within the active + question and when the user then clicks a different question. + """ + app = _AskUserTestApp( + [ + { + "question": "Color?", + "type": "multiple_choice", + "choices": [{"value": "red"}, {"value": "blue"}], + }, + {"question": "Name?", "type": "text"}, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + qw0 = menu._question_widgets[0] + + # Select "Other" in Q1 (red -> blue -> Other), revealing its input. + await pilot.press("down", "down") + await pilot.press("enter") + await pilot.pause() + + other_input = qw0.query_one(".ask-user-other-input", AskUserTextArea) + assert other_input.has_focus + # Focus is inside Q1, so the highlight stays on Q1 (no-op sync). + assert menu._current_question == 0 + assert qw0.has_class("ask-user-question-active") + + # Clicking Q2 moves the highlight off the active Other input. + second_input = menu._question_widgets[1].query_one( + ".ask-user-text-input", AskUserTextArea + ) + await pilot.click(second_input) + await pilot.pause() + + assert menu._current_question == 1 + assert menu._question_widgets[1].has_class("ask-user-question-active") + assert qw0.has_class("ask-user-question-inactive") + assert second_input.has_focus + + async def test_focus_outside_any_question_leaves_highlight_unchanged(self) -> None: + """Focus with no `_QuestionWidget` ancestor is a no-op for the highlight. + + The walk-up in `on_descendant_focus` terminates at `None` when the + focused widget has no enclosing question. No focusable widget outside a + question exists in the normal layout, so drive the handler directly to + guard the walk-up termination and the `node is not None` check against + regression. + """ + app = _AskUserTestApp( + [ + {"question": "Q1?", "type": "text"}, + {"question": "Q2?", "type": "text"}, + ] + ) + + async with app.run_test() as pilot: + await pilot.pause() + menu = app.query_one("#ask-user-menu", AskUserMenu) + menu._set_active_question(1) + await pilot.pause() + + # `event.widget` is the menu itself; the walk-up climbs its + # ancestors (none are `_QuestionWidget`s) and terminates at `None`. + menu.on_descendant_focus(events.DescendantFocus(menu)) + + assert menu._current_question == 1 + assert menu._question_widgets[1].has_class("ask-user-question-active") + assert menu._question_widgets[0].has_class("ask-user-question-inactive") + async def test_previous_question_clamps_at_first(self) -> None: """At first question: previous is a no-op.""" app = _AskUserTestApp(