feat: add terminal-delivered client notifications

This commit is contained in:
Ogulcan Celik 2026-04-22 17:01:25 +03:00
parent 5bd939fbfc
commit 4b482f37a1
21 changed files with 788 additions and 405 deletions

View File

@ -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

View File

@ -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(),

View File

@ -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);
}

View File

@ -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")
});
}
}

View File

@ -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),
}
}

View File

@ -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],

View File

@ -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);
}
}

View File

@ -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))
}
};
}

View File

@ -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]

View File

@ -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,

View File

@ -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");
}
}
}
}
}

View File

@ -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;

View File

@ -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"));
}
}

View File

@ -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!(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]

View File

@ -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]

View File

@ -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.

143
src/terminal_notify.rs Normal file
View File

@ -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\\");
}
}

View File

@ -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::{

View File

@ -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),
);
}

View File

@ -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);
}

View File

@ -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],