From 4b482f37a1e233b9aca6bf99b12e8b4da3e5c05e Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Wed, 22 Apr 2026 17:01:25 +0300 Subject: [PATCH] feat: add terminal-delivered client notifications --- CONFIGURATION.md | 25 +++-- src/app/actions.rs | 38 ++++--- src/app/api.rs | 57 ++++++++++ src/app/config_io.rs | 17 ++- src/app/input/mod.rs | 37 ++----- src/app/input/modal.rs | 12 --- src/app/input/overlays.rs | 81 ++++---------- src/app/input/settings.rs | 68 ++++++++---- src/app/mod.rs | 35 +----- src/app/state.rs | 10 +- src/client/mod.rs | 25 ++++- src/config.rs | 6 +- src/config/io.rs | 57 +++++++--- src/config/model.rs | 75 +++++++++++-- src/main.rs | 8 +- src/server/headless.rs | 221 ++++++++++++++++++++++++++++++++------ src/terminal_notify.rs | 143 ++++++++++++++++++++++++ src/ui.rs | 4 +- src/ui/onboarding.rs | 113 +------------------ src/ui/settings.rs | 74 +++++-------- src/ui/widgets.rs | 87 +++++++++++++-- 21 files changed, 788 insertions(+), 405 deletions(-) create mode 100644 src/terminal_notify.rs diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 3396cc33..d57542f7 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -27,7 +27,7 @@ onboarding = true notes: - missing `onboarding` currently behaves like `true` - set `onboarding = true` to force the setup screen again for testing -- after onboarding, herdr writes `onboarding = false` plus the chosen sound/toast settings +- continuing from onboarding writes `onboarding = false` and opens the normal settings UI ## keybindings @@ -191,22 +191,33 @@ accent = "cyan" ```toml [ui.toast] -enabled = false +delivery = "off" ``` ### options | option | default | description | |--------|---------|-------------| -| `ui.toast.enabled` | `false` | show top-right visual toasts for background agent events | +| `ui.toast.delivery` | `off` | where background popup notifications should appear | -current v1 behavior: +available values: +- `off` — disable popup notifications +- `herdr` — show top-right in-app toasts +- `terminal` — ask the outer terminal to show a desktop notification + +compatibility note: +- older configs may still use `ui.toast.enabled = true|false` +- herdr still reads that legacy key for compatibility +- if you save toast settings from inside herdr, it rewrites the setting to `ui.toast.delivery` + +current behavior: - informational only -- one toast at a time -- top-right placement +- one notification event at a time - shown for background agent events like `needs attention` and `finished` - suppression is tab-aware: the active tab stays quiet, but background tabs in the same workspace can still notify -- no keyboard action or temporary key semantics +- `terminal` delivery is best-effort and depends on terminal support +- currently targets terminals such as Ghostty, Kitty, iTerm2, and WezTerm +- inside tmux, herdr wraps notification escapes with tmux passthrough ## sound diff --git a/src/app/actions.rs b/src/app/actions.rs index f9136b9c..33952b9f 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -35,7 +35,7 @@ pub fn notification_sound_for_state_change( } } -fn notification_toast_for_state_change( +pub fn notification_toast_for_state_change( is_active_tab: bool, prev_state: AgentState, new_state: AgentState, @@ -57,7 +57,7 @@ fn toast_agent_label(agent_label: &str) -> &str { agent_label } -fn notification_context( +pub fn notification_context( ws: &crate::workspace::Workspace, ws_idx: usize, pane_id: PaneId, @@ -469,11 +469,16 @@ impl AppState { self.update_available = Some(version.clone()); self.latest_release_notes_available = true; self.update_dismissed = true; - self.toast = Some(ToastNotification { - kind: ToastKind::UpdateInstalled, - title: format!("v{version} available"), - context: "detach, then run `herdr update`".to_string(), - }); + if matches!( + self.toast_config.delivery, + crate::config::ToastDelivery::Herdr + ) { + self.toast = Some(ToastNotification { + kind: ToastKind::UpdateInstalled, + title: format!("v{version} available"), + context: "detach, then run `herdr update`".to_string(), + }); + } Vec::new() } AppEvent::StateChanged { @@ -575,7 +580,10 @@ impl AppState { } } - if self.toast_config.enabled { + if matches!( + self.toast_config.delivery, + crate::config::ToastDelivery::Herdr + ) { if let (Some(agent_label), Some(kind)) = ( change.agent_label.as_deref(), notification_toast_for_state_change( @@ -665,6 +673,7 @@ mod tests { #[test] fn update_ready_sets_explicit_upgrade_toast() { let mut state = AppState::test_new(); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let updates = state.handle_app_event(crate::events::AppEvent::UpdateReady { version: "0.5.0".into(), @@ -921,7 +930,7 @@ mod tests { fn background_waiting_sets_attention_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); - state.toast_config.enabled = true; + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::StateChanged { @@ -940,7 +949,7 @@ mod tests { fn hook_reported_unknown_agent_sets_toast_title_from_label() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); - state.toast_config.enabled = true; + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::HookStateReported { @@ -961,7 +970,7 @@ mod tests { fn background_idle_sets_finished_toast() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); - state.toast_config.enabled = true; + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); state.workspaces[1] .panes @@ -985,7 +994,7 @@ mod tests { fn background_toast_includes_tab_name_when_workspace_has_multiple_tabs() { let mut state = app_with_workspaces(&["active", "background"]); state.active = Some(0); - state.toast_config.enabled = true; + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.workspaces[1].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[1].test_add_tab(Some("logs")); let bg_pane_id = state.workspaces[1].tabs[second_tab].root_pane; @@ -1006,7 +1015,7 @@ mod tests { fn background_tab_in_active_workspace_still_sets_toast() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); - state.toast_config.enabled = true; + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; state.workspaces[0].tabs[0].set_custom_name("main".into()); let second_tab = state.workspaces[0].test_add_tab(Some("logs")); let bg_pane_id = state.workspaces[0].tabs[second_tab].root_pane; @@ -1027,7 +1036,7 @@ mod tests { fn active_workspace_active_tab_does_not_set_toast() { let mut state = app_with_workspaces(&["active"]); state.active = Some(0); - state.toast_config.enabled = true; + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); state.handle_app_event(AppEvent::StateChanged { @@ -1042,6 +1051,7 @@ mod tests { #[test] fn update_ready_sets_manual_update_toast() { let mut state = AppState::test_new(); + state.toast_config.delivery = crate::config::ToastDelivery::Herdr; let updates = state.handle_app_event(AppEvent::UpdateReady { version: "0.5.0".into(), diff --git a/src/app/api.rs b/src/app/api.rs index e47eb452..b07c1267 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -47,6 +47,11 @@ impl App { None }; + let update_ready_version = if let AppEvent::UpdateReady { version } = &ev { + Some(version.clone()) + } else { + None + }; let previous_toast = self.state.toast.clone(); let pane_updates = self.state.handle_app_event(ev); for update in &pane_updates { @@ -64,6 +69,58 @@ impl App { if let Some(overlay) = overlay_state { self.restore_overlay_after_exit(overlay); } + + if matches!( + self.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) { + if let Some(version) = update_ready_version { + let _ = crate::terminal_notify::show_notification( + &format!("v{version} available"), + Some("detach, then run `herdr update`"), + ); + } else { + for update in &pane_updates { + let is_active_tab = self + .state + .pane_is_in_active_tab(update.ws_idx, update.pane_id); + let Some(kind) = crate::app::actions::notification_toast_for_state_change( + is_active_tab, + update.previous_state, + update.state, + ) else { + continue; + }; + let Some(ws) = self.state.workspaces.get(update.ws_idx) else { + continue; + }; + let Some(pane) = ws + .tabs + .iter() + .find_map(|tab| tab.panes.get(&update.pane_id)) + else { + continue; + }; + let Some(agent_label) = pane.effective_agent_label() else { + continue; + }; + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + ToastKind::UpdateInstalled => "updated", + }; + let _ = crate::terminal_notify::show_notification( + &format!("{} {}", agent_label, event_text), + Some(&crate::app::actions::notification_context( + ws, + update.ws_idx, + update.pane_id, + )), + ); + } + } + } + self.sync_toast_deadline(previous_toast); } diff --git a/src/app/config_io.rs b/src/app/config_io.rs index 21d4365b..5d4b2b13 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -25,6 +25,12 @@ impl App { } } + pub(super) fn mark_onboarding_complete(&mut self) { + self.update_config_file("onboarding setting", |content| { + crate::config::upsert_top_level_bool(content, "onboarding", false) + }); + } + pub(super) fn save_theme(&mut self, name: &str) { self.update_config_file("theme", |content| { crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) @@ -37,9 +43,16 @@ impl App { }); } - pub(super) fn save_toast(&mut self, enabled: bool) { + pub(super) fn save_toast_delivery(&mut self, delivery: crate::config::ToastDelivery) { + let value = match delivery { + crate::config::ToastDelivery::Off => "\"off\"", + crate::config::ToastDelivery::Herdr => "\"herdr\"", + crate::config::ToastDelivery::Terminal => "\"terminal\"", + }; self.update_config_file("toast setting", |content| { - crate::config::upsert_section_bool(content, "ui.toast", "enabled", enabled) + let content = + crate::config::upsert_section_value(content, "ui.toast", "delivery", value); + crate::config::remove_section_key(&content, "ui.toast", "enabled") }); } } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 2f069fea..9676da62 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -37,11 +37,11 @@ pub(crate) use self::{ handle_keybind_help_key, handle_rename_key, handle_resize_key, }, navigate::{handle_navigate_key, terminal_direct_navigation_action}, + settings::open_settings, }; use self::{ modal::{ - modal_action_from_key, ModalAction, ONBOARDING_NOTIFICATION_ACTIONS, - ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, + modal_action_from_key, ModalAction, ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS, }, settings::SettingsAction, }; @@ -89,32 +89,11 @@ impl App { } pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) { - match self.state.onboarding_step { - 0 => match key.code { - KeyCode::Right | KeyCode::Char('l') => { - self.state.onboarding_step = 1; - } - _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) { - Some(ModalAction::Continue) => self.state.onboarding_step = 1, - _ => {} - }, - }, - _ => match key.code { - KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(), - KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4), - KeyCode::Left | KeyCode::Char('h') => { - self.state.onboarding_step = 0; - } - KeyCode::Char(c) if ('1'..='4').contains(&c) => { - self.state - .onboarding_list - .select((c as usize) - ('1' as usize)); - } - _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) { - Some(ModalAction::Back) => self.state.onboarding_step = 0, - Some(ModalAction::Save) => self.complete_onboarding(), - _ => {} - }, + match key.code { + KeyCode::Right | KeyCode::Char('l') => self.open_settings_from_onboarding(), + _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) { + Some(ModalAction::Continue) => self.open_settings_from_onboarding(), + _ => {} }, } } @@ -170,7 +149,7 @@ impl App { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToast(enabled) => self.save_toast(enabled), + SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), } } diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index fe909326..5c3108f2 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -9,7 +9,6 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum ModalAction { Continue, - Back, Save, Clear, Cancel, @@ -211,17 +210,6 @@ pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec] = & bindings: &[ModalKeyBinding::Enter], }]; -pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec] = &[ - ModalActionSpec { - action: ModalAction::Back, - bindings: &[ModalKeyBinding::Esc], - }, - ModalActionSpec { - action: ModalAction::Save, - bindings: &[ModalKeyBinding::Enter], - }, -]; - pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec] = &[ModalActionSpec { action: ModalAction::Close, bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc], diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs index 6475895e..b283847e 100644 --- a/src/app/input/overlays.rs +++ b/src/app/input/overlays.rs @@ -272,51 +272,17 @@ impl AppState { return; } - match self.onboarding_step { - 0 => { - let Some(inner) = self.onboarding_modal_inner(64, 16) else { - return; - }; - let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) - .actions - .unwrap_or_default(); - let button = crate::ui::onboarding_welcome_continue_rect(actions); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && modal_action_from_buttons( - mouse.column, - mouse.row, - &[(button, ModalAction::Continue)], - ) == Some(ModalAction::Continue) - { - self.onboarding_step = 1; - } - } - _ => { - let Some(inner) = self.onboarding_modal_inner(56, 14) else { - return; - }; - let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1); - if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 { - self.onboarding_list - .select((mouse.row - stack.content.y) as usize); - return; - } - - let (back, save) = crate::ui::onboarding_notification_button_rects( - stack.actions.unwrap_or_default(), - ); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - match modal_action_from_buttons( - mouse.column, - mouse.row, - &[(back, ModalAction::Back), (save, ModalAction::Save)], - ) { - Some(ModalAction::Back) => self.onboarding_step = 0, - Some(ModalAction::Save) => self.request_complete_onboarding = true, - _ => {} - } - } - } + let Some(inner) = self.onboarding_modal_inner(64, 16) else { + return; + }; + let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) + .actions + .unwrap_or_default(); + let button = crate::ui::onboarding_welcome_continue_rect(actions); + if modal_action_from_buttons(mouse.column, mouse.row, &[(button, ModalAction::Continue)]) + == Some(ModalAction::Continue) + { + self.request_complete_onboarding = true; } } @@ -453,31 +419,30 @@ mod tests { fn onboarding_hover_does_not_change_selection() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; - app.state.onboarding_step = 1; - app.state.onboarding_list.select(1); - let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; + let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); + let content = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1).content; app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y)); - assert_eq!(app.state.onboarding_list.selected, 1); + assert!(!app.state.request_complete_onboarding); } #[test] - fn onboarding_click_selects_notification_option() { + fn onboarding_click_continue_requests_completion() { let mut app = app_for_mouse_test(); app.state.mode = Mode::Onboarding; - app.state.onboarding_step = 1; - app.state.onboarding_list.select(0); - let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); - let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content; + let inner = app.state.onboarding_modal_inner(64, 16).unwrap(); + let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1) + .actions + .unwrap(); + let continue_rect = crate::ui::onboarding_welcome_continue_rect(actions); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), - content.x + 2, - content.y + 2, + continue_rect.x, + continue_rect.y, )); - assert_eq!(app.state.onboarding_list.selected, 2); + assert!(app.state.request_complete_onboarding); } } diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 9433b7d8..8bf06dc6 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -1,16 +1,19 @@ use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::Rect; -use crate::app::{ - state::{AppState, SettingsSection, THEME_NAMES}, - App, Mode, +use crate::{ + app::{ + state::{AppState, SettingsSection, THEME_NAMES}, + App, Mode, + }, + config::ToastDelivery, }; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) enum SettingsAction { SaveTheme(String), SaveSound(bool), - SaveToast(bool), + SaveToastDelivery(ToastDelivery), } impl App { @@ -19,7 +22,7 @@ impl App { match action { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), - SettingsAction::SaveToast(enabled) => self.save_toast(enabled), + SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), } } } @@ -37,6 +40,22 @@ fn current_theme_index(theme_name: &str) -> usize { .unwrap_or(0) } +fn toast_delivery_index(delivery: ToastDelivery) -> usize { + match delivery { + ToastDelivery::Off => 0, + ToastDelivery::Herdr => 1, + ToastDelivery::Terminal => 2, + } +} + +fn toast_delivery_for_index(idx: usize) -> ToastDelivery { + match idx { + 0 => ToastDelivery::Off, + 1 => ToastDelivery::Herdr, + _ => ToastDelivery::Terminal, + } +} + fn preview_selected_theme(state: &mut AppState) { use crate::app::state::Palette; @@ -111,7 +130,7 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { state.settings.section = SettingsSection::Toast; - state.settings.list.selected = usize::from(!state.toast_config.enabled); + state.settings.list.selected = toast_delivery_index(state.toast_delivery()); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { state.settings.section = SettingsSection::Theme; @@ -123,13 +142,13 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti }, }, SettingsSection::Toast => match key.code { - KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { - state.settings.list.selected = 1 - state.settings.list.selected.min(1); - } + KeyCode::Up | KeyCode::Char('k') => state.settings.list.move_prev(), + KeyCode::Down | KeyCode::Char('j') => state.settings.list.move_next(3), KeyCode::Enter | KeyCode::Char(' ') => { - let enabled = state.settings.list.selected == 0; - state.toast_config.enabled = enabled; - return Some(SettingsAction::SaveToast(enabled)); + let delivery = toast_delivery_for_index(state.settings.list.selected); + state.toast_config.delivery = delivery; + state.toast = None; + return Some(SettingsAction::SaveToastDelivery(delivery)); } KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { state.settings.section = SettingsSection::Sound; @@ -149,7 +168,7 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti None } -pub(super) fn open_settings(state: &mut AppState) { +pub(crate) fn open_settings(state: &mut AppState) { state.settings.original_palette = Some(state.palette.clone()); state.settings.original_theme = Some(state.theme_name.clone()); state.settings.section = SettingsSection::Theme; @@ -159,7 +178,7 @@ pub(super) fn open_settings(state: &mut AppState) { impl AppState { fn settings_popup_rect(&self) -> Rect { - crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default() + crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default() } fn settings_inner_rect(&self) -> Rect { @@ -212,14 +231,22 @@ impl AppState { let idx = scroll + (row - area.y) as usize; (idx < THEME_NAMES.len()).then_some(idx) } - SettingsSection::Sound | SettingsSection::Toast => { - let list_y = area.y + 2; + SettingsSection::Sound => { + let list_y = area.y + 3; if row >= list_y && row < list_y + 2 { Some((row - list_y) as usize) } else { None } } + SettingsSection::Toast => { + let list_y = area.y + 3; + if row >= list_y && row < list_y + 6 { + Some(((row - list_y) / 2) as usize) + } else { + None + } + } } } @@ -231,7 +258,7 @@ impl AppState { self.settings.list.select(match section { SettingsSection::Theme => current_theme_index(&self.theme_name), SettingsSection::Sound => usize::from(!self.sound_enabled()), - SettingsSection::Toast => usize::from(!self.toast_config.enabled), + SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), }); return None; } @@ -248,9 +275,10 @@ impl AppState { Some(SettingsAction::SaveSound(enabled)) } SettingsSection::Toast => { - let enabled = idx == 0; - self.toast_config.enabled = enabled; - Some(SettingsAction::SaveToast(enabled)) + let delivery = toast_delivery_for_index(idx); + self.toast_config.delivery = delivery; + self.toast = None; + Some(SettingsAction::SaveToastDelivery(delivery)) } }; } diff --git a/src/app/mod.rs b/src/app/mod.rs index ff6dc7ac..78681e06 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -254,8 +254,6 @@ impl App { request_complete_onboarding: false, name_input: String::new(), name_input_replace_on_type: false, - onboarding_step: 0, - onboarding_list: state::SelectionListState::new(1), release_notes: startup_release_notes.map(|notes| state::ReleaseNotesState { version: notes.version, body: notes.body, @@ -401,7 +399,7 @@ impl App { if self.state.request_complete_onboarding { self.state.request_complete_onboarding = false; - self.complete_onboarding(); + self.open_settings_from_onboarding(); needs_render = true; } @@ -528,30 +526,9 @@ impl App { } } - pub(crate) fn complete_onboarding(&mut self) { - let (sound_enabled, toast_enabled) = match self.state.onboarding_list.selected { - 0 => (false, false), - 1 => (false, true), - 2 => (true, false), - _ => (true, true), - }; - - match crate::config::save_onboarding_choices(sound_enabled, toast_enabled) { - Ok(()) => { - self.state.sound.enabled = sound_enabled; - self.state.toast_config.enabled = toast_enabled; - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; - } - Err(err) => { - self.state.config_diagnostic = - Some(format!("failed to save onboarding config: {err}")); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(8)); - } - } + pub(crate) fn open_settings_from_onboarding(&mut self) { + self.mark_onboarding_complete(); + crate::app::input::open_settings(&mut self.state); } pub(crate) fn reload_keybinds(&mut self) { @@ -1395,12 +1372,10 @@ mod tests { fn route_client_input_advances_onboarding_modal() { let mut app = test_app(); app.state.mode = Mode::Onboarding; - app.state.onboarding_step = 0; app.route_client_input(b"\r".to_vec()); - assert_eq!(app.state.onboarding_step, 1); - assert_eq!(app.state.mode, Mode::Onboarding); + assert_eq!(app.state.mode, Mode::Settings); } #[test] diff --git a/src/app/state.rs b/src/app/state.rs index 67f89606..e54b431a 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,4 @@ -use crate::config::{Keybinds, SoundConfig, ToastConfig}; +use crate::config::{Keybinds, SoundConfig, ToastConfig, ToastDelivery}; use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; @@ -602,8 +602,6 @@ pub struct AppState { pub request_complete_onboarding: bool, pub name_input: String, pub name_input_replace_on_type: bool, - pub onboarding_step: usize, - pub onboarding_list: SelectionListState, pub release_notes: Option, pub keybind_help: KeybindHelpState, pub workspace_scroll: usize, @@ -665,6 +663,10 @@ impl AppState { self.sound.enabled } + pub fn toast_delivery(&self) -> ToastDelivery { + self.toast_config.delivery + } + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { key_matches(key, self.prefix_code, self.prefix_mods) } @@ -722,8 +724,6 @@ impl AppState { request_complete_onboarding: false, name_input: String::new(), name_input_replace_on_type: false, - onboarding_step: 0, - onboarding_list: SelectionListState::new(1), release_notes: None, keybind_help: KeybindHelpState { scroll: 0 }, workspace_scroll: 0, diff --git a/src/client/mod.rs b/src/client/mod.rs index 0e4f229c..66e11ad2 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -255,6 +255,7 @@ pub fn run_client() -> io::Result<()> { let loaded_config = crate::config::Config::load(); let sound_config = loaded_config.config.ui.sound; + let toast_config = loaded_config.config.ui.toast; let socket_path = client_socket_path(); info!(path = %socket_path.display(), "connecting to server"); @@ -309,8 +310,9 @@ pub fn run_client() -> io::Result<()> { quit_flag.store(true, Ordering::Release); }); - let result = - rt.block_on(async { run_client_loop(stream, cols, rows, should_quit, sound_config).await }); + let result = rt.block_on(async { + run_client_loop(stream, cols, rows, should_quit, sound_config, toast_config).await + }); // Restore the terminal before printing any final status message. drop(_guard); @@ -350,6 +352,7 @@ async fn run_client_loop( rows: u16, should_quit: Arc, sound_config: crate::config::SoundConfig, + toast_config: crate::config::ToastConfig, ) -> Result<(), ClientError> { let mut state = ClientState { last_frame: None, @@ -425,7 +428,7 @@ async fn run_client_loop( return Err(ClientError::ServerShutdown { reason }); } ServerMessage::Notify { kind, message } => { - handle_notify(kind, &message, &sound_config); + handle_notify(kind, &message, &sound_config, &toast_config); } ServerMessage::Clipboard { data } => { forward_clipboard(&data); @@ -523,7 +526,12 @@ fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<( // Notifications // --------------------------------------------------------------------------- -fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) { +fn handle_notify( + kind: NotifyKind, + message: &str, + sound_config: &crate::config::SoundConfig, + toast_config: &crate::config::ToastConfig, +) { match kind { NotifyKind::Sound => { let Some(sound) = sound_from_notify_message(message) else { @@ -539,6 +547,15 @@ fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config:: } NotifyKind::Toast => { debug!(message = message, "received toast notification from server"); + if matches!( + toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) { + let (title, body) = crate::terminal_notify::split_message(message); + if let Err(err) = crate::terminal_notify::show_notification(title, body) { + warn!(err = %err, "failed to emit terminal notification"); + } + } } } } diff --git a/src/config.rs b/src/config.rs index 4fa772d0..71b20f52 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,18 +8,20 @@ mod theme; pub use self::{ io::{ - config_dir, config_path, load_live_keybinds, save_onboarding_choices, upsert_section_bool, + config_dir, config_path, load_live_keybinds, remove_section_key, upsert_section_bool, upsert_section_value, }, keybinds::{ format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind, Keybinds, LiveKeybindConfig, }, - model::{Config, ToastConfig}, + model::{Config, ToastConfig, ToastDelivery}, sound::{AgentSoundSetting, SoundConfig}, theme::{parse_color, CustomThemeColors, ThemeConfig}, }; +pub(crate) use self::io::upsert_top_level_bool; + pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH"; pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000; diff --git a/src/config/io.rs b/src/config/io.rs index 3bc18c4b..1a3c1920 100644 --- a/src/config/io.rs +++ b/src/config/io.rs @@ -70,19 +70,6 @@ pub(super) fn resolve_config_relative_path(path: &Path) -> PathBuf { .join(path) } -pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std::io::Result<()> { - let path = config_path(); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let content = upsert_top_level_bool(&content, "onboarding", false); - let content = upsert_section_bool(&content, "ui.sound", "enabled", sound_enabled); - let content = upsert_section_bool(&content, "ui.toast", "enabled", toast_enabled); - std::fs::write(path, content) -} - pub fn config_path() -> PathBuf { if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) { return PathBuf::from(path); @@ -109,7 +96,7 @@ pub fn load_live_keybinds() -> Result> { config.live_keybinds() } -pub(super) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { +pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { let replacement = format!("{key} = {value}"); let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); let mut in_section = false; @@ -145,6 +132,38 @@ pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) upsert_section_raw(content, section, key, &value.to_string()) } +pub fn remove_section_key(content: &str, section: &str, key: &str) -> String { + let header = format!("[{section}]"); + let lines: Vec<&str> = content.lines().collect(); + let mut result = Vec::new(); + let mut i = 0; + let mut in_section = false; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = trimmed == header; + result.push(line.to_string()); + i += 1; + continue; + } + + if in_section + && (trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}="))) + { + i += 1; + continue; + } + + result.push(line.to_string()); + i += 1; + } + + result.join("\n") + "\n" +} + fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String { let header = format!("[{section}]"); let assignment = format!("{key} = {value}"); @@ -223,4 +242,14 @@ mod tests { assert!(updated.contains("[ui.toast]")); assert!(updated.contains("enabled = true")); } + + #[test] + fn remove_section_key_removes_matching_key_from_section() { + let content = + "[ui.toast]\nenabled = true\ndelivery = \"herdr\"\n[ui.sound]\nenabled = true\n"; + let updated = remove_section_key(content, "ui.toast", "enabled"); + assert!(!updated.contains("[ui.toast]\nenabled = true")); + assert!(updated.contains("delivery = \"herdr\"")); + assert!(updated.contains("[ui.sound]\nenabled = true")); + } } diff --git a/src/config/model.rs b/src/config/model.rs index 70eede5a..e2c39f25 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -1,11 +1,19 @@ -use serde::Deserialize; +use serde::{Deserialize, Deserializer, Serialize}; use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES}; -#[derive(Debug, Clone, Deserialize)] -#[serde(default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ToastDelivery { + #[default] + Off, + Herdr, + Terminal, +} + +#[derive(Debug, Clone)] pub struct ToastConfig { - pub enabled: bool, + pub delivery: ToastDelivery, } #[derive(Debug, Default, Deserialize)] @@ -144,7 +152,31 @@ impl Default for UiConfig { impl Default for ToastConfig { fn default() -> Self { - Self { enabled: false } + Self { + delivery: ToastDelivery::Off, + } + } +} + +impl<'de> Deserialize<'de> for ToastConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize, Default)] + #[serde(default)] + struct RawToastConfig { + delivery: Option, + enabled: Option, + } + + let raw = RawToastConfig::deserialize(deserializer)?; + let delivery = raw.delivery.unwrap_or_else(|| match raw.enabled { + Some(true) => ToastDelivery::Herdr, + Some(false) => ToastDelivery::Off, + None => ToastDelivery::Off, + }); + Ok(Self { delivery }) } } @@ -165,10 +197,41 @@ mod tests { fn toast_config_parses() { let toml = r#" [ui.toast] +delivery = "terminal" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.toast.delivery, ToastDelivery::Terminal); + } + + #[test] + fn toast_config_legacy_enabled_true_maps_to_herdr() { + let toml = r#" +[ui.toast] enabled = true "#; let config: Config = toml::from_str(toml).unwrap(); - assert!(config.ui.toast.enabled); + assert_eq!(config.ui.toast.delivery, ToastDelivery::Herdr); + } + + #[test] + fn toast_config_legacy_enabled_false_maps_to_off() { + let toml = r#" +[ui.toast] +enabled = false +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.toast.delivery, ToastDelivery::Off); + } + + #[test] + fn toast_config_delivery_wins_over_legacy_enabled() { + let toml = r#" +[ui.toast] +enabled = true +delivery = "terminal" +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.ui.toast.delivery, ToastDelivery::Terminal); } #[test] diff --git a/src/main.rs b/src/main.rs index 2637668e..4f929b22 100644 --- a/src/main.rs +++ b/src/main.rs @@ -39,6 +39,7 @@ mod release_notes; mod selection; mod server; mod sound; +mod terminal_notify; mod terminal_theme; mod ui; mod update; @@ -110,9 +111,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Accepts: hex (#89b4fa), named colors (cyan, blue, magenta), or rgb(r,g,b) # accent = "cyan" -# Optional visual toast notifications for background workspace events +# Background notification popup delivery [ui.toast] -# enabled = false +# off = disable pop-up notifications +# herdr = show top-right in-app toasts +# terminal = ask the outer terminal to show a desktop notification +# delivery = "off" # Play sounds when agents change state in background workspaces [ui.sound] diff --git a/src/server/headless.rs b/src/server/headless.rs index fc7a27db..414b2fca 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -454,7 +454,7 @@ impl HeadlessServer { // Handle deferred requests. if self.app.state.request_complete_onboarding { self.app.state.request_complete_onboarding = false; - self.app.complete_onboarding(); + self.app.open_settings_from_onboarding(); needs_render = true; } @@ -799,15 +799,67 @@ impl HeadlessServer { } } - // Forward any new toast as a notification. - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - if let Some(toast) = &self.app.state.toast { - let msg = format!("{}: {}", toast.title, toast.context); - self.send_to_all_clients(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: msg, - }); - } + let toast_msg = + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else if matches!( + self.app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) { + self.app + .state + .workspaces + .iter() + .enumerate() + .find_map(|(ws_idx, ws)| { + ws.tabs.iter().find_map(|tab| { + tab.panes.get(&pane_id_val).and_then(|pane| { + pane.effective_agent_label().and_then(|agent_label| { + crate::app::actions::notification_toast_for_state_change( + is_active_tab, + prev_state, + state_val, + ) + .map(|kind| { + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => { + "needs attention" + } + crate::app::state::ToastKind::Finished => { + "finished" + } + crate::app::state::ToastKind::UpdateInstalled => { + "updated" + } + }; + format!( + "{} {}: {}", + agent_label, + event_text, + crate::app::actions::notification_context( + ws, + ws_idx, + pane_id_val, + ) + ) + }) + }) + }) + }) + }) + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_to_all_clients(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: msg, + }); } true @@ -880,33 +932,100 @@ impl HeadlessServer { } } - // Forward any new toast as a notification. - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - if let Some(toast) = &self.app.state.toast { - let msg = format!("{}: {}", toast.title, toast.context); - self.send_to_all_clients(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: msg, - }); - } + let toast_msg = + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else if matches!( + self.app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) { + self.app + .state + .workspaces + .iter() + .enumerate() + .find_map(|(ws_idx, ws)| { + ws.tabs.iter().find_map(|tab| { + tab.panes.get(&pane_id_val).and_then(|pane| { + pane.effective_agent_label().and_then(|agent_label| { + crate::app::actions::notification_toast_for_state_change( + is_active_tab, + prev_hook_state, + hook_state_val, + ) + .map(|kind| { + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => { + "needs attention" + } + crate::app::state::ToastKind::Finished => { + "finished" + } + crate::app::state::ToastKind::UpdateInstalled => { + "updated" + } + }; + format!( + "{} {}: {}", + agent_label, + event_text, + crate::app::actions::notification_context( + ws, + ws_idx, + pane_id_val, + ) + ) + }) + }) + }) + }) + }) + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_to_all_clients(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: msg, + }); } true } - AppEvent::UpdateReady { version: _ } => { + AppEvent::UpdateReady { version } => { let toast_before = self.app.state.toast.clone(); + let version = version.clone(); self.app.handle_internal_event(ev); - // Forward the update toast notification. - if self.app.state.toast.is_some() && self.app.state.toast != toast_before { - if let Some(toast) = &self.app.state.toast { - let msg = format!("{}: {}", toast.title, toast.context); - self.send_to_all_clients(ServerMessage::Notify { - kind: protocol::NotifyKind::Toast, - message: msg, - }); - } + let toast_msg = + if self.app.state.toast.is_some() && self.app.state.toast != toast_before { + self.app + .state + .toast + .as_ref() + .map(|toast| format!("{}: {}", toast.title, toast.context)) + } else if matches!( + self.app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) { + Some(format!( + "v{version} available: detach, then run `herdr update`" + )) + } else { + None + }; + + if let Some(msg) = toast_msg { + self.send_to_all_clients(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: msg, + }); } true @@ -1209,7 +1328,7 @@ impl HeadlessServer { // Forward any new toast as a notification to clients. let toast_after = self.app.state.toast.clone(); - if toast_after.is_some() && toast_after != toast_before { + let forwarded_toast_from_state = if toast_after.is_some() && toast_after != toast_before { if let Some(toast) = &toast_after { let msg_text = format!("{}: {}", toast.title, toast.context); debug!(msg = %msg_text, "forwarding toast notification from API request"); @@ -1217,8 +1336,13 @@ impl HeadlessServer { kind: protocol::NotifyKind::Toast, message: msg_text, }); + true + } else { + false } - } + } else { + false + }; // Forward sound notifications for any pane state changes that occurred // during the API request. Compare before/after pane states (including @@ -1278,6 +1402,41 @@ impl HeadlessServer { "pane state changed during API request, checking sound notification" ); + if !forwarded_toast_from_state + && matches!( + self.app.state.toast_config.delivery, + crate::config::ToastDelivery::Terminal + ) + { + if let Some(kind) = crate::app::actions::notification_toast_for_state_change( + is_active_tab, + prev_state, + new_state, + ) { + if let Some(agent_label) = pane_after.effective_agent_label() { + let event_text = match kind { + crate::app::state::ToastKind::NeedsAttention => "needs attention", + crate::app::state::ToastKind::Finished => "finished", + crate::app::state::ToastKind::UpdateInstalled => "updated", + }; + let msg_text = format!( + "{} {}: {}", + agent_label, + event_text, + crate::app::actions::notification_context( + &self.app.state.workspaces[*ws_idx], + *ws_idx, + *pane_id, + ) + ); + self.send_to_all_clients(ServerMessage::Notify { + kind: protocol::NotifyKind::Toast, + message: msg_text, + }); + } + } + } + // Check agent-specific sound setting but NOT sound.enabled, // because the server sets enabled=false to prevent local playback. // Clients decide locally whether to play sounds. diff --git a/src/terminal_notify.rs b/src/terminal_notify.rs new file mode 100644 index 00000000..e6380c7b --- /dev/null +++ b/src/terminal_notify.rs @@ -0,0 +1,143 @@ +use std::io::{self, Write as _}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalNotificationBackend { + Ghostty, + Iterm2, + Kitty, + WezTerm, +} + +pub fn detect_backend() -> Option { + let term_program = std::env::var("TERM_PROGRAM").ok(); + let term = std::env::var("TERM").ok(); + + match term_program.as_deref() { + Some("ghostty") => return Some(TerminalNotificationBackend::Ghostty), + Some("iTerm.app") => return Some(TerminalNotificationBackend::Iterm2), + Some("WezTerm") => return Some(TerminalNotificationBackend::WezTerm), + _ => {} + } + + if std::env::var_os("KITTY_WINDOW_ID").is_some() { + return Some(TerminalNotificationBackend::Kitty); + } + + match term.as_deref() { + Some("xterm-ghostty") => Some(TerminalNotificationBackend::Ghostty), + Some("xterm-kitty") => Some(TerminalNotificationBackend::Kitty), + Some(term) if term.contains("wezterm") => Some(TerminalNotificationBackend::WezTerm), + _ => None, + } +} + +pub fn show_notification(title: &str, body: Option<&str>) -> io::Result { + let Some(backend) = detect_backend() else { + return Ok(false); + }; + + let sequence = match backend { + TerminalNotificationBackend::Ghostty + | TerminalNotificationBackend::Iterm2 + | TerminalNotificationBackend::WezTerm => build_osc9_notification(title, body), + TerminalNotificationBackend::Kitty => build_osc99_notification(title, body), + }; + + let sequence = if std::env::var_os("TMUX").is_some() { + wrap_tmux_passthrough(&sequence) + } else { + sequence + }; + + let mut stdout = io::stdout(); + stdout.write_all(&sequence)?; + stdout.flush()?; + Ok(true) +} + +pub fn split_message(message: &str) -> (&str, Option<&str>) { + match message.split_once(": ") { + Some((title, body)) if !title.is_empty() && !body.is_empty() => (title, Some(body)), + _ => (message, None), + } +} + +fn build_osc9_notification(title: &str, body: Option<&str>) -> Vec { + let message = sanitize_text(match body { + Some(body) if !body.is_empty() => format!("{title}: {body}"), + _ => title.to_string(), + }); + format!("\x1b]9;{message}\x1b\\").into_bytes() +} + +fn build_osc99_notification(title: &str, body: Option<&str>) -> Vec { + let title = sanitize_text(title); + match body { + Some(body) if !body.is_empty() => { + let body = sanitize_text(body); + format!("\x1b]99;i=1:d=0;{title}\x1b\\\x1b]99;i=1:p=body;{body}\x1b\\").into_bytes() + } + _ => format!("\x1b]99;;{title}\x1b\\").into_bytes(), + } +} + +fn sanitize_text(text: impl AsRef) -> String { + text.as_ref() + .chars() + .filter(|ch| *ch != '\u{1b}' && *ch != '\u{7}' && *ch != '\u{9c}') + .map(|ch| match ch { + '\n' | '\r' | '\t' => ' ', + _ => ch, + }) + .collect() +} + +fn wrap_tmux_passthrough(sequence: &[u8]) -> Vec { + let mut wrapped = Vec::with_capacity(sequence.len() + 16); + wrapped.extend_from_slice(b"\x1bPtmux;"); + for &byte in sequence { + if byte == 0x1b { + wrapped.push(0x1b); + } + wrapped.push(byte); + } + wrapped.extend_from_slice(b"\x1b\\"); + wrapped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_message_splits_title_and_body() { + assert_eq!( + split_message("agent done: ws · 1"), + ("agent done", Some("ws · 1")) + ); + } + + #[test] + fn split_message_leaves_plain_message_alone() { + assert_eq!(split_message("agent done"), ("agent done", None)); + } + + #[test] + fn sanitize_text_strips_control_bytes() { + assert_eq!(sanitize_text("a\n\tb\u{1b}c\u{7}"), "a bc"); + } + + #[test] + fn kitty_notification_uses_structured_title_and_body() { + let sequence = String::from_utf8(build_osc99_notification("pi finished", Some("ws · 1"))) + .expect("utf8"); + assert!(sequence.contains("]99;i=1:d=0;pi finished")); + assert!(sequence.contains("]99;i=1:p=body;ws · 1")); + } + + #[test] + fn tmux_passthrough_wraps_and_escapes() { + let wrapped = wrap_tmux_passthrough(b"\x1b]9;hi\x1b\\"); + assert_eq!(wrapped, b"\x1bPtmux;\x1b\x1b]9;hi\x1b\x1b\\\x1b\\"); + } +} diff --git a/src/ui.rs b/src/ui.rs index 2c66f461..5c791fec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -24,10 +24,8 @@ use self::menus::{ render_context_menu, render_global_launcher_menu, render_navigate_overlay, render_resize_overlay, }; +pub(crate) use self::onboarding::onboarding_welcome_continue_rect; use self::onboarding::render_onboarding_overlay; -pub(crate) use self::onboarding::{ - onboarding_notification_button_rects, onboarding_welcome_continue_rect, -}; use self::panes::{compute_pane_infos, render_panes}; use self::release_notes::render_release_notes_overlay; pub(crate) use self::release_notes::{ diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs index 6f35c1a6..4a1d4f57 100644 --- a/src/ui/onboarding.rs +++ b/src/ui/onboarding.rs @@ -7,8 +7,8 @@ use ratatui::{ }; use super::widgets::{ - action_button_row_rects, action_button_width, modal_stack_areas, panel_contrast_fg, - render_action_button, render_modal_header, render_modal_shell, ActionButtonSpec, + action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button, + render_modal_shell, }; use crate::app::AppState; @@ -16,11 +16,7 @@ const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { super::dim_background(frame, area); - - match app.onboarding_step { - 0 => render_onboarding_welcome(app, frame, area), - _ => render_onboarding_notifications(app, frame, area), - } + render_onboarding_welcome(app, frame, area); } pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { @@ -32,25 +28,6 @@ pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect { ) } -pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) { - let rects = action_button_row_rects( - area, - &[ - ActionButtonSpec { - hint: Some("esc"), - label: "back", - }, - ActionButtonSpec { - hint: Some("↵"), - label: "start", - }, - ], - 2, - 0, - ); - (rects[0], rects[1]) -} - fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else { return; @@ -129,87 +106,3 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { .add_modifier(Modifier::BOLD), ); } - -fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else { - return; - }; - - if inner.height < 11 { - return; - } - - let stack = modal_stack_areas(inner, 3, 0, 1, 1); - let header_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - ]) - .areas::<3>(stack.header); - let option_rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .areas::<5>(stack.content); - - render_modal_header(frame, header_rows[0], "notification style", &app.palette); - frame.render_widget( - Paragraph::new(" herdr watches background panes and can alert you") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[1], - ); - frame.render_widget( - Paragraph::new(" when agents finish or need attention.") - .style(Style::default().fg(app.palette.overlay1)), - header_rows[2], - ); - - let options = [ - "quiet no interruptions", - "visual only top-right toasts", - "sound only sound alerts", - "both sound and toasts", - ]; - - for (idx, option) in options.iter().enumerate() { - let selected = idx == app.onboarding_list.selected; - let prefix = if selected { "›" } else { " " }; - let style = if selected { - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - } else { - Style::default().fg(app.palette.text) - }; - frame.render_widget( - Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), - option_rows[idx], - ); - } - - let (back_rect, save_rect) = - onboarding_notification_button_rects(stack.actions.unwrap_or_default()); - render_action_button( - frame, - back_rect, - Some("esc"), - "back", - Style::default() - .fg(app.palette.text) - .bg(app.palette.surface0) - .add_modifier(Modifier::BOLD), - ); - render_action_button( - frame, - save_rect, - Some("↵"), - "start", - Style::default() - .fg(panel_contrast_fg(&app.palette)) - .bg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ); -} diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1123f408..5269f380 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -8,15 +8,18 @@ use ratatui::{ use super::widgets::{ action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg, - render_action_button, render_panel_shell, ActionButtonSpec, + render_action_button, render_modal_choice_list, render_panel_shell, ActionButtonSpec, +}; +use crate::{ + app::{state::Palette, AppState}, + config::ToastDelivery, }; -use crate::app::{state::Palette, AppState}; pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) { use crate::app::state::SettingsSection; let p = &app.palette; - let Some(popup) = centered_popup_rect(area, 56, 20) else { + let Some(popup) = centered_popup_rect(area, 76, 22) else { return; }; @@ -87,14 +90,20 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R ); } SettingsSection::Toast => { - render_settings_toggle( + render_modal_choice_list( frame, content_area, - p, - "visual toasts", - "show top-right notifications for background events", - app.toast_config.enabled, + "notification popups", + "choose where background popup notifications should appear", + &[ + ("off", ToastDelivery::Off), + ("inside herdr", ToastDelivery::Herdr), + ("via terminal", ToastDelivery::Terminal), + ], + app.toast_delivery(), app.settings.list.selected, + p, + 2, ); } } @@ -195,44 +204,15 @@ fn render_settings_toggle( current_value: bool, selected_idx: usize, ) { - let [desc_area, _, list_area] = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(2), - ]) - .areas::<3>(area); - - let max_desc_len = (desc_area.width as usize).saturating_sub(2); - let desc_text = if description.len() > max_desc_len { - format!(" {}…", &description[..max_desc_len.saturating_sub(2)]) - } else { - format!(" {description}") - }; - frame.render_widget( - Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))), - desc_area, + render_modal_choice_list( + frame, + area, + title, + description, + &[("on", true), ("off", false)], + current_value, + selected_idx, + p, + 1, ); - - let items: Vec = ["on", "off"] - .into_iter() - .map(|label| { - let is_active = (label == "on") == current_value; - let marker = if is_active { " ✓" } else { "" }; - ListItem::new(Line::from(vec![ - Span::styled(format!("{title}: {label}"), Style::default().fg(p.subtext0)), - Span::styled(marker, Style::default().fg(p.green)), - ])) - }) - .collect(); - - let list = List::new(items) - .highlight_style( - Style::default() - .bg(p.surface0) - .fg(p.text) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(" ▸ "); - let mut state = ListState::default().with_selected(Some(selected_idx.min(1))); - frame.render_stateful_widget(list, list_area, &mut state); } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 354b7884..ef416134 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -2,7 +2,7 @@ use ratatui::{ layout::{Alignment, Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, Clear, Paragraph}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, Frame, }; @@ -60,14 +60,10 @@ pub(super) fn render_modal_shell( } pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) { - let line = Line::from(vec![ - Span::styled( - title, - Style::default().fg(p.text).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled("⬆", Style::default().fg(p.accent)), - ]); + let line = Line::from(vec![Span::styled( + title, + Style::default().fg(p.text).add_modifier(Modifier::BOLD), + )]); frame.render_widget(Paragraph::new(line), area); } @@ -180,6 +176,79 @@ pub(super) fn render_action_button( ); } +pub(crate) fn render_modal_description(frame: &mut Frame, area: Rect, text: &str, style: Style) { + frame.render_widget( + Paragraph::new(format!(" {text}")) + .style(style) + .wrap(Wrap { trim: false }), + area, + ); +} + +pub(crate) fn modal_choice_rows(area: Rect, count: usize, row_height: u16) -> Vec { + let mut rows = Vec::with_capacity(count); + let mut y = area.y; + for _ in 0..count { + if y >= area.y + area.height { + break; + } + let remaining = area.y + area.height - y; + let height = row_height.min(remaining); + rows.push(Rect::new(area.x, y, area.width, height)); + y = y.saturating_add(row_height); + } + rows +} + +pub(crate) fn render_modal_choice_list( + frame: &mut Frame, + area: Rect, + title: &str, + description: &str, + options: &[(&str, T)], + current_value: T, + selected_idx: usize, + p: &Palette, + row_height: u16, +) where + T: Copy + PartialEq, +{ + let [desc_area, _, list_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(2), + ]) + .areas::<3>(area); + + render_modal_description( + frame, + desc_area, + description, + Style::default().fg(p.overlay1), + ); + + let rows = modal_choice_rows(list_area, options.len(), row_height); + for (idx, ((label, value), row)) in options.iter().zip(rows.iter()).enumerate() { + let is_active = *value == current_value; + let is_selected = idx == selected_idx; + let marker = if is_active { " ✓" } else { "" }; + let style = if is_selected { + Style::default() + .bg(p.surface0) + .fg(p.text) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(p.subtext0) + }; + frame.render_widget( + Paragraph::new(format!(" {title}: {label}{marker}")) + .style(style) + .wrap(Wrap { trim: false }), + *row, + ); + } +} + pub(super) fn centered_button_row( inner: Rect, widths: &[u16],