use anyhow::Result;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use std::cell::Cell;
use std::path::PathBuf;
use std::time::Duration;
use crate::action::{Action, QuitCombo};
use crate::async_diff::{DiffRequest, DiffWorker};
use crate::components::action_hud::{hud_height, ActionHud};
use crate::components::agent_outputs::AgentOutputs;
use crate::components::agent_selector::render_agent_selector;
use crate::components::agentic_review_panel::AgenticReviewPanel;
use crate::components::annotation_menu::render_annotation_menu;
use crate::components::bookmark_list::render_bookmark_list;
use crate::components::checklist_panel::ChecklistPanel;
use crate::components::command_bar::render_command_bar;
use crate::components::comment_editor::render_comment_editor;
use crate::components::commit_dialog::render_commit_dialog;
use crate::components::context_bar::ContextBar;
use crate::components::diff_view::{
compute_split_visual_row_metrics, compute_unified_visual_row_metrics, DiffView,
};
use crate::components::file_picker::render_file_picker;
use crate::components::global_search_bar::render_global_search_bar;
use crate::components::navigator::Navigator;
use crate::components::prompt_preview::render_prompt_preview;
use crate::components::restore_confirm::render_restore_confirm;
use crate::components::settings_modal::render_settings_modal;
use crate::components::target_dialog::render_target_dialog;
use crate::components::which_key;
use crate::components::worktree_browser::WorktreeBrowser;
use crate::components::Component;
use crate::config::{
self, agentic_models_for_provider, checklist_config_to_items, load_checklist_config,
next_agentic_model, next_agentic_provider, prev_agentic_model, prev_agentic_provider,
MdiffConfig, PersistentSettings,
};
use crate::display_map::{build_display_map, DisplayRowInfo};
use crate::event::{
map_key_to_action, map_mouse_to_action, Event, EventReader, KeyContext, MouseContext,
};
use crate::git::commands::GitCli;
use crate::git::types::{ComparisonTarget, DiffLineOrigin, FileDelta};
use crate::git::worktree;
use crate::highlight::HighlightEngine;
use crate::pty_runner::{key_event_to_bytes, PtyEvent, PtyRunner};
use crate::session;
use crate::state::agent_state::{AgentRun, AgentRunStatus};
use crate::state::annotation_state::{Annotation, LineAnchor};
use crate::state::app_state::{ActiveView, FocusPanel};
use crate::state::file_picker_state::FilePickerEntry;
use crate::state::review_state::{compute_diff_hashes, FileReviewStatus};
use crate::state::settings_state::SETTINGS_ROW_COUNT;
use crate::state::{AppState, ChecklistState, DiffOptions, DiffViewMode};
use crate::theme::{next_theme, prev_theme, Theme};
use crate::tui::Tui;
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
struct PendingEditorOpen {
path: PathBuf,
line: u32,
editor: String,
is_gui: bool,
}
pub struct App {
state: AppState,
worker: DiffWorker,
target: ComparisonTarget,
generation: u64,
highlight_engine: HighlightEngine,
git_cli: GitCli,
status_clear_countdown: u32,
hud_collapse_countdown: u32,
quit_confirm_countdown: u32,
last_quit_combo: Option<QuitCombo>,
repo_path: PathBuf,
nav_area: Cell<Rect>,
diff_viewport_height: Cell<usize>,
config: MdiffConfig,
pty_runner: Option<PtyRunner>,
last_navigator_rect: Rect,
last_diff_view_rect: Rect,
pending_editor: Option<PendingEditorOpen>,
tree_z_pending: bool,
bracket_pending: Option<char>,
mark_pending: bool,
jump_mark_pending: bool,
window_pending: bool,
agentic_review_runner: Option<crate::agentic_review::AgenticReviewRunner>,
}
impl App {
pub fn new(
diff_options: DiffOptions,
open_worktree_browser: bool,
target: ComparisonTarget,
repo_path: PathBuf,
config: MdiffConfig,
context_lines: Option<usize>,
) -> Self {
let theme = config.theme.clone();
let mut state = AppState::new(diff_options, theme);
state.target_label = match &target {
ComparisonTarget::HeadVsWorkdir => "HEAD".to_string(),
ComparisonTarget::Branch(name) => name.clone(),
ComparisonTarget::Commit(oid) => format!("{:.7}", oid),
};
if open_worktree_browser {
state.active_view = ActiveView::WorktreeBrowser;
}
if let Some(ctx) = context_lines {
state.diff.display_context = ctx;
}
if config.tree_mode == Some(true) {
state.navigator.tree_mode = true;
}
let (annotations, saved_checklist, bookmarks) =
session::load_session_data(&repo_path, &state.target_label);
state.annotations = annotations;
state.bookmarks = bookmarks;
if let Some(saved) = saved_checklist {
state.checklist = saved;
} else if let Some(checklist_config) = load_checklist_config(&repo_path) {
let items = checklist_config_to_items(&checklist_config);
state.checklist = ChecklistState::from_config_items(items);
}
let worker = DiffWorker::new(repo_path.clone());
let highlight_engine = HighlightEngine::new();
let git_cli = GitCli::new(&repo_path);
Self {
state,
worker,
target,
generation: 0,
highlight_engine,
git_cli,
status_clear_countdown: 0,
hud_collapse_countdown: 0,
quit_confirm_countdown: 0,
last_quit_combo: None,
repo_path,
nav_area: Cell::new(Rect::default()),
diff_viewport_height: Cell::new(20),
config,
pty_runner: None,
last_navigator_rect: Rect::default(),
last_diff_view_rect: Rect::default(),
pending_editor: None,
tree_z_pending: false,
bracket_pending: None,
mark_pending: false,
jump_mark_pending: false,
window_pending: false,
agentic_review_runner: None,
}
}
pub async fn run(&mut self, terminal: &mut Tui) -> Result<()> {
self.request_diff();
if self.state.active_view == ActiveView::WorktreeBrowser {
self.refresh_worktrees();
}
let mut events = EventReader::new(Duration::from_millis(50));
let context_bar = ContextBar;
let navigator = Navigator;
let diff_view = DiffView;
let action_hud = ActionHud;
let worktree_browser = WorktreeBrowser;
let agent_outputs = AgentOutputs;
let checklist_panel = ChecklistPanel;
let agentic_review_panel = AgenticReviewPanel;
loop {
self.poll_diff_results();
self.poll_pty_output();
self.poll_agentic_review();
terminal.draw(|frame| {
let hud_h = hud_height(&self.state, frame.area().width);
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(hud_h),
])
.split(frame.area());
context_bar.render(frame, outer[0], &self.state);
match self.state.active_view {
ActiveView::DiffExplorer => {
let show_checklist =
self.state.checklist.panel_open && !self.state.checklist.is_empty();
let show_ai_review = self.state.agentic_review_panel_open
&& (!self.state.agentic_review_stream_output.is_empty()
|| self.state.agentic_review_running
|| self.state.agentic_review_composing
|| !self.state.agentic_review_text.text().is_empty());
let main = if show_checklist && show_ai_review {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(15),
Constraint::Percentage(45),
Constraint::Percentage(20),
Constraint::Percentage(20),
])
.split(outer[1])
} else if show_checklist || show_ai_review {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(60),
Constraint::Percentage(20),
])
.split(outer[1])
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(20),
Constraint::Percentage(80),
])
.split(outer[1])
};
self.nav_area.set(main[0]);
self.last_navigator_rect = main[0];
navigator.render(frame, main[0], &self.state);
let diff_area = main[1];
if self.state.prompt_preview_visible {
let vsplit = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(60),
Constraint::Percentage(40),
])
.split(diff_area);
let vh = vsplit[0].height.saturating_sub(2) as usize;
self.diff_viewport_height.set(vh);
self.state.diff.viewport_height = vh;
self.last_diff_view_rect = vsplit[0];
self.update_diff_visual_metrics(vsplit[0]);
diff_view.render(frame, vsplit[0], &self.state);
render_prompt_preview(frame, vsplit[1], &self.state);
} else {
let vh = diff_area.height.saturating_sub(2) as usize;
self.diff_viewport_height.set(vh);
self.state.diff.viewport_height = vh;
self.last_diff_view_rect = diff_area;
self.update_diff_visual_metrics(diff_area);
diff_view.render(frame, diff_area, &self.state);
}
if show_checklist && show_ai_review {
checklist_panel.render(frame, main[2], &self.state);
agentic_review_panel.render(frame, main[3], &self.state);
} else if show_checklist {
checklist_panel.render(frame, main[2], &self.state);
} else if show_ai_review {
agentic_review_panel.render(frame, main[2], &self.state);
}
}
ActiveView::WorktreeBrowser => {
worktree_browser.render(frame, outer[1], &self.state);
}
ActiveView::AgentOutputs => {
agent_outputs.render(frame, outer[1], &self.state);
}
ActiveView::FeedbackSummary => {
use crate::components::feedback_summary::FeedbackSummary;
FeedbackSummary.render(frame, outer[1], &self.state);
}
}
action_hud.render(frame, outer[2], &self.state);
if self.state.target_dialog_open {
render_target_dialog(frame, &self.state);
}
if self.state.commit_dialog_open {
render_commit_dialog(frame, &self.state);
}
if self.state.comment_editor_open {
render_comment_editor(frame, &self.state);
}
if self.state.category_picker_open {
crate::components::category_picker::render_category_picker(
frame,
frame.area(),
&self.state,
);
}
if self.state.annotation_menu_open {
render_annotation_menu(frame, &self.state);
}
if self.state.bookmarks.list_visible {
render_bookmark_list(frame, frame.area(), &self.state);
}
if self.state.agent_selector.open {
render_agent_selector(frame, &self.state.agent_selector);
}
if self.state.restore_confirm_open {
render_restore_confirm(frame, &self.state);
}
if self.state.settings.open {
render_settings_modal(frame, &self.state, &self.config);
}
if self.state.global_search.active {
render_global_search_bar(frame, &self.state);
}
if self.state.command_bar.active {
render_command_bar(frame, &self.state);
}
if self.state.file_picker.active {
render_file_picker(frame, &self.state);
}
which_key::render_which_key(frame, frame.area(), &self.state);
})?;
self.state.diff.viewport_height = self.diff_viewport_height.get();
let first = events.next().await;
let mut pending = Vec::new();
if let Some(ev) = first {
pending.push(ev);
}
while let Some(ev) = events.try_next() {
pending.push(ev);
}
let mut scroll_delta: i32 = 0;
let mut actions: Vec<Action> = Vec::new();
for event in pending {
let ctx = KeyContext {
focus: self.state.focus,
search_active: self.state.navigator.search_active,
diff_search_active: self.state.diff.search_active,
global_search_active: self.state.global_search.active,
commit_dialog_open: self.state.commit_dialog_open,
target_dialog_open: self.state.target_dialog_open,
comment_editor_open: self.state.comment_editor_open,
category_picker_open: self.state.category_picker_open,
category_picker_phase: self.state.category_picker_phase,
agent_selector_open: self.state.agent_selector.open,
annotation_menu_open: self.state.annotation_menu_open,
restore_confirm_open: self.state.restore_confirm_open,
settings_open: self.state.settings.open,
visual_mode_active: self.state.selection.active,
active_view: self.state.active_view,
pty_focus: self.state.pty_focus,
checklist_panel_open: self.state.checklist.panel_open,
bookmark_list_open: self.state.bookmarks.list_visible,
which_key_visible: self.state.which_key_visible,
tree_mode: self.state.navigator.tree_mode,
tree_z_pending: self.tree_z_pending,
bracket_pending: self.bracket_pending,
mark_pending: self.mark_pending,
jump_mark_pending: self.jump_mark_pending,
command_bar_active: self.state.command_bar.active,
file_picker_active: self.state.file_picker.active,
agentic_review_modal_open: self.state.agentic_review_modal_open,
agentic_review_panel_open: self.state.agentic_review_panel_open,
agentic_review_composing: self.state.agentic_review_composing,
window_pending: self.window_pending,
};
let action = match event {
Event::Key(key) => {
let mapped = map_key_to_action(key, &ctx);
self.tree_z_pending = ctx.tree_mode
&& ctx.focus == FocusPanel::Navigator
&& !ctx.tree_z_pending
&& key.code == crossterm::event::KeyCode::Char('z')
&& mapped.is_none();
if ctx.window_pending {
self.window_pending = false;
} else if ctx.bracket_pending.is_some() {
self.bracket_pending = None;
} else if ctx.mark_pending {
self.mark_pending = false;
} else if ctx.jump_mark_pending {
self.jump_mark_pending = false;
} else if ctx.active_view == ActiveView::DiffExplorer
&& ctx.focus == FocusPanel::DiffView
&& !ctx.visual_mode_active
&& mapped.is_none()
{
match key.code {
crossterm::event::KeyCode::Char(']') => {
self.bracket_pending = Some(']');
}
crossterm::event::KeyCode::Char('[') => {
self.bracket_pending = Some('[');
}
crossterm::event::KeyCode::Char('m') => {
self.mark_pending = true;
}
crossterm::event::KeyCode::Char('\'') => {
self.jump_mark_pending = true;
}
_ => {}
}
}
mapped
}
Event::Mouse(mouse) => {
if !self.config.mouse.enabled
|| ctx.commit_dialog_open
|| ctx.target_dialog_open
|| ctx.comment_editor_open
|| ctx.agent_selector_open
|| ctx.annotation_menu_open
|| ctx.restore_confirm_open
|| ctx.settings_open
|| ctx.search_active
|| ctx.diff_search_active
|| ctx.command_bar_active
|| ctx.file_picker_active
{
None
} else {
if self.state.active_view == ActiveView::AgentOutputs {
match mouse.kind {
MouseEventKind::ScrollUp => Some(Action::PtyScrollUp),
MouseEventKind::ScrollDown => Some(Action::PtyScrollDown),
_ => None,
}
} else if self.state.navigator.tree_mode
&& !self.state.navigator.search_active
{
self.map_tree_mouse_to_action(mouse)
} else {
let visible_entries = self.state.navigator.visible_entries();
let inner_height =
self.last_navigator_rect.height.saturating_sub(2) as usize;
let selected = self.state.navigator.selected;
let scroll_offset = if selected >= inner_height {
selected - inner_height + 1
} else {
0
};
let mouse_ctx = MouseContext {
navigator_rect: self.last_navigator_rect,
diff_view_rect: self.last_diff_view_rect,
navigator_scroll_offset: scroll_offset,
navigator_item_count: visible_entries.len(),
navigator_visible_entries: &visible_entries,
};
map_mouse_to_action(mouse, &mouse_ctx)
}
}
}
Event::Paste(text) if self.state.pty_focus => Some(Action::PtyPaste(text)),
Event::Paste(text) => Some(Action::TextPaste(text)),
Event::Resize => Some(Action::Resize),
Event::Tick => Some(Action::Tick),
};
if let Some(action) = action {
match action {
Action::ScrollUp => scroll_delta -= 1,
Action::ScrollDown => scroll_delta += 1,
other => actions.push(other),
}
}
}
if scroll_delta < 0 {
for _ in 0..(-scroll_delta) {
self.update(Action::ScrollUp);
}
} else if scroll_delta > 0 {
for _ in 0..scroll_delta {
self.update(Action::ScrollDown);
}
}
for action in actions {
if self.state.which_key_visible
&& !matches!(
action,
Action::ToggleWhichKey | Action::Tick | Action::Resize
)
{
self.state.which_key_visible = false;
}
self.update(action);
}
if self.pending_editor.is_some() {
self.execute_pending_editor(terminal);
}
if self.state.should_quit {
break;
}
}
session::save_session_data(
&self.repo_path,
&self.state.target_label,
&self.state.annotations,
if self.state.checklist.is_empty() {
None
} else {
Some(&self.state.checklist)
},
&self.state.bookmarks,
);
Ok(())
}
fn request_diff(&mut self) {
self.generation += 1;
self.state.diff.loading = true;
self.worker.request(DiffRequest {
generation: self.generation,
target: self.target.clone(),
options: self.state.diff.options.clone(),
});
}
fn poll_diff_results(&mut self) {
while let Some(result) = self.worker.try_recv() {
if result.generation < self.generation {
continue;
}
self.state.diff.loading = false;
match result.deltas {
Ok(deltas) => {
let new_hashes = compute_diff_hashes(&deltas);
self.state.review.on_diff_refresh(new_hashes);
self.state.navigator.update_from_deltas(&deltas);
self.state.diff.deltas = deltas;
if !self.state.diff.deltas.is_empty() && self.state.diff.selected_file.is_none()
{
self.state.diff.selected_file = Some(0);
self.update_highlights();
}
}
Err(_e) => {
self.state.diff.deltas.clear();
self.state.navigator.update_from_deltas(&[]);
}
}
}
}
fn poll_pty_output(&mut self) {
let Some(runner) = self.pty_runner.as_mut() else {
return;
};
let mut events = Vec::new();
while let Some(event) = runner.try_recv() {
events.push(event);
}
let exit_code = runner.try_wait();
for event in events {
match event {
PtyEvent::Output(run_id, bytes) => {
if let Some(run) = self
.state
.agent_outputs
.runs
.iter_mut()
.find(|r| r.id == run_id)
{
run.terminal.process(&bytes);
}
}
}
}
if let Some(code) = exit_code {
if let Some(run) = self
.state
.agent_outputs
.runs
.iter_mut()
.find(|r| matches!(r.status, AgentRunStatus::Running))
{
run.status = if code == 0 {
AgentRunStatus::Success { exit_code: code }
} else {
AgentRunStatus::Failed { exit_code: code }
};
}
self.state.pty_focus = false;
self.pty_runner = None;
self.request_diff();
}
}
fn poll_agentic_review(&mut self) {
let Some(runner) = self.agentic_review_runner.as_mut() else {
return;
};
let mut events = Vec::new();
while let Some(event) = runner.try_recv() {
events.push(event);
}
for event in events {
use crate::agentic_review::AgenticReviewEvent;
match event {
AgenticReviewEvent::StreamToken(token) => {
self.update(Action::AgenticReviewStreamToken(token));
}
AgenticReviewEvent::ChildProgress(done, total) => {
self.update(Action::AgenticReviewChildProgress(done, total));
}
AgenticReviewEvent::Complete(annotations) => {
self.update(Action::AgenticReviewComplete(annotations));
}
AgenticReviewEvent::Error(msg) => {
self.update(Action::AgenticReviewError(msg));
}
}
}
}
fn update_highlights(&mut self) {
let Some(delta) = self.state.diff.selected_delta() else {
self.state.diff.old_highlights.clear();
self.state.diff.new_highlights.clear();
return;
};
let path = delta.path.clone();
let (old_content, old_line_count) = reconstruct_content(delta, ContentSide::Old);
let (new_content, new_line_count) = reconstruct_content(delta, ContentSide::New);
let syntax = &self.state.theme.syntax;
self.state.diff.old_highlights = self
.highlight_engine
.highlight_lines(&path, &old_content, syntax)
.unwrap_or_else(|| vec![Vec::new(); old_line_count + 1]);
self.state.diff.new_highlights = self
.highlight_engine
.highlight_lines(&path, &new_content, syntax)
.unwrap_or_else(|| vec![Vec::new(); new_line_count + 1]);
}
fn current_display_map(&self) -> Vec<DisplayRowInfo> {
let Some(delta) = self.state.diff.selected_delta() else {
return Vec::new();
};
build_display_map(
delta,
self.state.diff.options.view_mode,
self.state.diff.display_context,
&self.state.diff.gap_expansions,
)
}
fn update_diff_visual_metrics(&mut self, area: Rect) {
let Some(delta) = self.state.diff.selected_delta() else {
self.state.diff.visual_row_offsets.clear();
self.state.diff.visual_row_heights.clear();
self.state.diff.visual_total_rows = 0;
self.state.diff.scroll_offset = 0;
self.state.diff.cursor_row = 0;
return;
};
let inner = Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
};
let metrics = match self.state.diff.options.view_mode {
DiffViewMode::Split => {
let halves = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(inner);
compute_split_visual_row_metrics(
delta,
&self.state,
halves[0].width,
halves[1].width,
)
}
DiffViewMode::Unified => {
compute_unified_visual_row_metrics(delta, &self.state, inner.width)
}
};
self.state.diff.visual_row_offsets = metrics.row_offsets;
self.state.diff.visual_row_heights = metrics.row_heights;
self.state.diff.visual_total_rows = metrics.total_rows;
self.clamp_diff_view_state();
}
fn clamp_scroll(&mut self) {
let vh = self.state.diff.viewport_height.max(1);
let max_scroll = self.state.diff.visual_total_rows.saturating_sub(vh);
self.state.diff.scroll_offset = self.state.diff.scroll_offset.min(max_scroll);
}
fn clamp_diff_view_state(&mut self) {
let display_map = self.current_display_map();
let max_cursor_row = display_map.len().saturating_sub(1);
let vh = self.state.diff.viewport_height.max(1);
let max_scroll = self.state.diff.visual_total_rows.saturating_sub(vh);
if self.state.diff.cursor_row > max_cursor_row {
self.state.diff.cursor_row = max_cursor_row;
}
if self.state.diff.scroll_offset > max_scroll {
self.state.diff.scroll_offset = max_scroll;
}
self.ensure_cursor_visible();
}
fn visual_offset_for_row(&self, row: usize) -> usize {
self.state
.diff
.visual_row_offsets
.get(row)
.copied()
.unwrap_or(0)
}
fn row_for_visual_offset(&self, offset: usize) -> usize {
let offsets = &self.state.diff.visual_row_offsets;
if offsets.is_empty() {
return 0;
}
let idx = match offsets.binary_search(&offset) {
Ok(i) => i,
Err(i) => i.saturating_sub(1),
};
idx.min(offsets.len().saturating_sub(1))
}
fn ensure_cursor_visible(&mut self) {
if self.state.diff.visual_row_offsets.is_empty() {
return;
}
let vh = self.state.diff.viewport_height.max(1);
let cursor_visual = self.visual_offset_for_row(self.state.diff.cursor_row);
if cursor_visual < self.state.diff.scroll_offset {
self.state.diff.scroll_offset = cursor_visual;
} else if cursor_visual >= self.state.diff.scroll_offset + vh {
self.state.diff.scroll_offset = cursor_visual.saturating_sub(vh - 1);
}
}
fn find_next_hunk_row(
&self,
current_row: usize,
display_map: &[DisplayRowInfo],
) -> Option<usize> {
display_map
.iter()
.enumerate()
.skip(current_row + 1)
.find(|(_, info)| info.is_header)
.map(|(idx, _)| idx)
.or_else(|| {
display_map
.iter()
.enumerate()
.take(current_row + 1)
.find(|(_, info)| info.is_header)
.map(|(idx, _)| idx)
})
}
fn find_prev_hunk_row(
&self,
current_row: usize,
display_map: &[DisplayRowInfo],
) -> Option<usize> {
if current_row > 0 {
if let Some(idx) = (0..current_row)
.rev()
.find(|&idx| display_map[idx].is_header)
{
return Some(idx);
}
}
(0..display_map.len())
.rev()
.find(|&idx| display_map[idx].is_header)
}
fn selection_to_anchor(&self) -> Option<LineAnchor> {
let delta = self.state.diff.selected_delta()?;
let display_map = self.current_display_map();
let (start, end) = self.state.selection.range();
let file_path = delta.path.to_string_lossy().to_string();
let mut old_min: Option<u32> = None;
let mut old_max: Option<u32> = None;
let mut new_min: Option<u32> = None;
let mut new_max: Option<u32> = None;
for row_idx in start..=end {
if let Some(info) = display_map.get(row_idx) {
if let Some(n) = info.old_lineno {
old_min = Some(old_min.map_or(n, |m: u32| m.min(n)));
old_max = Some(old_max.map_or(n, |m: u32| m.max(n)));
}
if let Some(n) = info.new_lineno {
new_min = Some(new_min.map_or(n, |m: u32| m.min(n)));
new_max = Some(new_max.map_or(n, |m: u32| m.max(n)));
}
}
}
if old_min.is_none() && new_min.is_none() {
return None;
}
Some(LineAnchor {
file_path,
old_range: old_min.zip(old_max),
new_range: new_min.zip(new_max),
})
}
fn cursor_to_anchor(&self) -> Option<LineAnchor> {
let delta = self.state.diff.selected_delta()?;
let display_map = self.current_display_map();
let info = display_map.get(self.state.diff.cursor_row)?;
let file_path = delta.path.to_string_lossy().to_string();
if info.old_lineno.is_none() && info.new_lineno.is_none() {
return None;
}
Some(LineAnchor {
file_path,
old_range: info.old_lineno.map(|n| (n, n)),
new_range: info.new_lineno.map(|n| (n, n)),
})
}
fn update(&mut self, action: Action) {
if self.state.hud_expanded {
match action {
Action::Tick
| Action::Resize
| Action::ToggleHud
| Action::OpenSettings
| Action::CloseSettings
| Action::SettingsUp
| Action::SettingsDown
| Action::SettingsLeft
| Action::SettingsRight
| Action::OpenCommandBar
| Action::CommandBarChar(_)
| Action::CommandBarBackspace
| Action::CommandBarConfirm
| Action::CommandBarCancel
| Action::OpenFilePicker
| Action::FilePickerChar(_)
| Action::FilePickerBackspace
| Action::FilePickerUp
| Action::FilePickerDown
| Action::FilePickerConfirm
| Action::FilePickerCancel
| Action::OpenAgenticReview
| Action::AgenticReviewChar(_)
| Action::AgenticReviewBackspace
| Action::AgenticReviewNewline
| Action::AgenticReviewConfirm
| Action::ToggleAgenticReviewPanel
| Action::AgenticReviewStreamToken(_)
| Action::AgenticReviewChildProgress(_, _)
| Action::AgenticReviewComplete(_)
| Action::AgenticReviewError(_)
| Action::AgenticReviewPanelUp
| Action::AgenticReviewPanelDown
| Action::WindowPrefix
| Action::CycleFocus => {}
_ => {
self.state.hud_expanded = false;
self.hud_collapse_countdown = 0;
}
}
}
match action {
Action::Quit => {
self.state.should_quit = true;
}
Action::ConfirmQuitSignal(combo) => {
if self.quit_confirm_countdown > 0 && self.last_quit_combo == Some(combo) {
self.state.should_quit = true;
} else {
self.quit_confirm_countdown = 40;
self.last_quit_combo = Some(combo);
self.set_status_for_ticks(
format!("Press {} again to exit", combo.label()),
false,
self.quit_confirm_countdown,
);
}
}
Action::NavigatorUp => {
if self.state.navigator.tree_mode {
self.state.navigator.tree_select_up();
self.sync_tree_selection();
} else {
self.state.navigator.select_up();
self.sync_selection();
}
}
Action::NavigatorDown => {
if self.state.navigator.tree_mode {
self.state.navigator.tree_select_down();
self.sync_tree_selection();
} else {
self.state.navigator.select_down();
self.sync_selection();
}
}
Action::NavigatorTop => {
if self.state.navigator.tree_mode {
self.state.navigator.tree_selected = 0;
self.sync_tree_selection();
} else {
self.state.navigator.selected = 0;
self.sync_selection();
}
}
Action::NavigatorBottom => {
if self.state.navigator.tree_mode {
let len = self.state.navigator.tree_nodes.len();
if len > 0 {
self.state.navigator.tree_selected = len - 1;
}
self.sync_tree_selection();
} else {
let len = self.state.navigator.visible_entries().len();
if len > 0 {
self.state.navigator.selected = len - 1;
}
self.sync_selection();
}
}
Action::SelectFile(idx) => {
self.state.diff.selected_file = Some(idx);
self.state.diff.scroll_offset = 0;
self.state.diff.cursor_row = 0;
if self.state.navigator.tree_mode {
if let Some(tree_idx) = self.state.navigator.tree_nodes.iter().position(|n| {
matches!(
n.kind,
crate::state::navigator_state::TreeNodeKind::File {
entry_index
} if entry_index == idx
)
}) {
self.state.navigator.tree_selected = tree_idx;
}
} else if let Some(vis_idx) = self
.state
.navigator
.visible_entries()
.iter()
.position(|(_, e)| e.delta_index == idx)
{
self.state.navigator.selected = vis_idx;
}
self.state.focus = FocusPanel::Navigator;
self.update_highlights();
self.check_viewport_review();
}
Action::ScrollUp => {
self.state.diff.cursor_row = self.state.diff.cursor_row.saturating_sub(1);
self.ensure_cursor_visible();
self.check_viewport_review();
}
Action::ScrollDown => {
let max = self.current_display_map().len().saturating_sub(1);
if self.state.diff.cursor_row < max {
self.state.diff.cursor_row += 1;
}
self.ensure_cursor_visible();
self.check_auto_review();
self.check_viewport_review();
}
Action::ScrollToTop => {
self.state.diff.cursor_row = 0;
self.state.diff.scroll_offset = 0;
self.check_viewport_review();
}
Action::ScrollToBottom => {
let display_map = self.current_display_map();
let vh = self.state.diff.viewport_height.max(1);
let max_scroll = self.state.diff.visual_total_rows.saturating_sub(vh);
self.state.diff.scroll_offset = max_scroll;
let max_cursor_row = display_map.len().saturating_sub(1);
let mut cursor_row = max_cursor_row;
for row in (0..=max_cursor_row).rev() {
let visual_offset = self.visual_offset_for_row(row);
if visual_offset >= self.state.diff.scroll_offset
&& visual_offset < self.state.diff.scroll_offset + vh
{
cursor_row = row;
break;
}
}
self.state.diff.cursor_row = cursor_row;
self.check_auto_review();
self.check_viewport_review();
}
Action::ScrollPageUp => {
let vh = self.state.diff.viewport_height.max(1);
let new_scroll = self.state.diff.scroll_offset.saturating_sub(vh);
self.state.diff.cursor_row = self.row_for_visual_offset(new_scroll);
self.state.diff.scroll_offset =
self.visual_offset_for_row(self.state.diff.cursor_row);
self.clamp_scroll();
self.check_viewport_review();
}
Action::ScrollPageDown => {
let vh = self.state.diff.viewport_height.max(1);
let max_scroll = self.state.diff.visual_total_rows.saturating_sub(vh);
let new_scroll = (self.state.diff.scroll_offset + vh).min(max_scroll);
self.state.diff.cursor_row = self.row_for_visual_offset(new_scroll);
self.state.diff.scroll_offset =
self.visual_offset_for_row(self.state.diff.cursor_row);
self.clamp_scroll();
self.check_auto_review();
self.check_viewport_review();
}
Action::ToggleViewMode => {
self.state.diff.options.view_mode = match self.state.diff.options.view_mode {
DiffViewMode::Split => DiffViewMode::Unified,
DiffViewMode::Unified => DiffViewMode::Split,
};
self.state.selection.active = false;
self.state.diff.cursor_row = 0;
self.state.diff.scroll_offset = 0;
}
Action::ToggleWhitespace => {
self.state.diff.options.ignore_whitespace =
!self.state.diff.options.ignore_whitespace;
self.request_diff();
}
Action::FocusDiffView => {
self.state.focus = FocusPanel::DiffView;
self.state.agentic_review_composing = false;
let vh = self.state.diff.viewport_height.max(1);
let scroll = self.state.diff.scroll_offset;
let cursor_visual = self.visual_offset_for_row(self.state.diff.cursor_row);
if cursor_visual < scroll || cursor_visual >= scroll + vh {
self.state.diff.cursor_row = self.row_for_visual_offset(scroll);
}
self.check_viewport_review();
}
Action::CycleFocus => {
self.state.focus = match self.state.active_view {
ActiveView::AgentOutputs => match self.state.focus {
FocusPanel::AgentRunList => FocusPanel::AgentOutput,
_ => FocusPanel::AgentRunList,
},
_ => {
let review_open = self.state.agentic_review_panel_open;
let checklist_open =
self.state.checklist.panel_open && !self.state.checklist.is_empty();
match self.state.focus {
FocusPanel::Navigator => FocusPanel::DiffView,
FocusPanel::DiffView => {
if review_open {
FocusPanel::ReviewPanel
} else if checklist_open {
FocusPanel::ChecklistPanel
} else {
FocusPanel::Navigator
}
}
FocusPanel::ReviewPanel => {
if checklist_open {
FocusPanel::ChecklistPanel
} else {
FocusPanel::Navigator
}
}
FocusPanel::ChecklistPanel => FocusPanel::Navigator,
_ => FocusPanel::Navigator,
}
}
};
self.sync_pty_focus();
self.state.agentic_review_composing = self.state.focus == FocusPanel::ReviewPanel;
}
Action::WindowPrefix => {
self.window_pending = true;
}
Action::FocusPaneLeft => {
let new_focus = match self.state.active_view {
ActiveView::AgentOutputs => match self.state.focus {
FocusPanel::AgentOutput => FocusPanel::AgentRunList,
_ => self.state.focus, },
_ => match self.state.focus {
FocusPanel::Navigator => FocusPanel::Navigator,
FocusPanel::DiffView => FocusPanel::Navigator,
FocusPanel::ReviewPanel => FocusPanel::DiffView,
FocusPanel::ChecklistPanel => {
if self.state.agentic_review_panel_open {
FocusPanel::ReviewPanel
} else {
FocusPanel::DiffView
}
}
_ => self.state.focus,
},
};
self.state.focus = new_focus;
self.sync_pty_focus();
self.state.agentic_review_composing = self.state.focus == FocusPanel::ReviewPanel;
}
Action::FocusPaneRight => {
let review_open = self.state.agentic_review_panel_open
&& (!self.state.agentic_review_stream_output.is_empty()
|| self.state.agentic_review_running
|| self.state.agentic_review_composing
|| !self.state.agentic_review_text.text().is_empty());
let checklist_open =
self.state.checklist.panel_open && !self.state.checklist.is_empty();
let new_focus = match self.state.active_view {
ActiveView::AgentOutputs => match self.state.focus {
FocusPanel::AgentRunList => FocusPanel::AgentOutput,
_ => self.state.focus, },
_ => match self.state.focus {
FocusPanel::Navigator => FocusPanel::DiffView,
FocusPanel::DiffView => {
if review_open {
FocusPanel::ReviewPanel
} else if checklist_open {
FocusPanel::ChecklistPanel
} else {
FocusPanel::DiffView
}
}
FocusPanel::ReviewPanel => {
if checklist_open {
FocusPanel::ChecklistPanel
} else {
FocusPanel::ReviewPanel
}
}
FocusPanel::ChecklistPanel => FocusPanel::ChecklistPanel,
_ => self.state.focus,
},
};
self.state.focus = new_focus;
self.sync_pty_focus();
self.state.agentic_review_composing = self.state.focus == FocusPanel::ReviewPanel;
}
Action::StartSearch => {
self.state.navigator.start_search();
self.state.focus = FocusPanel::Navigator;
}
Action::ConfirmSearch => {
self.state.navigator.confirm_search();
self.sync_selection();
self.state.focus = FocusPanel::DiffView;
}
Action::CancelSearch => {
self.state.navigator.cancel_search();
self.sync_selection();
}
Action::SearchChar(c) => {
self.state.navigator.search_push(c);
self.sync_selection();
}
Action::SearchBackspace => {
self.state.navigator.search_pop();
self.sync_selection();
}
Action::StartDiffSearch => {
self.state.diff.search_active = true;
self.state.diff.search_query.clear();
self.state.diff.search_matches.clear();
self.state.diff.search_match_index = None;
}
Action::EndDiffSearch => {
self.state.diff.search_active = false;
}
Action::DiffSearchChar(c) => {
self.state.diff.search_query.insert_char(c);
self.recompute_diff_search_matches();
}
Action::DiffSearchBackspace => {
self.state.diff.search_query.delete_back();
self.recompute_diff_search_matches();
}
Action::DiffSearchNext => {
if self.state.diff.search_matches.is_empty() {
} else {
let next = match self.state.diff.search_match_index {
Some(idx) => (idx + 1) % self.state.diff.search_matches.len(),
None => 0,
};
self.state.diff.search_match_index = Some(next);
let row = self.state.diff.search_matches[next];
self.state.diff.cursor_row = row;
let vh = self.state.diff.viewport_height.max(1);
let cursor_visual = self.visual_offset_for_row(row);
if cursor_visual < self.state.diff.scroll_offset
|| cursor_visual >= self.state.diff.scroll_offset + vh
{
self.state.diff.scroll_offset = cursor_visual.saturating_sub(vh / 4);
}
}
}
Action::DiffSearchPrev => {
if !self.state.diff.search_matches.is_empty() {
let prev = match self.state.diff.search_match_index {
Some(0) => self.state.diff.search_matches.len() - 1,
Some(idx) => idx - 1,
None => self.state.diff.search_matches.len() - 1,
};
self.state.diff.search_match_index = Some(prev);
let row = self.state.diff.search_matches[prev];
self.state.diff.cursor_row = row;
let vh = self.state.diff.viewport_height.max(1);
let cursor_visual = self.visual_offset_for_row(row);
if cursor_visual < self.state.diff.scroll_offset
|| cursor_visual >= self.state.diff.scroll_offset + vh
{
self.state.diff.scroll_offset = cursor_visual.saturating_sub(vh / 4);
}
}
}
Action::StartGlobalSearch => {
self.state.global_search.active = true;
self.state.global_search.query.clear();
self.state.global_search.matches.clear();
self.state.global_search.current_match = 0;
}
Action::EndGlobalSearch => {
self.state.global_search.active = false;
}
Action::GlobalSearchChar(c) => {
self.state.global_search.query.insert_char(c);
self.recompute_global_search_matches();
}
Action::GlobalSearchBackspace => {
self.state.global_search.query.delete_back();
self.recompute_global_search_matches();
}
Action::GlobalSearchNext => {
if !self.state.global_search.matches.is_empty() {
let next = (self.state.global_search.current_match + 1)
% self.state.global_search.matches.len();
self.state.global_search.current_match = next;
self.jump_to_global_search_match();
}
}
Action::GlobalSearchPrev => {
if !self.state.global_search.matches.is_empty() {
let prev = if self.state.global_search.current_match == 0 {
self.state.global_search.matches.len() - 1
} else {
self.state.global_search.current_match - 1
};
self.state.global_search.current_match = prev;
self.jump_to_global_search_match();
}
}
Action::ToggleWorktreeBrowser => {
self.state.active_view = match self.state.active_view {
ActiveView::DiffExplorer
| ActiveView::AgentOutputs
| ActiveView::FeedbackSummary => {
self.refresh_worktrees();
ActiveView::WorktreeBrowser
}
ActiveView::WorktreeBrowser => ActiveView::DiffExplorer,
};
}
Action::WorktreeUp => {
self.state.worktree.select_up();
}
Action::WorktreeDown => {
self.state.worktree.select_down();
}
Action::WorktreeSelect => {
if let Some(wt) = self.state.worktree.selected_worktree().cloned() {
let new_path = wt.path.clone();
self.repo_path = new_path.clone();
self.worker = DiffWorker::new(new_path.clone());
self.git_cli = GitCli::new(&new_path);
self.generation = 0;
self.state.diff.deltas.clear();
self.state.diff.selected_file = None;
self.state.diff.scroll_offset = 0;
self.state.navigator.entries.clear();
self.state.navigator.filtered_indices.clear();
self.state.review.reset();
self.state.active_view = ActiveView::DiffExplorer;
self.request_diff();
self.set_status(format!("Switched to: {}", wt.name), false);
}
}
Action::WorktreeRefresh => {
self.refresh_worktrees();
}
Action::WorktreeFreeze => {
if let Some(wt) = self.state.worktree.selected_worktree().cloned() {
let freeze_cli = GitCli::new(&wt.path);
match freeze_cli
.stage_all()
.and_then(|()| freeze_cli.commit("Agent Checkpoint"))
{
Ok(()) => {
self.set_status(format!("Frozen: {}", wt.name), false);
self.refresh_worktrees();
}
Err(e) => {
self.set_status(format!("Freeze failed: {e}"), true);
}
}
}
}
Action::WorktreeBack => {
self.state.active_view = ActiveView::DiffExplorer;
}
Action::StageFile => {
if let Some(path) = self.selected_file_path() {
match self.git_cli.stage_file(&path) {
Ok(()) => {
self.set_status(format!("Staged: {}", path.display()), false);
self.request_diff();
}
Err(e) => {
self.set_status(format!("Stage failed: {e}"), true);
}
}
}
}
Action::UnstageFile => {
if let Some(path) = self.selected_file_path() {
match self.git_cli.unstage_file(&path) {
Ok(()) => {
self.set_status(format!("Unstaged: {}", path.display()), false);
self.request_diff();
}
Err(e) => {
self.set_status(format!("Unstage failed: {e}"), true);
}
}
}
}
Action::RestoreFile => {
if self.selected_file_path().is_some() {
self.state.restore_confirm_open = true;
}
}
Action::ConfirmRestore => {
self.state.restore_confirm_open = false;
if let Some(path) = self.selected_file_path() {
match self.git_cli.restore_file(&path) {
Ok(()) => {
self.set_status(format!("Restored: {}", path.display()), false);
self.request_diff();
}
Err(e) => {
self.set_status(format!("Restore failed: {e}"), true);
}
}
}
}
Action::CancelRestore => {
self.state.restore_confirm_open = false;
}
Action::OpenCommitDialog => {
self.state.commit_dialog_open = true;
self.state.commit_message.clear();
}
Action::CancelCommit => {
self.state.commit_dialog_open = false;
self.state.commit_message.clear();
}
Action::ConfirmCommit => {
if self.state.commit_message.text().trim().is_empty() {
self.set_status("Commit message cannot be empty".to_string(), true);
} else {
let msg = self.state.commit_message.text().to_string();
match self.git_cli.commit(&msg) {
Ok(()) => {
self.set_status("Committed successfully".to_string(), false);
self.state.commit_dialog_open = false;
self.state.commit_message.clear();
self.request_diff();
}
Err(e) => {
self.set_status(format!("Commit failed: {e}"), true);
}
}
}
}
Action::CommitChar(c) => {
self.state.commit_message.insert_char(c);
}
Action::CommitBackspace => {
self.state.commit_message.delete_back();
}
Action::CommitNewline => {
self.state.commit_message.insert_char('\n');
}
Action::OpenTargetDialog => {
self.state.target_dialog_open = true;
self.state.target_dialog_input.clear();
}
Action::CancelTarget => {
self.state.target_dialog_open = false;
self.state.target_dialog_input.clear();
}
Action::TargetChar(c) => {
self.state.target_dialog_input.insert_char(c);
}
Action::TargetBackspace => {
self.state.target_dialog_input.delete_back();
}
Action::ConfirmTarget => {
let input = self.state.target_dialog_input.text().trim().to_string();
if input.is_empty() {
self.state.target_dialog_open = false;
self.state.target_dialog_input.clear();
self.apply_new_target(ComparisonTarget::HeadVsWorkdir, "HEAD".to_string());
} else {
match self.validate_ref(&input) {
Ok((target, label)) => {
self.state.target_dialog_open = false;
self.state.target_dialog_input.clear();
self.apply_new_target(target, label);
}
Err(e) => {
self.set_status(format!("Invalid ref '{}': {}", input, e), true);
}
}
}
}
Action::EnterVisualMode => {
self.state.selection.active = true;
self.state.selection.anchor = self.state.diff.cursor_row;
self.state.selection.cursor = self.state.diff.cursor_row;
self.state.focus = FocusPanel::DiffView;
}
Action::ExitVisualMode => {
self.state.selection.active = false;
}
Action::ExtendSelectionUp => {
self.state.selection.cursor = self.state.selection.cursor.saturating_sub(1);
let cursor_visual = self.visual_offset_for_row(self.state.selection.cursor);
if cursor_visual < self.state.diff.scroll_offset {
self.state.diff.scroll_offset = cursor_visual;
}
}
Action::ExtendSelectionDown => {
let display_map = self.current_display_map();
let max = display_map.len().saturating_sub(1);
if self.state.selection.cursor < max {
self.state.selection.cursor += 1;
}
let vh = self.state.diff.viewport_height.max(1);
let cursor_visual = self.visual_offset_for_row(self.state.selection.cursor);
if cursor_visual >= self.state.diff.scroll_offset + vh {
self.state.diff.scroll_offset = cursor_visual.saturating_sub(vh - 1);
}
}
Action::OpenCommentEditor => {
if !self.state.selection.active {
self.state.selection.active = true;
self.state.selection.anchor = self.state.diff.cursor_row;
self.state.selection.cursor = self.state.diff.cursor_row;
}
self.state.category_picker_open = true;
self.state.category_picker_phase =
crate::state::app_state::CategoryPickerPhase::SelectCategory;
self.state.pending_category = None;
self.state.pending_severity = None;
}
Action::SelectCategory(cat) => {
self.state.pending_category = Some(cat);
self.state.category_picker_phase =
crate::state::app_state::CategoryPickerPhase::SelectSeverity;
}
Action::SelectSeverity(sev) => {
self.state.pending_severity = Some(sev);
self.state.category_picker_open = false;
self.state.comment_editor_open = true;
self.state.comment_editor_text.clear();
}
Action::CategoryPickerDefault => {
use crate::state::annotation_state::{AnnotationCategory, AnnotationSeverity};
self.state.pending_category = Some(AnnotationCategory::Suggestion);
self.state.pending_severity = Some(AnnotationSeverity::Minor);
self.state.category_picker_open = false;
self.state.comment_editor_open = true;
self.state.comment_editor_text.clear();
}
Action::CancelCategoryPicker => {
self.state.category_picker_open = false;
self.state.pending_category = None;
self.state.pending_severity = None;
}
Action::CancelComment => {
self.state.comment_editor_open = false;
self.state.comment_editor_text.clear();
self.state.editing_annotation = None;
}
Action::ConfirmComment => {
use crate::state::annotation_state::{AnnotationCategory, AnnotationSeverity};
if !self.state.comment_editor_text.text().trim().is_empty() {
if let Some(editing) = self.state.editing_annotation.take() {
let comment_text = self.state.comment_editor_text.text().to_string();
self.state.annotations.update_comment(
&editing.file_path,
editing.old_range,
editing.new_range,
&editing.old_comment,
&comment_text,
);
self.set_status("Comment updated".to_string(), false);
} else if self.state.checklist.panel_open {
let note_text = self.state.comment_editor_text.text().to_string();
self.state.checklist.set_current_note(note_text);
self.set_status("Checklist note updated".to_string(), false);
} else if let Some(anchor) = self.selection_to_anchor() {
let now = chrono::Utc::now().to_rfc3339();
let category = self
.state
.pending_category
.unwrap_or(AnnotationCategory::Suggestion);
let severity = self
.state
.pending_severity
.unwrap_or(AnnotationSeverity::Minor);
self.state.annotations.add(Annotation {
anchor,
comment: self.state.comment_editor_text.text().to_string(),
created_at: now,
category,
severity,
});
self.set_status("Comment added".to_string(), false);
}
}
self.state.comment_editor_open = false;
self.state.comment_editor_text.clear();
self.state.selection.active = false;
self.state.editing_annotation = None;
self.state.pending_category = None;
self.state.pending_severity = None;
}
Action::CommentChar(c) => {
self.state.comment_editor_text.insert_char(c);
}
Action::CommentBackspace => {
self.state.comment_editor_text.delete_back();
}
Action::CommentNewline => {
self.state.comment_editor_text.insert_char('\n');
}
Action::DeleteAnnotation => {
if let Some(anchor) = self.selection_to_anchor() {
self.state.annotations.delete_at(
&anchor.file_path,
anchor.old_range,
anchor.new_range,
);
self.set_status("Annotation deleted".to_string(), false);
}
}
Action::NextAnnotation => {
let file_path = self
.state
.diff
.selected_delta()
.map(|d| d.path.to_string_lossy().to_string())
.unwrap_or_default();
let display_map = self.current_display_map();
let current_row = self.row_for_visual_offset(self.state.diff.scroll_offset);
let current_lineno = display_map
.get(current_row)
.and_then(|info| info.new_lineno.or(info.old_lineno))
.unwrap_or(0);
if let Some((_next_file, next_line)) = self
.state
.annotations
.next_after(&file_path, current_lineno)
{
self.scroll_to_line(next_line);
}
}
Action::PrevAnnotation => {
let file_path = self
.state
.diff
.selected_delta()
.map(|d| d.path.to_string_lossy().to_string())
.unwrap_or_default();
let display_map = self.current_display_map();
let current_row = self.row_for_visual_offset(self.state.diff.scroll_offset);
let current_lineno = display_map
.get(current_row)
.and_then(|info| info.new_lineno.or(info.old_lineno))
.unwrap_or(0);
if let Some((_prev_file, prev_line)) = self
.state
.annotations
.prev_before(&file_path, current_lineno)
{
self.scroll_to_line(prev_line);
}
}
Action::OpenAnnotationMenu => {
if let Some(anchor) = self.cursor_to_anchor() {
let overlapping = self.state.annotations.annotations_overlapping(
&anchor.file_path,
anchor.old_range.map(|(s, _)| s),
anchor.new_range.map(|(s, _)| s),
);
if overlapping.is_empty() {
self.set_status("No annotations on this line".to_string(), false);
} else {
self.state.annotation_menu_items = overlapping
.iter()
.map(|a| crate::state::app_state::AnnotationMenuItem {
file_path: a.anchor.file_path.clone(),
old_range: a.anchor.old_range,
new_range: a.anchor.new_range,
comment: a.comment.clone(),
category: a.category,
severity: a.severity,
})
.collect();
self.state.annotation_menu_selected = 0;
self.state.annotation_menu_open = true;
}
}
}
Action::AnnotationMenuUp => {
if !self.state.annotation_menu_items.is_empty() {
if self.state.annotation_menu_selected == 0 {
self.state.annotation_menu_selected =
self.state.annotation_menu_items.len() - 1;
} else {
self.state.annotation_menu_selected -= 1;
}
}
}
Action::AnnotationMenuDown => {
if !self.state.annotation_menu_items.is_empty() {
self.state.annotation_menu_selected = (self.state.annotation_menu_selected + 1)
% self.state.annotation_menu_items.len();
}
}
Action::AnnotationMenuDelete => {
if let Some(item) = self
.state
.annotation_menu_items
.get(self.state.annotation_menu_selected)
.cloned()
{
self.state.annotations.delete_annotation(
&item.file_path,
item.old_range,
item.new_range,
&item.comment,
);
self.state
.annotation_menu_items
.remove(self.state.annotation_menu_selected);
if self.state.annotation_menu_items.is_empty() {
self.state.annotation_menu_open = false;
self.set_status("Annotation deleted".to_string(), false);
} else {
if self.state.annotation_menu_selected
>= self.state.annotation_menu_items.len()
{
self.state.annotation_menu_selected =
self.state.annotation_menu_items.len() - 1;
}
self.set_status("Annotation deleted".to_string(), false);
}
}
}
Action::AnnotationMenuEdit => {
if let Some(item) = self
.state
.annotation_menu_items
.get(self.state.annotation_menu_selected)
.cloned()
{
self.state.editing_annotation =
Some(crate::state::app_state::EditingAnnotation {
file_path: item.file_path.clone(),
old_range: item.old_range,
new_range: item.new_range,
old_comment: item.comment.clone(),
});
self.state.annotation_menu_open = false;
self.state.comment_editor_open = true;
self.state.comment_editor_text.set(&item.comment);
}
}
Action::CancelAnnotationMenu => {
self.state.annotation_menu_open = false;
self.state.annotation_menu_items.clear();
}
Action::CopyPromptToClipboard => {
if let Some(rendered) = self.render_prompt_for_all_files() {
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(&rendered)) {
Ok(()) => self.set_status("Prompt copied to clipboard".to_string(), false),
Err(e) => {
self.set_status(format!("Clipboard error: {e}"), true);
}
}
} else {
self.set_status("No diff to copy".to_string(), true);
}
}
Action::TogglePromptPreview => {
self.state.prompt_preview_visible = !self.state.prompt_preview_visible;
if self.state.prompt_preview_visible {
self.update_prompt_preview();
}
}
Action::SetLineScore(value) => {
let anchor = if self.state.selection.active {
self.selection_to_anchor()
} else {
self.cursor_to_anchor()
};
if let Some(anchor) = anchor {
let score = crate::state::annotation_state::LineScore {
file_path: anchor.file_path.clone(),
old_range: anchor.old_range,
new_range: anchor.new_range,
score: value,
created_at: chrono::Utc::now().to_rfc3339(),
};
self.state.annotations.set_score(score);
if self.state.selection.active {
self.state.selection.active = false;
}
let dots = "●".repeat(value as usize) + &"○".repeat(5 - value as usize);
self.set_status(format!("Score: {} ({}/5)", dots, value), false);
}
}
Action::RemoveLineScore => {
let anchor = if self.state.selection.active {
self.selection_to_anchor()
} else {
self.cursor_to_anchor()
};
if let Some(anchor) = anchor {
self.state.annotations.remove_score(
&anchor.file_path,
anchor.old_range,
anchor.new_range,
);
if self.state.selection.active {
self.state.selection.active = false;
}
self.set_status("Score removed".to_string(), false);
}
}
Action::OpenAgentSelector => {
if self.config.agents.is_empty() {
self.set_status("No agents configured".to_string(), true);
} else {
self.state
.agent_selector
.last_models
.clone_from(&self.config.agent_models);
self.state.agent_selector.populate(&self.config.agents);
self.state.agent_selector.rerun_prompt = None;
self.state.agent_selector.open = true;
}
}
Action::CancelAgentSelector => {
self.state.agent_selector.open = false;
self.state.agent_selector.rerun_prompt = None;
}
Action::AgentSelectorUp => {
self.state.agent_selector.select_up();
}
Action::AgentSelectorDown => {
self.state.agent_selector.select_down();
}
Action::AgentSelectorFilter(c) => {
self.state.agent_selector.filter.insert_char(c);
self.state.agent_selector.refilter();
}
Action::AgentSelectorBackspace => {
self.state.agent_selector.filter.delete_back();
self.state.agent_selector.refilter();
}
Action::AgentSelectorCycleModel => {
self.state.agent_selector.cycle_model();
}
Action::SelectAgent => {
let agent = self.state.agent_selector.selected_agent_config().cloned();
let model = self.state.agent_selector.selected_model_name();
let rerun_prompt = self.state.agent_selector.rerun_prompt.clone();
if let (Some(agent), Some(model)) = (agent, model) {
let rendered_prompt =
rerun_prompt.or_else(|| self.render_prompt_for_all_files());
if let Some(prompt) = rendered_prompt {
for run in &mut self.state.agent_outputs.runs {
if matches!(run.status, AgentRunStatus::Running) {
run.status = AgentRunStatus::Failed { exit_code: -1 };
}
}
let command = build_agent_command(&agent.command, &model, &prompt);
let run_id = self.state.agent_outputs.next_id;
let (term_cols, term_rows) =
crossterm::terminal::size().unwrap_or((120, 40));
let pty_cols = (term_cols * 70 / 100).saturating_sub(2).max(40);
let pty_rows = term_rows.saturating_sub(4).max(10);
let worktree_name = self
.repo_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".to_string());
let worktree_path = self.repo_path.clone();
let run = AgentRun {
id: run_id,
agent_name: agent.name.clone(),
model: model.clone(),
command: command.clone(),
rendered_prompt: prompt,
terminal: vt100::Parser::new(pty_rows, pty_cols, 10000),
status: AgentRunStatus::Running,
started_at: chrono::Utc::now().format("%H:%M").to_string(),
worktree_name,
worktree_path,
};
self.state.agent_outputs.add_run(run);
self.pty_runner = Some(PtyRunner::spawn(
run_id,
&command,
pty_rows,
pty_cols,
&self.repo_path,
));
self.state.agent_selector.open = false;
self.state.active_view = ActiveView::AgentOutputs;
self.state.pty_focus = true;
self.state.annotations = Default::default();
session::save_session_data(
&self.repo_path,
&self.state.target_label,
&self.state.annotations,
if self.state.checklist.is_empty() {
None
} else {
Some(&self.state.checklist)
},
&self.state.bookmarks,
);
self.config
.agent_models
.insert(agent.name.clone(), model.clone());
config::save_agent_model(&agent.name, &model);
self.set_status(format!("Running {}/{}", agent.name, model), false);
} else {
self.set_status("No diff to review".to_string(), true);
}
}
}
Action::SwitchToAgentOutputs => {
if self.state.active_view == ActiveView::AgentOutputs {
self.state.active_view = ActiveView::DiffExplorer;
self.state.pty_focus = false;
self.state.focus = FocusPanel::Navigator;
} else {
self.state.active_view = ActiveView::AgentOutputs;
self.state.focus = FocusPanel::AgentRunList;
}
}
Action::AgentOutputsUp => {
self.state.agent_outputs.select_up();
}
Action::AgentOutputsDown => {
self.state.agent_outputs.select_down();
}
Action::AgentOutputsCopyPrompt => {
if let Some(run) = self.state.agent_outputs.selected() {
let prompt = run.rendered_prompt.clone();
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(&prompt)) {
Ok(()) => self.set_status("Prompt copied to clipboard".to_string(), false),
Err(e) => {
self.set_status(format!("Clipboard error: {e}"), true);
}
}
}
}
Action::KillAgentProcess => {
let selected_info = self.state.agent_outputs.selected().map(|run| {
(
run.id,
run.agent_name.clone(),
run.model.clone(),
run.status.clone(),
)
});
if let Some((selected_id, agent_name, model, status)) = selected_info {
if matches!(status, AgentRunStatus::Running) {
if let Some(runner) = self.pty_runner.as_mut() {
runner.kill();
self.state.pty_focus = false;
if let Some(run) = self
.state
.agent_outputs
.runs
.iter_mut()
.find(|r| r.id == selected_id)
{
run.status = AgentRunStatus::Failed { exit_code: -1 };
}
self.set_status(
format!("Killed agent: {}/{}", agent_name, model),
false,
);
} else {
self.set_status("No active agent process to kill".to_string(), true);
}
} else {
self.set_status("Selected agent is not running".to_string(), true);
}
} else {
self.set_status("No agent selected".to_string(), true);
}
}
Action::AgentOutputsSwitchWorktree => {
if let Some(run) = self.state.agent_outputs.selected() {
let new_path = run.worktree_path.clone();
let name = run.worktree_name.clone();
self.repo_path = new_path.clone();
self.worker = DiffWorker::new(new_path.clone());
self.git_cli = GitCli::new(&new_path);
self.generation = 0;
self.state.diff.deltas.clear();
self.state.diff.selected_file = None;
self.state.diff.scroll_offset = 0;
self.state.navigator.entries.clear();
self.state.navigator.filtered_indices.clear();
self.state.review.reset();
self.state.active_view = ActiveView::DiffExplorer;
self.request_diff();
self.set_status(format!("Switched to: {name}"), false);
}
}
Action::ToggleFileReviewed => {
let delta_idx = if self.state.navigator.tree_mode {
self.state.navigator.selected_tree_delta_index()
} else {
self.state.navigator.selected_delta_index()
};
if let Some(delta_idx) = delta_idx {
if let Some(delta) = self.state.diff.deltas.get(delta_idx) {
let path = delta.path.to_string_lossy().to_string();
self.state.review.toggle_reviewed(&path);
}
}
}
Action::NextUnreviewed => {
use crate::state::review_state::FileReviewStatus;
let visible = self.state.navigator.visible_entries();
if visible.is_empty() {
return;
}
let current = self.state.navigator.selected;
let len = visible.len();
for offset in 1..=len {
let idx = (current + offset) % len;
let path = &visible[idx].1.path;
let status = self.state.review.status(path);
if matches!(
status,
FileReviewStatus::Unreviewed
| FileReviewStatus::ChangedSinceReview
| FileReviewStatus::New
) {
self.state.navigator.selected = idx;
self.sync_selection();
return;
}
}
self.set_status("All files reviewed".to_string(), false);
}
Action::PtyInput(key) => {
if let Some(runner) = self.pty_runner.as_mut() {
let bytes = key_event_to_bytes(&key);
if !bytes.is_empty() {
runner.write_input(&bytes);
}
}
}
Action::PtyPaste(text) => {
if let Some(runner) = self.pty_runner.as_mut() {
runner.write_input(text.as_bytes());
}
}
Action::TextPaste(text) => {
let allows_newlines = self.state.comment_editor_open
|| self.state.commit_dialog_open
|| self.state.agentic_review_composing;
if self.state.navigator.search_active {
for c in text.chars().filter(|c| !c.is_control()) {
self.state.navigator.search_push(c);
}
} else if let Some(buf) = self.active_text_buffer() {
for c in text.chars() {
if c == '\n' && allows_newlines {
buf.insert_char('\n');
} else if !c.is_control() {
buf.insert_char(c);
}
}
}
if self.state.navigator.search_active {
self.sync_selection();
} else if self.state.diff.search_active {
self.recompute_diff_search_matches();
} else if self.state.global_search.active {
self.recompute_global_search_matches();
} else if self.state.agent_selector.open {
self.state.agent_selector.refilter();
} else if self.state.file_picker.active {
self.update_file_picker_filter();
}
}
Action::PtyScrollUp => {
if let Some(runner) = self.pty_runner.as_mut() {
runner.write_input(b"\x1b[A\x1b[A\x1b[A");
}
}
Action::PtyScrollDown => {
if let Some(runner) = self.pty_runner.as_mut() {
runner.write_input(b"\x1b[B\x1b[B\x1b[B");
}
}
Action::RefreshDiff => {
self.request_diff();
self.set_status("Refreshed".to_string(), false);
}
Action::OpenInEditor => {
self.prepare_open_in_editor();
}
Action::ToggleHud => {
self.state.hud_expanded = !self.state.hud_expanded;
self.hud_collapse_countdown = if self.state.hud_expanded { 200 } else { 0 };
}
Action::ToggleWhichKey => {
self.state.which_key_visible = !self.state.which_key_visible;
}
Action::Tick => {
if self.quit_confirm_countdown > 0 {
self.quit_confirm_countdown -= 1;
if self.quit_confirm_countdown == 0 {
self.last_quit_combo = None;
}
}
if self.status_clear_countdown > 0 {
self.status_clear_countdown -= 1;
if self.status_clear_countdown == 0 {
self.state.status_message = None;
}
}
if self.hud_collapse_countdown > 0 {
self.hud_collapse_countdown -= 1;
if self.hud_collapse_countdown == 0 {
self.state.hud_expanded = false;
}
}
}
Action::ExpandContext => {
let display_map = self.current_display_map();
if let Some(info) = display_map.get(self.state.diff.cursor_row) {
if info.is_collapsed_indicator {
if let Some(gap_id) = info.gap_id {
let current = self
.state
.diff
.gap_expansions
.get(&gap_id)
.copied()
.unwrap_or(0);
self.state.diff.gap_expansions.insert(gap_id, current + 20);
}
}
}
}
Action::JumpNextHunk => {
let display_map = self.current_display_map();
if let Some(row) = self.find_next_hunk_row(self.state.diff.cursor_row, &display_map)
{
self.state.diff.cursor_row = row;
self.state.diff.scroll_offset = self.visual_offset_for_row(row);
self.clamp_scroll();
let total_hunks = display_map.iter().filter(|r| r.is_header).count();
let current_hunk = display_map[..=row].iter().filter(|r| r.is_header).count();
self.state.status_message =
Some((format!("Hunk {}/{}", current_hunk, total_hunks), false));
self.check_viewport_review();
}
}
Action::JumpPrevHunk => {
let display_map = self.current_display_map();
if let Some(row) = self.find_prev_hunk_row(self.state.diff.cursor_row, &display_map)
{
self.state.diff.cursor_row = row;
self.state.diff.scroll_offset = self.visual_offset_for_row(row);
self.clamp_scroll();
let total_hunks = display_map.iter().filter(|r| r.is_header).count();
let current_hunk = display_map[..=row].iter().filter(|r| r.is_header).count();
self.state.status_message =
Some((format!("Hunk {}/{}", current_hunk, total_hunks), false));
self.check_viewport_review();
}
}
Action::OpenSettings => {
self.state.settings.open = true;
self.state.settings.selected_row = 0;
}
Action::CloseSettings => {
self.state.settings.open = false;
config::save_settings(&PersistentSettings {
theme: self.state.theme.name.clone(),
unified: self.state.diff.options.view_mode == DiffViewMode::Unified,
ignore_whitespace: self.state.diff.options.ignore_whitespace,
context_lines: self.state.diff.display_context,
tree_mode: self.state.navigator.tree_mode,
agentic_parent_provider: self
.config
.agentic_review
.resolved_parent_provider()
.to_string(),
agentic_parent_model: self.config.agentic_review.parent_model.clone(),
agentic_child_provider: self
.config
.agentic_review
.resolved_child_provider()
.to_string(),
agentic_child_model: self.config.agentic_review.child_model.clone(),
max_agent_turns: self.config.agentic_review.max_agent_turns,
});
}
Action::SettingsUp => {
if self.state.settings.selected_row > 0 {
self.state.settings.selected_row -= 1;
}
}
Action::SettingsDown => {
if self.state.settings.selected_row < SETTINGS_ROW_COUNT - 1 {
self.state.settings.selected_row += 1;
}
}
Action::SettingsLeft => {
match self.state.settings.selected_row {
0 => {
let new_name = prev_theme(&self.state.theme.name);
self.state.theme = Theme::from_name(new_name);
self.update_highlights();
}
1 => {
self.state.diff.options.view_mode = match self.state.diff.options.view_mode
{
DiffViewMode::Split => DiffViewMode::Unified,
DiffViewMode::Unified => DiffViewMode::Split,
};
self.state.diff.cursor_row = 0;
self.state.diff.scroll_offset = 0;
self.state.selection.active = false;
}
2 => {
self.state.diff.options.ignore_whitespace =
!self.state.diff.options.ignore_whitespace;
self.request_diff();
}
3 => {
if self.state.diff.display_context > 1 {
self.state.diff.display_context -= 1;
}
}
4 => {
self.state.navigator.toggle_tree_mode();
}
5 => {
let cur = self
.config
.agentic_review
.resolved_parent_provider()
.to_string();
let new_provider = prev_agentic_provider(&cur);
self.config.agentic_review.parent_provider = Some(new_provider.to_string());
let models = agentic_models_for_provider(new_provider);
if let Some(first) = models.first() {
self.config.agentic_review.parent_model = first.to_string();
}
}
6 => {
let provider = self
.config
.agentic_review
.resolved_parent_provider()
.to_string();
self.config.agentic_review.parent_model =
prev_agentic_model(&provider, &self.config.agentic_review.parent_model);
}
7 => {
let cur = self
.config
.agentic_review
.resolved_child_provider()
.to_string();
let new_provider = prev_agentic_provider(&cur);
self.config.agentic_review.child_provider = Some(new_provider.to_string());
let models = agentic_models_for_provider(new_provider);
if let Some(first) = models.first() {
self.config.agentic_review.child_model = first.to_string();
}
}
8 => {
let provider = self
.config
.agentic_review
.resolved_child_provider()
.to_string();
self.config.agentic_review.child_model =
prev_agentic_model(&provider, &self.config.agentic_review.child_model);
}
9 => {
if self.config.agentic_review.max_agent_turns > 1 {
self.config.agentic_review.max_agent_turns -= 1;
}
}
_ => {}
}
}
Action::SettingsRight => {
match self.state.settings.selected_row {
0 => {
let new_name = next_theme(&self.state.theme.name);
self.state.theme = Theme::from_name(new_name);
self.update_highlights();
}
1 => {
self.state.diff.options.view_mode = match self.state.diff.options.view_mode
{
DiffViewMode::Split => DiffViewMode::Unified,
DiffViewMode::Unified => DiffViewMode::Split,
};
self.state.diff.cursor_row = 0;
self.state.diff.scroll_offset = 0;
self.state.selection.active = false;
}
2 => {
self.state.diff.options.ignore_whitespace =
!self.state.diff.options.ignore_whitespace;
self.request_diff();
}
3 => {
if self.state.diff.display_context < 20 {
self.state.diff.display_context += 1;
}
}
4 => {
self.state.navigator.toggle_tree_mode();
}
5 => {
let cur = self
.config
.agentic_review
.resolved_parent_provider()
.to_string();
let new_provider = next_agentic_provider(&cur);
self.config.agentic_review.parent_provider = Some(new_provider.to_string());
let models = agentic_models_for_provider(new_provider);
if let Some(first) = models.first() {
self.config.agentic_review.parent_model = first.to_string();
}
}
6 => {
let provider = self
.config
.agentic_review
.resolved_parent_provider()
.to_string();
self.config.agentic_review.parent_model =
next_agentic_model(&provider, &self.config.agentic_review.parent_model);
}
7 => {
let cur = self
.config
.agentic_review
.resolved_child_provider()
.to_string();
let new_provider = next_agentic_provider(&cur);
self.config.agentic_review.child_provider = Some(new_provider.to_string());
let models = agentic_models_for_provider(new_provider);
if let Some(first) = models.first() {
self.config.agentic_review.child_model = first.to_string();
}
}
8 => {
let provider = self
.config
.agentic_review
.resolved_child_provider()
.to_string();
self.config.agentic_review.child_model =
next_agentic_model(&provider, &self.config.agentic_review.child_model);
}
9 => {
if self.config.agentic_review.max_agent_turns < 200 {
self.config.agentic_review.max_agent_turns += 1;
}
}
_ => {}
}
}
Action::ToggleFeedbackSummary => {
if self.state.active_view == ActiveView::FeedbackSummary {
self.state.active_view = ActiveView::DiffExplorer;
} else {
self.state.active_view = ActiveView::FeedbackSummary;
self.state.feedback_summary_scroll = 0;
}
}
Action::FeedbackSummaryUp => {
self.state.feedback_summary_scroll =
self.state.feedback_summary_scroll.saturating_sub(1);
}
Action::FeedbackSummaryDown => {
self.state.feedback_summary_scroll += 1;
}
Action::FeedbackSummaryCopyJson => {
let json = self.build_feedback_summary_json();
if let Ok(text) = serde_json::to_string_pretty(&json) {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(&text);
self.set_status("Feedback JSON copied to clipboard".to_string(), false);
}
}
}
Action::FeedbackSummaryCopyPrompt => {
let prompt_text = self.build_feedback_summary_prompt();
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(&prompt_text);
self.set_status("Feedback summary copied to clipboard".to_string(), false);
}
}
Action::ExportFeedback => {
crate::export::ensure_gitignore(&self.repo_path);
match crate::export::export_feedback(
&self.state,
&self.repo_path,
&self.state.target_label,
) {
Ok(path) => {
self.set_status(format!("Exported to {}", path.display()), false);
}
Err(e) => {
self.set_status(format!("Export failed: {e}"), true);
}
}
}
Action::TextCursorLeft => {
if let Some(buf) = self.active_text_buffer() {
buf.move_left();
}
}
Action::TextCursorRight => {
if let Some(buf) = self.active_text_buffer() {
buf.move_right();
}
}
Action::TextCursorHome => {
if let Some(buf) = self.active_text_buffer() {
buf.move_home();
}
}
Action::TextCursorEnd => {
if let Some(buf) = self.active_text_buffer() {
buf.move_end();
}
}
Action::TextDeleteWord => {
if let Some(buf) = self.active_text_buffer() {
buf.delete_word_back();
}
if self.state.navigator.search_active {
self.state.navigator.refilter();
self.sync_selection();
} else if self.state.diff.search_active {
self.recompute_diff_search_matches();
} else if self.state.agent_selector.open {
self.state.agent_selector.refilter();
} else if self.state.file_picker.active {
self.update_file_picker_filter();
}
}
Action::Resize => {
if let Some(runner) = self.pty_runner.as_ref() {
let (term_cols, term_rows) = crossterm::terminal::size().unwrap_or((120, 40));
let pty_cols = (term_cols * 70 / 100).saturating_sub(2).max(40);
let pty_rows = term_rows.saturating_sub(4).max(10);
runner.resize(pty_rows, pty_cols);
if let Some(run) = self
.state
.agent_outputs
.runs
.iter_mut()
.find(|r| matches!(r.status, AgentRunStatus::Running))
{
run.terminal.set_size(pty_rows, pty_cols);
}
}
}
Action::ToggleChecklist => {
if self.state.checklist.is_empty() {
self.set_status(
"No checklist configured. Add [checklist] to config.toml".to_string(),
false,
);
} else {
self.state.checklist.panel_open = !self.state.checklist.panel_open;
}
}
Action::ChecklistUp => {
if self.state.checklist.panel_open {
self.state.checklist.select_up();
}
}
Action::ChecklistDown => {
if self.state.checklist.panel_open {
self.state.checklist.select_down();
}
}
Action::ChecklistToggleItem => {
if self.state.checklist.panel_open {
self.state.checklist.toggle_current_item();
}
}
Action::ChecklistAddNote => {
if self.state.checklist.panel_open {
self.state.comment_editor_open = true;
self.state.comment_editor_text.clear();
if let Some(item) = self.state.checklist.current_item() {
if let Some(ref note) = item.note {
self.state.comment_editor_text.set(note);
}
}
}
}
Action::ToggleTreeView => {
self.state.navigator.toggle_tree_mode();
config::save_settings(&PersistentSettings {
theme: self.state.theme.name.clone(),
unified: self.state.diff.options.view_mode == DiffViewMode::Unified,
ignore_whitespace: self.state.diff.options.ignore_whitespace,
context_lines: self.state.diff.display_context,
tree_mode: self.state.navigator.tree_mode,
agentic_parent_provider: self
.config
.agentic_review
.resolved_parent_provider()
.to_string(),
agentic_parent_model: self.config.agentic_review.parent_model.clone(),
agentic_child_provider: self
.config
.agentic_review
.resolved_child_provider()
.to_string(),
agentic_child_model: self.config.agentic_review.child_model.clone(),
max_agent_turns: self.config.agentic_review.max_agent_turns,
});
if self.state.navigator.tree_mode {
self.sync_tree_selection();
} else {
self.sync_selection();
}
}
Action::TreeToggleCollapse => {
if let Some(entry_index) = self.state.navigator.tree_toggle_collapse() {
self.state.diff.selected_file = Some(entry_index);
self.state.diff.scroll_offset = 0;
self.state.diff.cursor_row = 0;
self.update_highlights();
self.state.selection.active = false;
self.state.diff.gap_expansions.clear();
self.state.diff.search_query.clear();
self.state.diff.search_matches.clear();
self.state.diff.search_match_index = None;
self.state.diff.search_active = false;
self.state.focus = FocusPanel::DiffView;
}
}
Action::TreeCollapseAll => {
self.state.navigator.tree_collapse_all();
}
Action::TreeExpandAll => {
self.state.navigator.tree_expand_all();
}
Action::ToggleBookmark => {
if let Some(anchor) = self.cursor_to_anchor() {
let line = anchor
.new_range
.map(|(s, _)| s)
.or(anchor.old_range.map(|(s, _)| s));
let is_new = anchor.new_range.is_some();
if let Some(line) = line {
let added = self.state.bookmarks.toggle(&anchor.file_path, line, is_new);
let msg = if added {
format!("Bookmark added at {}:{}", anchor.file_path, line)
} else {
format!("Bookmark removed at {}:{}", anchor.file_path, line)
};
self.set_status(msg, false);
}
}
}
Action::ToggleBookmarkList => {
self.state.bookmarks.list_visible = !self.state.bookmarks.list_visible;
if self.state.bookmarks.list_visible && self.state.bookmarks.bookmarks.is_empty() {
self.state.bookmarks.list_visible = false;
self.set_status("No bookmarks set".to_string(), false);
}
}
Action::NextBookmark => {
let file_path = self
.state
.diff
.selected_delta()
.map(|d| d.path.to_string_lossy().to_string())
.unwrap_or_default();
let display_map = self.current_display_map();
let current_row = self.state.diff.cursor_row;
let current_lineno = display_map
.get(current_row)
.and_then(|info| info.new_lineno.or(info.old_lineno))
.unwrap_or(0);
if let Some((_, bm)) = self.state.bookmarks.next_after(&file_path, current_lineno) {
let target_file = bm.file_path.clone();
let target_line = bm.line;
self.navigate_to_file_line(&target_file, target_line);
} else {
self.set_status("No bookmarks".to_string(), false);
}
}
Action::PrevBookmark => {
let file_path = self
.state
.diff
.selected_delta()
.map(|d| d.path.to_string_lossy().to_string())
.unwrap_or_default();
let display_map = self.current_display_map();
let current_row = self.state.diff.cursor_row;
let current_lineno = display_map
.get(current_row)
.and_then(|info| info.new_lineno.or(info.old_lineno))
.unwrap_or(0);
if let Some((_, bm)) = self.state.bookmarks.prev_before(&file_path, current_lineno)
{
let target_file = bm.file_path.clone();
let target_line = bm.line;
self.navigate_to_file_line(&target_file, target_line);
} else {
self.set_status("No bookmarks".to_string(), false);
}
}
Action::SetNamedBookmark(label) => {
if let Some(anchor) = self.cursor_to_anchor() {
let line = anchor
.new_range
.map(|(s, _)| s)
.or(anchor.old_range.map(|(s, _)| s));
let is_new = anchor.new_range.is_some();
if let Some(line) = line {
self.state
.bookmarks
.set_named(&anchor.file_path, line, is_new, label);
self.set_status(
format!("Bookmark '{label}' set at {}:{}", anchor.file_path, line),
false,
);
}
}
}
Action::JumpToNamedBookmark(label) => {
if let Some(bm) = self.state.bookmarks.find_named(label) {
let target_file = bm.file_path.clone();
let target_line = bm.line;
self.navigate_to_file_line(&target_file, target_line);
} else {
self.set_status(format!("No bookmark '{label}'"), false);
}
}
Action::BookmarkListUp => {
if self.state.bookmarks.list_visible && !self.state.bookmarks.bookmarks.is_empty() {
if self.state.bookmarks.list_selected == 0 {
self.state.bookmarks.list_selected =
self.state.bookmarks.all_sorted().len() - 1;
} else {
self.state.bookmarks.list_selected -= 1;
}
}
}
Action::BookmarkListDown => {
if self.state.bookmarks.list_visible && !self.state.bookmarks.bookmarks.is_empty() {
let count = self.state.bookmarks.all_sorted().len();
if self.state.bookmarks.list_selected >= count - 1 {
self.state.bookmarks.list_selected = 0;
} else {
self.state.bookmarks.list_selected += 1;
}
}
}
Action::BookmarkListSelect => {
if self.state.bookmarks.list_visible {
let sorted = self.state.bookmarks.all_sorted();
if let Some((_, bm)) = sorted.get(self.state.bookmarks.list_selected) {
let target_file = bm.file_path.clone();
let target_line = bm.line;
self.state.bookmarks.list_visible = false;
self.navigate_to_file_line(&target_file, target_line);
}
}
}
Action::BookmarkListDelete => {
if self.state.bookmarks.list_visible {
let sorted = self.state.bookmarks.all_sorted();
if let Some(&(real_idx, _)) = sorted.get(self.state.bookmarks.list_selected) {
self.state.bookmarks.delete(real_idx);
let new_count = self.state.bookmarks.bookmarks.len();
if self.state.bookmarks.list_selected >= new_count && new_count > 0 {
self.state.bookmarks.list_selected = new_count - 1;
}
if new_count == 0 {
self.state.bookmarks.list_visible = false;
}
}
}
}
Action::OpenCommandBar => {
self.state.command_bar.open();
}
Action::CommandBarChar(c) => {
self.state.command_bar.buffer.insert_char(c);
}
Action::CommandBarBackspace => {
self.state.command_bar.buffer.delete_back();
}
Action::CommandBarCancel => {
self.state.command_bar.close();
}
Action::CommandBarConfirm => {
let input = self.state.command_bar.buffer.text().trim().to_string();
self.state.command_bar.close();
if let Ok(line_num) = input.parse::<u32>() {
self.update(Action::GoToLine(line_num));
} else if input == "review" {
if !self.state.agentic_review_stream_output.is_empty()
&& !self.state.agentic_review_modal_open
{
self.update(Action::ToggleAgenticReviewPanel);
} else {
self.update(Action::OpenAgenticReview);
}
} else if input == "help" || input == "settings" {
self.update(Action::OpenSettings);
} else if !input.is_empty() {
self.state.status_message = Some((format!("Unknown command: {input}"), true));
self.status_clear_countdown = 60;
}
}
Action::GoToLine(target_line) => {
self.goto_line(target_line);
}
Action::OpenFilePicker => {
let entries: Vec<FilePickerEntry> = self
.state
.diff
.deltas
.iter()
.enumerate()
.map(|(i, delta)| FilePickerEntry {
delta_index: i,
path: delta.path.to_string_lossy().to_string(),
additions: delta.additions,
deletions: delta.deletions,
})
.collect();
self.state.file_picker.open(entries);
}
Action::FilePickerChar(c) => {
self.state.file_picker.query.insert_char(c);
self.update_file_picker_filter();
}
Action::FilePickerBackspace => {
self.state.file_picker.query.delete_back();
self.update_file_picker_filter();
}
Action::FilePickerUp => {
self.state.file_picker.move_up();
}
Action::FilePickerDown => {
self.state.file_picker.move_down();
}
Action::FilePickerConfirm => {
if let Some(delta_idx) = self.state.file_picker.selected_delta_index() {
self.state.file_picker.close();
self.update(Action::SelectFile(delta_idx));
self.state.focus = FocusPanel::DiffView;
}
}
Action::FilePickerCancel => {
self.state.file_picker.close();
}
Action::OpenAgenticReview => {
for (role, provider_str) in [
(
"parent",
self.config.agentic_review.resolved_parent_provider(),
),
(
"child",
self.config.agentic_review.resolved_child_provider(),
),
] {
match crate::ai_client::AiProvider::from_str(provider_str) {
Ok(p) => {
let key = crate::ai_client::resolve_api_key(&p, &self.config.api_keys);
if key.is_none() {
self.state.status_message = Some((
format!(
"Missing API key for {} ({} agent). Set {} or add to config.toml [api_keys].",
provider_str,
role,
p.env_var_name()
),
true,
));
self.status_clear_countdown = 120;
return;
}
}
Err(_) => {
self.state.status_message = Some((
format!(
"Unknown provider '{}' for {} agent in config.",
provider_str, role
),
true,
));
self.status_clear_countdown = 120;
return;
}
}
}
self.state.agentic_review_panel_open = true;
self.state.agentic_review_composing = true;
self.state.focus = FocusPanel::ReviewPanel;
self.state.agentic_review_text = crate::state::TextBuffer::new();
}
Action::AgenticReviewChar(c) => {
self.state.agentic_review_text.insert_char(c);
}
Action::AgenticReviewBackspace => {
self.state.agentic_review_text.delete_back();
}
Action::AgenticReviewNewline => {
self.state.agentic_review_text.insert_char('\n');
}
Action::AgenticReviewConfirm => {
let text = self.state.agentic_review_text.text().to_string();
if text.trim().is_empty() {
self.state.status_message = Some(("Review text is empty.".to_string(), true));
self.status_clear_countdown = 60;
return;
}
self.state.agentic_review_composing = false;
self.state.agentic_review_running = true;
self.state.agentic_review_panel_open = true;
self.state.agentic_review_stream_output.clear();
self.state.agentic_review_child_done = 0;
self.state.agentic_review_child_total = 0;
self.state.agentic_review_scroll = 0;
self.state.agentic_review_auto_scroll = true;
let deltas = self.state.diff.deltas.clone();
let config = self.config.agentic_review.clone();
let api_keys = self.config.api_keys.clone();
self.agentic_review_runner =
Some(crate::agentic_review::AgenticReviewRunner::spawn(
text,
deltas,
config,
api_keys,
self.repo_path.clone(),
));
}
Action::ToggleAgenticReviewPanel => {
self.state.agentic_review_panel_open = !self.state.agentic_review_panel_open;
}
Action::AgenticReviewStreamToken(token) => {
self.state.agentic_review_stream_output.push_str(&token);
}
Action::AgenticReviewChildProgress(done, total) => {
self.state.agentic_review_child_done = done;
self.state.agentic_review_child_total = total;
}
Action::AgenticReviewComplete(annotations) => {
self.state.agentic_review_running = false;
self.agentic_review_runner = None;
let count = annotations.len();
let summary = self.build_agentic_review_summary(&annotations);
for ann in annotations {
self.state.annotations.add(ann);
}
self.state.agentic_review_stream_output.push_str(&summary);
if self.state.prompt_preview_visible {
self.update_prompt_preview();
}
session::save_session_data(
&self.repo_path,
&self.state.target_label,
&self.state.annotations,
if self.state.checklist.is_empty() {
None
} else {
Some(&self.state.checklist)
},
&self.state.bookmarks,
);
self.state.status_message = Some((
format!("Agentic review: {count} annotation(s) created."),
false,
));
self.status_clear_countdown = 120;
}
Action::AgenticReviewError(msg) => {
self.state.agentic_review_running = false;
self.agentic_review_runner = None;
self.state
.agentic_review_stream_output
.push_str(&format!("\n\nError: {msg}"));
self.state.status_message = Some((format!("Agentic review error: {msg}"), true));
self.status_clear_countdown = 120;
}
Action::AgenticReviewPanelUp => {
self.state.agentic_review_scroll =
self.state.agentic_review_scroll.saturating_sub(1);
self.state.agentic_review_auto_scroll = false;
}
Action::AgenticReviewPanelDown => {
self.state.agentic_review_scroll += 1;
}
}
}
fn sync_pty_focus(&mut self) {
if self.state.focus == FocusPanel::AgentOutput && self.pty_runner.is_some() {
if let Some(run) = self.state.agent_outputs.selected() {
if matches!(run.status, AgentRunStatus::Running) {
self.state.pty_focus = true;
return;
}
}
}
self.state.pty_focus = false;
}
fn active_text_buffer(&mut self) -> Option<&mut crate::state::TextBuffer> {
if self.state.commit_dialog_open {
Some(&mut self.state.commit_message)
} else if self.state.target_dialog_open {
Some(&mut self.state.target_dialog_input)
} else if self.state.comment_editor_open {
Some(&mut self.state.comment_editor_text)
} else if self.state.global_search.active {
Some(&mut self.state.global_search.query)
} else if self.state.diff.search_active {
Some(&mut self.state.diff.search_query)
} else if self.state.navigator.search_active {
Some(&mut self.state.navigator.search_query)
} else if self.state.agent_selector.open {
Some(&mut self.state.agent_selector.filter)
} else if self.state.command_bar.active {
Some(&mut self.state.command_bar.buffer)
} else if self.state.file_picker.active {
Some(&mut self.state.file_picker.query)
} else if self.state.agentic_review_composing && self.state.focus == FocusPanel::ReviewPanel
{
Some(&mut self.state.agentic_review_text)
} else {
None
}
}
fn goto_line(&mut self, target_line: u32) {
if target_line == 0 {
self.state.status_message = Some(("Line numbers start at 1".to_string(), true));
self.status_clear_countdown = 60;
return;
}
let delta = match self.state.diff.selected_delta() {
Some(d) => d.clone(),
None => {
self.state.status_message = Some(("No file selected".to_string(), true));
self.status_clear_countdown = 60;
return;
}
};
let display_map = build_display_map(
&delta,
self.state.diff.options.view_mode,
self.state.diff.display_context,
&self.state.diff.gap_expansions,
);
let target_row = display_map
.iter()
.position(|row| row.new_lineno == Some(target_line));
if let Some(row_idx) = target_row {
self.state.diff.cursor_row = row_idx;
self.ensure_cursor_visible();
self.state.focus = FocusPanel::DiffView;
} else {
let nearest = display_map
.iter()
.enumerate()
.filter_map(|(idx, row)| row.new_lineno.map(|n| (idx, n)))
.min_by_key(|(_, n)| ((*n as i64) - (target_line as i64)).unsigned_abs());
if let Some((row_idx, actual_line)) = nearest {
self.state.diff.cursor_row = row_idx;
self.ensure_cursor_visible();
self.state.focus = FocusPanel::DiffView;
self.state.status_message = Some((
format!("Line {target_line} not in diff, jumped to nearest line {actual_line}"),
false,
));
self.status_clear_countdown = 60;
} else {
self.state.status_message =
Some((format!("Line {target_line} not found in diff"), true));
self.status_clear_countdown = 60;
}
}
}
fn update_file_picker_filter(&mut self) {
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use nucleo::Matcher;
let query = self.state.file_picker.query.text().to_string();
if query.is_empty() {
self.state.file_picker.filtered = self
.state
.file_picker
.entries
.iter()
.enumerate()
.map(|(i, _)| crate::state::file_picker_state::FilteredEntry {
entry_index: i,
score: 0,
match_indices: Vec::new(),
})
.collect();
self.state.file_picker.selected = 0;
return;
}
let mut matcher = Matcher::new(nucleo::Config::DEFAULT.match_paths());
let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
let mut results: Vec<crate::state::file_picker_state::FilteredEntry> = Vec::new();
let mut buf = Vec::new();
for (i, entry) in self.state.file_picker.entries.iter().enumerate() {
let haystack = nucleo::Utf32String::from(entry.path.as_str());
if let Some(score) = pattern.score(haystack.slice(..), &mut matcher) {
buf.clear();
buf.resize(query.chars().count(), 0);
pattern.indices(haystack.slice(..), &mut matcher, &mut buf);
results.push(crate::state::file_picker_state::FilteredEntry {
entry_index: i,
score,
match_indices: buf.clone(),
});
}
}
results.sort_by(|a, b| b.score.cmp(&a.score));
self.state.file_picker.filtered = results;
self.state.file_picker.selected = 0;
}
fn refresh_worktrees(&mut self) {
match worktree::list_worktrees(&self.repo_path) {
Ok(wts) => {
self.state.worktree.worktrees = wts;
self.state.worktree.loading = false;
}
Err(e) => {
self.set_status(format!("Failed to list worktrees: {e}"), true);
}
}
}
fn set_status(&mut self, msg: String, is_error: bool) {
self.set_status_for_ticks(msg, is_error, 60);
}
fn set_status_for_ticks(&mut self, msg: String, is_error: bool, ticks: u32) {
self.state.status_message = Some((msg, is_error));
self.status_clear_countdown = ticks;
}
fn prepare_open_in_editor(&mut self) {
let Some(delta) = self.state.diff.selected_delta() else {
return;
};
if delta.binary {
self.set_status("Cannot open binary file in editor".to_string(), true);
return;
}
let file_path = delta.path.clone();
let abs_path = self.repo_path.join(&file_path);
if delta.status == crate::git::types::FileStatus::Deleted && !abs_path.exists() {
self.set_status("File no longer exists on disk".to_string(), true);
return;
}
let line = self.state.diff.current_source_line().unwrap_or(1);
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let editor_name = resolve_editor_name(&editor);
let is_gui = is_gui_editor(&editor_name);
self.pending_editor = Some(PendingEditorOpen {
path: abs_path,
line,
editor,
is_gui,
});
}
fn execute_pending_editor(&mut self, terminal: &mut Tui) {
let Some(pending) = self.pending_editor.take() else {
return;
};
let path_display = pending
.path
.strip_prefix(&self.repo_path)
.unwrap_or(&pending.path)
.display()
.to_string();
let editor_name = resolve_editor_name(&pending.editor);
let mut cmd = build_editor_command(&pending.editor, &pending.path, pending.line);
if pending.is_gui {
match cmd.spawn() {
Ok(_) => {
self.set_status(
format!(
"Opened {}:{} in {}",
path_display, pending.line, editor_name
),
false,
);
}
Err(e) => {
self.set_status(format!("Failed to open editor: {e}"), true);
}
}
} else {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture,
crossterm::event::DisableBracketedPaste
);
let status = cmd.status();
let _ = crossterm::terminal::enable_raw_mode();
let _ = crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture,
crossterm::event::EnableBracketedPaste
);
let _ = terminal.clear();
match status {
Ok(s) if s.success() => {
self.set_status(
format!(
"Opened {}:{} in {}",
path_display, pending.line, editor_name
),
false,
);
}
Ok(s) => {
self.set_status(
format!(
"Editor exited with {}",
s.code().map_or("signal".to_string(), |c| c.to_string())
),
true,
);
}
Err(e) => {
self.set_status(format!("Failed to open editor: {e}"), true);
}
}
self.request_diff();
}
}
fn validate_ref(&self, input: &str) -> Result<(ComparisonTarget, String), String> {
let repo =
git2::Repository::open(&self.repo_path).map_err(|e| format!("open repo: {e}"))?;
repo.revparse_single(input).map_err(|e| format!("{e}"))?;
let target = parse_target(Some(input));
let label = match &target {
ComparisonTarget::HeadVsWorkdir => "HEAD".to_string(),
ComparisonTarget::Branch(name) => name.clone(),
ComparisonTarget::Commit(oid) => format!("{:.7}", oid),
};
Ok((target, label))
}
fn apply_new_target(&mut self, target: ComparisonTarget, label: String) {
session::save_session_data(
&self.repo_path,
&self.state.target_label,
&self.state.annotations,
if self.state.checklist.is_empty() {
None
} else {
Some(&self.state.checklist)
},
&self.state.bookmarks,
);
self.target = target;
self.state.target_label = label.clone();
let (annotations, saved_checklist, bookmarks) =
session::load_session_data(&self.repo_path, &label);
self.state.annotations = annotations;
self.state.bookmarks = bookmarks;
if let Some(saved) = saved_checklist {
self.state.checklist = saved;
} else if let Some(checklist_config) = load_checklist_config(&self.repo_path) {
let items = checklist_config_to_items(&checklist_config);
self.state.checklist = ChecklistState::from_config_items(items);
} else {
self.state.checklist.reset();
}
self.state.diff.deltas.clear();
self.state.diff.selected_file = None;
self.state.diff.scroll_offset = 0;
self.state.diff.cursor_row = 0;
self.state.navigator.entries.clear();
self.state.navigator.filtered_indices.clear();
self.state.selection.active = false;
self.state.review.reset();
self.request_diff();
self.set_status(format!("Target: {label}"), false);
}
fn check_auto_review(&mut self) {
if self.state.focus != FocusPanel::DiffView {
return;
}
let max = self.current_display_map().len().saturating_sub(1);
if self.state.diff.cursor_row >= max {
if let Some(delta) = self.state.diff.selected_delta() {
let path = delta.path.to_string_lossy().to_string();
self.state.review.mark_reviewed(&path);
}
}
}
fn check_viewport_review(&mut self) {
if self.state.focus != FocusPanel::DiffView {
return;
}
let total_rows = self.state.diff.visual_total_rows;
if total_rows == 0 {
return;
}
let viewport_bottom = self.state.diff.scroll_offset + self.state.diff.viewport_height;
if viewport_bottom >= total_rows {
if let Some(delta_idx) = self.state.navigator.selected_delta_index() {
if let Some(delta) = self.state.diff.deltas.get(delta_idx) {
let path = delta.path.to_string_lossy().to_string();
if self.state.review.status(&path) != FileReviewStatus::Reviewed {
self.state.review.mark_reviewed(&path);
}
}
}
}
}
fn selected_file_path(&self) -> Option<PathBuf> {
self.state
.diff
.selected_file
.and_then(|idx| self.state.diff.deltas.get(idx))
.map(|delta| delta.path.clone())
}
fn map_tree_mouse_to_action(&mut self, mouse: MouseEvent) -> Option<Action> {
use crate::state::navigator_state::TreeNodeKind;
let nav_rect = self.last_navigator_rect;
let diff_rect = self.last_diff_view_rect;
let col = mouse.column;
let row = mouse.row;
let in_navigator = nav_rect.x <= col
&& col < nav_rect.x + nav_rect.width
&& nav_rect.y <= row
&& row < nav_rect.y + nav_rect.height;
let in_diff = diff_rect.x <= col
&& col < diff_rect.x + diff_rect.width
&& diff_rect.y <= row
&& row < diff_rect.y + diff_rect.height;
match mouse.kind {
MouseEventKind::ScrollUp => {
if in_navigator {
Some(Action::NavigatorUp)
} else if in_diff {
Some(Action::ScrollUp)
} else {
None
}
}
MouseEventKind::ScrollDown => {
if in_navigator {
Some(Action::NavigatorDown)
} else if in_diff {
Some(Action::ScrollDown)
} else {
None
}
}
MouseEventKind::Down(MouseButton::Left) => {
if in_navigator {
let inner_height = nav_rect.height.saturating_sub(2) as usize;
let tree_nodes = self.state.navigator.visible_tree_nodes();
let selected = self.state.navigator.tree_selected;
let scroll = if selected >= inner_height {
selected - inner_height + 1
} else {
0
};
let relative_row = row.saturating_sub(nav_rect.y + 1) as usize;
let node_index = scroll + relative_row;
if node_index < tree_nodes.len() {
let action = match &tree_nodes[node_index].kind {
TreeNodeKind::File { entry_index } => {
Some(Action::SelectFile(*entry_index))
}
TreeNodeKind::Directory { .. } => Some(Action::TreeToggleCollapse),
};
self.state.navigator.tree_selected = node_index;
action
} else {
None
}
} else if in_diff {
Some(Action::FocusDiffView)
} else {
None
}
}
_ => None,
}
}
fn sync_selection(&mut self) {
if let Some(delta_idx) = self.state.navigator.selected_delta_index() {
self.apply_file_selection(delta_idx);
}
}
fn sync_tree_selection(&mut self) {
if let Some(delta_idx) = self.state.navigator.selected_tree_delta_index() {
self.apply_file_selection(delta_idx);
}
}
fn apply_file_selection(&mut self, delta_idx: usize) {
let changed = self.state.diff.selected_file != Some(delta_idx);
self.state.diff.selected_file = Some(delta_idx);
self.state.diff.scroll_offset = 0;
if changed {
self.state.diff.cursor_row = 0;
self.update_highlights();
self.state.selection.active = false;
self.state.diff.gap_expansions.clear();
self.state.diff.search_query.clear();
self.state.diff.search_matches.clear();
self.state.diff.search_match_index = None;
self.state.diff.search_active = false;
}
}
fn recompute_diff_search_matches(&mut self) {
self.state.diff.search_matches.clear();
self.state.diff.search_match_index = None;
let query = self.state.diff.search_query.text().to_lowercase();
if query.is_empty() {
return;
}
let matches: Vec<usize> = {
let Some(delta) = self.state.diff.selected_delta() else {
return;
};
let display_map = build_display_map(
delta,
self.state.diff.options.view_mode,
self.state.diff.display_context,
&self.state.diff.gap_expansions,
);
display_map
.iter()
.enumerate()
.filter_map(|(row_idx, info)| {
if info.is_header || info.is_collapsed_indicator {
return None;
}
let line_idx = info.line_index?;
let hunk = delta.hunks.get(info.hunk_index)?;
let line = hunk.lines.get(line_idx)?;
if line.content.to_lowercase().contains(&query) {
Some(row_idx)
} else {
None
}
})
.collect()
};
self.state.diff.search_matches = matches;
if !self.state.diff.search_matches.is_empty() {
let cursor = self.state.diff.cursor_row;
let idx = self
.state
.diff
.search_matches
.iter()
.position(|&r| r >= cursor)
.unwrap_or(0);
self.state.diff.search_match_index = Some(idx);
let row = self.state.diff.search_matches[idx];
self.state.diff.cursor_row = row;
let vh = self.state.diff.viewport_height.max(1);
let cursor_visual = self.visual_offset_for_row(row);
if cursor_visual < self.state.diff.scroll_offset
|| cursor_visual >= self.state.diff.scroll_offset + vh
{
self.state.diff.scroll_offset = cursor_visual.saturating_sub(vh / 4);
}
}
}
fn recompute_global_search_matches(&mut self) {
use crate::state::search_state::GlobalSearchMatch;
self.state.global_search.matches.clear();
self.state.global_search.current_match = 0;
let query = self.state.global_search.query.text().to_lowercase();
if query.is_empty() {
return;
}
for (file_index, delta) in self.state.diff.deltas.iter().enumerate() {
let file_path = delta.path.to_string_lossy().to_string();
let display_map = build_display_map(
delta,
self.state.diff.options.view_mode,
self.state.diff.display_context,
&self.state.diff.gap_expansions,
);
for (display_row, info) in display_map.iter().enumerate() {
if info.is_header || info.is_collapsed_indicator {
continue;
}
let Some(line_idx) = info.line_index else {
continue;
};
let Some(hunk) = delta.hunks.get(info.hunk_index) else {
continue;
};
let Some(line) = hunk.lines.get(line_idx) else {
continue;
};
if line.content.to_lowercase().contains(&query) {
let line_number = line.new_lineno.or(line.old_lineno).unwrap_or(0);
self.state.global_search.matches.push(GlobalSearchMatch {
file_index,
file_path: file_path.clone(),
line_number,
display_row,
});
}
}
}
if !self.state.global_search.matches.is_empty() {
self.jump_to_global_search_match();
}
}
fn jump_to_global_search_match(&mut self) {
if self.state.global_search.matches.is_empty() {
return;
}
let current_match =
&self.state.global_search.matches[self.state.global_search.current_match];
self.state.diff.selected_file = Some(current_match.file_index);
self.state.navigator.selected = current_match.file_index;
self.state.diff.cursor_row = current_match.display_row;
let vh = self.state.diff.viewport_height.max(1);
let cursor_visual = self.visual_offset_for_row(current_match.display_row);
if cursor_visual < self.state.diff.scroll_offset
|| cursor_visual >= self.state.diff.scroll_offset + vh
{
self.state.diff.scroll_offset = cursor_visual.saturating_sub(vh / 4);
}
}
fn navigate_to_file_line(&mut self, target_file: &str, target_line: u32) {
let current_file = self
.state
.diff
.selected_delta()
.map(|d| d.path.to_string_lossy().to_string());
if current_file.as_deref() != Some(target_file) {
if let Some(idx) = self
.state
.diff
.deltas
.iter()
.position(|d| d.path.to_string_lossy() == target_file)
{
if let Some(nav_idx) = self
.state
.navigator
.entries
.iter()
.position(|e| e.delta_index == idx)
{
self.state.navigator.selected = nav_idx;
}
self.state.diff.selected_file = Some(idx);
self.state.diff.scroll_offset = 0;
self.state.diff.cursor_row = 0;
self.update_highlights();
self.state.selection.active = false;
self.state.diff.gap_expansions.clear();
self.state.diff.search_query.clear();
self.state.diff.search_matches.clear();
self.state.diff.search_match_index = None;
self.state.diff.search_active = false;
}
}
self.scroll_to_line(target_line);
self.state.focus = FocusPanel::DiffView;
}
fn scroll_to_line(&mut self, target_lineno: u32) {
let display_map = self.current_display_map();
for (row_idx, info) in display_map.iter().enumerate() {
let matches =
info.new_lineno == Some(target_lineno) || info.old_lineno == Some(target_lineno);
if matches {
self.state.diff.cursor_row = row_idx;
self.state.diff.scroll_offset = self.visual_offset_for_row(row_idx);
self.clamp_scroll();
return;
}
}
}
fn format_annotation_line_ref(anchor: &LineAnchor) -> String {
match (anchor.old_range, anchor.new_range) {
(_, Some((s, e))) if s == e => format!("Line {s}"),
(_, Some((s, e))) => format!("Lines {s}-{e}"),
(Some((s, e)), None) if s == e => format!("Removed line {s} (old)"),
(Some((s, e)), None) => format!("Removed lines {s}-{e} (old)"),
(None, None) => "File-level".to_string(),
}
}
fn build_agentic_review_summary(&self, annotations: &[Annotation]) -> String {
let mut lines = vec![format!(
"\n\n--- review results ---\nCreated {} annotation(s)\n",
annotations.len()
)];
for ann in annotations {
lines.push(format!(
"- {} [{}|{}] {}: {}",
ann.anchor.file_path,
ann.category.label(),
ann.severity.label(),
Self::format_annotation_line_ref(&ann.anchor),
ann.comment
));
}
lines.push(String::new());
lines.join("\n")
}
fn render_prompt_for_all_files(&self) -> Option<String> {
if self.state.diff.deltas.is_empty() {
return None;
}
let padding: u32 = 5;
let mut file_sections = Vec::new();
for delta in &self.state.diff.deltas {
let filename = delta.path.to_string_lossy().to_string();
let file_annotations = self.state.annotations.annotations.get(&filename);
if file_annotations.is_none_or(|anns| anns.is_empty()) {
continue;
}
let anns = file_annotations.unwrap();
let mut sorted_anns: Vec<&Annotation> = anns.iter().collect();
sorted_anns.sort_by_key(|a| a.anchor.sort_line());
let mut groups: Vec<(u32, u32, Vec<&Annotation>)> = Vec::new();
for ann in &sorted_anns {
let sl = ann.anchor.sort_line();
let (ann_start, ann_end) = ann
.anchor
.new_range
.or(ann.anchor.old_range)
.unwrap_or((sl, sl));
let start = ann_start.saturating_sub(padding);
let end = ann_end + padding;
if let Some(last) = groups.last_mut() {
if start <= last.1 + 1 {
last.1 = last.1.max(end);
last.2.push(ann);
continue;
}
}
groups.push((start, end, vec![ann]));
}
let mut group_sections = Vec::new();
for (range_start, range_end, group_anns) in &groups {
let mut diff_lines = Vec::new();
for hunk in &delta.hunks {
let mut new_pos: u32 = 0;
for line in &hunk.lines {
if let Some(n) = line.new_lineno {
new_pos = n;
}
let effective_lineno = line.new_lineno.unwrap_or(new_pos);
if effective_lineno >= *range_start && effective_lineno <= *range_end {
let prefix = match line.origin {
DiffLineOrigin::Addition => "+",
DiffLineOrigin::Deletion => "-",
DiffLineOrigin::Context => " ",
};
let lineno_display = match line.new_lineno {
Some(n) => format!("{:>4}", n),
None => " ".to_string(),
};
diff_lines.push(format!(
"{} |{}{}",
lineno_display,
prefix,
line.content.trim_end()
));
}
}
}
let mut section = if diff_lines.is_empty() {
"_No precise diff line range was captured for this comment._".to_string()
} else {
format!("```diff\n{}\n```", diff_lines.join("\n"))
};
for ann in group_anns {
section.push_str(&format!(
"\n\n> **[{} | {}] ({}):** {}",
ann.category.label(),
ann.severity.label(),
Self::format_annotation_line_ref(&ann.anchor),
ann.comment
));
}
group_sections.push(section);
}
if !group_sections.is_empty() {
file_sections.push(format!(
"### {}\n\n{}",
filename,
group_sections.join("\n\n")
));
}
}
if file_sections.is_empty() && self.state.annotations.score_count() == 0 {
return None;
}
let mut prompt = String::from(
"You are reviewing a code change. A reviewer has left comments on the diff below. \
Address each review comment by making the necessary code changes. If a comment asks \
a question, answer it and make any implied fixes. Keep changes minimal and focused \
on what the reviewer asked for.\n\n",
);
if !self.state.checklist.is_empty() {
prompt.push_str("## Review Checklist\n");
for item in &self.state.checklist.items {
let checkbox = if item.checked { "[x]" } else { "[ ]" };
prompt.push_str(&format!("- {} {}", checkbox, item.label));
if let Some(ref note) = item.note {
prompt.push_str(&format!(" (Note: {})", note));
}
prompt.push('\n');
}
prompt.push('\n');
}
let all_scores = self.state.annotations.all_scores_sorted();
if !all_scores.is_empty() {
prompt.push_str("### Scores\n");
for score in &all_scores {
let range = match score.new_range {
Some((s, e)) if s == e => format!("{}:{}", score.file_path, s),
Some((s, e)) => format!("{}:{}-{}", score.file_path, s, e),
None => match score.old_range {
Some((s, e)) if s == e => format!("{}:{} (old)", score.file_path, s),
Some((s, e)) => format!("{}:{}-{} (old)", score.file_path, s, e),
None => score.file_path.clone(),
},
};
prompt.push_str(&format!("- {} [Score: {}/5]\n", range, score.score));
}
prompt.push('\n');
}
if !file_sections.is_empty() {
prompt.push_str(&file_sections.join("\n\n"));
}
Some(prompt)
}
fn update_prompt_preview(&mut self) {
self.state.prompt_preview_text = self.render_prompt_for_all_files().unwrap_or_default();
}
fn build_feedback_summary_json(&self) -> serde_json::Value {
let total_annotations = self.state.annotations.count();
let total_scores = self.state.annotations.score_count();
let files_with_feedback = self.state.annotations.files_with_annotations();
let total_files = self.state.navigator.entries.len();
let reviewed = self.state.review.reviewed_count();
let all_scores = self.state.annotations.all_scores_sorted();
let mut score_dist = [0usize; 5];
let mut score_sum = 0usize;
for s in &all_scores {
if s.score >= 1 && s.score <= 5 {
score_dist[(s.score - 1) as usize] += 1;
score_sum += s.score as usize;
}
}
let avg_score = if total_scores > 0 {
(score_sum as f64 / total_scores as f64 * 10.0).round() / 10.0
} else {
0.0
};
let mut files = Vec::new();
for (file_path, anns) in &self.state.annotations.annotations {
files.push(serde_json::json!({
"path": file_path,
"annotations": anns.len(),
"scores": 0,
"average_score": 0.0,
}));
}
serde_json::json!({
"session": self.state.target_label,
"timestamp": chrono::Utc::now().to_rfc3339(),
"summary": {
"total_annotations": total_annotations,
"total_scores": total_scores,
"files_with_feedback": files_with_feedback,
"files_reviewed": reviewed,
"files_total": total_files,
"average_score": avg_score,
},
"score_distribution": {
"1": score_dist[0],
"2": score_dist[1],
"3": score_dist[2],
"4": score_dist[3],
"5": score_dist[4],
},
"files": files,
})
}
fn build_feedback_summary_prompt(&self) -> String {
let json = self.build_feedback_summary_json();
let total = json["summary"]["total_annotations"].as_u64().unwrap_or(0);
let files = json["summary"]["files_with_feedback"].as_u64().unwrap_or(0);
let scores = json["summary"]["total_scores"].as_u64().unwrap_or(0);
let avg = json["summary"]["average_score"].as_f64().unwrap_or(0.0);
if scores > 0 {
format!(
"## Review Summary\n\n\
- {} annotations across {} files\n\
- {} lines scored, average quality: {:.1}/5\n\n\
See detailed annotations in the full prompt output.",
total, files, scores, avg,
)
} else {
format!(
"## Review Summary\n\n\
- {} annotations across {} files\n\n\
See detailed annotations in the full prompt output.",
total, files,
)
}
}
}
enum ContentSide {
Old,
New,
}
fn reconstruct_content(delta: &FileDelta, side: ContentSide) -> (String, usize) {
let mut lines: Vec<(u32, String)> = Vec::new();
for hunk in &delta.hunks {
for line in &hunk.lines {
match (&side, &line.origin) {
(ContentSide::Old, DiffLineOrigin::Context | DiffLineOrigin::Deletion) => {
if let Some(n) = line.old_lineno {
lines.push((n, line.content.trim_end_matches('\n').to_string()));
}
}
(ContentSide::New, DiffLineOrigin::Context | DiffLineOrigin::Addition) => {
if let Some(n) = line.new_lineno {
lines.push((n, line.content.trim_end_matches('\n').to_string()));
}
}
_ => {}
}
}
}
if lines.is_empty() {
return (String::new(), 0);
}
let max_line = lines.iter().map(|(n, _)| *n).max().unwrap_or(0) as usize;
let mut content_lines = vec![String::new(); max_line + 1];
for (n, text) in &lines {
content_lines[*n as usize] = text.clone();
}
let content = content_lines.join("\n");
(content, max_line)
}
fn build_agent_command(command_template: &str, model: &str, prompt: &str) -> String {
let escaped_prompt = prompt.replace('\'', "'\\''");
command_template
.replace("{model}", model)
.replace("{rendered_prompt}", &escaped_prompt)
}
pub fn parse_target(target: Option<&str>) -> ComparisonTarget {
match target {
None => ComparisonTarget::HeadVsWorkdir,
Some(s) => {
if s.len() >= 7 && s.chars().all(|c| c.is_ascii_hexdigit()) {
if let Ok(oid) = git2::Oid::from_str(s) {
return ComparisonTarget::Commit(oid);
}
}
ComparisonTarget::Branch(s.to_string())
}
}
}
fn resolve_editor_name(editor: &str) -> String {
let first_token = editor.split_whitespace().next().unwrap_or(editor);
std::path::Path::new(first_token)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(first_token)
.to_string()
}
fn is_gui_editor(editor_name: &str) -> bool {
matches!(
editor_name,
"code" | "code-insiders" | "subl" | "sublime_text" | "atom" | "zed"
)
}
fn build_editor_command(editor: &str, file: &std::path::Path, line: u32) -> std::process::Command {
let parts: Vec<&str> = editor.split_whitespace().collect();
let program = parts.first().copied().unwrap_or(editor);
let editor_name = resolve_editor_name(editor);
let mut cmd = std::process::Command::new(program);
for arg in &parts[1..] {
cmd.arg(arg);
}
let file_str = file.to_string_lossy();
match editor_name.as_str() {
"code" | "code-insiders" => {
cmd.arg("--goto").arg(format!("{file_str}:{line}"));
}
"subl" | "sublime_text" => {
cmd.arg(format!("{file_str}:{line}"));
}
_ => {
cmd.arg(format!("+{line}")).arg(file.as_os_str());
}
}
cmd
}