feat(tui): Sprint 6 — Agent View overlay with session monitoring

- Create src/agent_view.rs: AgentView data model with session tracking,
  status filtering (All/Running/Done/Failed), sorting, selection
- Agent View renders as full-screen overlay with session list + detail panel
- Keybindings: Ctrl+A open, Esc/q close, j/k navigate, Tab filter, s sort
- All colors from theme (agent_running/waiting/done/failed/cancelled)
- Command Palette overlay also rendered (from Sprint 4)
- 7 AgentView tests + 5 CommandPalette tests
- 274 tests pass

Sprint 6 of 7.

Authored by TheArchitectit
This commit is contained in:
Claude 2026-06-11 19:19:38 -05:00
parent 81beafa2a1
commit 6c872ba2e2
3 changed files with 470 additions and 2 deletions

View File

@ -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<String>,
}
#[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<AgentSession>,
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);
}
}

View File

@ -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<dyn std::error::Error>> {
}
}
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();
}
}
}
}

View File

@ -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<String> = 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<Line> = 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<ListItem> = 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.