Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).**
Expand Down
45 changes: 19 additions & 26 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -2210,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
Expand All @@ -2230,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"):
Expand Down
28 changes: 10 additions & 18 deletions tests/ui_and_conv/test_native_update_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import os
import time
from types import SimpleNamespace
from typing import cast

Expand Down Expand Up @@ -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,
Expand All @@ -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):
Expand Down
10 changes: 7 additions & 3 deletions tests/ui_and_conv/test_silent_auto_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand All @@ -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
Expand Down
Loading