aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/notifications.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/client/shell/notifications.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/client/shell/notifications.rs')
-rw-r--r--src/client/shell/notifications.rs418
1 files changed, 418 insertions, 0 deletions
diff --git a/src/client/shell/notifications.rs b/src/client/shell/notifications.rs
new file mode 100644
index 00000000..0310e57d
--- /dev/null
+++ b/src/client/shell/notifications.rs
@@ -0,0 +1,418 @@
+use super::*;
+use ratatui::{
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph, Widget},
+};
+
+pub(super) fn render_mobile_notification_banner(
+ buffer: &mut Buffer,
+ area: Rect,
+ notification: &ClientVisibleNotification,
+ offset_for_warning: bool,
+ palette: &Palette,
+) -> Rect {
+ if area.is_empty() {
+ return Rect::default();
+ }
+ let warning_offset = u16::from(offset_for_warning);
+ let y = area.y
+ + area
+ .height
+ .saturating_sub(1u16.saturating_add(warning_offset));
+ let rect = Rect::new(area.x, y, area.width, 1);
+ let background = palette.surface0;
+ Clear.render(rect, buffer);
+ buffer.set_style(rect, Style::default().bg(background));
+ let event = &notification.event;
+ let title = match event.kind {
+ SemanticNotificationKind::NeedsAttention => event
+ .title
+ .strip_suffix(" needs attention")
+ .map(|agent| format!("{agent} waiting"))
+ .unwrap_or_else(|| event.title.clone()),
+ SemanticNotificationKind::Finished => event
+ .title
+ .strip_suffix(" finished")
+ .map(|agent| format!("{agent} done"))
+ .unwrap_or_else(|| event.title.clone()),
+ SemanticNotificationKind::UpdateInstalled => "update ready".to_owned(),
+ SemanticNotificationKind::Custom => event.title.clone(),
+ };
+ let dot_color = match event.kind {
+ SemanticNotificationKind::NeedsAttention => palette.red,
+ SemanticNotificationKind::Finished => palette.blue,
+ SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
+ palette.accent
+ }
+ };
+ let mut x = rect.x;
+ x = super::render::put_segment(
+ buffer,
+ x,
+ rect.y,
+ rect.right(),
+ " ",
+ Style::default().bg(background),
+ );
+ x = super::render::put_segment(
+ buffer,
+ x,
+ rect.y,
+ rect.right(),
+ "●",
+ Style::default().fg(dot_color).bg(background),
+ );
+ x = super::render::put_segment(
+ buffer,
+ x,
+ rect.y,
+ rect.right(),
+ " ",
+ Style::default().bg(background),
+ );
+ x = super::render::put_segment(
+ buffer,
+ x,
+ rect.y,
+ rect.right(),
+ &title,
+ Style::default()
+ .fg(palette.text)
+ .bg(background)
+ .add_modifier(Modifier::BOLD),
+ );
+ if let Some(body) = event.body.as_deref().filter(|body| !body.is_empty()) {
+ x = super::render::put_segment(
+ buffer,
+ x,
+ rect.y,
+ rect.right(),
+ " · ",
+ Style::default().fg(palette.overlay0).bg(background),
+ );
+ super::render::put_text(
+ buffer,
+ x,
+ rect.y,
+ rect.right().saturating_sub(x),
+ body,
+ Style::default().fg(palette.overlay0).bg(background),
+ );
+ }
+ rect
+}
+
+pub(super) fn render_visible_notification(
+ buffer: &mut Buffer,
+ area: Rect,
+ notification: &ClientVisibleNotification,
+ default_position: crate::config::ToastHerdrPosition,
+ top_offset: u16,
+ palette: &Palette,
+) -> Rect {
+ if area.is_empty() {
+ return Rect::default();
+ }
+ let event = &notification.event;
+ let body = event.body.as_deref().unwrap_or_default();
+ let content_width = unicode_width::UnicodeWidthStr::width(event.title.as_str())
+ .max(unicode_width::UnicodeWidthStr::width(body))
+ .saturating_add(6);
+ let width = u16::try_from(content_width)
+ .unwrap_or(u16::MAX)
+ .min(area.width);
+ let height: u16 = if body.is_empty() { 3 } else { 4 }.min(area.height);
+ let position = event.position.unwrap_or(default_position);
+ let x = match position {
+ crate::config::ToastHerdrPosition::TopLeft
+ | crate::config::ToastHerdrPosition::BottomLeft => area.x,
+ crate::config::ToastHerdrPosition::TopRight
+ | crate::config::ToastHerdrPosition::BottomRight => area.right().saturating_sub(width),
+ };
+ let max_y = area.bottom().saturating_sub(height).max(area.y);
+ let y = match position {
+ crate::config::ToastHerdrPosition::TopLeft
+ | crate::config::ToastHerdrPosition::TopRight => area.y.saturating_add(top_offset),
+ crate::config::ToastHerdrPosition::BottomLeft
+ | crate::config::ToastHerdrPosition::BottomRight => area
+ .bottom()
+ .saturating_sub(height.saturating_add(top_offset)),
+ }
+ .clamp(area.y, max_y);
+ let rect = Rect::new(x, y, width, height);
+ Clear.render(rect, buffer);
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(palette.overlay0))
+ .style(Style::default().bg(palette.panel_bg));
+ let inner = block.inner(rect);
+ block.render(rect, buffer);
+ Paragraph::new(Line::from(vec![
+ Span::styled(
+ "●",
+ Style::default().fg(match event.kind {
+ SemanticNotificationKind::NeedsAttention => palette.red,
+ SemanticNotificationKind::Finished => palette.blue,
+ SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => {
+ palette.accent
+ }
+ }),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ event.title.as_str(),
+ Style::default()
+ .fg(palette.text)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ]))
+ .render(Rect::new(inner.x, inner.y, inner.width, 1), buffer);
+ if !body.is_empty() && inner.height > 1 {
+ Paragraph::new(Line::from(Span::styled(
+ body,
+ Style::default().fg(palette.overlay0),
+ )))
+ .render(
+ Rect::new(
+ inner.x.saturating_add(2),
+ inner.y + 1,
+ inner.width.saturating_sub(2),
+ 1,
+ ),
+ buffer,
+ );
+ }
+ rect
+}
+
+impl ClientShellState {
+ pub(super) fn focus_visible_notification(&mut self, outcome: &mut ClientShellInput) {
+ let Some(notification) = self.visible_notification.take() else {
+ return;
+ };
+ outcome.repaint = true;
+ if let Some(pane_id) = notification.event.pane_id {
+ self.push_endpoint_method(
+ crate::api::schema::Method::PaneFocus(crate::api::schema::PaneTarget { pane_id }),
+ outcome,
+ );
+ }
+ }
+
+ pub(crate) fn receive_notification(
+ &mut self,
+ event: SemanticNotification,
+ now: std::time::Instant,
+ ) -> (Vec<ClientShellNotificationEffect>, bool) {
+ let delay = if event.kind == SemanticNotificationKind::Custom {
+ 0
+ } else {
+ self.config.toast_delay_seconds
+ };
+ let deadline = now
+ .checked_add(std::time::Duration::from_secs(delay))
+ .unwrap_or(now);
+ let cleared_visible = event.pane_id.as_deref().is_some_and(|pane_id| {
+ self.visible_notification
+ .as_ref()
+ .is_some_and(|visible| visible.event.pane_id.as_deref() == Some(pane_id))
+ });
+ if let Some(pane_id) = event.pane_id.as_deref() {
+ self.pending_notifications
+ .retain(|pending| pending.event.pane_id.as_deref() != Some(pane_id));
+ if cleared_visible {
+ self.visible_notification = None;
+ }
+ }
+ self.pending_notifications.push(ClientPendingNotification {
+ event,
+ deadline,
+ validate_state: delay > 0,
+ });
+ let (effects, repaint) = self.tick_notifications(now);
+ (effects, repaint || cleared_visible)
+ }
+
+ pub(crate) fn tick_notifications(
+ &mut self,
+ now: std::time::Instant,
+ ) -> (Vec<ClientShellNotificationEffect>, bool) {
+ let mut repaint = false;
+ if self
+ .visible_notification
+ .as_ref()
+ .is_some_and(|visible| now >= visible.deadline)
+ {
+ self.visible_notification = None;
+ repaint = true;
+ }
+
+ let pending = std::mem::take(&mut self.pending_notifications);
+ let mut effects = Vec::new();
+ for pending in pending {
+ if pending.deadline > now {
+ self.pending_notifications.push(pending);
+ continue;
+ }
+ if pending.validate_state && !self.notification_still_current(&pending.event) {
+ continue;
+ }
+ let target_active = self.notification_target_is_active(&pending.event);
+ let suppress_external = target_active && self.outer_focused != Some(false);
+ if let Some(sound) = pending.event.sound {
+ let suppress_sound =
+ pending.event.kind == SemanticNotificationKind::Finished && suppress_external;
+ if !suppress_sound {
+ effects.push(ClientShellNotificationEffect::Sound {
+ sound: match sound {
+ SemanticNotificationSound::Done => crate::sound::Sound::Done,
+ SemanticNotificationSound::Request => crate::sound::Sound::Request,
+ },
+ agent: pending.event.agent.clone(),
+ });
+ }
+ }
+
+ match self.config.toast_delivery {
+ crate::config::ToastDelivery::Off => {}
+ crate::config::ToastDelivery::Herdr if !target_active => {
+ let duration = match pending.event.kind {
+ SemanticNotificationKind::NeedsAttention => 8,
+ SemanticNotificationKind::Finished => 5,
+ SemanticNotificationKind::UpdateInstalled => 3,
+ SemanticNotificationKind::Custom => 5,
+ };
+ self.visible_notification = Some(ClientVisibleNotification {
+ event: pending.event,
+ deadline: now + std::time::Duration::from_secs(duration),
+ });
+ repaint = true;
+ }
+ crate::config::ToastDelivery::Herdr => {}
+ crate::config::ToastDelivery::Terminal if !suppress_external => {
+ effects.push(ClientShellNotificationEffect::Terminal {
+ title: pending.event.title,
+ body: pending.event.body,
+ });
+ }
+ crate::config::ToastDelivery::System if !suppress_external => {
+ effects.push(ClientShellNotificationEffect::System {
+ title: pending.event.title,
+ body: pending.event.body,
+ });
+ }
+ crate::config::ToastDelivery::Terminal | crate::config::ToastDelivery::System => {}
+ }
+ }
+ (effects, repaint)
+ }
+
+ fn notification_target_is_active(&self, event: &SemanticNotification) -> bool {
+ let Some(snapshot) = self.snapshot.as_deref() else {
+ return false;
+ };
+ if let Some(tab_id) = event.tab_id.as_deref() {
+ return snapshot.focused_tab_id.as_deref() == Some(tab_id);
+ }
+ event.workspace_id.as_deref().is_some_and(|workspace_id| {
+ snapshot.focused_workspace_id.as_deref() == Some(workspace_id)
+ })
+ }
+
+ fn notification_still_current(&self, event: &SemanticNotification) -> bool {
+ let Some(pane_id) = event.pane_id.as_deref() else {
+ return true;
+ };
+ let Some(agent) = self.snapshot.as_deref().and_then(|snapshot| {
+ snapshot
+ .agents
+ .iter()
+ .find(|agent| agent.pane_id == pane_id)
+ }) else {
+ return false;
+ };
+ match event.kind {
+ SemanticNotificationKind::NeedsAttention => {
+ agent.agent_status == crate::api::schema::AgentStatus::Blocked
+ }
+ SemanticNotificationKind::Finished => matches!(
+ agent.agent_status,
+ crate::api::schema::AgentStatus::Idle | crate::api::schema::AgentStatus::Done
+ ),
+ SemanticNotificationKind::UpdateInstalled | SemanticNotificationKind::Custom => true,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn notification() -> ClientVisibleNotification {
+ ClientVisibleNotification {
+ event: SemanticNotification {
+ kind: SemanticNotificationKind::Custom,
+ title: "notice".into(),
+ body: None,
+ sound: None,
+ agent: None,
+ workspace_id: None,
+ tab_id: None,
+ pane_id: None,
+ position: None,
+ },
+ deadline: std::time::Instant::now(),
+ }
+ }
+
+ #[test]
+ fn mobile_notification_is_a_bottom_banner_with_released_title() {
+ let palette = crate::app::client_palette_from_config(&Config::default());
+ let mut notification = notification();
+ notification.event.kind = SemanticNotificationKind::NeedsAttention;
+ notification.event.title = "pi needs attention".into();
+ notification.event.body = Some("workspace · tab 1".into());
+ let area = Rect::new(0, 0, 44, 20);
+ let mut buffer = Buffer::empty(area);
+ for cell in &mut buffer.content {
+ cell.set_symbol("X");
+ }
+ let rect =
+ render_mobile_notification_banner(&mut buffer, area, &notification, true, &palette);
+ assert_eq!(rect, Rect::new(0, 18, 44, 1));
+ let text = buffer
+ .content
+ .iter()
+ .map(|cell| cell.symbol())
+ .collect::<String>();
+ assert!(text.contains("pi waiting"));
+ assert!(text.contains("workspace · tab 1"));
+ assert!(buffer.content[18 * 44..19 * 44]
+ .iter()
+ .all(|cell| cell.symbol() != "X"));
+ }
+
+ #[test]
+ fn notification_rect_stays_inside_short_nonzero_area() {
+ let palette = crate::app::client_palette_from_config(&Config::default());
+ for height in [1, 2] {
+ let area = Rect::new(3, 4, 8, height);
+ for position in [
+ crate::config::ToastHerdrPosition::TopRight,
+ crate::config::ToastHerdrPosition::BottomRight,
+ ] {
+ let mut buffer = Buffer::empty(Rect::new(0, 0, 20, 10));
+ let rect = render_visible_notification(
+ &mut buffer,
+ area,
+ &notification(),
+ position,
+ 1,
+ &palette,
+ );
+ assert!(rect.y >= area.y);
+ assert!(rect.bottom() <= area.bottom());
+ }
+ }
+ }
+}