use crate::diff::{self, FileDiff, Kind, Line as DLine};
use crate::gh::{self, Anchor, Pr, Thread};
use crate::highlight::{Colors, Highlighter};
use crate::theme::{Settings, Theme, Ui};
use crate::lsp::{self, Loc, Lsp};
use anyhow::{anyhow, bail, Context, Result};
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
DefaultTerminal, Frame,
};
use std::collections::{HashMap, HashSet};
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod agent;
mod amend;
mod rebase;
pub(crate) use agent::{REPLACE_TOOLS, tools_for, answer_prompt, close_if_done, pane_screen, asking, working, claude_command, claude_wrote, fix_checks_prompt, review_prompt, turn, update_prompt, with_instructions};
mod build;
use build::*;
mod files;
mod git;
mod info;
use info::*;
#[cfg(test)]
pub(crate) use info::info_lines_of;
mod keys;
mod layout;
mod mouse;
mod motion;
mod ra;
mod render;
mod review;
mod select;
mod term;
pub use term::{MOUSE, init_terminal, restore_terminal};
pub(crate) use term::{compose_with, in_zellij};
pub(crate) use review::prefetch_github;
use term::*;
#[cfg(test)]
pub(crate) mod tests;
const HELP: &str = "
MOVE
h j k l, arrows move (counts: 5j)
w b e W B E word motions (select)
gh gl gs line start / end / first non-blank
f t F T <c> find char
gw jump labels
mm matching bracket
gg ge top / end (in the comments pane: its first / last)
Ctrl-d Ctrl-u half page down / up
Ctrl-e Ctrl-y scroll 5 lines
zz zt zb cursor line to center / top / bottom
Ctrl-o jump back
DIFF
]g [g next / prev hunk (change)
]f [f, Space f next / prev file, file picker
]c [c next / prev comment thread
]u [u next / prev unresolved one (wraps around)
]a [a next / prev Claude comment (wraps around)
]p [p next / prev comment of your pending review (wraps around)
/ n N * search, next / prev match, search selection
SELECT & COPY
x select line (repeat to extend)
v ; Esc select mode / collapse / exit select mode
y (or Space y) copy text
Y copy as patch
Space c copy: a link's URL, file:line, or a comment's / the PR's link
VIEW
+ = - hunk context +10 / -10 lines
Z whole file
Tab fold file / PR info / thread
gn gp comments pane: next / previous tab (comments, Claude's, the PR's)
Shift-Tab fold / unfold all files
Ctrl-w v toggle split (side-by-side) view
Ctrl-w n relative line numbers on / off (kept for next time)
Ctrl-w e the contents on the left (description, messages, files), on / off
and the PR's commits under it: Enter shows one, v … Enter a range,
m its whole message, a asks Claude about it (or for a change),
f folds what isn't committed into it;
Ctrl-w j / Ctrl-w k (or Tab there) go to the commits / the files
Ctrl-w h Ctrl-w l pane left / right: files, old / new side, comments (or Ctrl-h Ctrl-l)
Ctrl-w c show / hide the comments pane (╎ ● mark their lines)
Ctrl-w f comments pane: a column beside the code / floating over it
Ctrl-w , Ctrl-w . the pane you're in narrower / wider (Ctrl-w = usual width)
Ctrl-w q hide the pane you're in (files' list or comments)
Enter open hx at cursor (on a link: open it)
gf open the link under the cursor, or go to the file:line under it
(as comments cite code) or to where the function / type named
under it is defined (Ctrl-o comes back)
RUST-ANALYZER
gd gr gy gi definition / references / type / impl (on a name in a comment too)
Space k hover; on an issue/PR link: preview it
ON SELECTED LINES
c comment, in your pending review (C: post it now)
s suggest a change, in your pending review
a a comment for Claude: an answer, a suggestion, or the change you want
(it acts right away; not on GitHub); on a
replacement's description or closing note: ask Claude to redraft it
ON A REVIEW THREAD
r reply, in your pending review (C: post it now)
Space r resolve / unresolve
Space = react (👍 🎉 …), or take your reaction back (PR comments too)
Space l the PR's labels, assignees, reviewers: add or remove (request reviews)
Space e Space d edit / delete your comment (delete asks first)
ON CLAUDE'S COMMENT
r reply to Claude, saying what you want (it acts right away, in the
background)
c copy it into your pending review (C: post it now)
Space r Space d resolve (Claude skips it) / dismiss
ON PR TEXT
c quote it in your review summary (S starts from it)
C quote it in a new conversation comment
ON CLAUDE'S REVIEW (its summary)
c add it to your review summary (S starts from it)
C post it now, as your PR comment
REVIEW
S submit (comment / approve / request changes), or discard
Space g git: show only what isn't committed (Claude's changes), commit, amend
it into the commits it belongs in, push, update the checkout, rebase
onto the base (Claude resolves conflicts)
Space p Space t pick from your pending comments (Ctrl-d deletes) / all threads
Space v mark the file viewed (folds it, moves on)
Space a ask Claude Code to review, answer your replies, or make the changes
(or check on its run now, or stop one that's stuck)
Space A pick from Claude's comments ([waiting]: its turn, [answered]: yours)
]n [n Space n next / prev [new] thread (ones you haven't seen) / pick from them
GENERAL
? this help (/ to search it)
R refresh (git diff + GitHub)
Space m messages so far, in full (the status bar cuts long ones)
Space s a shell in the checkout's directory (zellij: a floating pane)
Space o the PR's whole activity, or its latest (beside the description)
mouse wheel scrolls, click moves (or folds, on an arrow), drag selects
q, Ctrl-c quit
";
const DEFAULT_CONTEXT: u32 = 3;
const CTRL_W: char = '\u{17}';
const HL_MAX_BYTES: usize = 1 << 20;
const INFO: usize = usize::MAX - 1;
pub(super) const MARK_STALE_LINE: u8 = 1;
pub(super) const MARK_LINE: u8 = 2;
pub(super) const MARK_STALE_DOT: u8 = 3;
pub(super) const MARK_CLAUDE_DOT: u8 = 4;
pub(super) const MARK_DOT: u8 = 5;
const SUMMARY: usize = usize::MAX;
const DRAFT: usize = usize::MAX - 2;
const FACTS: usize = usize::MAX - 3;
const MORE_CONVERSATION: usize = usize::MAX - 4;
const LATEST: usize = 3;
const DESCRIPTION: usize = 0;
const ON_SUMMARY: &str = "summary";
const CLAUDE_REVIEW: usize = usize::MAX;
const CLOSING_NOTE: usize = usize::MAX - 1;
const CLOSING_PR: usize = usize::MAX - 2;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pos {
row: usize,
col: usize,
}
struct Row {
file: usize,
line: DLine,
thread: Option<usize>,
syn: Vec<Option<Style>>,
links: Vec<(usize, usize, String)>,
info: Option<usize>,
edge: bool,
nowrap: bool,
main: Option<usize>,
attached: bool,
}
impl Row {
fn new(file: usize, line: DLine) -> Row {
Row { file, line, thread: None, syn: vec![], links: vec![], info: None, edge: false, nowrap: false, main: None, attached: false }
}
fn text(file: usize, kind: Kind, text: String) -> Row {
Row::new(file, DLine { kind, old: None, new: None, text })
}
}
struct CursorKey {
line: Option<(String, Option<u32>, Option<u32>)>,
thread: Option<(u64, usize)>,
info: Option<(Option<String>, usize, usize)>,
section: Option<(usize, usize)>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum BoxPart {
Top,
Divider,
Body,
Bottom,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum DRow {
Full(usize),
Pair(Option<usize>, Option<usize>),
Margin(Option<usize>, Option<usize>, Option<usize>),
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Bar {
Comments,
Claude,
Pending,
}
const MARGIN_MIN: usize = 60;
const MARGIN_SPLIT_MIN: usize = 70;
const MARGIN_STEP: isize = 6;
const MARGIN_NARROW: usize = 36;
const MARGIN_MAX: usize = 80;
const MARGIN_CODE_MIN: usize = 80;
const MARGIN_CODE_MAX: usize = 200;
#[derive(Clone)]
enum Action {
Shell,
Open(String),
Comment { anchor: Anchor, review: bool, prefill: String, claude: Option<String> },
Reply { thread: String, comment: u64, review: bool, quote: String },
ClaudeReply(String, &'static str, String),
AskClaude { anchor: Anchor, code: Vec<String>, about: Option<(u64, String)>, want: &'static str },
AskConversation { url: String, prefill: String, want: &'static str },
ClaudeWith(agent::Ask),
Replace(Vec<u64>, Option<String>),
Publish(bool),
CiFixPr,
ClosePr,
CloseOriginals,
Converse { quote: String, as_is: bool },
Edit { comment: String, body: String, author: String },
Submit(&'static str),
Commit(String),
}
enum Job {
Refresh,
Pull,
Resolve(String, bool),
Viewed(String, bool),
React(String, &'static str, bool),
Discard(String),
DeleteComment(String),
Preview(String),
Reactions(String),
Rebase,
Retarget(String),
Rerun,
Labels,
SetField(gh::Field, String, bool),
Push(bool),
Post(Post),
}
enum Post {
Thread { anchor: Anchor, review: bool, body: String, claude: Option<String> },
Reply { thread: String, comment: u64, review: bool, body: String },
Converse(String),
Summary(String),
Edit { comment: String, body: String },
Submit(&'static str, String),
}
enum Target {
Row(usize),
Loc(Loc),
Submit(&'static str),
Discard,
DiscardYes,
DeleteComment(String),
Comment(usize, usize),
Claude(agent::Ask),
Want(&'static str),
ClaudeNotes,
ClaudeQuestion,
ClaudeReplace(Vec<u64>),
Publish(bool),
CloseOriginals,
ReplaceRemote(String, bool),
ClaudeFresh,
ClaudePane,
ClaudeCheck,
ClaudeStop,
GitView,
GitCommit,
SetField(gh::Field, String, bool),
React(String, &'static str, bool),
GitUpdate,
AmendMenu,
Amend,
AmendAsk,
GitRerun,
Rebase,
RebaseClaude,
RebaseAbort,
GitPush,
GitForcePush,
GitTakeTheirs(bool),
GitMerge,
ClaudeDismissOutdated(bool),
GitReady,
CiFixPr,
GitApproveRuns,
DescApprove(bool),
DescDiscard,
ClosePr,
CloseDiscard,
Nothing,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(super) enum View {
All,
Claude,
Pending,
}
struct Item {
label: String,
detail: Vec<String>,
target: Target,
}
impl Item {
fn new(label: impl Into<String>, target: Target) -> Item {
Item { label: label.into(), detail: vec![], target }
}
}
pub(super) fn commit_of(url: &str) -> Option<&str> {
url.rsplit_once("/commits/").map(|(_, s)| s).or_else(|| url.strip_prefix("commit:"))
}
impl App {
pub(super) fn commit_short(&self, sha: &str) -> String {
self.commits.iter().find(|c| c.sha == sha).map_or_else(|| sha.chars().take(7).collect(), |c| c.short.clone())
}
}
const THREAD_TABS: [&str; 5] = [
" threads: [all] unresolved pending Claude new ",
" threads: all [unresolved] pending Claude new ",
" threads: all unresolved [pending] Claude new ",
" threads: all unresolved pending [Claude] new ",
" threads: all unresolved pending Claude [new] ",
];
const THREADS_ALL: usize = 0;
const THREADS_UNRESOLVED: usize = 1;
const THREADS_PENDING: usize = 2;
const THREADS_CLAUDE: usize = 3;
const THREADS_NEW: usize = 4;
struct Picker {
title: &'static str,
items: Vec<Item>,
query: String,
sel: usize,
}
enum Req {
Init,
Goto,
Hover,
Symbol { name: String, then: Option<&'static str> },
}
#[derive(Default)]
struct ClaudeState {
path: Option<PathBuf>,
mtime: Option<std::time::SystemTime>,
summary: String,
head: String,
description: String,
closing: String,
job: Option<agent::ClaudeJob>,
session: Option<String>,
again: bool,
runs: u32,
log: Option<PathBuf>,
spent: f64,
pane_check: Option<std::time::Instant>,
asking: bool,
pane_busy: bool,
}
struct Seen {
keys: HashSet<String>,
before: HashSet<String>,
path: Option<PathBuf>,
current: Vec<String>,
conversation: bool,
since: std::time::Instant,
}
pub struct App {
base: String,
full_base: String,
pub follow_base: Option<String>,
to: Option<String>,
commits: Vec<files::Commit>,
unsigned: Vec<String>,
awaiting: usize,
failing: usize,
commit_shown: Option<(usize, usize)>,
commit_cur: usize,
commit_anchor: Option<usize>,
in_commits: bool,
files: Vec<FileDiff>,
extra: HashMap<(String, usize), i64>,
view_extra: HashMap<(String, usize), i64>,
whole: HashSet<String>,
collapsed: HashSet<String>,
rows: Vec<Row>,
anchor: Pos,
head: Pos,
want_col: usize,
select: bool,
count: usize,
pending: Option<char>,
prompt: Option<String>,
picker: Option<Picker>,
wanting: Option<Action>,
github_base: Option<String>,
refresh_at: Option<std::time::Instant>,
dirty: files::Dirty,
rekeyed: Vec<String>,
search: String,
msg: String,
top: usize,
left: usize,
pin: bool,
bar_counts: bool,
code_at: Option<usize>,
code_left: usize,
height: usize,
width: u16,
action: Option<Action>,
job: Option<Job>,
review: Option<String>,
viewed: HashMap<String, String>,
split: bool,
right: bool,
margin_pref: Option<bool>,
margin_active: bool,
margin_w: usize,
margin_w_pref: Option<usize>,
margin_float_pref: Option<bool>,
files_pref: Option<bool>,
files_w_pref: Option<usize>,
files_w: usize,
files_top: usize,
list_hits: Vec<files::Hit>,
files_cur: Option<files::Spot>,
files_focus: bool,
files_sel: Option<files::Spot>,
pane_file: Option<PathBuf>,
margin_float: bool,
code_head: Pos,
margin_anchor: HashMap<usize, usize>,
marks: HashMap<(usize, bool), u8>,
geom: mouse::Geom,
drows: Vec<DRow>,
d_of: Vec<usize>,
emph: HashMap<usize, Vec<(usize, usize)>>,
settings: Settings,
quit: bool,
pub merge: bool,
pr: Option<Pr>,
threads: Vec<Thread>,
info: Vec<InfoLine>,
info_collapsed: bool,
sync: String,
sections_folded: HashSet<usize>,
commit_msgs: Vec<String>,
hl: Highlighter,
ui: Ui,
hl_cache: HashMap<(String, bool), Colors>,
md_cache: std::cell::RefCell<HashMap<String, Vec<crate::md::MdLine>>>,
wrap_w: usize,
toggled: HashSet<u64>,
claude: ClaudeState,
theirs: bool,
view: View,
pr_state: Option<State>,
replacement: Option<crate::replace::Record>,
replacement_mtime: Option<std::time::SystemTime>,
summary_draft: String,
me: String,
seen: Seen,
hx: Option<HxPane>,
writing: Option<(Action, term::Writing)>,
quit_armed: Option<std::time::Instant>,
tree_checked: std::time::Instant,
tree_seen: Option<(Option<std::time::SystemTime>, u64)>,
msgs: Vec<(String, String)>,
root: PathBuf,
lsp: Option<Lsp>,
lsp_ready: bool,
inflight: HashMap<u64, Req>,
progress: HashMap<String, (String, String)>,
popup: Option<(String, Vec<Line<'static>>, u16)>,
help: Option<(String, bool)>,
jumps: Vec<Pos>,
alphabet: Vec<char>,
labels: Vec<(Pos, Pos, [char; 2])>,
label_first: Option<char>,
github: Option<std::sync::mpsc::Receiver<(u64, review::Fetched)>>,
github_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
github_poke: Option<std::sync::mpsc::Sender<()>>,
conversation_all: bool,
gone_at: usize,
revealed: HashSet<String>,
hid_again: HashSet<String>,
base_said: String,
github_stale: bool,
}
impl App {
pub fn new(base: String, pr: Option<Pr>) -> Result<Self> {
crate::progress(Some("loading the diff"));
let files = diff::load(&base, None, DEFAULT_CONTEXT)?;
let mut app = Self::with(base, files);
app.load_commits();
let theme = Theme::load();
(app.hl, app.ui) = (Highlighter::new(&theme), theme.ui());
app.settings = crate::theme::settings();
app.load_pane();
app.claude.path = crate::claude::path(pr.as_ref().map(|p| p.number)).ok();
app.load_seen();
let session = app.claude.path.as_ref().and_then(|p| crate::claude::load_session(p, &app.root));
let other_skill = session.as_ref().is_some_and(|s| !s.1);
app.claude.session = session.filter(|s| s.1).map(|s| s.0);
if pr.is_some() {
crate::progress(Some("loading comments and PR info"));
}
app.pr = pr;
let n = app.pr.as_ref().map(|p| p.number);
let cached = n.is_some_and(|n| app.show_cached(n));
match cached {
true => app.load_claude(),
false => app.load_threads(),
}
app.rekey_commits(); if let Some(n) = n {
app.start_github_watch(n, cached);
}
if other_skill && (app.msg.is_empty() || app.github_stale) {
app.msg = "dv's Claude skill has changed since this PR's Claude session began: the next Claude run starts a new one".into();
}
app.refresh_sync();
app.build();
match Lsp::start(&app.root) {
Ok((l, id)) => {
app.lsp = Some(l);
app.inflight.insert(id, Req::Init);
}
Err(e) => app.msg = format!("{e:#}"),
}
Ok(app)
}
fn log_msg(&mut self) {
let m = self.msg.trim();
if m.is_empty() || m.ends_with('…') || self.msgs.last().is_some_and(|l| l.1 == m) {
return; }
let at = crate::claude::now().get(11..19).unwrap_or("").to_owned();
self.msgs.push((at, m.to_owned()));
if self.msgs.len() > 100 {
self.msgs.remove(0);
}
}
fn messages(&mut self) {
self.log_msg();
let mut md: String = self.msgs.iter().rev().map(|(at, m)| format!("- `{at}` {m}\n")).collect();
if md.is_empty() {
md = "_no messages yet_\n".into();
}
if let Some(log) = &self.claude.log {
md += &format!("\nThe last background Claude run's log: `{}` (its errors: `.err`)\n", log.display());
md += &format!("\nBackground Claude runs so far: {}, ${:.2} (runs in a pane aren't counted)\n", self.claude.runs, self.claude.spent);
}
self.popup = Some((" messages (UTC) · j/k scroll ".into(), self.hover_lines(&md), 0));
}
pub fn notice(&mut self, msg: String) {
self.msg = msg;
}
fn with(base: String, files: Vec<FileDiff>) -> Self {
let mut app = App {
full_base: base.clone(),
follow_base: None,
base,
to: None,
commits: vec![],
unsigned: vec![],
awaiting: 0,
failing: 0,
commit_shown: None,
commit_cur: 0,
commit_anchor: None,
in_commits: false,
files,
extra: HashMap::new(),
view_extra: HashMap::new(),
whole: HashSet::new(),
collapsed: HashSet::new(),
rows: vec![],
anchor: Pos::default(),
head: Pos::default(),
want_col: 0,
select: false,
count: 0,
pending: None,
prompt: None,
picker: None,
wanting: None,
rekeyed: vec![],
dirty: Default::default(),
github_base: None,
refresh_at: None,
search: String::new(),
msg: String::new(),
top: 0,
left: 0,
pin: false,
bar_counts: false,
code_at: None,
code_left: 0,
height: 1,
width: 80,
action: None,
job: None,
review: None,
viewed: HashMap::new(),
split: false,
right: true,
margin_pref: None,
margin_active: false,
margin_w: MARGIN_MIN,
margin_w_pref: None,
margin_float_pref: None,
pane_file: None,
files_pref: None,
files_w_pref: None,
files_w: 0,
files_top: 0,
list_hits: vec![],
files_cur: None,
files_focus: false,
files_sel: None,
margin_float: false,
code_head: Pos { row: 0, col: 0 },
margin_anchor: HashMap::new(),
marks: HashMap::new(),
geom: Default::default(),
drows: vec![],
d_of: vec![],
emph: HashMap::new(),
settings: Settings::default(),
quit: false,
merge: false,
pr: None,
threads: vec![],
info: vec![],
info_collapsed: false,
sync: String::new(),
sections_folded: HashSet::new(),
commit_msgs: vec![],
wrap_w: 100,
hl: Highlighter::new(&Theme::ansi()),
ui: Theme::ansi().ui(),
hl_cache: HashMap::new(),
md_cache: Default::default(),
toggled: HashSet::new(),
theirs: false,
view: View::All,
pr_state: None,
summary_draft: String::new(),
replacement: None,
replacement_mtime: None,
me: String::new(),
seen: Seen { keys: HashSet::new(), before: HashSet::new(), path: None, current: vec![], conversation: false, since: std::time::Instant::now() },
claude: ClaudeState::default(),
hx: None,
writing: None,
quit_armed: None,
tree_checked: std::time::Instant::now(),
tree_seen: None,
msgs: vec![],
root: std::env::current_dir().unwrap_or_default(),
lsp: None,
lsp_ready: false,
inflight: HashMap::new(),
progress: HashMap::new(),
popup: None,
help: None,
jumps: vec![],
alphabet: jump_alphabet(),
labels: vec![],
label_first: None,
github: None,
github_gen: Default::default(),
github_poke: None,
base_said: String::new(),
revealed: HashSet::new(),
hid_again: HashSet::new(),
gone_at: 0,
conversation_all: false,
github_stale: false,
};
app.shape();
app.build();
app
}
pub(super) fn hold_quit(&mut self) {
if !self.quit || self.writing.is_none() {
return;
}
let again = self.quit_armed.take().is_some_and(|t| (QUIT_AFTER..QUIT_WITHIN).contains(&t.elapsed()));
if !again {
(self.quit, self.merge, self.quit_armed) = (false, false, Some(std::time::Instant::now()));
self.msg = "you're writing in the editor's pane: save or :cq it first (again: leaves anyway)".into();
}
}
pub(super) fn press(&mut self, k: KeyEvent) {
self.key(k);
if !self.quit {
self.quit_armed = None;
}
}
pub fn run(&mut self, term: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
self.log_msg();
self.watch_seen();
term.draw(|f| self.draw(f))?;
if let Some(job) = self.job.take() {
self.run_job(job);
continue;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => self.press(k),
Event::Mouse(m) => self.mouse(m),
_ => {}
}
}
self.hold_quit();
self.lsp_poll();
self.watch_tree();
self.watch_claude();
self.watch_github();
if self.refresh_at.is_some_and(|t| t <= std::time::Instant::now()) {
self.refresh_at = None;
if let Some(p) = &self.github_poke {
_ = p.send(());
}
}
self.watch_replacement();
self.poll_claude();
self.watch_claude_pane();
let (mut given, mut kept) = (None, None); 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() {
let in_pane = in_zellij();
if !in_pane {
restore_terminal();
}
let again = a.clone();
let (res, left) = term::writing_beside(self.writing.is_some(), given.take(), || self.perform(a));
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();
continue;
}
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,
};
self.hl_cache.retain(|k, _| !k.1); if !in_pane {
*term = init_terminal();
}
if let Err(e) = self.reload(false) {
self.msg = e.to_string();
}
match res {
Ok(m) => self.msg = m,
Err(e) => self.msg = format!("{e:#}"),
}
if let Some(k) = kept.take() {
self.msg = format!("{}: its pane closed; what it had saved is kept in {}", self.msg, k.display());
}
}
}
self.seen.current.push(String::new()); self.watch_seen();
if let Some(pr) = &self.pr {
_ = crate::claude::set_looked(pr.number); }
Ok(())
}
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")?; 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, "e, 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{}", agent::want_hint(want));
let Some(body) = compose_with(&what, false, &prefill, false)? else { return Ok("reply cancelled".into()) };
keep_on_error(crate::claude::reply(&p, &k, &body, want), &body)?;
Ok(self.answer_now())
}
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, agent::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)?;
self.load_claude();
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), agent::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)?;
self.load_claude();
Ok(self.answer_now())
}
Action::ClaudeWith(ask) => {
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)",
};
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));
}
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) => {
if crate::replace::load(prs[0]).is_ok_and(|r| r.drafting_now()) {
return Ok(format!("Claude is already drafting the replacement of #{}", prs[0]));
}
let which = prs.iter().map(|n| format!("#{n}")).collect::<Vec<_>>().join(", ");
let (what, prefill) = match about {
Some(q) => (format!("ask Claude to redraft the replacement of {which}: what to change, under the quote"), q),
None => (format!("instructions for Claude's replacement of {which} (optional)"), crate::claude::instructions("replace")),
};
let Some(extra) = compose_with(&what, true, prefill.trim_end(), true)? else { return Ok("not asked".into()) };
crate::claude::save_instructions("replace", &extra)?; agent::spawn_replace(&prs)?;
Ok(format!("Claude is drafting a replacement for {which} in the background: dv list's replacements tab shows it"))
}
Action::Publish(close) => {
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(); self.show_replacement(r);
if close {
self.action = Some(Action::CloseOriginals); }
let then = if originals { ": S closes what it replaces, with the note" } else { "" };
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 prefill = c.prefill(n);
let what = "a new PR fixing CI, for Claude to draft: what it should do; unpublished until you publish it";
let Some(task) = compose_with(what, false, &prefill, true)?.filter(|t| !t.trim().is_empty()) else { return Ok("not asked".into()) };
let id = crate::replace::create_new(&task, &self.root)?;
crate::claude::ci_fix_drafted(n); agent::spawn_replace(&[id])?;
Ok(format!("Claude is drafting {} in the background: dv list's drafts tab shows it", crate::replace::label(id)))
}
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, ¬e);
self.show_replacement(r);
let done = keep_on_error(res.as_ref().map(drop).map_err(|e| anyhow!("{e:#}")), ¬e).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, "e, as_is)? else {
return Ok("comment cancelled".into());
};
self.post_later(Post::Converse(body))
}
Action::Edit { comment, body: old, author } => {
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) => {
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" {
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(draft) => self.commit(&draft),
}
}
fn open_in_hx_pane(&mut self, target: &str) -> Result<String> {
let zellij = |args: &[&str]| -> Result<String> {
let out = std::process::Command::new("zellij").args(args).stdin(std::process::Stdio::null()).output()?;
anyhow::ensure!(out.status.success(), "zellij {} failed", args.get(1).unwrap_or(&""));
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
};
if let Some(id) = self.hx.as_ref().map(|h| h.pane.clone()).filter(|id| pane_alive(id)) {
let mut parts = target.rsplitn(3, ':');
let (_col, line, path) = (parts.next(), parts.next().unwrap_or("1"), parts.next().unwrap_or(target));
zellij(&["action", "write-chars", "-p", &id, "\x1b"])?;
zellij(&["action", "write-chars", "-p", &id, &format!(":open {path}\r:goto {line}\r")])?;
zellij(&["action", "focus-pane-id", &id])?;
return Ok("opened in hx (its pane on the right); 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 on the right; dv reloads as you save, and Enter opens the next file there".into())
}
fn tree_stamp(&self) -> (Option<std::time::SystemTime>, u64) {
use std::hash::{Hash, Hasher};
let newest = self.files.iter().filter_map(|f| std::fs::metadata(self.root.join(&f.path)).and_then(|m| m.modified()).ok()).max();
let mut h = std::collections::hash_map::DefaultHasher::new();
diff::git(&["status", "--porcelain"]).unwrap_or_default().hash(&mut h);
(newest, h.finish())
}
fn watch_tree(&mut self) {
if self.tree_checked.elapsed() < std::time::Duration::from_secs(1) {
return;
}
self.tree_checked = std::time::Instant::now();
if self.hx.as_ref().is_some_and(|h| !pane_alive(&h.pane)) {
self.hx = None;
}
let now = self.tree_stamp();
if self.tree_seen.replace(now).is_none_or(|before| before == now) {
return;
}
self.reload_tree();
}
pub(super) fn reload_tree(&mut self) {
self.hl_cache.retain(|k, _| !k.1); if let Err(e) = self.reload(false) {
self.msg = format!("{e:#}");
}
self.rekey_commits(); self.follow_github_base(false); self.tree_seen = Some(self.tree_stamp());
}
fn reload(&mut self, threads: bool) -> Result<()> {
let keep = self.cursor_key();
self.follow_rebase();
self.load_commits();
self.files = diff::load(&self.base, self.to.as_deref(), DEFAULT_CONTEXT)?;
match threads {
true => self.load_threads(),
false => self.load_claude(),
}
self.shape();
self.rebuild(keep);
self.refresh_sync();
Ok(())
}
pub(super) fn follow_rebase(&mut self) {
let Some(branch) = &self.follow_base else { return };
if rebase::rebasing(&self.root) {
return;
}
let git = |args: &[&str]| diff::git_in(&self.root, args);
let Ok(now) = git(&["merge-base", branch, "HEAD"]).map(|s| s.trim().to_owned()) else { return };
if now == self.full_base || git(&["merge-base", "--is-ancestor", &self.full_base, &now]).is_err() {
return;
}
if !self.uncommitted() {
self.base = now.clone(); }
self.full_base = now;
(self.to, self.commit_shown, self.commit_anchor) = (None, None, None);
self.hl_cache.retain(|k, _| !k.1);
}
fn repo_url(&self) -> Option<&str> {
self.pr.as_ref().and_then(|p| p.url.rsplit_once("/pull/")).map(|(r, _)| r)
}
fn file_path(&self) -> Option<&str> {
Some(&self.files.get(self.rows[self.head.row].file)?.path)
}
fn len(&self, r: usize) -> usize {
self.rows[r].line.text.chars().count()
}
fn chars(&self, r: usize) -> Vec<char> {
self.rows[r].line.text.chars().collect()
}
fn open_target(&self) -> Option<String> {
let row = &self.rows[self.head.row];
let f = self.files.get(row.file)?;
let line = self.rows[self.head.row..]
.iter()
.take_while(|r| r.file == row.file)
.find_map(|r| r.line.new)
.unwrap_or(1);
let col = if row.line.new.is_some() { self.head.col + 1 } else { 1 };
Some(format!("{}:{line}:{col}", f.path))
}
fn visible_rows(&self) -> Vec<usize> {
let mut out = vec![];
for d in &self.drows[self.top.min(self.drows.len())..(self.top + self.height).min(self.drows.len())] {
match *d {
DRow::Full(r) => out.push(r),
DRow::Pair(l, r) => out.extend(l.into_iter().chain(r.filter(|&r| Some(r) != l))),
DRow::Margin(l, r, c) => out.extend(l.into_iter().chain(r.filter(|&r| Some(r) != l)).chain(c)),
}
}
out
}
fn hl_key(&self, r: usize) -> Option<(String, bool)> {
let row = &self.rows[r];
let f = self.files.get(row.file).filter(|f| f.meta != "binary")?;
match row.line.kind {
Kind::Del => Some((f.old_path.clone(), false)),
Kind::Add | Kind::Ctx => Some((f.path.clone(), true)),
_ => None,
}
}
}
const HINT: &str = "<!-- dv:";
const QUIT_AFTER: std::time::Duration = std::time::Duration::from_millis(300);
const QUIT_WITHIN: std::time::Duration = std::time::Duration::from_secs(5);
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 }
}
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() {
true => anyhow!("{e:#} (text saved to {})", saved.display()),
_ => e,
}
})
}
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
),
}
}
fn posted_msg(review: bool) -> &'static str {
if review { "added to pending review (S to submit)" } else { "posted" }
}
fn wrap(text: &str, w: usize) -> Vec<String> {
let mut out = vec![];
for line in text.lines() {
if line.chars().count() <= w {
out.push(line.to_owned());
continue;
}
let body = line.trim_start();
let indent = &line[..line.len() - body.len()];
let mut cur = indent.to_owned();
for word in body.split(' ') {
if cur.len() > indent.len() && cur.chars().count() + 1 + word.chars().count() > w {
out.push(std::mem::replace(&mut cur, indent.to_owned()));
} else if cur.len() > indent.len() {
cur.push(' ');
}
cur.push_str(word);
}
out.push(cur);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn flip<T: std::hash::Hash + Eq>(s: &mut HashSet<T>, x: T) {
if !s.remove(&x) {
s.insert(x);
}
}
fn quote(text: &str) -> String {
if text.trim().is_empty() {
return String::new(); }
let q: Vec<String> = text.lines().map(|l| format!("> {l}").trim_end().to_owned()).collect();
format!("{}\n\n", q.join("\n"))
}
pub(crate) fn chip(hex: &str) -> Style {
let n = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap_or(0x6e7681);
let (r, g, b) = ((n >> 16) as u8, (n >> 8) as u8, n as u8);
let light = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32 > 150.0;
let fg = if light { Color::Black } else { Color::White };
Style::new().bg(Color::Rgb(r, g, b)).fg(fg).add_modifier(ratatui::style::Modifier::BOLD)
}
fn jump_alphabet() -> Vec<char> {
let cfg = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|_| std::env::var("HOME").map(|h| Path::new(&h).join(".config")))
.map(|d| std::fs::read_to_string(d.join("helix/config.toml")).unwrap_or_default())
.unwrap_or_default();
cfg.lines()
.filter_map(|l| l.trim().strip_prefix("jump-label-alphabet")?.trim().strip_prefix('=')?.trim().strip_prefix('"')?.strip_suffix('"').map(str::to_owned))
.next()
.filter(|a| !a.is_empty())
.unwrap_or_else(|| "abcdefghijklmnopqrstuvwxyz".into())
.chars()
.collect()
}
fn disk_line(cache: &mut HashMap<PathBuf, String>, p: &Path, n: u32) -> String {
let text = cache.entry(p.to_owned()).or_insert_with(|| std::fs::read_to_string(p).unwrap_or_default());
text.lines().nth(n as usize).unwrap_or("").to_owned()
}