use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use super::{Action, Context, Result, View};
use crate::domain::TaskState;
use crate::review::{Change, DiffLine, FileChange, FileDiff, Reviewer};
use crate::tmux::Tmux;
enum Mode {
Browsing,
Committing { message: String },
Rejecting { instructions: String },
}
impl Mode {
fn field(&self) -> Option<(&str, &str)> {
match self {
Mode::Browsing => None,
Mode::Committing { message } => {
Some((message, "Commit message — enter to commit, esc to cancel"))
}
Mode::Rejecting { instructions } => Some((
instructions,
"Send back to the agent — enter to send, esc to cancel",
)),
}
}
}
pub struct ReviewView {
task_id: i64,
files: Vec<FileChange>,
list: ListState,
diff: Option<FileDiff>,
diff_key: Option<(usize, bool)>,
show_staged: bool,
scroll: u16,
mode: Mode,
loaded: bool,
unavailable: Option<String>,
tmux: Tmux,
}
impl ReviewView {
pub fn new(task_id: i64) -> Self {
Self {
task_id,
files: Vec::new(),
list: ListState::default().with_selected(Some(0)),
diff: None,
diff_key: None,
show_staged: false,
scroll: 0,
mode: Mode::Browsing,
loaded: false,
unavailable: None,
tmux: Tmux::new(),
}
}
#[cfg(test)]
pub(crate) fn with_tmux(mut self, tmux: Tmux) -> Self {
self.tmux = tmux;
self
}
fn reload(&mut self, ctx: &mut Context) -> Result<()> {
let task = ctx.store.get_task(self.task_id)?;
let review = Reviewer::review(ctx.store, &task)?;
self.unavailable = (!review.is_provisioned())
.then(|| format!("task {} has no worktrees yet", self.task_id));
let selected = self
.selected()
.map(|f| (f.worktree.clone(), f.path.clone()));
self.files = review.files;
let index = selected
.and_then(|(wt, path)| {
self.files
.iter()
.position(|f| f.worktree == wt && f.path == path)
})
.unwrap_or_else(|| self.list.selected().unwrap_or(0));
self.list
.select(Some(index.min(self.files.len().saturating_sub(1))));
self.diff_key = None;
self.loaded = true;
Ok(())
}
pub fn selected(&self) -> Option<&FileChange> {
self.list.selected().and_then(|i| self.files.get(i))
}
pub fn staged_count(&self) -> usize {
self.files.iter().filter(|f| f.staged).count()
}
fn move_by(&mut self, delta: isize) {
if self.files.is_empty() {
return;
}
let current = self.list.selected().unwrap_or(0) as isize;
let last = self.files.len() as isize - 1;
self.list
.select(Some(current.saturating_add(delta).clamp(0, last) as usize));
self.scroll = 0;
}
fn ensure_diff(&mut self) {
let Some(index) = self.list.selected() else {
return;
};
let key = (index, self.show_staged);
if self.diff_key == Some(key) {
return;
}
self.diff = self
.files
.get(index)
.and_then(|file| Reviewer::file_diff(file, self.show_staged).ok());
self.diff_key = Some(key);
}
fn toggle_stage(&mut self, ctx: &mut Context) -> Result<()> {
let Some(file) = self.selected().cloned() else {
return Ok(());
};
if file.staged {
Reviewer::unstage(&file)?;
} else {
Reviewer::stage(&file)?;
}
self.reload(ctx)
}
fn commit(&mut self, ctx: &mut Context, message: &str) -> Result<Action> {
let task = ctx.store.get_task(self.task_id)?;
match Reviewer::commit(ctx.store, &task, message, chrono::Utc::now()) {
Ok(done) => {
let commits = done
.commits
.iter()
.map(|c| c.commit.as_str())
.collect::<Vec<_>>()
.join(", ");
ctx.say(format!("committed {commits}"));
Ok(Action::Pop)
}
Err(err) => {
ctx.say(err.to_string());
self.mode = Mode::Browsing;
Ok(Action::None)
}
}
}
fn reject(&mut self, ctx: &mut Context, instructions: &str) -> Result<Action> {
let task = ctx.store.get_task(self.task_id)?;
match Reviewer::reject(
ctx.store,
&self.tmux,
&task,
instructions,
chrono::Utc::now(),
) {
Ok(()) => {
ctx.say(format!("task {} is working again", self.task_id));
Ok(Action::Pop)
}
Err(err) => {
ctx.say(err.to_string());
self.mode = Mode::Browsing;
Ok(Action::None)
}
}
}
}
impl View for ReviewView {
fn title(&self) -> String {
format!(
"Review task {} — {} changed, {} staged",
self.task_id,
self.files.len(),
self.staged_count()
)
}
fn render(&mut self, frame: &mut Frame, area: Rect, ctx: &mut Context) {
if !self.loaded {
if let Err(err) = self.reload(ctx) {
self.unavailable = Some(err.to_string());
self.loaded = true;
}
}
self.ensure_diff();
let [body, message_area] = if self.mode.field().is_some() {
Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).areas(area)
} else {
[area, Rect::new(0, 0, 0, 0)]
};
if self.files.is_empty() {
let message = match &self.unavailable {
Some(reason) => format!(" Nothing to review: {reason}"),
None => " The agent changed nothing.".to_string(),
};
frame.render_widget(
Paragraph::new(vec![Line::from(""), Line::from(message)]),
body,
);
return;
}
let [left, right] =
Layout::horizontal([Constraint::Percentage(34), Constraint::Min(20)]).areas(body);
let items: Vec<ListItem> = self
.files
.iter()
.map(|file| {
ListItem::new(Line::from(vec![
Span::styled(
if file.staged { "[x] " } else { "[ ] " },
Style::default().fg(Color::Green),
),
Span::styled(
format!("{} ", change_mark(file.change)),
change_style(file.change),
),
Span::raw(file.path.clone()),
]))
})
.collect();
frame.render_stateful_widget(
List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().add_modifier(Modifier::DIM))
.title("Files"),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)),
left,
&mut self.list,
);
let side = if self.show_staged {
"staged"
} else {
"working tree"
};
let diff_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().add_modifier(Modifier::DIM))
.title(format!("Diff ({side})"));
let lines: Vec<Line> = match &self.diff {
Some(diff) if diff.binary => vec![Line::from(Span::styled(
" binary file",
Style::default().add_modifier(Modifier::DIM),
))],
Some(diff) if diff.hunks.is_empty() => vec![Line::from(Span::styled(
format!(" nothing on the {side} side"),
Style::default().add_modifier(Modifier::DIM),
))],
Some(diff) => diff_lines(diff),
None => vec![Line::from(" …")],
};
frame.render_widget(
Paragraph::new(lines)
.block(diff_block)
.scroll((self.scroll, 0)),
right,
);
if let Some((text, title)) = self.mode.field() {
frame.render_widget(
Paragraph::new(format!("{text}▌")).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta))
.title(title.to_string()),
),
message_area,
);
}
}
fn handle_key(&mut self, key: KeyEvent, ctx: &mut Context) -> Result<Action> {
match &mut self.mode {
Mode::Browsing => {}
Mode::Committing { message: text } | Mode::Rejecting { instructions: text } => {
match key.code {
KeyCode::Esc => self.mode = Mode::Browsing,
KeyCode::Enter => {
let typed = text.clone();
let was = std::mem::replace(&mut self.mode, Mode::Browsing);
return match was {
Mode::Rejecting { .. } => self.reject(ctx, &typed),
_ => self.commit(ctx, &typed),
};
}
KeyCode::Char(c) => text.push(c),
KeyCode::Backspace => {
text.pop();
}
_ => {}
}
return Ok(Action::None);
}
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => return Ok(Action::Pop),
KeyCode::Char('j') | KeyCode::Down => self.move_by(1),
KeyCode::Char('k') | KeyCode::Up => self.move_by(-1),
KeyCode::Char(' ') | KeyCode::Char('s') => self.toggle_stage(ctx)?,
KeyCode::Char('a') => {
let task = ctx.store.get_task(self.task_id)?;
Reviewer::stage_all(ctx.store, &task)?;
self.reload(ctx)?;
ctx.say("staged everything");
}
KeyCode::Tab => {
self.show_staged = !self.show_staged;
self.scroll = 0;
}
KeyCode::Char('r') => self.reload(ctx)?,
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll = self.scroll.saturating_add(10);
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll = self.scroll.saturating_sub(10);
}
KeyCode::Char('c') => {
if self.staged_count() == 0 {
ctx.say("nothing staged — press a or space first");
} else {
self.mode = Mode::Committing {
message: String::new(),
};
}
}
KeyCode::Char('x') => {
let state = ctx.store.get_task(self.task_id)?.state;
if state == TaskState::AwaitingReview {
self.mode = Mode::Rejecting {
instructions: String::new(),
};
} else {
ctx.say(format!(
"task {} is {state}, not awaiting review",
self.task_id
));
}
}
_ => {}
}
Ok(Action::None)
}
fn keys(&self) -> Vec<(&'static str, &'static str)> {
match self.mode {
Mode::Committing { .. } => vec![("↵", "commit"), ("esc", "cancel")],
Mode::Rejecting { .. } => vec![("↵", "send"), ("esc", "cancel")],
Mode::Browsing => vec![
("space", "stage"),
("a", "stage all"),
("tab", "side"),
("c", "commit"),
("x", "reject"),
("q", "back"),
],
}
}
fn captures_input(&self) -> bool {
self.mode.field().is_some()
}
}
fn change_mark(change: Change) -> char {
match change {
Change::Added => 'A',
Change::Modified => 'M',
Change::Deleted => 'D',
Change::Renamed => 'R',
Change::Copied => 'C',
Change::Untracked => '?',
Change::Other => '·',
}
}
fn change_style(change: Change) -> Style {
let colour = match change {
Change::Added | Change::Untracked => Color::Green,
Change::Deleted => Color::Red,
Change::Renamed | Change::Copied => Color::Cyan,
_ => Color::Yellow,
};
Style::default().fg(colour)
}
fn diff_lines(diff: &FileDiff) -> Vec<Line<'static>> {
let mut lines = Vec::new();
for hunk in &diff.hunks {
lines.push(Line::from(Span::styled(
hunk.header.clone(),
Style::default().fg(Color::Cyan),
)));
for line in &hunk.lines {
let (prefix, text, style) = match line {
DiffLine::Added(text) => ("+", text, Style::default().fg(Color::Green)),
DiffLine::Removed(text) => ("-", text, Style::default().fg(Color::Red)),
DiffLine::Context(text) => (" ", text, Style::default()),
DiffLine::Note(text) => ("", text, Style::default().add_modifier(Modifier::DIM)),
};
lines.push(Line::from(Span::styled(format!("{prefix}{text}"), style)));
}
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::{Repo, Task, TaskState};
use crate::git;
use crate::git::testing::init_repo;
use crate::store::{Store, Transition};
use crate::tmux::testing::TestServer;
use crate::tui::testing::{press, render_view};
use chrono::{DateTime, Utc};
use ratatui::crossterm::event::KeyModifiers;
use std::path::PathBuf;
use tempfile::TempDir;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::from_timestamp(secs, 0).unwrap()
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
struct Fixture {
_tmp: TempDir,
repos_dir: PathBuf,
tasks_dir: PathBuf,
store: Store,
}
impl Fixture {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let repos_dir = tmp.path().join("repos");
std::fs::create_dir_all(&repos_dir).unwrap();
Self {
tasks_dir: tmp.path().join("tasks"),
repos_dir,
store: Store::open_in_memory().unwrap(),
_tmp: tmp,
}
}
fn repo(&self, name: &str) -> Repo {
let path = self.repos_dir.join(name);
init_repo(&path, "main");
self.store.upsert_repo(&path, name, at(0)).unwrap()
}
fn reviewable(&mut self, repos: &[Repo]) -> Task {
let ids: Vec<i64> = repos.iter().map(|r| r.id).collect();
let task = self
.store
.create_task("Fix it", "p", &self.tasks_dir, &ids, at(0))
.unwrap();
std::fs::create_dir_all(&task.workspace_dir).unwrap();
for repo in repos {
let wt = task.workspace_dir.join(&repo.name);
git::worktree_add(&repo.path, &wt, &format!("t{}", task.id), "main").unwrap();
self.store
.record_worktree(task.id, repo.id, &wt, &format!("t{}", task.id), "main")
.unwrap();
}
self.store
.transition(task.id, TaskState::Running, Transition::Plain, at(1))
.unwrap();
self.store
.transition(task.id, TaskState::AwaitingReview, Transition::Plain, at(2))
.unwrap();
self.store.get_task(task.id).unwrap()
}
fn worktree(&self, task: &Task, repo: &Repo) -> PathBuf {
task.workspace_dir.join(&repo.name)
}
}
#[test]
fn a_task_that_changed_nothing_says_so() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
let mut view = ReviewView::new(task.id);
let screen = render_view(&mut view, &mut fx.store, 80, 12);
assert!(
screen.iter().any(|l| l.contains("changed nothing")),
"{screen:?}"
);
}
#[test]
fn a_task_with_no_worktrees_says_that_instead() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let ids = [repo.id];
let task = fx
.store
.create_task("Not started", "p", &fx.tasks_dir, &ids, at(0))
.unwrap();
let mut view = ReviewView::new(task.id);
let screen = render_view(&mut view, &mut fx.store, 80, 12);
assert!(
screen.iter().any(|l| l.contains("Nothing to review")),
"must not claim the agent produced nothing: {screen:?}"
);
assert!(
!screen.iter().any(|l| l.contains("changed nothing")),
"{screen:?}"
);
assert!(view.loaded, "a failed load must not repeat every frame");
}
#[test]
fn changed_files_are_listed_with_their_status() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
let wt = fx.worktree(&task, &repo);
std::fs::write(wt.join("README.md"), "changed\n").unwrap();
std::fs::write(wt.join("new.rs"), "fn new() {}\n").unwrap();
let mut view = ReviewView::new(task.id);
let screen = render_view(&mut view, &mut fx.store, 100, 14);
assert!(
screen.iter().any(|l| l.contains("[ ] M README.md")),
"{screen:?}"
);
assert!(
screen.iter().any(|l| l.contains("[ ] ? new.rs")),
"{screen:?}"
);
}
#[test]
fn the_diff_of_the_selected_file_is_shown() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(
fx.worktree(&task, &repo).join("README.md"),
"brand new line\n",
)
.unwrap();
let mut view = ReviewView::new(task.id);
let screen = render_view(&mut view, &mut fx.store, 110, 16);
assert!(
screen.iter().any(|l| l.contains("+brand new line")),
"the added line should be visible: {screen:?}"
);
}
#[test]
fn an_untracked_file_shows_its_contents_as_additions() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(
fx.worktree(&task, &repo).join("added.rs"),
"fn brand_new() {}\n",
)
.unwrap();
let mut view = ReviewView::new(task.id);
let screen = render_view(&mut view, &mut fx.store, 110, 16);
assert!(
screen.iter().any(|l| l.contains("+fn brand_new() {}")),
"a new file is most of what an agent produces: {screen:?}"
);
}
#[test]
fn space_stages_and_unstages_the_selected_file() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
assert_eq!(view.staged_count(), 0);
press(&mut view, &mut fx.store, key(KeyCode::Char(' ')));
assert_eq!(view.staged_count(), 1);
press(&mut view, &mut fx.store, key(KeyCode::Char(' ')));
assert_eq!(view.staged_count(), 0, "space must toggle both ways");
}
#[test]
fn the_cursor_stays_put_when_staging_reorders_nothing() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
let wt = fx.worktree(&task, &repo);
std::fs::write(wt.join("a.txt"), "a\n").unwrap();
std::fs::write(wt.join("b.txt"), "b\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('j')));
let before = view.selected().map(|f| f.path.clone());
press(&mut view, &mut fx.store, key(KeyCode::Char(' ')));
assert_eq!(
view.selected().map(|f| f.path.clone()),
before,
"staging must not move the cursor to another file"
);
}
#[test]
fn tab_switches_between_the_working_tree_and_the_index() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "staged me\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 110, 16);
press(&mut view, &mut fx.store, key(KeyCode::Char(' ')));
let working = render_view(&mut view, &mut fx.store, 110, 16);
assert!(
working
.iter()
.any(|l| l.contains("nothing on the working tree side")),
"once staged the working tree matches the index: {working:?}"
);
press(&mut view, &mut fx.store, key(KeyCode::Tab));
let staged = render_view(&mut view, &mut fx.store, 110, 16);
assert!(
staged.iter().any(|l| l.contains("+staged me")),
"the staged side is what a commit would record: {staged:?}"
);
}
#[test]
fn committing_with_nothing_staged_is_refused_before_typing_a_message() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
assert!(
matches!(view.mode, Mode::Browsing),
"the message box should not open with nothing to commit"
);
}
#[test]
fn a_full_stage_and_commit_finishes_the_task() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
let wt = fx.worktree(&task, &repo);
std::fs::write(wt.join("README.md"), "changed\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('a')));
assert_eq!(view.staged_count(), 1);
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
assert!(matches!(view.mode, Mode::Committing { .. }));
for c in "fix the thing".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
let action = press(&mut view, &mut fx.store, key(KeyCode::Enter));
assert!(matches!(action, Action::Pop));
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::Committed
);
assert_eq!(
git::run(&wt, &["log", "-1", "--pretty=%s"]).unwrap(),
"fix the thing"
);
}
#[test]
fn q_types_a_q_while_writing_a_message() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('a')));
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
assert!(
view.captures_input(),
"the message box must swallow globals"
);
for c in "quick".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
let Mode::Committing { message } = &view.mode else {
panic!("should still be writing");
};
assert_eq!(message, "quick");
}
#[test]
fn escaping_the_message_box_returns_to_browsing_without_committing() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('a')));
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
press(&mut view, &mut fx.store, key(KeyCode::Esc));
assert!(matches!(view.mode, Mode::Browsing));
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview
);
}
#[test]
fn an_empty_message_is_rejected_and_leaves_the_task_alone() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('a')));
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
let action = press(&mut view, &mut fx.store, key(KeyCode::Enter));
assert!(matches!(action, Action::None));
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview
);
}
fn live_session(fx: &mut Fixture, task: &Task, server: &TestServer) -> Task {
let session = crate::tmux::session_name(task.id);
server
.tmux
.new_session_running(
&session,
&task.workspace_dir,
crate::tmux::DEFAULT_SIZE,
&[std::ffi::OsString::from("cat")],
)
.unwrap();
fx.store.set_session_name(task.id, &session, at(3)).unwrap();
fx.store.get_task(task.id).unwrap()
}
#[test]
fn rejecting_sends_the_agent_back_to_work_and_leaves_the_screen() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let server = TestServer::new();
let task = live_session(&mut fx, &task, &server);
let mut view = ReviewView::new(task.id).with_tmux(server.tmux.clone());
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('x')));
for c in "MARVERAGAIN with tests".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
let action = press(&mut view, &mut fx.store, key(KeyCode::Enter));
assert!(matches!(action, Action::Pop), "the diff is about to change");
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::Running
);
let session = task.session_name.clone().unwrap();
let mut seen = String::new();
for _ in 0..60 {
let panes = server.tmux.list_panes(&session).unwrap();
seen = server.tmux.capture_pane(&panes[0]).unwrap();
if seen.contains("MARVERAGAIN") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(
seen.contains("MARVERAGAIN with tests"),
"the instructions should reach the agent: {seen:?}"
);
}
#[test]
fn x_opens_a_field_rather_than_acting_immediately() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('x')));
assert!(
view.captures_input(),
"typing must not fall through to the global keymap"
);
let screen = render_view(&mut view, &mut fx.store, 100, 14);
assert!(
screen.iter().any(|l| l.contains("Send back to the agent")),
"{screen:?}"
);
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview,
"nothing happens until enter"
);
}
#[test]
fn esc_abandons_a_rejection() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('x')));
for c in "never mind".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
press(&mut view, &mut fx.store, key(KeyCode::Esc));
assert!(!view.captures_input());
assert_eq!(
fx.store.get_task(task.id).unwrap().state,
TaskState::AwaitingReview
);
}
#[test]
fn x_on_a_task_that_moved_on_never_opens_the_field() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
fx.store
.transition(task.id, TaskState::Cancelled, Transition::Plain, at(4))
.unwrap();
press(&mut view, &mut fx.store, key(KeyCode::Char('x')));
assert!(
!view.captures_input(),
"being refused after typing a paragraph is worse than being told now"
);
}
#[test]
fn typing_a_commit_message_and_typing_a_rejection_do_not_share_text() {
let mut fx = Fixture::new();
let repo = fx.repo("api");
let task = fx.reviewable(std::slice::from_ref(&repo));
std::fs::write(fx.worktree(&task, &repo).join("README.md"), "x\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
press(&mut view, &mut fx.store, key(KeyCode::Char('a')));
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
for c in "half a message".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
press(&mut view, &mut fx.store, key(KeyCode::Esc));
press(&mut view, &mut fx.store, key(KeyCode::Char('x')));
let screen = render_view(&mut view, &mut fx.store, 100, 14);
assert!(
!screen.iter().any(|l| l.contains("half a message")),
"{screen:?}"
);
}
#[test]
fn a_multi_repo_review_lists_both_worktrees() {
let mut fx = Fixture::new();
let api = fx.repo("api");
let web = fx.repo("web");
let task = fx.reviewable(&[api.clone(), web.clone()]);
std::fs::write(fx.worktree(&task, &api).join("a.txt"), "a\n").unwrap();
std::fs::write(fx.worktree(&task, &web).join("b.txt"), "b\n").unwrap();
let mut view = ReviewView::new(task.id);
render_view(&mut view, &mut fx.store, 100, 14);
assert_eq!(view.files.len(), 2);
press(&mut view, &mut fx.store, key(KeyCode::Char('a')));
press(&mut view, &mut fx.store, key(KeyCode::Char('c')));
for c in "both".chars() {
press(&mut view, &mut fx.store, key(KeyCode::Char(c)));
}
press(&mut view, &mut fx.store, key(KeyCode::Enter));
for repo in [&api, &web] {
assert_eq!(
git::run(&fx.worktree(&task, repo), &["log", "-1", "--pretty=%s"]).unwrap(),
"both",
"one message across every repo the task touched"
);
}
}
}