From 369b6082a64ccacf397d4bb338bfa03ade0140a4 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 30 May 2026 17:26:54 -0500 Subject: [PATCH] feat(familiars): add access tier so build-tier familiars can use git/shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `access` field to `CovenFamiliar` (`~/.coven/familiars.toml`) and wires Coven familiars into the existing agent-mode pipeline so the familiar's access tier (`full`/`read-only`/`search-only`) controls which tools the familiar can invoke when selected via `--agent ` or the `/agents` picker. Default is `read-only` — write/exec is opt-in per familiar. Changes: - `coven_shared::CovenFamiliar` gains `access: Option` with a `resolved_access()` accessor that falls back to `DEFAULT_FAMILIAR_ACCESS` (`"read-only"`). - New `familiar_to_agent_definition()` and `default_agents_with_familiars()` helpers convert each familiar to an `AgentDefinition` and merge them with the built-in agents (built-ins win on collision). - CLI agent merge (headless `--agent` flow and interactive agent-mode switch) now uses `default_agents_with_familiars()` so familiars take part in tool filtering exactly like `build`/`plan`/`explore`. - `/agents` picker: selecting a familiar from the list (or its detail view) now activates it as the session's agent mode and closes the menu; user-defined agents continue to open the editor. Tests: new unit coverage in `coven_shared` for `resolved_access()`, `familiar_to_agent_definition()`, and the merged-agents helper; new unit tests in `agents_view` for `familiar_id_from_source` and the `confirm_selection` return paths (familiar vs user agent). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/familiars.md | 57 +++- src-rust/crates/cli/src/main.rs | 10 +- src-rust/crates/core/src/coven_shared.rs | 185 +++++++++++++ src-rust/crates/tui/src/agents_view.rs | 328 +++++++++++++++-------- src-rust/crates/tui/src/app.rs | 19 +- 5 files changed, 474 insertions(+), 125 deletions(-) diff --git a/docs/familiars.md b/docs/familiars.md index 6df5d74..0d548af 100644 --- a/docs/familiars.md +++ b/docs/familiars.md @@ -129,7 +129,8 @@ display_name = "Dev" emoji = "🤖" role = "Code Agent" description = "Fast, focused code implementation and review." -pronounces = "they/them" +pronouns = "they/them" +access = "full" [[familiar]] id = "research" @@ -137,6 +138,7 @@ display_name = "Research" emoji = "🧙" role = "Research & Reasoning" description = "Deep research, synthesis, and structured thinking." +# access omitted → defaults to "read-only" [[familiar]] id = "writer" @@ -144,7 +146,8 @@ display_name = "Writer" emoji = "✍️" role = "Writing & Communication" description = "Clear writing, docs, and async communication." -pronounces = "she/her" +pronouns = "she/her" +access = "read-only" ``` ### Fields @@ -157,6 +160,56 @@ pronounces = "she/her" | `role` | | Short role label — shown in the detail view and persona prefix. | | `description` | | Full description used to build the persona system prompt. | | `pronouns` | | Appended to the persona prompt if present. | +| `access` | | Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`. Defaults to `"read-only"` when omitted. See [Tool access tiers](#tool-access-tiers) below. | + +--- + +## Tool access tiers + +The `access` field controls **which tools** a familiar may invoke once you select them as the active agent (via `--agent ` or the `/agents` picker). The same tool-filter pipeline used for the built-in `build` / `plan` / `explore` modes applies, so the rules are consistent across the product. + +| Tier | What the familiar can do | Typical role | +|---|---|---| +| `full` | Read, write, and execute — full tool set (Edit/Write/Bash/etc.) | Build-tier familiars: `cody`, `nova`, `kitty` | +| `read-only` | Read & search the workspace, no writes or shell. **Default.** | Research/strategy familiars: `sage`, `astra`, `echo` | +| `search-only` | Web/search lookups only — no filesystem access | Pure-research personas with no codebase context | + +### Why the default is restrictive + +`access` defaults to `read-only`. Granting write/exec power is **opt-in**: you must set `access = "full"` explicitly on a familiar to let it edit files or run shell commands. This avoids surprise when a freshly-defined familiar (perhaps written for a research role) accidentally gains the ability to mutate the workspace. + +### Recommended defaults per role + +| Role | Suggested `access` | +|---|---| +| Code / Build / Ship | `"full"` | +| General Helper / Assistant | `"full"` (set if you want them to edit/run; otherwise leave to default) | +| Orchestrator / Queen | `"full"` (they coordinate work that requires writes) | +| Research / Synthesis | `"read-only"` (default — keep them honest) | +| Strategy / Navigation | `"read-only"` (default) | +| Memory / Reflection | `"read-only"` (default) | +| Comms / Social | `"read-only"` (default) | + +### Example: minimal opt-in roster + +```toml +# Build-tier — can edit and run. +[[familiar]] +id = "cody" +display_name = "Cody" +role = "Code" +access = "full" + +# Research-tier — read-only by default (no `access` line needed). +[[familiar]] +id = "sage" +display_name = "Sage" +role = "Research" +``` + +### How `access` interacts with `settings.json` agents + +User-defined agents in `.coven-code/agents/*.md` or `settings.json` continue to win on id collisions. Familiars are merged after the built-in `build` / `plan` / `explore` agents, before any user-defined agents — so a workspace override of the same name shadows the familiar entirely (including its `access` value). --- diff --git a/src-rust/crates/cli/src/main.rs b/src-rust/crates/cli/src/main.rs index 6ac509f..73f35d2 100644 --- a/src-rust/crates/cli/src/main.rs +++ b/src-rust/crates/cli/src/main.rs @@ -766,10 +766,11 @@ async fn main() -> anyhow::Result<()> { query_config.provider_registry = Some(provider_registry.clone()); // Wire in the named agent (--agent flag). - // Merge built-in default agents with user-defined agents (user wins on collision). + // Merge built-in default agents + Coven familiars with user-defined agents. + // Order: built-ins → familiars (built-ins win) → settings.json agents (user wins). let tools = if let Some(ref agent_name) = cli.agent { query_config.agent_name = Some(agent_name.clone()); - let mut all_agents = claurst_core::default_agents(); + let mut all_agents = claurst_core::coven_shared::default_agents_with_familiars(); all_agents.extend(config.agents.clone()); if let Some(def) = all_agents.get(agent_name) { let access = def.access.clone(); @@ -2791,11 +2792,12 @@ async fn run_interactive( if !app.model_name.is_empty() { session.model = app.model_name.clone(); } - // Handle agent mode change (Tab key cycles build→plan→explore) + // Handle agent mode change (Tab key cycles build→plan→explore; + // /agents picker can also select a Coven familiar). if app.agent_mode_changed { app.agent_mode_changed = false; let mode = app.agent_mode.as_deref().unwrap_or("build"); - let mut all_agents = claurst_core::default_agents(); + let mut all_agents = claurst_core::coven_shared::default_agents_with_familiars(); all_agents.extend(cmd_ctx.config.agents.clone()); if let Some(def) = all_agents.get(mode) { base_query_config.agent_name = Some(mode.to_string()); diff --git a/src-rust/crates/core/src/coven_shared.rs b/src-rust/crates/core/src/coven_shared.rs index 960885e..50d3b16 100644 --- a/src-rust/crates/core/src/coven_shared.rs +++ b/src-rust/crates/core/src/coven_shared.rs @@ -39,6 +39,12 @@ pub(crate) static COVEN_HOME_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex:: // Familiars (~/.coven/familiars.toml) // --------------------------------------------------------------------------- +/// Default tool-access tier applied when a familiar omits the `access` field. +/// +/// Intentionally restrictive — write/exec power is opt-in by setting +/// `access = "full"` per familiar in `~/.coven/familiars.toml`. +pub const DEFAULT_FAMILIAR_ACCESS: &str = "read-only"; + /// One entry in `~/.coven/familiars.toml`. /// /// Schema mirrors what the daemon serves at `GET /api/v1/familiars`. @@ -55,6 +61,17 @@ pub struct CovenFamiliar { pub description: Option, #[serde(default)] pub pronouns: Option, + /// Tool-access tier: `"full"`, `"read-only"`, or `"search-only"`. + /// Absent → [`DEFAULT_FAMILIAR_ACCESS`] (`"read-only"`). + #[serde(default)] + pub access: Option, +} + +impl CovenFamiliar { + /// Resolved access tier — the explicit value or [`DEFAULT_FAMILIAR_ACCESS`]. + pub fn resolved_access(&self) -> &str { + self.access.as_deref().unwrap_or(DEFAULT_FAMILIAR_ACCESS) + } } #[derive(Debug, Deserialize)] @@ -76,6 +93,65 @@ pub fn load_familiars() -> Option> { } } +/// Build a [`crate::config::AgentDefinition`] from a familiar so it can be +/// selected through the same `--agent` / agent-mode plumbing as built-in +/// agents. Returns `(id, def)` keyed on the familiar's lowercase id. +/// +/// The familiar's `access` tier flows into [`crate::config::AgentDefinition::access`] +/// so the existing tool-filter pipeline in the CLI is the single source of +/// truth for what tools a familiar can use. +pub fn familiar_to_agent_definition( + fam: &CovenFamiliar, +) -> (String, crate::config::AgentDefinition) { + let display = fam.display_name.as_deref().unwrap_or(&fam.id).to_string(); + let emoji = fam.emoji.as_deref().unwrap_or("✨"); + let role = fam.role.as_deref().unwrap_or("Familiar"); + let desc_body = fam + .description + .as_deref() + .unwrap_or("A Coven familiar persona.") + .to_string(); + let pronouns = fam + .pronouns + .as_deref() + .map(|p| format!(" Pronouns: {p}.")) + .unwrap_or_default(); + + let prompt = format!( + "You are {emoji} {display}, a Coven familiar with the role of {role}.{pronouns}\n\n{desc_body}\n\nStay in character and remain focused on the developer's goals." + ); + + let def = crate::config::AgentDefinition { + description: Some(format!("{emoji} {role} — {desc_body}")), + model: None, + temperature: None, + prompt: Some(prompt), + access: fam.resolved_access().to_string(), + visible: true, + max_turns: None, + color: None, + }; + (fam.id.to_lowercase(), def) +} + +/// Return the merged built-in + familiar agent map. +/// +/// Built-in agents win on id collision (familiars share lowercase keyspace +/// with `build`/`plan`/`explore`, so collisions are unexpected — but the +/// rule keeps `build` etc. inviolate). Callers extend with user-defined +/// `config.agents` afterwards so user overrides still win. +pub fn default_agents_with_familiars( +) -> std::collections::HashMap { + let mut map = crate::config::default_agents(); + if let Some(fams) = load_familiars() { + for fam in &fams { + let (id, def) = familiar_to_agent_definition(fam); + map.entry(id).or_insert(def); + } + } + map +} + // --------------------------------------------------------------------------- // Skills (~/.coven/skills//metadata.json) // --------------------------------------------------------------------------- @@ -220,6 +296,115 @@ role = "General Helper" assert!(load_familiars().is_none()); } + #[test] + fn familiar_access_defaults_to_read_only_when_absent() { + let _g = with_coven_home(|home| { + fs::write( + home.join("familiars.toml"), + r#" +[[familiar]] +id = "sage" +display_name = "Sage" +role = "Research" +"#, + ) + .unwrap(); + }); + let familiars = load_familiars().expect("should parse"); + assert!(familiars[0].access.is_none()); + assert_eq!(familiars[0].resolved_access(), DEFAULT_FAMILIAR_ACCESS); + assert_eq!(familiars[0].resolved_access(), "read-only"); + } + + #[test] + fn familiar_access_parses_explicit_tiers() { + let _g = with_coven_home(|home| { + fs::write( + home.join("familiars.toml"), + r#" +[[familiar]] +id = "cody" +access = "full" + +[[familiar]] +id = "sage" +access = "read-only" + +[[familiar]] +id = "scout" +access = "search-only" +"#, + ) + .unwrap(); + }); + let familiars = load_familiars().expect("should parse"); + assert_eq!(familiars[0].resolved_access(), "full"); + assert_eq!(familiars[1].resolved_access(), "read-only"); + assert_eq!(familiars[2].resolved_access(), "search-only"); + } + + #[test] + fn familiar_to_agent_definition_threads_access_tier() { + let fam = CovenFamiliar { + id: "Cody".to_string(), + display_name: Some("Cody".to_string()), + emoji: Some("⚡".to_string()), + role: Some("Code".to_string()), + description: Some("Builds and ships.".to_string()), + pronouns: None, + access: Some("full".to_string()), + }; + let (id, def) = familiar_to_agent_definition(&fam); + assert_eq!(id, "cody", "id should be lowercased for map keys"); + assert_eq!(def.access, "full"); + assert!(def.visible); + let prompt = def.prompt.as_deref().unwrap_or(""); + assert!(prompt.contains("Cody")); + assert!(prompt.contains("Code")); + } + + #[test] + fn familiar_to_agent_definition_defaults_to_read_only() { + let fam = CovenFamiliar { + id: "sage".to_string(), + display_name: None, + emoji: None, + role: None, + description: None, + pronouns: None, + access: None, + }; + let (_id, def) = familiar_to_agent_definition(&fam); + assert_eq!(def.access, "read-only"); + } + + #[test] + fn default_agents_with_familiars_merges_without_clobbering_builtins() { + let _g = with_coven_home(|home| { + fs::write( + home.join("familiars.toml"), + r#" +[[familiar]] +id = "cody" +display_name = "Cody" +role = "Code" +access = "full" + +[[familiar]] +id = "build" # collides with built-in; built-in must win +display_name = "Imposter" +access = "search-only" +"#, + ) + .unwrap(); + }); + let merged = default_agents_with_familiars(); + // Built-in `build` is untouched. + assert_eq!(merged.get("build").map(|d| d.access.as_str()), Some("full")); + // Familiar `cody` was merged in with its declared access. + assert_eq!(merged.get("cody").map(|d| d.access.as_str()), Some("full")); + } + #[test] fn list_daemon_skills_scans_metadata_files() { let _g = with_coven_home(|home| { diff --git a/src-rust/crates/tui/src/agents_view.rs b/src-rust/crates/tui/src/agents_view.rs index 765be4d..f87bc42 100644 --- a/src-rust/crates/tui/src/agents_view.rs +++ b/src-rust/crates/tui/src/agents_view.rs @@ -8,10 +8,10 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph, Widget}, }; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use std::time::{Duration, Instant}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; use claurst_core::coven_shared; @@ -289,30 +289,38 @@ impl AgentsMenuState { self.selected_row = (self.selected_row + 1) % row_count; } - pub fn confirm_selection(&mut self) { + /// Confirm the current selection. + /// + /// Returns `Some((id, display))` when the user picked a Coven familiar so + /// the caller can activate it as the session's agent mode (familiars are + /// read-only, so we do not push them into the editor). `None` means the + /// menu navigated to a new route (Detail/Editor) or no-op'd. + pub fn confirm_selection(&mut self) -> Option<(String, String)> { match self.route { AgentsRoute::List => { if self.selected_row == 0 { self.open_editor(None); - } else { - let idx = self.selected_row - 1; - if idx < self.definitions.len() { - self.route = AgentsRoute::Detail(idx); + return None; + } + let idx = self.selected_row - 1; + if let Some(def) = self.definitions.get(idx) { + if let Some(id) = familiar_id_from_source(&def.source) { + return Some((id, def.name.clone())); } + self.route = AgentsRoute::Detail(idx); } + None } AgentsRoute::Detail(idx) => { - // Coven familiars are read-only — do not open editor. - let is_familiar = self - .definitions - .get(idx) - .map(|d| d.source.starts_with("coven:familiar")) - .unwrap_or(false); - if !is_familiar { + if let Some(def) = self.definitions.get(idx) { + if let Some(id) = familiar_id_from_source(&def.source) { + return Some((id, def.name.clone())); + } self.open_editor(Some(idx)); } + None } - AgentsRoute::Editor(_) => {} + AgentsRoute::Editor(_) => None, } } @@ -437,8 +445,8 @@ pub fn load_agent_definitions(project_root: &std::path::Path) -> Vec Vec Vec { Vec::new() } -fn slugify_agent_name(name: &str) -> String { +/// Extract the familiar id slug from an `AgentDefinition::source` string. +/// +/// `coven:familiar:` → `Some("")` (lowercased). Anything else → `None`. +fn familiar_id_from_source(source: &str) -> Option { + source + .strip_prefix("coven:familiar:") + .map(|s| s.to_lowercase()) +} + +fn slugify_agent_name(name: &str) -> String { let mut slug = String::new(); for ch in name.chars() { if ch.is_ascii_alphanumeric() { @@ -591,95 +608,170 @@ fn slugify_agent_name(name: &str) -> String { slug.push('-'); } } - slug.trim_matches('-').to_string() -} - -const DAEMON_STATUS_CACHE_TTL: Duration = Duration::from_secs(2); - -type DaemonStatusCache = Option<(Instant, HashMap)>; - -fn daemon_status_cache() -> &'static Mutex { - static CACHE: OnceLock> = OnceLock::new(); - CACHE.get_or_init(|| Mutex::new(None)) -} - -fn daemon_familiar_statuses() -> HashMap { - let cache = daemon_status_cache(); - if let Ok(guard) = cache.lock() { - if let Some((loaded_at, statuses)) = &*guard { - if loaded_at.elapsed() < DAEMON_STATUS_CACHE_TTL { - return statuses.clone(); - } - } - } - - let statuses: HashMap = coven_shared::DaemonClient::new() - .map(|client| { - client - .familiar_statuses() - .into_iter() - .filter(|status| familiar_live_badge(status).is_some()) - .map(|status| (status.id.clone(), status)) - .collect() - }) - .unwrap_or_default(); - - if let Ok(mut guard) = cache.lock() { - *guard = Some((Instant::now(), statuses.clone())); - } - statuses -} - -fn familiar_live_badge(live: &coven_shared::FamiliarStatus) -> Option { - if live.active_sessions > 0 { - return Some(format!(" · active ({} sessions)", live.active_sessions)); - } - - match live.status.as_str() { - "active" | "online" => Some(" · online".to_string()), - "offline" | "unknown" | "" => None, - status => Some(format!(" · {status}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn status(status: &str, active_sessions: u32) -> coven_shared::FamiliarStatus { - coven_shared::FamiliarStatus { - id: "sage".to_string(), - display_name: "Sage".to_string(), - emoji: String::new(), - status: status.to_string(), - active_sessions, - memory_freshness: String::new(), - } - } - - #[test] - fn familiar_live_badge_omits_static_offline_status() { - assert_eq!(familiar_live_badge(&status("offline", 0)), None); - } - - #[test] - fn familiar_live_badge_preserves_idle_without_calling_it_offline() { - assert_eq!( - familiar_live_badge(&status("idle", 0)), - Some(" · idle".to_string()) - ); - } - - #[test] - fn familiar_live_badge_prefers_active_session_count() { - assert_eq!( - familiar_live_badge(&status("offline", 2)), - Some(" · active (2 sessions)".to_string()) - ); - } -} - -fn validate_editor(editor: &AgentEditorState) -> Result<(), String> { + slug.trim_matches('-').to_string() +} + +const DAEMON_STATUS_CACHE_TTL: Duration = Duration::from_secs(2); + +type DaemonStatusCache = Option<(Instant, HashMap)>; + +fn daemon_status_cache() -> &'static Mutex { + static CACHE: OnceLock> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(None)) +} + +fn daemon_familiar_statuses() -> HashMap { + let cache = daemon_status_cache(); + if let Ok(guard) = cache.lock() { + if let Some((loaded_at, statuses)) = &*guard { + if loaded_at.elapsed() < DAEMON_STATUS_CACHE_TTL { + return statuses.clone(); + } + } + } + + let statuses: HashMap = coven_shared::DaemonClient::new() + .map(|client| { + client + .familiar_statuses() + .into_iter() + .filter(|status| familiar_live_badge(status).is_some()) + .map(|status| (status.id.clone(), status)) + .collect() + }) + .unwrap_or_default(); + + if let Ok(mut guard) = cache.lock() { + *guard = Some((Instant::now(), statuses.clone())); + } + statuses +} + +fn familiar_live_badge(live: &coven_shared::FamiliarStatus) -> Option { + if live.active_sessions > 0 { + return Some(format!(" · active ({} sessions)", live.active_sessions)); + } + + match live.status.as_str() { + "active" | "online" => Some(" · online".to_string()), + "offline" | "unknown" | "" => None, + status => Some(format!(" · {status}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn status(status: &str, active_sessions: u32) -> coven_shared::FamiliarStatus { + coven_shared::FamiliarStatus { + id: "sage".to_string(), + display_name: "Sage".to_string(), + emoji: String::new(), + status: status.to_string(), + active_sessions, + memory_freshness: String::new(), + } + } + + #[test] + fn familiar_live_badge_omits_static_offline_status() { + assert_eq!(familiar_live_badge(&status("offline", 0)), None); + } + + #[test] + fn familiar_live_badge_preserves_idle_without_calling_it_offline() { + assert_eq!( + familiar_live_badge(&status("idle", 0)), + Some(" · idle".to_string()) + ); + } + + #[test] + fn familiar_live_badge_prefers_active_session_count() { + assert_eq!( + familiar_live_badge(&status("offline", 2)), + Some(" · active (2 sessions)".to_string()) + ); + } + + #[test] + fn familiar_id_from_source_parses_coven_familiar_prefix() { + assert_eq!( + familiar_id_from_source("coven:familiar:cody"), + Some("cody".to_string()) + ); + assert_eq!( + familiar_id_from_source("coven:familiar:Nova"), + Some("nova".to_string()) + ); + assert_eq!(familiar_id_from_source("user"), None); + assert_eq!(familiar_id_from_source("plugin:foo"), None); + } + + fn familiar_def(id: &str, display: &str) -> AgentDefinition { + AgentDefinition { + file_path: std::path::PathBuf::from("/tmp/familiars.toml"), + name: display.to_string(), + source: format!("coven:familiar:{}", id), + model: None, + memory_scope: Some("workspace".to_string()), + description: format!("✨ Familiar — {}", display), + tools: Vec::new(), + shadowed_by: None, + instructions: format!("You are {}.", display), + } + } + + fn user_def(name: &str) -> AgentDefinition { + AgentDefinition { + file_path: std::path::PathBuf::from(format!("/tmp/.coven-code/agents/{}.md", name)), + name: name.to_string(), + source: "user".to_string(), + model: None, + memory_scope: None, + description: "A workspace agent".to_string(), + tools: Vec::new(), + shadowed_by: None, + instructions: "You are a workspace agent.".to_string(), + } + } + + #[test] + fn confirm_selection_returns_familiar_id_from_list_route() { + let mut state = AgentsMenuState::new(); + state.definitions = vec![user_def("review"), familiar_def("cody", "Cody")]; + state.route = AgentsRoute::List; + // selected_row 0 = "Create new"; row 1 = first def (user); row 2 = second def (familiar). + state.selected_row = 2; + let result = state.confirm_selection(); + assert_eq!(result, Some(("cody".to_string(), "Cody".to_string()))); + // List route is unchanged — caller is responsible for closing the menu. + assert!(matches!(state.route, AgentsRoute::List)); + } + + #[test] + fn confirm_selection_navigates_to_detail_for_user_agents() { + let mut state = AgentsMenuState::new(); + state.definitions = vec![user_def("review")]; + state.route = AgentsRoute::List; + state.selected_row = 1; + let result = state.confirm_selection(); + assert_eq!(result, None); + assert!(matches!(state.route, AgentsRoute::Detail(0))); + } + + #[test] + fn confirm_selection_returns_familiar_id_from_detail_route() { + let mut state = AgentsMenuState::new(); + state.definitions = vec![familiar_def("nova", "Nova")]; + state.route = AgentsRoute::Detail(0); + let result = state.confirm_selection(); + assert_eq!(result, Some(("nova".to_string(), "Nova".to_string()))); + } +} + +fn validate_editor(editor: &AgentEditorState) -> Result<(), String> { let name = editor.name.trim(); if name.is_empty() { return Err("Familiar name is required.".to_string()); diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 50a9eee..af74078 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -4610,12 +4610,29 @@ impl App { KeyCode::Esc | KeyCode::Char('q') | KeyCode::Backspace => self.agents_menu.go_back(), KeyCode::Up | KeyCode::Char('k') => self.agents_menu.select_prev(), KeyCode::Down | KeyCode::Char('j') => self.agents_menu.select_next(), - KeyCode::Enter | KeyCode::Right => self.agents_menu.confirm_selection(), + KeyCode::Enter | KeyCode::Right => { + if let Some((id, display)) = self.agents_menu.confirm_selection() { + self.activate_familiar_agent(id, display); + } + } KeyCode::Left => self.agents_menu.go_back(), _ => {} } } + /// Activate a Coven familiar as the session's agent mode and close the picker. + /// + /// Sets `agent_mode_changed` so the main loop swaps `query_config.agent_definition` + /// and re-filters the tool list according to the familiar's access tier. + fn activate_familiar_agent(&mut self, id: String, display: String) { + self.agents_menu.close(); + self.agent_mode = Some(id); + self.agent_mode_changed = true; + self.accent_color = accent_for_mode(self.agent_mode.as_deref()); + self.plan_mode = false; + self.status_message = Some(format!("Switched to {} familiar.", display)); + } + fn handle_diff_viewer_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => self.diff_viewer.close(),