Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src-rust/crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,15 @@ pub struct App {
pub last_exit_key_warning: Option<std::time::Instant>,
/// Which exit key ('c' or 'd') started the current confirmation sequence.
pub exit_key_sequence_start: Option<char>,

// ---- 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<String>,
pub familiar_switcher_idx: usize,
}

// Spinner verbs are now imported from claurst_core::spinner
Expand Down Expand Up @@ -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<String> = 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,
}
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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() {
Expand Down
101 changes: 101 additions & 0 deletions src-rust/crates/tui/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -2026,6 +2031,36 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
} else {
let mut spans: Vec<Span> = 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(
Expand Down Expand Up @@ -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<ListItem> = 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);
}
Loading