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(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)
}
impl App {
pub(super) fn waiting_replies(&self) -> usize {
let yours = |t: &Thread| t.comments.last().is_some_and(|c| c.author == "you");
self.threads.iter().filter(|t| t.claude.is_some() && !t.resolved && yours(t)).count()
}
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 {
true => "Update Claude's review (keeps comments that still apply, and your replies)",
false => "Ask Claude to review this",
};
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 {
0 => "Ask Claude to make a change (no open comments: you say what)".to_owned(),
n => format!("Ask Claude to make the changes ({n} open comments)"),
},
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)));
}
self.picker = Some(Picker { title: " claude ", items, query: String::new(), sel: if n > 0 { 1 } else { 0 } });
}
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 => format!("Use the dv-review skill to review {what} for dv. If there's already a review, update it."),
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.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."
),
};
match extra.trim() {
"" => prompt,
e => format!("{prompt}\n\nThe user adds:\n{e}"),
}
}
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 => "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 (msg, asked) = match in_zellij() {
true => match std::process::Command::new("zellij")
.args(["run", "--direction", "right", "--name", &format!("claude: {label}"), "--cwd"])
.arg(&self.root)
.args(["--", "claude", &prompt])
.args(self.session_args())
.args(self.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()))
})
}
pub(super) fn claude_dirs(&self) -> Vec<std::ffi::OsString> {
match crate::claude::dv_dir() {
Ok(dv) => vec!["--add-dir".into(), dv.into_os_string()],
Err(_) => vec![],
}
}
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 cmd = std::process::Command::new("claude");
cmd.args(["-p", prompt, "--output-format", "json", "--permission-mode", "acceptEdits"]);
cmd.args(self.session_args());
if forked {
cmd.arg("--fork-session");
}
let child = cmd
.args(self.claude_dirs())
.arg("--allowedTools")
.args(ALLOWED)
.current_dir(&self.root)
.stdin(std::process::Stdio::null())
.stdout(std::fs::File::create(&log)?)
.stderr(std::fs::File::create(&err)?)
.spawn()
.context("is Claude Code (`claude`) installed?")?;
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);
self.msg = match (failed, &refused) {
(false, None) => format!("Claude is done {label}"),
(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 ["Do you want", "Would you like", "❯ 1."].iter().any(|p| screen.contains(p)) {
return Ok(false);
}
zellij(&["action", "paste", "-p", &id, prompt])?;
zellij(&["action", "focus-pane-id", &id])?;
Ok(true)
}