Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion libs/code/deepagents_code/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12990,7 +12990,7 @@ async def action_toggle_auto_approve(self) -> None:
self.screen.action_move_up()
return
if isinstance(self.screen, MCPViewerScreen):
self.screen.action_move_up()
self.screen.action_jump_up()
return
# shift+tab is reused for navigation inside modal screens (e.g.
# ModelSelectorScreen); skip the toggle so it doesn't fire through.
Expand Down
74 changes: 45 additions & 29 deletions libs/code/deepagents_code/tui/widgets/mcp_viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -1345,10 +1345,10 @@ def _move_to(self, index: int) -> None:
# bottom for up).

def _move_selection(self, delta: int) -> None:
"""Move selection by delta row positions, clamped at the list ends.
"""Move selection by delta row positions within the list bounds.

No wrap-around — pressing `Down` past the last row stays put rather
than jumping to the first. Walks every row (headers + tools).
Walks every row (headers + tools). Navigation actions handle wrapping
before calling this helper at a list boundary.

Args:
delta: Number of row positions to move.
Expand All @@ -1359,22 +1359,22 @@ def _move_selection(self, delta: int) -> None:
if 0 <= target < len(self._row_widgets):
self._move_to(target)

def _next_tool_row(self, start: int, step: int) -> int | None:
"""Return the index of the next `MCPToolItem` row in `step` direction.

Used by `Tab` / `Shift+Tab` to skip server-header rows during
cross-tool navigation. Returns `None` when there is no tool row in
the requested direction.
def _next_server_header(self, start: int, step: int) -> int | None:
"""Return the next server-header index in the requested direction.

Args:
start: Index to start searching from (exclusive).
step: `+1` (forward) or `-1` (backward).

Returns:
The index of the nearest `MCPServerHeaderItem` in that direction,
or `None` when no server header exists there.
"""
idx = start + step
while 0 <= idx < len(self._row_widgets):
if isinstance(self._row_widgets[idx], MCPToolItem):
return idx
idx += step
index = start + step
while 0 <= index < len(self._row_widgets):
if isinstance(self._row_widgets[index], MCPServerHeaderItem):
return index
index += step
return None

def _scroll_widget_bottom_to_view(
Expand Down Expand Up @@ -1426,19 +1426,22 @@ def action_move_up(self) -> None:
"""Smart up: scroll one row inside a tall expanded row, else jump.

If the selected row's top edge is already inside the viewport, jump
to the previous row (header or tool). For rows taller than the
viewport, pin the new selection's **bottom** to the viewport so the
next `Up` resumes line-stepping through that row; otherwise just
ensure the row is visible. `Tab` / `Shift+Tab` skip the smart check
AND skip header rows (see `action_jump_up`).
to the previous row (header or tool), wrapping to the final row from
the first. For rows taller than the viewport, pin the new selection's
**bottom** to the viewport so the next `Up` resumes line-stepping
through that row; otherwise just ensure the row is visible. `Tab` /
`Shift+Tab` jump between server headers (see `action_jump_up`).
"""
if not self._row_widgets:
return
scroll = self.query_one(".mcp-list", VerticalScroll)
selected = self._row_widgets[self._selected_index]
if selected.region.y >= scroll.region.y:
old = self._selected_index
self._move_selection(-1)
if old == 0:
self._move_to(len(self._row_widgets) - 1)
else:
self._move_selection(-1)
if self._selected_index != old:
self._reveal_selection(
self._row_widgets[self._selected_index], direction=-1
Expand All @@ -1450,10 +1453,11 @@ def action_move_down(self) -> None:
"""Smart down: scroll one row inside a tall expanded row, else jump.

If the selected row's bottom edge is already inside the viewport,
jump to the next row (header or tool). For rows taller than the
viewport, pin the new selection's top to the viewport; otherwise
just ensure the row is visible. `Tab` / `Shift+Tab` skip the smart
check AND skip header rows (see `action_jump_down`).
jump to the next row (header or tool), wrapping to the first row from
the final one. For rows taller than the viewport, pin the new
selection's top to the viewport; otherwise just ensure the row is
visible. `Tab` / `Shift+Tab` jump between server headers (see
`action_jump_down`).
"""
if not self._row_widgets:
return
Expand All @@ -1463,7 +1467,10 @@ def action_move_down(self) -> None:
viewport_bottom = scroll.region.y + scroll.region.height
if selected_bottom <= viewport_bottom:
old = self._selected_index
self._move_selection(1)
if old == len(self._row_widgets) - 1:
self._move_to(0)
else:
self._move_selection(1)
if self._selected_index != old:
self._reveal_selection(
self._row_widgets[self._selected_index], direction=1
Expand All @@ -1472,17 +1479,26 @@ def action_move_down(self) -> None:
scroll.scroll_relative(y=1, animate=False)

def action_jump_up(self) -> None:
"""Jump to the previous tool (Shift+Tab); skips headers."""
target = self._next_tool_row(self._selected_index, -1)
"""Jump backward to the nearest server header (Shift+Tab), wrapping.

From a tool row this lands on the current server's own header; from a
header it moves to the previous server. Wraps to the final header from
the top.
"""
target = self._next_server_header(self._selected_index, -1)
if target is None:
target = self._next_server_header(len(self._row_widgets), -1)
if target is None or target == self._selected_index:
return
self._move_to(target)
self._reveal_selection(self._row_widgets[target], direction=-1)

def action_jump_down(self) -> None:
"""Jump to the next tool (Tab); skips headers."""
target = self._next_tool_row(self._selected_index, +1)
"""Jump to the next server (Tab), wrapping at the end."""
target = self._next_server_header(self._selected_index, +1)
if target is None:
target = self._next_server_header(-1, +1)
if target is None or target == self._selected_index:
return
self._move_to(target)
self._reveal_selection(self._row_widgets[target], direction=1)
Expand Down
30 changes: 30 additions & 0 deletions libs/code/tests/unit_tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17444,6 +17444,36 @@ async def test_notification_detail_shift_tab_moves_cursor_up(self) -> None:
assert screen._selected != start
assert app._auto_approve is False

async def test_mcp_viewer_shift_tab_jumps_to_previous_server(self) -> None:
"""App-level shift+tab routes to MCPViewerScreen.jump_up."""
from deepagents_code.mcp_tools import MCPServerInfo, MCPToolInfo
from deepagents_code.tui.widgets.mcp_viewer import MCPViewerScreen

servers = [
MCPServerInfo(
name="first",
transport="stdio",
tools=(MCPToolInfo(name="first-tool", description=""),),
),
MCPServerInfo(
name="second",
transport="stdio",
tools=(MCPToolInfo(name="second-tool", description=""),),
),
]
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")

async with app.run_test() as pilot:
await pilot.pause()
screen = MCPViewerScreen(server_info=servers)
app.push_screen(screen)
await pilot.pause()
assert screen._selected_index == 0
await pilot.press("shift+tab")
await pilot.pause()
assert screen._selected_index == 2
assert app._auto_approve is False

async def test_toast_identity_warn_once_semantics(
self, caplog: pytest.LogCaptureFixture
) -> None:
Expand Down
Loading
Loading