feat: add terminal-delivered client notifications
This commit is contained in:
parent
5bd939fbfc
commit
4b482f37a1
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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\\");
|
||||
}
|
||||
}
|
||||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
Loading…
Reference in New Issue