Watch
1
0
Fork
You've already forked souveraine
0

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.
This commit is contained in:
Fimeg 2026-06-19 10:10:13 -04:00
commit d35370b9c9
40 changed files with 3593 additions and 1035 deletions

View file

@ -119,17 +119,52 @@ impl Backend for RemoteBackend {
self.ensure_conversation(agent_id).await self.ensure_conversation(agent_id).await
} }
async fn list_conversations(&self, _agent_id: &str) -> Result<Vec<ConversationInfo>> { async fn list_conversations(&self, agent_id: &str) -> Result<Vec<ConversationInfo>> {
// TODO: implement remote conversation listing via GET /v1/agents/:id/conversations #[derive(Deserialize)]
Ok(Vec::new()) struct Wire {
id: String,
#[serde(default)]
agent_id: Option<String>,
#[serde(default)]
summary: Option<String>,
#[serde(default)]
message_count: Option<u32>,
#[serde(default)]
updated_at: Option<String>,
}
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<Wire> = 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( async fn load_conversation(
&self, &self,
_conversation_id: &str, conversation_id: &str,
) -> Result<Vec<crate::core::session::ConversationMessage>> { ) -> Result<Vec<crate::core::session::ConversationMessage>> {
// TODO: implement remote conversation loading let url = format!("/v1/conversations/{}", conversation_id);
anyhow::bail!("Remote conversation loading not yet implemented") let resp = self
.auth_req(self.client.get(self.url(&url)))
.send()
.await
.context("GET /v1/conversations/:id")?
.error_for_status()?;
let messages: Vec<crate::core::session::ConversationMessage> = resp.json().await?;
Ok(messages)
} }
async fn ensure_conversation(&self, agent_id: &str) -> Result<String> { async fn ensure_conversation(&self, agent_id: &str) -> Result<String> {

View file

@ -17,79 +17,70 @@ use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::api::models::AgentState; 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. /// Build the active (global default) LLM provider from config.
/// ///
/// `[inference] provider` selects the implementation: /// Convenience wrapper that builds the full registry and returns its default.
/// - `"bifrost"` (default) — any OpenAI-compatible gateway via `BifrostClient`. /// For inference, prefer [`build_registry`] + [`ProviderRegistry::for_agent`].
/// - `"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`].
pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result<Arc<dyn LlmProvider>> { pub fn build_provider(config: &ConsciousnessConfig) -> anyhow::Result<Arc<dyn LlmProvider>> {
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 /// Build a provider from a [`ProviderConfig`] entry, dispatching on `provider_type`.
/// a config section. Reuses `BifrostClient` (generic OpenAI-compatible) for any pub fn build_provider_from_config(
/// gateway-style provider; the OAuth provider is the endpoint-specific special
/// case.
pub fn build_provider_named(
name: &str, name: &str,
config: &ConsciousnessConfig, cfg: &ProviderConfig,
) -> anyhow::Result<Arc<dyn LlmProvider>> { ) -> anyhow::Result<Arc<dyn LlmProvider>> {
match name { match cfg.provider_type.as_str() {
"openai-oauth" | "openai-codex" => { "openai-oauth" => {
let provider = providers::openai_oauth::OpenAiOAuthProvider::from_codex_login( let provider = providers::openai_oauth::OpenAiOAuthProvider::from_codex_login(
config.bifrost.primary_model.clone(), cfg.primary_model.clone(),
config.bifrost.timeout_secs, cfg.timeout_secs,
)?; )?;
Ok(Arc::new(provider)) 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. // "openai-compatible" and any unrecognized type → OpenAI-compatible gateway.
let bf = &config.bifrost;
let client = BifrostClient::new( let client = BifrostClient::new(
&bf.base_url, &cfg.base_url,
&bf.api_key, &cfg.api_key,
&bf.virtual_key, &cfg.virtual_key,
&bf.primary_model, &cfg.primary_model,
bf.timeout_secs, cfg.timeout_secs,
)?; )?
.with_id(name);
Ok(Arc::new(client)) Ok(Arc::new(client))
} }
} }
} }
/// Build the per-agent provider registry: the global default provider (from /// Build the per-agent provider registry from the unified `[providers]` map.
/// `[inference] provider`) plus every additionally-configured named provider ///
/// (e.g. `zai` when `[zai]` is set). Engines resolve a provider per agent from /// Iterates every entry in `config.providers`, builds a provider for each, and
/// this instead of holding one global client. /// resolves the default from `[inference] provider`. Returns an error if no
/// providers are configured.
pub fn build_registry(config: &ConsciousnessConfig) -> anyhow::Result<ProviderRegistry> { pub fn build_registry(config: &ConsciousnessConfig) -> anyhow::Result<ProviderRegistry> {
let default_name = config.inference.provider.clone();
let default = build_provider_named(&default_name, config)?;
let mut map = HashMap::new(); let mut map = HashMap::new();
map.insert(default_name.clone(), default.clone());
// z.ai (GLM Coding Plan) — register when configured. for (name, pcfg) in &config.providers {
if !config.zai.base_url.is_empty() && !config.zai.api_key.is_empty() { let provider = build_provider_from_config(name, pcfg)?;
map.insert("zai".to_string(), build_provider_named("zai", config)?); map.insert(name.clone(), provider);
} }
if map.is_empty() {
anyhow::bail!(
"no providers configured — add at least one [providers.<name>] 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 { Ok(ProviderRegistry {
map, map,
default_name, 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<Arc<dyn LlmProvider>> {
self.map.get(name).cloned()
}
/// The global default provider (for contexts with no agent). /// The global default provider (for contexts with no agent).
pub fn default_provider(&self) -> Arc<dyn LlmProvider> { pub fn default_provider(&self) -> Arc<dyn LlmProvider> {
self.default.clone() self.default.clone()

View file

@ -94,6 +94,8 @@ pub struct DefaultCompactionEngine {
Arc<dyn Fn(&str, Vec<ConversationMessage>) -> anyhow::Result<()> + Send + Sync>, Arc<dyn Fn(&str, Vec<ConversationMessage>) -> anyhow::Result<()> + Send + Sync>,
pub get_repo: Arc<dyn Fn(&str) -> Option<MemoryRepo> + Send + Sync>, pub get_repo: Arc<dyn Fn(&str) -> Option<MemoryRepo> + Send + Sync>,
pub get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync>, pub get_agent_type: Arc<dyn Fn(&str) -> Option<String> + Send + Sync>,
/// Per-agent provider override name (e.g. "zai"). None = use global default.
pub get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + Send + Sync>,
} }
#[async_trait] #[async_trait]
@ -154,7 +156,11 @@ impl CompactionEngine for DefaultCompactionEngine {
let plan = match strategy_kind { let plan = match strategy_kind {
CompactionStrategyKind::Summary => match &self.providers { CompactionStrategyKind::Summary => match &self.providers {
Some(reg) => { 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 let model = self
.model .model
.as_deref() .as_deref()
@ -182,7 +188,11 @@ impl CompactionEngine for DefaultCompactionEngine {
} }
CompactionStrategyKind::SlidingReflect => match &self.providers { CompactionStrategyKind::SlidingReflect => match &self.providers {
Some(reg) => { 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 let model = self
.model .model
.as_deref() .as_deref()
@ -345,6 +355,7 @@ mod tests {
replace_messages: Arc::new(|_, _| Ok(())), replace_messages: Arc::new(|_, _| Ok(())),
get_repo: Arc::new(|_| None), get_repo: Arc::new(|_| None),
get_agent_type: Arc::new(|_| Some("primary".to_string())), get_agent_type: Arc::new(|_| Some("primary".to_string())),
get_agent_provider: Arc::new(|_| None),
}; };
let report = engine let report = engine

View file

@ -12,14 +12,20 @@ pub struct ConsciousnessConfig {
pub inference: InferenceConfig, pub inference: InferenceConfig,
/// Bifrost inference gateway config /// Bifrost inference gateway config
#[serde(default)] #[serde(default, skip_serializing)]
pub bifrost: BifrostConfig, pub bifrost: BifrostConfig,
/// z.ai (GLM Coding Plan) inference config — an OpenAI-compatible /// z.ai (GLM Coding Plan) inference config — an OpenAI-compatible
/// provider reachable per-agent via `_souveraine.provider = "zai"`. /// provider reachable per-agent via `_souveraine.provider = "zai"`.
#[serde(default)] #[serde(default, skip_serializing)]
pub zai: ZaiConfig, 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<String, ProviderConfig>,
/// Per-model physics configs /// Per-model physics configs
#[serde(default)] #[serde(default)]
pub models: HashMap<String, ModelConfig>, pub models: HashMap<String, ModelConfig>,
@ -265,6 +271,203 @@ fn default_zai_timeout() -> u64 {
300 300
} }
/// A named inference provider entry in `[providers.<name>]`.
///
/// 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.<id>]` 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BifrostModelConfig { pub struct BifrostModelConfig {
#[serde(default = "default_128k")] #[serde(default = "default_128k")]
@ -693,6 +896,65 @@ impl Default for ConsciousnessConfig {
agent: AgentConfig::default(), agent: AgentConfig::default(),
tui: TuiConfig::default(), tui: TuiConfig::default(),
image: ImageConfig::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,
},
);
} }
} }
} }

View file

@ -80,6 +80,9 @@ pub struct MatrixSensorium {
turns: Arc<Mutex<HashMap<String, MatrixTurn>>>, turns: Arc<Mutex<HashMap<String, MatrixTurn>>>,
/// Agent ID to route inbound messages to. Set at registration time. /// Agent ID to route inbound messages to. Set at registration time.
agent_id: String, agent_id: String,
/// Shared matrix client — available after `run()` connects.
/// Used by `send_message` / `send_direct_reply` for outbound sends.
client: Option<Arc<matrix_sdk::Client>>,
} }
impl MatrixSensorium { impl MatrixSensorium {
@ -100,6 +103,7 @@ impl MatrixSensorium {
auth: Some(auth), auth: Some(auth),
turns: Arc::new(Mutex::new(HashMap::new())), turns: Arc::new(Mutex::new(HashMap::new())),
agent_id: agent_id.into(), agent_id: agent_id.into(),
client: None,
} }
} }
@ -205,6 +209,9 @@ impl Sensorium for MatrixSensorium {
info!("matrix sensorium: connecting (account {})", self.account); info!("matrix sensorium: connecting (account {})", self.account);
let (matrix_client, record) = build_client(auth, &dir).await?; 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. // Persist the (possibly refreshed) session so the next run restores.
save_session_record(&dir, &record)?; save_session_record(&dir, &record)?;
@ -299,25 +306,23 @@ impl Sensorium for MatrixSensorium {
} }
async fn send_message(&self, chat_id: &str, text: &str) -> Result<super::OutboundResult> { async fn send_message(&self, chat_id: &str, text: &str) -> Result<super::OutboundResult> {
// Find the room, send a text message. let Some(client) = &self.client else {
// This is the wire that Phase 5's streaming edits build on — anyhow::bail!("matrix client not connected");
// first chunk creates a new message, subsequent deltas edit it. };
// let room_id = chat_id.parse().context("invalid room id")?;
// TODO: when we don't have direct room access from the sensorium let room = client
// itself (it lives in the run loop), this needs matrix_client to be .get_room(&room_id)
// shared. For now, stubbed — the transport spike sends via the .context("room not found")?;
// event handler path. let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(text);
debug!("matrix::send_message: {chat_id} ({})", text.len()); let response = room.send(content).await.context("send failed")?;
debug!("matrix::send_message: {chat_id} ({}) → {}", text.len(), response.event_id);
Ok(super::OutboundResult { 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<super::OutboundResult> { async fn send_direct_reply(&self, chat_id: &str, text: &str) -> Result<super::OutboundResult> {
debug!("matrix::send_direct_reply: {chat_id} {text}"); self.send_message(chat_id, text).await
Ok(super::OutboundResult {
message_id: String::new(),
})
} }
fn is_running(&self) -> bool { fn is_running(&self) -> bool {

View file

@ -1325,12 +1325,14 @@ async fn load_config() -> anyhow::Result<ConsciousnessConfig> {
if path.exists() { if path.exists() {
info!("loading config from {:?}", path); 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)"); 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 // Try to seed model list from the active inference provider
info!("discovering models from provider"); info!("discovering models from provider");

View file

@ -84,7 +84,8 @@ pub struct ServerConfig {
} }
impl SouveraineServer { impl SouveraineServer {
pub async fn new(config: ConsciousnessConfig) -> anyhow::Result<Self> { pub async fn new(mut config: ConsciousnessConfig) -> anyhow::Result<Self> {
config.normalize_providers();
let data_dir = dirs::home_dir() let data_dir = dirs::home_dir()
.unwrap() .unwrap()
.join(".souveraine") .join(".souveraine")
@ -183,6 +184,26 @@ impl SouveraineServer {
Some("primary".to_string()) Some("primary".to_string())
} }
}); });
let get_agent_provider: Arc<dyn Fn(&str) -> Option<String> + 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<dyn CompactionEngine> = Arc::new(DefaultCompactionEngine { let compaction_engine: Arc<dyn CompactionEngine> = Arc::new(DefaultCompactionEngine {
config: app_cfg, config: app_cfg,
@ -194,6 +215,7 @@ impl SouveraineServer {
replace_messages, replace_messages,
get_repo, get_repo,
get_agent_type, get_agent_type,
get_agent_provider,
}); });
let consciousness = Arc::new(ConsciousnessEngine::new( let consciousness = Arc::new(ConsciousnessEngine::new(

View file

@ -244,6 +244,7 @@ impl App {
name: self.agent_pref.clone(), name: self.agent_pref.clone(),
model: model.clone(), model: model.clone(),
model_original: model, model_original: model,
provider: None, // populated from agent.json if present
subconscious_id: format!("{id}-sub"), subconscious_id: format!("{id}-sub"),
memory_root, memory_root,
subconscious_root, subconscious_root,

View file

@ -193,6 +193,7 @@ impl ChatState {
if self.cockpit_log.len() > 200 { if self.cockpit_log.len() > 200 {
self.cockpit_log.drain(..self.cockpit_log.len() - 200); self.cockpit_log.drain(..self.cockpit_log.len() - 200);
} }
self.surfacings_count += 1;
self.messages.push(ChatMessage::Surfacing { self.messages.push(ChatMessage::Surfacing {
source: source.clone(), source: source.clone(),
content: content.clone(), content: content.clone(),
@ -206,6 +207,7 @@ impl ChatState {
kind: CockpitKind::Reflection, kind: CockpitKind::Reflection,
text: content.clone(), text: content.clone(),
}); });
self.last_reflection = Some(chrono::Local::now().format("%H:%M").to_string());
self.messages.push(ChatMessage::System { self.messages.push(ChatMessage::System {
text: format!("reflection: {}", content), text: format!("reflection: {}", content),
ts: Instant::now(), ts: Instant::now(),
@ -218,6 +220,7 @@ impl ChatState {
kind: CockpitKind::Archivist, kind: CockpitKind::Archivist,
text: format!("{:.0}% — {}", pressure * 100.0, synthesis), text: format!("{:.0}% — {}", pressure * 100.0, synthesis),
}); });
self.last_archivist = Some(chrono::Local::now().format("%H:%M").to_string());
self.messages.push(ChatMessage::System { self.messages.push(ChatMessage::System {
text: format!("archivist: {} (pressure {:.0}%)", synthesis, pressure * 100.0), text: format!("archivist: {} (pressure {:.0}%)", synthesis, pressure * 100.0),
ts: Instant::now(), ts: Instant::now(),
@ -226,6 +229,7 @@ impl ChatState {
} }
BackendEvent::CompactionWarning { pressure, tier } => { BackendEvent::CompactionWarning { pressure, tier } => {
self.pressure = pressure; 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 label = match tier { 3 => "critical", 2 => "urgent", _ => "warn" };
let kind = match tier { let kind = match tier {
3 => CockpitKind::CompactionCritical, 3 => CockpitKind::CompactionCritical,
@ -257,6 +261,11 @@ impl ChatState {
kind: CockpitKind::InferenceStrain, kind: CockpitKind::InferenceStrain,
text, text,
}); });
match status {
504 => self.strain_504 += 1,
429 => self.strain_429 += 1,
_ => self.strain_other += 1,
}
self.pending_consciousness.push(BackendEvent::InferenceStrain { self.pending_consciousness.push(BackendEvent::InferenceStrain {
attempt, status, model: String::new(), attempt, status, model: String::new(),
}); });
@ -327,6 +336,10 @@ impl ChatState {
if active { if active {
self.subconscious_stream.clear(); self.subconscious_stream.clear();
self.subconscious_current.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)); self.pending_consciousness.push(BackendEvent::SubconsciousPass(active));
} }

View file

@ -19,7 +19,7 @@ mod events;
mod footer; mod footer;
mod overlays; mod overlays;
mod render; mod render;
mod tool_renderers; pub mod tool_renderers;
pub mod wrap; pub mod wrap;
pub use render::draw; pub use render::draw;
@ -372,6 +372,19 @@ pub struct ChatState {
/// The human's preferred name, read from system/human.md frontmatter. /// The human's preferred name, read from system/human.md frontmatter.
/// Falls back to "you" if unset. /// Falls back to "you" if unset.
pub human_name: String, 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<String>,
pub last_archivist: Option<String>,
pub last_compaction: Option<(u8, String)>,
pub strain_504: u32,
pub strain_429: u32,
pub strain_other: u32,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -520,6 +533,17 @@ impl ChatState {
subconscious_stream: Vec::new(), subconscious_stream: Vec::new(),
subconscious_current: String::new(), subconscious_current: String::new(),
itinerary_line: 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,
}) })
} }
} }

View file

@ -1,34 +1,80 @@
//! Agent manager screen — select an agent from the available list. //! Unified agent screen — masterdetail view replacing the old AgentsScreen
//! and ManagerScreen.
//! //!
//! Shows available agents as bordered cards with names and descriptions. //! Wide terminals (>=100 cols): agent list on the left, selected agent's
//! Arrow keys navigate, Enter selects, Esc returns to Welcome. //! 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::cell::Cell;
use std::rc::Rc; use std::rc::Rc;
use tuie::prelude::*; use tuie::prelude::*;
/// An agent entry shown in the selection list. use crate::ui::chat::ChatPalette;
struct AgentEntry { use crate::ui::theme;
id: String, use crate::ui::tuie_app::AgentSummary;
name: String, use crate::ui::widgets::portrait::Portrait;
description: String, 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<Pane>,
name: WidgetId<Text>,
glyph: WidgetId<Text>,
description: WidgetId<Text>,
primary_badge: WidgetId<Text>,
instances: WidgetId<Text>,
uptime: WidgetId<Text>,
files: WidgetId<Text>,
pubkey: WidgetId<Text>,
activity_header: WidgetId<Text>,
activity_lines: WidgetId<Text>,
} }
// ── AgentsScreen ───────────────────────────────────────────────────────────
pub struct AgentsScreen { pub struct AgentsScreen {
root: Box<Pane>, root: Box<Pane>,
/// WidgetId for the scroll body pane — replaced on selection change. /// Selection lists — one per Responsive layout, selection mirrored between them.
scroll_id: WidgetId<Pane>, list_wide_id: WidgetId<SelectList>,
agents: Vec<AgentEntry>, list_narrow_id: WidgetId<SelectList>,
/// Detail panes for each layout variant.
detail_wide: DetailIds,
detail_narrow: DetailIds,
/// All known agents.
agents: Vec<AgentSummary>,
/// Currently selected index.
selected: usize, selected: usize,
/// Shared with TuieApp — set to Some(index) when Enter is pressed. palette: ChatPalette,
pub selection: Rc<Cell<Option<usize>>>, primary_color: Color,
/// Whether the inspect overlay is toggled on.
inspecting: bool,
// Signals shared with TuieApp.
selection: Rc<Cell<Option<usize>>>,
back_pressed: Rc<Cell<bool>>,
kill_requested: Rc<Cell<Option<usize>>>,
restart_requested: Rc<Cell<Option<usize>>>,
pin_requested: Rc<Cell<Option<usize>>>,
} }
impl DelegateWidget for AgentsScreen { impl DelegateWidget for AgentsScreen {
tuie::delegate_widget!(root); 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 { fn override_on_input(&mut self, queue: &mut InputQueue) -> InputResult {
use tuie::input::key::Key; use tuie::input::key::Key;
@ -39,23 +85,49 @@ impl DelegateWidget for AgentsScreen {
match key { match key {
Key::Arrow(Direction2D::Up) => { Key::Arrow(Direction2D::Up) => {
queue.next(); queue.next();
if self.selected > 0 { self.move_up();
self.selected -= 1;
self.update_selection();
}
return InputResult::Handled; return InputResult::Handled;
} }
Key::Arrow(Direction2D::Down) => { Key::Arrow(Direction2D::Down) => {
queue.next(); queue.next();
if self.selected + 1 < self.agents.len() { self.move_down();
self.selected += 1;
self.update_selection();
}
return InputResult::Handled; return InputResult::Handled;
} }
Key::Enter => { Key::Enter => {
queue.next(); 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; return InputResult::Handled;
} }
_ => {} _ => {}
@ -64,111 +136,705 @@ impl DelegateWidget for AgentsScreen {
} }
self.get_delegate_mut().on_input(queue) 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::<ActivateEvent>(self.list_wide_id) {
self.selected = idx;
self.refresh_detail();
self.selection.set(Some(idx));
}
if let Some(&ActivateEvent(idx)) = event.get_by::<ActivateEvent>(self.list_narrow_id) {
self.selected = idx;
self.refresh_detail();
self.selection.set(Some(idx));
}
}
} }
impl AgentsScreen { 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( pub fn new(
agents: Vec<(String, String, String)>, // (id, name, description) agents: Vec<AgentSummary>,
) -> (Box<Self>, Rc<Cell<Option<usize>>>) { palette: &ChatPalette,
) -> (
Box<Self>,
Rc<Cell<Option<usize>>>,
Rc<Cell<bool>>,
Rc<Cell<Option<usize>>>,
Rc<Cell<Option<usize>>>,
Rc<Cell<Option<usize>>>,
) {
let selection = Rc::new(Cell::new(None)); 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<AgentEntry> = agents let primary = theme::to_tuie_color(palette.agent_primary);
.into_iter() let dim = theme::to_tuie_color(palette.agent_dim);
.map(|(id, name, description)| AgentEntry { id, name, description })
.collect();
let mut scroll_id = WidgetId::EMPTY; let rows = build_rows(&agents, palette);
let cards = Self::build_cards(&entries, 0); // 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<dyn Widget>,
list_wide,
]);
let wide = Pane::new()
.horizontal()
.flex(1)
.gap(2)
.children([list_col as Box<dyn Widget>, 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() .vertical()
.flex(1) .flex(1)
.gap(1); .bordered()
for card in cards { .border_style(Style::new().fg(dim).dim())
scroll_body.add_child(card); .gap(0)
} .children([
let scroll_body = scroll_body.id(&mut scroll_id); Text::new().content(" Agents ".fg(primary).bold()) as Box<dyn Widget>,
list_narrow,
]);
let narrow = Pane::new()
.vertical()
.flex(1)
.gap(1)
.children([narrow_list_col as Box<dyn Widget>, detail_narrow_pane]);
let responsive = Responsive::new(WIDE_BREAKPOINT, wide, narrow);
// ── Root ─────────────────────────────────────────────────────────
let root = Pane::new() let root = Pane::new()
.vertical() .vertical()
.flex(1) .flex(1)
.padding(Spacing::new().horizontal(1).top(1).bottom(1)) .padding(Spacing::new().horizontal(1).top(1).bottom(1))
.gap(1) .gap(0)
.children([ .children([
Text::new() Text::new().content(" Agents ".fg(primary).bold()) as Box<dyn Widget>,
.content(" Agents ".fg(Color::YELLOW).bold()), Text::new().content(
Text::new() StyledStr::new(
.content( " \u{2190}\u{2192} select · Enter talk · k kill · r restart · f pin · i inspect · Esc back",
StyledStr::new(" arrow keys select · Enter choose · Esc back") )
.fg(Color::BRIGHT_BLACK), .fg(dim),
), ) as Box<dyn Widget>,
scroll_body, responsive as Box<dyn Widget>,
]); ]);
let sel = selection.clone(); let mut this = Self {
let this = Box::new(Self {
root, root,
scroll_id, list_wide_id,
agents: entries, list_narrow_id,
detail_wide: detail_wide_ids,
detail_narrow: detail_narrow_ids,
agents,
selected: 0, selected: 0,
palette: *palette,
primary_color: primary,
inspecting: false,
selection, selection,
}); back_pressed,
(this, sel) kill_requested,
} restart_requested,
pin_requested,
fn build_cards(entries: &[AgentEntry], selected: usize) -> Vec<Box<dyn Widget>> {
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 { " " };
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<dyn Widget> = Pane::new() // Show the first agent's detail immediately.
.vertical() this.refresh_detail();
.bordered()
.border_style(Style::new().fg(border_color).dim()) let sel = this.selection.clone();
.padding(Spacing::new().horizontal(2).vertical(1)) let back = this.back_pressed.clone();
.children([ let kill = this.kill_requested.clone();
Text::new().content(content) as Box<dyn Widget>, let restart = this.restart_requested.clone();
Text::new().content( let pin = this.pin_requested.clone();
StyledStr::new(&format!(" {}", desc))
.fg(Color::BRIGHT_BLACK) (Box::new(this), sel, back, kill, restart, pin)
.italic(),
),
]);
card
})
.collect()
} }
fn update_selection(&mut self) { // ── Selection navigation ─────────────────────────────────────────────
// Rebuild all cards with new selection state, swap into scroll body.
let new_cards = Self::build_cards(&self.agents, self.selected); fn move_up(&mut self) {
if let Some(scroll) = self.root.get_widget_mut(self.scroll_id) { if self.selected > 0 {
scroll.clear(); self.selected -= 1;
for card in new_cards { self.apply_selection();
scroll.add_child(card); self.refresh_detail();
} }
} }
self.root.dirty_layout();
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();
}
}
}
// ── 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<Pane>, 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<Pane>,
name: WidgetId<Text>,
glyph: WidgetId<Text>,
description: WidgetId<Text>,
primary_badge: WidgetId<Text>,
instances: WidgetId<Text>,
uptime: WidgetId<Text>,
files: WidgetId<Text>,
pubkey: WidgetId<Text>,
activity_header: WidgetId<Text>,
activity_lines: WidgetId<Text>,
}
// ── Build helpers ──────────────────────────────────────────────────────────
/// Build one styled row per agent for the [`SelectList`].
fn build_rows(agents: &[AgentSummary], palette: &ChatPalette) -> Vec<StyledString> {
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<SelectList> {
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<Pane>, 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<dyn Widget>,
name as Box<dyn Widget>,
]);
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<AgentSummary> {
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:?}"
);
} }
} }

View file

@ -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:?}"
);
}
}

View file

@ -9,7 +9,6 @@
//! root (Pane, vertical) //! root (Pane, vertical)
//! header (Text) — "✦ Souveraine · agent_name" //! header (Text) — "✦ Souveraine · agent_name"
//! itinerary_strip (ItineraryStrip) — hidden when empty //! itinerary_strip (ItineraryStrip) — hidden when empty
//! phase_bar (PhaseBar) — hidden when idle
//! body (Pane, horizontal, flex=1) //! body (Pane, horizontal, flex=1)
//! chat_column (Pane, vertical, flex=1/3) //! chat_column (Pane, vertical, flex=1/3)
//! messages_pane (Pane, flex=1, scrollable, bordered) //! messages_pane (Pane, flex=1, scrollable, bordered)
@ -18,8 +17,9 @@
//! cockpit_pane (Pane, flex=1) //! cockpit_pane (Pane, flex=1)
//! sidebar_pane (Pane, flex=0) — ChatSidebar //! sidebar_pane (Pane, flex=0) — ChatSidebar
//! overlay (ChatOverlay) — hidden when Overlay::None //! 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) //! input_row (Pane, horizontal, bordered)
//! footer (Footer)
//! prompt "" | input (Input, flex=1) | SendButton (clickable ▸) //! prompt "" | input (Input, flex=1) | SendButton (clickable ▸)
//! ``` //! ```
@ -29,7 +29,7 @@ use tokio::sync::RwLock;
use tuie::prelude::*; use tuie::prelude::*;
use crate::core::config::ConsciousnessConfig; 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::theme;
use crate::ui::widgets::btw_pane::BtwPane; use crate::ui::widgets::btw_pane::BtwPane;
use crate::ui::widgets::chat_input; 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::phase_bar::{PhaseBar, PhaseKind};
use crate::ui::widgets::send_button::SendButton; use crate::ui::widgets::send_button::SendButton;
mod convert;
use convert::chat_message_to_msgkind;
pub struct ChatScreen { pub struct ChatScreen {
root: Box<Pane>, root: Box<Pane>,
@ -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 let Some(chat) = self.chat.as_ref() {
if !matches!(chat.overlay, Overlay::ConversationPicker { .. }) { if !matches!(chat.overlay, Overlay::ConversationPicker { .. }) {
self.update_slash_overlay(); self.update_slash_overlay();
} }
} }
// Forward remaining input to root pane (contains the Input widget). result
self.get_delegate_mut().on_input(queue)
} }
} }
@ -395,9 +403,9 @@ impl ChatScreen {
.children([ .children([
header as Box<dyn Widget>, header as Box<dyn Widget>,
itinerary as Box<dyn Widget>, itinerary as Box<dyn Widget>,
phase_bar as Box<dyn Widget>,
body, body,
overlay as Box<dyn Widget>, overlay as Box<dyn Widget>,
phase_bar as Box<dyn Widget>,
input_row, input_row,
footer as Box<dyn Widget>, footer as Box<dyn Widget>,
]); ]);
@ -482,15 +490,23 @@ impl ChatScreen {
let text = self.get_input_text(); let text = self.get_input_text();
if text.starts_with('/') { if text.starts_with('/') {
let prefix = text.trim(); 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() .iter()
.filter(|cmd| cmd.name.starts_with(prefix) || prefix.len() <= 1) .filter(|cmd| cmd.name.starts_with(prefix) || prefix.len() <= 1)
.map(|cmd| cmd)
.collect(); .collect();
if !match_refs.is_empty() { 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) { 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; return;
} }
@ -498,6 +514,14 @@ impl ChatScreen {
if let Some(o) = self.root.get_widget_mut(self.overlay_id) { if let Some(o) = self.root.get_widget_mut(self.overlay_id) {
o.hide(); 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) { fn dismiss_overlay(&mut self) {
@ -720,6 +744,22 @@ impl ChatScreen {
0u32, // memory_commits placeholder 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 { PollSnapshot {
msgs, msgs,
needs_scroll, needs_scroll,
@ -741,6 +781,9 @@ impl ChatScreen {
conversation_id, conversation_id,
tools_expanded, tools_expanded,
sidebar_data, 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_subconscious(cockpit_log, &self.palette);
c.set_active(active); c.set_active(active);
c.set_pressure(snapshot.pressure, &self.palette); 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 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)); 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); 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 0
}; };
s.update(&status, snapshot.pressure, n1, &self.palette); 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, conversation_id: String,
tools_expanded: bool, tools_expanded: bool,
sidebar_data: Option<(String, String, u8, usize, u32)>, sidebar_data: Option<(String, String, u8, usize, u32)>,
health_data: crate::ui::widgets::chat_sidebar::HealthData,
subconscious_stream_lines: Vec<String>,
subconscious_live_line: String,
} }
// ── Tests ──────────────────────────────────────────────────────────────────── // ── Tests ────────────────────────────────────────────────────────────────────
@ -1013,15 +1072,12 @@ struct PollSnapshot {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::ui::chat::{ChatMessage, ChatPalette, ToolResultBlock}; use crate::ui::chat::ChatPalette;
use crate::ui::widgets::message_list::MsgKind;
use std::cell::RefCell;
use std::time::Instant;
use tuie::input::chord::Chord; use tuie::input::chord::Chord;
use tuie::input::key::Key; use tuie::input::key::Key;
use tuie::input::modifiers::{Modifier, Modifiers}; use tuie::input::modifiers::{Modifier, Modifiers};
use tuie::input::trigger::Trigger; use tuie::input::trigger::Trigger;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
// ── Input routing helpers ───────────────────────────────────────────── // ── Input routing helpers ─────────────────────────────────────────────
@ -1037,10 +1093,10 @@ mod tests {
/// Create a chat screen, render it, and focus its input so typed keys are /// 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). /// routed to the `Input` leaf (the focus-chain forward walk needs a target).
fn focused_screen() -> (Box<ChatScreen>, TestTerminal) { fn focused_screen() -> (Box<ChatScreen>, Emulator) {
let config = Arc::new(RwLock::new(ConsciousnessConfig::default())); let config = Arc::new(RwLock::new(ConsciousnessConfig::default()));
let mut screen = ChatScreen::new(config, ChatPalette::default(), "test-agent".into()); 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(); screen.focus_input();
// Force a dirty frame so `layout_and_render` runs `repair_selection`, // Force a dirty frame so `layout_and_render` runs `repair_selection`,
// which is what actually applies a pending focus request. Without a // which is what actually applies a pending focus request. Without a
@ -1124,7 +1180,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut screen = ChatScreen::new(config, palette, "test-agent".into()); 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(); let rendered = term.get_snapshot_text();
assert!( 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 ───────────────────────────────────────────────────── // ── Toggle tests ─────────────────────────────────────────────────────
#[test] #[test]
@ -1398,8 +1221,8 @@ mod tests {
term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]); term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]);
let out = term.get_snapshot_text(); let out = term.get_snapshot_text();
assert!(out.contains("thinking") && out.contains("subconscious"), assert!(out.contains("thinking") && out.contains("subconscious") && out.contains("inner voice"),
"both cockpit panes should render\n{out}"); "all three cockpit panes should render\n{out}");
// When the panes fill the ~36-row body, the right border glyph appears // 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 // on many rows. When collapsed (the bug) it was ~4. Use a conservative
// threshold well above the collapsed count. // threshold well above the collapsed count.
@ -1456,14 +1279,14 @@ mod tests {
fn cockpit_renders_on_screen_when_toggled() { fn cockpit_renders_on_screen_when_toggled() {
let config = Arc::new(RwLock::new(ConsciousnessConfig::default())); let config = Arc::new(RwLock::new(ConsciousnessConfig::default()));
let mut screen = ChatScreen::new(config, ChatPalette::default(), "test-agent".into()); 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(); tuie::dirty_paint();
term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]); term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]);
// Hidden: the cockpit pane titles must NOT be on screen. // Hidden: the cockpit pane titles must NOT be on screen.
let before = term.get_snapshot_text(); let before = term.get_snapshot_text();
assert!( 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:?}" "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) { if let Some(c) = screen.root.get_widget_mut(screen.cockpit_id) {
c.set_thinking(&["weighing the request".to_string()], &ChatPalette::default()); c.set_thinking(&["weighing the request".to_string()], &ChatPalette::default());
c.set_subconscious(&[], &ChatPalette::default()); c.set_subconscious(&[], &ChatPalette::default());
c.refresh_inner_voice();
} }
tuie::dirty_paint(); tuie::dirty_paint();
term.update(&mut *screen, &[RuntimeEvent::Resize(Vec2::new(120, 40))]); 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}]"),
},
}
}

View file

@ -1,9 +1,9 @@
//! Cron schedule editor screen. //! Cron schedule editor screen.
//! //!
//! Shows configured cron schedules as a scrollable list with expression, //! Schedules render as bordered cards in a [`SelectList`] — expression,
//! description, enabled/disabled status, and last run time. Arrow keys //! description, enabled/disabled badge, last run. Arrow keys navigate; Enter or
//! navigate, Enter toggles enable/disable, 'n' adds a new schedule //! a single click toggles enable/disable; 'n' adds a new schedule (placeholder),
//! (placeholder), 'd' deletes the selected schedule, Esc returns to Welcome. //! 'd' deletes the selected one; Esc returns to Welcome (handled by `TuieApp`).
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -13,18 +13,17 @@ use tuie::prelude::*;
use crate::core::nervous::cron::{self, ScheduleEntry, ScheduleKind, ScheduleState}; use crate::core::nervous::cron::{self, ScheduleEntry, ScheduleKind, ScheduleState};
use crate::ui::chat::ChatPalette; use crate::ui::chat::ChatPalette;
use crate::ui::theme; use crate::ui::theme;
use crate::ui::widgets::select_list::{ActivateEvent, SelectList};
pub struct CronScreen { pub struct CronScreen {
root: Box<Pane>, root: Box<Pane>,
/// WidgetId for the scroll body pane — replaced on rebuild. list_id: WidgetId<SelectList>,
list_id: WidgetId<Pane>,
/// Schedule entries loaded from the schedules directory. /// Schedule entries loaded from the schedules directory.
entries: Vec<ScheduleEntry>, entries: Vec<ScheduleEntry>,
/// Live run-state keyed by entry name. /// Live run-state keyed by entry name.
run_state: HashMap<String, ScheduleState>, run_state: HashMap<String, ScheduleState>,
selected: usize,
primary: Color,
dim: Color, dim: Color,
#[allow(dead_code)] // retained for persistence work (writing edits back)
schedules_dir: PathBuf, schedules_dir: PathBuf,
} }
@ -44,23 +43,23 @@ impl DelegateWidget for CronScreen {
match key { match key {
Key::Arrow(Direction2D::Up) => { Key::Arrow(Direction2D::Up) => {
queue.next(); queue.next();
if self.selected > 0 { if let Some(list) = self.root.get_widget_mut(self.list_id) {
self.selected -= 1; list.move_up();
self.rebuild_list();
} }
return InputResult::Handled; return InputResult::Handled;
} }
Key::Arrow(Direction2D::Down) => { Key::Arrow(Direction2D::Down) => {
queue.next(); queue.next();
if self.selected + 1 < self.entries.len() { if let Some(list) = self.root.get_widget_mut(self.list_id) {
self.selected += 1; list.move_down();
self.rebuild_list();
} }
return InputResult::Handled; return InputResult::Handled;
} }
Key::Enter => { Key::Enter => {
queue.next(); queue.next();
self.toggle_selected(); if let Some(list) = self.root.get_widget_mut(self.list_id) {
list.activate_selected();
}
return InputResult::Handled; return InputResult::Handled;
} }
Key::Char('n') => { Key::Char('n') => {
@ -79,6 +78,13 @@ impl DelegateWidget for CronScreen {
} }
self.get_delegate_mut().on_input(queue) 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::<ActivateEvent>(self.list_id) {
self.toggle(idx);
}
}
} }
impl CronScreen { impl CronScreen {
@ -91,7 +97,7 @@ impl CronScreen {
// Load persisted run-state (.state.json). // Load persisted run-state (.state.json).
let state_file = schedules_dir.join(".state.json"); let state_file = schedules_dir.join(".state.json");
let run_state = if state_file.exists() { let run_state: HashMap<String, ScheduleState> = if state_file.exists() {
std::fs::read_to_string(&state_file) std::fs::read_to_string(&state_file)
.ok() .ok()
.and_then(|data| serde_json::from_str(&data).ok()) .and_then(|data| serde_json::from_str(&data).ok())
@ -100,8 +106,11 @@ impl CronScreen {
HashMap::new() HashMap::new()
}; };
let mut list_id = WidgetId::EMPTY; let list = SelectList::new()
let scroll_body = build_scroll_body(&entries, &run_state, 0, primary, dim, &mut list_id); .colors(primary, Color::BRIGHT_BLACK)
.bordered()
.items(build_rows(&entries, &run_state, dim));
let list_id = list.get_id();
let root = Pane::new() let root = Pane::new()
.vertical() .vertical()
@ -109,14 +118,13 @@ impl CronScreen {
.padding(Spacing::new().horizontal(1).top(1).bottom(1)) .padding(Spacing::new().horizontal(1).top(1).bottom(1))
.gap(1) .gap(1)
.children([ .children([
Text::new().content(StyledStr::new(" Cron Schedules ").fg(primary).bold())
as Box<dyn Widget>,
Text::new().content( Text::new().content(
StyledStr::new(" Cron Schedules ").fg(primary).bold(), StyledStr::new(" arrow keys select \u{b7} Enter or click toggle \u{b7} n add \u{b7} d delete \u{b7} Esc back")
),
Text::new().content(
StyledStr::new(" arrow keys select · Enter toggle · n add · d delete · Esc back")
.fg(Color::BRIGHT_BLACK), .fg(Color::BRIGHT_BLACK),
), ),
scroll_body, list,
footer_hint(dim), footer_hint(dim),
]); ]);
@ -125,46 +133,29 @@ impl CronScreen {
list_id, list_id,
entries, entries,
run_state, run_state,
selected: 0,
primary,
dim, dim,
schedules_dir, schedules_dir,
}) })
} }
/// Rebuild the scroll body when selection or entries change. /// Push the rebuilt rows into the list, holding selection at `keep`.
fn rebuild_list(&mut self) { fn refresh(&mut self, keep: usize) {
let cards = build_cards(&self.entries, &self.run_state, self.selected, self.primary, self.dim); let rows = build_rows(&self.entries, &self.run_state, self.dim);
if let Some(scroll) = self.root.get_widget_mut(self.list_id) { if let Some(list) = self.root.get_widget_mut(self.list_id) {
scroll.clear(); list.set_items(rows);
if cards.is_empty() { list.select(keep.min(self.entries.len().saturating_sub(1)));
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);
} }
} }
}
self.root.dirty_layout();
}
/// Toggle enabled/disabled on the selected entry. /// Toggle enabled/disabled on a specific entry (activation target).
fn toggle_selected(&mut self) { fn toggle(&mut self, idx: usize) {
if let Some(entry) = self.entries.get_mut(self.selected) { if let Some(entry) = self.entries.get_mut(idx) {
entry.enabled = !entry.enabled; 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) { fn add_placeholder(&mut self) {
let count = self.entries.len() + 1; let count = self.entries.len() + 1;
let entry = ScheduleEntry { let entry = ScheduleEntry {
@ -178,8 +169,8 @@ impl CronScreen {
prompt: String::new(), prompt: String::new(),
}; };
self.entries.push(entry); self.entries.push(entry);
self.selected = self.entries.len().saturating_sub(1); let last = self.entries.len().saturating_sub(1);
self.rebuild_list(); self.refresh(last);
} }
/// Delete the selected entry from the in-memory list. /// Delete the selected entry from the in-memory list.
@ -187,65 +178,30 @@ impl CronScreen {
if self.entries.is_empty() { if self.entries.is_empty() {
return; 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(); let name = self.entries[idx].name.clone();
self.entries.remove(idx); self.entries.remove(idx);
self.run_state.remove(&name); self.run_state.remove(&name);
if self.selected >= self.entries.len() { self.refresh(idx);
self.selected = self.entries.len().saturating_sub(1);
}
self.rebuild_list();
} }
} }
// ── Builders ───────────────────────────────────────────────────────────────── // ── Builders ─────────────────────────────────────────────────────────────────
/// Build the scrollable body pane containing all schedule cards. /// Build one styled row per schedule entry. Selection prefix and border are
fn build_scroll_body( /// owned by [`SelectList`]; this is the content only.
fn build_rows(
entries: &[ScheduleEntry], entries: &[ScheduleEntry],
run_state: &HashMap<String, ScheduleState>, run_state: &HashMap<String, ScheduleState>,
selected: usize,
primary: Color,
dim: Color, dim: Color,
list_id: &mut WidgetId<Pane>, ) -> Vec<StyledString> {
) -> Box<Pane> {
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<String, ScheduleState>,
selected: usize,
primary: Color,
dim: Color,
) -> Vec<Box<dyn Widget>> {
entries entries
.iter() .iter()
.enumerate() .map(|entry| {
.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 { " " };
let (enabled_marker, enabled_color) = if entry.enabled { let (enabled_marker, enabled_color) = if entry.enabled {
("enabled", Color::GREEN) ("enabled", Color::GREEN)
} else { } else {
@ -265,47 +221,23 @@ fn build_cards(
.unwrap_or_else(|| "never".to_string()); .unwrap_or_else(|| "never".to_string());
let fire_count = state.map(|s| s.fire_count).unwrap_or(0); let fire_count = state.map(|s| s.fire_count).unwrap_or(0);
let name_color = if is_selected { primary } else { Color::Foreground }; // Line 1: enabled badge, name, schedule, kind.
// Build the card content: two lines of styled text.
let mut content = StyledString::new(); let mut content = StyledString::new();
content.push_span(StyledStr::new(&format!("{enabled_marker} ")).fg(enabled_color));
// Line 1: marker, enabled badge, name, schedule, kind content.push_span(StyledStr::new(&entry.name).bold());
content.push_span( content.push_span(StyledStr::new(&format!(" {}", entry.schedule)).fg(dim));
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( content.push_span(
StyledStr::new(&format!(" ({kind_label})")) StyledStr::new(&format!(" ({kind_label})"))
.fg(Color::BRIGHT_BLACK) .fg(Color::BRIGHT_BLACK)
.italic(), .italic(),
); );
// Line 2: last run time, fire count.
// Line 2: last run time, fire count
content.push_str("\n"); content.push_str("\n");
content.push_span( content.push_span(
StyledStr::new(&format!( StyledStr::new(&format!("last run: {last_run} \u{b7} fired {fire_count} times"))
" last run: {last_run} · fired {fire_count} times"
))
.fg(dim), .fg(dim),
); );
content
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<dyn Widget>
}) })
.collect() .collect()
} }
@ -313,7 +245,7 @@ fn build_cards(
/// Footer with keybinding hints. /// Footer with keybinding hints.
fn footer_hint(dim: Color) -> Box<Text> { fn footer_hint(dim: Color) -> Box<Text> {
Text::new().content( 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), .fg(dim),
) )
} }
@ -322,7 +254,7 @@ fn footer_hint(dim: Color) -> Box<Text> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
use super::*; use super::*;
use crate::ui::chat::ChatPalette; use crate::ui::chat::ChatPalette;
@ -333,7 +265,7 @@ mod tests {
let dir = std::path::PathBuf::from("/tmp/test_cron_header"); let dir = std::path::PathBuf::from("/tmp/test_cron_header");
let _ = std::fs::create_dir_all(&dir); let _ = std::fs::create_dir_all(&dir);
let mut screen = CronScreen::new(dir, &palette); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("Cron") || rendered.contains("Schedule"), rendered.contains("Cron") || rendered.contains("Schedule"),
@ -342,16 +274,16 @@ mod tests {
} }
#[test] #[test]
fn cron_screen_empty_state() { fn cron_screen_shows_add_hint() {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let dir = std::path::PathBuf::from("/tmp/test_cron_empty"); let dir = std::path::PathBuf::from("/tmp/test_cron_empty");
let _ = std::fs::create_dir_all(&dir); let _ = std::fs::create_dir_all(&dir);
let mut screen = CronScreen::new(dir, &palette); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("n") || rendered.contains("add"), rendered.contains("n") || rendered.contains("add"),
"expected 'n' or 'add' hint in empty state, got: {rendered:?}" "expected 'n' or 'add' hint, got: {rendered:?}"
); );
} }
} }

View file

@ -1,7 +1,8 @@
//! Agent manager screen — scrollable list of agent processes with status info. //! 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 //! Each known agent is a row with name, instance count, status, uptime %, and
//! file count. Arrow keys navigate, Enter selects, k=kill, r=restart, Esc=back. //! 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). //! 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::chat::ChatPalette;
use crate::ui::theme; use crate::ui::theme;
use crate::ui::widgets::select_list::{ActivateEvent, SelectList};
// ── Agent process info ─────────────────────────────────────────────────────────── // ── Agent process info ───────────────────────────────────────────────────────────
@ -37,13 +39,12 @@ pub struct AgentProcessInfo {
pub struct ManagerScreen { pub struct ManagerScreen {
root: Box<Pane>, root: Box<Pane>,
/// WidgetId for the scroll body — rebuilt on selection or data change. list_id: WidgetId<SelectList>,
scroll_id: WidgetId<Pane>,
/// All known agents. /// All known agents.
agents: Vec<AgentProcessInfo>, agents: Vec<AgentProcessInfo>,
/// Currently highlighted row index. /// Shadow of the list's selection, kept current for k/r and selected_agent.
selected: usize, 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<Cell<Option<usize>>>, pub selection: Rc<Cell<Option<usize>>>,
/// Shared flag — set to true when Esc is pressed (caller pops this screen). /// Shared flag — set to true when Esc is pressed (caller pops this screen).
pub back_pressed: Rc<Cell<bool>>, pub back_pressed: Rc<Cell<bool>>,
@ -69,24 +70,26 @@ impl DelegateWidget for ManagerScreen {
match key { match key {
Key::Arrow(Direction2D::Up) => { Key::Arrow(Direction2D::Up) => {
queue.next(); queue.next();
if self.selected > 0 { if let Some(list) = self.root.get_widget_mut(self.list_id) {
self.selected -= 1; list.move_up();
self.rebuild_rows(); self.selected = list.selected_index();
} }
return InputResult::Handled; return InputResult::Handled;
} }
Key::Arrow(Direction2D::Down) => { Key::Arrow(Direction2D::Down) => {
queue.next(); queue.next();
if self.selected + 1 < self.agents.len() { if let Some(list) = self.root.get_widget_mut(self.list_id) {
self.selected += 1; list.move_down();
self.rebuild_rows(); self.selected = list.selected_index();
} }
return InputResult::Handled; return InputResult::Handled;
} }
Key::Enter => { Key::Enter => {
queue.next(); queue.next();
if !self.agents.is_empty() { 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; return InputResult::Handled;
} }
@ -115,6 +118,15 @@ impl DelegateWidget for ManagerScreen {
} }
self.get_delegate_mut().on_input(queue) self.get_delegate_mut().on_input(queue)
} }
fn after_on_event(&mut self, event: &mut WidgetEvent) {
if let Some(&ActivateEvent(idx)) = event.get_by::<ActivateEvent>(self.list_id) {
if !self.agents.is_empty() {
self.selected = idx;
self.selection.set(Some(idx));
}
}
}
} }
impl ManagerScreen { impl ManagerScreen {
@ -129,34 +141,19 @@ impl ManagerScreen {
let dim = theme::to_tuie_color(palette.agent_dim); let dim = theme::to_tuie_color(palette.agent_dim);
let primary = theme::to_tuie_color(palette.agent_primary); let primary = theme::to_tuie_color(palette.agent_primary);
// Header row. let header = Text::new().content(StyledStr::new(" Agent Manager ").fg(primary).bold());
let header = Text::new()
.content(
StyledStr::new(" Agent Manager ")
.fg(primary)
.bold(),
);
// Column guide.
let col_guide = Text::new().content( let col_guide = Text::new().content(
StyledStr::new(" name pid status uptime memory") StyledStr::new(" name pid status uptime memory").fg(dim),
.fg(dim),
); );
// Scroll body — starts empty, rebuilt by refresh(). // Empty to start — rebuilt by refresh().
let mut scroll_id = WidgetId::EMPTY; let list = SelectList::new()
let scroll_body = Pane::new() .colors(primary, dim)
.vertical() .items(Vec::new());
.flex(1) let list_id = list.get_id();
.gap(0)
.y_scroll(Scrollbar::AutoHide)
.id(&mut scroll_id);
// Key bindings footer. let footer = Text::new()
let footer = Text::new().content( .content(StyledStr::new(" Enter/click=select k=kill r=restart Esc=back").fg(dim));
StyledStr::new(" Enter=select k=kill r=restart Esc=back")
.fg(dim),
);
let root = Pane::new() let root = Pane::new()
.vertical() .vertical()
@ -164,12 +161,9 @@ impl ManagerScreen {
.padding(Spacing::new().horizontal(1).top(1).bottom(1)) .padding(Spacing::new().horizontal(1).top(1).bottom(1))
.gap(0) .gap(0)
.children([ .children([
header, header as Box<dyn Widget>,
col_guide, col_guide,
Pane::new().vertical().flex(0).children([ list,
Text::new().content(StyledStr::new("").fg(Color::BLACK)),
]),
scroll_body,
footer, footer,
]); ]);
@ -179,7 +173,7 @@ impl ManagerScreen {
let restart = restart_requested.clone(); let restart = restart_requested.clone();
let this = Box::new(Self { let this = Box::new(Self {
root, root,
scroll_id, list_id,
agents: Vec::new(), agents: Vec::new(),
selected: 0, selected: 0,
selection, selection,
@ -194,127 +188,51 @@ impl ManagerScreen {
/// Replace the agent list and rebuild rows. /// Replace the agent list and rebuild rows.
pub fn refresh(&mut self, agents: Vec<AgentProcessInfo>, palette: &ChatPalette) { pub fn refresh(&mut self, agents: Vec<AgentProcessInfo>, palette: &ChatPalette) {
self.agents = agents; self.agents = agents;
self.selected = self self.selected = self.selected.min(self.agents.len().saturating_sub(1));
.selected let rows = build_rows(&self.agents, palette);
.min(self.agents.len().saturating_sub(1)); if let Some(list) = self.root.get_widget_mut(self.list_id) {
self.rebuild_rows_with_palette(palette); list.set_items(rows);
list.select(self.selected);
}
} }
/// Return the currently selected agent, if any. /// Return the currently selected agent, if any.
#[allow(dead_code)] // public accessor; not yet consumed by TuieApp
pub fn selected_agent(&self) -> Option<&AgentProcessInfo> { pub fn selected_agent(&self) -> Option<&AgentProcessInfo> {
self.agents.get(self.selected) 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<StyledString> {
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);
}
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<Box<dyn Widget>> {
let dim = theme::to_tuie_color(palette.agent_dim); let dim = theme::to_tuie_color(palette.agent_dim);
let primary = theme::to_tuie_color(palette.agent_primary); let green = Color::Rgb(120, 220, 160);
let fg = Color::WHITE;
agents agents
.iter() .iter()
.enumerate() .map(|agent| {
.map(|(i, agent)| { let status = if agent.instance_count > 0 { "active" } else { "idle " };
let is_selected = i == selected; let status_color = if agent.instance_count > 0 { green } else { dim };
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(); let mut content = StyledString::new();
// Cursor prefix. content.push_span(StyledStr::new(&format!("{:<16}", truncate_str(&agent.name, 16))).bold());
content.push_span(StyledStr::new(cursor).fg(row_dim)); content.push_span(StyledStr::new(" ").fg(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 { let pid_str = if agent.instance_count > 0 {
format!("{:>5}", agent.instance_count) format!("{:>5}", agent.instance_count)
} else { } else {
format!("{:>5}", "") format!("{:>5}", "\u{2014}")
}; };
content.push_span(StyledStr::new(&pid_str).fg(row_dim)); content.push_span(StyledStr::new(&pid_str).fg(dim));
// Separator. content.push_span(StyledStr::new(" ").fg(dim));
content.push_span(StyledStr::new(" ").fg(row_dim));
// Status.
content.push_span(StyledStr::new(&format!("{:<7}", status)).fg(status_color)); content.push_span(StyledStr::new(&format!("{:<7}", status)).fg(status_color));
// Separator. content.push_span(StyledStr::new(" ").fg(dim));
content.push_span(StyledStr::new(" ").fg(row_dim));
// Uptime %.
let uptime_str = format!("{:>3}%", agent.uptime_pct); let uptime_str = format!("{:>3}%", agent.uptime_pct);
content.push_span( content.push_span(StyledStr::new(&uptime_str).fg(if agent.uptime_pct > 0 { green } else { dim }));
StyledStr::new(&uptime_str).fg(if agent.uptime_pct > 0 { content.push_span(StyledStr::new(" ").fg(dim));
Color::Rgb(120, 220, 160) content.push_span(StyledStr::new(&format!("{:>5} files", agent.memory_count)).fg(dim));
} else { content
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<dyn Widget> = Pane::new()
.horizontal()
.padding(Spacing::new().horizontal(0).vertical(0))
.children([
Text::new().content(content),
])
.style(row_style);
row
}) })
.collect() .collect()
}
} }
/// Truncate a string to at most `max_len` characters, appending "…" if cut. /// 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() s.to_string()
} else { } else {
let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); 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)] #[cfg(test)]
mod tests { mod tests {
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
use super::*; use super::*;
use crate::ui::chat::ChatPalette; use crate::ui::chat::ChatPalette;
@ -340,7 +258,7 @@ mod tests {
fn manager_screen_renders_header() { fn manager_screen_renders_header() {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("Agent Manager"), rendered.contains("Agent Manager"),
@ -352,10 +270,8 @@ mod tests {
fn manager_screen_empty_state() { fn manager_screen_empty_state() {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let (mut screen, _selection, _back, _kill, _restart) = ManagerScreen::new(&palette); 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(); 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!( assert!(
!rendered.trim().is_empty(), !rendered.trim().is_empty(),
"empty manager screen should still render column guide and footer" "empty manager screen should still render column guide and footer"
@ -381,7 +297,7 @@ mod tests {
pubkey_prefix: "abcdef01".into(), pubkey_prefix: "abcdef01".into(),
}]; }];
screen.refresh(agents, &palette); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("TestBot"), rendered.contains("TestBot"),

View file

@ -11,4 +11,3 @@ pub mod welcome;
pub mod presence; pub mod presence;
pub mod cron; pub mod cron;
pub mod agents; pub mod agents;
pub mod manager;

View file

@ -587,7 +587,7 @@ fn posture_color(posture: Posture, palette: &ChatPalette) -> Color {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
use super::*; use super::*;
use crate::ui::atmosphere::Atmosphere; use crate::ui::atmosphere::Atmosphere;
@ -599,7 +599,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let (mut screen, _should_exit) = let (mut screen, _should_exit) =
PresenceScreen::new(&palette, Atmosphere::Default, Posture::Idle); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("Presence"), rendered.contains("Presence"),
@ -612,7 +612,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let (mut screen, _should_exit) = let (mut screen, _should_exit) =
PresenceScreen::new(&palette, Atmosphere::Default, Posture::Idle); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("R") || rendered.contains("Record") || rendered.contains("Esc"), rendered.contains("R") || rendered.contains("Record") || rendered.contains("Esc"),

View file

@ -35,7 +35,7 @@ use crate::ui::chat::ChatPalette;
pub fn is_model_loc(loc: FieldLoc) -> bool { pub fn is_model_loc(loc: FieldLoc) -> bool {
matches!( matches!(
loc, loc,
FieldLoc::BfPrimaryModel FieldLoc::PvPrimaryModel
| FieldLoc::AgModel | FieldLoc::AgModel
| FieldLoc::ScModel | FieldLoc::ScModel
| FieldLoc::RfModel | FieldLoc::RfModel

View file

@ -220,14 +220,14 @@ impl DelegateWidget for SettingsScreen {
Key::Arrow(Direction2D::Right) => { Key::Arrow(Direction2D::Right) => {
queue.next(); queue.next();
self.process_action(SettingsAction::CycleEnum( self.process_action(SettingsAction::CycleEnum(
self.selected_field().unwrap_or(FieldLoc::BfBaseUrl), self.selected_field().unwrap_or(FieldLoc::PvName),
1, 1,
)); ));
InputResult::Handled InputResult::Handled
} }
Key::Arrow(Direction2D::Left) if self.focus_col == FocusCol::Fields => { Key::Arrow(Direction2D::Left) if self.focus_col == FocusCol::Fields => {
queue.next(); 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) { if !self.try_cycle_enum(loc, -1) {
self.process_action(SettingsAction::FocusCategories); self.process_action(SettingsAction::FocusCategories);
} }
@ -537,7 +537,7 @@ impl SettingsScreen {
EditableValue::Text(_) | EditableValue::Secret(_) | EditableValue::OptionalText(_) => { EditableValue::Text(_) | EditableValue::Secret(_) | EditableValue::OptionalText(_) => {
// Model fields open the model picker; all others open the text editor. // Model fields open the model picker; all others open the text editor.
if matches!(loc, if matches!(loc,
FieldLoc::BfPrimaryModel | FieldLoc::PvPrimaryModel |
FieldLoc::AgModel | FieldLoc::AgModel |
FieldLoc::ScModel | FieldLoc::ScModel |
FieldLoc::RfModel | FieldLoc::RfModel |
@ -565,6 +565,9 @@ impl SettingsScreen {
if next != self.cat_idx as i32 { if next != self.cat_idx as i32 {
self.cat_idx = next as usize; self.cat_idx = next as usize;
self.field_idx = 0; self.field_idx = 0;
if Category::all().get(self.cat_idx) == Some(&Category::Providers) {
self.view.ensure_provider_selected();
}
self.rebuild(); self.rebuild();
} }
} }
@ -646,6 +649,10 @@ impl SettingsScreen {
} }
} }
SettingsAction::OpenModelPicker(loc) => { SettingsAction::OpenModelPicker(loc) => {
if self.view.available_models.is_empty() {
self.enqueue(SettingsAction::FetchModels);
return;
}
let models = self.view.available_models.clone(); let models = self.view.available_models.clone();
let current = self.get_current_model_value(loc); let current = self.get_current_model_value(loc);
let picker = ModelPicker::new(&models, &current, &self.palette) let picker = ModelPicker::new(&models, &current, &self.palette)
@ -692,6 +699,22 @@ impl SettingsScreen {
Ok(()) => { Ok(()) => {
self.save_signal.set(true); 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) => { Err(e) => {
// TODO: show error in footer // TODO: show error in footer
tracing::warn!("settings save failed: {e}"); 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 { fn get_current_model_value(&self, loc: FieldLoc) -> String {
let fields = self.view.fields_for_category(self.view.selected_category()); let fields = self.view.fields_for_category(self.view.selected_category());
fields.iter() fields.iter()
@ -1029,15 +1060,26 @@ impl SettingsScreen {
// into the tree; both layouts contribute (their widget ids are unique). // into the tree; both layouts contribute (their widget ids are unique).
self.field_map.clear(); 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()); 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) { if let Some(pane) = self.root.get_widget_mut(self.wide_field_grid_id) {
pane.clear(); pane.clear();
pane.add_child(wide_grid.widget() as Box<dyn Widget>); pane.add_child(wide_grid.widget() as Box<dyn Widget>);
} }
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()); 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) { if let Some(pane) = self.root.get_widget_mut(self.narrow_field_grid_id) {
pane.clear(); pane.clear();
pane.add_child(narrow_grid.widget() as Box<dyn Widget>); pane.add_child(narrow_grid.widget() as Box<dyn Widget>);
@ -1096,7 +1138,7 @@ mod tests {
use super::*; use super::*;
use tuie::input::chord::Chord; use tuie::input::chord::Chord;
use tuie::input::modifiers::Modifiers; use tuie::input::modifiers::Modifiers;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
fn screen() -> Box<SettingsScreen> { fn screen() -> Box<SettingsScreen> {
let config = crate::core::config::ConsciousnessConfig::default(); let config = crate::core::config::ConsciousnessConfig::default();
@ -1115,7 +1157,7 @@ mod tests {
#[test] #[test]
fn settings_renders_without_panic() { fn settings_renders_without_panic() {
let mut s = screen(); 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"); 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. /// 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 /// Uses the narrow (<80 col) layout because the wide layout's accordion
/// expands via a scheduler-driven animation that doesn't advance under /// 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.) /// (Regression: rebuild() used to discard the new fields entirely.)
#[test] #[test]
fn category_change_swaps_field_grid() { fn category_change_swaps_field_grid() {
let mut s = screen(); 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!( assert!(
!term.get_snapshot_text().contains("virtual key"), !term.get_snapshot_text().contains("virtual key"),
"Agent category should not show Bifrost's virtual key field" "Agent category should not show Bifrost's virtual key field"
@ -1151,7 +1193,7 @@ mod tests {
#[test] #[test]
fn enum_renders_as_radio_group() { fn enum_renders_as_radio_group() {
let mut s = screen(); 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). // Agent(0) -> Inference(1) -> Bifrost(2) -> Subconscious(3).
term.update(&mut *s, &[down(), down(), down()]); term.update(&mut *s, &[down(), down(), down()]);
assert_eq!(s.cat_idx, 3, "expected to land on Subconscious"); assert_eq!(s.cat_idx, 3, "expected to land on Subconscious");
@ -1166,7 +1208,7 @@ mod tests {
#[test] #[test]
fn down_advances_category_selection() { fn down_advances_category_selection() {
let mut s = screen(); 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); assert_eq!(s.cat_idx, 0);
term.update(&mut *s, &[down()]); term.update(&mut *s, &[down()]);
assert_eq!(s.cat_idx, 1, "Down should move to the next category"); assert_eq!(s.cat_idx, 1, "Down should move to the next category");

View file

@ -25,9 +25,10 @@ use crate::ui::chat::ChatPalette;
use crate::ui::theme; use crate::ui::theme;
use crate::ui::tuie_app::AgentStatus; use crate::ui::tuie_app::AgentStatus;
use crate::ui::widgets::brand_title::BrandTitle; 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::portrait::Portrait;
use crate::ui::widgets::select_list::{ActivateEvent, SelectList};
use crate::ui::widgets::responsive::Responsive; 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. /// Terminal width (in columns) at or above which the wide layout is used.
pub const WIDE_BREAKPOINT: u16 = 100; pub const WIDE_BREAKPOINT: u16 = 100;
@ -35,9 +36,9 @@ pub const WIDE_BREAKPOINT: u16 = 100;
pub struct WelcomeScreen { pub struct WelcomeScreen {
root: Box<Pane>, root: Box<Pane>,
/// Menu in the wide arrangement. /// Menu in the wide arrangement.
menu_wide_id: WidgetId<MenuList>, menu_wide_id: WidgetId<SelectList>,
/// Menu in the stacked arrangement. /// Menu in the stacked arrangement.
menu_narrow_id: WidgetId<MenuList>, menu_narrow_id: WidgetId<SelectList>,
title_id: WidgetId<BrandTitle>, title_id: WidgetId<BrandTitle>,
/// Selected menu index, mirrored into both menus. /// Selected menu index, mirrored into both menus.
selected: usize, selected: usize,
@ -74,7 +75,7 @@ impl DelegateWidget for WelcomeScreen {
} }
Key::Enter => { Key::Enter => {
queue.next(); queue.next();
self.menu_action.set(Some(self.selected)); self.choose(self.selected);
return InputResult::Handled; return InputResult::Handled;
} }
_ => {} _ => {}
@ -83,6 +84,17 @@ impl DelegateWidget for WelcomeScreen {
} }
self.get_delegate_mut().on_input(queue) 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::<ActivateEvent>(id) {
self.selected = idx;
self.apply_selection();
self.choose(idx);
}
}
}
} }
impl WelcomeScreen { 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 { pub fn selected_index(&self) -> usize {
self.selected self.selected
} }
pub fn selected_menu_label(&self) -> Option<String> { pub fn selected_menu_label(&self) -> Option<String> {
menu_items().get(self.selected).map(|i| i.label.clone()) menu_items().get(self.selected).map(|i| i.label.to_string())
} }
} }
// ── Breathing colour ───────────────────────────────────────────────────────── // ── Breathing colour ─────────────────────────────────────────────────────────
//
/// Gently pulse `base`'s brightness with a slow sine — palette-agnostic, so it // `breathe_color` now lives in `crate::ui::widgets::stats` so the unified agent
/// works whatever atmosphere colour the agent currently wears. // screen can share it.
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,
)
}
// ── Sub-widget builders ────────────────────────────────────────────────────── // ── Sub-widget builders ──────────────────────────────────────────────────────
/// One dashboard menu entry.
struct MenuItem {
label: &'static str,
description: &'static str,
available: bool,
}
fn menu_items() -> Vec<MenuItem> { fn menu_items() -> Vec<MenuItem> {
vec![ vec![
MenuItem { label: "Chat".into(), description: "Talk with your agent".into(), available: true }, MenuItem { label: "Chat", description: "Talk with your agent", available: true },
MenuItem { label: "Agents".into(), description: "Select an agent".into(), available: true }, MenuItem { label: "Agents", description: "Select an agent", available: true },
MenuItem { label: "Schedule".into(), description: "Cron jobs & tasks".into(), available: true }, MenuItem { label: "Schedule", description: "Cron jobs & tasks", available: true },
MenuItem { label: "Settings".into(), description: "Configure".into(), available: true }, MenuItem { label: "Settings", description: "Configure", available: true },
] ]
} }
fn make_menu(palette: &ChatPalette) -> Box<MenuList> { /// Menu rows as content for [`SelectList`] (selection prefix/tint owned by it).
fn menu_rows() -> Vec<StyledString> {
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<SelectList> {
let primary = theme::to_tuie_color(palette.agent_primary); let primary = theme::to_tuie_color(palette.agent_primary);
let dim = theme::to_tuie_color(palette.agent_dim); let dim = theme::to_tuie_color(palette.agent_dim);
MenuList::new() SelectList::new().colors(primary, dim).items(menu_rows())
.set_items(menu_items())
.set_colors(primary, dim, theme::to_tuie_color(palette.tool_dim))
} }
/// A bordered pane with a coloured title bar at the top. /// A bordered pane with a coloured title bar at the top.

View file

@ -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 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", 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 { if view.models_fetching {
format!("{base} · fetching models…") format!("{base} · fetching models…")
} else if view.available_models.is_empty() { } else if view.available_models.is_empty() {

View file

@ -73,7 +73,7 @@ impl SettingsView {
// r: fetch models (only in Bifrost category, any focus). // r: fetch models (only in Bifrost category, any focus).
if key.code == KeyCode::Char('r') if key.code == KeyCode::Char('r')
&& matches!(self.selected_category(), Category::Bifrost) && matches!(self.selected_category(), Category::Providers)
&& !self.models_fetching && !self.models_fetching
{ {
self.models_fetching = true; self.models_fetching = true;
@ -254,7 +254,7 @@ impl SettingsView {
FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent | FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent |
FieldLoc::SaTimeout | FieldLoc::SaMaxDepth | FieldLoc::SaTimeout | FieldLoc::SaMaxDepth |
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs | FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs | FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::PvTimeoutSecs |
FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::ScN1Every | FieldLoc::ScN1Secs |
FieldLoc::TuCennoThreshold | FieldLoc::TuCennoThreshold |
FieldLoc::EvRetainDays | FieldLoc::PrPulseIntervalSecs FieldLoc::EvRetainDays | FieldLoc::PrPulseIntervalSecs
@ -294,7 +294,7 @@ impl SettingsView {
FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent | FieldLoc::ArInterval | FieldLoc::SaMaxConcurrent |
FieldLoc::SaTimeout | FieldLoc::SaMaxDepth | FieldLoc::SaTimeout | FieldLoc::SaMaxDepth |
FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs | FieldLoc::SaMaxToolRounds | FieldLoc::SaInterRoundDelayMs |
FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::BfTimeoutSecs | FieldLoc::WsPort | FieldLoc::SvPort | FieldLoc::PvTimeoutSecs |
FieldLoc::ScN1Every | FieldLoc::ScN1Secs | FieldLoc::ScN1Every | FieldLoc::ScN1Secs |
FieldLoc::TuCennoThreshold | FieldLoc::TuCennoThreshold |
FieldLoc::PrPulseIntervalSecs => { FieldLoc::PrPulseIntervalSecs => {

View file

@ -48,7 +48,7 @@ impl CategoryGroup {
/// Categories in display order within this group. /// Categories in display order within this group.
pub fn categories(&self) -> &'static [Category] { pub fn categories(&self) -> &'static [Category] {
match self { match self {
CategoryGroup::Core => &[Category::Agent, Category::Inference, Category::Bifrost], CategoryGroup::Core => &[Category::Agent, Category::Inference, Category::Providers],
CategoryGroup::Intelligence => &[ CategoryGroup::Intelligence => &[
Category::Subconscious, Category::Reflection, Category::Archivist, Category::Subconscious, Category::Reflection, Category::Archivist,
Category::Subagent, Category::Compaction, Category::Subagent, Category::Compaction,
@ -70,7 +70,7 @@ impl CategoryGroup {
pub enum Category { pub enum Category {
Agent, Agent,
Inference, Inference,
Bifrost, Providers,
Subconscious, Subconscious,
Reflection, Reflection,
Archivist, Archivist,
@ -94,7 +94,7 @@ impl Category {
&[ &[
Category::Agent, Category::Agent,
Category::Inference, Category::Inference,
Category::Bifrost, Category::Providers,
Category::Subconscious, Category::Subconscious,
Category::Reflection, Category::Reflection,
Category::Archivist, Category::Archivist,
@ -118,7 +118,7 @@ impl Category {
match self { match self {
Category::Agent => "Agent", Category::Agent => "Agent",
Category::Inference => "Inference", Category::Inference => "Inference",
Category::Bifrost => "Bifrost", Category::Providers => "Providers",
Category::Subconscious => "Subconscious", Category::Subconscious => "Subconscious",
Category::Reflection => "Reflection", Category::Reflection => "Reflection",
Category::Archivist => "Archivist", Category::Archivist => "Archivist",
@ -141,7 +141,7 @@ impl Category {
/// The logical group this category belongs to. /// The logical group this category belongs to.
pub fn group(&self) -> CategoryGroup { pub fn group(&self) -> CategoryGroup {
match self { 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::Subconscious | Category::Reflection | Category::Archivist
| Category::Subagent | Category::Compaction => CategoryGroup::Intelligence, | Category::Subagent | Category::Compaction => CategoryGroup::Intelligence,
Category::Presence | Category::Voice | Category::Sensorium Category::Presence | Category::Voice | Category::Sensorium
@ -169,13 +169,20 @@ pub enum FieldLoc {
// Agent // Agent
AgSystemPrompt, AgSystemPrompt,
AgModel, AgModel,
// Inference AgProvider,
// Inference (global default provider selector)
IfProvider, IfProvider,
// Bifrost // Providers (per-provider fields — operates on the selected provider)
BfBaseUrl, PvName,
BfApiKey, PvType,
BfVirtualKey, PvBaseUrl,
BfPrimaryModel, PvApiKey,
PvVirtualKey,
PvPrimaryModel,
PvTimeoutSecs,
PvAddProvider,
PvRemoveProvider,
PvSelectProvider,
// Subconscious // Subconscious
ScN1Enabled, ScN1Enabled,
ScN1Trigger, ScN1Trigger,
@ -245,8 +252,6 @@ pub enum FieldLoc {
VcTtsUrl, VcTtsUrl,
VcVoiceId, VcVoiceId,
VcPushToTalkKey, VcPushToTalkKey,
// Bifrost (cont.)
BfTimeoutSecs,
// Server auth // Server auth
SvAuthRequired, SvAuthRequired,
SvAuthLoopback, SvAuthLoopback,
@ -274,9 +279,11 @@ pub enum FieldLoc {
impl FieldLoc { impl FieldLoc {
pub fn category(&self) -> Category { pub fn category(&self) -> Category {
match self { match self {
FieldLoc::AgSystemPrompt | FieldLoc::AgModel => Category::Agent, FieldLoc::AgSystemPrompt | FieldLoc::AgModel | FieldLoc::AgProvider => Category::Agent,
FieldLoc::IfProvider => Category::Inference, 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::ScN1Enabled | FieldLoc::ScN1Trigger | FieldLoc::ScN1Every | FieldLoc::ScN1Secs
| FieldLoc::ScInboxEnabled | FieldLoc::ScModel | FieldLoc::ScMaxTokens | FieldLoc::ScInboxEnabled | FieldLoc::ScModel | FieldLoc::ScMaxTokens
| FieldLoc::ScSystemPrompt => Category::Subconscious, | FieldLoc::ScSystemPrompt => Category::Subconscious,
@ -306,11 +313,18 @@ impl FieldLoc {
match self { match self {
FieldLoc::AgSystemPrompt => "system_prompt", FieldLoc::AgSystemPrompt => "system_prompt",
FieldLoc::AgModel => "model", FieldLoc::AgModel => "model",
FieldLoc::AgProvider => "provider",
FieldLoc::IfProvider => "provider", FieldLoc::IfProvider => "provider",
FieldLoc::BfBaseUrl => "base_url", FieldLoc::PvName => "name",
FieldLoc::BfApiKey => "api_key", FieldLoc::PvType => "type",
FieldLoc::BfVirtualKey => "virtual_key", FieldLoc::PvBaseUrl => "base_url",
FieldLoc::BfPrimaryModel => "primary_model", 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::ScN1Enabled => "n1_enabled",
FieldLoc::ScN1Trigger => "n1_trigger", FieldLoc::ScN1Trigger => "n1_trigger",
FieldLoc::ScN1Every => "n1_every_n_responses", FieldLoc::ScN1Every => "n1_every_n_responses",
@ -366,7 +380,6 @@ impl FieldLoc {
FieldLoc::VcTtsUrl => "tts_url", FieldLoc::VcTtsUrl => "tts_url",
FieldLoc::VcVoiceId => "voice_id", FieldLoc::VcVoiceId => "voice_id",
FieldLoc::VcPushToTalkKey => "push_to_talk_key", FieldLoc::VcPushToTalkKey => "push_to_talk_key",
FieldLoc::BfTimeoutSecs => "timeout_secs",
FieldLoc::SvAuthRequired => "required", FieldLoc::SvAuthRequired => "required",
FieldLoc::SvAuthLoopback => "allow_loopback", FieldLoc::SvAuthLoopback => "allow_loopback",
FieldLoc::FdRole => "role", FieldLoc::FdRole => "role",
@ -389,7 +402,7 @@ impl FieldLoc {
/// Returns true if this field's value should be masked in browse mode. /// Returns true if this field's value should be masked in browse mode.
pub fn is_secret(&self) -> bool { 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. /// Returns true for informational fields that cannot be edited.
@ -408,11 +421,18 @@ impl FieldLoc {
match self { match self {
FieldLoc::AgSystemPrompt => "platform prompt", FieldLoc::AgSystemPrompt => "platform prompt",
FieldLoc::AgModel => "agent model", FieldLoc::AgModel => "agent model",
FieldLoc::IfProvider => "active provider", FieldLoc::AgProvider => "provider",
FieldLoc::BfBaseUrl => "endpoint", FieldLoc::IfProvider => "default provider",
FieldLoc::BfApiKey => "API key", FieldLoc::PvName => "name",
FieldLoc::BfVirtualKey => "virtual key", FieldLoc::PvType => "type",
FieldLoc::BfPrimaryModel => "new-agent default", 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::ScN1Enabled => "N+1 enabled",
FieldLoc::ScN1Trigger => "N+1 trigger", FieldLoc::ScN1Trigger => "N+1 trigger",
FieldLoc::ScN1Every => " └ every N responses", FieldLoc::ScN1Every => " └ every N responses",
@ -468,7 +488,6 @@ impl FieldLoc {
FieldLoc::VcTtsUrl => "TTS endpoint", FieldLoc::VcTtsUrl => "TTS endpoint",
FieldLoc::VcVoiceId => "voice ID", FieldLoc::VcVoiceId => "voice ID",
FieldLoc::VcPushToTalkKey => "push-to-talk", FieldLoc::VcPushToTalkKey => "push-to-talk",
FieldLoc::BfTimeoutSecs => "request timeout (s)",
FieldLoc::SvAuthRequired => "require auth", FieldLoc::SvAuthRequired => "require auth",
FieldLoc::SvAuthLoopback => "allow loopback bypass", FieldLoc::SvAuthLoopback => "allow loopback bypass",
FieldLoc::FdRole => "role", FieldLoc::FdRole => "role",
@ -495,19 +514,25 @@ impl FieldLoc {
// Agent // Agent
FieldLoc::AgSystemPrompt => "System-level instructions prepended to every conversation", FieldLoc::AgSystemPrompt => "System-level instructions prepended to every conversation",
FieldLoc::AgModel => "Primary LLM model used by the agent", 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::AgAgentId => "Unique identifier for this agent instance",
FieldLoc::AgSubconsciousId => "Unique identifier for the subconscious agent", FieldLoc::AgSubconsciousId => "Unique identifier for the subconscious agent",
FieldLoc::AgMemoryPath => "On-disk path to the agent memory directory", FieldLoc::AgMemoryPath => "On-disk path to the agent memory directory",
FieldLoc::AgSubconsciousPath => "On-disk path to the subconscious agent directory", FieldLoc::AgSubconsciousPath => "On-disk path to the subconscious agent directory",
FieldLoc::AgSubconsciousStatus => "Whether the subconscious agent has been initialized on disk", FieldLoc::AgSubconsciousStatus => "Whether the subconscious agent has been initialized on disk",
// Inference // Inference
FieldLoc::IfProvider => "Active LLM provider routing requests through Bifrost", FieldLoc::IfProvider => "Default inference provider used when no per-agent override is set",
// Bifrost // Providers
FieldLoc::BfBaseUrl => "Bifrost API endpoint URL", FieldLoc::PvName => "Short name for this provider (used in config and agent settings)",
FieldLoc::BfApiKey => "API key used to authenticate with Bifrost", FieldLoc::PvType => "Provider type — openai-compatible for standard endpoints, openai-oauth for ChatGPT login",
FieldLoc::BfVirtualKey => "Virtual key for Bifrost multi-tenant routing", FieldLoc::PvBaseUrl => "API base URL for /v1/chat/completions",
FieldLoc::BfPrimaryModel => "Default model assigned to newly created agents", FieldLoc::PvApiKey => "Bearer token or API key for this provider",
FieldLoc::BfTimeoutSecs => "HTTP request timeout in seconds for Bifrost calls", 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 // Subconscious
FieldLoc::ScN1Enabled => "Allow the subconscious agent to respond after every reply", FieldLoc::ScN1Enabled => "Allow the subconscious agent to respond after every reply",
FieldLoc::ScN1Trigger => "Condition that activates an N+1 subconscious response", FieldLoc::ScN1Trigger => "Condition that activates an N+1 subconscious response",
@ -601,7 +626,7 @@ impl FieldLoc {
pub fn applies_live(&self) -> bool { pub fn applies_live(&self) -> bool {
matches!( matches!(
self, self,
FieldLoc::PrAtmosphere | FieldLoc::PrOutfit | FieldLoc::BfPrimaryModel | FieldLoc::AgModel FieldLoc::PrAtmosphere | FieldLoc::PrOutfit | FieldLoc::PvPrimaryModel | FieldLoc::AgModel
) )
} }
} }

View file

@ -24,6 +24,8 @@ pub struct ActiveAgentSettings {
pub model: String, pub model: String,
/// The model as loaded — for save-time diffing. /// The model as loaded — for save-time diffing.
pub model_original: String, pub model_original: String,
/// Per-agent provider override (`_souveraine.provider`). None = use global default.
pub provider: Option<String>,
/// Display-only: the paired subconscious agent id (always `{id}-sub`). /// Display-only: the paired subconscious agent id (always `{id}-sub`).
pub subconscious_id: String, pub subconscious_id: String,
/// Display-only: the primary's memory directory path. /// 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 /// 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. /// App on entry from the active agent, so Settings follows agent switches.
pub active_agent: Option<ActiveAgentSettings>, pub active_agent: Option<ActiveAgentSettings>,
/// Which provider is selected for editing in the Providers category.
pub selected_provider_name: Option<String>,
} }
impl SettingsView { impl SettingsView {
@ -85,6 +90,7 @@ impl SettingsView {
models_fetching: false, models_fetching: false,
palette: ChatPalette::default(), palette: ChatPalette::default(),
active_agent: None, active_agent: None,
selected_provider_name: None,
} }
} }
@ -95,6 +101,15 @@ impl SettingsView {
self.active_agent = agent; 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 /// Whether the active agent's model was changed and needs pushing back to
/// the agent record on save. /// the agent record on save.
pub fn agent_model_dirty(&self) -> bool { 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); let idx = variants.iter().position(|m| m == &agent.model).unwrap_or(0);
out.push((AgModel, EditableValue::EnumVariant { index: idx, variants })); out.push((AgModel, EditableValue::EnumVariant { index: idx, variants }));
} }
// Per-agent provider selector
let mut pnames: Vec<String> = 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 // Read-only diagnostics
out.push((AgAgentId, EditableValue::Text(agent.id.clone()))); out.push((AgAgentId, EditableValue::Text(agent.id.clone())));
out.push((AgSubconsciousId, EditableValue::Text(agent.subconscious_id.clone()))); out.push((AgSubconsciousId, EditableValue::Text(agent.subconscious_id.clone())));
@ -174,7 +199,8 @@ impl SettingsView {
} }
Category::Inference => { Category::Inference => {
let provider = &self.config.inference.provider; let provider = &self.config.inference.provider;
let variants = vec!["bifrost".to_string(), "openai-oauth".to_string()]; let mut variants: Vec<String> = self.config.providers.keys().cloned().collect();
variants.sort();
let mut all = variants.clone(); let mut all = variants.clone();
if !all.contains(provider) { if !all.contains(provider) {
all.insert(0, provider.clone()); all.insert(0, provider.clone());
@ -182,21 +208,46 @@ impl SettingsView {
let idx = all.iter().position(|p| p == provider).unwrap_or(0); let idx = all.iter().position(|p| p == provider).unwrap_or(0);
out.push((IfProvider, EditableValue::EnumVariant { index: idx, variants: all })); out.push((IfProvider, EditableValue::EnumVariant { index: idx, variants: all }));
} }
Category::Bifrost => { Category::Providers => {
out.push((BfBaseUrl, EditableValue::Text(self.config.bifrost.base_url.clone()))); // Provider selector — choose which provider to edit
out.push((BfApiKey, EditableValue::Secret(self.config.bifrost.api_key.clone()))); let mut pnames: Vec<String> = self.config.providers.keys().cloned().collect();
out.push((BfVirtualKey, EditableValue::Secret(self.config.bifrost.virtual_key.clone()))); pnames.sort();
if self.available_models.is_empty() { if pnames.is_empty() {
out.push((BfPrimaryModel, EditableValue::Text(self.config.bifrost.primary_model.clone()))); out.push((PvSelectProvider, EditableValue::Text("(no providers)".to_string())));
} else { } else {
let mut variants = self.available_models.clone(); let selected = self.selected_provider_name.clone()
if !variants.contains(&self.config.bifrost.primary_model) { .filter(|n| self.config.providers.contains_key(n))
variants.insert(0, self.config.bifrost.primary_model.clone()); .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 }));
} }
let idx = variants.iter().position(|m| m == &self.config.bifrost.primary_model).unwrap_or(0);
out.push((BfPrimaryModel, EditableValue::EnumVariant { index: idx, variants })); // 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((BfTimeoutSecs, EditableValue::Uint(self.config.bifrost.timeout_secs))); out.push((PvTimeoutSecs, EditableValue::Uint(p.timeout_secs)));
out.push((PvRemoveProvider, EditableValue::Bool(false)));
}
}
out.push((PvAddProvider, EditableValue::Bool(false)));
} }
Category::Subconscious => { Category::Subconscious => {
let sc = &self.config.subconscious; let sc = &self.config.subconscious;
@ -434,18 +485,115 @@ impl SettingsView {
} }
} }
} }
BfBaseUrl => { if let EditableValue::Text(v) = value { self.config.bifrost.base_url = v; } } AgProvider => {
BfApiKey => { if let EditableValue::Text(v) = value { self.config.bifrost.api_key = v; } } if let Some(agent) = &mut self.active_agent {
BfVirtualKey => { if let EditableValue::Text(v) = value { self.config.bifrost.virtual_key = v; } } if let EditableValue::EnumVariant { index, variants } = &value {
BfPrimaryModel => { 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 { match value {
EditableValue::Text(v) => self.config.bifrost.primary_model = v, EditableValue::Text(v) => p.primary_model = v,
EditableValue::EnumVariant { index, variants } => { EditableValue::EnumVariant { index, variants } => {
if let Some(m) = variants.get(index) { self.config.bifrost.primary_model = m.clone(); } 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();
}
}
}
ScN1Enabled => { if let EditableValue::Bool(v) = value { self.config.subconscious.n1_enabled = v; } } ScN1Enabled => { if let EditableValue::Bool(v) = value { self.config.subconscious.n1_enabled = v; } }
ScN1Trigger => { ScN1Trigger => {
@ -633,7 +781,7 @@ impl SettingsView {
VcVoiceId => { if let EditableValue::Text(v) = value { self.config.voice.voice_id = v; } } 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; } } 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; } } 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; } } SvAuthLoopback => { if let EditableValue::Bool(v) = value { self.config.server.auth.allow_loopback = v; } }

View file

@ -20,7 +20,6 @@ use crate::ui::chat::{ChatPalette, ChatState};
use crate::ui::presence::Presence; use crate::ui::presence::Presence;
use crate::ui::screens::chat::ChatScreen; use crate::ui::screens::chat::ChatScreen;
use crate::ui::screens::cron::CronScreen; use crate::ui::screens::cron::CronScreen;
use crate::ui::screens::manager::ManagerScreen;
use crate::ui::screens::presence::PresenceScreen; use crate::ui::screens::presence::PresenceScreen;
use crate::ui::screens::settings::SettingsScreen; use crate::ui::screens::settings::SettingsScreen;
use crate::ui::screens::splash::SplashScreen; use crate::ui::screens::splash::SplashScreen;
@ -28,6 +27,54 @@ use crate::ui::screens::welcome::WelcomeScreen;
use crate::ui::theme; use crate::ui::theme;
use crate::ui::presence::Posture; 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 masterdetail 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<String>,
/// Recent commit subject lines or activity entries, newest first.
pub recent_activity: Vec<String>,
}
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 ─────────────────────────────────────────────────────────────── // ── Agent status ───────────────────────────────────────────────────────────────
/// Live agent data populated by dashboard refresh. /// Live agent data populated by dashboard refresh.
@ -53,7 +100,7 @@ pub struct AgentStatus {
/// Number of agents the backend reports. /// Number of agents the backend reports.
pub agent_count: usize, pub agent_count: usize,
/// All agents the backend knows about — used for agent selection. /// All agents the backend knows about — used for agent selection.
pub available_agents: Vec<(String, String, String)>, // (id, name, description) pub available_agents: Vec<AgentSummary>,
} }
impl Default for AgentStatus { 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 ──────────────────────────────────────────────────────────────── // ── Screen enum ────────────────────────────────────────────────────────────────
/// Which screen is currently active. /// Which screen is currently active.
@ -87,7 +146,6 @@ pub enum Screen {
Presence, Presence,
Cron, Cron,
Agents, Agents,
Manager,
} }
// ── TuieApp ──────────────────────────────────────────────────────────────────── // ── TuieApp ────────────────────────────────────────────────────────────────────
@ -174,17 +232,13 @@ impl DelegateWidget for TuieApp {
} }
} }
// After any input, check for Manager screen signals. // After any input, check for Agents screen signals (back / kill / restart).
if self.current_screen == Screen::Manager { if self.current_screen == Screen::Agents {
if let Some(ref signal) = self.manager_back_signal { if let Some(ref signal) = self.manager_back_signal {
if signal.get() { if signal.get() {
self.go_to_welcome(); 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(ref signal) = self.manager_kill_signal {
if let Some(idx) = signal.take() { if let Some(idx) = signal.take() {
tracing::info!("Kill requested for agent at index {}", idx); tracing::info!("Kill requested for agent at index {}", idx);
@ -497,6 +551,7 @@ impl TuieApp {
name: self.agent_name.clone(), name: self.agent_name.clone(),
model: model.clone(), model: model.clone(),
model_original: model, model_original: model,
provider: None,
subconscious_id: format!("{id}-sub"), subconscious_id: format!("{id}-sub"),
memory_root, memory_root,
subconscious_root, subconscious_root,
@ -530,22 +585,20 @@ impl TuieApp {
CronScreen::new(schedules_dir, &self.palette) CronScreen::new(schedules_dir, &self.palette)
} }
Screen::Agents => { Screen::Agents => {
let agents: Vec<(String, String, String)> = self let agents: Vec<AgentSummary> = self
.agent_status .agent_status
.as_ref() .as_ref()
.map(|s| s.available_agents.clone()) .map(|s| s.available_agents.clone())
.unwrap_or_default(); .unwrap_or_default();
let (agents_screen, selection_signal) = crate::ui::screens::agents::AgentsScreen::new(agents); let (agents_screen, selection_signal, back_signal, kill_signal, restart_signal, pin_signal) =
self.menu_action = selection_signal; crate::ui::screens::agents::AgentsScreen::new(agents, &self.palette);
agents_screen
}
Screen::Manager => {
let (manager, selection_signal, back_signal, kill_signal, restart_signal) = ManagerScreen::new(&self.palette);
self.menu_action = selection_signal; self.menu_action = selection_signal;
self.manager_back_signal = Some(back_signal); self.manager_back_signal = Some(back_signal);
self.manager_kill_signal = Some(kill_signal); self.manager_kill_signal = Some(kill_signal);
self.manager_restart_signal = Some(restart_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), 1 => self.switch_screen(Screen::Agents),
2 => self.switch_screen(Screen::Cron), 2 => self.switch_screen(Screen::Cron),
3 => self.switch_screen(Screen::Settings), 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. /// Handle an agent selection from the Agents screen.
fn handle_agent_select(&mut self, idx: usize) { fn handle_agent_select(&mut self, idx: usize) {
if let Some(ref mut status) = self.agent_status { if let Some(ref mut status) = self.agent_status {
if let Some((agent_id, agent_name, _)) = status.available_agents.get(idx) { if let Some(agent) = status.available_agents.get(idx) {
status.name = agent_name.clone(); status.name = agent.name.clone();
status.agent_id = Some(agent_id.clone()); status.agent_id = Some(agent.id.clone());
self.agent_name = agent_name.clone(); self.agent_name = agent.name.clone();
self.go_to_welcome(); self.go_to_welcome();
} }
} }
@ -692,7 +744,6 @@ impl TuieApp {
Key::Char('p') => { self.switch_screen(Screen::Presence); true } Key::Char('p') => { self.switch_screen(Screen::Presence); true }
Key::Char('a') => { self.switch_screen(Screen::Agents); true } Key::Char('a') => { self.switch_screen(Screen::Agents); true }
Key::Char('j') => { self.switch_screen(Screen::Cron); true } Key::Char('j') => { self.switch_screen(Screen::Cron); true }
Key::Char('m') => { self.switch_screen(Screen::Manager); true }
_ => false, _ => false,
}, },
// Chat is a text-entry screen — never steal printable keys (e.g. 'q'), // 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) .find(|a| a.name == agent_pref || a.id == agent_pref)
.or_else(|| agents.first()); .or_else(|| agents.first());
let available_agents: Vec<_> = agents let available_agents: Vec<AgentSummary> = agents
.iter() .iter()
.map(|a| (a.id.clone(), a.name.clone(), a.description.clone().unwrap_or_default())) .map(|a| AgentSummary::from_agent_info(a))
.collect(); .collect();
return AgentStatus { return AgentStatus {
@ -818,10 +869,62 @@ async fn load_dashboard_data(
(0, Vec::new()) (0, Vec::new())
}; };
let available_agents: Vec<_> = agents // Enrich every agent with per-agent stats (glyph, instances,
.iter() // uptime, memory count, pubkey, atmosphere) following the
.map(|a| (a.id.clone(), a.name.clone(), a.description.clone().unwrap_or_default())) // ratatui `fetch_agent_cards` recipe.
.collect(); let inv = local.server_agents();
let mut available_agents: Vec<AgentSummary> = 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 { AgentStatus {
name: chosen.map(|a| a.name.clone()).unwrap_or(agent_pref), name: chosen.map(|a| a.name.clone()).unwrap_or(agent_pref),

View file

@ -108,13 +108,13 @@ impl BtwPane {
mod tests { mod tests {
use super::*; use super::*;
use crate::ui::chat::{BtwState, ChatPalette}; use crate::ui::chat::{BtwState, ChatPalette};
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
#[test] #[test]
fn btw_pane_starts_hidden() { fn btw_pane_starts_hidden() {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = BtwPane::new(&palette); 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(); let output = term.get_snapshot_text();
assert!( assert!(
!output.contains("Forking") && !output.contains("Error") && !output.contains("OK"), !output.contains("Forking") && !output.contains("Error") && !output.contains("OK"),
@ -128,7 +128,7 @@ mod tests {
let mut widget = BtwPane::new(&palette); let mut widget = BtwPane::new(&palette);
let state = BtwState::Forking { question: "test question".into() }; let state = BtwState::Forking { question: "test question".into() };
widget.set_state(&state, &palette); 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(); let output = term.get_snapshot_text();
assert!( assert!(
output.contains("Forking") || output.contains("test question"), output.contains("Forking") || output.contains("test question"),
@ -142,7 +142,7 @@ mod tests {
let mut widget = BtwPane::new(&palette); let mut widget = BtwPane::new(&palette);
let state = BtwState::Streaming { question: "q".into(), response_so_far: "streaming response".into() }; let state = BtwState::Streaming { question: "q".into(), response_so_far: "streaming response".into() };
widget.set_state(&state, &palette); 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(); let output = term.get_snapshot_text();
assert!( assert!(
output.contains("streaming response"), output.contains("streaming response"),
@ -160,7 +160,7 @@ mod tests {
forked_id: "abc123".into(), forked_id: "abc123".into(),
}; };
widget.set_state(&state, &palette); 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(); let output = term.get_snapshot_text();
assert!( assert!(
output.contains("OK") || output.contains("abc123"), output.contains("OK") || output.contains("abc123"),
@ -174,7 +174,7 @@ mod tests {
let mut widget = BtwPane::new(&palette); let mut widget = BtwPane::new(&palette);
let state = BtwState::Error { question: "q".into(), error: "fail".into() }; let state = BtwState::Error { question: "q".into(), error: "fail".into() };
widget.set_state(&state, &palette); 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(); let output = term.get_snapshot_text();
assert!( assert!(
output.contains("fail") || output.contains("Error"), output.contains("fail") || output.contains("Error"),

View file

@ -188,6 +188,10 @@ impl ChatOverlay {
self.pane.set_bordered(true); self.pane.set_bordered(true);
self.pane.set_border(Some(Border::SINGLE)); self.pane.set_border(Some(Border::SINGLE));
self.pane.set_border_style(Style::new().fg(dim)); 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. /// Show the conversation picker.
@ -225,6 +229,7 @@ impl ChatOverlay {
self.pane.set_bordered(true); self.pane.set_bordered(true);
self.pane.set_border(Some(Border::SINGLE)); self.pane.set_border(Some(Border::SINGLE));
self.pane.set_border_style(Style::new().fg(primary)); self.pane.set_border_style(Style::new().fg(primary));
self.pane.set_height(Some(count.max(1) as u16 + 2));
} }
/// Hide the overlay. /// Hide the overlay.
@ -239,16 +244,18 @@ impl ChatOverlay {
} }
} }
self.pane.set_bordered(false); self.pane.set_bordered(false);
// Release the reserved height so the hidden overlay collapses to zero.
self.pane.set_height(None);
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
fn snapshot(overlay: &mut Box<ChatOverlay>, size: Vec2<u16>) -> String { fn snapshot(overlay: &mut Box<ChatOverlay>, size: Vec2<u16>) -> String {
let mut term = TestTerminal::new(&mut **overlay, size); let mut term = Emulator::new(&mut **overlay, size);
term.get_snapshot_text() term.get_snapshot_text()
} }

View file

@ -1,14 +1,45 @@
//! Chat sidebar — agent vitals displayed as a toggleable panel. //! Chat sidebar — agent vitals displayed as a toggleable panel.
//! //!
//! Shows agent name, mood, energy bar, memory pressure bar, N+1 cycles, //! Shows agent name, mood, energy bar, memory pressure bar, N+1 cycles,
//! recent memory commits, and pending tasks. Each section is a Text widget //! recent memory commits, pending tasks, backend status, N+25/N+100 timestamps,
//! row inside a vertical Pane, updated from AgentStatus snapshots. //! 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 tuie::prelude::*;
use crate::ui::chat::ChatPalette; use crate::ui::chat::ChatPalette;
use crate::ui::theme; use crate::ui::theme;
use crate::ui::tuie_app::AgentStatus; 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<String>,
pub last_archivist: Option<String>,
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. /// Toggleable sidebar widget for the chat screen.
/// ///
@ -24,6 +55,13 @@ pub struct ChatSidebar {
n1_id: WidgetId<Text>, n1_id: WidgetId<Text>,
commits_id: WidgetId<Text>, commits_id: WidgetId<Text>,
tasks_id: WidgetId<Text>, tasks_id: WidgetId<Text>,
backend_id: WidgetId<Text>,
n25_id: WidgetId<Text>,
n100_id: WidgetId<Text>,
compaction_id: WidgetId<Text>,
strain_id: WidgetId<Text>,
uptime_id: WidgetId<Text>,
started: Instant,
} }
impl DelegateWidget for ChatSidebar { impl DelegateWidget for ChatSidebar {
@ -47,6 +85,12 @@ impl ChatSidebar {
let mut n1_id = WidgetId::EMPTY; let mut n1_id = WidgetId::EMPTY;
let mut commits_id = WidgetId::EMPTY; let mut commits_id = WidgetId::EMPTY;
let mut tasks_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 title = {
let mut s = StyledString::new(); 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 n1_id),
Text::new().content(dim_text.clone()).id(&mut commits_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 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 { Box::new(Self {
@ -80,9 +130,119 @@ impl ChatSidebar {
n1_id, n1_id,
commits_id, commits_id,
tasks_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. /// Update all vitals from an AgentStatus snapshot + pressure value.
pub fn update( pub fn update(
&mut self, &mut self,
@ -183,7 +343,7 @@ impl ChatSidebar {
mod tests { mod tests {
use super::*; use super::*;
use crate::ui::tuie_app::AgentStatus; use crate::ui::tuie_app::AgentStatus;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
#[test] #[test]
fn sidebar_renders_agent_name() { fn sidebar_renders_agent_name() {
@ -198,7 +358,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("TestBot"), "got: {rendered:?}"); assert!(rendered.contains("TestBot"), "got: {rendered:?}");
} }
@ -216,7 +376,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("Happy"), "got: {rendered:?}"); assert!(rendered.contains("Happy"), "got: {rendered:?}");
} }
@ -234,7 +394,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("80%"), "got: {rendered:?}"); assert!(rendered.contains("80%"), "got: {rendered:?}");
assert!(rendered.contains('['), "got: {rendered:?}"); assert!(rendered.contains('['), "got: {rendered:?}");
@ -254,7 +414,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.3, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(!rendered.is_empty(), "output was empty"); assert!(!rendered.is_empty(), "output was empty");
assert!(rendered.contains("MemPres"), "got: {rendered:?}"); assert!(rendered.contains("MemPres"), "got: {rendered:?}");
@ -273,7 +433,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.9, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(!rendered.is_empty(), "output was empty"); assert!(!rendered.is_empty(), "output was empty");
} }
@ -291,7 +451,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 3, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("3 active"), "got: {rendered:?}"); assert!(rendered.contains("3 active"), "got: {rendered:?}");
} }
@ -309,7 +469,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 0, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("idle"), "got: {rendered:?}"); assert!(rendered.contains("idle"), "got: {rendered:?}");
} }
@ -327,7 +487,7 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("42"), "got: {rendered:?}"); assert!(rendered.contains("42"), "got: {rendered:?}");
} }
@ -345,23 +505,8 @@ mod tests {
..Default::default() ..Default::default()
}; };
widget.update(&status, 0.45, 2, &palette); 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(); let rendered = term.get_snapshot_text();
assert!(rendered.contains("3"), "got: {rendered:?}"); 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));
}

View file

@ -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: //! A single bordered column split into three sections by thin rules:
//! • thinking (top) — the agent's reasoning log //! • thinking (top) — the agent's reasoning log
//! • subconscious (below) — N+1 surfacings, reflections, archivist notes //! • 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* //! 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 //! 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 //! section collapses to a one-line header with a chevron. Older entries
//! fade so the newest line is always brightest. //! 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 tuie::prelude::*;
use super::progress_bar::ProgressBar; use super::progress_bar::ProgressBar;
@ -22,7 +27,7 @@ pub enum ActivePane {
Subconscious, Subconscious,
} }
/// Two-section cockpit — thinking (top) and subconscious (bottom). /// Three-section cockpit — thinking (top), subconscious (mid), inner voice (bottom).
pub struct Cockpit { pub struct Cockpit {
root: Box<Pane>, root: Box<Pane>,
thinking_title_id: WidgetId<Text>, thinking_title_id: WidgetId<Text>,
@ -31,13 +36,23 @@ pub struct Cockpit {
sub_title_id: WidgetId<Text>, sub_title_id: WidgetId<Text>,
sub_body_id: WidgetId<Pane>, sub_body_id: WidgetId<Pane>,
sub_text_id: WidgetId<Text>, sub_text_id: WidgetId<Text>,
voice_title_id: WidgetId<Text>,
voice_body_id: WidgetId<Pane>,
voice_text_id: WidgetId<Text>,
pressure_bar_id: WidgetId<ProgressBar>, pressure_bar_id: WidgetId<ProgressBar>,
pressure_label_id: WidgetId<Text>, pressure_label_id: WidgetId<Text>,
palette: ChatPalette, palette: ChatPalette,
thinking_count: usize, thinking_count: usize,
sub_count: usize, sub_count: usize,
voice_count: usize,
active: ActivePane, active: ActivePane,
// Inner voice file tracking
inner_voice_path: Option<PathBuf>,
inner_voice_mtime: Option<SystemTime>,
inner_voice_lines: VecDeque<String>,
last_scan: Option<Instant>,
} }
impl DelegateWidget for Cockpit { impl DelegateWidget for Cockpit {
@ -55,6 +70,9 @@ impl Cockpit {
let mut sub_title_id = WidgetId::EMPTY; let mut sub_title_id = WidgetId::EMPTY;
let mut sub_body_id = WidgetId::EMPTY; let mut sub_body_id = WidgetId::EMPTY;
let mut sub_text_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_bar_id = WidgetId::EMPTY;
let mut pressure_label_id = WidgetId::EMPTY; let mut pressure_label_id = WidgetId::EMPTY;
@ -86,6 +104,20 @@ impl Cockpit {
]) ])
.id(&mut sub_body_id); .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<dyn Widget>,
])
.id(&mut voice_body_id);
// ── Pressure footer ────────────────────────────────────────────── // ── Pressure footer ──────────────────────────────────────────────
let pressure_label = Text::new() let pressure_label = Text::new()
.content(StyledStr::new(" ctx ").fg(dim)) .content(StyledStr::new(" ctx ").fg(dim))
@ -119,6 +151,9 @@ impl Cockpit {
sub_title as Box<dyn Widget>, sub_title as Box<dyn Widget>,
sub_body, sub_body,
Rule::new(dim) as Box<dyn Widget>, Rule::new(dim) as Box<dyn Widget>,
voice_title as Box<dyn Widget>,
voice_body,
Rule::new(dim) as Box<dyn Widget>,
footer, footer,
]); ]);
@ -130,12 +165,20 @@ impl Cockpit {
sub_title_id, sub_title_id,
sub_body_id, sub_body_id,
sub_text_id, sub_text_id,
voice_title_id,
voice_body_id,
voice_text_id,
pressure_bar_id, pressure_bar_id,
pressure_label_id, pressure_label_id,
palette: *palette, palette: *palette,
thinking_count: 0, thinking_count: 0,
sub_count: 0, sub_count: 0,
voice_count: 0,
active: ActivePane::None, 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. /// Recompute titles (glow + chevron) and the section room split.
fn relayout(&mut self) { fn relayout(&mut self) {
let primary = theme::to_tuie_color(self.palette.agent_primary); let primary = theme::to_tuie_color(self.palette.agent_primary);
@ -194,6 +305,7 @@ impl Cockpit {
let sub_active = self.active == ActivePane::Subconscious; let sub_active = self.active == ActivePane::Subconscious;
let think_open = self.thinking_count > 0; let think_open = self.thinking_count > 0;
let sub_open = self.sub_count > 0; let sub_open = self.sub_count > 0;
let voice_open = self.voice_count > 0;
// Titles — accent + filled chevron when active, dim otherwise. // Titles — accent + filled chevron when active, dim otherwise.
if let Some(t) = self.root.get_widget_mut(self.thinking_title_id) { 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 }; let c = if sub_active { surfacing } else { dim };
t.set_content(section_title("subconscious", sub_open, self.sub_count, c)); 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 // Room split: a collapsed (empty) section hides its body; otherwise the
// active section gets double the weight so the live stream has room. // active section gets double the weight so the live stream has room.
let think_flex = section_flex(think_open, think_active); let think_flex = section_flex(think_open, think_active);
let sub_flex = section_flex(sub_open, sub_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) { if let Some(b) = self.root.get_widget_mut(self.thinking_body_id) {
apply_section_room(b, think_open, think_flex); apply_section_room(b, think_open, think_flex);
} }
if let Some(b) = self.root.get_widget_mut(self.sub_body_id) { if let Some(b) = self.root.get_widget_mut(self.sub_body_id) {
apply_section_room(b, sub_open, sub_flex); 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(); self.root.dirty_layout();
} }
} }
@ -304,6 +423,45 @@ fn build_subconscious_text(entries: &[ChatCockpitEntry], palette: &ChatPalette)
content content
} }
/// Build inner-voice text from the tail of `subconscious.md`.
fn build_voice_text(lines: &VecDeque<String>, 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::<String>();
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 { fn entry_kind_color(kind: CockpitKind, p: &ChatPalette) -> Color {
match kind { match kind {
CockpitKind::Surfacing => theme::to_tuie_color(p.surfacing), CockpitKind::Surfacing => theme::to_tuie_color(p.surfacing),

View file

@ -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<String>,
}
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<String, HashMap<ExpressionKey, ImageSource>>,
}
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<ExpressionKey, ImageSource>,
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<ImageSource> {
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<String>,
) {
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()
}
}

View file

@ -157,14 +157,14 @@ fn pressure_color(pressure: f32) -> Color {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
#[test] #[test]
fn footer_renders_agent_name() { fn footer_renders_agent_name() {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = Footer::new(&palette); let mut widget = Footer::new(&palette);
widget.set_agent_name("TestAgent"); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("TestAgent"), rendered.contains("TestAgent"),
@ -177,7 +177,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = Footer::new(&palette); let mut widget = Footer::new(&palette);
widget.set_mode("local"); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("Enter send"), rendered.contains("Enter send"),
@ -194,7 +194,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = Footer::new(&palette); let mut widget = Footer::new(&palette);
widget.set_conversation_id("abcdef1234567890"); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("conv abcdef12"), rendered.contains("conv abcdef12"),
@ -207,7 +207,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = Footer::new(&palette); let mut widget = Footer::new(&palette);
widget.set_pressure(0.52); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("52%"), rendered.contains("52%"),
@ -220,7 +220,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = Footer::new(&palette); let mut widget = Footer::new(&palette);
widget.set_model("test-model"); 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("test-model"), rendered.contains("test-model"),
@ -233,11 +233,11 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = Footer::new(&palette); let mut widget = Footer::new(&palette);
widget.set_cockpit_open(false); 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")); assert!(term.get_snapshot_text().contains("Tab cockpit"));
widget.set_cockpit_open(true); 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")); assert!(term.get_snapshot_text().contains("Tab close cockpit"));
} }
} }

View file

@ -66,7 +66,7 @@ impl ItineraryStrip {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
#[test] #[test]
fn itinerary_starts_hidden() { fn itinerary_starts_hidden() {
@ -74,7 +74,7 @@ mod tests {
let mut widget = ItineraryStrip::new(&palette); let mut widget = ItineraryStrip::new(&palette);
// Height 1: when hidden the widget collapses to zero height, so // Height 1: when hidden the widget collapses to zero height, so
// rendering should be empty or a single blank line. // 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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.trim().is_empty(), rendered.trim().is_empty(),
@ -87,7 +87,7 @@ mod tests {
let palette = ChatPalette::default(); let palette = ChatPalette::default();
let mut widget = ItineraryStrip::new(&palette); let mut widget = ItineraryStrip::new(&palette);
widget.set_line("step1 → step2 → step3", &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(); let rendered = term.get_snapshot_text();
assert!( assert!(
rendered.contains("step1") || rendered.contains("step2"), rendered.contains("step1") || rendered.contains("step2"),
@ -101,7 +101,7 @@ mod tests {
let mut widget = ItineraryStrip::new(&palette); let mut widget = ItineraryStrip::new(&palette);
widget.set_line("step1 → step2 → step3", &palette); widget.set_line("step1 → step2 → step3", &palette);
widget.set_line("", &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(); let rendered = term.get_snapshot_text();
assert!( assert!(
!rendered.contains("step1") && !rendered.contains("step2"), !rendered.contains("step1") && !rendered.contains("step2"),

View file

@ -15,7 +15,7 @@ pub enum MsgKind {
Surfacing { source: String, content: String, priority: String }, Surfacing { source: String, content: String, priority: String },
System { text: String }, System { text: String },
SystemContext { summary: String, _full_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<String> },
Interjection { text: String, delivered: bool }, Interjection { text: String, delivered: bool },
Interstitial { text: String, is_voice: bool }, Interstitial { text: String, is_voice: bool },
} }
@ -148,7 +148,7 @@ fn render_message(
.container_width(w), .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 p = &msgs.palette;
let mode = if msgs.tool_cards_expanded { let mode = if msgs.tool_cards_expanded {
super::tool_card::ToolCardMode::Expanded super::tool_card::ToolCardMode::Expanded
@ -169,6 +169,7 @@ fn render_message(
.round(*round) .round(*round)
.is_error(*is_error) .is_error(*is_error)
.is_pending(*is_pending) .is_pending(*is_pending)
.result_output(result_output.clone())
.mode(mode) .mode(mode)
.glyph_style(to_style(glyph_color)) .glyph_style(to_style(glyph_color))
.name_style(to_style(glyph_color).bold()) .name_style(to_style(glyph_color).bold())

View file

@ -15,6 +15,7 @@ pub mod checkbox;
pub mod cockpit; pub mod cockpit;
pub mod counter; pub mod counter;
pub mod dropdown; pub mod dropdown;
pub mod expression_cache;
pub mod flat_button; pub mod flat_button;
pub mod focus_pane; pub mod focus_pane;
pub mod footer; pub mod footer;
@ -22,7 +23,6 @@ pub mod global_chords;
pub mod horizontal_rule; pub mod horizontal_rule;
pub mod itinerary_strip; pub mod itinerary_strip;
pub mod link; pub mod link;
pub mod menu_list;
pub mod message_list; pub mod message_list;
pub mod page_layout; pub mod page_layout;
pub mod phase_bar; pub mod phase_bar;
@ -32,6 +32,8 @@ pub mod progress_bar;
pub mod radio_group; pub mod radio_group;
pub mod responsive; pub mod responsive;
pub mod segmented_control; pub mod segmented_control;
pub mod select_list;
pub mod send_button; pub mod send_button;
pub mod slider; pub mod slider;
pub mod stats;
pub mod tool_card; pub mod tool_card;

View file

@ -85,8 +85,93 @@ impl PhaseBar {
self.text.dirty_layout(); 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). /// Clear the phase bar (hide it).
pub fn clear(&mut self) { pub fn clear(&mut self) {
self.text.set_content(""); 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
}

View file

@ -4,13 +4,34 @@
//! (`~/.souveraine/agents/{id}/memory/assets/portrait.{png,jpg,jpeg}`). //! (`~/.souveraine/agents/{id}/memory/assets/portrait.{png,jpg,jpeg}`).
//! Falls back to ASCII diamond art when no image is found or image //! Falls back to ASCII diamond art when no image is found or image
//! support isn't available. //! 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::*; use tuie::prelude::*;
/// A widget that shows an agent's portrait — either as a terminal image use super::expression_cache::{ExpressionCache, ExpressionKey};
/// (kitty/sixel/half-block) or as ASCII art. 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 { pub struct Portrait {
inner: Box<dyn Widget>, inner: Box<dyn Widget>,
agent_id: Option<String>,
current_key: Option<ExpressionKey>,
name: String,
color: Color,
has_hud: bool,
} }
impl DelegateWidget for Portrait { impl DelegateWidget for Portrait {
@ -19,15 +40,9 @@ impl DelegateWidget for Portrait {
} }
impl 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<Self> { pub fn new(agent_id: Option<&str>, name: &str, color: Color) -> Box<Self> {
let inner: Box<dyn Widget> = match agent_id.and_then(|id| load_portrait_image(id)) { let inner: Box<dyn Widget> = match agent_id.and_then(load_portrait_image) {
Some(source) => { 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); let mut img = Image::new(source);
img.set_fill(true); img.set_fill(true);
img.flex(1).x_align(FlexAlign::Center) img.flex(1).x_align(FlexAlign::Center)
@ -35,11 +50,83 @@ impl Portrait {
None => ascii_portrait(name, color), 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<ImageSource> { fn load_portrait_image(agent_id: &str) -> Option<ImageSource> {
let assets_dir = dirs::home_dir()? let assets_dir = dirs::home_dir()?
.join(".souveraine") .join(".souveraine")
@ -57,7 +144,6 @@ fn load_portrait_image(agent_id: &str) -> Option<ImageSource> {
ImageSource::from_encoded(bytes).ok() ImageSource::from_encoded(bytes).ok()
} }
/// ASCII art fallback — diamond shape with agent name.
fn ascii_portrait(name: &str, color: Color) -> Box<dyn Widget> { fn ascii_portrait(name: &str, color: Color) -> Box<dyn Widget> {
let mut content = StyledString::new(); let mut content = StyledString::new();
content.push_str("\n"); content.push_str("\n");

View file

@ -224,7 +224,7 @@ impl Widget for Responsive {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use tuie::test::TestTerminal; use tuie::emulator::Emulator;
fn label(s: &str) -> Box<dyn Widget> { fn label(s: &str) -> Box<dyn Widget> {
Text::new().content(s).flex(1) Text::new().content(s).flex(1)
@ -235,7 +235,7 @@ mod tests {
let mut root = Responsive::new(100, label("WIDEMODE"), label("NARROWMODE")).flex(1); let mut root = Responsive::new(100, label("WIDEMODE"), label("NARROWMODE")).flex(1);
// At/above the breakpoint the wide arrangement is shown. // 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(); let wide = term.get_snapshot_text();
assert!(wide.contains("WIDEMODE"), "expected wide arrangement, got: {wide:?}"); assert!(wide.contains("WIDEMODE"), "expected wide arrangement, got: {wide:?}");
assert!(!wide.contains("NARROWMODE"), "narrow leaked into wide: {wide:?}"); assert!(!wide.contains("NARROWMODE"), "narrow leaked into wide: {wide:?}");

View file

@ -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<Cell<Option<usize>>>` 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<StyledString>)`. 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::<ActivateEvent>(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<Pane>,
index: usize,
selected: bool,
active: Color,
bordered: bool,
}
impl ListRow {
fn new(
index: usize,
content: Box<dyn Widget>,
selected: bool,
active: Color,
dim: Color,
bordered: bool,
) -> Box<Self> {
let body = Pane::new().horizontal().children([
Text::new().content(
StyledStr::new(if selected { "\u{25b6} " } else { " " })
.fg(if selected { active } else { dim }),
) as Box<dyn Widget>,
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<StyledString>,
selected: usize,
active: Color,
dim: Color,
bordered: bool,
}
fn render_row(ctx: &mut RowCtx, index: usize) -> Option<Box<dyn Widget>> {
let content = ctx.rows.get(index)?.clone();
let widget = Text::new().content(content) as Box<dyn Widget>;
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<Pane>,
list_id: WidgetId<List>,
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<Self> {
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<Self>, active: Color, dim: Color) -> Box<Self> {
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<Self>) -> Box<Self> {
self.bordered = true;
self
}
/// Vertical gap (in rows) between items.
pub fn gap(mut self: Box<Self>, gap: u8) -> Box<Self> {
self.gap = gap;
self
}
/// Set the initial selection (clamped on [`items`](Self::items)).
pub fn selected(mut self: Box<Self>, index: usize) -> Box<Self> {
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<Self>, rows: Vec<StyledString>) -> Box<Self> {
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<StyledString>) {
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::<RowCtx>() {
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::<RowCtx>() {
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::<RowClicked>() {
self.set_selected(index);
self.activate(index);
}
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use tuie::emulator::Emulator;
fn rows(n: usize) -> Vec<StyledString> {
(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);
}
}

44
src/ui/widgets/stats.rs Normal file
View file

@ -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.01.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,
)
}

View file

@ -20,6 +20,7 @@ pub struct ToolCard {
round: u32, round: u32,
is_error: bool, is_error: bool,
is_pending: bool, is_pending: bool,
result_output: Option<String>,
mode: ToolCardMode, mode: ToolCardMode,
glyph_style: Style, glyph_style: Style,
name_style: Style, name_style: Style,
@ -41,6 +42,7 @@ impl ToolCard {
round: 1, round: 1,
is_error: false, is_error: false,
is_pending: true, is_pending: true,
result_output: None,
mode: ToolCardMode::Compact, mode: ToolCardMode::Compact,
glyph_style: Style::new(), glyph_style: Style::new(),
name_style: Style::new(), name_style: Style::new(),
@ -128,11 +130,17 @@ impl ToolCard {
self.rebuild(); self.rebuild();
} }
pub fn set_result_details(&mut self, _output: &str) { pub fn set_result_details(&mut self, output: &str) {
// Store result for expanded mode rendering self.result_output = if output.is_empty() { None } else { Some(output.to_string()) };
self.rebuild(); self.rebuild();
} }
pub fn result_output(mut self: Box<Self>, output: Option<String>) -> Box<Self> {
self.result_output = output;
self.rebuild();
self
}
// ── Rebuild ────────────────────────────────────────────────────────────── // ── Rebuild ──────────────────────────────────────────────────────────────
fn rebuild(&mut self) { fn rebuild(&mut self) {
@ -192,13 +200,52 @@ impl ToolCard {
self.text.set_content(content); self.text.set_content(content);
} }
fn build_expanded(&mut self, _glyph: char) { fn build_expanded(&mut self, glyph: char) {
// Expanded mode uses a ChatBubble-like layout with args + result body. // Header: " ⟳ tool_name · r3" (same as compact header)
// For now, render as compact with a "[expanded]" marker.
let mut content = StyledString::new(); let mut content = StyledString::new();
content.push_str(&format!(" [expanded] {} (r{})", self.name, self.round)); let header = format!(" {glyph} {} · r{}", self.name, self.round);
let len = content.as_ref().len(); content.push_str(&header);
content.style_range(0..len, |s| *s = self.name_style);
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); self.text.set_content(content);
} }
} }