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:
Mason Daugherty
2026-07-13 09:59:47 -04:00
committed by GitHub
parent 724e24a315
commit e4c29b5264
2 changed files with 280 additions and 1 deletions
@@ -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(