diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 21:13:41 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-03-28 21:13:41 +0300 |
| commit | fa1adf1a26033fc50a2920d96045e76aba93bddb (patch) | |
| tree | 383bd48d0c6bcad7ccf1d5c907f78fd7eee1797f /src/app | |
| parent | d05ba2ea9f1ab2379f5be5ef7fc3898a03f5dce1 (diff) | |
feat: add onboarding and toast notification preferences
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/actions.rs | 115 | ||||
| -rw-r--r-- | src/app/input.rs | 103 | ||||
| -rw-r--r-- | src/app/mod.rs | 62 | ||||
| -rw-r--r-- | src/app/state.rs | 26 |
4 files changed, 302 insertions, 4 deletions
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<ToastKind> { + 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; @@ -434,6 +488,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"]); state.workspaces[0].test_split(Direction::Horizontal); 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<ratatui::layout::Rect> { + 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<AppEvent>, no_session: bool, config_diagnostic_deadline: Option<Instant>, + toast_deadline: Option<Instant>, } 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<DragState>, @@ -62,6 +79,7 @@ pub struct AppState { pub update_available: Option<String>, pub update_dismissed: bool, pub config_diagnostic: Option<String>, + pub toast: Option<ToastNotification>, // 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(), |
