fix(code): show loading state during model switch (#4209)

Show an animated loading indicator in the status bar while switching
models, so slow provider switches no longer appear to hang.

---

Switching to a heavy provider (e.g. `google_genai`) imports a large
package during model creation, which previously gave no feedback and
made the switch look frozen. The heavy work already runs off the event
loop in a worker thread, so this adds the missing visual feedback rather
than changing the threading model.

A shared animated busy indicator is added to `StatusBar` (`set_busy`)
that reuses the existing connection spinner machinery, and
`_switch_model` drives it around the threaded model creation. The
indicator is cleared in a `finally` block so the status slot can never
get stuck after a failed or successful switch, and regular status
messages are restored once it clears.

Made by [Open
SWE](https://openswe.vercel.app/agents/c4a420f3-6b03-f87a-cf70-eb26b4366b09)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-02 02:34:16 -04:00
committed by GitHub
parent b927147d02
commit 7cc4e9f9bd
4 changed files with 298 additions and 7 deletions
+9
View File
@@ -15935,6 +15935,12 @@ class DeepAgentsApp(App):
if provider and not parsed:
display = f"{provider}:{model_name}"
# Provider package imports (e.g. langchain_google_genai) can take a
# noticeable moment; show an animated busy indicator so it doesn't look
# frozen. The work itself already runs off the event loop via
# `asyncio.to_thread`, so the UI stays responsive meanwhile.
if self._status_bar:
self._status_bar.set_busy("Switching model")
try:
result = await asyncio.to_thread(
_create_model_with_deepagents_import_lock,
@@ -15954,6 +15960,9 @@ class DeepAgentsApp(App):
ErrorMessage(_build_model_switch_error_body(exc)),
)
return
finally:
if self._status_bar:
self._status_bar.set_busy("")
# Set the model override for ConfigurableModelMiddleware.
# The next stream call passes CLIContext via context= and the
+55 -7
View File
@@ -263,6 +263,7 @@ class StatusBar(Horizontal):
self._hide_git_branch = is_env_truthy(HIDE_GIT_BRANCH)
self._spinner = Spinner()
self._spinner_timer: Timer | None = None
self._busy_message = ""
def compose(self) -> ComposeResult: # noqa: PLR6301 — Textual widget method
"""Compose the status bar layout.
@@ -391,6 +392,10 @@ class StatusBar(Horizontal):
def watch_status_message(self, new_value: str) -> None:
"""Update status message display."""
if self._busy_message:
# The busy indicator owns the status-message slot while active;
# defer regular status updates until `set_busy("")` clears it.
return
try:
msg_widget = self.query_one("#status-message", Static)
except NoMatches:
@@ -404,20 +409,32 @@ class StatusBar(Horizontal):
else:
msg_widget.update("")
def watch_connection_state(self, new_value: ConnectionState) -> None:
def watch_connection_state(self, _new_value: ConnectionState) -> None:
"""Start or stop the spinner and re-render when connection state changes."""
if new_value in {"connecting", "reconnecting", "resuming"}:
self._start_spinner()
else:
self._stop_spinner()
self._sync_spinner()
self._render_connection()
def watch_queued_count(self, _new_value: int) -> None:
"""Re-render the connection indicator when the queued count changes."""
self._render_connection()
def _spinner_active(self) -> bool:
"""Whether any indicator (connection or busy) needs the shared spinner.
Returns:
`True` when a connection state or a busy message is active.
"""
return bool(self.connection_state) or bool(self._busy_message)
def _sync_spinner(self) -> None:
"""Start or stop the shared spinner to match connection/busy state."""
if self._spinner_active():
self._start_spinner()
else:
self._stop_spinner()
def _start_spinner(self) -> None:
"""Begin cycling the connection-indicator spinner frames.
"""Begin cycling the shared spinner frames.
No-op when not yet running (e.g. before mount) since `set_interval`
requires a live event loop, or when an animation is already active.
@@ -436,9 +453,10 @@ class StatusBar(Horizontal):
self._spinner = Spinner()
def _tick_spinner(self) -> None:
"""Advance the spinner frame and re-render the connection indicator."""
"""Advance the spinner frame and re-render the animated indicators."""
self._spinner.next_frame()
self._render_connection()
self._render_busy()
def _render_connection(self) -> None:
"""Render the combined connection + queued-count indicator text."""
@@ -464,6 +482,36 @@ class StatusBar(Horizontal):
widget.display = bool(text)
widget.update(text)
def _render_busy(self) -> None:
"""Render the animated busy indicator into the status-message slot."""
if not self._busy_message:
return
try:
widget = self.query_one("#status-message", Static)
except NoMatches:
return
widget.remove_class("thinking")
frame = self._spinner.current_frame()
widget.update(Content.assemble(frame, " ", Content(self._busy_message)))
def set_busy(self, message: str) -> None:
"""Show or clear an animated busy indicator in the status-message slot.
Reuses the shared status-bar spinner so heavier UI operations (e.g. a
model switch that imports a provider package) show activity instead of
appearing to hang.
Args:
message: Busy text to animate with a spinner, or empty string to
clear it and restore the regular status message.
"""
self._busy_message = message
self._sync_spinner()
if message:
self._render_busy()
else:
self.watch_status_message(self.status_message)
def set_connection(self, state: ConnectionState) -> None:
"""Set the connection indicator state.
@@ -5,6 +5,7 @@ from typing import Any
from unittest.mock import AsyncMock, Mock, patch
import pytest
from textual.app import App, ComposeResult
from deepagents_code import model_config
from deepagents_code.app import (
@@ -22,6 +23,7 @@ from deepagents_code.model_config import (
)
from deepagents_code.remote_client import RemoteAgent
from deepagents_code.widgets.messages import AppMessage, ErrorMessage
from deepagents_code.widgets.status import StatusBar
_CONFIGURED_AUTH_STATUS = ProviderAuthStatus(
state=ProviderAuthState.CONFIGURED,
@@ -1219,3 +1221,120 @@ class TestModelCommandIntegration:
assert len(captured_errors) == 1
assert "cannot be used with --default" in captured_errors[0]
class _StatusBarHarness(App[None]):
"""Minimal app that mounts a `StatusBar` so its child widgets exist.
`_switch_model`'s success path calls `set_model`, which queries the
`#model-display` child, so the bar must be mounted to be driven end-to-end.
"""
def compose(self) -> ComposeResult:
"""Yield a single status bar."""
yield StatusBar(id="status-bar")
class TestModelSwitchBusyIndicator:
"""Tests that `_switch_model` drives the status-bar busy indicator.
These use a real mounted `StatusBar` rather than a mock so the wiring is
verified against actual `set_busy` behavior end-to-end.
"""
async def test_busy_set_during_switch_and_cleared_on_success(
self, mock_create_model: Mock
) -> None:
"""Busy shows "Switching model" mid-flight and clears after success."""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._agent = _make_remote_agent()
settings.model_name = "gpt-5.5"
settings.model_provider = "openai"
async with _StatusBarHarness().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
app._status_bar = bar
busy_mid_flight: list[str] = []
def capture_busy(
model_spec: str,
*,
extra_kwargs: dict[str, object] | None = None,
profile_overrides: dict[str, object] | None = None,
) -> _FakeModelResult:
# Runs in the worker thread: record what the bar shows while the
# (normally slow) provider import is notionally in progress.
del model_spec, extra_kwargs, profile_overrides
busy_mid_flight.append(bar._busy_message)
return _FakeModelResult(
model_name="claude-sonnet-4-5",
provider="anthropic",
context_limit=200_000,
)
mock_create_model.side_effect = capture_busy
with (
patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=_CONFIGURED_AUTH_STATUS,
),
patch(
"deepagents_code.model_config.save_recent_model",
return_value=True,
),
):
await app._switch_model("anthropic:claude-sonnet-4-5")
assert busy_mid_flight == ["Switching model"]
assert bar._busy_message == ""
async def test_busy_cleared_when_switch_fails(
self, mock_create_model: Mock
) -> None:
"""A failed model creation must still clear busy via the `finally` block.
Regression guard: if busy were cleared only on the success path, a
failed switch would leave the status bar spinning "Switching model"
indefinitely. The clear lives in a `finally`, so it must run here too.
"""
app = DeepAgentsApp()
app._mount_message = AsyncMock() # ty: ignore
app._agent = _make_remote_agent()
settings.model_name = "gpt-5.5"
settings.model_provider = "openai"
async with _StatusBarHarness().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
app._status_bar = bar
busy_mid_flight: list[str] = []
def fail_after_capture(
model_spec: str,
*,
extra_kwargs: dict[str, object] | None = None,
profile_overrides: dict[str, object] | None = None,
) -> _FakeModelResult:
del model_spec, extra_kwargs, profile_overrides
busy_mid_flight.append(bar._busy_message)
msg = "provider import blew up"
raise RuntimeError(msg)
mock_create_model.side_effect = fail_after_capture
with patch(
"deepagents_code.model_config.get_provider_auth_status",
return_value=_CONFIGURED_AUTH_STATUS,
):
await app._switch_model("anthropic:claude-sonnet-4-5")
# Busy showed while the switch ran, then cleared despite the failure.
assert busy_mid_flight == ["Switching model"]
assert bar._busy_message == ""
# The failure surfaced to the user rather than being swallowed.
app._mount_message.assert_called_once() # ty: ignore
+115
View File
@@ -622,6 +622,121 @@ class TestConnectionIndicator:
assert bar._spinner_timer is None
class TestBusyIndicator:
"""Tests for the animated busy indicator used during model switches."""
async def test_set_busy_shows_message_and_spinner(self) -> None:
"""`set_busy` should render a spinner-prefixed message and run the timer."""
from deepagents_code.config import get_glyphs
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_busy("Switching model")
await pilot.pause()
msg = pilot.app.query_one("#status-message", Static)
rendered = str(msg.render())
assert "Switching model" in rendered
# A spinner frame prefixes the message. Don't pin frame[0]: the
# 0.1s timer may have ticked during the pause, so accept any frame.
assert any(frame in rendered for frame in get_glyphs().spinner_frames)
assert bar._spinner_timer is not None
async def test_set_busy_treats_bracket_text_as_literal(self) -> None:
"""A model spec with markup-like brackets must render verbatim, not crash."""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_busy("Switching to openai:[00]")
await pilot.pause()
msg = pilot.app.query_one("#status-message", Static)
assert "Switching to openai:[00]" in str(msg.render())
async def test_clear_busy_stops_spinner_and_clears_message(self) -> None:
"""Clearing the busy state should stop the timer and empty the slot."""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_busy("Switching model")
await pilot.pause()
bar.set_busy("")
await pilot.pause()
msg = pilot.app.query_one("#status-message", Static)
assert str(msg.render()) == ""
assert bar._spinner_timer is None
async def test_clear_busy_restores_status_message(self) -> None:
"""A status message set before busy should reappear once busy clears."""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_status_message("Thinking")
await pilot.pause()
bar.set_busy("Switching model")
await pilot.pause()
msg = pilot.app.query_one("#status-message", Static)
assert "Switching" in str(msg.render())
bar.set_busy("")
await pilot.pause()
assert str(msg.render()) == "Thinking"
async def test_status_message_deferred_while_busy(self) -> None:
"""Regular status updates must not clobber an active busy indicator."""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_busy("Switching model")
await pilot.pause()
bar.set_status_message("Executing")
await pilot.pause()
msg = pilot.app.query_one("#status-message", Static)
assert "Switching" in str(msg.render())
async def test_busy_keeps_spinner_running_while_connecting(self) -> None:
"""Clearing busy while connecting must leave the shared spinner running."""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_connection("connecting")
bar.set_busy("Switching model")
await pilot.pause()
assert bar._spinner_timer is not None
bar.set_busy("")
await pilot.pause()
assert bar._spinner_timer is not None
async def test_clear_busy_while_connecting_restores_message(self) -> None:
"""Clearing busy mid-connection restores the message and keeps the spinner.
Exercises the combined state the shared-spinner refactor targets: the
busy slot and the independent connection indicator are both active, and
clearing busy must repaint the deferred message without stopping the
spinner that the still-active connection state owns.
"""
async with StatusBarApp().run_test() as pilot:
bar = pilot.app.query_one("#status-bar", StatusBar)
bar.set_connection("connecting")
bar.set_status_message("Thinking")
bar.set_busy("Switching model")
await pilot.pause()
msg = pilot.app.query_one("#status-message", Static)
conn = pilot.app.query_one("#connection-indicator", Static)
# Busy owns the message slot; the connection indicator is separate.
assert "Switching model" in str(msg.render())
assert "Connecting" in str(conn.render())
bar.set_busy("")
await pilot.pause()
# Deferred message reappears; spinner keeps ticking for the
# still-active connection state.
assert str(msg.render()) == "Thinking"
assert "Connecting" in str(conn.render())
assert bar._spinner_timer is not None
async def test_set_busy_before_mount_does_not_raise(self) -> None:
"""`set_busy` on an unmounted bar is a safe no-op (no widgets, no timer)."""
bar = StatusBar()
bar.set_busy("Switching model")
assert bar._busy_message == "Switching model"
assert bar._spinner_timer is None
bar.set_busy("")
assert bar._busy_message == ""
assert bar._spinner_timer is None
class TestQueuedCount:
"""Tests for the queued-message count in the connection indicator."""