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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
10 changes: 8 additions & 2 deletions docs/en/reference/slash-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
97 changes: 88 additions & 9 deletions src/pythinker_code/ui/shell/slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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"""
Expand Down
134 changes: 133 additions & 1 deletion tests/ui_and_conv/test_update_auto_slash.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading