Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ to clipboard in a format ready to paste back to the agent.
- **Infinite scroll diff view** - All changed files in one continuous scroll (GitHub-style)
- **Vim keybindings** - Navigate with `j/k`, `Ctrl-d/u`, `g/G`, `{/}`, `[/]`
- **Expandable context** - Press Enter on "... expand (N lines) ..." to reveal hidden context between hunks
- **Comments** - Add file-level or line-level comments with types
- **Comments** - Add review-level, file-level, or line-level comments with types
- **Visual mode** - Select line ranges with `v` / `V` and comment on multiple lines at once
- **Review tracking** - Mark files as reviewed, persist progress to disk
- **`.tuicrignore` support** - Exclude matching files from review diffs
Expand Down Expand Up @@ -204,6 +204,7 @@ dist/
| `r` | Toggle file reviewed |
| `c` | Add line comment (or file comment if not on a diff line) |
| `C` | Add file comment |
| `;c` | Add review comment |
| `v` / `V` | Enter visual mode for range comments |
| `dd` | Delete comment at cursor |
| `i` | Edit comment at cursor |
Expand Down
209 changes: 157 additions & 52 deletions src/app.rs

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ fn main() -> anyhow::Result<()> {
// Otherwise fall through to normal handling
}

// Handle pending ; command for ;e toggle file list, ;h/;l/;k/;j panel focus
// Handle pending ; command for panel focus, file list toggle, and review comments
if pending_semicolon {
pending_semicolon = false;
match key.code {
Expand All @@ -274,6 +274,10 @@ fn main() -> anyhow::Result<()> {
app.focused_panel = app::FocusedPanel::Diff;
continue;
}
crossterm::event::KeyCode::Char('c') => {
app.enter_review_comment_mode();
continue;
}
_ => {}
}
// Otherwise fall through to normal handling
Expand Down
8 changes: 6 additions & 2 deletions src/model/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ pub struct ReviewSession {
pub commit_range: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(default)]
pub review_comments: Vec<Comment>,
pub files: HashMap<PathBuf, FileReview>,
pub session_notes: Option<String>,
}
Expand All @@ -85,6 +87,7 @@ impl ReviewSession {
commit_range: None,
created_at: now,
updated_at: now,
review_comments: Vec::new(),
files: HashMap::new(),
session_notes: None,
}
Expand All @@ -105,11 +108,12 @@ impl ReviewSession {
}

pub fn has_comments(&self) -> bool {
self.files.values().any(|f| f.comment_count() > 0)
!self.review_comments.is_empty() || self.files.values().any(|f| f.comment_count() > 0)
}

