mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user