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
12 changes: 12 additions & 0 deletions docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,18 @@ API — use the Anthropic API entry above for Claude models.

Without configuration, `Ctrl-A` shows a setup hint pointing at `:ai`.

### Turning the agent off and on

`Ctrl-A` only toggles the panel's *visibility* — the runtime stays
connected. To actually disconnect for the session, use `:ai off`: the
provider connection is released, the status bar flips to `AI off`, and
prompt submission is disabled while the transcript stays visible. The
configured provider, model, profile, and credentials are all kept (nothing
is rewritten in `config.yaml`), so a bare `:ai` reopens the wizard with the
current settings and reconnects when applied. `:ai off` is refused while a
turn is running — stop the turn first (`Ctrl-X`) — and is a no-op when the
agent is already off.

## Capability profiles

Small local models (3B–14B) handle the agent's default surface — up to 15
Expand Down
53 changes: 42 additions & 11 deletions src/korvid/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ def _agent_unavailable_wiring(
None,
None,
Callable[[AgentRuntime | None, bool, str | None], None],
Callable[[], None],
list[LLMProvider | None],
_UIBridgeProxy,
]:
Expand All @@ -432,7 +433,7 @@ def _retarget_noop(
) -> None:
return None

return None, None, None, _retarget_noop, provider_box, ui_proxy
return None, None, None, _retarget_noop, lambda: None, provider_box, ui_proxy


