diff --git a/src-rust/crates/tui/src/app.rs b/src-rust/crates/tui/src/app.rs index 04ce468..f5d9c9b 100644 --- a/src-rust/crates/tui/src/app.rs +++ b/src-rust/crates/tui/src/app.rs @@ -1090,6 +1090,15 @@ pub struct App { pub last_exit_key_warning: Option, /// Which exit key ('c' or 'd') started the current confirmation sequence. pub exit_key_sequence_start: Option, + + // ---- Coven daemon integration ---------------------------------------- + pub daemon_online: bool, + pub daemon_last_checked: u64, + + // ---- Familiar switcher (F2) ------------------------------------------ + pub familiar_switcher_open: bool, + pub familiar_switcher_list: Vec, + pub familiar_switcher_idx: usize, } // Spinner verbs are now imported from claurst_core::spinner @@ -1442,6 +1451,26 @@ impl App { managed_agents_active: false, last_exit_key_warning: None, exit_key_sequence_start: None, + daemon_online: dirs::home_dir() + .map(|h| h.join(".coven").join("coven.sock").exists()) + .unwrap_or(false), + daemon_last_checked: 0, + familiar_switcher_open: false, + familiar_switcher_list: { + let mut ids: Vec = vec![ + "nova".to_string(), "kitty".to_string(), "cody".to_string(), + "charm".to_string(), "sage".to_string(), "astra".to_string(), + "echo".to_string(), + ]; + use claurst_core::coven_shared; + if let Some(familiars) = coven_shared::load_familiars() { + for f in familiars { + if !ids.contains(&f.id) { ids.push(f.id); } + } + } + ids + }, + familiar_switcher_idx: 0, } } @@ -2949,6 +2978,34 @@ impl App { return false; } + // ---- Familiar switcher (F2) ---------------------------------------- + if self.familiar_switcher_open { + match key.code { + KeyCode::Esc | KeyCode::F(2) => { self.familiar_switcher_open = false; } + KeyCode::Char('j') | KeyCode::Down => { + let len = self.familiar_switcher_list.len(); + if len > 0 { self.familiar_switcher_idx = (self.familiar_switcher_idx + 1) % len; } + } + KeyCode::Char('k') | KeyCode::Up => { + let len = self.familiar_switcher_list.len(); + if len > 0 { self.familiar_switcher_idx = (self.familiar_switcher_idx + len - 1) % len; } + } + KeyCode::Enter => { + if let Some(id) = self.familiar_switcher_list.get(self.familiar_switcher_idx).cloned() { + self.config.familiar = Some(id.clone()); + self.push_notification( + crate::notifications::NotificationKind::Info, + format!("\u{2728} Familiar: {}", id), + None, + ); + } + self.familiar_switcher_open = false; + } + _ => {} + } + return false; + } + if self.global_search.visible { return self.handle_global_search_key(key); @@ -4123,6 +4180,19 @@ impl App { self.show_help = !self.show_help; self.help_overlay.toggle(); } + KeyCode::F(2) => { + if self.familiar_switcher_open { + self.familiar_switcher_open = false; + } else { + self.familiar_switcher_open = true; + let current = self.config.familiar.as_deref().unwrap_or("kitty"); + if let Some(idx) = self.familiar_switcher_list.iter().position(|id| id == current) { + self.familiar_switcher_idx = idx; + } else { + self.familiar_switcher_idx = 0; + } + } + } KeyCode::Char('?') if !self.is_streaming && self.prompt_input.is_empty() @@ -6138,6 +6208,14 @@ impl App { loop { self.frame_count = self.frame_count.wrapping_add(1); + // Re-check daemon socket ~every 30 seconds (300 frames at 10fps). + if self.frame_count.wrapping_sub(self.daemon_last_checked) >= 300 { + self.daemon_last_checked = self.frame_count; + self.daemon_online = dirs::home_dir() + .map(|h| h.join(".coven").join("coven.sock").exists()) + .unwrap_or(false); + } + // Drain background session-list results. if let Some(ref mut rx) = self.session_list_rx { match rx.try_recv() { diff --git a/src-rust/crates/tui/src/render.rs b/src-rust/crates/tui/src/render.rs index 08c07d6..5e1909f 100644 --- a/src-rust/crates/tui/src/render.rs +++ b/src-rust/crates/tui/src/render.rs @@ -561,6 +561,11 @@ pub fn render_app(frame: &mut Frame, app: &App) { render_global_search(&app.global_search, size, frame.buffer_mut()); } + // Familiar switcher popup (F2) + if app.familiar_switcher_open { + render_familiar_switcher(frame, app, size); + } + if app.feedback_survey.visible { render_feedback_survey(&app.feedback_survey, size, frame.buffer_mut()); } @@ -2026,6 +2031,36 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { } else { let mut spans: Vec = Vec::new(); + // Daemon online/offline indicator + { + let (label, color) = if app.daemon_online { + ("\u{2726} coven", Color::Rgb(139, 92, 246)) + } else { + ("\u{25cb} coven", Color::DarkGray) + }; + spans.push(Span::styled(label, Style::default().fg(color))); + spans.push(Span::raw(" ")); + } + + // Current familiar emoji + name + { + let familiar_id = app.config.familiar.as_deref().unwrap_or("kitty"); + let emoji = match familiar_id { + "nova" => "\u{1f451}", + "kitty" => "\u{1f431}", + "cody" => "\u{1f4bb}", + "charm" => "\u{2728}", + "sage" => "\u{1f33f}", + "astra" => "\u{1f319}", + "echo" => "\u{1f47b}", + _ => "\u{2b50}", + }; + spans.push(Span::styled( + format!("{} {} ", emoji, familiar_id), + Style::default().fg(Color::DarkGray), + )); + } + // Agent type badge (shown when running as subagent / coordinator) if let Some(ref badge) = app.agent_type_badge { spans.push(Span::styled( @@ -2961,3 +2996,69 @@ pub fn render_teammate_header( Line::from(spans) } + + +// --------------------------------------------------------------------------- +// Familiar switcher popup (F2) +// --------------------------------------------------------------------------- + +fn render_familiar_switcher(frame: &mut Frame, app: &App, area: Rect) { + use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState}; + + let list_len = app.familiar_switcher_list.len() as u16; + let popup_h = list_len.saturating_add(2).min(area.height.saturating_sub(4)); + let popup_w = 26u16.min(area.width.saturating_sub(4)); + let popup_x = area.x + area.width.saturating_sub(popup_w) / 2; + let popup_y = area.y + area.height.saturating_sub(popup_h) / 2; + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_w, + height: popup_h, + }; + + frame.render_widget(Clear, popup_area); + + let builtin_emoji: &[(&str, &str)] = &[ + ("nova", "\u{1f451}"), + ("kitty", "\u{1f431}"), + ("cody", "\u{1f4bb}"), + ("charm", "\u{2728}"), + ("sage", "\u{1f33f}"), + ("astra", "\u{1f319}"), + ("echo", "\u{1f47b}"), + ]; + + let items: Vec = app + .familiar_switcher_list + .iter() + .enumerate() + .map(|(i, id)| { + let emoji = builtin_emoji + .iter() + .find(|(k, _)| *k == id.as_str()) + .map(|(_, e)| *e) + .unwrap_or("\u{2b50}"); + let label = format!(" {} {} ", emoji, id); + let style = if i == app.familiar_switcher_idx { + Style::default() + .fg(Color::Black) + .bg(Color::Rgb(139, 92, 246)) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + ListItem::new(label).style(style) + }) + .collect(); + + let block = Block::default() + .title(" \u{2728} Familiar (F2) ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(139, 92, 246))); + + let list = List::new(items).block(block); + let mut state = ListState::default(); + state.select(Some(app.familiar_switcher_idx)); + frame.render_stateful_widget(list, popup_area, &mut state); +}