pub fn clear_comments(&mut self) -> usize {
let mut cleared = 0;
let mut cleared = self.review_comments.len();
self.review_comments.clear();
for file in self.files.values_mut() {
cleared += file.comment_count();
file.file_comments.clear();
Expand Down
57 changes: 57 additions & 0 deletions src/output/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ fn write_osc52<W: IoWrite>(writer: &mut W, text: &str) -> Result<()> {
Ok(())
}

fn review_scope_label(diff_source: &DiffSource) -> String {
let scope = match diff_source {
DiffSource::WorkingTree => "working tree changes".to_string(),
DiffSource::CommitRange(_) => "selected commit range".to_string(),
DiffSource::WorkingTreeAndCommits(_) => {
"selected commit range + working tree changes".to_string()
}
};

format!("Review Comment (scope: {scope})")
}

fn generate_markdown(session: &ReviewSession, diff_source: &DiffSource) -> String {
let mut md = String::new();

Expand Down Expand Up @@ -166,6 +178,17 @@ fn generate_markdown(session: &ReviewSession, diff_source: &DiffSource) -> Strin

// Collect all comments into a flat list
let mut all_comments: Vec<CommentEntry> = Vec::new();
let review_comment_location = review_scope_label(diff_source);

for comment in &session.review_comments {
all_comments.push((
review_comment_location.clone(),
None,
None,
comment.comment_type.as_str(),
&comment.content,
));
}

// Sort files by path for consistent output
let mut files: Vec<_> = session.files.iter().collect();
Expand Down Expand Up @@ -310,6 +333,40 @@ mod tests {
assert!(markdown.contains("2. **[ISSUE]**"));
}

#[test]
fn should_include_review_comments_in_export() {
let mut session = create_test_session();
session.review_comments.push(Comment::new(
"Please split this into smaller commits".to_string(),
CommentType::Note,
None,
));

let markdown = generate_markdown(&session, &DiffSource::WorkingTree);

assert!(markdown
.contains("`Review Comment (scope: working tree changes)` - Please split this into smaller commits"));
}

#[test]
fn should_include_commit_range_scope_for_review_comments() {
let mut session = create_test_session();
session.review_comments.push(Comment::new(
"High-level concern across commits".to_string(),
CommentType::Issue,
None,
));

let markdown = generate_markdown(
&session,
&DiffSource::CommitRange(vec!["abc1234567890".to_string()]),
);

assert!(markdown.contains(
"`Review Comment (scope: selected commit range)` - High-level concern across commits"
));
}

#[test]
fn should_fail_export_when_no_comments() {
// given
Expand Down
176 changes: 175 additions & 1 deletion src/ui/app_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,92 @@ fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let mut comment_cursor_logical_line: Option<usize> = None;
let mut comment_cursor_column: u16 = 0;

let is_review_comment_mode =
app.input_mode == InputMode::Comment && app.comment_is_review_level;

let general_indicator = cursor_indicator_spaced(line_idx, current_line_idx);
lines.push(Line::from(vec![
Span::styled(
general_indicator,
styles::current_line_indicator_style(&app.theme),
),
Span::styled(
"═══ Review Comments ",
styles::file_header_style(&app.theme),
),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
line_idx += 1;

for comment in &app.session.review_comments {
let is_being_edited =
app.editing_comment_id.as_ref() == Some(&comment.id) && is_review_comment_mode;

if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
app.comment_type,
&app.comment_buffer,
app.comment_cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;

for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment.comment_type,
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
comment_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(comment_line);
line_idx += 1;
}
}
}

if is_review_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
app.comment_type,
&app.comment_buffer,
app.comment_cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;

for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}

for (file_idx, file) in app.diff_files.iter().enumerate() {
let path = file.display_path();
let status = file.status.as_char();
Expand Down Expand Up @@ -1329,7 +1415,9 @@ fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let content_width = available_width / 2;

// Determine if we're in line comment mode (not file-level)
let comment_input_mode = app.input_mode == InputMode::Comment && !app.comment_is_file_level;
let comment_input_mode = app.input_mode == InputMode::Comment
&& !app.comment_is_file_level
&& !app.comment_is_review_level;

let ctx = SideBySideContext {
theme: &app.theme,
Expand All @@ -1353,6 +1441,92 @@ fn render_side_by_side_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let mut comment_cursor_logical_line: Option<usize> = None;
let mut comment_cursor_column: u16 = 0;

let is_review_comment_mode =
app.input_mode == InputMode::Comment && app.comment_is_review_level;

let general_indicator = cursor_indicator_spaced(line_idx, ctx.current_line_idx);
lines.push(Line::from(vec![
Span::styled(
general_indicator,
styles::current_line_indicator_style(&app.theme),
),
Span::styled(
"═══ Review Comments ",
styles::file_header_style(&app.theme),
),
Span::styled("═".repeat(40), styles::file_header_style(&app.theme)),
]));
line_idx += 1;

for comment in &app.session.review_comments {
let is_being_edited =
app.editing_comment_id.as_ref() == Some(&comment.id) && is_review_comment_mode;

if is_being_edited {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
app.comment_type,
&app.comment_buffer,
app.comment_cursor,
None,
true,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;

for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
} else {
let comment_lines = comment_panel::format_comment_lines(
&app.theme,
comment.comment_type,
&comment.content,
None,
);
for mut comment_line in comment_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
comment_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(comment_line);
line_idx += 1;
}
}
}

if is_review_comment_mode && app.editing_comment_id.is_none() {
let (input_lines, cursor_info) = comment_panel::format_comment_input_lines(
&app.theme,
app.comment_type,
&app.comment_buffer,
app.comment_cursor,
None,
false,
app.supports_keyboard_enhancement,
);
comment_cursor_logical_line = Some(line_idx + cursor_info.line_offset);
comment_cursor_column = 1 + cursor_info.column;

for mut input_line in input_lines {
let indicator = cursor_indicator(line_idx, ctx.current_line_idx);
input_line.spans.insert(
0,
Span::styled(indicator, styles::current_line_indicator_style(&app.theme)),
);
lines.push(input_line);
line_idx += 1;
}
}

for (file_idx, file) in app.diff_files.iter().enumerate() {
let path = file.display_path();
let status = file.status.as_char();
Expand Down
7 changes: 7 additions & 0 deletions src/ui/help_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,13 @@ pub fn render_help(frame: &mut Frame, app: &mut App) {
),
Span::raw("Add file comment"),
]),
Line::from(vec![
Span::styled(
" ;c ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw("Add review comment"),
]),
Line::from(vec![
Span::styled(
" i ",
Expand Down
2 changes: 1 addition & 1 deletion src/ui/status_bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ pub fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) {

let hints = match app.input_mode {
InputMode::Normal => {
" j/k:scroll {/}:file r:reviewed c:comment V:visual /:search ?:help :q:quit "
" j/k:scroll {/}:file r:reviewed c:comment ;c:review V:visual /:search ?:help :q:quit "
}
InputMode::Command => " Enter:execute Esc:cancel ",
InputMode::Search => " Enter:search Esc:cancel ",
Expand Down
Loading