def _build_agent_wiring(
Expand All @@ -448,6 +449,7 @@ def _build_agent_wiring(
AgentConfigurator | None,
Callable[[AgentSettings], AgentRuntime | None] | None,
Callable[[AgentRuntime | None, bool, str | None], None],
Callable[[], None],
list[LLMProvider | None],
_UIBridgeProxy,
]:
Expand Down Expand Up @@ -621,7 +623,33 @@ def retarget_agent(
)
runtime.retarget(tools=retarget_profile.tools, cluster_context=cluster_context)

return agent_runtime, configurator, rebuild_agent, retarget_agent, provider_box, ui_proxy
return (
agent_runtime,
configurator,
rebuild_agent,
retarget_agent,
_make_disconnect_agent(provider_box, close_tasks),
provider_box,
ui_proxy,
)


def _make_disconnect_agent(
provider_box: list[LLMProvider | None], close_tasks: set[asyncio.Task[None]]
) -> Callable[[], None]:
"""`:ai off` (issue #167): release the live provider for the session.

Persisted configuration is untouched, so a later wizard rebuild
reconnects with the kept settings. Idempotent when already off.
"""

def disconnect_agent() -> None:
old = provider_box[0]
provider_box[0] = None
if old is not None:
_close_provider_in_background(old, close_tasks)

return disconnect_agent


def _load_startup_config(
Expand Down Expand Up @@ -910,15 +938,17 @@ async def fetch(
# the agent system prompt and the Service/Ingress describe footer.
provider_info = await _probe_cloud_provider(kube)

agent_runtime, configurator, rebuild_agent, retarget_agent, _, ui_proxy = _build_agent_wiring(
config,
kube,
aliases,
pod_resize_supported=pod_resize_supported,
cluster_context=cluster_context_note(provider_info),
# Ownership lands in the teardown guard's box the moment the
# provider exists, so partial agent wiring is also cleaned up.
provider_box=state.provider_box,
agent_runtime, configurator, rebuild_agent, retarget_agent, disconnect_agent, _, ui_proxy = (
_build_agent_wiring(
config,
kube,
aliases,
pod_resize_supported=pod_resize_supported,
cluster_context=cluster_context_note(provider_info),
# Ownership lands in the teardown guard's box the moment the
# provider exists, so partial agent wiring is also cleaned up.
provider_box=state.provider_box,
)
)

mcp_hooks = _MCPAppHooks()
Expand Down Expand Up @@ -949,6 +979,7 @@ async def fetch(
agent_model_name=config.agent_model,
agent_configurator=configurator,
rebuild_agent=rebuild_agent,
disconnect_agent=disconnect_agent,
# The wiring returns no configurator only when the [agent] extra is
# absent — the app then hides the agent panel and its commands.
agent_available=configurator is not None,
Expand Down
47 changes: 46 additions & 1 deletion src/korvid/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,7 @@ def __init__(
agent_model_name: str | None = None,
agent_configurator: AgentConfigurator | None = None,
rebuild_agent: Callable[[AgentSettings], AgentRuntime | None] | None = None,
disconnect_agent: Callable[[], None] | None = None,
agent_available: bool = True,
write_ops: WriteOps | None = None,
audit: AuditLog | None = None,
Expand Down Expand Up @@ -899,6 +900,9 @@ def __init__(
self._agent_model_name = agent_model_name
self._agent_configurator = agent_configurator
self._rebuild_agent = rebuild_agent
#: Releases the live provider on `:ai off` (issue #167) — session
#: state only; persisted configuration is untouched.
self._disconnect_agent = disconnect_agent
#: False when the [agent] extra is absent (issue #73): the agent
#: panel is not mounted and :ai/:model/Ctrl-A are not offered.
self._agent_available = agent_available
Expand All @@ -921,6 +925,9 @@ def __init__(
profile=config.agent_profile or "full",
)
self._agent_task: asyncio.Task[None] | None = None
#: True after :ai off (issue #167): the agent was configured and
#: explicitly disconnected — reconnect hint, not the setup wipe.
self._agent_disconnected = False
# Interrupt-and-submit (issue #170): the latest correction typed
# while a turn runs; started once the cancelled turn is finalized.
self._agent_replacement: str | None = None
Expand Down Expand Up @@ -2929,6 +2936,9 @@ def on_unknown_command(self, message: UnknownCommand) -> None:
if len(parts) > 1 and parts[1].lower() == "follow":
self._handle_agent_follow_command(parts[2:])
return
if len(parts) > 1 and parts[1].lower() == "off":
self._handle_agent_off()
return
self._open_agent_setup()
return
if head == "model" and self._agent_available:
Expand Down Expand Up @@ -2968,6 +2978,33 @@ def on_unknown_command(self, message: UnknownCommand) -> None:
severity="warning",
)

def _handle_agent_off(self) -> None:
"""`:ai off` (issue #167): disconnect the runtime for this session.

Keeps the configured provider/model/profile/credentials so bare
`:ai` reconnects without re-entry; never rewrites `agent.enabled`
Comment thread
hellices marked this conversation as resolved.
or the persisted config. Refused while a turn runs — cancelling
midway is the interrupt key's job, not a state command's.
"""
if self._agent_runtime is None:
self.notify("Agent is already off")
return
if self._agent_task is not None and not self._agent_task.done():
self.notify(
"Agent is busy — wait for the turn to finish (or stop it) before :ai off",
severity="warning",
)
return
if self._disconnect_agent is not None:
self._disconnect_agent()
self._agent_runtime = None
# Disconnected-but-configured (vs never-configured): visibility
# toggles must show the reconnect hint, never the setup wipe.
self._agent_disconnected = True
self._refresh_status()
self._agent_panel.show_reconnect_hint()
Comment thread
hellices marked this conversation as resolved.
self.notify("Agent disconnected — run :ai to reconnect")

def _open_agent_setup(self) -> None:
if self._agent_configurator is None:
self.notify(
Expand All @@ -2982,6 +3019,7 @@ def _open_agent_setup(self) -> None:
self._agent_configurator,
apply_settings=self._apply_agent_settings,
current_profile=self._configured_agent_profile,
current_settings=self._agent_settings,
)
)

Expand Down Expand Up @@ -3275,6 +3313,7 @@ def _apply_agent_settings(self, settings: AgentSettings) -> bool:
)
return False
self._agent_runtime = runtime
self._agent_disconnected = False # reconnected (issue #167)
self._agent_model_name = settings.model
self._agent_settings = settings
self._agent_profile = settings.profile
Expand Down Expand Up @@ -8366,7 +8405,13 @@ def action_toggle_agent(self) -> None:
return
panel.display = True
if self._agent_runtime is None:
panel.show_setup_hint()
if self._agent_disconnected:
# Disconnected-but-configured (:ai off, issue #167): the
# transcript must survive visibility toggles — never the
# setup wipe meant for a never-configured agent.
panel.show_reconnect_hint()
else:
panel.show_setup_hint()
return
if self._agent_model_name:
runtime = self._agent_runtime
Expand Down
10 changes: 10 additions & 0 deletions src/korvid/ui/widgets/agent_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,16 @@ def show_setup_hint(self) -> None:
chat.mount(ChatEntry(_SETUP_HINT, raw=_SETUP_HINT, classes="agent-msg"))
self.query_one("#agent-input", Input).disabled = True

def show_reconnect_hint(self) -> None:
"""Disconnected state (issue #167): the transcript stays, prompt
submission is disabled, and the way back is named. Idempotent —
repeated panel toggles must not stack hint entries."""
hint = "agent off — run :ai to reconnect"
entries = list(self.query(ChatEntry))
if not entries or entries[-1].raw != hint:
self._mount_entry(ChatEntry(Text(hint, style="dim"), raw=hint, classes="agent-msg"))
self.query_one("#agent-input", Input).disabled = True

def echo_user(self, text: str) -> None:
"""Show a user message immediately, before its turn starts.

Expand Down
63 changes: 63 additions & 0 deletions src/korvid/ui/widgets/agent_setup_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@
"ollama": "ollama — local models, native API (no auth)",
}

# Registry aliases (providers/registry.py) that all resolve to the wizard's
# openai-compat entry: settings configured under an alias must still
# pre-highlight and prefill the wizard on reconnect (issue #167).
_OPENAI_COMPAT_ALIASES = frozenset({"openai", "vllm", "github", "anthropic", "claude"})


def _canonical_provider(name: str | None) -> str | None:
"""The wizard entry a configured provider name maps onto, or None."""
if not name:
return None
lowered = name.strip().lower()
if lowered in _DEFAULTS:
return lowered
if lowered in _OPENAI_COMPAT_ALIASES:
return "openai-compat"
return None


class AgentSetupScreen(ModalScreen["AgentSettings | None"]):
"""Conversational wizard: one question at a time + completed-step checklist."""
Expand Down Expand Up @@ -76,6 +93,7 @@ def __init__(
configurator: AgentConfigurator,
apply_settings: Callable[[AgentSettings], bool] | None = None,
current_profile: str | None = None,
current_settings: AgentSettings | None = None,
) -> None:
super().__init__()
self._configurator = configurator
Expand All @@ -84,6 +102,14 @@ def __init__(
# explicit choice is preserved; only an unset profile receives the
# Ollama `small` suggestion (issue #71).
self._current_profile = current_profile
# Kept settings from a configured (possibly :ai off'd) agent
# (issue #167): the wizard starts from them so reconnecting is
# confirm-through, not re-entry. Registry aliases normalize onto
# the wizard's canonical entries for highlighting/prefilling.
self._current_settings = current_settings
self._current_canonical = _canonical_provider(
current_settings.provider if current_settings is not None else None
)
self._provider = ""
self._auth_method = ""
self._base_url: str | None = None
Expand Down Expand Up @@ -122,6 +148,8 @@ def on_mount(self) -> None:
self.query_one(widget_id).display = False
provider_list = self.query_one("#setup-provider", OptionList)
provider_list.highlighted = 0
if self._current_canonical is not None:
provider_list.highlighted = list(_DEFAULTS).index(self._current_canonical)
provider_list.focus()

# ------------------------------------------------------------------
Expand Down Expand Up @@ -154,9 +182,28 @@ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> No
auth_list = self.query_one("#setup-auth", OptionList)
auth_list.display = True
auth_list.highlighted = 0
current = self._current_settings
if (
self._current_canonical == "azure"
and current is not None
and current.auth_method == "entra"
):
# Confirm-through reconnect must not silently switch
# the retained Entra flow to api_key (issue #167).
auth_list.highlighted = 1
auth_list.focus()
return
self._auth_method = _DEFAULTS[self._provider][0]
current = self._current_settings
if (
current is not None
and self._current_canonical == self._provider
and current.auth_method
):
# Confirm-through reconnect keeps the retained auth method:
# a no-auth endpoint (local vLLM) must not be reset to
# api_key and prompted for a nonexistent key env.
self._auth_method = current.auth_method
self._after_auth_method()
elif event.option_list.id == "setup-auth":
self._auth_method = str(event.option.prompt)
Expand All @@ -171,6 +218,11 @@ def _after_auth_method(self) -> None:
self.run_worker(self._copilot_connect(), exclusive=True)
return
_, base_url, _ = _DEFAULTS[self._provider]
current = self._current_settings
if current is not None and self._current_canonical == self._provider and current.base_url:
# Same provider as the kept settings: start from the kept
# endpoint, not the provider default (issue #167 reconnect).
base_url = current.base_url
self._ask(f"Where is your {self._provider} endpoint?")
base_input = self.query_one("#setup-base-url", Input)
base_input.value = base_url
Expand All @@ -185,6 +237,13 @@ def on_input_submitted(self, event: Input.Submitted) -> None:
if self._auth_method == "api_key":
self._ask("Which environment variable holds your API key?")
env_input = self.query_one("#setup-api-key-env", Input)
current = self._current_settings
if (
current is not None
and self._current_canonical == self._provider
and current.api_key_env
):
env_input.value = current.api_key_env
env_input.display = True
env_input.focus()
else:
Expand Down Expand Up @@ -249,6 +308,10 @@ def _draft_settings(self, model: str) -> AgentSettings:
def _show_model_step(self, models: list[str]) -> None:
self._models = models
default_model = _DEFAULTS[self._provider][2]
current = self._current_settings
if current is not None and self._current_canonical == self._provider and current.model:
# Reconnect flow (issue #167): the kept model beats the default.
default_model = current.model
if models:
self._ask(f"Choose a model ({len(models)} available)")
self.query_one("#setup-model-filter", Input).display = True
Expand Down
Loading
Loading