aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 14:53:45 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-08 14:53:45 +0300
commitc08e51e666f6df9a940393b568943b67940ceedc (patch)
tree6a6c4cd3c8ea5ddf9e97a4695c6b0d31eb0d0717 /src
parentb0aa7b2cddc57a55a1c0285a1ea82b59ac9783bd (diff)
feat: make agent toasts focus panes
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs10
-rw-r--r--src/app/input/mouse.rs81
-rw-r--r--src/app/input/navigate.rs1
-rw-r--r--src/app/mod.rs3
-rw-r--r--src/app/state.rs11
-rw-r--r--src/ui.rs19
-rw-r--r--src/ui/mobile.rs18
-rw-r--r--src/ui/status.rs26
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(),
},
diff --git a/src/ui.rs b/src/ui.rs
index 4350a6d7..874cc65e 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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()