use super::*;
#[derive(Clone, Copy)]
pub(super) enum Ask {
Review,
Answer,
Fix,
Describe,
Edit,
FixChecks,
Close,
}
const ALLOWED: &[&str] = &[
"Bash(dv review-info:*)",
"Bash(dv review-check:*)",
"Bash(dv review-comments:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(git log:*)",
"Bash(git rev-parse:*)",
"Bash(gh pr view:*)",
"Bash(gh pr checks:*)",
"Bash(gh run view:*)",
"Bash(cargo check:*)",
"Bash(cargo build:*)",
"Bash(cargo test:*)",
"Bash(cargo clippy:*)",
];
pub(crate) fn tools_for(fixing_ci: Option<u64>) -> Option<String> {
fixing_ci.map(|n| format!("Bash(dv rerun-failed -p {n})"))
}
pub(crate) const REPLACE_TOOLS: &[&str] = &[
"Bash(dv replace-info:*)",
"Bash(gh pr view:*)",
"Bash(gh pr diff:*)",
"Bash(gh issue view:*)",
"Bash(git status:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
];
pub(super) struct ClaudeJob {
pub(super) child: std::process::Child,
pub(super) log: PathBuf,
pub(super) label: &'static str,
pub(super) forked: bool,
pub(super) started: std::time::Instant,
}
pub(crate) fn claude_wrote(t: &Thread) -> bool {
t.claude.is_some() && t.comments.first().is_some_and(|c| c.author == crate::claude::AUTHOR)
}
const WANTS: [(&str, &str, &str, &str); 3] = [
("ask", "[ask] ", " (Claude answers; it changes nothing)", "Ask: Claude answers (it changes nothing)"),
(
"suggest",
"[suggest] ",
" (Claude drafts a suggestion for you to post; it doesn't edit the code)",
"Suggest: Claude drafts a suggestion for you to post on GitHub (no code changes)",
),
("change", "[change] ", " (Claude makes the change in the code here; nothing goes to GitHub)", "Change: Claude edits the code here"),
];
pub(crate) fn want_tag(t: &Thread) -> Option<&'static str> {
let tag = WANTS.iter().find(|w| w.0 == t.want)?.1;
(turn(t) == Some("[waiting] ")).then_some(tag)
}
pub(crate) fn turn(t: &Thread) -> Option<&'static str> {
let last = t.comments.last().filter(|_| t.claude.is_some() && !t.resolved)?;
match last.author == "you" {
true => Some("[waiting] "),
false => t.comments.iter().any(|c| c.author == "you").then_some("[answered] "),
}
}
#[derive(Default)]
pub(super) struct ClaudeState {
pub(super) path: Option<PathBuf>,
pub(super) mtime: Option<std::time::SystemTime>,
pub(super) summary: String,
pub(super) head: String,
pub(super) description: String,
pub(super) closing: String,
pub(super) job: Option<agent::ClaudeJob>,
pub(super) session: Option<String>,
pub(super) again: bool,
pub(super) runs: u32,
pub(super) log: Option<PathBuf>,
pub(super) spent: f64,
pub(super) pane_check: Option<std::time::Instant>,
pub(super) asking: bool,
pub(super) pane_busy: bool,
}
impl App {
pub(super) fn in_view(&self, t: &Thread) -> bool {
self.in_view_of(self.view, t)
}
pub(super) fn in_view_of(&self, v: View, t: &Thread) -> bool {
if self.before_head() && commit_of(&t.conversation).is_none() {
return false;
}
Self::of_view(v, t)
}
fn of_view(v: View, t: &Thread) -> bool {
match v {
View::All => true,
View::Claude => t.claude.is_some(),
View::Pending => t.comments.iter().any(|c| c.pending),
}
}
pub(super) fn toggle_view(&mut self, v: View) {
self.view = if self.view == v { View::All } else { v };
let keep = self.cursor_key();
self.rebuild(keep);
let any = self.any_in_view();
self.msg = match self.view {
View::Claude if !any && self.claude.summary.is_empty() => "Claude view: Claude hasn't reviewed this yet (Space a asks it; gn / gp: the other tabs)",
View::Claude => "Claude view: its summary and the code it commented on (gn / gp: the other tabs)",
View::Pending if !any && self.summary_draft.is_empty() => "pending view: no pending review yet (c queues a comment; gn / gp: the other tabs)",
View::Pending => "pending view: your pending review and the code it's on (S submits it; gn / gp: the other tabs)",
View::All => "the whole diff",
}
.into();
}
pub(super) fn pick_want(&mut self, action: Action, t: Option<usize>) {
if self.claude.path.is_none() {
return self.msg = "no review file here (not in a git repo?)".into();
}
let commit = matches!(&action, Action::AskConversation { url, .. } if commit_of(url).is_some());
let wants: Vec<_> = WANTS.iter().filter(|w| !(commit && w.0 == "suggest")).collect();
let last = t.and_then(|t| wants.iter().position(|w| w.0 == self.threads[t].want));
let sel = last.unwrap_or(match (commit, self.theirs) {
(true, _) => 0,
(false, true) => 1,
(false, false) => 2,
});
let items = wants
.iter()
.map(|&&(key, _, _, label)| match key == "change" && self.theirs {
true => Item::new(format!("{label} (their PR: Space g pushes what you commit)"), Target::Want(key)),
false => Item::new(label, Target::Want(key)),
})
.collect();
self.wanting = Some(action);
self.picker = Some(Picker { sel, ..Picker::new(" what do you want of Claude? ", items) });
}
pub(super) fn ask_commit(&mut self, i: usize) {
let Some(c) = self.commits.get(i) else { return };
let url = match &self.pr {
Some(p) => format!("{}/commits/{}", p.url, c.sha),
None => format!("commit:{}", c.sha),
};
let prefill = format!("About commit {} ({}):\n", c.short, c.subject);
let t = self.threads.iter().rposition(|t| t.conversation == url);
self.pick_want(Action::AskConversation { url, prefill, want: "" }, t);
}
pub(super) fn want(&mut self, w: &'static str) {
self.action = match self.wanting.take() {
Some(Action::AskClaude { anchor, code, about, .. }) => Some(Action::AskClaude { anchor, code, about, want: w }),
Some(Action::ClaudeReply(k, _, q)) => Some(Action::ClaudeReply(k, w, q)),
Some(Action::AskConversation { url, prefill, .. }) => Some(Action::AskConversation { url, prefill, want: w }),
other => other,
};
}
pub(super) fn any_in_view(&self) -> bool {
self.threads.iter().any(|t| Self::of_view(self.view, t))
}
pub(super) fn edits(&self, ask: Ask) -> bool {
match ask {
Ask::Describe | Ask::Close => false,
Ask::Answer => self.threads.iter().any(|t| turn(t) == Some("[waiting] ") && t.want == "change"),
_ => true,
}
}
pub(super) fn waiting_replies(&self) -> usize {
self.threads.iter().filter(|t| turn(t) == Some("[waiting] ")).count()
}
pub(super) fn reviewed_before(&self) -> Option<&str> {
let old = self.claude.head.as_str();
let head = diff::git(&["rev-parse", "HEAD"]).ok()?;
let newer = !old.is_empty() && !head.trim().starts_with(old);
(newer && diff::git(&["merge-base", "--is-ancestor", old, "HEAD"]).is_ok()).then_some(old)
}
pub(super) fn change_requests(&self) -> usize {
self.threads.iter().filter(|t| t.claude.is_some() && !t.resolved && !t.outdated && (claude_wrote(t) || !matches!(t.want.as_str(), "ask" | "suggest"))).count()
}
pub(super) fn claude_blocked(&self, ask: Option<Ask>) -> Option<String> {
let edits = ask.is_none_or(|a| self.edits(a));
let busy = self.claude.job.is_some();
if busy {
return Some("Claude is still working on the last request".into());
}
if edits && super::rebase::rebasing(&self.root) {
return Some(super::rebase::stopped_says(&self.root).into());
}
None
}
fn pane_cant(&self, ask: Ask) -> Option<String> {
let needs_files = !matches!(ask, Ask::Fix | Ask::Edit) || self.change_requests() > 0;
let (_, cmd) = self.claude_pane_cmd()?;
let dv = crate::claude::dv_dir().map(|d| d.display().to_string()).unwrap_or_default();
(needs_files && !dv.is_empty() && !cmd.contains(&dv)).then(|| {
"Claude's pane for this PR can't reach dv's review files (it's from an older dv): close it, and Space a starts a new one".into()
})
}
pub(super) fn claude_menu(&mut self) {
if self.claude.path.is_none() {
return self.msg = "no review file here (not in a git repo?)".into();
}
let has_review = self.threads.iter().any(claude_wrote) || !self.claude.summary.is_empty();
let review = match (has_review, self.reviewed_before()) {
(true, Some(old)) => format!("Update Claude's review: what changed since it reviewed {}", &old[..old.len().min(8)]),
(true, None) => "Update Claude's review (keeps comments that still apply, and your replies)".into(),
(false, _) => "Ask Claude to review this".into(),
};
let n = self.waiting_replies();
let open = self.change_requests();
let notes = match crate::claude::notes_path().is_ok_and(|p| p.exists()) {
true => "Edit Claude's notes on this repo (it reads them before each task)",
false => "Write notes for Claude on this repo (it reads them before each task)",
};
let mut items = vec![
Item::new(review, Target::Claude(Ask::Review)),
Item::new(format!("Ask Claude to answer your replies ({n} waiting)"), Target::Claude(Ask::Answer)),
Item::new(
match (open, self.theirs) {
(0, false) => "Ask Claude to make a change (no open comments: you say what)".to_owned(),
(n, false) => format!("Ask Claude to make the changes ({n} open comments)"),
(0, true) => "Ask Claude to suggest a change (you say what; their PR: no edits)".to_owned(),
(n, true) => format!("Ask Claude to suggest the changes ({n} open comments; their PR: no edits)"),
},
Target::Claude(Ask::Fix),
),
];
if self.theirs {
let push = match self.pr.as_ref().is_some_and(|p| p.maintainer_can_modify || !p.cross_repo) {
true => "Space g pushes them to their branch",
false => "they don't allow maintainer edits: the commits stay here",
};
let what = if open == 0 { "a change (you say what)".to_owned() } else { format!("the changes ({open} open comments)") };
items.push(Item::new(format!("Ask Claude to make {what} here, as commits ({push})"), Target::Claude(Ask::Edit)));
}
if let Some(n) = self.pr.as_ref().map(|p| p.number).filter(|_| self.failing > 0 && !self.theirs) {
let s = if self.failing == 1 { "" } else { "s" };
items.push(Item::new(format!("Ask Claude to fix the failing CI ({} check{s}: it reads the failed runs' logs; you push)", self.failing), Target::Claude(Ask::FixChecks)));
if let Some(c) = self.ci_note(n).filter(|c| !c.task.is_empty()) {
items.insert(0, Item::new(format!("Have Claude draft a PR fixing CI (not this PR's doing: {})…", c.why), Target::Do(Action::CiFixPr)));
}
}
items.push(Item::new("Ask Claude a question about this PR… (it answers in the Claude tab)", Target::ClaudeQuestion));
items.extend([
Item::new(notes, Target::ClaudeNotes),
Item::new("Check Claude's review now (dv watches it; this reads it again)", Target::ClaudeCheck),
]);
if self.claude_pane().is_some() {
items.insert(0, Item::new("Show Claude's pane (its session on this PR)", Target::ClaudePane));
}
if !self.claude.description.is_empty() {
items.insert(0, Item::new("Discard Claude's proposed description", Target::DescDiscard));
items.insert(0, Item::new("Ask Claude for more changes to the description…", Target::Claude(Ask::Describe)));
items.insert(0, Item::new("Approve Claude's description: it replaces the PR's on GitHub…", Target::DescApprove(false)));
} else if self.pr.is_some() {
items.push(Item::new("Ask Claude to change the PR's description (you approve it)…", Target::Claude(Ask::Describe)));
}
let open = self.pr_state.is_some_and(|s| matches!(s.0, "OPEN" | "DRAFT"));
let drafted = !self.claude.closing.trim().is_empty();
if drafted {
items.insert(0, Item::new("Discard Claude's closing comment", Target::CloseDiscard));
}
if drafted && open {
items.insert(0, Item::new("Ask Claude for changes to the closing comment…", Target::Claude(Ask::Close)));
items.insert(0, Item::new("Close the PR with Claude's comment (your editor first)…", Target::Do(Action::ClosePr)));
} else if self.pr.is_some() && open {
items.push(Item::new("Ask Claude to draft a comment closing the PR (you edit it, then close)…", Target::Claude(Ask::Close)));
}
if let Some(j) = &self.claude.job {
items.insert(0, Item::new(format!("Stop Claude ({} for {})", j.label, ago(j.started)), Target::ClaudeStop));
}
let outdated = self.outdated_claude_comments().len();
if outdated > 0 {
items.push(Item::new(format!("Dismiss Claude's outdated comments ({outdated})"), Target::ClaudeDismissOutdated(false)));
}
if self.claude.session.is_some() {
items.push(Item::new("Start a fresh Claude session for this PR (the review stays)", Target::ClaudeFresh));
}
match (&self.pr, &self.replacement) {
(Some(p), _) => items.push(Item::new(
format!("Draft a replacement for #{} (Claude writes it properly, unpublished, in the background)", p.number),
Target::Do(Action::Replace(vec![p.number], None)),
)),
(None, Some(r)) if r.drafting_now() => items.push(Item::new("Claude is drafting this replacement (dv shows it as it goes)", Target::Nothing)),
(None, Some(r)) => items.push(Item::new(
"Redraft this (Claude carries on, with your instructions)",
Target::Do(Action::Replace(r.ids(), None)),
)),
_ => {}
}
let want = |t: &Target| matches!(t, Target::Claude(a) if matches!((a, n > 0), (Ask::Answer, true) | (Ask::Review, false)));
let sel = items.iter().position(|i| want(&i.target)).unwrap_or(0);
self.picker = Some(Picker { sel, ..Picker::new(" claude ", items) });
}
pub(super) fn check_claude(&mut self) {
self.claude.mtime = Some(std::time::SystemTime::UNIX_EPOCH);
self.watch_claude();
let ran = self.claude.job.is_some();
self.poll_claude(); if let Some(j) = &self.claude.job {
self.msg = format!("Claude is still {} ({} so far); Space a can stop it", j.label, ago(j.started));
} else if !ran && !self.claude.path.as_ref().is_some_and(|p| p.exists()) {
self.msg = "Claude hasn't written a review yet (Space a → review asks for one)".into();
}
}
pub(super) fn stop_claude(&mut self) {
let Some(mut j) = self.claude.job.take() else { return self.msg = "Claude isn't working on anything".into() };
if let Some(p) = self.pr.as_ref().filter(|_| !j.forked) {
crate::claude::clear_running(p.number);
}
let group = format!("-{}", j.child.id());
_ = std::process::Command::new("kill").args(["-KILL", "--", &group]).stderr(std::process::Stdio::null()).status();
_ = j.child.kill();
_ = j.child.wait();
self.claude.again = false;
self.reload_tree(); self.msg = format!("stopped Claude ({} for {}); its log: {} (and .err)", j.label, ago(j.started), j.log.display());
}
pub(super) fn fresh_claude_session(&mut self) {
if self.claude.job.is_some() {
return self.msg = "Claude is still working on the last request; once it's done".into();
}
let pane = self.claude_pane().is_some();
self.set_session(None);
self.msg = match pane {
true => "the next Claude run starts a new session; close Claude's pane, which has the old one".into(),
false => "the next Claude run starts a new session".into(),
};
}
pub(super) fn approve_description(&mut self, sure: bool) {
let (Some(pr), Some(path)) = (self.pr.as_ref().map(|p| p.number), self.claude.path.clone()) else { return };
if !sure {
let items = vec![
Item::new("No, not yet", Target::Nothing),
Item::new(format!("Yes, replace #{pr}'s description on GitHub with Claude's"), Target::DescApprove(true)),
];
return self.picker = Some(Picker::new(" description ", items));
}
let text = self.claude.description.clone();
let body = (0..self.info.len()).find(|&i| self.is_description(i)).map(|h| h + 1).filter(|&b| self.info.get(b).is_some_and(|l| l.md));
let shown = body.map_or(String::new(), |b| self.info[b].text.clone());
let now = match crate::gh::body(pr) {
Ok(t) => t,
Err(e) => return self.msg = format!("description not changed: {e:#}"),
};
if now.trim() != shown.trim() {
if let Some(b) = body {
self.info[b].text = now;
}
let keep = self.cursor_key();
self.rebuild(keep);
return self.msg = "description not changed: it was edited on GitHub since; dv shows it now against Claude's: approve again if it's still right".into();
}
if let Err(e) = crate::gh::set_body(pr, &text) {
return self.msg = format!("description not changed: {e:#}");
}
self.github_changed();
if let Some(b) = body {
self.info[b].text = text;
}
let cleared = crate::claude::clear_description(&path);
let keep = self.cursor_key();
self.load_claude();
self.rebuild(keep);
self.msg = match cleared {
Ok(()) => format!("#{pr}'s description is Claude's now"),
Err(e) => format!("#{pr}'s description is Claude's now, but its proposal is still in the review file: {e:#} (Space a discards it)"),
};
}
pub(super) fn close_pr(&mut self) -> Result<String> {
let (Some(n), Some(path)) = (self.pr.as_ref().map(|p| p.number), self.claude.path.clone()) else { return Ok(String::new()) };
let what = format!("comment closing #{n} (posted on its conversation, then it's closed; empty closes nothing)");
let Some(text) = compose_with(&what, false, self.claude.closing.trim(), true)?.filter(|t| !t.trim().is_empty()) else {
return Ok(format!("#{n} not closed"));
};
keep_on_error(crate::gh::close(n, Some(text.trim())), &text)?;
_ = crate::claude::clear_closing(&path);
self.pr_state = Some(("CLOSED", "da3633"));
self.github_changed();
let keep = self.cursor_key();
self.load_claude();
self.rebuild(keep);
Ok(format!("#{n} closed, with your comment"))
}
pub(super) fn discard_closing(&mut self) {
let Some(path) = self.claude.path.clone() else { return };
self.msg = match crate::claude::clear_closing(&path) {
Ok(()) => {
let keep = self.cursor_key();
self.load_claude();
self.rebuild(keep);
"Claude's closing comment discarded".into()
}
Err(e) => format!("{e:#}"),
};
}
pub(super) fn discard_description(&mut self) {
let Some(path) = self.claude.path.clone() else { return };
self.msg = match crate::claude::clear_description(&path) {
Ok(()) => {
let keep = self.cursor_key();
self.load_claude();
self.rebuild(keep);
"Claude's proposed description discarded".into()
}
Err(e) => format!("{e:#}"),
};
}
pub(super) fn outdated_claude_comments(&self) -> Vec<String> {
self.threads.iter().filter(|t| claude_wrote(t) && t.outdated).filter_map(|t| t.claude.clone()).collect()
}
pub(super) fn dismiss_outdated(&mut self, sure: bool) {
let keys = self.outdated_claude_comments();
if keys.is_empty() {
return self.msg = "no outdated comments from Claude".into();
}
if !sure {
let items = vec![
Item::new("No, keep them", Target::Nothing),
Item::new(format!("Yes, dismiss Claude's {} outdated comment(s)", keys.len()), Target::ClaudeDismissOutdated(true)),
];
return self.picker = Some(Picker::new(" dismiss outdated? ", items));
}
let Some(p) = self.claude.path.clone() else { return };
let keep = self.cursor_key();
let res = crate::claude::dismiss_all(&p, &keys);
self.load_claude();
self.rebuild(keep);
self.msg = match res {
Ok(n) => format!("dismissed {n} outdated comment(s) from Claude"),
Err(e) => format!("{e:#}"),
};
}
pub(super) fn claude_prompt(&self, ask: Ask, extra: &str) -> String {
let what = match &self.pr {
Some(p) => format!("PR #{}", p.number),
None => "the current branch".into(),
};
let prompt = match ask {
Ask::Review => match self.reviewed_before() {
Some(old) => update_prompt(&what, old),
None => review_prompt(&what),
},
Ask::Answer => answer_prompt(&what, self.theirs),
Ask::FixChecks => fix_checks_prompt(self.pr.as_ref().map_or(0, |p| p.number)),
Ask::Close => format!(
"Use the dv-review skill to draft a comment closing {what}, as the user asks below, in the review file's \
top-level \"closing\" field (see \"Draft a closing comment\"). Start from the draft already there, if there \
is one. Don't close the PR or comment on GitHub: the user closes it in dv."
),
Ask::Describe => format!(
"Use the dv-review skill to propose a new description for {what}, as the user asks below: the whole new text, \
in the review file's top-level \"description\" field (see \"Propose a description\"). Start from the proposal \
already there, if there is one, else from the PR's current description. Don't change the PR on GitHub: the user \
approves it in dv."
),
Ask::Fix if self.theirs && self.change_requests() == 0 => format!(
"Use the dv-review skill to suggest the change the user asks for below, in the dv review of {what}: \
it's someone else's PR, so don't edit the code; add a comment of yours there with a suggestion block."
),
Ask::Fix if self.theirs => format!(
"Use the dv-review skill to suggest the changes the open comments in the dv review of {what} ask for \
(see \"Suggest the changes\"): it's someone else's PR, so don't edit the code."
),
Ask::Fix | Ask::Edit if self.change_requests() == 0 => format!(
"Make the change the user asks for below, in {what}. First run `dv review-info`: if the notes it lists \
exist, read them and follow them (they're the dv-review skill's notes on this repo)."
),
Ask::Fix | Ask::Edit => format!(
"Use the dv-review skill to make the changes the open comments in the dv review of {what} ask for \
(your own, and the user's: those with \"author\": \"user\"; every one that isn't resolved or outdated), \
replying on each with what you changed and resolving the ones you addressed."
),
};
with_instructions(&format!("{prompt} (It's {} now.)", crate::claude::now()), extra)
}
pub(super) fn ci_note(&self, n: u64) -> Option<crate::claude::CiNote> {
crate::claude::ci_note(n, &self.pr.as_ref()?.head_oid)
}
pub(super) fn edit_claude_notes(&mut self) {
match crate::claude::notes_path() {
Ok(p) => {
_ = std::fs::create_dir_all(p.parent().unwrap_or(&p));
self.action = Some(Action::Open(p.display().to_string()));
}
Err(e) => self.msg = format!("{e:#}"),
}
}
pub(super) fn start_ask_claude(&mut self) {
if let Some(which) = self.replacement_draft(self.head.row) {
let (s, e) = self.ordered();
let info = self.rows[self.head.row].info;
let lines: Vec<String> = (s.row..=e.row)
.filter(|&r| self.rows[r].info == info)
.map(|r| format!("> {}", self.rows[r].line.text.trim_end()))
.collect();
let prs = self.replacement.as_ref().map(crate::replace::Record::ids).unwrap_or_default();
self.select = false;
return self.action = Some(Action::Replace(prs, Some(format!("About the {which}:\n{}\n\n", lines.join("\n")))));
}
if self.claude.path.is_none() {
return self.msg = "no review file here (not in a git repo?)".into();
}
let row = &self.rows[self.head.row];
if row.info == Some(SUMMARY) && row.thread.is_none() {
let prefill = match self.selected_quote() {
Some(q) => format!("About this part of your review:\n{q}"),
None => String::new(),
};
self.select = false;
return self.action = Some(Action::AskConversation { url: ON_SUMMARY.into(), prefill, want: "" });
}
if self.section_of(self.head.row) == Some(DESCRIPTION) {
return self.pick_claude(Ask::Describe);
}
if self.section_of(self.head.row) == Some(CLOSING_PR) {
return self.pick_claude(Ask::Close);
}
if let Some((url, prefill)) = self.conversation_at_cursor() {
return self.action = Some(Action::AskConversation { url, prefill, want: "" });
}
if let Some((t, k)) = self.claude_at_cursor() {
let quote = self.selected_quote().map_or(String::new(), |q| format!("About this:\n{q}\n"));
self.select = false;
let reply = Action::ClaudeReply(k, "", quote);
return match self.threads[t].conversation.is_empty() {
true => self.pick_want(reply, Some(t)),
false => self.action = Some(reply),
};
}
if let Some(t) = self.rows[self.head.row].thread.filter(|&t| self.threads[t].claude.is_none()) {
return self.ask_about(t);
}
let anchor = match self.selection_anchor() {
Ok(a) => a,
Err(_) => return self.msg = "a on code lines leaves Claude a comment there".into(),
};
if anchor.start.is_some_and(|(_, left)| left != anchor.left) {
return self.msg = "select lines on one side (removed, or added/unchanged) to leave Claude a comment".into();
}
let first = anchor.start.map_or(anchor.line, |s| s.0);
let code = self.files.iter().find(|f| f.path == anchor.path).and_then(|f| crate::claude::code_at(f, anchor.left, first, anchor.line));
match code {
Some(code) => self.pick_want(Action::AskClaude { anchor, code, about: None, want: "" }, None),
None => self.msg = "those lines aren't in this diff".into(),
}
}
fn conversation_at_cursor(&self) -> Option<(String, String)> {
let row = &self.rows[self.head.row];
let body = row.info.filter(|_| row.file == INFO && row.thread.is_none() && row.main.is_none())?;
let text = self.info.get(body).filter(|l| l.md)?;
let head = self.info.get(body.checked_sub(1)?).filter(|h| h.heading && !h.url.is_empty() && !h.event)?;
let who = head.text.split(" · ").next().unwrap_or("");
let quote = match self.seen_info(body) {
None => format!("— {}, not shown here", hidden_words(&head.hidden)),
Some(_) => self.selected_quote().unwrap_or_else(|| super::quote(&text.text)),
};
Some((head.url.clone(), format!("About {who}'s comment ({}):\n{quote}", head.url)))
}
fn selected_quote(&self) -> Option<String> {
(self.select || self.anchor != self.head).then(|| super::quote(self.selection_text(false).trim_end())).filter(|q| !q.is_empty())
}
pub(super) fn replacement_draft(&self, r: usize) -> Option<&'static str> {
self.replacement.as_ref()?;
let row = &self.rows[r];
let i = row.info.filter(|_| row.file == INFO)?;
let heading = self.info.get(i.checked_sub(1)?).filter(|h| h.heading && self.info[i].md)?;
[("description", "description"), ("closing note", "closing note")].into_iter().find(|(h, _)| heading.text.starts_with(h)).map(|x| x.1)
}
fn ask_about(&mut self, t: usize) {
let th = &self.threads[t];
let Some(line) = th.line else { return self.msg = "this thread is outdated: its lines aren't in the diff to ask about".into() };
let Some(c) = self.comment_under_cursor(t) else { return self.msg = "move onto a comment to ask about it".into() };
let anchor = Anchor { path: th.path.clone(), line, left: th.left, start: th.start_line.filter(|&s| s < line).map(|s| (s, th.left)) };
let first_line = anchor.start.map_or(line, |s| s.0);
let Some(code) = self.files.iter().find(|f| f.path == anchor.path).and_then(|f| crate::claude::code_at(f, anchor.left, first_line, line)) else {
return self.msg = "those lines aren't in this diff".into();
};
let link = self.pr.as_ref().map(|p| format!(" ({}#discussion_r{})", p.url, c.id)).unwrap_or_default();
let quote: String = match self.seen_body(c) {
Some(body) => body.lines().map(|l| format!("> {l}\n")).collect(),
None => format!("— {}, not shown here\n", hidden_words(&c.hidden)),
};
let link = if link.is_empty() { format!(" (id {})", c.id) } else { link };
let prefill = format!("About @{}'s comment{link}:\n{quote}\n", c.author);
self.action = Some(Action::AskClaude { anchor, code, about: Some((c.id, prefill)), want: "" });
}
pub(super) fn pick_claude(&mut self, ask: Ask) {
if let Some(why) = self.claude_blocked(Some(ask)).or_else(|| self.pane_cant(ask)) {
return self.msg = why;
}
match ask {
Ask::Describe | Ask::Close if self.pr.is_none() => self.msg = "no PR here".into(),
Ask::Review | Ask::Fix | Ask::Edit | Ask::Describe | Ask::Close => self.action = Some(Action::ClaudeWith(ask)),
ask => _ = self.run_claude(ask, ""),
}
}
pub(super) fn run_claude(&mut self, ask: Ask, extra: &str) -> bool {
if matches!(ask, Ask::Answer) && self.waiting_replies() == 0 {
self.msg = "no replies waiting for Claude (r on a Claude comment replies)".into();
return false;
}
let requests = self.change_requests();
if matches!(ask, Ask::Fix | Ask::Edit) && requests == 0 && extra.trim().is_empty() {
self.msg = "nothing to change: no open comments, and no instructions written".into();
return false;
}
if let Some(why) = self.claude_blocked(Some(ask)) {
self.msg = why;
return false;
}
if let (Ask::FixChecks, Some(p)) = (ask, &self.pr) {
crate::claude::ci_note_path(p.number).map(std::fs::remove_file).ok();
}
let prompt = self.claude_prompt(ask, extra);
let label = match ask {
Ask::Review => "reviewing",
Ask::Answer => "answering your replies",
Ask::Describe => "proposing a description",
Ask::Fix if self.theirs => "suggesting the changes",
Ask::Edit => "making the changes",
Ask::Close => "drafting a closing comment",
Ask::Fix => "making the changes",
Ask::FixChecks => "fixing CI",
};
if let Some(pane) = self.claude_pane() {
if let Some(why) = self.pane_cant(ask) {
self.msg = why;
return false;
}
let (msg, asked) = match offer_to_pane(pane, &prompt) {
Ok(true) => (format!("{label}: the request is in Claude's pane, press Enter there to send it"), true),
Ok(false) => ("Claude's pane is waiting for you to answer it; ask again after".into(), false),
Err(e) => (format!("Claude's pane: {e:#}"), false),
};
self.msg = msg;
return asked;
}
let mut path_env = std::ffi::OsString::from("PATH=");
path_env.push(path_for_claude());
let (msg, asked) = match in_zellij() {
true => match std::process::Command::new("zellij")
.args(["run", "--floating", "--no-focus", "--name", &format!("claude: {label}"), "--cwd"])
.arg(&self.root)
.args(["--", "env"])
.arg(path_env) .args(data_env()) .args(["claude", &prompt])
.args(self.session_args())
.args(claude_dirs())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => {
if let (Some(p), Some(id)) = (&self.pr, &self.claude.session) {
crate::claude::mark_running(p.number, &format!("{label} (in its pane)"), None, Some(id));
}
(format!("Claude is {label} in a hidden pane (Space a shows it; it closes when Claude's done); its comments show up here as it writes them"), true)
}
Ok(s) => (format!("zellij run failed ({s})"), false),
Err(e) => (format!("running zellij: {e}"), false),
},
false => match self.spawn_claude(&prompt, label, false, tools_for(self.pr.as_ref().filter(|_| matches!(ask, Ask::FixChecks)).map(|p| p.number)).as_deref().as_slice()) {
Ok(job) => {
self.claude.job = Some(job);
(format!("Claude is {label} in the background…"), true)
}
Err(e) => (format!("starting claude: {e:#}"), false),
},
};
self.msg = msg;
asked
}
pub(super) fn answer_now(&mut self) -> String {
if self.claude.job.is_some() {
self.claude.again = true;
return "reply saved; Claude answers it when it's done with what it's doing".into();
}
if let Some(why) = self.claude_blocked(Some(Ask::Answer)) {
return format!("reply saved; {why} (then Space a has Claude answer)");
}
let prompt = self.claude_prompt(Ask::Answer, "");
let forked = self.claude_pane().is_some();
match self.spawn_claude(&prompt, "answering", forked, &[]) {
Ok(job) => {
self.claude.job = Some(job);
"reply sent; Claude is answering…".into()
}
Err(e) => format!("reply saved, but starting claude failed: {e:#} (Space a to retry)"),
}
}
pub(super) fn claude_pane(&self) -> Option<u64> {
self.claude_pane_cmd().map(|p| p.0)
}
pub(super) fn show_claude_pane(&mut self) {
let Some((pane, ..)) = self.claude_pane_cmd() else { return self.msg = "Claude's pane is closed".into() };
if show_pane(pane).is_err() {
self.msg = "zellij couldn't show Claude's pane".into();
}
}
pub(super) fn watch_claude_pane(&mut self) {
if self.claude.session.is_none() || !in_zellij() || self.claude.pane_check.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(3)) {
return;
}
self.claude.pane_check = Some(std::time::Instant::now());
let pane = self.claude_pane();
let screen = pane.map(pane_screen);
let asking_now = screen.as_deref().is_some_and(asking);
if asking_now && !self.claude.asking {
self.msg = "Claude is asking you something in its pane: Space a → Show Claude's pane".into();
}
self.claude.asking = asking_now;
self.claude.pane_busy = screen.as_deref().is_some_and(working);
let (Some(pr), Some(pane)) = (self.pr.as_ref().map(|p| p.number), pane) else { return };
let Some((session, since)) = crate::claude::pane_run(pr).filter(|r| Some(&r.0) == self.claude.session.as_ref()) else { return };
if close_if_done(pane, &session, since) {
crate::claude::clear_running(pr);
self.msg = "Claude's done: its pane closed (Space a asks it more, in the same session)".into();
}
}
pub(super) fn claude_doing(&self) -> Option<String> {
match &self.claude.job {
Some(j) => Some(format!("◐ claude: {} {}", j.label, ago(j.started))),
None if self.claude.asking => Some("◐ claude is asking you (Space a)".into()),
None if self.claude.pane_busy => Some("◐ claude working (its pane)".into()),
None => None,
}
}
fn claude_pane_cmd(&self) -> Option<(u64, String)> {
let id = self.claude.session.as_ref().filter(|_| in_zellij())?;
super::term::zellij_panes(&["-c", "-t"]).unwrap_or_default().iter().find_map(|p| {
let cmd = p["pane_command"].as_str().filter(|c| p["exited"] != true && c.contains(id.as_str()))?;
Some((p["id"].as_u64()?, cmd.to_owned()))
})
}
fn session_args(&mut self) -> [String; 2] {
match self.claude.session.clone().filter(|id| crate::claude::session_exists(id)) {
Some(id) => ["--resume".into(), id],
None => {
let id = crate::claude::new_session_id();
self.set_session(Some(id.clone()));
["--session-id".into(), id]
}
}
}
pub(super) fn set_session(&mut self, id: Option<String>) {
if let Some(p) = &self.claude.path {
_ = crate::claude::save_session(p, &self.root, id.as_deref());
}
self.claude.session = id;
}
pub(super) fn spawn_claude(&mut self, prompt: &str, label: &'static str, forked: bool, more: &[&str]) -> Result<ClaudeJob> {
use std::os::unix::process::CommandExt;
self.claude.runs += 1;
let log = std::env::temp_dir().join(format!("dv-claude-{}-{}.log", std::process::id(), self.claude.runs));
let err = log.with_extension("err");
let mut session = self.session_args().to_vec();
if forked {
session.push("--fork-session".into());
}
let child = claude_command(prompt, &self.root, &session, more, None)
.stdin(std::process::Stdio::null())
.stdout(std::fs::File::create(&log)?)
.stderr(std::fs::File::create(&err)?)
.process_group(0) .spawn()
.context("is Claude Code (`claude`) installed?")?;
self.claude.log = Some(log.clone());
if let Some(p) = self.pr.as_ref().filter(|_| !forked) {
crate::claude::mark_running(p.number, label, Some(child.id()), None);
}
Ok(ClaudeJob { child, log, label, forked, started: std::time::Instant::now() })
}
pub(super) fn poll_claude(&mut self) {
let Some(job) = &mut self.claude.job else { return };
let Ok(Some(st)) = job.child.try_wait() else { return };
let Some(ClaudeJob { log, label, forked, .. }) = self.claude.job.take() else { return };
if let Some(p) = self.pr.as_ref().filter(|_| !forked) {
crate::claude::clear_running(p.number);
}
self.reload_tree(); let out: Option<serde_json::Value> = std::fs::read_to_string(&log).ok().and_then(|t| serde_json::from_str(&t).ok());
let failed = !st.success() || out.as_ref().is_none_or(|v| v["is_error"] == true);
match out.as_ref().and_then(|v| v["session_id"].as_str()) {
_ if forked => {}
Some(id) if !failed => self.set_session(Some(id.to_owned())),
_ => self.set_session(None), }
let refused = out.as_ref().and_then(refusal);
let cost = out.as_ref().and_then(|v| v["total_cost_usd"].as_f64());
self.claude.spent += cost.unwrap_or(0.0);
let took = match (out.as_ref().and_then(|v| v["num_turns"].as_u64()), cost) {
(Some(n), Some(c)) => format!(" ({n} turns, ${c:.2})"),
_ => String::new(),
};
self.msg = match (failed, &refused) {
(false, None) => format!("Claude is done {label}{took}"),
(false, Some(r)) => format!("Claude finished {label}, but was refused {r}: see {}", log.display()),
(true, Some(r)) => format!("Claude stopped while {label}, refused {r}: see {}", log.display()),
(true, None) => format!("Claude stopped ({st}) while {label}: see {} (and .err)", log.display()),
};
if label == super::rebase::REBASE_LABEL {
self.rebase_ended();
}
let then = std::mem::take(&mut self.amend_then);
if label == super::amend::AMEND_LABEL && !failed && refused.is_none() && then {
self.amend_now();
} else if label == super::amend::AMEND_LABEL && !failed && refused.is_none() {
match self.picker.is_none() && self.prompt.is_none() && self.action.is_none() {
true => self.amend_menu(),
false => self.msg += " (Space g → amend shows where it put them)",
}
}
if std::mem::take(&mut self.claude.again) && self.waiting_replies() > 0 {
match failed || refused.is_some() || super::rebase::rebasing(&self.root) {
true => self.msg += " · your newer replies wait (Space a to retry)",
false => self.msg = self.answer_now(),
}
}
}
}
pub(super) fn refusal(out: &serde_json::Value) -> Option<String> {
let denied = out["permission_denials"].as_array().filter(|d| !d.is_empty())?;
let d = &denied[0];
let input = &d["tool_input"];
let target = ["file_path", "path", "command", "pattern", "url"].iter().find_map(|k| input[*k].as_str()).unwrap_or("");
let target: String = target.lines().next().unwrap_or("").chars().take(60).collect();
let more = match denied.len() {
1 => String::new(),
n => format!(" (and {} more)", n - 1),
};
Some(format!("{} {target}{more}", d["tool_name"].as_str().unwrap_or("a tool")).replace(" ", " ").trim().to_owned())
}
fn offer_to_pane(pane: u64, prompt: &str) -> Result<bool> {
let id = format!("terminal_{pane}");
let zellij = |args: &[&str]| -> Result<std::process::Output> {
let out = std::process::Command::new("zellij").args(args).stdin(std::process::Stdio::null()).output()?;
anyhow::ensure!(out.status.success(), "zellij {} failed ({})", args[1], out.status);
Ok(out)
};
let screen = String::from_utf8_lossy(&zellij(&["action", "dump-screen", "-p", &id])?.stdout).into_owned();
if asking(&screen) {
return Ok(false);
}
anyhow::ensure!(!in_other_tab(pane), "it's in another zellij tab (an older dv's): close it there, and Space a starts one here");
zellij(&["action", "paste", "-p", &id, prompt])?;
show_pane(pane)?;
Ok(true)
}
pub(crate) fn close_if_done(pane: u64, session: &str, since: i64) -> bool {
let Some(last) = crate::claude::turn_ended(session, since) else { return false };
if last.trim_end().ends_with('?') {
return false;
}
let id = format!("terminal_{pane}");
let zellij = |args: &[&str]| std::process::Command::new("zellij").arg("action").args(args).stdin(std::process::Stdio::null()).output().ok();
let Some(screen) = zellij(&["dump-screen", "-a", "-p", &id]).map(|o| String::from_utf8_lossy(&o.stdout).into_owned()) else { return false };
if asking(&strip_ansi(&screen)) || !input_empty(&screen) {
return false;
}
let panes = super::term::zellij_panes(&["-c", "-t"]).unwrap_or_default();
let Some(me) = panes.iter().find(|p| p["id"].as_u64() == Some(pane) && p["is_plugin"] != true) else { return false };
let tab = me["tab_id"].as_u64().map(|t| t.to_string()).unwrap_or_default();
let shown = || zellij(&["are-floating-panes-visible", "--tab-id", &tab]).is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == "true");
if me["is_focused"] == true && (me["is_floating"] != true || shown()) {
return false;
}
zellij(&["close-pane", "-p", &id]).is_some_and(|o| o.status.success())
}
pub(super) fn input_empty(screen: &str) -> bool {
let lines: Vec<&str> = screen.lines().collect();
let rule = |l: &str| {
let t = strip_ansi(l);
t.trim().chars().count() >= 10 && t.trim().chars().all(|c| c == '─')
};
let rules: Vec<usize> = (0..lines.len()).filter(|&i| rule(lines[i])).collect();
let [.., a, b] = rules[..] else { return false };
!lines[a + 1..b].iter().any(|l| typed(l))
}
fn typed(line: &str) -> bool {
let (mut dim, mut grey) = (false, false);
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if chars.peek() == Some(&'[') {
chars.next();
let mut params = String::new();
for d in chars.by_ref() {
if d.is_ascii_alphabetic() {
if d == 'm' {
sgr(¶ms, &mut dim, &mut grey);
}
break;
}
params.push(d);
}
}
continue;
}
if !dim && !grey && !c.is_whitespace() && !matches!(c, '❯' | '>' | '\u{a0}' | '│') {
return true;
}
}
false
}
fn sgr(params: &str, dim: &mut bool, grey: &mut bool) {
let n: Vec<u32> = params.split(';').map(|p| p.parse().unwrap_or(0)).collect();
let mut i = 0;
while i < n.len() {
match n[i] {
0 => (*dim, *grey) = (false, false),
2 => *dim = true,
22 => *dim = false,
30..=37 | 39 | 91..=97 => *grey = false,
90 => *grey = true,
38 if n.get(i + 1) == Some(&2) && i + 4 < n.len() => {
let (r, g, b) = (n[i + 2], n[i + 3], n[i + 4]);
*grey = r == g && g == b && r < 200;
i += 4;
}
38 if n.get(i + 1) == Some(&5) => {
*grey = n.get(i + 2).is_some_and(|&c| c == 8 || (232..=250).contains(&c));
i += 2;
}
_ => {}
}
i += 1;
}
}
pub(crate) fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for d in chars.by_ref() {
if d.is_ascii_alphabetic() {
break;
}
}
continue;
}
out.push(c);
}
out
}
pub(crate) fn pane_screen(pane: u64) -> String {
let out = std::process::Command::new("zellij").args(["action", "dump-screen", "-p", &format!("terminal_{pane}")]).stdin(std::process::Stdio::null()).output();
out.map(|o| String::from_utf8_lossy(&o.stdout).into_owned()).unwrap_or_default()
}
fn show_pane(pane: u64) -> Result<()> {
let args = match pane_tab(pane).filter(|_| in_other_tab(pane)) {
Some(t) => vec!["go-to-tab-by-id".to_owned(), t.to_string()],
None => vec!["focus-pane-id".to_owned(), format!("terminal_{pane}")],
};
let st = std::process::Command::new("zellij").arg("action").args(&args).stdin(std::process::Stdio::null()).status()?;
anyhow::ensure!(st.success(), "zellij {} failed ({st})", args[0]);
Ok(())
}
pub(crate) fn working(screen: &str) -> bool {
screen.contains("esc to interrupt") || screen.lines().any(|l| l.split_once("… (").is_some_and(|(_, t)| t.starts_with(|c: char| c.is_ascii_digit())))
}
pub(crate) fn asking(screen: &str) -> bool {
["Do you want", "Would you like", "❯ 1.", "trust this folder"].iter().any(|p| screen.contains(p))
}
pub(super) fn spawn_replace(prs: &[u64]) -> Result<()> {
use std::os::unix::process::CommandExt;
let log = std::env::temp_dir().join(format!("dv-replace-{}.log", prs[0]));
std::process::Command::new(crate::self_exe())
.arg("replace")
.args(prs.iter().map(u64::to_string))
.stdin(std::process::Stdio::null())
.stdout(std::fs::File::create(&log)?)
.stderr(std::fs::File::create(log.with_extension("err"))?)
.process_group(0) .spawn()?;
Ok(())
}
fn pane_tab(pane: u64) -> Option<u64> {
super::term::zellij_panes(&["-c", "-t"]).unwrap_or_default().iter().find(|p| p["id"].as_u64() == Some(pane) && p["is_plugin"] != true).and_then(|p| p["tab_id"].as_u64())
}
fn in_other_tab(pane: u64) -> bool {
let own = std::env::var("ZELLIJ_PANE_ID").ok().and_then(|id| id.parse().ok()).and_then(pane_tab);
pane_tab(pane).is_some_and(|t| own.is_some_and(|o| o != t))
}
pub(crate) fn with_instructions(prompt: &str, extra: &str) -> String {
match extra.trim() {
"" => prompt.to_owned(),
e => format!("{prompt}\n\nThe user adds:\n{e}"),
}
}
pub(super) fn claude_dirs() -> Vec<std::ffi::OsString> {
match crate::claude::dv_dir() {
Ok(dv) => vec!["--add-dir".into(), dv.into_os_string()],
Err(_) => vec![],
}
}
pub(crate) fn update_prompt(what: &str, old: &str) -> String {
format!(
"Use the dv-review skill to update your dv review of {what}: review only what changed since you reviewed {old} \
(`git diff {old}`), and keep the comments that still apply."
)
}
pub(super) fn want_hint(want: &str) -> &'static str {
WANTS.iter().find(|w| w.0 == want).map_or(" (it answers right away; nothing goes to GitHub)", |w| w.2)
}
pub(crate) fn answer_prompt(what: &str, theirs: bool) -> String {
let base = format!(
"Use the dv-review skill to answer my replies (and the comments I left for you) in the dv review of {what}. \
Where I said what I want (\"want\"), do exactly that (see \"What the user wants\")."
);
match theirs {
true => format!(
"{base} It's someone else's PR: where I didn't say, and ask for a change, suggest it rather than edit the code \
(see \"Suggest the changes\")."
),
false => base,
}
}
pub(crate) fn fix_checks_prompt(n: u64) -> String {
let note = crate::claude::ci_note_path(n).map(|p| p.display().to_string()).unwrap_or_else(|_| "ci/pr-N.json in dv's directory".into());
format!(
"CI fails on PR #{n}. See which checks fail (`gh pr checks`) and why (`gh run view <run id> --log-failed`), \
then fix the cause in the code here, and check the fix (build, tests). Don't commit or push: the user \
reviews and pushes it. If it isn't this PR's fault (a flaky test, CI itself, something already broken on \
its base branch), change nothing here; instead write {note} as JSON: \
{{\"head\": \"<git rev-parse HEAD>\", \"why\": \"<one line: what fails and why it isn't this PR>\", \
\"task\": \"<what a separate PR fixing it should do, a few lines; empty if nothing in the repo would fix \
it>\", \"rerun\": false}}: the user can have a PR drafted from it. If the failure looks transient (a \
network error, a timeout, a runner problem, a test known to be flaky), also rerun the failed jobs once \
(`dv rerun-failed -p {n}`: the failed jobs only, of every failed run on its head) and set \"rerun\": true; but not if {note} \
already has \"rerun\": true for this head (CI was rerun once already: it isn't transient), and then \
keep \"rerun\": true in the note you write. First run \
`dv review-info`: if the notes it lists exist, read them and follow them."
)
}
pub(crate) fn review_prompt(what: &str) -> String {
format!("Use the dv-review skill to review {what} for dv. If there's already a review, update it.")
}
pub(crate) fn claude_command(prompt: &str, dir: &Path, session: &[String], more: &[&str], access: Option<&Path>) -> std::process::Command {
let mut cmd = std::process::Command::new("claude");
cmd.env("PATH", path_for_claude()).env("GIT_EDITOR", "true")
.args(["-p", prompt, "--output-format", "json", "--permission-mode", "acceptEdits"])
.args(session);
match access {
Some(d) => _ = cmd.arg("--add-dir").arg(d),
None => _ = cmd.args(claude_dirs()),
}
cmd.arg("--allowedTools").args(ALLOWED).args(more).current_dir(dir);
cmd
}
fn path_for_claude() -> std::ffi::OsString {
let own = crate::self_exe().parent().map(Path::to_path_buf).filter(|d| !d.as_os_str().is_empty());
let rest = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect::<Vec<_>>()).unwrap_or_default();
std::env::join_paths(own.into_iter().chain(rest)).unwrap_or_default()
}
fn data_env() -> Option<std::ffi::OsString> {
let mut v = std::ffi::OsString::from("XDG_DATA_HOME=");
v.push(crate::claude::data_home().ok()?.parent()?);
Some(v)
}
pub(super) fn ago(t: std::time::Instant) -> String {
crate::gh::ago_secs(t.elapsed().as_secs() as i64)
}
#[test]
#[ignore]
fn live_claude_change_on_their_pr() {
let dir = crate::diff::scratch_repo("live-change");
let data = std::env::temp_dir().join(format!("dv-live-change-data-{}", std::process::id()));
let git = |args: &[&str]| crate::diff::git_in(&dir, args).unwrap();
std::fs::write(dir.join("count.py"), "def total(xs):\n return 0\n").unwrap();
git(&["add", "."]);
git(&["commit", "-q", "-m", "base"]);
git(&["checkout", "-q", "-b", "feat"]);
std::fs::write(dir.join("count.py"), "def total(xs):\n x = 0\n for v in xs:\n x += v\n return x\n").unwrap();
git(&["commit", "-q", "-am", "total: sum them"]);
let info = std::process::Command::new("dv").arg("review-info").current_dir(&dir).env("XDG_DATA_HOME", &data).output().unwrap();
let info = String::from_utf8_lossy(&info.stdout);
let file = info.lines().find_map(|l| l.strip_prefix("file: ")).expect("review-info's file").trim().to_owned();
std::fs::create_dir_all(Path::new(&file).parent().unwrap()).unwrap();
let review = serde_json::json!({ "comments": [{
"path": "count.py", "line": 2, "side": "new", "author": "user", "code": [" x = 0"],
"body": "Rename `x` to `acc`, here and below.", "created": crate::claude::now(), "want": "change",
}]});
std::fs::write(&file, review.to_string()).unwrap();
let out = claude_command(&answer_prompt("the current branch", true), &dir, &[], &[], Some(&data.join("dv"))).env("XDG_DATA_HOME", &data).stdin(std::process::Stdio::null()).output().unwrap();
let said: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap_or_default();
println!("claude: {}\nrefused: {}", said["result"].as_str().unwrap_or(&String::from_utf8_lossy(&out.stderr)), said["permission_denials"]);
let code = std::fs::read_to_string(dir.join("count.py")).unwrap();
println!("count.py:\n{code}");
let after: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&file).unwrap()).unwrap();
println!("review: {after:#}");
assert!(code.contains("acc = 0") && !code.contains(" x "), "the code wasn't changed");
assert!(!after.to_string().contains("```suggestion"), "suggested instead");
_ = std::fs::remove_dir_all(&dir);
_ = std::fs::remove_dir_all(&data);
}