From f1ee59e7f7db6b0e8edc3767e0c71de1f0062568 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 20 Jun 2026 14:48:05 -0400 Subject: [PATCH 1/2] fix(update): keep restart notice and brew upgrades clear of the live session Two update-flow UX fixes reported together. - TUI: the persistent "Restart to apply" / "Update available" footer line rendered directly under the input box. It now renders as the last footer row, below the status/clock line, in both toolbar layouts, clear of the input. - Homebrew: a silent self-upgrade ran `brew upgrade` in place mid-session, where brew's cleanup can delete the in-use Cellar version the running process resolves through. The upgrade now runs with HOMEBREW_NO_INSTALL_CLEANUP and HOMEBREW_NO_AUTO_UPDATE, so the new build installs side-by-side and goes live on restart without touching the live session. --- CHANGELOG.md | 9 ++++++++ docs/en/release-notes/changelog.md | 9 ++++++++ src/pythinker_code/ui/shell/prompt.py | 17 ++++++++------ src/pythinker_code/ui/shell/update.py | 16 ++++++++++++- tests/ui_and_conv/test_prompt_tips.py | 23 ++++++++++-------- tests/ui_and_conv/test_shell_update.py | 32 ++++++++++++++++++++++++++ 6 files changed, 88 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e32ee3c..470f4f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Update notice no longer crowds the prompt.** The persistent "Restart to apply" + / "Update available" line now renders as the last footer row — below the + status/clock line — instead of directly under the input box, keeping the input + area clear. +- **Homebrew self-upgrades no longer risk the live session.** The updater now runs + `brew upgrade` with `HOMEBREW_NO_INSTALL_CLEANUP` and `HOMEBREW_NO_AUTO_UPDATE`, + so brew can't delete the in-use Cellar version mid-session; the new build is + staged side-by-side and goes live on restart. + ## 0.50.0 (2026-06-20) - **Silent native updates no longer crash a running session.** The downloaded diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 50d80c38..b3145ed0 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -17,6 +17,15 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Update notice no longer crowds the prompt.** The persistent "Restart to apply" + / "Update available" line now renders as the last footer row — below the + status/clock line — instead of directly under the input box, keeping the input + area clear. +- **Homebrew self-upgrades no longer risk the live session.** The updater now runs + `brew upgrade` with `HOMEBREW_NO_INSTALL_CLEANUP` and `HOMEBREW_NO_AUTO_UPDATE`, + so brew can't delete the in-use Cellar version mid-session; the new build is + staged side-by-side and goes live on restart. + ## 0.50.0 (2026-06-20) - **Silent native updates no longer crash a running session.** The downloaded diff --git a/src/pythinker_code/ui/shell/prompt.py b/src/pythinker_code/ui/shell/prompt.py index 37744696..bd7f382a 100644 --- a/src/pythinker_code/ui/shell/prompt.py +++ b/src/pythinker_code/ui/shell/prompt.py @@ -3916,10 +3916,13 @@ def _append_history_entry(self, text: str) -> None: ) def _append_update_notice(self, fragments: list[tuple[str, str]], columns: int) -> None: - """Append a persistent yellow 'update available' line *below* the footer - separator, so it renders underneath the prompt input box rather than - inside it. Call this right after the separator rule. No-op when no update - is pending; style-agnostic across both toolbar layouts.""" + """Append a persistent yellow 'update available' line as the *last* footer + row — below the status/clock line — so it sits fully clear of the prompt + input box instead of glued to it. Call this last, after the status lines + are assembled: it prepends its own newline (the prior footer line carries + none) and adds no trailing newline, so it never leaves a blank row at the + bottom. 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 @@ -3931,7 +3934,7 @@ def _append_update_notice(self, fragments: list[tuple[str, str]], columns: int) return tokens = _get_tui_tokens() style = f"fg:{tokens.warning or 'ansiyellow'} bold" - fragments.extend([(style, line), ("", "\n")]) + fragments.extend([("", "\n"), (style, line)]) def _render_bottom_toolbar(self) -> FormattedText: if ( @@ -3957,7 +3960,6 @@ def _render_bottom_toolbar(self) -> FormattedText: fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) - self._append_update_notice(fragments, columns) remaining = columns @@ -4077,6 +4079,7 @@ def _render_bottom_toolbar(self) -> FormattedText: fragments.append(("", " " * max(0, columns - left_width - right_width))) fragments.append((secondary_style, right_text)) + self._append_update_notice(fragments, columns) return FormattedText(fragments) def _build_statusline_context(self, columns: int) -> StatusLineContext: @@ -4188,7 +4191,6 @@ def _render_card_bottom_toolbar(self, columns: int) -> FormattedText: fragments: list[tuple[str, str]] = [] fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns))) fragments.append(("", "\n")) - self._append_update_notice(fragments, columns) try: ctx = self._build_statusline_context(columns) @@ -4251,6 +4253,7 @@ def _render_card_bottom_toolbar(self, columns: int) -> FormattedText: fragments.append(("", " " * max(0, usable - left_width - right_width))) fragments.extend(line2_right) + self._append_update_notice(fragments, columns) return FormattedText(fragments) def _get_two_rotating_tips(self) -> str | None: diff --git a/src/pythinker_code/ui/shell/update.py b/src/pythinker_code/ui/shell/update.py index 8e83c69d..4639a6e5 100644 --- a/src/pythinker_code/ui/shell/update.py +++ b/src/pythinker_code/ui/shell/update.py @@ -1262,6 +1262,20 @@ def _emit(text: str) -> None: if print_output: console.print(text, markup=False) + env = get_clean_env() + if command[:1] == ["brew"]: + # Self-upgrade hardening: pythinker is the running Homebrew formula. + # `brew upgrade` installs the new version side-by-side, but its + # post-upgrade cleanup would delete the in-use old Cellar version that + # `sys.executable` resolves through — crashing the live session before the + # user restarts. Suppress cleanup so the swap only takes effect on the next + # launch (matching the "Restart to apply" notice). Also skip brew's + # implicit pre-command auto-update: `_refresh_brew_metadata` already + # refreshed the tap explicitly, so the implicit pass is only redundant + # network/lock work during the user's session. + env["HOMEBREW_NO_INSTALL_CLEANUP"] = "1" + env["HOMEBREW_NO_AUTO_UPDATE"] = "1" + proc = subprocess.Popen( command, stdout=subprocess.PIPE, @@ -1269,7 +1283,7 @@ def _emit(text: str) -> None: text=True, encoding="utf-8", errors="replace", - env=get_clean_env(), + env=env, bufsize=1, ) diff --git a/tests/ui_and_conv/test_prompt_tips.py b/tests/ui_and_conv/test_prompt_tips.py index 6640cfd7..b72f2d04 100644 --- a/tests/ui_and_conv/test_prompt_tips.py +++ b/tests/ui_and_conv/test_prompt_tips.py @@ -1711,18 +1711,21 @@ async def prompt_async(self, **kwargs): assert prompt_session.last_submission_was_running is False -def test_append_update_notice_inserts_line_below_separator(): - # Caller appends the separator rule + newline first, then the notice — so the - # notice renders underneath the input box (below the bottom border), not inside it. - fragments: list[tuple[str, str]] = [("sep", "────"), ("", "\n")] +def test_append_update_notice_appends_as_last_footer_row(): + # Called last, after the status lines: the notice becomes the final footer + # row (below the status/clock line), fully clear of the input box. It prepends + # its own newline and adds no trailing one, so it never leaves a blank bottom row. + fragments: list[tuple[str, str]] = [("status", "◇ model"), ("", " ctx")] fake = SimpleNamespace(_update_notice_provider=lambda: "↑ Update available — v9.9.9 · /update") CustomPromptSession._append_update_notice(cast(Any, fake), fragments, 80) - assert fragments[0] == ("sep", "────") - assert fragments[1] == ("", "\n") - assert "Update available" in fragments[2][1] - assert "v9.9.9" in fragments[2][1] - assert "bold" in fragments[2][0] - assert fragments[3] == ("", "\n") + # Existing footer fragments are untouched; the notice is appended at the end. + assert fragments[0] == ("status", "◇ model") + assert fragments[1] == ("", " ctx") + assert fragments[2] == ("", "\n") # leading newline starts a fresh row + assert "Update available" in fragments[3][1] + assert "v9.9.9" in fragments[3][1] + assert "bold" in fragments[3][0] + assert len(fragments) == 4 # no trailing newline → no blank row at the bottom def test_append_update_notice_noop_when_no_update(): diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 6381f0b2..66ebea55 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -763,6 +763,38 @@ def fake_popen(command, **kwargs): assert messages == ["first line", "second line"] +def test_run_upgrade_command_guards_homebrew_self_upgrade(monkeypatch): + # A brew self-upgrade must not let brew clean up the in-use Cellar version + # mid-session (it would crash the live session), and should skip the redundant + # implicit auto-update. Non-brew upgrades are left untouched. + captured: dict[str, dict[str, str]] = {} + + class FakeProc: + stdout: list[str] = [] + + def wait(self, *, timeout: float) -> int: + return 0 + + def fake_popen(command, **kwargs): + captured[command[0]] = dict(kwargs["env"]) + return FakeProc() + + monkeypatch.setattr(update.subprocess, "Popen", fake_popen) + + update._run_upgrade_command( + ["brew", "upgrade", "pythinker-code"], print_output=False, output_callback=None + ) + update._run_upgrade_command( + ["uv", "tool", "upgrade", "pythinker-code"], print_output=False, output_callback=None + ) + + assert captured["brew"]["HOMEBREW_NO_INSTALL_CLEANUP"] == "1" + assert captured["brew"]["HOMEBREW_NO_AUTO_UPDATE"] == "1" + # Non-brew upgrades don't get Homebrew guards. + assert "HOMEBREW_NO_INSTALL_CLEANUP" not in captured["uv"] + assert "HOMEBREW_NO_AUTO_UPDATE" not in captured["uv"] + + @pytest.mark.asyncio async def test_do_update_reports_non_native_upgrade_failure_to_callback(monkeypatch, tmp_path): messages: list[str] = [] From 34ce9106aae0cc58bdbee570d08b097d46ed8cf7 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 20 Jun 2026 15:08:33 -0400 Subject: [PATCH 2/2] test(update): isolate ambient Homebrew guards in upgrade env test CI runners export HOMEBREW_NO_AUTO_UPDATE (and sometimes HOMEBREW_NO_INSTALL_CLEANUP), which leaked into the inherited subprocess env and made the non-brew assertions fail. Clear them up front so the test asserts the guard delta the code adds, not ambient state. --- tests/ui_and_conv/test_shell_update.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/ui_and_conv/test_shell_update.py b/tests/ui_and_conv/test_shell_update.py index 66ebea55..eaaac7ed 100644 --- a/tests/ui_and_conv/test_shell_update.py +++ b/tests/ui_and_conv/test_shell_update.py @@ -767,6 +767,12 @@ def test_run_upgrade_command_guards_homebrew_self_upgrade(monkeypatch): # A brew self-upgrade must not let brew clean up the in-use Cellar version # mid-session (it would crash the live session), and should skip the redundant # implicit auto-update. Non-brew upgrades are left untouched. + # Isolate from the ambient environment: CI runners (and some dev machines) + # already export these Homebrew guards, which would otherwise leak into the + # inherited env and mask whether the code adds them only for brew. + monkeypatch.delenv("HOMEBREW_NO_INSTALL_CLEANUP", raising=False) + monkeypatch.delenv("HOMEBREW_NO_AUTO_UPDATE", raising=False) + captured: dict[str, dict[str, str]] = {} class FakeProc: