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:
parent
1734346e59
commit
d35370b9c9
40 changed files with 3593 additions and 1035 deletions
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,80 @@
|
||||||
//! Agent manager screen — select an agent from the available list.
|
//! Unified agent screen — master–detail view replacing the old AgentsScreen
|
||||||
|
//! and ManagerScreen.
|
||||||
//!
|
//!
|
||||||
//! Shows available agents as bordered cards with names and descriptions.
|
//! 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(),
|
}
|
||||||
),
|
|
||||||
]);
|
// ── Selection navigation ─────────────────────────────────────────────
|
||||||
card
|
|
||||||
|
fn move_up(&mut self) {
|
||||||
|
if self.selected > 0 {
|
||||||
|
self.selected -= 1;
|
||||||
|
self.apply_selection();
|
||||||
|
self.refresh_detail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_selection(&mut self) {
|
/// Build a [`SelectList`] pre-filled with the given rows.
|
||||||
// Rebuild all cards with new selection state, swap into scroll body.
|
fn build_list(rows: &[StyledString], palette: &ChatPalette) -> Box<SelectList> {
|
||||||
let new_cards = Self::build_cards(&self.agents, self.selected);
|
let primary = theme::to_tuie_color(palette.agent_primary);
|
||||||
if let Some(scroll) = self.root.get_widget_mut(self.scroll_id) {
|
let dim = theme::to_tuie_color(palette.agent_dim);
|
||||||
scroll.clear();
|
SelectList::new()
|
||||||
for card in new_cards {
|
.colors(primary, dim)
|
||||||
scroll.add_child(card);
|
.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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.root.dirty_layout();
|
|
||||||
|
// ── 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:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
354
src/ui/screens/chat/convert.rs
Normal file
354
src/ui/screens/chat/convert.rs
Normal 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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}]"),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,128 +188,52 @@ 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 ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
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) {
|
/// Build one styled row per agent. Selection prefix/tint owned by [`SelectList`].
|
||||||
let rows = Self::build_rows(&self.agents, self.selected, palette);
|
fn build_rows(agents: &[AgentProcessInfo], palette: &ChatPalette) -> Vec<StyledString> {
|
||||||
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.
|
||||||
fn truncate_str(s: &str, max_len: usize) -> String {
|
fn truncate_str(s: &str, max_len: usize) -> String {
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, ¤t, &self.palette)
|
let picker = ModelPicker::new(&models, ¤t, &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");
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; } }
|
||||||
|
|
|
||||||
|
|
@ -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 master–detail view.
|
||||||
|
///
|
||||||
|
/// Supersedes both the lossy `(String, String, String)` tuples that
|
||||||
|
/// `AgentStatus` used to carry and `manager::AgentProcessInfo`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgentSummary {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// 4-glyph SeedID badge (e.g. "◇◆◇◆").
|
||||||
|
pub glyph: String,
|
||||||
|
/// Running instance count (≈ process count).
|
||||||
|
pub instance_count: i64,
|
||||||
|
/// Lifetime uptime percentage, capped at 99.
|
||||||
|
pub uptime_pct: u8,
|
||||||
|
/// Number of files in the agent's memory repo.
|
||||||
|
pub memory_count: usize,
|
||||||
|
/// First 16 hex chars of the pubkey.
|
||||||
|
pub pubkey_prefix: String,
|
||||||
|
/// Whether this agent is marked as the primary.
|
||||||
|
pub is_primary: bool,
|
||||||
|
/// Atmosphere name from the agent's preferences, if set.
|
||||||
|
pub atmosphere: Option<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),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
206
src/ui/widgets/expression_cache.rs
Normal file
206
src/ui/widgets/expression_cache.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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:?}");
|
||||||
|
|
|
||||||
448
src/ui/widgets/select_list.rs
Normal file
448
src/ui/widgets/select_list.rs
Normal 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
44
src/ui/widgets/stats.rs
Normal 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.0–1.0; filled blocks use `color`, empty blocks and
|
||||||
|
/// brackets use dim gray.
|
||||||
|
pub(crate) fn push_bar(out: &mut StyledString, fraction: f32, width: usize, color: Color) {
|
||||||
|
let filled = ((fraction.clamp(0.0, 1.0)) * width as f32).round() as usize;
|
||||||
|
let empty = width.saturating_sub(filled);
|
||||||
|
let bracket = Color::BRIGHT_BLACK;
|
||||||
|
|
||||||
|
out.push_span(StyledStr::new("[").fg(bracket));
|
||||||
|
if filled > 0 {
|
||||||
|
out.push_span(StyledStr::new(&"█".repeat(filled)).fg(color));
|
||||||
|
}
|
||||||
|
if empty > 0 {
|
||||||
|
out.push_span(StyledStr::new(&"░".repeat(empty)).fg(bracket));
|
||||||
|
}
|
||||||
|
out.push_span(StyledStr::new("] ").fg(bracket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gently pulse `base`'s brightness with a slow sine — palette-agnostic, so it
|
||||||
|
/// works whatever atmosphere colour an agent currently wears.
|
||||||
|
///
|
||||||
|
/// `tick` is a wrapping counter advanced on each animation step (~every 90 ms).
|
||||||
|
pub(crate) fn breathe_color(base: Color, tick: u64) -> Color {
|
||||||
|
let (r, g, b) = match base {
|
||||||
|
Color::Rgb(r, g, b) => (r, g, b),
|
||||||
|
_ => (255u8, 140, 66),
|
||||||
|
};
|
||||||
|
let phase = (tick as f32 * 0.06).sin() * 0.5 + 0.5; // 0..1
|
||||||
|
let f = 0.78 + phase * 0.22; // 0.78..1.0
|
||||||
|
Color::Rgb(
|
||||||
|
(r as f32 * f) as u8,
|
||||||
|
(g as f32 * f) as u8,
|
||||||
|
(b as f32 * f) as u8,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue