From fa1adf1a26033fc50a2920d96045e76aba93bddb Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 28 Mar 2026 21:13:41 +0300 Subject: [PATCH] feat: add onboarding and toast notification preferences --- CONFIGURATION.md | 35 ++++++ src/app/actions.rs | 115 ++++++++++++++++++- src/app/input.rs | 103 +++++++++++++++++ src/app/mod.rs | 62 +++++++++- src/app/state.rs | 26 ++++- src/config.rs | 159 +++++++++++++++++++++++++- src/main.rs | 8 ++ src/ui.rs | 278 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 781 insertions(+), 5 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index f78ec7ca..75cd0471 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -14,6 +14,21 @@ herdr --default-config if a config value is invalid, or two navigate actions use the same keybinding, herdr falls back to a safe default and shows a startup warning in the UI. +## onboarding + +```toml +onboarding = true +``` + +| option | default | description | +|--------|---------|-------------| +| `onboarding` | unset | show first-run notification setup; set `false` after choosing | + +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 + ## keybindings keybindings live under `[keys]`. @@ -83,6 +98,26 @@ accent = "cyan" - hex like `#89b4fa` - rgb like `rgb(137,180,250)` +## toast notifications + +```toml +[ui.toast] +enabled = false +``` + +### options + +| option | default | description | +|--------|---------|-------------| +| `ui.toast.enabled` | `false` | show top-right visual toasts for background agent events | + +current v1 behavior: +- informational only +- one toast at a time +- top-right placement +- shown for background agent events like `needs attention` and `finished` +- no keyboard action or temporary key semantics + ## sound ```toml diff --git a/src/app/actions.rs b/src/app/actions.rs index 7c323da5..aa767eb3 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -7,7 +7,7 @@ use crate::detect::AgentState; use crate::events::AppEvent; use crate::layout::{find_in_direction, NavDirection, PaneId}; -use super::state::{AppState, Mode}; +use super::state::{AppState, Mode, ToastKind, ToastNotification}; fn notification_sound_for_state_change( is_active_ws: bool, @@ -27,6 +27,38 @@ fn notification_sound_for_state_change( } } +fn notification_toast_for_state_change( + is_active_ws: bool, + prev_state: AgentState, + new_state: AgentState, +) -> Option { + if is_active_ws || new_state == prev_state { + return None; + } + + match new_state { + AgentState::Waiting => Some(ToastKind::NeedsAttention), + AgentState::Idle if prev_state != AgentState::Idle => Some(ToastKind::Finished), + _ => None, + } +} + +fn agent_label(agent: crate::detect::Agent) -> &'static str { + match agent { + crate::detect::Agent::Pi => "pi", + crate::detect::Agent::Claude => "claude", + crate::detect::Agent::Codex => "codex", + crate::detect::Agent::Gemini => "gemini", + crate::detect::Agent::Cursor => "cursor", + crate::detect::Agent::Cline => "cline", + crate::detect::Agent::OpenCode => "opencode", + crate::detect::Agent::GithubCopilot => "copilot", + crate::detect::Agent::Kimi => "kimi", + crate::detect::Agent::Droid => "droid", + crate::detect::Agent::Amp => "amp", + } +} + // --------------------------------------------------------------------------- // Workspace operations // --------------------------------------------------------------------------- @@ -174,6 +206,7 @@ impl AppState { state, } => { for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() { + let workspace_name = ws.display_name(); if let Some(pane) = ws.panes.get_mut(&pane_id) { let is_active_ws = self.active == Some(ws_idx); let prev_state = pane.state; @@ -195,6 +228,27 @@ impl AppState { } } + if self.toast_config.enabled { + if let (Some(agent), Some(kind)) = ( + agent, + notification_toast_for_state_change( + is_active_ws, + prev_state, + state, + ), + ) { + let event_text = match kind { + ToastKind::NeedsAttention => "needs attention", + ToastKind::Finished => "finished", + }; + self.toast = Some(ToastNotification { + kind, + title: format!("{} {}", agent_label(agent), event_text), + context: format!("{} · {}", workspace_name, ws_idx + 1), + }); + } + } + pane.detected_agent = agent; pane.state = state; break; @@ -433,6 +487,65 @@ mod tests { ); } + #[test] + fn background_waiting_sets_attention_toast() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.enabled = true; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Pi), + state: AgentState::Waiting, + }); + + let toast = state.toast.as_ref().unwrap(); + assert_eq!(toast.kind, ToastKind::NeedsAttention); + assert_eq!(toast.title, "pi needs attention"); + assert_eq!(toast.context, "background · 2"); + } + + #[test] + fn background_idle_sets_finished_toast() { + let mut state = app_with_workspaces(&["active", "background"]); + state.active = Some(0); + state.toast_config.enabled = true; + let bg_pane_id = *state.workspaces[1].panes.keys().next().unwrap(); + state.workspaces[1] + .panes + .get_mut(&bg_pane_id) + .unwrap() + .state = AgentState::Busy; + + state.handle_app_event(AppEvent::StateChanged { + pane_id: bg_pane_id, + agent: Some(Agent::Droid), + state: AgentState::Idle, + }); + + let toast = state.toast.as_ref().unwrap(); + assert_eq!(toast.kind, ToastKind::Finished); + assert_eq!(toast.title, "droid finished"); + assert_eq!(toast.context, "background · 2"); + } + + #[test] + fn active_workspace_does_not_set_toast() { + let mut state = app_with_workspaces(&["active"]); + state.active = Some(0); + state.toast_config.enabled = true; + let pane_id = *state.workspaces[0].panes.keys().next().unwrap(); + + state.handle_app_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Waiting, + }); + + assert!(state.toast.is_none()); + } + #[test] fn toggle_fullscreen_works() { let mut state = app_with_workspaces(&["test"]); diff --git a/src/app/input.rs b/src/app/input.rs index 590e9548..7d2e709e 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -18,6 +18,7 @@ use super::App; impl App { pub(super) async fn handle_key(&mut self, key: KeyEvent) { match self.state.mode { + Mode::Onboarding => self.handle_onboarding_key(key), Mode::Navigate => handle_navigate_key(&mut self.state, key), Mode::Terminal => self.handle_terminal_key(key).await, Mode::RenameSession => handle_rename_key(&mut self.state, key), @@ -49,6 +50,39 @@ impl App { } } + fn handle_onboarding_key(&mut self, key: KeyEvent) { + match self.state.onboarding_step { + 0 => match key.code { + KeyCode::Enter | KeyCode::Right | KeyCode::Char('l') => { + self.state.onboarding_step = 1; + } + KeyCode::Char('q') => self.state.should_quit = true, + _ => {} + }, + _ => match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if self.state.onboarding_selected > 0 { + self.state.onboarding_selected -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.state.onboarding_selected < 3 { + self.state.onboarding_selected += 1; + } + } + KeyCode::Left | KeyCode::Esc | KeyCode::Char('h') => { + self.state.onboarding_step = 0; + } + KeyCode::Char(c) if ('1'..='4').contains(&c) => { + self.state.onboarding_selected = (c as usize) - ('1' as usize); + } + KeyCode::Enter => self.complete_onboarding(), + KeyCode::Char('q') => self.state.should_quit = true, + _ => {} + }, + } + } + async fn handle_terminal_key(&mut self, key: KeyEvent) { self.state.clear_selection(); self.state.update_dismissed = true; @@ -338,7 +372,76 @@ fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) { // --------------------------------------------------------------------------- impl AppState { + fn onboarding_full_area(&self) -> ratatui::layout::Rect { + self.view.sidebar_rect.union(self.view.terminal_area) + } + + fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option { + let area = self.onboarding_full_area(); + let popup_w = popup_w.min(area.width.saturating_sub(4)); + let popup_h = popup_h.min(area.height.saturating_sub(2)); + if popup_w < 4 || popup_h < 4 { + return None; + } + let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h); + let block = ratatui::widgets::Block::default().borders(ratatui::widgets::Borders::ALL); + Some(block.inner(popup)) + } + + fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) { + if mouse.kind != MouseEventKind::Down(MouseButton::Left) { + return; + } + + match self.onboarding_step { + 0 => { + let Some(inner) = self.onboarding_modal_inner(64, 15) else { + return; + }; + let footer_y = inner.y + 9; + let button_x = inner.x; + let button_w = 14; + if mouse.row == footer_y + && mouse.column >= button_x + && mouse.column < button_x + button_w + { + self.onboarding_step = 1; + } + } + _ => { + let Some(inner) = self.onboarding_modal_inner(52, 10) else { + return; + }; + let options_start_y = inner.y + 2; + if mouse.row >= options_start_y && mouse.row < options_start_y + 4 { + self.onboarding_selected = (mouse.row - options_start_y) as usize; + return; + } + + let footer_y = inner.y + 6; + let back_x = inner.x; + let back_w = 10; + let save_x = inner.x + 12; + let save_w = 10; + if mouse.row == footer_y { + if mouse.column >= back_x && mouse.column < back_x + back_w { + self.onboarding_step = 0; + } else if mouse.column >= save_x && mouse.column < save_x + save_w { + self.request_complete_onboarding = true; + } + } + } + } + } + pub(crate) fn handle_mouse(&mut self, mouse: MouseEvent) { + if self.mode == Mode::Onboarding { + self.handle_onboarding_mouse(mouse); + return; + } + let sidebar = self.view.sidebar_rect; let in_sidebar = mouse.column >= sidebar.x && mouse.column < sidebar.x + sidebar.width diff --git a/src/app/mod.rs b/src/app/mod.rs index d02f68d6..f8036315 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -21,7 +21,7 @@ use crate::config::Config; use crate::events::AppEvent; use crate::workspace::Workspace; -pub use state::{AppState, Mode, ViewState, CONTEXT_MENU_ITEMS}; +pub use state::{AppState, Mode, ToastKind, ViewState, CONTEXT_MENU_ITEMS}; /// Full application: AppState + runtime concerns (event channels, async I/O). pub struct App { @@ -30,6 +30,7 @@ pub struct App { event_rx: mpsc::Receiver, no_session: bool, config_diagnostic_deadline: Option, + toast_deadline: Option, } impl App { @@ -55,7 +56,9 @@ impl App { (Vec::new(), None, 0) }; - let mode = if active.is_some() { + let mode = if config.should_show_onboarding() { + state::Mode::Onboarding + } else if active.is_some() { state::Mode::Terminal } else { state::Mode::Navigate @@ -68,7 +71,10 @@ impl App { mode, should_quit: false, request_new_workspace: false, + request_complete_onboarding: false, name_input: String::new(), + onboarding_step: 0, + onboarding_selected: 1, view: state::ViewState { sidebar_rect: Rect::default(), terminal_area: Rect::default(), @@ -81,6 +87,7 @@ impl App { update_available: None, update_dismissed: false, config_diagnostic, + toast: None, prefix_code, prefix_mods, sidebar_width: config.ui.sidebar_width, @@ -88,6 +95,7 @@ impl App { confirm_close: config.ui.confirm_close, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), + toast_config: config.ui.toast.clone(), keybinds: config.keybinds(), }; @@ -102,6 +110,7 @@ impl App { .config_diagnostic .as_ref() .map(|_| Instant::now() + Duration::from_secs(8)), + toast_deadline: None, state, event_tx, event_rx, @@ -119,6 +128,14 @@ impl App { self.state.config_diagnostic = None; } + if self + .toast_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + self.toast_deadline = None; + self.state.toast = None; + } + terminal.draw(|frame| { crate::ui::compute_view(&mut self.state, frame.area()); crate::ui::render(&self.state, frame); @@ -126,7 +143,17 @@ impl App { // Drain internal events while let Ok(ev) = self.event_rx.try_recv() { + let previous_toast = self.state.toast.clone(); self.state.handle_app_event(ev); + if self.state.toast != previous_toast { + self.toast_deadline = self.state.toast.as_ref().map(|toast| { + let duration = match toast.kind { + ToastKind::NeedsAttention => Duration::from_secs(8), + ToastKind::Finished => Duration::from_secs(5), + }; + Instant::now() + duration + }); + } } if event::poll(Duration::from_millis(16))? { @@ -141,6 +168,11 @@ impl App { } } + if self.state.request_complete_onboarding { + self.state.request_complete_onboarding = false; + self.complete_onboarding(); + } + if self.state.request_new_workspace { self.state.request_new_workspace = false; self.create_workspace(); @@ -160,6 +192,32 @@ impl App { Ok(()) } + pub(crate) fn complete_onboarding(&mut self) { + let (sound_enabled, toast_enabled) = match self.state.onboarding_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)); + } + } + } + /// Create a workspace with a real PTY (needs event_tx). fn create_workspace(&mut self) { let (rows, cols) = self.state.estimate_pane_size(); diff --git a/src/app/state.rs b/src/app/state.rs index 8ee5e67a..343d935d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1,4 +1,4 @@ -use crate::config::{Keybinds, SoundConfig}; +use crate::config::{Keybinds, SoundConfig, ToastConfig}; use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::layout::{Direction, Rect}; use ratatui::style::Color; @@ -18,6 +18,7 @@ pub struct ViewState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { + Onboarding, Navigate, Terminal, RenameSession, @@ -43,6 +44,19 @@ pub struct ContextMenuState { pub const CONTEXT_MENU_ITEMS: &[&str] = &["Rename", "Close"]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToastKind { + NeedsAttention, + Finished, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToastNotification { + pub kind: ToastKind, + pub title: String, + pub context: String, +} + /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. pub struct AppState { @@ -52,7 +66,10 @@ pub struct AppState { pub mode: Mode, pub should_quit: bool, pub request_new_workspace: bool, + pub request_complete_onboarding: bool, pub name_input: String, + pub onboarding_step: usize, + pub onboarding_selected: usize, // View geometry (computed before render, consumed by render + mouse) pub view: ViewState, pub(crate) drag: Option, @@ -62,6 +79,7 @@ pub struct AppState { pub update_available: Option, pub update_dismissed: bool, pub config_diagnostic: Option, + pub toast: Option, // Config pub prefix_code: KeyCode, pub prefix_mods: KeyModifiers, @@ -70,6 +88,7 @@ pub struct AppState { pub confirm_close: bool, pub accent: Color, pub sound: SoundConfig, + pub toast_config: ToastConfig, pub keybinds: Keybinds, } @@ -121,7 +140,10 @@ impl AppState { mode: Mode::Navigate, should_quit: false, request_new_workspace: false, + request_complete_onboarding: false, name_input: String::new(), + onboarding_step: 0, + onboarding_selected: 1, view: ViewState { sidebar_rect: Rect::default(), terminal_area: Rect::default(), @@ -134,6 +156,7 @@ impl AppState { update_available: None, update_dismissed: false, config_diagnostic: None, + toast: None, prefix_code: KeyCode::Char('b'), prefix_mods: KeyModifiers::CONTROL, sidebar_width: 26, @@ -144,6 +167,7 @@ impl AppState { enabled: false, ..SoundConfig::default() }, + toast_config: ToastConfig::default(), keybinds: Keybinds { new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()), new_workspace_label: "n".into(), diff --git a/src/config.rs b/src/config.rs index 8df9d6b5..c1195a24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,9 +6,16 @@ use tracing::warn; use crate::detect::Agent; +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct ToastConfig { + pub enabled: bool, +} + #[derive(Debug, Default, Deserialize)] #[serde(default)] pub struct Config { + pub onboarding: Option, pub keys: KeysConfig, pub ui: UiConfig, } @@ -53,6 +60,8 @@ pub struct UiConfig { /// Accent color for highlights, borders, and navigation UI. /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)). pub accent: String, + /// Optional visual toast notifications for background workspace events. + pub toast: ToastConfig, /// Play sounds when agents change state in background workspaces. pub sound: SoundConfig, } @@ -141,11 +150,18 @@ impl Default for UiConfig { sidebar_width: 26, confirm_close: true, accent: "cyan".into(), + toast: ToastConfig::default(), sound: SoundConfig::default(), } } } +impl Default for ToastConfig { + fn default() -> Self { + Self { enabled: false } + } +} + impl Default for SoundConfig { fn default() -> Self { Self { @@ -174,6 +190,10 @@ impl Default for AgentSoundOverrides { } impl Config { + pub fn should_show_onboarding(&self) -> bool { + self.onboarding.unwrap_or(true) + } + pub fn load() -> LoadedConfig { let path = config_path(); if path.exists() { @@ -470,7 +490,20 @@ pub fn parse_color(s: &str) -> ratatui::style::Color { } } -fn config_path() -> PathBuf { +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(dir) = std::env::var("XDG_CONFIG_HOME") { PathBuf::from(dir).join("herdr/config.toml") } else if let Ok(home) = std::env::var("HOME") { @@ -480,6 +513,93 @@ fn config_path() -> PathBuf { } } +fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String { + let replacement = format!("{key} = {value}"); + let mut lines: Vec = content.lines().map(|line| line.to_string()).collect(); + let mut in_section = false; + + for line in &mut lines { + let trimmed = line.trim(); + if trimmed.starts_with('[') && trimmed.ends_with(']') { + in_section = true; + continue; + } + if in_section { + continue; + } + if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) { + *line = replacement.clone(); + return lines.join("\n") + "\n"; + } + } + + if lines.is_empty() { + format!("{replacement}\n") + } else { + format!("{replacement}\n{}\n", lines.join("\n").trim_end()) + } +} + +fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String { + let header = format!("[{section}]"); + let assignment = format!("{key} = {value}"); + let lines: Vec<&str> = content.lines().collect(); + let mut result = Vec::new(); + let mut i = 0; + let mut found_section = false; + let mut inserted = false; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed == header { + found_section = true; + result.push(line.to_string()); + i += 1; + + while i < lines.len() { + let current = lines[i]; + let current_trimmed = current.trim(); + if current_trimmed.starts_with('[') && current_trimmed.ends_with(']') { + if !inserted { + result.push(assignment.clone()); + inserted = true; + } + break; + } + + if current_trimmed.starts_with(&format!("{key} ")) + || current_trimmed.starts_with(&format!("{key}=")) + { + result.push(assignment.clone()); + inserted = true; + } else { + result.push(current.to_string()); + } + i += 1; + } + + continue; + } + + result.push(line.to_string()); + i += 1; + } + + if !found_section { + if !result.is_empty() && !result.last().is_some_and(|line| line.trim().is_empty()) { + result.push(String::new()); + } + result.push(header); + result.push(assignment); + } else if found_section && !inserted { + result.push(assignment); + } + + result.join("\n") + "\n" +} + fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> { let parts: Vec<&str> = s.split('+').collect(); let mut modifiers = KeyModifiers::empty(); @@ -723,6 +843,43 @@ rename_workspace = "wat" assert_eq!(kb.rename_workspace_label, "shift+n"); } + #[test] + fn toast_config_parses() { + let toml = r#" +[ui.toast] +enabled = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.ui.toast.enabled); + } + + #[test] + fn missing_onboarding_shows_setup() { + let config = Config::default(); + assert!(config.should_show_onboarding()); + } + + #[test] + fn onboarding_false_skips_setup() { + let config: Config = toml::from_str("onboarding = false").unwrap(); + assert!(!config.should_show_onboarding()); + } + + #[test] + fn upsert_top_level_bool_replaces_existing_value() { + let content = "onboarding = true\n[keys]\nprefix = \"ctrl+b\"\n"; + let updated = upsert_top_level_bool(content, "onboarding", false); + assert!(updated.contains("onboarding = false")); + assert!(!updated.contains("onboarding = true")); + } + + #[test] + fn upsert_section_bool_adds_missing_section() { + let updated = upsert_section_bool("", "ui.toast", "enabled", true); + assert!(updated.contains("[ui.toast]")); + assert!(updated.contains("enabled = true")); + } + #[test] fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() { let toml = r#" diff --git a/src/main.rs b/src/main.rs index 4f1d9cfe..d52ddfc3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,6 +63,10 @@ fn init_logging() { const DEFAULT_CONFIG: &str = r#"# herdr configuration # Place this file at ~/.config/herdr/config.toml +# Show first-run notification setup on startup. +# Missing also shows onboarding; set false after you've chosen. +# onboarding = true + [keys] # Prefix key to enter navigate mode (default: "ctrl+b") # Examples: "ctrl+b", "f12", "esc", "-" @@ -93,6 +97,10 @@ 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 +[ui.toast] +# enabled = false + # Play sounds when agents change state in background workspaces [ui.sound] # enabled = true diff --git a/src/ui.rs b/src/ui.rs index 235be9e3..1f1b4ad0 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,6 +7,7 @@ use ratatui::{ }; use tui_term::widget::PseudoTerminal; +use crate::app::state::{ToastKind, ToastNotification}; use crate::app::{AppState, Mode}; use crate::detect::AgentState; use crate::layout::PaneInfo; @@ -60,6 +61,7 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_panes(app, frame, terminal_area); match app.mode { + Mode::Onboarding => render_onboarding_overlay(app, frame, frame.area()), Mode::Navigate => render_navigate_overlay(app, frame, terminal_area), Mode::Resize => render_resize_overlay(app, frame, terminal_area), Mode::ConfirmClose => render_confirm_close_overlay(app, frame, terminal_area), @@ -77,9 +79,13 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_update_notification(frame, terminal_area, version, app.accent); } } + let has_config_diagnostic = app.config_diagnostic.is_some(); if let Some(message) = &app.config_diagnostic { render_config_diagnostic(frame, terminal_area, message); } + if let Some(toast) = &app.toast { + render_toast_notification(frame, terminal_area, toast, has_config_diagnostic); + } } /// Compute pane layout info and resize pane runtimes to match. @@ -513,6 +519,227 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) { ); } +const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b"; + +fn dim_background(frame: &mut Frame, area: Rect) { + let buf = frame.buffer_mut(); + for y in area.y..area.y + area.height { + for x in area.x..area.x + area.width { + let cell = &mut buf[(x, y)]; + cell.set_style(cell.style().add_modifier(Modifier::DIM)); + } + } +} + +fn render_modal_shell( + frame: &mut Frame, + area: Rect, + popup_w: u16, + popup_h: u16, + accent: Color, +) -> Option { + let popup_w = popup_w.min(area.width.saturating_sub(4)); + let popup_h = popup_h.min(area.height.saturating_sub(2)); + if popup_w < 4 || popup_h < 4 { + return None; + } + + let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2; + let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2; + let popup = Rect::new(popup_x, popup_y, popup_w, popup_h); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(accent)) + .style(Style::default().bg(Color::Black)); + let inner = block.inner(popup); + frame.render_widget(Clear, popup); + frame.render_widget(block, popup); + Some(inner) +} + +fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, accent: Color) { + frame.render_widget( + Paragraph::new(Span::styled( + format!(" {title} "), + Style::default() + .fg(Color::Black) + .bg(accent) + .add_modifier(Modifier::BOLD), + )), + area, + ); +} + +fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) { + dim_background(frame, area); + + match app.onboarding_step { + 0 => render_onboarding_welcome(app, frame, area), + _ => render_onboarding_notifications(app, frame, area), + } +} + +fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(inner) = render_modal_shell(frame, area, 64, 15, app.accent) else { + return; + }; + if inner.height < 10 { + return; + } + + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas::<11>(inner); + + frame.render_widget( + Paragraph::new(" herdr").style( + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + rows[1], + ); + frame.render_widget( + Paragraph::new(" workspace manager for coding agents") + .style(Style::default().fg(Color::DarkGray)), + rows[2], + ); + + let line1 = Line::from(vec![ + Span::styled( + format!(" {}", ONBOARDING_PREFIX_LABEL), + Style::default().fg(app.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" navigate ", Style::default().fg(Color::Gray)), + Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::styled(" click sidebar ", Style::default().fg(Color::Gray)), + Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::styled(" scroll panes", Style::default().fg(Color::Gray)), + ]); + frame.render_widget(Paragraph::new(line1), rows[4]); + + let line2 = Line::from(vec![ + Span::styled( + " ↑↓", + Style::default().fg(app.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" switch workspace ", Style::default().fg(Color::Gray)), + Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::styled(" drag borders ", Style::default().fg(Color::Gray)), + Span::styled("·", Style::default().fg(Color::DarkGray)), + Span::styled( + " ⇥", + Style::default().fg(app.accent).add_modifier(Modifier::BOLD), + ), + Span::styled(" pane", Style::default().fg(Color::Gray)), + ]); + frame.render_widget(Paragraph::new(line2), rows[5]); + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" ● ", Style::default().fg(Color::Red)), + Span::styled("needs you ", Style::default().fg(Color::Gray)), + Span::styled("○ ", Style::default().fg(Color::Yellow)), + Span::styled("working ", Style::default().fg(Color::Gray)), + Span::styled("◌ ", Style::default().fg(Color::DarkGray)), + Span::styled("no agent", Style::default().fg(Color::Gray)), + ])), + rows[7], + ); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " continue ", + Style::default() + .fg(Color::Black) + .bg(app.accent) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + " readme has config and more", + Style::default().fg(Color::DarkGray), + ), + ])), + rows[9], + ); +} + +fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { + let Some(inner) = render_modal_shell(frame, area, 52, 10, app.accent) else { + return; + }; + + if inner.height < 7 { + return; + } + + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas::<7>(inner); + + render_modal_header(frame, rows[0], "choose notification style", app.accent); + frame.render_widget( + Paragraph::new(" herdr can alert you when background work needs attention or finishes.") + .style(Style::default().fg(Color::Gray)), + rows[1], + ); + + let options = [ + "quiet no sound, no visual toasts", + "visual only top-right toasts, no sound", + "sound only sound alerts, no toasts", + "both sound and visual toasts", + ]; + + for (idx, option) in options.iter().enumerate() { + let selected = idx == app.onboarding_selected; + let prefix = if selected { "›" } else { " " }; + let style = if selected { + Style::default().fg(Color::Black).bg(app.accent) + } else { + Style::default().fg(Color::White) + }; + frame.render_widget( + Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), + rows[idx + 2], + ); + } + + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" [ back ] ", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled( + " [ save ] ", + Style::default() + .fg(Color::Black) + .bg(app.accent) + .add_modifier(Modifier::BOLD), + ), + ])), + rows[6], + ); +} + /// Floating overlay for navigate mode — appears at bottom of terminal area. fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) { let key = Style::default().fg(app.accent).add_modifier(Modifier::BOLD); @@ -787,6 +1014,57 @@ fn render_update_notification(frame: &mut Frame, area: Rect, version: &str, acce ); } +fn render_toast_notification( + frame: &mut Frame, + area: Rect, + toast: &ToastNotification, + offset_for_warning: bool, +) { + let dot_color = match toast.kind { + ToastKind::NeedsAttention => Color::Red, + ToastKind::Finished => Color::Blue, + }; + let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4; + let width = content_width.saturating_add(2).min(area.width); + let height = 4u16.min(area.height); + let x = area.x + area.width.saturating_sub(width); + let y = area.y + if offset_for_warning { 1 } else { 0 }; + let toast_area = Rect::new(x, y, width, height); + + frame.render_widget(Clear, toast_area); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .style(Style::default().bg(Color::Black)); + let inner = block.inner(toast_area); + frame.render_widget(block, toast_area); + + if inner.height < 2 { + return; + } + + let [title_row, context_row] = + Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); + + let title = Line::from(vec![ + Span::styled("●", Style::default().fg(dot_color)), + Span::raw(" "), + Span::styled( + &toast.title, + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD), + ), + ]); + let context = Line::from(vec![ + Span::styled(" ", Style::default().fg(Color::DarkGray)), + Span::styled(&toast.context, Style::default().fg(Color::DarkGray)), + ]); + + frame.render_widget(Paragraph::new(title), title_row); + frame.render_widget(Paragraph::new(context), context_row); +} + /// Visual badge for a pane's state + seen flag. /// /// | State | Icon | Color |