feat: add onboarding and toast notification preferences
This commit is contained in:
parent
d05ba2ea9f
commit
fa1adf1a26
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
103
src/app/input.rs
103
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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
159
src/config.rs
159
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();
|
||||
|
|
@ -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#"
|
||||
|
|
|
|||
|
|
@ -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
278
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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue