feat(maestro): warn on kill and mark blocked sessions in the TUI (#83)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user