mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-21 17:25:26 -04:00
fix(code): sync ask_user active-question highlight with focus (#4599)
Fixed the interactive `ask_user` prompt so the active-question highlight follows mouse clicks — clicking a question now highlights it instead of leaving a different question highlighted while you interact. --- The `ask_user` widget highlights one active question and dims the rest, but the highlight was driven only by keyboard/programmatic navigation (`_set_active_question`). Clicking a dimmed question moved focus into its input or choice list without updating the highlight, so a user could navigate choices or type free text in a question while a *different* question stayed highlighted. This makes the highlight follow focus. The class-toggling logic is split out of `_set_active_question` into a focus-neutral `_highlight_question`, and a new `on_descendant_focus` handler on `AskUserMenu` walks up to the enclosing question and re-highlights it when focus lands there. It deliberately does not move focus, so the widget the user clicked keeps focus; it's idempotent when the focused question is already active. Made by [Open SWE](https://openswe.vercel.app/agents/60f8a2c3-89ae-482f-26f0-bbed49bf671d) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -264,12 +264,16 @@ class AskUserMenu(Container):
|
||||
|
||||
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 @@ class AskUserMenu(Container):
|
||||
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()
|
||||
|
||||
@@ -6,6 +6,7 @@ import asyncio
|
||||
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 @@ class TestAskUserMenu:
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user