diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d5d48c4..474ce9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Startup auto-update now picks up new releases within half an hour instead of up to a day.** The background update check was throttled to once every 24h, so a freshly published release could go unnoticed for a full day after the last check; the interval is now 30 minutes. The silent installer also no longer marks the throttle *before* the network call — a transient startup network error returns `FAILED` and is retried on the next launch instead of suppressing updates for the whole window. +- **The welcome banner now keeps the robot mark visible in compact terminals.** Below ~68 columns the robot was dropped (it could not sit beside the welcome copy); it now stacks centered above the copy instead, so the mark stays on screen at any width that can render its Unicode glyphs. ASCII-only terminals are unaffected. +- **A persistent update notice now sits directly under the prompt input.** When a newer release is available the footer shows a yellow `↑ Update available — vX · /update` line; once a release has been installed in the background it switches to a restart-to-apply message instead of pointing at `/update`, and it clears after you restart onto the new version. The line is suppressed for dismissed versions, disabled auto-update, and source checkouts. + ## 0.45.0 (2026-06-14) - **Agent-tracing dashboard.** Added `pythinker dashboard` — a local web UI for inspecting sessions, wire events, context messages, tool statistics, and usage over time. It is also reachable from the interactive shell via the `/reports` slash command (aliased `/dashboard`). diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 85d0d46b..aedbdee0 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -81,6 +81,7 @@ ) from pythinker_code.ui.shell.update_orchestrator import ( SMOKE_CHECK_FAILED_PREFIX, + UpdateJobState, read_update_status, run_update_job, ) @@ -565,6 +566,10 @@ def __init__( self._prefill_text = prefill_text self._background_tasks: set[asyncio.Task[Any]] = set() self._prompt_session: CustomPromptSession | None = None + # (timestamp, text) memo for the under-input update line; refreshed on a + # short TTL so the hot toolbar render path does not stat the update + # cache on every repaint (mirrors the footer's git-branch TTL). + self._update_notice_cache: tuple[float, str | None] = (0.0, None) self._running_input_handler: Callable[[UserInput], None] | None = None self._running_interrupt_handler: Callable[[], None] | None = None self._active_approval_sink: Any | None = None @@ -916,6 +921,7 @@ def _bg_task_counts() -> BgTaskCounts: status_block_provider=_mcp_status_block, fast_refresh_provider=_mcp_status_loading, background_task_count_provider=_bg_task_counts, + update_notice_provider=self._update_notice_text, model_capabilities=self.soul.model_capabilities or set(), model_name=model_display_name( self.soul.model_name, @@ -2110,9 +2116,13 @@ async def _silent_auto_update(self) -> None: """Install a newer release silently in the background at startup.""" if not _should_auto_check_for_updates(): return - _mark_auto_update_check_attempt() result = await self._run_silent_update_job() + # Throttle only after a completed round-trip. Marking before the network + # call (or after a FAILED one) would suppress updates for the whole + # interval on a transient startup blip — mirrors _refresh_update_cache. + if result is not None and result is not UpdateResult.FAILED: + _mark_auto_update_check_attempt() if result is UpdateResult.UPDATED: self._surface_installed_update_notice() elif result is UpdateResult.UPDATE_AVAILABLE: @@ -2180,6 +2190,42 @@ def _update_toast(self, notice: str, *, style: str) -> None: if self._prompt_session is not None: self._prompt_session.invalidate() + def _update_notice_text(self) -> str | None: + """Persistent under-input update line, or None when up to date. + + Memoized on a short TTL (5s) so the hot toolbar render path doesn't stat + the update cache on every repaint; the background updater invalidates the + prompt when it changes the cache, so the line still appears promptly. + """ + now = time.monotonic() + cached_at, cached = self._update_notice_cache + if now - cached_at < 5.0: + return cached + text = self._compute_update_notice() + self._update_notice_cache = (now, text) + return text + + def _compute_update_notice(self) -> str | None: + target = welcome_update_target() + if not target: + return None + # A release already installed this session needs a restart, not /update — + # keep this line in agreement with the install toast instead of telling + # the user to re-run an update that has already landed. + status = read_update_status() + installed = ( + status is not None + and status.state is UpdateJobState.UPDATED + and status.target_version == target + ) + if installed and not self._installed_update_smoke_check_failed(): + text = self._installed_update_restart_notice() + else: + text = f"↑ Update available — v{target} · /update" + if ascii_glyphs_enabled(): + text = text.translate(_WELCOME_ASCII_FALLBACKS) + return text + def _schedule_startup_update_task(self) -> None: """Pick the startup update behavior and schedule it (non-blocking). @@ -2559,7 +2605,11 @@ def _tips_block(width: int, *, with_rule: bool) -> Group: show_logo = not ascii_mode use_columns = bool(tips) and content_width >= _WELCOME_COLUMNS_MIN_WIDTH - logo_rendered = show_logo and (use_columns or content_width >= 68) + # The robot sits beside the copy when there's room (~68 cells); in a compact + # terminal it stacks centered above the copy instead of being dropped, so the + # mark stays visible at any width that can render its Unicode glyphs. + logo_beside_copy = show_logo and not use_columns and content_width >= 68 + logo_rendered = show_logo # columns-centered, beside, or stacked — only ASCII hides it version_title = Text.assemble( ("Pythinker Code", tui_rich_style("muted")), @@ -2608,7 +2658,7 @@ def _panel() -> Panel: columns.add_row(Group(*left_rows), _tips_block(tips_width, with_rule=True)) rows.append(columns) else: - if logo_rendered: + if logo_beside_copy: # Logo on the left; the text block centers vertically against # the robot so the lines sit beside the face while the antenna # floats above. @@ -2617,6 +2667,10 @@ def _panel() -> Panel: table.add_column(justify="left", vertical="middle", no_wrap=True) table.add_row(_logo_text(), Group(head, strapline, help_text)) rows.append(table) + elif show_logo: + # Compact terminal: too narrow to seat the robot beside the + # copy, so stack it centered above instead of dropping it. + rows.extend([Align.center(_logo_text()), Text(""), head, strapline, help_text]) else: rows.extend([head, strapline, help_text]) diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 5494dd74..2c59fbd0 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -2126,6 +2126,7 @@ def __init__( status_block_provider: Callable[[int], AnyFormattedText | None] | None = None, fast_refresh_provider: Callable[[], bool] | None = None, background_task_count_provider: Callable[[], BgTaskCounts] | None = None, + update_notice_provider: Callable[[], str | None] | None = None, model_capabilities: set[ModelCapability], model_name: str | None, thinking: bool, @@ -2173,6 +2174,7 @@ def __init__( self._status_block_provider = status_block_provider self._fast_refresh_provider = fast_refresh_provider self._background_task_count_provider = background_task_count_provider + self._update_notice_provider = update_notice_provider self._editor_command_provider = editor_command_provider self._turn_recaps_provider = turn_recaps_provider self._plan_mode_toggle_callback = plan_mode_toggle_callback @@ -3711,6 +3713,23 @@ def _append_history_entry(self, text: str) -> None: error=exc, ) + def _prepend_update_notice(self, fragments: list[tuple[str, str]], columns: int) -> None: + """Prepend a persistent yellow 'update available' line above the footer + separator, so it renders directly under the prompt input. No-op when no + update is pending; style-agnostic across both toolbar layouts.""" + provider = getattr(self, "_update_notice_provider", None) + if provider is None: + return + text = provider() + if not text: + return + line = _truncate_right(text, max(0, columns - 1)) + if not line: + return + tokens = _get_tui_tokens() + style = f"fg:{tokens.warning or 'ansiyellow'} bold" + fragments[:0] = [(style, line), ("", "\n")] + def _render_bottom_toolbar(self) -> FormattedText: if ( hasattr(self, "_session") @@ -3733,6 +3752,7 @@ def _render_bottom_toolbar(self) -> FormattedText: fragments: list[tuple[str, str]] = [] tc = get_toolbar_colors() + self._prepend_update_notice(fragments, columns) fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) @@ -3963,6 +3983,7 @@ def _render_card_bottom_toolbar(self, columns: int) -> FormattedText: secondary_style = f"fg:{tokens.muted}" fragments: list[tuple[str, str]] = [] + self._prepend_update_notice(fragments, columns) fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index ef56136f..f12fc221 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -64,7 +64,13 @@ LAST_UPDATE_CHECK_FILE = get_share_dir() / "last_update_check.txt" DISMISSED_VERSION_FILE = get_share_dir() / "dismissed_update_version.txt" LAST_SEEN_VERSION_FILE = get_share_dir() / "last_seen_version.txt" -AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 24 * 60 * 60 +# ponytail: 30m throttle so a freshly-pushed release is picked up on the next +# restart instead of up to a day later. The check is unauthenticated, so each +# poll costs one of GitHub's 60 req/hr/IP budget regardless of the cached ETag +# (304s are only rate-limit-free when authenticated) — at ~2 polls/hr/startup +# that is a wide margin. A throttle miss fails safe: a transient error returns +# FAILED, which skips the mark and retries next launch. +AUTO_UPDATE_CHECK_INTERVAL_SECONDS = 30 * 60 PROMPT_UPDATE_REFRESH_TIMEOUT_SECONDS = 2.0 WINDOWS_UPDATE_STAGING_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 UPGRADE_COMMAND_TIMEOUT_SECONDS = 30 * 60 diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 130f2681..14a19ca4 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -1703,3 +1703,30 @@ async def prompt_async(self, **kwargs): assert result.command == "follow-up" assert prompt_session.last_submission_was_running is False + + +def test_prepend_update_notice_inserts_line_above_separator(): + fragments: list[tuple[str, str]] = [("sep", "────"), ("", "\n")] + fake = SimpleNamespace(_update_notice_provider=lambda: "↑ Update available — v9.9.9 · /update") + CustomPromptSession._prepend_update_notice(cast(Any, fake), fragments, 80) + # Notice is the first row (its own line), then a newline, then the original + # separator — i.e. it renders directly under the input, above the footer rule. + assert "Update available" in fragments[0][1] + assert "v9.9.9" in fragments[0][1] + assert "bold" in fragments[0][0] + assert fragments[1] == ("", "\n") + assert fragments[2] == ("sep", "────") + + +def test_prepend_update_notice_noop_when_no_update(): + fragments: list[tuple[str, str]] = [("sep", "────")] + fake = SimpleNamespace(_update_notice_provider=lambda: None) + CustomPromptSession._prepend_update_notice(cast(Any, fake), fragments, 80) + assert fragments == [("sep", "────")] + + +def test_prepend_update_notice_noop_when_no_provider(): + fragments: list[tuple[str, str]] = [("sep", "x")] + fake = SimpleNamespace(_update_notice_provider=None) + CustomPromptSession._prepend_update_notice(cast(Any, fake), fragments, 80) + assert fragments == [("sep", "x")] diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 63577fac..51bdc7ab 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -123,6 +123,9 @@ def test_welcome_banner_layout_width_matrix(monkeypatch): from pythinker_code.ui.shell.components.render_utils import cell_width monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + # Pin Unicode glyph rendering so the robot-mark assertion below is + # deterministic regardless of the ambient locale/stdout encoding. + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) items = [ WelcomeInfoItem(name="Directory", value="/home/ai/Projects/pythinker-code-main"), WelcomeInfoItem(name="Model", value="gpt-5.1-codex"), @@ -155,10 +158,9 @@ def test_welcome_banner_layout_width_matrix(monkeypatch): assert "Tips" in output assert "/update" in lines[-1] assert "/help" in output - if width == 60: - assert "▛" not in output - else: - assert "▛" in output + # The robot mark renders at every width, including the compact 60-col + # layout where it stacks above the copy instead of being dropped. + assert "▛" in output def test_welcome_two_column_layout_when_wide(monkeypatch): @@ -189,6 +191,29 @@ def test_welcome_two_column_layout_when_wide(monkeypatch): assert "/tmp/proj" in dir_line +def test_welcome_compact_terminal_stacks_robot_above_copy(monkeypatch): + """In a compact terminal the robot is prioritized: it stacks centered above + the welcome copy instead of being dropped (the pre-fix behavior).""" + from pythinker_code.ui.shell import WelcomeInfoItem + + console = Console(record=True, width=58, color_system=None) + monkeypatch.setattr(shell_module, "console", console) + monkeypatch.setattr(shell_module, "get_version", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) + + shell_module._print_welcome_info( + "Pythinker Code", + [WelcomeInfoItem(name="Tip", value="Type /help for commands.")], + ) + + lines = [ln for ln in console.export_text().splitlines() if ln.strip()] + robot_line = next(i for i, ln in enumerate(lines) if "▛" in ln) + welcome_line = next(i for i, ln in enumerate(lines) if "Welcome to Pythinker" in ln) + # Robot is shown (not dropped) and sits on its own line above the copy. + assert robot_line < welcome_line + assert "Welcome to Pythinker" not in lines[robot_line] + + def test_welcome_ascii_mode_emits_pure_ascii(monkeypatch): from pythinker_code.ui.shell import WelcomeInfoItem diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index c697805b..d6c6ac79 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time from pathlib import Path from types import SimpleNamespace from unittest.mock import MagicMock @@ -107,6 +108,46 @@ async def fake_job(**kw): assert _toasts == [] +@pytest.mark.parametrize( + ("result", "expected_marks"), + [ + (UpdateResult.FAILED, 0), + (UpdateResult.UP_TO_DATE, 1), + (UpdateResult.UPDATED, 1), + ], +) +@pytest.mark.asyncio +async def test_silent_update_marks_throttle_only_after_non_failed_run( + runtime: Runtime, tmp_path: Path, monkeypatch, _toasts, result, expected_marks +): + """A FAILED run (e.g. a transient startup network blip) must not burn the + throttle window: the mark fires only after a completed, non-FAILED job.""" + shell = _make_shell(runtime, tmp_path) + marks = 0 + + def _spy_mark() -> None: + nonlocal marks + marks += 1 + + monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: True) + monkeypatch.setattr(shell_module, "_mark_auto_update_check_attempt", _spy_mark) + monkeypatch.setattr(shell_module, "_detect_upgrade_command", lambda: ["pip"]) + monkeypatch.setattr( + shell_module, + "read_update_status", + lambda: SimpleNamespace(message="updated", target_version="0.43.0"), + ) + + async def fake_job(**kw): + return result + + monkeypatch.setattr(shell_module, "run_update_job", fake_job) + + await shell._silent_auto_update() + + assert marks == expected_marks + + @pytest.mark.asyncio async def test_silent_update_managed_channel_toasts_channel_hint( runtime: Runtime, tmp_path: Path, monkeypatch, _toasts @@ -270,3 +311,57 @@ def test_auto_update_override_reason_none_when_config_decides(monkeypatch): monkeypatch.setattr(update_policy, "auto_update_disabled", lambda: False) monkeypatch.setattr(update_policy, "is_running_from_source_checkout", lambda: False) assert update_policy.auto_update_override_reason() is None + + +def _updated_status(target: str, *, message: str = "updated"): + from pythinker_code.ui.shell.update_orchestrator import UpdateJobState + + return SimpleNamespace(state=UpdateJobState.UPDATED, target_version=target, message=message) + + +def test_update_notice_available_points_to_slash_update(runtime, tmp_path, monkeypatch): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "welcome_update_target", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "read_update_status", lambda: None) + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) + assert shell._compute_update_notice() == "↑ Update available — v9.9.9 · /update" + + +def test_update_notice_installed_this_session_says_restart(runtime, tmp_path, monkeypatch): + """Critical: after a silent install the cache still reports a newer version, + but the line must say restart-to-apply, not /update (it would contradict the + install toast and tell the user to re-run an update that already landed).""" + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "welcome_update_target", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) + monkeypatch.setattr(shell_module, "read_update_status", lambda: _updated_status("9.9.9")) + text = shell._compute_update_notice() + assert text is not None and "Restart" in text and "9.9.9" in text + assert "/update" not in text + + +def test_update_notice_installed_but_smoke_failed_falls_back(runtime, tmp_path, monkeypatch): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "welcome_update_target", lambda: "9.9.9") + monkeypatch.setattr(shell_module, "ascii_glyphs_enabled", lambda: False) + status = _updated_status("9.9.9", message=shell_module.SMOKE_CHECK_FAILED_PREFIX + "boom") + monkeypatch.setattr(shell_module, "read_update_status", lambda: status) + # A failed-verification install must not claim restart-to-apply. + assert shell._compute_update_notice() == "↑ Update available — v9.9.9 · /update" + + +def test_update_notice_none_when_up_to_date(runtime, tmp_path, monkeypatch): + shell = _make_shell(runtime, tmp_path) + monkeypatch.setattr(shell_module, "welcome_update_target", lambda: None) + assert shell._compute_update_notice() is None + + +def test_update_notice_text_uses_ttl_cache(runtime, tmp_path, monkeypatch): + shell = _make_shell(runtime, tmp_path) + shell._update_notice_cache = (time.monotonic(), "cached") + + def _boom() -> str: + raise AssertionError("welcome_update_target should not be called within TTL") + + monkeypatch.setattr(shell_module, "welcome_update_target", _boom) + assert shell._update_notice_text() == "cached"