diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-06 19:24:33 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-06 19:24:33 +0300 |
| commit | cbe5c150baa147649912bb1bfad40522dc3c25f3 (patch) | |
| tree | 44622605ee8972bf8cf27f2ae784a122712b00f9 | |
| parent | 62b1f5e011c46403c3b78f661a3e5a385d2ec72c (diff) | |
feat: add all-workspaces scope to agent panel
| -rw-r--r-- | src/app/input.rs | 109 | ||||
| -rw-r--r-- | src/app/mod.rs | 22 | ||||
| -rw-r--r-- | src/app/state.rs | 14 | ||||
| -rw-r--r-- | src/persist.rs | 28 | ||||
| -rw-r--r-- | src/ui.rs | 298 |
5 files changed, 402 insertions, 69 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 81089217..6af7cf0e 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -27,8 +27,8 @@ enum WheelRouting { const WORKSPACE_DRAG_THRESHOLD: u16 = 1; use super::state::{ - key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, MenuListState, - Mode, WorkspacePressState, + key_matches, AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, + DragTarget, MenuListState, Mode, WorkspacePressState, }; use super::App; @@ -2025,6 +2025,14 @@ impl AppState { return None; } + if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) { + self.agent_panel_scope = match self.agent_panel_scope { + AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces, + AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace, + }; + return None; + } + if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row) { self.switch_workspace(ws_idx); @@ -2458,29 +2466,26 @@ impl AppState { best.map(|(insert_idx, _)| insert_idx) } - fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> { + fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool { if self.sidebar_collapsed { - return None; + return false; } - let content = Rect::new( - self.view.sidebar_rect.x, - self.view.sidebar_rect.y, - self.view.sidebar_rect.width.saturating_sub(1), - self.view.sidebar_rect.height, - ); - if content.width == 0 || content.height == 0 { + let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect); + let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope); + rect.width > 0 + && col >= rect.x + && col < rect.x + rect.width + && row >= rect.y + && row < rect.y + rect.height + } + + fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> { + if self.sidebar_collapsed { return None; } - let total_h = content.height as usize; - let ws_h = (total_h + 1) / 2; - let detail_area = Rect::new( - content.x, - content.y + ws_h as u16, - content.width, - total_h.saturating_sub(ws_h) as u16, - ); + let (_, detail_area) = crate::ui::expanded_sidebar_sections(self.view.sidebar_rect); if detail_area.height < 4 || row < detail_area.y + 3 || row >= detail_area.y + detail_area.height @@ -2488,17 +2493,15 @@ impl AppState { return None; } - let detail_ws_idx = self.collapsed_detail_workspace_idx()?; - let ws = self.workspaces.get(detail_ws_idx)?; let relative_row = row - (detail_area.y + 3); let entry_height = 3; if relative_row % entry_height == 2 { return None; } let detail_idx = (relative_row / entry_height) as usize; - let details = ws.pane_details(); + let details = crate::ui::agent_panel_entries(self); let detail = details.get(detail_idx)?; - Some((detail_ws_idx, detail.tab_idx, detail.pane_id)) + Some((detail.ws_idx, detail.tab_idx, detail.pane_id)) } fn screen_rect(&self) -> Rect { @@ -3513,6 +3516,66 @@ mod tests { } #[test] + fn clicking_agent_panel_toggle_switches_scope() { + let mut app = app_for_mouse_test(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect); + let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + toggle.x, + toggle.y, + )); + + assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces); + } + + #[test] + fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() { + let mut app = app_for_mouse_test(); + let mut first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + first.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let mut second = Workspace::test_new("two"); + let second_pane = second.tabs[0].root_pane; + second.tabs[0] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + app.state.workspaces = vec![first, second]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces; + + let (_, detail_area) = crate::ui::expanded_sidebar_sections(app.state.view.sidebar_rect); + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + detail_area.x + 2, + detail_area.y + 6, + )); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.selected, 1); + assert_eq!(app.state.workspaces[1].active_tab, 0); + assert_eq!( + app.state.workspaces[1].tabs[0].layout.focused(), + second_pane + ); + } + + #[test] fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() { let mut app = app_for_mouse_test(); let mut ws = Workspace::test_new("test"); diff --git a/src/app/mod.rs b/src/app/mod.rs index 14274210..f06bbb19 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -157,8 +157,13 @@ impl App { let render_dirty = Arc::new(AtomicBool::new(false)); // Try to restore previous session - let (workspaces, active, selected) = if no_session { - (Vec::new(), None, 0) + let (workspaces, active, selected, agent_panel_scope) = if no_session { + ( + Vec::new(), + None, + 0, + state::AgentPanelScope::CurrentWorkspace, + ) } else if let Some(snap) = crate::persist::load() { let ws = crate::persist::restore( &snap, @@ -170,15 +175,20 @@ impl App { ); if ws.is_empty() { info!("session file found but no workspaces restored"); - (Vec::new(), None, 0) + (Vec::new(), None, 0, snap.agent_panel_scope) } else { info!(count = ws.len(), "session restored"); let active = snap.active.filter(|&i| i < ws.len()); let selected = snap.selected.min(ws.len().saturating_sub(1)); - (ws, active, selected) + (ws, active, selected, snap.agent_panel_scope) } } else { - (Vec::new(), None, 0) + ( + Vec::new(), + None, + 0, + state::AgentPanelScope::CurrentWorkspace, + ) }; let latest_release_notes_available = crate::release_notes::load_latest().is_some(); @@ -240,6 +250,7 @@ impl App { sidebar_width: config.ui.sidebar_width, sidebar_width_auto: true, sidebar_collapsed: false, + agent_panel_scope, confirm_close: config.ui.confirm_close, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), @@ -422,6 +433,7 @@ impl App { &self.state.workspaces, self.state.active, self.state.selected, + self.state.agent_panel_scope, ); crate::persist::save(&snap); } diff --git a/src/app/state.rs b/src/app/state.rs index 74daeeb8..60cb0511 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -356,6 +356,18 @@ pub enum Mode { KeybindHelp, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum AgentPanelScope { + CurrentWorkspace, + AllWorkspaces, +} + +impl Default for AgentPanelScope { + fn default() -> Self { + Self::CurrentWorkspace + } +} + // --------------------------------------------------------------------------- // Settings UI state // --------------------------------------------------------------------------- @@ -586,6 +598,7 @@ pub struct AppState { pub sidebar_width: u16, pub sidebar_width_auto: bool, pub sidebar_collapsed: bool, + pub agent_panel_scope: AgentPanelScope, pub confirm_close: bool, #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, @@ -693,6 +706,7 @@ impl AppState { sidebar_width: 26, sidebar_width_auto: true, sidebar_collapsed: false, + agent_panel_scope: AgentPanelScope::CurrentWorkspace, confirm_close: true, accent: Color::Cyan, sound: SoundConfig { diff --git a/src/persist.rs b/src/persist.rs index 345fe9f7..72ac9bef 100644 --- a/src/persist.rs +++ b/src/persist.rs @@ -30,6 +30,8 @@ pub struct SessionSnapshot { pub workspaces: Vec<WorkspaceSnapshot>, pub active: Option<usize>, pub selected: usize, + #[serde(default)] + pub agent_panel_scope: crate::app::state::AgentPanelScope, } #[derive(Serialize)] @@ -215,12 +217,14 @@ pub fn capture( workspaces: &[Workspace], active: Option<usize>, selected: usize, + agent_panel_scope: crate::app::state::AgentPanelScope, ) -> SessionSnapshot { SessionSnapshot { version: SNAPSHOT_VERSION, workspaces: workspaces.iter().map(capture_workspace).collect(), active, selected, + agent_panel_scope, } } @@ -556,6 +560,7 @@ mod tests { workspaces: vec![], active: None, selected: 0, + agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace, }; let json = serde_json::to_string(&snap).unwrap(); let restored: SessionSnapshot = serde_json::from_str(&json).unwrap(); @@ -624,6 +629,7 @@ mod tests { }], active: Some(0), selected: 0, + agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace, version: SNAPSHOT_VERSION, }; @@ -642,6 +648,28 @@ mod tests { restored.workspaces[0].tabs[0].panes[&0].cwd, PathBuf::from("/home/can/Projects/herdr") ); + assert_eq!( + restored.agent_panel_scope, + crate::app::state::AgentPanelScope::CurrentWorkspace + ); + } + + #[test] + fn old_snapshot_defaults_agent_panel_scope() { + let json = serde_json::json!({ + "version": SNAPSHOT_VERSION, + "workspaces": [], + "active": null, + "selected": 0 + }) + .to_string(); + + let restored: SessionSnapshot = serde_json::from_str(&json).unwrap(); + + assert_eq!( + restored.agent_panel_scope, + crate::app::state::AgentPanelScope::CurrentWorkspace + ); } #[test] @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; -use crate::app::state::{ToastKind, ToastNotification}; +use crate::app::state::{AgentPanelScope, ToastKind, ToastNotification}; use crate::app::{AppState, Mode}; use crate::detect::AgentState; use crate::layout::PaneInfo; @@ -26,6 +26,183 @@ fn spinner_frame(tick: u32) -> &'static str { use crate::app::state::Palette; +pub(crate) struct AgentPanelEntry { + pub ws_idx: usize, + pub tab_idx: usize, + pub pane_id: crate::layout::PaneId, + pub primary_label: String, + pub primary_tab_label: Option<String>, + pub agent_label: Option<String>, + pub state: AgentState, + pub seen: bool, +} + +pub(crate) fn expanded_sidebar_sections(area: Rect) -> (Rect, Rect) { + let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); + if content.width == 0 || content.height == 0 { + return (Rect::default(), Rect::default()); + } + + let total_h = content.height as usize; + let ws_h = (total_h + 1) / 2; + let detail_h = total_h.saturating_sub(ws_h); + + let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); + let detail_area = Rect::new( + content.x, + content.y + ws_h as u16, + content.width, + detail_h as u16, + ); + (ws_area, detail_area) +} + +fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> { + if matches!( + app.mode, + Mode::Navigate + | Mode::RenameWorkspace + | Mode::Resize + | Mode::ConfirmClose + | Mode::ContextMenu + | Mode::Settings + | Mode::GlobalMenu + | Mode::KeybindHelp + ) { + Some(app.selected) + } else { + app.active + } +} + +fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str { + match scope { + AgentPanelScope::CurrentWorkspace => "current", + AgentPanelScope::AllWorkspaces => "all", + } +} + +pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect { + if area.width == 0 || area.height < 2 { + return Rect::default(); + } + + let label = agent_panel_toggle_label(scope); + let width = label.chars().count() as u16; + Rect::new( + area.x + area.width.saturating_sub(width), + area.y + 1, + width, + 1, + ) +} + +pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> { + match app.agent_panel_scope { + AgentPanelScope::CurrentWorkspace => { + let Some(ws_idx) = agent_panel_current_workspace_idx(app) else { + return Vec::new(); + }; + let Some(ws) = app.workspaces.get(ws_idx) else { + return Vec::new(); + }; + ws.pane_details() + .into_iter() + .map(|detail| AgentPanelEntry { + ws_idx, + tab_idx: detail.tab_idx, + pane_id: detail.pane_id, + primary_label: detail.label, + primary_tab_label: None, + agent_label: None, + state: detail.state, + seen: detail.seen, + }) + .collect() + } + AgentPanelScope::AllWorkspaces => app + .workspaces + .iter() + .enumerate() + .flat_map(|(ws_idx, ws)| { + let multi_tab = ws.tabs.len() > 1; + let workspace_label = ws.display_name(); + ws.pane_details() + .into_iter() + .map(move |detail| AgentPanelEntry { + ws_idx, + tab_idx: detail.tab_idx, + pane_id: detail.pane_id, + primary_label: workspace_label.clone(), + primary_tab_label: multi_tab.then_some(detail.tab_label), + agent_label: Some(detail.agent_label), + state: detail.state, + seen: detail.seen, + }) + }) + .collect(), + } +} + +fn truncate_text(text: &str, max_width: usize) -> String { + let len = text.chars().count(); + if len <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "…".to_string(); + } + let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect(); + format!("{prefix}…") +} + +fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String { + let Some(tab_label) = entry.primary_tab_label.as_deref() else { + return truncate_text(&entry.primary_label, max_width); + }; + + let separator = " · "; + let separator_width = separator.chars().count(); + if max_width <= separator_width + 2 { + return truncate_text( + &format!("{}{}{}", entry.primary_label, separator, tab_label), + max_width, + ); + } + + let available = max_width.saturating_sub(separator_width); + let min_tab = 4.min(available.saturating_sub(1)).max(1); + let preferred_workspace = ((available * 2) / 3).max(1); + let mut workspace_budget = preferred_workspace + .min(available.saturating_sub(min_tab)) + .max(1); + let mut tab_budget = available.saturating_sub(workspace_budget); + + let workspace_len = entry.primary_label.chars().count(); + let tab_len = tab_label.chars().count(); + + if workspace_len < workspace_budget { + let spare = workspace_budget - workspace_len; + workspace_budget = workspace_len; + tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget)); + } + if tab_len < tab_budget { + let spare = tab_budget - tab_len; + tab_budget = tab_len; + workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget)); + } + + format!( + "{}{}{}", + truncate_text(&entry.primary_label, workspace_budget), + separator, + truncate_text(tab_label, tab_budget) + ) +} + /// Compute view geometry and reconcile pane sizes. /// Called before render to separate mutation from drawing. pub fn compute_view(app: &mut AppState, area: Rect) { @@ -621,37 +798,13 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) { buf[(sep_x, y)].set_style(sep_style); } - let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height); - - // Determine which workspace to show in the detail panel - let detail_ws_idx = if is_navigating { - Some(app.selected) - } else { - app.active - }; - - // Split sidebar in half: workspaces on top, agents on bottom - let total_h = content.height as usize; - let ws_h = (total_h + 1) / 2; // top half (ceiling) - let detail_h = total_h.saturating_sub(ws_h); - - let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16); - let detail_area = Rect::new( - content.x, - content.y + ws_h as u16, - content.width, - detail_h as u16, - ); + let (ws_area, detail_area) = expanded_sidebar_sections(area); // --- Top section: Workspaces --- render_workspace_list(app, frame, ws_area, is_navigating); // --- Bottom section: Agent detail --- - if let Some(ws_idx) = detail_ws_idx { - if let Some(ws) = app.workspaces.get(ws_idx) { - render_agent_detail(app, frame, detail_area, ws); - } - } + render_agent_detail(app, frame, detail_area); render_sidebar_toggle(app, frame, area, false, p); } @@ -848,12 +1001,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig } /// Render the agent detail panel in the bottom section of the sidebar. -fn render_agent_detail( - app: &AppState, - frame: &mut Frame, - area: Rect, - ws: &crate::workspace::Workspace, -) { +fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) { let p = &app.palette; if area.height < 3 { @@ -870,7 +1018,6 @@ fn render_agent_detail( ); row_y += 1; - // Section header frame.render_widget( Paragraph::new(Line::from(vec![Span::styled( " agents", @@ -878,14 +1025,23 @@ fn render_agent_detail( )])), Rect::new(area.x, row_y, area.width, 1), ); + let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope); + if toggle_rect != Rect::default() { + frame.render_widget( + Paragraph::new(Span::styled( + agent_panel_toggle_label(app.agent_panel_scope), + Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD), + )) + .alignment(Alignment::Right), + toggle_rect, + ); + } row_y += 1; // Blank line for breathing room row_y += 1; - // Per-pane agent entries, sorted by urgency - let details = ws.pane_details(); - for detail in &details { + for detail in &agent_panel_entries(app) { if row_y + 1 >= area.y + area.height { break; } @@ -896,12 +1052,15 @@ fn render_agent_detail( let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD); let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM); + let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM); + let primary_label = + format_agent_panel_primary_label(detail, area.width.saturating_sub(3) as usize); let name_line = Line::from(vec![ Span::styled(" ", Style::default()), Span::styled(icon, icon_style), Span::styled(" ", Style::default()), - Span::styled(&detail.label, name_style), + Span::styled(primary_label, name_style), ]); frame.render_widget( Paragraph::new(name_line), @@ -913,12 +1072,16 @@ fn render_agent_detail( break; } - let status_line = Line::from(vec![ + let mut status_spans = vec![ Span::styled(" ", Style::default()), Span::styled(label, status_style), - ]); + ]; + if let Some(agent_label) = &detail.agent_label { + status_spans.push(Span::styled(" · ", agent_style)); + status_spans.push(Span::styled(agent_label, agent_style)); + } frame.render_widget( - Paragraph::new(status_line), + Paragraph::new(Line::from(status_spans)), Rect::new(area.x, row_y, area.width, 1), ); row_y += 1; @@ -2818,6 +2981,59 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V #[cfg(test)] mod tests { use super::*; + use crate::{detect::Agent, workspace::Workspace}; + + #[test] + fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() { + let mut app = crate::app::state::AppState::test_new(); + let mut first = Workspace::test_new("one"); + let first_pane = first.tabs[0].root_pane; + first.tabs[0] + .panes + .get_mut(&first_pane) + .unwrap() + .detected_agent = Some(Agent::Pi); + + let mut second = Workspace::test_new("two"); + let second_tab = second.test_add_tab(Some("logs")); + let second_pane = second.tabs[second_tab].root_pane; + second.tabs[second_tab] + .panes + .get_mut(&second_pane) + .unwrap() + .detected_agent = Some(Agent::Claude); + + app.workspaces = vec![first, second]; + app.active = Some(0); + app.selected = 0; + app.agent_panel_scope = AgentPanelScope::AllWorkspaces; + + let entries = agent_panel_entries(&app); + assert_eq!(entries[0].primary_label, "one"); + assert!(entries[0].primary_tab_label.is_none()); + assert_eq!(entries[0].agent_label.as_deref(), Some("pi")); + assert_eq!(entries[1].primary_label, "two"); + assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs")); + assert_eq!(entries[1].agent_label.as_deref(), Some("claude")); + } + + #[test] + fn all_workspaces_primary_label_truncates_workspace_and_tab() { + let entry = AgentPanelEntry { + ws_idx: 0, + tab_idx: 0, + pane_id: crate::layout::PaneId::from_raw(1), + primary_label: "agent-browser".into(), + primary_tab_label: Some("test-escalation".into()), + agent_label: Some("claude".into()), + state: AgentState::Idle, + seen: true, + }; + + let label = format_agent_panel_primary_label(&entry, 18); + + assert_eq!(label, "agent-bro… · test…"); + } #[test] fn pane_scrollbar_rect_uses_rightmost_inner_column() { |
