diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/input.rs | 42 | ||||
| -rw-r--r-- | src/app/mod.rs | 61 | ||||
| -rw-r--r-- | src/pane.rs | 88 | ||||
| -rw-r--r-- | src/ui.rs | 175 |
4 files changed, 258 insertions, 108 deletions
diff --git a/src/app/input.rs b/src/app/input.rs index 9d8e5d6b..a1ad6d0c 100644 --- a/src/app/input.rs +++ b/src/app/input.rs @@ -1249,12 +1249,24 @@ impl AppState { let notes = self.release_notes.as_ref()?; let body = self.release_notes_body_rect()?; let viewport_rows = body.height.max(1) as usize; - let wrap_width = body.width.max(1) as usize; - let total_rows = notes - .body - .lines() - .map(|line| line.chars().count().max(1).div_ceil(wrap_width)) - .sum::<usize>(); + let lines = crate::ui::release_notes_lines(¬es.body, &self.palette); + + let rows_for_width = |wrap_width: usize| { + lines + .iter() + .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1))) + .sum::<usize>() + }; + + let full_width = body.width.max(1) as usize; + let mut total_rows = rows_for_width(full_width); + let wrap_width = if total_rows > viewport_rows && full_width > 1 { + body.width.saturating_sub(1).max(1) as usize + } else { + full_width + }; + total_rows = rows_for_width(wrap_width); + let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows); Some(crate::pane::ScrollMetrics { offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize), @@ -1319,12 +1331,12 @@ impl AppState { match self.onboarding_step { 0 => { - let Some(inner) = self.onboarding_modal_inner(64, 15) else { + let Some(inner) = self.onboarding_modal_inner(64, 16) else { return; }; let button = crate::ui::onboarding_welcome_continue_rect(Rect::new( inner.x, - inner.y + 9, + inner.y + 10, inner.width, 1, )); @@ -1339,10 +1351,10 @@ impl AppState { } } _ => { - let Some(inner) = self.onboarding_modal_inner(52, 10) else { + let Some(inner) = self.onboarding_modal_inner(56, 14) else { return; }; - let options_start_y = inner.y + 2; + let options_start_y = inner.y + 5; if mouse.row >= options_start_y && mouse.row < options_start_y + 4 { self.onboarding_list .select((mouse.row - options_start_y) as usize); @@ -1351,7 +1363,7 @@ impl AppState { let (back, save) = crate::ui::onboarding_notification_button_rects(Rect::new( inner.x, - inner.y + 6, + inner.y + 10, inner.width, 1, )); @@ -2884,8 +2896,8 @@ mod tests { app.state.onboarding_step = 1; app.state.onboarding_list.select(1); - let inner = app.state.onboarding_modal_inner(52, 10).unwrap(); - app.handle_mouse(mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 4)); + let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); + app.handle_mouse(mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 7)); assert_eq!(app.state.onboarding_list.selected, 1); } @@ -2897,11 +2909,11 @@ mod tests { app.state.onboarding_step = 1; app.state.onboarding_list.select(0); - let inner = app.state.onboarding_modal_inner(52, 10).unwrap(); + let inner = app.state.onboarding_modal_inner(56, 14).unwrap(); app.handle_mouse(mouse( MouseEventKind::Down(MouseButton::Left), inner.x + 2, - inner.y + 4, + inner.y + 7, )); assert_eq!(app.state.onboarding_list.selected, 2); diff --git a/src/app/mod.rs b/src/app/mod.rs index 202fa8c3..3b744d02 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2085,6 +2085,7 @@ fn agent_name(agent: crate::detect::Agent) -> String { mod tests { use super::*; use crate::config::Config; + use crate::detect::{Agent, AgentState}; use crate::workspace::Workspace; fn test_app() -> App { @@ -2152,4 +2153,64 @@ mod tests { assert_eq!(ws_idx, 1); assert_eq!(seed_cwd, std::path::PathBuf::from("/tmp/pion")); } + + #[tokio::test] + async fn full_internal_event_queue_eventually_applies_working_to_idle_transition() { + let mut app = test_app(); + let ws = Workspace::test_new("test"); + let pane_id = ws.tabs[0].root_pane; + + app.state.workspaces = vec![ws]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Working, + }); + assert_eq!( + app.state.workspaces[0].pane_state(pane_id).unwrap().state, + AgentState::Working + ); + + for i in 0..64 { + app.event_tx + .try_send(AppEvent::UpdateReady { + version: format!("9.9.{i}"), + }) + .unwrap(); + } + + let tx = app.event_tx.clone(); + let send = tx.send(AppEvent::StateChanged { + pane_id, + agent: Some(Agent::Pi), + state: AgentState::Idle, + }); + tokio::pin!(send); + + let blocked = + tokio::time::timeout(Duration::from_millis(20), async { (&mut send).await }).await; + assert!( + blocked.is_err(), + "state change sender should wait for queue space instead of failing" + ); + + app.drain_internal_events(); + + tokio::time::timeout(Duration::from_millis(50), async { (&mut send).await }) + .await + .expect("state change should enqueue once queue space is available") + .expect("app event receiver should still be alive"); + + app.drain_internal_events(); + + assert_eq!( + app.state.workspaces[0].pane_state(pane_id).unwrap().state, + AgentState::Idle, + "Working→Idle should still apply after temporary queue pressure" + ); + } } diff --git a/src/pane.rs b/src/pane.rs index 01270005..45b0358d 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -39,6 +39,31 @@ fn active_pending_release( } } +async fn publish_state_changed_event( + state_events: mpsc::Sender<AppEvent>, + pane_id: PaneId, + agent: Option<Agent>, + state: AgentState, +) { + // This runs on the async detector task, not the PTY reader thread. + // Waiting for queue space here preserves correctness-critical state transitions + // without blocking pane I/O. + if let Err(e) = state_events + .send(AppEvent::StateChanged { + pane_id, + agent, + state, + }) + .await + { + warn!( + pane = pane_id.raw(), + err = %e, + "failed to deliver StateChanged event" + ); + } +} + fn stabilize_agent_state( agent: Option<Agent>, previous: AgentState, @@ -637,17 +662,13 @@ impl PaneRuntime { "state changed" ); state = new_state; - if let Err(e) = state_events.try_send(AppEvent::StateChanged { + publish_state_changed_event( + state_events.clone(), pane_id, agent, - state: new_state, - }) { - warn!( - pane = pane_id.raw(), - err = %e, - "dropped StateChanged event" - ); - } + new_state, + ) + .await; } } }); @@ -987,4 +1008,53 @@ mod tests { assert_eq!(pane.fallback_state, AgentState::Unknown); assert_eq!(pane.state, AgentState::Unknown); } + + #[tokio::test] + async fn state_changed_event_waits_for_queue_space_instead_of_dropping() { + let (tx, mut rx) = mpsc::channel(1); + let pane_id = PaneId::from_raw(42); + + tx.try_send(AppEvent::UpdateReady { + version: "9.9.9".into(), + }) + .unwrap(); + + let publish = + publish_state_changed_event(tx.clone(), pane_id, Some(Agent::Pi), AgentState::Idle); + tokio::pin!(publish); + + let blocked = tokio::time::timeout(std::time::Duration::from_millis(20), async { + (&mut publish).await; + }) + .await; + assert!( + blocked.is_err(), + "publisher should wait for queue space instead of dropping StateChanged" + ); + + let first = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()) + .await + .expect("queue should yield first event") + .expect("sender still alive"); + assert!(matches!(first, AppEvent::UpdateReady { .. })); + + tokio::time::timeout(std::time::Duration::from_millis(50), async { + (&mut publish).await; + }) + .await + .expect("publisher should complete once queue space is available"); + + let second = tokio::time::timeout(std::time::Duration::from_millis(50), rx.recv()) + .await + .expect("queue should yield second event") + .expect("sender still alive"); + assert!(matches!( + second, + AppEvent::StateChanged { + pane_id: delivered_pane, + agent: Some(Agent::Pi), + state: AgentState::Idle, + } if delivered_pane == pane_id + )); + } } @@ -1188,10 +1188,12 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { }) .unwrap_or(body_area); - let body = Paragraph::new(render_release_notes_lines( - notes.body.as_str(), - &app.palette, - )) + let body = Paragraph::new( + release_notes_lines(notes.body.as_str(), &app.palette) + .into_iter() + .map(|(_, line)| line) + .collect::<Vec<_>>(), + ) .wrap(Wrap { trim: false }) .scroll((notes.scroll, 0)); frame.render_widget(body, text_area); @@ -1218,39 +1220,52 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) { ); } -fn render_release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<Line<'a>> { +pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> { let mut lines = Vec::new(); for raw in body.lines() { let trimmed = raw.trim_end(); if trimmed.is_empty() { - lines.push(Line::raw("")); + lines.push((0, Line::raw(""))); continue; } if let Some(rest) = trimmed.strip_prefix("### ") { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - rest.to_lowercase(), - Style::default().fg(p.accent).add_modifier(Modifier::BOLD), - ), - ])); + let text = rest.to_lowercase(); + let width = 1 + text.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::raw(" "), + Span::styled( + text, + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), + ), + ]), + )); continue; } if let Some(rest) = trimmed.strip_prefix("- ") { - lines.push(Line::from(vec![ - Span::styled(" • ", Style::default().fg(p.accent)), - Span::styled(rest.to_string(), Style::default().fg(p.text)), - ])); + let width = 3 + rest.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::styled(" • ", Style::default().fg(p.accent)), + Span::styled(rest.to_string(), Style::default().fg(p.text)), + ]), + )); continue; } - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(trimmed.to_string(), Style::default().fg(p.text)), - ])); + let width = 1 + trimmed.chars().count(); + lines.push(( + width, + Line::from(vec![ + Span::raw(" "), + Span::styled(trimmed.to_string(), Style::default().fg(p.text)), + ]), + )); } lines @@ -1298,7 +1313,7 @@ pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) { }, ActionButtonSpec { hint: Some("↵"), - label: "save", + label: "start", }, ], 2, @@ -1308,10 +1323,10 @@ pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) { } fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 64, 15, &app.palette) else { + let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else { return; }; - if inner.height < 10 { + if inner.height < 11 { return; } @@ -1326,9 +1341,10 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), Constraint::Min(0), ]) - .areas::<11>(inner); + .areas::<12>(inner); frame.render_widget( Paragraph::new(" herdr").style( @@ -1339,62 +1355,53 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { rows[1], ); frame.render_widget( - Paragraph::new(" workspace manager for coding agents") + Paragraph::new(" terminal workspace manager for coding agents") .style(Style::default().fg(app.palette.overlay0)), rows[2], ); - let line1 = Line::from(vec![ - Span::styled( - format!(" {}", ONBOARDING_PREFIX_LABEL), - Style::default() - .fg(app.palette.accent) - .add_modifier(Modifier::BOLD), - ), - Span::styled(" navigate ", Style::default().fg(app.palette.overlay1)), - Span::styled("·", Style::default().fg(app.palette.overlay0)), - Span::styled(" click sidebar ", Style::default().fg(app.palette.overlay1)), - Span::styled("·", Style::default().fg(app.palette.overlay0)), - Span::styled(" scroll panes", Style::default().fg(app.palette.overlay1)), - ]); - frame.render_widget(Paragraph::new(line1), rows[4]); + frame.render_widget( + Paragraph::new(" this is a mouse-first terminal.") + .style(Style::default().fg(app.palette.overlay1)), + rows[4], + ); + frame.render_widget( + Paragraph::new(" click the sidebar to switch workspaces, drag pane") + .style(Style::default().fg(app.palette.overlay1)), + rows[5], + ); + frame.render_widget( + Paragraph::new(" borders to resize, right-click for context menus.") + .style(Style::default().fg(app.palette.overlay1)), + rows[6], + ); - let line2 = Line::from(vec![ + let key_line = Line::from(vec![ + Span::styled(" ", Style::default()), Span::styled( - " ↑↓", + ONBOARDING_PREFIX_LABEL, Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD), ), Span::styled( - " switch workspace ", + " enters navigate mode · ", Style::default().fg(app.palette.overlay1), ), - Span::styled("·", Style::default().fg(app.palette.overlay0)), - Span::styled(" drag borders ", Style::default().fg(app.palette.overlay1)), - Span::styled("·", Style::default().fg(app.palette.overlay0)), Span::styled( - " ⇥", + "?", Style::default() .fg(app.palette.accent) .add_modifier(Modifier::BOLD), ), - Span::styled(" pane", Style::default().fg(app.palette.overlay1)), + Span::styled( + " shows keybinds and settings", + Style::default().fg(app.palette.overlay1), + ), ]); - frame.render_widget(Paragraph::new(line2), rows[5]); + frame.render_widget(Paragraph::new(key_line), rows[8]); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" ● ", Style::default().fg(app.palette.red)), - Span::styled("needs you ", Style::default().fg(app.palette.overlay1)), - Span::styled("○ ", Style::default().fg(app.palette.yellow)), - Span::styled("working ", Style::default().fg(app.palette.overlay1)), - Span::styled("◌ ", Style::default().fg(app.palette.overlay0)), - Span::styled("no agent", Style::default().fg(app.palette.overlay1)), - ])), - rows[7], - ); - let continue_rect = onboarding_welcome_continue_rect(rows[9]); + let continue_rect = onboarding_welcome_continue_rect(rows[10]); render_action_button( frame, continue_rect, @@ -1405,24 +1412,14 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) { .bg(app.palette.accent) .add_modifier(Modifier::BOLD), ); - frame.render_widget( - Paragraph::new(" readme has config and more") - .style(Style::default().fg(app.palette.overlay0)), - Rect::new( - continue_rect.x + continue_rect.width + 2, - rows[9].y, - rows[9].width.saturating_sub(continue_rect.width + 2), - 1, - ), - ); } fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) { - let Some(inner) = render_modal_shell(frame, area, 52, 10, &app.palette) else { + let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else { return; }; - if inner.height < 7 { + if inner.height < 11 { return; } @@ -1434,21 +1431,31 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect 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::<7>(inner); + .areas::<12>(inner); - render_modal_header(frame, rows[0], "choose notification style", &app.palette); + render_modal_header(frame, rows[0], "notification style", &app.palette); frame.render_widget( - Paragraph::new(" herdr can alert you when background work needs attention or finishes.") + Paragraph::new(" herdr watches background panes and can alert you") .style(Style::default().fg(app.palette.overlay1)), - rows[1], + rows[2], + ); + frame.render_widget( + Paragraph::new(" when agents finish or need attention.") + .style(Style::default().fg(app.palette.overlay1)), + rows[3], ); 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", + "quiet no interruptions", + "visual only top-right toasts", + "sound only sound alerts", + "both sound and toasts", ]; for (idx, option) in options.iter().enumerate() { @@ -1463,11 +1470,11 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect }; frame.render_widget( Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style), - rows[idx + 2], + rows[idx + 5], ); } - let (back_rect, save_rect) = onboarding_notification_button_rects(rows[6]); + let (back_rect, save_rect) = onboarding_notification_button_rects(rows[10]); render_action_button( frame, back_rect, @@ -1482,7 +1489,7 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect frame, save_rect, Some("↵"), - "save", + "start", Style::default() .fg(app.palette.panel_bg) .bg(app.palette.accent) |
