"""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, cast

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 (  # noqa: PLC2701
    Toast as _Toast,  # 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,
    SYSTEM_MESSAGE_PREFIX,
)
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.formatting import format_message_timestamp
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.subagent_panel import SubagentPanel
from deepagents_code.widgets.welcome import WelcomeBanner

logger = logging.getLogger(__name__)
_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()

_DEEPAGENTS_IMPORT_LOCK = threading.RLock()
"""Serializes process-local cold imports into the Deep Agents SDK graph.

The SDK currently has a package-to-backend circular import that is safe when a
single thread imports it re-entrantly, but can trip CPython's per-module import
deadlock detector when two threads cold-import overlapping modules.
"""

_MESSAGE_TIMESTAMP_FOOTER_CLASS = "message-timestamp-footer"
"""CSS class applied to individual message timestamp footer widgets."""

_MESSAGE_TIMESTAMP_FOOTER_VISIBLE_CLASS = "message-timestamp-footer-visible"
"""CSS class applied to a footer widget when it should be shown.

Visibility is toggled on the footer leaves rather than on `#messages`: a class
change on the container would force Textual to re-cascade styles across every
message subtree (O(mounted widgets)), whereas flipping the leaf footers
restyles only the footers.
"""

_TIMESTAMP_FOOTER_EXCLUDED_TYPES: frozenset[MessageType] = frozenset(
    {MessageType.APP, MessageType.SUMMARIZATION}
)
"""Message types that never receive a timestamp footer.

App-status notes (e.g. "Resumed thread: ...", version/update notices, command
feedback) are not conversation turns, so they do not get timestamp footers.
`SUMMARIZATION` is an `APP`-style system notice and is excluded for the same
reason.
"""


def _message_timestamp_footer_id(message_id: str) -> str:
    """Return the DOM id for a message timestamp footer."""
    return f"{message_id}-timestamp-footer"


def _create_model_with_deepagents_import_lock(
    model_spec: str | None = None,
    *,
    extra_kwargs: dict[str, Any] | None = None,
    profile_overrides: dict[str, Any] | None = None,
) -> ModelResult:
    """Create a model while serializing Deep Agents SDK import entry.

    Args:
        model_spec: Model specification in `provider:model` format.
        extra_kwargs: Extra model constructor kwargs.
        profile_overrides: Model profile metadata overrides.

    Returns:
        Created model and resolved metadata.
    """
    with _DEEPAGENTS_IMPORT_LOCK:
        from deepagents_code.config import create_model

        return create_model(
            model_spec,
            extra_kwargs=extra_kwargs,
            profile_overrides=profile_overrides,
        )


def _extra_is_ready(extra: str) -> bool | None:
    """Return whether all dependencies for `extra` are installed.

    Returns:
        `True` when every package declared by `extra` is importable, `False`
            when one or more are missing, or `None` when the extra metadata
            can't be introspected — an unknown state, distinct from a negative
            one, so callers don't treat "couldn't check" as "not installed".
    """
    from deepagents_code.extras_info import (
        ExtrasIntrospectionError,
        get_optional_dependency_status,
    )

    try:
        statuses = get_optional_dependency_status(strict=True)
    except ExtrasIntrospectionError:
        logger.warning(
            "Could not verify whether extra %r is installed",
            extra,
            exc_info=True,
        )
        return None
    return any(status.name == extra and status.ready for status in statuses)


@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.messages import BaseMessage
    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.timer import Timer
    from textual.widget import Widget
    from textual.worker import Worker

    from deepagents_code._ask_user_types import AskUserWidgetResult, Question
    from deepagents_code.config import ModelResult
    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.model_selector import ModelSelectorScreen
    from deepagents_code.widgets.notification_center import (
        NotificationSuppressRequested,
    )
    from deepagents_code.widgets.restart_prompt import RestartChoice
    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.
"""

_CONNECTING_STATUS_REVEAL_DELAY_SECONDS = 5.0
"""Maximum seconds to defer initial status-bar connection progress.

Fast local-server startup should not flash a spinner. If startup takes longer,
or the user queues input while waiting, the status bar reveals the connection
state as the single app-wide progress indicator.
"""

_UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
"""How often long-running TUI sessions quietly re-check for app updates."""

_MODAL_WATCHDOG_TIMEOUT_SECONDS = 600.0
"""Upper bound on awaiting a confirmation modal's dismissal.

Bounds command/worker handling against a modal that never resolves (compose
crash, programmatic teardown that skips the dismiss callback). 10 minutes is
well past any human latency but stops a genuinely broken modal from wedging
the caller. Shared by the install-confirm, MCP-reconnect, and restart-prompt
watchdogs so the three stay in lockstep.
"""


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.
    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 _load_message_timestamps_visible() -> bool:
    """Load the saved message-timestamp-footer visibility preference.

    Reads `[ui].show_message_timestamps` from `~/.deepagents/config.toml`.

    Returns:
        The saved preference, or `False` when it is unset or unreadable.
    """
    import tomllib

    from deepagents_code.model_config import DEFAULT_CONFIG_PATH

    if not DEFAULT_CONFIG_PATH.exists():
        return False
    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 timestamp preference: %s", exc)
        return False

    ui = data.get("ui", {})
    if not isinstance(ui, dict):
        logger.warning(
            "[ui] should be a table; got %s while loading timestamp preference",
            type(ui).__name__,
        )
        return False

    value = ui.get("show_message_timestamps")
    if isinstance(value, bool):
        return value
    if value is not None:
        logger.warning(
            "[ui].show_message_timestamps should be a boolean; got %s",
            type(value).__name__,
        )
    return False


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 UI settings."
        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 _load_bool_ui_preference(key: str, *, log_label: str) -> bool:
    """Load a boolean `[ui]` preference from `~/.deepagents/config.toml`.

    These preferences have no in-app command; the file is edited manually. The
    loader is intentionally forgiving: any problem reading or parsing the config
    falls back to `True` (the feature stays on) after logging a warning, so a
    typo in a cosmetic setting never breaks startup.

    Args:
        key: The key to read from the `[ui]` table.
        log_label: Human-readable name of the preference, used in warning logs.

    Returns:
        The saved `[ui].<key>` value, or `True` when unset, unreadable,
            or malformed.
    """
    import tomllib

    from deepagents_code.model_config import DEFAULT_CONFIG_PATH

    if not DEFAULT_CONFIG_PATH.exists():
        return True
    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 %s preference: %s", log_label, exc)
        return True

    ui = data.get("ui", {})
    if not isinstance(ui, dict):
        logger.warning(
            "[ui] should be a table; got %s while loading %s preference",
            type(ui).__name__,
            log_label,
        )
        return True

    value = ui.get(key)
    if isinstance(value, bool):
        return value
    if value is not None:
        logger.warning(
            "[ui].%s should be a boolean; got %s",
            key,
            type(value).__name__,
        )
    return True


def _load_cursor_blink_preference() -> bool:
    """Load the saved cursor-blink preference from `~/.deepagents/config.toml`.

    The chat input cursor blink can be turned off by setting
    `[ui].cursor_blink = false` in the config file. There is no in-app command
    for this; the file is edited manually.

    Returns:
        The saved `[ui].cursor_blink` value, or `True` (blink on) when unset,
        unreadable, or malformed.
    """
    return _load_bool_ui_preference("cursor_blink", log_label="cursor blink")


def _load_terminal_progress_preference() -> bool:
    """Load the `OSC 9;4` progress preference from `~/.deepagents/config.toml`.

    The terminal taskbar/dock/tab progress indicator (where supported) can be
    turned off by setting `[ui].terminal_progress = false` in the config file.
    There is no in-app command for this; the file is edited manually. The
    `DEEPAGENTS_CODE_NO_TERMINAL_ESCAPE` environment variable still disables all
    terminal escapes regardless of this value.

    Returns:
        The saved `[ui].terminal_progress` value, or `True` (progress on) when
        unset, unreadable, or malformed.
    """
    return _load_bool_ui_preference("terminal_progress", log_label="terminal progress")


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 _save_message_timestamps_visible_result(visible: bool) -> _ConfigWriteResult:
    """Persist the timestamp-footer visibility preference.

    Writes `[ui].show_message_timestamps` atomically (temp file +
    `Path.replace`). Mirrors `_save_theme_preference_result`.

    Returns:
        Write status and a message suitable for a toast when the user needs to
            know about a repair or failure.
    """
    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["show_message_timestamps"] = visible

            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 timestamp preference")
        return _ConfigWriteResult(
            False,
            f"Timestamps toggled for this session but could not be saved "
            f"({type(exc).__name__}).",
            "error",
        )
    return _ConfigWriteResult(True, repair_message)


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)}"


def _display_model_label(spec: str | None) -> str | None:
    """Strip the provider prefix from a model spec for display.

    `anthropic:opus` becomes `opus`; only the first colon splits, so a model
    name that itself contains a colon is preserved. A spec without a colon (or
    an empty/`None` spec) is returned unchanged. This is a cosmetic label only,
    so a malformed spec degrades to a slightly-off label rather than an error.

    Returns:
        The display label, or the spec unchanged when there is no prefix.
    """
    return spec.split(":", 1)[1] if spec and ":" in spec else spec


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."""

_RAPID_QUIT_CTRL_C_PRESSES: int = 2
"""Consecutive rapid `Ctrl+C` presses that force the quit sequence.

When a draft is present, a single `Ctrl+C` copies it (matching terminal copy
semantics), which otherwise leaves no way to reach the quit arm by pressing
`Ctrl+C`. Mashing `Ctrl+C` is the universal "get me out" reflex, so the second
rapid press bypasses the copy branches and arms quit instead.
"""

_RAPID_QUIT_CTRL_C_WINDOW_SECONDS: float = 1.0
"""Window within which repeated `Ctrl+C` presses count toward the rapid-quit
escape hatch. Tight enough that a deliberate copy-then-interrupt sequence,
which has much larger gaps, never trips it."""


@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)."""

    model_spec: str
    """Persisted `_model_spec` from the checkpoint, or `""` for legacy threads
    saved before model persistence existed."""


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}"


_GATEWAY_DOCS_URL = (
    "https://docs.langchain.com/oss/python/deepagents/code/configuration"
    "#endpoints-keys-and-gateways"
)
"""Docs section on how a provider's API key and endpoint resolve together.

Linked from `PermissionDeniedError` guidance: a common cause is a provider key
that does not match the endpoint it is sent to — e.g. an `OPENAI_API_KEY`
exported in the shell while a gateway overrides the provider base URL, so the
key is sent to the gateway, which rejects it.
"""

_LANGSMITH_KEY_PREFIX = "lsv2_"
"""Prefix LangSmith API keys carry. Used as a heuristic to recognize when a
provider key is *not* a LangSmith gateway key. Only the prefix is inspected —
the secret value is never logged or otherwise introspected.
"""

_LANGSMITH_GATEWAY_HOST = "smith.langchain.com"
"""Host substring identifying the LangSmith gateway endpoint."""


def _langsmith_gateway_key_mismatch(provider: str | None) -> str | None:
    """Detect a non-LangSmith key being routed through the LangSmith gateway.

    Returns the provider's API-key env var name when its resolved endpoint is
    the LangSmith gateway but its key is not a LangSmith key (no `lsv2_`
    prefix). Only the key prefix is checked; the secret value is never logged.
    Returns `None` when there is no provider, no key, no gateway endpoint, or
    the key already looks like a LangSmith key.

    Performs blocking filesystem reads (config + credential store), so callers
    on the event loop must invoke it via `asyncio.to_thread`.

    Args:
        provider: The active provider name, or `None` if undetected.

    Returns:
        The API-key env var name to mention in the error, or `None`.
    """
    if not provider:
        return None
    try:
        from deepagents_code.model_config import (
            ModelConfig,
            get_credential_env_var,
            resolve_env_var,
            resolved_env_var_name,
        )

        base_url = ModelConfig.load().get_base_url(provider)
        if not base_url or _LANGSMITH_GATEWAY_HOST not in base_url:
            return None
        key_env = get_credential_env_var(provider)
        if not key_env:
            return None
        key = resolve_env_var(key_env)
    except Exception:
        # The wrapped config/credential reads are not expected to raise (they
        # degrade to empty/None internally), so reaching here signals API drift
        # worth surfacing — log louder than debug. Still degrade to the generic
        # message rather than escalating on this best-effort diagnostic path.
        logger.warning("gateway key-mismatch check failed", exc_info=True)
        return None
    if not key or key.startswith(_LANGSMITH_KEY_PREFIX):
        return None
    return resolved_env_var_name(key_env)


def _build_agent_error_body(
    text: str, exc: BaseException, *, key_env: str | None = None
) -> str | Content:
    """Format an agent-stream exception for `ErrorMessage`.

    Pure synchronous formatter — all blocking detection happens in the caller
    (see `_langsmith_gateway_key_mismatch`) so this can run on the event loop.

    For `PermissionDeniedError`, appends gateway guidance plus a docs link. When
    `key_env` is supplied (a non-LangSmith key being routed through the
    LangSmith gateway), the message names that env var and how to fix it.
    Otherwise a generic "key does not match endpoint" message is shown. Returns
    `text` unchanged for any other error.

    Args:
        text: The already-formatted error string (e.g. `"Agent error: ..."`).
        exc: The exception caught from the agent stream.
        key_env: The offending API-key env var name when a gateway/key mismatch
            was detected, else `None`.

    Returns:
        A `Content` with a clickable docs link for `PermissionDeniedError`;
            otherwise the plain `text`.
    """
    from deepagents_code.remote_client import agent_error_type

    if agent_error_type(exc) != "PermissionDeniedError":
        return text
    if key_env:
        detail = (
            f"\n\nYour `{key_env}` is not a LangSmith key, but requests are "
            "being routed through the LangSmith gateway, which rejects it. "
            f"Unset `{key_env}` to use the gateway, or set "
            "`LANGCHAIN_DISABLE_GATEWAY=1` to bypass the gateway. See "
        )
    else:
        detail = (
            "\n\nThis usually means your API key does not match the endpoint it "
            "is sent to — for example a gateway overriding the provider base "
            "URL, so the key is rejected. See "
        )
    return Content.assemble(
        text,
        detail,
        (_GATEWAY_DOCS_URL, TStyle(underline=True, link=_GATEWAY_DOCS_URL)),
    )


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),
        ),
    )


_STARTUP_ERROR_HEADLINE_LIMIT = 300
"""Max characters of a startup-error headline shown in chat before truncation.

Long single-line errors (e.g. the `interpreter_ptc` "Available tools: ..." list)
overflow this. `on_deep_agents_app_server_start_failed` appends a pointer to the
full error in the debug log when the headline is clipped, since the truncated
tail is often the actionable part.
"""


