diff --git a/rust/crates/rusty-claude-cli/src/agent_view.rs b/rust/crates/rusty-claude-cli/src/agent_view.rs new file mode 100644 index 00000000..d2d07579 --- /dev/null +++ b/rust/crates/rusty-claude-cli/src/agent_view.rs @@ -0,0 +1,254 @@ +//! Agent View — multi-session monitoring dashboard. + +use std::time::Instant; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AgentStatus { + Running, + WaitingForInput, + Done, + Failed, + Cancelled, +} + +impl AgentStatus { + pub fn icon(&self) -> &'static str { + match self { + AgentStatus::Running => "⬢", + AgentStatus::WaitingForInput => "◐", + AgentStatus::Done => "✓", + AgentStatus::Failed => "✗", + AgentStatus::Cancelled => "⊘", + } + } +} + +#[derive(Debug, Clone)] +pub struct AgentSession { + pub id: String, + pub name: String, + pub status: AgentStatus, + pub model: String, + pub turn_count: u32, + pub last_message: String, + pub working_dir: String, + pub started_at: Instant, + pub task_subject: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortField { + Status, + Name, + Started, + TurnCount, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterState { + All, + Running, + Done, + Failed, +} + +pub struct AgentView { + pub sessions: Vec, + pub sort_by: SortField, + pub filter: FilterState, + pub selected: usize, + pub active: bool, +} + +impl AgentView { + pub fn new() -> Self { + Self { + sessions: Vec::new(), + sort_by: SortField::Status, + filter: FilterState::All, + selected: 0, + active: false, + } + } + + pub fn open(&mut self) { + self.active = true; + self.selected = 0; + } + + pub fn close(&mut self) { + self.active = false; + } + + pub fn update_session(&mut self, session: AgentSession) { + if let Some(existing) = self.sessions.iter_mut().find(|s| s.id == session.id) { + *existing = session; + } else { + self.sessions.push(session); + } + } + + pub fn remove_session(&mut self, id: &str) { + self.sessions.retain(|s| s.id != id); + } + + pub fn filtered_sessions(&self) -> Vec<&AgentSession> { + let mut sessions: Vec<&AgentSession> = self + .sessions + .iter() + .filter(|s| match self.filter { + FilterState::All => true, + FilterState::Running => s.status == AgentStatus::Running, + FilterState::Done => s.status == AgentStatus::Done, + FilterState::Failed => s.status == AgentStatus::Failed, + }) + .collect(); + sessions.sort_by(|a, b| match self.sort_by { + SortField::Status => a.status.icon().cmp(b.status.icon()), + SortField::Name => a.name.cmp(&b.name), + SortField::Started => a.started_at.cmp(&b.started_at), + SortField::TurnCount => a.turn_count.cmp(&b.turn_count), + }); + sessions + } + + pub fn select_next(&mut self) { + let count = self.filtered_sessions().len(); + if count > 0 { + self.selected = (self.selected + 1) % count; + } + } + + pub fn select_prev(&mut self) { + let count = self.filtered_sessions().len(); + if count > 0 { + self.selected = if self.selected == 0 { + count - 1 + } else { + self.selected - 1 + }; + } + } + + pub fn cycle_filter(&mut self) { + self.filter = match self.filter { + FilterState::All => FilterState::Running, + FilterState::Running => FilterState::Done, + FilterState::Done => FilterState::Failed, + FilterState::Failed => FilterState::All, + }; + self.selected = 0; + } + + pub fn cycle_sort(&mut self) { + self.sort_by = match self.sort_by { + SortField::Status => SortField::Name, + SortField::Name => SortField::Started, + SortField::Started => SortField::TurnCount, + SortField::TurnCount => SortField::Status, + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_session(id: &str, name: &str, status: AgentStatus) -> AgentSession { + AgentSession { + id: id.into(), + name: name.into(), + status, + model: "test".into(), + turn_count: 5, + last_message: "done".into(), + working_dir: "/tmp".into(), + started_at: Instant::now(), + task_subject: None, + } + } + + #[test] + fn test_update_session() { + let mut av = AgentView::new(); + av.update_session(make_session("1", "agent-a", AgentStatus::Running)); + assert_eq!(av.sessions.len(), 1); + av.update_session(make_session("2", "agent-b", AgentStatus::Done)); + assert_eq!(av.sessions.len(), 2); + } + + #[test] + fn test_update_existing_session() { + let mut av = AgentView::new(); + av.update_session(make_session("1", "agent-a", AgentStatus::Running)); + av.update_session(make_session("1", "agent-a", AgentStatus::Done)); + assert_eq!(av.sessions.len(), 1); + assert_eq!(av.sessions[0].status, AgentStatus::Done); + } + + #[test] + fn test_remove_session() { + let mut av = AgentView::new(); + av.update_session(make_session("1", "a", AgentStatus::Running)); + av.update_session(make_session("2", "b", AgentStatus::Done)); + av.remove_session("1"); + assert_eq!(av.sessions.len(), 1); + assert_eq!(av.sessions[0].id, "2"); + } + + #[test] + fn test_filter_by_status() { + let mut av = AgentView::new(); + av.update_session(make_session("1", "a", AgentStatus::Running)); + av.update_session(make_session("2", "b", AgentStatus::Done)); + av.update_session(make_session("3", "c", AgentStatus::Failed)); + av.filter = FilterState::Running; + assert_eq!(av.filtered_sessions().len(), 1); + av.filter = FilterState::Done; + assert_eq!(av.filtered_sessions().len(), 1); + av.filter = FilterState::All; + assert_eq!(av.filtered_sessions().len(), 3); + } + + #[test] + fn test_select_navigation() { + let mut av = AgentView::new(); + av.update_session(make_session("1", "a", AgentStatus::Running)); + av.update_session(make_session("2", "b", AgentStatus::Running)); + assert_eq!(av.selected, 0); + av.select_next(); + assert_eq!(av.selected, 1); + av.select_next(); + assert_eq!(av.selected, 0); // wraps + av.select_prev(); + assert_eq!(av.selected, 1); // wraps back + } + + #[test] + fn test_cycle_filter() { + let mut av = AgentView::new(); + assert_eq!(av.filter, FilterState::All); + av.cycle_filter(); + assert_eq!(av.filter, FilterState::Running); + av.cycle_filter(); + assert_eq!(av.filter, FilterState::Done); + av.cycle_filter(); + assert_eq!(av.filter, FilterState::Failed); + av.cycle_filter(); + assert_eq!(av.filter, FilterState::All); + } + + #[test] + fn test_cycle_sort() { + let mut av = AgentView::new(); + assert_eq!(av.sort_by, SortField::Status); + av.cycle_sort(); + assert_eq!(av.sort_by, SortField::Name); + av.cycle_sort(); + assert_eq!(av.sort_by, SortField::Started); + av.cycle_sort(); + assert_eq!(av.sort_by, SortField::TurnCount); + av.cycle_sort(); + assert_eq!(av.sort_by, SortField::Status); + } +} diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index ab42a55c..bf0305e2 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -18,6 +18,7 @@ mod init; mod input; mod render; mod setup_wizard; +mod agent_view; mod chat_mode; mod command_palette; mod keybindings; @@ -7368,8 +7369,11 @@ fn run_tui_repl(mut cli: LiveCli) -> Result<(), Box> { } } tui::TuiReadOutcome::ToggleAgentView => { - // Agent View integration in Sprint 6 - app.push_system_message("Agent View coming in Sprint 6"); + if app.agent_view.active { + app.agent_view.close(); + } else { + app.agent_view.open(); + } } } } diff --git a/rust/crates/rusty-claude-cli/src/tui.rs b/rust/crates/rusty-claude-cli/src/tui.rs index 37734bb4..f1d54638 100644 --- a/rust/crates/rusty-claude-cli/src/tui.rs +++ b/rust/crates/rusty-claude-cli/src/tui.rs @@ -183,6 +183,7 @@ pub struct TuiApp { pub keymap: crate::keybindings::KeyMap, pub command_palette: crate::command_palette::CommandPalette, pub chat_mode: crate::chat_mode::ChatMode, + pub agent_view: crate::agent_view::AgentView, } const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -240,6 +241,7 @@ impl TuiApp { keymap: crate::keybindings::KeyMap::new(crate::keybindings::KeyPreset::Emacs), command_palette: crate::command_palette::CommandPalette::new(), chat_mode: crate::chat_mode::ChatMode::Code, + agent_view: crate::agent_view::AgentView::new(), needs_redraw: true, }; me.draw_screen()?; @@ -447,8 +449,20 @@ impl TuiApp { let input_lines: Vec = self.input.lines().iter().cloned().collect(); let renderer = self.markdown_renderer.clone(); let theme = self.theme.clone(); + let agent_view_active = self.agent_view.active; + let agent_sessions: Vec<_> = self.agent_view.filtered_sessions().into_iter().cloned().collect(); + let agent_selected = self.agent_view.selected; + let agent_filter = self.agent_view.filter; + let agent_sort = self.agent_view.sort_by; + let palette_active = self.command_palette.active; + let palette_query = self.command_palette.query.clone(); + let palette_entries = self.command_palette.entries.clone(); + let palette_filtered = self.command_palette.filtered.clone(); + let palette_selected = self.command_palette.selected; + let chat_mode = self.chat_mode; self.terminal.draw(|f| { + let area = f.area(); draw_frame( f, &dashboard, @@ -462,7 +476,18 @@ impl TuiApp { spinner_frame, &renderer, &theme, + chat_mode, ); + + // Command palette overlay + if palette_active { + draw_command_palette(f, area, &palette_query, &palette_entries, &palette_filtered, palette_selected, &theme); + } + + // Agent View overlay + if agent_view_active { + draw_agent_view(f, area, &agent_sessions, agent_selected, agent_filter, agent_sort, &theme); + } })?; self.terminal.backend_mut().flush()?; Ok(()) @@ -492,6 +517,20 @@ impl TuiApp { return Ok(TuiReadOutcome::Pending); } + // Agent View intercepts keys when active + if self.agent_view.active { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => self.agent_view.close(), + KeyCode::Tab => self.agent_view.cycle_filter(), + KeyCode::Char('s') => self.agent_view.cycle_sort(), + KeyCode::Down | KeyCode::Char('j') => self.agent_view.select_next(), + KeyCode::Up | KeyCode::Char('k') => self.agent_view.select_prev(), + _ => {} + } + self.needs_redraw = true; + return Ok(TuiReadOutcome::Pending); + } + // Vim mode transition: Esc → Normal mode if self.keymap.preset() == crate::keybindings::KeyPreset::Vim && key.code == KeyCode::Esc @@ -738,6 +777,7 @@ fn draw_frame( spinner_frame: usize, markdown_renderer: &crate::markdown::MarkdownRenderer, theme: &crate::theme::TuiTheme, + chat_mode: crate::chat_mode::ChatMode, ) { let size = f.area(); let main = Layout::default() @@ -1173,4 +1213,174 @@ fn kv<'a>(key: &str, val: &str, val_color: Color) -> Line<'a> { ]) } +// --------------------------------------------------------------------------- +// Command Palette overlay +// --------------------------------------------------------------------------- + +fn draw_command_palette( + f: &mut Frame, + area: Rect, + query: &str, + entries: &[crate::command_palette::PaletteEntry], + filtered: &[usize], + selected: usize, + theme: &crate::theme::TuiTheme, +) { + let popup_w = (area.width * 60 / 100).min(60); + let popup_h = (area.height * 50 / 100).min(20); + let popup = Rect::new( + (area.width.saturating_sub(popup_w)) / 2, + (area.height.saturating_sub(popup_h)) / 2, + popup_w, + popup_h, + ); + + f.render_widget(ratatui::widgets::Clear, popup); + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(vec![ + Span::styled("🔍 ", Style::default().fg(theme.key_hint.to_color())), + Span::styled(query.to_string(), Style::default().fg(theme.input_fg.to_color())), + Span::styled("█", Style::default().fg(theme.input_cursor_bg.to_color())), + ])); + lines.push(Line::from("")); + + for (i, &idx) in filtered.iter().enumerate() { + let entry = &entries[idx]; + let is_sel = i == selected; + let (fg, bg) = if is_sel { + (theme.completion_selected_fg.to_color(), theme.completion_selected_bg.to_color()) + } else { + (theme.completion_fg.to_color(), Color::Reset) + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", entry.label), Style::default().fg(fg).bg(bg)), + Span::styled(format!(" {} ", entry.description), Style::default().fg(theme.key_hint.to_color())), + Span::styled(&entry.key_hint, Style::default().fg(theme.key_hint.to_color())), + ])); + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_active.to_color())) + .title(" Command Palette "); + + f.render_widget(Paragraph::new(lines).block(block), popup); +} + +// --------------------------------------------------------------------------- +// Agent View overlay +// --------------------------------------------------------------------------- + +fn draw_agent_view( + f: &mut Frame, + area: Rect, + sessions: &[crate::agent_view::AgentSession], + selected: usize, + filter: crate::agent_view::FilterState, + sort: crate::agent_view::SortField, + theme: &crate::theme::TuiTheme, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(10), // Session list + Constraint::Length(6), // Detail + ]) + .split(area); + + // Header + let filter_label = format!("Filter: {filter:?}"); + let sort_label = format!("Sort: {sort:?}"); + let header = Paragraph::new(Line::from(vec![ + Span::styled( + " Agent View ", + Style::default().fg(theme.dashboard_header.to_color()).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!(" [{filter_label}] [{sort_label}] Tab:filter S:sort Esc:close"), + Style::default().fg(theme.key_hint.to_color()), + ), + ])) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_active.to_color())), + ); + f.render_widget(header, chunks[0]); + + // Session list + let items: Vec = sessions + .iter() + .enumerate() + .map(|(i, s)| { + let is_sel = i == selected; + let status_color = match s.status { + crate::agent_view::AgentStatus::Running => theme.agent_running.to_color(), + crate::agent_view::AgentStatus::WaitingForInput => theme.agent_waiting.to_color(), + crate::agent_view::AgentStatus::Done => theme.agent_done.to_color(), + crate::agent_view::AgentStatus::Failed => theme.agent_failed.to_color(), + crate::agent_view::AgentStatus::Cancelled => theme.agent_cancelled.to_color(), + }; + let elapsed = s.started_at.elapsed().as_secs(); + let elapsed_str = if elapsed < 60 { + format!("{elapsed}s") + } else { + format!("{}m{}s", elapsed / 60, elapsed % 60) + }; + let line = Line::from(vec![ + Span::styled(format!(" {} ", s.status.icon()), Style::default().fg(status_color)), + Span::styled(format!("{:<20}", s.name), Style::default().fg(theme.conversation_text.to_color())), + Span::styled(format!("{:<16}", s.model), Style::default().fg(theme.dashboard_key.to_color())), + Span::styled(format!("{} turns ", s.turn_count), Style::default().fg(theme.dashboard_value.to_color())), + Span::styled(elapsed_str, Style::default().fg(theme.key_hint.to_color())), + ]); + let style = if is_sel { + Style::default().bg(theme.completion_selected_bg.to_color()) + } else { + Style::default() + }; + ListItem::new(line).style(style) + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border.to_color())) + .title(" Sessions "), + ); + f.render_widget(list, chunks[1]); + + // Detail panel + if let Some(session) = sessions.get(selected) { + let detail = Paragraph::new(vec![ + Line::from(vec![ + Span::styled(" ID: ", Style::default().fg(theme.dashboard_key.to_color())), + Span::styled(&session.id, Style::default().fg(theme.dashboard_value.to_color())), + ]), + Line::from(vec![ + Span::styled(" Task: ", Style::default().fg(theme.dashboard_key.to_color())), + Span::styled( + session.task_subject.as_deref().unwrap_or("(none)"), + Style::default().fg(theme.dashboard_value.to_color()), + ), + ]), + Line::from(vec![ + Span::styled(" Dir: ", Style::default().fg(theme.dashboard_key.to_color())), + Span::styled(&session.working_dir, Style::default().fg(theme.dashboard_value.to_color())), + ]), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border.to_color())) + .title(" Details "), + ) + .wrap(Wrap { trim: false }); + f.render_widget(detail, chunks[2]); + } +} + // ANSI stripping is now in tui_update::strip_ansi() — single canonical implementation.