diff --git a/libs/code/deepagents_code/tui/widgets/mcp_viewer.py b/libs/code/deepagents_code/tui/widgets/mcp_viewer.py index 0d15ad1107e..f6eed005d2b 100644 --- a/libs/code/deepagents_code/tui/widgets/mcp_viewer.py +++ b/libs/code/deepagents_code/tui/widgets/mcp_viewer.py @@ -551,8 +551,13 @@ class MCPServerErrorScreen(ModalScreen[None]): color: $text; } + /* Same treatment as `.mcp-viewer-help`: `height: auto` so the hints + wrap instead of truncating on a narrow window, `dock: bottom` so the + rows they wrap onto are reserved before `.mcp-error-body` claims the + rest — otherwise wrapping pushes the footer past the modal's edge. */ MCPServerErrorScreen .mcp-error-help { - height: 1; + dock: bottom; + height: auto; color: $text-muted; text-style: italic; margin-top: 1; @@ -897,8 +902,14 @@ class MCPViewerScreen(ModalScreen[str | None]): margin-top: 2; } + /* `height: auto` lets the hints wrap instead of truncating on a narrow + window. `dock: bottom` then reserves those rows before `.mcp-list` takes + the remainder — without it the list's `min-height` wins the fight for + space and shoves the whole footer past the modal's bottom edge, hiding + `Esc close` entirely on terminals under ~24 rows. */ MCPViewerScreen .mcp-viewer-help { - height: 1; + dock: bottom; + height: auto; color: $text-muted; text-style: italic; margin-top: 1; diff --git a/libs/code/tests/unit_tests/tui/widgets/test_mcp_viewer.py b/libs/code/tests/unit_tests/tui/widgets/test_mcp_viewer.py index 995b8402ddb..4530481c0c6 100644 --- a/libs/code/tests/unit_tests/tui/widgets/test_mcp_viewer.py +++ b/libs/code/tests/unit_tests/tui/widgets/test_mcp_viewer.py @@ -4,6 +4,7 @@ import pytest from textual.app import App, ComposeResult +from textual.containers import Vertical from textual.notifications import Notification from textual.widget import Widget from textual.widgets import Static @@ -188,6 +189,49 @@ async def test_reconnect_hint_shown_when_pending(self) -> None: help_widget = screen.query_one(".mcp-viewer-help", Static) assert "Ctrl+R reconnect" in _widget_text(help_widget) + @pytest.mark.parametrize("size", [(80, 24), (80, 14), (50, 20)]) + async def test_footer_hints_stay_on_screen(self, size: tuple[int, int]) -> None: + """Every hint renders, however narrow or short the window is. + + Asserts the footer's region sits inside the modal, not just that + its text is set: the bug being guarded here is that the string is + complete but laid out past the modal's bottom edge, where the + compositor never paints it. `_widget_text` cannot observe that. + `(80, 14)` and `(50, 20)` are both sizes where the footer used to + be pushed clean out of the modal. + """ + app = MCPViewerTestApp() + async with app.run_test(size=size) as pilot: + screen = MCPViewerScreen( + server_info=_sample_info(), + pending_reconnect=True, + ) + app.push_screen(screen) + await pilot.pause() + + help_widget = screen.query_one(".mcp-viewer-help", Static) + modal = screen.query_one(Vertical) + assert help_widget in app.screen._compositor.visible_widgets + assert modal.content_region.contains_region(help_widget.region) + assert "Ctrl+R reconnect" in _widget_text(help_widget) + assert "Esc close" in _widget_text(help_widget) + + async def test_footer_wraps_rather_than_truncating(self) -> None: + """A footer too long for one line grows instead of losing hints.""" + app = MCPViewerTestApp() + async with app.run_test(size=(80, 24)) as pilot: + screen = MCPViewerScreen( + server_info=_sample_info(), + pending_reconnect=True, + ) + app.push_screen(screen) + await pilot.pause() + + help_widget = screen.query_one(".mcp-viewer-help", Static) + # The modal caps at `width: 80`, so the full hint string never + # fits on one line — it must occupy two. + assert help_widget.size.height == 2 + async def test_ctrl_r_dismisses_with_reconnect_sentinel_when_pending( self, ) -> None: @@ -2113,6 +2157,27 @@ async def test_error_modal_falls_back_when_error_missing(self) -> None: body = app.screen.query_one(".mcp-error-text", Static) assert "No error details were reported." in _widget_text(body) + async def test_error_modal_footer_wraps_in_narrow_window(self) -> None: + """The error modal's footer wraps rather than truncating.""" + server = MCPServerInfo( + name="broken", + transport="stdio", + status="error", + error="Server exited with code 1.", + ) + app = MCPViewerTestApp() + # 30 columns leaves 25 for the hints, two short of the 27 they + # need — the narrowest realistic window that forces a wrap. + async with app.run_test(size=(30, 12)) as pilot: + app.push_screen(MCPServerErrorScreen(server)) + await pilot.pause() + + help_widget = app.screen.query_one(".mcp-error-help", Static) + modal = app.screen.query_one(Vertical) + assert help_widget.size.height == 2 + assert modal.content_region.contains_region(help_widget.region) + assert "Esc close" in _widget_text(help_widget) + async def test_click_expands_tool(self) -> None: """Clicking a tool selects it and toggles expand.""" app = MCPViewerTestApp()