diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 3f540a6..8ac67ca 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -14,23 +14,28 @@ use ratatui::Frame; #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, + /// #83: what the daemon's watcher last saw in the worktree, phrased by + /// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused" + /// is visible before the attempt. `None` while nothing blocks. + pub blockers: Option, } impl KillConfirm { pub fn render(&self, f: &mut Frame, parent: Rect) { - let area = centered(parent, 50, 5); + let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 }); f.render_widget(Clear, area); let block = Block::default().title(" kill ").borders(Borders::ALL); let inner = block.inner(area); f.render_widget(block, area); - f.render_widget( - Paragraph::new(vec![ - Line::from(format!("Kill session {}?", self.label)), - Line::from(""), - Line::from("[y] yes [n] no"), - ]), - inner, - ); + let mut lines = vec![Line::from(format!("Kill session {}?", self.label))]; + if let Some(blockers) = &self.blockers { + // Same phrasing the reclaim confirmation uses for its own guard. + lines.push(Line::from(format!("Currently in the way: {blockers}"))); + lines.push(Line::from("The kill will be refused unless forced.")); + } + lines.push(Line::from("")); + lines.push(Line::from("[y] yes [n] no")); + f.render_widget(Paragraph::new(lines), inner); } } @@ -121,6 +126,7 @@ mod tests { fn kill_confirm_renders_prompt() { let modal = KillConfirm { label: "session-7".into(), + blockers: None, }; let rendered = render_to_string(60, 10, |f| modal.render(f, f.area())); assert!(rendered.contains("Kill")); @@ -129,6 +135,22 @@ mod tests { assert!(rendered.contains("no")); } + /// #83: when the worktree blocks, the confirm names it before the + /// operator commits — in `InTheWay`'s own words. + #[test] + fn kill_confirm_names_what_blocks() { + let modal = KillConfirm { + label: "session-7".into(), + blockers: Some("2 uncommitted files".into()), + }; + let rendered = render_to_string(70, 10, |f| modal.render(f, f.area())); + assert!( + rendered.contains("Currently in the way: 2 uncommitted files"), + "{rendered}" + ); + assert!(rendered.contains("refused"), "{rendered}"); + } + #[test] fn help_overlay_lists_lines() { let help = HelpOverlay { diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 01e3c9b..12673cb 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -146,8 +146,19 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { match inputs.state.overlay() { Some(OverlayKind::KillConfirm(id)) => { + // #83: surface what the watcher last saw in the worktree, in + // InTheWay's own words, before the operator commits to the kill. + let blockers = inputs + .sidebar + .sessions + .iter() + .find(|s| &s.id == id) + .and_then(|s| s.blocked_work) + .filter(|b| !b.is_clear()) + .map(|b| b.to_string()); let kc = KillConfirm { label: id.to_string(), + blockers, }; kc.render(f, area); map.modal = Some(area); diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index ea9b566..724eff0 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -52,6 +52,13 @@ impl Sidebar { (SessionStatus::Running, false) => "○", }; let label = name_for(s); + // #83: a session whose worktree blocks a kill gets a warning + // glyph; the kill confirm names the specifics. + let blocked = s + .blocked_work + .filter(|b| !b.is_clear()) + .map(|_| " ⚠") + .unwrap_or_default(); let style = if self.highlight == Some(s.id) { Style::default().add_modifier(Modifier::REVERSED) } else { @@ -59,7 +66,7 @@ impl Sidebar { }; lines.push(Line::from(vec![ Span::raw(" "), - Span::styled(format!("{glyph} {label}"), style), + Span::styled(format!("{glyph} {label}{blocked}"), style), ])); } f.render_widget(Paragraph::new(lines), inner); @@ -173,6 +180,40 @@ mod tests { assert!(line.contains('✗'), "expected dead glyph in: {line:?}"); } + /// #83: a session whose worktree blocks a kill carries the warning glyph; + /// a clear or unprobed one does not. + #[test] + fn blocked_session_gets_a_warning_glyph() { + use maestro_protocol::InTheWay; + + let mut blocked = session_named(1, "claude"); + blocked.blocked_work = Some(InTheWay { + unpushed_commits: 4, + ..InTheWay::default() + }); + let mut clear = session_named(2, "bash"); + clear.blocked_work = Some(InTheWay::default()); + let unprobed = session_named(3, "vim"); + let sb = Sidebar { + sessions: vec![blocked, clear, unprobed], + highlight: None, + attached: Default::default(), + }; + let rendered = render_to_string(&sb, 40, 8); + let line = rendered + .lines() + .find(|l| l.contains("claude")) + .expect("blocked row"); + assert!(line.contains('⚠'), "expected warning glyph in: {line:?}"); + for name in ["bash", "vim"] { + let line = rendered + .lines() + .find(|l| l.contains(name)) + .expect("clear row"); + assert!(!line.contains('⚠'), "{name} must not warn: {line:?}"); + } + } + #[test] fn falls_back_to_command_when_name_metadata_is_absent() { let s = Session {