aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 16:27:25 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 16:27:25 +0300
commit282fb654649ea89cd9b22b1dcf43e435a18c7179 (patch)
treef63ba651d5bc508c9ac67490cef9a9f30f2922bf /src
parent37581b1dd8dea770459699b7532644d53c82cede (diff)
fix onboarding spacing and preserve pane state transitions
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs42
-rw-r--r--src/app/mod.rs61
-rw-r--r--src/pane.rs88
-rw-r--r--src/ui.rs175
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(&notes.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
+ ));
+ }
}
diff --git a/src/ui.rs b/src/ui.rs
index bf6c075a..c708c7fb 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -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)