Skip to content

Commit f0891c9

Browse files
authored
feat: add familiar UX polish (#7)
Verified locally before merge: cargo check -p claurst-tui. Adds daemon indicator, F2 familiar switcher, and status bar familiar display.
1 parent f63dba4 commit f0891c9

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

src-rust/crates/tui/src/app.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,15 @@ pub struct App {
10901090
pub last_exit_key_warning: Option<std::time::Instant>,
10911091
/// Which exit key ('c' or 'd') started the current confirmation sequence.
10921092
pub exit_key_sequence_start: Option<char>,
1093+
1094+
// ---- Coven daemon integration ----------------------------------------
1095+
pub daemon_online: bool,
1096+
pub daemon_last_checked: u64,
1097+
1098+
// ---- Familiar switcher (F2) ------------------------------------------
1099+
pub familiar_switcher_open: bool,
1100+
pub familiar_switcher_list: Vec<String>,
1101+
pub familiar_switcher_idx: usize,
10931102
}
10941103

10951104
// Spinner verbs are now imported from claurst_core::spinner
@@ -1442,6 +1451,26 @@ impl App {
14421451
managed_agents_active: false,
14431452
last_exit_key_warning: None,
14441453
exit_key_sequence_start: None,
1454+
daemon_online: dirs::home_dir()
1455+
.map(|h| h.join(".coven").join("coven.sock").exists())
1456+
.unwrap_or(false),
1457+
daemon_last_checked: 0,
1458+
familiar_switcher_open: false,
1459+
familiar_switcher_list: {
1460+
let mut ids: Vec<String> = vec![
1461+
"nova".to_string(), "kitty".to_string(), "cody".to_string(),
1462+
"charm".to_string(), "sage".to_string(), "astra".to_string(),
1463+
"echo".to_string(),
1464+
];
1465+
use claurst_core::coven_shared;
1466+
if let Some(familiars) = coven_shared::load_familiars() {
1467+
for f in familiars {
1468+
if !ids.contains(&f.id) { ids.push(f.id); }
1469+
}
1470+
}
1471+
ids
1472+
},
1473+
familiar_switcher_idx: 0,
14451474
}
14461475
}
14471476

@@ -2949,6 +2978,34 @@ impl App {
29492978
return false;
29502979
}
29512980

2981+
// ---- Familiar switcher (F2) ----------------------------------------
2982+
if self.familiar_switcher_open {
2983+
match key.code {
2984+
KeyCode::Esc | KeyCode::F(2) => { self.familiar_switcher_open = false; }
2985+
KeyCode::Char('j') | KeyCode::Down => {
2986+
let len = self.familiar_switcher_list.len();
2987+
if len > 0 { self.familiar_switcher_idx = (self.familiar_switcher_idx + 1) % len; }
2988+
}
2989+
KeyCode::Char('k') | KeyCode::Up => {
2990+
let len = self.familiar_switcher_list.len();
2991+
if len > 0 { self.familiar_switcher_idx = (self.familiar_switcher_idx + len - 1) % len; }
2992+
}
2993+
KeyCode::Enter => {
2994+
if let Some(id) = self.familiar_switcher_list.get(self.familiar_switcher_idx).cloned() {
2995+
self.config.familiar = Some(id.clone());
2996+
self.push_notification(
2997+
crate::notifications::NotificationKind::Info,
2998+
format!("\u{2728} Familiar: {}", id),
2999+
None,
3000+
);
3001+
}
3002+
self.familiar_switcher_open = false;
3003+
}
3004+
_ => {}
3005+
}
3006+
return false;
3007+
}
3008+
29523009

