Skip to content

Commit 3c03c18

Browse files
committed
feat(update): add interactive menu to /update with auto-update toggle
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.
1 parent 7e476c1 commit 3c03c18

4 files changed

Lines changed: 195 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18-
- **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.
18+
- **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.
1919

2020
## 0.43.0 (2026-06-13)
2121

docs/en/reference/slash-commands.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,17 @@ Check for and optionally install the latest Pythinker Code version.
176176

177177
Alias: `/upgrade`
178178

179+
Running `/update` opens a menu: *Check for updates now* (the default, so a bare
180+
`/update` + Enter checks immediately) or *Auto-update on startup*, which shows
181+
the current state and jumps to the toggle.
182+
179183
Use `/update auto on` or `/update auto off` to turn silent startup auto-updates
180184
on or off (persisted to the `auto_update` config field); `/update auto` with no
181-
argument reports the effective state. When an external override is active — the
185+
argument opens an interactive On/Off picker, with the cursor defaulted to the
186+
current setting. When an external override is active — the
182187
`PYTHINKER_CLI_NO_AUTO_UPDATE` kill-switch or a source checkout — it is surfaced
183-
as the reason and outranks the setting. The same toggle is available in
188+
as the reason and outranks the setting, and `/update auto` reports that
189+
read-only state instead of opening the picker. The same toggle is available in
184190
`/settings`, and `pythinker info` reports the auto-update status.
185191

186192
### `/reload`

src/pythinker_code/ui/shell/slash.py

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2093,6 +2093,17 @@ async def update_command(app: Shell, args: str):
20932093
await _auto_update_toggle(app, parts[1:])
20942094
return
20952095

2096+
# Bare `/update` opens a top-level menu so the auto-update toggle is
2097+
# discoverable without knowing the `auto` subcommand. Explicit args skip
2098+
# straight to the check/update flow.
2099+
if not parts:
2100+
action = await _prompt_update_action(app)
2101+
if action == "auto":
2102+
await _auto_update_toggle(app, [])
2103+
return
2104+
if action != "check":
2105+
return
2106+
20962107
async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult:
20972108
return await run_update_job(
20982109
print_output=print_output, check_only=check_only, source="slash"
@@ -2103,8 +2114,45 @@ async def _runner(*, print_output: bool, check_only: bool) -> UpdateResult:
21032114
console.print("Updated — restart Pythinker to use the new version.")
21042115

21052116

2117+
async def _prompt_update_action(app: Shell) -> str | None:
2118+
"""Top-level `/update` menu.
2119+
2120+
Returns ``"check"`` to run the update check/install flow, ``"auto"`` to open
2121+
the auto-update toggle, or ``None`` when the user cancels/aborts. The cursor
2122+
defaults to "check" so a bare ``/update`` + Enter still goes straight to the
2123+
update check.
2124+
"""
2125+
from prompt_toolkit.shortcuts.choice_input import ChoiceInput
2126+
2127+
from pythinker_code.update_policy import auto_update_enabled
2128+
2129+
auto_label = "Auto-update on startup"
2130+
if isinstance(app.soul, PythinkerSoul):
2131+
state = "on" if auto_update_enabled(app.soul.runtime.config) else "off"
2132+
auto_label = f"{auto_label}: {state}"
2133+
2134+
try:
2135+
selection = await ChoiceInput(
2136+
message="Update",
2137+
options=[
2138+
("check", "Check for updates now"),
2139+
("auto", auto_label),
2140+
("cancel", "Cancel"),
2141+
],
2142+
default="check",
2143+
).prompt_async()
2144+
except (EOFError, KeyboardInterrupt):
2145+
return None
2146+
return selection if selection in {"check", "auto"} else None
2147+
2148+
21062149
async def _auto_update_toggle(app: Shell, args: list[str]) -> None:
2107-
"""Show or set the silent startup auto-update preference (`/update auto [on|off]`)."""
2150+
"""Show or set the silent startup auto-update preference.
2151+
2152+
`/update auto on|off` sets it directly; `/update auto` with no value opens an
2153+
interactive On/Off picker (or, when an external override has made the setting
2154+
read-only, reports the effective state instead of popping a no-op picker).
2155+
"""
21082156
from pythinker_code.telemetry import track
21092157
from pythinker_code.ui.theme import get_tui_tokens as _get_tok
21102158
from pythinker_code.update_policy import auto_update_enabled, auto_update_override_reason
@@ -2120,20 +2168,27 @@ def _print_override() -> None:
21202168
if override is not None:
21212169
console.print(f"[{_t.muted}]Note: {override}; this overrides the setting.[/]")
21222170

2123-
# No value → report effective state.
2124-
if not args:
2171+
if args:
2172+
value = args[0].lower()
2173+
if len(args) > 1 or value not in {"on", "off"}:
2174+
console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]")
2175+
return
2176+
enabled = value == "on"
2177+
elif override is not None:
2178+
# An override makes the stored setting read-only: changing it would not
2179+
# change behavior, so report the effective state instead of a no-op picker.
21252180
effective = "on" if auto_update_enabled(config) else "off"
21262181
stored = "on" if config.auto_update else "off"
21272182
console.print(f"[{_t.info}]Auto-update: {effective}[/] (config auto_update={stored})")
21282183
_print_override()
21292184
return
2185+
else:
2186+
selected = await _prompt_auto_update_selection(current=config.auto_update)
2187+
if selected is None:
2188+
return
2189+
enabled = selected
21302190

