aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-22 17:01:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-22 17:01:25 +0300
commit4b482f37a1e233b9aca6bf99b12e8b4da3e5c05e (patch)
tree69d1a56ad6335547eec9727691310cf96ebe9623
parent5bd939fbfc92a8073d0eac11dfb02a31ea638b15 (diff)
feat: add terminal-delivered client notifications
-rw-r--r--CONFIGURATION.md25
-rw-r--r--src/app/actions.rs38
-rw-r--r--src/app/api.rs57
-rw-r--r--src/app/config_io.rs17
-rw-r--r--src/app/input/mod.rs37
-rw-r--r--src/app/input/modal.rs12
-rw-r--r--src/app/input/overlays.rs81
-rw-r--r--src/app/input/settings.rs68
-rw-r--r--src/app/mod.rs35
-rw-r--r--src/app/state.rs10
-rw-r--r--src/client/mod.rs25
-rw-r--r--src/config.rs6
-rw-r--r--src/config/io.rs57
-rw-r--r--src/config/model.rs75
-rw-r--r--src/main.rs8
-rw-r--r--src/server/headless.rs221
-rw-r--r--src/terminal_notify.rs143
-rw-r--r--src/ui.rs4
-rw-r--r--src/ui/onboarding.rs113
-rw-r--r--src/ui/settings.rs74
-rw-r--r--src/ui/widgets.rs87
21 files changed, 788 insertions, 405 deletions
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<ModalAction>] = &
bindings: &[ModalKeyBinding::Enter],
}];
-pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Back,
- bindings: &[ModalKeyBinding::Esc],
- },
- ModalActionSpec {
- action: ModalAction::Save,
- bindings: &[ModalKeyBinding::Enter],
- },
-];
-
pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[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<ReleaseNotesState>,
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<AtomicBool>,
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<LiveKeybindConfig, Vec<String>> {
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<String> = 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<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ #[derive(Deserialize, Default)]
+ #[serde(default)]
+ struct RawToastConfig {
+ delivery: Option<ToastDelivery>,
+ enabled: Option<bool>,
+ }
+
+ 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_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!(config.ui.toast.enabled);
+ 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<TerminalNotificationBackend> {
+ 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<bool> {
+ 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<u8> {
+ 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<u8> {
+ 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<str>) -> 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<u8> {
+ 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<ListItem> = ["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<Rect> {
+ 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<T>(
+ 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],