aboutsummaryrefslogtreecommitdiff
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
parentd05ba2ea9f1ab2379f5be5ef7fc3898a03f5dce1 (diff)
feat: add onboarding and toast notification preferences
-rw-r--r--CONFIGURATION.md35
-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
-rw-r--r--src/config.rs159
-rw-r--r--src/main.rs8
-rw-r--r--src/ui.rs278
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<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(),
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<bool>,
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<String> = 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();
@@ -724,6 +844,43 @@ rename_workspace = "wat"
}
#[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#"
[keys]
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<Rect> {
+ 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 |