From 218f0aab025cb310039bc9e055dc53c7e88ac22f Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Wed, 17 Jun 2026 18:56:55 -0400 Subject: [PATCH 1/3] fix(tui): stop duplicate update notice at startup The UPDATED/UPDATE_AVAILABLE startup paths fired both a footer toast and the persistent under-input line with the same text, so the restart hint rendered twice. Drop the toast and refresh the persistent notice line (bust its memo + invalidate the prompt) so a single line owns the hint. --- CHANGELOG.md | 4 +++ src/pythinker_code/ui/shell/__init__.py | 33 ++++++++------------ tests/ui_and_conv/test_silent_auto_update.py | 10 ++++-- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f89b453d..a83f7a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Fix: duplicate update notices at startup.** When a background install finishes + or a cached update is detected, the hint now renders only on the persistent + under-input line instead of also flashing as a footer toast. + ## 0.48.0 (2026-06-17) - **Fix: tool outputs invisible on Anthropic-compatible proxies (GLM-5.2 via z.ai).** diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 3a202a20..c205406c 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2095,22 +2095,10 @@ def _pop_next_pending_approval_request(self) -> ApprovalRequest | None: async def _auto_update(self) -> None: # Background-refresh the cached latest version (throttled); never blocks startup. await refresh_update_cache_if_due() - # Non-blocking shell notice based on the cached value. - notice = pending_update_notice() - if notice: - # Make version notices easy to see on macOS/Linux terminals too: - # put them at the front of the toast queue, keep them around long - # enough to survive startup redraws, and force a repaint if the - # prompt is already active. - toast( - notice, - topic="update", - duration=30.0, - immediate=True, - style="fg:ansibrightyellow bold", - ) - if self._prompt_session is not None: - self._prompt_session.invalidate() + # The persistent under-input line renders the cached update hint; refresh + # it when the cache changes instead of duplicating the text as a toast. + if pending_update_notice(): + self._refresh_update_notice_line() async def _silent_auto_update(self) -> None: """Install a newer release silently in the background at startup.""" @@ -2147,10 +2135,15 @@ def _surface_installed_update_notice(self) -> None: style="fg:ansiyellow", ) return - self._update_toast( - self._installed_update_restart_notice(), - style="fg:ansibrightyellow bold", - ) + # The persistent under-input line (_prepend_update_notice) already renders + # the restart message; a toast duplicates it on the footer's second row. + self._refresh_update_notice_line() + + def _refresh_update_notice_line(self) -> None: + """Drop the update-notice memo and repaint so the footer picks up new text.""" + self._update_notice_cache = (0.0, None) + if self._prompt_session is not None: + self._prompt_session.invalidate() def _installed_update_smoke_check_failed(self) -> bool: status = read_update_status() diff --git a/tests/ui_and_conv/test_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index d6c6ac79..1fb88079 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -37,10 +37,13 @@ def _toasts(monkeypatch): @pytest.mark.asyncio -async def test_silent_update_success_toasts_restart( +async def test_silent_update_success_refreshes_persistent_notice_not_toast( runtime: Runtime, tmp_path: Path, monkeypatch, _toasts ): shell = _make_shell(runtime, tmp_path) + invalidated: list[bool] = [] + shell._prompt_session = SimpleNamespace(invalidate=lambda: invalidated.append(True)) # type: ignore[assignment] + shell._update_notice_cache = (time.monotonic(), "stale") monkeypatch.setattr(shell_module, "_should_auto_check_for_updates", lambda: True) monkeypatch.setattr(shell_module, "_mark_auto_update_check_attempt", lambda: None) monkeypatch.setattr(shell_module, "_detect_upgrade_command", lambda: ["pip"]) @@ -59,8 +62,9 @@ async def fake_job(**kw): await shell._silent_auto_update() - assert any("Restart Pythinker to apply" in m for m, _ in _toasts) - assert any("0.43.0" in m for m, _ in _toasts) + assert _toasts == [] + assert invalidated == [True] + assert shell._update_notice_cache == (0.0, None) @pytest.mark.asyncio From 77499e6abd502824ec67f234bf0435e727b2dd84 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Wed, 17 Jun 2026 18:57:34 -0400 Subject: [PATCH 2/3] test(tui): update _auto_update parity test for no-toast path --- .../ui_and_conv/test_native_update_parity.py | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/tests/ui_and_conv/test_native_update_parity.py b/tests/ui_and_conv/test_native_update_parity.py index 1e4ddebc..d8b69641 100644 --- a/tests/ui_and_conv/test_native_update_parity.py +++ b/tests/ui_and_conv/test_native_update_parity.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import time from types import SimpleNamespace from typing import cast @@ -86,25 +87,25 @@ def test_windows_source_checkout_keeps_python_upgrade_command(monkeypatch): ] -async def test_shell_auto_update_toast_shows_new_version_immediately(monkeypatch): +async def test_shell_auto_update_refreshes_persistent_notice(monkeypatch): import pythinker_code.ui.shell as shell_mod async def fake_refresh(): return update.UpdateResult.UPDATE_AVAILABLE - toast_calls: list[tuple[str, dict[str, object]]] = [] invalidated: list[bool] = [] - def fake_toast(message: str, **kwargs): - toast_calls.append((message, kwargs)) - shell = shell_mod.Shell.__new__(shell_mod.Shell) - # SimpleNamespace stand-in for the CustomPromptSession; only invalidate() - # is exercised by _auto_update(). + shell._update_notice_cache = (time.monotonic(), "stale") # type: ignore[attr-defined] shell._prompt_session = SimpleNamespace( # type: ignore[assignment] invalidate=lambda: invalidated.append(True) ) + toast_calls: list[tuple[str, dict[str, object]]] = [] + + def fake_toast(message: str, **kwargs): + toast_calls.append((message, kwargs)) + monkeypatch.setattr(shell_mod, "refresh_update_cache_if_due", fake_refresh) monkeypatch.setattr( shell_mod, @@ -115,18 +116,9 @@ def fake_toast(message: str, **kwargs): await shell_mod.Shell._auto_update(shell) - assert toast_calls == [ - ( - "Update available: 0.19.0 → 0.21.0. Run /update to install.", - { - "topic": "update", - "duration": 30.0, - "immediate": True, - "style": "fg:ansibrightyellow bold", - }, - ) - ] + assert toast_calls == [] assert invalidated == [True] + assert shell._update_notice_cache == (0.0, None) # type: ignore[attr-defined] def test_windows_installer_launches_signed_inno_directly(monkeypatch, tmp_path): From 7876b35096797130e6ab233e27235b596efda242 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Wed, 17 Jun 2026 19:11:04 -0400 Subject: [PATCH 3/3] docs(tui): align update-notice comments with no-toast behavior --- src/pythinker_code/ui/shell/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index c205406c..4c648235 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2203,8 +2203,8 @@ def _compute_update_notice(self) -> str | None: 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. + # surface that here instead of telling the user to re-run an update that + # has already landed. status = read_update_status() installed = ( status is not None @@ -2223,12 +2223,12 @@ def _schedule_startup_update_task(self) -> None: """Pick the startup update behavior and schedule it (non-blocking). - env kill-switch set → nothing (cache filters already suppress the - toast, matching today's hard-disable behavior). + notice, matching today's hard-disable behavior). - enabled → silent background install. - - config-disabled OR source checkout → informational toast only - (`_auto_update`); self-suppresses for source checkouts because + - config-disabled OR source checkout → refresh the persistent notice + only (`_auto_update`); self-suppresses for source checkouts because `pending_update_notice()` returns None in that path. - - non-PythinkerSoul → same toast-only path (no runtime config to + - non-PythinkerSoul → same notice-refresh path (no runtime config to consult), matching the prior unconditional `_auto_update` behavior. """ if get_env_bool("PYTHINKER_CLI_NO_AUTO_UPDATE"):