aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-20 00:54:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-20 00:55:36 +0300
commitd30ab1b54f6b6be95a0cb47ddc38da1d5e088700 (patch)
treeeb2002b408c856d9fcd7a573afe73b917f22cd97 /src/ui
parent3f80947678a9723434aa7fddfe23750b5393e24c (diff)
feat: add plugin-driven agent views and startup hooks
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/mobile.rs102
-rw-r--r--src/ui/sidebar.rs75
-rw-r--r--src/ui/sidebar/tokens.rs1
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,