diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-20 00:54:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-20 00:55:36 +0300 |
| commit | d30ab1b54f6b6be95a0cb47ddc38da1d5e088700 (patch) | |
| tree | eb2002b408c856d9fcd7a573afe73b917f22cd97 /src/ui | |
| parent | 3f80947678a9723434aa7fddfe23750b5393e24c (diff) | |
feat: add plugin-driven agent views and startup hooks
Diffstat (limited to 'src/ui')
| -rw-r--r-- | src/ui/mobile.rs | 102 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 75 | ||||
| -rw-r--r-- | src/ui/sidebar/tokens.rs | 1 |
3 files changed, 137 insertions, 41 deletions
diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index d1e6ac61..10b58af0 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -94,13 +94,12 @@ pub(crate) fn mobile_switcher_max_scroll_for_height(app: &AppState, viewport_hei mobile_switcher_content_height(app).saturating_sub(viewport_height as usize) } -/// Doc-row height of the agents section (title + two rows per agent), or 0 when -/// there are no agents so we don't show an empty header. The switcher leads with -/// agents, so every section below it is offset by this. +/// Doc-row height of the agents section. An active query keeps its title and an +/// empty-state row visible even when no agents match. fn mobile_agents_block_height(app: &AppState) -> usize { let count = agent_panel_entries(app).len(); if count == 0 { - 0 + usize::from(app.agent_view_override.is_some()) * 2 } else { 1 + count * 2 } @@ -136,27 +135,34 @@ pub(crate) fn mobile_switcher_target_at( return None; } - let doc_row = app + let scroll = app .mobile_switcher_scroll - .saturating_add(row.saturating_sub(areas.viewport.y) as usize); + .min(mobile_switcher_max_scroll_for_height( + app, + areas.viewport.height, + )); + let doc_row = scroll.saturating_add(row.saturating_sub(areas.viewport.y) as usize); let mut cursor = 0usize; // Agents lead the switcher: the primary job is switching between running // agents. Spaces/tabs/create actions follow for navigation and management. - // The section is omitted entirely when there are no agents. let agents = agent_panel_entries(app); - if !agents.is_empty() { + if !agents.is_empty() || app.agent_view_override.is_some() { cursor += 1; // agents title - let agents_end = cursor + agents.len() * 2; - if doc_row >= cursor && doc_row < agents_end { - let idx = (doc_row - cursor) / 2; - return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { - ws_idx: entry.ws_idx, - tab_idx: entry.tab_idx, - pane_id: entry.pane_id, - }); + if agents.is_empty() { + cursor += 1; // active-query empty state + } else { + let agents_end = cursor + agents.len() * 2; + if doc_row >= cursor && doc_row < agents_end { + let idx = (doc_row - cursor) / 2; + return agents.get(idx).map(|entry| MobileSwitcherTarget::Agent { + ws_idx: entry.ws_idx, + tab_idx: entry.tab_idx, + pane_id: entry.pane_id, + }); + } + cursor = agents_end; } - cursor = agents_end; } cursor += 1; // spaces title @@ -492,22 +498,42 @@ fn render_mobile_switcher_content( let mut doc_y = 0usize; let entries = agent_panel_entries_from(app, terminal_runtimes); - if !entries.is_empty() { + if !entries.is_empty() || app.agent_view_override.is_some() { let focused_agent = app.active.and_then(|ws_idx| { let ws = app.workspaces.get(ws_idx)?; ws.focused_pane_id() .map(|pane_id| (ws_idx, ws.active_tab, pane_id)) }); + let title = app + .agent_view_override + .as_ref() + .map(|view| format!("agents · {}", view.label.as_deref().unwrap_or("filtered"))) + .unwrap_or_else(|| "agents".to_string()); render_section_title_at( frame, viewport, content, doc_y, app.mobile_switcher_scroll, - "agents", + &title, p, ); doc_y += 1; + if entries.is_empty() { + render_one_line_item( + frame, + viewport, + content, + doc_y, + app.mobile_switcher_scroll, + ratatui::style::Color::Reset, + Line::from(Span::styled( + " no matching agents", + Style::default().fg(p.overlay0).add_modifier(Modifier::DIM), + )), + ); + doc_y += 1; + } for entry in &entries { let active = focused_agent.is_some_and(|(ws_idx, tab_idx, pane_id)| { entry.ws_idx == ws_idx && entry.tab_idx == tab_idx && entry.pane_id == pane_id @@ -968,7 +994,7 @@ impl GlobalAgentCounts { fn global_agent_counts(app: &AppState) -> GlobalAgentCounts { let mut counts = GlobalAgentCounts::default(); - for entry in agent_panel_entries(app) { + for entry in crate::ui::all_agent_panel_entries(app) { match (entry.state, entry.seen) { (AgentState::Blocked, _) => counts.blocked += 1, (AgentState::Idle, false) => counts.done += 1, @@ -1140,6 +1166,7 @@ mod tests { terminal_title: None, terminal_title_stripped: None, agent_label: agent_label.map(str::to_string), + agent_kind_label: agent_label.map(str::to_string), agent: agent_label.and_then(crate::detect::parse_agent_label), state: AgentState::Idle, seen: true, @@ -1150,6 +1177,40 @@ mod tests { } #[test] + fn global_agent_counts_ignore_active_agent_view_filter() { + let mut app = AppState::test_new(); + app.workspaces = vec![ + crate::workspace::Workspace::test_new("blocked"), + crate::workspace::Workspace::test_new("working"), + ]; + app.ensure_test_terminals(); + for (ws_idx, state) in [(0, AgentState::Blocked), (1, AgentState::Working)] { + let pane_id = app.workspaces[ws_idx].tabs[0].root_pane; + let terminal_id = app.workspaces[ws_idx].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let terminal = app.terminals.get_mut(&terminal_id).unwrap(); + terminal.detected_agent = Some(crate::detect::Agent::Claude); + terminal.state = state; + } + app.agent_view_override = Some(crate::api::schema::AgentViewSetParams { + source: "example.views".to_string(), + label: None, + filter: Some(crate::api::schema::AgentViewFilter::Eq { + field: crate::api::schema::AgentViewField::Builtin( + crate::api::schema::AgentViewBuiltinField::Status, + ), + value: crate::api::schema::AgentViewValue::String("working".to_string()), + }), + sort: Vec::new(), + }); + + let counts = global_agent_counts(&app); + assert_eq!(counts.blocked, 1); + assert_eq!(counts.working, 1); + } + + #[test] fn agent_summary_leads_with_attention_states_in_priority_order() { let counts = GlobalAgentCounts { blocked: 2, @@ -1252,6 +1313,7 @@ mod tests { assert_eq!(mobile_switcher_workspace_doc_range(&app, 0).start, 7); let viewport = mobile_switcher_areas(&app).viewport; + app.mobile_switcher_scroll = 100; let agent_hit = mobile_switcher_target_at(&app, viewport.x + 2, viewport.y + 1); assert!(matches!( agent_hit, diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index f89b6e6d..0ecfb652 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -30,6 +30,7 @@ pub(crate) struct AgentPanelEntry { pub terminal_title: Option<String>, pub terminal_title_stripped: Option<String>, pub agent_label: Option<String>, + pub agent_kind_label: Option<String>, pub agent: Option<crate::detect::Agent>, pub state: AgentState, pub seen: bool, @@ -85,12 +86,15 @@ fn agent_panel_sort_label(sort: AgentPanelSort) -> &'static str { } pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect { + agent_panel_header_label_rect(area, agent_panel_sort_label(sort)) +} + +fn agent_panel_header_label_rect(area: Rect, label: &str) -> Rect { if area.width == 0 || area.height < 2 { return Rect::default(); } - let label = agent_panel_sort_label(sort); - let width = display_width_u16(label); + let width = display_width_u16(label).min(area.width); Rect::new( area.x + area.width.saturating_sub(width), area.y + 1, @@ -99,10 +103,20 @@ pub(crate) fn agent_panel_toggle_rect(area: Rect, sort: AgentPanelSort) -> Rect ) } +fn active_agent_view_label(app: &AppState) -> Option<&str> { + app.agent_view_override + .as_ref() + .map(|view| view.label.as_deref().unwrap_or("filtered")) +} + pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { agent_panel_entries_with_runtimes(app, None) } +pub(crate) fn all_agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { + collect_agent_panel_entries_with_runtimes(app, None) +} + pub(crate) fn agent_panel_entries_from( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, @@ -114,6 +128,15 @@ fn agent_panel_entries_with_runtimes( app: &AppState, terminal_runtimes: Option<&TerminalRuntimeRegistry>, ) -> Vec<AgentPanelEntry> { + let mut entries = collect_agent_panel_entries_with_runtimes(app, terminal_runtimes); + crate::app::agent_view::apply_agent_view(app, &mut entries); + entries +} + +fn collect_agent_panel_entries_with_runtimes( + app: &AppState, + terminal_runtimes: Option<&TerminalRuntimeRegistry>, +) -> Vec<AgentPanelEntry> { let empty_runtimes; let terminal_runtimes = match terminal_runtimes { Some(terminal_runtimes) => terminal_runtimes, @@ -123,8 +146,7 @@ fn agent_panel_entries_with_runtimes( } }; - let mut entries: Vec<_> = app - .workspaces + app.workspaces .iter() .enumerate() .flat_map(|(ws_idx, ws)| { @@ -148,6 +170,7 @@ fn agent_panel_entries_with_runtimes( terminal_title: detail.terminal_title, terminal_title_stripped: detail.terminal_title_stripped, agent_label: Some(detail.agent_label), + agent_kind_label: detail.agent_kind_label, agent: detail.agent, state: detail.state, seen: detail.seen, @@ -157,18 +180,7 @@ fn agent_panel_entries_with_runtimes( } }) }) - .collect(); - - if matches!(app.agent_panel_sort, AgentPanelSort::Priority) { - entries.sort_by_key(|entry| { - ( - std::cmp::Reverse(workspace_attention_priority(entry.state, entry.seen)), - std::cmp::Reverse(entry.last_agent_state_change_seq), - ) - }); - } - - entries + .collect() } pub(super) fn agent_panel_status_key(state: AgentState, seen: bool) -> &'static str { @@ -800,9 +812,14 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: if let Some(divider_y) = divider_y { let buf = frame.buffer_mut(); + let divider_color = if app.agent_view_override.is_some() { + p.accent + } else { + p.surface_dim + }; for x in ws_area.x..ws_area.x + ws_area.width { buf[(x, divider_y)].set_symbol("─"); - buf[(x, divider_y)].set_style(Style::default().fg(p.surface_dim)); + buf[(x, divider_y)].set_style(Style::default().fg(divider_color)); } } @@ -1315,12 +1332,19 @@ fn render_agent_detail( )])), Rect::new(area.x, area.y + 1, area.width, 1), ); - let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_sort); + let control_label = active_agent_view_label(app) + .unwrap_or_else(|| agent_panel_sort_label(app.agent_panel_sort)); + let toggle_rect = agent_panel_header_label_rect(area, control_label); if toggle_rect != Rect::default() { + let color = if app.agent_view_override.is_some() { + p.accent + } else { + p.overlay0 + }; frame.render_widget( Paragraph::new(Span::styled( - agent_panel_sort_label(app.agent_panel_sort), - Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), + control_label, + Style::default().fg(color).add_modifier(Modifier::BOLD), )) .alignment(Alignment::Right), toggle_rect, @@ -1334,10 +1358,19 @@ fn render_agent_detail( if body == Rect::default() { return; } + if details.is_empty() && app.agent_view_override.is_some() { + frame.render_widget( + Paragraph::new(" no matching agents") + .style(Style::default().fg(p.overlay0).add_modifier(Modifier::DIM)), + Rect::new(body.x, body.y, body.width, 1), + ); + return; + } + let scroll = app.agent_panel_scroll.min(metrics.max_offset_from_bottom); let mut row_y = body.y; let body_bottom = body.y + body.height; - for (index, detail) in details.iter().enumerate().skip(app.agent_panel_scroll) { + for (index, detail) in details.iter().enumerate().skip(scroll) { let label_color = state_label_color(detail.state, detail.seen, p); let rows = resolved_agent_rows(app, detail); let height = (rows.len().max(1) as u16).min(body.height); diff --git a/src/ui/sidebar/tokens.rs b/src/ui/sidebar/tokens.rs index 22e13e86..c578e13f 100644 --- a/src/ui/sidebar/tokens.rs +++ b/src/ui/sidebar/tokens.rs @@ -168,6 +168,7 @@ mod tests { terminal_title: None, terminal_title_stripped: None, agent_label: Some("pi".into()), + agent_kind_label: Some("pi".into()), agent: Some(crate::detect::Agent::Pi), state: AgentState::Working, seen: true, |
