diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb6cbb0..7f0be0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased -- **Toggle auto-update from the CLI.** `/update auto on|off` turns silent startup auto-updates on or off (and `/update auto` reports the effective state); the same toggle now appears in the interactive `/settings` panel, and `pythinker info` reports the auto-update status. All three show the *effective* state — an external override (`PYTHINKER_CLI_NO_AUTO_UPDATE` or a source checkout) is surfaced as the reason and renders the `/settings` row read-only, so the toggle is never a silent no-op. +- **Toggle auto-update from the CLI.** Running `/update` now opens a menu — *Check for updates now* (the default, so a bare `/update` + Enter still checks immediately) or *Auto-update on startup* with its current state — so the toggle is discoverable without knowing a subcommand. `/update auto on|off` still sets it directly, and `/update auto` with no value opens an interactive On/Off picker (cursor defaulted to the current setting). The same toggle appears in the interactive `/settings` panel, and `pythinker info` reports the auto-update status. All surfaces show the *effective* state — an external override (`PYTHINKER_CLI_NO_AUTO_UPDATE` or a source checkout) is surfaced as the reason, renders the `/settings` row read-only, and makes `/update auto` report the read-only state rather than popping a no-op picker, so the toggle is never a silent no-op. ## 0.43.0 (2026-06-13) diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 21823566..38d2bbf3 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -176,11 +176,17 @@ Check for and optionally install the latest Pythinker Code version. Alias: `/upgrade` +Running `/update` opens a menu: *Check for updates now* (the default, so a bare +`/update` + Enter checks immediately) or *Auto-update on startup*, which shows +the current state and jumps to the toggle. + Use `/update auto on` or `/update auto off` to turn silent startup auto-updates on or off (persisted to the `auto_update` config field); `/update auto` with no -argument reports the effective state. When an external override is active — the +argument opens an interactive On/Off picker, with the cursor defaulted to the +current setting. When an external override is active — the `PYTHINKER_CLI_NO_AUTO_UPDATE` kill-switch or a source checkout — it is surfaced -as the reason and outranks the setting. The same toggle is available in +as the reason and outranks the setting, and `/update auto` reports that +read-only state instead of opening the picker. The same toggle is available in `/settings`, and `pythinker info` reports the auto-update status. ### `/reload` diff --git a/src/pythinker_code/ui/shell/slash.py b/src/pythinker_code/ui/shell/slash.py index 87e3b32a..ed19b885 100644 --- a/src/pythinker_code/ui/shell/slash.py +++ b/src/pythinker_code/ui/shell/slash.py @@ -2093,6 +2093,17 @@ async def update_command(app: Shell, args: str): await _auto_update_toggle(app, parts[1:]) return + # Bare `/update` opens a top-level menu so the auto-update toggle is + # discoverable without knowing the `auto` subcommand. Explicit args skip + # straight to the check/update flow. + if not parts: + action = await _prompt_update_action(app) + if action == "auto": + await _auto_update_toggle(app, []) + return + if action != "check": + return + async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: return await run_update_job( print_output=print_output, check_only=check_only, source="slash" @@ -2103,8 +2114,45 @@ async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult: console.print("Updated — restart Pythinker to use the new version.") +async def _prompt_update_action(app: Shell) -> str | None: + """Top-level `/update` menu. + + Returns ``"check"`` to run the update check/install flow, ``"auto"`` to open + the auto-update toggle, or ``None`` when the user cancels/aborts. The cursor + defaults to "check" so a bare ``/update`` + Enter still goes straight to the + update check. + """ + from prompt_toolkit.shortcuts.choice_input import ChoiceInput + + from pythinker_code.update_policy import auto_update_enabled + + auto_label = "Auto-update on startup" + if isinstance(app.soul, PythinkerSoul): + state = "on" if auto_update_enabled(app.soul.runtime.config) else "off" + auto_label = f"{auto_label}: {state}" + + try: + selection = await ChoiceInput( + message="Update", + options=[ + ("check", "Check for updates now"), + ("auto", auto_label), + ("cancel", "Cancel"), + ], + default="check", + ).prompt_async() + except (EOFError, KeyboardInterrupt): + return None + return selection if selection in {"check", "auto"} else None + + async def _auto_update_toggle(app: Shell, args: list[str]) -> None: - """Show or set the silent startup auto-update preference (`/update auto [on|off]`).""" + """Show or set the silent startup auto-update preference. + + `/update auto on|off` sets it directly; `/update auto` with no value opens an + interactive On/Off picker (or, when an external override has made the setting + read-only, reports the effective state instead of popping a no-op picker). + """ from pythinker_code.telemetry import track from pythinker_code.ui.theme import get_tui_tokens as _get_tok from pythinker_code.update_policy import auto_update_enabled, auto_update_override_reason @@ -2120,20 +2168,27 @@ def _print_override() -> None: if override is not None: console.print(f"[{_t.muted}]Note: {override}; this overrides the setting.[/]") - # No value → report effective state. - if not args: + if args: + value = args[0].lower() + if len(args) > 1 or value not in {"on", "off"}: + console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]") + return + enabled = value == "on" + elif override is not None: + # An override makes the stored setting read-only: changing it would not + # change behavior, so report the effective state instead of a no-op picker. effective = "on" if auto_update_enabled(config) else "off" stored = "on" if config.auto_update else "off" console.print(f"[{_t.info}]Auto-update: {effective}[/] (config auto_update={stored})") _print_override() return + else: + selected = await _prompt_auto_update_selection(current=config.auto_update) + if selected is None: + return + enabled = selected - value = args[0].lower() - if len(args) > 1 or value not in {"on", "off"}: - console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]") - return - enabled = value == "on" - + value = "on" if enabled else "off" if config.auto_update == enabled: console.print(f"[{_t.warning}]Auto-update already {value}.[/]") _print_override() @@ -2163,6 +2218,30 @@ def _print_override() -> None: _print_override() +async def _prompt_auto_update_selection(*, current: bool) -> bool | None: + """Interactive On/Off picker for silent startup auto-update. + + Returns ``True``/``False`` for the chosen state, or ``None`` when the user + cancels (selects Cancel, or aborts with Esc/Ctrl-C). The cursor defaults to + the current setting so leaving it unchanged is the zero-effort choice. + """ + from prompt_toolkit.shortcuts.choice_input import ChoiceInput + + try: + selection = await ChoiceInput( + message="Auto-update on startup", + options=[("on", "On"), ("off", "Off"), ("cancel", "Cancel")], + default="on" if current else "off", + ).prompt_async() + except (EOFError, KeyboardInterrupt): + return None + if selection == "on": + return True + if selection == "off": + return False + return None + + @registry.command async def mcp(app: Shell, args: str): """Show MCP servers and tools""" diff --git a/tests/ui_and_conv/test_update_auto_slash.py b/tests/ui_and_conv/test_update_auto_slash.py index 8397d23e..2a0ce6df 100644 --- a/tests/ui_and_conv/test_update_auto_slash.py +++ b/tests/ui_and_conv/test_update_auto_slash.py @@ -6,7 +6,7 @@ from pathlib import Path from types import SimpleNamespace from typing import cast -from unittest.mock import Mock +from unittest.mock import AsyncMock, Mock import pytest from pythinker_core.tooling.empty import EmptyToolset @@ -18,6 +18,7 @@ from pythinker_code.soul.pythinkersoul import PythinkerSoul from pythinker_code.ui.shell import Shell from pythinker_code.ui.shell import slash as shell_slash +from pythinker_code.ui.shell import update as update_module def _make_shell_app(runtime: Runtime, tmp_path: Path) -> SimpleNamespace: @@ -35,6 +36,32 @@ async def _run_update(app: SimpleNamespace, args: str) -> None: await cast(Awaitable[None], shell_slash.update_command(cast(Shell, app), args)) +def _fake_choices( + monkeypatch: pytest.MonkeyPatch, selections: dict[str, str] +) -> dict[str, dict[str, object]]: + """Drive the real menu/picker by faking the interactive ``ChoiceInput`` boundary. + + ``selections`` maps a prompt's ``message`` to the option key the user "picks". + Returns a dict recording each prompt's constructor kwargs so tests can assert + the observable choice boundary (offered options and default cursor) instead of + patching the private helpers that build them. + """ + import prompt_toolkit.shortcuts.choice_input as choice_input + + recorded: dict[str, dict[str, object]] = {} + + class _FakeChoiceInput: + def __init__(self, *, message: object, **kwargs: object) -> None: + self._message = str(message) + recorded[self._message] = {"message": self._message, **kwargs} + + async def prompt_async(self) -> str: + return selections[self._message] + + monkeypatch.setattr(choice_input, "ChoiceInput", _FakeChoiceInput) + return recorded + + @pytest.fixture(autouse=True) def _no_override(monkeypatch: pytest.MonkeyPatch) -> None: # The suite runs from a source checkout, where the override would otherwise @@ -137,6 +164,111 @@ async def test_update_auto_requires_config_file( assert "config file" in str(print_mock.call_args.args[0]) +@pytest.mark.asyncio +async def test_bare_update_menu_check_runs_update_flow( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.auto_update = True + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + # Stub only the public update-flow boundary; the real menu still runs. + run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) + monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) + recorded = _fake_choices(monkeypatch, {"Update": "check"}) + + await _run_update(app, "") + + # Picking "check" runs the update flow and leaves the auto setting untouched. + run_prompt.assert_awaited_once() + assert runtime.config.auto_update is True + assert recorded["Update"]["default"] == "check" + + +@pytest.mark.asyncio +async def test_bare_update_menu_auto_persists_chosen_state( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PYTHINKER_AUTO_UPDATE", raising=False) + config_path = (tmp_path / "config.toml").resolve() + _seed_config_file(config_path, auto_update=False) + runtime.config.source_file = config_path + runtime.config.auto_update = False + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) + monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) + # Drive the whole menu -> toggle -> picker chain via the input boundary. + recorded = _fake_choices(monkeypatch, {"Update": "auto", "Auto-update on startup": "on"}) + + await _run_update(app, "") + + # The chosen state is persisted and mirrored live; the update flow is skipped. + assert load_config(config_path).auto_update is True + assert runtime.config.auto_update is True + run_prompt.assert_not_called() + # The picker's cursor defaults to the current (off) state. + assert recorded["Auto-update on startup"]["default"] == "off" + + +@pytest.mark.asyncio +async def test_bare_update_menu_cancel_is_noop( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.auto_update = False + app = _make_shell_app(runtime, tmp_path) + save_mock = Mock() + monkeypatch.setattr(shell_slash, "save_config", save_mock) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) + monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) + _fake_choices(monkeypatch, {"Update": "cancel"}) + + await _run_update(app, "") + + run_prompt.assert_not_called() + save_mock.assert_not_called() + assert runtime.config.auto_update is False + + +@pytest.mark.asyncio +async def test_update_auto_no_args_opens_picker_and_persists( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv("PYTHINKER_AUTO_UPDATE", raising=False) + config_path = (tmp_path / "config.toml").resolve() + _seed_config_file(config_path, auto_update=False) + runtime.config.source_file = config_path + runtime.config.auto_update = False + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + recorded = _fake_choices(monkeypatch, {"Auto-update on startup": "on"}) + + await _run_update(app, "auto") + + # The picker's cursor defaults to the current value... + assert recorded["Auto-update on startup"]["default"] == "off" + # ...and the chosen state is persisted and mirrored live. + assert load_config(config_path).auto_update is True + assert runtime.config.auto_update is True + + +@pytest.mark.asyncio +async def test_update_auto_no_args_cancel_is_noop( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + runtime.config.auto_update = False + app = _make_shell_app(runtime, tmp_path) + save_mock = Mock() + monkeypatch.setattr(shell_slash, "save_config", save_mock) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + _fake_choices(monkeypatch, {"Auto-update on startup": "cancel"}) + + await _run_update(app, "auto") + + save_mock.assert_not_called() + assert runtime.config.auto_update is False + + @pytest.mark.asyncio async def test_update_auto_status_surfaces_override( runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch