feat(code): /model Ctrl+N toggle for names vs raw specs (#4592)

The `/model` selector now supports `Ctrl+N` to toggle rows between
friendly model names and raw `provider:model` IDs.

---

The `/model` selector always rendered rows with friendly display names,
with no way to see or copy the canonical `provider:model` spec. This
adds a `Ctrl+N` keybinding that toggles every row between the friendly
name and the raw spec, mirroring the `/theme` picker's label/key toggle.
`Ctrl+N` (rather than a bare `n` like `/theme`) is used because the
selector's filter input is always focused, so a plain letter would be
swallowed as filter text.

Made by [Open
SWE](https://openswe.vercel.app/agents/6733d56c-39b6-2387-4daf-02ac68097a13)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-11 19:22:28 -04:00
committed by GitHub
parent c9b7ac2075
commit 518c322e7d
5 changed files with 359 additions and 13 deletions
+31
View File
@@ -1945,6 +1945,9 @@ class DeepAgentsApp(App):
show=False,
priority=True,
),
# `check_action` steps this binding aside (returns `False`) while a
# `ModelSelectorScreen` is active so the selector's own priority
# `ctrl+n` (toggle_names) wins; keep the action name in sync there.
Binding(
"ctrl+n",
"open_notifications",
@@ -14259,6 +14262,34 @@ class DeepAgentsApp(App):
self._update_modal_pending.set()
self.call_after_refresh(self._open_update_available_modal, update_notification)
def check_action(
self,
action: str,
parameters: tuple[object, ...], # noqa: ARG002 # Textual override signature
) -> bool | None:
"""Disable `open_notifications` while the model selector is open.
Textual resolves `priority=True` bindings App-first, so the App's
`ctrl+n -> open_notifications` binding (see `BINDINGS`) would otherwise
win over `ModelSelectorScreen`'s own priority `ctrl+n -> toggle_names`.
Returning `False` here disables the App's binding for this dispatch, so
resolution falls through to the selector, whose binding then runs.
Branches on the action name, not the key, so it stays correct if
`ctrl+n` is ever rebound.
Returns:
`False` to disable `open_notifications` while a `ModelSelectorScreen`
is active, letting the selector handle Ctrl+N; `True` otherwise,
leaving the binding enabled.
"""
if action == "open_notifications":
from deepagents_code.tui.widgets.model_selector import ModelSelectorScreen
if isinstance(self.screen, ModelSelectorScreen):
return False
return True
def action_open_notifications(self) -> None:
"""Open the notification center via the `ctrl+n` keybind."""
self._open_notification_center()
@@ -259,6 +259,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
show=False,
priority=True,
),
Binding("ctrl+n", "toggle_names", "Model IDs", show=False, priority=True),
Binding("escape", "cancel", "Cancel", show=False, priority=True),
]
"""Key bindings for model navigation, selection, defaulting, and cancel.
@@ -266,11 +267,14 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
Arrows move the cursor, Page Up/Down jump by a visual page, Tab copies
the highlighted spec into the filter input, Enter selects, Ctrl+S
toggles the default model, Ctrl+R toggles between showing all installed
models and the hand-curated "recommended" subset, and Esc dismisses. All
bindings use `priority=True` so they take precedence over the embedded
`Input`; vim-style `j`/`k` bindings are deliberately omitted because
they would prevent typing those letters into the always-focused filter
input.
models and the hand-curated "recommended" subset, Ctrl+N toggles rows
between friendly display names and raw `provider:model` specs (the analog
of the `/theme` picker's `n` key), and Esc dismisses. All bindings use
`priority=True` so they take precedence over the embedded `Input`;
vim-style `j`/`k` bindings — and a bare `n` mirroring the `/theme`
picker — are deliberately omitted because they would prevent typing those
letters into the always-focused filter input, which is why the names
toggle is bound to `ctrl+n` instead.
"""
CSS = """
@@ -418,6 +422,10 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
# constrains the list via `_curated`, so leaving this False there
# avoids double-flagging in `_apply_subset`.
self._recommended_only = not curated
# Rows show friendly display names by default; Ctrl+N flips every row
# to its raw `provider:model` spec (mirrors the `/theme` picker's
# label/key toggle) so a user can read or copy the canonical id.
self._show_specs = False
self._unfiltered_models: list[tuple[str, str]] = []
self._recent_specs: list[str] = []
@@ -470,12 +478,12 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
def _help_text(self) -> str:
"""Build the footer help text.
Curated/onboarding mode omits the Ctrl+S and Ctrl+R 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.
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.
Returns:
The bullet-separated help line.
@@ -487,7 +495,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
"Enter select",
]
if not self._curated:
parts.extend(("Ctrl+S set default", "Ctrl+R recommended"))
parts.extend(("Ctrl+S set default", "Ctrl+R recommended", "Ctrl+N IDs"))
sep = f" {glyphs.bullet} "
return sep.join(parts)
@@ -1299,6 +1307,13 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
from deepagents_code.tui.widgets.auth import provider_short_name
provider_label = provider_short_name(provider)
# `_show_specs` (Ctrl+N) renders the raw `provider:model` spec instead
# of the friendly name; `display_name=None` makes `_format_option_label`
# fall back to the spec and drop the redundant `(provider)` tag, which
# the spec already embeds.
display_name = (
None if self._show_specs else self._get_model_display_name(model_spec)
)
return self._format_option_label(
model_spec,
selected=selected,
@@ -1307,7 +1322,7 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
is_default=model_spec == self._default_spec,
status=self._get_model_status(model_spec),
install_required=provider in self._install_extras,
display_name=self._get_model_display_name(model_spec),
display_name=display_name,
provider_label=provider_label,
)
@@ -1930,6 +1945,42 @@ class ModelSelectorScreen(ModalScreen[tuple[str, str] | None]):
await self._update_display()
def action_toggle_names(self) -> None:
"""Toggle rows between friendly display names and raw model specs.
Mirrors the `/theme` picker's label/key toggle: pressing Ctrl+N flips
every visible row between its human-readable name (e.g.
`Claude Sonnet 5`) and the canonical `provider:model` spec (e.g.
`anthropic:claude-sonnet-5`) so the user can read or copy the exact id
without leaving the picker. Relabels the mounted rows in place — the
toggle changes neither ordering nor selection, so a full rebuild is
unnecessary — and stays available in curated/onboarding mode since it
only affects presentation.
"""
if not self._loaded:
return
self._show_specs = not self._show_specs
self._relabel_options()
def _relabel_options(self) -> None:
"""Rebuild each mounted row's label for the current display mode.
Used by `action_toggle_names` after flipping `_show_specs`. Each entry
in `_option_widgets` lines up with its `_filtered_models` index, so the
highlighted row is re-derived from `_selected_index` to preserve the
selected styling.
"""
for index, widget in enumerate(self._option_widgets):
widget.update(
self._build_option_label(
widget.model_spec,
widget.provider,
widget.auth_status,
selected=index == self._selected_index,
show_provider=widget.show_provider,
)
)
def action_cancel(self) -> None:
"""Cancel the selection."""
self._dismiss_with_result(None)
@@ -19,6 +19,7 @@ _TIPS: dict[str, int] = {
"Use /mcp login <server> to authenticate MCP OAuth servers without leaving the TUI": 1, # noqa: E501
"Use /remember to save learnings from this conversation": 1,
"Use /model to switch models mid-conversation": 2,
"In /model, press Ctrl+N to toggle between friendly names and raw model IDs": 1,
"Use /effort high to change the current model's reasoning effort": 1,
"Press ctrl+x to compose prompts in your external editor": 1,
"Press ctrl+u to delete to the start of the line in the chat input": 1,
+43
View File
@@ -15454,6 +15454,49 @@ class TestNotificationCenterIntegration:
assert any("Close the current dialog" in m for m in notified)
async def test_ctrl_n_in_model_selector_toggles_model_ids(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The model selector handles ctrl+n instead of the notification center."""
from deepagents_code.tui.widgets import model_selector
from deepagents_code.tui.widgets.model_selector import ModelSelectorScreen
from deepagents_code.tui.widgets.notification_center import (
NotificationCenterScreen,
)
monkeypatch.setattr(
model_selector,
"get_available_models",
lambda: {"anthropic": ["claude-sonnet-5"]},
)
monkeypatch.setattr(model_selector, "load_recent_models", list)
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
# Seed a pending entry so a broken `check_action` would push the
# notification center on ctrl+n; the assertions below then genuinely
# prove the model selector suppressed it, rather than passing only
# because an empty registry never opens the center anyway.
app._notice_registry.add(_missing_dep_entry())
screen = ModelSelectorScreen()
async with app.run_test() as pilot:
await pilot.pause()
app.push_screen(screen)
await pilot.pause()
assert not screen._show_specs
assert "Claude Sonnet 5" in str(screen._option_widgets[0].content)
await pilot.press("ctrl+n")
await pilot.pause()
# ctrl+n toggled the selector and did not open the notification
# center despite the pending entry.
assert app.screen is screen
assert not isinstance(app.screen, NotificationCenterScreen)
assert screen._show_specs
assert "anthropic:claude-sonnet-5" in str(screen._option_widgets[0].content)
async def test_ctrl_n_with_pending_opens_modal(self) -> None:
"""ctrl+n pushes the NotificationCenterScreen when entries exist."""
from deepagents_code.tui.widgets.notification_center import (
@@ -518,6 +518,226 @@ class TestRecommendedToggle:
assert "Ctrl+R" not in str(help_text.content)
class TestNamesToggle:
"""Tests for the Ctrl+N names/raw-spec toggle in `/model`."""
async def test_ctrl_n_toggles_rows_between_names_and_specs(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ctrl+N flips rows between the friendly name and the raw spec."""
from deepagents_code.tui.widgets import model_selector
monkeypatch.setattr(
model_selector,
"get_available_models",
lambda: {"anthropic": ["claude-sonnet-5"]},
)
monkeypatch.setattr(model_selector, "load_recent_models", list)
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen()
app.push_screen(screen)
await pilot.pause()
row = screen._option_widgets[0]
assert row.model_spec == "anthropic:claude-sonnet-5"
assert not screen._show_specs
text = str(row.content)
assert "Claude Sonnet 5" in text
assert "anthropic:claude-sonnet-5" not in text
await pilot.press("ctrl+n")
await pilot.pause()
assert screen._show_specs
text = str(screen._option_widgets[0].content)
assert "anthropic:claude-sonnet-5" in text
assert "Claude Sonnet 5" not in text
await pilot.press("ctrl+n")
await pilot.pause()
assert not screen._show_specs
text = str(screen._option_widgets[0].content)
assert "Claude Sonnet 5" in text
assert "anthropic:claude-sonnet-5" not in text
async def test_help_text_advertises_names_toggle_in_standard_mode(self) -> None:
"""Standard `/model` help footer should mention Ctrl+N."""
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen()
app.push_screen(screen)
await pilot.pause()
help_text = screen.query_one(".model-selector-help", Static)
assert "Ctrl+N" in str(help_text.content)
async def test_help_text_omits_names_toggle_in_curated_mode(self) -> None:
"""Onboarding's curated help footer should not mention Ctrl+N."""
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen(curated=True)
app.push_screen(screen)
await pilot.pause()
help_text = screen.query_one(".model-selector-help", Static)
assert "Ctrl+N" not in str(help_text.content)
async def test_names_toggle_available_in_curated_mode(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Curated/onboarding mode still honors Ctrl+N (presentation only)."""
from deepagents_code.tui.widgets import model_selector
monkeypatch.setattr(
model_selector,
"get_available_models",
lambda: {"anthropic": ["claude-sonnet-5"]},
)
monkeypatch.setattr(model_selector, "load_recent_models", list)
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen(curated=True)
app.push_screen(screen)
await pilot.pause()
await pilot.press("ctrl+n")
await pilot.pause()
assert screen._show_specs
assert "anthropic:claude-sonnet-5" in str(screen._option_widgets[0].content)
async def test_show_specs_persists_across_filter_rebuild(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Spec mode survives the full rebuild that filtering triggers.
`_relabel_options` (the in-place Ctrl+N path) is not the only renderer:
typing into the filter runs `_update_display`, which rebuilds every row
from scratch. That rebuild path must honor `_show_specs` too, so toggling
specs on and then filtering keeps the surviving rows in spec mode.
"""
from deepagents_code.tui.widgets import model_selector
monkeypatch.setattr(
model_selector,
"get_available_models",
lambda: {"anthropic": ["claude-sonnet-5"]},
)
monkeypatch.setattr(model_selector, "load_recent_models", list)
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen()
app.push_screen(screen)
await pilot.pause()
await pilot.press("ctrl+n")
await pilot.pause()
assert screen._show_specs
# Typing filters the list, rebuilding rows via `_update_display`.
await pilot.press("c", "l", "a", "u", "d", "e")
await pilot.pause()
assert screen._show_specs
specs = [str(w.content) for w in screen._option_widgets]
assert any("anthropic:claude-sonnet-5" in s for s in specs)
assert all("Claude Sonnet 5" not in s for s in specs)
async def test_selection_preserved_after_names_toggle(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Toggling names keeps the highlighted row highlighted.
Uses a multi-row list with the cursor moved off row 0 so a regression
that dropped the selection (e.g. hardcoding `selected=False` in
`_relabel_options`) would surface as a missing cursor glyph.
"""
from deepagents_code.tui.widgets import model_selector
monkeypatch.setattr(
model_selector,
"get_available_models",
lambda: {"anthropic": ["claude-sonnet-5"], "openai": ["gpt-5.5"]},
)
monkeypatch.setattr(model_selector, "load_recent_models", list)
cursor = get_glyphs().cursor
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen()
screen._recommended_only = False
app.push_screen(screen)
await pilot.pause()
assert len(screen._option_widgets) >= 2
await pilot.press("down")
await pilot.pause()
selected = screen._selected_index
assert selected != 0
await pilot.press("ctrl+n")
await pilot.pause()
assert screen._selected_index == selected
for index, widget in enumerate(screen._option_widgets):
text = str(widget.content)
if index == selected:
assert text.startswith(f"{cursor} ")
else:
assert not text.startswith(f"{cursor} ")
async def test_names_toggle_drops_provider_tag_in_recent_section(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""In spec mode a Recent row shows the bare spec with no `(provider)` tag.
Recent rows (`show_provider=True`) append a dim ` (provider)` tag after
the friendly name to disambiguate the same model across providers. The
raw spec already embeds the provider, so spec mode must drop that tag
rather than print the provider twice.
"""
from deepagents_code.tui.widgets import model_selector
monkeypatch.setattr(
model_selector,
"get_available_models",
lambda: {"anthropic": ["claude-sonnet-5"]},
)
monkeypatch.setattr(
model_selector,
"load_recent_models",
lambda: ["anthropic:claude-sonnet-5"],
)
app = ModelSelectorTestApp()
async with app.run_test() as pilot:
screen = ModelSelectorScreen()
app.push_screen(screen)
await pilot.pause()
recent_rows = [w for w in screen._option_widgets if w.show_provider]
assert recent_rows, "expected a Recent-section row"
before = str(recent_rows[0].content)
assert "Claude Sonnet 5" in before
assert "(Anthropic)" in before
await pilot.press("ctrl+n")
await pilot.pause()
recent_rows = [w for w in screen._option_widgets if w.show_provider]
after = str(recent_rows[0].content)
assert "anthropic:claude-sonnet-5" in after
assert "Claude Sonnet 5" not in after
assert "(Anthropic)" not in after
class TestRecentModelsSection:
"""Tests for the "Recent" pseudo-provider section pinned at the top."""