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