2131-
value = args[0].lower()
2132-
if len(args) > 1 or value not in {"on", "off"}:
2133-
console.print(f"[{_t.warning}]Usage: /update auto [on|off][/]")
2134-
return
2135-
enabled = value == "on"
2136-
2191+
value = "on" if enabled else "off"
21372192
if config.auto_update == enabled:
21382193
console.print(f"[{_t.warning}]Auto-update already {value}.[/]")
21392194
_print_override()
@@ -2163,6 +2218,30 @@ def _print_override() -> None:
21632218
_print_override()
21642219

21652220

2221+
async def _prompt_auto_update_selection(*, current: bool) -> bool | None:
2222+
"""Interactive On/Off picker for silent startup auto-update.
2223+
2224+
Returns ``True``/``False`` for the chosen state, or ``None`` when the user
2225+
cancels (selects Cancel, or aborts with Esc/Ctrl-C). The cursor defaults to
2226+
the current setting so leaving it unchanged is the zero-effort choice.
2227+
"""
2228+
from prompt_toolkit.shortcuts.choice_input import ChoiceInput
2229+
2230+
try:
2231+
selection = await ChoiceInput(
2232+
message="Auto-update on startup",
2233+
options=[("on", "On"), ("off", "Off"), ("cancel", "Cancel")],
2234+
default="on" if current else "off",
2235+
).prompt_async()
2236+
except (EOFError, KeyboardInterrupt):
2237+
return None
2238+
if selection == "on":
2239+
return True
2240+
if selection == "off":
2241+
return False
2242+
return None
2243+
2244+
21662245
@registry.command
21672246
async def mcp(app: Shell, args: str):
21682247
"""Show MCP servers and tools"""

tests/ui_and_conv/test_update_auto_slash.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from types import SimpleNamespace
88
from typing import cast
9-
from unittest.mock import Mock
9+
from unittest.mock import AsyncMock, Mock
1010

1111
import pytest
1212
from pythinker_core.tooling.empty import EmptyToolset
@@ -18,6 +18,7 @@
1818
from pythinker_code.soul.pythinkersoul import PythinkerSoul
1919
from pythinker_code.ui.shell import Shell
2020
from pythinker_code.ui.shell import slash as shell_slash
21+
from pythinker_code.ui.shell import update as update_module
2122

2223

