feat(cli): live progress UI and periodic recheck for self-updates (#3280)

Adds a live progress modal for self-updates, hourly background rechecks,
and debug tooling to make the update flow observable and non-disruptive.
Upgrade output is now streamed in real-time and persisted to timestamped
logs with automatic cleanup.
This commit is contained in:
Mason Daugherty
2026-05-09 19:03:14 -04:00
committed by GitHub
parent 4005bd1906
commit 3e9e80597d
8 changed files with 963 additions and 67 deletions
+207 -41
View File
@@ -115,6 +115,7 @@ if TYPE_CHECKING:
from deepagents_cli.widgets.approval import ApprovalMenu
from deepagents_cli.widgets.ask_user import AskUserMenu
from deepagents_cli.widgets.notification_center import NotificationSuppressRequested
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
_LAUNCH_INIT_CONNECTION_TIMEOUT_SECONDS = 60.0
"""Upper bound on waiting for server readiness during onboarding model switch.
@@ -123,6 +124,9 @@ Server startup is normally seconds; this ceiling exists only so a stuck
backend cannot trap the user inside a finished onboarding modal forever.
"""
_UPDATE_RECHECK_INTERVAL_SECONDS = 60 * 60
"""How often long-running TUI sessions quietly re-check for CLI updates."""
def _load_theme_preference() -> str:
"""Load the forced or saved theme name, or return the default.
@@ -1160,6 +1164,9 @@ class DeepAgentsApp(App):
`CACHE_TTL`) leaves this clear so missing-dep toasts still fire.
"""
self._update_install_running = False
"""True while a self-update command is running."""
# Skills cache
self._discovered_skills: list[ExtendedSkillMetadata] = []
"""Cached skill metadata (populated by startup discovery worker,
@@ -1579,6 +1586,14 @@ class DeepAgentsApp(App):
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,
@@ -2264,21 +2279,24 @@ class DeepAgentsApp(App):
except Exception:
logger.warning("Could not prewarm model caches", exc_info=True)
async def _check_for_updates(self) -> None:
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()
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) -> None:
async def _check_for_updates_impl(self, *, periodic: bool = False) -> None:
"""Check PyPI for a newer version and either auto-update or queue a modal.
Phase 1 contacts PyPI and records the latest version on the app.
@@ -2296,7 +2314,9 @@ class DeepAgentsApp(App):
upgrade_command,
)
available, latest = await asyncio.to_thread(is_update_available)
available, latest = await asyncio.to_thread(
is_update_available, bypass_cache=periodic
)
if not available or latest is None:
return
@@ -2310,14 +2330,29 @@ class DeepAgentsApp(App):
from deepagents_cli._version import __version__ as cli_version
if is_auto_update_enabled():
from deepagents_cli.update_check import perform_upgrade
from deepagents_cli._env_vars import DEBUG_UPDATE
from deepagents_cli.update_check import (
create_update_log_path,
perform_upgrade,
)
if os.environ.get(DEBUG_UPDATE):
self.notify(
"Skipped update install (debug mode).",
severity="information",
timeout=4,
markup=False,
)
return
log_path = create_update_log_path()
self.notify(
f"Updating to v{latest}...",
f"Updating to v{latest}... Logs: {log_path}",
severity="information",
timeout=5,
markup=False,
)
success, output = await perform_upgrade()
success, output = await perform_upgrade(log_path=log_path)
if success:
self.notify(
f"Updated to v{latest}. Restart to use the new version.",
@@ -2332,7 +2367,9 @@ class DeepAgentsApp(App):
)
cmd = upgrade_command()
snippet = _truncate(output, limit=160) if output else ""
message = f"Auto-update failed. Run manually: {cmd}"
message = (
f"Auto-update failed. Run manually: {cmd}\nLog: {log_path}"
)
if snippet:
message = f"{message}\n{snippet}"
self.notify(
@@ -2366,6 +2403,15 @@ class DeepAgentsApp(App):
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
@@ -2409,7 +2455,8 @@ class DeepAgentsApp(App):
"""
body = (
f"v{latest} is available{release_age}.\n"
f"Currently installed: {cli_version}{installed_age}."
f"Currently installed: {cli_version}{installed_age}.\n"
"Your session will not be interrupted."
)
return PendingNotification(
key="update:available",
@@ -2462,6 +2509,7 @@ class DeepAgentsApp(App):
"""Handle the `/update` slash command — check for and install updates."""
await self._mount_message(UserMessage("/update"))
try:
from deepagents_cli._env_vars import DEBUG_UPDATE
from deepagents_cli._version import __version__ as cli_version
from deepagents_cli.config import _is_editable_install
from deepagents_cli.update_check import (
@@ -2517,6 +2565,11 @@ class DeepAgentsApp(App):
"Upgrading..."
)
)
if os.environ.get(DEBUG_UPDATE):
await self._mount_message(
AppMessage("Skipped update install (debug mode).")
)
return
success, output = await perform_upgrade()
if success:
self._update_available = (False, None)
@@ -6867,6 +6920,7 @@ class DeepAgentsApp(App):
*,
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.
@@ -6878,10 +6932,11 @@ class DeepAgentsApp(App):
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\nctrl+n for options"
toast_body = f"{notification.body}\n\n{action_hint}"
effective_timeout = (
timeout if timeout is not None else self.NOTIFICATION_TIMEOUT
)
@@ -7061,8 +7116,8 @@ class DeepAgentsApp(App):
# the user how to reach it.
self._update_modal_pending.clear()
self.notify(
"Update available. Close the current dialog, "
"then press ctrl+n to review it.",
"Update available. Your session will not be interrupted. "
"Press ctrl+n to review it.",
severity="information",
timeout=8,
markup=False,
@@ -7254,6 +7309,7 @@ class DeepAgentsApp(App):
"""
from deepagents_cli.update_check import (
clear_update_notified,
create_update_log_path,
mark_update_notified,
perform_upgrade,
upgrade_command,
@@ -7262,58 +7318,168 @@ class DeepAgentsApp(App):
if action_id == ActionId.INSTALL:
from deepagents_cli._env_vars import DEBUG_UPDATE
if os.environ.get(DEBUG_UPDATE):
self._notice_registry.remove(entry.key)
if self._update_install_running:
self.notify(
"Skipped update install (debug mode).",
"Update already running.",
severity="information",
timeout=4,
markup=False,
)
return
self.notify(
f"Updating to v{payload.latest}...",
severity="information",
timeout=5,
markup=False,
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
cmd = upgrade_command()
log_path = create_update_log_path()
screen = UpdateProgressScreen(
latest=payload.latest,
command=cmd,
log_path=log_path,
)
success, output = await perform_upgrade()
if success:
self._notice_registry.remove(entry.key)
progress_modal_visible = not isinstance(self.screen, ModalScreen)
if progress_modal_visible:
self.push_screen(screen)
else:
self.notify(
f"Updated to v{payload.latest}. Restart to use the new version.",
f"Updating to v{payload.latest}... Logs: {log_path}",
severity="information",
timeout=10,
timeout=8,
markup=False,
)
return
logger.warning(
"Auto-upgrade failed for v%s. Output:\n%s", payload.latest, output
)
self._notice_registry.remove(entry.key)
cmd = upgrade_command()
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,
)
self._update_install_running = True
try:
if os.environ.get(DEBUG_UPDATE):
await self._run_debug_update_install(
entry=entry,
payload=payload,
screen=screen,
log_path=log_path,
show_toast=not progress_modal_visible,
)
return
success, output = await perform_upgrade(
progress=screen.append_line,
log_path=log_path,
)
if success:
self._notice_registry.remove(entry.key)
screen.mark_success()
if not progress_modal_visible:
self.notify(
f"Updated to v{payload.latest}. "
"Restart to use the new version.",
severity="information",
timeout=10,
markup=False,
)
return
logger.warning(
"Auto-upgrade failed for v%s. Output:\n%s",
payload.latest,
output,
)
self._notice_registry.remove(entry.key)
screen.mark_failure(cmd)
snippet = _truncate(output, limit=160) if output else ""
message = f"Auto-update failed. Run manually: {cmd}"
if snippet:
message = f"{message}\n{snippet}"
self.notify(
message,
severity="warning",
timeout=15,
markup=False,
)
finally:
self._update_install_running = False
return
if action_id == ActionId.SKIP_VERSION:
await asyncio.to_thread(mark_update_notified, payload.latest)
self._notice_registry.remove(entry.key)
self.notify(
f"Skipped v{payload.latest}.",
severity="information",
timeout=4,
markup=False,
)
return
if action_id == ActionId.SKIP_ONCE:
await asyncio.to_thread(clear_update_notified)
self._notice_registry.remove(entry.key)
self.notify(
"We'll remind you next launch.",
severity="information",
timeout=4,
markup=False,
)
return
self._log_unknown_action(entry, action_id)
async def _run_debug_update_install(
self,
*,
entry: PendingNotification,
payload: UpdateAvailablePayload,
screen: UpdateProgressScreen,
log_path: Path,
show_toast: bool,
) -> None:
"""Exercise the update progress UI without invoking a package manager.
Args:
entry: The update notification entry to clear when complete.
payload: Update payload with the mocked target version.
screen: Progress modal to update.
log_path: Debug log path to write mock output into.
show_toast: Whether to show a completion toast.
"""
steps = (
("Debug mode: no package manager command was started.", 0.3),
(f"Resolving deepagents-cli v{payload.latest}...", 0.8),
("Looking up compatible build tags...", 0.2),
("Downloading wheel metadata...", 0.5),
("Downloading deepagents_cli-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 _show_mcp_viewer(self) -> None:
"""Show read-only MCP server/tool viewer as a modal screen."""
from deepagents_cli.widgets.mcp_viewer import MCPViewerScreen
+13 -1
View File
@@ -1682,9 +1682,11 @@ def cli_main() -> None:
try:
from rich.markup import escape
from deepagents_cli._env_vars import DEBUG_UPDATE
from deepagents_cli._version import __version__ as cli_version
from deepagents_cli.config import _is_editable_install
from deepagents_cli.update_check import (
create_update_log_path,
format_age_suffix,
format_installed_age_suffix,
format_release_age_parenthetical,
@@ -1725,7 +1727,17 @@ def cli_main() -> None:
f"Currently installed: {cli_version}{installed_age}. "
"Upgrading..."
)
success, output = asyncio.run(perform_upgrade())
if os.environ.get(DEBUG_UPDATE):
console.print("Skipped update install (debug mode).", style="dim")
sys.exit(0)
log_path = create_update_log_path()
console.print(
f"Update log: {log_path}\nTail progress: tail -f {log_path}",
style="dim",
highlight=False,
markup=False,
)
success, output = asyncio.run(perform_upgrade(log_path=log_path))
if success:
console.print(f"[green]Updated to v{latest}.[/green]")
else:
+151 -19
View File
@@ -14,23 +14,25 @@ from __future__ import annotations
import asyncio
import json
import logging
import operator
import os
import shutil
import sys
import time
import tomllib
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal, TextIO
from packaging.version import InvalidVersion, Version
from deepagents_cli._version import PYPI_URL, SDK_PYPI_URL, USER_AGENT, __version__
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
if TYPE_CHECKING:
from pathlib import Path
from deepagents_cli.model_config import DEFAULT_CONFIG_PATH, DEFAULT_STATE_DIR
logger = logging.getLogger(__name__)
CACHE_FILE: Path = DEFAULT_STATE_DIR / "latest_version.json"
@@ -88,6 +90,17 @@ no fallback chain.
_UPGRADE_TIMEOUT = 120 # seconds
UPDATE_LOG_DIR: Path = DEFAULT_STATE_DIR / "update_logs"
"""Directory for persisted update command logs."""
UPDATE_LOG_RETENTION_DAYS = 14
"""Delete update logs older than this many days."""
UPDATE_LOG_MAX_FILES = 10
"""Keep at most this many newest update logs."""
UpgradeProgressCallback = Callable[[str], Awaitable[None] | None]
def _parse_version(v: str) -> Version:
"""Parse a PEP 440 version string into a comparable `Version` object.
@@ -669,12 +682,88 @@ def upgrade_command(method: InstallMethod | None = None) -> str:
return _UPGRADE_COMMANDS.get(method, _UPGRADE_COMMANDS["pip"])
async def perform_upgrade() -> tuple[bool, str]:
def cleanup_update_logs(
*,
retention_days: int = UPDATE_LOG_RETENTION_DAYS,
max_files: int = UPDATE_LOG_MAX_FILES,
) -> None:
"""Remove old update logs while preserving the newest recent logs.
Args:
retention_days: Maximum age in days to keep.
max_files: Maximum number of newest log files to keep.
"""
try:
if not UPDATE_LOG_DIR.exists():
return
logs = sorted(
(
(p, p.stat().st_mtime)
for p in UPDATE_LOG_DIR.glob("*.log")
if p.is_file()
),
key=operator.itemgetter(1),
reverse=True,
)
cutoff = time.time() - (retention_days * 86_400)
for idx, (path, mtime) in enumerate(logs):
if idx >= max_files or mtime < cutoff:
path.unlink(missing_ok=True)
except OSError:
logger.debug("Failed to clean up update logs", exc_info=True)
def create_update_log_path() -> Path:
"""Return a new timestamped update log path and clean stale logs."""
cleanup_update_logs()
stamp = datetime.now(tz=UTC).strftime("%Y%m%d-%H%M%S")
return UPDATE_LOG_DIR / f"{stamp}-update.log"
async def _emit_progress(callback: UpgradeProgressCallback | None, line: str) -> None:
"""Send a progress line to *callback*, supporting sync or async callbacks."""
if callback is None:
return
result = callback(line)
if isinstance(result, Awaitable):
await result
async def _read_stream(
stream: asyncio.StreamReader,
*,
lines: list[str],
log_file: TextIO | None,
progress: UpgradeProgressCallback | None,
) -> None:
"""Read subprocess output, append it to the log file, and emit progress."""
while True:
raw = await stream.readline()
if not raw:
return
line = raw.decode(errors="replace").rstrip("\n")
lines.append(line)
if log_file is not None:
with suppress(OSError):
log_file.write(f"{line}\n")
log_file.flush()
await _emit_progress(progress, line)
async def perform_upgrade(
*,
progress: UpgradeProgressCallback | None = None,
log_path: Path | None = None,
) -> tuple[bool, str]:
"""Attempt to upgrade `deepagents-cli` using the detected install method.
Only tries the detected method — does not fall back to other package
managers to avoid cross-environment contamination.
Args:
progress: Optional callback invoked for each output line.
log_path: Optional path to persist command output.
Returns:
`(success, output)` — *output* is the combined stdout/stderr.
"""
@@ -690,6 +779,21 @@ async def perform_upgrade() -> tuple[bool, str]:
if method == "brew" and not shutil.which("brew"):
return False, "brew not found on PATH."
if log_path is None:
log_path = create_update_log_path()
output_lines: list[str] = []
proc: asyncio.subprocess.Process | None = None
log_file: TextIO | None = None
try:
log_path.parent.mkdir(parents=True, exist_ok=True)
log_file = log_path.open("w", encoding="utf-8")
log_file.write(f"$ {cmd}\n")
log_file.flush()
except OSError:
logger.debug("Could not create update log at %s", log_path, exc_info=True)
log_file = None
try:
proc = await asyncio.create_subprocess_shell(
cmd,
@@ -697,29 +801,57 @@ async def perform_upgrade() -> tuple[bool, str]:
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
stdout, stderr = await asyncio.wait_for(
proc.communicate(), timeout=_UPGRADE_TIMEOUT
await asyncio.wait_for(
asyncio.gather(
_read_stream(
proc.stdout, # type: ignore[arg-type]
lines=output_lines,
log_file=log_file,
progress=progress,
),
_read_stream(
proc.stderr, # type: ignore[arg-type]
lines=output_lines,
log_file=log_file,
progress=progress,
),
proc.wait(),
),
timeout=_UPGRADE_TIMEOUT,
)
output = (stdout or b"").decode() + (stderr or b"").decode()
if proc.returncode == 0:
return True, output.strip()
logger.warning(
"Upgrade via %s exited with code %d: %s",
method,
proc.returncode,
output.strip(),
)
return False, output.strip()
except TimeoutError:
proc.kill()
await proc.wait()
if proc is not None:
proc.kill()
await proc.wait()
msg = f"Upgrade command timed out after {_UPGRADE_TIMEOUT}s: {cmd}"
if log_file is not None:
with suppress(OSError):
log_file.write(f"{msg}\n")
log_file.close()
await _emit_progress(progress, msg)
logger.warning(msg)
return False, msg
except OSError:
if log_file is not None:
with suppress(OSError):
log_file.close()
logger.warning("Failed to execute upgrade command: %s", cmd, exc_info=True)
return False, f"Failed to execute: {cmd}"
if log_file is not None:
with suppress(OSError):
log_file.close()
output = "\n".join(output_lines).strip()
if proc.returncode == 0:
return True, output
logger.warning(
"Upgrade via %s exited with code %d: %s",
method,
proc.returncode,
output,
)
return False, output
# ---------------------------------------------------------------------------
# Config helpers
@@ -0,0 +1,277 @@
"""Progress modal for CLI self-update installs."""
from __future__ import annotations
from collections import deque
from typing import TYPE_CHECKING, ClassVar
from textual.binding import Binding, BindingType
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Log, Static
if TYPE_CHECKING:
from pathlib import Path
from textual.app import ComposeResult
from textual.timer import Timer
from deepagents_cli import theme
from deepagents_cli.config import get_glyphs, is_ascii_mode
from deepagents_cli.widgets.loading import Spinner
class UpdateProgressScreen(ModalScreen[None]):
"""Modal that shows self-update progress and a bounded log tail."""
BINDINGS: ClassVar[list[BindingType]] = [
Binding("d", "toggle_details", "Details", show=False),
Binding("c", "copy_log_path", "Copy log path", show=False),
Binding("escape", "cancel", "Close", show=False),
]
CSS = """
UpdateProgressScreen {
align: center middle;
}
UpdateProgressScreen > Vertical {
width: 78;
max-width: 92%;
height: auto;
max-height: 85%;
background: $surface;
border: solid $primary;
padding: 1 2;
}
UpdateProgressScreen .up-title {
text-style: bold;
color: $primary;
text-align: center;
margin-bottom: 1;
}
UpdateProgressScreen .up-status {
color: $text;
}
UpdateProgressScreen .up-status-row {
height: auto;
margin-bottom: 1;
}
UpdateProgressScreen .up-spinner {
width: auto;
color: $primary;
margin-right: 1;
}
UpdateProgressScreen .up-details,
UpdateProgressScreen .up-log,
UpdateProgressScreen Log.up-tail,
UpdateProgressScreen .up-help {
color: $text-muted;
}
UpdateProgressScreen .up-details {
margin-top: 1;
margin-bottom: 1;
}
UpdateProgressScreen Log.up-tail {
height: 10;
min-height: 10;
max-height: 10;
background: $surface-lighten-1;
border: solid $surface-lighten-1;
overflow-x: hidden;
}
UpdateProgressScreen .up-log {
margin-top: 1;
margin-bottom: 1;
}
UpdateProgressScreen .up-help {
height: 1;
text-style: italic;
margin-top: 1;
text-align: center;
}
"""
def __init__(
self,
*,
latest: str,
command: str,
log_path: Path,
tail_limit: int = 30,
) -> None:
"""Initialize the progress modal.
Args:
latest: Version being installed.
command: Upgrade command being run.
log_path: Persisted log file path.
tail_limit: Maximum output lines to keep in memory.
"""
super().__init__()
self._latest = latest
self._command = command
self._log_path = log_path
self._tail_limit = tail_limit
self._tail: deque[str] = deque(maxlen=tail_limit)
self._details_visible = False
self._done = False
self._status = f"Installing v{latest}..."
self._status_widget: Static | None = None
self._spinner = Spinner()
self._spinner_widget: Static | None = None
self._spinner_timer: Timer | None = None
self._command_widget: Static | None = None
self._log_path_widget: Static | None = None
self._tail_widget: Log | None = None
self._help_widget: Static | None = None
def compose(self) -> ComposeResult:
"""Compose the modal.
Yields:
Static widgets for status, command, output tail, log path, and help.
"""
with Vertical():
yield Static("Updating Deep Agents CLI", classes="up-title")
with Horizontal(classes="up-status-row"):
self._spinner_widget = Static(
self._spinner.current_frame(), classes="up-spinner"
)
yield self._spinner_widget
self._status_widget = Static(
self._status, classes="up-status", markup=False
)
yield self._status_widget
self._command_widget = Static(
f"Running command: {self._command}",
classes="up-details",
markup=False,
)
self._command_widget.display = False
yield self._command_widget
self._tail_widget = Log(
highlight=False,
max_lines=self._tail_limit,
auto_scroll=True,
classes="up-tail",
)
self._tail_widget.display = False
yield self._tail_widget
self._log_path_widget = Static(
f"Log: {self._log_path}",
classes="up-log",
markup=False,
)
self._log_path_widget.display = False
yield self._log_path_widget
self._help_widget = Static(self._help_text(), classes="up-help")
yield self._help_widget
def on_mount(self) -> None:
"""Apply ASCII border when configured."""
if is_ascii_mode():
container = self.query_one(Vertical)
colors = theme.get_theme_colors(self)
container.styles.border = ("ascii", colors.primary)
self._spinner_timer = self.set_interval(0.1, self._update_spinner)
def append_line(self, line: str) -> None:
"""Append a command output line to the in-memory tail."""
self._tail.append(line)
if self._tail_widget is not None:
self._tail_widget.write_line(line)
def mark_success(self) -> None:
"""Render the completed-success state."""
self._done = True
self._status = f"Update complete. Restart the CLI to use v{self._latest}."
self._stop_spinner_timer()
self._refresh_status()
def mark_failure(self, command: str) -> None:
"""Render the completed-failure state.
Args:
command: Manual command users can run to retry.
"""
self._done = True
self._status = f"Update failed. Try manually: {command}"
self._details_visible = True
self._stop_spinner_timer()
self._refresh_status()
self._apply_details_visibility()
def action_toggle_details(self) -> None:
"""Show or hide the live log tail."""
self._details_visible = not self._details_visible
self._apply_details_visibility()
def action_cancel(self) -> None:
"""Close the modal only after the update command has finished."""
if self._done:
self.dismiss(None)
def action_copy_log_path(self) -> None:
"""Copy the persisted log path when details are visible."""
if not self._details_visible:
return
self.app.copy_to_clipboard(str(self._log_path))
self.app.notify(
"Copied log path.",
severity="information",
timeout=3,
markup=False,
)
def _refresh_status(self) -> None:
if self._status_widget is not None:
self._status_widget.update(self._status)
if self._spinner_widget is not None:
if self._done:
self._spinner_widget.update(get_glyphs().checkmark)
self._spinner_widget.display = True
else:
self._spinner_widget.display = True
if self._help_widget is not None:
self._help_widget.update(self._help_text())
def _apply_details_visibility(self) -> None:
if self._tail_widget is None:
return
if self._command_widget is not None:
self._command_widget.display = self._details_visible
if self._log_path_widget is not None:
self._log_path_widget.display = self._details_visible
self._tail_widget.display = self._details_visible
if self._help_widget is not None:
self._help_widget.update(self._help_text())
def _stop_spinner_timer(self) -> None:
if self._spinner_timer is not None:
self._spinner_timer.stop()
self._spinner_timer = None
def _help_text(self) -> str:
glyphs = get_glyphs()
details = "Hide details" if self._details_visible else "Show details"
close = "Esc close" if self._done else "Esc close when complete"
parts = [f"d {details}", close]
if self._details_visible:
parts.insert(1, "c copy log path")
return f" {glyphs.bullet} ".join(parts)
def _update_spinner(self) -> None:
"""Advance the glyph spinner while the update is running."""
if self._done or self._spinner_widget is None:
return
self._spinner_widget.update(self._spinner.next_frame())
+88 -5
View File
@@ -5365,7 +5365,8 @@ def test_build_update_notification_uses_release_and_installed_age_copy() -> None
assert notification.body == (
"v2.0.0 is available (released 3d ago).\n"
"Currently installed: 1.0.0 (8 days old)."
"Currently installed: 1.0.0 (8 days old).\n"
"Your session will not be interrupted."
)
assert notification.title == "Update available"
@@ -5859,8 +5860,8 @@ class TestNotificationCenterIntegration:
"""Debug update modal can exercise Install now without changing packages."""
from deepagents_cli._env_vars import DEBUG_UPDATE
from deepagents_cli.notifications import ActionId
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
monkeypatch.setenv(DEBUG_UPDATE, "1")
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
entry = _update_entry()
app._notice_registry.add(entry)
@@ -5876,16 +5877,22 @@ class TestNotificationCenterIntegration:
async with app.run_test() as pilot:
await pilot.pause()
monkeypatch.setenv(DEBUG_UPDATE, "1")
with patch(
"deepagents_cli.update_check.perform_upgrade",
new=AsyncMock(return_value=(True, "Updated deepagents-cli")),
) as mock_upgrade:
await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
with patch(
"deepagents_cli.app.asyncio.sleep",
new=AsyncMock(),
):
await app._dispatch_notification_action(entry.key, ActionId.INSTALL)
await pilot.pause()
assert isinstance(app.screen, UpdateProgressScreen)
mock_upgrade.assert_not_called()
assert app._notice_registry.get("update:available") is None
assert any("debug mode" in m for m in notified)
assert not any("Mock update complete" in m for m in notified)
async def test_install_failure_removes_entry_and_toasts_manual(self) -> None:
"""Failed install removes the stale entry and surfaces the manual command."""
@@ -5924,6 +5931,14 @@ class TestNotificationCenterIntegration:
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
entry = _update_entry()
app._notice_registry.add(entry)
notified: list[str] = []
original_notify = app.notify
def capture_notify(message: str, **kwargs: Any) -> None:
notified.append(message)
original_notify(message, **kwargs)
app.notify = capture_notify # type: ignore[method-assign]
with patch(
"deepagents_cli.update_check.clear_update_notified",
@@ -5935,6 +5950,7 @@ class TestNotificationCenterIntegration:
mock_clear.assert_called_once()
assert app._notice_registry.get("update:available") is None
assert any("remind you next launch" in m for m in notified)
async def test_update_skip_version_marks_notified_for_latest(self) -> None:
"""'Skip this version' marks the version notified and removes the entry."""
@@ -5943,6 +5959,14 @@ class TestNotificationCenterIntegration:
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
entry = _update_entry(latest="3.1.4")
app._notice_registry.add(entry)
notified: list[str] = []
original_notify = app.notify
def capture_notify(message: str, **kwargs: Any) -> None:
notified.append(message)
original_notify(message, **kwargs)
app.notify = capture_notify # type: ignore[method-assign]
with patch(
"deepagents_cli.update_check.mark_update_notified",
@@ -5956,6 +5980,7 @@ class TestNotificationCenterIntegration:
mock_mark.assert_called_once_with("3.1.4")
assert app._notice_registry.get("update:available") is None
assert any("Skipped v3.1.4" in m for m in notified)
async def test_dispatcher_handler_exception_surfaces_action_label(self) -> None:
"""A handler raising OSError produces a warning toast naming the action."""
@@ -6189,6 +6214,59 @@ class TestNotificationCenterIntegration:
await pilot.pause()
assert isinstance(app.screen, UpdateAvailableScreen)
async def test_periodic_update_check_toasts_without_opening_modal(self) -> None:
"""Hourly rechecks surface updates without interrupting the session."""
app = DeepAgentsApp(agent=MagicMock(), thread_id="t")
bodies: list[str] = []
original_notify_actionable = app._notify_actionable
def capture_notify_actionable(
entry: PendingNotification, **kwargs: Any
) -> None:
bodies.append(f"{entry.body}\n\n{kwargs.get('action_hint', '')}")
original_notify_actionable(entry, **kwargs)
app._notify_actionable = capture_notify_actionable # type: ignore[method-assign]
with (
patch(
"deepagents_cli.update_check.is_update_available",
return_value=(True, "9.9.9"),
),
patch(
"deepagents_cli.update_check.is_auto_update_enabled",
return_value=False,
),
patch(
"deepagents_cli.update_check.should_notify_update",
return_value=True,
),
patch(
"deepagents_cli.update_check.mark_update_notified",
),
patch(
"deepagents_cli.update_check.format_release_age_parenthetical",
return_value="",
),
patch(
"deepagents_cli.update_check.format_installed_age_suffix",
return_value="",
),
patch(
"deepagents_cli.update_check.upgrade_command",
return_value="pip install -U deepagents-cli",
),
):
async with app.run_test() as pilot:
await pilot.pause()
await app._check_for_updates(periodic=True)
await pilot.pause()
entry = app._notice_registry.get("update:available")
assert entry is not None
assert any("session will not be interrupted" in body for body in bodies)
assert any("Press ctrl+n to install." in body for body in bodies)
async def test_open_update_available_modal_over_modal_toasts_hint(self) -> None:
"""Another modal already open: update modal is deferred with a hint toast."""
from deepagents_cli.widgets.update_available import UpdateAvailableScreen
@@ -6221,7 +6299,12 @@ class TestNotificationCenterIntegration:
# Hint toast surfaced with ctrl+n pointer; pending event cleared so
# subsequent missing-dep toasts aren't suppressed.
assert any("Update available" in m and "ctrl+n" in m for m in notified)
assert any(
"Update available" in m
and "session will not be interrupted" in m
and "ctrl+n" in m
for m in notified
)
assert not app._update_modal_pending.is_set()
assert app._notice_registry.get(entry.key) is entry
@@ -876,15 +876,19 @@ class TestUpdateSubcommand:
@staticmethod
def _run_update(
*,
debug: bool = False,
editable: bool,
is_update_available_return: tuple[bool, str | None],
log_path: str = "/tmp/deepagents-update.log",
) -> tuple[int, MagicMock, MagicMock]:
"""Invoke `cli_main()` with `update` subcommand; return exit code + mocks."""
from deepagents_cli._env_vars import DEBUG_UPDATE
from deepagents_cli.main import cli_main
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = True
with (
patch.dict(os.environ, {DEBUG_UPDATE: "1" if debug else ""}),
patch.object(sys, "argv", ["deepagents", "update"]),
patch.object(sys, "stdin", mock_stdin),
patch("deepagents_cli.main.check_cli_dependencies"),
@@ -893,6 +897,10 @@ class TestUpdateSubcommand:
"deepagents_cli.update_check.is_update_available",
return_value=is_update_available_return,
) as is_update_mock,
patch(
"deepagents_cli.update_check.create_update_log_path",
return_value=log_path,
),
patch(
"deepagents_cli.update_check.perform_upgrade",
new_callable=AsyncMock,
@@ -935,6 +943,26 @@ class TestUpdateSubcommand:
assert code == 0
perform_upgrade_mock.assert_not_called()
def test_debug_update_skips_upgrade(self) -> None:
"""Debug update mode exits 0 without invoking the installer."""
code, _, perform_upgrade_mock = self._run_update(
debug=True,
editable=False,
is_update_available_return=(True, "99.0.0"),
)
assert code == 0
perform_upgrade_mock.assert_not_called()
def test_markup_like_log_path_does_not_break_output(self) -> None:
"""Dynamic log paths are printed without Rich markup parsing."""
code, _, perform_upgrade_mock = self._run_update(
editable=False,
is_update_available_return=(True, "99.0.0"),
log_path="/tmp/[/red]/deepagents-update.log",
)
assert code == 0
perform_upgrade_mock.assert_awaited_once()
def test_update_available_runs_upgrade(self) -> None:
"""`(True, "x.y.z")` triggers `perform_upgrade` and exits 0 on success."""
code, _, perform_upgrade_mock = self._run_update(
+83 -1
View File
@@ -3,9 +3,10 @@
from __future__ import annotations
import json
import os
import time
import tomllib
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, mock_open, patch
import pytest
from packaging.version import InvalidVersion, Version
@@ -16,7 +17,9 @@ from deepagents_cli.update_check import (
_extract_release_times,
_latest_from_releases,
_parse_version,
cleanup_update_logs,
clear_update_notified,
create_update_log_path,
format_age_suffix,
format_installed_age_suffix,
format_release_age,
@@ -31,6 +34,7 @@ from deepagents_cli.update_check import (
is_update_available,
mark_update_notified,
mark_version_seen,
perform_upgrade,
set_auto_update,
should_notify_update,
)
@@ -44,6 +48,14 @@ def cache_file(tmp_path):
yield path
@pytest.fixture
def update_log_dir(tmp_path):
"""Override UPDATE_LOG_DIR to use a temporary directory."""
path = tmp_path / "update_logs"
with patch("deepagents_cli.update_check.UPDATE_LOG_DIR", path):
yield path
def _mock_pypi_response(
version: str = "99.0.0",
releases: dict[str, list[dict[str, object]]] | None = None,
@@ -772,6 +784,76 @@ class TestFormatInstalledAgeSuffix:
assert format_installed_age_suffix("1.0.0") == ""
class TestUpdateLogs:
def test_create_update_log_path_uses_log_dir(self, update_log_dir) -> None:
path = create_update_log_path()
assert path.parent == update_log_dir
assert path.name.endswith("-update.log")
def test_cleanup_update_logs_removes_old_and_excess(self, update_log_dir) -> None:
update_log_dir.mkdir(parents=True)
now = time.time()
paths = []
for idx in range(4):
path = update_log_dir / f"{idx}-update.log"
path.write_text(str(idx))
os.utime(path, (now - idx, now - idx))
paths.append(path)
old = update_log_dir / "old-update.log"
old.write_text("old")
os.utime(old, (now - 30 * 86_400, now - 30 * 86_400))
cleanup_update_logs(retention_days=14, max_files=2)
remaining = {path.name for path in update_log_dir.glob("*.log")}
assert remaining == {paths[0].name, paths[1].name}
async def test_perform_upgrade_runs_when_log_cannot_be_created(
self, tmp_path
) -> None:
"""Log persistence is best-effort and must not block the updater."""
blocked_parent = tmp_path / "not-a-dir"
blocked_parent.write_text("file")
log_path = blocked_parent / "update.log"
with (
patch(
"deepagents_cli.update_check.detect_install_method",
return_value="pip",
),
patch.dict(
"deepagents_cli.update_check._UPGRADE_COMMANDS",
{"pip": "printf 'ok\\n'"},
),
):
success, output = await perform_upgrade(log_path=log_path)
assert success is True
assert output == "ok"
async def test_perform_upgrade_ignores_log_close_failure(self, tmp_path) -> None:
"""A close-time log flush failure must not fail a successful upgrade."""
log_path = tmp_path / "update.log"
opener = mock_open()
opener.return_value.close.side_effect = OSError("flush failed")
with (
patch(
"deepagents_cli.update_check.detect_install_method",
return_value="pip",
),
patch.dict(
"deepagents_cli.update_check._UPGRADE_COMMANDS",
{"pip": "printf 'ok\\n'"},
),
patch("pathlib.Path.open", opener),
):
success, output = await perform_upgrade(log_path=log_path)
assert success is True
assert output == "ok"
def _mock_sdk_pypi_response(
releases: dict[str, list[dict[str, object]]] | None = None,
) -> MagicMock:
@@ -0,0 +1,116 @@
"""Tests for `UpdateProgressScreen`."""
from __future__ import annotations
from textual.app import App
from textual.widgets import Log, Static
from deepagents_cli.config import get_glyphs
from deepagents_cli.widgets.update_progress import UpdateProgressScreen
async def test_update_progress_screen_shows_tail_when_details_toggle(tmp_path) -> None:
"""The progress modal keeps a bounded tail hidden until details are toggled."""
screen = UpdateProgressScreen(
latest="2.0.0",
command="pip install --upgrade deepagents-cli",
log_path=tmp_path / "update.log",
tail_limit=2,
)
app = App()
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
details = screen.query(Static).filter(".up-details").first()
log_path = screen.query(Static).filter(".up-log").first()
assert details.display is False
assert log_path.display is False
assert "Running command: pip install --upgrade deepagents-cli" in str(
details.render()
)
assert "tail -f" not in str(details.render())
screen.append_line("first")
screen.append_line("second")
screen.append_line("third")
await pilot.press("d")
await pilot.pause()
tail = screen.query(Log).filter(".up-tail").first()
assert details.display is True
assert log_path.display is True
assert tail.display is True
assert tail.line_count == 2
assert list(screen._tail) == ["second", "third"]
async def test_update_progress_screen_copies_log_path_only_in_details(tmp_path) -> None:
"""Pressing c copies the log path only when details are visible."""
log_path = tmp_path / "update.log"
screen = UpdateProgressScreen(
latest="2.0.0",
command="pip install --upgrade deepagents-cli",
log_path=log_path,
)
copied: list[str] = []
app = App()
app.copy_to_clipboard = copied.append # type: ignore[method-assign]
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("c")
await pilot.pause()
assert copied == []
await pilot.press("d")
await pilot.press("c")
await pilot.pause()
assert copied == [str(log_path)]
async def test_update_progress_screen_renders_markup_path_plainly(tmp_path) -> None:
"""Dynamic command and log path text must not be parsed as Rich markup."""
log_path = tmp_path / "[/red]" / "update.log"
screen = UpdateProgressScreen(
latest="2.0.0",
command="echo [/red]",
log_path=log_path,
)
app = App()
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("d")
await pilot.pause()
command = screen.query(Static).filter(".up-details").first()
log = screen.query(Static).filter(".up-log").first()
assert "[/red]" in str(command.render())
assert "[/red]" in str(log.render())
async def test_update_progress_screen_close_waits_until_done(tmp_path) -> None:
"""Esc is ignored while the update is running and closes after completion."""
screen = UpdateProgressScreen(
latest="2.0.0",
command="pip install --upgrade deepagents-cli",
log_path=tmp_path / "update.log",
)
app = App()
async with app.run_test() as pilot:
app.push_screen(screen)
await pilot.pause()
await pilot.press("escape")
await pilot.pause()
assert app.screen is screen
screen.mark_success()
spinner = screen.query(Static).filter(".up-spinner").first()
assert str(spinner.render()) == get_glyphs().checkmark
await pilot.press("escape")
await pilot.pause()
assert app.screen is app.screen_stack[0]