From 6486ddce72cc9f876958428d2a7ac641797045f1 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sun, 14 Jun 2026 20:45:38 -0400 Subject: [PATCH 1/4] fix(update): shorten auto-update check interval to 30m and harden throttle Background startup auto-update was throttled to once per 24h, so a freshly published release could go unnoticed for up to a day after the last check. Lower the interval to 30 minutes. Also stop marking the throttle before the network call in the silent path: a transient startup error now returns FAILED and is retried on the next launch instead of suppressing updates for the whole window (mirrors the notice-only refresh path). Add a focused test pinning that the throttle is marked only after a non-FAILED run. --- CHANGELOG.md | 2 + src/pythinker_code/ui/shell/__init__.py | 6 ++- src/pythinker_code/ui/shell/update.py | 8 +++- tests/ui_and_conv/test_silent_auto_update.py | 40 ++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d5d48c4..86833dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ 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. + ## 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..3172dcbb 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2110,9 +2110,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: 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_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index c697805b..9679d4fd 100644 --- a/tests/ui_and_conv/test_silent_auto_update.py +++ b/tests/ui_and_conv/test_silent_auto_update.py @@ -107,6 +107,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 From 891847a2ac001b3754dcc0d12ad24fd943d89f73 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sun, 14 Jun 2026 20:50:21 -0400 Subject: [PATCH 2/4] fix(shell): keep the welcome robot visible in compact terminals Below ~68 content columns the robot mark could not sit beside the welcome copy, so it was dropped entirely. Stack it centered above the copy instead, so the mark stays visible at any width that can render its Unicode glyphs. ASCII-only terminals are unchanged (no robot). The boot antenna blink now fires on the stacked logo too, still gated by the existing on-screen height guard. --- CHANGELOG.md | 1 + src/pythinker_code/ui/shell/__init__.py | 12 ++++++-- tests/ui_and_conv/test_shell_welcome_info.py | 30 +++++++++++++++++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86833dcc..31f6dd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ 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. ## 0.45.0 (2026-06-14) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 3172dcbb..6cee3382 100644 --- a/src/pythinker_code/ui/shell/__init__.py +++ b/src/pythinker_code/ui/shell/__init__.py @@ -2563,7 +2563,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")), @@ -2612,7 +2616,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. @@ -2621,6 +2625,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/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 63577fac..007bfd2c 100644 --- a/tests/ui_and_conv/test_shell_welcome_info.py +++ b/tests/ui_and_conv/test_shell_welcome_info.py @@ -155,10 +155,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 +188,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 From 9e3950cc072b487c91177686bea711d9b8fd9b36 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sun, 14 Jun 2026 21:17:15 -0400 Subject: [PATCH 3/4] feat(shell): show a persistent update notice under the prompt input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a newer release is available the footer now renders a yellow "↑ Update available — vX · /update" line directly under the input, so the hint persists instead of only flashing as a transient toast. It renders above the separator in both the legacy and card footer layouts. The text is state-aware: after a release is installed in the background this session the cache still reports a newer version, so the line switches to the restart-to-apply message (agreeing with the install toast) instead of pointing at /update, and clears once the user restarts onto the new version. Sourced from welcome_update_target(), so it is suppressed for dismissed versions, disabled auto-update, and source checkouts. The cache read is memoized on a 5s TTL to keep it off the hot toolbar render path. --- CHANGELOG.md | 1 + src/pythinker_code/ui/shell/__init__.py | 42 +++++++++++++++ src/pythinker_code/ui/shell/prompt.py | 21 ++++++++ tests/ui_and_conv/test_prompt_tips.py | 27 ++++++++++ tests/ui_and_conv/test_silent_auto_update.py | 55 ++++++++++++++++++++ 5 files changed, 146 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f6dd05..474ce9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ GitHub Releases page; `0.8.0` is the new starting line. - **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) diff --git a/src/pythinker_code/ui/shell/__init__.py b/src/pythinker_code/ui/shell/__init__.py index 6cee3382..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, @@ -2184,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). 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/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_silent_auto_update.py b/tests/ui_and_conv/test_silent_auto_update.py index 9679d4fd..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 @@ -310,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" From 71e074b267e66a531cc7fe20bf960df47cd0b6b1 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sun, 14 Jun 2026 21:19:57 -0400 Subject: [PATCH 4/4] test(shell): pin unicode glyphs in welcome width-matrix test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix test asserts the robot mark ("▛") renders at every width but relied on the ambient glyph mode; pin ascii_glyphs_enabled to False so the assertion is deterministic regardless of locale/stdout encoding, matching test_welcome_two_column_layout_when_wide. --- tests/ui_and_conv/test_shell_welcome_info.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/ui_and_conv/test_shell_welcome_info.py b/tests/ui_and_conv/test_shell_welcome_info.py index 007bfd2c..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"),