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:
Mason Daugherty
2026-06-22 22:40:42 -04:00
committed by GitHub
parent c20546feac
commit d79cd74cb8
2 changed files with 70 additions and 1 deletions
@@ -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`.
+41 -1
View File
@@ -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.