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:
Mason Daugherty
2026-08-21 16:25:05 -04:00
committed by GitHub
parent 3ac059e157
commit 85c6833734
23 changed files with 513 additions and 32 deletions
+18 -1
View File
@@ -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"
)
+162
View File
@@ -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