dv-review 0.77.2

A terminal diff viewer for reviewing GitHub PRs: Helix keys, rust-analyzer, inline review comments, and Claude Code reviews
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! What's done with what you write in the editor (`Action`): a comment, a reply, a question for
//! Claude, a commit message, …: `perform`, and what it posts (`Post`).
use super::*;

/// Work that needs the terminal back (an editor), run between frames.
#[derive(Clone)]
pub(super) enum Action {
    /// A shell in the checkout's directory: a floating pane in zellij, else this terminal.
    Shell,
    Open(String),
    /// `review`: add to the pending review instead of posting immediately.
    /// `prefill`: starting text in the editor (a ```suggestion block for `s`).
    /// `claude`: copying Claude's comment (its id in the review file) into the review; the
    /// prefill is its text, posted as is if left unchanged, and the comment is dismissed after.
    Comment { anchor: Anchor, review: bool, prefill: String, claude: Option<String> },
    /// `quote`: the comment under the cursor as a `> ` quote, pre-filled in the editor.
    Reply { thread: String, comment: u64, review: bool, quote: String },
    /// A reply to Claude on its comment (by id), saved to the review file for Claude to answer,
    /// with what you want of it ("ask", "suggest", "change"; empty: unsaid).
    /// (And what the editor starts from: a quote of what's selected, say.)
    ClaudeReply(String, &'static str, String),
    /// One reply to several of Claude's threads (their keys), with what you want of it.
    ClaudeReplyAll(Vec<String>, &'static str),
    /// A comment for Claude on these lines (with their text), then Claude answers it; `about`:
    /// the GitHub review comment it asks about (its id, and a quote of it for the editor).
    AskClaude { anchor: Anchor, code: Vec<String>, about: Option<(u64, String)>, want: &'static str },
    /// `a` on a PR conversation comment: a question about it (its URL), starting from `prefill`.
    /// On a commit (its URL): a question about it, or a change to it (`want`).
    AskConversation { url: String, prefill: String, want: &'static str },
    /// Run Claude (review, or make the changes) after your own instructions for it.
    ClaudeWith(agent::Ask),
    /// Have Claude draft a replacement for these PRs, after your instructions for it (starting
    /// from these, else your last ones: `a` on a draft quotes what it's about).
    /// Claude's replacement of these PRs, drafted (again); `Some`: what you ask of one of its
    /// drafts (which, the quote, what you want: `"ask"`, `"change"`).
    Replace(Vec<u64>, Option<(&'static str, String, &'static str)>),
    /// Publish the replacement here as a draft PR, after the editor (title, description): (then
    /// close what it replaces, next).
    Publish(bool),
    /// A new PR fixing CI for Claude to draft, after the editor (from Claude's note on this PR).
    CiFixPr,
    /// A new PR for Claude to draft, from this prefill (`Space N`: what's selected).
    NewPr(String),
    /// Close the PR with Claude's draft comment, after the editor.
    ClosePr,
    /// Close the PRs the replacement here replaces, after the editor (the note).
    CloseOriginals,
    /// A new PR conversation comment, starting with `quote` (may be empty); `as_is`: posted
    /// even if left untouched (Claude's review, yours to post).
    Converse { quote: String, as_is: bool },
    /// Rewrite one of your comments (node id, current body).
    Edit { comment: String, body: String, author: String },
    /// Submit the pending review: COMMENT, APPROVE or REQUEST_CHANGES.
    Submit(&'static str),
    /// Commit everything uncommitted: Claude writes the message first (`commit_ask`).
    Commit,
    /// Commit everything uncommitted, the editor starting from this message.
    CommitWith(String),
}

/// Something written in the editor, to send to GitHub (as a job, so "posting…" shows first).
pub(super) enum Post {
    /// A new comment on lines (`claude`: copied from Claude's comment, dismissed once sent).
    Thread { anchor: Anchor, review: bool, body: String, claude: Option<String> },
    Reply { thread: String, comment: u64, review: bool, body: String },
    Converse(String),
    /// Your pending review's new text (`c` in the PR section, or on Claude's summary).
    Summary(String),
    Edit { comment: String, body: String },
    Submit(&'static str, String),
}

/// How dv's hint lines in the editor start; they're dropped from what's posted.
pub(super) const HINT: &str = "<!-- dv:";

/// Drop dv's hint line and trim; untouched prefill alone counts as nothing written.
pub(super) fn clean_body(text: &str, mark: &str, prefill: &str) -> String {
    let body: Vec<&str> = text.lines().filter(|l| !l.starts_with(mark)).collect();
    let body = body.join("\n").trim().to_owned();
    if !prefill.is_empty() && body == prefill.trim() { String::new() } else { body }
}

/// If posting failed, save what was written so it isn't lost, and say where.
pub(super) fn keep_on_error(res: Result<()>, body: &str) -> Result<()> {
    res.map_err(|e| {
        let saved = term::unsent_path();
        match !body.is_empty() && std::fs::write(&saved, body).is_ok() {
            // The reason first: a narrow status bar cuts the end off.
            true => anyhow!("{e:#} (text saved to {})", saved.display()),
            _ => e,
        }
    })
}

/// The summary an approval starts from: `ACK <the PR's head commit>`, as Bitcoin-style projects
/// write it. If what you reviewed (`local`, your HEAD) isn't that commit (your branch has
/// diverged from the PR), a hint line above it says so; hint lines aren't posted. Other
/// reviews start empty.
pub(super) fn ack(event: &str, pr: Option<&Pr>, local: &str) -> String {
    let Some(p) = pr.filter(|p| event == "APPROVE" && !p.head_oid.is_empty()) else { return String::new() };
    let short = |h: &str| h.chars().take(8).collect::<String>();
    match p.head_oid == local || local.is_empty() {
        true => format!("ACK {}", p.head_oid),
        false => format!(
            "{HINT} your checkout is at {}, not the PR's head {}: check this ACK names what you reviewed -->\nACK {}",
            short(local),
            short(&p.head_oid),
            p.head_oid
        ),
    }
}

pub(super) fn posted_msg(review: bool) -> &'static str {
    if review { "added to pending review (S to submit)" } else { "posted" }
}

impl App {
    /// Your editor on `prefill` (what a new PR should do: `untouched_ok`, as it is), then Claude
    /// drafting it in a process of its own (`dv replace`: it carries on if dv quits),
    /// unpublished: dv list's drafts tab has it. Its id (None: not asked).
    fn new_pr(&self, what: &str, prefill: &str, untouched_ok: bool) -> Result<Option<u64>> {
        let Some(task) = compose_with(what, false, prefill, untouched_ok)?.filter(|t| !t.trim().is_empty()) else { return Ok(None) };
        let id = crate::replace::create_new(&task, &self.root)?;
        crate::replace::spawn(&[id])?;
        Ok(Some(id))
    }

    pub(super) fn perform(&mut self, a: Action) -> Result<String> {
        match a {
            Action::Shell => {
                let shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
                let root = self.root.display().to_string();
                if in_zellij() {
                    let name = format!("shell: {}", self.root.file_name().map_or(root.clone(), |n| n.to_string_lossy().into_owned()));
                    let st = std::process::Command::new("zellij")
                        .args(["run", "--floating", "--close-on-exit", "--name", &name, "--cwd", &root, "--", &shell])
                        .stdin(std::process::Stdio::null())
                        .stdout(std::process::Stdio::null())
                        .stderr(std::process::Stdio::null())
                        .status()?;
                    anyhow::ensure!(st.success(), "zellij run failed");
                    return Ok(format!("a shell in {root}, floating (exit closes it)"));
                }
                std::process::Command::new(&shell).current_dir(&self.root).status().context("running your shell")?;
                Ok(String::new())
            }
            Action::Open(target) if in_zellij() => self.open_in_hx_pane(&target),
            Action::Open(target) => {
                interactive("hx", std::slice::from_ref(&target), &format!("hx {target}")).context("running hx")?;
                Ok(String::new())
            }
            Action::Comment { anchor, review, prefill, claude } => {
                let range = match anchor.start {
                    Some((s, _)) => format!("{s}-{}", anchor.line),
                    None => anchor.line.to_string(),
                };
                let kind = match (&claude, prefill.is_empty()) {
                    (Some(_), _) => "Claude's comment",
                    (None, true) => "comment",
                    (None, false) => "suggestion",
                };
                let what = format!("{kind} on {}:{range}", anchor.path);
                self.pr.as_ref().context("no PR")?; // before the editor, so nothing written is lost
                let Some(body) = compose_with(&what, false, &prefill, claude.is_some())? else {
                    return Ok(format!("{kind} cancelled"));
                };
                self.post_later(Post::Thread { anchor, review, body, claude })
            }
            Action::Reply { thread, comment, review, quote } => {
                self.pr.as_ref().context("no PR")?;
                let Some(body) = compose_with("reply", false, &quote, false)? else { return Ok("reply cancelled".into()) };
                self.post_later(Post::Reply { thread, comment, review, body })
            }
            Action::ClaudeReply(k, want, prefill) => {
                let p = self.claude.path.clone().context("no review file")?;
                let what = format!("reply to Claude{}", ask::want_hint(want));
                let Some(body) = compose_change(&what, want, &prefill)? else { return Ok("reply cancelled".into()) };
                keep_on_error(crate::claude::reply(&p, &k, &body, want), &body)?;
                Ok(self.answer_now())
            }
            Action::ClaudeReplyAll(keys, want) => {
                let p = self.claude.path.clone().context("no review file")?;
                let what = format!("one reply to Claude's {} comments{}", keys.len(), ask::want_hint(want));
                let Some(body) = compose_change(&what, want, "")? else { return Ok("reply cancelled".into()) };
                // Each written that can be (one gone meanwhile: said), and those answered.
                let mut errs: Vec<(String, anyhow::Error)> = keys.iter().filter_map(|k| crate::claude::reply(&p, k, &body, want).err().map(|e| (k.clone(), e))).collect();
                if errs.len() == keys.len() {
                    keep_on_error(Err::<(), _>(errs.remove(0).1), &body)?;
                }
                let failed: Vec<String> = errs.into_iter().map(|(k, e)| format!("{k}: {e:#}")).collect();
                let said = self.answer_now();
                Ok(match failed.is_empty() {
                    true => said,
                    false => format!("{said} (not replied to: {})", failed.join("; ")),
                })
            }
            Action::AskClaude { anchor, code, about, want } => {
                let p = self.claude.path.clone().context("no review file")?;
                let range = anchor.start.map_or(anchor.line.to_string(), |(s, _)| format!("{s}-{}", anchor.line));
                let what = match about {
                    Some(_) => format!("ask Claude about this review comment on {}:{range} (it answers right away; nothing goes to GitHub)", anchor.path),
                    None => format!("a comment for Claude on {}:{range}{}", anchor.path, ask::want_hint(want)),
                };
                let prefill = about.as_ref().map_or(String::new(), |(_, q)| q.clone());
                let Some(body) = compose_with(&what, false, &prefill, false)? else { return Ok("comment cancelled".into()) };
                keep_on_error(crate::claude::add_comment(&p, &anchor, code, &body, about.map(|a| a.0), want).map(drop), &body)?;
                Ok(self.answer_now())
            }
            Action::AskConversation { url, prefill, want } => {
                let p = self.claude.path.clone().context("no review file")?;
                let what = match (url == ON_SUMMARY, commit_of(&url)) {
                    (true, _) => "a question for Claude about the PR, or a reply to its review (it answers in the Claude tab; nothing goes to GitHub)".to_owned(),
                    (_, Some(sha)) => format!("for Claude, about commit {}{}", self.commit_short(sha), ask::want_hint(want)),
                    _ => "ask Claude about this conversation comment (it answers under it; nothing goes to GitHub)".to_owned(),
                };
                let Some(body) = compose_with(&what, false, &prefill, false)? else { return Ok("question cancelled".into()) };
                keep_on_error(crate::claude::add_conversation(&p, &url, &body, want).map(drop), &body)?;
                Ok(self.answer_now())
            }
            Action::ClaudeWith(ask) => {
                // Optional (empty goes ahead) unless they're the whole request: a change with no
                // open comments. Quitting the editor with an error (`:cq`) cancels.
                let alone = matches!(ask, agent::Ask::Fix | agent::Ask::Edit) && self.change_requests() == 0 || matches!(ask, agent::Ask::Describe);
                let what = match (ask, alone) {
                    (agent::Ask::Review, _) => "anything for Claude to look at or keep in mind in its review (optional)",
                    (agent::Ask::Describe, _) if !self.claude.description.is_empty() => "what else to change in Claude's proposed description",
                    (agent::Ask::Describe, _) => "what to change in the PR's description (Claude proposes a new one; you approve it)",
                    (agent::Ask::Close, _) if !self.claude.closing.is_empty() => "what to change in Claude's closing comment",
                    (agent::Ask::Close, _) => "why close it, and anything the comment should say (optional: Claude drafts it; you edit it before it goes)",
                    (_, true) => "what Claude should change (there are no open comments to act on)",
                    _ => "instructions for Claude, on top of the open comments (optional)",
                };
                // Review instructions start from the last ones (kept if left as they are).
                let review = matches!(ask, agent::Ask::Review);
                let last = if review { crate::claude::instructions("review") } else { String::new() };
                let Some(extra) = compose_with(what, !alone, last.trim(), review)? else { return Ok("Claude not asked".into()) };
                if review {
                    _ = crate::claude::save_instructions("review", &extra);
                }
                if self.run_claude(ask, &extra) || extra.trim().is_empty() {
                    return Ok(std::mem::take(&mut self.msg));
                }
                // It couldn't be asked after all: keep what you wrote.
                let why = std::mem::take(&mut self.msg);
                Ok(keep_on_error(Err(anyhow::anyhow!(why)), &extra).err().map_or(String::new(), |e| format!("{e:#}")))
            }
            Action::Replace(prs, about) => {
                // Claude at work in its checkout: drafting it (from anywhere), or this dv's run or
                // pane there (a replacement's view is its checkout).
                let here = self.replacement.as_ref().is_some_and(|r| r.id == prs[0]) && (self.claude.job.is_some() || self.claude.pane_busy);
                let drafting = || here || crate::replace::load(prs[0]).is_ok_and(|r| r.drafting_now());
                let which = prs.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
                // Drafted already: what you want is asked of its drafts (kept with them, shown
                // there), for the redraft to take in.
                if crate::replace::drafted(prs[0]) || crate::replace::is_new(prs[0]) {
                    let (what, (on, prefill, want)) = match about {
                        Some(q @ (_, _, "ask")) => (format!("ask Claude about the replacement of {which}, under the quote (it answers; it changes nothing)"), q),
                        Some(q) => (format!("ask Claude to redraft the replacement of {which}: what to change, under the quote"), q),
                        None => (format!("what else Claude should do in its redraft of {which} (optional)"), ("", String::new(), "")),
                    };
                    let asking = !prefill.is_empty();
                    let Some(ask) = compose_with(&what, !asking, prefill.trim_end(), !asking)? else { return Ok("not asked".into()) };
                    if !ask.trim().is_empty() {
                        crate::replace::ask(prs[0], &ask, on, want)?;
                    }
                    if drafting() {
                        return Ok(match ask.trim() {
                            "" => format!("Claude is already drafting the replacement of #{}", prs[0]),
                            _ => "kept with the drafts, for Claude's redraft: shown as it takes it in (else Space a → Redraft once it's done)".into(),
                        });
                    }
                } else {
                    if drafting() {
                        return Ok(format!("Claude is already drafting the replacement of #{}", prs[0]));
                    }
                    let what = format!("instructions for Claude's replacement of {which} (optional)");
                    let Some(extra) = compose_with(&what, true, crate::claude::instructions("replace").trim_end(), true)? else { return Ok("not asked".into()) };
                    crate::claude::save_instructions("replace", &extra)?; // `dv replace` reads them
                    if drafting() {
                        return Ok(format!("Claude started drafting the replacement of #{} meanwhile", prs[0]));
                    }
                }
                crate::replace::spawn(&prs)?;
                Ok(format!("Claude is drafting a replacement for {which} in the background: dv list's replacements tab shows it"))
            }
            Action::Publish(close) => {
                // (It rebases and pushes the branch.)
                anyhow::ensure!(!self.claude_busy(), "Claude is still at work here: publish once it's done");
                let mut r = self.replacement.clone().context("not a replacement")?;
                if crate::replace::remote().is_none() {
                    return Ok(self.ask_replace_remote(close));
                }
                let prefill = format!("{}\n\n{}", r.title.trim(), r.body.trim());
                let kind = "draft PR";
                let what = format!("{kind} for {}: its title (first line), then its description", crate::replace::branch(r.id));
                let Some(text) = compose_with(&what, false, &prefill, true)? else { return Ok("not published".into()) };
                let (title, body) = text.split_once('\n').map_or((text.as_str(), ""), |(t, b)| (t, b));
                let (title, body) = (title.trim(), body.trim());
                if title.is_empty() {
                    return Ok("not published: it needs a title (the first line)".into());
                }
                let res = crate::replace::publish(&mut r, &self.root, title, body);
                keep_on_error(res.as_ref().map(drop).map_err(|e| anyhow!("{e:#}")), &text)?;
                let originals = !r.replaces.is_empty(); // a new PR replaces nothing
                self.show_replacement(r);
                if let Ok(n) = res {
                    self.become_pr(n);
                }
                if close {
                    self.action = Some(Action::CloseOriginals); // its own editor, next
                }
                let then = if originals { ": S closes what it replaces, with the note" } else { "" };
                // Ready for review only once CI passes: `Space g` there, which checks.
                Ok(format!("published as {kind} #{}{then} (ready for review once CI passes: Space g)", res.unwrap_or(0)))
            }
            Action::ClosePr => self.close_pr(),
            Action::CiFixPr => {
                let n = self.pr.as_ref().map_or(0, |p| p.number);
                let c = self.ci_note(n).context("no fix in mind for CI any more")?;
                let id = self.new_pr("a new PR fixing CI, for Claude to draft: what it should do; unpublished until you publish it", &c.prefill(n), true)?;
                if id.is_some() {
                    crate::claude::ci_fix_drafted(n); // not offered again
                }
                Ok(drafting(id))
            }
            Action::NewPr(prefill) => Ok(drafting(self.new_pr("a new PR for Claude to draft: what it should do; unpublished until you publish it", &prefill, false)?)),
            Action::CloseOriginals => {
                let mut r = self.replacement.clone().context("not a replacement")?;
                let which: Vec<String> = r.replaces.iter().filter(|o| !r.closed.contains(&o.number)).map(|o| format!("#{}", o.number)).collect();
                let what = format!("note closing {} (each gets it as a comment, then is closed)", which.join(", "));
                let Some(note) = compose_with(&what, false, &crate::replace::note(&r), true)? else { return Ok("nothing closed".into()) };
                let res = crate::replace::close_originals(&mut r, &note);
                self.show_replacement(r);
                let done = keep_on_error(res.as_ref().map(drop).map_err(|e| anyhow!("{e:#}")), &note).map(|_| res.unwrap_or_default())?;
                let done: Vec<String> = done.iter().map(|n| format!("#{n}")).collect();
                Ok(format!("closed {}, with the note", done.join(", ")))
            }
            Action::Converse { quote, as_is } => {
                self.pr.as_ref().context("no PR")?;
                let Some(body) = compose_with("comment on the PR conversation", false, &quote, as_is)? else {
                    return Ok("comment cancelled".into());
                };
                self.post_later(Post::Converse(body))
            }
            Action::Edit { comment, body: old, author } => {
                // Unchanged or emptied counts as cancelled; deleting a comment is a separate thing.
                // Maintainers can edit others' comments too, so the hint names whose this is.
                let what = format!("editing @{author}'s comment");
                let Some(body) = compose_with(&what, false, &old, false)? else { return Ok("edit cancelled".into()) };
                self.post_later(Post::Edit { comment, body })
            }
            Action::Submit(event) => {
                // An empty summary is fine; quitting the editor with an error (`:cq`) cancels.
                // Approving starts from "ACK <the PR's head commit>", posted as is if kept.
                let what = match event {
                    review::CLOSE => "closing comment (empty: just close the PR)".to_owned(),
                    e => format!("review summary ({})", e.to_lowercase().replace('_', " ")),
                };
                let head = diff::git(&["rev-parse", "HEAD"]).unwrap_or_default();
                let mut prefill = ack(event, self.pr.as_ref(), head.trim());
                if !self.summary_draft.is_empty() {
                    prefill = format!("{}\n\n{prefill}", self.summary_draft.trim_end());
                }
                if event == "APPROVE" {
                    // (A checkout that isn't the PR's head: the ACK's own hint says so.)
                    let notes = self.submit_notes().into_iter().filter(|n| !n.starts_with("your checkout"));
                    let notes: String = notes.map(|n| format!("{HINT} still open: {n} -->\n")).collect();
                    prefill = notes + &prefill;
                }
                let Some(body) = compose_with(&what, true, &prefill, true)? else {
                    return Ok("review not submitted".into());
                };
                self.summary_draft.clear();
                self.post_later(Post::Submit(event, body))
            }
            Action::Commit => {
                anyhow::ensure!(!self.claude_busy(), "Claude is still at work here: commit once it's done (its changes would be half made)");
                // Claude writes the message (then your editor has it); no review file to keep
                // it beside: the draft from its replies, as before.
                match self.claude.path.is_some() {
                    true => self.commit_ask(),
                    false => {
                        let draft = self.commit_draft();
                        self.commit(&draft)
                    }
                }
            }
            Action::CommitWith(msg) => self.commit(&msg),
        }
    }

    /// `Enter` inside zellij: open the file in dv's hx pane, reusing it if it's still open
    /// (`:open` + `:goto` typed into it, after `Esc` for normal mode), else in a new pane below
    /// dv. dv keeps working either way.
    pub(super) fn open_in_hx_pane(&mut self, target: &str) -> Result<String> {
        if let Some(id) = self.hx.as_ref().map(|h| h.pane.clone()).filter(|id| crate::pane::alive(id)) {
            // `target` is path:line:col.
            let mut parts = target.rsplitn(3, ':');
            let (_col, line, path) = (parts.next(), parts.next().unwrap_or("1"), parts.next().unwrap_or(target));
            crate::pane::action(&["write-chars", "-p", &id, "\x1b"])?;
            crate::pane::action(&["write-chars", "-p", &id, &format!(":open {path}\r:goto {line}\r")])?;
            crate::pane::action(&["focus-pane-id", &id])?;
            return Ok("opened in hx (its pane below); dv reloads as you save".into());
        }
        let (st, id) = term::zellij_pane("hx", &[target.to_owned()], "hx", &self.root, false)?;
        anyhow::ensure!(st.success(), "zellij run failed ({st})");
        self.hx = id.map(|pane| HxPane { pane });
        Ok("hx is open below; dv reloads as you save, and Enter opens the next file there".into())
    }
}

impl App {
    /// Between frames: what was asked that takes the editor (or its text, written beside dv
    /// once its pane is done) done, and the diff reloaded after.
    pub(super) fn act(&mut self, term: &mut DefaultTerminal) {
        // Written (the editor's pane closed): what asked for it goes on, given the text
        // (once nothing else is to be done first).
        let (mut given, mut kept) = (None, None); // (set below, once the pane's done)
        if self.action.is_none()
            && let Some(text) = self.writing.as_mut().and_then(|w| w.1.done())
        {
            let (a, w) = self.writing.take().unwrap();
            (self.action, given, kept) = (Some(a), Some(text), w.kept);
            focus_dv();
        }
        if let Some(a) = self.action.take() {
            // In zellij the editor gets a pane below and dv stays on screen above it.
            let in_pane = in_zellij();
            if !in_pane {
                restore_terminal();
            }
            // Anything to post is queued as a job (it runs after a "posting…" frame, then
            // refetches); here only the diff reloads, as hx may have changed files.
            let again = a.clone();
            let (res, left) = term::writing_beside(self.writing.is_some(), given.take(), || self.perform(a));
            // The editor in its pane, dv going on: the rest once it's written.
            if res.as_ref().is_err_and(|e| e.is::<term::Writing>()) {
                let w = res.unwrap_err().downcast().unwrap();
                self.writing = Some((again, w));
                self.msg = "writing in the editor's pane: dv's yours meanwhile (save and quit there to send; :cq cancels)".into();
                return;
            }
            // Written, but what asked gave up before its editor: kept, not lost.
            let res = match left.flatten().filter(|b| !b.trim().is_empty()) {
                Some(body) => {
                    let why = res.as_ref().map_or_else(|e| format!("{e:#}"), String::clone);
                    Err(keep_on_error(Err(anyhow!("{why}: what you wrote wasn't sent")), &body).unwrap_err())
                }
                None => res,
            };
            if !in_pane {
                *term = init_terminal();
            }
            self.reloaded(res, false);
            if let Some(k) = kept.take() {
                self.msg = format!("{}: its pane closed; what it had saved is kept in {}", self.msg, k.display());
            }
        }
    }
}

/// What you write to Claude (`compose_with`): wanting the code changed, left empty (or only
/// the quote it started with) it's "what your comment says".
fn compose_change(what: &str, want: &str, prefill: &str) -> Result<Option<String>> {
    if !ask::edits_code(want) {
        return compose_with(what, false, prefill, false);
    }
    let what = format!("{what}; left empty: what Claude's comment says");
    Ok(compose_with(&what, true, prefill, true)?.map(|b| match b.trim().is_empty() || b.trim() == prefill.trim() {
        true => format!("{prefill}Make the change your comment describes.").trim_start().to_owned(),
        false => b,
    }))
}

/// What's said of a new PR started (`App::new_pr`), or not.
fn drafting(id: Option<u64>) -> String {
    match id {
        Some(id) => format!("Claude is drafting {} in the background: dv list's drafts tab shows it (Enter there reviews it, S publishes it)", crate::replace::label(id)),
        None => "not asked".into(),
    }
}