2324
def _make_shell_app(runtime: Runtime, tmp_path: Path) -> SimpleNamespace:
@@ -137,6 +138,102 @@ async def test_update_auto_requires_config_file(
137138
assert "config file" in str(print_mock.call_args.args[0])
138139

139140

141+
@pytest.mark.asyncio
142+
async def test_bare_update_menu_check_runs_update_flow(
143+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
144+
) -> None:
145+
app = _make_shell_app(runtime, tmp_path)
146+
monkeypatch.setattr(shell_slash.console, "print", Mock())
147+
monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value="check"))
148+
run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE)
149+
monkeypatch.setattr(update_module, "run_update_prompt", run_prompt)
150+
auto_toggle = AsyncMock()
151+
monkeypatch.setattr(shell_slash, "_auto_update_toggle", auto_toggle)
152+
153+
await _run_update(app, "")
154+
155+
run_prompt.assert_awaited_once()
156+
auto_toggle.assert_not_called()
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_bare_update_menu_auto_routes_to_toggle(
161+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
162+
) -> None:
163+
app = _make_shell_app(runtime, tmp_path)
164+
monkeypatch.setattr(shell_slash.console, "print", Mock())
165+
monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value="auto"))
166+
run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE)
167+
monkeypatch.setattr(update_module, "run_update_prompt", run_prompt)
168+
auto_toggle = AsyncMock()
169+
monkeypatch.setattr(shell_slash, "_auto_update_toggle", auto_toggle)
170+
171+
await _run_update(app, "")
172+
173+
auto_toggle.assert_awaited_once_with(app, [])
174+
run_prompt.assert_not_called()
175+
176+
177+
@pytest.mark.asyncio
178+
async def test_bare_update_menu_cancel_is_noop(
179+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
180+
) -> None:
181+
app = _make_shell_app(runtime, tmp_path)
182+
monkeypatch.setattr(shell_slash.console, "print", Mock())
183+
monkeypatch.setattr(shell_slash, "_prompt_update_action", AsyncMock(return_value=None))
184+
run_prompt = AsyncMock(return_value=update_module.UpdateResult.UP_TO_DATE)
185+
monkeypatch.setattr(update_module, "run_update_prompt", run_prompt)
186+
187+
await _run_update(app, "")
188+
189+
run_prompt.assert_not_called()
190+
191+
192+
@pytest.mark.asyncio
193+
async def test_update_auto_no_args_opens_picker_and_persists(
194+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
195+
) -> None:
196+
monkeypatch.delenv("PYTHINKER_AUTO_UPDATE", raising=False)
197+
config_path = (tmp_path / "config.toml").resolve()
198+
_seed_config_file(config_path, auto_update=False)
199+
runtime.config.source_file = config_path
200+
runtime.config.auto_update = False
201+
app = _make_shell_app(runtime, tmp_path)
202+
monkeypatch.setattr(shell_slash.console, "print", Mock())
203+
204+
picker = AsyncMock(return_value=True)
205+
monkeypatch.setattr(shell_slash, "_prompt_auto_update_selection", picker)
206+
207+
await _run_update(app, "auto")
208+
209+
# The picker is consulted with the current value as its default cursor...
210+
assert picker.call_args.kwargs == {"current": False}
211+
# ...and the chosen state is persisted and mirrored live.
212+
assert load_config(config_path).auto_update is True
213+
assert runtime.config.auto_update is True
214+
215+
216+
@pytest.mark.asyncio
217+
async def test_update_auto_no_args_cancel_is_noop(
218+
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
219+
) -> None:
220+
runtime.config.auto_update = False
221+
app = _make_shell_app(runtime, tmp_path)
222+
save_mock = Mock()
223+
monkeypatch.setattr(shell_slash, "save_config", save_mock)
224+
monkeypatch.setattr(shell_slash.console, "print", Mock())
225+
monkeypatch.setattr(
226+
shell_slash,
227+
"_prompt_auto_update_selection",
228+
AsyncMock(return_value=None),
229+
)
230+
231+
await _run_update(app, "auto")
232+
233+
save_mock.assert_not_called()
234+
assert runtime.config.auto_update is False
235+
236+
140237
@pytest.mark.asyncio
141238
async def test_update_auto_status_surfaces_override(
142239
runtime: Runtime, tmp_path: Path, monkeypatch: pytest.MonkeyPatch

0 commit comments

Comments
 (0)