feat(maestro): warn on kill and mark blocked sessions in the TUI (#83)

This commit is contained in:
Miguel Palhas
2026-08-24 13:08:13 +01:00
parent 524a848fc6
commit 6161cabe77
3 changed files with 84 additions and 10 deletions
+31 -9
View File
@@ -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<String>,
}
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 {
+11
View File
@@ -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);
+42 -1
View File
@@ -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 {