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

- **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)

- **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`).
Expand Down
60 changes: 57 additions & 3 deletions src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
)
from pythinker_code.ui.shell.update_orchestrator import (
SMOKE_CHECK_FAILED_PREFIX,
UpdateJobState,
read_update_status,
run_update_job,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2110,9 +2116,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:
Expand Down Expand Up @@ -2180,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).

Expand Down Expand Up @@ -2559,7 +2605,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")),
Expand Down Expand Up @@ -2608,7 +2658,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.
Expand All @@ -2617,6 +2667,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])

Expand Down
21 changes: 21 additions & 0 deletions src/pythinker_code/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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"))

Expand Down Expand Up @@ -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"))

Expand Down
8 changes: 7 additions & 1 deletion src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/ui_and_conv/test_prompt_tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
33 changes: 29 additions & 4 deletions tests/ui_and_conv/test_shell_welcome_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -155,10 +158,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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def test_welcome_two_column_layout_when_wide(monkeypatch):
Expand Down Expand Up @@ -189,6 +191,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

Expand Down
95 changes: 95 additions & 0 deletions tests/ui_and_conv/test_silent_auto_update.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -107,6 +108,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
Expand Down Expand Up @@ -270,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"
Loading