diff --git a/README.md b/README.md index d7b0b84..5f6d7f4 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/src/app.rs b/src/app.rs index d3a056d..3783b3c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -43,6 +43,10 @@ pub struct GapId { /// Describes what a rendered line represents - built once and used for O(1) cursor queries #[derive(Debug, Clone)] pub enum AnnotatedLine { + /// Review comments section header line + ReviewCommentsHeader, + /// A review-level comment line (part of a multi-line comment box) + ReviewComment { comment_idx: usize }, /// File header line FileHeader { file_idx: usize }, /// A file-level comment line (part of a multi-line comment box) @@ -208,6 +212,7 @@ pub struct App { pub comment_buffer: String, pub comment_cursor: usize, pub comment_type: CommentType, + pub comment_is_review_level: bool, pub comment_is_file_level: bool, pub comment_line: Option<(u32, LineSide)>, pub editing_comment_id: Option, @@ -340,11 +345,14 @@ pub struct HelpState { /// Represents a comment location for deletion enum CommentLocation { - FileComment { + Review { + index: usize, + }, + File { path: std::path::PathBuf, index: usize, }, - LineComment { + Line { path: std::path::PathBuf, line: u32, side: LineSide, @@ -490,6 +498,7 @@ impl App { comment_buffer: String::new(), comment_cursor: 0, comment_type: CommentType::Note, + comment_is_review_level: false, comment_is_file_level: true, comment_line: None, editing_comment_id: None, @@ -1136,6 +1145,11 @@ impl App { fn line_text_for_search(&self, line_idx: usize) -> Option { match self.line_annotations.get(line_idx)? { + AnnotatedLine::ReviewCommentsHeader => Some("Review comments".to_string()), + AnnotatedLine::ReviewComment { comment_idx } => { + let comment = self.session.review_comments.get(*comment_idx)?; + Some(comment.content.clone()) + } AnnotatedLine::FileHeader { file_idx } => { let file = self.diff_files.get(*file_idx)?; Some(format!( @@ -1352,7 +1366,7 @@ impl App { pub fn next_hunk(&mut self) { // Find the next hunk header position after current cursor - let mut cumulative = 0; + let mut cumulative = self.review_comments_render_height(); for file in &self.diff_files { let path = file.display_path(); @@ -1391,7 +1405,7 @@ impl App { pub fn prev_hunk(&mut self) { // Find the previous hunk header position before current cursor let mut hunk_positions: Vec = Vec::new(); - let mut cumulative = 0; + let mut cumulative = self.review_comments_render_height(); for file in &self.diff_files { let path = file.display_path(); @@ -1436,7 +1450,7 @@ impl App { } fn calculate_file_scroll_offset(&self, file_idx: usize) -> usize { - let mut offset = 0; + let mut offset = self.review_comments_render_height(); for (i, file) in self.diff_files.iter().enumerate() { if i == file_idx { break; @@ -1446,6 +1460,21 @@ impl App { offset } + fn review_comments_render_height(&self) -> usize { + let mut height = 1; // Header line + for comment in &self.session.review_comments { + height += Self::comment_display_lines(comment); + } + if self.input_mode == InputMode::Comment + && self.comment_is_review_level + && self.editing_comment_id.is_none() + { + // Header + one content line + footer + height += 3; + } + height + } + fn file_render_height(&self, file_idx: usize, file: &DiffFile) -> usize { let path = file.display_path(); @@ -1638,7 +1667,14 @@ impl App { } fn update_current_file_from_cursor(&mut self) { - let mut cumulative = 0; + let mut cumulative = self.review_comments_render_height(); + if self.diff_state.cursor_line < cumulative { + if !self.diff_files.is_empty() { + self.diff_state.current_file_idx = 0; + self.file_list_state.select(0); + } + return; + } for (i, file) in self.diff_files.iter().enumerate() { let height = self.file_render_height(i, file); if cumulative + height > self.diff_state.cursor_line { @@ -1655,11 +1691,13 @@ impl App { } pub fn total_lines(&self) -> usize { - self.diff_files - .iter() - .enumerate() - .map(|(i, f)| self.file_render_height(i, f)) - .sum() + self.review_comments_render_height() + + self + .diff_files + .iter() + .enumerate() + .map(|(i, f)| self.file_render_height(i, f)) + .sum::() } /// Calculate the maximum scroll offset. @@ -1715,12 +1753,15 @@ impl App { fn find_comment_at_cursor(&self) -> Option { let target = self.diff_state.cursor_line; match self.line_annotations.get(target) { + Some(AnnotatedLine::ReviewComment { comment_idx }) => Some(CommentLocation::Review { + index: *comment_idx, + }), Some(AnnotatedLine::FileComment { file_idx, comment_idx, }) => { let path = self.diff_files.get(*file_idx)?.display_path().clone(); - Some(CommentLocation::FileComment { + Some(CommentLocation::File { path, index: *comment_idx, }) @@ -1732,7 +1773,7 @@ impl App { comment_idx, }) => { let path = self.diff_files.get(*file_idx)?.display_path().clone(); - Some(CommentLocation::LineComment { + Some(CommentLocation::Line { path, line: *line, side: *side, @@ -1749,7 +1790,16 @@ impl App { let location = self.find_comment_at_cursor(); match location { - Some(CommentLocation::FileComment { path, index }) => { + Some(CommentLocation::Review { index }) => { + if index < self.session.review_comments.len() { + self.session.review_comments.remove(index); + self.dirty = true; + self.set_message("Review comment deleted"); + self.rebuild_annotations(); + return true; + } + } + Some(CommentLocation::File { path, index }) => { if let Some(review) = self.session.get_file_mut(&path) { review.file_comments.remove(index); self.dirty = true; @@ -1758,7 +1808,7 @@ impl App { return true; } } - Some(CommentLocation::LineComment { + Some(CommentLocation::Line { path, line, side, @@ -1816,7 +1866,20 @@ impl App { let location = self.find_comment_at_cursor(); match location { - Some(CommentLocation::FileComment { path, index }) => { + Some(CommentLocation::Review { index }) => { + if let Some(comment) = self.session.review_comments.get(index) { + self.input_mode = InputMode::Comment; + self.comment_buffer = comment.content.clone(); + self.comment_cursor = self.comment_buffer.len(); + self.comment_type = comment.comment_type; + self.comment_is_review_level = true; + self.comment_is_file_level = false; + self.comment_line = None; + self.editing_comment_id = Some(comment.id.clone()); + return true; + } + } + Some(CommentLocation::File { path, index }) => { if let Some(review) = self.session.files.get(&path) && let Some(comment) = review.file_comments.get(index) { @@ -1824,13 +1887,14 @@ impl App { self.comment_buffer = comment.content.clone(); self.comment_cursor = self.comment_buffer.len(); self.comment_type = comment.comment_type; + self.comment_is_review_level = false; self.comment_is_file_level = true; self.comment_line = None; self.editing_comment_id = Some(comment.id.clone()); return true; } } - Some(CommentLocation::LineComment { + Some(CommentLocation::Line { path, line, side, @@ -1849,6 +1913,7 @@ impl App { self.comment_buffer = comment.content.clone(); self.comment_cursor = self.comment_buffer.len(); self.comment_type = comment.comment_type; + self.comment_is_review_level = false; self.comment_is_file_level = false; self.comment_line = Some((line, side)); self.editing_comment_id = Some(comment.id.clone()); @@ -1890,14 +1955,28 @@ impl App { self.comment_buffer.clear(); self.comment_cursor = 0; self.comment_type = CommentType::Note; + self.comment_is_review_level = false; self.comment_is_file_level = file_level; self.comment_line = line; } + pub fn enter_review_comment_mode(&mut self) { + self.input_mode = InputMode::Comment; + self.comment_buffer.clear(); + self.comment_cursor = 0; + self.comment_type = CommentType::Note; + self.comment_is_review_level = true; + self.comment_is_file_level = false; + self.comment_line = None; + self.comment_line_range = None; + self.editing_comment_id = None; + } + pub fn exit_comment_mode(&mut self) { self.input_mode = InputMode::Normal; self.comment_buffer.clear(); self.comment_cursor = 0; + self.comment_is_review_level = false; self.editing_comment_id = None; self.comment_line_range = None; } @@ -1951,6 +2030,7 @@ impl App { self.comment_buffer.clear(); self.comment_cursor = 0; self.comment_type = CommentType::Note; + self.comment_is_review_level = false; self.comment_is_file_level = false; self.visual_anchor = None; } else { @@ -1967,15 +2047,22 @@ impl App { let content = self.comment_buffer.trim().to_string(); - if let Some(path) = self.current_file_path().cloned() - && let Some(review) = self.session.get_file_mut(&path) - { - let message: String; + let mut message = "Error: Could not save comment".to_string(); - // Check if we're editing an existing comment - if let Some(editing_id) = &self.editing_comment_id { - // Update existing comment - // Search in file comments + // Check if we're editing an existing comment + if let Some(editing_id) = &self.editing_comment_id { + if let Some(comment) = self + .session + .review_comments + .iter_mut() + .find(|c| &c.id == editing_id) + { + comment.content = content.clone(); + comment.comment_type = self.comment_type; + message = "Review comment updated".to_string(); + } else if let Some(path) = self.current_file_path().cloned() + && let Some(review) = self.session.get_file_mut(&path) + { if let Some(comment) = review .file_comments .iter_mut() @@ -2006,39 +2093,47 @@ impl App { message = "Error: Comment to edit not found".to_string(); } } - } else { - // Create new comment - if self.comment_is_file_level { - let comment = Comment::new(content, self.comment_type, None); - review.add_file_comment(comment); - message = "File comment added".to_string(); - } else if let Some((range, side)) = self.comment_line_range { - // Range comment from visual selection - let comment = - Comment::new_with_range(content, self.comment_type, Some(side), range); - // Store by end line of the range - review.add_line_comment(range.end, comment); - if range.is_single() { - message = format!("Comment added to line {}", range.end); - } else { - message = format!("Comment added to lines {}-{}", range.start, range.end); - } - } else if let Some((line, side)) = self.comment_line { - let comment = Comment::new(content, self.comment_type, Some(side)); - review.add_line_comment(line, comment); - message = format!("Comment added to line {line}"); + } + } else if self.comment_is_review_level { + let comment = Comment::new(content, self.comment_type, None); + self.session.review_comments.push(comment); + message = "Review comment added".to_string(); + } else if let Some(path) = self.current_file_path().cloned() + && let Some(review) = self.session.get_file_mut(&path) + { + // Create new comment + if self.comment_is_file_level { + let comment = Comment::new(content, self.comment_type, None); + review.add_file_comment(comment); + message = "File comment added".to_string(); + } else if let Some((range, side)) = self.comment_line_range { + // Range comment from visual selection + let comment = + Comment::new_with_range(content, self.comment_type, Some(side), range); + // Store by end line of the range + review.add_line_comment(range.end, comment); + if range.is_single() { + message = format!("Comment added to line {}", range.end); } else { - // Fallback to file comment if no line specified - let comment = Comment::new(content, self.comment_type, None); - review.add_file_comment(comment); - message = "File comment added".to_string(); + message = format!("Comment added to lines {}-{}", range.start, range.end); } + } else if let Some((line, side)) = self.comment_line { + let comment = Comment::new(content, self.comment_type, Some(side)); + review.add_line_comment(line, comment); + message = format!("Comment added to line {line}"); + } else { + // Fallback to file comment if no line specified + let comment = Comment::new(content, self.comment_type, None); + review.add_file_comment(comment); + message = "File comment added".to_string(); } + } + if !message.starts_with("Error:") { self.dirty = true; - self.set_message(message); - self.rebuild_annotations(); } + self.set_message(message); + self.rebuild_annotations(); self.exit_comment_mode(); } @@ -2856,6 +2951,16 @@ impl App { pub fn rebuild_annotations(&mut self) { self.line_annotations.clear(); + self.line_annotations + .push(AnnotatedLine::ReviewCommentsHeader); + for (comment_idx, comment) in self.session.review_comments.iter().enumerate() { + let comment_lines = Self::comment_display_lines(comment); + for _ in 0..comment_lines { + self.line_annotations + .push(AnnotatedLine::ReviewComment { comment_idx }); + } + } + for (file_idx, file) in self.diff_files.iter().enumerate() { let path = file.display_path(); diff --git a/src/main.rs b/src/main.rs index ab4d712..4084558 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { @@ -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 diff --git a/src/model/review.rs b/src/model/review.rs index 94e593c..4ce5bad 100644 --- a/src/model/review.rs +++ b/src/model/review.rs @@ -63,6 +63,8 @@ pub struct ReviewSession { pub commit_range: Option>, pub created_at: DateTime, pub updated_at: DateTime, + #[serde(default)] + pub review_comments: Vec, pub files: HashMap, pub session_notes: Option, } @@ -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, } @@ -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(); diff --git a/src/output/markdown.rs b/src/output/markdown.rs index a9bc3fd..9019f9c 100644 --- a/src/output/markdown.rs +++ b/src/output/markdown.rs @@ -115,6 +115,18 @@ fn write_osc52(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(); @@ -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 = 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(); @@ -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 diff --git a/src/ui/app_layout.rs b/src/ui/app_layout.rs index f7723f6..bb9a241 100644 --- a/src/ui/app_layout.rs +++ b/src/ui/app_layout.rs @@ -578,6 +578,92 @@ fn render_unified_diff(frame: &mut Frame, app: &mut App, area: Rect) { let mut comment_cursor_logical_line: Option = 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(); @@ -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, @@ -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 = 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(); diff --git a/src/ui/help_popup.rs b/src/ui/help_popup.rs index 0bc4009..34b95bb 100644 --- a/src/ui/help_popup.rs +++ b/src/ui/help_popup.rs @@ -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 ", diff --git a/src/ui/status_bar.rs b/src/ui/status_bar.rs index 8065c98..c3815db 100644 --- a/src/ui/status_bar.rs +++ b/src/ui/status_bar.rs @@ -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 ",