core/cli: /config opens plyngent.toml and reloads

Add slash /config to edit the active config path in $EDITOR, then reload
providers/agent settings while keeping a valid provider/model selection.
This commit is contained in:
2026-07-15 14:52:19 +08:00
parent 95df11713d
commit 54712b541a
5 changed files with 88 additions and 0 deletions
+1
View File
@@ -124,6 +124,7 @@ Type `/help` in the REPL for the live list. Common ones:
| `/stream` `/verbose` `/tools` `/rounds` | Toggles and limits | | `/stream` `/verbose` `/tools` `/rounds` | Toggles and limits |
| `/retry` | Re-run incomplete last user turn (after error/cancel) | | `/retry` | Re-run incomplete last user turn (after error/cancel) |
| `/provider` `/model` | Switch without restarting | | `/provider` `/model` | Switch without restarting |
| `/config` | Edit `plyngent.toml` in `$EDITOR` and reload |
| `/quit` | Leave the REPL | | `/quit` | Leave the REPL |
User messages are saved immediately. On API error or Ctrl+C, partial assistant/tool output is discarded but the user message stays so `/retry` works after resume. Interactive auto-retry uses 10s / 20s / 30s delays. User messages are saved immediately. On API error or Ctrl+C, partial assistant/tool output is discarded but the user message stays so `/retry` works after resume. Interactive auto-retry uses 10s / 20s / 30s delays.
+29
View File
@@ -293,6 +293,35 @@ def edit_cmd(state: ReplState) -> None:
click.echo(f"(edit) {len(text)} characters ready to send") click.echo(f"(edit) {len(text)} characters ready to send")
@slash.command("config")
@click.pass_obj
def config_cmd(state: ReplState) -> None:
"""Open plyngent.toml in ``$EDITOR``, then reload providers/agent settings.
Same file as ``plyngent config edit``. After the editor exits, config is
re-read; current provider/model are kept when still valid.
"""
from plyngent import config as config_mod
from plyngent.cli.editor import open_in_editor
path = state.config.path
try:
open_in_editor(path)
except click.ClickException as exc:
click.echo(f"error: {exc}")
return
try:
state.reload_config_from_disk()
except (config_mod.ConfigFormatError, ValueError, OSError) as exc:
click.secho(f"error: config reload failed: {exc}", fg="red")
click.echo(f"config file: {path}")
return
if state.config.bad_providers:
names = ", ".join(sorted(state.config.bad_providers.keys()))
click.secho(f"warning: ignored bad providers: {names}", fg="yellow")
click.echo(f"config reloaded from {path}\nprovider={state.provider_name} model={state.model}")
@slash.command("status") @slash.command("status")
@click.pass_obj @click.pass_obj
def status_cmd(state: ReplState) -> None: def status_cmd(state: ReplState) -> None:
+33
View File
@@ -111,6 +111,39 @@ class ReplState:
self.agent.messages = messages self.agent.messages = messages
self.sync_display_flags() self.sync_display_flags()
def reload_config_from_disk(self) -> None:
"""Re-read TOML config and re-bind provider/model when still valid."""
import click
from plyngent.cli.selection import select_model, select_provider
from plyngent.runtime import ProviderNotSupportedError
from plyngent.tools import set_path_denylist
self.config.reload()
set_path_denylist(self.config.agent_config.path_denylist or None)
preferred_provider = self.provider_name if self.provider_name in self.config.providers else None
preferred_model = self.model
try:
pname, provider = select_provider(
self.config.providers,
preferred=preferred_provider,
interactive=False,
)
except (click.ClickException, ProviderNotSupportedError) as exc:
msg = f"config reloaded but provider selection failed: {exc}"
raise ValueError(msg) from exc
try:
model_id = select_model(provider, preferred=preferred_model, interactive=False)
except click.ClickException:
# Previous model not on this provider; pick first listed or keep free-form.
model_id = next(iter(sorted(provider.models.keys()))) if provider.models else preferred_model
self.provider_name = pname
self.provider = provider
self.model = model_id
self.rebuild_client()
def _set_workspace(self, path: Path) -> None: def _set_workspace(self, path: Path) -> None:
"""Update REPL + tool workspace root.""" """Update REPL + tool workspace root."""
resolved = path.expanduser().resolve() resolved = path.expanduser().resolve()
+5
View File
@@ -99,6 +99,11 @@ class ConfigStore:
self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {}))) self._agent = _parse_agent(cast("dict[str, object]", raw.get("agent", {})))
self._providers, self._bad_providers = _parse_providers(document) self._providers, self._bad_providers = _parse_providers(document)
@property
def path(self) -> Path:
"""Filesystem path of the TOML config file."""
return self._path
# -- database (read-only) -- # -- database (read-only) --
@property @property
+20
View File
@@ -173,6 +173,26 @@ async def test_rename_slash(state: ReplState) -> None:
assert row.name == "my-chat" assert row.name == "my-chat"
async def test_config_slash_reloads(
state: ReplState,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
path = state.config.path
assert path is not None
# Fixture ConfigStore may not have written the file yet.
state.config.write()
def fake_open(p: object, **_k: object) -> None:
assert str(p) == str(path)
monkeypatch.setattr("plyngent.cli.editor.open_in_editor", fake_open)
assert await handle_slash(state, "/config") is True
out = capsys.readouterr().out
assert "config reloaded" in out
assert state.provider_name
async def test_model_switch_persists(state: ReplState) -> None: async def test_model_switch_persists(state: ReplState) -> None:
from plyngent.config.models import ModelConfig from plyngent.config.models import ModelConfig