Files
openfang/crates/openfang-cli/src/tui/mod.rs
iven 92e5def702
Some checks failed
CI / Check / macos-latest (push) Has been cancelled
CI / Check / ubuntu-latest (push) Has been cancelled
CI / Check / windows-latest (push) Has been cancelled
CI / Test / macos-latest (push) Has been cancelled
CI / Test / ubuntu-latest (push) Has been cancelled
CI / Test / windows-latest (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Security Audit (push) Has been cancelled
CI / Secrets Scan (push) Has been cancelled
CI / Install Script Smoke Test (push) Has been cancelled
初始化提交
2026-03-01 16:24:24 +08:00

2218 lines
87 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<OpenFangKernel> },
None,
}
impl Backend {
fn to_ref(&self) -> Option<BackendRef> {
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<String>,
agent_id_inprocess: Option<AgentId>,
agent_name: String,
}
struct App {
phase: Phase,
active_tab: Tab,
tab_scroll_offset: usize,
config_path: Option<PathBuf>,
should_quit: bool,
event_tx: mpsc::Sender<AppEvent>,
/// 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<ChatTarget>,
// 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<String>,
}
// ─── App construction ────────────────────────────────────────────────────────
impl App {
fn new(config_path: Option<PathBuf>, event_tx: mpsc::Sender<AppEvent>) -> 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<openfang_runtime::agent_loop::AgentLoopResult, String>,
) {
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<OpenFangKernel>) {
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::<serde_json::Value>() {
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::<serde_json::Value>() {
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<Span> = 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<PathBuf>) {
// 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();
}