use ag_forge::ReviewCommentSnapshot;
use super::help_action::{
self, HelpAction, ViewActionAvailability, ViewHelpState, ViewSessionState,
};
use super::prompt::{
PromptAtMentionState, PromptAttachmentState, PromptHistoryState, PromptSlashState,
};
use crate::domain::input::InputState;
use crate::domain::question::QuestionItem;
use crate::domain::session::{
PublishBranchAction, Session, SessionId, Status, can_reply_to_session_in_stack,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DiffLineSide {
New,
Old,
}
impl DiffLineSide {
pub(crate) fn prompt_label(self) -> &'static str {
match self {
Self::New => "new",
Self::Old => "old",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffLineCommentAnchor {
pub(crate) content: String,
pub(crate) line: u32,
pub(crate) path: String,
pub(crate) side: DiffLineSide,
}
impl DiffLineCommentAnchor {
pub(crate) fn prompt_line(&self, comment: &str) -> String {
match self.side {
DiffLineSide::New => format!(
"- {}:{} [{}]: {}",
self.path,
self.line,
self.side.prompt_label(),
comment.trim(),
),
DiffLineSide::Old => format!(
"- {}:{} [{}, source={:?}]: {}",
self.path,
self.line,
self.side.prompt_label(),
self.content,
comment.trim(),
),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffLineComment {
pub(crate) anchor: DiffLineCommentAnchor,
pub(crate) input: InputState,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DiffLineComments {
pub(crate) editing_index: Option<usize>,
pub(crate) comments: Vec<DiffLineComment>,
}
impl DiffLineComments {
pub(crate) fn start_editing(&mut self, anchor: DiffLineCommentAnchor) -> usize {
let editing_index = self
.comments
.iter()
.position(|comment| comment.anchor == anchor)
.unwrap_or_else(|| {
self.comments.push(DiffLineComment {
anchor,
input: InputState::default(),
});
self.comments.len().saturating_sub(1)
});
self.editing_index = Some(editing_index);
editing_index
}
pub(crate) fn editing_input_mut(&mut self) -> Option<&mut InputState> {
self.editing_index
.and_then(|editing_index| self.comments.get_mut(editing_index))
.map(|comment| &mut comment.input)
}
pub(crate) fn finish_editing(&mut self) {
let Some(editing_index) = self.editing_index.take() else {
return;
};
if self
.comments
.get(editing_index)
.is_some_and(|comment| comment.input.text().trim().is_empty())
{
self.comments.remove(editing_index);
}
}
pub(crate) fn is_editing(&self) -> bool {
self.editing_index.is_some()
}
pub(crate) fn prompt_text(&self) -> String {
let comments = self
.comments
.iter()
.filter(|comment| !comment.input.text().trim().is_empty())
.map(|comment| comment.anchor.prompt_line(comment.input.text()))
.collect::<Vec<_>>()
.join("\n");
if comments.is_empty() {
return comments;
}
format!("Line comments:\n{comments}")
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ReviewCommentAction {
Address,
Deny,
}
impl ReviewCommentAction {
pub(crate) fn prompt_label(self) -> &'static str {
match self {
Self::Address => "Address",
Self::Deny => "Deny",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewCommentActionSelection {
pub(crate) action: ReviewCommentAction,
pub(crate) thread_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffReviewComments {
pub comment_actions: Vec<ReviewCommentActionSelection>,
pub comment_error: Option<String>,
pub comment_snapshot: Option<ReviewCommentSnapshot>,
pub is_loading_comments: bool,
pub request_id: u64,
pub selected_comment_index: usize,
pub sidebar_focus: DiffSidebarFocus,
}
impl DiffReviewComments {
pub fn loading(request_id: u64) -> Self {
Self {
comment_actions: Vec::new(),
comment_error: None,
comment_snapshot: None,
is_loading_comments: true,
request_id,
selected_comment_index: 0,
sidebar_focus: DiffSidebarFocus::Files,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DiffSidebarFocus {
#[default]
Files,
Comments,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DiffFocus {
#[default]
Files,
Content,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfirmationIntent {
Quit,
CancelSession,
ContinueSession,
ForkSession,
MergeSession,
RegenerateReview,
DetachManagedSession,
OpenManagedWorktree,
ChooseIntegrationApproach,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfirmationViewMode {
pub scroll_offset: Option<u16>,
pub session_id: SessionId,
}
impl ConfirmationViewMode {
#[must_use]
pub fn into_view_mode(self) -> AppMode {
AppMode::View {
session_id: self.session_id,
scroll_offset: self.scroll_offset,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DiffScrollCache {
pub content_area: ViewportRect,
pub file_explorer_selected_index: usize,
pub max_scroll_offset: u16,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DiffPreview {
Off {
request_id: u64,
},
Unsupported {
request_id: u64,
},
Loading {
path: String,
request_id: u64,
},
Ready {
content: String,
path: String,
request_id: u64,
},
Unavailable {
path: String,
reason: DiffPreviewUnavailableReason,
request_id: u64,
},
}
impl DiffPreview {
#[must_use]
pub fn is_enabled(&self) -> bool {
!matches!(self, Self::Off { .. })
}
#[must_use]
pub fn request_id(&self) -> u64 {
match self {
Self::Off { request_id }
| Self::Unsupported { request_id }
| Self::Loading { request_id, .. }
| Self::Ready { request_id, .. }
| Self::Unavailable { request_id, .. } => *request_id,
}
}
#[must_use]
pub fn path(&self) -> Option<&str> {
match self {
Self::Loading { path, .. }
| Self::Ready { path, .. }
| Self::Unavailable { path, .. } => Some(path),
Self::Off { .. } | Self::Unsupported { .. } => None,
}
}
#[must_use]
pub fn next_request_id(&self) -> u64 {
let next_request_id = self.request_id().wrapping_add(1);
next_request_id.max(1)
}
#[must_use]
pub fn disabled(&self) -> Self {
Self::Off {
request_id: self.next_request_id(),
}
}
}
impl Default for DiffPreview {
fn default() -> Self {
Self::Off { request_id: 0 }
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DiffPreviewUnavailableReason {
Deleted,
Binary,
TooLarge,
LoadFailed(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ViewportRect {
pub height: u16,
pub width: u16,
pub x: u16,
pub y: u16,
}
pub struct QuestionModeSnapshot {
pub at_mention_state: Option<PromptAtMentionState>,
pub current_index: usize,
pub input: InputState,
pub questions: Vec<QuestionItem>,
pub responses: Vec<String>,
pub scroll_offset: Option<u16>,
pub selected_option_index: Option<usize>,
pub session_id: SessionId,
}
impl QuestionModeSnapshot {
#[must_use]
pub fn into_question_mode(self) -> AppMode {
AppMode::Question {
at_mention_state: self.at_mention_state,
current_index: self.current_index,
focus: ChatFocus::Input,
input: self.input,
questions: self.questions,
responses: self.responses,
scroll_offset: self.scroll_offset,
selected_option_index: self.selected_option_index,
session_id: self.session_id,
}
}
}
#[derive(Clone)]
pub struct PromptModeSnapshot {
pub at_mention_state: Option<PromptAtMentionState>,
pub attachment_state: PromptAttachmentState,
pub history_state: PromptHistoryState,
pub input: InputState,
pub scroll_offset: Option<u16>,
pub session_id: SessionId,
pub slash_state: PromptSlashState,
}
impl PromptModeSnapshot {
#[must_use]
pub fn into_prompt_mode(self) -> AppMode {
AppMode::Prompt {
at_mention_state: self.at_mention_state,
attachment_state: self.attachment_state,
focus: ChatFocus::Input,
history_state: self.history_state,
slash_state: self.slash_state,
session_id: self.session_id,
input: self.input,
scroll_offset: self.scroll_offset,
}
}
}
pub enum DiffRestoreTarget {
Prompt(PromptModeSnapshot),
Question(QuestionModeSnapshot),
}
impl DiffRestoreTarget {
#[must_use]
pub fn into_mode(self) -> AppMode {
match self {
DiffRestoreTarget::Prompt(snapshot) => snapshot.into_prompt_mode(),
DiffRestoreTarget::Question(snapshot) => snapshot.into_question_mode(),
}
}
}
pub(crate) fn allows_diff_line_comment_reply(
session: &Session,
sessions: &[Session],
restore: Option<&DiffRestoreTarget>,
) -> bool {
!matches!(restore, Some(DiffRestoreTarget::Question(_)))
&& session.status.allows_chat_composer()
&& session.accepts_user_turns()
&& (session.status == Status::Draft
|| can_reply_to_session_in_stack(sessions, session.id.as_str()))
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ChatFocus {
#[default]
Input,
Chat,
}
pub enum AppMode {
List,
SessionCreation {
selected_option_index: usize,
},
PreCommitHookWarning {
message: String,
},
ProjectSwitcher {
selected_option_index: usize,
},
Confirmation {
confirmation_intent: ConfirmationIntent,
confirmation_message: String,
confirmation_title: String,
restore_view: Option<ConfirmationViewMode>,
session_id: Option<SessionId>,
selected_confirmation_index: usize,
},
SyncBlockedPopup {
project_name: Option<String>,
default_branch: Option<String>,
is_loading: bool,
message: String,
title: String,
},
ViewInfoPopup {
is_loading: bool,
loading_label: String,
message: String,
restore_view: ConfirmationViewMode,
title: String,
},
LaunchConfigurationSelector {
commands: Vec<String>,
restore_view: ConfirmationViewMode,
selected_command_index: usize,
},
PublishBranchInput {
default_branch_name: String,
input: InputState,
locked_upstream_ref: Option<String>,
publish_branch_action: PublishBranchAction,
restore_view: ConfirmationViewMode,
},
Prompt {
at_mention_state: Option<PromptAtMentionState>,
attachment_state: PromptAttachmentState,
focus: ChatFocus,
history_state: PromptHistoryState,
slash_state: PromptSlashState,
session_id: SessionId,
input: InputState,
scroll_offset: Option<u16>,
},
View {
session_id: SessionId,
scroll_offset: Option<u16>,
},
DiffLoading {
fallback_view_scroll_offset: Option<u16>,
request_id: u64,
restore: Option<Box<DiffRestoreTarget>>,
session_id: SessionId,
sidebar_focus: DiffSidebarFocus,
},
Diff {
diff: String,
file_explorer_selected_index: usize,
focus: DiffFocus,
line_comments: DiffLineComments,
preview: DiffPreview,
review_comments: Option<DiffReviewComments>,
restore: Option<Box<DiffRestoreTarget>>,
scroll_cache: Option<DiffScrollCache>,
scroll_offset: u16,
selected_diff_line_index: usize,
session_id: SessionId,
},
Question {
at_mention_state: Option<PromptAtMentionState>,
session_id: SessionId,
questions: Vec<QuestionItem>,
responses: Vec<String>,
current_index: usize,
focus: ChatFocus,
input: InputState,
scroll_offset: Option<u16>,
selected_option_index: Option<usize>,
},
Help {
context: HelpContext,
scroll_offset: u16,
},
}
pub enum HelpContext {
List {
keybindings: Vec<HelpAction>,
},
View {
can_fork_session: bool,
can_merge_session_branch: bool,
can_mutate_session_branch: bool,
can_open_worktree: bool,
can_rebase_session_branch: bool,
can_show_diff: bool,
can_reply_to_session: bool,
can_start_staged_session: bool,
can_view_review_comments: bool,
publish_pull_request_action: Option<PublishBranchAction>,
session_id: SessionId,
session_state: ViewSessionState,
scroll_offset: Option<u16>,
},
Diff {
can_comment: bool,
diff: String,
file_explorer_selected_index: usize,
focus: DiffFocus,
line_comments: DiffLineComments,
preview: DiffPreview,
review_comments: Option<Box<DiffReviewComments>>,
restore: Option<Box<DiffRestoreTarget>>,
session_id: SessionId,
scroll_offset: u16,
selected_diff_line_index: usize,
},
}
impl HelpContext {
pub fn keybindings(&self) -> Vec<HelpAction> {
match self {
HelpContext::View {
can_fork_session,
can_merge_session_branch,
can_mutate_session_branch,
can_open_worktree,
can_rebase_session_branch,
can_reply_to_session,
can_show_diff,
can_start_staged_session,
can_view_review_comments,
publish_pull_request_action,
session_state,
..
} => help_action::view_actions_with_review_comments(
ViewHelpState {
can_fork_session: ViewActionAvailability::from_bool(*can_fork_session),
can_merge_session_branch: ViewActionAvailability::from_bool(
*can_merge_session_branch,
),
can_mutate_session_branch: ViewActionAvailability::from_bool(
*can_mutate_session_branch,
),
can_open_worktree: ViewActionAvailability::from_bool(*can_open_worktree),
can_rebase_session_branch: ViewActionAvailability::from_bool(
*can_rebase_session_branch,
),
can_show_diff: ViewActionAvailability::from_bool(*can_show_diff),
reply_to_session: ViewActionAvailability::from_bool(*can_reply_to_session),
can_start_staged_session: ViewActionAvailability::from_bool(
*can_start_staged_session,
),
publish_pull_request_action: *publish_pull_request_action,
session_state: *session_state,
},
*can_view_review_comments,
),
HelpContext::List { keybindings } => keybindings.clone(),
HelpContext::Diff { can_comment, .. } => help_action::diff_actions(*can_comment),
}
}
pub fn restore_mode(self) -> AppMode {
match self {
HelpContext::List { .. } => AppMode::List,
HelpContext::View {
publish_pull_request_action: _,
session_id,
scroll_offset,
..
} => AppMode::View {
session_id,
scroll_offset,
},
HelpContext::Diff {
can_comment: _,
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments,
restore,
selected_diff_line_index,
session_id,
scroll_offset,
} => AppMode::Diff {
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments: review_comments.map(|review_comments| *review_comments),
restore,
scroll_cache: None,
selected_diff_line_index,
session_id,
scroll_offset,
},
}
}
pub fn title(&self) -> &'static str {
"Keybindings"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::session::PublishBranchAction;
#[test]
fn test_diff_line_comments_edit_and_build_compact_prompt() {
let anchor = DiffLineCommentAnchor {
content: "println!(\"review\");".to_string(),
line: 12,
path: "src/main.rs".to_string(),
side: DiffLineSide::New,
};
let mut line_comments = DiffLineComments::default();
line_comments.start_editing(anchor.clone());
line_comments
.editing_input_mut()
.expect("new comment should be editable")
.insert_text("Please explain this change.");
line_comments.finish_editing();
let prompt = line_comments.prompt_text();
assert_eq!(
prompt,
"Line comments:\n- src/main.rs:12 [new]: Please explain this change."
);
assert!(!line_comments.is_editing());
let editing_index = line_comments.start_editing(anchor);
assert_eq!(editing_index, 0);
assert_eq!(line_comments.comments.len(), 1);
}
#[test]
fn test_deleted_diff_line_prompt_includes_captured_source() {
let anchor = DiffLineCommentAnchor {
content: "let message = \"old\";".to_string(),
line: 7,
path: "src/old.rs".to_string(),
side: DiffLineSide::Old,
};
let prompt_line = anchor.prompt_line("Keep this behavior.");
assert_eq!(
prompt_line,
"- src/old.rs:7 [old, source=\"let message = \\\"old\\\";\"]: Keep this behavior."
);
}
#[test]
fn test_diff_line_comments_remove_blank_editor() {
let mut line_comments = DiffLineComments::default();
line_comments.start_editing(DiffLineCommentAnchor {
content: "removed".to_string(),
line: 3,
path: "src/lib.rs".to_string(),
side: DiffLineSide::Old,
});
line_comments.finish_editing();
line_comments.finish_editing();
assert!(line_comments.comments.is_empty());
assert!(line_comments.editing_input_mut().is_none());
assert!(line_comments.prompt_text().is_empty());
}
#[test]
fn test_confirmation_view_mode_into_view_mode_restores_view_identity() {
let confirmation_view_mode = ConfirmationViewMode {
scroll_offset: Some(7),
session_id: "session-id".into(),
};
let mode = confirmation_view_mode.into_view_mode();
assert!(matches!(
mode,
AppMode::View {
ref session_id,
scroll_offset: Some(7),
} if session_id == "session-id"
));
}
#[test]
fn test_help_context_view_keybindings_for_in_progress_show_sync_and_hide_edit_actions() {
let context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_show_diff: true,
can_reply_to_session: true,
can_start_staged_session: false,
can_view_review_comments: false,
publish_pull_request_action: None,
session_id: "session-id".into(),
session_state: ViewSessionState::InProgress,
scroll_offset: Some(2),
};
let bindings = context.keybindings();
assert!(bindings.iter().any(|binding| binding.key == "q"));
assert!(bindings.iter().any(|binding| binding.key == "j/k"));
assert!(bindings.iter().any(|binding| binding.key == "?"));
assert!(bindings.iter().any(|binding| binding.key == "Ctrl+c"));
assert!(bindings.iter().any(|binding| binding.key == "r"));
assert!(!bindings.iter().any(|binding| binding.key == "Enter"));
assert!(!bindings.iter().any(|binding| binding.key == "d"));
assert!(!bindings.iter().any(|binding| binding.key == "m"));
assert!(!bindings.iter().any(|binding| binding.key == "S-Tab"));
}
#[test]
fn test_help_context_restore_mode_ignores_help_only_view_fields() {
let context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_show_diff: true,
can_reply_to_session: true,
can_start_staged_session: false,
can_view_review_comments: false,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_id: "session-id".into(),
session_state: ViewSessionState::InProgress,
scroll_offset: Some(4),
};
let mode = context.restore_mode();
assert!(matches!(
mode,
AppMode::View {
ref session_id,
scroll_offset: Some(4),
..
} if session_id == "session-id"
));
}
#[test]
fn test_help_context_view_keybindings_include_publish_pull_request_action() {
let context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_show_diff: true,
can_reply_to_session: true,
can_start_staged_session: false,
can_view_review_comments: false,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_id: "session-id".into(),
session_state: ViewSessionState::Interactive,
scroll_offset: None,
};
let bindings = context.keybindings();
assert!(bindings.iter().any(|binding| binding.key == "p"));
}
#[test]
fn test_help_context_list_keybindings_return_stored_actions() {
let keybindings = vec![
HelpAction::new("quit", "q", "Quit"),
HelpAction::new("help", "?", "Help"),
];
let context = HelpContext::List { keybindings };
let bindings = context.keybindings();
assert_eq!(bindings.len(), 2);
assert!(bindings.iter().any(|binding| binding.key == "q"));
assert!(bindings.iter().any(|binding| binding.key == "?"));
}
#[test]
fn test_diff_preview_tracks_enabled_state_and_request_generation() {
let states = [
DiffPreview::Off { request_id: 0 },
DiffPreview::Unsupported { request_id: 1 },
DiffPreview::Loading {
path: "README.md".to_string(),
request_id: 2,
},
DiffPreview::Ready {
content: "# Ready".to_string(),
path: "README.md".to_string(),
request_id: 3,
},
DiffPreview::Unavailable {
path: "README.md".to_string(),
reason: DiffPreviewUnavailableReason::Deleted,
request_id: 4,
},
];
let enabled = states
.iter()
.map(DiffPreview::is_enabled)
.collect::<Vec<_>>();
let request_ids = states
.iter()
.map(DiffPreview::request_id)
.collect::<Vec<_>>();
let paths = states.iter().map(DiffPreview::path).collect::<Vec<_>>();
let next_request_id = states[4].next_request_id();
let disabled = states[3].disabled();
assert_eq!(enabled, [false, true, true, true, true]);
assert_eq!(request_ids, [0, 1, 2, 3, 4]);
assert_eq!(
paths,
[
None,
None,
Some("README.md"),
Some("README.md"),
Some("README.md")
]
);
assert_eq!(next_request_id, 5);
assert_eq!(disabled, DiffPreview::Off { request_id: 4 });
}
}