agents: wire the rest of the detail widgets into the section children so PRIMARY badge etc actually render; drop the HARDCODED debug text. settings: test needs a provider in the map before navigating to Providers cat. clippy: pin CI to rust 1.94 per Cargo.toml rust-version; also apply what cargo clippy --fix could auto-fix for 1.96 compat.
408 lines
19 KiB
Rust
408 lines
19 KiB
Rust
|
|
use tracing::{info, warn};
|
|
|
|
use super::{App, Screen, short_now, recent_commits};
|
|
use crate::ui::chat::ChatState;
|
|
use crate::ui::component::TuiEvent;
|
|
use crate::ui::setup::SetupState;
|
|
|
|
impl App {
|
|
pub(super) async fn select_menu_item(&mut self) {
|
|
// Welcome now hosts the dashboard inline, so the menu enters
|
|
// *destinations* only — Chat, Schedule, and three placeholders.
|
|
// Items marked (coming soon) are no-ops until their screens are real.
|
|
match self.menu_selected {
|
|
0 => {
|
|
// Chat — lazily connect to a backend on first entry.
|
|
if self.chat.is_none() {
|
|
match ChatState::connect(self.config.clone(), &self.agent_pref).await {
|
|
Ok(mut c) => {
|
|
// Fresh chat surface (first entry, or after an
|
|
// agent switch): offer resume-or-new.
|
|
c.offer_resume_or_new();
|
|
self.chat = Some(c);
|
|
self.chat_error = None;
|
|
}
|
|
Err(e) => {
|
|
self.chat_error = Some(e.to_string());
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
self.current_screen = Screen::Chat;
|
|
}
|
|
1 => {
|
|
if self.schedules.is_none() {
|
|
self.schedules = Some(self.build_schedules_view().await);
|
|
self.sync_palette();
|
|
} else if let Some(view) = self.schedules.as_mut() {
|
|
view.reload();
|
|
}
|
|
self.current_screen = Screen::Cron;
|
|
}
|
|
// 2 Settings, 3 Therapy, 4 Agent Time.
|
|
2 => {
|
|
// Lazily init settings from the current config snapshot.
|
|
if self.settings.is_none() {
|
|
let cfg = self.config.read().await.clone();
|
|
let mut view = crate::ui::settings::SettingsView::new(&cfg);
|
|
let agent_id = self.agent_id_by_name(&self.agent_pref);
|
|
let expr_path = agent_id.as_ref()
|
|
.and_then(|id| Self::agent_assets_dir(id))
|
|
.map(|a| a.join("expressions"));
|
|
view.set_expressions_path(expr_path);
|
|
self.settings = Some(view);
|
|
self.sync_palette();
|
|
} else {
|
|
let cfg = self.config.read().await.clone();
|
|
let agent_id = self.agent_id_by_name(&self.agent_pref);
|
|
let expr_path = agent_id.as_ref()
|
|
.and_then(|id| Self::agent_assets_dir(id))
|
|
.map(|a| a.join("expressions"));
|
|
if let Some(view) = self.settings.as_mut() {
|
|
view.refresh(&cfg);
|
|
view.set_expressions_path(expr_path);
|
|
}
|
|
}
|
|
// Per-agent settings follow the active agent — load its model
|
|
// so the Agent category edits this agent and tracks switches.
|
|
let active = self.active_agent_settings();
|
|
if let Some(view) = self.settings.as_mut() {
|
|
view.set_active_agent(active);
|
|
}
|
|
self.current_screen = Screen::Settings;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Resolve the agent's schedules directory, preferring its UUID under
|
|
/// `~/.souveraine/agents/{id}/schedules/`. Falls back to the agent
|
|
/// name if the inventory isn't reachable — the CLI uses the same
|
|
/// path pattern, so a hand-managed dir keyed by name still works.
|
|
pub(super) async fn build_schedules_view(&self) -> crate::ui::schedules::SchedulesView {
|
|
use crate::backend::Backend;
|
|
|
|
let base = dirs::home_dir()
|
|
.unwrap_or_default()
|
|
.join(".souveraine")
|
|
.join("agents");
|
|
|
|
let cfg = self.config.read().await;
|
|
let url = cfg.server.effective_url();
|
|
drop(cfg);
|
|
|
|
let mut resolved_id: Option<String> = None;
|
|
let remote = crate::backend::RemoteBackend::new(&url).ok();
|
|
if let Some(ref remote) = remote {
|
|
if remote.health().await {
|
|
if let Ok(list) = remote.list_agents().await {
|
|
resolved_id = list
|
|
.iter()
|
|
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
|
.map(|a| a.id.clone());
|
|
}
|
|
}
|
|
}
|
|
if resolved_id.is_none() {
|
|
let cfg = self.config.read().await.clone();
|
|
if let Ok(local) = crate::backend::LocalBackend::new(cfg).await {
|
|
if let Ok(list) = local.list_agents().await {
|
|
resolved_id = list
|
|
.iter()
|
|
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
|
.map(|a| a.id.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
let dir_key = resolved_id.unwrap_or_else(|| self.agent_pref.clone());
|
|
let dir = base.join(&dir_key).join("schedules");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
crate::ui::schedules::SchedulesView::new(self.agent_pref.clone(), dir)
|
|
}
|
|
|
|
/// Detect what state the installation is in using the BootstrapPlan.
|
|
pub(super) async fn transition_from_splash(&mut self) {
|
|
let home = dirs::home_dir().unwrap_or_default();
|
|
let probe = crate::core::bootstrap::gather_probe(&home);
|
|
let plan = crate::core::bootstrap::BootstrapPlan::plan(&probe);
|
|
|
|
for phase in &plan.phases {
|
|
match phase {
|
|
crate::core::bootstrap::BootstrapPhase::SetupWizard(flow) => {
|
|
// No default model — let user type or fetch from Bifrost.
|
|
self.setup_state = Some(SetupState::new(*flow, ""));
|
|
self.current_screen = Screen::Setup;
|
|
self.dispatch(TuiEvent::ScreenChanged(Screen::Setup));
|
|
return;
|
|
}
|
|
crate::core::bootstrap::BootstrapPhase::ShowHint(hint) => {
|
|
// Store the hint for display on the Welcome screen.
|
|
// The dashboard reads it from a field we'll add below.
|
|
self.welcome_hint = Some(hint.clone());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
// Default: go to Welcome dashboard
|
|
self.refresh_dashboard().await;
|
|
self.current_screen = Screen::Welcome;
|
|
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
|
}
|
|
|
|
/// Called when the setup wizard completes or user skips to dashboard.
|
|
/// If the wizard collected an agent name, creates the agent via LocalBackend.
|
|
pub(super) async fn finish_setup(&mut self) {
|
|
use crate::backend::LocalBackend;
|
|
|
|
if let Some(ref mut setup) = self.setup_state.take() {
|
|
// If the wizard got far enough to name an agent, create it.
|
|
if !setup.agent_name.is_empty() && !setup.complete {
|
|
// Agent was configured but setup was skipped mid-way (Esc from Welcome)
|
|
// — don't create, just go to dashboard.
|
|
} else if setup.complete && !setup.agent_name.is_empty() && setup.created_agent_id.is_none() {
|
|
// Persist the Bifrost settings the wizard collected, otherwise
|
|
// they are lost and the next launch has no config.
|
|
{
|
|
let mut cfg = self.config.write().await;
|
|
cfg.bifrost.base_url = setup.bifrost_url.clone();
|
|
cfg.bifrost.primary_model = setup.model_handle.clone();
|
|
}
|
|
let key = setup.bifrost_key.trim();
|
|
if !key.is_empty() {
|
|
if let Err(e) = crate::core::credentials::store_bifrost_key(key) {
|
|
warn!("setup wizard could not store Bifrost key in keyring: {}", e);
|
|
}
|
|
}
|
|
{
|
|
let path = self.config_path.clone().unwrap_or_else(|| {
|
|
let home = dirs::home_dir().unwrap_or_default();
|
|
let dir = home.join(".souveraine");
|
|
let _ = std::fs::create_dir_all(&dir);
|
|
dir.join("config.toml")
|
|
});
|
|
let cfg = self.config.read().await;
|
|
if let Err(e) = cfg.save(&path) {
|
|
warn!("setup wizard could not save config to {}: {}", path.display(), e);
|
|
}
|
|
}
|
|
// Create the agent via LocalBackend against the updated config.
|
|
match LocalBackend::new(self.config.read().await.clone()).await {
|
|
Ok(backend) => {
|
|
let request = setup.build_create_request();
|
|
match backend.server_agents().create(request).await {
|
|
Ok(agent) => {
|
|
setup.created_agent_id = Some(agent.id.clone());
|
|
self.agent_pref = agent.name.clone();
|
|
info!("setup wizard created agent {} ({})", agent.name, agent.id);
|
|
}
|
|
Err(e) => {
|
|
setup.creation_error = Some(e.to_string());
|
|
warn!("setup wizard agent creation failed: {}", e);
|
|
// Still continue to dashboard — user can retry there
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
warn!("setup wizard backend init failed: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self.setup_state = None;
|
|
self.welcome_hint = None;
|
|
self.refresh_dashboard().await;
|
|
self.current_screen = Screen::Welcome;
|
|
self.dispatch(TuiEvent::ScreenChanged(Screen::Welcome));
|
|
}
|
|
|
|
/// Best-effort fetch of dashboard data from whichever backend is reachable.
|
|
/// Local mode also pulls recent git commits from the agent's memory repo.
|
|
pub(super) async fn refresh_dashboard(&mut self) {
|
|
use crate::backend::Backend;
|
|
|
|
let cfg = self.config.read().await;
|
|
let url = cfg.server.effective_url();
|
|
drop(cfg);
|
|
|
|
// Try remote first; fall back to local. Mirror of resolve_backend logic.
|
|
let remote = crate::backend::RemoteBackend::new(&url).ok();
|
|
let (agents_result, mode, local_repo) = if let Some(ref remote) = remote {
|
|
if remote.health().await {
|
|
(remote.list_agents().await, "remote", None)
|
|
} else {
|
|
// Fall through to local
|
|
let cfg = self.config.read().await.clone();
|
|
match crate::backend::LocalBackend::new(cfg).await {
|
|
Ok(local) => {
|
|
let agents = local.list_agents().await;
|
|
let repo = if let Ok(list) = &agents {
|
|
list.iter().find(|a| a.name == self.agent_pref || a.id == self.agent_pref).or_else(|| list.first()).map(|a| local.server_agents().memory_repo(&a.id))
|
|
} else { None };
|
|
(agents, "local", repo)
|
|
}
|
|
Err(e) => {
|
|
self.agent_status.mood = format!("backend err: {}", e);
|
|
self.agent_status.mode = "—".to_string();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
let cfg = self.config.read().await.clone();
|
|
match crate::backend::LocalBackend::new(cfg).await {
|
|
Ok(local) => {
|
|
let agents = local.list_agents().await;
|
|
// Pull a MemoryRepo for the current agent (if it exists)
|
|
// through the LocalBackend's server inventory.
|
|
let repo = if let Ok(list) = &agents {
|
|
list.iter().find(|a| a.name == self.agent_pref || a.id == self.agent_pref).or_else(|| list.first()).map(|a| local.server_agents().memory_repo(&a.id))
|
|
} else { None };
|
|
(agents, "local", repo)
|
|
}
|
|
Err(e) => {
|
|
self.agent_status.mood = format!("backend err: {}", e);
|
|
self.agent_status.mode = "—".to_string();
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
let agents = match agents_result {
|
|
Ok(a) => a,
|
|
Err(e) => {
|
|
self.agent_status.mood = format!("list err: {}", e);
|
|
self.agent_status.mode = mode.to_string();
|
|
return;
|
|
}
|
|
};
|
|
|
|
let chosen = agents
|
|
.iter()
|
|
.find(|a| a.name == self.agent_pref || a.id == self.agent_pref)
|
|
.or_else(|| agents.first());
|
|
|
|
self.agent_status.mode = mode.to_string();
|
|
self.agent_status.agent_count = agents.len();
|
|
if let Some(a) = chosen {
|
|
self.agent_status.name = a.name.clone();
|
|
self.agent_pref = a.name.clone(); // sync the active pref
|
|
self.agent_status.subconscious_active = true;
|
|
// Presence learns about the agent through the event stream below.
|
|
}
|
|
|
|
// Local mode: pull memory repo stats.
|
|
if let Some(repo) = local_repo {
|
|
if let Ok(status) = repo.status() {
|
|
self.agent_status.memory_commits = status.file_count as u32;
|
|
self.agent_status.last_commit = status.last_commit.clone();
|
|
}
|
|
// Walk the git log for the recent-activity list.
|
|
self.agent_status.recent_activity = recent_commits(&repo, 8).unwrap_or_default();
|
|
// Try to load a per-agent portrait from {memfs_root}/assets/.
|
|
// No-op if the file is absent — Presence falls back to the
|
|
// hand-crafted Annie grid.
|
|
self.presence.load_portrait_from_memfs(repo.root());
|
|
// Also load a real-image protocol for terminals that support
|
|
// kitty/sixel. Non-fatal: the half-block portrait is always
|
|
// available as fallback.
|
|
self.load_image_protocol_from_memfs(repo.root());
|
|
|
|
// Try to load a 3D portrait via RGP (ratty terminal).
|
|
if self.rgp_available {
|
|
let assets = repo.root().join("assets");
|
|
self.rgp_portrait = crate::ui::rgp::load_portrait_glb(&assets);
|
|
}
|
|
|
|
// Read the agent's last explicit atmosphere from
|
|
// system/preferences/visual.md and restore the chrome. The agent
|
|
// writes this when she uses the atmosphere tool; the TUI reads it
|
|
// at conversation start so her choice survives restarts.
|
|
let pref_path = repo.root().join("system").join("preferences").join("visual.md");
|
|
if let Ok(content) = std::fs::read_to_string(&pref_path) {
|
|
if let Some(body) = content.strip_prefix("---\n") {
|
|
if let Some(end) = body.find("\n---\n") {
|
|
for line in body[..end].lines() {
|
|
if let Some((key, val)) = line.split_once(':') {
|
|
let key = key.trim();
|
|
let val = val.trim().trim_matches('"');
|
|
if key == "atmosphere" {
|
|
if let Some(atm) = crate::ui::atmosphere::Atmosphere::from_name(val) {
|
|
self.presence.transition_atmosphere(atm);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Read energy balance from the agent's memfs. The file is written
|
|
// by the backend after every turn (write_energy_balance in local.rs).
|
|
// Parse the YAML frontmatter for generative/consumptive counts and
|
|
// seed the presence gauge so the TUI reflects real agent state.
|
|
let balance_path = repo.root().join("system").join("dynamic").join("energy-balance.md");
|
|
if let Ok(content) = std::fs::read_to_string(&balance_path) {
|
|
let mut gen: u32 = 0;
|
|
let mut con: u32 = 0;
|
|
let mut hot: u32 = 0;
|
|
let mut cold: u32 = 0;
|
|
if let Some(body) = content.strip_prefix("---\n") {
|
|
if let Some(end) = body.find("\n---\n") {
|
|
for line in body[..end].lines() {
|
|
if let Some((key, val)) = line.split_once(':') {
|
|
let key = key.trim();
|
|
let val = val.trim().trim_matches('"');
|
|
match key {
|
|
"generative" => gen = val.parse().unwrap_or(0),
|
|
"consumptive" => con = val.parse().unwrap_or(0),
|
|
"hot" => hot = val.parse().unwrap_or(0),
|
|
"cold" => cold = val.parse().unwrap_or(0),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.presence.volition = crate::ui::presence::VolitionGauge {
|
|
generative: gen,
|
|
consumptive: con,
|
|
hot_desires: hot,
|
|
cold_obligations: cold,
|
|
};
|
|
}
|
|
} else {
|
|
self.agent_status.recent_activity = vec![
|
|
format!("[{}] connected via {}", short_now(), mode),
|
|
format!("agents on backend: {}", agents.len()),
|
|
];
|
|
}
|
|
|
|
// Mood = most recent surfacing activity if any, else "Idle".
|
|
self.agent_status.mood = if self.agent_status.recent_activity.is_empty() {
|
|
"Idle".to_string()
|
|
} else {
|
|
"Active".to_string()
|
|
};
|
|
// Energy stub: derive from agent count (cosmetic).
|
|
self.agent_status.energy = ((self.agent_status.agent_count.min(10)) * 10) as u8;
|
|
|
|
// Dispatch state changes to all listeners (scene components + Presence).
|
|
// Presence updates its own internal state via handle_event; no polling.
|
|
let agent_name = self.agent_status.name.clone();
|
|
let energy = self.agent_status.energy;
|
|
let mood = self.agent_status.mood.clone();
|
|
let mode_str = mode.to_string();
|
|
self.dispatch(TuiEvent::AgentSelected(agent_name));
|
|
self.dispatch(TuiEvent::EnergyChanged(energy));
|
|
self.dispatch(TuiEvent::MoodChanged(mood));
|
|
self.dispatch(TuiEvent::BackendStatus {
|
|
mode: mode_str,
|
|
healthy: true,
|
|
});
|
|
}
|
|
|
|
}
|