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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions src/pythinker_code/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 (
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 15 additions & 1 deletion src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,14 +1262,28 @@ 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,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
env=get_clean_env(),
env=env,
bufsize=1,
)

Expand Down
23 changes: 13 additions & 10 deletions tests/ui_and_conv/test_prompt_tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
38 changes: 38 additions & 0 deletions tests/ui_and_conv/test_shell_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,44 @@ 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.
# 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:
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] = []
Expand Down
Loading