feat: add onboarding and toast notification preferences

This commit is contained in:
Ogulcan Celik 2026-03-28 21:13:41 +03:00
parent d05ba2ea9f
commit fa1adf1a26
8 changed files with 781 additions and 5 deletions

View File

@ -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

View File

@ -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;
@ -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"]);

View File

@ -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

View File

@ -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();

View File

@ -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(),

View File

@ -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();
@ -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#"

View File

@ -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

278
src/ui.rs
View File

@ -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 |