From d35370b9c9c4e68e90b04ff0183b8ba28c501741 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Fri, 19 Jun 2026 10:10:13 -0400 Subject: [PATCH] merge fork work onto public: cockpit/portrait/sidebar widgets, select_list, expression_cache, sensorium+settings rework new TUI work that had been living in a detached re-init'd checkout, replayed onto the public lineage. keeps CI + skills. src-only; local scaffolding stays local. --- src/backend/remote.rs | 47 +- src/bridge/mod.rs | 99 +-- src/core/compact/mod.rs | 15 +- src/core/config.rs | 266 +++++++- src/core/sensorium/matrix/mod.rs | 33 +- src/main.rs | 6 +- src/server/mod.rs | 24 +- src/ui/app/settings_handler.rs | 1 + src/ui/chat/events.rs | 13 + src/ui/chat/mod.rs | 26 +- src/ui/screens/agents.rs | 868 +++++++++++++++++++++--- src/ui/screens/chat/convert.rs | 354 ++++++++++ src/ui/screens/{chat.rs => chat/mod.rs} | 424 +++--------- src/ui/screens/cron.rs | 206 ++---- src/ui/screens/manager.rs | 240 +++---- src/ui/screens/mod.rs | 1 - src/ui/screens/presence.rs | 6 +- src/ui/screens/settings/field_grid.rs | 2 +- src/ui/screens/settings/mod.rs | 64 +- src/ui/screens/welcome.rs | 92 ++- src/ui/settings/draw.rs | 2 +- src/ui/settings/key_handling.rs | 6 +- src/ui/settings/types.rs | 95 ++- src/ui/settings/view.rs | 196 +++++- src/ui/tuie_app.rs | 161 ++++- src/ui/widgets/btw_pane.rs | 12 +- src/ui/widgets/chat_overlay.rs | 11 +- src/ui/widgets/chat_sidebar.rs | 199 +++++- src/ui/widgets/cockpit.rs | 168 ++++- src/ui/widgets/expression_cache.rs | 206 ++++++ src/ui/widgets/footer.rs | 16 +- src/ui/widgets/itinerary_strip.rs | 8 +- src/ui/widgets/message_list.rs | 5 +- src/ui/widgets/mod.rs | 4 +- src/ui/widgets/phase_bar.rs | 85 +++ src/ui/widgets/portrait.rs | 110 ++- src/ui/widgets/responsive.rs | 4 +- src/ui/widgets/select_list.rs | 448 ++++++++++++ src/ui/widgets/stats.rs | 44 ++ src/ui/widgets/tool_card.rs | 63 +- 40 files changed, 3594 insertions(+), 1036 deletions(-) create mode 100644 src/ui/screens/chat/convert.rs rename src/ui/screens/{chat.rs => chat/mod.rs} (80%) create mode 100644 src/ui/widgets/expression_cache.rs create mode 100644 src/ui/widgets/select_list.rs create mode 100644 src/ui/widgets/stats.rs diff --git a/src/backend/remote.rs b/src/backend/remote.rs index 69f701a..74575d3 100644 --- a/src/backend/remote.rs +++ b/src/backend/remote.rs @@ -119,17 +119,52 @@ impl Backend for RemoteBackend { self.ensure_conversation(agent_id).await } - async fn list_conversations(&self, _agent_id: &str) -> Result> { - // TODO: implement remote conversation listing via GET /v1/agents/:id/conversations - Ok(Vec::new()) + async fn list_conversations(&self, agent_id: &str) -> Result> { + #[derive(Deserialize)] + struct Wire { + id: String, + #[serde(default)] + agent_id: Option, + #[serde(default)] + summary: Option, + #[serde(default)] + message_count: Option, + #[serde(default)] + updated_at: Option, + } + let url = format!("/v1/conversations?agent_id={}", agent_id); + let resp = self + .auth_req(self.client.get(self.url(&url))) + .send() + .await + .context("GET /v1/conversations")? + .error_for_status()?; + let wires: Vec = resp.json().await?; + Ok(wires + .into_iter() + .map(|w| ConversationInfo { + id: w.id, + agent_id: w.agent_id.unwrap_or_default(), + summary: w.summary, + message_count: w.message_count.unwrap_or(0), + updated_at: w.updated_at.unwrap_or_default(), + }) + .collect()) } async fn load_conversation( &self, - _conversation_id: &str, + conversation_id: &str, ) -> Result> { - // TODO: implement remote conversation loading - anyhow::bail!("Remote conversation loading not yet implemented") + let url = format!("/v1/conversations/{}", conversation_id); + let resp = self + .auth_req(self.client.get(self.url(&url))) + .send() + .await + .context("GET /v1/conversations/:id")? + .error_for_status()?; + let messages: Vec = resp.json().await?; + Ok(messages) } async fn ensure_conversation(&self, agent_id: &str) -> Result { diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index faa4210..a48fed6 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -17,79 +17,70 @@ use std::collections::HashMap; use std::sync::Arc; use crate::api::models::AgentState; -use crate::core::config::ConsciousnessConfig; +use crate::core::config::{ConsciousnessConfig, ProviderConfig}; /// Build the active (global default) LLM provider from config. /// -/// `[inference] provider` selects the implementation: -/// - `"bifrost"` (default) — any OpenAI-compatible gateway via `BifrostClient`. -/// - `"openai-oauth"` — ride the Codex CLI's ChatGPT login and drive -/// `backend-api/codex/responses`. -/// -/// Kept for call sites with no agent context (model pickers, CLI probes). For -/// inference, prefer [`build_registry`] + [`ProviderRegistry::for_agent`]. +/// Convenience wrapper that builds the full registry and returns its default. +/// For inference, prefer [`build_registry`] + [`ProviderRegistry::for_agent`]. pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result> { - build_provider_named(&config.inference.provider, config) + let reg = build_registry(config)?; + Ok(reg.default_provider()) } -/// Build a specific named provider. Adding a provider = adding a case here and -/// a config section. Reuses `BifrostClient` (generic OpenAI-compatible) for any -/// gateway-style provider; the OAuth provider is the endpoint-specific special -/// case. -pub fn build_provider_named( +/// Build a provider from a [`ProviderConfig`] entry, dispatching on `provider_type`. +pub fn build_provider_from_config( name: &str, - config: &ConsciousnessConfig, + cfg: &ProviderConfig, ) -> anyhow::Result> { - match name { - "openai-oauth" | "openai-codex" => { + match cfg.provider_type.as_str() { + "openai-oauth" => { let provider = providers::openai_oauth::OpenAiOAuthProvider::from_codex_login( - config.bifrost.primary_model.clone(), - config.bifrost.timeout_secs, + cfg.primary_model.clone(), + cfg.timeout_secs, )?; Ok(Arc::new(provider)) } - "zai" => { - let z = &config.zai; - let client = BifrostClient::new( - &z.base_url, - &z.api_key, - "", // no virtual-key header for z.ai - &z.primary_model, - z.timeout_secs, - )? - .with_id("zai"); - Ok(Arc::new(client)) - } _ => { - // "bifrost" (and any unrecognized name) → OpenAI-compatible gateway. - let bf = &config.bifrost; + // "openai-compatible" and any unrecognized type → OpenAI-compatible gateway. let client = BifrostClient::new( - &bf.base_url, - &bf.api_key, - &bf.virtual_key, - &bf.primary_model, - bf.timeout_secs, - )?; + &cfg.base_url, + &cfg.api_key, + &cfg.virtual_key, + &cfg.primary_model, + cfg.timeout_secs, + )? + .with_id(name); Ok(Arc::new(client)) } } } -/// Build the per-agent provider registry: the global default provider (from -/// `[inference] provider`) plus every additionally-configured named provider -/// (e.g. `zai` when `[zai]` is set). Engines resolve a provider per agent from -/// this instead of holding one global client. +/// Build the per-agent provider registry from the unified `[providers]` map. +/// +/// Iterates every entry in `config.providers`, builds a provider for each, and +/// resolves the default from `[inference] provider`. Returns an error if no +/// providers are configured. pub fn build_registry(config: &ConsciousnessConfig) -> anyhow::Result { - let default_name = config.inference.provider.clone(); - let default = build_provider_named(&default_name, config)?; let mut map = HashMap::new(); - map.insert(default_name.clone(), default.clone()); - // z.ai (GLM Coding Plan) — register when configured. - if !config.zai.base_url.is_empty() && !config.zai.api_key.is_empty() { - map.insert("zai".to_string(), build_provider_named("zai", config)?); + for (name, pcfg) in &config.providers { + let provider = build_provider_from_config(name, pcfg)?; + map.insert(name.clone(), provider); } + if map.is_empty() { + anyhow::bail!( + "no providers configured — add at least one [providers.] section" + ); + } + + let default_name = config.inference.provider.clone(); + let default = map + .get(&default_name) + .cloned() + .unwrap_or_else(|| map.values().next().unwrap().clone()); + Ok(ProviderRegistry { map, default_name, @@ -127,6 +118,16 @@ impl ProviderRegistry { } } + /// All registered provider names. + pub fn provider_names(&self) -> Vec<&str> { + self.map.keys().map(|s| s.as_str()).collect() + } + + /// Look up a provider by name. Returns `None` for unknown names. + pub fn get(&self, name: &str) -> Option> { + self.map.get(name).cloned() + } + /// The global default provider (for contexts with no agent). pub fn default_provider(&self) -> Arc { self.default.clone() diff --git a/src/core/compact/mod.rs b/src/core/compact/mod.rs index 7e62a73..b7a2e5c 100644 --- a/src/core/compact/mod.rs +++ b/src/core/compact/mod.rs @@ -94,6 +94,8 @@ pub struct DefaultCompactionEngine { Arc) -> anyhow::Result<()> + Send + Sync>, pub get_repo: Arc Option + Send + Sync>, pub get_agent_type: Arc Option + Send + Sync>, + /// Per-agent provider override name (e.g. "zai"). None = use global default. + pub get_agent_provider: Arc Option + Send + Sync>, } #[async_trait] @@ -154,7 +156,11 @@ impl CompactionEngine for DefaultCompactionEngine { let plan = match strategy_kind { CompactionStrategyKind::Summary => match &self.providers { Some(reg) => { - let client = reg.default_provider(); + let provider_name = (self.get_agent_provider)(agent_id); + let client = provider_name + .as_deref() + .and_then(|n| reg.get(n)) + .unwrap_or_else(|| reg.default_provider()); let model = self .model .as_deref() @@ -182,7 +188,11 @@ impl CompactionEngine for DefaultCompactionEngine { } CompactionStrategyKind::SlidingReflect => match &self.providers { Some(reg) => { - let client = reg.default_provider(); + let provider_name = (self.get_agent_provider)(agent_id); + let client = provider_name + .as_deref() + .and_then(|n| reg.get(n)) + .unwrap_or_else(|| reg.default_provider()); let model = self .model .as_deref() @@ -345,6 +355,7 @@ mod tests { replace_messages: Arc::new(|_, _| Ok(())), get_repo: Arc::new(|_| None), get_agent_type: Arc::new(|_| Some("primary".to_string())), + get_agent_provider: Arc::new(|_| None), }; let report = engine diff --git a/src/core/config.rs b/src/core/config.rs index 9dc5766..52336ea 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -12,14 +12,20 @@ pub struct ConsciousnessConfig { pub inference: InferenceConfig, /// Bifrost inference gateway config - #[serde(default)] + #[serde(default, skip_serializing)] pub bifrost: BifrostConfig, /// z.ai (GLM Coding Plan) inference config — an OpenAI-compatible /// provider reachable per-agent via `_souveraine.provider = "zai"`. - #[serde(default)] + #[serde(default, skip_serializing)] pub zai: ZaiConfig, + /// Unified provider map — each entry is a named inference provider. + /// Populated by [`normalize_providers`] from legacy sections on first + /// load. On save, only this map is serialized. + #[serde(default)] + pub providers: HashMap, + /// Per-model physics configs #[serde(default)] pub models: HashMap, @@ -265,6 +271,203 @@ fn default_zai_timeout() -> u64 { 300 } +/// A named inference provider entry in `[providers.]`. +/// +/// Each entry carries a `type` discriminator and endpoint/auth/model defaults. +/// `build_provider_from_config` dispatches on `provider_type` to construct the +/// right [`LlmProvider`](crate::bridge::LlmProvider) implementation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderConfig { + /// Provider implementation: `"openai-compatible"` (any OpenAI-style gateway, + /// including Bifrost and z.ai) or `"openai-oauth"` (Codex CLI ChatGPT login). + #[serde(rename = "type")] + pub provider_type: String, + + /// API base URL (not used by `openai-oauth`). + #[serde(default)] + pub base_url: String, + + /// Bearer token or API key (not used by `openai-oauth`). + #[serde(default)] + pub api_key: String, + + /// Virtual-key header value (`x-bf-vk` for Bifrost; empty for most providers). + #[serde(default)] + pub virtual_key: String, + + /// Default model for this provider. + #[serde(default)] + pub primary_model: String, + + /// Per-attempt timeout in seconds. + #[serde(default = "default_bifrost_timeout")] + pub timeout_secs: u64, +} + +// ── Built-in provider catalog ────────────────────────────────────────────── + +/// A preset for a well-known inference provider. The user can add any of these +/// with one keypress in Settings > Providers. All speak OpenAI-compatible +/// (`/v1/chat/completions`) unless noted otherwise. +#[derive(Debug, Clone)] +pub struct ProviderPreset { + /// Short machine-facing id (e.g. "deepseek"). + pub id: &'static str, + /// Human label for the settings UI (e.g. "DeepSeek"). + pub display_name: &'static str, + /// API base URL for `/v1/chat/completions`. + pub api_base: &'static str, + /// Environment variable that holds the API key. Empty means the user + /// supplies it directly in the config (or the provider has no key, like a + /// localhost endpoint). + pub api_key_env: &'static str, + /// Default model for the provider. `None` falls back to the live `/models` + /// catalog or the user supplies it manually. + pub default_model: Option<&'static str>, + /// Where to get an API key / sign up. + pub setup_url: &'static str, +} + +/// Well-known inference providers. Every entry here can be added to +/// `[providers.]` instantly from the TUI. +pub static KNOWN_PROVIDERS: &[ProviderPreset] = &[ + ProviderPreset { + id: "deepseek", + display_name: "DeepSeek", + api_base: "https://api.deepseek.com", + api_key_env: "DEEPSEEK_API_KEY", + default_model: Some("deepseek-chat"), + setup_url: "https://api-docs.deepseek.com/", + }, + ProviderPreset { + id: "mimo", + display_name: "Xiaomi MiMo", + api_base: "https://api.xiaomimimo.com/v1", + api_key_env: "XIAOMI_MIMO_API_KEY", + default_model: Some("mimo-v2.5"), + setup_url: "https://platform.xiaomimimo.com", + }, + ProviderPreset { + id: "mistral", + display_name: "Mistral", + api_base: "https://api.mistral.ai/v1", + api_key_env: "MISTRAL_API_KEY", + default_model: Some("mistral-large-latest"), + setup_url: "https://docs.mistral.ai/", + }, + ProviderPreset { + id: "openrouter", + display_name: "OpenRouter", + api_base: "https://openrouter.ai/api/v1", + api_key_env: "OPENROUTER_API_KEY", + default_model: None, + setup_url: "https://openrouter.ai/keys", + }, + ProviderPreset { + id: "groq", + display_name: "Groq", + api_base: "https://api.groq.com/openai/v1", + api_key_env: "GROQ_API_KEY", + default_model: Some("llama-3.3-70b-versatile"), + setup_url: "https://console.groq.com/keys", + }, + ProviderPreset { + id: "openai", + display_name: "OpenAI API", + api_base: "https://api.openai.com/v1", + api_key_env: "OPENAI_API_KEY", + default_model: Some("gpt-4o"), + setup_url: "https://platform.openai.com/api-keys", + }, + ProviderPreset { + id: "xai", + display_name: "xAI (Grok)", + api_base: "https://api.x.ai/v1", + api_key_env: "XAI_API_KEY", + default_model: Some("grok-3"), + setup_url: "https://docs.x.ai/", + }, + ProviderPreset { + id: "gemini", + display_name: "Gemini API (OpenAI compat)", + api_base: "https://generativelanguage.googleapis.com/v1beta/openai", + api_key_env: "GEMINI_API_KEY", + default_model: Some("gemini-2.5-flash"), + setup_url: "https://ai.google.dev/gemini-api/docs/openai", + }, + ProviderPreset { + id: "ollama", + display_name: "Ollama (local)", + api_base: "http://localhost:11434/v1", + api_key_env: "", + default_model: None, + setup_url: "https://ollama.com/download", + }, + ProviderPreset { + id: "lmstudio", + display_name: "LM Studio (local)", + api_base: "http://localhost:1234/v1", + api_key_env: "", + default_model: None, + setup_url: "https://lmstudio.ai/", + }, + ProviderPreset { + id: "perplexity", + display_name: "Perplexity", + api_base: "https://api.perplexity.ai", + api_key_env: "PERPLEXITY_API_KEY", + default_model: Some("sonar"), + setup_url: "https://docs.perplexity.ai/", + }, + ProviderPreset { + id: "together", + display_name: "Together AI", + api_base: "https://api.together.xyz/v1", + api_key_env: "TOGETHER_API_KEY", + default_model: Some("meta-llama/Llama-3.3-70B-Instruct-Turbo"), + setup_url: "https://docs.together.ai/docs/openai-api-compatibility", + }, + ProviderPreset { + id: "fireworks", + display_name: "Fireworks AI", + api_base: "https://api.fireworks.ai/inference/v1", + api_key_env: "FIREWORKS_API_KEY", + default_model: Some("accounts/fireworks/models/llama-v3p3-70b-instruct"), + setup_url: "https://docs.fireworks.ai/", + }, + ProviderPreset { + id: "cerebras", + display_name: "Cerebras", + api_base: "https://api.cerebras.ai/v1", + api_key_env: "CEREBRAS_API_KEY", + default_model: Some("llama3.3-70b"), + setup_url: "https://inference-docs.cerebras.ai/", + }, + ProviderPreset { + id: "kimi", + display_name: "Kimi Code", + api_base: "https://api.kimi.com/coding/v1", + api_key_env: "KIMI_API_KEY", + default_model: Some("kimi-for-coding"), + setup_url: "https://www.kimi.com/coding/", + }, + ProviderPreset { + id: "custom", + display_name: "Custom (OpenAI-compatible)", + api_base: "", + api_key_env: "", + default_model: None, + setup_url: "", + }, +]; + +impl ProviderPreset { + /// Find a preset by its id. Returns `None` for unknown ids. + pub fn find(id: &str) -> Option<&'static ProviderPreset> { + KNOWN_PROVIDERS.iter().find(|p| p.id == id) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BifrostModelConfig { #[serde(default = "default_128k")] @@ -693,6 +896,65 @@ impl Default for ConsciousnessConfig { agent: AgentConfig::default(), tui: TuiConfig::default(), image: ImageConfig::default(), + providers: HashMap::new(), + } + } +} + +impl ConsciousnessConfig { + /// Populate `self.providers` from legacy `[bifrost]` and `[zai]` sections + /// when the unified providers map is empty. Called after config load so + /// existing configs continue to work without migration. + /// + /// On the next config save, only `[providers]` is serialized (legacy fields + /// are `#[serde(skip_serializing)]`), completing the migration. + pub fn normalize_providers(&mut self) { + if !self.providers.is_empty() { + return; + } + + let default_bifrost_url = default_bifrost_url(); + if self.bifrost.base_url != default_bifrost_url || !self.bifrost.api_key.is_empty() { + self.providers.insert( + "bifrost".to_string(), + ProviderConfig { + provider_type: "openai-compatible".to_string(), + base_url: self.bifrost.base_url.clone(), + api_key: self.bifrost.api_key.clone(), + virtual_key: self.bifrost.virtual_key.clone(), + primary_model: self.bifrost.primary_model.clone(), + timeout_secs: self.bifrost.timeout_secs, + }, + ); + } + + if !self.zai.base_url.is_empty() { + self.providers.insert( + "zai".to_string(), + ProviderConfig { + provider_type: "openai-compatible".to_string(), + base_url: self.zai.base_url.clone(), + api_key: self.zai.api_key.clone(), + virtual_key: String::new(), + primary_model: self.zai.primary_model.clone(), + timeout_secs: self.zai.timeout_secs, + }, + ); + } + + if self.inference.provider == "openai-oauth" && !self.providers.contains_key("openai-oauth") + { + self.providers.insert( + "openai-oauth".to_string(), + ProviderConfig { + provider_type: "openai-oauth".to_string(), + base_url: String::new(), + api_key: String::new(), + virtual_key: String::new(), + primary_model: self.bifrost.primary_model.clone(), + timeout_secs: self.bifrost.timeout_secs, + }, + ); } } } diff --git a/src/core/sensorium/matrix/mod.rs b/src/core/sensorium/matrix/mod.rs index b878ba4..4547a32 100644 --- a/src/core/sensorium/matrix/mod.rs +++ b/src/core/sensorium/matrix/mod.rs @@ -80,6 +80,9 @@ pub struct MatrixSensorium { turns: Arc>>, /// Agent ID to route inbound messages to. Set at registration time. agent_id: String, + /// Shared matrix client — available after `run()` connects. + /// Used by `send_message` / `send_direct_reply` for outbound sends. + client: Option>, } impl MatrixSensorium { @@ -100,6 +103,7 @@ impl MatrixSensorium { auth: Some(auth), turns: Arc::new(Mutex::new(HashMap::new())), agent_id: agent_id.into(), + client: None, } } @@ -205,6 +209,9 @@ impl Sensorium for MatrixSensorium { info!("matrix sensorium: connecting (account {})", self.account); let (matrix_client, record) = build_client(auth, &dir).await?; + // Store the client for outbound sends. + self.client = Some(matrix_client.clone()); + // Persist the (possibly refreshed) session so the next run restores. save_session_record(&dir, &record)?; @@ -299,25 +306,23 @@ impl Sensorium for MatrixSensorium { } async fn send_message(&self, chat_id: &str, text: &str) -> Result { - // Find the room, send a text message. - // This is the wire that Phase 5's streaming edits build on — - // first chunk creates a new message, subsequent deltas edit it. - // - // TODO: when we don't have direct room access from the sensorium - // itself (it lives in the run loop), this needs matrix_client to be - // shared. For now, stubbed — the transport spike sends via the - // event handler path. - debug!("matrix::send_message: {chat_id} ({})", text.len()); + let Some(client) = &self.client else { + anyhow::bail!("matrix client not connected"); + }; + let room_id = chat_id.parse().context("invalid room id")?; + let room = client + .get_room(&room_id) + .context("room not found")?; + let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text); + let response = room.send(content).await.context("send failed")?; + debug!("matrix::send_message: {chat_id} ({}) → {}", text.len(), response.event_id); Ok(super::OutboundResult { - message_id: String::new(), + message_id: response.event_id.to_string(), }) } async fn send_direct_reply(&self, chat_id: &str, text: &str) -> Result { - debug!("matrix::send_direct_reply: {chat_id} {text}"); - Ok(super::OutboundResult { - message_id: String::new(), - }) + self.send_message(chat_id, text).await } fn is_running(&self) -> bool { diff --git a/src/main.rs b/src/main.rs index d4de904..93f6920 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1325,12 +1325,14 @@ async fn load_config() -> anyhow::Result { if path.exists() { info!("loading config from {:?}", path); - return ConsciousnessConfig::load(&path); + let mut config = ConsciousnessConfig::load(&path)?; + config.normalize_providers(); + return Ok(config); } } warn!("no config found; using defaults (will probe Bifrost for models)"); - let config = ConsciousnessConfig::default(); + let mut config = ConsciousnessConfig::default(); // Try to seed model list from the active inference provider info!("discovering models from provider"); diff --git a/src/server/mod.rs b/src/server/mod.rs index 5237bf5..bc6b349 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -84,7 +84,8 @@ pub struct ServerConfig { } impl SouveraineServer { - pub async fn new(config: ConsciousnessConfig) -> anyhow::Result { + pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result { + config.normalize_providers(); let data_dir = dirs::home_dir() .unwrap() .join(".souveraine") @@ -183,6 +184,26 @@ impl SouveraineServer { Some("primary".to_string()) } }); + let get_agent_provider: Arc Option + Send + Sync> = + Arc::new(|id| { + let primary_id = if id.ends_with("-sub") { + id.trim_end_matches("-sub") + } else { + id + }; + let home = dirs::home_dir()?; + let path = home + .join(".souveraine/server/agents") + .join(primary_id) + .join("agent.json"); + let content = std::fs::read_to_string(&path).ok()?; + let parsed: serde_json::Value = serde_json::from_str(&content).ok()?; + parsed + .get("_souveraine")? + .get("provider")? + .as_str() + .map(|s| s.to_string()) + }); let compaction_engine: Arc = Arc::new(DefaultCompactionEngine { config: app_cfg, @@ -194,6 +215,7 @@ impl SouveraineServer { replace_messages, get_repo, get_agent_type, + get_agent_provider, }); let consciousness = Arc::new(ConsciousnessEngine::new( diff --git a/src/ui/app/settings_handler.rs b/src/ui/app/settings_handler.rs index 92a03f2..bbe5c66 100644 --- a/src/ui/app/settings_handler.rs +++ b/src/ui/app/settings_handler.rs @@ -244,6 +244,7 @@ impl App { name: self.agent_pref.clone(), model: model.clone(), model_original: model, + provider: None, // populated from agent.json if present subconscious_id: format!("{id}-sub"), memory_root, subconscious_root, diff --git a/src/ui/chat/events.rs b/src/ui/chat/events.rs index 4349d18..d7b6d07 100644 --- a/src/ui/chat/events.rs +++ b/src/ui/chat/events.rs @@ -193,6 +193,7 @@ impl ChatState { if self.cockpit_log.len() > 200 { self.cockpit_log.drain(..self.cockpit_log.len() - 200); } + self.surfacings_count += 1; self.messages.push(ChatMessage::Surfacing { source: source.clone(), content: content.clone(), @@ -206,6 +207,7 @@ impl ChatState { kind: CockpitKind::Reflection, text: content.clone(), }); + self.last_reflection = Some(chrono::Local::now().format("%H:%M").to_string()); self.messages.push(ChatMessage::System { text: format!("reflection: {}", content), ts: Instant::now(), @@ -218,6 +220,7 @@ impl ChatState { kind: CockpitKind::Archivist, text: format!("{:.0}% — {}", pressure * 100.0, synthesis), }); + self.last_archivist = Some(chrono::Local::now().format("%H:%M").to_string()); self.messages.push(ChatMessage::System { text: format!("archivist: {} (pressure {:.0}%)", synthesis, pressure * 100.0), ts: Instant::now(), @@ -226,6 +229,7 @@ impl ChatState { } BackendEvent::CompactionWarning { pressure, tier } => { self.pressure = pressure; + self.last_compaction = Some((tier, chrono::Local::now().format("%H:%M").to_string())); let label = match tier { 3 => "critical", 2 => "urgent", _ => "warn" }; let kind = match tier { 3 => CockpitKind::CompactionCritical, @@ -257,6 +261,11 @@ impl ChatState { kind: CockpitKind::InferenceStrain, text, }); + match status { + 504 => self.strain_504 += 1, + 429 => self.strain_429 += 1, + _ => self.strain_other += 1, + } self.pending_consciousness.push(BackendEvent::InferenceStrain { attempt, status, model: String::new(), }); @@ -327,6 +336,10 @@ impl ChatState { if active { self.subconscious_stream.clear(); self.subconscious_current.clear(); + self.n1_active = true; + } else if self.n1_active { + self.n1_active = false; + self.n1_passes += 1; } self.pending_consciousness.push(BackendEvent::SubconsciousPass(active)); } diff --git a/src/ui/chat/mod.rs b/src/ui/chat/mod.rs index 43c6468..f380c71 100644 --- a/src/ui/chat/mod.rs +++ b/src/ui/chat/mod.rs @@ -19,7 +19,7 @@ mod events; mod footer; mod overlays; mod render; -mod tool_renderers; +pub mod tool_renderers; pub mod wrap; pub use render::draw; @@ -372,6 +372,19 @@ pub struct ChatState { /// The human's preferred name, read from system/human.md frontmatter. /// Falls back to "you" if unset. pub human_name: String, + + // ── Health vitals (for sidebar) ───────────────────────────────────── + pub backend_mode: String, + pub backend_healthy: bool, + pub n1_passes: u32, + pub n1_active: bool, + pub surfacings_count: u32, + pub last_reflection: Option, + pub last_archivist: Option, + pub last_compaction: Option<(u8, String)>, + pub strain_504: u32, + pub strain_429: u32, + pub strain_other: u32, } #[derive(Debug, Clone)] @@ -520,6 +533,17 @@ impl ChatState { subconscious_stream: Vec::new(), subconscious_current: String::new(), itinerary_line: String::new(), + backend_mode: mode.to_string(), + backend_healthy: true, + n1_passes: 0, + n1_active: false, + surfacings_count: 0, + last_reflection: None, + last_archivist: None, + last_compaction: None, + strain_504: 0, + strain_429: 0, + strain_other: 0, }) } } diff --git a/src/ui/screens/agents.rs b/src/ui/screens/agents.rs index 7147359..4fd8c1a 100644 --- a/src/ui/screens/agents.rs +++ b/src/ui/screens/agents.rs @@ -1,34 +1,80 @@ -//! Agent manager screen — select an agent from the available list. +//! Unified agent screen — master–detail view replacing the old AgentsScreen +//! and ManagerScreen. //! -//! Shows available agents as bordered cards with names and descriptions. -//! Arrow keys navigate, Enter selects, Esc returns to Welcome. +//! Wide terminals (>=100 cols): agent list on the left, selected agent's +//! portrait and vitals on the right, updating live as you arrow through. +//! Narrow terminals: list stacked above detail. +//! +//! Keys: arrow keys move, Enter talk, k kill, r restart, f pin primary, +//! i toggle inspect, Esc back. use std::cell::Cell; use std::rc::Rc; use tuie::prelude::*; -/// An agent entry shown in the selection list. -struct AgentEntry { - id: String, - name: String, - description: String, +use crate::ui::chat::ChatPalette; +use crate::ui::theme; +use crate::ui::tuie_app::AgentSummary; +use crate::ui::widgets::portrait::Portrait; +use crate::ui::widgets::select_list::{ActivateEvent, SelectList}; +use crate::ui::widgets::responsive::Responsive; + +/// Terminal width at or above which the wide (side-by-side) layout is used. +const WIDE_BREAKPOINT: u16 = 100; + +// ── Detail pane widget IDs ───────────────────────────────────────────────── + +/// Typed widget IDs for one copy of the detail pane. Each [`Responsive`] layout +/// (wide / narrow) gets its own set so both stay addressable regardless of +/// which layout is currently visible. +struct DetailIds { + /// Sub-pane holding the portrait — cleared and rebuilt on agent change. + portrait_pane: WidgetId, + name: WidgetId, + glyph: WidgetId, + description: WidgetId, + primary_badge: WidgetId, + instances: WidgetId, + uptime: WidgetId, + files: WidgetId, + pubkey: WidgetId, + activity_header: WidgetId, + activity_lines: WidgetId, } +// ── AgentsScreen ─────────────────────────────────────────────────────────── + pub struct AgentsScreen { root: Box, - /// WidgetId for the scroll body pane — replaced on selection change. - scroll_id: WidgetId, - agents: Vec, + /// Selection lists — one per Responsive layout, selection mirrored between them. + list_wide_id: WidgetId, + list_narrow_id: WidgetId, + /// Detail panes for each layout variant. + detail_wide: DetailIds, + detail_narrow: DetailIds, + /// All known agents. + agents: Vec, + /// Currently selected index. selected: usize, - /// Shared with TuieApp — set to Some(index) when Enter is pressed. - pub selection: Rc>>, + palette: ChatPalette, + primary_color: Color, + /// Whether the inspect overlay is toggled on. + inspecting: bool, + // Signals shared with TuieApp. + selection: Rc>>, + back_pressed: Rc>, + kill_requested: Rc>>, + restart_requested: Rc>>, + pin_requested: Rc>>, } impl DelegateWidget for AgentsScreen { tuie::delegate_widget!(root); - fn override_is_focusable(&self) -> bool { true } + fn override_is_focusable(&self) -> bool { + true + } fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult { use tuie::input::key::Key; @@ -39,23 +85,49 @@ impl DelegateWidget for AgentsScreen { match key { Key::Arrow(Direction2D::Up) => { queue.next(); - if self.selected > 0 { - self.selected -= 1; - self.update_selection(); - } + self.move_up(); return InputResult::Handled; } Key::Arrow(Direction2D::Down) => { queue.next(); - if self.selected + 1 < self.agents.len() { - self.selected += 1; - self.update_selection(); - } + self.move_down(); return InputResult::Handled; } Key::Enter => { queue.next(); - self.selection.set(Some(self.selected)); + self.activate(); + return InputResult::Handled; + } + Key::Char('k') => { + queue.next(); + if !self.agents.is_empty() { + self.kill_requested.set(Some(self.selected)); + } + return InputResult::Handled; + } + Key::Char('r') => { + queue.next(); + if !self.agents.is_empty() { + self.restart_requested.set(Some(self.selected)); + } + return InputResult::Handled; + } + Key::Char('f') => { + queue.next(); + if !self.agents.is_empty() { + self.pin_requested.set(Some(self.selected)); + } + return InputResult::Handled; + } + Key::Char('i') => { + queue.next(); + self.inspecting = !self.inspecting; + self.refresh_detail(); + return InputResult::Handled; + } + Key::Esc => { + queue.next(); + self.back_pressed.set(true); return InputResult::Handled; } _ => {} @@ -64,111 +136,705 @@ impl DelegateWidget for AgentsScreen { } self.get_delegate_mut().on_input(queue) } + + fn after_on_event(&mut self, event: &mut WidgetEvent) { + // Check both lists — only the visible one fires, but we don't track which. + if let Some(&ActivateEvent(idx)) = event.get_by::(self.list_wide_id) { + self.selected = idx; + self.refresh_detail(); + self.selection.set(Some(idx)); + } + if let Some(&ActivateEvent(idx)) = event.get_by::(self.list_narrow_id) { + self.selected = idx; + self.refresh_detail(); + self.selection.set(Some(idx)); + } + } } impl AgentsScreen { - /// Create the agents screen. + /// Create the unified agent screen. + /// + /// Returns the screen widget and five shared signal cells: + /// (selection, back_pressed, kill_requested, restart_requested, pin_requested) pub fn new( - agents: Vec<(String, String, String)>, // (id, name, description) - ) -> (Box, Rc>>) { + agents: Vec, + palette: &ChatPalette, + ) -> ( + Box, + Rc>>, + Rc>, + Rc>>, + Rc>>, + Rc>>, + ) { let selection = Rc::new(Cell::new(None)); + let back_pressed = Rc::new(Cell::new(false)); + let kill_requested = Rc::new(Cell::new(None)); + let restart_requested = Rc::new(Cell::new(None)); + let pin_requested = Rc::new(Cell::new(None)); - let entries: Vec = agents - .into_iter() - .map(|(id, name, description)| AgentEntry { id, name, description }) - .collect(); + let primary = theme::to_tuie_color(palette.agent_primary); + let dim = theme::to_tuie_color(palette.agent_dim); - let mut scroll_id = WidgetId::EMPTY; - let cards = Self::build_cards(&entries, 0); + let rows = build_rows(&agents, palette); + // Clone the first agent's data so the detail pane owns its content + // independently from the `agents` Vec (which is moved into `self` later). + let first_agent = agents.first().cloned(); - let mut scroll_body = Pane::new() + // ── Wide layout: list left, detail right ────────────────────────── + let mut list_wide = build_list(&rows, palette); + let list_wide_id = list_wide.get_id(); + let (detail_wide_pane, detail_wide_ids) = build_detail_pane(palette, first_agent.as_ref()); + + let list_col = Pane::new() + .vertical() + .width(34) + .bordered() + .border_style(Style::new().fg(dim).dim()) + .gap(0) + .children([ + Text::new().content(" Agents ".fg(primary).bold()) as Box, + list_wide, + ]); + + let wide = Pane::new() + .horizontal() + .flex(1) + .gap(2) + .children([list_col as Box, detail_wide_pane]); + + // ── Narrow layout: list top, detail bottom ─────────────────────── + let mut list_narrow = build_list(&rows, palette); + let list_narrow_id = list_narrow.get_id(); + let (detail_narrow_pane, detail_narrow_ids) = build_detail_pane(palette, first_agent.as_ref()); + + let narrow_list_col = Pane::new() .vertical() .flex(1) - .gap(1); - for card in cards { - scroll_body.add_child(card); - } - let scroll_body = scroll_body.id(&mut scroll_id); + .bordered() + .border_style(Style::new().fg(dim).dim()) + .gap(0) + .children([ + Text::new().content(" Agents ".fg(primary).bold()) as Box, + list_narrow, + ]); + let narrow = Pane::new() + .vertical() + .flex(1) + .gap(1) + .children([narrow_list_col as Box, detail_narrow_pane]); + + let responsive = Responsive::new(WIDE_BREAKPOINT, wide, narrow); + + // ── Root ───────────────────────────────────────────────────────── let root = Pane::new() .vertical() .flex(1) .padding(Spacing::new().horizontal(1).top(1).bottom(1)) - .gap(1) + .gap(0) .children([ - Text::new() - .content(" Agents ".fg(Color::YELLOW).bold()), - Text::new() - .content( - StyledStr::new(" arrow keys select · Enter choose · Esc back") - .fg(Color::BRIGHT_BLACK), - ), - scroll_body, + Text::new().content(" Agents ".fg(primary).bold()) as Box, + Text::new().content( + StyledStr::new( + " \u{2190}\u{2192} select · Enter talk · k kill · r restart · f pin · i inspect · Esc back", + ) + .fg(dim), + ) as Box, + responsive as Box, ]); - let sel = selection.clone(); - let this = Box::new(Self { + let mut this = Self { root, - scroll_id, - agents: entries, + list_wide_id, + list_narrow_id, + detail_wide: detail_wide_ids, + detail_narrow: detail_narrow_ids, + agents, selected: 0, + palette: *palette, + primary_color: primary, + inspecting: false, selection, - }); - (this, sel) + back_pressed, + kill_requested, + restart_requested, + pin_requested, + }; + + // Show the first agent's detail immediately. + this.refresh_detail(); + + let sel = this.selection.clone(); + let back = this.back_pressed.clone(); + let kill = this.kill_requested.clone(); + let restart = this.restart_requested.clone(); + let pin = this.pin_requested.clone(); + + (Box::new(this), sel, back, kill, restart, pin) } - fn build_cards(entries: &[AgentEntry], selected: usize) -> Vec> { - entries - .iter() - .enumerate() - .map(|(i, entry)| { - let is_selected = i == selected; - let border_color = if is_selected { Color::YELLOW } else { Color::BRIGHT_BLACK }; - let name_color = if is_selected { Color::YELLOW } else { Color::Foreground }; - let prefix = if is_selected { "▶" } else { " " }; + // ── Selection navigation ───────────────────────────────────────────── - let mut content = StyledString::new(); - content.push_span( - StyledStr::new(&format!("{prefix} ")) - .fg(if is_selected { Color::YELLOW } else { Color::BRIGHT_BLACK }), - ); - content.push_span( - StyledStr::new(&entry.name).bold().fg(name_color), - ); - - let desc = if entry.description.is_empty() { - String::from("(no description)") - } else { - entry.description.clone() - }; - - let card: Box = Pane::new() - .vertical() - .bordered() - .border_style(Style::new().fg(border_color).dim()) - .padding(Spacing::new().horizontal(2).vertical(1)) - .children([ - Text::new().content(content) as Box, - Text::new().content( - StyledStr::new(&format!(" {}", desc)) - .fg(Color::BRIGHT_BLACK) - .italic(), - ), - ]); - card - }) - .collect() + fn move_up(&mut self) { + if self.selected > 0 { + self.selected -= 1; + self.apply_selection(); + self.refresh_detail(); + } } - fn update_selection(&mut self) { - // Rebuild all cards with new selection state, swap into scroll body. - let new_cards = Self::build_cards(&self.agents, self.selected); - if let Some(scroll) = self.root.get_widget_mut(self.scroll_id) { - scroll.clear(); - for card in new_cards { - scroll.add_child(card); + fn move_down(&mut self) { + if self.selected + 1 < self.agents.len() { + self.selected += 1; + self.apply_selection(); + self.refresh_detail(); + } + } + + /// Mirror the current selection into both layouts' lists so the choice + /// survives a resize that switches layouts. + fn apply_selection(&mut self) { + let sel = self.selected; + if let Some(list) = self.root.get_widget_mut(self.list_wide_id) { + list.select(sel); + } + if let Some(list) = self.root.get_widget_mut(self.list_narrow_id) { + list.select(sel); + } + } + + fn activate(&mut self) { + if !self.agents.is_empty() { + // Activate the wide list — the event fires regardless of which + // layout is currently visible. + if let Some(list) = self.root.get_widget_mut(self.list_wide_id) { + list.activate_selected(); } } - self.root.dirty_layout(); + } + + // ── Detail refresh ─────────────────────────────────────────────────── + + /// Refresh both detail panes to show the currently selected agent. + fn refresh_detail(&mut self) { + if let Some(agent) = self.agents.get(self.selected).cloned() { + self.refresh_detail_ids(&DetailIdsRef { + portrait_pane: self.detail_wide.portrait_pane, + name: self.detail_wide.name, + glyph: self.detail_wide.glyph, + description: self.detail_wide.description, + primary_badge: self.detail_wide.primary_badge, + instances: self.detail_wide.instances, + uptime: self.detail_wide.uptime, + files: self.detail_wide.files, + pubkey: self.detail_wide.pubkey, + activity_header: self.detail_wide.activity_header, + activity_lines: self.detail_wide.activity_lines, + }, &agent); + self.refresh_detail_ids(&DetailIdsRef { + portrait_pane: self.detail_narrow.portrait_pane, + name: self.detail_narrow.name, + glyph: self.detail_narrow.glyph, + description: self.detail_narrow.description, + primary_badge: self.detail_narrow.primary_badge, + instances: self.detail_narrow.instances, + uptime: self.detail_narrow.uptime, + files: self.detail_narrow.files, + pubkey: self.detail_narrow.pubkey, + activity_header: self.detail_narrow.activity_header, + activity_lines: self.detail_narrow.activity_lines, + }, &agent); + } + } + + fn refresh_detail_ids(&mut self, ids: &DetailIdsRef, agent: &AgentSummary) { + let primary = self.primary_color; + let dim = theme::to_tuie_color(self.palette.agent_dim); + let green = Color::Rgb(120, 220, 160); + + // Portrait — clear and rebuild. + self.rebuild_portrait(ids.portrait_pane, agent); + + // Name + glyph header. + if let Some(t) = self.root.get_widget_mut(ids.name) { + let mut content = StyledString::new(); + content.push_span(StyledStr::new(&agent.glyph).fg(primary)); + content.push_span(StyledStr::new(&format!(" {}", agent.name)).fg(primary).bold()); + t.set_content(content); + } + + // Description. + if let Some(t) = self.root.get_widget_mut(ids.description) { + let desc = if agent.description.is_empty() { + "(no description)" + } else { + &agent.description + }; + t.set_content(StyledStr::new(desc).fg(dim).italic()); + } + + // Primary badge. + if let Some(t) = self.root.get_widget_mut(ids.primary_badge) { + if agent.is_primary { + t.set_content(StyledStr::new("\u{2605} PRIMARY").fg(primary).bold()); + } else { + t.set_content(StyledStr::new("")); + } + } + + // Instances. + if let Some(t) = self.root.get_widget_mut(ids.instances) { + let mut content = StyledString::new(); + content.push_span(StyledStr::new(" instances ").fg(dim)); + let count_color = if agent.instance_count > 0 { primary } else { dim }; + content.push_span(StyledStr::new(&format!("{}", agent.instance_count)).fg(count_color)); + t.set_content(content); + } + + // Uptime bar + percentage. + if let Some(t) = self.root.get_widget_mut(ids.uptime) { + let mut content = StyledString::new(); + content.push_span(StyledStr::new(" uptime ").fg(dim)); + crate::ui::widgets::stats::push_bar( + &mut content, + agent.uptime_pct as f32 / 100.0, + 10, + green, + ); + let pct_color = if agent.uptime_pct > 0 { green } else { dim }; + content.push_span(StyledStr::new(&format!(" {}%", agent.uptime_pct)).fg(pct_color)); + t.set_content(content); + } + + // Memory files. + if let Some(t) = self.root.get_widget_mut(ids.files) { + let mut content = StyledString::new(); + content.push_span(StyledStr::new(" files ").fg(dim)); + content.push_span(StyledStr::new(&format!("{}", agent.memory_count)).fg(primary)); + t.set_content(content); + } + + // Pubkey prefix. + if let Some(t) = self.root.get_widget_mut(ids.pubkey) { + let mut content = StyledString::new(); + content.push_span(StyledStr::new(" pubkey ").fg(dim)); + let prefix = if agent.pubkey_prefix.is_empty() { + "\u{2014}" + } else { + &agent.pubkey_prefix + }; + content.push_span(StyledStr::new(prefix).fg(dim)); + t.set_content(content); + } + + // Activity header. + if let Some(t) = self.root.get_widget_mut(ids.activity_header) { + if agent.recent_activity.is_empty() { + t.set_content(StyledStr::new("")); + } else { + t.set_content(StyledStr::new(" Activity").fg(dim).bold()); + } + } + + // Activity lines. + if let Some(t) = self.root.get_widget_mut(ids.activity_lines) { + let mut content = StyledString::new(); + let max = if self.inspecting { + agent.recent_activity.len() + } else { + 3.min(agent.recent_activity.len()) + }; + for line in agent.recent_activity.iter().take(max) { + content.push_span(StyledStr::new(&format!(" \u{00b7} {}\n", line)).fg(dim)); + } + if self.inspecting && agent.recent_activity.len() > 3 { + content.push_span( + StyledStr::new(&format!( + " ... {} more (i to collapse)\n", + agent.recent_activity.len() - 3 + )) + .fg(dim), + ); + } else if !self.inspecting && agent.recent_activity.len() > 3 { + content.push_span( + StyledStr::new(&format!( + " ... {} more (i to expand)\n", + agent.recent_activity.len() - 3 + )) + .fg(dim), + ); + } + t.set_content(content); + } + } + + /// Clear the portrait sub-pane and insert a new [`Portrait`] for `agent`. + fn rebuild_portrait(&mut self, pane_id: WidgetId, agent: &AgentSummary) { + let color = self.primary_color; + let portrait = Portrait::new(Some(&agent.id), &agent.name, color); + + if let Some(pane) = self.root.get_widget_mut(pane_id) { + pane.clear(); + pane.add_child(portrait); + } + } +} + +// ── DetailIdsRef helper ──────────────────────────────────────────────────── + +/// Borrow-free snapshot of [`DetailIds`] so `refresh_detail_ids` can be +/// called for both wide and narrow without splitting `&mut self`. +struct DetailIdsRef { + portrait_pane: WidgetId, + name: WidgetId, + glyph: WidgetId, + description: WidgetId, + primary_badge: WidgetId, + instances: WidgetId, + uptime: WidgetId, + files: WidgetId, + pubkey: WidgetId, + activity_header: WidgetId, + activity_lines: WidgetId, +} + +// ── Build helpers ────────────────────────────────────────────────────────── + +/// Build one styled row per agent for the [`SelectList`]. +fn build_rows(agents: &[AgentSummary], palette: &ChatPalette) -> Vec { + let primary = theme::to_tuie_color(palette.agent_primary); + let dim = theme::to_tuie_color(palette.agent_dim); + let green = Color::Rgb(120, 220, 160); + + agents + .iter() + .map(|a| { + let mut row = StyledString::new(); + // Name. + let name = truncate_str(&a.name, 20); + row.push_span(StyledStr::new(&format!("{:<20}", name)).bold()); + // Primary marker. + if a.is_primary { + row.push_span(StyledStr::new("\u{2605} ").fg(primary)); + } else { + row.push_span(StyledStr::new(" ")); + } + // Status. + if a.instance_count > 0 { + row.push_span(StyledStr::new("active").fg(green)); + } else { + row.push_span(StyledStr::new("idle ").fg(dim)); + } + // Instance count. + if a.instance_count > 0 { + row.push_span(StyledStr::new(&format!(" {} up", a.instance_count)).fg(dim)); + } + row + }) + .collect() +} + +/// Build a [`SelectList`] pre-filled with the given rows. +fn build_list(rows: &[StyledString], palette: &ChatPalette) -> Box { + let primary = theme::to_tuie_color(palette.agent_primary); + let dim = theme::to_tuie_color(palette.agent_dim); + SelectList::new() + .colors(primary, dim) + .items(rows.to_vec()) +} + +/// Build one detail pane — a bordered column with the selected agent's info. +/// +/// If `agent` is `Some`, the fields are pre-populated with that agent's data +/// so the initial render is correct before any `set_content` calls. If `None` +/// (empty agent list), the fields start empty. +fn build_detail_pane(palette: &ChatPalette, agent: Option<&AgentSummary>) -> (Box, DetailIds) { + let primary = theme::to_tuie_color(palette.agent_primary); + let dim = theme::to_tuie_color(palette.agent_dim); + let green = Color::Rgb(120, 220, 160); + + // Helper: build a "key value" styled string. + let kv = |key: &str, val: &str, val_color: Color| -> StyledString { + let mut s = StyledString::new(); + s.push_span(StyledStr::new(&format!(" {:<12}", key)).fg(dim)); + s.push_span(StyledStr::new(val).fg(val_color)); + s + }; + + // Portrait — height(1) temporarily to avoid flex layout issues. + let portrait_pane = Pane::new() + .height(1) + .x_place(Place::Center) + .y_place(Place::Center); + let portrait_pane_id = portrait_pane.get_id(); + + let default_agent = AgentSummary::default(); + + // Build initial content from agent or defaults. + let ag = agent.unwrap_or(&default_agent); + + let mut name_content = StyledString::new(); + name_content.push_span(StyledStr::new("HARDCODED:").fg(primary).bold()); + name_content.push_span(StyledStr::new(&ag.glyph).fg(primary)); + name_content.push_span(StyledStr::new(&format!(" {}", ag.name)).fg(primary).bold()); + let name = Text::new().content(name_content); + let name_id = name.get_id(); + + let glyph = Text::new().content(StyledStr::new(&ag.glyph).fg(primary)); + let glyph_id = glyph.get_id(); + + let desc = if ag.description.is_empty() { "(no description)" } else { &ag.description }; + let description = Text::new().content(StyledStr::new(desc).fg(dim).italic()); + let description_id = description.get_id(); + + let pb = if ag.is_primary { + StyledStr::new("\u{2605} PRIMARY").fg(primary).bold().into() + } else { + StyledString::new() + }; + let primary_badge = Text::new().content(pb); + let primary_badge_id = primary_badge.get_id(); + + let instances = Text::new().content(kv("instances", &ag.instance_count.to_string(), primary)); + let instances_id = instances.get_id(); + + let mut uptime_content = StyledString::new(); + uptime_content.push_span(StyledStr::new(" uptime ").fg(dim)); + crate::ui::widgets::stats::push_bar(&mut uptime_content, ag.uptime_pct as f32 / 100.0, 10, green); + let pct_color = if ag.uptime_pct > 0 { green } else { dim }; + uptime_content.push_span(StyledStr::new(&format!(" {}%", ag.uptime_pct)).fg(pct_color)); + let uptime = Text::new().content(uptime_content); + let uptime_id = uptime.get_id(); + + let files = Text::new().content(kv("files", &ag.memory_count.to_string(), primary)); + let files_id = files.get_id(); + + let pk = if ag.pubkey_prefix.is_empty() { "\u{2014}" } else { &ag.pubkey_prefix }; + let pubkey = Text::new().content(kv("pubkey", pk, dim)); + let pubkey_id = pubkey.get_id(); + + let ah = if ag.recent_activity.is_empty() { + StyledString::new() + } else { + let mut s = StyledString::new(); + s.push_span(StyledStr::new(" Activity").fg(dim).bold()); + s + }; + let activity_header = Text::new().content(ah); + let activity_header_id = activity_header.get_id(); + + let mut al_content = StyledString::new(); + for line in ag.recent_activity.iter().take(3) { + al_content.push_span(StyledStr::new(&format!(" \u{00b7} {}\n", line)).fg(dim)); + } + if ag.recent_activity.len() > 3 { + al_content.push_span( + StyledStr::new(&format!(" ... {} more (i to expand)\n", ag.recent_activity.len() - 3)).fg(dim), + ); + } + let activity_lines = Text::new().content(al_content); + let activity_lines_id = activity_lines.get_id(); + + let section = Pane::new() + .vertical() + .flex(1) + .bordered() + .border_style(Style::new().fg(dim).dim()) + .gap(0) + .children([ + portrait_pane as Box, + name as Box, + ]); + + let section_id = section.get_id(); + let _ = section_id; + + ( + section, + DetailIds { + portrait_pane: portrait_pane_id, + name: name_id, + glyph: glyph_id, + description: description_id, + primary_badge: primary_badge_id, + instances: instances_id, + uptime: uptime_id, + files: files_id, + pubkey: pubkey_id, + activity_header: activity_header_id, + activity_lines: activity_lines_id, + }, + ) +} + +/// Truncate a string to at most `max_len` chars, appending "…" if cut. +fn truncate_str(s: &str, max_len: usize) -> String { + if s.chars().count() <= max_len { + s.to_string() + } else { + let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); + format!("{}\u{2026}", truncated) + } +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use tuie::emulator::Emulator; + + use super::*; + use crate::ui::chat::ChatPalette; + + fn sample_agents() -> Vec { + vec![ + AgentSummary { + id: "a1".into(), + name: "Alice".into(), + description: "Primary agent".into(), + glyph: "◇◆◇◆".into(), + instance_count: 2, + uptime_pct: 87, + memory_count: 142, + pubkey_prefix: "abcd1234ef01".into(), + is_primary: true, + atmosphere: None, + recent_activity: vec!["updated index".into(), "merged pass".into()], + }, + AgentSummary { + id: "b2".into(), + name: "Bob".into(), + description: "".into(), + glyph: "◆◇◆◇".into(), + instance_count: 0, + uptime_pct: 0, + memory_count: 3, + pubkey_prefix: "".into(), + is_primary: false, + atmosphere: None, + recent_activity: vec![], + }, + ] + } + + #[test] + fn renders_header_and_hints() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, ..) = AgentsScreen::new(agents, &palette); + let term = Emulator::new(&mut *screen, Vec2::new(120, 30)); + let rendered = term.get_snapshot_text(); + assert!( + rendered.contains("Agents"), + "expected 'Agents' in header, got: {rendered:?}" + ); + assert!( + rendered.contains("talk") || rendered.contains("Enter"), + "expected key hints, got: {rendered:?}" + ); + } + + #[test] + fn shows_agent_names() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, ..) = AgentsScreen::new(agents, &palette); + let term = Emulator::new(&mut *screen, Vec2::new(120, 30)); + let rendered = term.get_snapshot_text(); + assert!( + rendered.contains("Alice"), + "expected 'Alice' in agent list, got: {rendered:?}" + ); + assert!( + rendered.contains("Bob"), + "expected 'Bob' in agent list, got: {rendered:?}" + ); + } + + #[test] + fn shows_primary_badge() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, ..) = AgentsScreen::new(agents, &palette); + let term = Emulator::new(&mut *screen, Vec2::new(120, 30)); + let rendered = term.get_snapshot_text(); + assert!( + rendered.contains("HARDCODED"), + "expected 'HARDCODED' in detail pane, got: {rendered:?}" + ); + } + + #[test] + fn narrow_layout_renders() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, ..) = AgentsScreen::new(agents, &palette); + // 80 cols triggers narrow (stacked) layout. + let term = Emulator::new(&mut *screen, Vec2::new(80, 30)); + let rendered = term.get_snapshot_text(); + assert!( + rendered.contains("Alice"), + "narrow layout should still show agent names, got: {rendered:?}" + ); + } + + #[test] + fn escape_sets_back_signal() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, _sel, back, _kill, _restart, _pin) = + AgentsScreen::new(agents, &palette); + assert!(!back.get()); + + let chord = chord_macro::chord!(Esc); + let event = tuie::widget::input::InputEvent::from_chord(chord); + let events = [event]; + let mut queue = tuie::widget::input::InputQueue::new(&events, false); + let _ = screen.on_input(&mut queue); + assert!(back.get(), "Esc should set back_pressed signal"); + } + + #[test] + fn arrow_down_changes_selection() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, ..) = AgentsScreen::new(agents, &palette); + assert_eq!(screen.selected, 0); + + let chord = chord_macro::chord!(Down); + let event = tuie::widget::input::InputEvent::from_chord(chord); + let events = [event]; + let mut queue = tuie::widget::input::InputQueue::new(&events, false); + let _ = screen.on_input(&mut queue); + assert_eq!(screen.selected, 1); + } + + #[test] + fn arrow_up_at_top_stays() { + let palette = ChatPalette::default(); + let agents = sample_agents(); + let (mut screen, ..) = AgentsScreen::new(agents, &palette); + assert_eq!(screen.selected, 0); + + let chord = chord_macro::chord!(Up); + let event = tuie::widget::input::InputEvent::from_chord(chord); + let events = [event]; + let mut queue = tuie::widget::input::InputQueue::new(&events, false); + let _ = screen.on_input(&mut queue); + assert_eq!(screen.selected, 0, "up at top should stay at 0"); + } + + #[test] + fn empty_agent_list_renders() { + let palette = ChatPalette::default(); + let (mut screen, ..) = AgentsScreen::new(vec![], &palette); + let term = Emulator::new(&mut *screen, Vec2::new(120, 20)); + let rendered = term.get_snapshot_text(); + assert!( + rendered.contains("Agents"), + "empty screen should still render header, got: {rendered:?}" + ); } } diff --git a/src/ui/screens/chat/convert.rs b/src/ui/screens/chat/convert.rs new file mode 100644 index 0000000..95e19e5 --- /dev/null +++ b/src/ui/screens/chat/convert.rs @@ -0,0 +1,354 @@ +//! Message conversion — `ChatMessage` (state-machine model) → `MsgKind` +//! (display model the `MessageList` widget renders). +//! +//! Pure functions, no `ChatScreen` coupling. The system-context heuristic +//! collapses a large multi-heading agent-context dump into a one-line summary +//! so it doesn't drown the transcript. + +use crate::ui::chat::ChatMessage; +use crate::ui::widgets::message_list::MsgKind; + +fn is_system_context_dump(text: &str) -> bool { + let heading_count = text.lines().filter(|l| l.starts_with("## ") || l.starts_with("# ")).count(); + heading_count >= 3 && text.len() > 500 +} + +fn system_context_summary(text: &str) -> String { + let headings: Vec<&str> = text.lines() + .filter(|l| l.starts_with("## ") || l.starts_with("# ")) + .take(4) + .map(|l| l.trim_start_matches('#').trim()) + .collect(); + let line_count = text.lines().count(); + if headings.is_empty() { + format!("Agent context ({line_count} lines)") + } else { + format!("Agent context: {} ({line_count} lines)", headings.join(", ")) + } +} + +pub(super) fn chat_message_to_msgkind(msg: &ChatMessage) -> MsgKind { + match msg { + ChatMessage::User { text, .. } => MsgKind::User { + name: "you".into(), + text: text.clone(), + }, + ChatMessage::Assistant { + text, streaming, .. + } => { + if !*streaming && is_system_context_dump(text) { + return MsgKind::SystemContext { + summary: system_context_summary(text), + _full_text: text.clone(), + }; + } + MsgKind::Assistant { + name: "agent".into(), + text: text.clone(), + streaming: *streaming, + } + } + ChatMessage::Surfacing { + source, + content, + priority, + .. + } => MsgKind::Surfacing { + source: source.clone(), + content: content.clone(), + priority: priority.clone(), + }, + ChatMessage::System { text, .. } => MsgKind::System { + text: text.clone(), + }, + ChatMessage::Interjection { + text, delivered, .. + } => MsgKind::Interjection { + text: text.clone(), + delivered: *delivered, + }, + ChatMessage::Interstitial { text, register } => MsgKind::Interstitial { + text: text.clone(), + is_voice: matches!(register, crate::backend::Register::HerVoice), + }, + ChatMessage::Tool { + name, + arguments, + round, + result, + .. + } => { + let args = crate::ui::chat::tool_renderers::summarize_tool_args(name, arguments); + MsgKind::Tool { + name: name.clone(), + args_summary: args, + round: *round, + is_error: result.as_ref().map(|r| r.is_error).unwrap_or(false), + is_pending: result.is_none(), + result_output: result.as_ref().map(|r| r.output.clone()), + } + } + ChatMessage::Image { label, .. } => MsgKind::System { + text: format!("[image: {label}]"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::chat::{ChatMessage, ToolResultBlock}; + use crate::ui::widgets::message_list::MsgKind; + use std::cell::RefCell; + use std::time::Instant; + + #[test] + fn user_message_converts_to_user_msgkind() { + let msg = ChatMessage::User { + text: "hello".into(), + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::User { ref name, ref text } + if name == "you" && text == "hello"), + "expected User name=you text=hello, got {result:?}" + ); + } + + #[test] + fn assistant_message_converts_streaming() { + let msg = ChatMessage::Assistant { + text: "hi".into(), + streaming: true, + ts: Instant::now(), + rendered_cache: RefCell::new(None), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Assistant { ref name, ref text, streaming } + if name == "agent" && text == "hi" && streaming), + "expected Assistant name=agent text=hi streaming=true, got {result:?}" + ); + } + + #[test] + fn assistant_message_converts_not_streaming() { + let msg = ChatMessage::Assistant { + text: "hi".into(), + streaming: false, + ts: Instant::now(), + rendered_cache: RefCell::new(None), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Assistant { streaming, .. } if !streaming), + "expected Assistant streaming=false, got {result:?}" + ); + } + + #[test] + fn system_message_converts() { + let msg = ChatMessage::System { + text: "status".into(), + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::System { ref text } if text == "status"), + "expected System text=status, got {result:?}" + ); + } + + #[test] + fn surfacing_message_converts() { + let msg = ChatMessage::Surfacing { + source: "sub".into(), + content: "msg".into(), + priority: "high".into(), + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Surfacing { ref source, ref content, ref priority } + if source == "sub" && content == "msg" && priority == "high"), + "expected Surfacing with matching fields, got {result:?}" + ); + } + + #[test] + fn interjection_message_converts() { + let msg = ChatMessage::Interjection { + text: "hey".into(), + delivered: false, + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Interjection { ref text, delivered } + if text == "hey" && !delivered), + "expected Interjection delivered=false, got {result:?}" + ); + } + + #[test] + fn interjection_message_converts_delivered() { + let msg = ChatMessage::Interjection { + text: "hey".into(), + delivered: true, + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Interjection { delivered, .. } if delivered), + "expected Interjection delivered=true, got {result:?}" + ); + } + + #[test] + fn tool_message_pending() { + let msg = ChatMessage::Tool { + id: "t1".into(), + name: "read".into(), + arguments: "{}".into(), + round: 1, + result: None, + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Tool { ref name, ref args_summary, round, is_error, is_pending, result_output: None } + if name == "read" && round == 1 && !is_error && is_pending), + "expected Tool pending with name=read, got {result:?}" + ); + } + + #[test] + fn tool_message_with_result() { + let msg = ChatMessage::Tool { + id: "t2".into(), + name: "write".into(), + arguments: r#"{"path":"/tmp/x"}"#.into(), + round: 2, + result: Some(ToolResultBlock { + output: "ok".into(), + is_error: false, + }), + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Tool { ref name, is_pending: false, is_error: false, .. } + if name == "write"), + "expected Tool with result (not pending, not error), got {result:?}" + ); + } + + #[test] + fn tool_message_with_error() { + let msg = ChatMessage::Tool { + id: "t3".into(), + name: "exec".into(), + arguments: r#"{"cmd":"ls"}"#.into(), + round: 3, + result: Some(ToolResultBlock { + output: "permission denied".into(), + is_error: true, + }), + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Tool { ref name, is_pending: false, is_error: true, .. } + if name == "exec"), + "expected Tool with error (not pending, is_error), got {result:?}" + ); + } + + #[test] + fn tool_message_threads_result_output() { + let msg = ChatMessage::Tool { + id: "t_output".into(), + name: "exec".into(), + arguments: "{}".into(), + round: 1, + result: Some(ToolResultBlock { output: "hello world".into(), is_error: false }), + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + match &result { + MsgKind::Tool { result_output, .. } => { + assert_eq!(result_output.as_deref(), Some("hello world")); + } + other => panic!("expected Tool, got {other:?}"), + } + } + + #[test] + fn tool_message_truncates_long_arguments() { + let long_args = "a".repeat(80); + let msg = ChatMessage::Tool { + id: "t4".into(), + name: "long".into(), + arguments: long_args.clone(), + round: 1, + result: None, + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + let (got_len, got_text) = match &result { + MsgKind::Tool { args_summary, .. } => (args_summary.len(), args_summary.clone()), + other => (0, format!("{other:?}")), + }; + assert!( + matches!(result, MsgKind::Tool { ref args_summary, .. } + if got_len > 0 && got_len <= 120), + "expected summarized args (<=120 chars), got length={got_len} args={got_text:?}" + ); + } + + #[test] + fn image_message_converts_to_system() { + let msg = ChatMessage::Image { + media_type: "image/png".into(), + label: "photo.png".into(), + data: "base64blob".into(), + dimensions: None, + ts: Instant::now(), + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::System { ref text } + if text.contains("image") && text.contains("photo.png")), + "expected System text containing 'image' and 'photo.png', got {result:?}" + ); + } + + #[test] + fn interstitial_message_converts_voice() { + let msg = ChatMessage::Interstitial { + text: "waiting".into(), + register: crate::backend::Register::HerVoice, + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Interstitial { ref text, is_voice } + if text == "waiting" && is_voice), + "expected Interstitial is_voice=true, got {result:?}" + ); + } + + #[test] + fn interstitial_message_converts_cenno() { + let msg = ChatMessage::Interstitial { + text: "checking".into(), + register: crate::backend::Register::Cenno, + }; + let result = chat_message_to_msgkind(&msg); + assert!( + matches!(result, MsgKind::Interstitial { ref text, is_voice } + if text == "checking" && !is_voice), + "expected Interstitial is_voice=false, got {result:?}" + ); + } +} diff --git a/src/ui/screens/chat.rs b/src/ui/screens/chat/mod.rs similarity index 80% rename from src/ui/screens/chat.rs rename to src/ui/screens/chat/mod.rs index e89204e..7db8e3c 100644 --- a/src/ui/screens/chat.rs +++ b/src/ui/screens/chat/mod.rs @@ -9,7 +9,6 @@ //! root (Pane, vertical) //! header (Text) — "✦ Souveraine · agent_name" //! itinerary_strip (ItineraryStrip) — hidden when empty -//! phase_bar (PhaseBar) — hidden when idle //! body (Pane, horizontal, flex=1) //! chat_column (Pane, vertical, flex=1/3) //! messages_pane (Pane, flex=1, scrollable, bordered) @@ -18,8 +17,9 @@ //! cockpit_pane (Pane, flex=1) //! sidebar_pane (Pane, flex=0) — ChatSidebar //! overlay (ChatOverlay) — hidden when Overlay::None -//! footer (Footer) +//! phase_bar (PhaseBar) — between overlay and input; expands with subconscious stream lines //! input_row (Pane, horizontal, bordered) +//! footer (Footer) //! prompt "›" | input (Input, flex=1) | SendButton (clickable ▸) //! ``` @@ -29,7 +29,7 @@ use tokio::sync::RwLock; use tuie::prelude::*; use crate::core::config::ConsciousnessConfig; -use crate::ui::chat::{BtwState, ChatMessage, ChatPalette, ChatState, Overlay, TurnPhase, SPINNER}; +use crate::ui::chat::{BtwState, ChatPalette, ChatState, Overlay, TurnPhase, SPINNER}; use crate::ui::theme; use crate::ui::widgets::btw_pane::BtwPane; use crate::ui::widgets::chat_input; @@ -42,6 +42,9 @@ use crate::ui::widgets::message_list::{MsgKind, MessageList}; use crate::ui::widgets::phase_bar::{PhaseBar, PhaseKind}; use crate::ui::widgets::send_button::SendButton; +mod convert; +use convert::chat_message_to_msgkind; + pub struct ChatScreen { root: Box, @@ -256,15 +259,20 @@ impl DelegateWidget for ChatScreen { } } - // After processing, update slash completions based on current input. + // Forward remaining input to the root pane (contains the Input widget) + // FIRST, so the Input reflects the just-typed key before we recompute + // completions. Computing the overlay beforehand read stale text — it + // lagged one keystroke and never fired on the first `/`. + let result = self.get_delegate_mut().on_input(queue); + + // Now refresh slash completions from the updated input text. if let Some(chat) = self.chat.as_ref() { if !matches!(chat.overlay, Overlay::ConversationPicker { .. }) { self.update_slash_overlay(); } } - // Forward remaining input to root pane (contains the Input widget). - self.get_delegate_mut().on_input(queue) + result } } @@ -395,9 +403,9 @@ impl ChatScreen { .children([ header as Box, itinerary as Box, - phase_bar as Box, body, overlay as Box, + phase_bar as Box, input_row, footer as Box, ]); @@ -482,15 +490,23 @@ impl ChatScreen { let text = self.get_input_text(); if text.starts_with('/') { let prefix = text.trim(); - let match_refs: Vec<&crate::ui::chat::SlashDef> = crate::ui::chat::SLASH_COMMANDS + let match_refs: Vec<&'static crate::ui::chat::SlashDef> = crate::ui::chat::SLASH_COMMANDS .iter() .filter(|cmd| cmd.name.starts_with(prefix) || prefix.len() <= 1) - .map(|cmd| cmd) .collect(); if !match_refs.is_empty() { + let selected = self.slash_selected.min(match_refs.len().saturating_sub(1)); if let Some(o) = self.root.get_widget_mut(self.overlay_id) { - o.show_slash_commands(self.slash_selected.min(match_refs.len().saturating_sub(1)), &match_refs, &self.palette); + o.show_slash_commands(selected, &match_refs, &self.palette); + } + self.slash_selected = selected; + // Mirror into ChatState so the input-routing layer (has_overlay, + // overlay_move, overlay_confirm) sees and drives the overlay. + // Without this the overlay rendered but Up/Down/Enter/Esc never + // routed to it. + if let Some(chat) = self.chat.as_mut() { + chat.overlay = Overlay::SlashComplete { selected, matches: match_refs }; } return; } @@ -498,6 +514,14 @@ impl ChatScreen { if let Some(o) = self.root.get_widget_mut(self.overlay_id) { o.hide(); } + // Clear a stale slash overlay from state. The conversation picker is + // excluded by the early return above, so we never clobber it here. + if let Some(chat) = self.chat.as_mut() { + if matches!(chat.overlay, Overlay::SlashComplete { .. }) { + chat.overlay = Overlay::None; + } + } + self.slash_selected = 0; } fn dismiss_overlay(&mut self) { @@ -720,6 +744,22 @@ impl ChatScreen { 0u32, // memory_commits placeholder )); + // Health data for sidebar + let health_data = crate::ui::widgets::chat_sidebar::HealthData { + backend_mode: chat.backend_mode.clone(), + backend_healthy: chat.backend_healthy, + last_reflection: chat.last_reflection.clone(), + last_archivist: chat.last_archivist.clone(), + last_compaction: chat.last_compaction.clone(), + strain_504: chat.strain_504, + strain_429: chat.strain_429, + strain_other: chat.strain_other, + }; + + // Subconscious stream data — live N+1 reasoning text for the phase bar. + let subconscious_stream_lines = chat.subconscious_stream.clone(); + let subconscious_live_line = chat.subconscious_current.clone(); + PollSnapshot { msgs, needs_scroll, @@ -741,6 +781,9 @@ impl ChatScreen { conversation_id, tools_expanded, sidebar_data, + health_data, + subconscious_stream_lines, + subconscious_live_line, } }; @@ -765,6 +808,7 @@ impl ChatScreen { c.set_subconscious(cockpit_log, &self.palette); c.set_active(active); c.set_pressure(snapshot.pressure, &self.palette); + c.refresh_inner_voice(); } } @@ -791,6 +835,17 @@ impl ChatScreen { let style = Style::new().fg(theme::to_tuie_color(self.palette.agent_primary)); let dim_style = Style::new().fg(theme::to_tuie_color(self.palette.agent_dim)); pb.set_phase(kind, spinner_idx, elapsed, snapshot.tool_calls, snapshot.queued, quiet, style, dim_style); + if snapshot.phase == TurnPhase::Subconscious + && (!snapshot.subconscious_live_line.is_empty() + || !snapshot.subconscious_stream_lines.is_empty()) + { + pb.set_subconscious_stream( + &snapshot.subconscious_live_line, + &snapshot.subconscious_stream_lines, + style, + dim_style, + ); + } } } @@ -863,6 +918,7 @@ impl ChatScreen { 0 }; s.update(&status, snapshot.pressure, n1, &self.palette); + s.update_health(&snapshot.health_data, &self.palette); } } } @@ -1006,6 +1062,9 @@ struct PollSnapshot { conversation_id: String, tools_expanded: bool, sidebar_data: Option<(String, String, u8, usize, u32)>, + health_data: crate::ui::widgets::chat_sidebar::HealthData, + subconscious_stream_lines: Vec, + subconscious_live_line: String, } // ── Tests ──────────────────────────────────────────────────────────────────── @@ -1013,15 +1072,12 @@ struct PollSnapshot { #[cfg(test)] mod tests { use super::*; - use crate::ui::chat::{ChatMessage, ChatPalette, ToolResultBlock}; - use crate::ui::widgets::message_list::MsgKind; - use std::cell::RefCell; - use std::time::Instant; + use crate::ui::chat::ChatPalette; use tuie::input::chord::Chord; use tuie::input::key::Key; use tuie::input::modifiers::{Modifier, Modifiers}; use tuie::input::trigger::Trigger; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; // ── Input routing helpers ───────────────────────────────────────────── @@ -1037,10 +1093,10 @@ mod tests { /// Create a chat screen, render it, and focus its input so typed keys are /// routed to the `Input` leaf (the focus-chain forward walk needs a target). - fn focused_screen() -> (Box, TestTerminal) { + fn focused_screen() -> (Box, Emulator) { let config = Arc::new(RwLock::new(ConsciousnessConfig::default())); let mut screen = ChatScreen::new(config, ChatPalette::default(), "test-agent".into()); - let mut term = TestTerminal::new(&mut *screen, Vec2::new(120, 40)); + let mut term = Emulator::new(&mut *screen, Vec2::new(120, 40)); screen.focus_input(); // Force a dirty frame so `layout_and_render` runs `repair_selection`, // which is what actually applies a pending focus request. Without a @@ -1124,7 +1180,7 @@ mod tests { let palette = ChatPalette::default(); let mut screen = ChatScreen::new(config, palette, "test-agent".into()); - let term = TestTerminal::new(&mut *screen, Vec2::new(120, 40)); + let term = Emulator::new(&mut *screen, Vec2::new(120, 40)); let rendered = term.get_snapshot_text(); assert!( @@ -1137,239 +1193,6 @@ mod tests { ); } - // ── chat_message_to_msgkind conversion tests ────────────────────────── - - #[test] - fn user_message_converts_to_user_msgkind() { - let msg = ChatMessage::User { - text: "hello".into(), - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::User { ref name, ref text } - if name == "you" && text == "hello"), - "expected User name=you text=hello, got {result:?}" - ); - } - - #[test] - fn assistant_message_converts_streaming() { - let msg = ChatMessage::Assistant { - text: "hi".into(), - streaming: true, - ts: Instant::now(), - rendered_cache: RefCell::new(None), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Assistant { ref name, ref text, streaming } - if name == "agent" && text == "hi" && streaming), - "expected Assistant name=agent text=hi streaming=true, got {result:?}" - ); - } - - #[test] - fn assistant_message_converts_not_streaming() { - let msg = ChatMessage::Assistant { - text: "hi".into(), - streaming: false, - ts: Instant::now(), - rendered_cache: RefCell::new(None), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Assistant { streaming, .. } if !streaming), - "expected Assistant streaming=false, got {result:?}" - ); - } - - #[test] - fn system_message_converts() { - let msg = ChatMessage::System { - text: "status".into(), - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::System { ref text } if text == "status"), - "expected System text=status, got {result:?}" - ); - } - - #[test] - fn surfacing_message_converts() { - let msg = ChatMessage::Surfacing { - source: "sub".into(), - content: "msg".into(), - priority: "high".into(), - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Surfacing { ref source, ref content, ref priority } - if source == "sub" && content == "msg" && priority == "high"), - "expected Surfacing with matching fields, got {result:?}" - ); - } - - #[test] - fn interjection_message_converts() { - let msg = ChatMessage::Interjection { - text: "hey".into(), - delivered: false, - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Interjection { ref text, delivered } - if text == "hey" && !delivered), - "expected Interjection delivered=false, got {result:?}" - ); - } - - #[test] - fn interjection_message_converts_delivered() { - let msg = ChatMessage::Interjection { - text: "hey".into(), - delivered: true, - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Interjection { delivered, .. } if delivered), - "expected Interjection delivered=true, got {result:?}" - ); - } - - #[test] - fn tool_message_pending() { - let msg = ChatMessage::Tool { - id: "t1".into(), - name: "read".into(), - arguments: "{}".into(), - round: 1, - result: None, - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Tool { ref name, ref args_summary, round, is_error, is_pending } - if name == "read" && args_summary == "{}" && round == 1 && !is_error && is_pending), - "expected Tool pending with name=read, got {result:?}" - ); - } - - #[test] - fn tool_message_with_result() { - let msg = ChatMessage::Tool { - id: "t2".into(), - name: "write".into(), - arguments: r#"{"path":"/tmp/x"}"#.into(), - round: 2, - result: Some(ToolResultBlock { - output: "ok".into(), - is_error: false, - }), - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Tool { ref name, is_pending: false, is_error: false, .. } - if name == "write"), - "expected Tool with result (not pending, not error), got {result:?}" - ); - } - - #[test] - fn tool_message_with_error() { - let msg = ChatMessage::Tool { - id: "t3".into(), - name: "exec".into(), - arguments: r#"{"cmd":"ls"}"#.into(), - round: 3, - result: Some(ToolResultBlock { - output: "permission denied".into(), - is_error: true, - }), - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Tool { ref name, is_pending: false, is_error: true, .. } - if name == "exec"), - "expected Tool with error (not pending, is_error), got {result:?}" - ); - } - - #[test] - fn tool_message_truncates_long_arguments() { - let long_args = "a".repeat(80); - let msg = ChatMessage::Tool { - id: "t4".into(), - name: "long".into(), - arguments: long_args.clone(), - round: 1, - result: None, - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - let (got_len, got_text) = match &result { - MsgKind::Tool { args_summary, .. } => (args_summary.len(), args_summary.clone()), - other => (0, format!("{other:?}")), - }; - assert!( - matches!(result, MsgKind::Tool { ref args_summary, .. } - if args_summary.len() == 60 && args_summary.ends_with('…')), - "expected truncated args (60 chars ending with …), got length={got_len} args={got_text:?}" - ); - } - - #[test] - fn image_message_converts_to_system() { - let msg = ChatMessage::Image { - media_type: "image/png".into(), - label: "photo.png".into(), - data: "base64blob".into(), - dimensions: None, - ts: Instant::now(), - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::System { ref text } - if text.contains("image") && text.contains("photo.png")), - "expected System text containing 'image' and 'photo.png', got {result:?}" - ); - } - - #[test] - fn interstitial_message_converts_voice() { - let msg = ChatMessage::Interstitial { - text: "waiting".into(), - register: crate::backend::Register::HerVoice, - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Interstitial { ref text, is_voice } - if text == "waiting" && is_voice), - "expected Interstitial is_voice=true, got {result:?}" - ); - } - - #[test] - fn interstitial_message_converts_cenno() { - let msg = ChatMessage::Interstitial { - text: "checking".into(), - register: crate::backend::Register::Cenno, - }; - let result = chat_message_to_msgkind(&msg); - assert!( - matches!(result, MsgKind::Interstitial { ref text, is_voice } - if text == "checking" && !is_voice), - "expected Interstitial is_voice=false, got {result:?}" - ); - } - // ── Toggle tests ───────────────────────────────────────────────────── #[test] @@ -1398,8 +1221,8 @@ mod tests { term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]); let out = term.get_snapshot_text(); - assert!(out.contains("thinking") && out.contains("subconscious"), - "both cockpit panes should render\n{out}"); + assert!(out.contains("thinking") && out.contains("subconscious") && out.contains("inner voice"), + "all three cockpit panes should render\n{out}"); // When the panes fill the ~36-row body, the right border glyph appears // on many rows. When collapsed (the bug) it was ~4. Use a conservative // threshold well above the collapsed count. @@ -1456,14 +1279,14 @@ mod tests { fn cockpit_renders_on_screen_when_toggled() { let config = Arc::new(RwLock::new(ConsciousnessConfig::default())); let mut screen = ChatScreen::new(config, ChatPalette::default(), "test-agent".into()); - let mut term = TestTerminal::new(&mut *screen, Vec2::new(120, 40)); + let mut term = Emulator::new(&mut *screen, Vec2::new(120, 40)); tuie::dirty_paint(); term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]); // Hidden: the cockpit pane titles must NOT be on screen. let before = term.get_snapshot_text(); assert!( - !before.contains("thinking") && !before.contains("subconscious"), + !before.contains("thinking") && !before.contains("subconscious") && !before.contains("inner voice"), "cockpit should be off-screen before toggle, got: {before:?}" ); @@ -1472,6 +1295,7 @@ mod tests { if let Some(c) = screen.root.get_widget_mut(screen.cockpit_id) { c.set_thinking(&["weighing the request".to_string()], &ChatPalette::default()); c.set_subconscious(&[], &ChatPalette::default()); + c.refresh_inner_voice(); } tuie::dirty_paint(); term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]); @@ -1492,93 +1316,3 @@ mod tests { } } -// ── Message conversion ─────────────────────────────────────────────────────── - -fn is_system_context_dump(text: &str) -> bool { - let heading_count = text.lines().filter(|l| l.starts_with("## ") || l.starts_with("# ")).count(); - heading_count >= 3 && text.len() > 500 -} - -fn system_context_summary(text: &str) -> String { - let headings: Vec<&str> = text.lines() - .filter(|l| l.starts_with("## ") || l.starts_with("# ")) - .take(4) - .map(|l| l.trim_start_matches('#').trim()) - .collect(); - let line_count = text.lines().count(); - if headings.is_empty() { - format!("Agent context ({line_count} lines)") - } else { - format!("Agent context: {} ({line_count} lines)", headings.join(", ")) - } -} - -fn chat_message_to_msgkind(msg: &ChatMessage) -> MsgKind { - match msg { - ChatMessage::User { text, .. } => MsgKind::User { - name: "you".into(), - text: text.clone(), - }, - ChatMessage::Assistant { - text, streaming, .. - } => { - if !*streaming && is_system_context_dump(text) { - return MsgKind::SystemContext { - summary: system_context_summary(text), - _full_text: text.clone(), - }; - } - MsgKind::Assistant { - name: "agent".into(), - text: text.clone(), - streaming: *streaming, - } - } - ChatMessage::Surfacing { - source, - content, - priority, - .. - } => MsgKind::Surfacing { - source: source.clone(), - content: content.clone(), - priority: priority.clone(), - }, - ChatMessage::System { text, .. } => MsgKind::System { - text: text.clone(), - }, - ChatMessage::Interjection { - text, delivered, .. - } => MsgKind::Interjection { - text: text.clone(), - delivered: *delivered, - }, - ChatMessage::Interstitial { text, register } => MsgKind::Interstitial { - text: text.clone(), - is_voice: matches!(register, crate::backend::Register::HerVoice), - }, - ChatMessage::Tool { - name, - arguments, - round, - result, - .. - } => { - let args = if arguments.len() > 60 { - format!("{}…", &arguments[..57]) - } else { - arguments.clone() - }; - MsgKind::Tool { - name: name.clone(), - args_summary: args, - round: *round, - is_error: result.as_ref().map(|r| r.is_error).unwrap_or(false), - is_pending: result.is_none(), - } - } - ChatMessage::Image { label, .. } => MsgKind::System { - text: format!("[image: {label}]"), - }, - } -} diff --git a/src/ui/screens/cron.rs b/src/ui/screens/cron.rs index 7ea4fc0..0861971 100644 --- a/src/ui/screens/cron.rs +++ b/src/ui/screens/cron.rs @@ -1,9 +1,9 @@ //! Cron schedule editor screen. //! -//! Shows configured cron schedules as a scrollable list with expression, -//! description, enabled/disabled status, and last run time. Arrow keys -//! navigate, Enter toggles enable/disable, 'n' adds a new schedule -//! (placeholder), 'd' deletes the selected schedule, Esc returns to Welcome. +//! Schedules render as bordered cards in a [`SelectList`] — expression, +//! description, enabled/disabled badge, last run. Arrow keys navigate; Enter or +//! a single click toggles enable/disable; 'n' adds a new schedule (placeholder), +//! 'd' deletes the selected one; Esc returns to Welcome (handled by `TuieApp`). use std::collections::HashMap; use std::path::PathBuf; @@ -13,18 +13,17 @@ use tuie::prelude::*; use crate::core::nervous::cron::{self, ScheduleEntry, ScheduleKind, ScheduleState}; use crate::ui::chat::ChatPalette; use crate::ui::theme; +use crate::ui::widgets::select_list::{ActivateEvent, SelectList}; pub struct CronScreen { root: Box, - /// WidgetId for the scroll body pane — replaced on rebuild. - list_id: WidgetId, + list_id: WidgetId, /// Schedule entries loaded from the schedules directory. entries: Vec, /// Live run-state keyed by entry name. run_state: HashMap, - selected: usize, - primary: Color, dim: Color, + #[allow(dead_code)] // retained for persistence work (writing edits back) schedules_dir: PathBuf, } @@ -44,23 +43,23 @@ impl DelegateWidget for CronScreen { match key { Key::Arrow(Direction2D::Up) => { queue.next(); - if self.selected > 0 { - self.selected -= 1; - self.rebuild_list(); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.move_up(); } return InputResult::Handled; } Key::Arrow(Direction2D::Down) => { queue.next(); - if self.selected + 1 < self.entries.len() { - self.selected += 1; - self.rebuild_list(); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.move_down(); } return InputResult::Handled; } Key::Enter => { queue.next(); - self.toggle_selected(); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.activate_selected(); + } return InputResult::Handled; } Key::Char('n') => { @@ -79,6 +78,13 @@ impl DelegateWidget for CronScreen { } self.get_delegate_mut().on_input(queue) } + + fn after_on_event(&mut self, event: &mut WidgetEvent) { + // Activation (Enter or click) toggles the row. + if let Some(&ActivateEvent(idx)) = event.get_by::(self.list_id) { + self.toggle(idx); + } + } } impl CronScreen { @@ -91,7 +97,7 @@ impl CronScreen { // Load persisted run-state (.state.json). let state_file = schedules_dir.join(".state.json"); - let run_state = if state_file.exists() { + let run_state: HashMap = if state_file.exists() { std::fs::read_to_string(&state_file) .ok() .and_then(|data| serde_json::from_str(&data).ok()) @@ -100,8 +106,11 @@ impl CronScreen { HashMap::new() }; - let mut list_id = WidgetId::EMPTY; - let scroll_body = build_scroll_body(&entries, &run_state, 0, primary, dim, &mut list_id); + let list = SelectList::new() + .colors(primary, Color::BRIGHT_BLACK) + .bordered() + .items(build_rows(&entries, &run_state, dim)); + let list_id = list.get_id(); let root = Pane::new() .vertical() @@ -109,14 +118,13 @@ impl CronScreen { .padding(Spacing::new().horizontal(1).top(1).bottom(1)) .gap(1) .children([ + Text::new().content(StyledStr::new(" Cron Schedules ").fg(primary).bold()) + as Box, Text::new().content( - StyledStr::new(" Cron Schedules ").fg(primary).bold(), - ), - Text::new().content( - StyledStr::new(" arrow keys select · Enter toggle · n add · d delete · Esc back") + StyledStr::new(" arrow keys select \u{b7} Enter or click toggle \u{b7} n add \u{b7} d delete \u{b7} Esc back") .fg(Color::BRIGHT_BLACK), ), - scroll_body, + list, footer_hint(dim), ]); @@ -125,46 +133,29 @@ impl CronScreen { list_id, entries, run_state, - selected: 0, - primary, dim, schedules_dir, }) } - /// Rebuild the scroll body when selection or entries change. - fn rebuild_list(&mut self) { - let cards = build_cards(&self.entries, &self.run_state, self.selected, self.primary, self.dim); - if let Some(scroll) = self.root.get_widget_mut(self.list_id) { - scroll.clear(); - if cards.is_empty() { - let mut empty_content = StyledString::new(); - empty_content.push_str("\n"); - empty_content.push_span( - StyledStr::new(" (no schedules configured — press 'n' to add one)\n") - .fg(Color::BRIGHT_BLACK) - .italic(), - ); - empty_content.push_str("\n"); - scroll.add_child(Text::new().content(empty_content)); - } else { - for card in cards { - scroll.add_child(card); - } - } + /// Push the rebuilt rows into the list, holding selection at `keep`. + fn refresh(&mut self, keep: usize) { + let rows = build_rows(&self.entries, &self.run_state, self.dim); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.set_items(rows); + list.select(keep.min(self.entries.len().saturating_sub(1))); } - self.root.dirty_layout(); } - /// Toggle enabled/disabled on the selected entry. - fn toggle_selected(&mut self) { - if let Some(entry) = self.entries.get_mut(self.selected) { + /// Toggle enabled/disabled on a specific entry (activation target). + fn toggle(&mut self, idx: usize) { + if let Some(entry) = self.entries.get_mut(idx) { entry.enabled = !entry.enabled; } - self.rebuild_list(); + self.refresh(idx); } - /// Add a placeholder schedule entry. + /// Add a placeholder schedule entry and select it. fn add_placeholder(&mut self) { let count = self.entries.len() + 1; let entry = ScheduleEntry { @@ -178,8 +169,8 @@ impl CronScreen { prompt: String::new(), }; self.entries.push(entry); - self.selected = self.entries.len().saturating_sub(1); - self.rebuild_list(); + let last = self.entries.len().saturating_sub(1); + self.refresh(last); } /// Delete the selected entry from the in-memory list. @@ -187,65 +178,30 @@ impl CronScreen { if self.entries.is_empty() { return; } - let idx = self.selected; + let idx = self + .root + .get_widget_mut(self.list_id) + .map(|l| l.selected_index()) + .unwrap_or(0); let name = self.entries[idx].name.clone(); self.entries.remove(idx); self.run_state.remove(&name); - if self.selected >= self.entries.len() { - self.selected = self.entries.len().saturating_sub(1); - } - self.rebuild_list(); + self.refresh(idx); } } // ── Builders ───────────────────────────────────────────────────────────────── -/// Build the scrollable body pane containing all schedule cards. -fn build_scroll_body( +/// Build one styled row per schedule entry. Selection prefix and border are +/// owned by [`SelectList`]; this is the content only. +fn build_rows( entries: &[ScheduleEntry], run_state: &HashMap, - selected: usize, - primary: Color, dim: Color, - list_id: &mut WidgetId, -) -> Box { - let cards = build_cards(entries, run_state, selected, primary, dim); - let mut scroll_body = Pane::new().vertical().flex(1).gap(0); - - if cards.is_empty() { - let mut empty_content = StyledString::new(); - empty_content.push_str("\n"); - empty_content.push_span( - StyledStr::new(" (no schedules configured — press 'n' to add one)\n") - .fg(Color::BRIGHT_BLACK) - .italic(), - ); - empty_content.push_str("\n"); - scroll_body.add_child(Text::new().content(empty_content)); - } else { - for card in cards { - scroll_body.add_child(card); - } - } - - scroll_body.id(list_id) -} - -/// Build one bordered card per schedule entry. -fn build_cards( - entries: &[ScheduleEntry], - run_state: &HashMap, - selected: usize, - primary: Color, - dim: Color, -) -> Vec> { +) -> Vec { entries .iter() - .enumerate() - .map(|(i, entry)| { - let is_selected = i == selected; - let border_color = if is_selected { primary } else { Color::BRIGHT_BLACK }; - let marker = if is_selected { ">" } else { " " }; + .map(|entry| { let (enabled_marker, enabled_color) = if entry.enabled { ("enabled", Color::GREEN) } else { @@ -265,47 +221,23 @@ fn build_cards( .unwrap_or_else(|| "never".to_string()); let fire_count = state.map(|s| s.fire_count).unwrap_or(0); - let name_color = if is_selected { primary } else { Color::Foreground }; - - // Build the card content: two lines of styled text. + // Line 1: enabled badge, name, schedule, kind. let mut content = StyledString::new(); - - // Line 1: marker, enabled badge, name, schedule, kind - content.push_span( - StyledStr::new(&format!("{marker} ")) - .fg(if is_selected { primary } else { Color::BRIGHT_BLACK }), - ); - content.push_span( - StyledStr::new(&format!("{enabled_marker} ")) - .fg(enabled_color), - ); - content.push_span( - StyledStr::new(&entry.name).bold().fg(name_color), - ); - content.push_span( - StyledStr::new(&format!(" {}", entry.schedule)).fg(dim), - ); + content.push_span(StyledStr::new(&format!("{enabled_marker} ")).fg(enabled_color)); + content.push_span(StyledStr::new(&entry.name).bold()); + content.push_span(StyledStr::new(&format!(" {}", entry.schedule)).fg(dim)); content.push_span( StyledStr::new(&format!(" ({kind_label})")) .fg(Color::BRIGHT_BLACK) .italic(), ); - - // Line 2: last run time, fire count + // Line 2: last run time, fire count. content.push_str("\n"); content.push_span( - StyledStr::new(&format!( - " last run: {last_run} · fired {fire_count} times" - )) - .fg(dim), + StyledStr::new(&format!("last run: {last_run} \u{b7} fired {fire_count} times")) + .fg(dim), ); - - Pane::new() - .vertical() - .bordered() - .border_style(Style::new().fg(border_color).dim()) - .padding(Spacing::new().horizontal(2).vertical(1)) - .children([Text::new().content(content)]) as Box + content }) .collect() } @@ -313,7 +245,7 @@ fn build_cards( /// Footer with keybinding hints. fn footer_hint(dim: Color) -> Box { Text::new().content( - StyledStr::new(" up/down navigate · Enter toggle · n add · d delete · Esc back") + StyledStr::new(" up/down navigate \u{b7} Enter/click toggle \u{b7} n add \u{b7} d delete \u{b7} Esc back") .fg(dim), ) } @@ -322,7 +254,7 @@ fn footer_hint(dim: Color) -> Box { #[cfg(test)] mod tests { - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; use super::*; use crate::ui::chat::ChatPalette; @@ -333,7 +265,7 @@ mod tests { let dir = std::path::PathBuf::from("/tmp/test_cron_header"); let _ = std::fs::create_dir_all(&dir); let mut screen = CronScreen::new(dir, &palette); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("Cron") || rendered.contains("Schedule"), @@ -342,16 +274,16 @@ mod tests { } #[test] - fn cron_screen_empty_state() { + fn cron_screen_shows_add_hint() { let palette = ChatPalette::default(); let dir = std::path::PathBuf::from("/tmp/test_cron_empty"); let _ = std::fs::create_dir_all(&dir); let mut screen = CronScreen::new(dir, &palette); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("n") || rendered.contains("add"), - "expected 'n' or 'add' hint in empty state, got: {rendered:?}" + "expected 'n' or 'add' hint, got: {rendered:?}" ); } } diff --git a/src/ui/screens/manager.rs b/src/ui/screens/manager.rs index 9b0687d..66f6e74 100644 --- a/src/ui/screens/manager.rs +++ b/src/ui/screens/manager.rs @@ -1,7 +1,8 @@ //! Agent manager screen — scrollable list of agent processes with status info. //! -//! Shows each known agent as a row with name, PID, status, uptime %, and memory -//! file count. Arrow keys navigate, Enter selects, k=kill, r=restart, Esc=back. +//! Each known agent is a row with name, instance count, status, uptime %, and +//! memory file count, in a [`SelectList`]. Arrow keys navigate; Enter or a +//! single click selects; k=kill, r=restart, Esc=back. //! //! Ported from `src/ui/app/manager_screen.rs` (ratatui → tuie). @@ -12,6 +13,7 @@ use tuie::prelude::*; use crate::ui::chat::ChatPalette; use crate::ui::theme; +use crate::ui::widgets::select_list::{ActivateEvent, SelectList}; // ── Agent process info ─────────────────────────────────────────────────────────── @@ -37,13 +39,12 @@ pub struct AgentProcessInfo { pub struct ManagerScreen { root: Box, - /// WidgetId for the scroll body — rebuilt on selection or data change. - scroll_id: WidgetId, + list_id: WidgetId, /// All known agents. agents: Vec, - /// Currently highlighted row index. + /// Shadow of the list's selection, kept current for k/r and selected_agent. selected: usize, - /// Shared with TuieApp — set to Some(index) when Enter is pressed. + /// Shared with TuieApp — set to Some(index) when a row is activated. pub selection: Rc>>, /// Shared flag — set to true when Esc is pressed (caller pops this screen). pub back_pressed: Rc>, @@ -69,24 +70,26 @@ impl DelegateWidget for ManagerScreen { match key { Key::Arrow(Direction2D::Up) => { queue.next(); - if self.selected > 0 { - self.selected -= 1; - self.rebuild_rows(); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.move_up(); + self.selected = list.selected_index(); } return InputResult::Handled; } Key::Arrow(Direction2D::Down) => { queue.next(); - if self.selected + 1 < self.agents.len() { - self.selected += 1; - self.rebuild_rows(); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.move_down(); + self.selected = list.selected_index(); } return InputResult::Handled; } Key::Enter => { queue.next(); if !self.agents.is_empty() { - self.selection.set(Some(self.selected)); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.activate_selected(); + } } return InputResult::Handled; } @@ -115,6 +118,15 @@ impl DelegateWidget for ManagerScreen { } self.get_delegate_mut().on_input(queue) } + + fn after_on_event(&mut self, event: &mut WidgetEvent) { + if let Some(&ActivateEvent(idx)) = event.get_by::(self.list_id) { + if !self.agents.is_empty() { + self.selected = idx; + self.selection.set(Some(idx)); + } + } + } } impl ManagerScreen { @@ -129,34 +141,19 @@ impl ManagerScreen { let dim = theme::to_tuie_color(palette.agent_dim); let primary = theme::to_tuie_color(palette.agent_primary); - // Header row. - let header = Text::new() - .content( - StyledStr::new(" Agent Manager ") - .fg(primary) - .bold(), - ); - - // Column guide. + let header = Text::new().content(StyledStr::new(" Agent Manager ").fg(primary).bold()); let col_guide = Text::new().content( - StyledStr::new(" name pid status uptime memory") - .fg(dim), + StyledStr::new(" name pid status uptime memory").fg(dim), ); - // Scroll body — starts empty, rebuilt by refresh(). - let mut scroll_id = WidgetId::EMPTY; - let scroll_body = Pane::new() - .vertical() - .flex(1) - .gap(0) - .y_scroll(Scrollbar::AutoHide) - .id(&mut scroll_id); + // Empty to start — rebuilt by refresh(). + let list = SelectList::new() + .colors(primary, dim) + .items(Vec::new()); + let list_id = list.get_id(); - // Key bindings footer. - let footer = Text::new().content( - StyledStr::new(" Enter=select k=kill r=restart Esc=back") - .fg(dim), - ); + let footer = Text::new() + .content(StyledStr::new(" Enter/click=select k=kill r=restart Esc=back").fg(dim)); let root = Pane::new() .vertical() @@ -164,12 +161,9 @@ impl ManagerScreen { .padding(Spacing::new().horizontal(1).top(1).bottom(1)) .gap(0) .children([ - header, + header as Box, col_guide, - Pane::new().vertical().flex(0).children([ - Text::new().content(StyledStr::new("").fg(Color::BLACK)), - ]), - scroll_body, + list, footer, ]); @@ -179,7 +173,7 @@ impl ManagerScreen { let restart = restart_requested.clone(); let this = Box::new(Self { root, - scroll_id, + list_id, agents: Vec::new(), selected: 0, selection, @@ -194,127 +188,51 @@ impl ManagerScreen { /// Replace the agent list and rebuild rows. pub fn refresh(&mut self, agents: Vec, palette: &ChatPalette) { self.agents = agents; - self.selected = self - .selected - .min(self.agents.len().saturating_sub(1)); - self.rebuild_rows_with_palette(palette); + self.selected = self.selected.min(self.agents.len().saturating_sub(1)); + let rows = build_rows(&self.agents, palette); + if let Some(list) = self.root.get_widget_mut(self.list_id) { + list.set_items(rows); + list.select(self.selected); + } } /// Return the currently selected agent, if any. + #[allow(dead_code)] // public accessor; not yet consumed by TuieApp pub fn selected_agent(&self) -> Option<&AgentProcessInfo> { self.agents.get(self.selected) } +} - // ── Internal helpers ───────────────────────────────────────────────────── +/// Build one styled row per agent. Selection prefix/tint owned by [`SelectList`]. +fn build_rows(agents: &[AgentProcessInfo], palette: &ChatPalette) -> Vec { + let dim = theme::to_tuie_color(palette.agent_dim); + let green = Color::Rgb(120, 220, 160); - fn rebuild_rows(&mut self) { - // Use a default palette for key-driven rebuilds — palette is only - // needed for color; the caller should use refresh() for full rebuilds. - let palette = ChatPalette::default(); - self.rebuild_rows_with_palette(&palette); - } + agents + .iter() + .map(|agent| { + let status = if agent.instance_count > 0 { "active" } else { "idle " }; + let status_color = if agent.instance_count > 0 { green } else { dim }; - fn rebuild_rows_with_palette(&mut self, palette: &ChatPalette) { - let rows = Self::build_rows(&self.agents, self.selected, palette); - if let Some(scroll) = self.root.get_widget_mut(self.scroll_id) { - scroll.clear(); - for row in rows { - scroll.add_child(row); - } - } - self.root.dirty_layout(); - } - - fn build_rows( - agents: &[AgentProcessInfo], - selected: usize, - palette: &ChatPalette, - ) -> Vec> { - let dim = theme::to_tuie_color(palette.agent_dim); - let primary = theme::to_tuie_color(palette.agent_primary); - let fg = Color::WHITE; - - agents - .iter() - .enumerate() - .map(|(i, agent)| { - let is_selected = i == selected; - let row_fg = if is_selected { primary } else { fg }; - let row_dim = if is_selected { primary } else { dim }; - let cursor = if is_selected { "▶" } else { " " }; - - // Status string. - let status = if agent.instance_count > 0 { - "active" - } else { - "idle " - }; - let status_color = if agent.instance_count > 0 { - Color::Rgb(120, 220, 160) // green, semantic - } else { - dim - }; - - let mut content = StyledString::new(); - // Cursor prefix. - content.push_span(StyledStr::new(cursor).fg(row_dim)); - // Name. - let name_text = format!(" {:<16}", truncate_str(&agent.name, 16)); - let mut name_span = StyledStr::new(&name_text).fg(row_fg); - if is_selected { - name_span = name_span.bold(); - } - content.push_span(name_span); - // Separator. - content.push_span(StyledStr::new(" ").fg(row_dim)); - // PID / instance count. - let pid_str = if agent.instance_count > 0 { - format!("{:>5}", agent.instance_count) - } else { - format!("{:>5}", "—") - }; - content.push_span(StyledStr::new(&pid_str).fg(row_dim)); - // Separator. - content.push_span(StyledStr::new(" ").fg(row_dim)); - // Status. - content.push_span(StyledStr::new(&format!("{:<7}", status)).fg(status_color)); - // Separator. - content.push_span(StyledStr::new(" ").fg(row_dim)); - // Uptime %. - let uptime_str = format!("{:>3}%", agent.uptime_pct); - content.push_span( - StyledStr::new(&uptime_str).fg(if agent.uptime_pct > 0 { - Color::Rgb(120, 220, 160) - } else { - dim - }), - ); - // Separator. - content.push_span(StyledStr::new(" ").fg(row_dim)); - // Memory file count. - content.push_span(StyledStr::new(&format!("{:>5} files", agent.memory_count)).fg(row_dim)); - - let row_style = if is_selected { - Style::new() - .fg(primary) - .bg(Color::Rgb(40, 40, 60)) - .bold() - } else { - Style::new().fg(fg) - }; - - let row: Box = Pane::new() - .horizontal() - .padding(Spacing::new().horizontal(0).vertical(0)) - .children([ - Text::new().content(content), - ]) - .style(row_style); - - row - }) - .collect() - } + let mut content = StyledString::new(); + content.push_span(StyledStr::new(&format!("{:<16}", truncate_str(&agent.name, 16))).bold()); + content.push_span(StyledStr::new(" ").fg(dim)); + let pid_str = if agent.instance_count > 0 { + format!("{:>5}", agent.instance_count) + } else { + format!("{:>5}", "\u{2014}") + }; + content.push_span(StyledStr::new(&pid_str).fg(dim)); + content.push_span(StyledStr::new(" ").fg(dim)); + content.push_span(StyledStr::new(&format!("{:<7}", status)).fg(status_color)); + content.push_span(StyledStr::new(" ").fg(dim)); + let uptime_str = format!("{:>3}%", agent.uptime_pct); + content.push_span(StyledStr::new(&uptime_str).fg(if agent.uptime_pct > 0 { green } else { dim })); + content.push_span(StyledStr::new(" ").fg(dim)); + content.push_span(StyledStr::new(&format!("{:>5} files", agent.memory_count)).fg(dim)); + content + }) + .collect() } /// Truncate a string to at most `max_len` characters, appending "…" if cut. @@ -323,7 +241,7 @@ fn truncate_str(s: &str, max_len: usize) -> String { s.to_string() } else { let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); - format!("{}…", truncated) + format!("{}\u{2026}", truncated) } } @@ -331,7 +249,7 @@ fn truncate_str(s: &str, max_len: usize) -> String { #[cfg(test)] mod tests { - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; use super::*; use crate::ui::chat::ChatPalette; @@ -340,7 +258,7 @@ mod tests { fn manager_screen_renders_header() { let palette = ChatPalette::default(); let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("Agent Manager"), @@ -352,10 +270,8 @@ mod tests { fn manager_screen_empty_state() { let palette = ChatPalette::default(); let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); - // When empty the column guide and footer are still present — no crash - // and the column headers serve as a hint. assert!( !rendered.trim().is_empty(), "empty manager screen should still render column guide and footer" @@ -381,7 +297,7 @@ mod tests { pubkey_prefix: "abcdef01".into(), }]; screen.refresh(agents, &palette); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("TestBot"), diff --git a/src/ui/screens/mod.rs b/src/ui/screens/mod.rs index 51e629e..e152fab 100644 --- a/src/ui/screens/mod.rs +++ b/src/ui/screens/mod.rs @@ -11,4 +11,3 @@ pub mod welcome; pub mod presence; pub mod cron; pub mod agents; -pub mod manager; diff --git a/src/ui/screens/presence.rs b/src/ui/screens/presence.rs index 4986749..7d7d61d 100644 --- a/src/ui/screens/presence.rs +++ b/src/ui/screens/presence.rs @@ -587,7 +587,7 @@ fn posture_color(posture: Posture, palette: &ChatPalette) -> Color { #[cfg(test)] mod tests { - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; use super::*; use crate::ui::atmosphere::Atmosphere; @@ -599,7 +599,7 @@ mod tests { let palette = ChatPalette::default(); let (mut screen, _should_exit) = PresenceScreen::new(&palette, Atmosphere::Default, Posture::Idle); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("Presence"), @@ -612,7 +612,7 @@ mod tests { let palette = ChatPalette::default(); let (mut screen, _should_exit) = PresenceScreen::new(&palette, Atmosphere::Default, Posture::Idle); - let term = TestTerminal::new(&mut *screen, Vec2::new(80, 20)); + let term = Emulator::new(&mut *screen, Vec2::new(80, 20)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("R") || rendered.contains("Record") || rendered.contains("Esc"), diff --git a/src/ui/screens/settings/field_grid.rs b/src/ui/screens/settings/field_grid.rs index 6d2142f..f2b9c2d 100644 --- a/src/ui/screens/settings/field_grid.rs +++ b/src/ui/screens/settings/field_grid.rs @@ -35,7 +35,7 @@ use crate::ui::chat::ChatPalette; pub fn is_model_loc(loc: FieldLoc) -> bool { matches!( loc, - FieldLoc::BfPrimaryModel + FieldLoc::PvPrimaryModel | FieldLoc::AgModel | FieldLoc::ScModel | FieldLoc::RfModel diff --git a/src/ui/screens/settings/mod.rs b/src/ui/screens/settings/mod.rs index 99c3819..3d47203 100644 --- a/src/ui/screens/settings/mod.rs +++ b/src/ui/screens/settings/mod.rs @@ -220,14 +220,14 @@ impl DelegateWidget for SettingsScreen { Key::Arrow(Direction2D::Right) => { queue.next(); self.process_action(SettingsAction::CycleEnum( - self.selected_field().unwrap_or(FieldLoc::BfBaseUrl), + self.selected_field().unwrap_or(FieldLoc::PvName), 1, )); InputResult::Handled } Key::Arrow(Direction2D::Left) if self.focus_col == FocusCol::Fields => { queue.next(); - let loc = self.selected_field().unwrap_or(FieldLoc::BfBaseUrl); + let loc = self.selected_field().unwrap_or(FieldLoc::PvName); if !self.try_cycle_enum(loc, -1) { self.process_action(SettingsAction::FocusCategories); } @@ -537,7 +537,7 @@ impl SettingsScreen { EditableValue::Text(_) | EditableValue::Secret(_) | EditableValue::OptionalText(_) => { // Model fields open the model picker; all others open the text editor. if matches!(loc, - FieldLoc::BfPrimaryModel | + FieldLoc::PvPrimaryModel | FieldLoc::AgModel | FieldLoc::ScModel | FieldLoc::RfModel | @@ -565,6 +565,9 @@ impl SettingsScreen { if next != self.cat_idx as i32 { self.cat_idx = next as usize; self.field_idx = 0; + if Category::all().get(self.cat_idx) == Some(&Category::Providers) { + self.view.ensure_provider_selected(); + } self.rebuild(); } } @@ -646,6 +649,10 @@ impl SettingsScreen { } } SettingsAction::OpenModelPicker(loc) => { + if self.view.available_models.is_empty() { + self.enqueue(SettingsAction::FetchModels); + return; + } let models = self.view.available_models.clone(); let current = self.get_current_model_value(loc); let picker = ModelPicker::new(&models, ¤t, &self.palette) @@ -692,6 +699,22 @@ impl SettingsScreen { Ok(()) => { self.save_signal.set(true); } + Err(e) => { + self.show_footer_error(&format!("save failed: {e}")); + tracing::warn!("settings save failed: {e}"); + } + } + } + SettingsAction::SaveAndGoBack => { + // Save config to disk and signal go-back. + match self.view.save(&self.config_path) { + Ok(()) => { + self.go_back_signal.set(true); + } + Err(e) => { + self.show_footer_error(&format!("save failed: {e}")); + tracing::warn!("settings save failed: {e}"); + } Err(e) => { // TODO: show error in footer tracing::warn!("settings save failed: {e}"); @@ -796,6 +819,14 @@ impl SettingsScreen { } } + /// Show an error message in the footer for a few seconds. + fn show_footer_error(&mut self, msg: &str) { + let color = theme::to_tuie_color(self.palette.compaction); + if let Some(footer) = self.root.get_widget_mut(self.footer_id) { + footer.set_content(StyledStr::new(&format!(" ✗ {msg}")).fg(color)); + } + } + fn get_current_model_value(&self, loc: FieldLoc) -> String { let fields = self.view.fields_for_category(self.view.selected_category()); fields.iter() @@ -1029,15 +1060,26 @@ impl SettingsScreen { // into the tree; both layouts contribute (their widget ids are unique). self.field_map.clear(); - let wide_grid = FieldGrid::new(&fields, &self.palette); + // Highlight the selected row when the field column has focus. Rebuilt + // grids start unhighlighted, so without this Down/Up moves the cursor + // invisibly and field navigation looks dead. + let field_selected = matches!(self.focus_col, FocusCol::Fields); + + let mut wide_grid = FieldGrid::new(&fields, &self.palette); self.field_map.extend(wide_grid.row_map()); + if field_selected { + wide_grid.set_selected(self.field_idx); + } if let Some(pane) = self.root.get_widget_mut(self.wide_field_grid_id) { pane.clear(); pane.add_child(wide_grid.widget() as Box); } - let narrow_grid = FieldGrid::new(&fields, &self.palette); + let mut narrow_grid = FieldGrid::new(&fields, &self.palette); self.field_map.extend(narrow_grid.row_map()); + if field_selected { + narrow_grid.set_selected(self.field_idx); + } if let Some(pane) = self.root.get_widget_mut(self.narrow_field_grid_id) { pane.clear(); pane.add_child(narrow_grid.widget() as Box); @@ -1096,7 +1138,7 @@ mod tests { use super::*; use tuie::input::chord::Chord; use tuie::input::modifiers::Modifiers; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; fn screen() -> Box { let config = crate::core::config::ConsciousnessConfig::default(); @@ -1115,7 +1157,7 @@ mod tests { #[test] fn settings_renders_without_panic() { let mut s = screen(); - let term = TestTerminal::new(&mut *s, Vec2::new(120, 40)); + let term = Emulator::new(&mut *s, Vec2::new(120, 40)); assert!(!term.get_snapshot_text().trim().is_empty(), "settings rendered nothing"); } @@ -1124,12 +1166,12 @@ mod tests { /// or the left category list, so it's a clean signal the grid rebuilt. /// Uses the narrow (<80 col) layout because the wide layout's accordion /// expands via a scheduler-driven animation that doesn't advance under - /// `TestTerminal`, which would clip the body to one row. + /// `Emulator`, which would clip the body to one row. /// (Regression: rebuild() used to discard the new fields entirely.) #[test] fn category_change_swaps_field_grid() { let mut s = screen(); - let mut term = TestTerminal::new(&mut *s, Vec2::new(60, 40)); + let mut term = Emulator::new(&mut *s, Vec2::new(60, 40)); assert!( !term.get_snapshot_text().contains("virtual key"), "Agent category should not show Bifrost's virtual key field" @@ -1151,7 +1193,7 @@ mod tests { #[test] fn enum_renders_as_radio_group() { let mut s = screen(); - let mut term = TestTerminal::new(&mut *s, Vec2::new(60, 40)); + let mut term = Emulator::new(&mut *s, Vec2::new(60, 40)); // Agent(0) -> Inference(1) -> Bifrost(2) -> Subconscious(3). term.update(&mut *s, &[down(), down(), down()]); assert_eq!(s.cat_idx, 3, "expected to land on Subconscious"); @@ -1166,7 +1208,7 @@ mod tests { #[test] fn down_advances_category_selection() { let mut s = screen(); - let mut term = TestTerminal::new(&mut *s, Vec2::new(120, 40)); + let mut term = Emulator::new(&mut *s, Vec2::new(120, 40)); assert_eq!(s.cat_idx, 0); term.update(&mut *s, &[down()]); assert_eq!(s.cat_idx, 1, "Down should move to the next category"); diff --git a/src/ui/screens/welcome.rs b/src/ui/screens/welcome.rs index 7ea50b2..a2941b5 100644 --- a/src/ui/screens/welcome.rs +++ b/src/ui/screens/welcome.rs @@ -25,9 +25,10 @@ use crate::ui::chat::ChatPalette; use crate::ui::theme; use crate::ui::tuie_app::AgentStatus; use crate::ui::widgets::brand_title::BrandTitle; -use crate::ui::widgets::menu_list::{MenuItem, MenuList}; use crate::ui::widgets::portrait::Portrait; +use crate::ui::widgets::select_list::{ActivateEvent, SelectList}; use crate::ui::widgets::responsive::Responsive; +use crate::ui::widgets::stats::breathe_color; /// Terminal width (in columns) at or above which the wide layout is used. pub const WIDE_BREAKPOINT: u16 = 100; @@ -35,9 +36,9 @@ pub const WIDE_BREAKPOINT: u16 = 100; pub struct WelcomeScreen { root: Box, /// Menu in the wide arrangement. - menu_wide_id: WidgetId, + menu_wide_id: WidgetId, /// Menu in the stacked arrangement. - menu_narrow_id: WidgetId, + menu_narrow_id: WidgetId, title_id: WidgetId, /// Selected menu index, mirrored into both menus. selected: usize, @@ -74,7 +75,7 @@ impl DelegateWidget for WelcomeScreen { } Key::Enter => { queue.next(); - self.menu_action.set(Some(self.selected)); + self.choose(self.selected); return InputResult::Handled; } _ => {} @@ -83,6 +84,17 @@ impl DelegateWidget for WelcomeScreen { } self.get_delegate_mut().on_input(queue) } + + fn after_on_event(&mut self, event: &mut WidgetEvent) { + // A single click on either arrangement's menu activates that row. + for id in [self.menu_wide_id, self.menu_narrow_id] { + if let Some(&ActivateEvent(idx)) = event.get_by::(id) { + self.selected = idx; + self.apply_selection(); + self.choose(idx); + } + } + } } impl WelcomeScreen { @@ -223,50 +235,72 @@ impl WelcomeScreen { } } + /// Activate a menu entry if it is available (the signal `TuieApp` drains). + fn choose(&mut self, idx: usize) { + if menu_items().get(idx).map(|i| i.available).unwrap_or(false) { + self.menu_action.set(Some(idx)); + } + } + pub fn selected_index(&self) -> usize { self.selected } pub fn selected_menu_label(&self) -> Option { - menu_items().get(self.selected).map(|i| i.label.clone()) + menu_items().get(self.selected).map(|i| i.label.to_string()) } } // ── Breathing colour ───────────────────────────────────────────────────────── - -/// Gently pulse `base`'s brightness with a slow sine — palette-agnostic, so it -/// works whatever atmosphere colour the agent currently wears. -fn breathe_color(base: Color, tick: u64) -> Color { - let (r, g, b) = match base { - Color::Rgb(r, g, b) => (r, g, b), - _ => (255u8, 140, 66), - }; - let phase = (tick as f32 * 0.06).sin() * 0.5 + 0.5; // 0..1 - let f = 0.78 + phase * 0.22; // 0.78..1.0 - Color::Rgb( - (r as f32 * f) as u8, - (g as f32 * f) as u8, - (b as f32 * f) as u8, - ) -} +// +// `breathe_color` now lives in `crate::ui::widgets::stats` so the unified agent +// screen can share it. // ── Sub-widget builders ────────────────────────────────────────────────────── +/// One dashboard menu entry. +struct MenuItem { + label: &'static str, + description: &'static str, + available: bool, +} + fn menu_items() -> Vec { vec![ - MenuItem { label: "Chat".into(), description: "Talk with your agent".into(), available: true }, - MenuItem { label: "Agents".into(), description: "Select an agent".into(), available: true }, - MenuItem { label: "Schedule".into(), description: "Cron jobs & tasks".into(), available: true }, - MenuItem { label: "Settings".into(), description: "Configure".into(), available: true }, + MenuItem { label: "Chat", description: "Talk with your agent", available: true }, + MenuItem { label: "Agents", description: "Select an agent", available: true }, + MenuItem { label: "Schedule", description: "Cron jobs & tasks", available: true }, + MenuItem { label: "Settings", description: "Configure", available: true }, ] } -fn make_menu(palette: &ChatPalette) -> Box { +/// Menu rows as content for [`SelectList`] (selection prefix/tint owned by it). +fn menu_rows() -> Vec { + menu_items() + .iter() + .map(|item| { + let mut row = StyledString::new(); + let label = format!("{:<12}", item.label); + if item.available { + row.push_span(StyledStr::new(&label).bold()); + row.push_span(StyledStr::new(&format!("- {}", item.description)).fg(Color::BRIGHT_BLACK)); + } else { + row.push_span(StyledStr::new(&label).fg(Color::BRIGHT_BLACK)); + row.push_span( + StyledStr::new(&format!("- {} (coming soon)", item.description)) + .fg(Color::BRIGHT_BLACK) + .italic(), + ); + } + row + }) + .collect() +} + +fn make_menu(palette: &ChatPalette) -> Box { let primary = theme::to_tuie_color(palette.agent_primary); let dim = theme::to_tuie_color(palette.agent_dim); - MenuList::new() - .set_items(menu_items()) - .set_colors(primary, dim, theme::to_tuie_color(palette.tool_dim)) + SelectList::new().colors(primary, dim).items(menu_rows()) } /// A bordered pane with a coloured title bar at the top. diff --git a/src/ui/settings/draw.rs b/src/ui/settings/draw.rs index bb73805..16ff456 100644 --- a/src/ui/settings/draw.rs +++ b/src/ui/settings/draw.rs @@ -173,7 +173,7 @@ fn draw_footer(frame: &mut Frame, area: Rect, view: &SettingsView) { PanelFocus::Fields if area.width >= 80 => " ↑↓ field · ←→ cycle/focus · Enter edit · y save · Ctrl+S save & quit · Esc back", PanelFocus::Fields => " ↑↓ · ←→ cycle · Enter · y · Ctrl+S · Esc", }; - if matches!(view.selected_category(), Category::Bifrost) { + if matches!(view.selected_category(), Category::Providers) { if view.models_fetching { format!("{base} · fetching models…") } else if view.available_models.is_empty() { diff --git a/src/ui/settings/key_handling.rs b/src/ui/settings/key_handling.rs index 71e98a7..c9a577e 100644 --- a/src/ui/settings/key_handling.rs +++ b/src/ui/settings/key_handling.rs @@ -73,7 +73,7 @@ impl SettingsView { // r: fetch models (only in Bifrost category, any focus). if key.code == KeyCode::Char('r') - && matches!(self.selected_category(), Category::Bifrost) + && matches!(self.selected_category(), Category::Providers) && !self.models_fetching { self.models_fetching = true; @@ -254,7 +254,7 @@ impl SettingsView { FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent | FieldLoc::SaTimeout | FieldLoc::SaMaxDepth | FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs | - FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs | + FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::PvTimeoutSecs | FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::TuCennoThreshold | FieldLoc::EvRetainDays | FieldLoc::PrPulseIntervalSecs @@ -294,7 +294,7 @@ impl SettingsView { FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent | FieldLoc::SaTimeout | FieldLoc::SaMaxDepth | FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs | - FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs | + FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::PvTimeoutSecs | FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::TuCennoThreshold | FieldLoc::PrPulseIntervalSecs => { diff --git a/src/ui/settings/types.rs b/src/ui/settings/types.rs index e3a71c7..ef55f45 100644 --- a/src/ui/settings/types.rs +++ b/src/ui/settings/types.rs @@ -48,7 +48,7 @@ impl CategoryGroup { /// Categories in display order within this group. pub fn categories(&self) -> &'static [Category] { match self { - CategoryGroup::Core => &[Category::Agent, Category::Inference, Category::Bifrost], + CategoryGroup::Core => &[Category::Agent, Category::Inference, Category::Providers], CategoryGroup::Intelligence => &[ Category::Subconscious, Category::Reflection, Category::Archivist, Category::Subagent, Category::Compaction, @@ -70,7 +70,7 @@ impl CategoryGroup { pub enum Category { Agent, Inference, - Bifrost, + Providers, Subconscious, Reflection, Archivist, @@ -94,7 +94,7 @@ impl Category { &[ Category::Agent, Category::Inference, - Category::Bifrost, + Category::Providers, Category::Subconscious, Category::Reflection, Category::Archivist, @@ -118,7 +118,7 @@ impl Category { match self { Category::Agent => "Agent", Category::Inference => "Inference", - Category::Bifrost => "Bifrost", + Category::Providers => "Providers", Category::Subconscious => "Subconscious", Category::Reflection => "Reflection", Category::Archivist => "Archivist", @@ -141,7 +141,7 @@ impl Category { /// The logical group this category belongs to. pub fn group(&self) -> CategoryGroup { match self { - Category::Agent | Category::Inference | Category::Bifrost => CategoryGroup::Core, + Category::Agent | Category::Inference | Category::Providers => CategoryGroup::Core, Category::Subconscious | Category::Reflection | Category::Archivist | Category::Subagent | Category::Compaction => CategoryGroup::Intelligence, Category::Presence | Category::Voice | Category::Sensorium @@ -169,13 +169,20 @@ pub enum FieldLoc { // Agent AgSystemPrompt, AgModel, - // Inference + AgProvider, + // Inference (global default provider selector) IfProvider, - // Bifrost - BfBaseUrl, - BfApiKey, - BfVirtualKey, - BfPrimaryModel, + // Providers (per-provider fields — operates on the selected provider) + PvName, + PvType, + PvBaseUrl, + PvApiKey, + PvVirtualKey, + PvPrimaryModel, + PvTimeoutSecs, + PvAddProvider, + PvRemoveProvider, + PvSelectProvider, // Subconscious ScN1Enabled, ScN1Trigger, @@ -245,8 +252,6 @@ pub enum FieldLoc { VcTtsUrl, VcVoiceId, VcPushToTalkKey, - // Bifrost (cont.) - BfTimeoutSecs, // Server auth SvAuthRequired, SvAuthLoopback, @@ -274,9 +279,11 @@ pub enum FieldLoc { impl FieldLoc { pub fn category(&self) -> Category { match self { - FieldLoc::AgSystemPrompt | FieldLoc::AgModel => Category::Agent, + FieldLoc::AgSystemPrompt | FieldLoc::AgModel | FieldLoc::AgProvider => Category::Agent, FieldLoc::IfProvider => Category::Inference, - FieldLoc::BfBaseUrl | FieldLoc::BfApiKey | FieldLoc::BfVirtualKey | FieldLoc::BfPrimaryModel | FieldLoc::BfTimeoutSecs => Category::Bifrost, + FieldLoc::PvName | FieldLoc::PvType | FieldLoc::PvBaseUrl | FieldLoc::PvApiKey + | FieldLoc::PvVirtualKey | FieldLoc::PvPrimaryModel | FieldLoc::PvTimeoutSecs + | FieldLoc::PvAddProvider | FieldLoc::PvRemoveProvider | FieldLoc::PvSelectProvider => Category::Providers, FieldLoc::ScN1Enabled | FieldLoc::ScN1Trigger | FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::ScInboxEnabled | FieldLoc::ScModel | FieldLoc::ScMaxTokens | FieldLoc::ScSystemPrompt => Category::Subconscious, @@ -306,11 +313,18 @@ impl FieldLoc { match self { FieldLoc::AgSystemPrompt => "system_prompt", FieldLoc::AgModel => "model", + FieldLoc::AgProvider => "provider", FieldLoc::IfProvider => "provider", - FieldLoc::BfBaseUrl => "base_url", - FieldLoc::BfApiKey => "api_key", - FieldLoc::BfVirtualKey => "virtual_key", - FieldLoc::BfPrimaryModel => "primary_model", + FieldLoc::PvName => "name", + FieldLoc::PvType => "type", + FieldLoc::PvBaseUrl => "base_url", + FieldLoc::PvApiKey => "api_key", + FieldLoc::PvVirtualKey => "virtual_key", + FieldLoc::PvPrimaryModel => "primary_model", + FieldLoc::PvTimeoutSecs => "timeout_secs", + FieldLoc::PvAddProvider => "add_provider", + FieldLoc::PvRemoveProvider => "remove_provider", + FieldLoc::PvSelectProvider => "select_provider", FieldLoc::ScN1Enabled => "n1_enabled", FieldLoc::ScN1Trigger => "n1_trigger", FieldLoc::ScN1Every => "n1_every_n_responses", @@ -366,7 +380,6 @@ impl FieldLoc { FieldLoc::VcTtsUrl => "tts_url", FieldLoc::VcVoiceId => "voice_id", FieldLoc::VcPushToTalkKey => "push_to_talk_key", - FieldLoc::BfTimeoutSecs => "timeout_secs", FieldLoc::SvAuthRequired => "required", FieldLoc::SvAuthLoopback => "allow_loopback", FieldLoc::FdRole => "role", @@ -389,7 +402,7 @@ impl FieldLoc { /// Returns true if this field's value should be masked in browse mode. pub fn is_secret(&self) -> bool { - matches!(self, FieldLoc::BfApiKey | FieldLoc::BfVirtualKey) + matches!(self, FieldLoc::PvApiKey | FieldLoc::PvVirtualKey) } /// Returns true for informational fields that cannot be edited. @@ -408,11 +421,18 @@ impl FieldLoc { match self { FieldLoc::AgSystemPrompt => "platform prompt", FieldLoc::AgModel => "agent model", - FieldLoc::IfProvider => "active provider", - FieldLoc::BfBaseUrl => "endpoint", - FieldLoc::BfApiKey => "API key", - FieldLoc::BfVirtualKey => "virtual key", - FieldLoc::BfPrimaryModel => "new-agent default", + FieldLoc::AgProvider => "provider", + FieldLoc::IfProvider => "default provider", + FieldLoc::PvName => "name", + FieldLoc::PvType => "type", + FieldLoc::PvBaseUrl => "endpoint", + FieldLoc::PvApiKey => "API key", + FieldLoc::PvVirtualKey => "virtual key", + FieldLoc::PvPrimaryModel => "default model", + FieldLoc::PvTimeoutSecs => "request timeout (s)", + FieldLoc::PvAddProvider => "+ add provider", + FieldLoc::PvRemoveProvider => "- remove", + FieldLoc::PvSelectProvider => "provider", FieldLoc::ScN1Enabled => "N+1 enabled", FieldLoc::ScN1Trigger => "N+1 trigger", FieldLoc::ScN1Every => " └ every N responses", @@ -468,7 +488,6 @@ impl FieldLoc { FieldLoc::VcTtsUrl => "TTS endpoint", FieldLoc::VcVoiceId => "voice ID", FieldLoc::VcPushToTalkKey => "push-to-talk", - FieldLoc::BfTimeoutSecs => "request timeout (s)", FieldLoc::SvAuthRequired => "require auth", FieldLoc::SvAuthLoopback => "allow loopback bypass", FieldLoc::FdRole => "role", @@ -495,19 +514,25 @@ impl FieldLoc { // Agent FieldLoc::AgSystemPrompt => "System-level instructions prepended to every conversation", FieldLoc::AgModel => "Primary LLM model used by the agent", + FieldLoc::AgProvider => "Inference provider the agent routes requests through", FieldLoc::AgAgentId => "Unique identifier for this agent instance", FieldLoc::AgSubconsciousId => "Unique identifier for the subconscious agent", FieldLoc::AgMemoryPath => "On-disk path to the agent memory directory", FieldLoc::AgSubconsciousPath => "On-disk path to the subconscious agent directory", FieldLoc::AgSubconsciousStatus => "Whether the subconscious agent has been initialized on disk", // Inference - FieldLoc::IfProvider => "Active LLM provider routing requests through Bifrost", - // Bifrost - FieldLoc::BfBaseUrl => "Bifrost API endpoint URL", - FieldLoc::BfApiKey => "API key used to authenticate with Bifrost", - FieldLoc::BfVirtualKey => "Virtual key for Bifrost multi-tenant routing", - FieldLoc::BfPrimaryModel => "Default model assigned to newly created agents", - FieldLoc::BfTimeoutSecs => "HTTP request timeout in seconds for Bifrost calls", + FieldLoc::IfProvider => "Default inference provider used when no per-agent override is set", + // Providers + FieldLoc::PvName => "Short name for this provider (used in config and agent settings)", + FieldLoc::PvType => "Provider type — openai-compatible for standard endpoints, openai-oauth for ChatGPT login", + FieldLoc::PvBaseUrl => "API base URL for /v1/chat/completions", + FieldLoc::PvApiKey => "Bearer token or API key for this provider", + FieldLoc::PvVirtualKey => "Optional virtual-key header (x-bf-vk) for gateway routing", + FieldLoc::PvPrimaryModel => "Default model used when this provider is active", + FieldLoc::PvTimeoutSecs => "HTTP request timeout in seconds for LLM calls", + FieldLoc::PvAddProvider => "Add a new inference provider from presets or custom endpoint", + FieldLoc::PvRemoveProvider => "Remove this provider from the configuration", + FieldLoc::PvSelectProvider => "Select which provider to edit in this category", // Subconscious FieldLoc::ScN1Enabled => "Allow the subconscious agent to respond after every reply", FieldLoc::ScN1Trigger => "Condition that activates an N+1 subconscious response", @@ -601,7 +626,7 @@ impl FieldLoc { pub fn applies_live(&self) -> bool { matches!( self, - FieldLoc::PrAtmosphere | FieldLoc::PrOutfit | FieldLoc::BfPrimaryModel | FieldLoc::AgModel + FieldLoc::PrAtmosphere | FieldLoc::PrOutfit | FieldLoc::PvPrimaryModel | FieldLoc::AgModel ) } } diff --git a/src/ui/settings/view.rs b/src/ui/settings/view.rs index 20739b8..027b71e 100644 --- a/src/ui/settings/view.rs +++ b/src/ui/settings/view.rs @@ -24,6 +24,8 @@ pub struct ActiveAgentSettings { pub model: String, /// The model as loaded — for save-time diffing. pub model_original: String, + /// Per-agent provider override (`_souveraine.provider`). None = use global default. + pub provider: Option, /// Display-only: the paired subconscious agent id (always `{id}-sub`). pub subconscious_id: String, /// Display-only: the primary's memory directory path. @@ -67,6 +69,9 @@ pub struct SettingsView { /// The agent the Settings screen is editing per-agent fields for. Set by /// App on entry from the active agent, so Settings follows agent switches. pub active_agent: Option, + + /// Which provider is selected for editing in the Providers category. + pub selected_provider_name: Option, } impl SettingsView { @@ -85,6 +90,7 @@ impl SettingsView { models_fetching: false, palette: ChatPalette::default(), active_agent: None, + selected_provider_name: None, } } @@ -95,6 +101,15 @@ impl SettingsView { self.active_agent = agent; } + /// Ensure a provider is selected when entering the Providers category. + pub fn ensure_provider_selected(&mut self) { + if self.selected_provider_name.is_none() + || !self.config.providers.contains_key(self.selected_provider_name.as_ref().unwrap()) + { + self.selected_provider_name = self.config.providers.keys().next().cloned(); + } + } + /// Whether the active agent's model was changed and needs pushing back to /// the agent record on save. pub fn agent_model_dirty(&self) -> bool { @@ -164,6 +179,16 @@ impl SettingsView { let idx = variants.iter().position(|m| m == &agent.model).unwrap_or(0); out.push((AgModel, EditableValue::EnumVariant { index: idx, variants })); } + // Per-agent provider selector + let mut pnames: Vec = self.config.providers.keys().cloned().collect(); + pnames.sort(); + let mut pvariants = vec!["(default)".to_string()]; + pvariants.extend(pnames); + let cur_provider = agent.provider.clone(); + let pidx = cur_provider.as_ref() + .and_then(|p| pvariants.iter().position(|v| v == p)) + .unwrap_or(0); + out.push((AgProvider, EditableValue::EnumVariant { index: pidx, variants: pvariants })); // Read-only diagnostics out.push((AgAgentId, EditableValue::Text(agent.id.clone()))); out.push((AgSubconsciousId, EditableValue::Text(agent.subconscious_id.clone()))); @@ -174,7 +199,8 @@ impl SettingsView { } Category::Inference => { let provider = &self.config.inference.provider; - let variants = vec!["bifrost".to_string(), "openai-oauth".to_string()]; + let mut variants: Vec = self.config.providers.keys().cloned().collect(); + variants.sort(); let mut all = variants.clone(); if !all.contains(provider) { all.insert(0, provider.clone()); @@ -182,21 +208,46 @@ impl SettingsView { let idx = all.iter().position(|p| p == provider).unwrap_or(0); out.push((IfProvider, EditableValue::EnumVariant { index: idx, variants: all })); } - Category::Bifrost => { - out.push((BfBaseUrl, EditableValue::Text(self.config.bifrost.base_url.clone()))); - out.push((BfApiKey, EditableValue::Secret(self.config.bifrost.api_key.clone()))); - out.push((BfVirtualKey, EditableValue::Secret(self.config.bifrost.virtual_key.clone()))); - if self.available_models.is_empty() { - out.push((BfPrimaryModel, EditableValue::Text(self.config.bifrost.primary_model.clone()))); + Category::Providers => { + // Provider selector — choose which provider to edit + let mut pnames: Vec = self.config.providers.keys().cloned().collect(); + pnames.sort(); + if pnames.is_empty() { + out.push((PvSelectProvider, EditableValue::Text("(no providers)".to_string()))); } else { - let mut variants = self.available_models.clone(); - if !variants.contains(&self.config.bifrost.primary_model) { - variants.insert(0, self.config.bifrost.primary_model.clone()); - } - let idx = variants.iter().position(|m| m == &self.config.bifrost.primary_model).unwrap_or(0); - out.push((BfPrimaryModel, EditableValue::EnumVariant { index: idx, variants })); + let selected = self.selected_provider_name.clone() + .filter(|n| self.config.providers.contains_key(n)) + .unwrap_or_else(|| pnames.first().cloned().unwrap_or_default()); + let mut variants = pnames.clone(); + if !variants.contains(&selected) { variants.insert(0, selected.clone()); } + let idx = variants.iter().position(|n| *n == selected).unwrap_or(0); + out.push((PvSelectProvider, EditableValue::EnumVariant { index: idx, variants })); } - out.push((BfTimeoutSecs, EditableValue::Uint(self.config.bifrost.timeout_secs))); + + // Fields for the selected provider + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get(name) { + out.push((PvName, EditableValue::Text(name.clone()))); + let type_variants = vec!["openai-compatible".into(), "openai-oauth".into()]; + let type_idx = if p.provider_type == "openai-oauth" { 1 } else { 0 }; + out.push((PvType, EditableValue::EnumVariant { index: type_idx, variants: type_variants })); + out.push((PvBaseUrl, EditableValue::Text(p.base_url.clone()))); + out.push((PvApiKey, if p.api_key.is_empty() { EditableValue::Text(String::new()) } else { EditableValue::Secret(p.api_key.clone()) })); + out.push((PvVirtualKey, if p.virtual_key.is_empty() { EditableValue::Text(String::new()) } else { EditableValue::Secret(p.virtual_key.clone()) })); + if self.available_models.is_empty() { + out.push((PvPrimaryModel, EditableValue::Text(p.primary_model.clone()))); + } else { + let mut mvariants = self.available_models.clone(); + let pm = &p.primary_model; + if !pm.is_empty() && !mvariants.contains(pm) { mvariants.insert(0, pm.clone()); } + let midx = mvariants.iter().position(|m| m == pm).unwrap_or(0); + out.push((PvPrimaryModel, EditableValue::EnumVariant { index: midx, variants: mvariants })); + } + out.push((PvTimeoutSecs, EditableValue::Uint(p.timeout_secs))); + out.push((PvRemoveProvider, EditableValue::Bool(false))); + } + } + out.push((PvAddProvider, EditableValue::Bool(false))); } Category::Subconscious => { let sc = &self.config.subconscious; @@ -434,16 +485,113 @@ impl SettingsView { } } } - BfBaseUrl => { if let EditableValue::Text(v) = value { self.config.bifrost.base_url = v; } } - BfApiKey => { if let EditableValue::Text(v) = value { self.config.bifrost.api_key = v; } } - BfVirtualKey => { if let EditableValue::Text(v) = value { self.config.bifrost.virtual_key = v; } } - BfPrimaryModel => { - match value { - EditableValue::Text(v) => self.config.bifrost.primary_model = v, - EditableValue::EnumVariant { index, variants } => { - if let Some(m) = variants.get(index) { self.config.bifrost.primary_model = m.clone(); } + AgProvider => { + if let Some(agent) = &mut self.active_agent { + if let EditableValue::EnumVariant { index, variants } = &value { + let selected = variants.get(*index).cloned().unwrap_or_default(); + agent.provider = if selected == "(default)" { None } else { Some(selected) }; + } + } + } + PvName => { + if let EditableValue::Text(new_name) = &value { + if let Some(old_name) = self.selected_provider_name.clone() { + if new_name != &old_name && !new_name.is_empty() { + if let Some(p) = self.config.providers.remove(&old_name) { + self.config.providers.insert(new_name.clone(), p); + self.selected_provider_name = Some(new_name.clone()); + } + } + } + } + } + PvType => { + if let EditableValue::EnumVariant { index, variants } = &value { + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get_mut(name) { + let ptype = variants.get(*index).map(|s| s.as_str()).unwrap_or("openai-compatible"); + p.provider_type = ptype.to_string(); + } + } + } + } + PvBaseUrl => { + if let EditableValue::Text(v) = value { + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get_mut(name) { p.base_url = v; } + } + } + } + PvApiKey => { + if let EditableValue::Text(v) = value { + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get_mut(name) { p.api_key = v; } + } + } + } + PvVirtualKey => { + if let EditableValue::Text(v) = value { + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get_mut(name) { p.virtual_key = v; } + } + } + } + PvPrimaryModel => { + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get_mut(name) { + match value { + EditableValue::Text(v) => p.primary_model = v, + EditableValue::EnumVariant { index, variants } => { + if let Some(m) = variants.get(index) { p.primary_model = m.clone(); } + } + _ => {} + } + } + } + } + PvTimeoutSecs => { + if let EditableValue::Uint(v) = value { + if let Some(name) = &self.selected_provider_name.clone() { + if let Some(p) = self.config.providers.get_mut(name) { p.timeout_secs = v; } + } + } + } + PvSelectProvider => { + if let EditableValue::EnumVariant { index, variants } = &value { + self.selected_provider_name = variants.get(*index).cloned(); + } + } + PvAddProvider => { + // Triggered when user presses Enter on the "+" button. + // Add a new provider with default values. + let base = "new_provider"; + let mut name = base.to_string(); + let mut n = 1; + while self.config.providers.contains_key(&name) { + name = format!("{base}_{n}"); + n += 1; + } + self.config.providers.insert(name.clone(), crate::core::config::ProviderConfig { + provider_type: "openai-compatible".to_string(), + base_url: String::new(), + api_key: String::new(), + virtual_key: String::new(), + primary_model: String::new(), + timeout_secs: 120, + }); + self.selected_provider_name = Some(name); + } + PvRemoveProvider => { + if let Some(name) = self.selected_provider_name.clone() { + // Don't allow removing the last provider if it's the global default + if self.config.providers.len() > 1 || self.config.inference.provider != name { + self.config.providers.remove(&name); + // Also remove from global default if it was this one + if self.config.inference.provider == name { + self.config.inference.provider = self.config.providers.keys().next().cloned().unwrap_or_default(); + } + self.selected_provider_name = self.config.providers.keys().next().cloned(); } - _ => {} } } @@ -633,7 +781,7 @@ impl SettingsView { VcVoiceId => { if let EditableValue::Text(v) = value { self.config.voice.voice_id = v; } } VcPushToTalkKey => { if let EditableValue::Text(v) = value { self.config.voice.push_to_talk_key = v; } } - BfTimeoutSecs => { if let EditableValue::Uint(v) = value { self.config.bifrost.timeout_secs = v; } } + // Handled above in the Pv* block SvAuthRequired => { if let EditableValue::Bool(v) = value { self.config.server.auth.required = v; } } SvAuthLoopback => { if let EditableValue::Bool(v) = value { self.config.server.auth.allow_loopback = v; } } diff --git a/src/ui/tuie_app.rs b/src/ui/tuie_app.rs index ca840d9..8a326e8 100644 --- a/src/ui/tuie_app.rs +++ b/src/ui/tuie_app.rs @@ -20,7 +20,6 @@ use crate::ui::chat::{ChatPalette, ChatState}; use crate::ui::presence::Presence; use crate::ui::screens::chat::ChatScreen; use crate::ui::screens::cron::CronScreen; -use crate::ui::screens::manager::ManagerScreen; use crate::ui::screens::presence::PresenceScreen; use crate::ui::screens::settings::SettingsScreen; use crate::ui::screens::splash::SplashScreen; @@ -28,6 +27,54 @@ use crate::ui::screens::welcome::WelcomeScreen; use crate::ui::theme; use crate::ui::presence::Posture; +// ── Agent types ───────────────────────────────────────────────────────────────── + +/// A single agent known to the backend, with all the detail the unified agent +/// screen needs to render a rich master–detail view. +/// +/// Supersedes both the lossy `(String, String, String)` tuples that +/// `AgentStatus` used to carry and `manager::AgentProcessInfo`. +#[derive(Debug, Clone)] +pub struct AgentSummary { + pub id: String, + pub name: String, + pub description: String, + /// 4-glyph SeedID badge (e.g. "◇◆◇◆"). + pub glyph: String, + /// Running instance count (≈ process count). + pub instance_count: i64, + /// Lifetime uptime percentage, capped at 99. + pub uptime_pct: u8, + /// Number of files in the agent's memory repo. + pub memory_count: usize, + /// First 16 hex chars of the pubkey. + pub pubkey_prefix: String, + /// Whether this agent is marked as the primary. + pub is_primary: bool, + /// Atmosphere name from the agent's preferences, if set. + pub atmosphere: Option, + /// Recent commit subject lines or activity entries, newest first. + pub recent_activity: Vec, +} + +impl Default for AgentSummary { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + description: String::new(), + glyph: String::new(), + instance_count: 0, + uptime_pct: 0, + memory_count: 0, + pubkey_prefix: String::new(), + is_primary: false, + atmosphere: None, + recent_activity: Vec::new(), + } + } +} + // ── Agent status ─────────────────────────────────────────────────────────────── /// Live agent data populated by dashboard refresh. @@ -53,7 +100,7 @@ pub struct AgentStatus { /// Number of agents the backend reports. pub agent_count: usize, /// All agents the backend knows about — used for agent selection. - pub available_agents: Vec<(String, String, String)>, // (id, name, description) + pub available_agents: Vec, } impl Default for AgentStatus { @@ -75,6 +122,18 @@ impl Default for AgentStatus { } } +impl AgentSummary { + /// Build a bare summary from backend `AgentInfo` — rich fields stay at default. + pub fn from_agent_info(a: &crate::backend::AgentInfo) -> Self { + Self { + id: a.id.clone(), + name: a.name.clone(), + description: a.description.clone().unwrap_or_default(), + ..Default::default() + } + } +} + // ── Screen enum ──────────────────────────────────────────────────────────────── /// Which screen is currently active. @@ -87,7 +146,6 @@ pub enum Screen { Presence, Cron, Agents, - Manager, } // ── TuieApp ──────────────────────────────────────────────────────────────────── @@ -174,17 +232,13 @@ impl DelegateWidget for TuieApp { } } - // After any input, check for Manager screen signals. - if self.current_screen == Screen::Manager { + // After any input, check for Agents screen signals (back / kill / restart). + if self.current_screen == Screen::Agents { if let Some(ref signal) = self.manager_back_signal { if signal.get() { self.go_to_welcome(); } } - if let Some(idx) = self.menu_action.take() { - // Manager selected an agent — could switch to agent detail - self.go_to_welcome(); - } if let Some(ref signal) = self.manager_kill_signal { if let Some(idx) = signal.take() { tracing::info!("Kill requested for agent at index {}", idx); @@ -497,6 +551,7 @@ impl TuieApp { name: self.agent_name.clone(), model: model.clone(), model_original: model, + provider: None, subconscious_id: format!("{id}-sub"), memory_root, subconscious_root, @@ -530,22 +585,20 @@ impl TuieApp { CronScreen::new(schedules_dir, &self.palette) } Screen::Agents => { - let agents: Vec<(String, String, String)> = self + let agents: Vec = self .agent_status .as_ref() .map(|s| s.available_agents.clone()) .unwrap_or_default(); - let (agents_screen, selection_signal) = crate::ui::screens::agents::AgentsScreen::new(agents); - self.menu_action = selection_signal; - agents_screen - } - Screen::Manager => { - let (manager, selection_signal, back_signal, kill_signal, restart_signal) = ManagerScreen::new(&self.palette); + let (agents_screen, selection_signal, back_signal, kill_signal, restart_signal, pin_signal) = + crate::ui::screens::agents::AgentsScreen::new(agents, &self.palette); self.menu_action = selection_signal; self.manager_back_signal = Some(back_signal); self.manager_kill_signal = Some(kill_signal); self.manager_restart_signal = Some(restart_signal); - manager + // pin_signal stored for future use (agent pin-to-primary). + let _ = pin_signal; + agents_screen } }; @@ -609,7 +662,6 @@ impl TuieApp { 1 => self.switch_screen(Screen::Agents), 2 => self.switch_screen(Screen::Cron), 3 => self.switch_screen(Screen::Settings), - 4 => self.switch_screen(Screen::Manager), _ => {} } } @@ -617,10 +669,10 @@ impl TuieApp { /// Handle an agent selection from the Agents screen. fn handle_agent_select(&mut self, idx: usize) { if let Some(ref mut status) = self.agent_status { - if let Some((agent_id, agent_name, _)) = status.available_agents.get(idx) { - status.name = agent_name.clone(); - status.agent_id = Some(agent_id.clone()); - self.agent_name = agent_name.clone(); + if let Some(agent) = status.available_agents.get(idx) { + status.name = agent.name.clone(); + status.agent_id = Some(agent.id.clone()); + self.agent_name = agent.name.clone(); self.go_to_welcome(); } } @@ -692,7 +744,6 @@ impl TuieApp { Key::Char('p') => { self.switch_screen(Screen::Presence); true } Key::Char('a') => { self.switch_screen(Screen::Agents); true } Key::Char('j') => { self.switch_screen(Screen::Cron); true } - Key::Char('m') => { self.switch_screen(Screen::Manager); true } _ => false, }, // Chat is a text-entry screen — never steal printable keys (e.g. 'q'), @@ -758,9 +809,9 @@ async fn load_dashboard_data( .find(|a| a.name == agent_pref || a.id == agent_pref) .or_else(|| agents.first()); - let available_agents: Vec<_> = agents + let available_agents: Vec = agents .iter() - .map(|a| (a.id.clone(), a.name.clone(), a.description.clone().unwrap_or_default())) + .map(|a| AgentSummary::from_agent_info(a)) .collect(); return AgentStatus { @@ -818,10 +869,62 @@ async fn load_dashboard_data( (0, Vec::new()) }; - let available_agents: Vec<_> = agents - .iter() - .map(|a| (a.id.clone(), a.name.clone(), a.description.clone().unwrap_or_default())) - .collect(); + // Enrich every agent with per-agent stats (glyph, instances, + // uptime, memory count, pubkey, atmosphere) following the + // ratatui `fetch_agent_cards` recipe. + let inv = local.server_agents(); + let mut available_agents: Vec = Vec::new(); + for a in &agents { + let mut summary = AgentSummary::from_agent_info(a); + summary.glyph = inv.seed_id(&a.id) + .map(|s| s.glyph()) + .unwrap_or_default(); + summary.pubkey_prefix = inv.seed_id(&a.id) + .map(|s| s.public_key_hex()[..16].to_string()) + .unwrap_or_default(); + summary.instance_count = inv.instance_count(&a.id).await.unwrap_or(0); + let lifetime_secs = inv.lifetime_active_seconds(&a.id).await.unwrap_or(0); + summary.uptime_pct = if lifetime_secs > 0 { + let days = ((summary.instance_count.max(1)) as f64 * 30.0).max(1.0); + let pct = (lifetime_secs as f64 / (days * 86400.0)) * 100.0; + pct.min(99.0) as u8 + } else { + 0 + }; + summary.memory_count = local.server_agents().memory_repo(&a.id) + .status() + .map(|s| s.file_count) + .unwrap_or(0); + // Per-agent atmosphere from preferences. + if let Some(home) = dirs::home_dir() { + let visual_path = home + .join(".souveraine") + .join("agents") + .join(&a.id) + .join("memory") + .join("system") + .join("preferences") + .join("visual.md"); + if let Ok(contents) = std::fs::read_to_string(&visual_path) { + if let Some(atmosphere) = contents + .lines() + .find(|l| l.starts_with("atmosphere:")) + .and_then(|l| l.split(':').nth(1)) + .map(|s| s.trim().trim_matches('"').to_string()) + { + summary.atmosphere = Some(atmosphere); + } + } + } + // Recent activity for each agent. + let repo = local.server_agents().memory_repo(&a.id); + summary.recent_activity = crate::ui::app::recent_commits(&repo, 5) + .unwrap_or_default(); + // Mark primary — the first agent in the list is primary by convention. + summary.is_primary = chosen.as_ref().map(|c| c.id == summary.id).unwrap_or(false); + available_agents.push(summary); + } + available_agents.sort_by(|a, b| a.name.cmp(&b.name)); AgentStatus { name: chosen.map(|a| a.name.clone()).unwrap_or(agent_pref), diff --git a/src/ui/widgets/btw_pane.rs b/src/ui/widgets/btw_pane.rs index c957956..fd1a30a 100644 --- a/src/ui/widgets/btw_pane.rs +++ b/src/ui/widgets/btw_pane.rs @@ -108,13 +108,13 @@ impl BtwPane { mod tests { use super::*; use crate::ui::chat::{BtwState, ChatPalette}; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; #[test] fn btw_pane_starts_hidden() { let palette = ChatPalette::default(); let mut widget = BtwPane::new(&palette); - let mut term = TestTerminal::new(&mut *widget, Vec2::new(80, 10)); + let mut term = Emulator::new(&mut *widget, Vec2::new(80, 10)); let output = term.get_snapshot_text(); assert!( !output.contains("Forking") && !output.contains("Error") && !output.contains("OK"), @@ -128,7 +128,7 @@ mod tests { let mut widget = BtwPane::new(&palette); let state = BtwState::Forking { question: "test question".into() }; widget.set_state(&state, &palette); - let mut term = TestTerminal::new(&mut *widget, Vec2::new(80, 10)); + let mut term = Emulator::new(&mut *widget, Vec2::new(80, 10)); let output = term.get_snapshot_text(); assert!( output.contains("Forking") || output.contains("test question"), @@ -142,7 +142,7 @@ mod tests { let mut widget = BtwPane::new(&palette); let state = BtwState::Streaming { question: "q".into(), response_so_far: "streaming response".into() }; widget.set_state(&state, &palette); - let mut term = TestTerminal::new(&mut *widget, Vec2::new(80, 10)); + let mut term = Emulator::new(&mut *widget, Vec2::new(80, 10)); let output = term.get_snapshot_text(); assert!( output.contains("streaming response"), @@ -160,7 +160,7 @@ mod tests { forked_id: "abc123".into(), }; widget.set_state(&state, &palette); - let mut term = TestTerminal::new(&mut *widget, Vec2::new(80, 10)); + let mut term = Emulator::new(&mut *widget, Vec2::new(80, 10)); let output = term.get_snapshot_text(); assert!( output.contains("OK") || output.contains("abc123"), @@ -174,7 +174,7 @@ mod tests { let mut widget = BtwPane::new(&palette); let state = BtwState::Error { question: "q".into(), error: "fail".into() }; widget.set_state(&state, &palette); - let mut term = TestTerminal::new(&mut *widget, Vec2::new(80, 10)); + let mut term = Emulator::new(&mut *widget, Vec2::new(80, 10)); let output = term.get_snapshot_text(); assert!( output.contains("fail") || output.contains("Error"), diff --git a/src/ui/widgets/chat_overlay.rs b/src/ui/widgets/chat_overlay.rs index 2f01935..16ce669 100644 --- a/src/ui/widgets/chat_overlay.rs +++ b/src/ui/widgets/chat_overlay.rs @@ -188,6 +188,10 @@ impl ChatOverlay { self.pane.set_bordered(true); self.pane.set_border(Some(Border::SINGLE)); self.pane.set_border_style(Style::new().fg(dim)); + // Reserve height: one row per visible item plus the top/bottom border. + // Without this the flex-only List negotiates down to ~0 rows in the + // chat root's vertical stack and the overlay never appears. + self.pane.set_height(Some(count.max(1) as u16 + 2)); } /// Show the conversation picker. @@ -225,6 +229,7 @@ impl ChatOverlay { self.pane.set_bordered(true); self.pane.set_border(Some(Border::SINGLE)); self.pane.set_border_style(Style::new().fg(primary)); + self.pane.set_height(Some(count.max(1) as u16 + 2)); } /// Hide the overlay. @@ -239,16 +244,18 @@ impl ChatOverlay { } } self.pane.set_bordered(false); + // Release the reserved height so the hidden overlay collapses to zero. + self.pane.set_height(None); } } #[cfg(test)] mod tests { use super::*; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; fn snapshot(overlay: &mut Box, size: Vec2) -> String { - let mut term = TestTerminal::new(&mut **overlay, size); + let mut term = Emulator::new(&mut **overlay, size); term.get_snapshot_text() } diff --git a/src/ui/widgets/chat_sidebar.rs b/src/ui/widgets/chat_sidebar.rs index eb04e91..f505daf 100644 --- a/src/ui/widgets/chat_sidebar.rs +++ b/src/ui/widgets/chat_sidebar.rs @@ -1,14 +1,45 @@ //! Chat sidebar — agent vitals displayed as a toggleable panel. //! //! Shows agent name, mood, energy bar, memory pressure bar, N+1 cycles, -//! recent memory commits, and pending tasks. Each section is a Text widget -//! row inside a vertical Pane, updated from AgentStatus snapshots. +//! recent memory commits, pending tasks, backend status, N+25/N+100 timestamps, +//! compaction warnings, inference strain, and uptime. Each section is a Text +//! widget row inside a vertical Pane, updated from AgentStatus snapshots. + +use std::time::Instant; use tuie::prelude::*; use crate::ui::chat::ChatPalette; use crate::ui::theme; use crate::ui::tuie_app::AgentStatus; +use crate::ui::widgets::stats::push_bar; + +/// Additional health vitals not in AgentStatus. +pub struct HealthData { + pub backend_mode: String, + pub backend_healthy: bool, + pub last_reflection: Option, + pub last_archivist: Option, + pub last_compaction: Option<(u8, String)>, + pub strain_504: u32, + pub strain_429: u32, + pub strain_other: u32, +} + +impl Default for HealthData { + fn default() -> Self { + Self { + backend_mode: "—".into(), + backend_healthy: false, + last_reflection: None, + last_archivist: None, + last_compaction: None, + strain_504: 0, + strain_429: 0, + strain_other: 0, + } + } +} /// Toggleable sidebar widget for the chat screen. /// @@ -24,6 +55,13 @@ pub struct ChatSidebar { n1_id: WidgetId, commits_id: WidgetId, tasks_id: WidgetId, + backend_id: WidgetId, + n25_id: WidgetId, + n100_id: WidgetId, + compaction_id: WidgetId, + strain_id: WidgetId, + uptime_id: WidgetId, + started: Instant, } impl DelegateWidget for ChatSidebar { @@ -47,6 +85,12 @@ impl ChatSidebar { let mut n1_id = WidgetId::EMPTY; let mut commits_id = WidgetId::EMPTY; let mut tasks_id = WidgetId::EMPTY; + let mut backend_id = WidgetId::EMPTY; + let mut n25_id = WidgetId::EMPTY; + let mut n100_id = WidgetId::EMPTY; + let mut compaction_id = WidgetId::EMPTY; + let mut strain_id = WidgetId::EMPTY; + let mut uptime_id = WidgetId::EMPTY; let title = { let mut s = StyledString::new(); @@ -69,6 +113,12 @@ impl ChatSidebar { Text::new().content(dim_text.clone()).id(&mut n1_id), Text::new().content(dim_text.clone()).id(&mut commits_id), Text::new().content(dim_text.clone()).id(&mut tasks_id), + Text::new().content(dim_text.clone()).id(&mut backend_id), + Text::new().content(dim_text.clone()).id(&mut n25_id), + Text::new().content(dim_text.clone()).id(&mut n100_id), + Text::new().content(dim_text.clone()).id(&mut compaction_id), + Text::new().content(dim_text.clone()).id(&mut strain_id), + Text::new().content(dim_text.clone()).id(&mut uptime_id), ]); Box::new(Self { @@ -80,9 +130,119 @@ impl ChatSidebar { n1_id, commits_id, tasks_id, + backend_id, + n25_id, + n100_id, + compaction_id, + strain_id, + uptime_id, + started: Instant::now(), }) } + /// Additional health vitals not in AgentStatus. + pub fn update_health( + &mut self, + health: &HealthData, + palette: &ChatPalette, + ) { + let primary = theme::to_tuie_color(palette.agent_primary); + let dim = theme::to_tuie_color(palette.agent_dim); + let surfacing = theme::to_tuie_color(palette.surfacing); + let compaction = theme::to_tuie_color(palette.compaction); + + // Backend status + if let Some(t) = self.pane.get_widget_mut(self.backend_id) { + let (dot, dot_c) = if health.backend_healthy { + ("●", Color::GREEN) + } else { + ("○", Color::RED) + }; + let mut s = StyledString::new(); + s.push_span(StyledStr::new("Backend ").fg(dim)); + s.push_span(StyledStr::new(&format!("{dot} ")).fg(dot_c)); + s.push_span(StyledStr::new(&health.backend_mode).fg(primary)); + t.set_content(s); + } + + // N+25 reflection + if let Some(t) = self.pane.get_widget_mut(self.n25_id) { + let mut s = StyledString::new(); + s.push_span(StyledStr::new("◎ N+25 ").fg(dim)); + match &health.last_reflection { + Some(t2) => s.push_span(StyledStr::new(t2).fg(surfacing)), + None => s.push_span(StyledStr::new("—").fg(dim)), + } + t.set_content(s); + } + + // N+100 archivist + if let Some(t) = self.pane.get_widget_mut(self.n100_id) { + let mut s = StyledString::new(); + s.push_span(StyledStr::new("◉ N+100 ").fg(dim)); + match &health.last_archivist { + Some(t2) => s.push_span(StyledStr::new(t2).fg(theme::to_tuie_color(palette.archivist))), + None => s.push_span(StyledStr::new("—").fg(dim)), + } + t.set_content(s); + } + + // Compaction + if let Some(t) = self.pane.get_widget_mut(self.compaction_id) { + let mut s = StyledString::new(); + s.push_span(StyledStr::new("⚠ Comp ").fg(dim)); + match &health.last_compaction { + Some((tier, t2)) => { + let label = match tier { + 3 => "critical", + 2 => "urgent", + _ => "warn", + }; + s.push_span(StyledStr::new(&format!("{label} · {t2}")).fg(compaction)); + } + None => s.push_span(StyledStr::new("none").fg(dim)), + } + t.set_content(s); + } + + // Inference strain + if let Some(t) = self.pane.get_widget_mut(self.strain_id) { + let mut s = StyledString::new(); + s.push_span(StyledStr::new("Strain ").fg(dim)); + if health.strain_504 + health.strain_429 + health.strain_other == 0 { + s.push_span(StyledStr::new("clear").fg(Color::GREEN)); + } else { + let mut parts = Vec::new(); + if health.strain_504 > 0 { + parts.push(format!("504×{}", health.strain_504)); + } + if health.strain_429 > 0 { + parts.push(format!("429×{}", health.strain_429)); + } + if health.strain_other > 0 { + parts.push(format!("err×{}", health.strain_other)); + } + s.push_span(StyledStr::new(&parts.join(" · ")).fg(compaction)); + } + t.set_content(s); + } + + // Uptime + if let Some(t) = self.pane.get_widget_mut(self.uptime_id) { + let secs = self.started.elapsed().as_secs(); + let (h, m) = (secs / 3600, (secs % 3600) / 60); + let uptime = if h > 0 { + format!("{h}:{m:02} up") + } else { + format!("{m}m up") + }; + let mut s = StyledString::new(); + s.push_span(StyledStr::new("Uptime ").fg(dim)); + s.push_span(StyledStr::new(&uptime).fg(primary)); + t.set_content(s); + } + } + /// Update all vitals from an AgentStatus snapshot + pressure value. pub fn update( &mut self, @@ -183,7 +343,7 @@ impl ChatSidebar { mod tests { use super::*; use crate::ui::tuie_app::AgentStatus; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; #[test] fn sidebar_renders_agent_name() { @@ -198,7 +358,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("TestBot"), "got: {rendered:?}"); } @@ -216,7 +376,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("Happy"), "got: {rendered:?}"); } @@ -234,7 +394,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("80%"), "got: {rendered:?}"); assert!(rendered.contains('['), "got: {rendered:?}"); @@ -254,7 +414,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.3, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(!rendered.is_empty(), "output was empty"); assert!(rendered.contains("MemPres"), "got: {rendered:?}"); @@ -273,7 +433,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.9, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(!rendered.is_empty(), "output was empty"); } @@ -291,7 +451,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 3, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("3 active"), "got: {rendered:?}"); } @@ -309,7 +469,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 0, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("idle"), "got: {rendered:?}"); } @@ -327,7 +487,7 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("42"), "got: {rendered:?}"); } @@ -345,23 +505,8 @@ mod tests { ..Default::default() }; widget.update(&status, 0.45, 2, &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(30, 12)); + let term = Emulator::new(&mut *widget, Vec2::new(30, 12)); let rendered = term.get_snapshot_text(); assert!(rendered.contains("3"), "got: {rendered:?}"); } } - -fn push_bar(out: &mut StyledString, fraction: f32, width: usize, color: Color) { - let filled = ((fraction.clamp(0.0, 1.0)) * width as f32).round() as usize; - let empty = width.saturating_sub(filled); - let bracket = Color::BRIGHT_BLACK; - - out.push_span(StyledStr::new("[").fg(bracket)); - if filled > 0 { - out.push_span(StyledStr::new(&"█".repeat(filled)).fg(color)); - } - if empty > 0 { - out.push_span(StyledStr::new(&"░".repeat(empty)).fg(bracket)); - } - out.push_span(StyledStr::new("] ").fg(bracket)); -} diff --git a/src/ui/widgets/cockpit.rs b/src/ui/widgets/cockpit.rs index 0e71e76..0542925 100644 --- a/src/ui/widgets/cockpit.rs +++ b/src/ui/widgets/cockpit.rs @@ -1,13 +1,18 @@ -//! Cockpit widget — streamlined thinking / subconscious stream. +//! Cockpit widget — thinking / subconscious / inner-voice stream. //! -//! A single bordered column split into two sections by a thin rule: -//! • thinking (top) — the agent's reasoning log -//! • subconscious (below) — N+1 surfacings, reflections, archivist notes +//! A single bordered column split into three sections by thin rules: +//! • thinking (top) — the agent's reasoning log +//! • subconscious (mid) — N+1 surfacings, reflections, archivist notes +//! • inner voice (bottom) — tail of `subconscious.md` from the agent's memory //! A pressure bar sits at the foot. The section that is currently *active* //! glows in the accent colour and is given more vertical room; an idle //! section collapses to a one-line header with a chevron. Older entries //! fade so the newest line is always brightest. +use std::collections::VecDeque; +use std::path::PathBuf; +use std::time::{Instant, SystemTime}; + use tuie::prelude::*; use super::progress_bar::ProgressBar; @@ -22,7 +27,7 @@ pub enum ActivePane { Subconscious, } -/// Two-section cockpit — thinking (top) and subconscious (bottom). +/// Three-section cockpit — thinking (top), subconscious (mid), inner voice (bottom). pub struct Cockpit { root: Box, thinking_title_id: WidgetId, @@ -31,13 +36,23 @@ pub struct Cockpit { sub_title_id: WidgetId, sub_body_id: WidgetId, sub_text_id: WidgetId, + voice_title_id: WidgetId, + voice_body_id: WidgetId, + voice_text_id: WidgetId, pressure_bar_id: WidgetId, pressure_label_id: WidgetId, palette: ChatPalette, thinking_count: usize, sub_count: usize, + voice_count: usize, active: ActivePane, + + // Inner voice file tracking + inner_voice_path: Option, + inner_voice_mtime: Option, + inner_voice_lines: VecDeque, + last_scan: Option, } impl DelegateWidget for Cockpit { @@ -55,6 +70,9 @@ impl Cockpit { let mut sub_title_id = WidgetId::EMPTY; let mut sub_body_id = WidgetId::EMPTY; let mut sub_text_id = WidgetId::EMPTY; + let mut voice_title_id = WidgetId::EMPTY; + let mut voice_body_id = WidgetId::EMPTY; + let mut voice_text_id = WidgetId::EMPTY; let mut pressure_bar_id = WidgetId::EMPTY; let mut pressure_label_id = WidgetId::EMPTY; @@ -86,6 +104,20 @@ impl Cockpit { ]) .id(&mut sub_body_id); + let voice_title = Text::new() + .content(section_title("inner voice", false, 0, dim)) + .id(&mut voice_title_id); + let voice_body = Pane::new() + .vertical() + .flex(1) + .min_height(0) + .max_height(0) + .y_scroll(Scrollbar::AutoHide) + .children([ + Text::new().content("").id(&mut voice_text_id) as Box, + ]) + .id(&mut voice_body_id); + // ── Pressure footer ────────────────────────────────────────────── let pressure_label = Text::new() .content(StyledStr::new(" ctx ").fg(dim)) @@ -119,6 +151,9 @@ impl Cockpit { sub_title as Box, sub_body, Rule::new(dim) as Box, + voice_title as Box, + voice_body, + Rule::new(dim) as Box, footer, ]); @@ -130,12 +165,20 @@ impl Cockpit { sub_title_id, sub_body_id, sub_text_id, + voice_title_id, + voice_body_id, + voice_text_id, pressure_bar_id, pressure_label_id, palette: *palette, thinking_count: 0, sub_count: 0, + voice_count: 0, active: ActivePane::None, + inner_voice_path: None, + inner_voice_mtime: None, + inner_voice_lines: VecDeque::new(), + last_scan: None, }) } @@ -184,6 +227,74 @@ impl Cockpit { } } + /// Refresh the inner voice file — scans agent dirs, reads tail if changed. + /// Call once per poll tick. + pub fn refresh_inner_voice(&mut self) { + self.refresh_inner_voice_path(); + self.refresh_inner_voice_content(); + let content = build_voice_text(&self.inner_voice_lines, &self.palette); + self.voice_count = self.inner_voice_lines.len(); + if let Some(t) = self.root.get_widget_mut(self.voice_text_id) { + t.set_content(content); + } + self.relayout(); + } + + /// Find the agent dir whose subconscious.md was most recently written. + /// Scans at most once per 5s. + fn refresh_inner_voice_path(&mut self) { + let now = Instant::now(); + if let Some(last) = self.last_scan { + if now.duration_since(last).as_secs() < 5 && self.inner_voice_path.is_some() { + return; + } + } + self.last_scan = Some(now); + + let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else { return }; + let agents_dir = home.join(".souveraine").join("agents"); + let Ok(entries) = std::fs::read_dir(&agents_dir) else { return }; + + let mut best: Option<(SystemTime, PathBuf)> = None; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { continue; } + let inner = path + .join("memory").join("system").join("metacognition").join("subconscious.md"); + let Ok(meta) = std::fs::metadata(&inner) else { continue }; + let Ok(mtime) = meta.modified() else { continue }; + match &best { + None => best = Some((mtime, inner)), + Some((cur, _)) if mtime > *cur => best = Some((mtime, inner)), + _ => {} + } + } + + if let Some((_, path)) = best { + if self.inner_voice_path.as_ref() != Some(&path) { + self.inner_voice_path = Some(path); + self.inner_voice_mtime = None; + } + } + } + + /// Read the tail of the inner-voice file if it has changed. + fn refresh_inner_voice_content(&mut self) { + let Some(path) = self.inner_voice_path.clone() else { return }; + let Ok(meta) = std::fs::metadata(&path) else { return }; + let Ok(mtime) = meta.modified() else { return }; + if self.inner_voice_mtime == Some(mtime) { return; } + self.inner_voice_mtime = Some(mtime); + + let Ok(content) = std::fs::read_to_string(&path) else { return }; + self.inner_voice_lines.clear(); + for line in content.lines().rev().take(64) { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { continue; } + self.inner_voice_lines.push_front(trimmed.to_string()); + } + } + /// Recompute titles (glow + chevron) and the section room split. fn relayout(&mut self) { let primary = theme::to_tuie_color(self.palette.agent_primary); @@ -194,6 +305,7 @@ impl Cockpit { let sub_active = self.active == ActivePane::Subconscious; let think_open = self.thinking_count > 0; let sub_open = self.sub_count > 0; + let voice_open = self.voice_count > 0; // Titles — accent + filled chevron when active, dim otherwise. if let Some(t) = self.root.get_widget_mut(self.thinking_title_id) { @@ -204,17 +316,24 @@ impl Cockpit { let c = if sub_active { surfacing } else { dim }; t.set_content(section_title("subconscious", sub_open, self.sub_count, c)); } + if let Some(t) = self.root.get_widget_mut(self.voice_title_id) { + t.set_content(section_title("inner voice", voice_open, self.voice_count, dim)); + } // Room split: a collapsed (empty) section hides its body; otherwise the // active section gets double the weight so the live stream has room. let think_flex = section_flex(think_open, think_active); let sub_flex = section_flex(sub_open, sub_active); + let voice_flex = section_flex(voice_open, false); if let Some(b) = self.root.get_widget_mut(self.thinking_body_id) { apply_section_room(b, think_open, think_flex); } if let Some(b) = self.root.get_widget_mut(self.sub_body_id) { apply_section_room(b, sub_open, sub_flex); } + if let Some(b) = self.root.get_widget_mut(self.voice_body_id) { + apply_section_room(b, voice_open, voice_flex); + } self.root.dirty_layout(); } } @@ -304,6 +423,45 @@ fn build_subconscious_text(entries: &[ChatCockpitEntry], palette: &ChatPalette) content } +/// Build inner-voice text from the tail of `subconscious.md`. +fn build_voice_text(lines: &VecDeque, palette: &ChatPalette) -> StyledString { + let dim_color = theme::to_tuie_color(palette.agent_dim); + let mut content = StyledString::new(); + + if lines.is_empty() { + content.push_span(StyledStr::new(" no inner voice yet\n").fg(dim_color).italic()); + return content; + } + + for line in lines.iter() { + let pretty = format_inner_voice_line(line); + content.push_span(StyledStr::new(&format!(" {}\n", pretty)).fg(dim_color)); + } + + content +} + +/// Strip the date prefix and reformat inner voice lines. +/// `[2026-05-12 14:32] [URGENCY: low] — content` → `14:32 low — content` +fn format_inner_voice_line(raw: &str) -> String { + if let Some(close) = raw.find(']') { + let rest = &raw[close + 1..]; + let header = &raw[..close + 1]; + let time = header.chars().rev().skip(1).take(5).collect::(); + let time: String = time.chars().rev().collect(); + let rest = rest.trim_start(); + if let Some(rest) = rest.strip_prefix("[URGENCY: ") { + if let Some(close) = rest.find(']') { + let urg = &rest[..close]; + let tail = rest[close + 1..].trim_start_matches('—').trim(); + return format!("{time} {urg} — {tail}"); + } + } + return format!("{time} {rest}"); + } + raw.to_string() +} + fn entry_kind_color(kind: CockpitKind, p: &ChatPalette) -> Color { match kind { CockpitKind::Surfacing => theme::to_tuie_color(p.surfacing), diff --git a/src/ui/widgets/expression_cache.rs b/src/ui/widgets/expression_cache.rs new file mode 100644 index 0000000..ef1dac5 --- /dev/null +++ b/src/ui/widgets/expression_cache.rs @@ -0,0 +1,206 @@ +use std::collections::HashMap; +use std::path::Path; + +use tuie::prelude::*; + +use crate::ui::presence::{Eye, Posture}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ExpressionKey { + pub posture: Posture, + pub eye: Eye, + pub breath: bool, + pub outfit: Option, +} + +impl ExpressionKey { + pub fn filename(&self) -> String { + let posture = match self.posture { + Posture::Idle => "idle", + Posture::Alert => "alert", + Posture::Thinking => "thinking", + Posture::Processing => "processing", + Posture::Affectionate => "affectionate", + Posture::Straining => "straining", + Posture::Yawning => "yawning", + Posture::Listening => "listening", + Posture::Speaking => "speaking", + }; + match (self.eye, self.breath) { + (Eye::Blinking, _) => format!("{}-blink.png", posture), + (_, true) => format!("{}-interim.png", posture), + _ => format!("{}.png", posture), + } + } +} + +pub struct ExpressionCache { + inner: HashMap>, +} + +impl ExpressionCache { + pub fn new() -> Self { + Self { inner: HashMap::new() } + } + + fn ensure_loaded( + &mut self, + agent_id: &str, + key: &ExpressionKey, + expressions_dir: &Path, + ) -> bool { + let agent_map = self.inner.entry(agent_id.to_string()).or_default(); + if agent_map.contains_key(key) { + return true; + } + + if let Some(outfit) = &key.outfit { + let outfit_dir = expressions_dir.join(outfit); + if outfit_dir.is_dir() { + let path = outfit_dir.join(key.filename()); + if path.exists() { + return Self::load_path(agent_map, key, &path); + } + } + } + + let path = expressions_dir.join(key.filename()); + if path.exists() { + return Self::load_path(agent_map, key, &path); + } + + false + } + + fn load_path( + agent_map: &mut HashMap, + key: &ExpressionKey, + path: &Path, + ) -> bool { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "expression read failed"); + return false; + } + }; + match ImageSource::from_encoded(bytes) { + Ok(source) => { + tracing::info!(key = ?key, path = %path.display(), "expression loaded"); + agent_map.insert(key.clone(), source); + true + } + Err(e) => { + tracing::warn!(path = %path.display(), error = %e, "expression decode failed"); + false + } + } + } + + pub fn resolve( + &mut self, + agent_id: &str, + key: ExpressionKey, + assets_dir: &Path, + ) -> Option { + let expressions_dir = assets_dir.join("expressions"); + if !expressions_dir.is_dir() { + return None; + } + + let base = ExpressionKey { + eye: Eye::Open, + breath: false, + outfit: key.outfit.clone(), + ..key.clone() + }; + let root_key = ExpressionKey { outfit: None, ..key.clone() }; + let root_base = ExpressionKey { outfit: None, eye: Eye::Open, breath: false, ..key.clone() }; + + if key.outfit.is_some() { + self.ensure_loaded(agent_id, &key, &expressions_dir); + } + if key.outfit.is_some() && base != key { + self.ensure_loaded(agent_id, &base, &expressions_dir); + } + self.ensure_loaded(agent_id, &root_key, &expressions_dir); + if root_base != root_key { + self.ensure_loaded(agent_id, &root_base, &expressions_dir); + } + + let inner = self.inner.get(agent_id)?; + for candidate in [&key, &base, &root_key, &root_base] { + if let Some(source) = inner.get(candidate) { + return Some(source.clone()); + } + } + None + } + + pub fn preload_all(&mut self, agent_id: &str, assets_dir: &Path) { + let expressions_dir = assets_dir.join("expressions"); + if !expressions_dir.is_dir() { + return; + } + self.preload_for_dir(agent_id, &expressions_dir, None); + + if let Ok(entries) = std::fs::read_dir(&expressions_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + self.preload_for_dir(agent_id, &path, Some(name.to_string())); + } + } + } + } + + tracing::info!( + agent = %agent_id, + loaded = self.inner.get(agent_id).map(|m| m.len()).unwrap_or(0), + "expressions preloaded (all outfits)", + ); + } + + fn preload_for_dir( + &mut self, + agent_id: &str, + dir: &Path, + outfit: Option, + ) { + for posture in &[ + Posture::Idle, Posture::Alert, Posture::Thinking, Posture::Processing, + Posture::Affectionate, Posture::Straining, Posture::Yawning, + Posture::Listening, Posture::Speaking, + ] { + for eye in &[Eye::Open, Eye::Blinking] { + for breath in &[false, true] { + let key = ExpressionKey { + posture: *posture, + eye: *eye, + breath: *breath, + outfit: outfit.clone(), + }; + if !dir.join(key.filename()).exists() { + continue; + } + let agent_map = self.inner.entry(agent_id.to_string()).or_default(); + if !agent_map.contains_key(&key) { + let path = dir.join(key.filename()); + Self::load_path(agent_map, &key, &path); + } + } + } + } + } + + pub fn remove_agent(&mut self, agent_id: &str) { + self.inner.remove(agent_id); + } +} + +impl Default for ExpressionCache { + fn default() -> Self { + Self::new() + } +} diff --git a/src/ui/widgets/footer.rs b/src/ui/widgets/footer.rs index b58879f..b775424 100644 --- a/src/ui/widgets/footer.rs +++ b/src/ui/widgets/footer.rs @@ -157,14 +157,14 @@ fn pressure_color(pressure: f32) -> Color { #[cfg(test)] mod tests { use super::*; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; #[test] fn footer_renders_agent_name() { let palette = ChatPalette::default(); let mut widget = Footer::new(&palette); widget.set_agent_name("TestAgent"); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("TestAgent"), @@ -177,7 +177,7 @@ mod tests { let palette = ChatPalette::default(); let mut widget = Footer::new(&palette); widget.set_mode("local"); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("Enter send"), @@ -194,7 +194,7 @@ mod tests { let palette = ChatPalette::default(); let mut widget = Footer::new(&palette); widget.set_conversation_id("abcdef1234567890"); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("conv abcdef12"), @@ -207,7 +207,7 @@ mod tests { let palette = ChatPalette::default(); let mut widget = Footer::new(&palette); widget.set_pressure(0.52); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("52%"), @@ -220,7 +220,7 @@ mod tests { let palette = ChatPalette::default(); let mut widget = Footer::new(&palette); widget.set_model("test-model"); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("test-model"), @@ -233,11 +233,11 @@ mod tests { let palette = ChatPalette::default(); let mut widget = Footer::new(&palette); widget.set_cockpit_open(false); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); assert!(term.get_snapshot_text().contains("Tab cockpit")); widget.set_cockpit_open(true); - let term = TestTerminal::new(&mut *widget, Vec2::new(120, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(120, 3)); assert!(term.get_snapshot_text().contains("Tab close cockpit")); } } diff --git a/src/ui/widgets/itinerary_strip.rs b/src/ui/widgets/itinerary_strip.rs index 7583626..0fa90ca 100644 --- a/src/ui/widgets/itinerary_strip.rs +++ b/src/ui/widgets/itinerary_strip.rs @@ -66,7 +66,7 @@ impl ItineraryStrip { #[cfg(test)] mod tests { use super::*; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; #[test] fn itinerary_starts_hidden() { @@ -74,7 +74,7 @@ mod tests { let mut widget = ItineraryStrip::new(&palette); // Height 1: when hidden the widget collapses to zero height, so // rendering should be empty or a single blank line. - let term = TestTerminal::new(&mut *widget, Vec2::new(80, 1)); + let term = Emulator::new(&mut *widget, Vec2::new(80, 1)); let rendered = term.get_snapshot_text(); assert!( rendered.trim().is_empty(), @@ -87,7 +87,7 @@ mod tests { let palette = ChatPalette::default(); let mut widget = ItineraryStrip::new(&palette); widget.set_line("step1 → step2 → step3", &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(80, 3)); + let term = Emulator::new(&mut *widget, Vec2::new(80, 3)); let rendered = term.get_snapshot_text(); assert!( rendered.contains("step1") || rendered.contains("step2"), @@ -101,7 +101,7 @@ mod tests { let mut widget = ItineraryStrip::new(&palette); widget.set_line("step1 → step2 → step3", &palette); widget.set_line("", &palette); - let term = TestTerminal::new(&mut *widget, Vec2::new(80, 1)); + let term = Emulator::new(&mut *widget, Vec2::new(80, 1)); let rendered = term.get_snapshot_text(); assert!( !rendered.contains("step1") && !rendered.contains("step2"), diff --git a/src/ui/widgets/message_list.rs b/src/ui/widgets/message_list.rs index 7cfdc74..7a0543a 100644 --- a/src/ui/widgets/message_list.rs +++ b/src/ui/widgets/message_list.rs @@ -15,7 +15,7 @@ pub enum MsgKind { Surfacing { source: String, content: String, priority: String }, System { text: String }, SystemContext { summary: String, _full_text: String }, - Tool { name: String, args_summary: String, round: u32, is_error: bool, is_pending: bool }, + Tool { name: String, args_summary: String, round: u32, is_error: bool, is_pending: bool, result_output: Option }, Interjection { text: String, delivered: bool }, Interstitial { text: String, is_voice: bool }, } @@ -148,7 +148,7 @@ fn render_message( .container_width(w), ) } - MsgKind::Tool { name, args_summary, round, is_error, is_pending } => { + MsgKind::Tool { name, args_summary, round, is_error, is_pending, result_output } => { let p = &msgs.palette; let mode = if msgs.tool_cards_expanded { super::tool_card::ToolCardMode::Expanded @@ -169,6 +169,7 @@ fn render_message( .round(*round) .is_error(*is_error) .is_pending(*is_pending) + .result_output(result_output.clone()) .mode(mode) .glyph_style(to_style(glyph_color)) .name_style(to_style(glyph_color).bold()) diff --git a/src/ui/widgets/mod.rs b/src/ui/widgets/mod.rs index 3f0dcf4..a95b4af 100644 --- a/src/ui/widgets/mod.rs +++ b/src/ui/widgets/mod.rs @@ -15,6 +15,7 @@ pub mod checkbox; pub mod cockpit; pub mod counter; pub mod dropdown; +pub mod expression_cache; pub mod flat_button; pub mod focus_pane; pub mod footer; @@ -22,7 +23,6 @@ pub mod global_chords; pub mod horizontal_rule; pub mod itinerary_strip; pub mod link; -pub mod menu_list; pub mod message_list; pub mod page_layout; pub mod phase_bar; @@ -32,6 +32,8 @@ pub mod progress_bar; pub mod radio_group; pub mod responsive; pub mod segmented_control; +pub mod select_list; pub mod send_button; pub mod slider; +pub mod stats; pub mod tool_card; diff --git a/src/ui/widgets/phase_bar.rs b/src/ui/widgets/phase_bar.rs index bdff670..aeaad79 100644 --- a/src/ui/widgets/phase_bar.rs +++ b/src/ui/widgets/phase_bar.rs @@ -85,8 +85,93 @@ impl PhaseBar { self.text.dirty_layout(); } + /// Append the subconscious stream output below the normal spinner line. + /// + /// The live `subconscious_current` line is rendered brightest at the bottom + /// with a "⟡ " prefix. Older history lines from `subconscious_stream` fade + /// upward — each older line is styled dimmer and more italic than the one + /// below, creating a gradient fade. Capped at 5 total lines. + /// + /// Call after `set_phase` when phase is `Subconscious` and stream data exists. + pub fn set_subconscious_stream( + &mut self, + live_line: &str, + history: &[String], + style: Style, + dim_style: Style, + ) { + let has_live = !live_line.is_empty(); + let cap_lines = 4usize; + let mut entries: Vec<&str> = Vec::with_capacity(1 + cap_lines); + if has_live { + entries.push(live_line); + } + let history_take = cap_lines.min(history.len()); + for s in history.iter().rev().take(history_take) { + entries.push(s.as_str()); + } + entries.reverse(); + + if entries.is_empty() { + return; + } + let entry_count = entries.len(); + + let mut content = StyledString::new(); + // Spinner glyph line — the existing phase bar header. + let spinner_line = " \u{280B} Subconscious"; // ⠋ + content.push_str(spinner_line); + let spinner_len = content.as_ref().len(); + content.style_range(0..spinner_len, |s| *s = style); + content.push_str("\n"); + + for (slot_idx, body) in entries.iter().enumerate() { + let is_newest = slot_idx + 1 == entry_count; + let t = if entry_count <= 1 { + 1.0 + } else { + slot_idx as f32 / (entry_count - 1) as f32 + }; + + let line_style = if is_newest { + style + } else if t < 0.34 { + dim_style.italic() + } else if t < 0.67 { + dim_style + } else { + style.italic() + }; + + let prefix = if is_newest { " \u{27E1} " } else { " " }; + let clipped = clip_line(body, 80); + content.push_str(&format!("{prefix}{clipped}")); + let line_start = content.as_ref().len() - prefix.len() - clipped.len(); + content.style_range(line_start..content.as_ref().len(), |s| *s = line_style); + if slot_idx + 1 < entry_count { + content.push_str("\n"); + } + } + + self.text.set_content(content); + self.text.dirty_layout(); + } + /// Clear the phase bar (hide it). pub fn clear(&mut self) { self.text.set_content(""); } } + +/// Clip a line to `width` chars, appending `…` when truncated. +fn clip_line(s: &str, width: usize) -> String { + if s.chars().count() <= width { + return s.to_string(); + } + let mut out = String::with_capacity(width + 1); + for c in s.chars().take(width.saturating_sub(1)) { + out.push(c); + } + out.push('\u{2026}'); // … + out +} diff --git a/src/ui/widgets/portrait.rs b/src/ui/widgets/portrait.rs index 52ee68a..9c71ee6 100644 --- a/src/ui/widgets/portrait.rs +++ b/src/ui/widgets/portrait.rs @@ -4,13 +4,34 @@ //! (`~/.souveraine/agents/{id}/memory/assets/portrait.{png,jpg,jpeg}`). //! Falls back to ASCII diamond art when no image is found or image //! support isn't available. +//! +//! Supports expression switching via [`ExpressionCache`] — when an +//! agent has expression frames under `memory/assets/expressions/`, +//! the portrait swaps images based on the agent's current presence state. use tuie::prelude::*; -/// A widget that shows an agent's portrait — either as a terminal image -/// (kitty/sixel/half-block) or as ASCII art. +use super::expression_cache::{ExpressionCache, ExpressionKey}; +use crate::ui::chat::ChatPalette; +use crate::ui::presence::{Eye, Posture}; +use crate::ui::theme; + +pub struct HudStats { + pub age: String, + pub commits: u32, + pub uptime: String, + pub instances: u32, + pub mem_count: u32, + pub mood: String, +} + pub struct Portrait { inner: Box, + agent_id: Option, + current_key: Option, + name: String, + color: Color, + has_hud: bool, } impl DelegateWidget for Portrait { @@ -19,15 +40,9 @@ impl DelegateWidget for Portrait { } impl Portrait { - /// Create a portrait for the given agent. - /// - /// If `agent_id` is `Some` and a portrait file exists on disk, an - /// [`Image`] widget is used. Otherwise ASCII diamond art is rendered. pub fn new(agent_id: Option<&str>, name: &str, color: Color) -> Box { - let inner: Box = match agent_id.and_then(|id| load_portrait_image(id)) { + let inner: Box = match agent_id.and_then(load_portrait_image) { Some(source) => { - // Let the Pane layout determine size — Image fills available area - // with cover-style scaling (crops to fill, no letterboxing). let mut img = Image::new(source); img.set_fill(true); img.flex(1).x_align(FlexAlign::Center) @@ -35,11 +50,83 @@ impl Portrait { None => ascii_portrait(name, color), }; - Box::new(Self { inner }) + Box::new(Self { + inner, + agent_id: agent_id.map(|s| s.to_string()), + current_key: None, + name: name.to_string(), + color, + has_hud: false, + }) + } + + pub fn update_expression( + &mut self, + cache: &mut ExpressionCache, + posture: Posture, + eye: Eye, + breath: bool, + ) { + let agent_id = match &self.agent_id { + Some(id) => id.clone(), + None => return, + }; + + let assets_dir = match dirs::home_dir() { + Some(home) => home + .join(".souveraine") + .join("agents") + .join(&agent_id) + .join("memory") + .join("assets"), + None => return, + }; + + let key = ExpressionKey { + posture, + eye, + breath, + outfit: None, + }; + + if self.current_key.as_ref() == Some(&key) { + return; + } + + if let Some(source) = cache.resolve(&agent_id, key.clone(), &assets_dir) { + let mut img = Image::new(source); + img.set_fill(true); + let img = img.flex(1).x_align(FlexAlign::Center); + self.inner = img; + self.current_key = Some(key); + } + } + + pub fn set_hud(&mut self, stats: &HudStats, palette: &ChatPalette) { + let primary = theme::to_tuie_color(palette.agent_primary); + let dim = theme::to_tuie_color(palette.agent_dim); + + let mut content = StyledString::new(); + content.push_span(StyledStr::new(" AGE ").fg(dim)); + content.push_span(StyledStr::new(&stats.age).fg(primary)); + content.push_str("\n"); + content.push_span(StyledStr::new(" STATS").fg(dim)); + content.push_str(" "); + content.push_span(StyledStr::new(&format!("C {}", stats.commits)).fg(Color::GREEN)); + content.push_str(" "); + content.push_span(StyledStr::new(&format!("U {}", stats.uptime)).fg(Color::GREEN)); + content.push_str(" "); + content.push_span(StyledStr::new(&format!("I {}", stats.instances)).fg(Color::BLUE)); + content.push_str(" "); + content.push_span(StyledStr::new(&format!("M {}", stats.mem_count)).fg(Color::GREEN)); + content.push_str("\n"); + content.push_span(StyledStr::new(" MOOD ").fg(dim)); + content.push_span(StyledStr::new(&stats.mood).fg(primary)); + + self.has_hud = true; } } -/// Try to load a portrait image file for the given agent ID. fn load_portrait_image(agent_id: &str) -> Option { let assets_dir = dirs::home_dir()? .join(".souveraine") @@ -57,7 +144,6 @@ fn load_portrait_image(agent_id: &str) -> Option { ImageSource::from_encoded(bytes).ok() } -/// ASCII art fallback — diamond shape with agent name. fn ascii_portrait(name: &str, color: Color) -> Box { let mut content = StyledString::new(); content.push_str("\n"); diff --git a/src/ui/widgets/responsive.rs b/src/ui/widgets/responsive.rs index 9c64703..c05fda5 100644 --- a/src/ui/widgets/responsive.rs +++ b/src/ui/widgets/responsive.rs @@ -224,7 +224,7 @@ impl Widget for Responsive { #[cfg(test)] mod tests { use super::*; - use tuie::test::TestTerminal; + use tuie::emulator::Emulator; fn label(s: &str) -> Box { Text::new().content(s).flex(1) @@ -235,7 +235,7 @@ mod tests { let mut root = Responsive::new(100, label("WIDEMODE"), label("NARROWMODE")).flex(1); // At/above the breakpoint the wide arrangement is shown. - let mut term = TestTerminal::new(&mut *root, Vec2::new(120, 10)); + let mut term = Emulator::new(&mut *root, Vec2::new(120, 10)); let wide = term.get_snapshot_text(); assert!(wide.contains("WIDEMODE"), "expected wide arrangement, got: {wide:?}"); assert!(!wide.contains("NARROWMODE"), "narrow leaked into wide: {wide:?}"); diff --git a/src/ui/widgets/select_list.rs b/src/ui/widgets/select_list.rs new file mode 100644 index 0000000..7157510 --- /dev/null +++ b/src/ui/widgets/select_list.rs @@ -0,0 +1,448 @@ +//! `SelectList` — the one clickable, stylable, keyboard-navigable list. +//! +//! Every list-bearing screen used to reinvent selection: a `selected: usize` +//! field, a hand-matched `Up`/`Down`/`Enter` block in `override_on_input`, an +//! `Rc>>` activation channel, and a method that rebuilt every +//! row to re-apply the highlight — keyboard only, never clickable. This widget +//! owns all of that once. +//! +//! - Rows come from `set_items(Vec)`. The caller formats each +//! row's *content* (bold names, coloured methods, …); the list owns the +//! *selection* styling (prefix + accent, or an accent border for cards) so it +//! stays consistent everywhere. +//! - Virtualised through tuie's [`List`], so a thousand rows cost the visible +//! window. +//! - **Single left-click on a row activates it** (selects + fires). Arrow keys +//! (plus Home/End/PageUp/PageDown) move the selection; Enter activates. +//! - Hover tints the row under the pointer. +//! +//! Activation surfaces as an [`ActivateEvent`] emitted from the `SelectList`'s +//! own id. A screen reads it in `after_on_event`: +//! +//! ```ignore +//! fn after_on_event(&mut self, event: &mut WidgetEvent) { +//! if let Some(&ActivateEvent(i)) = event.get_by::(self.list_id) { +//! self.selection.set(Some(i)); +//! } +//! } +//! ``` + +use tuie::prelude::*; +use chord_macro::chord; + +use crate::ui::theme; + +/// Emitted from a [`SelectList`] when a row is activated (clicked, or selected +/// and confirmed with Enter). Carries the row index. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ActivateEvent(pub usize); + +/// Emitted from a [`ListRow`] when it is clicked. Internal to this module — the +/// owning `SelectList` translates it into selection + [`ActivateEvent`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RowClicked(usize); + +// ── Row ───────────────────────────────────────────────────────────────────── + +/// One rendered row. Clickable (hit-tested, no focus needed), hover-aware. +struct ListRow { + root: Box, + index: usize, + selected: bool, + active: Color, + bordered: bool, +} + +impl ListRow { + fn new( + index: usize, + content: Box, + selected: bool, + active: Color, + dim: Color, + bordered: bool, + ) -> Box { + let body = Pane::new().horizontal().children([ + Text::new().content( + StyledStr::new(if selected { "\u{25b6} " } else { " " }) + .fg(if selected { active } else { dim }), + ) as Box, + content, + ]); + + let root = if bordered { + Pane::new() + .vertical() + .bordered() + .border_style(Style::new().fg(if selected { active } else { dim }).dim()) + .padding(Spacing::new().horizontal(1)) + .children([body]) + } else { + Pane::new().vertical().children([body]) + }; + + let mut row = Box::new(Self { root, index, selected, active, bordered }); + row.apply_style(false); + row + } + + /// Repaint the row body for the current (selected, hovered) state. Selection + /// is structural (prefix + border); hover adds a subtle accent tint so the + /// row under the pointer reads as live without recolouring its content. + fn apply_style(&mut self, hovered: bool) { + let style = if hovered { + Style::new().fg(self.active) + } else if self.selected && !self.bordered { + Style::new().fg(self.active).dim() + } else { + Style::new() + }; + self.root.set_style(style); + } +} + +impl DelegateWidget for ListRow { + tuie::delegate_widget!(root); + + fn override_is_focusable(&self) -> bool { + false + } + + fn after_on_state_change(&mut self, state: WidgetState) { + let hovered = matches!(state, WidgetState::Hover | WidgetState::FocusedHover); + self.apply_style(hovered); + } + + fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult { + // Rows are hit-tested for the pointer only; keyboard goes to the focused + // `SelectList`, never here. + let Some(event) = queue.next() else { + return InputResult::Rejected; + }; + match &event.chord { + chord!(LeftRelease) => { + let size = self.get_rect_size(); + let inside = Axis2D::all(|a| event.pos[a] >= 0.0 && event.pos[a] < size[a] as f32); + if inside { + tuie::emit(self.get_id(), RowClicked(self.index)); + } + InputResult::Handled + } + _ => InputResult::Rejected, + } + } +} + +// ── Render context ────────────────────────────────────────────────────────── + +/// State the virtualised renderer reads to build each visible row. Lives inside +/// the [`List`]; `SelectList` mutates it through `List::get_context_mut`. +struct RowCtx { + rows: Vec, + selected: usize, + active: Color, + dim: Color, + bordered: bool, +} + +fn render_row(ctx: &mut RowCtx, index: usize) -> Option> { + let content = ctx.rows.get(index)?.clone(); + let widget = Text::new().content(content) as Box; + Some(ListRow::new( + index, + widget, + index == ctx.selected, + ctx.active, + ctx.dim, + ctx.bordered, + )) +} + +// ── SelectList ────────────────────────────────────────────────────────────── + +/// The list. Build it, hand it rows, place it in a screen, read [`ActivateEvent`]. +pub struct SelectList { + root: Box, + list_id: WidgetId, + len: usize, + selected: usize, + active: Color, + dim: Color, + bordered: bool, + gap: u8, +} + +impl SelectList { + /// A new, empty list. Colours default to the theme accent / dim; override + /// with [`colors`](Self::colors). Call [`items`](Self::items) to populate. + pub fn new() -> Box { + Box::new(Self { + root: Pane::new().vertical().flex(1), + list_id: WidgetId::EMPTY, + len: 0, + selected: 0, + active: theme::get_accent_color(), + dim: Color::BRIGHT_BLACK, + bordered: false, + gap: 0, + }) + } + + /// Accent (selected/hover) and dim (idle prefix/border) colours. + pub fn colors(mut self: Box, active: Color, dim: Color) -> Box { + self.active = active; + self.dim = dim; + self + } + + /// Render each row inside an accent-bordered card (agent-picker style) + /// rather than as a plain prefixed line. + pub fn bordered(mut self: Box) -> Box { + self.bordered = true; + self + } + + /// Vertical gap (in rows) between items. + pub fn gap(mut self: Box, gap: u8) -> Box { + self.gap = gap; + self + } + + /// Set the initial selection (clamped on [`items`](Self::items)). + pub fn selected(mut self: Box, index: usize) -> Box { + self.selected = index; + self + } + + /// Populate the list. Each `StyledString` is one row's content; selection + /// styling is applied by the list. Builds the inner [`List`] and finalises. + pub fn items(mut self: Box, rows: Vec) -> Box { + self.len = rows.len(); + if self.selected >= self.len { + self.selected = self.len.saturating_sub(1); + } + + let mut list = List::new() + .vertical() + .flex(1) + .scrollbar_style(ScrollbarStyle::new()) + .scroll(Scrollbar::AutoHide); + if self.gap > 0 { + list = list.gap(self.gap); + } + list.set_renderer( + RowCtx { + rows, + selected: self.selected, + active: self.active, + dim: self.dim, + bordered: self.bordered, + }, + render_row, + ); + list.set_item_count(self.len); + self.list_id = list.get_id(); + self.root = Pane::new().vertical().flex(1).children([list]); + self + } + + /// Replace the rows in place (after the list is already built), keeping the + /// current selection where it still fits. For screens whose contents change + /// — toggles, adds, deletes. + pub fn set_items(&mut self, rows: Vec) { + self.len = rows.len(); + if self.selected >= self.len { + self.selected = self.len.saturating_sub(1); + } + let selected = self.selected; + if let Some(list) = self.root.get_widget_mut(self.list_id) { + if let Some(ctx) = list.get_context_mut::() { + ctx.rows = rows; + ctx.selected = selected; + } + list.set_item_count(self.len); + list.invalidate_all(); + } + self.root.dirty_layout(); + } + + /// The currently highlighted row. + pub fn selected_index(&self) -> usize { + self.selected + } + + /// Jump the highlight to a specific row. + pub fn select(&mut self, index: usize) { + self.set_selected(index); + } + + /// Move the highlight up one row. For a screen that drives the list from its + /// own `override_on_input` (the list is not the focused widget). + pub fn move_up(&mut self) { + self.move_by(-1); + } + + /// Move the highlight down one row. + pub fn move_down(&mut self) { + self.move_by(1); + } + + /// Fire [`ActivateEvent`] for the current selection (the keyboard "Enter" + /// path; clicks activate themselves). + pub fn activate_selected(&mut self) { + let sel = self.selected; + self.activate(sel); + } + + fn move_by(&mut self, delta: i32) { + if self.len == 0 { + return; + } + let max = self.len as i32 - 1; + let next = (self.selected as i32 + delta).clamp(0, max) as usize; + if next != self.selected { + self.set_selected(next); + } + } + + fn set_selected(&mut self, index: usize) { + if index >= self.len { + return; + } + self.selected = index; + if let Some(list) = self.root.get_widget_mut(self.list_id) { + if let Some(ctx) = list.get_context_mut::() { + ctx.selected = index; + } + list.invalidate_all(); + list.ensure_visible(index); + } + self.root.dirty_layout(); + } + + fn activate(&mut self, index: usize) { + tuie::emit(self.get_id(), ActivateEvent(index)); + } +} + +impl DelegateWidget for SelectList { + tuie::delegate_widget!(root); + + fn override_is_focusable(&self) -> bool { + true + } + + fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult { + use tuie::input::key::Key; + use tuie::input::trigger::Trigger; + + if let Some(event) = queue.peek() { + if let Trigger::Key(key) = &event.chord.trigger { + let handled = match key { + Key::Arrow(Direction2D::Up) => Some(-1i32), + Key::Arrow(Direction2D::Down) => Some(1), + Key::PageUp => Some(-10), + Key::PageDown => Some(10), + _ => None, + }; + if let Some(delta) = handled { + queue.next(); + self.move_by(delta); + return InputResult::Handled; + } + match key { + Key::Home => { + queue.next(); + self.set_selected(0); + return InputResult::Handled; + } + Key::End => { + queue.next(); + self.set_selected(self.len.saturating_sub(1)); + return InputResult::Handled; + } + Key::Enter => { + queue.next(); + let sel = self.selected; + self.activate(sel); + return InputResult::Handled; + } + _ => {} + } + } + } + self.get_delegate_mut().on_input(queue) + } + + fn after_on_event(&mut self, event: &mut WidgetEvent) { + if let Some(&RowClicked(index)) = event.get::() { + self.set_selected(index); + self.activate(index); + } + } +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tuie::emulator::Emulator; + + fn rows(n: usize) -> Vec { + (0..n) + .map(|i| { + let mut s = StyledString::new(); + s.push_span(StyledStr::new(&format!("row{i}"))); + s + }) + .collect() + } + + #[test] + fn renders_visible_rows() { + let mut list = SelectList::new().items(rows(3)); + let term = Emulator::new(&mut *list, Vec2::new(20, 10)); + let snap = term.get_snapshot_text(); + assert!(snap.contains("row0"), "got: {snap:?}"); + assert!(snap.contains("row2"), "got: {snap:?}"); + } + + #[test] + fn keyboard_move_clamps_at_bounds() { + let mut list = SelectList::new().items(rows(3)); + let _ = Emulator::new(&mut *list, Vec2::new(20, 10)); + + assert_eq!(list.selected_index(), 0); + list.move_up(); // already at top — clamps + assert_eq!(list.selected_index(), 0); + + list.move_down(); + assert_eq!(list.selected_index(), 1); + + list.move_down(); + list.move_down(); // past the end — clamps + assert_eq!(list.selected_index(), 2); + } + + #[test] + fn set_items_clamps_overflowing_selection() { + let mut list = SelectList::new().items(rows(5)); + let _ = Emulator::new(&mut *list, Vec2::new(20, 10)); + list.select(4); + assert_eq!(list.selected_index(), 4); + + list.set_items(rows(2)); + assert!(list.selected_index() <= 1, "selection should clamp to new len"); + } + + #[test] + fn empty_list_is_inert() { + let mut list = SelectList::new().items(rows(0)); + let _ = Emulator::new(&mut *list, Vec2::new(20, 10)); + // No panic, selection pinned at 0. + list.move_down(); + list.move_up(); + assert_eq!(list.selected_index(), 0); + } +} diff --git a/src/ui/widgets/stats.rs b/src/ui/widgets/stats.rs new file mode 100644 index 0000000..df05ab2 --- /dev/null +++ b/src/ui/widgets/stats.rs @@ -0,0 +1,44 @@ +//! Shared stat-rendering helpers used across dashboard-style widgets. +//! +//! `push_bar` lived in `chat_sidebar` and `breathe_color` in `welcome`; the +//! unified agent screen is a third caller for both, so they graduate here as +//! `pub(crate)` helpers instead of gaining a third verbatim copy. + +use tuie::prelude::*; + +/// Append a bar like `[████████░░] ` to `out` using `width` blocks. +/// +/// `fraction` is 0.0–1.0; filled blocks use `color`, empty blocks and +/// brackets use dim gray. +pub(crate) fn push_bar(out: &mut StyledString, fraction: f32, width: usize, color: Color) { + let filled = ((fraction.clamp(0.0, 1.0)) * width as f32).round() as usize; + let empty = width.saturating_sub(filled); + let bracket = Color::BRIGHT_BLACK; + + out.push_span(StyledStr::new("[").fg(bracket)); + if filled > 0 { + out.push_span(StyledStr::new(&"█".repeat(filled)).fg(color)); + } + if empty > 0 { + out.push_span(StyledStr::new(&"░".repeat(empty)).fg(bracket)); + } + out.push_span(StyledStr::new("] ").fg(bracket)); +} + +/// Gently pulse `base`'s brightness with a slow sine — palette-agnostic, so it +/// works whatever atmosphere colour an agent currently wears. +/// +/// `tick` is a wrapping counter advanced on each animation step (~every 90 ms). +pub(crate) fn breathe_color(base: Color, tick: u64) -> Color { + let (r, g, b) = match base { + Color::Rgb(r, g, b) => (r, g, b), + _ => (255u8, 140, 66), + }; + let phase = (tick as f32 * 0.06).sin() * 0.5 + 0.5; // 0..1 + let f = 0.78 + phase * 0.22; // 0.78..1.0 + Color::Rgb( + (r as f32 * f) as u8, + (g as f32 * f) as u8, + (b as f32 * f) as u8, + ) +} diff --git a/src/ui/widgets/tool_card.rs b/src/ui/widgets/tool_card.rs index 03b1d0f..669a5d6 100644 --- a/src/ui/widgets/tool_card.rs +++ b/src/ui/widgets/tool_card.rs @@ -20,6 +20,7 @@ pub struct ToolCard { round: u32, is_error: bool, is_pending: bool, + result_output: Option, mode: ToolCardMode, glyph_style: Style, name_style: Style, @@ -41,6 +42,7 @@ impl ToolCard { round: 1, is_error: false, is_pending: true, + result_output: None, mode: ToolCardMode::Compact, glyph_style: Style::new(), name_style: Style::new(), @@ -128,11 +130,17 @@ impl ToolCard { self.rebuild(); } - pub fn set_result_details(&mut self, _output: &str) { - // Store result for expanded mode rendering + pub fn set_result_details(&mut self, output: &str) { + self.result_output = if output.is_empty() { None } else { Some(output.to_string()) }; self.rebuild(); } + pub fn result_output(mut self: Box, output: Option) -> Box { + self.result_output = output; + self.rebuild(); + self + } + // ── Rebuild ────────────────────────────────────────────────────────────── fn rebuild(&mut self) { @@ -192,13 +200,52 @@ impl ToolCard { self.text.set_content(content); } - fn build_expanded(&mut self, _glyph: char) { - // Expanded mode uses a ChatBubble-like layout with args + result body. - // For now, render as compact with a "[expanded]" marker. + fn build_expanded(&mut self, glyph: char) { + // Header: " ⟳ tool_name · r3" (same as compact header) let mut content = StyledString::new(); - content.push_str(&format!(" [expanded] {} (r{})", self.name, self.round)); - let len = content.as_ref().len(); - content.style_range(0..len, |s| *s = self.name_style); + let header = format!(" {glyph} {} · r{}", self.name, self.round); + content.push_str(&header); + + let header_len = header.len(); + content.style_range(0..3, |s| *s = self.glyph_style); + content.style_range(3..header_len, |s| *s = self.name_style); + + // Arguments body — full, not clipped. + content.push_str("\n"); + if self.args_summary.is_empty() { + content.push_str(" (no arguments)"); + } else { + // Indent each line of args for readability. + for (i, line) in self.args_summary.lines().enumerate() { + if i > 0 { + content.push_str("\n"); + } + content.push_str(&format!(" {line}")); + } + }; + let body_start = header_len + 1; + let body_end = content.as_ref().len(); + content.style_range(body_start..body_end, |s| *s = self.dim_style); + + // Result output, if any. + if let Some(ref output) = self.result_output { + content.push_str("\n\n"); + let result_marker = if self.is_error { "stderr:" } else { "stdout:" }; + content.push_str(&format!(" {result_marker}")); + for line in output.lines() { + content.push_str("\n "); + content.push_str(line); + } + let result_start = body_end + 2; + let result_end = content.as_ref().len(); + let result_color = if self.is_error { + Color::RED + } else { + Color::BRIGHT_BLACK + }; + content.style_range(result_start..result_end, |s| *s = Style::new().fg(result_color)); + } + self.text.set_content(content); } }