//! Ratatui TUI for OpenFang interactive mode. //! //! Two-level navigation: Phase::Boot (Welcome/Wizard) → Phase::Main with 16 tabs. pub mod chat_runner; pub mod event; pub mod screens; pub mod theme; use event::{AppEvent, BackendRef}; use openfang_kernel::OpenFangKernel; use openfang_runtime::llm_driver::StreamEvent; use openfang_types::agent::AgentId; use screens::{ agents, audit, channels, chat, dashboard, extensions, hands, logs, memory, peers, security, sessions, settings, skills, templates, triggers, usage, welcome, wizard, workflows, }; use std::path::PathBuf; use std::sync::{mpsc, Arc}; use std::time::Duration; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Style; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; // ─── Core types ────────────────────────────────────────────────────────────── #[derive(Clone, Copy, PartialEq, Eq)] enum Phase { Boot(BootScreen), Main, } #[derive(Clone, Copy, PartialEq, Eq)] enum BootScreen { Welcome, Wizard, } #[derive(Clone, Copy, PartialEq, Eq)] enum Tab { Dashboard, Agents, Chat, Sessions, Workflows, Triggers, Memory, Channels, Skills, Hands, Extensions, Templates, Peers, Security, Audit, Usage, Settings, Logs, } const TABS: &[Tab] = &[ Tab::Dashboard, Tab::Agents, Tab::Chat, Tab::Sessions, Tab::Workflows, Tab::Triggers, Tab::Memory, Tab::Channels, Tab::Skills, Tab::Hands, Tab::Extensions, Tab::Templates, Tab::Peers, Tab::Security, Tab::Audit, Tab::Usage, Tab::Settings, Tab::Logs, ]; impl Tab { fn label(self) -> &'static str { match self { Tab::Dashboard => "Dashboard", Tab::Agents => "Agents", Tab::Chat => "Chat", Tab::Sessions => "Sessions", Tab::Workflows => "Workflows", Tab::Triggers => "Triggers", Tab::Memory => "Memory", Tab::Channels => "Channels", Tab::Skills => "Skills", Tab::Hands => "Hands", Tab::Extensions => "Extensions", Tab::Templates => "Templates", Tab::Peers => "Peers", Tab::Security => "Security", Tab::Audit => "Audit", Tab::Usage => "Usage", Tab::Settings => "Settings", Tab::Logs => "Logs", } } fn index(self) -> usize { TABS.iter().position(|&t| t == self).unwrap_or(0) } } enum Backend { Daemon { base_url: String }, InProcess { kernel: Arc }, None, } impl Backend { fn to_ref(&self) -> Option { match self { Backend::Daemon { base_url } => Some(BackendRef::Daemon(base_url.clone())), Backend::InProcess { kernel } => Some(BackendRef::InProcess(kernel.clone())), Backend::None => None, } } } struct ChatTarget { agent_id_daemon: Option, agent_id_inprocess: Option, agent_name: String, } struct App { phase: Phase, active_tab: Tab, tab_scroll_offset: usize, config_path: Option, should_quit: bool, event_tx: mpsc::Sender, /// Double Ctrl+C quit: true after first Ctrl+C press. ctrl_c_pending: bool, /// Tick counter when first Ctrl+C was pressed (auto-resets after ~2s). ctrl_c_tick: usize, /// Global tick counter for Ctrl+C timeout tracking. tick_count: usize, backend: Backend, chat_target: Option, // Screen states welcome: welcome::WelcomeState, wizard: wizard::WizardState, agents: agents::AgentSelectState, chat: chat::ChatState, dashboard: dashboard::DashboardState, channels: channels::ChannelState, workflows: workflows::WorkflowState, triggers: triggers::TriggerState, sessions: sessions::SessionsState, memory: memory::MemoryState, skills: skills::SkillsState, hands: hands::HandsState, extensions: extensions::ExtensionsState, templates: templates::TemplatesState, security: security::SecurityState, audit: audit::AuditState, usage: usage::UsageState, settings: settings::SettingsState, peers: peers::PeersState, logs: logs::LogsState, kernel_booting: bool, kernel_boot_error: Option, } // ─── App construction ──────────────────────────────────────────────────────── impl App { fn new(config_path: Option, event_tx: mpsc::Sender) -> Self { Self { phase: Phase::Boot(BootScreen::Welcome), active_tab: Tab::Dashboard, tab_scroll_offset: 0, config_path, should_quit: false, event_tx, backend: Backend::None, chat_target: None, welcome: welcome::WelcomeState::new(), wizard: wizard::WizardState::new(), agents: agents::AgentSelectState::new(), chat: chat::ChatState::new(), dashboard: dashboard::DashboardState::new(), channels: channels::ChannelState::new(), workflows: workflows::WorkflowState::new(), triggers: triggers::TriggerState::new(), sessions: sessions::SessionsState::new(), memory: memory::MemoryState::new(), skills: skills::SkillsState::new(), hands: hands::HandsState::new(), extensions: extensions::ExtensionsState::new(), templates: templates::TemplatesState::new(), security: security::SecurityState::new(), audit: audit::AuditState::new(), usage: usage::UsageState::new(), settings: settings::SettingsState::new(), peers: peers::PeersState::new(), logs: logs::LogsState::new(), kernel_booting: false, kernel_boot_error: None, ctrl_c_pending: false, ctrl_c_tick: 0, tick_count: 0, } } // ─── Event dispatch ────────────────────────────────────────────────────── fn handle_event(&mut self, ev: AppEvent) { match ev { AppEvent::Key(key) => self.handle_key(key), AppEvent::Tick => self.handle_tick(), AppEvent::Stream(stream_ev) => self.handle_stream(stream_ev), AppEvent::StreamDone(result) => self.handle_stream_done(result), AppEvent::KernelReady(kernel) => self.handle_kernel_ready(kernel), AppEvent::KernelError(err) => self.handle_kernel_error(err), AppEvent::AgentSpawned { id, name } => self.handle_agent_spawned(id, name), AppEvent::AgentSpawnError(err) => self.handle_agent_spawn_error(err), AppEvent::DaemonDetected { url, agent_count } => { self.welcome.on_daemon_detected(url, agent_count); } // ── New tab events ── AppEvent::DashboardData { agent_count, uptime_secs, version, provider, model, } => { self.dashboard.agent_count = agent_count; self.dashboard.uptime_secs = uptime_secs; self.dashboard.version = version; self.dashboard.provider = provider; self.dashboard.model = model; self.dashboard.loading = false; } AppEvent::AuditLoaded(rows) => { self.dashboard.recent_audit = rows; self.dashboard.loading = false; } AppEvent::ChannelListLoaded(list) => { if !list.is_empty() { self.channels.channels = list; self.channels.list_state.select(Some(0)); } self.channels.loading = false; } AppEvent::ChannelTestResult { success, message } => { self.channels.test_result = Some((success, message)); } AppEvent::WorkflowListLoaded(list) => { self.workflows.workflows = list; if !self.workflows.workflows.is_empty() { self.workflows.list_state.select(Some(0)); } self.workflows.loading = false; } AppEvent::WorkflowRunsLoaded(runs) => { self.workflows.runs = runs; if !self.workflows.runs.is_empty() { self.workflows.runs_list_state.select(Some(0)); } self.workflows.loading = false; } AppEvent::WorkflowRunResult(result) => { self.workflows.run_result = Some(result); self.workflows.loading = false; } AppEvent::WorkflowCreated(_id) => { self.workflows.status_msg = "Workflow created!".to_string(); self.refresh_workflows(); } AppEvent::TriggerListLoaded(list) => { self.triggers.triggers = list; if !self.triggers.triggers.is_empty() { self.triggers.list_state.select(Some(0)); } self.triggers.loading = false; } AppEvent::TriggerCreated(_id) => { self.triggers.status_msg = "Trigger created!".to_string(); self.refresh_triggers(); } AppEvent::TriggerDeleted(id) => { self.triggers.triggers.retain(|t| t.id != id); self.triggers.status_msg = format!("Trigger {id} deleted."); } AppEvent::AgentKilled { id } => { self.agents.status_msg = format!("Agent {id} killed."); self.agents.sub = agents::AgentSubScreen::AgentList; self.refresh_agents(); } AppEvent::AgentKillError(err) => { self.agents.status_msg = format!("Kill failed: {err}"); } AppEvent::AgentSkillsLoaded { assigned, available, } => { // Populate skill editor: mark assigned skills as checked self.agents.available_skills = available .into_iter() .map(|name| { let checked = assigned.contains(&name); (name, checked) }) .collect(); self.agents.skill_cursor = 0; } AppEvent::AgentMcpServersLoaded { assigned, available, } => { // Populate MCP editor: mark assigned servers as checked self.agents.available_mcp = available .into_iter() .map(|name| { let checked = assigned.contains(&name); (name, checked) }) .collect(); self.agents.mcp_cursor = 0; } AppEvent::AgentSkillsUpdated(id) => { self.agents.status_msg = format!("Skills updated for agent {id}."); self.agents.sub = agents::AgentSubScreen::AgentDetail; } AppEvent::AgentMcpServersUpdated(id) => { self.agents.status_msg = format!("MCP servers updated for agent {id}."); self.agents.sub = agents::AgentSubScreen::AgentDetail; } AppEvent::FetchError(err) => { // Route to the active tab's status message match self.active_tab { Tab::Workflows => self.workflows.status_msg = err, Tab::Triggers => self.triggers.status_msg = err, Tab::Channels => self.channels.status_msg = err, Tab::Sessions => self.sessions.status_msg = err, Tab::Memory => self.memory.status_msg = err, Tab::Skills => self.skills.status_msg = err, Tab::Hands => self.hands.status_msg = err, Tab::Extensions => self.extensions.status_msg = err, Tab::Templates => self.templates.status_msg = err, Tab::Settings => self.settings.status_msg = err, _ => {} } } // ── New screen events ── AppEvent::SessionsLoaded(list) => { self.sessions.sessions = list; self.sessions.refilter(); self.sessions.loading = false; } AppEvent::SessionDeleted(id) => { self.sessions.sessions.retain(|s| s.id != id); self.sessions.refilter(); self.sessions.status_msg = format!("Session {id} deleted."); } AppEvent::MemoryAgentsLoaded(agents) => { self.memory.agents = agents; if !self.memory.agents.is_empty() { self.memory.agent_list_state.select(Some(0)); } self.memory.loading = false; } AppEvent::MemoryKvLoaded(pairs) => { self.memory.kv_pairs = pairs; if !self.memory.kv_pairs.is_empty() { self.memory.kv_list_state.select(Some(0)); } self.memory.loading = false; } AppEvent::MemoryKvSaved { key } => { self.memory.status_msg = format!("Saved key: {key}"); // Refresh KV pairs if let Some(agent) = &self.memory.selected_agent { if let Some(backend) = self.backend.to_ref() { event::spawn_fetch_memory_kv( backend, agent.id.clone(), self.event_tx.clone(), ); } } } AppEvent::MemoryKvDeleted(key) => { self.memory.kv_pairs.retain(|kv| kv.key != key); self.memory.status_msg = format!("Deleted key: {key}"); } AppEvent::SkillsLoaded(list) => { self.skills.installed = list; if !self.skills.installed.is_empty() { self.skills.installed_list.select(Some(0)); } self.skills.loading = false; } AppEvent::ClawHubLoaded(results) => { self.skills.clawhub_results = results; if !self.skills.clawhub_results.is_empty() { self.skills.clawhub_list.select(Some(0)); } self.skills.loading = false; } AppEvent::SkillInstalled(name) => { self.skills.status_msg = format!("Installed: {name}"); self.refresh_skills(); } AppEvent::SkillUninstalled(name) => { self.skills.installed.retain(|s| s.name != name); self.skills.status_msg = format!("Uninstalled: {name}"); } AppEvent::McpServersLoaded(servers) => { self.skills.mcp_servers = servers; if !self.skills.mcp_servers.is_empty() { self.skills.mcp_list.select(Some(0)); } self.skills.loading = false; } AppEvent::TemplateProvidersLoaded(providers) => { self.templates.providers = providers; } AppEvent::SecurityLoaded(features) => { self.security.features = features; self.security.loading = false; } AppEvent::SecurityChainVerified { valid, message } => { self.security.chain_verified = Some(valid); self.security.verify_result = message; self.security.loading = false; } AppEvent::AuditEntriesLoaded(entries) => { self.audit.entries = entries; self.audit.refilter(); self.audit.loading = false; } AppEvent::AuditChainVerified(valid) => { self.audit.chain_verified = Some(valid); } AppEvent::UsageSummaryLoaded(summary) => { self.usage.summary = summary; self.usage.loading = false; } AppEvent::UsageByModelLoaded(models) => { self.usage.by_model = models; if !self.usage.by_model.is_empty() { self.usage.model_list.select(Some(0)); } } AppEvent::UsageByAgentLoaded(agents) => { self.usage.by_agent = agents; if !self.usage.by_agent.is_empty() { self.usage.agent_list.select(Some(0)); } } AppEvent::SettingsProvidersLoaded(providers) => { self.settings.providers = providers; if !self.settings.providers.is_empty() { self.settings.provider_list.select(Some(0)); } self.settings.loading = false; } AppEvent::SettingsModelsLoaded(models) => { self.settings.models = models; if !self.settings.models.is_empty() { self.settings.model_list.select(Some(0)); } self.settings.loading = false; } AppEvent::SettingsToolsLoaded(tools) => { self.settings.tools = tools; if !self.settings.tools.is_empty() { self.settings.tool_list.select(Some(0)); } self.settings.loading = false; } AppEvent::ProviderKeySaved(name) => { self.settings.status_msg = format!("Key saved for {name}"); self.refresh_settings_providers(); } AppEvent::ProviderKeyDeleted(name) => { self.settings.status_msg = format!("Key deleted for {name}"); self.refresh_settings_providers(); } AppEvent::ProviderTestResult(result) => { self.settings.test_result = Some(result); } AppEvent::PeersLoaded(list) => { self.peers.peers = list; if !self.peers.peers.is_empty() && self.peers.list_state.selected().is_none() { self.peers.list_state.select(Some(0)); } self.peers.loading = false; } AppEvent::LogsLoaded(entries) => { self.logs.entries = entries; self.logs.refilter(); self.logs.loading = false; } AppEvent::HandsLoaded(list) => { self.hands.definitions = list; if !self.hands.definitions.is_empty() { self.hands.marketplace_list.select(Some(0)); } self.hands.loading = false; } AppEvent::ActiveHandsLoaded(list) => { self.hands.instances = list; if !self.hands.instances.is_empty() && self.hands.active_list.selected().is_none() { self.hands.active_list.select(Some(0)); } self.hands.loading = false; } AppEvent::HandActivated(name) => { self.hands.status_msg = format!("Activated: {name}"); self.refresh_hands(); } AppEvent::HandDeactivated(id) => { self.hands.instances.retain(|i| i.instance_id != id); self.hands.status_msg = format!("Deactivated: {id}"); } AppEvent::HandPaused(id) => { if let Some(inst) = self .hands .instances .iter_mut() .find(|i| i.instance_id == id) { inst.status = "Paused".to_string(); } self.hands.status_msg = "Hand paused".to_string(); } AppEvent::HandResumed(id) => { if let Some(inst) = self .hands .instances .iter_mut() .find(|i| i.instance_id == id) { inst.status = "Active".to_string(); } self.hands.status_msg = "Hand resumed".to_string(); } AppEvent::ExtensionsLoaded(list) => { self.extensions.all_extensions = list; if !self.extensions.all_extensions.is_empty() && self.extensions.browse_list.selected().is_none() { self.extensions.browse_list.select(Some(0)); } self.extensions.loading = false; } AppEvent::ExtensionHealthLoaded(entries) => { self.extensions.health_entries = entries; if !self.extensions.health_entries.is_empty() && self.extensions.health_list.selected().is_none() { self.extensions.health_list.select(Some(0)); } } AppEvent::ExtensionInstalled(id) => { self.extensions.status_msg = format!("Installed: {id}"); self.refresh_extensions(); } AppEvent::ExtensionRemoved(id) => { self.extensions.status_msg = format!("Removed: {id}"); self.refresh_extensions(); } AppEvent::ExtensionReconnected(id, tools) => { self.extensions.status_msg = format!("Reconnected {id}: {tools} tools"); self.refresh_extension_health(); } } } fn handle_key(&mut self, key: ratatui::crossterm::event::KeyEvent) { use ratatui::crossterm::event::{KeyCode, KeyModifiers}; // ── Global: Double Ctrl+C to quit (all phases) ────────────────────── let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); if is_ctrl_c { if self.ctrl_c_pending { self.should_quit = true; return; } self.ctrl_c_pending = true; self.ctrl_c_tick = self.tick_count; // In Main phase, don't pass the first Ctrl+C to screen handlers — // just show the "press again to quit" hint (rendered in status bar). if matches!(self.phase, Phase::Main) { return; } // In Boot phase, let it fall through to the welcome/wizard handler // which has its own double-Ctrl+C logic. } else { // Any other key clears the pending Ctrl+C state self.ctrl_c_pending = false; } // ── Global: Ctrl+Q quit from Main phase ───────────────────────────── if matches!(self.phase, Phase::Main) { if key.code == KeyCode::Char('q') && key.modifiers.contains(KeyModifiers::CONTROL) { self.should_quit = true; return; } // Tab switching: F1-F12 for direct jump (reliable on all terminals) match key.code { KeyCode::F(1) => { self.switch_tab(Tab::Dashboard); return; } KeyCode::F(2) => { self.switch_tab(Tab::Agents); return; } KeyCode::F(3) => { self.switch_tab(Tab::Chat); return; } KeyCode::F(4) => { self.switch_tab(Tab::Sessions); return; } KeyCode::F(5) => { self.switch_tab(Tab::Workflows); return; } KeyCode::F(6) => { self.switch_tab(Tab::Triggers); return; } KeyCode::F(7) => { self.switch_tab(Tab::Memory); return; } KeyCode::F(8) => { self.switch_tab(Tab::Channels); return; } KeyCode::F(9) => { self.switch_tab(Tab::Skills); return; } KeyCode::F(10) => { self.switch_tab(Tab::Templates); return; } KeyCode::F(11) => { self.switch_tab(Tab::Peers); return; } KeyCode::F(12) => { self.switch_tab(Tab::Security); return; } _ => {} } // Tab cycling: Tab / Shift+Tab if key.code == KeyCode::Tab && key.modifiers.is_empty() { self.next_tab(); return; } if key.code == KeyCode::BackTab { self.prev_tab(); return; } // Tab cycling: Ctrl+Left/Right if key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { KeyCode::Left => { self.prev_tab(); return; } KeyCode::Right => { self.next_tab(); return; } _ => {} } } // Tab cycling: Ctrl+[ / Ctrl+] (reliable on MINGW/Windows terminals) if key.modifiers.contains(KeyModifiers::CONTROL) { match key.code { KeyCode::Char('[') => { self.prev_tab(); return; } KeyCode::Char(']') => { self.next_tab(); return; } _ => {} } } // Fallback: Alt+1-9,0 if key.modifiers.contains(KeyModifiers::ALT) { match key.code { KeyCode::Char('1') => { self.switch_tab(Tab::Dashboard); return; } KeyCode::Char('2') => { self.switch_tab(Tab::Agents); return; } KeyCode::Char('3') => { self.switch_tab(Tab::Chat); return; } KeyCode::Char('4') => { self.switch_tab(Tab::Sessions); return; } KeyCode::Char('5') => { self.switch_tab(Tab::Workflows); return; } KeyCode::Char('6') => { self.switch_tab(Tab::Triggers); return; } KeyCode::Char('7') => { self.switch_tab(Tab::Memory); return; } KeyCode::Char('8') => { self.switch_tab(Tab::Channels); return; } KeyCode::Char('9') => { self.switch_tab(Tab::Skills); return; } KeyCode::Char('0') => { self.switch_tab(Tab::Templates); return; } _ => {} } } } // ── Route to screen handler ───────────────────────────────────────── match self.phase { Phase::Boot(BootScreen::Welcome) => { if let Some(action) = self.welcome.handle_key(key) { self.handle_welcome_action(action); } } Phase::Boot(BootScreen::Wizard) => match self.wizard.handle_key(key) { wizard::WizardResult::Cancelled => { self.phase = Phase::Boot(BootScreen::Welcome); self.start_daemon_detect(); } wizard::WizardResult::Continue => { if self.wizard.step == wizard::WizardStep::Done && self.wizard.created_config.is_some() { self.config_path = self.wizard.created_config.clone(); self.welcome.setup_just_completed = true; self.phase = Phase::Boot(BootScreen::Welcome); self.start_daemon_detect(); } } }, Phase::Main => match self.active_tab { Tab::Dashboard => { let action = self.dashboard.handle_key(key); self.handle_dashboard_action(action); } Tab::Agents => { let action = self.agents.handle_key(key); self.handle_agent_action(action); } Tab::Chat => { let action = self.chat.handle_key(key); self.handle_chat_action(action); } Tab::Channels => { let action = self.channels.handle_key(key); self.handle_channel_action(action); } Tab::Workflows => { let action = self.workflows.handle_key(key); self.handle_workflow_action(action); } Tab::Triggers => { let action = self.triggers.handle_key(key); self.handle_trigger_action(action); } Tab::Sessions => { let action = self.sessions.handle_key(key); self.handle_sessions_action(action); } Tab::Memory => { let action = self.memory.handle_key(key); self.handle_memory_action(action); } Tab::Skills => { let action = self.skills.handle_key(key); self.handle_skills_action(action); } Tab::Extensions => { let action = self.extensions.handle_key(key); self.handle_extensions_action(action); } Tab::Hands => { let action = self.hands.handle_key(key); self.handle_hands_action(action); } Tab::Templates => { let action = self.templates.handle_key(key); self.handle_templates_action(action); } Tab::Security => { let action = self.security.handle_key(key); self.handle_security_action(action); } Tab::Audit => { let action = self.audit.handle_key(key); self.handle_audit_action(action); } Tab::Usage => { let action = self.usage.handle_key(key); self.handle_usage_action(action); } Tab::Settings => { let action = self.settings.handle_key(key); self.handle_settings_action(action); } Tab::Peers => { let action = self.peers.handle_key(key); self.handle_peers_action(action); } Tab::Logs => { let action = self.logs.handle_key(key); self.handle_logs_action(action); } }, } } fn handle_tick(&mut self) { self.tick_count = self.tick_count.wrapping_add(1); // Auto-reset Ctrl+C pending after ~2s (40 ticks at 50ms) if self.ctrl_c_pending && self.tick_count.wrapping_sub(self.ctrl_c_tick) > 40 { self.ctrl_c_pending = false; } self.welcome.tick(); self.chat.tick(); self.dashboard.tick(); self.channels.tick(); self.workflows.tick(); self.triggers.tick(); self.sessions.tick(); self.memory.tick(); self.skills.tick(); self.hands.tick(); self.extensions.tick(); self.templates.tick(); self.security.tick(); self.audit.tick(); self.usage.tick(); self.settings.tick(); self.peers.tick(); self.logs.tick(); // Auto-poll for active tabs if self.phase == Phase::Main { match self.active_tab { Tab::Logs if self.logs.should_poll() => self.refresh_logs(), Tab::Peers if self.peers.should_poll() => self.refresh_peers(), _ => {} } } } // ─── Tab navigation ────────────────────────────────────────────────────── fn next_tab(&mut self) { let idx = self.active_tab.index(); let next = (idx + 1) % TABS.len(); self.switch_tab(TABS[next]); } fn prev_tab(&mut self) { let idx = self.active_tab.index(); let prev = if idx == 0 { TABS.len() - 1 } else { idx - 1 }; self.switch_tab(TABS[prev]); } fn switch_tab(&mut self, tab: Tab) { self.active_tab = tab; // Keep active tab visible in the scrollable tab bar let idx = tab.index(); if idx < self.tab_scroll_offset { self.tab_scroll_offset = idx; } // Will be further adjusted during draw based on actual width self.on_tab_enter(tab); } /// Called when a tab becomes active — load data if needed. fn on_tab_enter(&mut self, tab: Tab) { match tab { Tab::Dashboard => self.refresh_dashboard(), Tab::Agents => self.refresh_agents(), Tab::Channels => self.refresh_channels(), Tab::Workflows => self.refresh_workflows(), Tab::Triggers => self.refresh_triggers(), Tab::Sessions => self.refresh_sessions(), Tab::Memory => self.refresh_memory(), Tab::Skills => self.refresh_skills(), Tab::Hands => self.refresh_hands(), Tab::Extensions => self.refresh_extensions(), Tab::Templates => self.refresh_templates(), Tab::Security => self.refresh_security(), Tab::Audit => self.refresh_audit(), Tab::Usage => self.refresh_usage(), Tab::Settings => self.refresh_settings_providers(), Tab::Peers => self.refresh_peers(), Tab::Logs => self.refresh_logs(), Tab::Chat => {} // Chat doesn't need refresh on enter } } /// Transition from Boot to Main phase. fn enter_main_phase(&mut self) { self.phase = Phase::Main; self.active_tab = Tab::Agents; // Load initial data for visible tabs self.refresh_agents(); self.refresh_dashboard(); self.refresh_channels(); } // ─── Data refresh helpers ──────────────────────────────────────────────── fn refresh_dashboard(&mut self) { if let Some(backend) = self.backend.to_ref() { self.dashboard.loading = true; event::spawn_fetch_dashboard(backend, self.event_tx.clone()); } } fn refresh_agents(&mut self) { match &self.backend { Backend::Daemon { base_url } => { self.agents.load_daemon_agents(base_url); } Backend::InProcess { kernel } => { self.agents.load_inprocess_agents(kernel); } Backend::None => {} } } fn refresh_channels(&mut self) { if let Some(backend) = self.backend.to_ref() { self.channels.loading = true; event::spawn_fetch_channels(backend, self.event_tx.clone()); } // Also build defaults from env detection if self.channels.channels.is_empty() { self.channels.build_default_channels(); } } fn refresh_workflows(&mut self) { if let Some(backend) = self.backend.to_ref() { self.workflows.loading = true; event::spawn_fetch_workflows(backend, self.event_tx.clone()); } } fn refresh_triggers(&mut self) { if let Some(backend) = self.backend.to_ref() { self.triggers.loading = true; event::spawn_fetch_triggers(backend, self.event_tx.clone()); } } fn refresh_sessions(&mut self) { if let Some(backend) = self.backend.to_ref() { self.sessions.loading = true; event::spawn_fetch_sessions(backend, self.event_tx.clone()); } } fn refresh_memory(&mut self) { if let Some(backend) = self.backend.to_ref() { self.memory.loading = true; event::spawn_fetch_memory_agents(backend, self.event_tx.clone()); } } fn refresh_skills(&mut self) { if let Some(backend) = self.backend.to_ref() { self.skills.loading = true; event::spawn_fetch_skills(backend, self.event_tx.clone()); } } fn refresh_hands(&mut self) { if let Some(backend) = self.backend.to_ref() { self.hands.loading = true; event::spawn_fetch_hands(backend.clone(), self.event_tx.clone()); event::spawn_fetch_active_hands(backend, self.event_tx.clone()); } } fn refresh_extensions(&mut self) { if let Some(backend) = self.backend.to_ref() { self.extensions.loading = true; event::spawn_fetch_extensions(backend, self.event_tx.clone()); } } fn refresh_extension_health(&mut self) { if let Some(backend) = self.backend.to_ref() { event::spawn_fetch_extension_health(backend, self.event_tx.clone()); } } fn refresh_templates(&mut self) { if let Some(backend) = self.backend.to_ref() { event::spawn_fetch_template_providers(backend, self.event_tx.clone()); } } fn refresh_security(&mut self) { if let Some(backend) = self.backend.to_ref() { self.security.loading = true; event::spawn_fetch_security(backend, self.event_tx.clone()); } } fn refresh_audit(&mut self) { if let Some(backend) = self.backend.to_ref() { self.audit.loading = true; event::spawn_fetch_audit(backend, self.event_tx.clone()); } } fn refresh_usage(&mut self) { if let Some(backend) = self.backend.to_ref() { self.usage.loading = true; event::spawn_fetch_usage(backend, self.event_tx.clone()); } } fn refresh_settings_providers(&mut self) { if let Some(backend) = self.backend.to_ref() { self.settings.loading = true; event::spawn_fetch_providers(backend, self.event_tx.clone()); } } fn refresh_settings_models(&mut self) { if let Some(backend) = self.backend.to_ref() { self.settings.loading = true; event::spawn_fetch_models(backend, self.event_tx.clone()); } } fn refresh_settings_tools(&mut self) { if let Some(backend) = self.backend.to_ref() { event::spawn_fetch_tools(backend, self.event_tx.clone()); } } fn refresh_peers(&mut self) { if let Some(backend) = self.backend.to_ref() { self.peers.loading = true; event::spawn_fetch_peers(backend, self.event_tx.clone()); } } fn refresh_logs(&mut self) { if let Some(backend) = self.backend.to_ref() { self.logs.loading = true; event::spawn_fetch_logs(backend, self.event_tx.clone()); } } // ─── Streaming ─────────────────────────────────────────────────────────── fn handle_stream(&mut self, ev: StreamEvent) { match ev { StreamEvent::TextDelta { text } => { self.chat.thinking = false; if self.chat.active_tool.is_some() { self.chat.active_tool = None; } self.chat.append_stream(&text); } StreamEvent::ToolUseStart { name, .. } => { if !self.chat.streaming_text.is_empty() { let text = std::mem::take(&mut self.chat.streaming_text); self.chat.push_message(chat::Role::Agent, text); } self.chat.tool_start(&name); } StreamEvent::ToolInputDelta { text } => { self.chat.tool_input_buf.push_str(&text); } StreamEvent::ToolUseEnd { name, input, .. } => { let input_str = if !self.chat.tool_input_buf.is_empty() { std::mem::take(&mut self.chat.tool_input_buf) } else { serde_json::to_string(&input).unwrap_or_default() }; self.chat.tool_use_end(&name, &input_str); } StreamEvent::ContentComplete { usage, .. } => { self.chat.last_tokens = Some((usage.input_tokens, usage.output_tokens)); } StreamEvent::PhaseChange { phase, detail } => { if phase == "tool_use" { if let Some(tool_name) = detail { self.chat.tool_start(&tool_name); } } else if phase == "thinking" { self.chat.thinking = true; } } StreamEvent::ThinkingDelta { text } => { self.chat.thinking = true; self.chat.append_stream(&text); } StreamEvent::ToolExecutionResult { name, result_preview, is_error, } => { self.chat.tool_result(&name, &result_preview, is_error); } } } fn handle_stream_done( &mut self, result: Result, ) { self.chat.finalize_stream(); match result { Ok(r) => { // Only add if the response wasn't already streamed if !r.response.is_empty() && self.chat.messages.last().map(|m| m.text.as_str()) != Some(&r.response) { self.chat.push_message(chat::Role::Agent, r.response); } if r.total_usage.input_tokens > 0 || r.total_usage.output_tokens > 0 { self.chat.last_tokens = Some((r.total_usage.input_tokens, r.total_usage.output_tokens)); } } Err(e) => { self.chat.status_msg = Some(format!("Error: {e}")); } } // Auto-send the next staged message if any if let Some(msg) = self.chat.take_staged() { self.send_message(msg); } } // ─── Kernel lifecycle ──────────────────────────────────────────────────── fn handle_kernel_ready(&mut self, kernel: Arc) { self.kernel_booting = false; self.backend = Backend::InProcess { kernel }; self.agents.reset(); self.enter_main_phase(); } fn handle_kernel_error(&mut self, err: String) { self.kernel_booting = false; self.kernel_boot_error = Some(err.clone()); if err.contains("Missing API key") || err.contains("api_key") { self.wizard.reset(); self.phase = Phase::Boot(BootScreen::Wizard); } else { self.phase = Phase::Boot(BootScreen::Welcome); self.start_daemon_detect(); } } fn handle_agent_spawned(&mut self, id: String, name: String) { self.agents.sub = agents::AgentSubScreen::AgentList; self.enter_chat_daemon(id, name); } fn handle_agent_spawn_error(&mut self, err: String) { self.agents.status_msg = err; self.agents.sub = agents::AgentSubScreen::AgentList; } // ─── Screen transitions ────────────────────────────────────────────────── fn start_daemon_detect(&mut self) { self.welcome.detecting = true; event::spawn_daemon_detect(self.event_tx.clone()); } fn handle_welcome_action(&mut self, action: welcome::WelcomeAction) { match action { welcome::WelcomeAction::Exit => self.should_quit = true, welcome::WelcomeAction::ConnectDaemon => { if let Some(ref url) = self.welcome.daemon_url { self.backend = Backend::Daemon { base_url: url.clone(), }; self.agents.reset(); self.enter_main_phase(); } } welcome::WelcomeAction::InProcess => { if self.kernel_booting { return; } self.kernel_booting = true; self.kernel_boot_error = None; event::spawn_kernel_boot(self.config_path.clone(), self.event_tx.clone()); } welcome::WelcomeAction::Wizard => { self.wizard.reset(); self.phase = Phase::Boot(BootScreen::Wizard); } } } // ─── Tab action handlers ───────────────────────────────────────────────── fn handle_dashboard_action(&mut self, action: dashboard::DashboardAction) { match action { dashboard::DashboardAction::Continue => {} dashboard::DashboardAction::Refresh => self.refresh_dashboard(), dashboard::DashboardAction::GoToAgents => { self.switch_tab(Tab::Agents); } } } fn handle_agent_action(&mut self, action: agents::AgentAction) { match action { agents::AgentAction::Continue => {} agents::AgentAction::Back => { // In Main phase, Esc from agents just stays on the tab } agents::AgentAction::CreatedManifest(toml_content) => { self.spawn_agent(toml_content); } agents::AgentAction::ChatWithAgent { id, name } => { // From detail view — enter chat with this agent if let Some(agent) = self.agents.daemon_agents.iter().find(|a| a.id == id) { self.enter_chat_daemon(agent.id.clone(), agent.name.clone()); } else if let Some(agent) = self .agents .inprocess_agents .iter() .find(|a| format!("{}", a.id) == id) { self.enter_chat_inprocess(agent.id, agent.name.clone()); } else { // Fallback: treat as daemon self.enter_chat_daemon(id, name); } } agents::AgentAction::KillAgent(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_kill_agent(backend, id, self.event_tx.clone()); } } agents::AgentAction::UpdateSkills { id, skills } => { if let Some(backend) = self.backend.to_ref() { event::spawn_update_agent_skills(backend, id, skills, self.event_tx.clone()); } } agents::AgentAction::UpdateMcpServers { id, servers } => { if let Some(backend) = self.backend.to_ref() { event::spawn_update_agent_mcp_servers( backend, id, servers, self.event_tx.clone(), ); } } agents::AgentAction::FetchAgentSkills(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_fetch_agent_skills(backend, id, self.event_tx.clone()); } } agents::AgentAction::FetchAgentMcpServers(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_fetch_agent_mcp_servers(backend, id, self.event_tx.clone()); } } } } fn handle_chat_action(&mut self, action: chat::ChatAction) { match action { chat::ChatAction::Continue => {} chat::ChatAction::Back => { // In Main phase, go back to Agents tab self.chat.reset(); self.chat_target = None; self.switch_tab(Tab::Agents); } chat::ChatAction::SendMessage(msg) => self.send_message(msg), chat::ChatAction::SlashCommand(cmd) => self.handle_slash_command(&cmd), } } fn handle_channel_action(&mut self, action: channels::ChannelAction) { match action { channels::ChannelAction::Continue => {} channels::ChannelAction::Refresh => self.refresh_channels(), channels::ChannelAction::TestChannel(name) => { if let Some(backend) = self.backend.to_ref() { event::spawn_test_channel(backend, name, self.event_tx.clone()); } } channels::ChannelAction::ToggleChannel(_name, _enabled) => { // Toggle is handled locally in the state; daemon toggle // could be spawned here if the API supports it. } channels::ChannelAction::SaveChannel(name, values) => { // Save channel credentials via daemon API if let Some(backend) = self.backend.to_ref() { let tx = self.event_tx.clone(); std::thread::spawn(move || { if let event::BackendRef::Daemon(base_url) = backend { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .ok(); if let Some(client) = client { let mut fields = serde_json::Map::new(); for (k, v) in &values { fields.insert(k.clone(), serde_json::Value::String(v.clone())); } let body = serde_json::json!({ "fields": fields }); let _ = client .post(format!("{base_url}/api/channels/{name}/configure")) .json(&body) .send(); } } // Signal tick so the UI refreshes next cycle let _ = tx.send(event::AppEvent::Tick); }); } // Immediately trigger a refresh of the channel list self.refresh_channels(); } } } fn handle_workflow_action(&mut self, action: workflows::WorkflowAction) { match action { workflows::WorkflowAction::Continue => {} workflows::WorkflowAction::Refresh => self.refresh_workflows(), workflows::WorkflowAction::LoadRuns(wf_id) => { if let Some(backend) = self.backend.to_ref() { self.workflows.loading = true; event::spawn_fetch_workflow_runs(backend, wf_id, self.event_tx.clone()); } } workflows::WorkflowAction::CreateWorkflow { name, description, steps_json, } => { if let Some(backend) = self.backend.to_ref() { event::spawn_create_workflow( backend, name, description, steps_json, self.event_tx.clone(), ); } } workflows::WorkflowAction::RunWorkflow { id, input } => { if let Some(backend) = self.backend.to_ref() { self.workflows.loading = true; event::spawn_run_workflow(backend, id, input, self.event_tx.clone()); } } } } fn handle_trigger_action(&mut self, action: triggers::TriggerAction) { match action { triggers::TriggerAction::Continue => {} triggers::TriggerAction::Refresh => self.refresh_triggers(), triggers::TriggerAction::CreateTrigger { agent_id, pattern_type, pattern_param, prompt, max_fires, } => { if let Some(backend) = self.backend.to_ref() { event::spawn_create_trigger( backend, agent_id, pattern_type, pattern_param, prompt, max_fires, self.event_tx.clone(), ); } } triggers::TriggerAction::DeleteTrigger(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_delete_trigger(backend, id, self.event_tx.clone()); } } } } fn handle_sessions_action(&mut self, action: sessions::SessionsAction) { match action { sessions::SessionsAction::Continue => {} sessions::SessionsAction::Refresh => self.refresh_sessions(), sessions::SessionsAction::OpenInChat { agent_id, agent_name, } => { self.enter_chat_daemon(agent_id, agent_name); } sessions::SessionsAction::DeleteSession(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_delete_session(backend, id, self.event_tx.clone()); } } } } fn handle_memory_action(&mut self, action: memory::MemoryAction) { match action { memory::MemoryAction::Continue => {} memory::MemoryAction::LoadAgents => self.refresh_memory(), memory::MemoryAction::LoadKv(agent_id) => { if let Some(backend) = self.backend.to_ref() { self.memory.loading = true; event::spawn_fetch_memory_kv(backend, agent_id, self.event_tx.clone()); } } memory::MemoryAction::SaveKv { agent_id, key, value, } => { if let Some(backend) = self.backend.to_ref() { event::spawn_save_memory_kv( backend, agent_id, key, value, self.event_tx.clone(), ); } } memory::MemoryAction::DeleteKv { agent_id, key } => { if let Some(backend) = self.backend.to_ref() { event::spawn_delete_memory_kv(backend, agent_id, key, self.event_tx.clone()); } } } } fn handle_skills_action(&mut self, action: skills::SkillsAction) { match action { skills::SkillsAction::Continue => {} skills::SkillsAction::RefreshInstalled => self.refresh_skills(), skills::SkillsAction::SearchClawHub(query) => { if let Some(backend) = self.backend.to_ref() { self.skills.loading = true; event::spawn_search_clawhub(backend, query, self.event_tx.clone()); } } skills::SkillsAction::BrowseClawHub(sort) => { if let Some(backend) = self.backend.to_ref() { self.skills.loading = true; event::spawn_browse_clawhub(backend, sort, self.event_tx.clone()); } } skills::SkillsAction::InstallSkill(slug) => { if let Some(backend) = self.backend.to_ref() { event::spawn_install_skill(backend, slug, self.event_tx.clone()); } } skills::SkillsAction::UninstallSkill(name) => { if let Some(backend) = self.backend.to_ref() { event::spawn_uninstall_skill(backend, name, self.event_tx.clone()); } } skills::SkillsAction::RefreshMcp => { if let Some(backend) = self.backend.to_ref() { self.skills.loading = true; event::spawn_fetch_mcp_servers(backend, self.event_tx.clone()); } } } } fn handle_extensions_action(&mut self, action: extensions::ExtensionsAction) { match action { extensions::ExtensionsAction::Continue => {} extensions::ExtensionsAction::RefreshAll => self.refresh_extensions(), extensions::ExtensionsAction::RefreshHealth => self.refresh_extension_health(), extensions::ExtensionsAction::Install(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_install_extension(backend, id, self.event_tx.clone()); } } extensions::ExtensionsAction::Remove(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_remove_extension(backend, id, self.event_tx.clone()); } } extensions::ExtensionsAction::Reconnect(id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_reconnect_extension(backend, id, self.event_tx.clone()); } } } } fn handle_hands_action(&mut self, action: hands::HandsAction) { match action { hands::HandsAction::Continue => {} hands::HandsAction::RefreshDefinitions => self.refresh_hands(), hands::HandsAction::RefreshActive => { if let Some(backend) = self.backend.to_ref() { self.hands.loading = true; event::spawn_fetch_active_hands(backend, self.event_tx.clone()); } } hands::HandsAction::ActivateHand(hand_id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_activate_hand(backend, hand_id, self.event_tx.clone()); } } hands::HandsAction::DeactivateHand(instance_id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_deactivate_hand(backend, instance_id, self.event_tx.clone()); } } hands::HandsAction::PauseHand(instance_id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_pause_hand(backend, instance_id, self.event_tx.clone()); } } hands::HandsAction::ResumeHand(instance_id) => { if let Some(backend) = self.backend.to_ref() { event::spawn_resume_hand(backend, instance_id, self.event_tx.clone()); } } } } fn handle_templates_action(&mut self, action: templates::TemplatesAction) { match action { templates::TemplatesAction::Continue => {} templates::TemplatesAction::Refresh => self.refresh_templates(), templates::TemplatesAction::SpawnTemplate(name) => { // Find template and generate TOML manifest if let Some(t) = self.templates.templates.iter().find(|t| t.name == name) { let toml_content = format!( "name = \"{}\"\ndescription = \"{}\"\n\n[model]\nprovider = \"{}\"\nmodel = \"{}\"\n\n[capabilities]\ntools = [\"shell\", \"file_read\", \"file_write\", \"web_fetch\", \"web_search\"]\n", t.name, t.description, t.provider, t.model, ); self.spawn_agent(toml_content); } } } } fn handle_security_action(&mut self, action: security::SecurityAction) { match action { security::SecurityAction::Continue => {} security::SecurityAction::Refresh => self.refresh_security(), security::SecurityAction::VerifyChain => { if let Some(backend) = self.backend.to_ref() { self.security.loading = true; event::spawn_verify_chain(backend, self.event_tx.clone()); } } } } fn handle_audit_action(&mut self, action: audit::AuditAction) { match action { audit::AuditAction::Continue => {} audit::AuditAction::Refresh => self.refresh_audit(), audit::AuditAction::VerifyChain => { if let Some(backend) = self.backend.to_ref() { event::spawn_verify_chain(backend, self.event_tx.clone()); } } } } fn handle_usage_action(&mut self, action: usage::UsageAction) { match action { usage::UsageAction::Continue => {} usage::UsageAction::Refresh => self.refresh_usage(), } } fn handle_settings_action(&mut self, action: settings::SettingsAction) { match action { settings::SettingsAction::Continue => {} settings::SettingsAction::RefreshProviders => self.refresh_settings_providers(), settings::SettingsAction::RefreshModels => self.refresh_settings_models(), settings::SettingsAction::RefreshTools => self.refresh_settings_tools(), settings::SettingsAction::SaveProviderKey { name, key } => { if let Some(backend) = self.backend.to_ref() { event::spawn_save_provider_key(backend, name, key, self.event_tx.clone()); } } settings::SettingsAction::DeleteProviderKey(name) => { if let Some(backend) = self.backend.to_ref() { event::spawn_delete_provider_key(backend, name, self.event_tx.clone()); } } settings::SettingsAction::TestProvider(name) => { if let Some(backend) = self.backend.to_ref() { event::spawn_test_provider(backend, name, self.event_tx.clone()); } } } } fn handle_peers_action(&mut self, action: peers::PeersAction) { match action { peers::PeersAction::Continue => {} peers::PeersAction::Refresh => self.refresh_peers(), } } fn handle_logs_action(&mut self, action: logs::LogsAction) { match action { logs::LogsAction::Continue => {} logs::LogsAction::Refresh => self.refresh_logs(), } } // ─── Chat helpers ──────────────────────────────────────────────────────── fn enter_chat_daemon(&mut self, id: String, name: String) { self.chat.reset(); self.chat.agent_name = name.clone(); self.chat.mode_label = "daemon".to_string(); if let Backend::Daemon { ref base_url } = self.backend { let client = crate::daemon_client(); if let Ok(resp) = client.get(format!("{base_url}/api/agents/{id}")).send() { if let Ok(body) = resp.json::() { let provider = body["model_provider"].as_str().unwrap_or("?"); let model = body["model_name"].as_str().unwrap_or("?"); self.chat.model_label = format!("{provider}/{model}"); } } } self.chat_target = Some(ChatTarget { agent_id_daemon: Some(id), agent_id_inprocess: None, agent_name: name, }); self.chat.push_message( chat::Role::System, "/help for commands \u{2022} /exit to quit".to_string(), ); self.active_tab = Tab::Chat; } fn enter_chat_inprocess(&mut self, id: AgentId, name: String) { self.chat.reset(); self.chat.agent_name = name.clone(); self.chat.mode_label = "in-process".to_string(); if let Backend::InProcess { ref kernel } = self.backend { if let Some(entry) = kernel.registry.get(id) { self.chat.model_label = format!( "{}/{}", entry.manifest.model.provider, entry.manifest.model.model ); } } self.chat_target = Some(ChatTarget { agent_id_daemon: None, agent_id_inprocess: Some(id), agent_name: name, }); self.chat.push_message( chat::Role::System, "/help for commands \u{2022} /exit to quit".to_string(), ); self.active_tab = Tab::Chat; } fn send_message(&mut self, message: String) { self.chat.is_streaming = true; self.chat.thinking = true; self.chat.streaming_chars = 0; self.chat.last_tokens = None; self.chat.status_msg = None; match (&self.backend, &self.chat_target) { (Backend::Daemon { base_url }, Some(target)) if target.agent_id_daemon.is_some() => { event::spawn_daemon_stream( base_url.clone(), target.agent_id_daemon.as_ref().unwrap().clone(), message, self.event_tx.clone(), ); } (Backend::InProcess { kernel }, Some(target)) if target.agent_id_inprocess.is_some() => { event::spawn_inprocess_stream( kernel.clone(), target.agent_id_inprocess.unwrap(), message, self.event_tx.clone(), ); } _ => { self.chat.is_streaming = false; self.chat.status_msg = Some("No active connection".to_string()); } } } fn spawn_agent(&mut self, toml_content: String) { match &self.backend { Backend::Daemon { base_url } => { self.agents.sub = agents::AgentSubScreen::Spawning; event::spawn_daemon_agent(base_url.clone(), toml_content, self.event_tx.clone()); } Backend::InProcess { kernel } => { let manifest: openfang_types::agent::AgentManifest = match toml::from_str(&toml_content) { Ok(m) => m, Err(e) => { self.agents.status_msg = format!("Invalid manifest: {e}"); self.agents.sub = agents::AgentSubScreen::AgentList; return; } }; let name = manifest.name.clone(); match kernel.spawn_agent(manifest) { Ok(id) => self.enter_chat_inprocess(id, name), Err(e) => { self.agents.status_msg = format!("Spawn failed: {e}"); self.agents.sub = agents::AgentSubScreen::AgentList; } } } Backend::None => { self.agents.status_msg = "No backend connected".to_string(); self.agents.sub = agents::AgentSubScreen::AgentList; } } } // ─── Slash commands ────────────────────────────────────────────────────── fn handle_slash_command(&mut self, cmd: &str) { let parts: Vec<&str> = cmd.splitn(2, ' ').collect(); match parts[0] { "/exit" | "/quit" => self.handle_chat_action(chat::ChatAction::Back), "/help" => { self.chat.push_message( chat::Role::System, [ "/help \u{2014} show this help", "/status \u{2014} connection & agent info", "/agents \u{2014} list running agents", "/model \u{2014} show current model", "/clear \u{2014} clear chat history", "/kill \u{2014} kill the current agent", "/exit \u{2014} end chat session", ] .join("\n"), ); } "/status" => { let mut s = Vec::new(); match &self.backend { Backend::Daemon { base_url } => { s.push(format!("Mode: daemon ({base_url})")); if let Some(ref t) = self.chat_target { s.push(format!("Agent: {}", t.agent_name)); } } Backend::InProcess { kernel } => { s.push("Mode: in-process".to_string()); s.push(format!("Agents: {}", kernel.registry.count())); if let Some(ref t) = self.chat_target { s.push(format!("Agent: {}", t.agent_name)); } } Backend::None => s.push("Mode: disconnected".to_string()), } self.chat.push_message(chat::Role::System, s.join("\n")); } "/agents" => { let mut lines = Vec::new(); match &self.backend { Backend::Daemon { base_url } => { let client = crate::daemon_client(); if let Ok(resp) = client.get(format!("{base_url}/api/agents")).send() { if let Ok(body) = resp.json::() { if let Some(arr) = body.as_array() { for a in arr { lines.push(format!( "{} [{}] {}", a["name"].as_str().unwrap_or("?"), a["state"].as_str().unwrap_or("?"), a["model_name"].as_str().unwrap_or("?"), )); } } } } } Backend::InProcess { kernel } => { for e in kernel.registry.list() { lines.push(format!( "{} [{:?}] {}/{}", e.name, e.state, e.manifest.model.provider, e.manifest.model.model, )); } } Backend::None => {} } let msg = if lines.is_empty() { "No agents running.".to_string() } else { lines.join("\n") }; self.chat.push_message(chat::Role::System, msg); } "/clear" => { let name = self.chat.agent_name.clone(); let model = self.chat.model_label.clone(); let mode = self.chat.mode_label.clone(); self.chat.reset(); self.chat.agent_name = name; self.chat.model_label = model; self.chat.mode_label = mode; self.chat .push_message(chat::Role::System, "Chat history cleared.".to_string()); } "/kill" => { if let Some(ref target) = self.chat_target { let name = target.agent_name.clone(); match &self.backend { Backend::Daemon { base_url } => { if let Some(ref id) = target.agent_id_daemon { let client = crate::daemon_client(); let url = format!("{base_url}/api/agents/{id}"); match client.delete(&url).send() { Ok(r) if r.status().is_success() => { self.chat.push_message( chat::Role::System, format!("Agent \"{name}\" killed."), ); } _ => { self.chat.push_message( chat::Role::System, format!("Failed to kill agent \"{name}\"."), ); } } } } Backend::InProcess { kernel } => { if let Some(id) = target.agent_id_inprocess { match kernel.kill_agent(id) { Ok(()) => { self.chat.push_message( chat::Role::System, format!("Agent \"{name}\" killed."), ); } Err(e) => { self.chat.push_message( chat::Role::System, format!("Kill failed: {e}"), ); } } } } Backend::None => { self.chat.push_message( chat::Role::System, "No backend connected.".to_string(), ); } } } } "/model" => { self.chat.push_message( chat::Role::System, format!("Model: {}", self.chat.model_label), ); } "/hands" => match &self.backend { Backend::InProcess { kernel } => { let defs = kernel.hand_registry.list_definitions(); let instances = kernel.hand_registry.list_instances(); let mut msg = format!("Available hands ({}):\n", defs.len()); for d in &defs { let reqs_met = kernel .hand_registry .check_requirements(&d.id) .map(|r| r.iter().all(|(_, ok)| *ok)) .unwrap_or(false); let badge = if reqs_met { "Ready" } else { "Setup" }; msg.push_str(&format!( " {} {} — {} [{}]\n", d.icon, d.name, d.description, badge )); } if !instances.is_empty() { msg.push_str(&format!("\nActive hands ({}):\n", instances.len())); for i in &instances { msg.push_str(&format!( " {} — {} ({})\n", i.agent_name, i.hand_id, i.status )); } } self.chat.push_message(chat::Role::System, msg); } _ => { self.chat.push_message( chat::Role::System, "Hands info requires in-process mode. Use the Hands tab instead." .to_string(), ); } }, _ => { self.chat.push_message( chat::Role::System, format!("Unknown command: {}. Type /help", parts[0]), ); } } } // ─── Drawing ───────────────────────────────────────────────────────────── fn draw(&mut self, frame: &mut ratatui::Frame) { let area = frame.area(); match self.phase { Phase::Boot(BootScreen::Welcome) => { welcome::draw(frame, area, &mut self.welcome); // Overlay boot status on top of the welcome card if self.kernel_booting { let spinner = theme::SPINNER_FRAMES[self.welcome.tick % theme::SPINNER_FRAMES.len()]; let msg = format!(" {spinner} Booting kernel\u{2026}"); render_toast(frame, area, &msg, theme::YELLOW); } if let Some(ref err) = self.kernel_boot_error { let msg = format!(" \u{2718} {err}"); render_toast(frame, area, &msg, theme::RED); } } Phase::Boot(BootScreen::Wizard) => wizard::draw(frame, area, &mut self.wizard), Phase::Main => { // Split: tab bar (1 line) + content let chunks = Layout::vertical([ Constraint::Length(1), // tab bar Constraint::Min(1), // content ]) .split(area); self.draw_tab_bar(frame, chunks[0]); match self.active_tab { Tab::Dashboard => dashboard::draw(frame, chunks[1], &mut self.dashboard), Tab::Agents => agents::draw(frame, chunks[1], &mut self.agents), Tab::Chat => chat::draw(frame, chunks[1], &mut self.chat), Tab::Channels => channels::draw(frame, chunks[1], &mut self.channels), Tab::Workflows => workflows::draw(frame, chunks[1], &mut self.workflows), Tab::Triggers => triggers::draw(frame, chunks[1], &mut self.triggers), Tab::Sessions => sessions::draw(frame, chunks[1], &mut self.sessions), Tab::Memory => memory::draw(frame, chunks[1], &mut self.memory), Tab::Skills => skills::draw(frame, chunks[1], &mut self.skills), Tab::Hands => hands::draw(frame, chunks[1], &mut self.hands), Tab::Extensions => extensions::draw(frame, chunks[1], &mut self.extensions), Tab::Templates => templates::draw(frame, chunks[1], &mut self.templates), Tab::Security => security::draw(frame, chunks[1], &mut self.security), Tab::Audit => audit::draw(frame, chunks[1], &mut self.audit), Tab::Usage => usage::draw(frame, chunks[1], &mut self.usage), Tab::Settings => settings::draw(frame, chunks[1], &mut self.settings), Tab::Peers => peers::draw(frame, chunks[1], &mut self.peers), Tab::Logs => logs::draw(frame, chunks[1], &mut self.logs), } } } } fn draw_tab_bar(&mut self, frame: &mut ratatui::Frame, area: Rect) { let width = area.width as usize; // Compute all tab labels with their widths let tab_labels: Vec<(usize, String)> = TABS .iter() .map(|tab| { let label = format!(" {} ", tab.label()); let w = label.len() + 1; // +1 for spacing (w, label) }) .collect(); // Reserve space for overflow indicators (2 chars each) and hint let indicator_width = 2; // "< " or " >" let hint = if self.ctrl_c_pending { "Press Ctrl+C again to quit" } else { "Ctrl+C×2 quit Tab/Ctrl+\u{2190}\u{2192} switch" }; let hint_width = hint.len() + 2; let available = width.saturating_sub(hint_width + 2); // Ensure active tab is visible by adjusting scroll offset let active_idx = self.active_tab.index(); // Scroll so active tab fits in the visible window if active_idx < self.tab_scroll_offset { self.tab_scroll_offset = active_idx; } // Find how many tabs fit starting from scroll offset loop { let mut used = if self.tab_scroll_offset > 0 { indicator_width } else { 1 }; // leading space or left indicator let mut last_visible = self.tab_scroll_offset; for (i, (tab_w, _)) in tab_labels.iter().enumerate().skip(self.tab_scroll_offset) { if used + tab_w > available { break; } used += tab_w; last_visible = i; } if active_idx <= last_visible || self.tab_scroll_offset >= TABS.len() - 1 { break; } self.tab_scroll_offset += 1; } let mut spans: Vec = Vec::new(); // Left overflow indicator if self.tab_scroll_offset > 0 { spans.push(Span::styled( "\u{25c0} ", Style::default().fg(theme::TEXT_TERTIARY), )); } else { spans.push(Span::raw(" ")); } // Render visible tabs let mut used = if self.tab_scroll_offset > 0 { indicator_width } else { 1 }; let mut last_rendered = self.tab_scroll_offset; for (i, ((tab_w, label), &tab)) in tab_labels .iter() .zip(TABS.iter()) .enumerate() .skip(self.tab_scroll_offset) { if used + tab_w > available { break; } if tab == self.active_tab { spans.push(Span::styled(label.clone(), theme::tab_active())); } else { spans.push(Span::styled(label.clone(), theme::tab_inactive())); } spans.push(Span::raw(" ")); used += tab_w; last_rendered = i; } // Right overflow indicator if last_rendered < TABS.len() - 1 { spans.push(Span::styled( " \u{25b6}", Style::default().fg(theme::TEXT_TERTIARY), )); } // Right-aligned hint (yellow warning when Ctrl+C pending) let hint_style = if self.ctrl_c_pending { Style::default() .fg(theme::YELLOW) .add_modifier(ratatui::style::Modifier::BOLD) } else { theme::hint_style() }; let spans_width: usize = spans.iter().map(|s| s.content.len()).sum(); let padding = width.saturating_sub(spans_width + hint.len()); if padding > 0 { spans.push(Span::raw(" ".repeat(padding))); spans.push(Span::styled(hint, hint_style)); } let bar = Paragraph::new(Line::from(spans)).style(Style::default().bg(theme::BG_CARD)); frame.render_widget(bar, area); } } /// Draw a one-line toast at the bottom of the screen. fn render_toast(frame: &mut ratatui::Frame, area: Rect, msg: &str, color: ratatui::style::Color) { let w = (msg.len() as u16 + 4).min(area.width); let x = area.width.saturating_sub(w) / 2; let y = area.height.saturating_sub(2); let toast_area = Rect::new(x, y, w, 1); let para = Paragraph::new(Line::from(vec![Span::styled( msg, Style::default().fg(color), )])); frame.render_widget(para, toast_area); } // ─── Entry point ───────────────────────────────────────────────────────────── /// Entry point for the TUI interactive mode. pub fn run(config: Option) { // Panic hook: always restore terminal let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { ratatui::restore(); original_hook(info); })); let mut terminal = ratatui::init(); // 50ms tick → 20fps spinner animation, snappy key response let (tx, rx) = event::spawn_event_thread(Duration::from_millis(50)); let mut app = App::new(config, tx); // Initial screen if wizard::needs_setup() { app.wizard.reset(); app.phase = Phase::Boot(BootScreen::Wizard); } else { app.phase = Phase::Boot(BootScreen::Welcome); // Non-blocking daemon detection app.start_daemon_detect(); } // ── Main loop ──────────────────────────────────────────────────────────── // Draw first, then block on events. This ensures the first frame appears // immediately, before any event processing. while !app.should_quit { terminal .draw(|frame| app.draw(frame)) .expect("Failed to draw"); // Block until at least one event arrives (or 33ms timeout for ~30fps) match rx.recv_timeout(Duration::from_millis(33)) { Ok(ev) => app.handle_event(ev), Err(mpsc::RecvTimeoutError::Timeout) => {} Err(mpsc::RecvTimeoutError::Disconnected) => break, } // Drain all queued events immediately (batch processing) while let Ok(ev) = rx.try_recv() { app.handle_event(ev); } } ratatui::restore(); }