From 3c03c1843f0bad5190796ecb68570fbdba650d96 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 19:04:06 -0400 Subject: [PATCH 1/2] feat(update): add interactive menu to /update with auto-update toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the auto-update toggle discoverable from the bare `/update` command instead of requiring the `auto` subcommand: - `/update` now opens a top-level menu — "Check for updates now" (the default, so a bare `/update` + Enter still checks immediately) or "Auto-update on startup" with its current state, which jumps to the On/Off picker. - `/update auto` with no value opens an interactive On/Off picker, cursor defaulted to the current setting, instead of only printing status. - `/update auto on|off` still sets it directly; when an external override is active the setting is read-only, so `/update auto` reports that state rather than popping a no-op picker. Update slash-command docs and add focused tests for the menu routing and the picker path. --- CHANGELOG.md | 2 +- docs/en/reference/slash-commands.md | 10 ++- src/pythinker_code/ui/shell/slash.py | 97 ++++++++++++++++++-- tests/ui_and_conv/test_update_auto_slash.py | 99 ++++++++++++++++++++- 4 files changed, 195 insertions(+), 13 deletions(-) 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..0051d7be 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: @@ -137,6 +138,102 @@ 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: + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value="check")) + run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) + monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) + auto_toggle = AsyncMock() + monkeypatch.setattr(shell_slash, "_auto_update_toggle", auto_toggle) + + await _run_update(app, "") + + run_prompt.assert_awaited_once() + auto_toggle.assert_not_called() + + +@pytest.mark.asyncio +async def test_bare_update_menu_auto_routes_to_toggle( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value="auto")) + run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) + monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) + auto_toggle = AsyncMock() + monkeypatch.setattr(shell_slash, "_auto_update_toggle", auto_toggle) + + await _run_update(app, "") + + auto_toggle.assert_awaited_once_with(app, []) + run_prompt.assert_not_called() + + +@pytest.mark.asyncio +async def test_bare_update_menu_cancel_is_noop( + runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + app = _make_shell_app(runtime, tmp_path) + monkeypatch.setattr(shell_slash.console, "print", Mock()) + monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value=None)) + run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) + monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) + + await _run_update(app, "") + + run_prompt.assert_not_called() + + +@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()) + + picker = AsyncMock(return_value=True) + monkeypatch.setattr(shell_slash, "_prompt_auto_update_selection", picker) + + await _run_update(app, "auto") + + # The picker is consulted with the current value as its default cursor... + assert picker.call_args.kwargs == {"current": False} + # ...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()) + monkeypatch.setattr( + shell_slash, + "_prompt_auto_update_selection", + AsyncMock(return_value=None), + ) + + 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 From 3bfacc52018aa8bfe558e5734550b32994efd867 Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Sat, 13 Jun 2026 20:19:23 -0400 Subject: [PATCH 2/2] test(update): assert observable outcomes instead of patching private helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review: the /update menu and auto-update picker tests patched the private _prompt_update_action / _auto_update_toggle / _prompt_auto_update_selection helpers and asserted internal call wiring. Drive the real helpers by faking only the interactive ChoiceInput boundary (and stubbing the public run_update_prompt seam), and assert observable results — persisted/unchanged config, the update flow running or being skipped, and the picker's default cursor reflecting the current state. --- tests/ui_and_conv/test_update_auto_slash.py | 75 +++++++++++++++------ 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/tests/ui_and_conv/test_update_auto_slash.py b/tests/ui_and_conv/test_update_auto_slash.py index 0051d7be..2a0ce6df 100644 --- a/tests/ui_and_conv/test_update_auto_slash.py +++ b/tests/ui_and_conv/test_update_auto_slash.py @@ -36,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 @@ -142,51 +168,66 @@ async def test_update_auto_requires_config_file( 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()) - monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value="check")) + # 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) - auto_toggle = AsyncMock() - monkeypatch.setattr(shell_slash, "_auto_update_toggle", auto_toggle) + 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() - auto_toggle.assert_not_called() + assert runtime.config.auto_update is True + assert recorded["Update"]["default"] == "check" @pytest.mark.asyncio -async def test_bare_update_menu_auto_routes_to_toggle( +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()) - monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value="auto")) run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE) monkeypatch.setattr(update_module, "run_update_prompt", run_prompt) - auto_toggle = AsyncMock() - monkeypatch.setattr(shell_slash, "_auto_update_toggle", auto_toggle) + # 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, "") - auto_toggle.assert_awaited_once_with(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()) - monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value=None)) 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 @@ -200,14 +241,12 @@ async def test_update_auto_no_args_opens_picker_and_persists( runtime.config.auto_update = False app = _make_shell_app(runtime, tmp_path) monkeypatch.setattr(shell_slash.console, "print", Mock()) - - picker = AsyncMock(return_value=True) - monkeypatch.setattr(shell_slash, "_prompt_auto_update_selection", picker) + recorded = _fake_choices(monkeypatch, {"Auto-update on startup": "on"}) await _run_update(app, "auto") - # The picker is consulted with the current value as its default cursor... - assert picker.call_args.kwargs == {"current": False} + # 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 @@ -222,11 +261,7 @@ async def test_update_auto_no_args_cancel_is_noop( save_mock = Mock() monkeypatch.setattr(shell_slash, "save_config", save_mock) monkeypatch.setattr(shell_slash.console, "print", Mock()) - monkeypatch.setattr( - shell_slash, - "_prompt_auto_update_selection", - AsyncMock(return_value=None), - ) + _fake_choices(monkeypatch, {"Auto-update on startup": "cancel"}) await _run_update(app, "auto")