diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-08 14:53:45 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-08 14:53:45 +0300 |
| commit | c08e51e666f6df9a940393b568943b67940ceedc (patch) | |
| tree | 6a6c4cd3c8ea5ddf9e97a4695c6b0d31eb0d0717 /src | |
| parent | b0aa7b2cddc57a55a1c0285a1ea82b59ac9783bd (diff) | |
feat: make agent toasts focus panes
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 10 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 81 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 1 | ||||
| -rw-r--r-- | src/app/mod.rs | 3 | ||||
| -rw-r--r-- | src/app/state.rs | 11 | ||||
| -rw-r--r-- | src/ui.rs | 19 | ||||
| -rw-r--r-- | src/ui/mobile.rs | 18 | ||||
| -rw-r--r-- | src/ui/status.rs | 26 |
8 files changed, 151 insertions, 18 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 3b904a89..506930ca 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -8,7 +8,7 @@ use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; use crate::pane::EffectiveStateChange; -use super::state::{AppState, Mode, ToastKind, ToastNotification, ViewLayout}; +use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout}; fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool { matches!(new_state, AgentState::Idle) @@ -552,6 +552,7 @@ impl AppState { kind: ToastKind::UpdateInstalled, title: format!("v{version} available"), context: "detach, then run `herdr update`".to_string(), + target: None, }); } Vec::new() @@ -679,6 +680,10 @@ impl AppState { kind, title: format!("{} {}", toast_agent_label(agent_label), event_text), context, + target: Some(ToastTarget { + workspace_id: self.workspaces[ws_idx].id.clone(), + pane_id, + }), }); } } @@ -1086,6 +1091,9 @@ mod tests { assert_eq!(toast.kind, ToastKind::Finished); assert_eq!(toast.title, "droid finished"); assert_eq!(toast.context, "background ยท 2"); + let target = toast.target.as_ref().expect("toast target"); + assert_eq!(&target.workspace_id, &state.workspaces[1].id); + assert_eq!(target.pane_id, bg_pane_id); } #[test] diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 185393f0..d0c33138 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -31,6 +31,19 @@ impl AppState { return None; } + if self.clickable_toast_at(mouse.column, mouse.row) + && matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + { + self.focus_toast_target(); + return None; + } + + if self.clickable_toast_at(mouse.column, mouse.row) + && matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) + { + return None; + } + if self.mode == Mode::Settings { return self.handle_settings_mouse(mouse); } @@ -977,6 +990,34 @@ impl AppState { } } + fn clickable_toast_at(&self, col: u16, row: u16) -> bool { + self.toast + .as_ref() + .is_some_and(|toast| toast.target.is_some()) + && rect_contains(self.view.toast_hit_area, col, row) + } + + fn focus_toast_target(&mut self) { + let Some(target) = self.toast.as_ref().and_then(|toast| toast.target.clone()) else { + return; + }; + let Some(ws_idx) = self + .workspaces + .iter() + .position(|workspace| workspace.id == target.workspace_id) + else { + return; + }; + let Some(tab_idx) = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id) else { + return; + }; + + self.switch_workspace(ws_idx); + self.switch_tab(tab_idx); + self.focus_pane(target.pane_id); + self.mode = Mode::Terminal; + } + pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) { if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) { if let Some(rt) = ws.runtimes.get(&pane_id) { @@ -1205,6 +1246,7 @@ mod tests { use super::*; use crate::{ app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode, ViewLayout}, + detect::{Agent, AgentState}, workspace::Workspace, }; @@ -1226,6 +1268,45 @@ mod tests { } #[test] + fn clicking_agent_toast_focuses_target_pane() { + let mut app = app_for_mouse_test(); + let active = Workspace::test_new("active"); + let mut background = Workspace::test_new("background"); + let first_pane = background.tabs[0].root_pane; + let target_pane = background.test_split(Direction::Horizontal); + background.tabs[0].layout.focus_pane(first_pane); + + app.state.workspaces = vec![active, background]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr; + app.state.workspaces[1] + .panes + .get_mut(&target_pane) + .unwrap() + .state = AgentState::Working; + + app.state + .handle_app_event(crate::events::AppEvent::StateChanged { + pane_id: target_pane, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20)); + + let hit = app.state.view.toast_hit_area; + app.handle_mouse(mouse( + MouseEventKind::Down(MouseButton::Left), + hit.x + 1, + hit.y + 1, + )); + + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.workspaces[1].focused_pane_id(), Some(target_pane)); + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] fn clicking_confirm_close_accepts_workspace_close() { let mut app = app_for_mouse_test(); app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")]; diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 546228cc..3bc8da78 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -130,6 +130,7 @@ impl App { kind: crate::app::state::ToastKind::NeedsAttention, title: "custom command failed".to_string(), context: err.to_string(), + target: None, }); self.sync_toast_deadline(previous_toast); } diff --git a/src/app/mod.rs b/src/app/mod.rs index ffef6600..32e89f0e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -318,6 +318,7 @@ impl App { terminal_area: Rect::default(), mobile_header_rect: Rect::default(), mobile_menu_hit_area: Rect::default(), + toast_hit_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), }, @@ -684,6 +685,7 @@ impl App { kind: crate::app::state::ToastKind::UpdateInstalled, title: "reloaded config".to_string(), context: "using config.toml".to_string(), + target: None, }); } } else { @@ -694,6 +696,7 @@ impl App { kind: crate::app::state::ToastKind::UpdateInstalled, title: "reloaded config".to_string(), context: "with warnings".to_string(), + target: None, }); } } diff --git a/src/app/state.rs b/src/app/state.rs index 95caa3fe..315872f4 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; -use crate::layout::{PaneInfo, SplitBorder}; +use crate::layout::{PaneId, PaneInfo, SplitBorder}; use crate::selection::Selection; use crate::terminal_theme::TerminalTheme; use crate::workspace::Workspace; @@ -370,6 +370,7 @@ pub struct ViewState { pub terminal_area: Rect, pub mobile_header_rect: Rect, pub mobile_menu_hit_area: Rect, + pub toast_hit_area: Rect, pub pane_infos: Vec<PaneInfo>, pub split_borders: Vec<SplitBorder>, } @@ -594,10 +595,17 @@ pub enum ToastKind { } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToastTarget { + pub workspace_id: String, + pub pane_id: PaneId, +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct ToastNotification { pub kind: ToastKind, pub title: String, pub context: String, + pub target: Option<ToastTarget>, } pub struct ReleaseNotesState { @@ -793,6 +801,7 @@ impl AppState { terminal_area: Rect::default(), mobile_header_rect: Rect::default(), mobile_menu_hit_area: Rect::default(), + toast_hit_area: Rect::default(), pane_infos: Vec::new(), split_borders: Vec::new(), }, @@ -27,7 +27,8 @@ use self::menus::{ }; use self::mobile::{ compute_mobile_header_hit_areas, is_mobile_width, mobile_switcher_max_scroll_for_height, - render_mobile_header, render_mobile_panel, render_mobile_toast_banner, + mobile_toast_banner_rect, render_mobile_header, render_mobile_panel, + render_mobile_toast_banner, }; pub(crate) use self::onboarding::onboarding_welcome_continue_rect; use self::onboarding::render_onboarding_overlay; @@ -43,7 +44,7 @@ pub(crate) use self::scrollbar::{ }; use self::settings::render_settings_overlay; use self::sidebar::{render_sidebar, render_sidebar_collapsed}; -use self::status::{render_config_diagnostic, render_toast_notification}; +use self::status::{render_config_diagnostic, render_toast_notification, toast_notification_rect}; use self::tabs::render_tab_bar; pub(crate) use self::{ dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects}, @@ -160,6 +161,12 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); + let toast_hit_area = app + .toast + .as_ref() + .map(|toast| toast_notification_rect(terminal_area, toast, app.config_diagnostic.is_some())) + .unwrap_or_default(); + app.view = crate::app::ViewState { layout: ViewLayout::Desktop, sidebar_rect: sidebar_area, @@ -172,6 +179,7 @@ fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) { terminal_area, mobile_header_rect: Rect::default(), mobile_menu_hit_area: Rect::default(), + toast_hit_area, pane_infos, split_borders, }; @@ -202,6 +210,12 @@ fn compute_mobile_view(app: &mut AppState, area: Rect, resize_panes: bool) { let pane_infos = compute_pane_infos(app, terminal_area, resize_panes); let header_hits = compute_mobile_header_hit_areas(app, header_rect); + let toast_hit_area = app + .toast + .as_ref() + .map(|_| mobile_toast_banner_rect(area, app.config_diagnostic.is_some())) + .unwrap_or_default(); + app.view = crate::app::ViewState { layout: ViewLayout::Mobile, sidebar_rect: Rect::default(), @@ -214,6 +228,7 @@ fn compute_mobile_view(app: &mut AppState, area: Rect, resize_panes: bool) { terminal_area, mobile_header_rect: header_rect, mobile_menu_hit_area: header_hits.menu, + toast_hit_area, pane_infos, split_borders, }; diff --git a/src/ui/mobile.rs b/src/ui/mobile.rs index 79f97136..50cbed0c 100644 --- a/src/ui/mobile.rs +++ b/src/ui/mobile.rs @@ -172,6 +172,18 @@ pub(crate) fn render_mobile_header(app: &AppState, frame: &mut Frame, area: Rect render_switch_button(app, frame, switch); } +pub(crate) fn mobile_toast_banner_rect(area: Rect, offset_for_warning: bool) -> Rect { + if area.width == 0 || area.height == 0 { + return Rect::default(); + } + + let y = area.y + + area + .height + .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); + Rect::new(area.x, y, area.width, 1) +} + pub(crate) fn render_mobile_toast_banner( frame: &mut Frame, area: Rect, @@ -188,11 +200,7 @@ pub(crate) fn render_mobile_toast_banner( ToastKind::Finished => p.blue, ToastKind::UpdateInstalled => p.accent, }; - let y = area.y - + area - .height - .saturating_sub(1 + if offset_for_warning { 1 } else { 0 }); - let banner = Rect::new(area.x, y, area.width, 1); + let banner = mobile_toast_banner_rect(area, offset_for_warning); let bg = p.surface0; frame.render_widget(Clear, banner); diff --git a/src/ui/status.rs b/src/ui/status.rs index 2eece8a0..bb506da6 100644 --- a/src/ui/status.rs +++ b/src/ui/status.rs @@ -12,6 +12,22 @@ use crate::{ detect::AgentState, }; +pub(crate) fn toast_notification_rect( + area: Rect, + toast: &ToastNotification, + offset_for_warning: bool, +) -> Rect { + let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; + let width = content_width.saturating_add(2).min(area.width); + let height = 4u16.min(area.height); + let x = area.x + area.width.saturating_sub(width); + let y = area.y + + area + .height + .saturating_sub(height + if offset_for_warning { 1 } else { 0 }); + Rect::new(x, y, width, height) +} + pub(super) fn render_toast_notification( frame: &mut Frame, area: Rect, @@ -24,15 +40,7 @@ pub(super) fn render_toast_notification( ToastKind::Finished => p.blue, ToastKind::UpdateInstalled => p.accent, }; - let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; - let width = content_width.saturating_add(2).min(area.width); - let height = 4u16.min(area.height); - let x = area.x + area.width.saturating_sub(width); - let y = area.y - + area - .height - .saturating_sub(height + if offset_for_warning { 1 } else { 0 }); - let toast_area = Rect::new(x, y, width, height); + let toast_area = toast_notification_rect(area, toast, offset_for_warning); frame.render_widget(Clear, toast_area); let block = Block::default() |