def _startup_error_headline(error: BaseException) -> str:
    """Return the untruncated single-line `Type: message` startup headline.

    Args:
        error: The exception raised during server startup.

    Returns:
        A single-line `Type: message` summary (may exceed the banner width).
    """
    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__}: {first_line}"


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.
    """
    return _truncate(
        _startup_error_headline(error), limit=_STARTUP_ERROR_HEADLINE_LIMIT
    )


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()
        self.approval_mode_key: str | None = None

    def reset_thread(self) -> str:
        """Reset to a new thread.

        Returns:
            The new thread_id.
        """
        self.thread_id = _new_thread_id()
        self.approval_mode_key = None
        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 _ChatScroll(VerticalScroll):
    """Chat scroll container that doesn't steal focus when clicked.

    `ScrollableContainer` is focusable by default, so Textual's
    `Screen._forward_event` walks up from a clicked (non-focusable) message
    widget to this container and calls `set_focus` on it, de-focusing the chat
    input's `TextArea`. Setting `FOCUS_ON_CLICK = False` keeps the container
    focusable (e.g. for keyboard scrolling) while leaving input focus intact
    when the user clicks a message to expand it.
    """

    FOCUS_ON_CLICK = False


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("ctrl+g", "toggle_subagent_panel", "Toggle Subagents", 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,
        model_explicitly_set: bool = False,
        interpreter_arg: bool | 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 status-bar connection 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.
            model_explicitly_set: Whether the user passed `--model` on the
                command line.

                When `True`, an explicit choice wins over the model persisted
                in a resumed thread (no resume adoption).
            interpreter_arg: The raw `--interpreter`/`--no-interpreter` tri-state
                (`True`/`False`/`None`). Used only to distinguish an explicit
                opt-out from a sandbox-suppressed default when surfacing the
                disabled-by-sandbox advisory; the resolved value travels in
                `server_kwargs`.
            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._cursor_blink_enabled = _load_cursor_blink_preference()
        """Whether the chat input cursor should blink (user preference)."""

        self._terminal_progress_enabled = _load_terminal_progress_preference()
        """Whether to emit `OSC 9;4` taskbar progress (user preference)."""

        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._onboarding_session = launch_init
        """Whether onboarding runs this session (constant for the session).

        Unlike `_launch_init_requested`, which is cleared once the flow starts,
        this stays set so background workers (e.g. the optional-tools check)
        can register missing-dependency notices silently instead of toasting
        over the onboarding modals.

        Intentionally never reset, including on onboarding completion: the
        optional-tools check is scheduled once at startup and may not have run
        by the time the flow finishes, so clearing this would let that check
        toast the very "Web search disabled" notice onboarding means to defer.
        Leaving it set for the whole session is harmless because the check runs
        exactly once.
        """

        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.needs_attention()
        )
        """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._mcp_awaiting_reconnect = sum(
            1 for s in (mcp_server_info or []) if s.status == "awaiting_reconnect"
        )
        """MCP servers that completed OAuth login but are blocked on
        `/mcp reconnect` before their tools can load.

        See `MCPServerStatus` for the underlying state machine.
        """

        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 a status-bar
        connection 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`.
        """

        self._model_explicitly_set = model_explicitly_set
        self._interpreter_arg = interpreter_arg
        """Raw `--interpreter`/`--no-interpreter` tri-state for advisory gating."""
        """Whether `--model` was passed on the command line.

        Suppresses adopting a resumed thread's persisted model.
        """

        self._should_adopt_resumed_model = False
        """One-shot flag set by `_resolve_resume_thread` when an existing
        thread is resumed without an explicit `--model`.

        Consumed before the first resumed agent turn to adopt the thread's
        persisted model.
        """

        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`."""

        self._last_model_unchanged_message: str | None = None
        """Most recent same-model notice, used to suppress duplicates."""

        self._model_install_switching = False
        """True while a provider extra install-then-switch flow is active."""

        self._message_timestamps_visible = _load_message_timestamps_visible()
        """Whether message timestamp footers are shown in the chat surface.

        Restored from `[ui].show_message_timestamps` and re-persisted on toggle.
        """

        # 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._active_user_message: UserMessage | None = None
        """The `UserMessage` widget that started the in-flight turn, tracked so
        it can be dimmed if the turn is interrupted."""

        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._pending_shell_messages: list[BaseMessage] = []
        """Non-incognito `!` command/output messages awaiting flush.

        `!` runs outside the agent graph, so its command/output are buffered
        here and written into thread state on the next user send (see
        `_flush_pending_shell_messages`) — never proactively. `!!` (incognito)
        never appends here."""

        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).
        """

        # 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._defer_connection_status_display = (
            self._connecting and self._resume_thread_intent is None
        )
        """Whether initial connection progress is temporarily hidden.

        The status bar remains the only visible connection owner; this flag
        just avoids flashing it during fast startup. Initial startup owns this
        flag; mid-session reconnects must not re-arm it.
        """

        self._connection_status_reveal_timer: Timer | None = None
        """One-shot timer that reveals deferred status-bar connection progress."""

        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._reconnecting = False
        """True while a mid-session server restart is in flight.

        Distinguishes a reconnect (e.g. `/mcp reconnect`, `/restart`, agent or
        model swap) from the initial connect so the status bar can label the
        spinner accordingly.

        Only meaningful while `_connecting` is `True`; callers must reset it to
        `False` whenever they clear `_connecting` so the pair can't drift into
        the meaningless `(_connecting=False, _reconnecting=True)` state.
        """

        self._resuming = self._connecting and self._resume_thread_intent is not None
        """True while the initial connect is resuming a thread (`-r`).

        Lets the status bar label the spinner "Resuming" instead of the generic
        "Connecting" during `-r` startup. Only meaningful while `_connecting` is
        `True`; `_sync_status_connection` resets it to `False` whenever it
        observes `_connecting` cleared.

        Set once at init and never re-armed, since `_resume_thread_intent` is
        consumed on the first connect — so unlike `_reconnecting` it needs no
        caller-side reset discipline.
        """

        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._ctrl_c_times: list[float] = []
        """Monotonic timestamps of recent `Ctrl+C` presses, pruned to
        `_RAPID_QUIT_CTRL_C_WINDOW_SECONDS`. Drives the rapid-quit escape hatch
        so mashing `Ctrl+C` bypasses the clipboard-copy branches and reaches the
        quit arm even when a draft is present."""

        self._clear_input_pending = False
        """True after a first `Esc` (with nothing else to interrupt) so a second
        press within the window clears the chat input draft."""

        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.
        """

        self._initial_session_started = False
        """Set on first entry into `_run_session_start_sequence` past gating.

        Server respawns (`/mcp reconnect`, `/restart`) post a fresh
        `ServerReady`; without this flag the sequence re-runs and
        `_load_thread_history` bulk-mounts widgets whose IDs already exist in
        the DOM, raising `DuplicateIds`. Set on entry (not on success) because
        if `_load_thread_history` partially mounted before failing, retrying
        would still hit the duplicate-ID path.
        """

        # 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."""

        self._ripgrep_ensured = asyncio.Event()
        """Set once the managed-ripgrep install/prepend attempt has run.

        `_ensure_managed_ripgrep` runs the install + `PATH` prepend exactly
        once and signals here. `_start_server_background` awaits it before
        spawning the langgraph subprocess so the server inherits the managed
        `rg` on `PATH`; the optional-tools worker reuses the same result
        instead of installing a second time.
        """

        self._ripgrep_ensure_lock = asyncio.Lock()
        """Serializes the one-shot managed-ripgrep install across workers."""

        self._ripgrep_install_failed = False
        """True when the managed-ripgrep install attempt did not yield a binary.

        Lets the optional-tools worker still surface the missing-tool notice
        after `_start_server_background` has already attempted the install.
        """

        # 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.
        # `_ChatScroll` keeps clicks on messages from stealing input focus.
        with _ChatScroll(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,
                mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
                id="welcome-banner",
            )
            yield Container(id="messages")
        with Container(id="bottom-app-container"):
            # Live fan-out panel for subagents spawned from js_eval. Hidden
            # until the first spawn event; sits just above the input.
            yield SubagentPanel(id="subagent-panel")
            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)
        self._sync_status_connection()
        self._sync_status_queued()
        self._chat_input.set_cursor_blink(blink=self._cursor_blink_enabled)

        # 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:
            dependency_screen, dependency_result = (
                self._build_launch_dependencies_prompt()
            )

            def skip_dependency_prompt(_name: str) -> None:
                if not dependency_result.done():
                    dependency_result.set_result((False, None))

            name_result = self._push_launch_name_result_future(
                continue_screen=dependency_screen,
                on_continue_failed=skip_dependency_prompt,
            )
            self._ensure_launch_init_task(
                name_result=name_result,
                dependency_result=dependency_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()

        # Non-essential advisory: defer past first paint so it never delays
        # the initial frame.
        self.call_after_refresh(self._notify_interpreter_tools_without_interpreter)
        self.call_after_refresh(self._notify_interpreter_disabled_by_sandbox)
        self.call_after_refresh(self._notify_orphaned_tracing_disabled)

    def _notify_orphaned_tracing_disabled(self) -> None:
        """Toast if startup disabled tracing because credentials were missing."""
        from deepagents_code.config import consume_orphaned_tracing_disabled_notice

        notice = consume_orphaned_tracing_disabled_notice()
        if notice is None:
            return
        # The notice is already consumed (cleared) above, so a failed render
        # would drop the toast. The durable channel is the `logger.warning`
        # emitted at the mutation site in `_disable_orphaned_tracing`; this
        # toast is best-effort, so swallow-and-log rather than letting the
        # exception escape this deferred callback unlogged.
        try:
            self.notify(notice, severity="warning", timeout=8, markup=False)
        except Exception:
            logger.exception("Failed to surface orphaned-tracing disabled notice")

    def _notify_interpreter_tools_without_interpreter(self) -> None:
        """Toast when `--interpreter-tools` was set while the interpreter is off.

        The PTC allowlist applies only when the interpreter middleware is
        enabled, so the flag is a no-op on its own. This is the TUI counterpart
        of the non-interactive stderr warning emitted in
        `main._warn_if_interpreter_tools_without_interpreter`: a stderr line is
        invisible behind the alternate screen, so the same advisory is surfaced
        as a startup notification here.

        Reads the values from `self._server_kwargs` (which already carries them
        for server startup); no extra plumbing is required.
        """
        server_kwargs = self._server_kwargs or {}
        if server_kwargs.get("interpreter_ptc") is None:
            return
        if server_kwargs.get("enable_interpreter"):
            return
        self.notify(
            "--interpreter-tools has no effect when the interpreter is disabled.",
            severity="warning",
            markup=False,
        )

    def _notify_interpreter_disabled_by_sandbox(self) -> None:
        """Toast when a remote sandbox suppressed the otherwise-default interpreter.

        `js_eval` is on by default in local mode but unsupported under a remote
        sandbox, so a `--sandbox` run silently drops it. A stderr line would be
        clobbered by the alternate screen, so the advisory is surfaced here as a
        startup notification — the TUI counterpart of the non-interactive warning
        in `main._warn_if_interpreter_disabled_by_sandbox`.

        Gated on the raw `--interpreter` tri-state (`self._interpreter_arg`) so an
        explicit `--no-interpreter` opt-out stays quiet, and on the local default
        from `settings` so users who disabled the interpreter in config are not
        nagged.
        """
        from deepagents_code._server_config import _interpreter_suppressed_by_sandbox
        from deepagents_code.config import settings

        if not _interpreter_suppressed_by_sandbox(
            enable_interpreter=self._interpreter_arg,
            sandbox_type=self._sandbox_type,
            local_default=settings.enable_interpreter,
        ):
            return
        self.notify(
            "JS interpreter (js_eval) is unavailable under a remote sandbox; "
            "it runs in local mode only.",
            severity="warning",
            markup=False,
        )

    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,
            on_subagent_event=self._on_subagent_event,
        )
        # 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 _ensure_managed_ripgrep(self) -> bool:
        """Install the managed `rg` and prepend it to `PATH`, exactly once.

        Runs at most one install attempt per session, guarded by
        `_ripgrep_ensure_lock`. The first caller (typically
        `_start_server_background`) does the network download so the server
        subprocess inherits the managed binary on `PATH`; later callers
        (the optional-tools worker) observe the cached result via
        `_ripgrep_ensured` instead of installing again.

        Returns:
            `True` when a usable `rg` is resolved — the managed binary (with
            `BIN_DIR` prepended to `PATH`) or a system `rg` already on `PATH` —
            `False` when the install was skipped or failed (caller should
            surface the missing tool and fall back to the slow path).
        """
        async with self._ripgrep_ensure_lock:
            if self._ripgrep_ensured.is_set():
                return not self._ripgrep_install_failed

            try:
                from deepagents_code.main import _should_ensure_managed_ripgrep
                from deepagents_code.managed_tools import (
                    ChecksumMismatchError,
                    ensure_ripgrep,
                    managed_rg_path,
                    prepend_managed_bin_to_path,
                )
            except ImportError:
                logger.warning("Could not import managed-tools helpers", exc_info=True)
                self._ripgrep_install_failed = True
                self._ripgrep_ensured.set()
                return False

            try:
                should_ensure = await asyncio.to_thread(_should_ensure_managed_ripgrep)
            except OSError:
                logger.debug("Failed to check for optional tools", exc_info=True)
                self._ripgrep_install_failed = True
                self._ripgrep_ensured.set()
                return False

            if not should_ensure:
                self._ripgrep_ensured.set()
                return True

            installed = None
            try:
                installed = await ensure_ripgrep()
            except ChecksumMismatchError:
                logger.exception(
                    "ripgrep auto-install aborted: SHA-256 mismatch on downloaded "
                    "archive"
                )
                self.notify(
                    "ripgrep auto-install aborted: checksum verification failed.",
                    severity="error",
                    timeout=15,
                    markup=False,
                )
            except Exception:
                logger.warning(
                    "ripgrep auto-install failed unexpectedly", exc_info=True
                )
                self.notify(
                    "ripgrep auto-install failed unexpectedly — see logs.",
                    severity="warning",
                    timeout=10,
                    markup=False,
                )

            if installed is not None:
                if installed == managed_rg_path():
                    prepend_managed_bin_to_path()
                self._ripgrep_ensured.set()
                return True

            self._ripgrep_install_failed = True
            self._ripgrep_ensured.set()
            return False

    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

        # Install the managed `rg` (or reuse the install already done by
        # `_start_server_background`). `check_optional_tools` reports the
        # managed binary as still "missing", so drop it explicitly when the
        # ensure succeeds rather than relying on a re-check.
        if "ripgrep" in missing and await self._ensure_managed_ripgrep():
            missing = [tool for tool in missing if tool != "ripgrep"]

        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.
        # Onboarding suppresses too: the flow covers integrations (and
        # prompts for a Tavily key) itself, so a "Web search disabled"
        # toast over the onboarding modals is noise. Entries stay
        # reachable via ctrl+n.
        suppress_toasts = (
            self._update_modal_pending.is_set() or self._onboarding_session
        )

        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()
            skills, roots = await asyncio.to_thread(
                self._discover_skills_and_roots_with_import_lock,
            )
        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)

    def _discover_skills_and_roots_with_import_lock(
        self,
    ) -> tuple[list[ExtendedSkillMetadata], list[Path]]:
        """Discover skills while serializing Deep Agents SDK import entry.

        Returns:
            Tuple of `(skill metadata list, pre-resolved containment roots)`.
        """
        with _DEEPAGENTS_IMPORT_LOCK:
            return self._discover_skills_and_roots()

    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,
        )

        resumed_thread_id: str | None = None
        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
                    resumed_thread_id = thread_id
                    self._should_adopt_resumed_model = not self._model_explicitly_set
                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
                resumed_thread_id = resume
                self._should_adopt_resumed_model = not self._model_explicitly_set
                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)
            if resumed_thread_id is not None:
                # The cwd-switch offer is a post-resolution convenience. Isolate
                # its failures so they can't fall into the resume-resolution
                # handler below, which would discard the already-resolved thread
                # and misleadingly report "Could not look up thread history."
                try:
                    await self._offer_thread_cwd_switch(
                        resumed_thread_id,
                        restart_server=False,
                    )
                except Exception:
                    logger.exception(
                        "cwd switch offer failed for resumed thread %s",
                        resumed_thread_id,
                    )
                    self.notify(
                        "Resumed the thread, but could not check its working "
                        "directory. Local context may be stale.",
                        severity="warning",
                        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.model_config import (
                ModelConfigError,
                save_recent_model,
                touch_recent_model,
            )

            try:
                result = await asyncio.to_thread(
                    _create_model_with_deepagents_import_lock,
                    **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

        # Install the managed `rg` and prepend it to `PATH` BEFORE spawning
        # the langgraph subprocess: `ServerProcess.start()` snapshots
        # `os.environ` into the child, so an install that lands after the
        # server starts would never reach the SDK's filesystem backend and
        # grep would stay on the slow Python fallback until a restart.
        await self._ensure_managed_ripgrep()

        from deepagents_code.server_manager import start_server_and_get_agent

        coros: list[Any] = [start_server_and_get_agent(**self._server_kwargs)]  # ty: ignore[invalid-argument-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._reconnecting = 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.needs_attention()
        )
        self._mcp_errored = sum(
            1 for s in (event.mcp_server_info or []) if s.status == "error"
        )
        self._mcp_awaiting_reconnect = sum(
            1 for s in (event.mcp_server_info or []) if s.status == "awaiting_reconnect"
        )

        # 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,
                mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
            )
        except NoMatches:
            logger.warning("Welcome banner not found during server ready transition")
        self._sync_status_connection()

        # 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._reconnecting = False
        self._connection_ready_event.set()
        headline_truncated = False
        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)
            # A clipped headline drops the actionable tail (e.g. the
            # `interpreter_ptc` available-tools list), so point at the full log.
            headline_truncated = (
                len(_startup_error_headline(event.error))
                > _STARTUP_ERROR_HEADLINE_LIMIT
            )

        # 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")
        self._sync_status_connection()

        # 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.extras_info import ExtrasIntrospectionError
                from deepagents_code.update_check import (
                    ToolRequirementIntrospectionError,
                    install_package_command,
                )

                try:
                    install_cmd = install_package_command(missing.package)
                except (
                    ValueError,
                    ExtrasIntrospectionError,
                    ToolRequirementIntrospectionError,
                ) as exc:
                    logger.debug(
                        "install_package_command failed; falling back to "
                        "manual hint: %s",
                        exc,
                    )
                    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`."
                )

        if headline_truncated:
            from deepagents_code._debug import installed_debug_log_path

            # Base the pointer on the handler that was actually installed, not on
            # `DEEPAGENTS_CODE_DEBUG`: the var can read truthy (e.g. set in a
            # `.env`) while no log file exists, which would point users at a
            # nonexistent path.
            debug_path = installed_debug_log_path()
            if debug_path is not None:
                text += f"\n\nNote: error truncated — full error in {debug_path}."
            else:
                text += (
                    "\n\nNote: error truncated. Re-run with "
                    "`DEEPAGENTS_CODE_DEBUG=1` to write the full error to the "
                    "debug log."
                )

        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:
            # Defense in depth: `_prewarm_deferred_imports` swallows every
            # import failure in its own guard, so this branch is effectively
            # unreachable for import errors. It stays as a backstop for a
            # failure originating outside that guard (e.g. the worker
            # machinery itself) so a failed prewarm never propagates here.
            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.

        Prewarming is purely a cache optimization, so every failure is
        swallowed (logged at WARNING): the affected module simply cold-loads
        on first use instead. This guard is load-bearing when the installed
        package is replaced in place mid-session — e.g. a concurrent
        `uv tool upgrade deepagents-code`, which rewrites the tool
        environment's files. A module that hasn't been imported yet can be
        transiently absent on disk during that swap, and the deferred import
        then raises `ModuleNotFoundError`. Letting that propagate would crash
        the whole TUI (the worker exception surfaces as a fatal full-screen
        traceback) over a transient filesystem race that resolves itself by
        the time the user actually triggers the import.
        """
        try:
            DeepAgentsApp._load_deferred_modules()
        except Exception:
            logger.warning(
                "Import prewarm failed; deferred modules will cold-load on first use",
                exc_info=True,
            )

    @staticmethod
    def _load_deferred_modules() -> None:
        """Import the modules prewarmed by `_prewarm_deferred_imports`.

        Split out so the prewarm worker entry point can wrap the entire
        import sequence in a single best-effort guard — see that method's
        docstring for why a failure here must never be fatal.
        """
        # Internal modules moved from top-level to local imports. 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. This inner guard is intentionally narrower
            # than the outer one in `_prewarm_deferred_imports`: an absent
            # optional dep is expected, so it logs and lets the remaining
            # (always-present) modules still warm rather than aborting the
            # whole sequence.
            with _DEEPAGENTS_IMPORT_LOCK:
                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.
        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 surface it in-session.

        Phase 1 contacts PyPI and records the latest version on the app.
        Phase 2 surfaces a detected update without installing it in-session
        (the actual install runs at startup via `_run_startup_auto_update`):
        when auto-update is enabled it toasts a prompt to restart so the
        startup path can upgrade; otherwise it raises an actionable notice
        (periodic recheck) or registers the notice and schedules the update
        modal (initial check).
        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.config import _is_editable_install
            from deepagents_code.update_check import (
                is_auto_update_enabled,
                is_installed_version_at_least,
                is_update_available,
                upgrade_command,
            )

            if await asyncio.to_thread(_is_editable_install):
                return

            available, latest = await asyncio.to_thread(
                is_update_available,
                bypass_cache=periodic,
            )
            if not available or latest is None:
                return
            if await asyncio.to_thread(is_installed_version_at_least, latest):
                self._update_available = (False, 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
            from deepagents_code.update_check import (
                format_installed_age_suffix,
                format_release_age_parenthetical,
                mark_update_notified,
                release_requires_prereleases,
                should_notify_update,
            )

            if is_auto_update_enabled():
                if not await asyncio.to_thread(should_notify_update, latest):
                    return
                release_age = await asyncio.to_thread(
                    format_release_age_parenthetical,
                    latest,
                )
                installed_age = await asyncio.to_thread(
                    format_installed_age_suffix,
                    cli_version,
                )
                self.notify(
                    f"Update available: v{latest}{release_age}. "
                    f"Currently installed: {cli_version}{installed_age}. "
                    "Quit and relaunch dcode to install the update "
                    "automatically.",
                    severity="information",
                    timeout=12,
                    markup=False,
                )
                await asyncio.to_thread(mark_update_notified, latest)
                return

            if not await asyncio.to_thread(should_notify_update, latest):
                return

            update_needs_prereleases = await asyncio.to_thread(
                release_requires_prereleases,
                latest,
            )
            cmd = upgrade_command(
                include_prereleases=True if update_needs_prereleases else None,
                version=latest if update_needs_prereleases else None,
            )
            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, command: str = "/update") -> None:
        """Handle the `/update` slash command — check for and install updates.

        Parses optional `--prerelease` and `--deps` flags from the raw command
        line; any other option is rejected with a usage message. `--deps`
        re-resolves dependencies to their newest in-range versions even when
        `deepagents-code` itself is already current.

        Args:
            command: The raw slash-command line as typed, including any options.
        """
        parts = command.split()
        await self._mount_message(UserMessage(command))
        # Reject typo'd/miscased options (e.g. `--prereleases`, `--PRERELEASE`)
        # loudly instead of silently downgrading to a stable update — mirroring
        # the headless path, which also refuses unknown options rather than
        # silently ignoring them.
        allowed_options = {"--prerelease", "--deps"}
        unknown = [opt for opt in parts[1:] if opt not in allowed_options]
        if unknown:
            await self._mount_message(
                AppMessage(
                    f"Unknown option(s) for /update: {' '.join(unknown)}. "
                    "Usage: /update [--deps] [--prerelease]",
                ),
            )
            return
        prerelease_requested = "--prerelease" in parts[1:]
        deps_only = "--deps" in parts[1:]
        include_prereleases = True if prerelease_requested else None
        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 (
                _PRERELEASE_UNSUPPORTED_MESSAGE,
                dependency_refresh_supported,
                detect_shadowed_dcode_safe,
                format_age_suffix,
                format_dependency_changes,
                format_installed_age_suffix,
                format_release_age_parenthetical,
                format_shadowed_dcode_warning,
                is_update_available,
                parse_dependency_changes,
                perform_dependency_refresh_dry_run,
                perform_upgrade,
                prerelease_upgrade_supported,
                release_requires_prereleases,
                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

            # Refuse pre-release upgrades the install method can't honor before
            # promising an upgrade or hitting PyPI.
            if prerelease_requested:
                supported, reason = await asyncio.to_thread(
                    prerelease_upgrade_supported,
                )
                if not supported:
                    await self._mount_message(
                        AppMessage(reason or _PRERELEASE_UNSUPPORTED_MESSAGE),
                    )
                    return

            await self._mount_message(AppMessage("Checking for updates..."))
            available, latest = await asyncio.to_thread(
                is_update_available,
                bypass_cache=True,
                include_prereleases=include_prereleases,
            )
            if latest is None:
                await self._mount_message(
                    AppMessage(
                        "Could not determine the latest version. "
                        "Check your network and try again.",
                    ),
                )
                return
            upgrade_include_prereleases = include_prereleases
            pin_upgrade_version: str | None = None
            if include_prereleases is None and await asyncio.to_thread(
                release_requires_prereleases,
                latest,
            ):
                upgrade_include_prereleases = True
                pin_upgrade_version = latest
            if not available:
                if deps_only:
                    await self._refresh_dependencies(
                        include_prereleases=include_prereleases,
                    )
                    return

                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}).",
                    ),
                )
                # dcode is current, but its dependencies may have newer in-range
                # releases. Compute a dry-run plan first so the confirmation only
                # appears when there are concrete updates to apply. Keep the support
                # gate before the check so brew/other users aren't asked about an
                # action that cannot run for their install.
                refresh_supported, _reason = await asyncio.to_thread(
                    dependency_refresh_supported,
                )
                if not refresh_supported:
                    return
                await self._mount_message(
                    AppMessage("Checking for dependency updates...")
                )
                success, output = await perform_dependency_refresh_dry_run(
                    include_prereleases=include_prereleases,
                )
                if not success:
                    detail = f": {output[:200]}" if output else ""
                    await self._mount_message(
                        AppMessage(f"Could not check dependency updates{detail}"),
                    )
                    return
                dep_changes = parse_dependency_changes(output)
                if not dep_changes:
                    await self._mount_message(
                        AppMessage("Dependencies are already up to date."),
                    )
                    return
                planned = format_dependency_changes(dep_changes)
                if await self._confirm_refresh_dependencies(planned_changes=planned):
                    await self._refresh_dependencies(
                        include_prereleases=include_prereleases,
                    )
                else:
                    await self._mount_message(AppMessage("Dependency refresh skipped."))
                return

            if deps_only:
                refresh_supported, _reason = await asyncio.to_thread(
                    dependency_refresh_supported,
                )
                if (
                    refresh_supported
                    and not await self._confirm_update_before_dependency_refresh(
                        current=cli_version,
                        latest=latest,
                    )
                ):
                    await self._refresh_dependencies(
                        include_prereleases=include_prereleases,
                        app_update_version=latest,
                    )
                    return

            if upgrade_include_prereleases is True:
                supported, reason = await asyncio.to_thread(
                    prerelease_upgrade_supported,
                )
                if not supported:
                    await self._mount_message(
                        AppMessage(reason or _PRERELEASE_UNSUPPORTED_MESSAGE),
                    )
                    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(
                include_prereleases=include_prereleases,
                target_version=latest,
            )
            if success:
                self._update_available = (False, None)
                # uv may have installed the upgraded shim into a directory that
                # isn't first on the user's PATH (e.g. a leftover pre-uv
                # `dcode` from a former `pipx` install). Detect that before
                # mounting the success line so we don't follow a green
                # "relaunch to use the new version" with a warning that
                # relaunching will keep the old version. Use the
                # never-raises wrapper so a detector defect can't turn a
                # successful upgrade into a "/update failed" message.
                shadow = await asyncio.to_thread(detect_shadowed_dcode_safe)
                if shadow is None:
                    await self._mount_message(
                        AppMessage(
                            f"Updated to v{latest}. Quit and relaunch dcode "
                            "to use the new version."
                        ),
                    )
                else:
                    await self._mount_message(
                        ErrorMessage(format_shadowed_dcode_warning(shadow)),
                    )
                # The upgrade re-resolves the whole environment, so surface any
                # dependency bumps that rode along with the dcode release.
                dep_changes = [
                    change
                    for change in parse_dependency_changes(output)
                    if change.name != "deepagents-code"
                ]
                if dep_changes:
                    await self._mount_message(
                        AppMessage(
                            "Dependencies updated:\n"
                            f"{format_dependency_changes(dep_changes)}",
                        ),
                    )
            else:
                cmd = upgrade_command(
                    include_prereleases=upgrade_include_prereleases,
                    version=pin_upgrade_version,
                )
                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 _refresh_dependencies(
        self,
        *,
        include_prereleases: bool | None,
        app_update_version: str | None = None,
    ) -> None:
        """Re-resolve dependencies to their newest in-range versions.

        Reinstalls the current `deepagents-code` version with an upgraded
        dependency resolution, then reports which dependencies actually moved.
        Used by the `/update --deps` and already-current refresh flows. Editable
        installs are rejected by the caller before this runs; the refresh is
        uv-only by construction (other install methods are refused by
        `perform_dependency_refresh`), so pre-release resolution is always
        available here.

        Args:
            include_prereleases: Whether to include alpha/beta/rc releases;
                `None` follows the installed version's channel.
            app_update_version: Newer `deepagents-code` version discovered by
                the caller, if dependency refresh is intentionally staying on
                the current app version.
        """
        from deepagents_code._env_vars import DEBUG_UPDATE
        from deepagents_code.update_check import (
            format_dependency_changes,
            parse_dependency_changes,
            perform_dependency_refresh,
        )

        await self._mount_message(AppMessage("Refreshing dependencies..."))
        if os.environ.get(DEBUG_UPDATE):
            await self._mount_message(
                AppMessage("Skipped dependency refresh (debug mode)."),
            )
            return
        success, output = await perform_dependency_refresh(
            include_prereleases=include_prereleases,
        )
        if not success:
            # Lead with the start of the output for parity with the upgrade
            # failure path; uv prints the actionable summary (e.g. "No solution
            # found") first. The full output is persisted to the update log.
            detail = f": {output[:200]}" if output else ""
            await self._mount_message(
                AppMessage(
                    f"Dependency refresh failed{detail}",
                ),
            )
            return
        changes = parse_dependency_changes(output)
        if output.strip() and not changes:
            # The refresh succeeded but nothing parsed out of uv's diff. Either
            # nothing moved, or uv's output format drifted past our parser. Leave
            # a breadcrumb so the latter doesn't masquerade as "up to date"
            # without a trace (the raw output is retained in the update log).
            logger.warning(
                "Dependency refresh produced no parseable changes; uv output "
                "format may have drifted.",
            )
        self_changes = [
            change for change in changes if change.name == "deepagents-code"
        ]
        dep_changes = [change for change in changes if change.name != "deepagents-code"]
        if not dep_changes and not self_changes:
            if app_update_version is not None:
                await self._mount_message(
                    AppMessage(
                        "Dependencies are already up to date. "
                        "A deepagents-code update is available: "
                        f"v{app_update_version}.",
                    ),
                )
                return
            await self._mount_message(
                AppMessage("Dependencies are already up to date."),
            )
            return

        message_parts: list[str] = []
        if self_changes:
            message_parts.append(
                f"Updated deepagents-code:\n{format_dependency_changes(self_changes)}"
            )
        if dep_changes:
            message_parts.append(
                f"Refreshed dependencies:\n{format_dependency_changes(dep_changes)}"
            )
        if app_update_version is not None:
            message_parts.append(
                f"A deepagents-code update is available: v{app_update_version}."
            )
        await self._mount_message(
            AppMessage(
                "\n".join(message_parts) + "\nQuit and relaunch dcode to use them.",
            ),
        )

    async def _confirm_update_before_dependency_refresh(
        self,
        *,
        current: str,
        latest: str,
    ) -> bool:
        """Ask whether `/update --deps` should take an app update first.

        Args:
            current: Currently running `deepagents-code` version.
            latest: Latest available `deepagents-code` version.

        Returns:
            `True` only when the user explicitly chooses the app update; `False`
                on cancel, timeout, or mount failure so `/update --deps` continues
                with the requested dependency refresh.
        """
        from deepagents_code.widgets.update_confirm import (
            UpdateBeforeDependenciesConfirmScreen,
        )

        try:
            confirmed = await asyncio.wait_for(
                self._push_screen_wait(
                    UpdateBeforeDependenciesConfirmScreen(
                        current=current,
                        latest=latest,
                    )
                ),
                timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
            )
        except TimeoutError:
            logger.warning(
                "App-update confirmation timed out; continuing dependency refresh",
            )
            await self._mount_message(
                AppMessage(
                    "Update prompt timed out; refreshing dependencies for the "
                    "current version instead.",
                ),
            )
            return False
        except Exception:
            logger.exception("Failed to mount app-update confirmation")
            await self._mount_message(
                AppMessage(
                    "Couldn't show the update prompt; refreshing dependencies "
                    "for the current version instead.",
                ),
            )
            return False
        return confirmed is True

    async def _confirm_refresh_dependencies(
        self,
        *,
        planned_changes: str | None = None,
    ) -> bool:
        """Ask the user to confirm a dependency refresh via a modal.

        A watchdog bounds the wait so a modal that never resolves can't wedge
        command handling; a timeout or mount failure is treated as a cancel.

        Args:
            planned_changes: Optional preflight summary to show before confirming.

        Returns:
            `True` only when the user explicitly confirmed; `False` on cancel,
                timeout, or mount failure.
        """
        from deepagents_code.widgets.update_confirm import (
            RefreshDependenciesConfirmScreen,
        )

        try:
            confirmed = await asyncio.wait_for(
                self._push_screen_wait(
                    RefreshDependenciesConfirmScreen(planned_changes=planned_changes),
                ),
                timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
            )
        except TimeoutError:
            logger.warning(
                "Dependency-refresh confirmation timed out; treating as cancel",
            )
            await self._mount_message(
                AppMessage("Dependency-refresh prompt timed out; skipping."),
            )
            return False
        except Exception:
            logger.exception("Failed to mount dependency-refresh confirmation")
            await self._mount_message(
                AppMessage(
                    "Couldn't show the dependency-refresh prompt; skipping. "
                    "See logs for details.",
                ),
            )
            return False
        return confirmed is True

    async def _handle_install_command(self, command: str) -> None:
        """Handle the `/install <extra>` slash command.

        Adds an optional extra (e.g. `daytona`, `fireworks`) to the installed
        dcode tool by re-running
        `uv tool install --reinstall -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:]
        package_mode = "--package" in parts[1:]
        # `--yes` is an undocumented alias for `--force` in package mode,
        # mirroring the CLI's `--yes` confirmation bypass.
        yes = "--yes" in parts[1:]
        names = [p for p in parts[1:] if not p.startswith("-")]
        if not names:
            from deepagents_code.extras_info import format_known_extras

            await self._mount_message(
                AppMessage(
                    "Usage: /install <extra> [--force]\n"
                    "       /install <package> --package [--force]\n"
                    "Example: /install daytona\n\n"
                    f"{format_known_extras()}",
                ),
            )
            return
        if len(names) > 1:
            label = "package" if package_mode else "extra"
            await self._mount_message(
                AppMessage(
                    f"Only one {label} may be installed per /install command. "
                    f"Got: {', '.join(names)}",
                ),
            )
            return
        await self._mount_message(UserMessage(command))

        if package_mode:
            await self._handle_install_package(names[0], force=force or yes)
            return

        extra = names[0].lower()
        await self._install_extra(extra, force=force)

    async def _install_extra(
        self, extra: str, *, force: bool = False, auto_restart: bool = False
    ) -> bool:
        """Install a `deepagents-code` extra, mounting progress and restart offer.

        Shared by the `/install <extra>` command and the model selector's
        install-on-select flow. Mounts its own status/error messages and offers
        a one-keypress restart for restart-capable extras.

        Args:
            extra: The extra name to install (e.g. `"baseten"`, `"daytona"`).
            force: Skip the "unknown extra" guard for valid-but-unlisted names.
            auto_restart: Restart the app-owned server immediately after a
                restart-capable install. Used only when the user selected a model
                that cannot load until the server respawns.

        Returns:
            `True` when the extra installed successfully and, when `auto_restart`
                was requested, the server was restarted (or a fresh startup will
                load it); `False` otherwise. The interactive restart offer
                (non-`auto_restart` path) does not affect the return value.
        """
        try:
            from deepagents_code.config import _is_editable_install
            from deepagents_code.extras_info import (
                KNOWN_EXTRAS,
                MODEL_PROVIDER_EXTRAS,
                SANDBOX_EXTRAS,
                ExtrasIntrospectionError,
            )
            from deepagents_code.update_check import (
                ToolRequirementIntrospectionError,
                create_update_log_path,
                editable_extra_hint,
                install_extra_command,
                install_extra_recovery_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 False

        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 False

        if await asyncio.to_thread(_is_editable_install):
            await self._mount_message(
                AppMessage(
                    "Editable install detected — cannot install extras.\n"
                    + editable_extra_hint(extra),
                ),
            )
            return False

        # KNOWN_EXTRAS is a curated "did you mean" list, not the authoritative
        # set (that's pyproject, resolved by uv): defer to --force rather than
        # refuse, since valid-but-unlisted names exist (e.g. all-providers).
        if extra not in KNOWN_EXTRAS and not force:
            try:
                manual_cmd = await asyncio.to_thread(install_extra_command, extra)
            except (
                ExtrasIntrospectionError,
                ToolRequirementIntrospectionError,
                ValueError,
            ) as exc:
                logger.warning("/install command failed", exc_info=True)
                await self._mount_message(
                    ErrorMessage(f"Install failed: {type(exc).__name__}: {exc}"),
                )
                return False
            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: `{manual_cmd}`\n"
                    f"Re-run with `--force` to install anyway: "
                    f"`/install {extra} --force`",
                ),
            )
            return False

        log_path = create_update_log_path()
        # Load the restart modal before the upgrade rewrites our own package
        # tree; the post-install import then hits the in-memory cache.
        self._ensure_restart_prompt_loaded()
        await self._mount_message(
            AppMessage(f"Installing extra '{extra}'..."),
        )
        try:
            manual_cmd = await asyncio.to_thread(install_extra_command, extra)
        except (
            ExtrasIntrospectionError,
            ToolRequirementIntrospectionError,
            ValueError,
        ) as exc:
            logger.warning("/install command failed", exc_info=True)
            await self._mount_message(
                ErrorMessage(
                    f"Install failed: {type(exc).__name__}: {exc}\nLog: {log_path}",
                ),
            )
            return False
        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)
            # Best-effort upgrade of `manual_cmd` to the install-method-specific
            # recovery command. On failure, keep the install-script command
            # already bound above so the hint is never empty. `manual_cmd` is
            # rendered into a Textual `Content` (literal, not Rich markup), so no
            # bracket escaping is needed here.
            try:
                manual_cmd = await asyncio.to_thread(
                    install_extra_recovery_command, extra
                )
            except (
                ExtrasIntrospectionError,
                ToolRequirementIntrospectionError,
                ValueError,
            ):
                logger.warning(
                    "/install recovery command failed (install raised)",
                    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 False

        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 ""
            # See the OSError branch above: best-effort recovery command, falling
            # back to the already-bound install-script command on failure.
            try:
                manual_cmd = await asyncio.to_thread(
                    install_extra_recovery_command, extra
                )
            except (
                ExtrasIntrospectionError,
                ToolRequirementIntrospectionError,
                ValueError,
            ):
                logger.warning(
                    "/install recovery command failed (install reported failure)",
                    exc_info=True,
                )
            await self._mount_message(
                ErrorMessage(
                    f"Install failed{detail}\n"
                    f"Log: {log_path}\n"
                    f"Run manually: {manual_cmd}",
                ),
            )
            return False

        # Model-provider and sandbox extras are imported by the langgraph
        # server subprocess; `/restart` respawns that subprocess and picks
        # them up without exiting the TUI. STANDALONE_EXTRAS are wired into
        # the parent process at startup, so a full relaunch is required.
        restart_capable = extra in MODEL_PROVIDER_EXTRAS or extra in SANDBOX_EXTRAS
        if restart_capable and auto_restart:
            if self._restart_after_install_is_unneeded():
                # No running server to respawn; a deferred/errored startup will
                # import the extra on its first spawn. Acknowledge the install
                # regardless of whether the config reload below succeeds.
                await self._mount_message(
                    AppMessage(f"Installed extra '{extra}'."),
                )
                return await self._reload_configuration_for_restart()
            if await self._restart_after_install(extra):
                return True
            if self._server_kwargs is None:
                await self._mount_message(
                    AppMessage(
                        f"Installed extra '{extra}', but this app is connected "
                        "to a remote LangGraph server. Relaunch dcode to load it, "
                        "then select the model again."
                    ),
                )
            else:
                await self._mount_message(
                    AppMessage(
                        f"Installed extra '{extra}', but couldn't restart the server "
                        "automatically. Run `/restart` to load it, then select the "
                        "model again."
                    ),
                )
            return False

        if not restart_capable:
            next_step = "Exit and relaunch dcode to use the new dependencies."
            await self._mount_message(
                AppMessage(f"Installed extra '{extra}'. {next_step}"),
            )
            return True

        # Restart-capable extra: announce success, then offer a one-keypress
        # restart. `_offer_restart_after_install` owns all follow-up messaging
        # (the prompt's button is the call to action when shown; it mounts a
        # `/restart`-or-relaunch hint itself when it can't show the prompt), so
        # a redundant "Run /restart" line is never appended here.
        await self._mount_message(AppMessage(f"Installed extra '{extra}'."))
        await self._offer_restart_after_install(extra)
        return True

    async def _handle_install_package(self, package: str, *, force: bool) -> None:
        """Install an arbitrary package into the dcode tool env via `uv --with`.

        Backs `/install <package> --package`, the escape hatch for a provider
        whose package is not a `deepagents-code` extra (e.g. a custom
        `class_path` model). Arbitrary packages have no curated allowlist, so a
        non-blocking confirmation modal gates pulling in third-party code.
        `--force` (or `--yes`) bypasses the prompt.

        Args:
            package: The package name to install.
            force: Whether the user passed `--force`/`--yes` to skip the prompt.
        """
        try:
            from deepagents_code.config import _is_editable_install
            from deepagents_code.update_check import (
                create_update_log_path,
                editable_package_hint,
                is_valid_package_name,
                perform_install_package,
            )
        except ImportError as exc:
            logger.warning("/install --package import failed", exc_info=True)
            await self._mount_message(
                ErrorMessage(f"Install failed: {type(exc).__name__}: {exc}"),
            )
            return

        if not is_valid_package_name(package):
            await self._mount_message(
                AppMessage(
                    "Invalid package name. Package names must be "
                    "alphanumeric with `-`, `_`, or `.` (PEP 508).",
                ),
            )
            return

        if await asyncio.to_thread(_is_editable_install):
            await self._mount_message(
                AppMessage(
                    "Editable install detected — cannot install packages.\n"
                    + editable_package_hint(package),
                ),
            )
            return

        # `_confirm_install_package` mounts its own outcome message (cancel,
        # timeout, or mount failure), so the caller just aborts on a falsy
        # result rather than mounting a generic — and possibly inaccurate —
        # "Cancelled" line.
        if not force and not await self._confirm_install_package(package):
            return

        log_path = create_update_log_path()
        # Load the restart modal before the upgrade rewrites our own package
        # tree; the post-install import then hits the in-memory cache.
        self._ensure_restart_prompt_loaded()
        await self._mount_message(
            AppMessage(f"Installing package '{package}'..."),
        )
        try:
            success, output = await perform_install_package(package, log_path=log_path)
        except OSError as exc:
            # Let `asyncio.CancelledError` propagate — this runs in the message
            # pump, so swallowing it would suppress shutdown/cancellation.
            logger.warning("/install --package command failed", exc_info=True)
            await self._mount_message(
                ErrorMessage(
                    f"Install failed: {type(exc).__name__}: {exc}\nLog: {log_path}",
                ),
            )
            return

        if not success:
            detail = f": {output[-200:]}" if output else ""
            await self._mount_message(
                ErrorMessage(
                    f"Install failed{detail}\nLog: {log_path}",
                ),
            )
            return

        await self._mount_message(
            AppMessage(
                f"Installed package '{package}'. Run `/restart` to load it "
                "now, or relaunch dcode.",
            ),
        )
        await self._offer_restart_after_install(package)

    async def _confirm_install_package(self, package: str) -> bool:
        """Ask the user to confirm installing an arbitrary package.

        Pushes a non-blocking Textual modal explaining that the install runs
        third-party code. A watchdog bounds the wait so a modal that never
        resolves can't wedge command handling; a timeout or mount failure is
        treated as a cancel. Each non-confirming outcome mounts its own
        message so the user is told what actually happened rather than being
        told they "cancelled" a prompt that timed out or failed to appear.

        Args:
            package: The package name to confirm, surfaced in the modal.

        Returns:
            `True` only when the user explicitly confirmed; `False` on cancel,
                timeout, or mount failure.
        """
        from deepagents_code.widgets.install_confirm import (
            InstallPackageConfirmScreen,
        )

        try:
            confirmed = await asyncio.wait_for(
                self._push_screen_wait(InstallPackageConfirmScreen(package)),
                timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
            )
        except TimeoutError:
            logger.warning(
                "Install confirmation for %r timed out; treating as cancel",
                package,
            )
            await self._mount_message(
                AppMessage(
                    f"Install confirmation for '{package}' timed out; not "
                    "installed. Re-run with `--force` to skip the prompt.",
                ),
            )
            return False
        except Exception:
            logger.exception("Failed to mount install confirmation for %r", package)
            await self._mount_message(
                ErrorMessage(
                    f"Could not show the install confirmation for '{package}'; "
                    "not installed. Re-run with `--force` to skip the prompt.",
                ),
            )
            return False

        # Fail closed: a programmatic dismiss yields `None`, so only an
        # explicit `True` proceeds — anything else is treated as "do not
        # install".
        if confirmed is not True:
            await self._mount_message(
                AppMessage(f"Cancelled install of package '{package}'."),
            )
            return False
        return True

    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.

        Editable installs additionally surface the source path and the
        resolved versions of the core LangChain-ecosystem dependencies, which
        helps diagnose local checkouts.
        """
        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")

        from deepagents_code.extras_info import resolve_sdk_version

        sdk_version, sdk_status = resolve_sdk_version()
        if sdk_status == "resolved":
            from deepagents_code.update_check import format_sdk_age_suffix

            sdk_age_suffix = await asyncio.to_thread(format_sdk_age_suffix, sdk_version)
            lines.append(f"deepagents (SDK) version: {sdk_version}{sdk_age_suffix}")
        else:
            lines.append("deepagents (SDK) version: unknown")

        editable = False
        try:
            from deepagents_code.config import (
                _get_editable_install_path,
                _is_editable_install,
            )

            editable = await asyncio.to_thread(_is_editable_install)
            if editable:
                path = _get_editable_install_path()
                lines.append(
                    f"Editable install: {path}" if path else "Editable install"
                )
        except Exception:
            logger.warning("Unexpected error detecting editable install", exc_info=True)

        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)))

        if editable:
            try:
                from deepagents_code.extras_info import format_core_dependencies

                core_markdown = format_core_dependencies()
            except Exception:
                logger.warning(
                    "Failed to collect core dependency versions", exc_info=True
                )
                core_markdown = ""
            if core_markdown:
                await self._mount_message(AppMessage(core_markdown, markdown=True))

        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 _sync_status_connection(self) -> None:
        """Mirror the current connection state onto the bottom status bar.

        The app-level welcome banner keeps rendering its regular footer while
        the status bar is the single owner for connection progress. State is
        derived from `_connecting`/`_reconnecting` so callers only have to flip
        those flags before calling.
        """
        if self._status_bar is None:
            return
        if self._reconnecting and not self._connecting:
            # The two flags must never drift into this meaningless pair (see
            # `_reconnecting`). Self-heal loudly rather than silently rendering
            # a stale reconnect label off a half-cleared state.
            logger.warning(
                "Connection flags drifted to (_connecting=False, "
                "_reconnecting=True); resetting _reconnecting",
            )
            self._reconnecting = False
        if not self._connecting:
            self._defer_connection_status_display = False
            self._resuming = False
            self._cancel_connection_status_reveal_timer()
            self._status_bar.set_connection("")
        elif self._defer_connection_status_display:
            self._status_bar.set_connection("")
            self._schedule_connection_status_reveal_timer()
        elif self._reconnecting:
            self._status_bar.set_connection("reconnecting")
        elif self._resuming:
            self._status_bar.set_connection("resuming")
        else:
            self._status_bar.set_connection("connecting")

    def _schedule_connection_status_reveal_timer(self) -> None:
        """Schedule the one-shot timer that reveals deferred connection state."""
        if self._connection_status_reveal_timer is not None:
            return
        self._connection_status_reveal_timer = self.set_timer(
            _CONNECTING_STATUS_REVEAL_DELAY_SECONDS,
            self._on_connection_status_reveal_timer,
        )

    def _cancel_connection_status_reveal_timer(self) -> None:
        """Cancel and clear the deferred connection-status reveal timer."""
        if self._connection_status_reveal_timer is None:
            return
        self._connection_status_reveal_timer.stop()
        self._connection_status_reveal_timer = None

    def _on_connection_status_reveal_timer(self) -> None:
        """Reveal the status-bar connection indicator after the delay elapses."""
        self._connection_status_reveal_timer = None
        self._reveal_connection_status()

    def _reveal_connection_status(self) -> None:
        """Stop deferring and render the current status-bar connection state."""
        if not self._defer_connection_status_display:
            return
        self._defer_connection_status_display = False
        self._cancel_connection_status_reveal_timer()
        self._sync_status_connection()

    def _sync_status_queued(self) -> None:
        """Mirror the pending-message queue depth onto the status bar."""
        if self._status_bar is None:
            return
        self._status_bar.set_queued(len(self._pending_messages))

    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:
                footer = self._build_message_timestamp_footer(
                    msg_data, visible=self._message_timestamps_visible
                )
                if first_child:
                    if footer is not None:
                        await messages_container.mount(footer, before=first_child)
                        await messages_container.mount(widget, before=footer)
                    else:
                        await messages_container.mount(widget, before=first_child)
                else:
                    await messages_container.mount(widget)
                    if footer is not None:
                        await messages_container.mount(footer)
                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)

    async def _mount_transient_app_message(self, content: str) -> AppMessage | None:
        """Mount an `AppMessage` that is not tracked by the message store.

        Use for status text that should disappear once the state it describes
        resolves (e.g. "Restarting server..."). The returned widget can be
        removed directly; nothing lingers in the store to re-hydrate later.

        Args:
            content: The message text to display.

        Returns:
            The mounted widget, or `None` when the messages container is
                missing or detached.
        """
        try:
            messages = self.query_one("#messages", Container)
        except (NoMatches, ScreenStackError):
            logger.debug(
                "Messages container unavailable; skipping transient status %r",
                content,
                exc_info=True,
            )
            return None
        if not messages.is_attached:
            return None
        widget = AppMessage(content)
        await self._mount_before_queued(messages, widget)
        return 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)

    def _pause_loading_spinner_for_approval(self) -> None:
        """Pause the global spinner timer while an approval widget is visible."""
        if self._loading_widget is not None:
            self._loading_widget.pause()

    def _resume_loading_spinner_after_approval(
        self,
        _future: asyncio.Future[Any] | None = None,
    ) -> None:
        """Resume the global spinner timer after an approval decision.

        Accepts an unused `_future` argument so it can be registered directly as
        a `Future.add_done_callback`, which always passes the completed future
        positionally.
        """
        if self._loading_widget is not None:
            self._loading_widget.resume()

    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
            if self._terminal_progress_enabled:
                try:
                    clear_terminal_progress()
                except Exception:
                    # Cosmetic only — must never break spinner lifecycle.
                    logger.exception("clear_terminal_progress raised unexpectedly")
            return

        if self._terminal_progress_enabled:
            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:
            # A fresh status update means the agent is active again, so
            # un-pause as a backstop in case an approval future was ever
            # abandoned without completing the resume callback. `resume()` is a
            # no-op when the spinner is not paused.
            self._loading_widget.resume()
            # 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)

        # Pause the elapsed-time counter while the user decides, then resume it
        # when the decision future completes. Resolve, reject, and cancel all
        # fire the done-callback; the `_set_spinner` backstop covers the
        # remaining case where a future is abandoned without completing.
        self._pause_loading_spinner_for_approval()
        result_future.add_done_callback(self._resume_loading_spinner_after_approval)

        # 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)

    async def _write_live_approval_mode(self) -> bool:
        """Persist the current approval mode for the active thread.

        Returns:
            `True` when no write was needed or the write succeeded, otherwise
            `False`.
        """
        if self._session_state is None or self._agent is None:
            return True
        from deepagents_code.approval_mode import awrite_approval_mode

        try:
            live_key = await awrite_approval_mode(
                self._agent,
                self._session_state.thread_id,
                auto_approve=bool(self._session_state.auto_approve),
            )
        except Exception:
            self._session_state.approval_mode_key = None
            logger.warning("Failed to write live approval-mode state", exc_info=True)
            return False
        if live_key is None:
            # No store writer on the agent (a local/in-process agent rather
            # than a RemoteAgent). This is an expected configuration, not a
            # fault, so — unlike the except branch above — we clear the stale
            # key and fail closed without logging, to avoid noise on every
            # toggle. The run-context path persists the mode for local agents.
            self._session_state.approval_mode_key = None
            return False
        self._session_state.approval_mode_key = live_key
        return True

    def _warn_live_approval_mode_unavailable(self, message: str) -> None:
        """Surface live approval-mode degradation to the user."""
        self.notify(message, severity="warning", timeout=8, markup=False)

    async 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
            if not await self._write_live_approval_mode():
                self._warn_live_approval_mode_unavailable(
                    "Auto-approve could not sync to the running agent; "
                    "approval prompts may continue."
                )

    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._initial_session_started:
            # Server respawns (e.g. `/mcp reconnect`, `/restart`) fire another
            # `ServerReady`; rerunning the sequence would attempt to bulk-load
            # the active thread on top of widgets already mounted in the DOM.
            logger.debug(
                "Skipping session start sequence; already initialized for thread %s",
                self._lc_thread_id,
            )
            await self._drain_startup_backlog()
            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._initial_session_started = True
        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()
            elif self._has_initial_submission():
                try:
                    await self._adopt_resumed_model_if_needed(
                        thread_id=self._lc_thread_id
                    )
                except Exception:
                    logger.exception(
                        "Failed to adopt resumed model for %s before startup "
                        "submission",
                        self._lc_thread_id,
                    )
                    await self._mount_message(
                        ErrorMessage(
                            "Could not read the resumed thread state. "
                            "Startup prompt was not submitted."
                        ),
                    )
                    return

            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

        await self._drain_startup_backlog()

    async def _drain_startup_backlog(self) -> None:
        """Drain deferred actions and queued input after server readiness."""
        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). Startup command output is local setup output and is not
        buffered into model context. 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, incognito=True),
                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

    def _push_launch_name_result_future(
        self,
        *,
        continue_screen: ModalScreen[Any] | None = None,
        on_continue_failed: Callable[[str], None] | None = None,
    ) -> asyncio.Future[str | None]:
        """Push the launch name modal and return its result future.

        Args:
            continue_screen: Optional screen that replaces the name modal after
                submit, avoiding a frame where the base app is exposed.
            on_continue_failed: Optional callback invoked with the submitted
                name if replacing the name modal fails.

        Returns:
            Future completed with the submitted name or `None` when skipped.
        """
        from deepagents_code.widgets.launch_init import LaunchNameScreen

        loop = asyncio.get_running_loop()
        result_future: asyncio.Future[str | None] = loop.create_future()

        def handle_result(result: str | None) -> None:
            if not result_future.done():
                result_future.set_result(result)

        screen = LaunchNameScreen(
            continue_screen=continue_screen,
            on_continue=handle_result if continue_screen is not None else None,
            on_continue_failed=on_continue_failed,
        )
        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,
        dependency_result: Awaitable[tuple[bool, tuple[str, 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.
            dependency_result: Optional pre-wired dependency/model result. Used
                when the name screen switches directly to the dependency screen.

        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,
                    dependency_result=dependency_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,
        dependency_result: Awaitable[tuple[bool, tuple[str, 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),
                )

            if dependency_result is None:
                (
                    dependency_continued,
                    result,
                ) = await self._prompt_launch_dependencies_then_model()
            else:
                dependency_continued, result = await dependency_result
            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
            await self._prompt_launch_tavily()
            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_or_install_launch_model(model_spec, provider)
            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 _switch_or_install_launch_model(
        self,
        model_spec: str,
        provider: str,
    ) -> None:
        """Install a missing provider extra before switching from onboarding.

        Args:
            model_spec: The selected `provider:model` spec.
            provider: Provider returned by the model selector.
        """
        if provider:
            from deepagents_code.config_manifest import (
                is_provider_package_installed,
                provider_install_extra,
            )

            extra = provider_install_extra(provider)
            if extra is not None and not is_provider_package_installed(provider):
                await self._install_extra_then_switch(extra, model_spec)
                return
        await self._switch_model(model_spec, announce_unchanged=False)

    async def _prompt_launch_tavily(self) -> None:
        """Optionally collect and store a Tavily web-search key during onboarding.

        Skipped when a Tavily key is already configured (env or stored). A
        blank submission or Escape stores nothing; a non-empty key is persisted
        via the same `auth_store` path `/auth` uses. The key is also exported to
        the process environment (`apply_stored_service_credentials`) so a server
        respawn this session picks it up; the already-running server keeps its
        spawn-time tools, so web search takes full effect on the next launch (or
        after a restart).
        """
        from deepagents_code.config import settings

        if settings.has_tavily:
            return

        from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult

        result = await self._push_screen_wait(
            AuthPromptScreen(
                "tavily",
                "TAVILY_API_KEY",
                reason=(
                    "Web search is optional but strongly recommended to enhance "
                    "your agent's capabilities."
                ),
                allow_empty_submit=True,
                input_placeholder="Tavily API key (optional)",
                submit_label="Enter save/skip",
            )
        )
        if result is not AuthResult.SAVED:
            return

        from deepagents_code.model_config import apply_stored_service_credentials

        apply_stored_service_credentials()

        # `apply_stored_service_credentials` is best-effort: it swallows a
        # corrupt-store read with only a `logger.warning`, which is invisible
        # inside a Textual session. The user just saw the key accepted, so
        # confirm it actually reached the environment; if not, say so rather
        # than letting web search silently stay disabled. Reaching this branch
        # means `has_tavily` was False at bootstrap, so a populated
        # `TAVILY_API_KEY` here can only come from the export above.
        if not os.environ.get("TAVILY_API_KEY"):
            self.notify(
                "Saved your Tavily key, but couldn't activate it this "
                "session. Restart Deep Agents Code, or re-add it with /auth.",
                severity="warning",
                markup=False,
            )

    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,
        on_done: Callable[[bool | None], None] | None = None,
    ) -> ModalScreen:
        """Build the onboarding optional-dependency summary screen.

        Args:
            continue_screen: Optional screen to switch to when continuing.
            on_done: Optional callback invoked when the dependency screen finishes
                without switching to the model selector.

        Returns:
            Dependency summary modal.
        """
        from deepagents_code.widgets.launch_init import LaunchDependenciesScreen

        return LaunchDependenciesScreen(
            continue_screen=continue_screen,
            on_done=on_done,
        )

    def _build_launch_dependencies_prompt(
        self,
    ) -> tuple[ModalScreen, asyncio.Future[tuple[bool, tuple[str, str] | None]]]:
        """Build the first post-name onboarding screen and its result future.

        The integrations summary screen is disabled by default (the model
        selector already surfaces and installs uninstalled providers), so the
        model selector is normally the first screen. Setting
        `DEEPAGENTS_CODE_ONBOARDING_INTEGRATIONS_SCREEN` re-inserts the
        `LaunchDependenciesScreen` ahead of it.

        Returns:
            The first onboarding screen and a future resolved when the
            dependency or model screen finishes.
        """
        from deepagents_code._env_vars import (
            ONBOARDING_INTEGRATIONS_SCREEN,
            is_env_truthy,
        )

        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))

        def handle_dependencies(result: bool | None) -> None:
            if result is None:
                finish((False, None))
            elif result is True:
                finish((True, None))

        model_screen = self._build_model_selector_screen(
            curated=True,
            result_callback=handle_model,
        )
        if not is_env_truthy(ONBOARDING_INTEGRATIONS_SCREEN):
            return model_screen, result_future

        dependency_screen = self._build_launch_dependencies_screen(
            continue_screen=model_screen,
            on_done=handle_dependencies,
        )

        return dependency_screen, result_future

    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.
        """
        dependency_screen, result_future = self._build_launch_dependencies_prompt()
        self.push_screen(dependency_screen)
        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,
            STARTUP_RECOVERY_COMMANDS,
        )

        cmd = value.split(maxsplit=1)[0] if value else ""
        # Recovery escape hatch: when startup failed (`_server_startup_error`
        # set) and nothing is running, the commands that repair the session
        # must run instead of being parked behind the failure they fix — e.g.
        # `/install <pkg>` for a missing provider package. Gated on no active
        # work so a reinstall never swaps the running binary mid-turn.
        if (
            cmd in STARTUP_RECOVERY_COMMANDS
            and self._server_startup_error is not None
            and not (self._agent_running or self._shell_running)
        ):
            return True
        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,
        )

        # Union of two always-immediate sets. ALWAYS_IMMEDIATE holds public
        # urgent commands (/quit, /force-clear, /restart); HIDDEN_COMMANDS
        # holds debug helpers (/debug-error) that aren't registered in
        # COMMANDS and so carry no bypass tier. Both must run even when the
        # app is busy or wedged, so neither sits behind the queue.
        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)
            self._sync_status_queued()
            if self._connecting:
                self._reveal_connection_status()
            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  # ty: ignore[invalid-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()
                err_msg = "Command timed out (60s limit)"
                await self._mount_message(ErrorMessage(err_msg))
                if not incognito:
                    self._buffer_shell_for_model_context(command, err_msg, None)
                return
            except asyncio.CancelledError:
                await self._kill_shell_process()
                if not incognito:
                    self._buffer_shell_for_model_context(
                        command,
                        "Command interrupted",
                        None,
                    )
                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}"))

            # Non-incognito `!` only; `!!` stays local. Buffered, not written
            # now — see `_buffer_shell_for_model_context` for the rationale.
            if not incognito:
                self._buffer_shell_for_model_context(command, output, 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))
            if not incognito:
                self._buffer_shell_for_model_context(command, err_msg, None)
        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)

    def _buffer_shell_for_model_context(
        self, command: str, output: str, returncode: int | None
    ) -> None:
        """Buffer a non-incognito `!` command/output for the next user send.

        `!` commands run as local subprocesses that bypass the agent graph, so
        their command/output never reach the checkpoint the model reads. Rather
        than write to thread state immediately (which would spend a model turn
        on output the user may never reference), the command/output are queued
        here as a `HumanMessage`/`AIMessage` pair and flushed when the user
        sends their next message (see `_flush_pending_shell_messages`). `!!`
        (incognito) callers skip this and stay local-only.

        Args:
            command: The shell command that was run (without the `!` prefix).
            output: Combined stdout/stderr captured from the command.
            returncode: Process exit code, or `None` if unavailable.
        """
        from langchain_core.messages import AIMessage, HumanMessage

        status = f"\n[Command exited with code {returncode}]" if returncode else ""
        body = output or "(no output)"
        self._pending_shell_messages.extend(
            [
                HumanMessage(content=f"!{command}"),
                AIMessage(content=f"```\n{body}\n```{status}"),
            ]
        )

    async def _flush_pending_shell_messages(self) -> None:
        """Write buffered `!` command/output into thread state, then clear it.

        Called right before a user-driven agent turn so the model sees any
        `!` commands run since the last turn. Adopts the session thread id when
        one has not been resolved yet (e.g. a `!` run before the first send).
        Best-effort: a checkpoint write failure is logged and surfaced as a
        toast, and the buffer is still cleared so stale output is not replayed
        onto a later turn. Returns early when nothing is buffered; when output
        is buffered but no agent/thread is active yet, the buffer is left intact
        for a later send rather than dropped.
        """
        if not self._pending_shell_messages:
            return
        if not self._lc_thread_id and self._session_state:
            self._lc_thread_id = self._session_state.thread_id
        if not self._agent or not self._lc_thread_id:
            return

        messages = self._pending_shell_messages
        self._pending_shell_messages = []
        config: RunnableConfig = {"configurable": {"thread_id": self._lc_thread_id}}
        remote_config: dict[str, Any] = {
            "configurable": {"thread_id": self._lc_thread_id}
        }
        try:
            # Suppress the standalone `UpdateState` LangSmith run this write would
            # otherwise emit — it's bookkeeping, not a user-driven agent turn.
            from langsmith import tracing_context

            with tracing_context(enabled=False):
                if remote := self._remote_agent():
                    await remote.aensure_thread(remote_config)
                await self._agent.aupdate_state(config, {"messages": messages})
        except Exception:  # best-effort; UI already showed the output
            # Parity with the offload path's `aupdate_state` failure handling:
            # log the traceback and surface a non-blocking toast, since the
            # model silently lacking output the user expects is confusing.
            logger.exception("Failed to flush shell command into model context")
            with suppress(Exception):
                self.notify(
                    "Couldn't add ! output to the model's context.",
                    severity="warning",
                    markup=False,
                )

    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. When the thread has no messages yet, a note
        is appended warning that the trace stays empty until the first message.

        Args:
            command: The raw command text (displayed as user message).
        """
        from deepagents_code.config import (
            LangSmithApiError,
            LangSmithImportError,
            LangSmithLookupTimeoutError,
            LangSmithProjectNotFoundError,
            _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. "
                    "Run `/auth` and select LangSmith to enable tracing, or set "
                    "LANGSMITH_API_KEY and LANGSMITH_TRACING=true.",
                ),
            )
            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 --reinstall -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 LangSmithProjectNotFoundError:
            logger.debug(
                "LangSmith project %r not found yet for thread %s",
                project_name,
                thread_id,
            )
            await self._mount_message(UserMessage(command))
            await self._mount_message(
                AppMessage(
                    f"No traces have been recorded in LangSmith project "
                    f"{project_name!r} yet. The project is created automatically "
                    "the first time a run is traced — try `/trace` again after "
                    "your first message.",
                ),
            )
            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)

        # Warn when the thread has no human turn yet — the LangSmith view stays
        # empty until the first message is sent. `_has_conversation_messages`
        # returns True on errors so transient state failures suppress this warning
        # rather than showing a false empty-thread note.
        parts: list[str | Content | tuple[str, str | TStyle]] = [
            f"Opening tracing project {project_name!r}:\n",
            (url, TStyle(dim=True, italic=True, link=url)),
        ]
        if not await self._has_conversation_messages():
            parts.append(
                "\n\nYou haven't sent a message in this thread yet, so the "
                "trace will be empty until you send your first message.",
            )
        msg = Content.assemble(*parts)

        # 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))
                await self._mount_message(AppMessage(msg))

            # 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))
        await self._mount_message(AppMessage(msg))

    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, /restart, /skill:<name>, /remember, "
                "/skill-creator, /theme, /timestamps, /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()
            self._sync_status_queued()
            await self._clear_messages()
            # A fresh conversation drops any prior subagent fan-out too.
            subagent_panel = self._get_subagent_panel()
            if subagent_panel is not None:
                subagent_panel.reset()
            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" or cmd.startswith("/update "):
            await self._handle_update_command(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 == "/timestamps":
            await self._toggle_message_timestamp_footers()
            label = "shown" if self._message_timestamps_visible else "hidden"
            self.notify(
                f"Message timestamps {label}.",
                severity="information",
                timeout=5,
                markup=False,
            )
        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)
        # -- Debug 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",
                ),
            )
        # -- /restart: public, but ALWAYS_IMMEDIATE so it runs even when wedged
        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_with_import_lock,
                )
                # 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 callers do not block or warn based on an unreliable
                empty-thread check.
        """
        if not self._agent or not self._lc_thread_id:
            return False
        try:
            from langchain_core.messages import HumanMessage

            # Use the shared helper so the thread is registered first
            # (`aensure_thread`, remote agents only) in server mode — otherwise
            # the dev server returns empty state for a thread it has not seen
            # this session.
            state_values = await self._get_thread_state_values(self._lc_thread_id)
            messages = state_values.get("messages", [])
            # `RemoteGraph.aget_state` returns messages as raw JSON dicts, so an
            # `isinstance(m, HumanMessage)` check alone misses them and wrongly
            # reports "nothing to remember". Detect both object and dict forms.
            return any(
                isinstance(m, HumanMessage)
                or (isinstance(m, dict) and m.get("type") == "human")
                for m in messages
            )
        except Exception:
            logger.warning(
                "Failed to check conversation messages",
                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

    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, tracking it so it can be dimmed on interrupt.
        user_message = UserMessage(message)
        await self._mount_message(user_message)
        self._active_user_message = user_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

            # Flush any buffered non-incognito `!` shell output into thread
            # state so this turn's model sees commands run since the last turn.
            await self._flush_pending_shell_messages()

            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))

    def _effective_model_spec(self) -> str | None:
        """Return the `provider:model` spec in effect for the next invocation.

        Prefers a per-session `/model` override; otherwise falls back to the
        startup-resolved model from `settings`. Returns `None` when neither
        yields a usable spec (e.g. credentials not yet configured), so
        `ResumeStateMiddleware` records nothing rather than a malformed spec.
        """
        if self._model_override:
            return self._model_override
        from deepagents_code.config import settings

        provider = settings.model_provider or ""
        model = settings.model_name or ""
        if provider and model:
            return f"{provider}:{model}"
        return None

    def _active_provider(self) -> str | None:
        """Return the provider name in effect for the next invocation.

        Derives the provider from the effective `provider:model` spec, falling
        back to `settings.model_provider`. Used to diagnose gateway/key
        mismatches when an error is rendered.
        """
        spec = self._effective_model_spec()
        if spec and ":" in spec:
            return spec.split(":", 1)[0] or None
        from deepagents_code.config import settings

        return settings.model_provider or None

    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()

        # Arm the subagent fan-out panel for this turn, seeding the session
        # model that labels each row. The panel persists across turns and only
        # clears when this turn's first subagent actually starts, so a turn that
        # spawns none leaves the previous workflow's results on screen.
        panel = self._get_subagent_panel()
        if panel is not None:
            spec = self._effective_model_spec()
            panel.prepare_turn(model_label=_display_model_label(spec))

        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,
                # `auto_approve` is intentionally omitted here: execute_task_textual
                # writes it into this context from `session_state.auto_approve` at
                # the top of every stream iteration, so seeding it would be dead.
                context=CLIContext(
                    model=self._model_override,
                    model_params=self._model_params_override or {},
                    effective_model=self._effective_model_spec(),
                ),
                turn_stats=turn_stats,
            )
        except Exception as e:  # Resilient tool rendering
            logger.exception("Agent execution failed")
            try:
                from deepagents_code.remote_client import format_agent_exception

                error_text = f"Agent error: {format_agent_exception(e)}"
            except Exception:
                # The formatter itself must never mask the original error.
                logger.exception("format_agent_exception failed")
                error_text = f"Agent error: {e!r}"
            # 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(error_text)
            # Enrich the error body in its own guard so a bug here can never
            # swallow the underlying error — the user must always see
            # `error_text`. Gateway/key detection reads config + the credential
            # store from disk, so run it off the event loop.
            try:
                key_env = await asyncio.to_thread(
                    _langsmith_gateway_key_mismatch, self._active_provider()
                )
                body = _build_agent_error_body(error_text, e, key_env=key_env)
            except Exception:
                logger.exception("Failed to enrich agent error body")
                body = error_text
            try:
                await self._mount_message(ErrorMessage(body))
            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
            # Finalize any subagent rows left "running" — an interrupt cancels
            # the worker before the bridge emits terminal events (a cancel is a
            # BaseException, which the bridge's `except Exception` skips), so the
            # panel would otherwise spin forever. No-op when nothing's running.
            subagent_panel = self._get_subagent_panel()
            if subagent_panel is not None:
                subagent_panel.finalize_running()
            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()
            self._sync_status_queued()

            # 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
        self._active_user_message = 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_MESSAGE_PREFIX):
                    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, the persisted
            context-token count, and the persisted model spec (if any).
        """
        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
        )
        raw_spec = state_values.get("_model_spec")
        model_spec = raw_spec if isinstance(raw_spec, str) else ""
        messages = state_values.get("messages", [])

        if not messages:
            return _ThreadHistoryPayload([], context_tokens, model_spec)

        # 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, model_spec)

    async def _adopt_resumed_model_if_needed(
        self,
        *,
        model_spec: str | None = None,
        thread_id: str | None = None,
    ) -> None:
        """Adopt a resumed thread's persisted model for this session only.

        Args:
            model_spec: Already-fetched `_model_spec`, when available.
            thread_id: Thread ID to fetch `_model_spec` from if needed.
        """
        if not self._should_adopt_resumed_model:
            return

        self._should_adopt_resumed_model = False
        spec = model_spec
        if spec is None and thread_id:
            state_values = await self._get_thread_state_values(thread_id)
            raw_spec = state_values.get("_model_spec")
            spec = raw_spec if isinstance(raw_spec, str) else ""

        if spec:
            await self._switch_model(
                spec,
                announce_unchanged=False,
                persist=False,
                from_resume=True,
            )

    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)
            )
            # Adopt the resumed thread's model (session-only) so the session
            # continues on the model it was last using, not the global default.
            # One-shot: only on the initial `-r` resume, never on in-session
            # thread switches, and never when `--model` was passed explicitly.
            # Runs before the empty-history early return so the flag is always
            # consumed on this first load — otherwise a legacy thread (no
            # persisted spec) could leave it armed for a later in-session
            # `/threads` switch.
            await self._adopt_resumed_model_if_needed(model_spec=payload.model_spec)

            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:
                nodes: list[Widget] = []
                for widget, msg_data in zip(widgets, visible, strict=False):
                    nodes.append(widget)
                    footer = self._build_message_timestamp_footer(
                        msg_data, visible=self._message_timestamps_visible
                    )
                    if footer is not None:
                        nodes.append(footer)
                await messages_container.mount(*nodes)

            # 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}"))

    @staticmethod
    def _build_message_timestamp_footer(
        data: MessageData, *, visible: bool
    ) -> Static | None:
        """Build a timestamp footer for a message.

        Args:
            data: Message data carrying the timestamp.
            visible: Whether the footer should be shown immediately. New
                footers built while timestamps are on must carry the visible
                class so they render without waiting for a toggle.

        Returns:
            A footer widget, or `None` when the message type is in
                `_TIMESTAMP_FOOTER_EXCLUDED_TYPES` or when the timestamp is
                invalid.
        """
        if data.type in _TIMESTAMP_FOOTER_EXCLUDED_TYPES:
            return None
        label = format_message_timestamp(data.timestamp)
        if label is None:
            logger.warning("Invalid timestamp for message %s", data.id)
            return None
        classes = _MESSAGE_TIMESTAMP_FOOTER_CLASS
        if visible:
            classes = f"{classes} {_MESSAGE_TIMESTAMP_FOOTER_VISIBLE_CLASS}"
        return Static(
            Content.styled(label, "dim"),
            id=_message_timestamp_footer_id(data.id),
            classes=classes,
        )

    def _sync_message_timestamps_display(self) -> None:
        """Apply the current visibility to every mounted timestamp footer.

        Flips the visible class on the footer leaves directly (not on
        `#messages`) so a toggle restyles only the footers rather than
        re-cascading the entire message subtree. `batch_update` coalesces the
        relayout into a single pass.
        """
        footers = self.query(f".{_MESSAGE_TIMESTAMP_FOOTER_CLASS}")
        if not footers:
            return
        with self.batch_update():
            footers.set_class(
                self._message_timestamps_visible,
                _MESSAGE_TIMESTAMP_FOOTER_VISIBLE_CLASS,
            )

    async def _toggle_message_timestamp_footers(self) -> None:
        """Toggle visible timestamp footers and persist the preference."""
        self._message_timestamps_visible = not self._message_timestamps_visible
        self._sync_message_timestamps_display()
        await self._persist_message_timestamps_visible()

    async def _persist_message_timestamps_visible(self) -> None:
        """Persist the timestamp-footer preference without blocking the loop."""
        try:
            status = await asyncio.to_thread(
                _save_message_timestamps_visible_result,
                self._message_timestamps_visible,
            )
            if status.message is not None:
                self.notify(
                    status.message,
                    severity=status.severity,
                    timeout=6,
                    markup=False,
                )
        except Exception:
            logger.warning(
                "Failed to persist message timestamp preference",
                exc_info=True,
            )

    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

        if isinstance(widget, QueuedUserMessage):
            # Queued placeholders mount at the bottom and stay out of the
            # message store; drain remounts them as real UserMessage widgets.
            await messages.mount(widget)
            try:
                input_container = self.query_one("#bottom-app-container", Container)
                input_container.scroll_visible()
            except NoMatches:
                pass
            return

        # Store message data for virtualization
        message_data = MessageData.from_widget(widget)
        if not widget.id:
            # Keep the widget DOM id == store id so pruning can locate a
            # mounted widget (and its timestamp footer) from its MessageData.
            widget.id = message_data.id
        self._message_store.append(message_data)
        footer = self._build_message_timestamp_footer(
            message_data, visible=self._message_timestamps_visible
        )

        await self._mount_before_queued(messages, widget)
        if footer is not None:
            await self._mount_before_queued(messages, footer)

        # 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}")
                footer_id = _message_timestamp_footer_id(msg_data.id)
                with suppress(NoMatches):
                    footer = messages_container.query_one(f"#{footer_id}")
                    await footer.remove()
                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."""
        # Drop buffered `!` shell output so it never leaks across a thread
        # reset, switch, or resume.
        self._pending_shell_messages.clear()
        # Clear the message store first
        self._message_store.clear()
        # Drop the tracked in-flight prompt: its widget is about to leave the
        # DOM, so the pointer must not outlive it. Keeps the "cleared screen ⇒
        # nothing to dim" invariant self-enforcing regardless of caller timing.
        self._active_user_message = None
        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()
        self._sync_status_queued()
        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.set_value_at_end(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()
        self._sync_status_queued()

    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 a focused input has a non-empty selection, copy it (a failed
            copy falls through to the branches below)
        2. If shell command is running, kill it
        3. If approval menu is active, reject it
        4. If ask_user menu is active, cancel it
        5. If agent is running, interrupt it (preserve input)
        6. If double press (quit_pending), quit
        7. If a focused input has text, copy the whole draft (no selection)
        8. Otherwise show quit hint

        Rapid escape hatch: the clipboard-copy branches (1 and 7) are skipped
        once `Ctrl+C` is pressed `_RAPID_QUIT_CTRL_C_PRESSES` times within
        `_RAPID_QUIT_CTRL_C_WINDOW_SECONDS`. Without this, a non-empty draft
        makes branch 7 copy on every press, so the quit arm is unreachable by
        `Ctrl+C` alone. Mashing `Ctrl+C` then falls through to arm quit (and a
        further press exits). The interrupt branches (2-5) stay unconditional so
        a repeated press still cancels in-flight work rather than quitting.
        """
        now = _monotonic()
        window = _RAPID_QUIT_CTRL_C_WINDOW_SECONDS
        self._ctrl_c_times = [t for t in self._ctrl_c_times if now - t <= window]
        self._ctrl_c_times.append(now)
        rapid = len(self._ctrl_c_times) >= _RAPID_QUIT_CTRL_C_PRESSES

        # If a focused input widget has selected text, copy it instead of
        # quitting/interrupting so Ctrl+C matches standard terminal behavior.
        if not rapid and self._copy_focused_selection():
            self._quit_pending = False
            return

        # 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:
            if self._active_user_message is not None:
                self._active_user_message.set_cancelled()
            self._cancel_worker(self._agent_worker)
            self._quit_pending = False
            return

        # Double Ctrl+C to quit. Once the quit hint is visible, preserve the
        # armed quit path before draft-copy handling gets another chance to
        # consume Ctrl+C and clear `_quit_pending`.
        if self._quit_pending:
            self.exit()
            return

        # No selection and nothing to interrupt: copy the whole input draft so
        # Ctrl+C copies what was typed instead of arming quit. Skipped on a rapid
        # repeat press so mashing Ctrl+C escapes the copy loop and reaches quit.
        if not rapid and self._copy_focused_input_text():
            self._quit_pending = False
            return

        self._arm_quit_pending("Ctrl+C")

    def _copy_focused_selection(self) -> bool:
        """Copy the focused input's selection to the clipboard, if any.

        Returns:
            `True` when a non-empty selection was copied to the clipboard, so
                the caller should treat the keypress as handled and skip
                quit/interrupt. `False` when there was nothing to copy or every
                clipboard backend failed, so the caller should fall through to
                its normal quit/interrupt handling (a failed copy already
                notifies the user).
        """
        from textual.widgets import Input, TextArea

        widget = self.focused
        if not isinstance(widget, (TextArea, Input)):
            return False
        if isinstance(widget, Input) and widget.password:
            return False

        selected_text = widget.selected_text
        if not selected_text:
            return False

        from deepagents_code.clipboard import copy_text_with_feedback

        return copy_text_with_feedback(self, selected_text, failure_noun="selection")

    def _copy_focused_input_text(self) -> bool:
        """Copy the focused input's full text to the clipboard, if non-empty.

        Ctrl+C fallback used when there is no active selection, so the whole
        draft is copied instead of arming quit.

        Returns:
            `True` when non-empty text was handled by a clipboard attempt.
        """
        from textual.widgets import Input, TextArea

        widget = self.focused
        if not isinstance(widget, (TextArea, Input)):
            return False
        if isinstance(widget, Input) and widget.password:
            return False

        text = widget.text if isinstance(widget, TextArea) else widget.value
        if not text:
            return False

        from deepagents_code.clipboard import copy_text_with_feedback

        # Return True regardless of copy success: the keypress is consumed
        # either way (a failed copy already warned), so it never falls through
        # to arming quit.
        copy_text_with_feedback(
            self,
            text,
            failure_noun="input",
            success_message="Input copied to clipboard",
        )
        return True

    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
        9. Otherwise, a second Esc clears the chat input draft (undoable)
        """
        from deepagents_code.widgets.thread_selector import ThreadSelectorScreen

        # Any higher-priority Esc breaks the double-Esc clear sequence: only two
        # consecutive Escs with nothing else to handle should clear the draft.
        # Disarm up front and restore only at the terminal clear branch, so an
        # intervening interrupt (agent cancel, popup dismiss, queued-message pop,
        # ...) can't leave a stale flag that clears a later draft on a single
        # press.
        clear_was_pending = self._clear_input_pending
        self._clear_input_pending = False

        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:
            if self._active_user_message is not None:
                self._active_user_message.set_cancelled()
            self._cancel_worker(self._agent_worker)
            return

        # Nothing left to interrupt: a double Esc clears the chat input draft.
        # Restore the armed state captured above so a genuine consecutive Esc
        # still confirms the clear.
        self._clear_input_pending = clear_was_pending
        self._handle_clear_input_escape()

    def _handle_clear_input_escape(self) -> None:
        """Clear the chat input draft on a double `Esc` press.

        With nothing else to interrupt, the first `Esc` arms a pending flag and
        shows a hint; a second `Esc` within the window clears the draft. The
        clear is undoable via ctrl+z so a mistaken clear can be restored.

        When the draft is empty there is nothing to clear, so no hint is shown
        and any pending flag is reset.
        """
        chat_input = self._chat_input
        if chat_input is None or not chat_input.value:
            self._clear_input_pending = False
            return
        if self._clear_input_pending:
            self._clear_input_pending = False
            # The non-empty `value` guard above already implies a clear, so this
            # is defensive: it only suppresses the toast if `discard_text` ever
            # reports nothing cleared (e.g. a future `value` that diverges from
            # the text area), keeping the confirmation honest.
            if chat_input.discard_text():
                self.notify(
                    "Input cleared (ctrl+z to undo)",
                    timeout=3,
                    markup=False,
                )
            return
        self._arm_clear_input_pending()

    def _arm_clear_input_pending(self) -> None:
        """Set the clear-input flag and show a matching hint."""
        self._clear_input_pending = True
        timeout = 3
        self.notify(
            "Press Esc again to clear input",
            timeout=timeout,
            markup=False,
        )
        self.set_timer(timeout, lambda: setattr(self, "_clear_input_pending", False))

    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._cancel_connection_status_reveal_timer()
        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 _get_subagent_panel(self) -> SubagentPanel | None:
        """Return the subagent fan-out panel, or None if not yet mounted.

        Returns:
            The mounted `SubagentPanel`, or None during early startup.
        """
        try:
            return self.query_one("#subagent-panel", SubagentPanel)
        except Exception:  # noqa: BLE001 — not mounted during early startup
            return None

    def _on_subagent_event(self, event: dict[str, Any]) -> None:
        """Forward a validated subagent custom-stream event to the panel.

        Runs on the Textual event loop (same loop as the stream consumer), so
        the panel widget can be updated directly.
        """
        panel = self._get_subagent_panel()
        if panel is not None:
            panel.on_subagent_event(event)

    def action_toggle_subagent_panel(self) -> None:
        """Expand or collapse the subagent fan-out panel."""
        panel = self._get_subagent_panel()
        if panel is not None:
            panel.toggle()

    async 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, AuthPromptScreen
        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, (AuthPromptScreen, NotificationSettingsScreen)):
            # These modals hold multiple focusable inputs; reuse shift+tab to
            # step focus backward (the Screen's own app.focus_previous binding
            # never fires because this priority binding consumes the key first).
            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
            if not await self._write_live_approval_mode():
                if self._auto_approve:
                    self._warn_live_approval_mode_unavailable(
                        "Auto-approve could not sync to the running agent; "
                        "approval prompts may continue."
                    )
                elif self._agent_running:
                    # Switching to manual mid-run, but the agent never saw it:
                    # cancel the active run rather than let it keep auto-approving.
                    self._session_state.approval_mode_key = None
                    self._warn_live_approval_mode_unavailable(
                        "Manual approval could not sync to the running agent; "
                        "the active run was cancelled for safety."
                    )
                    self._force_interrupt_active_work()
                else:
                    self._warn_live_approval_mode_unavailable(
                        "Manual approval could not sync to the running agent; "
                        "start a new run before continuing."
                    )

    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 and tool_msg.has_expandable_output:
                tool_msg.toggle_output()
                return
            if tool_msg.has_expandable_args:
                tool_msg.toggle_args()
                return
            if tool_msg.has_output:
                tool_msg.toggle_output()
                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._notify_app_focus()
        self._chat_input.set_cursor_blink(blink=self._cursor_blink_enabled)
        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._notify_app_blur()
        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 after click-chain selection updates."""
        from deepagents_code.clipboard import copy_selection_to_clipboard

        self.call_after_refresh(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,
    ) -> ModelSelectorScreen:
        """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. Sandboxes and other integrations install "
                "anytime with /install."
                if curated
                else None
            ),
            result_callback=result_callback,
        )

    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`.
        """

        def handle_result(result: tuple[str, str] | None) -> None:
            """Handle the model selector result."""
            self._handle_model_selection(screen, result, 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 _handle_model_selection(
        self,
        screen: ModelSelectorScreen,
        result: tuple[str, str] | None,
        *,
        extra_kwargs: dict[str, Any] | None = None,
    ) -> None:
        """Route a model-selector result to install-then-switch or a switch.

        Args:
            screen: The dismissed selector, read for a confirmed install extra.
            result: The `(model_spec, provider)` pair, or `None` if cancelled.
            extra_kwargs: Extra constructor kwargs from `--model-params`.
        """
        if result is None:
            return
        model_spec, _ = result
        # When the selector confirmed installing a missing provider's extra,
        # install it first (with restart offer) before switching.
        extra = screen.pending_install_extra
        if extra:
            if self._model_install_switching:
                self.notify(
                    "A provider install is already in progress. Try again after "
                    "it finishes.",
                    severity="warning",
                    timeout=5,
                    markup=False,
                )
                return

            # Set synchronously (before the worker is scheduled) so a second
            # selection on the same message pump is rejected by the guard above
            # before its own worker can start.
            self._model_install_switching = True

            async def install_then_switch() -> None:
                try:
                    await self._install_extra_then_switch(
                        extra,
                        model_spec,
                        extra_kwargs=extra_kwargs,
                    )
                finally:
                    # Sole reset path once the worker awaits this coroutine; runs
                    # on success, exception, and cancellation alike.
                    self._model_install_switching = False

            def start_install_worker() -> None:
                # Run in a worker, not via `call_later`. `_install_extra_then_switch`
                # awaits a credential modal (`AuthPromptScreen`); `call_later` would
                # invoke the coroutine inline on the App message pump, blocking it
                # for the modal's lifetime so no key/mouse input ever reaches the
                # prompt. A worker is a separate task, so the pump stays free and
                # the modal is interactive.
                #
                # The guard is reset only by the coroutine's `finally`, which runs
                # once the worker awaits it. If `run_worker` raises while
                # scheduling, the coroutine never starts, so reset the guard here
                # (and close the orphan coroutine) to keep a failed start from
                # stranding the guard `True` and blocking every later install. A
                # dropped `call_after_refresh` callback only happens at app
                # teardown, where a stuck guard is harmless.
                coro = install_then_switch()
                try:
                    self.run_worker(
                        coro,
                        exclusive=False,
                        group="model-install-switch",
                    )
                except Exception:
                    # Worker never started: close the orphan coroutine and
                    # release the guard so the failed start can't strand it,
                    # then re-raise (never swallow the scheduling error).
                    coro.close()
                    self._model_install_switching = False
                    raise

            # `call_after_refresh` lets the dismissing selector unwind before the
            # worker starts (mirrors the thread selector).
            self.call_after_refresh(start_install_worker)
        else:
            self._dispatch_model_switch(model_spec, extra_kwargs=extra_kwargs)

    def _dispatch_model_switch(
        self,
        model_spec: str,
        *,
        extra_kwargs: dict[str, Any] | None = None,
    ) -> None:
        """Switch to `model_spec` now, or defer until in-flight work finishes.

        The deferral toast is shown only for genuine in-flight user work
        (`_agent_running`/`_shell_running`). A switch deferred solely because the
        server is reconnecting (`_connecting` — e.g. the transient restart during
        install-then-switch) drains automatically once the server is ready and is
        already confirmed by the following "Switched to ..." message, so the
        "after current task completes" toast there is misleading noise.

        Args:
            model_spec: The `provider:model` spec to switch to.
            extra_kwargs: Extra constructor kwargs from `--model-params`.
        """
        from functools import partial

        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,
                    ),
                ),
            )
            if self._agent_running or self._shell_running:
                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,
                ),
            )

    async def _install_extra_then_switch(
        self,
        extra: str,
        model_spec: str,
        *,
        extra_kwargs: dict[str, Any] | None = None,
    ) -> None:
        """Install a provider's extra, then switch to its model on success.

        Args:
            extra: The extra that installs the model's provider integration.
            model_spec: The `provider:model` spec to switch to once installed.
            extra_kwargs: Extra constructor kwargs from `--model-params`.
        """
        # `_install_extra` already surfaced the reason on any failure.
        if not await self._install_extra(extra, auto_restart=True):
            return
        # The extra is now installed regardless of what happens next. If the
        # user dismisses the credential prompt, only the switch is cancelled —
        # the extra stays installed so they can switch later once a key is set.
        # The selector is already gone, so confirm the install landed rather
        # than leaving a silent no-op after a multi-step flow.
        if not await self._prompt_model_auth_if_needed(model_spec):
            await self._mount_message(
                AppMessage(
                    f"Installed '{extra}'. Switch to {model_spec} anytime with "
                    f"`/model` — you'll be prompted for credentials.",
                ),
            )
            return
        self._dispatch_model_switch(model_spec, extra_kwargs=extra_kwargs)

    async def _prompt_model_auth_if_needed(self, model_spec: str) -> bool:
        """Prompt for missing credentials before switching to `model_spec`.

        Args:
            model_spec: The `provider:model` spec selected after installation.

        Returns:
            `True` when switching can continue, or `False` when the user did not
                save required credentials.
        """
        # This assumes API-key-style providers: it always uses the generic
        # `AuthPromptScreen` (key / base-url), never the codex OAuth flow that
        # the selector routes separately. That holds because only providers
        # with a `_PROVIDER_DEPENDENCIES` extra reach the install-then-switch
        # path, and the OAuth providers (e.g. `openai_codex`) have no such
        # entry. If an OAuth provider ever gains an extra, route it to its
        # dedicated sign-in here rather than the key prompt.
        from deepagents_code.config import detect_provider
        from deepagents_code.model_config import (
            ModelSpec,
            get_credential_env_var,
            get_provider_auth_status,
        )
        from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult

        parsed = ModelSpec.try_parse(model_spec)
        provider = parsed.provider if parsed else detect_provider(model_spec)
        if not provider:
            return True

        status = get_provider_auth_status(provider)
        if not status.blocks_start:
            return True

        env_var = status.env_var or get_credential_env_var(provider)
        result = await self._push_screen_wait(
            AuthPromptScreen(
                provider,
                env_var,
                reason=f"Required to use {model_spec}",
            )
        )
        return result is AuthResult.SAVED

    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, *, initial_provider: str | None = None) -> 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.

        Args:
            initial_provider: Provider to start highlighted — set when
                reopening after an install-on-select so the cursor lands on
                the just-installed provider instead of the top of the list.
        """
        from deepagents_code.widgets.auth import AuthManagerScreen

        def handle_result(_result: None) -> None:
            if self._chat_input:
                self._chat_input.focus_input()
            # When the user selected a greyed-out (uninstalled) provider and
            # confirmed installing it, install the extra and reopen the manager
            # so they can add a key against the now-installed provider.
            extra = screen.pending_install_extra
            if extra is not None:
                from functools import partial

                self.call_later(
                    partial(
                        self._install_provider_then_reopen_auth,
                        extra,
                        provider=screen.pending_install_provider,
                    ),
                )
                return
            task = asyncio.create_task(self._resume_server_after_auth_change())
            task.add_done_callback(_log_task_exception)

        screen = AuthManagerScreen(initial_provider=initial_provider)
        self.push_screen(screen, handle_result)

    def on_auth_manager_screen_credential_saved(self, event: Message) -> None:
        """Retry credentials-blocked startup immediately after `/auth` saves a key."""
        event.stop()
        task = asyncio.create_task(self._resume_server_after_auth_change())
        task.add_done_callback(_log_task_exception)

    async def _resume_server_after_auth_change(self) -> None:
        """Bring the server up after `/auth` if a credential now unblocks it.

        Two cases close on the same key entry: a deferred first launch (no
        credentials at startup) and a startup that failed with
        `MissingCredentialsError`. Try the deferred path first; if it doesn't
        apply, retry a credentials-blocked startup.
        """
        if await self._maybe_start_deferred_server_from_default():
            return
        await self._maybe_retry_startup_after_auth_change()

    async def _maybe_retry_startup_after_auth_change(self) -> bool:
        """Retry a credentials-blocked startup once `/auth` adds the key.

        After the server fails to start with `MissingCredentialsError`, `/auth`
        is the natural place to supply the missing key. Rather than make the
        user type `/restart` afterward, retry startup automatically once the
        blocking provider's credentials resolve.

        Returns:
            `True` when a startup retry was kicked off, otherwise `False`.
        """
        provider = self._server_startup_missing_credentials_provider
        if (
            self._server_startup_error is None
            or provider is None
            or self._server_kwargs is None
        ):
            return False

        from deepagents_code.model_config import get_provider_auth_status

        auth_status = get_provider_auth_status(provider)
        if auth_status.blocks_start:
            # Key still missing — don't loop back into the same failure.
            return False

        model_spec = self._server_kwargs.get("model_name")
        if not model_spec:
            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:
                # No usable default to fall back to — nothing to retry.
                return False
            except ModelConfigError as exc:
                # Malformed config is actionable; surface it instead of
                # silently doing nothing after the user closes `/auth`.
                await self._mount_message(ErrorMessage(str(exc)))
                return False

        extra_kwargs = self._server_kwargs.get("model_params")
        await self._retry_startup_with_model(model_spec, extra_kwargs=extra_kwargs)
        return True

    async def _install_provider_then_reopen_auth(
        self, extra: str, *, provider: str | None = None
    ) -> None:
        """Install a provider's extra from `/auth`, then reopen the manager.

        Args:
            extra: The extra that installs the selected provider's integration.
            provider: The provider being installed, highlighted in the
                reopened manager so the cursor lands on it ready for a key.
        """
        if await self._install_extra(extra, auto_restart=True):
            await self._show_auth_manager(initial_provider=provider)
            return
        # `_install_extra` returns `False` both when the install genuinely
        # failed (it already surfaced the reason) and when the package landed
        # but the server restart didn't. Adding a key doesn't need the restart,
        # so reopen whenever the extra is importable; only stay in chat on a
        # real failure the user has already seen explained.
        ready = await asyncio.to_thread(_extra_is_ready, extra)
        if ready:
            from deepagents_code.model_config import clear_caches

            clear_caches()
            await self._show_auth_manager(initial_provider=provider)
            return
        if ready is None:
            # Introspection couldn't confirm the state (rare). Don't dead-end
            # silently after a multi-step flow — point the user back to `/auth`.
            await self._mount_message(
                AppMessage(
                    f"Couldn't verify whether '{extra}' finished installing. "
                    "Reopen `/auth` to add a key once it has.",
                ),
            )
            return
        # `ready is False`: the extra genuinely didn't install. `_install_extra`
        # has already surfaced the reason to the user, so stay in chat rather
        # than reopen — but log it so an "install button did nothing" report is
        # debuggable without relying on that sibling method's invariant.
        logger.debug("Provider extra %r not importable after install attempt", extra)

    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._reconnecting = True
                self._agent = None
                try:
                    banner = self.query_one("#welcome-banner", WelcomeBanner)
                    banner.set_connecting()
                except NoMatches:
                    pass
                self._sync_status_connection()

                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()
                self._sync_status_queued()

                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 in a permanent connecting state.
                self._connecting = False
                self._reconnecting = 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,
                        mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
                    )
                except NoMatches:
                    pass
                self._sync_status_connection()
                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
                self._reconnecting = False
                self._sync_status_connection()
                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
            self._reconnecting = False
            try:
                banner = self.query_one("#welcome-banner", WelcomeBanner)
                banner.set_connected(self._mcp_tool_count)
            except NoMatches:
                pass
            self._sync_status_connection()

            # 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
        if action_id == ActionId.ENTER_API_KEY:
            await self._enter_service_api_key(entry, payload)
            return
        self._log_unknown_action(entry, action_id)

    async def _enter_service_api_key(
        self,
        entry: PendingNotification,
        payload: MissingDepPayload,
    ) -> None:
        """Open the API-key entry prompt (the one `/auth` uses) for a service.

        Lets the user store a service API key inline instead of exporting an
        env var before launch.

        Args:
            entry: The missing-dependency notification entry.
            payload: Typed payload carrying the service (tool) name.
        """
        from deepagents_code.model_config import SERVICE_API_KEY_ENV

        service = payload.tool
        # `env_var is None` covers any non-service tool, since `is_service` is
        # exactly membership in `SERVICE_API_KEY_ENV`.
        env_var = SERVICE_API_KEY_ENV.get(service)
        if env_var is None:
            self._log_unknown_action(entry, ActionId.ENTER_API_KEY)
            return

        from deepagents_code.widgets.auth import AuthPromptScreen, AuthResult

        result = await self._push_screen_wait(
            AuthPromptScreen(service, env_var),
        )
        if result == AuthResult.SAVED:
            self._notice_registry.remove(entry.key)
            self.notify(
                f"Saved {service} API key. Restart to apply.",
                severity="information",
                timeout=6,
                markup=False,
            )

    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,
            detect_shadowed_dcode_safe,
            format_shadowed_dcode_fix_command,
            format_shadowed_dcode_warning,
            mark_update_notified,
            perform_upgrade,
        )

        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 = payload.upgrade_cmd
            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,
                    target_version=payload.latest,
                )
                if success:
                    self._notice_registry.remove(entry.key)
                    # Same shadowing risk as `/update`: if a stale `dcode` is
                    # earlier on PATH, the user's next launch will silently
                    # run the old version. Surface that loudly even when only
                    # a toast is visible. Keep the modal itself out of the
                    # success state when relaunching would keep using the old
                    # binary.
                    shadow = await asyncio.to_thread(detect_shadowed_dcode_safe)
                    if shadow is not None:
                        warning = format_shadowed_dcode_warning(shadow)
                        if progress_modal_visible:
                            screen.mark_warning(
                                warning,
                                copy_text=format_shadowed_dcode_fix_command(shadow),
                            )
                        self.notify(
                            warning,
                            severity="warning",
                            timeout=20,
                            markup=False,
                        )
                        return
                    screen.mark_success()
                    if progress_modal_visible:
                        return
                    self.notify(
                        f"Updated to v{payload.latest}. "
                        "Quit and relaunch dcode 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
        )

    def _refresh_welcome_banner_mcp_counts(self) -> None:
        """Push current MCP counts into the welcome banner when it is mounted."""
        try:
            banner = self.query_one("#welcome-banner", WelcomeBanner)
        except NoMatches:
            logger.debug("Welcome banner not mounted during MCP count refresh")
            return
        banner.set_connected(
            self._mcp_tool_count,
            mcp_unauthenticated=self._mcp_unauthenticated,
            mcp_errored=self._mcp_errored,
            mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
        )

    def _clear_mcp_login_reconnect_banner_counts(self, server_name: str) -> None:
        """Optimistically clear splash login/reconnect prompts before restart.

        Args:
            server_name: Server whose successful login triggered the reconnect.
        """
        self._mcp_unauthenticated = sum(
            1
            for s in self._mcp_server_info or []
            if s.name != server_name and s.needs_attention()
        )
        self._mcp_awaiting_reconnect = 0
        self._refresh_welcome_banner_mcp_counts()

    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` or press Ctrl+R 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` or press Ctrl+R 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 — press Ctrl+R to load.",
                        ),
                    )
        self._mcp_server_info = updated

    def _apply_optimistic_mcp_login_pending_state(self, server_name: str) -> None:
        """Mark a just-authenticated server as waiting for reconnect.

        OAuth tokens are already persisted at this point, but the running
        LangGraph server still has the old MCP tool set. This keeps `/mcp`
        from continuing to label the server as unauthenticated after the
        user explicitly chose to defer the reconnect.
        """
        from deepagents_code.mcp_tools import MCPServerInfo

        info = self._mcp_server_info
        if not info:
            return

        updated: list[MCPServerInfo] = []
        matched = False
        for entry in info:
            if entry.name != server_name:
                updated.append(entry)
                continue
            matched = True
            updated.append(
                MCPServerInfo(
                    name=entry.name,
                    transport=entry.transport,
                    status="awaiting_reconnect",
                    error="Authenticated — run `/mcp reconnect` to load tools.",
                ),
            )
        self._mcp_server_info = updated
        self._mcp_unauthenticated = sum(
            1 for s in self._mcp_server_info if s.needs_attention()
        )
        self._mcp_errored = sum(1 for s in self._mcp_server_info if s.status == "error")
        self._mcp_awaiting_reconnect = sum(
            1 for s in self._mcp_server_info if s.status == "awaiting_reconnect"
        )
        if not matched:
            logger.warning(
                "MCP login completed for unknown server %r; pending state unchanged",
                server_name,
            )
        self._refresh_welcome_banner_mcp_counts()

    def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
        """Surface worker failures that escaped a worker's inner error handling."""
        from textual.worker import WorkerState

        worker = event.worker
        group = worker.group or ""
        if event.state != WorkerState.ERROR or worker.error is None:
            return
        if group.startswith("mcp-login-"):
            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.",
                ),
            )
        elif group == "server-startup":
            # `_start_server_background` normally posts ServerReady or
            # ServerStartFailed itself, ending in SUCCESS. Reaching ERROR
            # means an exception escaped before it could (e.g. an unguarded
            # await early in startup). Without this net nothing would clear
            # `_connecting`, leaving a permanent connection spinner with no
            # error surfaced. Convert the crash into the terminal failure
            # message so the standard reset handler runs.
            logger.warning(
                "Server startup worker failed unexpectedly: %s",
                worker.error,
                exc_info=worker.error,
            )
            self.post_message(
                self.ServerStartFailed(
                    error=worker.error
                    if isinstance(worker.error, Exception)
                    else RuntimeError(str(worker.error)),
                ),
            )

    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._apply_optimistic_mcp_login_pending_state(server_name)
            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).
                choice = await asyncio.wait_for(
                    choice_future, timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS
                )
            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()
            self._clear_mcp_login_reconnect_banner_counts(server_name)
            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()
        self._apply_optimistic_mcp_login_pending_state(server_name)
        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."
            ),
        )

    @staticmethod
    def _ensure_restart_prompt_loaded() -> None:
        """Load the restart-prompt modal before any in-place self-upgrade.

        `/install` runs `uv tool install --reinstall -U 'deepagents-code[...]'`, which
        rewrites deepagents-code's own on-disk package tree while this process
        is running. Modules already in `sys.modules` keep working from memory,
        but a *first* import after the rewrite reads the mutated (or
        partially-written) tree and raises `ModuleNotFoundError`.
        `restart_prompt` is imported only on the post-install path, so import
        it now — before the mutation — so the later import in
        `_offer_restart_after_install` resolves from `sys.modules` without
        re-reading disk. That import is still defended there for the genuine
        upgrade case where the on-disk module legitimately differs from what is
        resident.

        Catches only `ModuleNotFoundError` (the missing-tree failure), not the
        broader `ImportError`, so a genuine name-binding bug in `restart_prompt`
        still surfaces instead of being mistaken for an upgrade race.

        Best-effort: a failure here just means the post-install import falls
        back to its own guard, so swallow it rather than crash the install.
        """
        try:
            import deepagents_code.widgets.restart_prompt  # noqa: F401
        except ModuleNotFoundError:
            logger.warning("Could not preload restart_prompt modal", exc_info=True)

    async def _offer_restart_after_install(self, label: str) -> None:
        """Offer a one-keypress restart after a restart-capable install.

        Provider/sandbox extras and `--package` installs are imported by the
        app-owned LangGraph server subprocess, so a `/restart` loads them
        without exiting the TUI. When dcode owns that subprocess and is idle,
        prompt to run the restart immediately instead of making the user type
        `/restart`.

        Owns all of its own follow-up messaging so the caller never appends a
        redundant hint:

        - Owned + idle: show the prompt (its button is the call to action). If
            the prompt can't be shown, fall back to a `/restart` hint.
        - Owned + busy/connecting: a restart cancels in-flight work, so point
            at `/restart` for once the current task finishes.
        - No owned subprocess (remote server): `/restart` can't respawn it, so a
            full relaunch is the only way to load the package.

        Args:
            label: Installed extra/package name, surfaced in the prompt title.
        """
        if self._server_proc is None or self._server_kwargs is None:
            await self._mount_message(
                AppMessage(f"Relaunch dcode to load '{label}'."),
            )
            return
        if self._agent_running or self._connecting:
            await self._mount_message(
                AppMessage(
                    f"Run `/restart` to load '{label}' once the current task finishes.",
                ),
            )
            return

        # Owned + idle. A `/restart` respawns the subprocess (same effect as a
        # relaunch, without exiting), so every couldn't-show-the-prompt path
        # below degrades to this hint rather than mentioning a relaunch.
        manual_hint = f"Run `/restart` to load '{label}' now."

        try:
            from deepagents_code.widgets.restart_prompt import RestartPromptScreen
        except ModuleNotFoundError:
            # `/install` runs `uv tool install --reinstall -U
            # 'deepagents-code[...]'`, which can rewrite deepagents-code's own
            # on-disk package tree mid-session
            # (see `_ensure_restart_prompt_loaded`). A first import of the modal
            # here may then fail with `ModuleNotFoundError`. Degrade to the
            # manual `/restart` hint instead of crashing the TUI. The catch is
            # deliberately narrow — a genuine `ImportError` from a broken modal
            # still propagates rather than being mistaken for an upgrade race.
            logger.warning(
                "restart_prompt unavailable after installing %r; falling back "
                "to the manual /restart hint",
                label,
                exc_info=True,
            )
            await self._mount_message(AppMessage(manual_hint))
            return

        choice: RestartChoice | None
        try:
            # Watchdog: bound the handler against a screen that never resolves
            # (compose crash, programmatic teardown that skips the dismiss
            # callback).
            choice = await asyncio.wait_for(
                self._push_screen_wait(RestartPromptScreen(label)),
                timeout=_MODAL_WATCHDOG_TIMEOUT_SECONDS,
            )
        except TimeoutError:
            logger.warning(
                "Restart prompt after installing %r timed out; falling back to "
                "the manual /restart hint",
                label,
            )
            await self._mount_message(AppMessage(manual_hint))
            return
        except Exception:
            # Modal could not be mounted (e.g. another modal hijacked the
            # stack). Fall back to the manual `/restart` hint.
            logger.exception(
                "Failed to mount restart prompt after installing %r", label
            )
            await self._mount_message(AppMessage(manual_hint))
            return

        # The pre-prompt guards above ran before the modal await; server state
        # can flip while the user reads the prompt (e.g. an agent run starts),
        # tripping `_restart_after_install`'s busy/no-server guards, which only
        # log. Surface a message so an explicit "restart" choice never looks
        # like a silent no-op. Mirrors the `auto_restart` path.
        if choice == "restart" and not await self._restart_after_install(label):
            await self._mount_message(
                AppMessage(
                    f"Couldn't restart the server automatically to load "
                    f"'{label}'. Run `/restart` to load it.",
                ),
            )
        # Otherwise the prompt was shown and the user made an informed choice;
        # no further hint is needed.

    def _restart_after_install_is_unneeded(self) -> bool:
        """Return whether a fresh startup will load the installed dependency."""
        return (
            self._server_proc is None
            and self._server_kwargs is not None
            and (
                self._server_startup_deferred or self._server_startup_error is not None
            )
        )

    async def _restart_after_install(self, label: str) -> bool:
        """Restart the app-owned server after installing a dependency.

        Args:
            label: Installed extra/package name, used for logs and fallback copy.

        Returns:
            `True` when the server restarted successfully; `False` when restart is
                not currently available or fails.
        """
        if self._server_proc is None or self._server_kwargs is None:
            logger.info(
                "Cannot auto-restart after installing %s: no app-owned server", label
            )
            return False
        if self._agent_running or self._connecting:
            logger.info(
                "Cannot auto-restart after installing %s: server is busy",
                label,
            )
            return False
        if not await self._reload_configuration_for_restart():
            return False
        restarting = await self._mount_transient_app_message("Restarting server...")
        restarted = False
        try:
            restarted = await self._restart_server_manual()
        finally:
            if restarting is not None:
                with suppress(NoMatches, ScreenStackError):
                    await restarting.remove()
        if not restarted:
            return False
        await self._mount_message(AppMessage("Restart complete."))
        return True

    async def _reload_configuration_for_restart(self) -> bool:
        """Reload config state before respawning the owned server.

        Returns:
            Whether reload completed and restart should continue.
        """
        from deepagents_code.config import settings
        from deepagents_code.model_config import clear_caches

        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 False
        return True

    async def _handle_restart_command(self, command: str) -> None:
        """Drive the `/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.

        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.
        """
        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()

        if not await self._reload_configuration_for_restart():
            return

        if 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

        # We own a server (`_server_kwargs is not None`) but it may not be
        # ready to respawn. `_server_proc` stays `None` until the startup
        # worker obtains the subprocess (assigned before `ServerReady` is
        # posted; see `_run_startup_worker`), and `_connecting` stays set until
        # the `ServerReady` handler runs. Guarding on both also covers the
        # brief window where the proc is assigned but the handler hasn't fired,
        # where restarting would let the still-queued startup `ServerReady`
        # clobber state with the just-killed proc. A match here means the
        # server is still coming up, deferred for model selection, or failed
        # before a subprocess existed — not remote-server mode. Mirrors the
        # sibling guards elsewhere in this file.
        if self._connecting or self._server_proc is None:
            if self._server_startup_deferred:
                await self._mount_message(
                    AppMessage(
                        "Server startup is waiting for a model. Configuration "
                        "was reloaded; set credentials with `/auth`, reload the "
                        "environment with `/reload`, or pick a model with "
                        "`/model` to start the server.",
                    ),
                )
            elif self._connecting:
                await self._mount_message(
                    AppMessage(
                        "The server is still starting. Configuration was "
                        "reloaded and will apply once it finishes connecting; "
                        "run `/restart` again afterward if needed.",
                    ),
                )
            elif self._server_startup_error is not None:
                await self._mount_message(
                    AppMessage(
                        "Cannot restart yet because the server did not finish "
                        "starting. Configuration was reloaded; update "
                        "credentials with `/auth` if needed, then pick a model "
                        "with `/model` to try again. You can also relaunch "
                        "dcode.\n\n"
                        f"Last error: {self._server_startup_error}",
                    ),
                )
            else:
                await self._mount_message(
                    AppMessage(
                        "Cannot restart yet because the server is not running. "
                        "Configuration was reloaded; relaunch dcode to start "
                        "again.",
                    ),
                )
            return

        restarting = await self._mount_transient_app_message("Restarting server...")
        restarted = False
        try:
            restarted = await self._restart_server_manual()
        finally:
            if restarting is not None:
                with suppress(NoMatches, ScreenStackError):
                    await restarting.remove()
        if restarted:
            await self._mount_message(AppMessage("Restart complete."))

    async def _restart_server_manual(self) -> bool:
        """Respawn the app-owned LangGraph server for `/restart`.

        Returns:
            Whether the server was restarted successfully.
        """
        return 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,
    ) -> bool:
        """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.

        Returns:
            Whether the server was restarted successfully.
        """
        server_proc = self._server_proc
        if self._server_kwargs is None or server_proc is None:
            return False

        try:
            self._connecting = True
            self._reconnecting = True
            self._agent = None
            try:
                banner = self.query_one("#welcome-banner", WelcomeBanner)
                banner.set_connecting()
            except NoMatches:
                pass
            self._sync_status_connection()

            try:
                await asyncio.wait_for(server_proc.restart(), timeout=restart_timeout)
            except (Exception, TimeoutError) as exc:
                self._connecting = False
                self._reconnecting = False
                self._sync_status_connection()
                logger.exception(log_message)
                self.post_message(self.ServerStartFailed(error=exc))
                return False

            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,  # ty: ignore[invalid-argument-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
            self._reconnecting = False
            self._sync_status_connection()
            raise
        else:
            return True
        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)

        async def resume_and_refocus(thread_id: str) -> None:
            """Resume a selected thread, then restore focus to chat input."""
            try:
                await self._resume_thread(thread_id)
            finally:
                if self._chat_input:
                    self._chat_input.focus_input()

        def handle_result(result: str | None) -> None:
            """Handle the thread selector result after the modal dismisses."""
            if result is None:
                if self._chat_input:
                    self._chat_input.focus_input()
                return

            async def resume_later() -> None:
                await asyncio.sleep(0)
                if self._agent_running or self._shell_running or self._connecting:
                    self._defer_action(
                        DeferredAction(
                            kind="thread_switch",
                            execute=partial(resume_and_refocus, result),
                        ),
                    )
                    self.notify(
                        "Thread will switch after current task completes.",
                        timeout=3,
                    )
                else:
                    await resume_and_refocus(result)

            self.call_after_refresh(
                lambda: self.run_worker(
                    resume_later(),
                    exclusive=False,
                    group="thread-switch",
                )
            )

        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)

    def _apply_cwd_to_ui(self, cwd: Path) -> None:
        """Update cwd-dependent UI state after changing process cwd."""
        cwd_text = str(cwd)
        self._cwd = cwd_text
        if self._chat_input is not None:
            self._chat_input.set_cwd(cwd)
        if self._status_bar is not None:
            self._status_bar.cwd = cwd_text

    @staticmethod
    def _refresh_project_context_after_cwd_switch(cwd: Path) -> None:
        """Refresh project-scoped settings and caches after a cwd change."""
        from deepagents_code.config import settings
        from deepagents_code.model_config import clear_caches

        changes = settings.reload_from_environment(start_path=cwd)
        clear_caches()
        if changes:
            logger.debug("Refreshed project context after cwd switch: %s", changes)

    def _schedule_skill_discovery_after_cwd_switch(self) -> None:
        """Refresh skill autocomplete after a cwd-dependent project switch."""
        if not self.is_running:
            logger.debug(
                "Skipped skill rediscovery after cwd switch because app is not running"
            )
            return
        self.run_worker(
            self._discover_skills(),
            exclusive=True,
            group="startup-skill-discovery",
        )

    def _switch_process_cwd(self, cwd: Path) -> None:
        """Change process cwd and synchronize cwd-aware app state.

        Kept atomic with respect to the process cwd: if a post-`chdir` step
        fails, the `os.chdir` is undone and any partial UI update is reverted so
        the real cwd and the cached `self._cwd` never diverge. Rollback logic in
        `_restore_cwd_after_failed_thread_switch` compares the two, and a
        half-applied switch (process moved, `self._cwd` stale) would make that
        comparison report a false match and silently skip the restore.
        """
        previous_cwd = Path(self._cwd)
        os.chdir(cwd)
        try:
            self._refresh_project_context_after_cwd_switch(cwd)
            self._apply_cwd_to_ui(cwd)
        except BaseException:
            with suppress(OSError):
                os.chdir(previous_cwd)
            # Re-sync UI state to the restored cwd. Best-effort: a failure here
            # must not mask the original exception.
            with suppress(Exception):
                self._apply_cwd_to_ui(previous_cwd)
            raise
        self._schedule_skill_discovery_after_cwd_switch()

    @staticmethod
    def _absolutize_launch_relative_path(raw: object, launch_cwd: Path) -> str | None:
        """Resolve a CLI path before cwd changes can reinterpret it.

        Returns:
            Absolute path string, or `None` when `raw` is not a path.
        """
        if not isinstance(raw, str) or not raw:
            return None
        path = Path(raw).expanduser()
        if path.is_absolute():
            return str(path.resolve())
        return str((launch_cwd / path).resolve())

    def _preserve_launch_relative_server_paths(self, launch_cwd: Path) -> None:
        """Freeze launch-relative restart paths before switching process cwd."""
        if self._server_kwargs is not None:
            for key in ("mcp_config_path", "sandbox_setup"):
                resolved = self._absolutize_launch_relative_path(
                    self._server_kwargs.get(key),
                    launch_cwd,
                )
                if resolved is not None:
                    self._server_kwargs[key] = resolved

        if self._mcp_preload_kwargs is not None:
            resolved = self._absolutize_launch_relative_path(
                self._mcp_preload_kwargs.get("mcp_config_path"),
                launch_cwd,
            )
            if resolved is not None:
                self._mcp_preload_kwargs["mcp_config_path"] = resolved

    @staticmethod
    def _resolve_thread_cwd_mismatch(
        raw: str, current_cwd: str
    ) -> tuple[Literal["match", "unavailable", "mismatch"], Path | None]:
        """Classify a stored thread cwd against the current app cwd.

        Args:
            raw: The cwd recorded in the thread's checkpoint metadata. May be
                relative or use `~`; both are normalized here.
            current_cwd: The app's current working directory.

        Returns:
            A `(status, path)` pair. `path` is only set when `status` is
            `"mismatch"`; it is `None` otherwise. `status` is one of:

            - `"match"`: the stored cwd resolves to the current cwd; no action.
            - `"unavailable"`: the stored cwd is relative/malformed, or names an
                absolute directory that no longer exists — it cannot be honored,
                so the caller should warn and stay put.
            - `"mismatch"`: the stored cwd is a real directory that differs from
                the current cwd — the caller should offer to switch.
        """
        target = Path(raw).expanduser()
        if not target.is_absolute() or not target.is_dir():
            # Relative/malformed or missing directory: cannot be honored.
            return "unavailable", None
        try:
            current = Path(current_cwd).expanduser().resolve()
            resolved = target.resolve()
        except OSError:
            # Symlink resolution failed (e.g. ELOOP, permission on a path
            # component). Fall back to a non-resolving comparison, which can
            # report a spurious mismatch for symlinked-but-equal paths; log so
            # the degraded comparison is traceable.
            logger.debug(
                "Could not resolve cwd paths for mismatch check (%r vs %r); "
                "falling back to non-resolving comparison",
                current_cwd,
                raw,
                exc_info=True,
            )
            current = Path(current_cwd).expanduser().absolute()
            resolved = target.absolute()
        if current == resolved:
            return "match", None
        return "mismatch", resolved

    async def _thread_cwd_mismatch(self, thread_id: str) -> Path | None:
        """Return the thread cwd when it differs from the current app cwd."""
        from deepagents_code.sessions import get_thread_cwd

        raw = await get_thread_cwd(thread_id)
        if not raw:
            return None

        status, target = await asyncio.to_thread(
            self._resolve_thread_cwd_mismatch,
            raw,
            self._cwd,
        )
        if status == "unavailable":
            self.notify(
                f"Thread {thread_id} was last used in {raw!r}, but that directory "
                "is not available. Staying in the current directory; local "
                "context may be stale.",
                severity="warning",
                timeout=10,
                markup=False,
            )
        return target

    @staticmethod
    def _unwrap_cwd_switch_server_result(
        result: object,
    ) -> tuple[RemoteAgent, ServerProcess, object | None]:
        """Return a gathered server-startup result or raise its exception.

        `asyncio.gather(..., return_exceptions=True)` yields the raised object
        in place of a result. Any `BaseException` (not just `Exception`) is
        re-raised so a `CancelledError` surfaces as itself instead of being
        unpacked as a bogus success tuple.

        Returns:
            The successful `start_server_and_get_agent` result. The third slot
                (the session manager) is typed `object | None` rather than its source
                type because this caller discards it.
        """
        if isinstance(result, BaseException):
            raise result
        return cast("tuple[RemoteAgent, ServerProcess, object | None]", result)

    async def _replace_server_after_cwd_switch(
        self, cwd: Path
    ) -> Literal["continue", "abort"]:
        """Switch cwd and replace the app-owned server process.

        Returns:
            `"continue"` when the session can proceed (including the graceful
                no-owned-server case), or `"abort"` when a requested restart
                failed and the previous state was rolled back.

        A non-`Exception` failure (e.g. `CancelledError`) is re-raised after
        rolling back, so cancellation propagates rather than being reported as
        a failed switch.
        """
        if self._server_kwargs is None or self._server_proc is None:
            self.notify(
                "Switched cwd locally, but this session cannot restart its server. "
                "Relaunch dcode from the thread directory if tools look stale.",
                severity="warning",
                timeout=10,
                markup=False,
            )
            self._switch_process_cwd(cwd)
            return "continue"

        from deepagents_code.main import _preload_session_mcp_server_info
        from deepagents_code.server_manager import start_server_and_get_agent

        previous_cwd = Path(self._cwd)
        previous_agent = self._agent
        previous_server = self._server_proc
        previous_mcp_info = self._mcp_server_info

        try:
            self._connecting = True
            self._reconnecting = True
            self._agent = None
            try:
                banner = self.query_one("#welcome-banner", WelcomeBanner)
                banner.set_connecting()
            except NoMatches:
                pass
            self._sync_status_connection()
            self._preserve_launch_relative_server_paths(previous_cwd)
            self._switch_process_cwd(cwd)

            coros: list[Any] = [start_server_and_get_agent(**self._server_kwargs)]
            if self._mcp_preload_kwargs is not None:
                coros.append(
                    _preload_session_mcp_server_info(**self._mcp_preload_kwargs)
                )
            results = await asyncio.gather(*coros, return_exceptions=True)
            if (
                isinstance(results[0], BaseException)
                and len(results) > 1
                and isinstance(results[1], BaseException)
            ):
                # The server startup (results[0]) is about to be re-raised below.
                # Surface the concurrent MCP-preload failure too so it is not
                # silently dropped as an unretrieved gather result.
                logger.warning(
                    "MCP metadata preload also failed during cwd switch",
                    exc_info=(
                        type(results[1]),
                        results[1],
                        results[1].__traceback__,
                    ),
                )
            server_result = self._unwrap_cwd_switch_server_result(results[0])

            mcp_info: list[Any] | None = None
            if len(results) > 1:
                mcp_result = results[1]
                if isinstance(mcp_result, BaseException):
                    logger.warning(
                        "MCP metadata preload after cwd switch failed",
                        exc_info=(
                            type(mcp_result),
                            mcp_result,
                            mcp_result.__traceback__,
                        ),
                    )
                    self.notify(
                        "MCP tool metadata could not be refreshed after cwd switch. "
                        "Use /mcp to check.",
                        severity="warning",
                        timeout=8,
                        markup=False,
                    )
                    # Keep the prior tool metadata so the banner does not falsely
                    # drop to zero tools — the MCP servers themselves are fine.
                    mcp_info = previous_mcp_info
                else:
                    mcp_info = cast("list[Any] | None", mcp_result)

            agent, server_proc, _manager = server_result
            event = self.ServerReady(
                agent=agent,
                server_proc=server_proc,
                mcp_server_info=mcp_info,
            )
        except BaseException as exc:
            logger.exception("Failed to restart server after cwd switch")
            # Roll back regardless of exception type so a cancelled restart does
            # not strand the app mid-switch.
            try:
                self._switch_process_cwd(previous_cwd)
            except OSError:
                logger.warning(
                    "Failed to restore cwd to %s after failed server restart; "
                    "process cwd and app state are now inconsistent",
                    previous_cwd,
                    exc_info=True,
                )
                self.notify(
                    "Server restart failed and the previous directory could not "
                    "be restored. The session may be in the wrong directory — "
                    "please restart dcode.",
                    severity="error",
                    timeout=15,
                    markup=False,
                )
            self._agent = previous_agent
            self._server_proc = previous_server
            self._mcp_server_info = previous_mcp_info
            self._connecting = False
            self._reconnecting = 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,
                    mcp_awaiting_reconnect=self._mcp_awaiting_reconnect,
                )
            except NoMatches:
                pass
            self._sync_status_connection()
            if not isinstance(exc, Exception):
                # Cancellation / SystemExit: state is restored; let it propagate.
                raise
            self.notify(
                f"Could not switch to the thread cwd ({type(exc).__name__}: {exc}). "
                "Staying in the current directory.",
                severity="error",
                timeout=10,
                markup=False,
            )
            return "abort"
        else:
            # `stop()` joins the subprocess synchronously; keep the UI loop
            # responsive while the old server drains. A stop failure here is
            # cosmetic (the new server is already live), but must not skip the
            # ready transition below — otherwise `_connecting` strands `True`
            # and the freshly-built agent never gets wired up.
            try:
                await asyncio.to_thread(previous_server.stop)
            except Exception:  # old-server teardown is best-effort
                logger.exception("Failed to stop previous server after cwd switch")
            self.on_deep_agents_app_server_ready(event)
            return "continue"

    @staticmethod
    async def _preview_project_settings_change(cwd: Path) -> bool:
        """Return whether switching cwd would refresh project settings."""
        from deepagents_code.config import settings

        try:
            changes = await asyncio.to_thread(
                settings.preview_reload_from_environment,
                start_path=cwd,
            )
        except (OSError, ValueError):
            # Environmental failures (unreadable dotenv, malformed values) are
            # expected and non-fatal for a best-effort preview. Programming
            # errors (KeyError/TypeError/ImportError) are left to propagate so a
            # broken preview is not silently reported as "no settings change."
            logger.warning(
                "Could not preview project settings changes for cwd switch",
                exc_info=True,
            )
            return False
        return bool(changes)

    async def _offer_thread_cwd_switch(
        self,
        thread_id: str,
        *,
        restart_server: bool,
    ) -> Literal["continue", "abort"]:
        """Offer to switch to a resumed thread's cwd when it differs.

        Args:
            thread_id: The thread being resumed.
            restart_server: When True (in-session thread switch), an accepted
                switch replaces the app-owned server so the backend runs in the
                new cwd. When False (launch-time resume), the server has not
                started yet, so only the process cwd is changed.

        Returns:
            `"continue"` when resume may proceed, or `"abort"` when a requested
                switch was accepted but failed (the caller should stop
                the resume).
        """
        target = await self._thread_cwd_mismatch(thread_id)
        if target is None:
            return "continue"

        from deepagents_code.widgets.cwd_switch import CwdSwitchPromptScreen

        project_settings_change_detected = await self._preview_project_settings_change(
            target
        )
        choice = await self._push_screen_wait(
            CwdSwitchPromptScreen(
                current_cwd=self._cwd,
                thread_cwd=str(target),
                project_settings_change_detected=project_settings_change_detected,
            )
        )
        if choice == "switch":
            if restart_server:
                return await self._replace_server_after_cwd_switch(target)
            self._preserve_launch_relative_server_paths(Path(self._cwd))
            self._switch_process_cwd(target)
            return "continue"

        self.notify(
            "Continuing in the current directory. Cached local context may be "
            "stale and tools may operate in the wrong project.",
            severity="warning",
            timeout=10,
            markup=False,
        )
        return "continue"

    @staticmethod
    def _cwd_paths_equal(current_cwd: str, previous_cwd: Path) -> bool:
        """Return whether two cwd paths resolve to the same directory."""
        try:
            current = Path(current_cwd).expanduser().resolve()
            previous = previous_cwd.expanduser().resolve()
        except OSError:
            # See `_resolve_thread_cwd_mismatch`: a resolve failure downgrades to
            # a non-resolving comparison that may misjudge symlinked paths.
            logger.debug(
                "Could not resolve cwd paths for equality check (%r vs %r); "
                "falling back to non-resolving comparison",
                current_cwd,
                str(previous_cwd),
                exc_info=True,
            )
            current = Path(current_cwd).expanduser().absolute()
            previous = previous_cwd.expanduser().absolute()
        return current == previous

    async def _restore_cwd_after_failed_thread_switch(self, previous_cwd: Path) -> None:
        """Restore cwd-dependent state after a failed in-session thread switch."""
        if await asyncio.to_thread(self._cwd_paths_equal, self._cwd, previous_cwd):
            return

        if self._server_kwargs is not None and self._server_proc is not None:
            outcome = await self._replace_server_after_cwd_switch(previous_cwd)
            if outcome == "abort":
                # The restore restart itself failed. `_replace_server_after_cwd_switch`
                # has already notified the user and rolled back its own state, but
                # the recovery did not fully succeed -- record it so the worse
                # state ("rollback failed") is distinguishable in logs.
                logger.warning(
                    "Restoring server in previous cwd %s failed during thread-switch "
                    "rollback",
                    previous_cwd,
                )
            return

        try:
            self._switch_process_cwd(previous_cwd)
        except OSError:
            logger.warning(
                "Failed to restore cwd after failed thread switch to %s",
                previous_cwd,
                exc_info=True,
            )
            self.notify(
                "Could not restore the previous working directory after a failed "
                "thread switch. The session may be in the wrong directory — please "
                "restart dcode.",
                severity="error",
                timeout=15,
                markup=False,
            )

    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

        if self._session_state.thread_id == thread_id:
            prev_cwd = Path(self._cwd)
            cwd_choice = await self._offer_thread_cwd_switch(
                thread_id,
                restart_server=True,
            )
            if cwd_choice == "abort":
                return
            if await asyncio.to_thread(self._cwd_paths_equal, self._cwd, prev_cwd):
                await self._mount_message(AppMessage(f"Already on thread: {thread_id}"))
            else:
                await self._mount_message(
                    AppMessage(f"Switched to thread directory: {self._cwd}"),
                )
            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
        prev_cwd = Path(self._cwd)

        cwd_choice = await self._offer_thread_cwd_switch(thread_id, restart_server=True)
        if cwd_choice == "abort":
            return

        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}")
            await self._set_spinner("Loading thread")
            prefetched_payload = await self._fetch_thread_history_data(thread_id)

            # Clear conversation (similar to /clear, without creating a new thread)
            await self._set_spinner(None)
            self._pending_messages.clear()
            self._queued_widgets.clear()
            self._sync_status_queued()
            await self._clear_messages()
            await self._set_spinner("Loading thread")
            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,
            )

            # Adopt the switched-to thread's model (session-only), mirroring
            # launch-time `-r` resume — unless `--model` pinned an explicit
            # choice for this session. Consumed by `_load_thread_history`.
            self._should_adopt_resumed_model = not self._model_explicitly_set

            # 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._restore_cwd_after_failed_thread_switch(prev_cwd)
                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,
            )
            await self._restore_cwd_after_failed_thread_switch(prev_cwd)
            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
            await self._set_spinner(None)
            self._update_status("")
            if self._chat_input:
                self._chat_input.set_cursor_active(active=not self._agent_running)

    async def _mount_resume_adoption_failure(
        self, desired: str, reason: str, *, hint: str = ""
    ) -> None:
        """Tell the user a resumed thread's model couldn't be restored.

        Unlike the interactive `/model` errors, this names the desired model,
        the reason, and the model the session is falling back to — so a `-r`
        resume that can't restore its model doesn't silently switch the user
        onto a different one.

        Args:
            desired: The `provider:model` spec the resumed thread wanted.
            reason: Short human-readable cause (e.g. missing credentials).
            hint: Optional trailing remediation hint.
        """
        current = self._effective_model_spec()
        fallback = f"; continuing on {current}." if current else "."
        body = f"Couldn't restore this thread's model {desired} ({reason}){fallback}"
        if hint:
            body += f" {hint}"
        await self._mount_message(ErrorMessage(body))

    async def _switch_model(
        self,
        model_spec: str,
        *,
        extra_kwargs: dict[str, Any] | None = None,
        announce_unchanged: bool = True,
        persist: bool = True,
        from_resume: bool = False,
    ) -> 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.
            persist: Whether to write the model to the user's recent/default
                config.

                Set `False` for session-only switches (e.g. adopting a
                resumed thread's model) so a one-off resume does not redefine
                the user's persisted default.
            from_resume: Whether this switch is auto-adopting a resumed thread's
                model.

                When `True`, failures are reported with resume-specific
                messaging (which model couldn't be restored and what the session
                is falling back to) rather than the interactive `/model` errors.
        """
        from deepagents_code.config import 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,
                                persist=persist,
                                from_resume=from_resume,
                            ),
                        ),
                    )
                    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:
                if from_resume:
                    await self._mount_resume_adoption_failure(
                        model_spec,
                        f"missing credentials for '{auth_status.provider}'",
                        hint=f"Run `/auth` then `/model {model_spec}` to use it.",
                    )
                else:
                    await self._mount_message(
                        ErrorMessage(
                            f"Missing credentials: {auth_status.missing_detail()}\n\n"
                            f"Run `/auth` for the '{auth_status.provider}' provider, "
                            f"then 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:
                    message = f"Already using {current}{params_suffix}"
                    if message != self._last_model_unchanged_message:
                        await self._mount_message(AppMessage(message))
                        self._last_model_unchanged_message = message
                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 = await asyncio.to_thread(
                    _create_model_with_deepagents_import_lock,
                    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)
                if from_resume:
                    await self._mount_resume_adoption_failure(
                        display, "the model could not be initialized"
                    )
                else:
                    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 "",
                )

            self._last_model_unchanged_message = None
            params_suffix = _format_model_params(extra_kwargs)
            if not persist:
                # Session-only switch (e.g. adopting a resumed thread's model):
                # announce but never touch the user's persisted recent/default.
                await self._mount_message(
                    AppMessage(f"Switched to {display}{params_suffix}"),
                )
            elif 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:
                await self._mount_message(
                    AppMessage(f"Switched to {display}{params_suffix}"),
                )
            if persist:
                # 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 — the debug log captures it when
                # debug logging is enabled.
                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
        self._reconnecting = 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)
        self._sync_status_connection()

        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,
    model_explicitly_set: bool = False,
    interpreter_arg: bool | 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 status-bar connection state 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.
        model_explicitly_set: Whether the user passed `--model` on the command
            line.

            When `True`, the explicit choice wins over a resumed thread's
            persisted model (no resume adoption).
        interpreter_arg: The raw `--interpreter`/`--no-interpreter` tri-state,
            forwarded to the app so the disabled-by-sandbox advisory can tell an
            explicit opt-out from a sandbox-suppressed default.
        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,
        model_explicitly_set=model_explicitly_set,
        interpreter_arg=interpreter_arg,
        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,
    )
