mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(code): refocus /mcp filter input after in-place refresh (#4080)
Fix the `/mcp` viewer swallowing keystrokes after the server finishes (re)connecting while the modal is open. --- Opening `/mcp` while the LangGraph server is still connecting (or reconnecting after `Ctrl+R`) shows a placeholder with no filter input. When tools finish loading, `refresh_server_info` tears down the modal body and re-mounts the filter `Input` — but Textual only auto-focuses a widget on its *first* mount, so the rebuilt input was left unfocused. The result: keystrokes were silently swallowed and the text input appeared "blocked completely." This restores focus to the filter input explicitly after the in-place rebuild (which also covers the F2 disable-toggle fallback path that routes through `refresh_server_info`). Made by [Open SWE](https://openswe.vercel.app/agents/be0a0d4f-56b7-cfa3-302c-57a13d694e9a) --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
@@ -996,6 +996,7 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
self._move_to(idx)
|
||||
self._reveal_selection(widget, direction=1)
|
||||
break
|
||||
self._focus_filter_input()
|
||||
|
||||
async def apply_server_disable_toggle(
|
||||
self,
|
||||
@@ -1205,6 +1206,34 @@ class MCPViewerScreen(ModalScreen[str | None]):
|
||||
Static(self._build_help_text(glyphs), classes="mcp-viewer-help")
|
||||
)
|
||||
|
||||
def _focus_filter_input(self) -> None:
|
||||
"""Refocus the filter `Input` after an in-place body rebuild.
|
||||
|
||||
`refresh_server_info` clears the body via `remove_children`, which
|
||||
blurs the screen (Textual resets focus to `None` when the focused
|
||||
widget is pruned). The newly mounted filter `Input` is not
|
||||
auto-focused on a re-mount — Textual auto-focuses only on the first
|
||||
mount — so a viewer opened while the server is still connecting
|
||||
would leave the rebuilt input unfocused once tools load, and
|
||||
keystrokes would never reach it. Restore focus explicitly here,
|
||||
deferred via `call_after_refresh` because `_mount_body` mounts
|
||||
without awaiting.
|
||||
|
||||
The `Input` exists only when there are servers to filter (see
|
||||
`_mount_body`); when the list is empty there is nothing to focus, so
|
||||
return early. Gating on `_server_info` rather than swallowing a
|
||||
missing-widget error keeps a genuinely-absent input (id drift, a
|
||||
failed mount) visible instead of silently re-introducing the
|
||||
keystroke-swallow this method exists to prevent.
|
||||
"""
|
||||
|
||||
def _focus() -> None:
|
||||
if not self._server_info:
|
||||
return
|
||||
self.query_one("#mcp-filter", Input).focus()
|
||||
|
||||
self.call_after_refresh(_focus)
|
||||
|
||||
def _build_help_text(self, glyphs: Glyphs) -> str:
|
||||
"""Compose the help-footer string from the current `_pending_reconnect`.
|
||||
|
||||
|
||||
@@ -127,6 +127,38 @@ class TestMCPViewerScreen:
|
||||
empty = screen.query_one(".mcp-empty", Static)
|
||||
assert "--mcp-config" in _widget_text(empty)
|
||||
|
||||
async def test_refresh_focuses_filter_input(self) -> None:
|
||||
"""Refreshing after server-ready must refocus the filter input.
|
||||
|
||||
Regression: a viewer opened while the server is still connecting
|
||||
shows a placeholder with no filter input. When tools load,
|
||||
`refresh_server_info` rebuilds the body and mounts the filter
|
||||
`Input`, but Textual only auto-focuses on the first mount — so the
|
||||
input was left unfocused and keystrokes never reached it. The final
|
||||
`press` + `value` assertion verifies the typed text actually lands
|
||||
in the input, not just that focus was restored.
|
||||
"""
|
||||
from textual.widgets import Input
|
||||
|
||||
app = MCPViewerTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
screen = MCPViewerScreen(server_info=[], connecting=True)
|
||||
app.push_screen(screen)
|
||||
await pilot.pause()
|
||||
|
||||
# No filter input exists during the connecting placeholder.
|
||||
assert not screen.query("#mcp-filter")
|
||||
|
||||
await screen.refresh_server_info(_sample_info())
|
||||
await pilot.pause()
|
||||
|
||||
filter_input = screen.query_one("#mcp-filter", Input)
|
||||
assert app.focused is filter_input
|
||||
|
||||
await pilot.press("r", "e", "a", "d")
|
||||
await pilot.pause()
|
||||
assert filter_input.value == "read"
|
||||
|
||||
async def test_reconnect_hint_hidden_when_no_pending(self) -> None:
|
||||
"""Footer hint omits the `Ctrl+R` chip when nothing is queued."""
|
||||
app = MCPViewerTestApp()
|
||||
@@ -420,8 +452,13 @@ class TestMCPViewerScreen:
|
||||
Guards the `new_server is None` branch in
|
||||
`apply_server_disable_toggle` — a regression that skipped the
|
||||
fallback would leave the viewer showing stale rows for the
|
||||
missing server.
|
||||
missing server. Also pins focus: the fallback routes through
|
||||
`refresh_server_info`, which must refocus the filter `Input` (this
|
||||
is a second live caller of `_focus_filter_input` alongside the
|
||||
server-ready path).
|
||||
"""
|
||||
from textual.widgets import Input
|
||||
|
||||
app = MCPViewerTestApp()
|
||||
async with app.run_test() as pilot:
|
||||
|
||||
@@ -451,6 +488,9 @@ class TestMCPViewerScreen:
|
||||
assert isinstance(remaining, MCPServerHeaderItem)
|
||||
assert remaining.server.name == "remote-api"
|
||||
|
||||
# Fallback re-mounts the body; focus must land on the filter input.
|
||||
assert app.focused is screen.query_one("#mcp-filter", Input)
|
||||
|
||||
async def test_f2_renumbers_indices_so_clicks_resolve_correctly(self) -> None:
|
||||
"""Every widget's `index` matches its `_row_widgets` position post-F2.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user