Files
deepagents/libs/code/deepagents_code/app.py
T
Mason Daugherty 7ffaa93dca feat(code): /install optional extras (#3606)
Adds a first-class way to install optional extras into an existing
`dcode` install without dropping back to a shell or guessing package
names. Surfaces it as both the `/install <extra>` slash command (in-app)
and a `dcode --install EXTRA` headless flag, and rewrites the ecosystem
of `pip install …` recovery hints to point at the new flow.
2026-05-26 20:49:25 -04:00

9928 lines
390 KiB
Python

"""Textual UI application."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shlex
import signal
import sys
import threading
import time
import uuid
import webbrowser
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeVar
from textual import on
from textual.app import App, ScreenStackError
from textual.binding import Binding, BindingType
from textual.containers import Container, VerticalScroll
from textual.content import Content
from textual.css.query import NoMatches
from textual.events import Click
from textual.message import Message
from textual.notifications import Notification as _Notification, Notify as _Notify
from textual.screen import ModalScreen
from textual.style import Style as TStyle
from textual.theme import Theme
from textual.widgets import Header, Static
from textual.widgets._toast import (
Toast as _Toast, # noqa: PLC2701 # for Toast click routing
)
# Applied as an import-time side effect; must come before any App is created.
from deepagents_code import (
_textual_patches, # noqa: F401
theme,
)
from deepagents_code._cli_context import CLIContext
from deepagents_code._constants import DEFAULT_AGENT_NAME as DEFAULT_ASSISTANT_ID
from deepagents_code._debug import configure_debug_logging
from deepagents_code._git import (
read_git_branch_from_filesystem,
read_git_branch_via_subprocess,
)
from deepagents_code._session_stats import (
SessionStats,
SpinnerStatus,
format_token_count,
)
# Only is_ascii_mode is needed before first paint (on_mount scrollbar config).
# All other config imports — settings, create_model, detect_provider, etc. — are
# deferred to local imports at their call sites since they are only accessed
# after user interaction begins.
from deepagents_code._version import CHANGELOG_URL, DOCS_URL
from deepagents_code.config import is_ascii_mode
from deepagents_code.iterm_cursor_guide import restore_iterm_cursor_guide
from deepagents_code.notifications import (
ActionId,
MissingDepPayload,
NotificationAction,
NotificationRegistry,
PendingNotification,
UpdateAvailablePayload,
)
from deepagents_code.widgets._links import open_url_async
from deepagents_code.widgets.chat_input import ChatInput
from deepagents_code.widgets.loading import LoadingWidget
from deepagents_code.widgets.message_store import (
MessageData,
MessageStore,
MessageType,
ToolStatus,
)
from deepagents_code.widgets.messages import (
AppMessage,
AssistantMessage,
ErrorMessage,
QueuedUserMessage,
SkillMessage,
ToolCallMessage,
UserMessage,
)
from deepagents_code.widgets.status import StatusBar
from deepagents_code.widgets.welcome import WelcomeBanner
logger = logging.getLogger(__name__)
configure_debug_logging(logger)
_monotonic = time.monotonic
_DEFERRED_START_NOTICE = (
"No model is configured yet. Run `/model` to choose one. "
"Deep Agents will ask for credentials for the selected provider."
)
# Serializes process-local read-modify-write operations for `config.toml`.
# Without this, overlapping global-theme and per-terminal-theme saves can each
# read the same pre-mutation state and then clobber the other's keys.
_CONFIG_WRITE_LOCK = threading.Lock()
@dataclass(frozen=True)
class _ConfigWriteResult:
"""Result of a config write with TUI-facing failure context."""
ok: bool
"""Whether the write completed successfully."""
message: str | None = None
"""Optional user-facing detail for repairs or failures."""
severity: Literal["warning", "error"] = "warning"
"""Toast severity to use when `message` is shown."""
ScreenResultT = TypeVar("ScreenResultT")
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping
from deepagents.backends import CompositeBackend
from langchain_core.runnables import RunnableConfig
from langgraph.pregel import Pregel
from textual.app import ComposeResult
from textual.events import MouseUp, Paste
from textual.scrollbar import ScrollUp
from textual.widget import Widget
from textual.worker import Worker
from deepagents_code._ask_user_types import AskUserWidgetResult, Question
from deepagents_code.event_bus import EventSource, ExternalEvent
from deepagents_code.mcp_tools import MCPServerInfo
from deepagents_code.model_config import MissingProviderPackageError
from deepagents_code.remote_client import RemoteAgent
from deepagents_code.server import ServerProcess
from deepagents_code.skills.load import ExtendedSkillMetadata
from deepagents_code.textual_adapter import TextualUIAdapter
from deepagents_code.widgets.approval import ApprovalMenu
from deepagents_code.widgets.ask_user import AskUserMenu
from deepagents_code.widgets.notification_center import (
NotificationSuppressRequested,
)
from deepagents_code.widgets.update_progress import UpdateProgressScreen
_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS = 60.0
"""Upper bound on waiting for server readiness during onboarding model switch.
Server startup is normally seconds; this ceiling exists only so a stuck
backend cannot trap the user inside a finished onboarding modal forever.
"""
_UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
"""How often long-running TUI sessions quietly re-check for app updates."""
def _resolve_theme_name(value: object) -> str | None:
"""Resolve a user-supplied theme name to a canonical registry key.
Accepts the registry key or the human-readable label, case-insensitive
on both, with surrounding whitespace stripped — config values
(especially `[ui.terminal_themes]`) and the `DEEPAGENTS_CODE_THEME`
env var are commonly hand-edited. Also applies the legacy
`textual-ansi` → `ansi-light` migration (pre-Textual 8.2.5).
Args:
value: Raw value read from TOML or an environment variable.
Returns:
The canonical registry key, or `None` if the value is not a string or
does not match any registered theme by key or label
(case-insensitive).
"""
if not isinstance(value, str):
return None
name = value.strip()
if name == "textual-ansi":
name = "ansi-light"
registry = theme.get_registry()
if name in registry:
return name
folded = name.casefold()
for registered, entry in registry.items():
if registered.casefold() == folded or entry.label.casefold() == folded:
return registered
return None
def _as_toml_table(value: object) -> dict[str, object] | None:
"""Return `value` as a TOML table when it has the expected runtime shape."""
if not isinstance(value, dict):
return None
# `tomllib` parses TOML tables as string-keyed dicts; `ty` cannot infer
# that from a runtime `dict` check. Keep the cast at this boundary so it
# does not become a general-purpose escape hatch.
from typing import cast
return cast("dict[str, object]", value)
def _resolve_terminal_mapping(ui: Mapping[str, object]) -> str | None:
"""Resolve `[ui.terminal_themes][TERM_PROGRAM]` to a registered theme.
Centralizes both the lookup and the misconfiguration warnings shared by
`_load_theme_preference` (startup) and `_load_terminal_default` (picker
badge). Misconfiguration is logged exactly once per call.
Args:
ui: The `[ui]` table parsed from `config.toml`.
Returns:
The canonical registry key, or `None` if `terminal_themes` is absent,
malformed, references an unknown theme, or `TERM_PROGRAM` is unset
despite a non-empty mapping.
"""
terminal_themes = ui.get("terminal_themes")
if terminal_themes is None:
return None
terminal_themes_table = _as_toml_table(terminal_themes)
if terminal_themes_table is None:
logger.warning(
"[ui.terminal_themes] should be a table mapping TERM_PROGRAM "
"values to theme names; got %s",
type(terminal_themes).__name__,
)
return None
term_program = os.environ.get("TERM_PROGRAM", "").strip()
if not term_program:
if terminal_themes_table:
logger.warning(
"[ui.terminal_themes] is configured but TERM_PROGRAM is unset; "
"no per-terminal theme will be applied",
)
return None
mapped = terminal_themes_table.get(term_program)
resolved = _resolve_theme_name(mapped)
if resolved is not None:
return resolved
if isinstance(mapped, str):
logger.warning(
"Unknown theme '%s' mapped to TERM_PROGRAM='%s' "
"in [ui.terminal_themes]; ignoring",
mapped,
term_program,
)
elif mapped is not None:
logger.warning(
"Expected string theme name for TERM_PROGRAM='%s' in "
"[ui.terminal_themes], got %s; ignoring",
term_program,
type(mapped).__name__,
)
return None
def _load_terminal_default() -> str | None:
"""Return the saved default theme for the current `TERM_PROGRAM`.
Reads `[ui.terminal_themes][TERM_PROGRAM]` from `config.toml` and
resolves the value via `_resolve_theme_name`, so labels and case variants
are accepted. Used by `ThemeSelectorScreen` to badge the matching option
with `(default)`.
Returns:
The canonical registry key, or `None` if `TERM_PROGRAM` is unset, the
file is missing/unreadable, no mapping is set, or the mapped value
doesn't match a registered theme. Read errors and misconfigurations
are logged at WARNING.
"""
if not os.environ.get("TERM_PROGRAM", "").strip():
return None
import tomllib
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
if not DEFAULT_CONFIG_PATH.exists():
return None
try:
with DEFAULT_CONFIG_PATH.open("rb") as f:
data = tomllib.load(f)
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
logger.warning("Could not read config for terminal theme default: %s", exc)
return None
ui = data.get("ui")
if not isinstance(ui, dict):
if ui is not None:
logger.warning(
"[ui] should be a table; got %s while loading terminal theme default",
type(ui).__name__,
)
return None
return _resolve_terminal_mapping(ui)
def _load_theme_preference() -> str:
"""Load the forced or saved theme name, or return the default.
Resolution order:
1. `DEEPAGENTS_CODE_THEME` env var (explicit override). If it is set but
cannot be resolved, the default theme is used immediately.
2. `[ui.terminal_themes]` mapping keyed by `TERM_PROGRAM` — wins over the
saved preference so a user moving between terminals (e.g. dark iTerm,
light Apple Terminal) gets the right theme automatically.
3. `[ui].theme` in `~/.deepagents/config.toml` (saved preference, used
when no terminal mapping matches).
4. `theme.DEFAULT_THEME`.
Returns:
A Textual theme name (e.g., `'langchain'`, `'langchain-light'`).
"""
from deepagents_code._env_vars import THEME
env_name = os.environ.get(THEME)
if env_name is not None:
resolved = _resolve_theme_name(env_name)
if resolved is not None:
return resolved
logger.warning(
"Unknown theme '%s' in %s; falling back to default",
env_name,
THEME,
)
return theme.DEFAULT_THEME
import tomllib
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
if not DEFAULT_CONFIG_PATH.exists():
return theme.DEFAULT_THEME
try:
with DEFAULT_CONFIG_PATH.open("rb") as f:
data = tomllib.load(f)
except (tomllib.TOMLDecodeError, PermissionError, OSError) as exc:
logger.warning("Could not read config for theme preference: %s", exc)
return theme.DEFAULT_THEME
ui = data.get("ui", {})
if not isinstance(ui, dict):
logger.warning(
"[ui] should be a table; got %s while loading theme preference",
type(ui).__name__,
)
return theme.DEFAULT_THEME
resolved = _resolve_terminal_mapping(ui)
if resolved is not None:
return resolved
saved = ui.get("theme")
resolved = _resolve_theme_name(saved)
if resolved is not None:
return resolved
if isinstance(saved, str):
logger.warning(
"Unknown theme '%s' in config; falling back to default",
saved,
)
return theme.DEFAULT_THEME
def _replace_malformed_ui(
data: dict[str, object],
) -> tuple[dict[str, object], str | None]:
"""Return a writable `[ui]` table, replacing malformed values if needed."""
ui = data.get("ui")
table = _as_toml_table(ui)
if table is not None:
return table, None
replaced_malformed = ui is not None
if ui is not None:
logger.warning(
"Existing [ui] is not a table (got %r); replacing with a fresh table",
ui,
)
ui = {}
data["ui"] = ui
return ui, (
"Existing [ui] was not a table and was replaced while saving the theme "
"configuration."
if replaced_malformed
else None
)
def _save_theme_preference_result(name: str) -> _ConfigWriteResult:
"""Persist theme preference and return TUI-facing status details.
Returns:
Write status and a message suitable for a toast when the user needs to
know about a repair or failure.
"""
if name not in theme.get_registry():
logger.warning("Refusing to save unknown theme '%s'", name)
return _ConfigWriteResult(False, f"Unknown theme '{name}' was not saved.")
import contextlib
import tempfile
import tomllib
try:
import tomli_w
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with _CONFIG_WRITE_LOCK:
if DEFAULT_CONFIG_PATH.exists():
with DEFAULT_CONFIG_PATH.open("rb") as f:
data = tomllib.load(f)
else:
data = {}
ui, repair_message = _replace_malformed_ui(data)
ui["theme"] = name
fd, tmp_path = tempfile.mkstemp(
dir=DEFAULT_CONFIG_PATH.parent,
suffix=".tmp",
)
try:
with os.fdopen(fd, "wb") as f:
tomli_w.dump(data, f)
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
except BaseException:
with contextlib.suppress(OSError):
Path(tmp_path).unlink()
raise
except (
OSError,
tomllib.TOMLDecodeError,
ImportError,
TypeError,
ValueError,
) as exc:
logger.exception("Could not save theme preference")
return _ConfigWriteResult(
False,
f"Theme applied for this session but could not be saved "
f"({type(exc).__name__}).",
"error",
)
return _ConfigWriteResult(True, repair_message)
def save_theme_preference(name: str) -> bool:
"""Persist theme preference to `~/.deepagents/config.toml`.
Args:
name: Textual theme name to save.
Returns:
`True` if the preference was saved, `False` if any error occurred.
"""
return _save_theme_preference_result(name).ok
def _save_terminal_theme_mapping_result(
term_program: str,
name: str,
) -> _ConfigWriteResult:
"""Persist a terminal theme mapping and return TUI-facing status details.
Returns:
Write status and a message suitable for a toast when the user needs to
know about a repair or failure.
"""
if name not in theme.get_registry():
logger.warning("Refusing to map unknown theme '%s'", name)
return _ConfigWriteResult(False, f"Unknown theme '{name}' was not saved.")
term_program = term_program.strip()
if not term_program:
logger.warning("Refusing to save terminal mapping with empty TERM_PROGRAM")
return _ConfigWriteResult(
False,
"TERM_PROGRAM is unset; can't set a per-terminal default.",
)
import contextlib
import tempfile
import tomllib
try:
import tomli_w
from deepagents_code.model_config import DEFAULT_CONFIG_PATH
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
repair_messages: list[str] = []
with _CONFIG_WRITE_LOCK:
if DEFAULT_CONFIG_PATH.exists():
with DEFAULT_CONFIG_PATH.open("rb") as f:
data = tomllib.load(f)
else:
data = {}
ui, repair_message = _replace_malformed_ui(data)
if repair_message is not None:
repair_messages.append(repair_message)
terminal_themes = ui.get("terminal_themes")
terminal_themes_table = _as_toml_table(terminal_themes)
if terminal_themes_table is None:
if terminal_themes is not None:
logger.warning(
"Existing [ui.terminal_themes] is not a table (got %r); "
"replacing with a fresh table",
terminal_themes,
)
repair_messages.append(
"Existing [ui.terminal_themes] was not a table and was "
"replaced while saving this terminal default.",
)
terminal_themes_table = {}
ui["terminal_themes"] = terminal_themes_table
terminal_themes_table[term_program] = name
fd, tmp_path = tempfile.mkstemp(
dir=DEFAULT_CONFIG_PATH.parent,
suffix=".tmp",
)
try:
with os.fdopen(fd, "wb") as f:
tomli_w.dump(data, f)
Path(tmp_path).replace(DEFAULT_CONFIG_PATH)
except BaseException:
with contextlib.suppress(OSError):
Path(tmp_path).unlink()
raise
except (
OSError,
tomllib.TOMLDecodeError,
ImportError,
TypeError,
ValueError,
) as exc:
logger.exception("Could not save terminal theme mapping")
return _ConfigWriteResult(
False,
f"Could not save terminal mapping ({type(exc).__name__}).",
"error",
)
return _ConfigWriteResult(True, " ".join(repair_messages) or None)
def save_terminal_theme_mapping(term_program: str, name: str) -> bool:
"""Persist a `[ui.terminal_themes][term_program] = name` entry.
The write is atomic (temp file + `Path.replace`) to avoid corrupting
`config.toml` on crash or SIGINT. Mirrors `save_theme_preference`.
Args:
term_program: Value of the `TERM_PROGRAM` environment variable to key
on. Whitespace is stripped; the trimmed value is matched verbatim
against `os.environ["TERM_PROGRAM"]` at lookup time.
name: Theme name to map. Validated as an exact registry-key match —
labels and case variants are rejected here because the picker
writes canonical keys.
Returns:
`True` if the mapping was saved, `False` if `name` isn't a registered
theme, `term_program` is empty after stripping, or any error
occurred.
"""
return _save_terminal_theme_mapping_result(term_program, name).ok
def _extract_model_params_flag(raw_arg: str) -> tuple[str, dict[str, Any] | None]:
"""Extract `--model-params` and its JSON value from a `/model` arg string.
Handles quoted (`'...'` / `"..."`) and bare `{...}` values with balanced
braces so that JSON containing spaces works without quoting.
Note:
The bare-brace mode counts `{` / `}` characters without awareness of
JSON string contents. Values that contain literal braces inside strings
(e.g., `{"stop": "end}here"}`) will mis-parse. Users should quote the
value in that case.
Args:
raw_arg: The argument string after `/model `.
Returns:
Tuple of `(remaining_args, parsed_dict | None)`. Returns `None` for the
dict when the flag is absent.
Raises:
ValueError: If the value is missing, has unclosed quotes,
unbalanced braces, or is not valid JSON.
TypeError: If the parsed JSON is not a dict.
"""
flag = "--model-params"
idx = raw_arg.find(flag)
if idx == -1:
return raw_arg, None
before = raw_arg[:idx].rstrip()
after = raw_arg[idx + len(flag) :].lstrip()
if not after:
msg = "--model-params requires a JSON object value"
raise ValueError(msg)
# Determine the JSON string boundaries.
if after[0] in {"'", '"'}:
quote = after[0]
end = -1
backslash_count = 0
for i, ch in enumerate(after[1:], start=1):
if ch == "\\":
backslash_count += 1
continue
if ch == quote and backslash_count % 2 == 0:
end = i
break
backslash_count = 0
if end == -1:
msg = f"Unclosed {quote} in --model-params value"
raise ValueError(msg)
# Parse the quoted token with shlex so escaped quotes are unescaped.
json_str = shlex.split(after[: end + 1], posix=True)[0]
rest = after[end + 1 :].lstrip()
elif after[0] == "{":
# Walk forward to find the matching closing brace.
depth = 0
end = -1
for i, ch in enumerate(after):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break
if end == -1:
msg = "Unbalanced braces in --model-params value"
raise ValueError(msg)
json_str = after[: end + 1]
rest = after[end + 1 :].lstrip()
else:
# Non-brace, non-quoted — take the next whitespace-delimited token.
parts = after.split(None, 1)
json_str = parts[0]
rest = parts[1] if len(parts) > 1 else ""
remaining = f"{before} {rest}".strip()
try:
params = json.loads(json_str)
except json.JSONDecodeError:
msg = (
f"Invalid JSON in --model-params: {json_str!r}. "
'Expected format: --model-params \'{"key": "value"}\''
)
raise ValueError(msg) from None
if not isinstance(params, dict):
msg = "--model-params must be a JSON object, got " + type(params).__name__
raise TypeError(msg)
return remaining, params
def _format_model_params(extra_kwargs: dict[str, Any] | None) -> str:
"""Render `--model-params` as a stable, key-sorted JSON suffix.
Args:
extra_kwargs: The parsed `--model-params` payload, or `None`.
Returns:
` with model params {json}` when `extra_kwargs` is non-empty;
otherwise an empty string so callers can unconditionally concatenate.
"""
if not extra_kwargs:
return ""
return f" with model params {json.dumps(extra_kwargs, sort_keys=True)}"
InputMode = Literal["normal", "shell", "shell_incognito", "command"]
_RECONNECT_FORCE_TOKENS: frozenset[str] = frozenset({"force", "--force", "-f"})
def _parse_reconnect_args(rest: str) -> tuple[bool, bool]:
"""Parse the argument tail of `/mcp reconnect [force]`.
Trailing tokens after `force` reject because the user's intent is
unclear.
Args:
rest: Everything after `/mcp reconnect` (already stripped).
Returns:
`(force, valid)`. `valid=False` means the caller should surface
the usage message and skip the handler.
"""
tokens = rest.split()
if not tokens:
return False, True
if len(tokens) == 1 and tokens[0].lower() in _RECONNECT_FORCE_TOKENS:
return True, True
return False, False
_TYPING_IDLE_THRESHOLD_SECONDS: float = 2.0
"""Seconds since the last keystroke after which the user is considered idle and
a pending approval widget can be shown.
Two seconds balances responsiveness with avoiding accidental approval
key presses.
"""
_DEFERRED_APPROVAL_TIMEOUT_SECONDS: float = 30.0
"""Maximum seconds the deferred-approval worker will wait for the user to stop
typing before showing the approval widget regardless."""
@dataclass(frozen=True, slots=True)
class QueuedMessage:
"""Represents a queued user message awaiting processing."""
text: str
"""The message text content."""
mode: InputMode
"""The input mode that determines message routing."""
class ExternalInput(Message):
"""Textual message carrying an external prompt or command."""
def __init__(self, event: ExternalEvent) -> None:
"""Create an external input message.
Args:
event: Transport-independent external event.
"""
super().__init__()
self.event = event
DeferredActionKind = Literal[
"model_switch",
"thread_switch",
"chat_output",
"agent_switch",
"mcp_login",
]
"""Valid `DeferredAction.kind` values for type-checked deduplication."""
@dataclass(frozen=True, slots=True, kw_only=True)
class DeferredAction:
"""An action deferred until the current busy state resolves."""
kind: DeferredActionKind
"""Identity key for deduplication — one of `DeferredActionKind`."""
execute: Callable[[], Awaitable[None]]
"""Async callable that performs the actual work."""
@dataclass(frozen=True, slots=True)
class _ThreadHistoryPayload:
"""Data returned by `_fetch_thread_history_data`."""
messages: list[MessageData]
"""Converted message data ready for bulk loading."""
context_tokens: int
"""Persisted `_context_tokens` from the checkpoint (0 if absent)."""
def _new_thread_id() -> str:
"""Deferred-import wrapper around `sessions.generate_thread_id`.
Returns:
UUID7 string.
"""
from deepagents_code.sessions import generate_thread_id
return generate_thread_id()
def _action_label(entry: PendingNotification, action_id: ActionId) -> str:
"""Return the user-facing label for *action_id* on *entry*, or the id itself."""
for action in entry.actions:
if action.action_id == action_id:
return action.label
return action_id.value
def _truncate(text: str, *, limit: int) -> str:
"""Return *text* truncated to *limit* characters with an ellipsis suffix."""
text = text.strip()
if len(text) <= limit:
return text
return text[: limit - 1].rstrip() + ""
def _log_task_exception(task: asyncio.Task[Any]) -> None:
"""Done-callback that surfaces unhandled exceptions from fire-and-forget tasks.
Default `asyncio` behavior is to log "Task exception was never retrieved"
only when the task is GC'd — easy to miss. This callback runs at task
completion and routes failures through `logger.warning` with `exc_info`,
matching the codebase pattern at `_finalize_git_branch_refresh`. Use
when scheduling a coroutine via `asyncio.create_task` whose result is
not awaited (e.g. event-handler cleanup, single-fire mounts).
"""
try:
task.result()
except asyncio.CancelledError:
pass
except Exception:
logger.warning("Background task failed unexpectedly", exc_info=True)
def _build_model_switch_error_body(exc: BaseException) -> str | Content:
"""Format a model-switch failure for `ErrorMessage`.
Args:
exc: Exception raised by `create_model`.
Returns:
A `Content` with the docs URL as a clickable span when `exc` is
`UnknownProviderError`; a plain string otherwise.
"""
from deepagents_code.model_config import UnknownProviderError
if isinstance(exc, UnknownProviderError):
return Content.assemble(
"Failed to switch model: unable to infer a provider for ",
(exc.model_spec, TStyle(bold=True)),
".\n\nSpecify one explicitly (e.g. ",
(f"anthropic:{exc.model_spec}", TStyle(italic=True)),
") or see the provider reference: ",
(exc.docs_url, TStyle(underline=True, link=exc.docs_url)),
)
return f"Failed to switch model: {exc}"
def _build_whats_new_message(heading: str) -> Content:
"""Build the post-upgrade banner with a clickable changelog URL.
Args:
heading: First line of the post-upgrade banner.
Returns:
Styled banner content with the changelog URL embedded as a link.
"""
return Content.assemble(
(heading, TStyle(dim=True, italic=True)),
"\n",
("See what's new: ", TStyle(dim=True, italic=True)),
(
CHANGELOG_URL,
TStyle(dim=True, italic=True, underline=True, link=CHANGELOG_URL),
),
)
def _format_startup_error(error: BaseException) -> str:
"""Format a server-startup exception for the welcome banner.
`wait_for_server_healthy` appends a tail of the subprocess log to its
`RuntimeError` message (see `_LOG_TAIL_CHARS` in `server.py`), which
would overwhelm the banner. Trim to the headline so the user sees an
actionable line instead of a scrolling traceback; `DEEPAGENTS_CODE_DEBUG=1`
preserves the full log on disk for triage.
Args:
error: The exception raised during server startup.
Returns:
A single-line `Type: message` summary suitable for the banner.
"""
first_line = str(error).splitlines()[0].strip() if str(error) else ""
if not first_line:
first_line = error.__class__.__name__
return f"{type(error).__name__}: {_truncate(first_line, limit=300)}"
class TextualSessionState:
"""Session state for the Textual app."""
def __init__(
self,
*,
auto_approve: bool = False,
thread_id: str | None = None,
) -> None:
"""Initialize session state.
Args:
auto_approve: Whether to auto-approve tool calls
thread_id: Optional thread ID (generates UUID7 if not provided)
"""
self.auto_approve = auto_approve
self.thread_id = thread_id or _new_thread_id()
def reset_thread(self) -> str:
"""Reset to a new thread.
Returns:
The new thread_id.
"""
self.thread_id = _new_thread_id()
return self.thread_id
_COMMAND_URLS: dict[str, str] = {
"/changelog": CHANGELOG_URL,
"/docs": DOCS_URL,
"/feedback": "https://github.com/langchain-ai/deepagents/issues/new/choose",
}
"""Slash-command to URL mapping for commands that just open a browser."""
_SANDBOX_DISPLAY_NAMES: dict[str, str] = {
"agentcore": "AgentCore",
"daytona": "Daytona",
"langsmith": "LangSmith",
"modal": "Modal",
"runloop": "Runloop",
}
"""Human-readable display names for sandbox providers."""
_toast_internals_warned: list[bool] = [False]
"""Single-slot flag; once `_Toast._notification` is missing, log warning once.
Tests reset this directly (`_toast_internals_warned[0] = False`) when
they need to exercise the one-shot semantics deterministically.
"""
def _toast_identity(
widget: _Toast,
*,
app: App | None = None,
) -> str | None:
"""Return the identity of the notification backing *widget*, or `None`.
`_Toast._notification` is a Textual internal. If a future upgrade
renames it, toast-click routing silently becomes inert. Logs a
single warning, and — when *app* is supplied — also posts a
one-shot user-visible toast pointing users at the `ctrl+n`
fallback so the regression isn't invisible outside the debug log.
"""
notif = getattr(widget, "_notification", None)
if notif is None:
if not _toast_internals_warned[0]:
logger.warning(
"Textual Toast no longer exposes `_notification`; "
"toast-click routing is disabled.",
)
if app is not None:
app.notify(
"Toast click routing disabled after a Textual upgrade. "
"Press ctrl+n to view notifications.",
severity="warning",
timeout=10,
markup=False,
)
_toast_internals_warned[0] = True
return None
return getattr(notif, "identity", None)
class _StaticHeader(Header):
"""`Header` variant that doesn't toggle tall mode on click.
Textual's default `Header._on_click` toggles a `-tall` class to expand the
header from 1 to 3 lines. Subclassing alone isn't enough: Textual's message
dispatch walks the full MRO and invokes every matching handler, so the
parent's `_on_click` still fires unless we call `event.prevent_default()`,
which sets `_no_default_action` and breaks the MRO walk
(see `MessagePump._get_dispatch_methods`).
"""
@on(Click)
def _suppress_header_click(self, event: Click) -> None: # noqa: PLR6301
event.prevent_default()
event.stop()
class DeepAgentsApp(App):
"""Main Textual application for deepagents-code."""
TITLE = "Deep Agents"
"""Textual application title."""
CSS_PATH = "app.tcss"
"""Path to the Textual CSS stylesheet for the app layout."""
ENABLE_COMMAND_PALETTE = False
"""Disable Textual's built-in command palette in favor of the custom slash
command system."""
SCROLL_SENSITIVITY_Y = 1.0
"""Vertical scroll speed (reduced from Textual default for finer control)."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "interrupt", "Interrupt", show=False, priority=True),
Binding(
"ctrl+c",
"quit_or_interrupt",
"Quit/Interrupt",
show=False,
priority=True,
),
Binding("ctrl+d", "quit_app", "Quit", show=False, priority=True),
Binding("ctrl+t", "toggle_auto_approve", "Toggle Auto-Approve", show=False),
Binding(
"shift+tab",
"toggle_auto_approve",
"Toggle Auto-Approve",
show=False,
priority=True,
),
Binding(
"ctrl+o",
"toggle_tool_output",
"Toggle Tool Output",
show=False,
priority=True,
),
Binding(
"ctrl+x",
"open_editor",
"Open Editor",
show=False,
priority=True,
),
Binding(
"ctrl+n",
"open_notifications",
"Notifications",
show=False,
priority=True,
),
# Approval menu keys (handled at App level for reliability)
Binding("up", "approval_up", "Up", show=False),
Binding("k", "approval_up", "Up", show=False),
Binding("down", "approval_down", "Down", show=False),
Binding("j", "approval_down", "Down", show=False),
Binding("enter", "approval_select", "Select", show=False),
Binding("y", "approval_yes", "Yes", show=False),
Binding("1", "approval_yes", "Yes", show=False),
Binding("2", "approval_auto", "Auto", show=False),
Binding("a", "approval_auto", "Auto", show=False),
Binding("3", "approval_no", "No", show=False),
Binding("n", "approval_no", "No", show=False),
]
"""App-level keybindings for interrupt, quit, toggles, and approval menu
navigation."""
class ServerReady(Message):
"""Posted by the background server-startup worker on success."""
def __init__( # noqa: D107
self,
agent: Any, # noqa: ANN401
server_proc: Any, # noqa: ANN401
mcp_server_info: list[Any] | None,
) -> None:
super().__init__()
self.agent = agent
self.server_proc = server_proc
self.mcp_server_info = mcp_server_info
class ServerStartFailed(Message):
"""Posted by the background server-startup worker on failure."""
def __init__(self, error: Exception) -> None: # noqa: D107
super().__init__()
self.error = error
def __init__(
self,
*,
agent: Pregel | None = None,
assistant_id: str | None = None,
backend: CompositeBackend | None = None,
auto_approve: bool = False,
cwd: str | Path | None = None,
thread_id: str | None = None,
resume_thread: str | None = None,
initial_prompt: str | None = None,
initial_skill: str | None = None,
startup_cmd: str | None = None,
launch_init: bool = False,
mcp_server_info: list[MCPServerInfo] | None = None,
profile_override: dict[str, Any] | None = None,
server_proc: ServerProcess | None = None,
server_kwargs: dict[str, Any] | None = None,
mcp_preload_kwargs: dict[str, Any] | None = None,
model_kwargs: dict[str, Any] | None = None,
defer_server_start: bool = False,
title: str | None = None,
sub_title: str | None = None,
**kwargs: Any,
) -> None:
"""Initialize the Deep Agents application.
Args:
agent: Pre-configured LangGraph agent, or `None` when server
startup is deferred via `server_kwargs`.
assistant_id: Agent identifier for memory storage
backend: Backend for file operations
auto_approve: Whether to start with auto-approve enabled
cwd: Current working directory to display
thread_id: Thread ID for the session.
`None` when `resume_thread` is provided (resolved asynchronously).
resume_thread: Raw resume intent from `-r` flag.
`'__MOST_RECENT__'` for bare `-r`, a thread ID string for
`-r <id>`, or `None` for new sessions.
Resolved via `_resolve_resume_thread`
during `_start_server_background`.
Requires `server_kwargs` to be set; ignored otherwise.
initial_prompt: Optional prompt to auto-submit when session starts
initial_skill: Optional skill name to invoke when session starts.
startup_cmd: Optional shell command to run at startup before the
first prompt is accepted.
Output is rendered in the transcript and non-zero exits warn but
do not abort the session.
launch_init: Whether to run the onboarding setup flow
before accepting the first prompt.
mcp_server_info: MCP server metadata for the `/mcp` viewer.
profile_override: Extra profile fields from `--profile-override`,
retained so later profile-aware behavior stays consistent with
the app override, including model selection details,
offload budget display, and on-demand `create_model()`
calls such as `/offload`.
server_proc: LangGraph server process for the interactive session.
server_kwargs: When provided, server startup is deferred.
The app shows a "Connecting..." state and starts the server in
the background using these kwargs
for `start_server_and_get_agent`.
mcp_preload_kwargs: Kwargs for `_preload_session_mcp_server_info`,
run concurrently with server startup when `server_kwargs` is set.
model_kwargs: Kwargs for deferred `create_model()`.
When provided, model creation runs in a background worker after
first paint instead of blocking startup.
defer_server_start: Whether to keep app-owned server startup paused
until the user configures credentials or explicitly picks a model.
title: Override the Textual `App.title` shown in the optional
header bar.
When `None`, the class-level `TITLE` is used.
Reassigning `app.title` at runtime updates the header live.
sub_title: Override the Textual `App.sub_title` shown in the
optional header bar.
When `None`, the parent default is used.
Reassigning `app.sub_title` at runtime updates the header live.
**kwargs: Additional arguments passed to parent
"""
super().__init__(**kwargs)
if title is not None:
self.title = title
if sub_title is not None:
self.sub_title = sub_title
self._register_custom_themes()
self.theme = _load_theme_preference()
"""Active Textual theme name.
Loaded from the user's saved preference (or the default) so the app
boots with consistent colors before `/theme` runs.
"""
self.sync_terminal_background()
# Injected session config
self._agent = agent
"""Pre-configured agent (local `Pregel` or `RemoteAgent`).
`None` when server startup is deferred via `server_kwargs`; filled in
by the server-ready handler (and by the agent-swap worker when
`/agents` restarts the subprocess).
"""
self._assistant_id = assistant_id
"""Current session agent identity.
Scopes per-agent memory (`~/.deepagents/<id>/`) and skill discovery,
keys `FileOpTracker` file-op history, and is attached to LangSmith
traces as `assistant_id` / `agent_name`. Mutated by `/agents` swaps
and by `-r` resume when the resumed thread belongs to a different
agent.
"""
self._default_assistant_id = assistant_id
"""User-intended default agent — persisted as `[agents].recent`.
Tracks only explicit user choice (`-a`, picker, recent fallback);
never mutated by `-r` resume. Mirrors `recent_model`'s invariant —
a one-off thread resume must not redefine the default.
"""
self._backend = backend
"""Filesystem/storage backend for agent file operations."""
self._auto_approve = auto_approve
"""Current auto-approve state for tool calls.
Initialized from `--auto-approve` and toggled at runtime via
Ctrl+T / Shift+Tab or the approval menu's 'Auto' option; kept in
sync with `_session_state.auto_approve`.
"""
self._cwd = str(cwd) if cwd else str(Path.cwd())
"""Session cwd.
Shown in the status bar; used as the root for `@` file-mention
completion in the chat input.
"""
self._lc_thread_id = thread_id
"""LangChain thread identifier.
Named `_lc_thread_id` to avoid collision with Textual's `App._thread_id`.
"""
self._resume_thread_intent = resume_thread
"""Raw `-r` intent (`None`, `'__MOST_RECENT__'`, or a thread id).
Resolved into a concrete `_lc_thread_id` by `_resolve_resume_thread`
during background startup.
"""
self._resume_thread_resolved_event = asyncio.Event()
"""Set once `-r` resume resolution has completed or is unnecessary."""
if resume_thread is None:
self._resume_thread_resolved_event.set()
self._initial_prompt = initial_prompt
"""Prompt to auto-submit after first paint (from `-m`)."""
self._initial_skill = (
initial_skill.strip().lower()
if initial_skill and initial_skill.strip()
else None
)
"""Skill name to auto-invoke after first paint (from `--skill`).
Normalized to lowercase; `None` when not provided.
"""
self._startup_cmd = (
startup_cmd.strip() if startup_cmd and startup_cmd.strip() else None
)
"""Shell command to run once before the first prompt, from
`--startup-cmd`.
Cleared to `None` after it runs so later server swaps cannot re-run it.
"""
self._launch_init_requested = launch_init
"""Whether startup should show onboarding during the initial paint."""
self._launch_init_running = False
"""Re-entry guard for launch init modals."""
self._launch_init_task: asyncio.Task[None] | None = None
"""Active onboarding task while the multi-screen flow is in progress."""
self._session_start_waiting_for_launch_init = False
"""Whether session startup is scheduled to resume after onboarding."""
self._launch_user_name: str | None = None
"""Name captured from the onboarding flow for the current session."""
self._mcp_server_info = mcp_server_info
"""MCP server metadata surfaced in the `/mcp` viewer."""
self._mcp_optimistic_original_server_info: dict[str, MCPServerInfo] = {}
"""Pre-disable server metadata for optimistic viewer toggles."""
self._pending_mcp_login_reconnect = False
"""Whether a successful MCP login is waiting for reconnect."""
self._pending_mcp_disable_reconnect_servers: set[str] = set()
"""MCP servers with disable-state changes waiting for reconnect."""
self._mcp_tool_count = sum(len(s.tools) for s in (mcp_server_info or []))
"""Total tool count across MCP servers, displayed in the status bar."""
self._mcp_unauthenticated = sum(
1 for s in (mcp_server_info or []) if s.status == "unauthenticated"
)
"""MCP servers awaiting a `dcode mcp login` run."""
self._mcp_errored = sum(
1 for s in (mcp_server_info or []) if s.status == "error"
)
"""MCP servers that failed to load (config or network error)."""
self._active_mcp_viewer: Any = None
"""Handle to the `/mcp` modal so server-ready events can refresh it."""
self._pending_mcp_reconnect: bool = False
"""Set after a successful MCP login when the user defers the server
restart. Cleared by the next reconnect or restart so multiple deferred
logins coalesce into a single user-driven restart."""
self._profile_override = profile_override
"""Extra profile fields from `--profile-override`, retained so later
profile-aware behavior (model selection, offload budget display,
on-demand `create_model()`) stays consistent with the app override."""
self._server_proc = server_proc
"""Handle to the langgraph dev subprocess, when the app owns one.
`None` in remote-server mode (the app connects to an external server
and cannot restart it).
"""
self._server_kwargs = server_kwargs
"""Cached kwargs for `start_server_and_get_agent`.
When non-`None`, startup is deferred and the UI begins in
"Connecting..." state unless `_server_startup_deferred` is set.
Re-used so downstream features that restart the server (e.g. `/agents`)
start from the same config.
"""
self._server_startup_deferred = defer_server_start
"""True when no model can be selected yet, usually first launch with
no credentials. The TUI is usable, but server startup waits for
`/auth`, `/reload`, or `/model`.
"""
self._server_startup_deferred_notice_shown = False
"""Whether the first-launch no-model guidance has been mounted."""
self._mcp_preload_kwargs = mcp_preload_kwargs
"""Kwargs for `_preload_session_mcp_server_info`, run concurrently
with server startup when `server_kwargs` is set."""
self._model_kwargs = model_kwargs
"""Kwargs for deferred `create_model()`.
When non-`None`, model creation runs in a background worker after
first paint; consumed by the startup worker and reset to `None`.
"""
raw = (server_kwargs or {}).get("sandbox_type")
"""Raw argparse sandbox value from `server_kwargs` before normalization.
`ServerConfig.__post_init__` maps `"none"` to `None`, but
`server_kwargs` still carries the argparse string, so `_sandbox_type`
below guards against both representations.
"""
self._sandbox_type: str | None = raw if raw and raw != "none" else None
"""Normalized sandbox type (or `None`), attached to trace metadata."""
if sub_title is None and self._sandbox_type is not None:
display = _SANDBOX_DISPLAY_NAMES.get(
self._sandbox_type,
self._sandbox_type.title(),
)
self.sub_title = f"Sandbox: {display}"
# Per-turn model overrides
self._model_override: str | None = None
"""Per-turn model override set via `/model`; `None` uses session default."""
self._model_params_override: dict[str, Any] | None = None
"""Per-turn model params override set via `/model --model-params`."""
# Widget refs (populated in compose/on_mount)
self._status_bar: StatusBar | None = None
"""Status bar widget; populated in `on_mount`."""
self._chat_input: ChatInput | None = None
"""Chat input widget; populated in `on_mount`."""
self._loading_widget: LoadingWidget | None = None
"""Active spinner widget; populated by `_set_spinner(status)` and
cleared when status resolves to `None`."""
self._ui_adapter: TextualUIAdapter | None = None
"""Bridge that renders agent events into widgets; set in `on_mount`."""
self._approval_placeholder: Static | None = None
"""'Waiting for typing to finish...' placeholder mounted in place of
the approval menu while the user is mid-type, so stray keys (`y`,
`n`, `1`-`3`) can't trigger approval decisions. Swapped for the real
`ApprovalMenu` by `_deferred_show_approval` once typing settles."""
self._pending_approval_widget: ApprovalMenu | None = None
"""Currently-mounted HITL approval widget awaiting a decision."""
self._pending_ask_user_widget: AskUserMenu | None = None
"""Currently-mounted `ask_user` prompt awaiting an answer."""
# Agent & shell run state
self._agent_worker: Worker[None] | None = None
"""Active `_run_agent_task` worker, tracked so it can be cancelled
on interrupt (`Ctrl+C`) or exit."""
self._agent_running = False
"""True while the agent worker is streaming a response."""
self._shell_process: asyncio.subprocess.Process | None = None
"""Shell command process tracking for interruption (! commands)."""
self._shell_worker: Worker[None] | None = None
"""Active `!` shell-command worker, tracked for interruption."""
self._shell_running = False
"""True while a `!` shell command is executing."""
self._prewarm_worker: Worker[None] | None = None
"""Background worker that prewarms `deepagents`/LangChain imports.
Awaited via `_await_prewarm_imports` before any caller on the event
loop re-enters the same module graph (see that method for why).
"""
self._deepagents_import_lock = asyncio.Lock()
"""Serializes cold imports into the Deep Agents graph after prewarm.
Startup model creation and skill discovery can both be triggered during
first paint. If prewarm failed or did not cover a transitive module,
both paths may cold-import overlapping modules at the same time.
"""
# Lifecycle flags & re-entry guards
self._connecting = (
server_kwargs is not None and not self._server_startup_deferred
)
"""True while the backing server is being started or restarted.
Gates message handling so user input is queued until the agent is
actually reachable.
"""
self._connection_ready_event = asyncio.Event()
"""Set once the initial server connection has either succeeded or failed."""
if not self._connecting:
self._connection_ready_event.set()
self._server_startup_error: str | None = None
"""Set when the background server fails to start; persists for the
session lifetime (server failure is terminal).
Shown in place of the generic 'Agent not configured' message.
"""
self._server_startup_missing_credentials_provider: str | None = None
"""Set to the offending provider name when startup failed with
`MissingCredentialsError`; `None` otherwise. Gates the `/model`
recovery hint without string-matching on the formatted error.
"""
self._server_startup_missing_provider_package: (
MissingProviderPackageError | None
) = None
"""The exception itself when startup failed with
`MissingProviderPackageError`; `None` otherwise. Stashing the exception
rather than a tuple gives the hint builder named access to `.provider`
and `.package`, and gates the `/install` / `/model` recovery hint
without string-matching on the formatted error.
"""
self._retry_status_widget: AppMessage | None = None
"""Transient "Retrying startup with X…" breadcrumb. Mounted via
`_mount_before_queued` (not `_mount_message`) because it is ephemeral
state and must not appear in scrollback or serialized history.
"""
self._startup_failure_widget: ErrorMessage | None = None
"""Transient chat surface for the most recent server-startup failure.
Mounted by `on_deep_agents_app_server_start_failed`; removed on
`ServerReady` so a successful `/model` retry doesn't leave the stale
error dangling in scrollback.
"""
self._quit_pending = False
"""True after a first `Ctrl+C` so a second press within the window quits."""
self._thread_switching = False
"""Re-entry guard for `/threads` switches; blocks message handling
until the new thread's history finishes loading."""
self._model_switching = False
"""Re-entry guard for `/model` switches while the new model is being
resolved."""
self._agent_switching = False
"""Re-entry guard for `/agents` switches while the backing server is
being restarted with a new `assistant_id`."""
self._processing_pending = False
"""Re-entry guard for `_process_next_from_queue` so only one drain
loop runs at a time."""
self._startup_sequence_running = False
"""True while post-connect startup work is still being sequenced.
Covers resumed-history hydration, `--startup-cmd`, and the handoff to
the first queued or initial submission so user input stays serialized
until the session reaches its first stable busy/idle state.
"""
# Message queue & store
self._pending_messages: deque[QueuedMessage] = deque()
"""User message queue for sequential processing."""
self._queued_widgets: deque[QueuedUserMessage] = deque()
"""Placeholder widgets mounted for messages still sitting in
`_pending_messages`, removed as the queue drains."""
self._message_store = MessageStore()
"""Message virtualization store."""
self._deferred_actions: list[DeferredAction] = []
"""Deferred actions executed after the current busy state resolves."""
# Session stats & tokens
self._session_stats: SessionStats = SessionStats()
"""Cumulative usage stats across all turns in this session."""
self._inflight_turn_stats: SessionStats | None = None
"""Stats for the currently executing turn.
Held here so `exit()` can merge them synchronously before the event loop
tears down (e.g. `Ctrl+D` during a pending tool call).
"""
self._inflight_turn_start: float = 0.0
"""Monotonic timestamp when the current turn started."""
self._context_tokens: int = 0
"""Local cache of the last total-context token count.
Source of truth is `_context_tokens` in graph state; this is a sync
copy for the status bar.
"""
self._tokens_approximate: bool = False
"""Whether the cached token count is stale (interrupted generation)."""
# Session lazy state & startup
self._session_state: TextualSessionState | None = None
"""Auto-approve + thread state shared with `execute_task_textual`.
Lazily constructed by the session-init worker so we don't block
startup on it.
"""
self._startup_task: asyncio.Task[None] | None = None
"""Startup task reference (set in on_mount)."""
self._external_event_source: EventSource | None = None
"""External event source created when its env var is enabled.
Cleared back to `None` if the listener fails to start so callers can
distinguish a configured-and-running listener from a no-op.
"""
self._external_event_source_task: asyncio.Task[None] | None = None
"""Lifecycle task for `_external_event_source`; cleared together."""
self._git_branch_refresh_task: asyncio.Task[None] | None = None
"""Latest background git-branch refresh task, if one is running."""
self._last_typed_at: float | None = None
"""Typing-aware approval deferral state."""
self._update_available: tuple[bool, str | None] = (False, None)
"""Update availability state.
Set by `_check_for_updates` when PyPI reports a newer version;
read at shutdown (for the exit banner), by `_handle_version_command`
(for the `/version` update hint), and by downstream callers. Does
*not* drive missing-dep toast suppression — that's gated on
`_update_modal_pending`.
"""
self._update_check_done = asyncio.Event()
"""Set by `_check_for_updates` when it returns (success, failure, or
no-op). Lets `_check_optional_tools_background` defer posting
missing-dep toasts until we know whether the update modal is about
to clear them."""
self._update_modal_pending = asyncio.Event()
"""Set only immediately before the update modal is scheduled.
Used by `_check_optional_tools_background` to decide whether to
suppress missing-dep toasts: we only suppress when a modal is
actually about to open, not merely when an update was detected.
A detected-but-throttled update (already notified within
`CACHE_TTL`) leaves this clear so missing-dep toasts still fire.
"""
self._update_install_running = False
"""True while a self-update command is running."""
# Skills cache
self._discovered_skills: list[ExtendedSkillMetadata] = []
"""Cached skill metadata (populated by startup discovery worker,
refreshed on `/reload`).
Used by `_invoke_skill` to skip re-walking all skill directories on
every invocation.
"""
self._skill_allowed_roots: list[Path] = []
"""Pre-resolved skill root directories for containment checks in
`load_skill_content`.
Built alongside `_discovered_skills`.
"""
# Media
# Lazily imported here to avoid pulling image dependencies into
# argument parsing paths.
from deepagents_code.input import MediaTracker
self._image_tracker = MediaTracker()
"""Tracks image/media pastes in the chat input so they can be
attached to outgoing messages and cleared after submission."""
self._notice_registry = NotificationRegistry()
"""Pending actionable notifications.
Startup workers register notices (missing deps, update available)
here; the user opens them via toast click or `ctrl+n`.
"""
def _remote_agent(self) -> RemoteAgent | None:
"""Return the agent narrowed to `RemoteAgent`, or `None`.
Returns `None` when:
- No agent is configured (`self._agent is None`).
- The agent is a local `Pregel` graph (e.g. ACP mode, test harnesses).
Used to gate features that require a server-backed agent (e.g. model
switching via `ConfigurableModelMiddleware`, thread registration).
Checks the agent type rather than server ownership so this works for
both app-spawned servers and externally managed ones.
Returns:
The `RemoteAgent` instance, or `None` for local agents.
"""
from deepagents_code.remote_client import RemoteAgent
return self._agent if isinstance(self._agent, RemoteAgent) else None
def get_theme_variable_defaults(self) -> dict[str, str]:
"""Return custom CSS variable defaults for the current theme.
Most styling uses Textual's built-in variables (`$primary`,
`$text-muted`, `$error-muted`, etc.). This override injects the
app-specific variables (`$mode-bash`, `$mode-command`,
`$mode-incognito`, `$skill`, `$skill-hover`, `$tool`, `$tool-hover`)
that have no Textual equivalent.
Returns:
Dict of CSS variable names to hex color values.
"""
colors = theme.get_theme_colors(self)
return theme.get_css_variable_defaults(colors=colors)
def _fatal_error(self) -> None:
"""Render an unhandled-exception traceback without leaking secrets.
Textual's default `_fatal_error` renders with `show_locals=True`,
which prints local variables — including resolved API keys carried
in `kwargs` dicts on the call path through `create_model`. Locals
are only re-enabled when `DEEPAGENTS_CODE_DEBUG` matches a truthy
token (`"1"`, `"true"`, `"yes"`); any other value, including `"0"`
and `"false"`, leaves them disabled.
"""
try:
import rich
from rich.segment import Segments
from rich.traceback import Traceback
from deepagents_code._env_vars import DEBUG
except Exception: # noqa: BLE001 # mid-teardown import errors fall through to Textual's default rather than double-fault and swallow the original crash
super()._fatal_error()
return
self.bell()
show_locals = os.environ.get(DEBUG, "").lower() in {"1", "true", "yes"}
traceback = Traceback(
show_locals=show_locals,
width=None,
locals_max_length=5,
suppress=[rich],
)
self._exit_renderables.append(
Segments(self.console.render(traceback, self.console.options)),
)
self._close_messages_no_wait()
def compose(self) -> ComposeResult:
"""Compose the application layout.
Yields:
UI components for the main chat area and status bar.
"""
from deepagents_code._env_vars import SHOW_HEADER, is_env_truthy
if is_env_truthy(SHOW_HEADER):
yield _StaticHeader(id="app-header")
# Main chat area with scrollable messages
# VerticalScroll tracks user scroll intent for better auto-scroll behavior
with VerticalScroll(id="chat"):
yield WelcomeBanner(
thread_id=self._lc_thread_id,
mcp_tool_count=self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
connecting=self._connecting,
resuming=self._resume_thread_intent is not None,
local_server=self._server_kwargs is not None,
defer_connecting_display=self._connecting
and self._resume_thread_intent is None,
id="welcome-banner",
)
yield Container(id="messages")
with Container(id="bottom-app-container"):
yield ChatInput(
cwd=self._cwd,
image_tracker=self._image_tracker,
id="input-area",
)
# Status bar at bottom
yield StatusBar(cwd=self._cwd, id="status-bar")
async def on_mount(self) -> None:
"""Initialize components after mount.
Only widget queries and lightweight config go here. Anything that
would delay the first rendered frame (subprocess calls, heavy
imports) is deferred to `_post_paint_init` via `call_after_refresh`.
The optional onboarding setup starts here so its first modal participates
in the initial TUI render instead of appearing after the first frame.
"""
# Move all objects allocated during import/compose into the permanent
# generation so the cyclic GC skips them during first-paint rendering.
import gc
gc.freeze()
chat = self.query_one("#chat", VerticalScroll)
chat.anchor()
if is_ascii_mode():
chat.styles.scrollbar_size_vertical = 0
self._status_bar = self.query_one("#status-bar", StatusBar)
self._chat_input = self.query_one("#input-area", ChatInput)
# Apply any skill commands discovered before the widget was mounted
if self._discovered_skills:
from deepagents_code.command_registry import (
SLASH_COMMANDS,
build_skill_commands,
)
cmds = build_skill_commands(self._discovered_skills)
merged = list(SLASH_COMMANDS) + cmds
self._chat_input.update_slash_commands(merged)
# Set initial auto-approve state
if self._auto_approve:
self._status_bar.set_auto_approve(enabled=True)
# Focus the input immediately so the cursor is visible on first paint
self._chat_input.focus_input()
if self._launch_init_requested:
from deepagents_code.widgets.launch_init import LaunchNameScreen
name_result = self._push_screen_result_future(LaunchNameScreen())
self._ensure_launch_init_task(name_result=name_result)
# Pre-import `html.entities` on the main thread before the worker
# starts. Python 3.14 replaced the global import lock with per-module
# locks; a worker importing `markdown_it` (which transitively pulls
# `html.entities`) can race main-thread code looking up `html` *while
# `html` itself is still being initialized*, raising `KeyError: 'html'`
# from `_find_and_load_unlocked`.
import html.entities # noqa: F401
# Prewarm heavy imports in a thread while the first frame renders.
# The user can't type yet, so GIL contention is harmless. By the
# time _post_paint_init fires its inline imports are dict lookups.
# Handle is captured so `_await_prewarm_imports` can block on it.
self._prewarm_worker = self.run_worker(
asyncio.to_thread(self._prewarm_deferred_imports),
exclusive=True,
group="startup-import-prewarm",
)
# Start branch resolution immediately — the thread launches now
# (during on_mount) so by the time the first frame finishes painting
# the filesystem probe is already done. _post_paint_init fires the
# heavier workers (server, model creation) afterward.
self._startup_task = asyncio.create_task(
self._resolve_git_branch_and_continue(),
)
self._maybe_start_external_event_source()
def _maybe_start_external_event_source(self) -> None:
"""Start the external event listener when explicitly enabled."""
from deepagents_code._env_vars import (
EXTERNAL_EVENT_SOCKET,
EXTERNAL_EVENT_SOCKET_PATH,
is_env_truthy,
)
if not is_env_truthy(EXTERNAL_EVENT_SOCKET):
return
from deepagents_code.event_bus import UnixSocketEventSource
raw_path = os.environ.get(EXTERNAL_EVENT_SOCKET_PATH)
path = Path(raw_path).expanduser() if raw_path else None
source = UnixSocketEventSource(path)
self._external_event_source = source
self._external_event_source_task = asyncio.create_task(
self._run_external_event_source(source),
)
async def _run_external_event_source(self, source: EventSource) -> None:
"""Drive `source` from start to shutdown, surfacing failures to the user.
Args:
source: External event source whose lifecycle this task owns.
Raises:
asyncio.CancelledError: Re-raised when the task is cancelled
during app shutdown so the cleanup path runs to completion.
"""
async def sink(event: ExternalEvent) -> None: # noqa: RUF029 # protocol requires async callable; post_message is sync
self.post_message(ExternalInput(event))
try:
await source.start(sink)
await source.serve_forever()
except asyncio.CancelledError:
raise
except (OSError, RuntimeError, ValueError) as exc:
logger.exception("External event source failed to start")
self._external_event_source = None
with suppress(Exception):
self.notify(
f"External event listener failed: {exc}",
severity="error",
timeout=8,
markup=False,
)
finally:
try:
await source.stop()
except Exception:
logger.exception("Error while stopping external event source")
async def _refresh_git_branch(self) -> None:
"""Resolve the current git branch and update the status bar.
Reads repository metadata from `self._cwd` inline so the common path is
just local file I/O. Falls back to a thread-offloaded `git rev-parse`
only for unusual repository layouts. Swallows all errors — the status
bar simply stays empty (or keeps its prior value on unexpected failure)
if git is unavailable.
"""
try:
cwd = self._cwd
branch = read_git_branch_from_filesystem(cwd)
if branch is None:
branch = await asyncio.to_thread(read_git_branch_via_subprocess, cwd)
if self._status_bar:
self._status_bar.branch = branch
except Exception:
logger.warning("Git branch resolution failed", exc_info=True)
async def _refresh_git_branch_subprocess_fallback(self, cwd: str) -> None:
"""Run the `git rev-parse` fallback off-thread for unusual repo layouts."""
try:
branch = await asyncio.to_thread(read_git_branch_via_subprocess, cwd)
except Exception:
logger.warning("Git branch subprocess fallback failed", exc_info=True)
return
if self._status_bar:
self._status_bar.branch = branch
def _cancel_git_branch_refresh_task(self) -> None:
"""Cancel and clear any in-flight background branch refresh task."""
prior_task = self._git_branch_refresh_task
if prior_task is not None and not prior_task.done():
prior_task.cancel()
self._git_branch_refresh_task = None
def _schedule_git_branch_refresh(self) -> None:
"""Refresh the git branch, inline when possible.
The filesystem probe is sub-millisecond for the common repo layout, so
we run it synchronously and only spawn a background task for the
`git rev-parse` fallback. Keeping the hot path inline avoids an
event-loop tick plus a reactive watcher hop between a tool exiting and
the footer updating.
"""
if self._exit:
return
cwd = self._cwd
try:
branch = read_git_branch_from_filesystem(cwd)
except Exception:
logger.warning("Git branch filesystem probe failed", exc_info=True)
return
if branch is not None:
if self._status_bar:
self._status_bar.branch = branch
self._cancel_git_branch_refresh_task()
return
# Unusual repo layout — hop to a thread for `git rev-parse`.
self._cancel_git_branch_refresh_task()
refresh_task = asyncio.create_task(
self._refresh_git_branch_subprocess_fallback(cwd),
)
self._git_branch_refresh_task = refresh_task
def _finalize_git_branch_refresh(task: asyncio.Task[None]) -> None:
if self._git_branch_refresh_task is task:
self._git_branch_refresh_task = None
try:
task.result()
except asyncio.CancelledError:
pass
except Exception:
logger.warning(
"Background git branch refresh failed unexpectedly",
exc_info=True,
)
refresh_task.add_done_callback(_finalize_git_branch_refresh)
async def _resolve_git_branch_and_continue(self) -> None:
"""Resolve git branch, then schedule remaining init workers.
Launched via `asyncio.create_task()` during `on_mount` so branch
detection runs concurrently with first-paint rendering.
`_post_paint_init` is scheduled via `call_after_refresh` regardless
of whether branch resolution succeeds.
"""
try:
await self._refresh_git_branch()
finally:
# Always schedule post-paint init — even if branch resolution
# fails, the app must still start the server, session, etc.
self.call_after_refresh(self._post_paint_init)
async def _post_paint_init(self) -> None:
"""Fire background workers for remaining startup work.
Everything here is non-blocking: workers and thread-offloaded calls
so the UI stays responsive.
"""
# Create UI adapter unconditionally — it only holds UI callbacks and
# doesn't depend on the agent. The agent is injected later at
# execute_task_textual() call time.
from deepagents_code.textual_adapter import TextualUIAdapter
self._ui_adapter = TextualUIAdapter(
mount_message=self._mount_message,
update_status=self._update_status,
request_approval=self._request_approval,
on_auto_approve_enabled=self._on_auto_approve_enabled,
set_spinner=self._set_spinner,
set_active_message=self._set_active_message,
sync_message_content=self._sync_message_content,
request_ask_user=self._request_ask_user,
on_tool_complete=self._schedule_git_branch_refresh,
)
# Wire token display callbacks
self._ui_adapter._on_tokens_update = self._on_tokens_update
self._ui_adapter._on_tokens_pending = self._show_pending_tokens
self._ui_adapter._on_tokens_show = self._show_tokens
if self._server_startup_deferred:
await self._mount_deferred_start_notice()
# Fire-and-forget workers — none of these block the event loop.
# Discover skills first so /skill: autocomplete is ready as early
# as possible. The heavy filesystem scan runs in a thread.
self.run_worker(
self._discover_skills(),
exclusive=True,
group="startup-skill-discovery",
)
self.run_worker(self._init_session_state, exclusive=True, group="session-init")
# Server startup (model creation + server process)
if self._server_kwargs is not None and not self._server_startup_deferred:
self.run_worker(
self._start_server_background,
exclusive=True,
group="server-startup",
)
# Background update check and what's-new banner
# (opt-out via env var or config.toml [update].check)
from deepagents_code.update_check import is_update_check_enabled
if is_update_check_enabled():
self.run_worker(
self._check_for_updates,
exclusive=True,
group="startup-update-check",
)
self.set_interval(
_UPDATE_RECHECK_INTERVAL_SECONDS,
lambda: self.run_worker(
self._check_for_updates(periodic=True),
exclusive=True,
group="periodic-update-check",
),
)
self.run_worker(
self._show_whats_new,
exclusive=True,
group="startup-whats-new",
)
# Prewarm model discovery and profile caches unconditionally so
# /model opens instantly even before the agent/server is ready.
self.run_worker(
self._prewarm_model_caches,
exclusive=True,
group="startup-model-prewarm",
)
# Prewarm thread message counts so /threads opens instantly.
self.run_worker(
self._prewarm_threads_cache,
exclusive=True,
group="startup-thread-prewarm",
)
# Optional tool warnings in a thread (shutil.which is sync I/O)
self.run_worker(
self._check_optional_tools_background,
exclusive=True,
group="startup-tool-check",
)
# Debug helpers: exercise the notification center and update-modal
# flows without waiting for real conditions. The two env vars are
# independent so missing-dep notices can be surfaced without auto-
# stealing focus into the update modal.
from deepagents_code._env_vars import DEBUG_NOTIFICATIONS, DEBUG_UPDATE
if os.environ.get(DEBUG_NOTIFICATIONS):
self.call_after_refresh(self._inject_debug_notifications)
if os.environ.get(DEBUG_UPDATE):
self.call_after_refresh(self._inject_debug_update)
# Session-start sequence (history -> `--startup-cmd` -> initial prompt/
# skill -> queue drain). When connecting, defer until
# `on_deep_agents_app_server_ready` fires; otherwise run it now so the
# non-connecting path (pre-built agent) also honors `--startup-cmd` and
# serializes startup against user input.
if not self._connecting and not self._server_startup_deferred:
self.call_after_refresh(
lambda: asyncio.create_task(self._run_session_start_sequence()),
)
async def _init_session_state(self) -> None:
"""Create session state in a thread (imports deepagents_code.sessions)."""
def _create() -> TextualSessionState:
return TextualSessionState(
auto_approve=self._auto_approve,
thread_id=self._lc_thread_id,
)
try:
self._session_state = await asyncio.to_thread(_create)
except Exception:
logger.exception("Failed to create session state")
self.notify(
"Session initialization failed. Some features may be unavailable.",
severity="error",
timeout=10,
)
async def _check_optional_tools_background(self) -> None:
"""Check for optional tools and register actionable notices.
Missing tools are added to the notifications registry. Toasts
are posted only if no update modal is actually about to open;
otherwise the modal's `clear_notifications` call would
immediately drop them and cause visible flicker. Entries remain
reachable via ctrl+n either way.
"""
try:
from deepagents_code.main import (
build_missing_tool_notification,
check_optional_tools,
)
from deepagents_code.update_check import is_update_check_enabled
except ImportError:
logger.warning(
"Could not import optional tools checker",
exc_info=True,
)
return
try:
missing = await asyncio.to_thread(check_optional_tools)
except OSError:
logger.debug("Failed to check for optional tools", exc_info=True)
return
except Exception:
# Defensive: surface regressions (e.g. future refactors of
# check_optional_tools raising an unexpected exception type)
# instead of silently returning.
logger.warning("Optional-tools check failed unexpectedly", exc_info=True)
self.notify(
"Could not check optional tools — see logs.",
severity="warning",
timeout=6,
markup=False,
)
return
if not missing:
return
# Wait for the update check so we know whether the update
# modal is about to clear any toasts we post. Bounded by a
# short timeout to avoid blocking indefinitely if PyPI hangs.
if is_update_check_enabled():
try:
await asyncio.wait_for(self._update_check_done.wait(), timeout=5.0)
except TimeoutError:
logger.debug("Update check timed out; posting tool toasts anyway")
# Suppress only when a modal is actually going to open — not
# just when an update was detected. A detected-but-throttled
# update (already notified within CACHE_TTL) does not open the
# modal, so toasts must still fire or returning users never
# see the warning.
suppress_toasts = self._update_modal_pending.is_set()
for tool in missing:
notification = build_missing_tool_notification(tool)
if suppress_toasts:
# Register silently; the update modal's dismissal
# leaves these reachable via ctrl+n (notification center).
self._notice_registry.add(notification)
else:
self._notify_actionable(
notification,
severity="warning",
timeout=15,
)
async def _discover_skills(self) -> bool:
"""Discover skills, cache metadata, and update autocomplete.
Caches the full `ExtendedSkillMetadata` list and pre-resolved
containment roots so that `/skill:<name>` invocations can skip
re-walking every skill directory.
Runs filesystem I/O in a thread to avoid blocking the event loop.
On failure, prior cache is preserved so a transient error (e.g.,
a single unreadable subdir) doesn't wipe a known-good skill list.
Callers that need to distinguish "no skills" from "discovery
failed" can check the return value.
Returns:
`True` on success, `False` if discovery raised. Callers that
don't care (fire-and-forget startup/agent-switch workers)
simply ignore the result.
"""
from deepagents_code.command_registry import (
SLASH_COMMANDS,
build_skill_commands,
)
try:
# Discovery and prewarm import overlapping parts of the Deep Agents
# graph in separate workers. Let prewarm finish first so CPython's
# per-module import locks cannot form a cycle.
await self._await_prewarm_imports()
async with self._deepagents_import_lock:
skills, roots = await asyncio.to_thread(self._discover_skills_and_roots)
except OSError:
logger.warning(
"Filesystem error during skill discovery",
exc_info=True,
)
self.notify(
"Could not scan skill directories. "
"Some /skill: commands may be unavailable.",
severity="warning",
timeout=6,
markup=False,
)
return False
except Exception as exc:
logger.exception("Unexpected error during skill discovery")
self.notify(
f"Skill discovery failed unexpectedly ({type(exc).__name__}). "
"/skill: commands may not work. "
"Set DEEPAGENTS_CODE_DEBUG=1 for details.",
severity="warning",
timeout=8,
markup=False,
)
return False
self._discovered_skills = skills
self._skill_allowed_roots = roots
if skills:
skill_commands = build_skill_commands(skills)
if self._chat_input:
merged = list(SLASH_COMMANDS) + skill_commands
self._chat_input.update_slash_commands(merged)
else:
logger.debug(
"Skill discovery completed (%d skills) but chat input "
"not yet mounted; autocomplete deferred",
len(skills),
)
return True
def _discover_skills_and_roots(
self,
) -> tuple[list[ExtendedSkillMetadata], list[Path]]:
"""Discover skills and build pre-resolved containment roots.
Shared by `_discover_skills` (startup/reload) and the cache-miss
fallback in `_invoke_skill` to avoid duplicating the
`list_skills` call and root-resolution logic.
Returns:
Tuple of `(skill metadata list, pre-resolved containment roots)`.
"""
from deepagents_code.skills.invocation import discover_skills_and_roots
assistant_id = self._assistant_id or DEFAULT_ASSISTANT_ID
return discover_skills_and_roots(assistant_id)
async def _resolve_resume_thread(self) -> None:
"""Resolve a `-r` resume intent into a concrete thread ID.
Consumes `self._resume_thread_intent` and resolves it into a concrete
thread ID. Mutates `self._lc_thread_id` and optionally
`self._assistant_id` / `self._server_kwargs`. Does NOT touch
`self._default_assistant_id` — a one-off resume should not redefine
the user's persisted default agent. Falls back to a fresh thread on
any DB error.
"""
from deepagents_code.sessions import (
find_similar_threads,
generate_thread_id,
get_most_recent,
get_thread_agent,
thread_exists,
)
try:
resume = self._resume_thread_intent
self._resume_thread_intent = None # consumed
if not resume:
return
default_agent = DEFAULT_ASSISTANT_ID
if resume == "__MOST_RECENT__":
agent_filter = (
self._assistant_id if self._assistant_id != default_agent else None
)
thread_id = await get_most_recent(agent_filter)
if thread_id:
agent_name = await get_thread_agent(thread_id)
if agent_name:
self._assistant_id = agent_name
if self._server_kwargs:
self._server_kwargs["assistant_id"] = agent_name
self._lc_thread_id = thread_id
else:
self._lc_thread_id = generate_thread_id()
if agent_filter:
msg = f"No previous threads for '{agent_filter}', starting new."
else:
msg = "No previous threads, starting new."
self.notify(msg, severity="warning", markup=False)
elif await thread_exists(resume):
self._lc_thread_id = resume
if self._assistant_id == default_agent:
agent_name = await get_thread_agent(resume)
if agent_name:
self._assistant_id = agent_name
if self._server_kwargs:
self._server_kwargs["assistant_id"] = agent_name
else:
# Thread not found — notify + fall back to new thread
self._lc_thread_id = generate_thread_id()
similar = await find_similar_threads(resume)
hint = f"Thread '{resume}' not found."
if similar:
hint += f" Did you mean: {', '.join(str(t) for t in similar)}?"
self.notify(hint, severity="warning", timeout=6, markup=False)
except Exception:
logger.exception("Failed to resolve resume thread %r", resume)
self._lc_thread_id = generate_thread_id()
self.notify(
"Could not look up thread history. Starting new session.",
severity="warning",
)
finally:
self._resume_thread_resolved_event.set()
# Update session state if ready (may still be initializing in a
# concurrent worker)
if self._session_state:
self._session_state.thread_id = self._lc_thread_id
async def _start_server_background(self) -> None:
"""Background worker: resolve resume-thread intent, start server + MCP preload.
Also runs deferred model creation if `model_kwargs` was provided,
so the langchain import + init doesn't block first paint.
"""
# Phase 1: Resolve resume thread (if any) before server startup
if self._resume_thread_intent:
await self._resolve_resume_thread()
# Run deferred model creation. settings.model_name / model_provider
# are already set eagerly for the status bar display; this call
# does the heavy langchain import + SDK init and may refine them
# (e.g., context_limit from the model profile).
# Persist the user-chosen default so a later bare `deepagents`
# relaunch brings the user back to it. See
# `_restart_server_for_agent_swap` for why one-off resumes don't
# mutate `_default_assistant_id` and why the persisted default
# is decoupled from the per-session `_assistant_id`.
# Runs BEFORE deferred model creation so a `ModelConfigError`
# (e.g., missing API key) doesn't prevent the recent-agent write
# — the user's intent to use this agent shouldn't depend on
# whether their credentials happened to be valid this launch.
if self._default_assistant_id:
from deepagents_code.model_config import save_recent_agent
saved = await asyncio.to_thread(
save_recent_agent,
self._default_assistant_id,
)
if not saved:
logger.warning(
"Could not persist recent agent %r to config at startup",
self._default_assistant_id,
)
# Mirror the visibility of the picker-swap path: if the
# write fails here, the user has no way to know unless
# we surface it. Toast severity matches the swap path.
self.notify(
"Could not save recent agent to config at startup; "
"next bare launch will not return to it.",
severity="warning",
timeout=6,
markup=False,
)
if self._model_kwargs is not None:
# Block on prewarm before re-entering the import graph; see
# `_await_prewarm_imports` for the deadlock rationale.
await self._await_prewarm_imports()
from deepagents_code.config import create_model
from deepagents_code.model_config import (
ModelConfigError,
save_recent_model,
touch_recent_model,
)
try:
async with self._deepagents_import_lock:
result = create_model(**self._model_kwargs)
except ModelConfigError as exc:
self.post_message(self.ServerStartFailed(error=exc))
return
result.apply_to_settings()
resolved_spec = f"{result.provider}:{result.model_name}"
save_recent_model(resolved_spec)
touch_recent_model(resolved_spec)
self._model_kwargs = None # consumed
from deepagents_code.server_manager import start_server_and_get_agent
coros: list[Any] = [start_server_and_get_agent(**self._server_kwargs)] # type: ignore[arg-type]
if self._mcp_preload_kwargs is not None:
from deepagents_code.main import _preload_session_mcp_server_info
coros.append(_preload_session_mcp_server_info(**self._mcp_preload_kwargs))
try:
results = await asyncio.gather(*coros, return_exceptions=True)
except Exception as exc: # noqa: BLE001 # defensive catch around gather
self.post_message(self.ServerStartFailed(error=exc))
return
server_result = results[0]
if isinstance(server_result, BaseException):
self.post_message(
self.ServerStartFailed(
error=server_result
if isinstance(server_result, Exception)
else RuntimeError(str(server_result)),
),
)
return
agent, server_proc, _ = server_result
# Assign immediately so the finally block in run_textual_app can
# clean up the server even if the ServerReady message is never
# processed (e.g. user quits during startup).
self._server_proc = server_proc
mcp_info = None
if len(results) > 1 and not isinstance(results[1], BaseException):
mcp_info = results[1]
elif len(results) > 1 and isinstance(results[1], BaseException):
logger.warning(
"MCP metadata preload failed: %s",
results[1],
exc_info=results[1],
)
self.post_message(
self.ServerReady(
agent=agent,
server_proc=server_proc,
mcp_server_info=mcp_info,
),
)
def on_deep_agents_app_server_ready(self, event: ServerReady) -> None:
"""Handle successful background server startup."""
self._connecting = False
self._connection_ready_event.set()
self._agent = event.agent
self._server_proc = event.server_proc
self._mcp_server_info = event.mcp_server_info
self._mcp_optimistic_original_server_info.clear()
self._pending_mcp_login_reconnect = False
self._pending_mcp_disable_reconnect_servers.clear()
self._sync_pending_mcp_reconnect()
# Drop transient failure-state widgets — banner state and the agent
# response now convey "connected", so the prior error and breadcrumb
# would just dangle in scrollback.
for attr in ("_retry_status_widget", "_startup_failure_widget"):
widget = getattr(self, attr)
if widget is None:
continue
setattr(self, attr, None)
async def _drop(w: Widget = widget) -> None:
# Mount may still be in flight when `ServerReady` arrives;
# short-circuit on un-attached widgets instead of raising.
# `NoMatches`/`ScreenStackError` cover later-stage detach
# races (screen torn down mid-removal).
if not w.is_attached:
return
with suppress(NoMatches, ScreenStackError):
await w.remove()
task = asyncio.create_task(_drop())
task.add_done_callback(_log_task_exception)
self._mcp_tool_count = sum(len(s.tools) for s in (event.mcp_server_info or []))
self._mcp_unauthenticated = sum(
1 for s in (event.mcp_server_info or []) if s.status == "unauthenticated"
)
self._mcp_errored = sum(
1 for s in (event.mcp_server_info or []) if s.status == "error"
)
# Update welcome banner to show ready state
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_connected(
self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
)
except NoMatches:
logger.warning("Welcome banner not found during server ready transition")
# Refresh the status bar model so a successful retry after a failed
# startup (e.g. `/model` switching providers after `ModelConfigError`)
# surfaces the now-active model. `StatusBar.on_mount` only runs once,
# and `_retry_startup_with_model` updates `settings` via
# `apply_to_settings` without pushing into the widget.
if self._status_bar is None:
logger.warning("Status bar not found during server ready transition")
else:
from deepagents_code.config import settings
provider = settings.model_provider or ""
model = settings.model_name or ""
if not provider or not model:
logger.warning(
"Settings missing model identity at server ready "
"(provider=%r, model=%r); status bar will render blank",
provider,
model,
)
self._status_bar.set_model(provider=provider, model=model)
if self._active_mcp_viewer is not None:
viewer = self._active_mcp_viewer
async def _refresh_viewer() -> None:
# No local `suppress` — the `_log_task_exception` done
# callback is the single error sink. Silencing here
# would make that callback dead code (its `task.result()`
# call could never see a raised exception) and a real
# `DuplicateIds` / `AttributeError` would leave the
# viewer stuck on the connecting placeholder with no
# signal in the logs.
await viewer.refresh_server_info(self._mcp_server_info or [])
task = asyncio.create_task(_refresh_viewer())
task.add_done_callback(_log_task_exception)
# Session-start sequence: load resumed history, run `--startup-cmd`
# (if any), then dispatch the initial prompt/skill and drain
# user-typed messages. Sequenced through a single task so the
# startup command always resolves before the agent sees any user
# input.
self.call_after_refresh(
lambda: asyncio.create_task(self._run_session_start_sequence()),
)
# Drain deferred actions (e.g. model/thread switch queued during connection)
# if the agent is not actively running. Wrapped in a helper so that
# exceptions are logged rather than becoming unhandled task errors.
if self._deferred_actions and not self._agent_running:
async def _safe_drain() -> None:
try:
await self._maybe_drain_deferred()
except Exception:
logger.exception("Unhandled error while draining deferred actions")
with suppress(Exception):
await self._mount_message(
ErrorMessage(
"A deferred action failed during startup. "
"You may need to retry the operation.",
),
)
self.call_after_refresh(lambda: asyncio.create_task(_safe_drain()))
def on_deep_agents_app_server_start_failed(self, event: ServerStartFailed) -> None:
"""Handle background server startup failure."""
from deepagents_code.mcp_tools import MCPConfigError
from deepagents_code.model_config import (
MissingCredentialsError,
MissingProviderPackageError,
)
self._connecting = False
self._connection_ready_event.set()
if isinstance(event.error, MCPConfigError):
# Already carries the path + hint; showing the class name is noise.
self._server_startup_error = str(event.error)
else:
self._server_startup_error = _format_startup_error(event.error)
# Stash the provider for the `/model` recovery hint. Reset on every
# failure so a non-credentials retry-failure clears the prior flag.
self._server_startup_missing_credentials_provider = (
event.error.provider
if isinstance(event.error, MissingCredentialsError)
else None
)
self._server_startup_missing_provider_package = (
event.error
if isinstance(event.error, MissingProviderPackageError)
else None
)
logger.error("Server startup failed: %s", event.error, exc_info=event.error)
# Drop the banner's connecting spinner — chat surface owns the error.
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_idle()
except NoMatches:
logger.warning("Welcome banner not found during server failure transition")
# Keep any queued messages and widgets in place — `/model` retry can
# bring the server up, at which point `_run_session_start_sequence`
# drains them. Deferred actions (model/thread switches queued during
# the initial connect) are dropped because the failure invalidates
# their assumptions; the user can re-issue them after recovery.
self._deferred_actions.clear()
# Failure surfaces only in chat — keeps recovery hint adjacent to the
# input. Banner is set to idle above to drop the connecting spinner.
text = f"Server failed to start: {self._server_startup_error}"
if (
self._server_startup_missing_credentials_provider is not None
and self._server_kwargs is not None
):
text += (
"\n\nHint: run `/auth` to add a key for this provider, then "
"`/model <provider>:<model>` to retry startup. Or pick a "
"different provider directly with `/model`."
)
elif (
self._server_startup_missing_provider_package is not None
and self._server_kwargs is not None
):
missing = self._server_startup_missing_provider_package
from deepagents_code.extras_info import extra_for_package
extra = extra_for_package(missing.package)
if extra is not None:
text += (
f"\n\nHint: install the package with `/install {extra}`, "
f"then run `/model {missing.provider}:<model>` to retry. "
"Or pick a different provider with `/model`."
)
else:
from deepagents_code.update_check import install_package_command
try:
install_cmd = install_package_command(missing.package)
except ValueError:
install_hint = f"install the `{missing.package}` package manually"
else:
install_hint = f"run `{install_cmd}`"
text += (
f"\n\nHint: {install_hint}, then run "
f"`/model {missing.provider}:<model>` "
"to retry. Or pick a different provider with `/model`."
)
async def _mount_failure() -> None:
# Drop any prior failure widget (re-entrant on retry-then-fail).
prior = self._startup_failure_widget
self._startup_failure_widget = None
if prior is not None and prior.is_attached:
with suppress(NoMatches, ScreenStackError):
await prior.remove()
try:
messages = self.query_one("#messages", Container)
except (NoMatches, ScreenStackError):
return
if not messages.is_attached:
return
new_widget = ErrorMessage(text)
# Mount before storing the reference so `ServerReady` racing this
# await cannot observe a half-mounted widget.
await self._mount_before_queued(messages, new_widget)
self._startup_failure_widget = new_widget
# Fire-and-forget mount: this is the *only* failure surface, so log
# any exception loudly via `_log_task_exception`.
task = asyncio.create_task(_mount_failure())
task.add_done_callback(_log_task_exception)
async def _await_prewarm_imports(self) -> None:
"""Wait for prewarm imports before re-entering their module graph.
Prevents a multi-threaded import deadlock: the prewarm worker runs in
`asyncio.to_thread`, and any caller that imports `deepagents` or
LangChain from the event-loop thread while it's still running can
race on partially-initialized module locks.
`asyncio.CancelledError` propagates so app shutdown isn't silently
absorbed. `WorkerCancelled` and `WorkerFailed` (both `Exception`
subclasses, distinct from `CancelledError`) are caught: prewarm is a
cache optimization, so a cancelled or failed worker just means the
next inline import is a cold load instead of a dict lookup.
"""
from textual.worker import WorkerCancelled, WorkerFailed
worker = self._prewarm_worker
if worker is None:
return
try:
await worker.wait()
except WorkerCancelled:
# Cancellation is benign here: app shutdown or another exclusive
# worker in the same group displaced the prewarm. The subsequent
# inline imports will still succeed — just without the warm-up.
logger.debug("Import prewarm worker was cancelled", exc_info=True)
except WorkerFailed:
# Prewarm body best-efforts third-party imports and already
# warns; logging at WARNING here surfaces unexpected failures
# (e.g. a regression that breaks a non-optional import) that
# the body itself didn't catch.
logger.warning("Import prewarm worker failed", exc_info=True)
@staticmethod
def _prewarm_deferred_imports() -> None:
"""Background-load modules deferred from the startup path.
Populates `sys.modules` so the first user-triggered inline import
is a cheap dict lookup instead of a cold module load.
"""
# Internal modules moved from top-level to local imports — a failure
# here indicates a packaging or code bug, not a missing optional dep, so
# we let the exception propagate (the worker catches it and logs
# at WARNING). textual_adapter and update_check are included so
# _post_paint_init's inline imports are dict lookups.
from deepagents_code.clipboard import (
copy_selection_to_clipboard, # noqa: F401
)
from deepagents_code.command_registry import ALWAYS_IMMEDIATE # noqa: F401
from deepagents_code.config import settings # noqa: F401
from deepagents_code.hooks import dispatch_hook # noqa: F401
from deepagents_code.model_config import ModelSpec # noqa: F401
from deepagents_code.textual_adapter import TextualUIAdapter # noqa: F401
from deepagents_code.update_check import is_update_check_enabled # noqa: F401
try:
# Heavy third-party deps deferred from textual_adapter /
# tool_display — hit on first message send and first tool
# approval. Best-effort: missing optional deps should not block the
# TUI from rendering.
from deepagents.backends import DEFAULT_EXECUTE_TIMEOUT # noqa: F401
from langchain.agents.middleware.human_in_the_loop import ( # noqa: F401
ApproveDecision,
)
from langchain_core.messages import AIMessage # noqa: F401
from langgraph.types import Command # noqa: F401
except Exception:
logger.warning("Could not prewarm third-party imports", exc_info=True)
# Markdown rendering stack — ~170 ms cold (textual._markdown pulls in
# markdown_it, pygments, linkify_it — 438 modules). Hit on first
# SkillMessage compose() and first code-fence highlight. Warming
# here makes the first expand/Ctrl+O instant.
import markdown_it # noqa: F401
from pygments.lexers import get_lexer_by_name as _get_lexer
from textual.widgets import Markdown # noqa: F401
# Instantiate the Python lexer to populate Pygments' internal
# lexer cache (~12 ms cold). Python is the most common fence
# language in skill bodies.
_get_lexer("python")
# Widgets deferred from app.py module level — a failure here indicates
# a packaging or code bug (same as the block above), so we let
# exceptions propagate.
from deepagents_code.widgets.approval import ApprovalMenu # noqa: F401
from deepagents_code.widgets.ask_user import AskUserMenu # noqa: F401
from deepagents_code.widgets.launch_init import LaunchNameScreen # noqa: F401
from deepagents_code.widgets.model_selector import (
ModelSelectorScreen, # noqa: F401
)
from deepagents_code.widgets.thread_selector import ( # noqa: F401
DeleteThreadConfirmScreen,
ThreadSelectorScreen,
)
async def _prewarm_threads_cache(self) -> None: # noqa: PLR6301 # Worker hook kept as instance method
"""Prewarm thread selector cache without blocking app startup."""
from deepagents_code.sessions import (
get_thread_limit,
prewarm_thread_message_counts,
)
await prewarm_thread_message_counts(limit=get_thread_limit())
async def _prewarm_model_caches(self) -> None:
"""Prewarm model discovery and profile caches without blocking startup."""
try:
from deepagents_code.model_config import (
get_available_models,
get_model_profiles,
)
await asyncio.to_thread(get_available_models)
await asyncio.to_thread(
get_model_profiles,
cli_override=self._profile_override,
)
except Exception:
logger.warning("Could not prewarm model caches", exc_info=True)
async def _check_for_updates(self, *, periodic: bool = False) -> None:
"""Run the update check and signal completion for downstream waiters.
Wraps `_check_for_updates_impl` so `_update_check_done.set()`
always fires — lets `_check_optional_tools_background` unblock
after the PyPI round-trip regardless of success, failure, or no-op.
Args:
periodic: Whether this is a quiet in-session recheck.
"""
try:
await self._check_for_updates_impl(periodic=periodic)
finally:
# Always signal completion — the optional-tools worker
# waits on this before deciding whether to post toasts.
self._update_check_done.set()
async def _check_for_updates_impl(self, *, periodic: bool = False) -> None:
"""Check PyPI for a newer version and either auto-update or queue a modal.
Phase 1 contacts PyPI and records the latest version on the app.
Phase 2 either performs the auto-upgrade (when enabled), or
registers the actionable notice and schedules the update modal.
Phase 2 sets `_update_modal_pending` *only* when the modal is
actually being scheduled; a detected-but-throttled update
leaves the event clear so missing-dep toasts still fire.
"""
# Phase 1: version check (benign failure)
try:
from deepagents_code.update_check import (
is_auto_update_enabled,
is_update_available,
upgrade_command,
)
available, latest = await asyncio.to_thread(
is_update_available,
bypass_cache=periodic,
)
if not available or latest is None:
return
self._update_available = (True, latest)
except Exception:
logger.debug("Background update check failed", exc_info=True)
return
# Phase 2: auto-update or register actionable notice
try:
from deepagents_code._version import __version__ as cli_version
if is_auto_update_enabled():
from deepagents_code._env_vars import DEBUG_UPDATE
from deepagents_code.update_check import (
create_update_log_path,
perform_upgrade,
)
if os.environ.get(DEBUG_UPDATE):
self.notify(
"Skipped update install (debug mode).",
severity="information",
timeout=4,
markup=False,
)
return
log_path = create_update_log_path()
self.notify(
f"Updating to v{latest}... Logs: {log_path}",
severity="information",
timeout=5,
markup=False,
)
success, output = await perform_upgrade(log_path=log_path)
if success:
self.notify(
f"Updated to v{latest}. Restart to use the new version.",
severity="information",
timeout=10,
)
else:
logger.warning(
"Background auto-upgrade to v%s failed. Output:\n%s",
latest,
output,
)
cmd = upgrade_command()
snippet = _truncate(output, limit=160) if output else ""
message = (
f"Auto-update failed. Run manually: {cmd}\nLog: {log_path}"
)
if snippet:
message = f"{message}\n{snippet}"
self.notify(
message,
severity="warning",
timeout=15,
markup=False,
)
else:
from deepagents_code.update_check import (
format_installed_age_suffix,
format_release_age_parenthetical,
mark_update_notified,
should_notify_update,
)
if not await asyncio.to_thread(should_notify_update, latest):
return
cmd = upgrade_command()
release_age = await asyncio.to_thread(
format_release_age_parenthetical,
latest,
)
installed_age = await asyncio.to_thread(
format_installed_age_suffix,
cli_version,
)
notification = self._build_update_notification(
latest=latest,
cli_version=cli_version,
release_age=release_age,
installed_age=installed_age,
upgrade_cmd=cmd,
)
if periodic:
self._notify_actionable(
notification,
severity="information",
timeout=12,
action_hint="Press ctrl+n to install.",
)
await asyncio.to_thread(mark_update_notified, latest)
return
# Register without a toast: the dedicated modal is
# the update's UI, so a parallel toast would be
# redundant. Registration still makes the entry
# reachable via ctrl+n if the modal is dismissed.
self._notice_registry.add(notification)
await asyncio.to_thread(mark_update_notified, latest)
# Set *before* scheduling the modal: the optional-tools
# worker may race with this path, and it gates toast
# suppression on this event.
self._update_modal_pending.set()
self.call_after_refresh(self._open_update_available_modal, notification)
except Exception:
logger.warning("Update check/notify failed unexpectedly", exc_info=True)
if is_auto_update_enabled():
self.notify(
"Auto-update failed unexpectedly.",
severity="warning",
timeout=10,
)
@staticmethod
def _build_update_notification(
*,
latest: str,
cli_version: str,
release_age: str,
installed_age: str,
upgrade_cmd: str,
) -> PendingNotification:
"""Build the update-available registry entry.
Args:
latest: New version advertised by PyPI.
cli_version: Currently installed version string.
release_age: Pre-formatted " (released N days ago)" fragment.
installed_age: Pre-formatted " (N days old)" fragment.
upgrade_cmd: Shell command to install the update.
Returns:
Registry entry ready to pass to `_notify_actionable`.
"""
body = (
f"v{latest} is available{release_age}.\n"
f"Currently installed: {cli_version}{installed_age}.\n"
"Your session will not be interrupted."
)
return PendingNotification(
key="update:available",
title="Update available",
body=body,
actions=(
NotificationAction(ActionId.INSTALL, "Install now", primary=True),
NotificationAction(ActionId.SKIP_ONCE, "Remind me next launch"),
NotificationAction(ActionId.SKIP_VERSION, "Skip this version"),
),
payload=UpdateAvailablePayload(latest=latest, upgrade_cmd=upgrade_cmd),
)
async def _show_whats_new(self) -> None:
"""Show a 'what's new' banner on the first launch after an upgrade."""
try:
from deepagents_code.update_check import should_show_whats_new
if not await asyncio.to_thread(should_show_whats_new):
return
except Exception:
logger.debug("What's new check failed", exc_info=True)
return
try:
from deepagents_code._version import __version__ as cli_version
from deepagents_code.config import _is_editable_install
if await asyncio.to_thread(_is_editable_install):
heading = f"Now running v{cli_version}"
else:
heading = f"Updated to v{cli_version}"
await self._mount_message(AppMessage(_build_whats_new_message(heading)))
except Exception:
logger.debug("What's new banner display failed", exc_info=True)
return
try:
from deepagents_code._version import __version__ as cli_version
from deepagents_code.update_check import mark_version_seen
await asyncio.to_thread(mark_version_seen, cli_version)
except Exception:
logger.warning("Failed to persist seen-version marker", exc_info=True)
async def _handle_update_command(self) -> None:
"""Handle the `/update` slash command — check for and install updates."""
await self._mount_message(UserMessage("/update"))
try:
from deepagents_code._env_vars import DEBUG_UPDATE
from deepagents_code._version import __version__ as cli_version
from deepagents_code.config import _is_editable_install
from deepagents_code.update_check import (
format_age_suffix,
format_installed_age_suffix,
format_release_age_parenthetical,
is_update_available,
perform_upgrade,
upgrade_command,
)
if await asyncio.to_thread(_is_editable_install):
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
await self._mount_message(
AppMessage(
"Updates are not available for editable installs. "
f"Currently on v{cli_version}{age_suffix}.",
),
)
return
await self._mount_message(AppMessage("Checking for updates..."))
available, latest = await asyncio.to_thread(
is_update_available,
bypass_cache=True,
)
if latest is None:
await self._mount_message(
AppMessage(
"Could not determine the latest version. "
"Check your network and try again.",
),
)
return
if not available:
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
await self._mount_message(
AppMessage(
f"Already on the latest version (v{cli_version}{age_suffix}).",
),
)
return
release_age = await asyncio.to_thread(
format_release_age_parenthetical,
latest,
)
installed_age = await asyncio.to_thread(
format_installed_age_suffix,
cli_version,
)
await self._mount_message(
AppMessage(
f"Update available: v{latest}{release_age}. "
f"Currently installed: {cli_version}{installed_age}. "
"Upgrading...",
),
)
if os.environ.get(DEBUG_UPDATE):
await self._mount_message(
AppMessage("Skipped update install (debug mode)."),
)
return
success, output = await perform_upgrade()
if success:
self._update_available = (False, None)
await self._mount_message(
AppMessage(
f"Updated to v{latest}. Restart to use the new version."
),
)
else:
cmd = upgrade_command()
detail = f": {output[:200]}" if output else ""
await self._mount_message(
AppMessage(f"Auto-update failed{detail}\nRun manually: {cmd}"),
)
except Exception as exc:
logger.warning("/update command failed", exc_info=True)
await self._mount_message(
ErrorMessage(f"Update failed: {type(exc).__name__}: {exc}"),
)
async def _handle_install_command(self, command: str) -> None:
"""Handle the `/install <extra>` slash command.
Adds an optional extra (e.g. `quickjs`, `daytona`) to the installed
dcode tool by re-running `uv tool install -U 'deepagents-code[<extra>]'`.
Refuses unknown extras unless the user passes a `--force` token.
Args:
command: The full slash command line (e.g. `'/install quickjs'`
or `'/install foo --force'`).
"""
parts = command.split()
force = "--force" in parts[1:]
extras = [p for p in parts[1:] if not p.startswith("-")]
if not extras:
await self._mount_message(
AppMessage(
"Usage: /install <extra> [--force]\nExample: /install quickjs",
),
)
return
if len(extras) > 1:
await self._mount_message(
AppMessage(
"Only one extra may be installed per /install command. "
f"Got: {', '.join(extras)}",
),
)
return
extra = extras[0].lower()
await self._mount_message(UserMessage(command))
try:
from deepagents_code.config import _is_editable_install
from deepagents_code.extras_info import (
KNOWN_EXTRAS,
MODEL_PROVIDER_EXTRAS,
SANDBOX_EXTRAS,
)
from deepagents_code.update_check import (
create_update_log_path,
install_extra_command,
is_valid_extra_name,
perform_install_extra,
)
except ImportError as exc:
logger.warning("/install command import failed", exc_info=True)
await self._mount_message(
ErrorMessage(f"Install failed: {type(exc).__name__}: {exc}"),
)
return
if not is_valid_extra_name(extra):
await self._mount_message(
AppMessage(
"Invalid extra name. Extra names must be "
"alphanumeric with `-`, `_`, or `.` (PEP 508).",
),
)
return
if await asyncio.to_thread(_is_editable_install):
await self._mount_message(
AppMessage(
"Cannot install extras on editable installs. "
f"Run `uv sync --extra {extra}` from the "
"deepagents-code source directory instead.",
),
)
return
if extra not in KNOWN_EXTRAS and not force:
known = ", ".join(sorted(KNOWN_EXTRAS))
await self._mount_message(
AppMessage(
f"'{extra}' is not a known extra.\n"
f"Known extras: {known}\n\n"
f"This would run: `{install_extra_command(extra)}`\n"
f"Re-run with `--force` to install anyway: "
f"`/install {extra} --force`",
),
)
return
log_path = create_update_log_path()
await self._mount_message(
AppMessage(f"Installing extra '{extra}'..."),
)
manual_cmd = install_extra_command(extra)
try:
success, output = await perform_install_extra(extra, log_path=log_path)
except (OSError, asyncio.CancelledError) as exc:
logger.warning("/install command failed", exc_info=True)
await self._mount_message(
ErrorMessage(
f"Install failed: {type(exc).__name__}: {exc}\n"
f"Log: {log_path}\n"
f"Run manually: {manual_cmd}",
),
)
return
if not success:
# Tail the last 200 chars — uv resolver prints the resolved
# error at the end, not the beginning.
detail = f": {output[-200:]}" if output else ""
await self._mount_message(
ErrorMessage(
f"Install failed{detail}\n"
f"Log: {log_path}\n"
f"Run manually: {manual_cmd}",
),
)
return
# Model-provider and sandbox extras are imported by the langgraph
# server subprocess; `/restart` respawns that subprocess and picks
# them up without exiting the TUI. `quickjs` (and other
# STANDALONE_EXTRAS) are wired into the parent process at startup
# (`verify_interpreter_deps` gates `--interpreter`), so a full
# relaunch is required.
if extra in MODEL_PROVIDER_EXTRAS or extra in SANDBOX_EXTRAS:
next_step = "Run `/restart` to load it now, or relaunch dcode."
else:
next_step = "Exit and relaunch dcode to use the new dependencies."
await self._mount_message(
AppMessage(f"Installed extra '{extra}'. {next_step}"),
)
async def _handle_version_command(self) -> None:
"""Handle the `/version` slash command — show versions and update status.
The app's release age is served from the cache populated by the
background update check. The SDK release age is served from its own
cache; on the first call for a given SDK version (or on a cache
miss) this triggers a one-off PyPI fetch bounded by a 3s timeout,
then persists the result so subsequent calls stay local. The
update-available hint reads `self._update_available`, which
reflects the last completed background check.
"""
from importlib.metadata import (
PackageNotFoundError,
version as _pkg_version,
)
lines: list[str] = []
try:
from deepagents_code._version import __version__ as cli_version
from deepagents_code.update_check import format_age_suffix
age_suffix = await asyncio.to_thread(format_age_suffix, cli_version)
lines.append(f"deepagents-code version: {cli_version}{age_suffix}")
except ImportError:
logger.debug("deepagents_code._version module not found")
lines.append("deepagents-code version: unknown")
except Exception:
logger.warning("Unexpected error looking up app version", exc_info=True)
lines.append("deepagents-code version: unknown")
try:
from deepagents_code.update_check import format_sdk_age_suffix
sdk_version = _pkg_version("deepagents")
sdk_age_suffix = await asyncio.to_thread(format_sdk_age_suffix, sdk_version)
lines.append(f"deepagents (SDK) version: {sdk_version}{sdk_age_suffix}")
except PackageNotFoundError:
logger.debug("deepagents SDK package not found in environment")
lines.append("deepagents (SDK) version: unknown")
except Exception:
logger.warning("Unexpected error looking up SDK version", exc_info=True)
lines.append("deepagents (SDK) version: unknown")
available, latest = self._update_available
if available and latest:
try:
from deepagents_code.update_check import upgrade_command
cmd = upgrade_command()
except Exception:
logger.warning(
"Could not resolve upgrade command for /version; "
"falling back to generic upgrade hint",
exc_info=True,
)
from deepagents_code.update_check import FALLBACK_UPGRADE_COMMAND
cmd = FALLBACK_UPGRADE_COMMAND
lines.extend(("", f"Update available: v{latest}. Run: {cmd}"))
await self._mount_message(AppMessage("\n".join(lines)))
try:
from deepagents_code.extras_info import (
format_extras_status,
get_extras_status,
)
extras_markdown = format_extras_status(get_extras_status())
except Exception:
logger.warning(
"Failed to collect optional dependency status",
exc_info=True,
)
extras_markdown = ""
if extras_markdown:
await self._mount_message(AppMessage(extras_markdown, markdown=True))
async def _handle_auto_update_toggle(self) -> None:
"""Handle the `/auto-update` slash command — persist toggle immediately."""
try:
from deepagents_code.config import _is_editable_install
from deepagents_code.update_check import (
is_auto_update_enabled,
set_auto_update,
)
if await asyncio.to_thread(_is_editable_install):
self.notify(
"Auto-updates are not available for editable installs.",
severity="warning",
timeout=5,
)
return
currently_enabled = await asyncio.to_thread(is_auto_update_enabled)
new_state = not currently_enabled
await asyncio.to_thread(set_auto_update, new_state)
label = "enabled" if new_state else "disabled"
self.notify(
f"Auto-updates {label}.",
severity="information",
timeout=5,
markup=False,
)
except Exception as exc:
logger.warning("/auto-update command failed", exc_info=True)
self.notify(
f"Auto-update toggle failed: {type(exc).__name__}: {exc}",
severity="warning",
timeout=5,
markup=False,
)
def on_scroll_up(self, _event: ScrollUp) -> None:
"""Handle scroll up to check if we need to hydrate older messages."""
self._check_hydration_needed()
def _update_status(self, message: str) -> None:
"""Update the status bar with a message."""
if self._status_bar:
self._status_bar.set_status_message(message)
def _update_tokens(self, count: int, *, approximate: bool = False) -> None:
"""Update the token count in the status bar.
Low-level helper — only touches the UI. Callers that also need to
update the local cache should use `_on_tokens_update` instead.
Args:
count: Total context token count.
approximate: Append "+" to signal a stale/interrupted count.
"""
if self._status_bar:
self._status_bar.set_tokens(count, approximate=approximate)
def _on_tokens_update(self, count: int, *, approximate: bool = False) -> None:
"""Update the local cache *and* the status bar.
This is the callback wired to the adapter's `_on_tokens_update`.
Args:
count: Total context token count to cache and display.
approximate: Append "+" to signal a stale/interrupted count.
"""
self._context_tokens = count
self._tokens_approximate = approximate
self._update_tokens(count, approximate=approximate)
def _show_tokens(self, *, approximate: bool = False) -> None:
"""Restore the status bar to the cached token value.
Args:
approximate: Append "+" to signal a stale/interrupted count.
This flag is sticky until `_on_tokens_update` receives a fresh
count from the model.
"""
self._tokens_approximate = self._tokens_approximate or approximate
self._update_tokens(
self._context_tokens,
approximate=self._tokens_approximate,
)
def _show_pending_tokens(self) -> None:
"""Show the unknown token count placeholder during streaming."""
if self._status_bar:
self._status_bar.show_pending_tokens()
def _check_hydration_needed(self) -> None:
"""Check if we need to hydrate messages from the store.
Called when user scrolls up near the top of visible messages.
"""
if not self._message_store.has_messages_above:
return
try:
chat = self.query_one("#chat", VerticalScroll)
except NoMatches:
logger.debug("Skipping hydration check: #chat container not found")
return
scroll_y = chat.scroll_y
viewport_height = chat.size.height
if self._message_store.should_hydrate_above(scroll_y, viewport_height):
self.call_later(self._hydrate_messages_above)
async def _hydrate_messages_above(self) -> None:
"""Hydrate older messages when user scrolls near the top.
This recreates widgets for archived messages and inserts them
at the top of the messages container.
"""
if not self._message_store.has_messages_above:
return
try:
chat = self.query_one("#chat", VerticalScroll)
except NoMatches:
logger.debug("Skipping hydration: #chat not found")
return
try:
messages_container = self.query_one("#messages", Container)
except NoMatches:
logger.debug("Skipping hydration: #messages not found")
return
to_hydrate = self._message_store.get_messages_to_hydrate()
if not to_hydrate:
return
old_scroll_y = chat.scroll_y
first_child = (
messages_container.children[0] if messages_container.children else None
)
# Build widgets in chronological order, then mount in reverse so
# each is inserted before the previous first_child, resulting in
# correct chronological order in the DOM.
hydrated_count = 0
hydrated_widgets: list[tuple] = [] # (widget, msg_data)
for msg_data in to_hydrate:
try:
widget = msg_data.to_widget()
hydrated_widgets.append((widget, msg_data))
except Exception:
logger.warning(
"Failed to create widget for message %s",
msg_data.id,
exc_info=True,
)
for widget, msg_data in reversed(hydrated_widgets):
try:
if first_child:
await messages_container.mount(widget, before=first_child)
else:
await messages_container.mount(widget)
first_child = widget
hydrated_count += 1
# Render Markdown content for hydrated assistant messages
if isinstance(widget, AssistantMessage) and msg_data.content:
await widget.set_content(msg_data.content)
except Exception:
logger.warning(
"Failed to mount hydrated widget %s",
widget.id,
exc_info=True,
)
# Only update store for the number we actually mounted
if hydrated_count > 0:
self._message_store.mark_hydrated(hydrated_count)
# Adjust scroll position to maintain the user's view.
# Widget heights aren't known until after layout, so we use a
# heuristic. A more accurate approach would measure actual heights
# via call_after_refresh.
estimated_height_per_message = 5 # terminal rows, rough estimate
added_height = hydrated_count * estimated_height_per_message
chat.scroll_y = old_scroll_y + added_height
async def _mount_before_queued(self, container: Container, widget: Widget) -> None:
"""Mount a widget in the messages container, before any queued widgets.
Queued-message widgets must stay at the bottom of the container so
they remain visually anchored below the current agent response.
This helper inserts `widget` just before the first queued widget,
or appends at the end when the queue is empty.
Args:
container: The `#messages` container to mount into.
widget: The widget to mount.
"""
if not container.is_attached:
return
first_queued = self._queued_widgets[0] if self._queued_widgets else None
if first_queued is not None and first_queued.parent is container:
try:
await container.mount(widget, before=first_queued)
except Exception:
logger.warning(
"Stale queued-widget reference; appending at end",
exc_info=True,
)
else:
return
await container.mount(widget)
def _is_spinner_at_correct_position(self, container: Container) -> bool:
"""Check whether the loading spinner is already correctly positioned.
The spinner should be immediately before the first queued widget, or
at the very end of the container when the queue is empty.
Args:
container: The `#messages` container.
Returns:
`True` if the spinner is already in the correct position.
"""
children = list(container.children)
if not children or self._loading_widget not in children:
return False
if self._queued_widgets:
first_queued = self._queued_widgets[0]
if first_queued not in children:
return False
return children.index(self._loading_widget) == (
children.index(first_queued) - 1
)
return children[-1] == self._loading_widget
def sync_terminal_background(self) -> None:
"""Best-effort sync of terminal default background to the active theme.
Custom themes use their stored registry colors; built-in Textual themes
resolve colors from the active app theme. Terminal write failures are
logged and swallowed because the OSC background sync is cosmetic.
ANSI themes intentionally skip this step so the terminal's native
background is preserved.
"""
if self.theme in {"ansi-dark", "ansi-light"}:
return
from deepagents_code.terminal_escape import set_terminal_background
entry = theme.get_registry().get(self.theme)
colors = (
entry.colors
if entry is not None and entry.custom
else theme.get_theme_colors(self)
)
try:
set_terminal_background(colors.background)
except Exception:
# Cosmetic only: must never break app startup or theme changes.
logger.warning("set_terminal_background raised unexpectedly", exc_info=True)
async def _set_spinner(self, status: SpinnerStatus) -> None:
"""Show, update, or hide the loading spinner.
Also drives the terminal's `OSC 9;4` progress indicator, when
supported, so taskbar / dock / tab badges reflect agent activity while
the user is in another window.
Args:
status: The spinner status to display, or `None` to hide.
"""
from deepagents_code.terminal_escape import (
TerminalProgressState,
clear_terminal_progress,
set_terminal_progress,
)
if status is None:
# Hide
if self._loading_widget:
await self._loading_widget.remove()
self._loading_widget = None
try:
clear_terminal_progress()
except Exception:
# Cosmetic only — must never break spinner lifecycle.
logger.exception("clear_terminal_progress raised unexpectedly")
return
try:
set_terminal_progress(state=TerminalProgressState.INDETERMINATE)
except Exception:
# Cosmetic only — must never break spinner lifecycle.
logger.exception("set_terminal_progress raised unexpectedly")
try:
messages = self.query_one("#messages", Container)
except NoMatches:
# Container was torn down (e.g. shutdown mid-stream). Skip
# silently so the streaming loop doesn't crash.
return
if self._loading_widget is None:
# Create new
self._loading_widget = LoadingWidget(status)
await self._mount_before_queued(messages, self._loading_widget)
else:
# Update existing
self._loading_widget.set_status(status)
# Reposition via move_child so elapsed-time and animation state
# carry through; remove + re-mount would reset both.
if not self._is_spinner_at_correct_position(messages):
self._reposition_spinner(messages)
# NOTE: Don't call anchor() here - it would re-anchor and drag user back
# to bottom if they've scrolled away during streaming
def _reposition_spinner(self, container: Container) -> None:
"""Move the spinner to its correct position without resetting state.
The spinner must sit immediately before the first queued widget, or
at the very end of the container when no widgets are queued. Using
`move_child` preserves the widget's internal state (elapsed time,
animation frame) that a remove + re-mount would reset.
Args:
container: The messages container that hosts the spinner.
"""
if self._loading_widget is None:
return
if self._loading_widget not in container.children:
# The caller holds a spinner reference that isn't in this
# container — the widget was reparented or removed by another
# code path. Log so the desync is visible instead of silently
# leaving the spinner in the wrong place.
logger.debug(
"Spinner widget not in container children; skipping reposition",
)
return
first_queued = self._queued_widgets[0] if self._queued_widgets else None
if first_queued is not None and first_queued.parent is container:
container.move_child(self._loading_widget, before=first_queued)
return
non_spinner = [
child for child in container.children if child is not self._loading_widget
]
if non_spinner:
container.move_child(self._loading_widget, after=non_spinner[-1])
async def _request_approval(
self,
action_requests: Any, # noqa: ANN401 # ActionRequest uses dynamic typing
assistant_id: str | None,
) -> asyncio.Future:
"""Request user approval inline in the messages area.
Mounts ApprovalMenu in the messages area (inline with chat).
ChatInput stays visible - user can still see it.
If another approval is already pending, queue this one.
Auto-approves shell commands that are in the configured allow-list.
Args:
action_requests: List of action request dicts to approve
assistant_id: The assistant ID for display purposes
Returns:
A Future that resolves to the user's decision.
"""
from deepagents_code.config import (
is_shell_command_allowed,
settings,
)
loop = asyncio.get_running_loop()
result_future: asyncio.Future = loop.create_future()
# Check if ALL actions in the batch are auto-approvable shell commands
if settings.shell_allow_list and action_requests:
all_auto_approved = True
approved_commands = []
for req in action_requests:
if req.get("name") == "execute":
command = req.get("args", {}).get("command", "")
if is_shell_command_allowed(command, settings.shell_allow_list):
approved_commands.append(command)
else:
all_auto_approved = False
break
else:
# Non-shell commands need normal approval
all_auto_approved = False
break
if all_auto_approved and approved_commands:
# Auto-approve all commands in the batch
result_future.set_result({"type": "approve"})
# Mount system messages showing the auto-approvals
try:
messages = self.query_one("#messages", Container)
for command in approved_commands:
auto_msg = AppMessage(
f"✓ Auto-approved shell command (allow-list): {command}",
)
await self._mount_before_queued(messages, auto_msg)
with suppress(NoMatches, ScreenStackError):
self.query_one("#chat", VerticalScroll).anchor()
except Exception: # noqa: S110, BLE001 # Resilient auto-message display
pass # Don't fail if we can't show the message
return result_future
# If there's already a pending approval, wait for it to complete first
if self._pending_approval_widget is not None:
while self._pending_approval_widget is not None: # noqa: ASYNC110 # Simple polling is sufficient here
await asyncio.sleep(0.1)
# Create menu with unique ID to avoid conflicts
from deepagents_code.widgets.approval import ApprovalMenu
unique_id = f"approval-menu-{uuid.uuid4().hex[:8]}"
menu = ApprovalMenu(action_requests, assistant_id, id=unique_id)
menu.set_future(result_future)
self._pending_approval_widget = menu
if self._is_user_typing():
# Show a placeholder until the user stops typing, then swap in the
# real ApprovalMenu. This prevents accidental key presses (e.g.
# 'y', 'n') from triggering approval decisions mid-sentence.
placeholder = Static(
"Waiting for typing to finish...",
classes="approval-placeholder",
)
self._approval_placeholder = placeholder
try:
messages = self.query_one("#messages", Container)
await self._mount_before_queued(messages, placeholder)
self.call_after_refresh(placeholder.scroll_visible)
except Exception:
logger.exception("Failed to mount approval placeholder")
# Placeholder failed — fall back to showing the menu directly
# so the future is always resolvable.
self._approval_placeholder = None
await self._mount_approval_widget(menu, result_future)
return result_future
self.run_worker(
self._deferred_show_approval(placeholder, menu, result_future),
exclusive=False,
)
else:
await self._mount_approval_widget(menu, result_future)
return result_future
async def _mount_approval_widget(
self,
menu: ApprovalMenu,
result_future: asyncio.Future[dict[str, str]],
) -> None:
"""Mount the approval menu widget inline in the messages area.
If mounting fails, clears `_pending_approval_widget` and propagates
the exception via `result_future`.
Args:
menu: The `ApprovalMenu` instance to mount.
result_future: The future to resolve/reject for the caller.
"""
try:
messages = self.query_one("#messages", Container)
await self._mount_before_queued(messages, menu)
self.call_after_refresh(menu.scroll_visible)
self.call_after_refresh(menu.focus)
except Exception as e:
logger.exception(
"Failed to mount approval menu (id=%s) in messages container",
menu.id,
)
self._pending_approval_widget = None
if not result_future.done():
result_future.set_exception(e)
async def _deferred_show_approval(
self,
placeholder: Static,
menu: ApprovalMenu,
result_future: asyncio.Future[dict[str, str]],
) -> None:
"""Wait until the user is idle, then swap the placeholder for the real menu.
Exits early if the placeholder has already been detached (e.g. the
approval was cancelled while waiting). In that case the future is
cancelled so the caller is not left hanging.
Args:
placeholder: The temporary placeholder widget currently mounted.
menu: The `ApprovalMenu` to show once the user stops typing.
result_future: The future backing this approval flow.
"""
deadline = _monotonic() + _DEFERRED_APPROVAL_TIMEOUT_SECONDS
while self._is_user_typing(): # Simple polling
if _monotonic() > deadline:
logger.warning(
"Timed out waiting for user to stop typing; showing approval now",
)
break
await asyncio.sleep(0.2)
# Guard: if the placeholder was already removed (e.g. agent cancelled
# the approval while we were waiting), clean up and cancel the future.
if not placeholder.is_attached:
logger.warning(
"Approval placeholder detached before menu shown (id=%s)",
menu.id,
)
self._approval_placeholder = None
self._pending_approval_widget = None
if not result_future.done():
result_future.cancel()
return
self._approval_placeholder = None
try:
await placeholder.remove()
except Exception:
logger.warning(
"Failed to remove approval placeholder during swap",
exc_info=True,
)
await self._mount_approval_widget(menu, result_future)
def _on_auto_approve_enabled(self) -> None:
"""Handle auto-approve being enabled via the HITL approval menu.
Called when the user selects "Auto-approve all" from an approval
dialog. Syncs the auto-approve state across the app flag, status
bar indicator, and session state so subsequent tool calls skip
the approval prompt.
"""
self._auto_approve = True
if self._status_bar:
self._status_bar.set_auto_approve(enabled=True)
if self._session_state:
self._session_state.auto_approve = True
async def _remove_ask_user_widget( # noqa: PLR6301 # Shared helper used by ask_user event handlers
self,
widget: AskUserMenu,
*,
context: str,
) -> None:
"""Remove an ask_user widget without surfacing cleanup races.
Args:
widget: Ask-user widget instance to remove.
context: Short context string for diagnostics.
"""
try:
await widget.remove()
except Exception:
logger.debug(
"Failed to remove ask-user widget during %s",
context,
exc_info=True,
)
async def _request_ask_user(
self,
questions: list[Question],
) -> asyncio.Future[AskUserWidgetResult]:
"""Display the ask_user widget and return a Future with user response.
Args:
questions: List of question dicts, each with `question`, `type`,
and optional `choices` and `required` keys.
Returns:
A Future that resolves to a dict with `'type'` (`'answered'` or
`'cancelled'`) and, when answered, an `'answers'` list.
"""
loop = asyncio.get_running_loop()
result_future: asyncio.Future[AskUserWidgetResult] = loop.create_future()
if self._pending_ask_user_widget is not None:
deadline = _monotonic() + 30
while self._pending_ask_user_widget is not None:
if _monotonic() > deadline:
logger.error(
"Timed out waiting for previous ask-user widget to "
"clear. Forcefully cleaning up.",
)
old_widget = self._pending_ask_user_widget
if old_widget is not None:
old_widget.action_cancel()
self._pending_ask_user_widget = None
await self._remove_ask_user_widget(
old_widget,
context="ask-user timeout cleanup",
)
break
await asyncio.sleep(0.1)
from deepagents_code.widgets.ask_user import AskUserMenu
unique_id = f"ask-user-menu-{uuid.uuid4().hex[:8]}"
menu = AskUserMenu(questions, id=unique_id)
menu.set_future(result_future)
self._pending_ask_user_widget = menu
try:
messages = self.query_one("#messages", Container)
await self._mount_before_queued(messages, menu)
self.call_after_refresh(lambda: self._scroll_ask_user_into_view(menu))
self.call_after_refresh(menu.focus_active)
except Exception as e:
logger.exception(
"Failed to mount ask-user menu (id=%s)",
unique_id,
)
self._pending_ask_user_widget = None
if not result_future.done():
result_future.set_exception(e)
return result_future
def _scroll_ask_user_into_view(self, menu: AskUserMenu) -> None:
"""Scroll mounted ask_user prompts into view.
Oversized prompts should start at the top of the viewport so the first
question and menu border are visible, instead of only exposing the
bottom edge of the widget.
"""
chat = self.query_one("#chat", VerticalScroll)
if menu.outer_size.height > chat.size.height:
menu.scroll_visible(animate=False, top=True)
return
menu.scroll_visible()
async def on_ask_user_menu_answered(
self,
event: Any, # noqa: ARG002, ANN401
) -> None:
"""Handle ask_user menu answers - remove widget and refocus input."""
if self._pending_ask_user_widget:
widget = self._pending_ask_user_widget
self._pending_ask_user_widget = None
await self._remove_ask_user_widget(widget, context="ask-user answered")
if self._chat_input:
self.call_after_refresh(self._chat_input.focus_input)
async def on_ask_user_menu_cancelled(
self,
event: Any, # noqa: ARG002, ANN401
) -> None:
"""Handle ask_user menu cancellation - remove widget and refocus input."""
if self._pending_ask_user_widget:
widget = self._pending_ask_user_widget
self._pending_ask_user_widget = None
await self._remove_ask_user_widget(widget, context="ask-user cancelled")
if self._chat_input:
self.call_after_refresh(self._chat_input.focus_input)
async def _process_message(self, value: str, mode: InputMode) -> None:
"""Route a message to the appropriate handler based on mode.
Args:
value: The message text to process.
mode: The input mode that determines message routing.
"""
if mode == "shell_incognito":
await self._handle_shell_command(
self._strip_mode_value(value, "!!", "!", mode),
incognito=True,
)
elif mode == "shell":
await self._handle_shell_command(
self._strip_mode_value(value, "!", "!!", mode),
)
elif mode == "command":
await self._handle_command(value)
elif mode == "normal":
await self._handle_user_message(value)
else:
# Fail safe: never default to the agent dispatch path on an
# unrecognized mode, since that would silently leak `!!`/`!`
# prefixed text to the LLM if the mode literal is ever wrong.
logger.error(
"Unrecognized input mode %r; refusing to forward to agent",
mode,
)
await self._mount_message(
ErrorMessage(
f"Internal error: unknown input mode {mode!r}. "
"Message was not sent.",
),
)
@staticmethod
def _strip_mode_value(
value: str,
prefix: str,
conflicting_prefix: str,
mode: InputMode,
) -> str:
"""Strip `prefix` from `value`, logging if a wrong prefix was supplied.
Three submission paths feed `_process_message`: (1) typed input, where
the chat input has already stripped the prefix, so `value` does not
start with `prefix`; (2) re-submission via the queue, where the value
was re-prepended with `prefix`; and (3) external/programmatic callers,
which may send either form. `removeprefix` is a no-op for path (1) and
does the work for paths (2) and (3).
A leading `conflicting_prefix` (the sibling shell mode's trigger)
indicates state-machine drift between the declared `mode` and the
actual text — for example, mode `"shell_incognito"` paired with a
value starting with a single `!`. We log for diagnostics but still
strip `prefix` so the user is not surprised by a sudden refusal; the
sibling prefix becomes part of the command body and the shell will
report any resulting error locally.
Examples:
shell_incognito + `"!!ls"` -> `"ls"` (queued submission)
shell_incognito + `"ls"` -> `"ls"` (typed submission, prefix
already stripped)
shell_incognito + `"!ls"` -> `"!ls"` (drift; logs a warning,
shell sees `!ls`)
shell + `"!ls"` -> `"ls"`
shell + `"!!ls"` -> `"!ls"` (drift; logs a warning)
Args:
value: Submitted text expected to match `mode`.
prefix: Trigger prefix associated with `mode` (e.g. `"!!"` for
`shell_incognito`, `"!"` for `shell`).
conflicting_prefix: Sibling-mode prefix whose presence at the
start of `value` signals drift (e.g. pass `"!"` when
`prefix="!!"`).
mode: Input mode for diagnostic messages.
Returns:
`value` with a leading `prefix` removed if present, otherwise
`value` unchanged.
"""
if value.startswith(conflicting_prefix) and not value.startswith(prefix):
logger.warning(
"Mode %r received value with conflicting prefix %r",
mode,
conflicting_prefix,
)
return value.removeprefix(prefix)
def _has_initial_submission(self) -> bool:
"""Return whether startup should auto-submit a prompt or skill."""
return self._initial_skill is not None or bool(
self._initial_prompt and self._initial_prompt.strip(),
)
async def _run_session_start_sequence(self) -> None:
"""Load history, run `--startup-cmd`, then dispatch initial work.
Single entry point for the post-connect sequence. Sequencing the
startup command before any user-facing agent work guarantees the
agent never observes input until the command has completed.
"""
if self._server_startup_deferred:
return
if self._launch_init_requested:
self._ensure_launch_init_task()
launch_init_task = self._launch_init_task
if launch_init_task is not None and not launch_init_task.done():
self._schedule_session_start_after_launch_init(launch_init_task)
return
self._startup_sequence_running = True
try:
should_load_history = bool(self._lc_thread_id and self._agent) and (
self._resume_thread_intent is not None
or not self._has_initial_submission()
)
if should_load_history:
await self._load_thread_history()
if self._startup_cmd:
cmd = self._startup_cmd
# One-shot: clear to avoid re-running on any subsequent server swap.
self._startup_cmd = None
await self._run_startup_command(cmd)
if self._has_initial_submission():
await self._submit_initial_submission()
return
finally:
self._startup_sequence_running = False
if self._agent_running or self._shell_running:
return
try:
await self._maybe_drain_deferred()
except Exception:
logger.exception(
"Failed to drain deferred actions after startup sequencing",
)
with suppress(Exception):
await self._mount_message(
ErrorMessage(
"A deferred action failed during startup. "
"You may need to retry the operation.",
),
)
if self._pending_messages:
await self._process_next_from_queue()
def _schedule_session_start_after_launch_init(
self,
launch_init_task: asyncio.Task[None],
) -> None:
"""Resume post-connect startup after onboarding without awaiting it.
Args:
launch_init_task: Active onboarding task.
"""
if self._session_start_waiting_for_launch_init:
return
self._session_start_waiting_for_launch_init = True
def _resume_when_launch_done(_done: asyncio.Task[None]) -> None:
self._session_start_waiting_for_launch_init = False
if self._exit:
return
task = asyncio.create_task(self._run_session_start_sequence())
task.add_done_callback(_log_task_exception)
launch_init_task.add_done_callback(_resume_when_launch_done)
async def _run_startup_command(self, command: str) -> None:
"""Execute the `--startup-cmd` and render its output in the transcript.
Uses the same worker-backed subprocess path as the interactive `!`
shell prefix, with an app-style header (since the user did not type
the command). Non-zero exit is already rendered as an error by
`_run_shell_task` but does not abort the session.
Raises:
CancelledError: If the worker is cancelled (e.g. Esc/Ctrl+C);
re-raised so `_run_shell_task`'s finally can clean up.
"""
try:
await self._mount_message(
AppMessage(
Content.from_markup("Running startup command: $cmd", cmd=command),
),
)
except Exception:
logger.warning("Failed to mount startup-command header", exc_info=True)
self._shell_running = True
if self._chat_input:
self._chat_input.set_cursor_active(active=False)
try:
worker = self.run_worker(self._run_shell_task(command), exclusive=False)
except Exception:
# `run_worker` failed synchronously — `_run_shell_task`'s finally
# never fires, so reset the busy flags here or the UI stays wedged.
logger.exception("Failed to schedule startup-command worker")
self._shell_running = False
self._shell_worker = None
if self._chat_input:
self._chat_input.set_cursor_active(active=True)
with suppress(Exception):
await self._mount_message(
ErrorMessage(
"Failed to start startup command; continuing session."
),
)
return
self._shell_worker = worker
try:
await worker.wait()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Startup command worker raised unexpectedly")
async def _submit_initial_submission(self) -> None:
"""Submit the startup prompt or skill after the UI is ready."""
try:
if self._initial_skill is not None:
await self._invoke_skill(
self._initial_skill,
self._initial_prompt or "",
)
return
if self._initial_prompt and self._initial_prompt.strip():
await self._handle_user_message(self._initial_prompt)
except Exception:
logger.exception("Unhandled error during initial submission")
with suppress(Exception):
await self._mount_message(
ErrorMessage(
"Failed to submit startup prompt. "
"Try running the command manually in the session.",
),
)
def _push_screen_result_future(
self,
screen: ModalScreen[ScreenResultT],
) -> asyncio.Future[ScreenResultT | None]:
"""Push a modal screen and return a future for its dismissal result.
Args:
screen: Modal screen to display.
Returns:
Future completed with the result passed to `dismiss()`.
"""
loop = asyncio.get_running_loop()
result_future: asyncio.Future[ScreenResultT | None] = loop.create_future()
def handle_result(result: ScreenResultT | None) -> None:
if not result_future.done():
result_future.set_result(result)
self.push_screen(screen, handle_result)
return result_future
async def _push_screen_wait(
self,
screen: ModalScreen[ScreenResultT],
) -> ScreenResultT | None:
"""Push a modal screen and wait for its dismissal result.
Args:
screen: Modal screen to display.
Returns:
The result passed to `dismiss()`.
"""
result_future = self._push_screen_result_future(screen)
return await result_future
def _ensure_launch_init_task(
self,
*,
name_result: Awaitable[str | None] | None = None,
) -> asyncio.Task[None]:
"""Start the onboarding task if needed.
Args:
name_result: Optional pre-pushed name-screen result. Used during
app mount so the modal is present before the first frame.
Returns:
The active onboarding task.
"""
self._launch_init_requested = False
task = self._launch_init_task
if task is not None and not task.done():
return task
if name_result is None:
task = asyncio.create_task(self._run_launch_init_sequence())
else:
task = asyncio.create_task(
self._run_launch_init_sequence(name_result=name_result),
)
self._launch_init_task = task
def _finalize_launch_init(done: asyncio.Task[None]) -> None:
if self._launch_init_task is done:
self._launch_init_task = None
_log_task_exception(done)
task.add_done_callback(_finalize_launch_init)
return task
async def _run_launch_init_sequence(
self,
*,
name_result: Awaitable[str | None] | None = None,
) -> None:
"""Run the onboarding flow."""
if self._launch_init_running:
return
name_memory_task: asyncio.Task[None] | None = None
self._launch_init_running = True
try:
if name_result is None:
from deepagents_code.widgets.launch_init import LaunchNameScreen
name = await self._push_screen_wait(LaunchNameScreen())
else:
name = await name_result
if name is None:
await self._finish_launch_init(name=None)
return
if name:
self._launch_user_name = name
name_memory_task = asyncio.create_task(
self._write_launch_name_memory(name),
)
(
dependency_continued,
result,
) = await self._prompt_launch_dependencies_then_model()
if not dependency_continued:
await self._await_launch_name_memory(name_memory_task)
await self._finish_launch_init(name=name)
return
if result is None:
await self._await_launch_name_memory(name_memory_task)
await self._finish_launch_init(name=name)
return
model_spec, _provider = result
if self._connecting:
# Bound the wait so a stuck server never traps onboarding.
# Server startup typically completes in seconds; a minute is
# a generous ceiling that still beats hanging forever.
try:
await asyncio.wait_for(
self._connection_ready_event.wait(),
timeout=_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"Server connection did not become ready within %ss; "
"skipping onboarding model switch",
_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS,
)
self.notify(
"Server still starting. Use /model to switch when ready.",
severity="warning",
markup=False,
)
await self._await_launch_name_memory(name_memory_task)
await self._finish_launch_init(name=name)
return
if self._exit:
await self._await_launch_name_memory(name_memory_task)
return
try:
await self._switch_model(model_spec, announce_unchanged=False)
except Exception as exc: # surface to user, don't crash onboarding
logger.warning(
"Model switch during onboarding failed",
exc_info=True,
)
self.notify(
f"Could not switch to {model_spec}: {exc}. Use /model to "
"try again.",
severity="error",
markup=False,
)
await self._await_launch_name_memory(name_memory_task)
await self._finish_launch_init(name=name)
except Exception:
# Last-resort guard: surface unexpected failures and best-effort
# mark onboarding complete so the user is not trapped re-running
# a broken flow on every launch.
logger.exception("Onboarding sequence failed unexpectedly")
self.notify(
"Setup hit an unexpected error. You can configure things "
"manually with /model and /memory.",
severity="error",
markup=False,
)
with suppress(Exception):
await self._await_launch_name_memory(name_memory_task)
with suppress(Exception):
await self._mark_onboarding_complete()
finally:
self._launch_init_running = False
if self._chat_input:
self._chat_input.focus_input()
async def _finish_launch_init(self, *, name: str | None) -> None:
"""Persist onboarding completion and, when given, mount the welcome.
Args:
name: Submitted user name.
When `None` (skip path) or empty, the personalized
welcome message is not mounted.
"""
await self._mark_onboarding_complete()
if name:
await self._mount_launch_welcome(name)
async def _mount_launch_welcome(self, name: str) -> None:
"""Mount the personalized onboarding welcome message."""
await self._mount_message(
AppMessage(Content.from_markup("Welcome, $name.", name=name)),
)
@staticmethod
def _dispatch_launch_name_hook(name: str, assistant_id: str) -> None:
"""Fire the onboarding name hook for external integrations.
Args:
name: Submitted user name.
assistant_id: Agent identifier associated with the submitted name.
"""
from deepagents_code.hooks import dispatch_hook_fire_and_forget
dispatch_hook_fire_and_forget(
"user.name.set",
{
"name": name,
"assistant_id": assistant_id,
},
)
async def _mark_onboarding_complete(self) -> None:
"""Persist that first-run onboarding should not be shown again.
Surfaces a user-visible toast when the marker write fails so the user
understands why onboarding may reappear on the next launch.
"""
from deepagents_code.onboarding import mark_onboarding_complete
ok = await asyncio.to_thread(mark_onboarding_complete)
if not ok:
self.notify(
"Could not save onboarding state. Setup may run again next "
"launch — check permissions on ~/.deepagents/.state/.",
severity="warning",
markup=False,
)
async def _write_launch_name_memory(self, name: str) -> None:
"""Persist the optional onboarding name into agent memory.
Surfaces a user-visible toast when the memory write fails so the
promise made in `LaunchNameScreen` ("will be remembered for future
sessions") does not silently break.
"""
from deepagents_code.onboarding import write_onboarding_name_memory
await self._resume_thread_resolved_event.wait()
assistant_id = self._assistant_id or DEFAULT_ASSISTANT_ID
self._dispatch_launch_name_hook(name, assistant_id)
ok = await asyncio.to_thread(write_onboarding_name_memory, name, assistant_id)
if not ok:
self.notify(
"Could not save your name to agent memory. Future sessions "
"may not remember it.",
severity="warning",
markup=False,
)
@staticmethod
async def _await_launch_name_memory(
task: asyncio.Task[None] | None,
) -> None:
"""Wait for the optional name-memory write when one is in flight."""
if task is not None:
await task
@staticmethod
def _build_launch_dependencies_screen(
*,
continue_screen: ModalScreen[Any] | None = None,
) -> ModalScreen:
"""Build the onboarding optional-dependency summary screen.
Args:
continue_screen: Optional screen to switch to when continuing.
Returns:
Dependency summary modal.
"""
from deepagents_code.widgets.launch_init import LaunchDependenciesScreen
return LaunchDependenciesScreen(continue_screen=continue_screen)
async def _prompt_launch_dependencies(self) -> bool | None:
"""Show the onboarding dependency summary and wait for a choice.
Returns:
`True` when the user continues, or `None` when setup is skipped.
"""
result = await self._push_screen_wait(self._build_launch_dependencies_screen())
return result if result is None or isinstance(result, bool) else None
async def _prompt_launch_dependencies_then_model(
self,
) -> tuple[bool, tuple[str, str] | None]:
"""Show dependencies, then replace that modal with model selection.
Returns:
A tuple where the first value indicates whether the user continued
past the dependency screen, and the second is the selected model
result when one was chosen.
"""
loop = asyncio.get_running_loop()
result_future: asyncio.Future[tuple[bool, tuple[str, str] | None]] = (
loop.create_future()
)
def finish(result: tuple[bool, tuple[str, str] | None]) -> None:
if not result_future.done():
result_future.set_result(result)
def handle_model(result: tuple[str, str] | None) -> None:
finish((True, result))
model_screen = self._build_model_selector_screen(
curated=True,
result_callback=handle_model,
)
dependency_screen = self._build_launch_dependencies_screen(
continue_screen=model_screen,
)
def handle_dependencies(result: bool | None) -> None:
if result is None:
finish((False, None))
elif result is True:
finish((True, None))
self.push_screen(dependency_screen, handle_dependencies)
return await result_future
def _can_bypass_queue(self, value: str) -> bool:
"""Check if a slash command can skip the message queue.
Args:
value: The lowered, stripped command string (e.g. `/model`).
Returns:
`True` if the command should bypass the busy-state queue.
"""
from deepagents_code.command_registry import (
BYPASS_WHEN_CONNECTING,
IMMEDIATE_UI,
SIDE_EFFECT_FREE,
)
cmd = value.split(maxsplit=1)[0] if value else ""
if cmd in BYPASS_WHEN_CONNECTING:
return self._connecting and not (self._agent_running or self._shell_running)
if cmd in IMMEDIATE_UI:
# Only bare form (no args) bypasses — /model opens selector,
# /model <name> does a direct switch that shouldn't race with agent.
return value == cmd
return cmd in SIDE_EFFECT_FREE
async def _submit_input(
self,
value: str,
mode: InputMode,
*,
force_bypass: bool = False,
) -> None:
"""Submit input, fast-pathing always-immediate commands.
For commands in `ALWAYS_IMMEDIATE` (or whenever `force_bypass` is set
by an external caller), the value is processed directly. Otherwise
the standard queue and per-tier bypass policy applies.
Args:
value: Raw text submitted by the user or external source.
mode: Input routing mode.
force_bypass: When `True`, skip queueing and process the value
immediately. External callers use this to mirror the
`ALWAYS_IMMEDIATE` fast path for commands they classify as
urgent.
"""
from deepagents_code.command_registry import (
ALWAYS_IMMEDIATE,
HIDDEN_COMMANDS,
)
# Hidden commands are recovery / power-user escape hatches and
# must work even when the app is busy or wedged — treat them as
# always-immediate.
always_bypass = ALWAYS_IMMEDIATE | HIDDEN_COMMANDS
if force_bypass or (
mode == "command" and value.lower().strip() in always_bypass
):
await self._process_message(value, mode)
return
# Prevent message handling while a thread switch is in-flight.
if self._thread_switching:
self.notify(
"Thread switch in progress. Please wait.",
severity="warning",
timeout=3,
)
return
# If the app is busy, still sequencing startup work, or holding a
# post-failure recovery state (server hasn't come up yet but `/model`
# retry is still possible), enqueue instead of processing. Messages
# queued in any of these states are drained once the session reaches
# its first stable idle/running state.
if (
self._agent_running
or self._shell_running
or self._connecting
or self._startup_sequence_running
or self._server_startup_error is not None
):
if mode == "command" and self._can_bypass_queue(value.lower().strip()):
await self._process_message(value, mode)
return
self._pending_messages.append(QueuedMessage(text=value, mode=mode))
queued_widget = QueuedUserMessage(value)
self._queued_widgets.append(queued_widget)
await self._mount_message(queued_widget)
if self._connecting:
with suppress(NoMatches):
self.query_one(
"#welcome-banner",
WelcomeBanner,
).reveal_connecting_footer()
return
await self._process_message(value, mode)
async def on_chat_input_submitted(self, event: ChatInput.Submitted) -> None:
"""Handle submitted input from ChatInput widget."""
value = event.value
mode: InputMode = event.mode # type: ignore[assignment] # Textual event mode is str at type level but InputMode at runtime
# Reset quit pending state on any input
self._quit_pending = False
from deepagents_code.hooks import dispatch_hook
await dispatch_hook("user.prompt", {})
await self._submit_input(value, mode)
async def on_external_input(self, event: ExternalInput) -> None:
"""Route external prompt and command events through the app queue.
Honors `event.bypass`: when an external caller supplies any tier
other than `QUEUED`, the event skips the queue regardless of normal
per-command policy. This is the documented escape hatch for
scripted callers that need to inject high-priority work.
"""
from deepagents_code.command_registry import BypassTier
external = event.event
if external.kind == "signal":
await self._handle_external_signal(external.payload)
return
mode: InputMode = "command" if external.kind == "command" else "normal"
force_bypass = external.bypass is not BypassTier.QUEUED
await self._submit_input(external.payload, mode, force_bypass=force_bypass)
async def _handle_external_signal(self, payload: str) -> None:
"""Dispatch an external signal payload to the corresponding action.
The wire-protocol decoder rejects unknown signal names before they
reach this method, so the `else` branch only fires when callers
construct an `ExternalEvent` directly with an unvalidated payload.
"""
signal_name = payload.strip().lower()
if signal_name == "interrupt":
self.action_interrupt()
elif signal_name == "force-clear":
await self._submit_input("/force-clear", "command", force_bypass=True)
else:
logger.warning("Ignoring unknown external signal %r", payload)
def on_chat_input_mode_changed(self, event: ChatInput.ModeChanged) -> None:
"""Update status bar when input mode changes."""
if self._status_bar:
self._status_bar.set_mode(event.mode)
def on_chat_input_typing(
self,
event: ChatInput.Typing, # noqa: ARG002 # Textual event handler signature
) -> None:
"""Record the most recent keystroke time for typing-aware approval deferral."""
self._last_typed_at = _monotonic()
def _is_user_typing(self) -> bool:
"""Return whether the user typed recently (within the idle threshold).
Returns:
`True` if the last recorded typing event occurred within the last
`_TYPING_IDLE_THRESHOLD_SECONDS` seconds, `False` otherwise.
"""
if self._last_typed_at is None:
return False
return (_monotonic() - self._last_typed_at) < _TYPING_IDLE_THRESHOLD_SECONDS
async def on_approval_menu_decided(
self,
event: Any, # noqa: ARG002, ANN401 # Textual event handler signature
) -> None:
"""Handle approval menu decision - remove from messages and refocus input."""
# Defensively remove any lingering placeholder (should already be gone
# once the deferred worker swaps it, but guard against edge cases).
if self._approval_placeholder is not None:
if self._approval_placeholder.is_attached:
try:
await self._approval_placeholder.remove()
except Exception:
logger.warning(
"Failed to remove approval placeholder during cleanup",
exc_info=True,
)
self._approval_placeholder = None
# Remove ApprovalMenu using stored reference
if self._pending_approval_widget:
await self._pending_approval_widget.remove()
self._pending_approval_widget = None
# Refocus the chat input
if self._chat_input:
self.call_after_refresh(self._chat_input.focus_input)
async def _handle_shell_command(
self,
command: str,
*,
incognito: bool = False,
) -> None:
"""Handle a shell command (! prefix).
Thin dispatcher that mounts the user message and spawns a worker
so the event loop stays free for key events (Esc/Ctrl+C).
Args:
command: The shell command to execute.
incognito: Whether the command/output should remain local-only.
"""
if not incognito:
await self._mount_message(UserMessage(f"!{command}"))
self._shell_running = True
if self._chat_input:
self._chat_input.set_cursor_active(active=False)
self._shell_worker = self.run_worker(
self._run_shell_task(command, incognito=incognito),
exclusive=False,
)
async def _run_shell_task(self, command: str, *, incognito: bool = False) -> None:
"""Run a shell command in a background worker.
This mirrors `_run_agent_task`: running in a worker keeps the event
loop free so Esc/Ctrl+C can cancel the worker -> raise
`CancelledError` -> kill the process.
Args:
command: The shell command to execute.
incognito: Whether the command/output should remain local-only.
Raises:
CancelledError: If the command is interrupted by the user.
"""
refresh_started = False
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=self._cwd,
start_new_session=(sys.platform != "win32"),
)
self._shell_process = proc
try:
stdout_bytes, stderr_bytes = await asyncio.wait_for(
proc.communicate(),
timeout=60,
)
except TimeoutError:
await self._kill_shell_process()
await self._mount_message(ErrorMessage("Command timed out (60s limit)"))
return
except asyncio.CancelledError:
await self._kill_shell_process()
raise
# Start branch refresh as soon as the shell exits so it can overlap
# with output rendering instead of trailing it.
self._schedule_git_branch_refresh()
refresh_started = True
output = (stdout_bytes or b"").decode(errors="replace").strip()
stderr_text = (stderr_bytes or b"").decode(errors="replace").strip()
if stderr_text:
output += f"\n[stderr]\n{stderr_text}"
if output:
if incognito:
await self._mount_message(
AppMessage(f"```\n{output}\n```", markdown=True),
)
else:
msg = AssistantMessage(f"```\n{output}\n```")
await self._mount_message(msg)
await msg.write_initial_content()
else:
await self._mount_message(AppMessage("Command completed (no output)"))
if proc.returncode and proc.returncode != 0:
await self._mount_message(ErrorMessage(f"Exit code: {proc.returncode}"))
# Anchor to bottom so shell output stays visible
with suppress(NoMatches, ScreenStackError):
self.query_one("#chat", VerticalScroll).anchor()
except OSError as e:
logger.exception("Failed to execute shell command: %s", command)
err_msg = f"Failed to run command: {e}"
await self._mount_message(ErrorMessage(err_msg))
except Exception:
# Defense in depth: a crash between subprocess read and
# `_mount_message` could leave the user with no signal that the
# command ran at all (privacy-sensitive in the incognito path).
# Surface a local-only error and re-raise so the worker layer
# records the failure.
logger.exception(
"Shell task crashed (incognito=%s): %s",
incognito,
command,
)
with suppress(Exception):
await self._mount_message(
ErrorMessage("Shell command crashed; see logs."),
)
raise
finally:
await self._cleanup_shell_task(refresh_git_branch=not refresh_started)
async def _cleanup_shell_task(self, *, refresh_git_branch: bool = True) -> None:
"""Clean up after shell command task completes or is cancelled.
Args:
refresh_git_branch: Whether to schedule a footer branch refresh
during cleanup. Successful shell runs can launch this earlier
so refresh overlaps with output rendering.
"""
was_interrupted = self._shell_process is not None and (
self._shell_worker is not None and self._shell_worker.is_cancelled
)
self._shell_process = None
self._shell_running = False
self._shell_worker = None
if was_interrupted:
await self._mount_message(AppMessage("Command interrupted"))
if self._chat_input:
self._chat_input.set_cursor_active(active=True)
if refresh_git_branch:
# A `!` command may have changed git state (e.g. `git checkout`);
# re-resolve so the footer reflects the new branch.
self._schedule_git_branch_refresh()
try:
await self._maybe_drain_deferred()
except Exception:
logger.exception("Failed to drain deferred actions during shell cleanup")
with suppress(Exception):
await self._mount_message(
ErrorMessage(
"A deferred action failed after task completion. "
"You may need to retry the operation.",
),
)
if not self._startup_sequence_running:
await self._process_next_from_queue()
async def _kill_shell_process(self) -> None:
"""Terminate the running shell command process.
On POSIX, sends SIGTERM to the entire process group (killing children).
On Windows, terminates only the root process. No-op if the process has
already exited. Waits up to 5s for clean shutdown, then escalates
to SIGKILL.
"""
proc = self._shell_process
if proc is None or proc.returncode is not None:
return
try:
if sys.platform != "win32":
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
else:
proc.terminate()
except ProcessLookupError:
return
except OSError:
logger.warning(
"Failed to terminate shell process (pid=%s)",
proc.pid,
exc_info=True,
)
return
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except TimeoutError:
logger.warning(
"Shell process (pid=%s) did not exit after SIGTERM; sending SIGKILL",
proc.pid,
)
with suppress(ProcessLookupError, OSError):
if sys.platform != "win32":
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
else:
proc.kill()
with suppress(ProcessLookupError, OSError):
await proc.wait()
except (ProcessLookupError, OSError):
pass
async def _open_url_command(self, command: str, cmd: str) -> None:
"""Open a URL in the browser and display a clickable link.
The browser opens immediately regardless of busy state. When the app is
busy, a queued indicator is shown and the real chat output (user echo
+ clickable link) replaces it after the current task finishes.
Args:
command: The raw command text (displayed as user message).
cmd: The normalized slash command used to look up the URL.
"""
url = _COMMAND_URLS[cmd]
webbrowser.open(url)
if self._agent_running or self._shell_running:
queued_widget = QueuedUserMessage(command)
self._queued_widgets.append(queued_widget)
await self._mount_message(queued_widget)
async def _mount_output() -> None:
# Remove the ephemeral queued widget, then mount real output.
if queued_widget in self._queued_widgets:
self._queued_widgets.remove(queued_widget)
with suppress(Exception):
await queued_widget.remove()
await self._mount_message(UserMessage(command))
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
await self._mount_message(AppMessage(link))
# Append directly — no dedup; each URL command gets its own output.
self._deferred_actions.append(
DeferredAction(kind="chat_output", execute=_mount_output),
)
return
await self._mount_message(UserMessage(command))
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
await self._mount_message(AppMessage(link))
@staticmethod
async def _build_thread_message(prefix: str, thread_id: str) -> str | Content:
"""Build a thread status message, hyperlinking the ID when possible.
Attempts to resolve the LangSmith thread URL with a short timeout.
Falls back to plain text if tracing is not configured or resolution
fails.
Args:
prefix: Label before the thread ID (e.g. `'Resumed thread'`).
thread_id: The thread identifier.
Returns:
`Content` with a clickable thread ID, or a plain string.
"""
from deepagents_code.config import build_langsmith_thread_url
try:
url = await asyncio.wait_for(
asyncio.to_thread(build_langsmith_thread_url, thread_id),
timeout=2.0,
)
except (TimeoutError, Exception): # noqa: BLE001 # Resilient non-interactive mode error handling
url = None
if url:
return Content.assemble(
f"{prefix}: ",
(thread_id, TStyle(link=url)),
)
return f"{prefix}: {thread_id}"
async def _handle_trace_command(self, command: str) -> None:
"""Open the current thread in LangSmith.
Resolves the URL and opens the browser immediately regardless of busy
state. When the app is busy, chat output (user echo + clickable link)
is deferred until the current task finishes. Error conditions (no
session, URL failure, tracing not configured) render immediately
regardless of busy state.
Args:
command: The raw command text (displayed as user message).
"""
from deepagents_code.config import (
LangSmithApiError,
LangSmithImportError,
LangSmithLookupTimeoutError,
_assemble_langsmith_thread_url,
fetch_langsmith_project_url_or_raise,
get_langsmith_project_name,
)
if not self._session_state:
await self._mount_message(UserMessage(command))
await self._mount_message(AppMessage("No active session."))
return
thread_id = self._session_state.thread_id
try:
project_name = await asyncio.to_thread(get_langsmith_project_name)
except Exception:
logger.exception(
"Failed to resolve LangSmith project name for thread %s",
thread_id,
)
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage("Failed to resolve LangSmith project name."),
)
return
if not project_name:
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage(
"LangSmith tracing is not configured. "
"Set LANGSMITH_API_KEY and LANGSMITH_TRACING=true to enable.",
),
)
return
try:
project_url = await asyncio.to_thread(
fetch_langsmith_project_url_or_raise, project_name
)
except LangSmithImportError:
logger.warning(
"langsmith package not installed; cannot resolve thread URL for %s",
thread_id,
)
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage(
"The `langsmith` package is not installed. "
"Install it with "
"`uv tool install -U deepagents-code --with langsmith` "
"to enable `/trace`.",
),
)
return
except LangSmithLookupTimeoutError:
logger.warning(
"LangSmith project URL lookup timed out for thread %s",
thread_id,
)
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage(
"Could not reach LangSmith to resolve the thread URL. "
"Check your network connection and try again.",
),
)
return
except LangSmithApiError as exc:
logger.warning(
"LangSmith API call failed while resolving thread URL for %s: %s",
thread_id,
exc,
)
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage(
f"LangSmith rejected the project lookup: {exc}. "
"Verify LANGSMITH_API_KEY and the project name are correct.",
),
)
return
except Exception:
logger.exception(
"Failed to fetch LangSmith project URL for thread %s",
thread_id,
)
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage("Failed to resolve LangSmith thread URL."),
)
return
url = _assemble_langsmith_thread_url(project_url, thread_id)
def _open_browser() -> None:
try:
webbrowser.open(url)
except Exception:
logger.debug("Could not open browser for URL: %s", url, exc_info=True)
asyncio.get_running_loop().run_in_executor(None, _open_browser)
# Defer chat output while a turn is in progress — rendering the user
# echo + link immediately would splice it into the middle of the
# streaming assistant response
if self._agent_running or self._shell_running:
queued_widget = QueuedUserMessage(command)
self._queued_widgets.append(queued_widget)
await self._mount_message(queued_widget)
async def _mount_output() -> None:
if queued_widget in self._queued_widgets:
self._queued_widgets.remove(queued_widget)
with suppress(Exception):
await queued_widget.remove()
await self._mount_message(UserMessage(command))
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
await self._mount_message(AppMessage(link))
# Append directly — no dedup; each /trace invocation gets its own output.
self._deferred_actions.append(
DeferredAction(kind="chat_output", execute=_mount_output),
)
return
await self._mount_message(UserMessage(command))
link = Content.styled(url, TStyle(dim=True, italic=True, link=url))
await self._mount_message(AppMessage(link))
async def _handle_command(self, command: str) -> None:
"""Handle a slash command.
Args:
command: The slash command (including /)
"""
from deepagents_code.config import newline_shortcut, settings
cmd = command.lower().strip()
if cmd in {"/quit", "/q"}:
self.exit()
elif cmd == "/help":
await self._mount_message(UserMessage(command))
help_body = (
"Commands: /quit, /agents, /auth, /clear, /force-clear, "
"/copy, /offload, /editor, "
"/mcp, /model [--model-params JSON] [--default], "
"/notifications, /reload, /skill:<name>, /remember, "
"/skill-creator, /theme, /tokens, /threads, /trace, "
"/update, /auto-update, /install, /changelog, /docs, "
"/feedback, /help\n\n"
"Interactive Features:\n"
" Enter Submit your message\n"
f" {newline_shortcut():<15} Insert newline\n"
" Ctrl+X Open prompt in external editor\n"
" Ctrl+N Review pending notifications\n"
" Shift+Tab Toggle auto-approve mode\n"
" @filename Auto-complete files and inject content\n"
" /command Slash commands (/help, /clear, /quit)\n"
" !command Run shell commands directly\n"
" !!command Run shell commands without adding "
"command/output to model context\n\n"
"Docs: "
)
help_text = Content.assemble(
(help_body, "dim italic"),
(DOCS_URL, TStyle(dim=True, italic=True, link=DOCS_URL)),
)
await self._mount_message(AppMessage(help_text))
elif cmd in {"/changelog", "/docs", "/feedback"}:
await self._open_url_command(command, cmd)
elif cmd in {"/version", "/about"}:
await self._mount_message(UserMessage(command))
await self._handle_version_command()
elif cmd == "/agents":
await self._show_agent_selector()
elif cmd in {"/clear", "/force-clear"}:
if cmd == "/force-clear":
self._force_interrupt_active_work()
self._pending_messages.clear()
self._queued_widgets.clear()
await self._clear_messages()
self._context_tokens = 0
self._tokens_approximate = False
self._update_tokens(0)
# Clear status message (e.g., "Interrupted" from previous session)
self._update_status("")
# Reset thread to start fresh conversation
if self._session_state:
new_thread_id = self._session_state.reset_thread()
self._lc_thread_id = new_thread_id
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.update_thread_id(new_thread_id)
except NoMatches:
pass
thread_msg_widget = AppMessage(f"Started new thread: {new_thread_id}")
await self._mount_message(thread_msg_widget)
self._schedule_thread_message_link(
thread_msg_widget,
prefix="Started new thread",
thread_id=new_thread_id,
)
elif cmd == "/copy":
await self._mount_message(UserMessage(command))
# Reverse-scan for the newest assistant message that has finished
# streaming and contains visible text. Track whether we passed over
# an in-flight stream so we can explain the skip rather than say
# "No message to copy yet." misleadingly.
content: str | None = None
streaming_pending = False
for message in reversed(self._message_store.get_all_messages()):
if message.type != MessageType.ASSISTANT:
continue
if not message.content.strip():
continue
if message.is_streaming:
streaming_pending = True
continue
content = message.content
break
if content is None:
empty_msg = (
"Latest assistant message is still streaming;"
" try again in a moment."
if streaming_pending
else "No message to copy yet."
)
await self._mount_message(AppMessage(empty_msg))
return
from deepagents_code.clipboard import copy_text_to_clipboard
success, error = copy_text_to_clipboard(self, content)
if success:
await self._mount_message(
AppMessage("Copied latest assistant message to clipboard."),
)
else:
fail_msg = (
f"Failed to copy latest assistant message to clipboard: {error}"
if error
else "Failed to copy latest assistant message to clipboard."
)
await self._mount_message(AppMessage(fail_msg))
elif cmd == "/editor":
await self.action_open_editor()
elif cmd in {"/offload", "/compact"}:
await self._mount_message(UserMessage(command))
await self._handle_offload()
elif cmd == "/threads":
await self._show_thread_selector()
elif cmd == "/trace":
await self._handle_trace_command(command)
elif cmd == "/update":
await self._handle_update_command()
elif cmd == "/auto-update":
await self._handle_auto_update_toggle()
elif cmd == "/install" or cmd.startswith("/install "):
await self._handle_install_command(command)
elif cmd == "/tokens":
await self._mount_message(UserMessage(command))
if self._context_tokens > 0:
count = self._context_tokens
formatted = format_token_count(count)
model_name = settings.model_name
context_limit = settings.model_context_limit
if context_limit is not None:
limit_str = format_token_count(context_limit)
pct = count / context_limit * 100
usage = f"{formatted} / {limit_str} tokens ({pct:.0f}%)"
else:
usage = f"{formatted} tokens used"
msg = f"{usage} \u00b7 {model_name}" if model_name else usage
conv_tokens = await self._get_conversation_token_count()
if conv_tokens is not None:
overhead = max(0, count - conv_tokens)
overhead_str = format_token_count(overhead)
conv_str = format_token_count(conv_tokens)
overhead_unit = " tokens" if overhead < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
conv_unit = " tokens" if conv_tokens < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
msg += (
f"\n\u251c System prompt + tools: ~{overhead_str}{overhead_unit} (fixed)" # noqa: E501
f"\n\u2514 Conversation: ~{conv_str}{conv_unit}"
)
await self._mount_message(AppMessage(msg))
else:
model_name = settings.model_name
context_limit = settings.model_context_limit
parts: list[str] = ["No token usage yet"]
if context_limit is not None:
limit_str = format_token_count(context_limit)
parts.append(f"{limit_str} token context window")
if model_name:
parts.append(model_name)
await self._mount_message(AppMessage(" · ".join(parts)))
elif cmd == "/remember" or cmd.startswith("/remember "):
# Convenience alias for /skill:remember — shorter and discoverable
# before skill loading completes.
if not await self._has_conversation_messages():
await self._mount_message(UserMessage(command))
await self._mount_message(
AppMessage(
"Nothing to remember yet. Start a conversation first,"
" then use /remember to capture learnings.",
),
)
return
args = command.strip()[len("/remember") :].strip()
rewritten = f"/skill:remember {args}" if args else "/skill:remember"
await self._handle_skill_command(rewritten)
elif cmd == "/skill-creator" or cmd.startswith("/skill-creator "):
# Convenience alias for /skill:skill-creator — shorter and
# discoverable before skill loading completes.
args = command.strip()[len("/skill-creator") :].strip()
rewritten = (
f"/skill:skill-creator {args}" if args else "/skill:skill-creator"
)
await self._handle_skill_command(rewritten)
elif cmd == "/mcp":
await self._show_mcp_viewer()
elif cmd.startswith("/mcp "):
args = command.strip()[len("/mcp ") :].strip()
await self._mount_message(UserMessage(command))
await self._handle_mcp_subcommand(args)
elif cmd in {"/auth", "/connect"}:
await self._show_auth_manager()
elif cmd == "/theme":
await self._show_theme_selector()
elif cmd == "/notifications":
await self._show_notification_settings()
elif cmd == "/model" or cmd.startswith("/model "):
model_arg = None
set_default = False
extra_kwargs: dict[str, Any] | None = None
if cmd.startswith("/model "):
raw_arg = command.strip()[len("/model ") :].strip()
try:
raw_arg, extra_kwargs = _extract_model_params_flag(raw_arg)
except (ValueError, TypeError) as exc:
await self._mount_message(UserMessage(command))
await self._mount_message(ErrorMessage(str(exc)))
return
if raw_arg.startswith("--default"):
set_default = True
model_arg = raw_arg[len("--default") :].strip() or None
else:
model_arg = raw_arg or None
if set_default:
await self._mount_message(UserMessage(command))
if extra_kwargs:
await self._mount_message(
ErrorMessage(
"--model-params cannot be used with --default. "
"Model params are applied per-session, not "
"persisted.",
),
)
elif model_arg == "--clear":
await self._clear_default_model()
elif model_arg:
await self._set_default_model(model_arg)
else:
await self._mount_message(
AppMessage(
"Usage: /model --default provider:model\n"
" /model --default --clear",
),
)
elif model_arg:
# Direct switch: /model claude-sonnet-4-5
await self._mount_message(UserMessage(command))
await self._switch_model(model_arg, extra_kwargs=extra_kwargs)
else:
await self._show_model_selector(extra_kwargs=extra_kwargs)
elif cmd == "/reload":
await self._mount_message(UserMessage(command))
# Snapshot pre-reload skill names so the report can show diff.
old_skill_names = {s["name"] for s in self._discovered_skills}
try:
changes = settings.reload_from_environment()
from deepagents_code.model_config import clear_caches
clear_caches()
except (OSError, ValueError):
logger.exception("Failed to reload configuration")
await self._mount_message(
AppMessage(
"Failed to reload configuration. Check your .env "
"file and environment variables for syntax errors, "
"then try again.",
),
)
return
# Reload user themes from config.toml and re-register with Textual
theme_reload_ok = True
try:
theme.reload_registry()
self._register_custom_themes()
except Exception:
theme_reload_ok = False
logger.warning("Failed to reload user themes", exc_info=True)
# Re-discover skills so autocomplete reflects any new/removed
# skills. Run via the same exclusive-group worker used at
# startup so any in-flight startup discovery is cancelled
# rather than racing this one, then await its completion so
# the report can include the diff.
skill_worker = self.run_worker(
self._discover_skills(),
exclusive=True,
group="startup-skill-discovery",
)
await skill_worker.wait()
discovery_ok = skill_worker.result is True
new_skill_names = {s["name"] for s in self._discovered_skills}
added_skills = sorted(new_skill_names - old_skill_names)
removed_skills = sorted(old_skill_names - new_skill_names)
if changes:
report = "Configuration reloaded. Changes:\n" + "\n".join(
f" - {change}" for change in changes
)
else:
report = "Configuration reloaded. No changes detected."
report += "\nModel config caches cleared."
if theme_reload_ok:
report += "\nTheme registry reloaded."
else:
report += (
"\nTheme registry reload failed. Check config.toml for errors."
)
if not discovery_ok:
# Diff is meaningless when discovery failed: prior cache
# was preserved, so old vs. new is identical and
# `Skills reloaded. No changes detected.` would be a lie.
report += (
"\nSkill re-discovery failed; existing /skill: list left as-is."
)
elif added_skills or removed_skills:
skill_lines = []
if added_skills:
skill_lines.append(f" - Added: {', '.join(added_skills)}")
if removed_skills:
skill_lines.append(f" - Removed: {', '.join(removed_skills)}")
report += "\nSkills updated:\n" + "\n".join(skill_lines)
await self._mount_message(AppMessage(report))
await self._maybe_start_deferred_server_from_default()
elif cmd.startswith("/skill:"):
await self._handle_skill_command(command)
# -- Hidden commands (not in COMMANDS / autocomplete) -----------------
elif cmd == "/debug-error":
await self._mount_message(
ErrorMessage(
"Server failed to start: RuntimeError: Server process"
" exited with code 3",
),
)
elif cmd == "/restart":
await self._handle_restart_command(command)
else:
await self._mount_message(UserMessage(command))
await self._mount_message(AppMessage(f"Unknown command: {cmd}"))
# Anchor to bottom so command output stays visible
with suppress(NoMatches, ScreenStackError):
self.query_one("#chat", VerticalScroll).anchor()
async def _invoke_skill(
self,
skill_name: str,
args: str = "",
*,
command: str | None = None,
) -> None:
"""Load a skill, render its widget, and send its prompt to the agent.
Looks up the skill from cached metadata (populated at startup), falling
back to a fresh filesystem walk on cache miss. Reads the `SKILL.md`
body, wraps it in a prompt envelope with any user-provided arguments,
and sends the composed message to the agent.
Args:
skill_name: Skill name to invoke.
args: Optional user request to append after the skill body.
command: Original slash command text for UI echo, if any.
"""
from deepagents_code.skills.invocation import build_skill_invocation_envelope
from deepagents_code.skills.load import load_skill_content
normalized_name = skill_name.strip().lower()
async def _mount_error(message: str) -> None:
if command is not None:
await self._mount_message(UserMessage(command))
await self._mount_message(AppMessage(message))
if not normalized_name:
if command is not None:
await self._mount_message(UserMessage(command))
await self._mount_message(AppMessage("Usage: /skill:<name> [args]"))
else:
await self._mount_message(AppMessage("Skill name is required."))
return
# Fast path: look up from the cached discovery results
cached = next(
(s for s in self._discovered_skills if s["name"] == normalized_name),
None,
)
allowed_roots = self._skill_allowed_roots
# Cache miss — fall back to fresh discovery (offloaded to thread)
if cached is None:
try:
skills, allowed_roots = await asyncio.to_thread(
self._discover_skills_and_roots,
)
# Backfill cache so subsequent invocations are fast
self._discovered_skills = skills
self._skill_allowed_roots = allowed_roots
cached = next((s for s in skills if s["name"] == normalized_name), None)
except OSError as exc:
logger.warning(
"Filesystem error loading skill %r",
normalized_name,
exc_info=True,
)
await _mount_error(
f"Could not load skill: {normalized_name}. Filesystem error: {exc}",
)
return
except Exception as exc:
logger.warning(
"Error searching for skill %r",
normalized_name,
exc_info=True,
)
await _mount_error(
f"Error loading skill: {normalized_name}. "
f"Unexpected error: {type(exc).__name__}: {exc}",
)
return
if cached is None:
logger.warning("Skill not found: %r", normalized_name)
await _mount_error(f"Skill not found: {normalized_name}")
return
# Load SKILL.md content (filesystem I/O offloaded to thread)
skill_path = cached["path"]
def _load() -> str | None:
return load_skill_content(str(skill_path), allowed_roots=allowed_roots)
try:
content = await asyncio.to_thread(_load)
except PermissionError as exc:
logger.warning(
"Containment check failed for skill %r",
normalized_name,
exc_info=True,
)
await _mount_error(str(exc))
return
except OSError as exc:
logger.warning(
"Filesystem error loading skill %r",
normalized_name,
exc_info=True,
)
await _mount_error(
f"Could not load skill: {normalized_name}. Filesystem error: {exc}",
)
return
except Exception as exc:
logger.warning("Error reading skill %r", normalized_name, exc_info=True)
await _mount_error(
f"Error loading skill: {normalized_name}. "
f"Unexpected error: {type(exc).__name__}: {exc}",
)
return
if content is None:
await _mount_error(
f"Could not read content for skill: {normalized_name}. "
"Check that the SKILL.md file exists, is readable, "
"and is saved as UTF-8.",
)
return
if not content.strip():
await _mount_error(
f"Skill '{normalized_name}' has an empty SKILL.md file. "
"Add instructions to the file before invoking.",
)
return
envelope = build_skill_invocation_envelope(cached, content, args)
await self._mount_message(
SkillMessage(
skill_name=cached["name"],
description=str(cached.get("description", "")),
source=str(cached.get("source", "")),
body=content,
args=args,
),
)
await self._send_to_agent(
envelope.prompt,
message_kwargs=envelope.message_kwargs,
)
async def _handle_skill_command(self, command: str) -> None:
"""Handle a `/skill:<name>` command by loading and invoking a skill.
Args:
command: The full command string (e.g., `/skill:web-research find X`).
"""
from deepagents_code.command_registry import parse_skill_command
skill_name, args = parse_skill_command(command)
await self._invoke_skill(skill_name, args, command=command)
async def _has_conversation_messages(self) -> bool:
"""Check whether the current thread has at least one human message.
Returns:
`True` if the conversation contains a `HumanMessage`, `False`
otherwise. On transient errors (network, corrupt state) returns
`True` so that `/remember` is not blocked with a misleading
"nothing to remember" message.
"""
if not self._agent:
return False
try:
from langchain_core.messages import HumanMessage
config: RunnableConfig = {
"configurable": {"thread_id": self._lc_thread_id},
}
state = await self._agent.aget_state(config)
if not state or not state.values:
return False
messages = state.values.get("messages", [])
return any(isinstance(m, HumanMessage) for m in messages)
except Exception:
logger.warning(
"Failed to check conversation messages; allowing /remember to proceed",
exc_info=True,
)
return True
async def _get_conversation_token_count(self) -> int | None:
"""Return the approximate conversation-only token count.
Returns:
Token count as an integer, or `None` if state is unavailable.
"""
if not self._agent:
return None
try:
from langchain_core.messages.utils import (
count_tokens_approximately,
)
config: RunnableConfig = {
"configurable": {"thread_id": self._lc_thread_id},
}
state = await self._agent.aget_state(config)
if not state or not state.values:
return None
messages = state.values.get("messages", [])
if not messages:
return None
return count_tokens_approximately(messages)
except Exception: # best-effort for /tokens display
logger.debug("Failed to retrieve conversation token count", exc_info=True)
return None
def _resolve_offload_budget_str(self) -> str | None:
"""Resolve the offload retention budget as a human-readable string.
Instantiates a model and computes summarization defaults, so this is
not a trivial accessor.
Returns:
A string like `"20.0K (10% of 200.0K)"` or
`"last 6 messages"`, or `None` if the budget cannot be determined.
"""
from deepagents_code.config import create_model, settings
try:
from deepagents.middleware.summarization import (
compute_summarization_defaults,
)
model_spec = f"{settings.model_provider}:{settings.model_name}"
result = create_model(
model_spec,
profile_overrides=self._profile_override,
)
defaults = compute_summarization_defaults(result.model)
from deepagents_code.offload import format_offload_limit
return format_offload_limit(
defaults["keep"],
settings.model_context_limit,
)
except Exception: # best-effort for /tokens display
logger.debug("Failed to compute offload budget string", exc_info=True)
return None
async def _handle_offload(self) -> None:
"""Offload older messages to free context window space."""
from deepagents_code.config import settings
from deepagents_code.offload import (
OffloadModelError,
OffloadThresholdNotMet,
perform_offload,
)
if not self._agent or not self._lc_thread_id:
await self._mount_message(
AppMessage("Nothing to offload \u2014 start a conversation first"),
)
return
if self._agent_running:
await self._mount_message(
AppMessage("Cannot offload while agent is running"),
)
return
config: RunnableConfig = {"configurable": {"thread_id": self._lc_thread_id}}
try:
state_values = await self._get_thread_state_values(self._lc_thread_id)
except Exception as exc: # noqa: BLE001
await self._mount_message(ErrorMessage(f"Failed to read state: {exc}"))
return
if not state_values:
await self._mount_message(
AppMessage("Nothing to offload \u2014 start a conversation first"),
)
return
# Prevent concurrent user input while offload modifies state
self._agent_running = True
try:
from deepagents_code.hooks import dispatch_hook
await dispatch_hook("context.offload", {})
# Keep old hook name for backward compatibility
await dispatch_hook("context.compact", {})
await self._set_spinner("Offloading")
result = await perform_offload(
messages=state_values.get("messages", []),
prior_event=state_values.get("_summarization_event"),
thread_id=self._lc_thread_id,
model_spec=(f"{settings.model_provider}:{settings.model_name}"),
profile_overrides=self._profile_override,
context_limit=settings.model_context_limit,
total_context_tokens=self._context_tokens,
backend=self._backend,
)
if isinstance(result, OffloadThresholdNotMet):
conv_str = format_token_count(result.conversation_tokens)
if (
result.total_context_tokens > 0
and result.context_limit is not None
and result.total_context_tokens > result.context_limit
):
total_str = format_token_count(
result.total_context_tokens,
)
await self._mount_message(
AppMessage(
f"Offload threshold not met \u2014 conversation "
f"is only ~{conv_str} tokens.\n\n"
f"The remaining context "
f"({total_str} tokens) is system overhead "
f"that can't be offloaded.\n\n"
f"Use /tokens for a full breakdown.",
),
)
else:
await self._mount_message(
AppMessage(
f"Offload threshold not met \u2014 conversation "
f"(~{conv_str} tokens) is within the "
f"retention budget "
f"({result.budget_str}).\n\n"
f"Use /tokens for a full breakdown.",
),
)
return
# OffloadResult — success
if result.offload_warning:
await self._mount_message(ErrorMessage(result.offload_warning))
# Intentionally traced: the summarization event is a meaningful state
# transition that should surface in LangSmith alongside real agent turns.
# The new `_context_tokens` count rides along on the same update so it
# shares a checkpoint with the offload and doesn't create a separate
# `UpdateState` run.
await self._agent.aupdate_state(
config,
{
"_summarization_event": result.new_event,
"_context_tokens": result.tokens_after,
},
)
before = format_token_count(result.tokens_before)
after = format_token_count(result.tokens_after)
await self._mount_message(
AppMessage(
f"Offloaded {result.messages_offloaded} older messages, "
f"freeing up context window space.\n"
f"Context: {before} \u2192 {after} tokens "
f"({result.pct_decrease}% decrease), "
f"{result.messages_kept} messages kept.",
),
)
self._on_tokens_update(result.tokens_after)
except OffloadModelError as exc:
logger.warning("Offload model creation failed: %s", exc, exc_info=True)
await self._mount_message(ErrorMessage(str(exc)))
except Exception as exc: # surface offload errors to user
logger.exception("Offload failed")
await self._mount_message(ErrorMessage(f"Offload failed: {exc}"))
finally:
self._agent_running = False
try:
await self._set_spinner(None)
except Exception: # best-effort spinner cleanup
logger.exception("Failed to dismiss spinner after offload")
async def _handle_user_message(self, message: str) -> None:
"""Handle a user message to send to the agent.
Args:
message: The user's message
"""
# Mount the user message
await self._mount_message(UserMessage(message))
await self._send_to_agent(message)
async def _send_to_agent(
self,
message: str,
*,
message_kwargs: dict[str, Any] | None = None,
) -> None:
"""Send a message to the agent and start execution.
This is the low-level send path. It does NOT mount any widget — the
caller is responsible for mounting the appropriate visual representation
(e.g., `UserMessage`, `SkillMessage`) before calling this method.
Args:
message: The prompt to send to the agent.
message_kwargs: Extra fields merged into the stream input message
dict (e.g., `additional_kwargs` for skill metadata).
"""
# Anchor to bottom so streaming response stays visible
with suppress(NoMatches, ScreenStackError):
self.query_one("#chat", VerticalScroll).anchor()
# Check if agent is available
if self._agent and self._ui_adapter and self._session_state:
self._agent_running = True
if self._chat_input:
self._chat_input.set_cursor_active(active=False)
# Use run_worker to avoid blocking the main event loop
# This allows the UI to remain responsive during agent execution
self._agent_worker = self.run_worker(
self._run_agent_task(message, message_kwargs=message_kwargs),
exclusive=False,
)
elif self._server_startup_deferred:
await self._mount_message(AppMessage(_DEFERRED_START_NOTICE))
elif not self._server_startup_error:
# When a server-startup failure is in flight, the chat
# `ErrorMessage` mounted by `on_deep_agents_app_server_start_failed`
# is the single source of truth — don't duplicate it here.
await self._mount_message(
AppMessage("Agent not configured for this session."),
)
async def _mount_deferred_start_notice(self) -> None:
"""Tell first-launch users how to configure model credentials."""
if self._server_startup_deferred_notice_shown:
return
self._server_startup_deferred_notice_shown = True
await self._mount_message(AppMessage(_DEFERRED_START_NOTICE))
async def _run_agent_task(
self,
message: str,
*,
message_kwargs: dict[str, Any] | None = None,
) -> None:
"""Run the agent task in a background worker.
This runs in a Textual worker so the main event loop stays responsive.
Args:
message: The prompt to send to the agent.
message_kwargs: Extra fields merged into the stream input message
dict (e.g., `additional_kwargs` for skill metadata).
"""
# Caller ensures _ui_adapter is set (checked in _handle_user_message)
if self._ui_adapter is None:
return
from deepagents_code.textual_adapter import execute_task_textual
# Create the stats object up-front and store on the app so
# exit() can merge it synchronously if the worker is cancelled
# before this method can return (e.g. Ctrl+D during HITL).
turn_stats = SessionStats()
self._inflight_turn_stats = turn_stats
self._inflight_turn_start = time.monotonic()
try:
await execute_task_textual(
user_input=message,
agent=self._agent,
assistant_id=self._assistant_id,
session_state=self._session_state,
adapter=self._ui_adapter,
backend=self._backend,
image_tracker=self._image_tracker,
sandbox_type=self._sandbox_type,
message_kwargs=message_kwargs,
context=CLIContext(
model=self._model_override,
model_params=self._model_params_override or {},
),
turn_stats=turn_stats,
)
except Exception as e: # Resilient tool rendering
logger.exception("Agent execution failed")
# Ensure any in-flight tool calls don't remain stuck in "Running..."
# when streaming aborts before tool results arrive.
if self._ui_adapter:
self._ui_adapter.finalize_pending_tools_with_error(f"Agent error: {e}")
try:
await self._mount_message(ErrorMessage(f"Agent error: {e}"))
except Exception:
logger.debug(
"Could not mount error message (app closing?)",
exc_info=True,
)
finally:
# Merge turn stats before cleanup — _cleanup_agent_task may raise
# during teardown (widget removal on a torn-down DOM), and stats
# should ideally be captured regardless.
# exit() clears _inflight_turn_stats when it merges, so
# checking for None prevents double-counting.
if self._inflight_turn_stats is not None:
self._session_stats.merge(turn_stats)
self._inflight_turn_stats = None
await self._cleanup_agent_task()
async def _process_next_from_queue(self) -> None:
"""Process the next message from the queue if any exist.
Dequeues and processes the next pending message in FIFO order.
Uses the `_processing_pending` flag to prevent reentrant execution.
"""
if self._processing_pending or not self._pending_messages or self._exit:
return
self._processing_pending = True
try:
msg = self._pending_messages.popleft()
# Remove the ephemeral queued-message widget
if self._queued_widgets:
widget = self._queued_widgets.popleft()
await widget.remove()
await self._process_message(msg.text, msg.mode)
except Exception:
logger.exception("Failed to process queued message")
await self._mount_message(
ErrorMessage(f"Failed to process queued message: {msg.text[:60]}"),
)
finally:
self._processing_pending = False
# Command mode messages complete synchronously without spawning
# a worker, so cleanup won't fire again. Continue draining the
# queue if no worker was started.
busy = self._agent_running or self._shell_running
if not busy and self._pending_messages:
await self._process_next_from_queue()
async def _cleanup_agent_task(self) -> None:
"""Clean up after agent task completes or is cancelled."""
self._agent_running = False
self._agent_worker = None
# Remove spinner if present
await self._set_spinner(None)
if self._chat_input:
self._chat_input.set_cursor_active(active=True)
# Ensure token display is restored (in case of early cancellation).
# Pass the cached approximate flag so an interrupted "+" isn't clobbered.
self._show_tokens(approximate=self._tokens_approximate)
# Agent-executed commands and tools can mutate repo state (e.g. git
# checkout inside an execute call), so refresh the footer on turn end.
self._schedule_git_branch_refresh()
try:
await self._maybe_drain_deferred()
except Exception:
logger.exception("Failed to drain deferred actions during agent cleanup")
with suppress(Exception):
await self._mount_message(
ErrorMessage(
"A deferred action failed after task completion. "
"You may need to retry the operation.",
),
)
# Process next message from queue if any
if not self._startup_sequence_running:
await self._process_next_from_queue()
@staticmethod
def _convert_messages_to_data(messages: list[Any]) -> list[MessageData]:
"""Convert LangChain messages into lightweight `MessageData` objects.
This is a pure function with zero DOM operations. Tool call matching
happens here: `ToolMessage` results are matched by `tool_call_id` and
stored directly on the corresponding `MessageData`.
Args:
messages: LangChain message objects from a thread checkpoint.
Returns:
Ordered list of `MessageData` ready for `MessageStore.bulk_load`.
"""
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
result: list[MessageData] = []
# Maps tool_call_id -> index into result list
pending_tool_indices: dict[str, int] = {}
for msg in messages:
if isinstance(msg, HumanMessage):
content = (
msg.content if isinstance(msg.content, str) else str(msg.content)
)
if content.startswith("[SYSTEM]"):
continue
# Detect skill invocations persisted via additional_kwargs
skill_meta = (msg.additional_kwargs or {}).get("__skill")
if isinstance(skill_meta, dict) and skill_meta.get("name"):
result.append(
MessageData(
type=MessageType.SKILL,
content="",
skill_name=skill_meta["name"],
skill_description=str(skill_meta.get("description", "")),
skill_source=str(skill_meta.get("source", "")),
skill_args=str(skill_meta.get("args", "")),
skill_body=content,
),
)
else:
result.append(MessageData(type=MessageType.USER, content=content))
elif isinstance(msg, AIMessage):
# Extract text content
content = msg.content
text = ""
if isinstance(content, str):
text = content.strip()
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text += block.get("text", "")
elif isinstance(block, str):
text += block
text = text.strip()
if text:
result.append(MessageData(type=MessageType.ASSISTANT, content=text))
# Track tool calls for later matching
for tc in getattr(msg, "tool_calls", []):
tc_id = tc.get("id")
name = tc.get("name", "unknown")
args = tc.get("args", {})
data = MessageData(
type=MessageType.TOOL,
content="",
tool_name=name,
tool_args=args,
tool_status=ToolStatus.PENDING,
)
result.append(data)
if tc_id:
pending_tool_indices[tc_id] = len(result) - 1
else:
data.tool_status = ToolStatus.REJECTED
elif isinstance(msg, ToolMessage):
tc_id = getattr(msg, "tool_call_id", None)
if tc_id and tc_id in pending_tool_indices:
idx = pending_tool_indices.pop(tc_id)
data = result[idx]
status = getattr(msg, "status", "success")
content = (
msg.content
if isinstance(msg.content, str)
else str(msg.content)
)
if status == "success":
data.tool_status = ToolStatus.SUCCESS
else:
data.tool_status = ToolStatus.ERROR
data.tool_output = content
else:
logger.debug(
"ToolMessage with tool_call_id=%r could not be "
"matched to a pending tool call",
tc_id,
)
else:
logger.debug(
"Skipping unsupported message type %s during history conversion",
type(msg).__name__,
)
# Mark unmatched tool calls as rejected
for idx in pending_tool_indices.values():
result[idx].tool_status = ToolStatus.REJECTED
return result
async def _get_thread_state_values(self, thread_id: str) -> dict[str, Any]:
"""Fetch thread state values for a thread.
In server mode the LangGraph dev server starts with an empty in-memory
thread store, so `aget_state` returns empty state for any thread that
was not registered in the current server session. Calling
`aensure_thread` first registers the thread idempotently so the
subsequent `aget_state` call can read from the checkpointer correctly,
including proper reconstruction of delta channels.
Args:
thread_id: Thread ID to fetch from checkpoint storage.
Returns:
Thread state values keyed by channel name. Returns an empty dict
when no checkpointed values are available.
"""
if not self._agent:
return {}
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
remote_config: dict[str, Any] = {"configurable": {"thread_id": thread_id}}
if remote := self._remote_agent():
await remote.aensure_thread(remote_config)
state = await self._agent.aget_state(config)
if state and state.values:
return dict(state.values)
return {}
async def _fetch_thread_history_data(self, thread_id: str) -> _ThreadHistoryPayload:
"""Fetch and convert stored messages for a thread.
Args:
thread_id: Thread ID to fetch from checkpoint storage.
Returns:
Payload containing converted message data and the persisted
context-token count.
"""
state_values = await self._get_thread_state_values(thread_id)
raw_tokens = state_values.get("_context_tokens")
context_tokens = (
raw_tokens if isinstance(raw_tokens, int) and raw_tokens >= 0 else 0
)
messages = state_values.get("messages", [])
if not messages:
return _ThreadHistoryPayload([], context_tokens)
# RemoteGraph.aget_state returns values as raw JSON dicts; convert to
# LangChain message objects so _convert_messages_to_data works.
if any(isinstance(m, dict) for m in messages):
from langchain_core.messages.utils import convert_to_messages
messages = convert_to_messages(messages)
# Offload conversion so large histories don't block the UI loop.
data = await asyncio.to_thread(self._convert_messages_to_data, messages)
return _ThreadHistoryPayload(data, context_tokens)
async def _upgrade_thread_message_link(
self,
widget: AppMessage,
*,
prefix: str,
thread_id: str,
) -> None:
"""Upgrade a plain thread message to a linked one when URL resolves.
Args:
widget: The already-mounted app message.
prefix: Text prefix before thread ID.
thread_id: Thread ID to resolve.
"""
try:
thread_msg = await self._build_thread_message(prefix, thread_id)
if not isinstance(thread_msg, Content):
logger.debug(
"Skipping thread link upgrade for %s: URL did not resolve",
thread_id,
)
return
if widget.parent is None:
logger.debug(
"Skipping thread link upgrade for %s: widget no longer mounted",
thread_id,
)
return
# Keep serialized content in sync with the rendered content.
widget._content = thread_msg
widget.update(thread_msg)
except Exception:
logger.warning(
"Failed to upgrade thread message link for %s",
thread_id,
exc_info=True,
)
def _schedule_thread_message_link(
self,
widget: AppMessage,
*,
prefix: str,
thread_id: str,
) -> None:
"""Schedule thread URL link resolution and apply updates in the background.
Args:
widget: The message widget to update.
prefix: Text prefix before thread ID.
thread_id: Thread ID to resolve.
"""
self.run_worker(
self._upgrade_thread_message_link(
widget,
prefix=prefix,
thread_id=thread_id,
),
exclusive=False,
)
async def _load_thread_history(
self,
*,
thread_id: str | None = None,
preloaded_payload: _ThreadHistoryPayload | None = None,
) -> None:
"""Load and render message history when resuming a thread.
When `preloaded_payload` is provided (e.g., from `_resume_thread`),
this reuses that data. Otherwise, it fetches checkpoint state from the
agent and converts stored messages into lightweight `MessageData`
objects. The method then bulk-loads into the `MessageStore` and mounts
only the last `WINDOW_SIZE` widgets to reduce DOM operations on large
threads.
Args:
thread_id: Optional explicit thread ID to load.
Defaults to current.
preloaded_payload: Optional pre-fetched history payload for the
thread.
"""
history_thread_id = thread_id or self._lc_thread_id
if not history_thread_id:
logger.debug("Skipping history load: no thread ID available")
return
if preloaded_payload is None and not self._agent:
logger.debug(
"Skipping history load for %s: no active agent and no preloaded data",
history_thread_id,
)
return
try:
# Fetch + convert, or reuse preloaded payload on thread switch.
payload = (
preloaded_payload
if preloaded_payload is not None
else await self._fetch_thread_history_data(history_thread_id)
)
if not payload.messages:
return
# Seed token cache from persisted state
if payload.context_tokens > 0:
self._on_tokens_update(payload.context_tokens)
# 3. Bulk load into store (sets visible window)
_archived, visible = self._message_store.bulk_load(payload.messages)
# 5. Cache container ref (single query)
try:
messages_container = self.query_one("#messages", Container)
except NoMatches:
return
# 6-7. Create and mount only visible widgets (max WINDOW_SIZE)
widgets = [msg_data.to_widget() for msg_data in visible]
if widgets:
await messages_container.mount(*widgets)
# 8. Render content for AssistantMessage after mount
assistant_updates = [
widget.set_content(msg_data.content)
for widget, msg_data in zip(widgets, visible, strict=False)
if isinstance(widget, AssistantMessage) and msg_data.content
]
if assistant_updates:
assistant_results = await asyncio.gather(
*assistant_updates,
return_exceptions=True,
)
for error in assistant_results:
if isinstance(error, Exception):
logger.warning(
"Failed to render assistant history message for %s: %s",
history_thread_id,
error,
)
# 9. Add footer immediately and resolve link asynchronously
thread_msg_widget = AppMessage(f"Resumed thread: {history_thread_id}")
await self._mount_message(thread_msg_widget)
self._schedule_thread_message_link(
thread_msg_widget,
prefix="Resumed thread",
thread_id=history_thread_id,
)
# 10. Scroll once to bottom after history loads
def scroll_to_end() -> None:
with suppress(NoMatches):
chat = self.query_one("#chat", VerticalScroll)
chat.scroll_end(animate=False, immediate=True)
self.set_timer(0.1, scroll_to_end)
except Exception as e: # Resilient history loading
logger.exception(
"Failed to load thread history for %s",
history_thread_id,
)
await self._mount_message(AppMessage(f"Could not load history: {e}"))
async def _mount_message(
self,
widget: Static | AssistantMessage | ToolCallMessage | SkillMessage,
) -> None:
"""Mount a message widget to the messages area.
This method also stores the message data and handles pruning
when the widget count exceeds the maximum.
If the `#messages` container is not present (e.g. the screen has
been torn down during an interruption), the call is silently skipped
to avoid cascading `NoMatches` errors.
Args:
widget: The message widget to mount
"""
try:
messages = self.query_one("#messages", Container)
except NoMatches:
return
# During shutdown (e.g. Ctrl+D mid-stream) the container may still
# be in the DOM tree but already detached, so mount() would raise
# MountError. Bail out silently — the app is exiting anyway.
if not messages.is_attached:
return
# Store message data for virtualization
message_data = MessageData.from_widget(widget)
# Ensure the widget's DOM id matches the store id so that
# features like click-to-show-timestamp can look it up.
if not widget.id:
widget.id = message_data.id
self._message_store.append(message_data)
# Queued-message widgets must always stay at the bottom so they
# remain visually anchored below the current agent response.
if isinstance(widget, QueuedUserMessage):
await messages.mount(widget)
else:
await self._mount_before_queued(messages, widget)
# Prune old widgets if window exceeded
await self._prune_old_messages()
# Scroll to keep input bar visible
try:
input_container = self.query_one("#bottom-app-container", Container)
input_container.scroll_visible()
except NoMatches:
pass
async def _prune_old_messages(self) -> None:
"""Prune oldest message widgets if we exceed the window size.
This removes widgets from the DOM but keeps data in MessageStore
for potential re-hydration when scrolling up.
"""
if not self._message_store.window_exceeded():
return
try:
messages_container = self.query_one("#messages", Container)
except NoMatches:
logger.debug("Skipping pruning: #messages container not found")
return
to_prune = self._message_store.get_messages_to_prune()
if not to_prune:
return
pruned_ids: list[str] = []
for msg_data in to_prune:
try:
widget = messages_container.query_one(f"#{msg_data.id}")
await widget.remove()
pruned_ids.append(msg_data.id)
except NoMatches:
# Widget not found -- do NOT mark as pruned to avoid
# desyncing the store from the actual DOM state
logger.debug(
"Widget %s not found during pruning, skipping",
msg_data.id,
)
if pruned_ids:
self._message_store.mark_pruned(pruned_ids)
def _set_active_message(self, message_id: str | None) -> None:
"""Set the active streaming message (won't be pruned).
Args:
message_id: The ID of the active message, or None to clear.
"""
self._message_store.set_active_message(message_id)
def _sync_message_content(self, message_id: str, content: str) -> None:
"""Sync final message content back to the store after streaming.
Called when streaming finishes so the store holds the full text
instead of the empty string captured at mount time.
Args:
message_id: The ID of the message to update.
content: The final content after streaming.
"""
self._message_store.update_message(
message_id,
content=content,
is_streaming=False,
)
async def _clear_messages(self) -> None:
"""Clear the messages area and message store."""
# Clear the message store first
self._message_store.clear()
try:
messages = self.query_one("#messages", Container)
await messages.remove_children()
except NoMatches:
logger.warning(
"Messages container (#messages) not found during clear; "
"UI may be out of sync with message store",
)
def _pop_last_queued_message(self) -> None:
"""Remove the most recently queued message (LIFO).
If the chat input is empty the evicted text is restored there so the
user can edit and re-submit. Otherwise the message is discarded. The
toast message distinguishes between the two outcomes.
Caller must ensure `_pending_messages` is non-empty. A defensive guard
is included in case of async TOCTOU races.
"""
if not self._pending_messages:
return
msg = self._pending_messages.pop()
if self._queued_widgets:
widget = self._queued_widgets.pop()
widget.remove()
else:
logger.warning(
"Queued-widget deque empty while pending-messages was not; "
"widget/message tracking may be out of sync",
)
if not self._chat_input:
logger.warning(
"Chat input unavailable during queue pop; "
"message text cannot be restored: %s",
msg.text[:60],
)
self.notify("Queued message discarded", timeout=2)
return
if not self._chat_input.value.strip():
self._chat_input.value = msg.text
self.notify("Queued message moved to input", timeout=2)
else:
self.notify("Queued message discarded (input not empty)", timeout=3)
def _cleanup_external_event_source_sync(self) -> None:
"""Synchronously close the external event listener and unlink its socket.
Called from `exit()` because the event loop is about to be torn
down and the task's async `finally` would never complete. Close
the asyncio server (releases the file descriptor) and unlink the
socket path so we never leave stale entries on disk.
"""
source = self._external_event_source
if source is None:
return
from deepagents_code.event_bus import UnixSocketEventSource
if isinstance(source, UnixSocketEventSource):
server = source._server # synchronous teardown peer
source._server = None
if server is not None:
with suppress(Exception):
server.close()
with suppress(FileNotFoundError):
from deepagents_code.event_bus import _unlink_existing_socket
try:
_unlink_existing_socket(source.path)
except FileExistsError:
logger.warning(
"Leaving non-socket entry at %s during exit",
source.path,
)
except OSError as exc:
logger.warning(
"Failed to unlink event socket %s: %s",
source.path,
exc,
)
def _discard_queue(self) -> None:
"""Clear pending messages, deferred actions, and queued widgets."""
self._pending_messages.clear()
for w in self._queued_widgets:
w.remove()
self._queued_widgets.clear()
self._deferred_actions.clear()
def _force_interrupt_active_work(self) -> None:
"""Cancel in-flight work before the standard `/clear` path runs.
Rejects pending approvals, cancels pending ask-user prompts, kills
the shell worker, kills the agent worker, and drops the queued
message backlog. UI clearing itself happens in the calling
`/clear` handler. Each widget interaction is best-effort: a torn-
down widget should not abort the interrupt sequence, but the
underlying error is logged so regressions are visible.
"""
if self._pending_approval_widget:
try:
self._pending_approval_widget.action_select_reject()
except (AttributeError, RuntimeError):
logger.exception("force-clear: failed to reject pending approval")
if self._pending_ask_user_widget:
try:
self._pending_ask_user_widget.action_cancel()
except (AttributeError, RuntimeError):
logger.exception("force-clear: failed to cancel pending ask-user")
if self._shell_running and self._shell_worker:
self._shell_worker.cancel()
if self._agent_running and self._agent_worker:
self._agent_worker.cancel()
self._discard_queue()
def _defer_action(self, action: DeferredAction) -> None:
"""Queue a deferred action, replacing any existing action of the same kind.
Last-write-wins: if the user selects a model twice while busy, only the
final selection runs.
Args:
action: The deferred action to queue.
"""
self._deferred_actions = [
a for a in self._deferred_actions if a.kind != action.kind
]
self._deferred_actions.append(action)
async def _maybe_drain_deferred(self) -> None:
"""Drain deferred actions unless startup sequencing is still in progress."""
if not self._connecting and not self._startup_sequence_running:
await self._drain_deferred_actions()
async def _drain_deferred_actions(self) -> None:
"""Execute deferred actions queued while busy (e.g. model/thread switch)."""
while self._deferred_actions:
action = self._deferred_actions.pop(0)
try:
await action.execute()
except Exception:
logger.exception(
"Failed to execute deferred action %r (callable=%r)",
action.kind,
action.execute,
)
label = action.kind.replace("_", " ")
try:
await self._mount_message(
ErrorMessage(
f"Deferred {label} failed unexpectedly. "
"You may need to retry the operation.",
),
)
except Exception:
logger.debug(
"Could not mount error message for deferred %r",
action.kind,
exc_info=True,
)
def _cancel_worker(self, worker: Worker[None] | None) -> None:
"""Discard the message queue and cancel an active worker.
Args:
worker: The worker to cancel.
"""
self._discard_queue()
if worker is not None:
worker.cancel()
def action_quit_or_interrupt(self) -> None:
"""Handle Ctrl+C - interrupt agent, reject approval, or quit on double press.
Priority order:
1. If shell command is running, kill it
2. If approval menu is active, reject it
3. If agent is running, interrupt it (preserve input)
4. If double press (quit_pending), quit
5. Otherwise show quit hint
"""
# If shell command is running, cancel the worker
if self._shell_running and self._shell_worker:
self._cancel_worker(self._shell_worker)
self._quit_pending = False
return
# If approval menu is active, reject it before cancelling the agent worker.
# During HITL the agent worker remains active while awaiting approval,
# so this must be checked before the worker cancellation branch to
# avoid leaving a stale approval widget interactive after interruption.
if self._pending_approval_widget:
self._pending_approval_widget.action_select_reject()
self._quit_pending = False
return
# If ask_user menu is active, cancel it before cancelling the agent
# worker, following the same pattern as the approval widget above.
if self._pending_ask_user_widget:
self._pending_ask_user_widget.action_cancel()
self._quit_pending = False
return
# If agent is running, interrupt it and discard queued messages
if self._agent_running and self._agent_worker:
self._cancel_worker(self._agent_worker)
self._quit_pending = False
return
# Double Ctrl+C to quit
if self._quit_pending:
self.exit()
else:
self._arm_quit_pending("Ctrl+C")
def _arm_quit_pending(self, shortcut: str) -> None:
"""Set the pending-quit flag and show a matching hint.
Args:
shortcut: The key chord to show in the quit hint.
"""
self._quit_pending = True
quit_timeout = 3
self.notify(
f"Press {shortcut} again to quit",
timeout=quit_timeout,
markup=False,
)
self.set_timer(quit_timeout, lambda: setattr(self, "_quit_pending", False))
def action_interrupt(self) -> None:
"""Handle escape key.
Priority order:
1. If modal screen is active, dismiss it
2. If completion popup is open, dismiss it
3. If input is in command/shell mode, exit to normal mode
4. If shell command is running, kill it
5. If approval menu is active, reject it
6. If ask-user menu is active, cancel it
7. If queued messages exist, pop the last one (LIFO)
8. If agent is running, interrupt it
"""
from deepagents_code.widgets.thread_selector import ThreadSelectorScreen
if (
isinstance(self.screen, ThreadSelectorScreen)
and self.screen.is_delete_confirmation_open
):
self.screen.action_cancel()
return
# If a modal screen is active, let it cancel itself (so it can
# restore state, e.g. the theme selector reverts the previewed theme).
# Fall back to a plain dismiss for modals without action_cancel.
if isinstance(self.screen, ModalScreen):
cancel = getattr(self.screen, "action_cancel", None)
if cancel is not None:
cancel()
else:
self.screen.dismiss(None)
return
# Close completion popup or exit slash/shell command mode
if self._chat_input:
if self._chat_input.dismiss_completion():
return
if self._chat_input.exit_mode():
return
# If shell command is running, cancel the worker
if self._shell_running and self._shell_worker:
self._cancel_worker(self._shell_worker)
return
# If approval menu is active, reject it before cancelling the agent worker.
# During HITL the agent worker remains active while awaiting approval,
# so this must be checked before the worker cancellation branch to
# avoid leaving a stale approval widget interactive after interruption.
if self._pending_approval_widget:
self._pending_approval_widget.action_select_reject()
return
# If ask_user menu is active, cancel it before cancelling the agent
# worker, following the same pattern as the approval widget above.
if self._pending_ask_user_widget:
self._pending_ask_user_widget.action_cancel()
return
# If queued messages exist, pop the last one (LIFO) instead of
# interrupting the agent. This lets the user retract queued messages
# one at a time; once the queue is empty the next ESC will interrupt.
if self._pending_messages:
self._pop_last_queued_message()
return
# If agent is running, interrupt it and discard queued messages
if self._agent_running and self._agent_worker:
self._cancel_worker(self._agent_worker)
return
def action_quit_app(self) -> None:
"""Handle quit action (Ctrl+D)."""
from deepagents_code.widgets.auth import (
AuthPromptScreen,
DeleteCredentialConfirmScreen,
)
from deepagents_code.widgets.thread_selector import (
DeleteThreadConfirmScreen,
ThreadSelectorScreen,
)
if isinstance(self.screen, ThreadSelectorScreen):
self.screen.action_delete_thread()
return
if isinstance(self.screen, AuthPromptScreen):
self.screen.action_delete_stored()
return
if isinstance(
self.screen,
(DeleteThreadConfirmScreen, DeleteCredentialConfirmScreen),
):
if self._quit_pending:
self.exit()
return
self._arm_quit_pending("Ctrl+D")
return
self.exit()
def exit(
self,
result: Any = None, # noqa: ANN401 # Dynamic LangGraph stream result type
return_code: int = 0,
message: Any = None, # noqa: ANN401 # Dynamic LangGraph message type
) -> None:
"""Exit the app after shutting down background resources.
Args:
result: Return value passed to the app runner.
return_code: Exit code (non-zero for errors).
message: Optional message to display on exit.
"""
# Merge in-flight turn stats before any cleanup that might raise.
# When the agent worker is cancelled (e.g. Ctrl+D during a pending tool
# call), the worker's finally block will see _inflight_turn_stats is
# already None and skip the merge.
inflight = self._inflight_turn_stats
if inflight is not None:
self._inflight_turn_stats = None
if not inflight.wall_time_seconds:
inflight.wall_time_seconds = (
time.monotonic() - self._inflight_turn_start
)
self._session_stats.merge(inflight)
# Discard queued messages so _cleanup_agent_task won't try to
# process them after the event loop is torn down, and cancel
# active workers so their subprocesses are terminated
# (SIGTERM → SIGKILL) instead of being orphaned.
self._discard_queue()
if self._shell_running and self._shell_worker:
self._shell_worker.cancel()
if self._agent_running and self._agent_worker:
self._agent_worker.cancel()
if self._git_branch_refresh_task is not None:
self._git_branch_refresh_task.cancel()
if self._external_event_source_task is not None:
self._external_event_source_task.cancel()
# Cancellation alone is not enough: the task's `finally` block runs
# asynchronously, and the event loop is about to be torn down by
# `super().exit()`. Synchronously close the server and unlink the
# socket file so we never leave a stale entry on disk.
if self._external_event_source is not None:
self._cleanup_external_event_source_sync()
self._external_event_source = None
# Dispatch synchronously — the event loop is about to be torn down by
# super().exit(), so an async task would never complete.
from deepagents_code.hooks import _dispatch_hook_sync, _load_hooks
hooks = _load_hooks()
if hooks:
payload = json.dumps(
{
"event": "session.end",
"thread_id": getattr(self, "_lc_thread_id", ""),
},
).encode()
_dispatch_hook_sync("session.end", payload, hooks)
from deepagents_code.terminal_escape import reset_terminal_background
try:
reset_terminal_background()
except Exception:
# Cosmetic only: must never raise during shutdown.
logger.warning(
"reset_terminal_background raised unexpectedly",
exc_info=True,
)
restore_iterm_cursor_guide()
super().exit(result=result, return_code=return_code, message=message)
def action_toggle_auto_approve(self) -> None:
"""Toggle auto-approve mode for the current session.
When enabled, all tool calls (shell execution, file writes/edits,
web search, URL fetch) run without prompting. Updates the status
bar indicator and session state.
"""
from deepagents_code.widgets.agent_selector import AgentSelectorScreen
from deepagents_code.widgets.auth import AuthManagerScreen
from deepagents_code.widgets.mcp_viewer import MCPViewerScreen
from deepagents_code.widgets.notification_center import (
NotificationCenterScreen,
)
from deepagents_code.widgets.notification_detail import NotificationDetailScreen
from deepagents_code.widgets.notification_settings import (
NotificationSettingsScreen,
)
from deepagents_code.widgets.theme_selector import ThemeSelectorScreen
from deepagents_code.widgets.thread_selector import ThreadSelectorScreen
from deepagents_code.widgets.update_available import UpdateAvailableScreen
if isinstance(self.screen, ThreadSelectorScreen):
self.screen.action_focus_previous_filter()
return
if isinstance(
self.screen,
(ThemeSelectorScreen, AgentSelectorScreen, AuthManagerScreen),
):
self.screen.action_cursor_up()
return
if isinstance(self.screen, NotificationSettingsScreen):
self.screen.focus_previous()
return
if isinstance(
self.screen,
(UpdateAvailableScreen, NotificationCenterScreen, NotificationDetailScreen),
):
self.screen.action_move_up()
return
if isinstance(self.screen, MCPViewerScreen):
self.screen.action_move_up()
return
# shift+tab is reused for navigation inside modal screens (e.g.
# ModelSelectorScreen); skip the toggle so it doesn't fire through.
if isinstance(self.screen, ModalScreen):
return
# Delegate shift+tab to ask_user navigation when interview is active.
if self._pending_ask_user_widget is not None:
self._pending_ask_user_widget.action_previous_question()
return
self._auto_approve = not self._auto_approve
if self._status_bar:
self._status_bar.set_auto_approve(enabled=self._auto_approve)
if self._session_state:
self._session_state.auto_approve = self._auto_approve
def action_toggle_tool_output(self) -> None:
"""Toggle expand/collapse of the most recent tool output or skill body."""
# Pending ask_user takes precedence so Ctrl+O toggles the question card.
if self._pending_ask_user_widget is not None:
try:
tool_messages = list(self.query(ToolCallMessage))
except NoMatches:
tool_messages = []
for tool_msg in reversed(tool_messages):
if tool_msg.has_expandable_args:
tool_msg.toggle_args()
return
# Try skill messages first (most recent collapsible content)
try:
skill_messages = list(self.query(SkillMessage))
except NoMatches:
skill_messages = []
for skill_msg in reversed(skill_messages):
if skill_msg._stripped_body.strip():
skill_msg.toggle_body()
return
# Fall back to tool messages with output or expandable args
try:
tool_messages = list(self.query(ToolCallMessage))
except NoMatches:
tool_messages = []
for tool_msg in reversed(tool_messages):
if tool_msg.has_output:
tool_msg.toggle_output()
return
if tool_msg.has_expandable_args:
tool_msg.toggle_args()
return
# Approval menu action handlers (delegated from App-level bindings)
# NOTE: These only activate when approval widget is pending
# AND input is not focused
def action_approval_up(self) -> None:
"""Handle up arrow in approval menu."""
# Only handle if approval is active
# (input handles its own up for history/completion)
if self._pending_approval_widget and not self._is_input_focused():
self._pending_approval_widget.action_move_up()
def action_approval_down(self) -> None:
"""Handle down arrow in approval menu."""
if self._pending_approval_widget and not self._is_input_focused():
self._pending_approval_widget.action_move_down()
def action_approval_select(self) -> None:
"""Handle enter in approval menu."""
# Only handle if approval is active AND input is not focused
if self._pending_approval_widget and not self._is_input_focused():
self._pending_approval_widget.action_select()
def _is_input_focused(self) -> bool:
"""Check if the chat input (or its text area) has focus.
Returns:
True if the input widget has focus, False otherwise.
"""
if not self._chat_input:
return False
focused = self.focused
if focused is None:
return False
# Check if focused widget is the text area inside chat input
return focused.id == "chat-input" or focused in self._chat_input.walk_children()
def action_approval_yes(self) -> None:
"""Handle yes/1 in approval menu."""
if self._pending_approval_widget:
self._pending_approval_widget.action_select_approve()
def action_approval_auto(self) -> None:
"""Handle auto/2 in approval menu."""
if self._pending_approval_widget:
self._pending_approval_widget.action_select_auto()
def action_approval_no(self) -> None:
"""Handle no/3 in approval menu."""
if self._pending_approval_widget:
self._pending_approval_widget.action_select_reject()
def action_approval_escape(self) -> None:
"""Handle escape in approval menu - reject."""
if self._pending_approval_widget:
self._pending_approval_widget.action_select_reject()
async def action_open_editor(self) -> None:
"""Open the current prompt text in an external editor ($VISUAL/$EDITOR)."""
from deepagents_code.editor import open_in_editor
chat_input = self._chat_input
if not chat_input or not chat_input._text_area:
return
current_text = chat_input._text_area.text or ""
edited: str | None = None
try:
with self.suspend():
edited = open_in_editor(current_text)
except Exception:
logger.warning("External editor failed", exc_info=True)
self.notify(
"External editor failed. Check $VISUAL/$EDITOR.",
severity="error",
timeout=5,
)
chat_input.focus_input()
return
if edited is not None:
chat_input._text_area.text = edited
lines = edited.split("\n")
chat_input._text_area.move_cursor((len(lines) - 1, len(lines[-1])))
chat_input.focus_input()
def on_paste(self, event: Paste) -> None:
"""Route unfocused paste events to chat input for drag/drop reliability."""
if not self._chat_input:
return
if isinstance(self.screen, ModalScreen):
return
if (
self._pending_approval_widget
or self._pending_ask_user_widget
or self._is_input_focused()
):
return
if self._chat_input.handle_external_paste(event.text):
event.prevent_default()
event.stop()
def on_app_focus(self) -> None:
"""Restore chat input focus and resume cursor blink on terminal focus regain.
When the user opens a link via `webbrowser.open`, OS focus shifts to
the browser. On returning to the terminal, Textual fires `AppFocus`
(requires a terminal that supports FocusIn events). Re-focusing the chat
input here keeps it ready for typing.
"""
if self._chat_input is None:
return
self._chat_input.set_cursor_blink(blink=True)
if isinstance(self.screen, ModalScreen):
return
if self._pending_approval_widget or self._pending_ask_user_widget:
return
self._chat_input.focus_input()
def on_app_blur(self) -> None:
"""Pause the chat input cursor blink when the terminal loses OS focus.
`TextArea` pauses its own blink when its `has_focus` flips, but
`AppBlur` does not change widget focus, so we toggle `cursor_blink`
manually.
"""
if self._chat_input is None:
return
self._chat_input.set_cursor_blink(blink=False)
def on_click(self, event: Click) -> None:
"""Handle clicks anywhere in the terminal.
Clicks on registered actionable toasts open the notification
center. The toast itself dismisses as normal; we only piggyback
on the click. Other clicks restore focus to the chat input.
"""
widget = event.widget
if isinstance(widget, _Toast):
identity = _toast_identity(widget, app=self)
if identity is not None and self._notice_registry.is_actionable_toast(
identity,
):
self.call_after_refresh(self._open_notification_center)
return
if not self._chat_input:
return
if isinstance(self.screen, ModalScreen):
return
# Don't steal focus from approval or ask_user widgets
if self._pending_approval_widget or self._pending_ask_user_widget:
return
self.call_after_refresh(self._chat_input.focus_input)
def on_mouse_up(self, event: MouseUp) -> None: # noqa: ARG002 # Textual event handler signature
"""Copy selection to clipboard on mouse release."""
from deepagents_code.clipboard import copy_selection_to_clipboard
copy_selection_to_clipboard(self)
# =========================================================================
# Model Switching
# =========================================================================
def _build_model_selector_screen(
self,
*,
curated: bool = False,
result_callback: Callable[[tuple[str, str] | None], None] | None = None,
) -> ModalScreen:
"""Build the model selector screen with current app model state.
Args:
curated: Whether to use a shorter onboarding model list.
result_callback: Optional direct callback for selector results.
Returns:
Configured model selector modal.
"""
from deepagents_code.config import settings
from deepagents_code.widgets.model_selector import ModelSelectorScreen
return ModelSelectorScreen(
current_model=settings.model_name,
current_provider=settings.model_provider,
cli_profile_override=self._profile_override,
curated=curated,
title="Choose a Recommended Model" if curated else None,
description=(
"These models have performed well in Deep Agents evals and are "
"a solid starting set. You can explore the full model list "
"later with /model."
if curated
else None
),
result_callback=result_callback,
)
async def _prompt_model_selector(
self,
*,
curated: bool = False,
) -> tuple[str, str] | None:
"""Show the model selector and wait for a choice.
Args:
curated: Whether to use the shorter onboarding model list.
Returns:
`(model_spec, provider)` on selection, or `None` on cancel.
"""
screen = self._build_model_selector_screen(curated=curated)
result = await self._push_screen_wait(screen)
return result if result is None or isinstance(result, tuple) else None
async def _show_model_selector(
self,
*,
extra_kwargs: dict[str, Any] | None = None,
) -> None:
"""Show interactive model selector as a modal screen.
Args:
extra_kwargs: Extra constructor kwargs from `--model-params`.
"""
from functools import partial
def handle_result(result: tuple[str, str] | None) -> None:
"""Handle the model selector result."""
if result is not None:
model_spec, _ = result
if self._agent_running or self._shell_running or self._connecting:
self._defer_action(
DeferredAction(
kind="model_switch",
execute=partial(
self._switch_model,
model_spec,
extra_kwargs=extra_kwargs,
),
),
)
self.notify(
"Model will switch after current task completes.",
timeout=3,
)
else:
self.call_later(
partial(
self._switch_model,
model_spec,
extra_kwargs=extra_kwargs,
),
)
# Refocus input after modal closes
if self._chat_input:
self._chat_input.focus_input()
screen = self._build_model_selector_screen()
self.push_screen(screen, handle_result)
def _register_custom_themes(self) -> None:
"""Register all custom themes (built-in LC + user-defined) with Textual."""
for name, entry in theme.get_registry().items():
if entry.custom:
c = entry.colors
try:
self.register_theme(
Theme(
name=name,
primary=c.primary,
secondary=c.secondary,
accent=c.accent,
foreground=c.foreground,
background=c.background,
surface=c.surface,
panel=c.panel,
warning=c.warning,
error=c.error,
success=c.success,
dark=entry.dark,
variables={
"footer-key-foreground": c.primary,
},
),
)
except Exception:
logger.warning(
"Failed to register theme '%s'; skipping",
name,
exc_info=True,
)
async def _show_theme_selector(self) -> None:
"""Show interactive theme selector as a modal screen."""
from deepagents_code.widgets.theme_selector import ThemeSelectorScreen
# Capture scroll state. The submit handler may have already caused
# a reflow that re-anchored to the bottom, so we save the *current*
# offset and release the anchor to prevent further drift while the
# modal is open.
chat = self.query_one("#chat", VerticalScroll)
saved_y = chat.scroll_y
was_anchored = chat.is_anchored
chat.release_anchor()
def handle_result(result: str | None) -> None:
"""Handle the theme selector result."""
if result is not None:
self.theme = result
self.sync_terminal_background()
self.refresh_css(animate=False)
async def _persist() -> None:
try:
status = await asyncio.to_thread(
_save_theme_preference_result,
result,
)
if status.message is not None:
self.notify(
status.message,
severity=status.severity,
timeout=6,
markup=False,
)
except Exception:
logger.warning(
"Failed to persist theme preference",
exc_info=True,
)
self.notify(
"Theme applied for this session but could not be saved.",
severity="warning",
timeout=6,
markup=False,
)
self.call_later(_persist)
# Restore scroll position, then re-anchor if it was anchored.
chat.scroll_to(y=saved_y, animate=False)
if was_anchored:
chat.anchor()
if self._chat_input:
self._chat_input.focus_input()
screen = ThemeSelectorScreen(
current_theme=self.theme,
terminal_default=_load_terminal_default(),
)
self.push_screen(screen, handle_result)
async def _show_agent_selector(self) -> None:
"""Show the interactive agent selector modal."""
from deepagents_code.agent import get_available_agent_names
from deepagents_code.model_config import load_default_agent
from deepagents_code.widgets.agent_selector import AgentSelectorScreen
agent_names, default_agent = await asyncio.gather(
asyncio.to_thread(get_available_agent_names),
asyncio.to_thread(load_default_agent),
)
def handle_result(result: str | None) -> None:
"""Handle the agent selector result."""
if result is not None and result != self._assistant_id:
self._switch_agent(result)
if self._chat_input:
self._chat_input.focus_input()
screen = AgentSelectorScreen(
current_agent=self._assistant_id,
agent_names=agent_names,
default_agent=default_agent,
)
self.push_screen(screen, handle_result)
async def _show_auth_manager(self) -> None:
"""Show the `/auth` credential manager modal.
State changes persist via `auth_store`; the manager refreshes its
own option labels after each save/delete, so this caller only needs
to refocus the chat input on close.
"""
from deepagents_code.widgets.auth import AuthManagerScreen
def handle_result(_result: None) -> None:
if self._chat_input:
self._chat_input.focus_input()
task = asyncio.create_task(self._maybe_start_deferred_server_from_default())
task.add_done_callback(_log_task_exception)
self.push_screen(AuthManagerScreen(), handle_result)
def _switch_agent(self, agent_name: str) -> None:
"""Switch to a different agent and hot-restart the backing server.
Runs guard checks (remote-server mode, mid-run, re-entry, missing
agent directory), then kicks off `_restart_server_for_agent_swap` as
a worker. That worker restarts the langgraph subprocess with the new
`assistant_id` so the new agent's `AGENTS.md` is actually loaded —
memory, skills, thread, and system prompt all align.
Args:
agent_name: The name of the agent to switch to.
"""
from deepagents_code.config import settings
if agent_name == self._assistant_id:
return
if self._server_kwargs is None:
# Remote-server mode: we don't own the subprocess, so we can't
# restart it. Changing identity locally would leave the running
# server's system prompt pointing at a different agent.
self.notify(
"Cannot switch agents against a remote server. "
"Relaunch the app with -a <name> instead.",
severity="warning",
markup=False,
)
return
if self._server_proc is None:
if self._connecting:
async def _deferred_switch() -> None: # noqa: RUF029 # DeferredAction requires an awaitable; the UI mutation must stay on the main thread.
self._switch_agent(agent_name)
self._defer_action(
DeferredAction(
kind="agent_switch",
execute=_deferred_switch,
),
)
self.notify(
"Agent will switch after connection completes.",
timeout=3,
markup=False,
)
return
self.notify(
"Cannot switch agents until the local server is ready.",
severity="warning",
markup=False,
)
return
if self._agent_running or self._shell_running:
self.notify(
"Cannot switch agents while a task is running. "
"Interrupt or wait for it to finish first.",
severity="warning",
markup=False,
)
return
if self._agent_switching:
self.notify(
"Agent switch already in progress.",
severity="warning",
markup=False,
)
return
try:
agent_dir_exists = (settings.user_deepagents_dir / agent_name).is_dir()
except OSError:
logger.warning(
"Could not stat agent directory for %r",
agent_name,
exc_info=True,
)
agent_dir_exists = False
if not agent_dir_exists:
self.notify(
f"Agent {agent_name!r} is no longer available.",
severity="warning",
markup=False,
)
return
self._agent_switching = True
self.run_worker(
self._restart_server_for_agent_swap(agent_name),
exclusive=True,
group="agent-switch-restart",
)
async def _restart_server_for_agent_swap(self, agent_name: str) -> None:
"""Restart the langgraph server with a new `assistant_id`.
Runs in three phases so failures are attributable:
1. **UI teardown** — flip banner to connecting, clear chat, reject
pending HITL widgets, reset the thread. Failures here notify the
user and return early; the previous server is still alive and
identity is untouched.
2. **Server restart** — mutate `_assistant_id`, stage the new
`DEEPAGENTS_CODE_SERVER_ASSISTANT_ID` env var, call
`ServerProcess.restart()`, and rebuild the `RemoteAgent` against
the (possibly new) server URL. A failure rolls back identity and
posts `ServerStartFailed` because the old subprocess is dead.
3. **Confirmation** — show "Switched to X", optional resume hint,
persist the recent agent, and drain any messages queued during
the swap.
Args:
agent_name: The name of the agent to switch to.
"""
from deepagents_code._env_vars import SERVER_ENV_PREFIX
from deepagents_code.remote_client import RemoteAgent as _RemoteAgent
def _build_agent(url: str) -> Any: # noqa: ANN401 # see docstring
"""Build a new `RemoteAgent` typed as `Any`.
Returns `Any` so `self._agent`'s attribute type stays aligned
with the permissive type the startup path assigns, avoiding a
union that would trip call-site type checks on
`aget_state(config)` et al.
Args:
url: Server base URL to point the new client at.
Returns:
A fresh `RemoteAgent`, exposed as `Any`.
"""
return _RemoteAgent(url=url, graph_name="agent")
previous_agent = self._assistant_id
previous_default_agent = self._default_assistant_id
previous_thread_id = self._lc_thread_id
# Only offer a resume hint if the previous thread produced agent-side
# output. `USER` alone is not enough: local-only flows (`/update`,
# `!shell`, most slash commands) mount a `UserMessage` widget without
# ever invoking the server, so no checkpoint exists and `-r <thread>`
# would fail. `ASSISTANT` / `TOOL` / `SKILL` entries only land in the
# store after a server round-trip, which implies a checkpoint row.
checkpoint_signal_types = {
MessageType.ASSISTANT,
MessageType.TOOL,
MessageType.SKILL,
}
previous_thread_has_agent_output = any(
msg.type in checkpoint_signal_types
for msg in self._message_store.get_all_messages()
)
server_proc = self._server_proc
if server_proc is None:
# Guarded in _switch_agent, but the worker runs in the next tick
# so re-check to keep the type narrow.
self._agent_switching = False
return
try:
# Phase 1: UI teardown. A failure here does NOT mean the server
# is gone — we notify the user and bail out with the previous
# agent still live. Only Phase 2 escalates to ServerStartFailed.
try:
self._connecting = True
self._agent = None
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_connecting()
except NoMatches:
pass
if self._chat_input:
self._chat_input.set_cursor_active(active=False)
# Reject pending HITL prompts — they're bound to the old
# server's in-flight request and won't be resolved after
# restart. Wrap each call narrowly so a widget-cleanup bug
# can't abort the swap.
if self._pending_approval_widget is not None:
try:
self._pending_approval_widget.action_select_reject()
except Exception:
logger.debug(
"Failed to reject pending approval during agent swap",
exc_info=True,
)
if self._pending_ask_user_widget is not None:
try:
await self._pending_ask_user_widget.remove()
except Exception:
logger.debug(
"Failed to remove pending ask_user during agent swap",
exc_info=True,
)
self._pending_ask_user_widget = None
self._pending_messages.clear()
for widget in self._queued_widgets:
try:
await widget.remove()
except Exception:
logger.debug(
"Failed to remove queued widget during agent swap",
exc_info=True,
)
self._queued_widgets.clear()
self._deferred_actions.clear()
await self._clear_messages()
self._context_tokens = 0
self._tokens_approximate = False
self._update_tokens(0)
self._update_status("")
if self._session_state:
new_thread_id = self._session_state.reset_thread()
self._lc_thread_id = new_thread_id
self._update_welcome_banner(
new_thread_id,
missing_message=(
"Welcome banner not found during agent switch to %s"
),
warn_if_missing=False,
)
except Exception:
logger.exception(
"UI teardown failed during agent swap to %r",
agent_name,
)
# Restore the previous-agent UI state so the user isn't
# stuck on a permanent "Connecting..." banner.
self._connecting = False
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_connected(
self._mcp_tool_count,
mcp_unauthenticated=self._mcp_unauthenticated,
mcp_errored=self._mcp_errored,
)
except NoMatches:
pass
self.notify(
f"Could not prepare to switch to {agent_name!r}. "
"Staying on current agent.",
severity="error",
markup=False,
)
return
# Phase 2: server restart. Identity is mutated BEFORE
# `restart()` so the subprocess picks up the new assistant_id
# from the staged env override; on failure, both are rolled
# back and the old server is confirmed dead (ServerStartFailed).
# Picker switches are explicit user choice, so update both the
# session id and the persisted default.
self._assistant_id = agent_name
self._default_assistant_id = agent_name
if self._server_kwargs is not None:
self._server_kwargs["assistant_id"] = agent_name
try:
server_proc.update_env(
**{f"{SERVER_ENV_PREFIX}ASSISTANT_ID": agent_name},
)
await server_proc.restart()
# `ServerProcess.restart()` may rebind to a different port
# if the original is still in TIME_WAIT, so rebuild the
# client against the current URL rather than reusing it.
self._agent = _build_agent(server_proc.url)
except Exception as exc:
self._assistant_id = previous_agent
self._default_assistant_id = previous_default_agent
if self._server_kwargs is not None:
self._server_kwargs["assistant_id"] = previous_agent
self._agent = None
self._connecting = False
logger.exception(
"Server restart failed during agent swap to %r",
agent_name,
)
self.post_message(self.ServerStartFailed(error=exc))
return
# Phase 3: confirmation. Past here all failures are
# cosmetic — the new server is healthy.
self._connecting = False
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_connected(self._mcp_tool_count)
except NoMatches:
pass
# Refresh skills so /skill: autocomplete reflects the new agent's
# SKILL.md files.
self.run_worker(
self._discover_skills(),
exclusive=True,
group="agent-switch-skill-discovery",
)
# Persist the swap so a bare `deepagents` relaunch brings the
# user back to this agent (same pattern as `save_recent_model`).
# Offloaded to a thread to avoid blocking the event loop on disk I/O.
from deepagents_code.model_config import save_recent_agent
saved = await asyncio.to_thread(save_recent_agent, agent_name)
if not saved:
logger.warning(
"Could not persist recent agent %r to config; "
"next bare launch will not return to it",
agent_name,
)
# Mount the "Switched to X" confirmation BEFORE surfacing any
# save-failure toast. Otherwise the toast hovers next to a
# success line that scrolls past, which makes the causality
# confusing — the user reads success while the toast warns.
confirmation = Content.from_markup(
"Switched to $name. New thread started.",
name=agent_name,
)
await self._mount_message(AppMessage(confirmation))
if not saved:
# Surface the failure visibly — silent logger.warnings
# leave users wondering why their picker selection didn't
# stick across launches. See `model_config.save_recent_agent`
# for the underlying I/O codepath.
self.notify(
"Could not save recent agent to config; "
"next bare launch will not return to it.",
severity="warning",
timeout=6,
markup=False,
)
# Surface a resume command for the previous session so the
# previous thread isn't stranded out of reach. `-r <thread>`
# alone is enough: `_resolve_resume_thread` infers the owning
# agent from persisted thread metadata via `get_thread_agent`.
# Build via `from_markup` so a thread ID with stray brackets
# can't corrupt rendering. See checkpoint-gating rationale on
# `previous_thread_has_agent_output` above.
if previous_thread_id and previous_thread_has_agent_output:
resume_hint = Content.from_markup(
"[dim]Relaunch with[/dim] dcode -r $thread "
"[dim]to resume the previous thread.[/dim]",
thread=previous_thread_id,
)
await self._mount_message(AppMessage(resume_hint))
# Drain any messages the user typed after we cleared the queue
# but before the new server was ready.
if self._pending_messages and not self._agent_running:
self.call_after_refresh(
lambda: asyncio.create_task(self._process_next_from_queue()),
)
finally:
self._agent_switching = False
if self._chat_input:
self._chat_input.set_cursor_active(active=not self._agent_running)
async def _show_notification_settings(self) -> None:
"""Show notification settings modal."""
from deepagents_code.model_config import is_warning_suppressed
from deepagents_code.widgets.notification_settings import (
WARNING_TOGGLES,
NotificationSettingsScreen,
)
suppressed: set[str] = set()
try:
for key, _ in WARNING_TOGGLES:
if await asyncio.to_thread(is_warning_suppressed, key):
suppressed.add(key)
except Exception:
logger.warning("Failed to read notification settings", exc_info=True)
suppressed = set()
self.notify(
"Could not read notification preferences. Showing defaults.",
severity="warning",
timeout=6,
markup=False,
)
def handle_result(_result: None) -> None:
if self._chat_input:
self._chat_input.focus_input()
screen = NotificationSettingsScreen(suppressed=suppressed)
self.push_screen(screen, handle_result)
def _notify_actionable(
self,
notification: PendingNotification,
*,
severity: Literal["information", "warning", "error"] = "information",
timeout: float | None = None,
action_hint: str = "Press ctrl+n to review and take action.",
) -> None:
"""Register *notification* and post its actionable toast.
Posts the toast as a raw `Notification` so the identity can be
captured and bound to the registry entry for click routing.
Args:
notification: Registry entry to register and surface.
severity: Toast severity banner color.
timeout: Seconds the toast stays on screen (defaults to
`App.NOTIFICATION_TIMEOUT`).
action_hint: Final call-to-action line for the toast.
"""
self._notice_registry.add(notification)
toast_body = f"{notification.body}\n\n{action_hint}"
effective_timeout = (
timeout if timeout is not None else self.NOTIFICATION_TIMEOUT
)
# `markup=False` is load-bearing: `notification.body` can carry
# dynamic content (tool names, versions, URLs, exception text)
# with square brackets that would crash Textual's toast
# renderer if parsed as Rich markup.
toast = _Notification(
message=toast_body,
title=notification.title,
severity=severity,
timeout=effective_timeout,
markup=False,
)
self._notice_registry.bind_toast(notification.key, toast.identity)
self.post_message(_Notify(toast))
def _inject_debug_notifications(self) -> None:
"""Register sample missing-dependency entries for UI testing.
Gated by `DEEPAGENTS_CODE_DEBUG_NOTIFICATIONS`; no-op without it.
Uses `_notify_actionable` so each entry also posts a clickable
toast — mirroring the real missing-dep path and exercising both
the toast surface and the notification center.
Deliberately does *not* register an update-available entry or
open the update modal — that flow is exercised via
`DEEPAGENTS_CODE_DEBUG_UPDATE` / `_inject_debug_update`, so the
notification center can be browsed without focus being stolen
by the update modal.
"""
try:
from deepagents_code.main import build_missing_tool_notification
except ImportError:
logger.warning(
"Could not inject debug notifications; main import failed",
exc_info=True,
)
return
for tool in ("ripgrep", "tavily"):
self._notify_actionable(
build_missing_tool_notification(tool),
severity="warning",
timeout=15,
)
def _inject_debug_update(self) -> None:
"""Register a sample update entry and auto-open the update modal.
Gated by `DEEPAGENTS_CODE_DEBUG_UPDATE`; no-op without it.
Mirrors the real update-check path so the dedicated modal can
be exercised without waiting for a PyPI release.
"""
update_notification = self._build_update_notification(
latest="9.9.9",
cli_version="0.1.0",
release_age=" (released 2 days ago)",
installed_age="",
upgrade_cmd="uv tool upgrade deepagents-code",
)
self._notice_registry.add(update_notification)
self._update_modal_pending.set()
self.call_after_refresh(self._open_update_available_modal, update_notification)
def action_open_notifications(self) -> None:
"""Open the notification center via the `ctrl+n` keybind."""
self._open_notification_center()
def _open_notification_center(self) -> None:
"""Push the notification center modal, or toast when empty."""
from deepagents_code.widgets.notification_center import (
NotificationActionResult,
NotificationCenterScreen,
)
if isinstance(self.screen, ModalScreen):
# Don't stack on top of another modal (e.g. approval, model
# selector). Surface feedback so the user knows why ctrl+n
# appeared to do nothing.
self.notify(
"Close the current dialog to view notifications.",
severity="information",
timeout=3,
markup=False,
)
return
pending = self._notice_registry.list_all()
if not pending:
self.notify(
"No pending notifications.",
severity="information",
timeout=2,
markup=False,
)
return
self._dismiss_registered_toasts()
def handle_result(result: NotificationActionResult | None) -> None:
if result is not None:
self.run_worker(
self._dispatch_notification_action(result.key, result.action_id),
exclusive=False,
group=f"notification-action-{result.key}",
)
elif self._chat_input:
self._chat_input.focus_input()
self.push_screen(NotificationCenterScreen(pending), handle_result)
def _dismiss_registered_toasts(self) -> None:
"""Drop toasts bound to pending notifications.
Called when the notification center opens so the live toast
surface doesn't duplicate the modal list. Only toasts classified
as actionable by `NotificationRegistry.is_actionable_toast` are
dismissed; unrelated toasts (errors, generic info toasts) stay
visible.
"""
to_dismiss = [
notif
for notif in list(self._notifications)
if self._notice_registry.is_actionable_toast(notif.identity)
]
if not to_dismiss:
return
for notif in to_dismiss:
self._unnotify(notif, refresh=False)
self._notice_registry.unbind_toast(notif.identity)
self._refresh_notifications()
async def on_notification_suppress_requested(
self,
message: NotificationSuppressRequested,
) -> None:
"""Suppress the notice in place and refresh the open center."""
from deepagents_code.widgets.notification_center import NotificationCenterScreen
message.stop()
await self._dispatch_notification_action(message.key, ActionId.SUPPRESS)
screen = self.screen
if not isinstance(screen, NotificationCenterScreen):
return
try:
await screen.reload(self._notice_registry.list_all())
except Exception as exc: # defend against dismiss/mount races
# A concurrent dismissal can detach the VerticalScroll before
# `reload` queries it. The worst case is a stale row list,
# which the next open of the center will heal. Log + toast
# so the failure surfaces instead of vanishing into a worker.
logger.warning(
"Failed to refresh notification center after suppress: %s",
exc,
exc_info=True,
)
self.notify(
f"Could not refresh notifications: {type(exc).__name__}: {exc}",
severity="warning",
timeout=6,
markup=False,
)
def _open_update_available_modal(self, entry: PendingNotification) -> None:
"""Push the dedicated update-available modal for *entry*.
When another modal is already open the entry stays registered
and a toast hint points the user at `ctrl+n` once the blocking
modal closes. Also clears `_update_modal_pending` so
missing-dep toasts stop suppressing themselves.
"""
from deepagents_code.widgets.update_available import UpdateAvailableScreen
if isinstance(self.screen, ModalScreen):
# We can't stack; leave the entry in the registry and tell
# the user how to reach it.
self._update_modal_pending.clear()
self.notify(
"Update available. Your session will not be interrupted. "
"Press ctrl+n to review it.",
severity="information",
timeout=8,
markup=False,
)
return
# Textual layers are per-screen, so base-screen toasts visually
# bleed through the modal's dim. Drop them before opening so
# the modal reads cleanly; underlying notification entries
# stay in the registry and remain reachable via ctrl+n.
self.clear_notifications()
def handle_result(result: ActionId | None) -> None:
if result is not None:
self.run_worker(
self._dispatch_notification_action(entry.key, result),
exclusive=False,
group=f"notification-action-{entry.key}",
)
elif self._chat_input:
self._chat_input.focus_input()
self.push_screen(UpdateAvailableScreen(entry), handle_result)
async def _dispatch_notification_action(
self,
key: str,
action_id: ActionId,
) -> None:
"""Execute the side effect for a notification action.
Catches `Exception` broadly so any failure in the handler
surfaces as a warning toast instead of vanishing into the
background worker's log — this is the user-visibility guarantee
the registry is designed to provide.
Args:
key: Registry key of the notification.
action_id: The action the user selected.
"""
entry = self._notice_registry.get(key)
if entry is None:
return
action_label = _action_label(entry, action_id)
try:
await self._route_payload_action(entry, action_id)
except Exception as exc: # every failure surfaces to the user
logger.warning(
"Action %r on %r failed: %s",
action_id,
key,
exc,
exc_info=True,
)
self.notify(
f"{action_label} failed: {type(exc).__name__}: {exc}",
severity="warning",
timeout=8,
markup=False,
)
if self._chat_input:
self._chat_input.focus_input()
async def _route_payload_action(
self,
entry: PendingNotification,
action_id: ActionId,
) -> None:
"""Dispatch *action_id* to the payload-specific handler.
Raises:
TypeError: When `entry.payload` has no registered handler.
"""
if isinstance(entry.payload, MissingDepPayload):
await self._handle_missing_dep_action(entry, entry.payload, action_id)
return
if isinstance(entry.payload, UpdateAvailablePayload):
await self._handle_update_action(entry, entry.payload, action_id)
return
msg = f"unhandled payload type {type(entry.payload).__name__}"
raise TypeError(msg)
@staticmethod
def _log_unknown_action(entry: PendingNotification, action_id: ActionId) -> None:
"""Log a warning for an action id the handler does not recognize."""
logger.warning(
"Unknown action_id %r for %s entry %s",
action_id,
type(entry.payload).__name__,
entry.key,
)
async def _handle_missing_dep_action(
self,
entry: PendingNotification,
payload: MissingDepPayload,
action_id: ActionId,
) -> None:
"""Complete a missing-dependency action.
Args:
entry: The notification entry for the affected tool.
payload: Typed payload (tool name + install hint or URL).
action_id: The specific action the user selected.
Unknown ids are logged and treated as a no-op.
"""
if action_id == ActionId.SUPPRESS:
from deepagents_code._env_vars import DEBUG_NOTIFICATIONS
from deepagents_code.model_config import suppress_warning
# Debug mode injects sample entries via `_inject_debug_notifications`
# — persisted suppressions would silence the real warning on
# subsequent runs, defeating the point of replaying the UI.
if os.environ.get(DEBUG_NOTIFICATIONS):
self._notice_registry.remove(entry.key)
self.notify(
f"Suppressed {payload.tool} (debug mode; not persisted).",
severity="information",
timeout=4,
markup=False,
)
return
if await asyncio.to_thread(suppress_warning, payload.tool):
self._notice_registry.remove(entry.key)
self.notify(
f"Won't warn about {payload.tool} again.",
severity="information",
timeout=4,
markup=False,
)
else:
self.notify(
"Could not save notification preference. "
"Check file permissions for ~/.deepagents/config.toml.",
severity="warning",
timeout=6,
markup=False,
)
return
if action_id == ActionId.COPY_INSTALL:
if payload.install_command is None:
logger.warning(
"COPY_INSTALL action fired without install_command on %r",
entry.key,
)
self.notify(
"No install command recorded for this notification.",
severity="warning",
timeout=6,
markup=False,
)
return
self.copy_to_clipboard(payload.install_command)
self.notify(
f"Copied: {payload.install_command}",
severity="information",
timeout=4,
markup=False,
)
return
if action_id == ActionId.OPEN_WEBSITE:
if payload.url is None:
logger.warning("OPEN_WEBSITE action fired without url on %r", entry.key)
self.notify(
"No URL recorded for this notification.",
severity="warning",
timeout=6,
markup=False,
)
return
if await open_url_async(payload.url, app=self):
self.notify(
f"Opened {payload.url}",
severity="information",
timeout=3,
markup=False,
)
return
self._log_unknown_action(entry, action_id)
async def _handle_update_action(
self,
entry: PendingNotification,
payload: UpdateAvailablePayload,
action_id: ActionId,
) -> None:
"""Complete an update-available action.
Args:
entry: The update notification entry.
payload: Typed payload (target version + upgrade command).
action_id: The specific action the user selected.
Unknown ids are logged and treated as a no-op.
"""
from deepagents_code.update_check import (
clear_update_notified,
create_update_log_path,
mark_update_notified,
perform_upgrade,
upgrade_command,
)
if action_id == ActionId.INSTALL:
from deepagents_code._env_vars import DEBUG_UPDATE
if self._update_install_running:
self.notify(
"Update already running.",
severity="information",
timeout=4,
markup=False,
)
return
from deepagents_code.widgets.update_progress import UpdateProgressScreen
cmd = upgrade_command()
log_path = create_update_log_path()
screen = UpdateProgressScreen(
latest=payload.latest,
command=cmd,
log_path=log_path,
)
progress_modal_visible = not isinstance(self.screen, ModalScreen)
if progress_modal_visible:
await self.push_screen(screen)
else:
self.notify(
f"Updating to v{payload.latest}... Logs: {log_path}",
severity="information",
timeout=8,
markup=False,
)
self._update_install_running = True
try:
if os.environ.get(DEBUG_UPDATE):
await self._run_debug_update_install(
entry=entry,
payload=payload,
screen=screen,
log_path=log_path,
show_toast=not progress_modal_visible,
)
return
success, output = await perform_upgrade(
progress=screen.append_line,
log_path=log_path,
)
if success:
self._notice_registry.remove(entry.key)
screen.mark_success()
if not progress_modal_visible:
self.notify(
f"Updated to v{payload.latest}. "
"Restart to use the new version.",
severity="information",
timeout=10,
markup=False,
)
return
logger.warning(
"Auto-upgrade failed for v%s. Output:\n%s",
payload.latest,
output,
)
self._notice_registry.remove(entry.key)
screen.mark_failure(cmd)
snippet = _truncate(output, limit=160) if output else ""
message = f"Auto-update failed. Run manually: {cmd}"
if snippet:
message = f"{message}\n{snippet}"
self.notify(
message,
severity="warning",
timeout=15,
markup=False,
)
finally:
self._update_install_running = False
return
if action_id == ActionId.SKIP_VERSION:
await asyncio.to_thread(mark_update_notified, payload.latest)
self._notice_registry.remove(entry.key)
self.notify(
f"Skipped v{payload.latest}.",
severity="information",
timeout=4,
markup=False,
)
return
if action_id == ActionId.SKIP_ONCE:
await asyncio.to_thread(clear_update_notified)
self._notice_registry.remove(entry.key)
self.notify(
"We'll remind you next launch.",
severity="information",
timeout=4,
markup=False,
)
return
self._log_unknown_action(entry, action_id)
async def _run_debug_update_install(
self,
*,
entry: PendingNotification,
payload: UpdateAvailablePayload,
screen: UpdateProgressScreen,
log_path: Path,
show_toast: bool,
) -> None:
"""Exercise the update progress UI without invoking a package manager.
Args:
entry: The update notification entry to clear when complete.
payload: Update payload with the mocked target version.
screen: Progress modal to update.
log_path: Debug log path to write mock output into.
show_toast: Whether to show a completion toast.
"""
steps = (
("Debug mode: no package manager command was started.", 0.3),
(f"Resolving deepagents-code v{payload.latest}...", 0.8),
("Looking up compatible build tags...", 0.2),
("Downloading wheel metadata...", 0.5),
("Downloading deepagents_code-9.9.9-py3-none-any.whl...", 0.2),
("Downloading dependency metadata...", 0.2),
("Unpacking wheel...", 0.9),
("Checking installed entry points...", 0.2),
("Removing previous console script...", 0.2),
("Installing files...", 0.7),
("Writing dist-info metadata...", 0.2),
("Rebuilding executable shims...", 0.2),
("Validating import metadata...", 0.2),
("Verifying console script...", 0.4),
("Cleaning temporary build directory...", 0.2),
("Recording update receipt...", 0.2),
("Update complete.", 0.2),
)
wrote_log = False
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
with log_path.open("w", encoding="utf-8") as log:
log.write("$ debug mock update\n")
for line, delay in steps:
log.write(f"{line}\n")
log.flush()
screen.append_line(line)
await asyncio.sleep(delay)
wrote_log = True
except OSError:
logger.debug("Could not write debug update log", exc_info=True)
if not wrote_log:
for line, delay in steps:
screen.append_line(line)
await asyncio.sleep(delay)
self._notice_registry.remove(entry.key)
screen.mark_success()
if show_toast:
self.notify(
"Mock update complete (debug mode).",
severity="information",
timeout=5,
markup=False,
)
async def _handle_mcp_subcommand(self, args: str) -> None:
"""Dispatch `/mcp <subcommand>` strings.
Currently supports `login <server>`; unknown subcommands surface
an inline help message.
Args:
args: Everything after `/mcp ` (already stripped).
"""
parts = args.split(maxsplit=1)
if not parts:
await self._show_mcp_viewer()
return
subcommand = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
if subcommand == "login":
if not rest:
await self._mount_message(AppMessage("Usage: /mcp login <server>"))
return
server_name = rest.split()[0]
self._start_mcp_login(server_name)
return
if subcommand == "reconnect":
force, valid = _parse_reconnect_args(rest)
if not valid:
await self._mount_message(
AppMessage("Usage: /mcp reconnect [force]"),
)
return
await self._handle_mcp_reconnect_command(force=force)
return
await self._mount_message(
AppMessage(
f"Unknown `/mcp` subcommand: {subcommand!r}. "
"Try `/mcp`, `/mcp login <server>`, or `/mcp reconnect`.",
),
)
def _sync_pending_mcp_reconnect(self) -> None:
"""Refresh the aggregate MCP reconnect flag from tracked reasons."""
self._pending_mcp_reconnect = self._pending_mcp_login_reconnect or bool(
self._pending_mcp_disable_reconnect_servers
)
async def _handle_mcp_reconnect_command(self, *, force: bool = False) -> None:
"""Restart the server to pick up any deferred MCP login tokens.
No-op (with an inline notice) when nothing is pending so the
command is safe to run idempotently. `force=True` bypasses the
no-op guard via a confirmation modal — the escape hatch for
stale-cache or externally-edited-config cases where the server
needs a fresh load even though no login is queued in this
session.
Args:
force: When `True`, prompt to restart unconditionally even
if no MCP login is queued.
"""
if self._pending_mcp_reconnect:
await self._restart_server_for_mcp_refresh("pending login")
return
if not force:
await self._mount_message(
AppMessage(
"No MCP login is queued in this session. "
"If you logged in during an earlier run, relaunch "
"dcode to pick up the token. "
"Run `/mcp reconnect force` to restart anyway.",
),
)
return
from deepagents_code.widgets.mcp_reconnect import (
MCPReconnectForceConfirmScreen,
)
confirmed = await self._push_screen_wait(MCPReconnectForceConfirmScreen())
if confirmed:
await self._restart_server_for_mcp_refresh("forced reconnect")
async def _show_mcp_viewer(self) -> None:
"""Show the MCP server/tool viewer as a modal screen.
The viewer may dismiss with a server name (when the user activates
an `unauthenticated` header row to start in-TUI OAuth login) or
with `None` (close without action).
"""
from deepagents_code.widgets.mcp_viewer import (
MCP_VIEWER_RECONNECT_REQUEST,
MCPViewerScreen,
)
screen = MCPViewerScreen(
server_info=self._mcp_server_info or [],
connecting=self._connecting,
pending_reconnect=self._pending_mcp_reconnect,
on_toggle_disable=self._toggle_mcp_server_disabled,
)
self._active_mcp_viewer = screen
def handle_result(result: str | None) -> None:
self._active_mcp_viewer = None
if result == MCP_VIEWER_RECONNECT_REQUEST:
# `action_reconnect` gates dismiss on pending state, so
# `force=False` is correct.
self.call_later(self._reconnect_from_viewer_safe)
return
if result:
# User picked an unauthenticated server — start login.
self._start_mcp_login(result)
elif self._chat_input:
self._chat_input.focus_input()
self.push_screen(screen, handle_result)
async def _reconnect_from_viewer_safe(self) -> None:
"""Run the post-viewer reconnect and surface unexpected failures.
`call_later` schedules this on Textual's message pump, which
logs but does not display exceptions. Re-checks pending state
so a flip between dismiss and the pump tick silently no-ops
instead of degrading to the CLI no-op notice.
"""
if not self._pending_mcp_reconnect:
return
try:
await self._handle_mcp_reconnect_command()
except Exception as exc:
logger.exception("Reconnect after viewer dismiss failed")
await self._mount_message(
ErrorMessage(f"Reconnect failed: {type(exc).__name__}: {exc}"),
)
async def _toggle_mcp_server_disabled(self, server_name: str) -> None:
"""Flip the persistent disabled state for `server_name` and signal a reconnect.
Looks up the current state from the loaded `MCPServerInfo` list so
the toggle is correct regardless of whether the server was disabled
in a previous session or by an external edit of `config.toml`.
Persists the new value, updates pending reconnect state, and
refreshes the open viewer in-place — keeping the cursor on the
same server header — so the user sees the updated status without
a screen-swap flicker.
Args:
server_name: Name of the MCP server to toggle. Empty names
are impossible by construction (the only caller pulls
from `MCPServerHeaderItem.server.name`) and silently
no-op as defense-in-depth. Unknown names — possible if
config was reloaded between the viewer opening and F2 —
surface a toast so the user knows F2 didn't take effect.
"""
if not server_name:
logger.debug("Empty server name in disable toggle; ignoring")
return
known_names = {info.name for info in self._mcp_server_info or ()}
if server_name not in known_names:
logger.warning(
"Unknown server %r in disable toggle; ignoring",
server_name,
)
self.notify(
f"MCP server {server_name!r} is no longer configured.",
severity="warning",
markup=False,
)
return
from deepagents_code.mcp_disabled import (
is_server_disabled,
set_server_disabled,
)
currently_disabled = await asyncio.to_thread(is_server_disabled, server_name)
new_state = not currently_disabled
ok, detail = await asyncio.to_thread(
set_server_disabled,
server_name,
new_state,
)
if not ok:
message = f"Could not persist disabled state for {server_name!r}"
if detail:
message += f": {detail}"
else:
message += "."
self.notify(
message,
severity="error",
markup=False,
)
return
had_original = server_name in self._mcp_optimistic_original_server_info
verb = "disabled" if new_state else "enabled"
self._apply_optimistic_disabled_state(server_name, disabled=new_state)
if new_state:
self._pending_mcp_disable_reconnect_servers.add(server_name)
message = (
f"MCP server {server_name!r} {verb}. Run `/mcp reconnect` to apply."
)
else:
message = f"MCP server {server_name!r} {verb}."
if had_original:
self._pending_mcp_disable_reconnect_servers.discard(server_name)
else:
self._pending_mcp_disable_reconnect_servers.add(server_name)
message += " Run `/mcp reconnect` to apply."
self._sync_pending_mcp_reconnect()
self.notify(message, markup=False)
# Refresh the viewer in place so the new status glyph and the
# `Ctrl+R` reconnect hint appear without tearing the screen
# down. Persistence already succeeded and the user has seen
# the toast, so a failed in-place patch is non-fatal — but log
# with traceback so a real bug (e.g. signature drift,
# `DuplicateIds`) isn't masked the way `suppress(Exception)`
# would have masked it.
viewer = self._active_mcp_viewer
if viewer is not None:
try:
await viewer.apply_server_disable_toggle(
self._mcp_server_info or [],
toggled_server=server_name,
pending_reconnect=self._pending_mcp_reconnect,
)
except Exception:
logger.warning(
"Failed to refresh MCP viewer in place after toggle "
"of %r; state has been persisted but the open "
"viewer will not reflect it until reopened",
server_name,
exc_info=True,
)
def _apply_optimistic_disabled_state(
self,
server_name: str,
*,
disabled: bool,
) -> None:
"""Update `_mcp_server_info` so the viewer reflects the toggle immediately.
The authoritative state is recomputed on the next reconnect; this is
purely cosmetic so the user sees their action take effect without
waiting for the server restart.
"""
from deepagents_code.mcp_tools import MCPServerInfo
info = self._mcp_server_info
if not info:
if disabled:
self._mcp_server_info = [
MCPServerInfo(
name=server_name,
transport="unknown",
status="disabled",
error="Disabled by user (pending reconnect).",
),
]
return
updated: list[MCPServerInfo] = []
for entry in info:
if entry.name != server_name:
updated.append(entry)
continue
if disabled:
if entry.status != "disabled":
self._mcp_optimistic_original_server_info[server_name] = entry
updated.append(
MCPServerInfo(
name=entry.name,
transport=entry.transport,
status="disabled",
error="Disabled by user (pending reconnect).",
),
)
else:
original = self._mcp_optimistic_original_server_info.pop(
server_name,
None,
)
if original is not None:
updated.append(original)
else:
# Best-effort re-enable when the app started with this
# server disabled. Keep `status="disabled"` so the muted
# pause glyph is shown instead of a red error badge —
# the real status will be recomputed by the reconnect.
updated.append(
MCPServerInfo(
name=entry.name,
transport=entry.transport,
status="disabled",
error="Re-enabled — run `/mcp reconnect` to load.",
),
)
self._mcp_server_info = updated
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
"""Surface login worker failures that escaped the inner error handling."""
from textual.worker import WorkerState
worker = event.worker
if not (worker.group or "").startswith("mcp-login-"):
return
if event.state == WorkerState.ERROR and worker.error is not None:
logger.warning(
"MCP login worker failed unexpectedly: %s",
worker.error,
exc_info=worker.error,
)
self.call_later(
self._mount_message,
ErrorMessage(
f"MCP login failed unexpectedly: {worker.error}. "
"You may need to retry.",
),
)
def _start_mcp_login(self, server_name: str) -> None:
"""Begin in-TUI OAuth login for `server_name`.
Guards against remote-server mode (no owned server to restart),
an absent local server, missing MCP config, an unknown server
name, and busy states. When the session is mid-run, the login
attempt is queued via `_defer_action` and runs once the user is
idle.
Args:
server_name: MCP server name from `mcpServers`.
"""
if self._mcp_preload_kwargs is None:
self.notify(
"MCP is disabled in this session; nothing to log into.",
severity="warning",
markup=False,
)
return
if self._server_kwargs is None:
# Remote-server mode: we cannot restart the server, so the new
# token would never reach the MCP tool factory.
self.notify(
"Cannot log into MCP servers against a remote server. "
"Relaunch dcode locally to authenticate.",
severity="warning",
markup=False,
)
return
if self._connecting or self._server_proc is None:
self.notify(
"MCP login is unavailable until the local server is ready.",
severity="warning",
markup=False,
)
return
if self._agent_switching:
self.notify(
"An agent switch is in progress; try again once it completes.",
severity="warning",
markup=False,
)
return
if self._agent_running or self._shell_running:
self.notify(
"MCP login will start once the current task completes.",
timeout=5,
markup=False,
)
self._defer_action(
DeferredAction(
kind="mcp_login",
execute=lambda: self._run_mcp_login_worker(server_name),
),
)
return
self.run_worker(
self._run_mcp_login_worker(server_name),
exclusive=False,
group=f"mcp-login-{server_name}",
)
async def _run_mcp_login_worker(self, server_name: str) -> None:
"""Resolve config, run the login modal, and refresh on success.
Args:
server_name: MCP server name from `mcpServers`.
"""
from deepagents_code.mcp_login_service import (
ConfigResolution,
ConfigResolutionError,
resolve_mcp_config,
select_server,
)
if self._mcp_preload_kwargs is None:
return
config_path = self._mcp_preload_kwargs.get("mcp_config_path")
resolution = resolve_mcp_config(config_path)
if isinstance(resolution, ConfigResolutionError):
await self._mount_message(
ErrorMessage(f"MCP login failed: {resolution.message}"),
)
return
if not isinstance(resolution, ConfigResolution): # pragma: no cover - safety
return
selection = select_server(resolution, server_name)
if isinstance(selection, ConfigResolutionError):
await self._mount_message(
ErrorMessage(f"MCP login failed: {selection.message}"),
)
return
if selection.server_config.get("auth") != "oauth":
await self._mount_message(
ErrorMessage(
f"MCP server {server_name!r} does not use OAuth; "
"nothing to log into.",
),
)
return
from deepagents_code.mcp_auth import login as mcp_login
from deepagents_code.widgets.mcp_login import (
LoginOutcome,
MCPLoginCancelledError,
MCPLoginScreen,
)
screen = MCPLoginScreen(server_name)
outcome_future: asyncio.Future[LoginOutcome | None] = (
asyncio.get_running_loop().create_future()
)
def _on_dismiss(outcome: LoginOutcome | None) -> None:
if not outcome_future.done():
outcome_future.set_result(outcome)
self.push_screen(screen, _on_dismiss)
# Pump one event loop iteration so `compose`/`on_mount` run before
# the worker awaits its first interaction method.
await asyncio.sleep(0)
login_error: Exception | None = None
try:
await mcp_login(
server_name=server_name,
server_config=selection.server_config,
ui=screen,
)
except MCPLoginCancelledError:
screen.finish(success=False, message="Login cancelled.")
await asyncio.wait_for(outcome_future, timeout=5.0)
await self._mount_message(
AppMessage(f"MCP login for {server_name!r} cancelled."),
)
return
except Exception as exc: # noqa: BLE001 # surface unexpected errors
login_error = exc
except BaseException:
# Worker cancelled or app shutdown — unblock the modal and
# let the cancellation propagate.
if not screen.is_done:
screen.finish(success=False, message="Login interrupted.")
if not outcome_future.done():
outcome_future.set_result(None)
raise
if login_error is not None:
from deepagents_code.mcp_auth import format_login_failure
# Token-safe: never `%r`, `str()`, or `exc_info=` on the raw
# exception — its `args`/repr may include an `OAuthToken` from
# the MCP SDK. `format_login_failure` unwraps `ExceptionGroup`
# roots and degrades to a class-name chain for unknown types.
summary = format_login_failure(login_error)
logger.warning("MCP login for %r failed: %s", server_name, summary)
screen.finish(success=False, message=f"Login failed: {summary}")
await asyncio.wait_for(outcome_future, timeout=5.0)
await self._mount_message(
ErrorMessage(f"MCP login for {server_name!r} failed: {summary}"),
)
return
screen.finish(
success=True,
message=(
f"Logged in to {server_name!r}. Reconnect required to load new tools."
),
)
await asyncio.wait_for(outcome_future, timeout=5.0)
# Ask the user whether to restart now or defer. Deferring lets them
# authenticate against additional MCP servers before paying the
# restart cost. `/mcp reconnect` (or another login confirmed with
# "reconnect") drives the restart later.
try:
await self._prompt_mcp_reconnect(server_name)
except Exception:
# The token is already on disk — surface the failure and
# remember the pending state so `/mcp reconnect` still works
# even though the prompt never reached the user.
logger.exception(
"MCP reconnect prompt for %r raised after successful login",
server_name,
)
self._pending_mcp_login_reconnect = True
self._sync_pending_mcp_reconnect()
self.notify(
f"Logged in to {server_name!r} but the reconnect prompt "
"failed. Run `/mcp reconnect` when ready to load the new tools.",
severity="warning",
timeout=8,
markup=False,
)
async def _prompt_mcp_reconnect(self, server_name: str) -> None:
"""Ask whether to restart now or defer after an MCP login succeeds.
Args:
server_name: Server whose login just completed — surfaced in the
modal title and downstream messages only.
"""
from deepagents_code.widgets.mcp_reconnect import (
MCPReconnectPromptScreen,
ReconnectChoice,
)
choice_future: asyncio.Future[ReconnectChoice | None] = (
asyncio.get_running_loop().create_future()
)
def _on_dismiss(result: ReconnectChoice | None) -> None:
if not choice_future.done():
choice_future.set_result(result)
choice: ReconnectChoice | None
try:
self.push_screen(MCPReconnectPromptScreen(server_name), _on_dismiss)
except Exception:
# Modal could not be mounted (e.g. another modal hijacked the
# stack). Fall back to defer so the login isn't silently lost.
logger.exception("Failed to mount MCP reconnect prompt for %r", server_name)
choice = "later"
else:
try:
# Watchdog: guard against a screen that never resolves
# (compose crash, programmatic teardown that skips the
# callback). 10 minutes is well past any human latency
# but bounds the worker if the modal is genuinely broken.
choice = await asyncio.wait_for(choice_future, timeout=600.0)
except TimeoutError:
logger.warning(
"MCP reconnect prompt for %r timed out; defaulting to defer",
server_name,
)
choice = "later"
if choice == "reconnect":
self._pending_mcp_login_reconnect = False
self._pending_mcp_disable_reconnect_servers.clear()
self._sync_pending_mcp_reconnect()
await self._restart_server_for_mcp_refresh(server_name)
return
# Defer: keep the running server in place so the user can authenticate
# with additional MCP servers. The token is on disk either way, so
# remember the pending state regardless of how the modal closed.
# Only notify on an explicit "later" choice — `None` (programmatic
# dismiss / timeout) stays quiet to avoid telling the user about
# an action they didn't take.
self._pending_mcp_login_reconnect = True
self._sync_pending_mcp_reconnect()
if choice == "later":
self.notify(
f"Logged in to {server_name!r}. Run `/mcp reconnect` when ready "
"to load the new tools.",
severity="information",
timeout=8,
markup=False,
)
# Defer is the "log into another server first" path, so route the
# user back to the switcher where the next unauthenticated server
# is one click away. Timeout and push_screen-failure fallbacks
# also land here (both set `choice = "later"`), so they share
# both the notify above and this navigation — acceptable
# degradation since the viewer push is itself best-effort.
try:
await self._show_mcp_viewer()
except Exception:
# Broad catch: real failures here are Textual mount/stack
# errors plus the deferred SDK import — none worth crashing
# the worker for, since the token is already on disk.
# Surface a toast so the user knows why the switcher didn't
# come back; without it, the "logged in" notify is the only
# signal and the missing viewer looks like a UI hang.
logger.exception(
"Failed to reopen MCP viewer after deferring reconnect for %r",
server_name,
)
self.notify(
"Couldn't reopen the MCP viewer — run `/mcp` to open it manually.",
severity="warning",
timeout=8,
markup=False,
)
async def _restart_server_for_mcp_refresh(self, server_name: str) -> None:
"""Restart the app-owned LangGraph server to pick up new MCP tokens.
Skips and notifies when the app does not own a server process.
Failures roll back to the previous state via `ServerStartFailed`
from `_start_server_background`.
Args:
server_name: Server whose login just completed — used in user
messages only.
"""
# Clear the pending flag up front so deferred state can't leak
# past a no-op early return — e.g. the server died between defer
# and `/mcp reconnect`. The token is on disk; the user must
# relaunch dcode to pick it up, and `/mcp reconnect` shouldn't
# keep claiming there's something to do.
self._pending_mcp_login_reconnect = False
self._pending_mcp_disable_reconnect_servers.clear()
self._sync_pending_mcp_reconnect()
if self._server_kwargs is None or self._server_proc is None:
self.notify(
"Cannot restart the LangGraph server automatically; "
"relaunch dcode to pick up the new MCP token.",
severity="warning",
markup=False,
)
return
await self._respawn_server(
log_message=(f"Server restart after MCP login for {server_name!r} failed"),
mcp_failure_log="MCP metadata preload after login refresh failed",
mcp_failure_toast=(
"MCP tool metadata could not be refreshed after login. "
"Your tool list may be stale — use /mcp to check."
),
)
async def _handle_restart_command(self, command: str) -> None:
"""Drive the hidden `/restart` slash command.
Superset of `/reload`: re-reads `.env` / environment, clears
configuration caches, then respawns the app-owned LangGraph
server subprocess. Used as a recovery escape hatch when the
server wedges; intentionally hidden from autocomplete and help.
Cancels any in-flight agent work and drops the queued message
backlog before respawning. The streaming HTTP connection to the
dying subprocess would otherwise raise into the Textual reactor
after the new server advertises ready, leaving the UI wedged.
Args:
command: Raw command string for echoing back to chat.
"""
from deepagents_code.config import settings
from deepagents_code.model_config import clear_caches
await self._mount_message(UserMessage(command))
# Sever in-flight work bound to the dying subprocess. `_cancel_worker`
# discards the queued backlog too — those messages would otherwise
# fire against the freshly respawned agent silently.
if self._agent_running and self._agent_worker:
self._cancel_worker(self._agent_worker)
self._agent_running = False
else:
self._discard_queue()
try:
settings.reload_from_environment()
clear_caches()
except (OSError, ValueError, KeyError, TypeError, ImportError) as exc:
logger.exception("Failed to reload configuration during /restart")
await self._mount_message(
AppMessage(
"Failed to reload configuration "
f"({type(exc).__name__}: {exc}). Check your .env "
"file and environment variables for syntax errors, "
"then try again.",
),
)
return
if self._server_proc is None or self._server_kwargs is None:
await self._mount_message(
AppMessage(
"Cannot restart: this app is connected to a remote "
"LangGraph server (no owned subprocess). Configuration "
"was reloaded; relaunch dcode to fully restart.",
),
)
return
await self._restart_server_manual()
async def _restart_server_manual(self) -> None:
"""Respawn the app-owned LangGraph server for `/restart`.
Shares the respawn/preload sequence with
`_restart_server_for_mcp_refresh` via `_respawn_server`. Failures
surface via `ServerStartFailed` so the existing recovery UI takes
over.
"""
await self._respawn_server(
log_message="Manual /restart of server failed",
mcp_failure_log="MCP metadata preload after /restart failed",
mcp_failure_toast=(
"MCP tool metadata could not be refreshed. Use /mcp to check."
),
)
async def _respawn_server(
self,
*,
log_message: str,
mcp_failure_log: str,
mcp_failure_toast: str,
restart_timeout: float = 30.0,
) -> None:
"""Stop the app-owned server subprocess and rebuild the agent.
Used by `_restart_server_manual` (the `/restart` command) and
`_restart_server_for_mcp_refresh` (post-OAuth-login refresh).
Args:
log_message: Error log written when `server_proc.restart()`
raises or times out.
mcp_failure_log: Error log written when post-restart MCP
metadata preload raises.
mcp_failure_toast: User-facing toast shown when MCP preload
fails. Restart still succeeds; the agent comes up with
`mcp_info=None`.
restart_timeout: Seconds to wait for the subprocess restart
before giving up. Bounded so a wedged shutdown — the very
condition `/restart` exists to recover from — cannot
deadlock the handler.
"""
server_proc = self._server_proc
if self._server_kwargs is None or server_proc is None:
return
try:
self._connecting = True
self._agent = None
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_connecting()
except NoMatches:
pass
try:
await asyncio.wait_for(server_proc.restart(), timeout=restart_timeout)
except (Exception, TimeoutError) as exc:
self._connecting = False
logger.exception(log_message)
self.post_message(self.ServerStartFailed(error=exc))
return
from deepagents_code.main import _preload_session_mcp_server_info
from deepagents_code.remote_client import RemoteAgent as _RemoteAgent
mcp_info = None
try:
mcp_info = await _preload_session_mcp_server_info(
**self._mcp_preload_kwargs, # type: ignore[arg-type]
)
except Exception as exc:
logger.exception(mcp_failure_log)
self.notify(
f"{mcp_failure_toast} ({type(exc).__name__})",
severity="warning",
markup=False,
)
def _build_agent(url: str) -> Any: # noqa: ANN401 # union narrowed elsewhere
return _RemoteAgent(url=url, graph_name="agent")
self._agent = _build_agent(server_proc.url)
self.post_message(
self.ServerReady(
agent=self._agent,
server_proc=server_proc,
mcp_server_info=mcp_info,
),
)
except BaseException:
self._connecting = False
raise
finally:
if self._chat_input:
self._chat_input.set_cursor_active(active=not self._agent_running)
async def _show_thread_selector(self) -> None:
"""Show interactive thread selector as a modal screen."""
from functools import partial
from deepagents_code.sessions import get_cached_threads, get_thread_limit
from deepagents_code.widgets.thread_selector import ThreadSelectorScreen
current = self._session_state.thread_id if self._session_state else None
thread_limit = get_thread_limit()
initial_threads = get_cached_threads(limit=thread_limit)
def handle_result(result: str | None) -> None:
"""Handle the thread selector result."""
if result is not None:
if self._agent_running or self._shell_running or self._connecting:
self._defer_action(
DeferredAction(
kind="thread_switch",
execute=partial(self._resume_thread, result),
),
)
self.notify(
"Thread will switch after current task completes.",
timeout=3,
)
else:
self.call_later(self._resume_thread, result)
if self._chat_input:
self._chat_input.focus_input()
screen = ThreadSelectorScreen(
current_thread=current,
thread_limit=thread_limit,
initial_threads=initial_threads,
)
self.push_screen(screen, handle_result)
def _update_welcome_banner(
self,
thread_id: str,
*,
missing_message: str,
warn_if_missing: bool,
) -> None:
"""Update the welcome banner thread ID when the banner is mounted.
Args:
thread_id: Thread ID to display on the banner.
missing_message: Log message template when banner is missing.
warn_if_missing: Whether to log missing-banner cases at warning level.
"""
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.update_thread_id(thread_id)
except NoMatches:
if warn_if_missing:
logger.warning(missing_message, thread_id)
else:
logger.debug(missing_message, thread_id)
async def _resume_thread(self, thread_id: str) -> None:
"""Resume a previously saved thread.
Fetches the selected thread history, then atomically switches UI state.
Prefetching first avoids clearing the active chat when history loading
fails.
Args:
thread_id: The thread ID to resume.
"""
if not self._agent:
await self._mount_message(
AppMessage("Cannot switch threads: no active agent"),
)
return
if not self._session_state:
await self._mount_message(
AppMessage("Cannot switch threads: no active session"),
)
return
# Skip if already on this thread
if self._session_state.thread_id == thread_id:
await self._mount_message(AppMessage(f"Already on thread: {thread_id}"))
return
if self._thread_switching:
await self._mount_message(AppMessage("Thread switch already in progress."))
return
# Save previous state for rollback on failure
prev_thread_id = self._lc_thread_id
prev_session_thread = self._session_state.thread_id
self._thread_switching = True
if self._chat_input:
self._chat_input.set_cursor_active(active=False)
prefetched_payload: _ThreadHistoryPayload | None = None
try:
self._update_status(f"Loading thread: {thread_id}")
prefetched_payload = await self._fetch_thread_history_data(thread_id)
# Clear conversation (similar to /clear, without creating a new thread)
self._pending_messages.clear()
self._queued_widgets.clear()
await self._clear_messages()
self._context_tokens = 0
self._tokens_approximate = False
self._update_tokens(0)
self._update_status("")
# Switch to the selected thread
self._session_state.thread_id = thread_id
self._lc_thread_id = thread_id
self._update_welcome_banner(
thread_id,
missing_message="Welcome banner not found during thread switch to %s",
warn_if_missing=False,
)
# Load thread history
await self._load_thread_history(
thread_id=thread_id,
preloaded_payload=prefetched_payload,
)
except Exception as exc:
if prefetched_payload is None:
logger.exception("Failed to prefetch history for thread %s", thread_id)
await self._mount_message(
AppMessage(
f"Failed to switch to thread {thread_id}: {exc}. "
"Use /threads to try again.",
),
)
return
logger.exception("Failed to switch to thread %s", thread_id)
# Restore previous thread IDs so the user can retry
self._session_state.thread_id = prev_session_thread
self._lc_thread_id = prev_thread_id
self._update_welcome_banner(
prev_session_thread,
missing_message=(
"Welcome banner not found during rollback to thread %s; "
"banner may display stale thread ID"
),
warn_if_missing=True,
)
rollback_restore_failed = False
# Attempt to restore the previous thread's visible history
try:
await self._clear_messages()
await self._load_thread_history(thread_id=prev_session_thread)
except Exception: # Resilient session state saving
rollback_restore_failed = True
msg = (
"Could not restore previous thread history after failed "
"switch to %s"
)
logger.warning(msg, thread_id, exc_info=True)
error_message = f"Failed to switch to thread {thread_id}: {exc}."
if rollback_restore_failed:
error_message += " Previous thread history could not be restored."
error_message += " Use /threads to try again."
await self._mount_message(AppMessage(error_message))
finally:
self._thread_switching = False
self._update_status("")
if self._chat_input:
self._chat_input.set_cursor_active(active=not self._agent_running)
async def _switch_model(
self,
model_spec: str,
*,
extra_kwargs: dict[str, Any] | None = None,
announce_unchanged: bool = True,
) -> None:
"""Switch to a new model, preserving conversation history.
This requires a server-backed interactive session. It sets a model
override that `ConfigurableModelMiddleware` picks up on the next
invocation, so the conversation thread stays intact and no server
restart is required.
Args:
model_spec: The model specification to switch to.
Can be in `provider:model` format
(e.g., `'anthropic:claude-sonnet-4-5'`) or just the model name
for auto-detection.
extra_kwargs: Extra constructor kwargs from `--model-params`.
announce_unchanged: Whether to mount a message when the requested
model is already active.
"""
from deepagents_code.config import create_model, detect_provider, settings
from deepagents_code.model_config import (
ModelSpec,
ProviderAuthState,
get_provider_auth_status,
save_recent_model,
touch_recent_model,
)
logger.info("Switching model to %s", model_spec)
if self._model_switching:
await self._mount_message(AppMessage("Model switch already in progress."))
return
self._model_switching = True
try:
# Defensively strip leading colon in case of empty provider,
# treat ":claude-opus-4-6" as "claude-opus-4-6"
model_spec = model_spec.removeprefix(":")
if not self._remote_agent():
if self._connecting:
from functools import partial
self._defer_action(
DeferredAction(
kind="model_switch",
execute=partial(
self._switch_model,
model_spec,
extra_kwargs=extra_kwargs,
announce_unchanged=announce_unchanged,
),
),
)
self.notify(
"Model will switch once the session is ready.",
timeout=3,
)
return
# Recover from a startup that has not produced a server yet:
# either a deferred first launch with no credentials, or a
# failed startup such as `MissingCredentialsError`.
if (
self._server_startup_deferred
or self._server_startup_error is not None
) and self._server_kwargs is not None:
await self._retry_startup_with_model(
model_spec,
extra_kwargs=extra_kwargs,
)
return
await self._mount_message(
ErrorMessage("Model switching requires a server-backed session."),
)
return
parsed = ModelSpec.try_parse(model_spec)
if parsed:
provider: str | None = parsed.provider
model_name = parsed.model
else:
model_name = model_spec
provider = detect_provider(model_spec)
# Check credentials
auth_status = get_provider_auth_status(provider) if provider else None
if auth_status is not None and auth_status.blocks_start:
await self._mount_message(
ErrorMessage(
f"Missing credentials: {auth_status.missing_detail()}\n\n"
f"Run `/auth` for the '{auth_status.provider}' provider, then "
f"re-issue `/model {model_spec}`.",
),
)
return
if (
auth_status is not None
and auth_status.state is ProviderAuthState.UNKNOWN
):
logger.debug(
"Credentials for provider '%s' cannot be verified;"
" proceeding anyway",
provider,
)
# Check if already using this exact model
if model_name == settings.model_name and (
not provider or provider == settings.model_provider
):
current = f"{settings.model_provider}:{settings.model_name}"
# Mirror the regular-switch path so `--model-params` semantics
# are consistent across same-model and different-model cases:
# passing params applies them, omitting params clears any
# prior per-session override.
self._model_override = current
self._model_params_override = extra_kwargs
params_suffix = _format_model_params(extra_kwargs)
if announce_unchanged:
await self._mount_message(
AppMessage(f"Already using {current}{params_suffix}"),
)
logger.info(
"Model unchanged (%s); model_params=%s",
current,
extra_kwargs,
)
return
# Build the provider:model spec for the configurable middleware.
display = model_spec
if provider and not parsed:
display = f"{provider}:{model_name}"
try:
result = create_model(
display,
extra_kwargs=extra_kwargs,
profile_overrides=self._profile_override,
)
result.apply_to_settings()
except Exception as exc:
logger.exception("Failed to resolve model metadata for %s", display)
await self._mount_message(
ErrorMessage(_build_model_switch_error_body(exc)),
)
return
# Set the model override for ConfigurableModelMiddleware.
# The next stream call passes CLIContext via context= and the
# middleware swaps the model per-invocation — no graph recreation.
self._model_override = display
self._model_params_override = extra_kwargs
if self._status_bar:
self._status_bar.set_model(
provider=settings.model_provider or "",
model=settings.model_name or "",
)
if not await asyncio.to_thread(save_recent_model, display):
await self._mount_message(
ErrorMessage(
"Model switched for this session, but could not save "
"preference. Check permissions for ~/.deepagents/",
),
)
else:
params_suffix = _format_model_params(extra_kwargs)
await self._mount_message(
AppMessage(f"Switched to {display}{params_suffix}"),
)
# Best-effort MRU update for the `/model` Recent section.
# `display` may be a bare model name when provider auto-detection
# fails; use the post-resolution spec so touch_recent_model always
# gets a valid "provider:model" string. Silent on failure —
# debug log captures it when DEEPAGENTS_CODE_DEBUG=1.
resolved_spec = f"{result.provider}:{result.model_name}"
await asyncio.to_thread(touch_recent_model, resolved_spec)
logger.info(
"Model switched to %s (via configurable middleware); model_params=%s",
display,
extra_kwargs,
)
# Anchor to bottom so the confirmation message is visible
with suppress(NoMatches, ScreenStackError):
self.query_one("#chat", VerticalScroll).anchor()
finally:
self._model_switching = False
async def _retry_startup_with_model(
self,
model_spec: str,
*,
extra_kwargs: dict[str, Any] | None = None,
) -> None:
"""Retry deferred server startup after a failed initial startup.
Exists because the server never came up (typically a
`MissingCredentialsError`), so the only escape without restarting
the app is re-running the deferred startup worker with a new spec.
Args:
model_spec: The new model specification (`provider:model` or bare
model name for auto-detection).
extra_kwargs: Extra constructor kwargs from `--model-params`.
"""
from deepagents_code.config import detect_provider
from deepagents_code.model_config import ModelSpec, get_provider_auth_status
if self._server_kwargs is None:
await self._mount_message(
ErrorMessage("Cannot retry startup: server is not app-owned."),
)
return
parsed = ModelSpec.try_parse(model_spec)
if parsed:
provider: str | None = parsed.provider
model_name = parsed.model
else:
model_name = model_spec
provider = detect_provider(model_spec)
# Tri-state credentials check (`UNKNOWN` = unknown provider, treated
# as proceed); bail early so retrying with still-missing creds doesn't
# loop right back into the same `MissingCredentialsError`.
auth_status = get_provider_auth_status(provider) if provider else None
if auth_status is not None and auth_status.blocks_start:
await self._mount_message(
ErrorMessage(f"Missing credentials: {auth_status.missing_detail()}"),
)
return
display = model_spec
if provider and not parsed:
display = f"{provider}:{model_name}"
new_model_kwargs: dict[str, Any] = {
"model_spec": display,
"extra_kwargs": extra_kwargs,
"profile_overrides": self._profile_override,
}
self._model_kwargs = new_model_kwargs
self._server_kwargs["model_name"] = display
if extra_kwargs is not None:
self._server_kwargs["model_params"] = extra_kwargs
self._server_startup_error = None
self._server_startup_missing_credentials_provider = None
self._server_startup_missing_provider_package = None
self._server_startup_deferred = False
self._connecting = True
try:
banner = self.query_one("#welcome-banner", WelcomeBanner)
banner.set_connecting()
except (NoMatches, ScreenStackError):
logger.debug("Welcome banner not found during startup retry", exc_info=True)
if self._retry_status_widget is not None:
with suppress(NoMatches, ScreenStackError):
await self._retry_status_widget.remove()
self._retry_status_widget = None
try:
messages = self.query_one("#messages", Container)
except (NoMatches, ScreenStackError):
messages = None
if messages is not None and messages.is_attached:
new_widget = AppMessage(f"Retrying startup with {display}")
# Mount before storing the reference so `on_deep_agents_app_server_ready`
# cannot observe a half-mounted widget if it races during this await.
await self._mount_before_queued(messages, new_widget)
self._retry_status_widget = new_widget
logger.info("Retrying server startup with model %s", display)
self.run_worker(
self._start_server_background,
exclusive=True,
group="server-startup",
)
async def _maybe_start_deferred_server_from_default(self) -> bool:
"""Start a deferred first-launch server once a default model resolves.
Returns:
`True` when startup was kicked off, otherwise `False`.
"""
if not self._server_startup_deferred:
return False
from deepagents_code.config import _get_default_model_spec
from deepagents_code.model_config import (
ModelConfigError,
NoCredentialsConfiguredError,
)
try:
model_spec = _get_default_model_spec()
except NoCredentialsConfiguredError:
return False
except ModelConfigError as exc:
await self._mount_message(ErrorMessage(str(exc)))
return False
await self._retry_startup_with_model(model_spec)
return True
async def _set_default_model(self, model_spec: str) -> None:
"""Set the default model in config without switching the current session.
Updates `[models].default` in `~/.deepagents/config.toml` so that
future app launches use this model. Does not affect the running session.
Args:
model_spec: The model specification (e.g., `'anthropic:claude-opus-4-6'`).
"""
from deepagents_code.config import detect_provider
from deepagents_code.model_config import ModelSpec, save_default_model
model_spec = model_spec.removeprefix(":")
parsed = ModelSpec.try_parse(model_spec)
if not parsed:
provider = detect_provider(model_spec)
if provider:
model_spec = f"{provider}:{model_spec}"
if await asyncio.to_thread(save_default_model, model_spec):
await self._mount_message(AppMessage(f"Default model set to {model_spec}"))
else:
await self._mount_message(
ErrorMessage(
"Could not save default model. "
"Check permissions for ~/.deepagents/",
),
)
async def _clear_default_model(self) -> None:
"""Remove the default model from config.
After clearing, future launches fall back to `[models].recent` or
environment auto-detection.
"""
from deepagents_code.model_config import clear_default_model
if await asyncio.to_thread(clear_default_model):
await self._mount_message(
AppMessage(
"Default model cleared. "
"Future launches will use recent model or auto-detect.",
),
)
else:
await self._mount_message(
ErrorMessage(
"Could not clear default model. "
"Check permissions for ~/.deepagents/",
),
)
@dataclass(frozen=True)
class AppResult:
"""Result from running the Textual application."""
return_code: int
"""Exit code (0 for success, non-zero for error)."""
thread_id: str | None
"""The final thread ID at shutdown. May differ from the initial thread ID if
the user switched threads via `/threads`."""
session_stats: SessionStats = field(default_factory=SessionStats)
"""Cumulative usage stats across all turns in the session."""
update_available: tuple[bool, str | None] = (False, None)
"""`(is_available, latest_version)` for post-exit update warning."""
async def run_textual_app(
*,
agent: Any = None, # noqa: ANN401
assistant_id: str | None = None,
backend: CompositeBackend | None = None,
auto_approve: bool = False,
cwd: str | Path | None = None,
thread_id: str | None = None,
resume_thread: str | None = None,
initial_prompt: str | None = None,
initial_skill: str | None = None,
startup_cmd: str | None = None,
launch_init: bool = False,
mcp_server_info: list[MCPServerInfo] | None = None,
profile_override: dict[str, Any] | None = None,
server_proc: ServerProcess | None = None,
server_kwargs: dict[str, Any] | None = None,
mcp_preload_kwargs: dict[str, Any] | None = None,
model_kwargs: dict[str, Any] | None = None,
defer_server_start: bool = False,
title: str | None = None,
sub_title: str | None = None,
) -> AppResult:
"""Run the Textual application.
When `server_kwargs` is provided (and `agent` is `None`), the app starts
immediately with a "Connecting..." banner and launches the server in the
background. Server cleanup is handled automatically after the app exits.
Args:
agent: Pre-configured LangGraph agent (optional).
assistant_id: Agent identifier for memory storage.
backend: Backend for file operations.
auto_approve: Whether to start with auto-approve enabled.
cwd: Current working directory to display.
thread_id: Thread ID for the session.
`None` when `resume_thread` is provided (the TUI resolves the final
ID asynchronously).
resume_thread: Raw resume intent from `-r` flag. `'__MOST_RECENT__'` for
bare `-r`, a thread ID string for `-r <id>`, or `None` for new
sessions.
Resolved asynchronously during TUI startup.
initial_prompt: Optional prompt to auto-submit when session starts.
initial_skill: Optional skill name to invoke when session starts.
startup_cmd: Optional shell command to run at startup before the first
prompt is accepted. Output is rendered in the transcript and
non-zero exits warn but do not abort the session.
launch_init: Whether to run the first-run onboarding setup flow
(name entry, dependency summary, model picker) before accepting
the first prompt.
mcp_server_info: MCP server metadata for the `/mcp` viewer.
profile_override: Extra profile fields from `--profile-override`,
retained so later profile-aware behavior stays consistent with
the app override, including model selection details, offload
budget display, and on-demand `create_model()` calls such
as `/offload`.
server_proc: LangGraph server process for the interactive session.
server_kwargs: Kwargs for deferred `start_server_and_get_agent` call.
mcp_preload_kwargs: Kwargs for concurrent MCP metadata preload.
model_kwargs: Kwargs for deferred `create_model()` call.
When provided, model creation runs in a background worker after
first paint so the splash screen appears immediately.
defer_server_start: Whether to keep app-owned server startup paused
until credentials or a model are configured from inside the TUI.
title: Override the Textual `App.title` shown in the optional header
bar (gated on `DEEPAGENTS_CODE_SHOW_HEADER`). When `None`, the
default `"Deep Agents"` is used.
sub_title: Override the Textual `App.sub_title` shown in the optional
header bar.
Returns:
An `AppResult` with the return code and final thread ID.
"""
app = DeepAgentsApp(
agent=agent,
assistant_id=assistant_id,
backend=backend,
auto_approve=auto_approve,
cwd=cwd,
thread_id=thread_id,
resume_thread=resume_thread,
initial_prompt=initial_prompt,
initial_skill=initial_skill,
startup_cmd=startup_cmd,
launch_init=launch_init,
mcp_server_info=mcp_server_info,
profile_override=profile_override,
server_proc=server_proc,
server_kwargs=server_kwargs,
mcp_preload_kwargs=mcp_preload_kwargs,
model_kwargs=model_kwargs,
defer_server_start=defer_server_start,
title=title,
sub_title=sub_title,
)
try:
await app.run_async()
finally:
# Guarantee server cleanup regardless of how the app exits.
# Covers both the pre-started server_proc path and the deferred
# server_kwargs path (where the background worker sets _server_proc).
if app._server_proc is not None:
app._server_proc.stop()
return AppResult(
return_code=app.return_code or 0,
thread_id=app._lc_thread_id,
session_stats=app._session_stats,
update_available=app._update_available,
)