diff --git a/raven/cli/tui_commands.py b/raven/cli/tui_commands.py index c72d30cb..5cd349a9 100644 --- a/raven/cli/tui_commands.py +++ b/raven/cli/tui_commands.py @@ -990,6 +990,14 @@ def tui( ) raise typer.Exit(code=1) + # Refresh the cached latest-release version in the background (once per + # launch, throttled, best-effort) so the status bar can nudge + # `raven upgrade`. The gateway reads that cache when it builds the session + # info bundle. + from raven.cli.update_notice import maybe_refresh_async + + maybe_refresh_async() + # `--dev` runs tsx from the source tree, so it requires the ui-tui/ checkout. # The production path resolves a packaged or source-built bundle separately # (see resolve_dist_entry), so it must NOT hard-require the source tree — diff --git a/raven/cli/update_notice.py b/raven/cli/update_notice.py new file mode 100644 index 00000000..7f72c6cd --- /dev/null +++ b/raven/cli/update_notice.py @@ -0,0 +1,188 @@ +"""Startup update nudge for the TUI status bar. + +The status bar's right slot shows an "update available" hint in place of the +cwd/branch label when the session init bundle carries ``update_available`` / +``update_command`` (see ``ui-tui/src/components/appChrome.tsx``); this module +is what fills those in. + +The live check hits the GitHub releases API, which is too slow to run on the +session-create hot path, so we keep a small cache in the runtime cache dir and +refresh it in a daemon thread at most once a day. A launch therefore shows the +notice based on the *cached* latest version; the first launch after a release +lands refreshes the cache and the notice appears on the next launch. Any +network or parse failure is swallowed -- an update nudge must never break +startup. + +Set ``RAVEN_NO_UPDATE_CHECK=1`` to opt out of both the fetch and the hint. +""" + +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +_CACHE_NAME = "update_check.json" +_REFRESH_TTL_SECONDS = 24 * 60 * 60 +_UPGRADE_COMMAND = "raven upgrade" +_OPT_OUT_ENV = "RAVEN_NO_UPDATE_CHECK" +_TRUTHY = {"1", "true", "yes", "on"} + + +def _cache_path() -> Path: + # Resolved per call, not at import: get_cache_dir() follows the active + # config path, which set_config_path() can move after this module loads. + from raven.config import paths + + return paths.get_cache_dir() / _CACHE_NAME + + +def _disabled() -> bool: + return os.environ.get(_OPT_OUT_ENV, "").strip().lower() in _TRUTHY + + +def _release_prefix(value: str) -> str: + """Reduce ``0.2.0rc1`` / ``0.1.9.dev1`` to the ``X.Y.Z`` it builds on. + + The strict parser matches the whole string, so a prerelease or dev suffix + would read as unparseable and silence the hint for anyone running one. The + suffix is dropped rather than ordered: an rc of a release compares equal to + it, so an rc user is not nagged to "upgrade" to the version they are + already testing. + """ + raw = value.strip().lstrip("vV") + parts = [] + for part in raw.split(".")[:3]: + digits = "" + for ch in part: + if not ch.isdigit(): + break + digits += ch + if not digits: + return raw + parts.append(digits) + return ".".join(parts) if len(parts) == 3 else raw + + +def _version_key(value: str) -> tuple[int, int, int] | None: + """Parse ``1.2.3`` / ``v1.2.3`` / ``1.2.3rc1``, ``None`` when unparseable. + + ``upgrade_commands._version_key`` is the single source of truth for the + grammar; it raises for anything it cannot read, which here just means + "show no notice". + """ + from raven.cli.upgrade_commands import UpgradeError + from raven.cli.upgrade_commands import _version_key as strict_key + + try: + return strict_key(_release_prefix(value)) + except (UpgradeError, AttributeError): + return None + + +def _read_cache() -> dict | None: + try: + parsed = json.loads(_cache_path().read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + # A hand-edited cache can be valid JSON and still not an object; without + # this guard the .get() below raises and takes `raven tui` down with it. + return parsed if isinstance(parsed, dict) else None + + +def _write_cache(latest_version: str | None, *, now: float) -> None: + payload: dict[str, object] = {"checked_at": now} + if latest_version is not None: + payload["latest_version"] = latest_version + try: + path = _cache_path() + path.write_text(json.dumps(payload), encoding="utf-8") + except OSError: + pass + + +def _refresh() -> None: + # Imported lazily: the GitHub client pulls in httpx, which we keep off the + # session-create hot path (this runs in a daemon thread). + cache = _read_cache() or {} + previous = cache.get("latest_version") + keep = previous if isinstance(previous, str) else None + + try: + from raven.cli.upgrade_commands import _fetch_latest_release + + release = _fetch_latest_release() + _write_cache(release.version, now=time.time()) + except Exception: + # Offline, rate-limited, or the latest release is a draft/prerelease. + # Stamp checked_at anyway so we back off for a full TTL instead of + # refetching on every launch, and keep whatever version we had. + _write_cache(keep, now=time.time()) + + +def maybe_refresh_async() -> None: + """Refresh the cached latest version in the background if it is stale. + + Fire-and-forget: spawns a daemon thread only when the cache is missing or + older than the TTL, so a normal launch touches the network at most once a + day and never blocks. Installs that cannot run ``raven upgrade`` skip the + fetch entirely -- they would never be shown the result. + """ + if _disabled() or not _upgrade_command_works(): + return + + cache = _read_cache() + if cache is not None: + checked_at = cache.get("checked_at") + if isinstance(checked_at, (int, float)) and (time.time() - checked_at) < _REFRESH_TTL_SECONDS: + return + + import threading + + threading.Thread(target=_refresh, daemon=True).start() + + +def _upgrade_command_works() -> bool: + """Whether ``raven upgrade`` can actually do anything on this install. + + It refuses to run for editable and non-uv-tool installs, so nudging those + users points them at a command that always exits 1. + """ + try: + from raven.cli.upgrade_commands import _is_uv_tool_install + + return _is_uv_tool_install() + except Exception: + # A malformed uv receipt raises UpgradeError; treat unknown as "no". + return False + + +def update_notice(current_version: str) -> tuple[bool, str] | None: + """Return ``(available, command)`` when the cached latest release is newer. + + Returns ``None`` when up to date, when the cache is absent or unreadable, + when either version is unparseable, or when ``raven upgrade`` would fail on + this install anyway. + """ + if _disabled(): + return None + + cache = _read_cache() + if not cache: + return None + + latest = cache.get("latest_version") + if not isinstance(latest, str): + return None + + latest_key = _version_key(latest) + current_key = _version_key(current_version) + if latest_key is None or current_key is None or latest_key <= current_key: + return None + + if not _upgrade_command_works(): + return None + + return True, _UPGRADE_COMMAND diff --git a/raven/tui_rpc/methods/session.py b/raven/tui_rpc/methods/session.py index 62a73fe9..9f34135c 100644 --- a/raven/tui_rpc/methods/session.py +++ b/raven/tui_rpc/methods/session.py @@ -31,6 +31,7 @@ from loguru import logger +from raven.cli.update_notice import update_notice from raven.config.loader import load_config from raven.session.export import default_export_path, write_transcript from raven.session.manager import SessionManager, new_chat_id @@ -135,7 +136,7 @@ def _default_session_info( zero usage, ``lazy=True``); version is always real (cached at module load). """ model_id = config.agents.defaults.model - return { + info: dict[str, Any] = { "model": model_id, "model_id": model_id, "provider": config.agents.defaults.provider, @@ -149,6 +150,16 @@ def _default_session_info( "mcp_servers": [], } + # Nudge the status bar to run `raven upgrade` when the cached latest release + # is newer. Reading the cache is pure/fast; the cache is refreshed once per + # launch from the `raven tui` entrypoint (see cli/tui_commands.py), so a + # freshly published release shows up on the next launch. + notice = update_notice(_RAVEN_VERSION) + if notice is not None: + info["update_available"], info["update_command"] = notice + + return info + def _get_or_build_manager(config: "Config") -> SessionManager: """Return a ``SessionManager`` for the configured workspace. diff --git a/tests/conftest.py b/tests/conftest.py index 994463e8..0354414c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -55,6 +55,24 @@ def _restore_loguru_enabled_state(): logger.enable("raven") +@pytest.fixture(autouse=True) +def _no_update_check(tmp_path, monkeypatch): + """Keep the startup update check off the network and off the real disk. + + ``raven tui`` fires ``maybe_refresh_async()`` and ``session.create`` reads + the cache, so any test reaching either path would otherwise fetch the + GitHub releases API and write ``/update_check.json`` under the + real home. Redirecting the cache dir alone still leaves an empty cache, + which is exactly the state that spawns the fetch -- so opt out by env for + the whole suite. Tests that exercise the notice clear the variable. + """ + from raven.cli import update_notice + + monkeypatch.setenv(update_notice._OPT_OUT_ENV, "1") + monkeypatch.setattr(update_notice, "_cache_path", lambda: tmp_path / "update_check.json") + yield + + @pytest.fixture(autouse=True) def _no_openrouter_network(tmp_path): """Keep the OpenRouter catalog fetch off the network and off the real disk. diff --git a/tests/test_cli_update_notice.py b/tests/test_cli_update_notice.py new file mode 100644 index 00000000..7bc7929d --- /dev/null +++ b/tests/test_cli_update_notice.py @@ -0,0 +1,196 @@ +"""Tests for the TUI startup update nudge (raven/cli/update_notice.py).""" + +from __future__ import annotations + +import json +import sys +import time + +import pytest + +from raven.cli import update_notice as un + + +@pytest.fixture +def cache(tmp_path, monkeypatch): + """Point the module at a temp cache and let the notice run. + + The suite-wide autouse fixture opts every test out of the update check; + this is the one place that exercises it, so it clears the flag. + """ + path = tmp_path / "update_check.json" + monkeypatch.delenv(un._OPT_OUT_ENV, raising=False) + monkeypatch.setattr(un, "_cache_path", lambda: path) + monkeypatch.setattr(un, "_upgrade_command_works", lambda: True) + return path + + +def _write(path, latest, *, checked_at=None): + payload = {"latest_version": latest, "checked_at": time.time() if checked_at is None else checked_at} + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_no_cache_yields_no_notice(cache): + assert un.update_notice("0.1.9") is None + + +def test_newer_cached_release_yields_notice(cache): + _write(cache, "0.2.0") + assert un.update_notice("0.1.9") == (True, "raven upgrade") + + +def test_same_version_yields_no_notice(cache): + _write(cache, "0.1.9") + assert un.update_notice("0.1.9") is None + + +def test_older_cached_release_yields_no_notice(cache): + # A local dev build ahead of the latest release must not nag. + _write(cache, "0.1.9") + assert un.update_notice("0.2.0") is None + + +def test_unparseable_versions_are_ignored(cache): + _write(cache, "not-a-version") + assert un.update_notice("0.1.9") is None + _write(cache, "0.2.0") + assert un.update_notice("garbage") is None + + +def test_v_prefix_is_tolerated(cache): + _write(cache, "v0.2.0") + assert un.update_notice("v0.1.9") == (True, "raven upgrade") + + +@pytest.mark.parametrize("installed", ["0.1.9rc1", "0.1.9.dev1", "0.1.9+local"]) +def test_prerelease_installs_still_get_the_notice(cache, installed): + # The strict grammar matches the whole string, so without normalising the + # suffix these users would silently never see the hint. + _write(cache, "0.2.0") + assert un.update_notice(installed) == (True, "raven upgrade") + + +def test_prerelease_of_the_latest_release_is_not_nagged(cache): + # 0.2.0rc1 compares equal to 0.2.0: the suffix is dropped, not ordered. + _write(cache, "0.2.0") + assert un.update_notice("0.2.0rc1") is None + + +def test_refresh_skipped_when_upgrade_command_would_fail(cache, monkeypatch): + # An install that cannot run `raven upgrade` never sees the result, so it + # should not pay for the fetch either. + monkeypatch.setattr(un, "_upgrade_command_works", lambda: False) + spawned = [] + monkeypatch.setattr("threading.Thread", lambda *a, **k: spawned.append(k) or _FakeThread()) + un.maybe_refresh_async() + assert spawned == [] + + +def test_corrupt_cache_is_ignored(cache): + cache.write_text("{not json", encoding="utf-8") + assert un.update_notice("0.1.9") is None + + +@pytest.mark.parametrize("payload", ['"0.2.0"', "[1]", "42", "null"]) +def test_non_object_cache_is_ignored(cache, monkeypatch, payload): + # Valid JSON that is not an object used to raise AttributeError out of + # update_notice(), which took `raven tui` and session.create down with it. + # + # A non-object reads back as "no cache", so the refresh call below would + # spawn a real thread whose write lands after monkeypatch has restored + # _cache_path -- i.e. on the real home. The assertion only needs "does not + # raise", so the thread is stubbed. + monkeypatch.setattr("threading.Thread", lambda *a, **k: _FakeThread()) + cache.write_text(payload, encoding="utf-8") + assert un.update_notice("0.1.9") is None + un.maybe_refresh_async() + + +def test_no_notice_when_upgrade_command_would_fail(cache, monkeypatch): + _write(cache, "0.2.0") + monkeypatch.setattr(un, "_upgrade_command_works", lambda: False) + assert un.update_notice("0.1.9") is None + + +def test_opt_out_env_silences_notice_and_fetch(cache, monkeypatch): + _write(cache, "0.2.0") + monkeypatch.setenv(un._OPT_OUT_ENV, "1") + assert un.update_notice("0.1.9") is None + + spawned = [] + monkeypatch.setattr("threading.Thread", lambda *a, **k: spawned.append(k) or _FakeThread()) + un.maybe_refresh_async() + assert spawned == [] + + +def test_refresh_skipped_when_cache_is_fresh(cache, monkeypatch): + _write(cache, "0.1.9", checked_at=time.time()) + spawned = [] + monkeypatch.setattr("threading.Thread", lambda *a, **k: spawned.append((a, k)) or _FakeThread()) + un.maybe_refresh_async() + assert spawned == [] + + +def test_refresh_spawned_when_cache_is_stale(cache, monkeypatch): + _write(cache, "0.1.9", checked_at=time.time() - un._REFRESH_TTL_SECONDS - 1) + spawned = [] + monkeypatch.setattr("threading.Thread", lambda *a, **k: spawned.append(k) or _FakeThread()) + un.maybe_refresh_async() + assert len(spawned) == 1 + assert spawned[0]["daemon"] is True + + +def test_refresh_spawned_when_cache_absent(cache, monkeypatch): + spawned = [] + monkeypatch.setattr("threading.Thread", lambda *a, **k: spawned.append(k) or _FakeThread()) + un.maybe_refresh_async() + assert len(spawned) == 1 + + +def test_failed_refresh_still_backs_off_and_keeps_version(cache, monkeypatch): + # Offline / rate-limited / draft-release: stamping checked_at is what stops + # every launch from refetching, and the known version must survive. + _write(cache, "0.2.0", checked_at=0.0) + + def _boom(): + raise RuntimeError("offline") + + monkeypatch.setitem(sys.modules, "raven.cli.upgrade_commands", _FakeUpgrade(_boom)) + un._refresh() + + saved = json.loads(cache.read_text(encoding="utf-8")) + assert saved["latest_version"] == "0.2.0" + assert time.time() - saved["checked_at"] < 60 + + +def test_successful_refresh_records_fetched_version(cache, monkeypatch): + monkeypatch.setitem(sys.modules, "raven.cli.upgrade_commands", _FakeUpgrade(lambda: _Release("0.3.0"))) + un._refresh() + + saved = json.loads(cache.read_text(encoding="utf-8")) + assert saved["latest_version"] == "0.3.0" + + +class _FakeThread: + def start(self): # noqa: D102 - test double + pass + + +class _Release: + def __init__(self, version: str) -> None: + self.version = version + + +class _FakeUpgrade: + """Stands in for raven.cli.upgrade_commands during _refresh().""" + + UpgradeError = RuntimeError + + def __init__(self, fetch) -> None: + self._fetch = fetch + + def _fetch_latest_release(self): # noqa: D102 - test double + return self._fetch() + + def _version_key(self, value): # noqa: D102 - test double + raise RuntimeError(value) diff --git a/ui-tui/src/__tests__/branding.test.tsx b/ui-tui/src/__tests__/branding.test.tsx index 7a259ebd..3f0919dd 100644 --- a/ui-tui/src/__tests__/branding.test.tsx +++ b/ui-tui/src/__tests__/branding.test.tsx @@ -26,16 +26,17 @@ describe('Branding', () => { }) describe('SessionPanel', () => { - it('recommends the real raven upgrade command', () => { + it('does not render the update nudge in the banner (it lives in the status bar)', () => { const info: SessionInfo = { model: 'anthropic/claude-sonnet-4-6', skills: {}, tools: {}, - update_behind: 1 + update_available: true, + update_command: 'raven upgrade' } const { lastFrame } = render() - expect(lastFrame()).toContain('raven upgrade') - expect(lastFrame()).not.toContain('raven update') + expect(lastFrame()).not.toContain('Update available') + expect(lastFrame()).not.toContain('upgrade') }) }) diff --git a/ui-tui/src/__tests__/statusRule.test.tsx b/ui-tui/src/__tests__/statusRule.test.tsx new file mode 100644 index 00000000..bf6ad2ee --- /dev/null +++ b/ui-tui/src/__tests__/statusRule.test.tsx @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 EverMind. +// See NOTICES.md. + +import { render } from 'ink-testing-library' +import React from 'react' +import { describe, expect, it } from 'vitest' + +import type { Usage } from '../types.js' + +import { StatusRule } from '../components/appChrome.js' +import { stripAnsi } from '../lib/text.js' +import { DEFAULT_THEME } from '../theme.js' + +const USAGE: Usage = { calls: 0, input: 0, output: 0, total: 0 } + +const base = { + bgCount: 0, + busy: false, + cols: 100, + cwdLabel: '~/proj (main)', + model: 'minimax/m3', + showCost: false, + status: 'ready', + statusColor: DEFAULT_THEME.color.accent, + t: DEFAULT_THEME, + usage: USAGE +} + +const frameOf = (extra: Record) => + stripAnsi(render().lastFrame() ?? '') + +describe('StatusRule update nudge', () => { + it('shows the cwd/branch label when no update is available', () => { + const frame = frameOf({}) + expect(frame).toContain('~/proj (main)') + expect(frame).not.toContain('Update available') + }) + + it('replaces the cwd/branch label with the upgrade nudge when an update is available', () => { + const frame = frameOf({ updateAvailable: true, updateCommand: 'raven upgrade' }) + expect(frame).toContain('Update available') + expect(frame).toContain('raven upgrade') + expect(frame).not.toContain('~/proj (main)') + }) +}) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index bba1ad4b..eb7d5281 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -295,6 +295,8 @@ export function StatusRule({ sessionStartedAt, showCost, turnStartedAt, + updateAvailable, + updateCommand, t }: StatusRuleProps) { const pct = usage.context_percent @@ -307,7 +309,10 @@ export function StatusRule({ : '' const bar = usage.context_max ? ctxBar(pct) : '' - const leftWidth = Math.max(12, cols - cwdLabel.length - 3) + // When an update is available, the bottom-right slot shows the upgrade nudge + // in place of the cwd/branch label (dynamic, no extra line). + const rightLabel = updateAvailable ? `↑ Update available — run ${updateCommand || 'raven upgrade'}` : cwdLabel + const leftWidth = Math.max(12, cols - rightLabel.length - 3) return ( @@ -354,7 +359,7 @@ export function StatusRule({ - {cwdLabel} + {rightLabel} ) } @@ -471,6 +476,8 @@ interface StatusRuleProps { statusColor: string t: Theme turnStartedAt?: null | number + updateAvailable?: boolean + updateCommand?: string usage: Usage } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 1323f4fd..11902667 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -391,6 +391,8 @@ const StatusRulePane = memo(function StatusRulePane({ cols={composer.cols} cwdLabel={status.cwdLabel} model={ui.info?.model ?? ''} + updateAvailable={Boolean(ui.info?.update_available)} + updateCommand={ui.info?.update_command || 'raven upgrade'} modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'} modelReasoningEffort={ui.info?.reasoning_effort} sessionStartedAt={status.sessionStartedAt} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index ba42ef53..7b443aa6 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -436,30 +436,6 @@ export function SessionPanel({ info, maxCols, sid, t }: SessionPanelProps) { {footerMeta} - - {typeof info.update_behind === 'number' && info.update_behind > 0 && ( - <> - - - {'─'.repeat(Math.max(1, w))} - - - - ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind - - {' '} - - run{' '} - - - {info.update_command || 'raven upgrade'} - - - {' '} - to update - - - - )} diff --git a/ui-tui/src/demo/gallery.tsx b/ui-tui/src/demo/gallery.tsx index 2f16b3d7..0a11636e 100644 --- a/ui-tui/src/demo/gallery.tsx +++ b/ui-tui/src/demo/gallery.tsx @@ -99,8 +99,6 @@ const sessionInfo: SessionInfo = { core_tools: ['read', 'write', 'edit', 'bash'], search_tools: ['grep', 'glob'] }, - update_behind: 2, - update_command: 'raven upgrade', version: '0.0.1' } @@ -191,6 +189,24 @@ function AppChromePage() { usage={usage} /> + + + Floating panel content diff --git a/ui-tui/src/lib/stubGatewayFixtures.ts b/ui-tui/src/lib/stubGatewayFixtures.ts index a531ff10..3fd54a16 100644 --- a/ui-tui/src/lib/stubGatewayFixtures.ts +++ b/ui-tui/src/lib/stubGatewayFixtures.ts @@ -28,8 +28,7 @@ export const STUB_SESSION_INFO: SessionInfo = { model: 'claude-sonnet-4-6', skills: {}, tools: {}, - // Raven Agent fork: independent version line, "X commits behind" semantic n/a. - update_behind: null + update_available: null } export const STUB_SESSION_LIST_ITEM: SessionListItem = { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 23228ccf..1b626896 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -163,7 +163,7 @@ export interface SessionInfo { skills: Record system_prompt?: string tools: Record - update_behind?: number | null + update_available?: boolean | null update_command?: string usage?: Usage version?: string