mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-27 10:51:26 -04:00
fix(code): standardize modal navigation hints (#5699)
Modal footers now consistently advertise forward and reverse Tab navigation, with specialized hints where Tab autocompletes, jumps servers, or switches tabs. --- Centralizes the standard copy, fixes app-level Shift+Tab routing for the effort and launch preference selectors, and keeps the MCP footer compact enough to preserve list navigation at standard terminal sizes. Made by [Open SWE](https://openswe.vercel.app/agents/1e0aca0d-8819-5754-acdc-4a08b1b2ac0c) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -159,6 +159,13 @@ class _SupportsReverseNav(Protocol):
|
||||
`shift+tab` in mind. A screen that wants different behavior -- or none --
|
||||
needs an explicit branch ahead of the protocol check in
|
||||
`action_toggle_auto_approve`.
|
||||
|
||||
Enrollment is by method *name*, so it covers only one of the two cursor
|
||||
conventions in this codebase. `OptionList`-hosting modals name their action
|
||||
`action_cursor_up` instead; those never match this protocol, fall through
|
||||
to the `ModalScreen` catch-all, and swallow `shift+tab` with no error while
|
||||
their footer still advertises it. Such a screen must be added to the
|
||||
explicit `isinstance` tuple in `action_toggle_auto_approve`.
|
||||
"""
|
||||
|
||||
def action_move_up(self) -> None: ...
|
||||
@@ -20836,6 +20843,10 @@ class DeepAgentsApp(App):
|
||||
from deepagents_code.tui.modals.plugin_manager import PluginManagerScreen
|
||||
from deepagents_code.tui.widgets.agent_selector import AgentSelectorScreen
|
||||
from deepagents_code.tui.widgets.auth import AuthManagerScreen, AuthPromptScreen
|
||||
from deepagents_code.tui.widgets.effort_selector import EffortSelectorScreen
|
||||
from deepagents_code.tui.widgets.launch_init import (
|
||||
LaunchGoalCriteriaPreferenceScreen,
|
||||
)
|
||||
from deepagents_code.tui.widgets.mcp_viewer import MCPViewerScreen
|
||||
from deepagents_code.tui.widgets.theme_selector import ThemeSelectorScreen
|
||||
from deepagents_code.tui.widgets.thread_selector import ThreadSelectorScreen
|
||||
@@ -20845,7 +20856,13 @@ class DeepAgentsApp(App):
|
||||
return
|
||||
if isinstance(
|
||||
self.screen,
|
||||
(ThemeSelectorScreen, AgentSelectorScreen, AuthManagerScreen),
|
||||
(
|
||||
ThemeSelectorScreen,
|
||||
AgentSelectorScreen,
|
||||
AuthManagerScreen,
|
||||
EffortSelectorScreen,
|
||||
LaunchGoalCriteriaPreferenceScreen,
|
||||
),
|
||||
):
|
||||
self.screen.action_cursor_up()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Shared keyboard hints for terminal UI components.
|
||||
|
||||
Modal footers advertise the same navigation keys in a dozen screens. Keeping
|
||||
the wording here means a binding change is edited once instead of being chased
|
||||
across every modal, and the glyph substitution for ASCII terminals cannot drift
|
||||
between them.
|
||||
"""
|
||||
|
||||
from deepagents_code.config import Glyphs
|
||||
|
||||
|
||||
def modal_navigation_hint(glyphs: Glyphs) -> str:
|
||||
"""Build the navigation hint for modals whose Tab keys move the cursor.
|
||||
|
||||
Only for screens where Tab and Shift+Tab step the selection. A modal that
|
||||
binds Tab to something else writes its own line rather than using this one
|
||||
-- `mcp_viewer` (Tab jumps between servers), `model_selector` (Tab
|
||||
autocompletes), and `plugin_manager` (Tab cycles tabs) all do.
|
||||
|
||||
The hint is long enough to wrap once a modal narrows, so the host `Static`
|
||||
needs `height: auto` to grow and `dock: bottom` to reserve the extra row.
|
||||
Without the dock the wrapped row is laid out past the modal's bottom edge,
|
||||
where the compositor never paints it.
|
||||
|
||||
Args:
|
||||
glyphs: Glyph set for the active terminal mode.
|
||||
|
||||
Returns:
|
||||
The navigation hint for the active glyph set.
|
||||
"""
|
||||
return f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab navigate"
|
||||
@@ -14,6 +14,7 @@ from textual.widgets import Static
|
||||
from deepagents_code._session_stats import format_cost_estimate, format_token_count
|
||||
from deepagents_code.cold_cache import format_cache_age, format_cache_window
|
||||
from deepagents_code.config import get_glyphs
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import ComposeResult
|
||||
@@ -310,7 +311,7 @@ class ColdCacheWarningScreen(ModalScreen[ColdCacheChoice | None]):
|
||||
self._options.append(option)
|
||||
yield option
|
||||
help_text = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate "
|
||||
f"{modal_navigation_hint(glyphs)} "
|
||||
f"{glyphs.bullet} Enter select "
|
||||
f"{glyphs.bullet} Esc cancel"
|
||||
)
|
||||
|
||||
@@ -559,7 +559,7 @@ class PluginManagerScreen(ModalScreen[PluginManagerResult]): # noqa: RUF067
|
||||
help_text.update(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} "
|
||||
f"Enter add/view {glyphs.bullet} "
|
||||
f"Left/Right tabs {glyphs.bullet} Esc close"
|
||||
f"Left/Right or Tab/Shift+Tab tabs {glyphs.bullet} Esc close"
|
||||
)
|
||||
elif self._tab in {"discover", "installed"}:
|
||||
if self._tab == "installed":
|
||||
@@ -573,11 +573,13 @@ class PluginManagerScreen(ModalScreen[PluginManagerResult]): # noqa: RUF067
|
||||
)
|
||||
help_text.update(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} select {glyphs.bullet} "
|
||||
f"Enter {action} {glyphs.bullet} {search_hint}Left/Right tabs "
|
||||
f"{glyphs.bullet} Esc close"
|
||||
f"Enter {action} {glyphs.bullet} {search_hint}Left/Right or "
|
||||
f"Tab/Shift+Tab tabs {glyphs.bullet} Esc close"
|
||||
)
|
||||
else:
|
||||
help_text.update(f"Left/Right tabs {glyphs.bullet} Esc close")
|
||||
help_text.update(
|
||||
f"Left/Right or Tab/Shift+Tab tabs {glyphs.bullet} Esc close"
|
||||
)
|
||||
|
||||
def _active_details_options(self) -> list[Option]:
|
||||
if self._mode == "plugin_details":
|
||||
|
||||
@@ -88,6 +88,7 @@ PluginManagerScreen #plugin-marketplace-source:focus {
|
||||
}
|
||||
|
||||
PluginManagerScreen .plugin-manager-help {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
|
||||
@@ -20,6 +20,7 @@ if TYPE_CHECKING:
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import Glyphs, get_glyphs, is_ascii_mode
|
||||
from deepagents_code.model_config import clear_default_agent, save_default_agent
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,6 +86,7 @@ class AgentSelectorScreen(ModalScreen[str | None]):
|
||||
}
|
||||
|
||||
AgentSelectorScreen .agent-selector-help {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
@@ -196,12 +198,14 @@ class AgentSelectorScreen(ModalScreen[str | None]):
|
||||
def _help_text(glyphs: Glyphs) -> str:
|
||||
r"""Build the help-line text shown beneath the option list.
|
||||
|
||||
Split into two balanced rows joined by `\n` so the wrap is
|
||||
predictable at the modal's fixed 60-column width — Textual's
|
||||
default word-wrap might otherwise break mid-phrase (e.g.,
|
||||
between "set" and "default"), which reads as a bug. The Static
|
||||
host has `height: auto` and `text-align: center`, so each row
|
||||
centers on its own line.
|
||||
Split into two rows joined by `\n` so the break point is fixed
|
||||
rather than left to Textual's word-wrap, which might otherwise
|
||||
break mid-phrase (e.g., between "set" and "default") and read as
|
||||
a bug. Each row stays inside the modal's 56-column content width
|
||||
(60 wide, less `padding: 1 2`); they are not equal length. The
|
||||
Static host has `dock: bottom`, `height: auto`, and
|
||||
`text-align: center`, so each row centers on its own line and a
|
||||
row that wraps anyway stays inside the modal.
|
||||
|
||||
Args:
|
||||
glyphs: Glyph set for the active terminal mode.
|
||||
@@ -210,7 +214,7 @@ class AgentSelectorScreen(ModalScreen[str | None]):
|
||||
Two-line help string describing the available key bindings.
|
||||
"""
|
||||
return (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
|
||||
f"{modal_navigation_hint(glyphs)}"
|
||||
f" {glyphs.bullet} Enter select\n"
|
||||
f"Ctrl+S set default {glyphs.bullet} Esc cancel"
|
||||
)
|
||||
|
||||
@@ -71,6 +71,7 @@ from deepagents_code.model_config import (
|
||||
is_service,
|
||||
resolved_env_var_name,
|
||||
)
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
from deepagents_code.tui.widgets._links import open_style_link
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1588,6 +1589,7 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
}
|
||||
|
||||
AuthManagerScreen .auth-manager-help {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
@@ -1666,7 +1668,7 @@ class AuthManagerScreen(ModalScreen[None]):
|
||||
glyphs = get_glyphs()
|
||||
action = self._action_for_provider(provider)
|
||||
return (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab/Shift+Tab navigate "
|
||||
f"{modal_navigation_hint(glyphs)} "
|
||||
f"{glyphs.bullet} Enter {action} {glyphs.bullet} Esc close"
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
|
||||
class EffortSelectorScreen(ModalScreen[str | None]):
|
||||
@@ -64,6 +65,7 @@ class EffortSelectorScreen(ModalScreen[str | None]):
|
||||
}
|
||||
|
||||
EffortSelectorScreen .effort-selector-help {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
@@ -110,7 +112,7 @@ class EffortSelectorScreen(ModalScreen[str | None]):
|
||||
except ValueError:
|
||||
highlighted = 0
|
||||
help_text = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
|
||||
f"{modal_navigation_hint(glyphs)}"
|
||||
f" {glyphs.bullet} Enter select"
|
||||
f" {glyphs.bullet} Esc cancel"
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ from deepagents_code.extras_info import (
|
||||
SANDBOX_EXTRAS,
|
||||
STANDALONE_EXTRAS,
|
||||
)
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -168,7 +169,7 @@ class LaunchGoalCriteriaPreferenceScreen(ModalScreen[bool]):
|
||||
classes="launch-init-note",
|
||||
)
|
||||
yield Static(
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
|
||||
f"{modal_navigation_hint(glyphs)}"
|
||||
f" {glyphs.bullet} Enter select"
|
||||
f" {glyphs.bullet} Esc review",
|
||||
classes="launch-init-help",
|
||||
|
||||
@@ -1292,7 +1292,10 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
Returns:
|
||||
The rendered help line for the modal footer.
|
||||
"""
|
||||
help_parts = [f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate"]
|
||||
help_parts = [
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate",
|
||||
"Tab/Shift+Tab servers",
|
||||
]
|
||||
enter_hint = self._selected_enter_hint()
|
||||
if enter_hint is not None:
|
||||
help_parts.append(enter_hint)
|
||||
|
||||
@@ -591,9 +591,13 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
|
||||
Curated/onboarding mode omits the Ctrl+S, Ctrl+R, and Ctrl+N hints.
|
||||
Escape stays bound but is left off the hint line — modal dismissal via
|
||||
Escape is conventional, and advertising it would only lengthen an
|
||||
already-wrapping line. In standard mode the full line exceeds the modal
|
||||
width, so the help `Static` is sized to grow (auto height) and wraps to
|
||||
two rows rather than clipping the trailing hints.
|
||||
already-wrapping line. Shift+Tab is likewise bound (`action_move_up`,
|
||||
routed by `_SupportsReverseNav`) but omitted: this modal binds Tab to
|
||||
autocomplete, so the shared "Tab/Shift+Tab navigate" phrasing from
|
||||
`tui.key_hints` would misdescribe Tab here. In standard mode the full
|
||||
line exceeds the modal width, so the help `Static` is sized to grow
|
||||
(auto height) and wraps to two rows rather than clipping the trailing
|
||||
hints.
|
||||
|
||||
The Ctrl+N hint names what the next press *does* rather than the
|
||||
current mode, so it reads "Ctrl+N IDs" while friendly names are shown
|
||||
|
||||
@@ -41,6 +41,7 @@ if TYPE_CHECKING:
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.notifications import ActionId, UpdateAvailablePayload
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
from deepagents_code.tui.widgets.notification_settings import WARNING_TOGGLES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -411,7 +412,8 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
|
||||
}
|
||||
|
||||
NotificationCenterScreen .nc-help {
|
||||
height: 1;
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
@@ -566,12 +568,12 @@ class NotificationCenterScreen(ModalScreen[NotificationActionResult | None]):
|
||||
glyphs = get_glyphs()
|
||||
if self._settings_expanded:
|
||||
return (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate "
|
||||
f"{modal_navigation_hint(glyphs)} "
|
||||
f"{glyphs.bullet} Space/Enter toggle "
|
||||
f"{glyphs.bullet} Esc collapse"
|
||||
)
|
||||
return (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate "
|
||||
f"{modal_navigation_hint(glyphs)} "
|
||||
f"{glyphs.bullet} Enter open "
|
||||
f"{glyphs.bullet} Esc close"
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
|
||||
class DetailActionActivated(Message):
|
||||
@@ -154,7 +155,8 @@ class NotificationDetailScreen(ModalScreen["ActionId | None"]):
|
||||
}
|
||||
|
||||
NotificationDetailScreen .nd-help {
|
||||
height: 1;
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
@@ -192,7 +194,7 @@ class NotificationDetailScreen(ModalScreen["ActionId | None"]):
|
||||
self._options.append(option)
|
||||
yield option
|
||||
help_text = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate "
|
||||
f"{modal_navigation_hint(glyphs)} "
|
||||
f"{glyphs.bullet} Enter select "
|
||||
f"{glyphs.bullet} Esc back"
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
||||
|
||||
from deepagents_code import theme
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -70,8 +71,7 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
ThemeSelectorScreen > Vertical {
|
||||
width: 50;
|
||||
max-width: 90%;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
height: 80%;
|
||||
background: $surface;
|
||||
border: solid $primary;
|
||||
padding: 1 2;
|
||||
@@ -85,12 +85,13 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
}
|
||||
|
||||
ThemeSelectorScreen OptionList {
|
||||
height: auto;
|
||||
max-height: 16;
|
||||
height: 1fr;
|
||||
min-height: 3;
|
||||
background: $background;
|
||||
}
|
||||
|
||||
ThemeSelectorScreen .theme-selector-help {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
@@ -165,7 +166,7 @@ class ThemeSelectorScreen(ModalScreen[str | None]):
|
||||
option_list.highlighted = highlight_index
|
||||
yield option_list
|
||||
nav_line = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab switch"
|
||||
f"{modal_navigation_hint(glyphs)}"
|
||||
f" {glyphs.bullet} Enter select"
|
||||
f" {glyphs.bullet} Esc cancel"
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from deepagents_code import theme
|
||||
from deepagents_code._version import CHANGELOG_URL
|
||||
from deepagents_code.config import get_glyphs, is_ascii_mode
|
||||
from deepagents_code.notifications import ActionId
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
from deepagents_code.tui.widgets._links import open_url_async
|
||||
|
||||
|
||||
@@ -188,7 +189,8 @@ class UpdateAvailableScreen(ModalScreen[ActionId | None]):
|
||||
}
|
||||
|
||||
UpdateAvailableScreen .ua-help {
|
||||
height: 1;
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $text-muted;
|
||||
text-style: italic;
|
||||
margin-top: 1;
|
||||
@@ -229,7 +231,7 @@ class UpdateAvailableScreen(ModalScreen[ActionId | None]):
|
||||
self._options.append(option)
|
||||
yield option
|
||||
help_text = (
|
||||
f"{glyphs.arrow_up}/{glyphs.arrow_down} or Tab navigate "
|
||||
f"{modal_navigation_hint(glyphs)} "
|
||||
f"{glyphs.bullet} Enter select "
|
||||
f"{glyphs.bullet} Esc close"
|
||||
)
|
||||
|
||||
@@ -4248,6 +4248,168 @@ class TestModalScreenShiftTabHandling:
|
||||
assert options.highlighted < after_tab
|
||||
assert app._auto_approve is False
|
||||
|
||||
async def test_tab_round_trips_in_the_effort_selector(self) -> None:
|
||||
"""Tab and Shift+Tab both move the effort cursor, as the footer says.
|
||||
|
||||
`EffortSelectorScreen` names its action `action_cursor_up`, so it does
|
||||
not match `_SupportsReverseNav` and reaches the explicit `isinstance`
|
||||
tuple in `action_toggle_auto_approve` instead. Dropping it from that
|
||||
tuple sends Shift+Tab to the `ModalScreen` catch-all, which swallows it
|
||||
silently while the footer still advertises it.
|
||||
"""
|
||||
from deepagents_code.approval_mode import ApprovalMode
|
||||
from deepagents_code.tui.widgets.effort_selector import EffortSelectorScreen
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
screen = EffortSelectorScreen(
|
||||
model_spec="anthropic:claude-sonnet-4-5",
|
||||
efforts=("low", "medium", "high"),
|
||||
)
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
options = screen.query_one("#effort-options", OptionList)
|
||||
assert options.highlighted == 0
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 1
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 0
|
||||
|
||||
# Wraps backward off the first row rather than sticking.
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 2
|
||||
|
||||
# The key must be consumed by the modal, never reach the toggle.
|
||||
assert app._approval_mode is ApprovalMode.MANUAL
|
||||
|
||||
async def test_tab_round_trips_in_the_goal_preference_prompt(self) -> None:
|
||||
"""Tab and Shift+Tab both move the launch-preference cursor.
|
||||
|
||||
Same `action_cursor_up` routing as the effort selector above.
|
||||
"""
|
||||
from deepagents_code.approval_mode import ApprovalMode
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
screen = LaunchGoalCriteriaPreferenceScreen()
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
options = screen.query_one(OptionList)
|
||||
assert options.highlighted == 0
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 1
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 0
|
||||
|
||||
assert app._approval_mode is ApprovalMode.MANUAL
|
||||
|
||||
async def test_tab_round_trips_in_the_theme_selector(self) -> None:
|
||||
"""Tab and Shift+Tab both move the theme cursor, as the footer says."""
|
||||
from deepagents_code.approval_mode import ApprovalMode
|
||||
from deepagents_code.tui.widgets.theme_selector import ThemeSelectorScreen
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
screen = ThemeSelectorScreen(current_theme="langchain")
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
options = screen.query_one(OptionList)
|
||||
start = options.highlighted
|
||||
assert start is not None
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
after_tab = options.highlighted
|
||||
assert after_tab == start + 1
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == start
|
||||
|
||||
assert app._approval_mode is ApprovalMode.MANUAL
|
||||
|
||||
async def test_tab_round_trips_in_the_agent_selector(self) -> None:
|
||||
"""Tab and Shift+Tab both move the agent cursor, as the footer says."""
|
||||
from deepagents_code.approval_mode import ApprovalMode
|
||||
from deepagents_code.tui.widgets.agent_selector import AgentSelectorScreen
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
screen = AgentSelectorScreen(
|
||||
current_agent="general",
|
||||
agent_names=["general", "research", "review"],
|
||||
default_agent=None,
|
||||
)
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
options = screen.query_one(OptionList)
|
||||
assert options.highlighted == 0
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 1
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert options.highlighted == 0
|
||||
|
||||
assert app._approval_mode is ApprovalMode.MANUAL
|
||||
|
||||
async def test_tab_round_trips_focus_in_notification_settings(self) -> None:
|
||||
"""Tab and Shift+Tab step focus between the notification toggles.
|
||||
|
||||
This screen is the odd one out: `action_toggle_auto_approve` routes it
|
||||
to `focus_previous()` rather than a cursor action, because its rows are
|
||||
focusable `Checkbox` widgets. The footer advertises the same
|
||||
"Tab/Shift+Tab navigate" copy, so the traversal has to work both ways.
|
||||
"""
|
||||
from deepagents_code.approval_mode import ApprovalMode
|
||||
from deepagents_code.tui.widgets.notification_center import (
|
||||
NotificationCenterScreen,
|
||||
)
|
||||
|
||||
app = DeepAgentsApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
screen = NotificationCenterScreen([], suppressed=set())
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
# Open the inline settings section so its checkboxes take focus.
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
first = screen.focused
|
||||
assert first is not None
|
||||
|
||||
await pilot.press("tab")
|
||||
await pilot.pause()
|
||||
second = screen.focused
|
||||
assert second is not None
|
||||
assert second is not first
|
||||
|
||||
await pilot.press("shift+tab")
|
||||
await pilot.pause()
|
||||
assert screen.focused is first
|
||||
|
||||
assert app._approval_mode is ApprovalMode.MANUAL
|
||||
|
||||
|
||||
class TestModalScreenCtrlCHandling:
|
||||
"""Tests for app-level Ctrl+C behavior while modals are open."""
|
||||
|
||||
@@ -506,6 +506,8 @@ async def test_plugin_search_and_footer_fit_standard_terminal() -> None:
|
||||
assert search.display is True
|
||||
assert options.region.height >= 5
|
||||
assert options.region.bottom <= container.content_region.bottom
|
||||
assert "Left/Right or Tab/Shift+Tab tabs" in str(help_text.content)
|
||||
assert "Esc close" in str(help_text.content)
|
||||
assert help_text.region.height >= 1
|
||||
assert help_text.region.bottom <= container.content_region.bottom
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Tests for shared terminal UI keyboard hints."""
|
||||
|
||||
import pytest
|
||||
|
||||
from deepagents_code.config import ASCII_GLYPHS, UNICODE_GLYPHS, Glyphs
|
||||
from deepagents_code.tui.key_hints import modal_navigation_hint
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("glyphs", "expected"),
|
||||
[
|
||||
(UNICODE_GLYPHS, "↑/↓ or Tab/Shift+Tab navigate"),
|
||||
(ASCII_GLYPHS, "^/v or Tab/Shift+Tab navigate"),
|
||||
],
|
||||
ids=["unicode", "ascii"],
|
||||
)
|
||||
def test_modal_navigation_hint_copy(glyphs: Glyphs, expected: str) -> None:
|
||||
"""Both glyph sets render the literal footer copy users read.
|
||||
|
||||
Spelled out rather than re-derived from `glyphs.arrow_up` so a hardcoded
|
||||
arrow (the regression the shared helper exists to prevent) fails here
|
||||
instead of passing against its own f-string.
|
||||
"""
|
||||
assert modal_navigation_hint(glyphs) == expected
|
||||
|
||||
|
||||
def test_modal_navigation_hint_advertises_both_tab_directions() -> None:
|
||||
"""The copy names Shift+Tab, not just Tab.
|
||||
|
||||
Reverse navigation is routed by the app rather than the screen, so it is
|
||||
the direction most likely to be dropped without the footer noticing.
|
||||
"""
|
||||
assert "Tab/Shift+Tab" in modal_navigation_hint(UNICODE_GLYPHS)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Layout guards for the shared modal navigation footer.
|
||||
|
||||
`modal_navigation_hint` is long enough to wrap once a modal narrows. Wrapping
|
||||
is the intended behavior -- the alternative is truncating the trailing hints --
|
||||
but the extra row has to come from somewhere, and there are three distinct ways
|
||||
for it not to. Each assertion below covers one, because passing any two of them
|
||||
still leaves a footer the user cannot act on:
|
||||
|
||||
1. The row is laid out past the container's content region and never painted.
|
||||
2. The row is painted *over* a sibling -- `dock: bottom` does not reserve space
|
||||
inside a `height: auto` container, because the docked child is excluded from
|
||||
the parent's auto-height, so it lands on top of the last siblings.
|
||||
3. The container itself outgrows the viewport, carrying an in-container footer
|
||||
off-screen with it.
|
||||
|
||||
Containment inside the container is therefore necessary but not sufficient; the
|
||||
overlap and viewport checks are what make this test able to fail.
|
||||
|
||||
`(60, 20)` and `(50, 20)` are sizes where the hint wraps. They mirror the guard
|
||||
at `tui/widgets/test_mcp_viewer.py::test_footer_hints_stay_on_screen`.
|
||||
|
||||
`ColdCacheWarningScreen` and `LaunchGoalCriteriaPreferenceScreen` are absent on
|
||||
purpose: their body text grows as the window narrows (the cold-cache body alone
|
||||
wants seven rows at any width, and ten at 50 columns), so there is no room for a
|
||||
second hint row without hiding cost or policy text the user needs more than the
|
||||
hint. Both keep a single-row footer that clips sideways instead, which is what
|
||||
they did before the shared hint landed. Capping their body height was tried and
|
||||
rejected: `max-height` applies at every window size, so it hid two rows of the
|
||||
warning even on a large terminal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from textual.app import App
|
||||
from textual.containers import Vertical, VerticalGroup
|
||||
from textual.geometry import Region
|
||||
from textual.widgets import Static
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widget import Widget
|
||||
|
||||
from deepagents_code.notifications import (
|
||||
ActionId,
|
||||
MissingDepPayload,
|
||||
NotificationAction,
|
||||
PendingNotification,
|
||||
UpdateAvailablePayload,
|
||||
)
|
||||
from deepagents_code.tui.widgets.agent_selector import AgentSelectorScreen
|
||||
from deepagents_code.tui.widgets.effort_selector import EffortSelectorScreen
|
||||
from deepagents_code.tui.widgets.notification_center import NotificationCenterScreen
|
||||
from deepagents_code.tui.widgets.notification_detail import NotificationDetailScreen
|
||||
from deepagents_code.tui.widgets.theme_selector import ThemeSelectorScreen
|
||||
from deepagents_code.tui.widgets.update_available import UpdateAvailableScreen
|
||||
|
||||
FOOTER_SIZES = [(80, 24), (60, 20), (50, 20)]
|
||||
|
||||
|
||||
def _update_entry() -> PendingNotification:
|
||||
return PendingNotification(
|
||||
key="update:available",
|
||||
title="Update available",
|
||||
body="v2.0.0 is available.\nCurrently installed: 1.0.0.",
|
||||
actions=(
|
||||
NotificationAction(ActionId.INSTALL, "Install now", primary=True),
|
||||
NotificationAction(ActionId.SKIP_ONCE, "Remind me next launch"),
|
||||
NotificationAction(ActionId.SKIP_VERSION, "Skip this version"),
|
||||
),
|
||||
payload=UpdateAvailablePayload(
|
||||
latest="2.0.0", upgrade_cmd="uv tool upgrade deepagents-code"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _dep_entry() -> PendingNotification:
|
||||
return PendingNotification(
|
||||
key="dep:ripgrep",
|
||||
title="ripgrep is not installed",
|
||||
body="Install with: brew install ripgrep",
|
||||
actions=(
|
||||
NotificationAction(
|
||||
ActionId.COPY_INSTALL, "Copy install command", primary=True
|
||||
),
|
||||
NotificationAction(ActionId.SUPPRESS, "Don't show notification again"),
|
||||
),
|
||||
payload=MissingDepPayload(
|
||||
tool="ripgrep", install_command="brew install ripgrep"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# (id, screen factory, footer selector)
|
||||
FOOTER_CASES: list[tuple[str, Callable[[], ModalScreen], str]] = [
|
||||
(
|
||||
"notification_center",
|
||||
lambda: NotificationCenterScreen([_dep_entry(), _update_entry()]),
|
||||
".nc-help",
|
||||
),
|
||||
(
|
||||
"notification_detail",
|
||||
lambda: NotificationDetailScreen(_update_entry()),
|
||||
".nd-help",
|
||||
),
|
||||
(
|
||||
"update_available",
|
||||
lambda: UpdateAvailableScreen(_update_entry()),
|
||||
".ua-help",
|
||||
),
|
||||
(
|
||||
"theme_selector",
|
||||
lambda: ThemeSelectorScreen(current_theme="langchain"),
|
||||
".theme-selector-help",
|
||||
),
|
||||
(
|
||||
"agent_selector",
|
||||
lambda: AgentSelectorScreen(
|
||||
current_agent="general",
|
||||
agent_names=["general", "research"],
|
||||
default_agent=None,
|
||||
),
|
||||
".agent-selector-help",
|
||||
),
|
||||
(
|
||||
"effort_selector",
|
||||
lambda: EffortSelectorScreen(
|
||||
model_spec="anthropic:claude-sonnet-4-5",
|
||||
efforts=("low", "medium", "high"),
|
||||
),
|
||||
".effort-selector-help",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _modal_container(screen: ModalScreen) -> Widget:
|
||||
"""Return the modal's outer container, whichever vertical type it uses."""
|
||||
for node in screen.query(Vertical):
|
||||
return node
|
||||
return screen.query_one(VerticalGroup)
|
||||
|
||||
|
||||
def _overlaps(a: Region, b: Region) -> bool:
|
||||
"""Report whether two regions share at least one cell."""
|
||||
return not (a.right <= b.x or b.right <= a.x or a.bottom <= b.y or b.bottom <= a.y)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", FOOTER_SIZES, ids=lambda s: f"{s[0]}x{s[1]}")
|
||||
@pytest.mark.parametrize(
|
||||
("factory", "selector"),
|
||||
[(factory, selector) for _, factory, selector in FOOTER_CASES],
|
||||
ids=[case_id for case_id, _, _ in FOOTER_CASES],
|
||||
)
|
||||
async def test_navigation_footer_stays_inside_the_modal(
|
||||
size: tuple[int, int],
|
||||
factory: Callable[[], ModalScreen],
|
||||
selector: str,
|
||||
) -> None:
|
||||
"""Every hint row is painted, in the modal, and over nothing else."""
|
||||
screen = factory()
|
||||
app: App[None] = App()
|
||||
async with app.run_test(size=size) as pilot:
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
footer = screen.query_one(selector, Static)
|
||||
container = _modal_container(screen)
|
||||
viewport = Region(0, 0, *size)
|
||||
|
||||
# 1. Painted at all.
|
||||
assert footer in app.screen._compositor.visible_widgets, (
|
||||
f"footer {footer.region} is not painted at {size}"
|
||||
)
|
||||
|
||||
# 2. Inside the modal.
|
||||
assert container.content_region.contains_region(footer.region), (
|
||||
f"footer {footer.region} escapes {container.content_region} at {size}"
|
||||
)
|
||||
|
||||
# 3. Not on top of a sibling. A docked footer inside a `height: auto`
|
||||
# container is excluded from the parent's auto-height, so it lands
|
||||
# over the last children instead of pushing them up.
|
||||
collisions = [
|
||||
" ".join(sibling.classes) or type(sibling).__name__
|
||||
for sibling in container.children
|
||||
if sibling is not footer and _overlaps(footer.region, sibling.region)
|
||||
]
|
||||
assert not collisions, (
|
||||
f"footer {footer.region} paints over {collisions} at {size}"
|
||||
)
|
||||
|
||||
# 4. The container cannot carry an in-container footer off-screen.
|
||||
assert viewport.contains_region(container.region), (
|
||||
f"modal {container.region} escapes the {size} viewport"
|
||||
)
|
||||
@@ -249,6 +249,7 @@ class TestAgentSelectorDefaultLabel:
|
||||
await pilot.pause()
|
||||
statics = app.screen.query(".agent-selector-help")
|
||||
rendered = " ".join(str(s.render()) for s in statics)
|
||||
assert "Tab/Shift+Tab navigate" in rendered
|
||||
assert "Ctrl+S" in rendered
|
||||
assert "set default" in rendered
|
||||
|
||||
|
||||
@@ -1908,6 +1908,7 @@ class TestMCPViewerScreen:
|
||||
help_widget = help_widgets[0]
|
||||
text = _widget_text(help_widget).lower()
|
||||
assert "navigate" in text
|
||||
assert "tab/shift+tab servers" in text
|
||||
assert "enter" not in text
|
||||
assert "f2" in text
|
||||
assert "ctrl+e" in text
|
||||
|
||||
@@ -280,6 +280,12 @@ class TestModelSelectorChrome:
|
||||
|
||||
help_text = screen.query_one(".model-selector-help", Static)
|
||||
|
||||
# Deliberately not the shared `modal_navigation_hint` copy: Tab
|
||||
# autocompletes here, so advertising "Tab/Shift+Tab navigate"
|
||||
# would misdescribe it. Shift+Tab still works via
|
||||
# `_SupportsReverseNav`; it is simply unadvertised.
|
||||
assert "navigate" in str(help_text.content)
|
||||
assert "Tab/Shift+Tab navigate" not in str(help_text.content)
|
||||
assert "Tab autocomplete" in str(help_text.content)
|
||||
assert "Esc skip setup" not in str(help_text.content)
|
||||
assert "Esc cancel" not in str(help_text.content)
|
||||
|
||||
@@ -71,6 +71,7 @@ async def test_help_footer_documents_both_toggle_keys_when_expanded() -> None:
|
||||
|
||||
help_text = str(screen.query_one(".nc-help", Static).content)
|
||||
|
||||
assert "Tab/Shift+Tab navigate" in help_text
|
||||
assert "Space/Enter toggle" in help_text
|
||||
assert "Esc collapse" in help_text
|
||||
|
||||
|
||||
Reference in New Issue
Block a user