29533010
if self.global_search.visible {
29543011
return self.handle_global_search_key(key);
@@ -4123,6 +4180,19 @@ impl App {
41234180
self.show_help = !self.show_help;
41244181
self.help_overlay.toggle();
41254182
}
4183+
KeyCode::F(2) => {
4184+
if self.familiar_switcher_open {
4185+
self.familiar_switcher_open = false;
4186+
} else {
4187+
self.familiar_switcher_open = true;
4188+
let current = self.config.familiar.as_deref().unwrap_or("kitty");
4189+
if let Some(idx) = self.familiar_switcher_list.iter().position(|id| id == current) {
4190+
self.familiar_switcher_idx = idx;
4191+
} else {
4192+
self.familiar_switcher_idx = 0;
4193+
}
4194+
}
4195+
}
41264196
KeyCode::Char('?')
41274197
if !self.is_streaming
41284198
&& self.prompt_input.is_empty()
@@ -6138,6 +6208,14 @@ impl App {
61386208
loop {
61396209
self.frame_count = self.frame_count.wrapping_add(1);
61406210

6211+
// Re-check daemon socket ~every 30 seconds (300 frames at 10fps).
6212+
if self.frame_count.wrapping_sub(self.daemon_last_checked) >= 300 {
6213+
self.daemon_last_checked = self.frame_count;
6214+
self.daemon_online = dirs::home_dir()
6215+
.map(|h| h.join(".coven").join("coven.sock").exists())
6216+
.unwrap_or(false);
6217+
}
6218+
61416219
// Drain background session-list results.
61426220
if let Some(ref mut rx) = self.session_list_rx {
61436221
match rx.try_recv() {

src-rust/crates/tui/src/render.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -562,6 +562,11 @@ pub fn render_app(frame: &mut Frame, app: &App) {
562562
render_global_search(&app.global_search, size, frame.buffer_mut());
563563
}
564564

565+
// Familiar switcher popup (F2)
566+
if app.familiar_switcher_open {
567+
render_familiar_switcher(frame, app, size);
568+
}
569+
565570
if app.feedback_survey.visible {
566571
render_feedback_survey(&app.feedback_survey, size, frame.buffer_mut());
567572
}
@@ -2031,6 +2036,36 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
20312036
} else {
20322037
let mut spans: Vec<Span> = Vec::new();
20332038

2039+
// Daemon online/offline indicator
2040+
{
2041+
let (label, color) = if app.daemon_online {
2042+
("\u{2726} coven", Color::Rgb(139, 92, 246))
2043+
} else {
2044+
("\u{25cb} coven", Color::DarkGray)
2045+
};
2046+
spans.push(Span::styled(label, Style::default().fg(color)));
2047+
spans.push(Span::raw(" "));
2048+
}
2049+
2050+
// Current familiar emoji + name
2051+
{
2052+
let familiar_id = app.config.familiar.as_deref().unwrap_or("kitty");
2053+
let emoji = match familiar_id {
2054+
"nova" => "\u{1f451}",
2055+
"kitty" => "\u{1f431}",
2056+
"cody" => "\u{1f4bb}",
2057+
"charm" => "\u{2728}",
2058+
"sage" => "\u{1f33f}",
2059+
"astra" => "\u{1f319}",
2060+
"echo" => "\u{1f47b}",
2061+
_ => "\u{2b50}",
2062+
};
2063+
spans.push(Span::styled(
2064+
format!("{} {} ", emoji, familiar_id),
2065+
Style::default().fg(Color::DarkGray),
2066+
));
2067+
}
2068+
20342069
// Agent type badge (shown when running as subagent / coordinator)
20352070
if let Some(ref badge) = app.agent_type_badge {
20362071
spans.push(Span::styled(
@@ -2966,3 +3001,69 @@ pub fn render_teammate_header(
29663001

29673002
Line::from(spans)
29683003
}
3004+
3005+
3006+
// ---------------------------------------------------------------------------
3007+
// Familiar switcher popup (F2)
3008+
// ---------------------------------------------------------------------------
3009+
3010+
fn render_familiar_switcher(frame: &mut Frame, app: &App, area: Rect) {
3011+
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState};
3012+
3013+
let list_len = app.familiar_switcher_list.len() as u16;
3014+
let popup_h = list_len.saturating_add(2).min(area.height.saturating_sub(4));
3015+
let popup_w = 26u16.min(area.width.saturating_sub(4));
3016+
let popup_x = area.x + area.width.saturating_sub(popup_w) / 2;
3017+
let popup_y = area.y + area.height.saturating_sub(popup_h) / 2;
3018+
let popup_area = Rect {
3019+
x: popup_x,
3020+
y: popup_y,
3021+
width: popup_w,
3022+
height: popup_h,
3023+
};
3024+
3025+
frame.render_widget(Clear, popup_area);
3026+
3027+
let builtin_emoji: &[(&str, &str)] = &[
3028+
("nova", "\u{1f451}"),
3029+
("kitty", "\u{1f431}"),
3030+
("cody", "\u{1f4bb}"),
3031+
("charm", "\u{2728}"),
3032+
("sage", "\u{1f33f}"),
3033+
("astra", "\u{1f319}"),
3034+
("echo", "\u{1f47b}"),
3035+
];
3036+
3037+
let items: Vec<ListItem> = app
3038+
.familiar_switcher_list
3039+
.iter()
3040+
.enumerate()
3041+
.map(|(i, id)| {
3042+
let emoji = builtin_emoji
3043+
.iter()
3044+
.find(|(k, _)| *k == id.as_str())
3045+
.map(|(_, e)| *e)
3046+
.unwrap_or("\u{2b50}");
3047+
let label = format!(" {} {} ", emoji, id);
3048+
let style = if i == app.familiar_switcher_idx {
3049+
Style::default()
3050+
.fg(Color::Black)
3051+
.bg(Color::Rgb(139, 92, 246))
3052+
.add_modifier(Modifier::BOLD)
3053+
} else {
3054+
Style::default().fg(Color::White)
3055+
};
3056+
ListItem::new(label).style(style)
3057+
})
3058+
.collect();
3059+
3060+
let block = Block::default()
3061+
.title(" \u{2728} Familiar (F2) ")
3062+
.borders(Borders::ALL)
3063+
.border_style(Style::default().fg(Color::Rgb(139, 92, 246)));
3064+
3065+
let list = List::new(items).block(block);
3066+
let mut state = ListState::default();
3067+
state.select(Some(app.familiar_switcher_idx));
3068+
frame.render_stateful_widget(list, popup_area, &mut state);
3069+
}

0 commit comments

Comments
 (0)