use super::*;
#[derive(Clone, Copy)]
pub(super) enum Ask {
Review,
Answer,
Fix,
}
const ALLOWED: &[&str] = &[
"Bash(dv review-info:*)",
"Bash(dv review-check:*)",
"Bash(git diff:*)",
"Bash(git show:*)",
"Bash(git log:*)",
"Bash(git rev-parse:*)",
"Bash(cargo check:*)",
"Bash(cargo build:*)",
"Bash(cargo test:*)",
"Bash(cargo clippy:*)",
];
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) fn claude_wrote(t: &Thread) -> bool {
t.claude.is_some() && t.comments.first().is_some_and(|c| c.author == crate::claude::AUTHOR)
}
pub(super) 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] "),
}
}
impl App {
pub(super) fn in_view(&self, t: &Thread) -> bool {
match self.view {
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.threads.iter().any(|t| self.in_view(t));
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; Ctrl-w a: the whole diff)",
View::Claude => "Claude view: its summary and the code it commented on (Ctrl-w a: the whole diff)",
View::Pending if !any && self.summary_draft.is_empty() => "pending view: no pending review yet (c queues a comment; Ctrl-w p: the whole diff)",
View::Pending => "pending view: your pending review and the code it's on (S submits it; Ctrl-w p: the whole diff)",
View::All => "the whole diff",
}
.into();
}
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).count()
}
fn claude_blocked(&self, needs_files: bool) -> Option<String> {
let busy = self.claude_job.is_some();
if busy {
return Some("Claude is still working on the last request".into());
}
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),
),
Item::new(notes, Target::ClaudeNotes),
];
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::ClaudeReplace(vec![p.number]),
)),
(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 replacement (Claude carries on, with your instructions)",
Target::ClaudeReplace(r.replaces.iter().map(|o| o.number).collect()),
)),
_ => {}
}
self.picker = Some(Picker { title: " claude ", items, query: String::new(), sel: if n > 0 { 1 } else { 0 } });
}
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 the pane on the right, which has the old one".into(),
false => "the next Claude run starts a new session".into(),
};
}
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 { title: " dismiss outdated? ", items, query: String::new(), sel: 0 });
}
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) => 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."
),
None => review_prompt(&what),
},
Ask::Answer if self.theirs => format!(
"Use the dv-review skill to answer my replies (and the comments I left for you) in the dv review of {what}. \
It's someone else's PR, so don't edit the code: where I ask for a change, suggest it instead (see \
\"Suggest the changes\")."
),
Ask::Answer => format!(
"Use the dv-review skill to answer my replies (and the comments I left for you) in the dv review of {what}."
),
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 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 => 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 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 self.claude_path.is_none() {
return self.msg = "no review file here (not in a git repo?)".into();
}
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.action = Some(Action::AskClaude { anchor, code }),
None => self.msg = "those lines aren't in this diff".into(),
}
}
pub(super) fn pick_claude(&mut self, ask: Ask) {
let needs_files = !matches!(ask, Ask::Fix) || self.change_requests() > 0;
if let Some(why) = self.claude_blocked(needs_files) {
return self.msg = why;
}
match ask {
Ask::Review | Ask::Fix => 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) && 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(!matches!(ask, Ask::Fix) || requests > 0) {
self.msg = why;
return false;
}
let prompt = self.claude_prompt(ask, extra);
let label = match ask {
Ask::Review => "reviewing",
Ask::Answer => "answering your replies",
Ask::Fix if self.theirs => "suggesting the changes",
Ask::Fix => "making the changes",
};
if let Some(pane) = self.claude_pane() {
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(zellij_run())
.args(["--name", &format!("claude: {label}"), "--cwd"])
.arg(&self.root)
.args(["--", "env"])
.arg(path_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() => (format!("Claude is {label} in the pane on the right; 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) {
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();
}
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)"),
}
}
fn claude_pane(&self) -> Option<u64> {
self.claude_pane_cmd().map(|p| p.0)
}
fn claude_pane_cmd(&self) -> Option<(u64, String)> {
let id = self.claude_session.as_ref().filter(|_| in_zellij())?;
let out = std::process::Command::new("zellij").args(["action", "list-panes", "-j", "-c", "-s"]).output().ok()?;
let panes: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
panes.as_array()?.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;
}
fn spawn_claude(&mut self, prompt: &str, label: &'static str, forked: bool) -> Result<ClaudeJob> {
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, &[], None)
.stdin(std::process::Stdio::null())
.stdout(std::fs::File::create(&log)?)
.stderr(std::fs::File::create(&err)?)
.spawn()
.context("is Claude Code (`claude`) installed?")?;
self.claude_log = Some(log.clone());
Ok(ClaudeJob { child, log, label, forked })
}
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 };
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 std::mem::take(&mut self.claude_again) && self.waiting_replies() > 0 {
match failed || refused.is_some() {
true => self.msg += " · your newer replies wait (Space a to retry)",
false => self.msg = self.answer_now(),
}
}
}
pub(super) fn claude_working(&self) -> bool {
self.claude_job.is_some()
}
}
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);
}
zellij(&["action", "paste", "-p", &id, prompt])?;
zellij(&["action", "focus-pane-id", &id])?;
Ok(true)
}
pub(super) 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(std::env::current_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(())
}
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 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())
.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 = std::env::current_exe().ok().and_then(|e| e.parent().map(Path::to_path_buf));
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()
}