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:
parent
81beafa2a1
commit
6c872ba2e2
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue