use super::*;
use crate::claude::{answer_prompt, claude_command, claude_dirs, data_env, fix_checks_prompt, path_for_claude, review_prompt, tools_for, update_prompt, with_instructions};
#[derive(Clone, Copy)]
pub(super) enum Ask {
Review,
Answer,
Fix,
Describe,
Edit,
FixChecks,
Close,
}
impl Ask {
pub(super) fn work(self) -> crate::claude::Work {
use crate::claude::Work;
match self {
Ask::Review => Work::Review,
Ask::Answer => Work::Answer,
Ask::Fix | Ask::Edit => Work::Change,
Ask::Describe => Work::Describe,
Ask::FixChecks => Work::FixCi,
Ask::Close => Work::Close,
}
}
}
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 want_tag(t: &Thread) -> Option<&'static str> {
let tag = ask::WANTS.iter().find(|w| w.0 == t.want)?.1;
t.waiting().then_some(tag)
}
pub(super) fn turn(t: &Thread) -> Option<&'static str> {
match (t.waiting(), t.answered()) {
(true, _) => Some("[waiting] "),
(_, true) => Some("[answered] "),
_ => None,
}
}
#[derive(Default)]
pub(super) struct ClaudeState {
pub(super) land: &'static str,
pub(super) path: Option<PathBuf>,
pub(super) mtime: Option<std::time::SystemTime>,
pub(super) broken: Option<String>,
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,
}
pub(super) const SUGGEST: &str = "suggest";
pub(super) const LANDS: [(&str, &str); 3] = [
("", "Leave them uncommitted (you commit them)"),
("amend", "Amend each into the commit it belongs in (dv's amend: a backup branch first)"),
("commit", "As new commits"),
];
impl App {
pub(super) fn edits(&self, ask: Ask) -> bool {
match ask {
Ask::Describe | Ask::Close => false,
Ask::Answer => self.threads.iter().any(|t| t.waiting() && super::ask::edits_code(&t.want)),
_ => true,
}
}
pub(super) fn waiting_replies(&self) -> usize {
self.threads.iter().filter(|t| t.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.stale() && (t.by_claude() || !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));
if self.claude.job.is_some() || self.replacement.as_ref().is_some_and(|r| r.drafting_now()) {
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
}
pub(super) fn claude_busy(&self) -> bool {
self.claude.job.is_some() || self.claude.pane_busy || self.replacement.as_ref().is_some_and(|r| r.drafting_now())
}
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(Thread::by_claude) || !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, _) => "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!("Answer your replies ({n} waiting)"), Target::Claude(Ask::Answer)),
Item::new(
match open {
0 => "Make a changeโฆ (no open comments: you say what)".to_owned(),
n => format!("Make the changesโฆ ({n} open comments)"),
},
Target::Claude(Ask::Fix),
),
];
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!("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!("Draft a PR fixing CI (not this PR's doing: {})โฆ", c.why), Target::Do(Action::CiFixPr)));
}
}
items.push(Item::new("Ask 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));
} else if in_zellij() {
let what = match self.claude_busy() {
true => "a copy of its session: it's at work on this one",
false => "its session on this PR, to talk to it",
};
items.push(Item::new(format!("Open Claude in a pane ({what})"), Target::ClaudeOpen));
}
if !self.claude.description.is_empty() {
items.insert(0, Item::new("Discard Claude's proposed description", Target::DescDiscard));
items.insert(0, Item::new("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("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("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("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 at work on this replacement (dv shows it as it goes)", Target::Note(""))),
(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)));
self.picker = Some(Picker::new(" claude ", items).select(want));
}
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);
}
if let Some(r) = &self.replacement {
crate::replace::mark_at_work(r.id, None, j.child.id());
}
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.msg = format!("stopped Claude ({} for {}); its log: {} (and .err)", j.label, ago(j.started), j.log.display());
self.reload_tree(); }
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(&path, "description");
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(&path, "closing");
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(&mut self, key: &str, what: &str) {
let Some(path) = self.claude.path.clone() else { return };
self.msg = match crate::claude::clear(&path, key) {
Ok(()) => {
let keep = self.cursor_key();
self.load_claude();
self.rebuild(keep);
format!("{what} discarded")
}
Err(e) => format!("{e:#}"),
};
}
pub(super) fn outdated_claude_comments(&self) -> Vec<String> {
self.threads.iter().filter(|t| t.by_claude() && 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."
),
};
let land = match ask {
Ask::Fix if self.theirs => "",
Ask::Fix | Ask::Edit => match self.claude.land {
"amend" => " Amend each change into the commit of the branch it belongs in (`dv amend <sha> <files>`, as the dv-review skill says), and say in its reply which commit it went in; a comment whose `want` says otherwise, as it says.",
"commit" => " Commit the changes as new commits (`git add`, `git commit`, messages in the repo's style): one for each comment, or one for those that belong together; say in each reply which commit has it; a comment whose `want` says otherwise, as it says.",
_ => " Leave the changes uncommitted (the user commits them); a comment whose `want` says otherwise, as it says.",
},
_ => "",
};
with_instructions(&format!("{prompt}{land} (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 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::Fix | Ask::Edit => {
let here = if self.theirs { Ask::Edit } else { Ask::Fix };
let mut items: Vec<Item> = LANDS.iter().map(|&(key, label)| Item::new(label, Target::Land(here, key))).collect();
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",
};
items.iter_mut().for_each(|i| i.label = format!("{} ({push})", i.label));
items.insert(0, Item::new("As suggestions for you to post (their code stays as it is)", Target::Land(Ask::Fix, SUGGEST)));
}
let land = match (self.theirs, self.claude.land) {
(true, "") => SUGGEST,
(_, l) => l,
};
let p = Picker::new(" how should the changes land? ", items).select(|t| matches!(t, Target::Land(_, k) if *k == land));
self.picker = Some(p);
}
Ask::Review | Ask::Describe | Ask::Close => self.action = Some(Action::ClaudeWith(ask)),
ask => _ = self.run_claude(ask, ""),
}
}
pub(super) fn land(&mut self, ask: Ask, land: &'static str) {
if land != SUGGEST {
self.claude.land = land;
}
self.action = Some(Action::ClaudeWith(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 crate::pane::offer(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 ci = self.pr.as_ref().filter(|_| matches!(ask, Ask::FixChecks)).map(|p| p.number);
let more = tools_for(ci, matches!(ask, Ask::Fix | Ask::Edit));
let (msg, asked) = match in_zellij() {
true => match self.claude_pane_run(&format!("claude: {label}"), Some(&prompt), Some(ask.work()), false, false) {
Ok(()) => {
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)
}
Err(e) => (format!("{e:#}"), false),
},
false => match self.spawn_claude(&prompt, label, ask.work(), false, &more.iter().map(String::as_str).collect::<Vec<_>>()) {
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 {
let keep = self.cursor_key();
self.load_claude();
self.rebuild(keep);
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)");
}
if self.claude.pane_busy && self.threads.iter().any(|t| t.waiting() && matches!(t.want.as_str(), "amend" | "commit")) {
return "reply saved; Claude's pane is at work here, and this commits: Space a has Claude answer once it's done".into();
}
let prompt = self.claude_prompt(Ask::Answer, "");
let forked = self.claude_pane().is_some();
let more = tools_for(None, true);
match self.spawn_claude(&prompt, "answering", Ask::Answer.work(), forked, &more.iter().map(String::as_str).collect::<Vec<_>>()) {
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)
}
fn claude_pane_run(&mut self, name: &str, prompt: Option<&str>, work: Option<crate::claude::Work>, focus: bool, fork: bool) -> Result<()> {
let mut path_env = std::ffi::OsString::from("PATH=");
path_env.push(path_for_claude());
let mut cmd = std::process::Command::new("zellij");
cmd.args(["run", "--floating", "--name", name, "--cwd"]).arg(&self.root);
if !focus {
cmd.arg("--no-focus");
}
cmd.args(["--", "env"])
.arg(path_env) .args(data_env()) .args(self.pr.as_ref().map(|p| format!("{}={}", crate::claude::PR_ENV, p.number)))
.arg("claude")
.args(prompt)
.args(crate::claude::model_args(work, &self.root)) .args(self.session_args())
.args(fork.then_some("--fork-session"))
.args(claude_dirs())
.args(["--allowedTools", "Bash(dv scratch:*)"]);
let s = cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
.status().context("running zellij")?;
anyhow::ensure!(s.success(), "zellij run failed ({s})");
Ok(())
}
pub(super) fn open_claude_pane(&mut self) {
let busy = self.claude_busy();
if let Err(e) = self.claude_pane_run(if busy { "claude (a copy)" } else { "claude" }, None, None, true, busy) {
self.msg = format!("{e:#}");
}
}
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 crate::pane::show(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() {
return (self.claude.pane_busy, self.claude.asking) = (false, false);
}
if 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(crate::pane::screen);
let asking_now = screen.as_deref().is_some_and(crate::pane::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(crate::pane::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 crate::pane::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())?;
crate::pane::list(&["-c"]).unwrap_or_default().iter().find_map(|p| {
let cmd = p["pane_command"].as_str().filter(|c| p["exited"] != true && c.contains(id.as_str()) && !c.contains("--fork-session"))?;
Some((p["id"].as_u64()?, cmd.to_owned()))
})
}
fn session_args(&mut self) -> [String; 2] {
let args = match &self.claude.path {
Some(p) => crate::claude::session_args(p, &self.root).ok(),
None => None,
};
let args = args.unwrap_or_else(|| ["--session-id".into(), crate::claude::new_session_id()]);
self.claude.session = Some(args[1].clone());
args
}
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, work: crate::claude::Work, 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(work, prompt, &self.root, &session, more, None)
.envs(self.pr.as_ref().map(|p| (crate::claude::PR_ENV, p.number.to_string())))
.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);
}
if let Some(r) = self.replacement.as_ref().filter(|_| self.unpublished()) {
crate::replace::mark_at_work(r.id, Some(child.id()), 0);
}
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 { child, 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);
}
if let Some(r) = &self.replacement {
crate::replace::mark_at_work(r.id, None, child.id());
}
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 && then {
self.amend_now();
} else if label == super::amend::AMEND_LABEL && !failed {
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 label == super::git::COMMIT_LABEL {
let wrote = self.commit_msg_path().and_then(|p| std::fs::read_to_string(&p).ok()).map(|m| m.trim().to_owned()).filter(|m| !m.is_empty() && !failed);
match self.picker.is_none() && self.prompt.is_none() && self.action.is_none() {
true => self.action = Some(Action::CommitWith(wrote.unwrap_or_else(|| self.commit_draft()))),
false => self.msg += " (Space g โ commit opens with its message)",
}
}
if std::mem::take(&mut self.claude.again) && self.waiting_replies() > 0 {
match failed || refused.is_some() || super::rebase::rebasing(&self.root) || label == super::git::COMMIT_LABEL {
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())
}
pub(super) fn ago(t: std::time::Instant) -> String {
crate::gh::ago_secs(t.elapsed().as_secs() as i64)
}