aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 21:13:41 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-28 21:13:41 +0300
commitfa1adf1a26033fc50a2920d96045e76aba93bddb (patch)
tree383bd48d0c6bcad7ccf1d5c907f78fd7eee1797f /src/app
parentd05ba2ea9f1ab2379f5be5ef7fc3898a03f5dce1 (diff)
feat: add onboarding and toast notification preferences
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs115
-rw-r--r--src/app/input.rs103
-rw-r--r--src/app/mod.rs62
-rw-r--r--src/app/state.rs26
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(),