From 06871e5b341d5ba149713eb4bc34efd6d8374e1a Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 15:33:23 -0300 Subject: [PATCH 01/14] feat: input history recall via Up/Down arrows and /history command - Add input_history ring buffer (max 100) and input_history_index to App - Up arrow in empty input recalls previous submitted inputs - Down arrow navigates forward through history, clearing at the end - Any manual edit (typing, backspace) cancels history browsing - Consecutive duplicate inputs are not stored - /history lists all stored inputs with indices - /history input N loads entry N into the input box - Works in both local and remote TUI modes - Help overlay updated with new keybindings and /history command - 13 unit tests covering all history operations Closes #264 --- crates/jcode-tui/src/tui/app.rs | 4 + crates/jcode-tui/src/tui/app/commands.rs | 39 ++++ crates/jcode-tui/src/tui/app/input.rs | 12 ++ .../src/tui/app/remote/key_handling.rs | 17 +- .../src/tui/app/state_ui_input_helpers.rs | 1 + .../jcode-tui/src/tui/app/state_ui_runtime.rs | 58 ++++++ .../tests/remote_startup_input_02/part_01.rs | 190 ++++++++++++++++++ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 4 + crates/jcode-tui/src/tui/ui_overlays.rs | 6 +- 9 files changed, 326 insertions(+), 5 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 474f9be30..a69d4ef6a 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -944,6 +944,10 @@ pub struct App { scroll_bookmark: Option, // Stashed input: saved via Ctrl+S for later retrieval stashed_input: Option<(String, usize)>, + // Input history for recall (ring buffer, newest at the end) + input_history: Vec, + // Index into `input_history` while browsing; None when not browsing + input_history_index: Option, // Undo history for in-progress input editing (Ctrl+Z) input_undo_stack: Vec<(String, usize)>, // Short-lived notice for status feedback (model switch, cycle diff mode, etc.) diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 9d6b63416..692a95d9c 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -1894,6 +1894,45 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool { return true; } + if trimmed == "/history input" || trimmed == "/history" { + if app.input_history.is_empty() { + app.push_display_message(DisplayMessage::system( + "No input history yet.".to_string(), + )); + return true; + } + let mut listing = String::from("**Input history:**\n\n"); + for (i, entry) in app.input_history.iter().enumerate() { + let preview = crate::util::truncate_str(entry, 80); + listing.push_str(&format!(" `{}` {}\n", i + 1, preview)); + } + listing.push_str( + "\nUse `/history input N` to load entry N into the input box.", + ); + app.push_display_message(DisplayMessage::system(listing)); + return true; + } + + if let Some(num_str) = trimmed.strip_prefix("/history input ") { + let num_str = num_str.trim(); + match num_str.parse::() { + Ok(n) if n >= 1 && n <= app.input_history.len() => { + let entry = app.input_history[n - 1].clone(); + app.input = entry.clone(); + app.cursor_pos = app.input.len(); + app.reset_input_history_browse(); + app.set_status_notice(format!("📋 Loaded input #{}", n)); + } + _ => { + app.push_display_message(DisplayMessage::system(format!( + "Invalid index. Use `/history input N` where N is 1..{}.", + app.input_history.len() + ))); + } + } + return true; + } + if trimmed == "/rewind" { let visible_messages = app.session.visible_conversation_messages(); if visible_messages.is_empty() { diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index fe7087722..936116c5d 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -717,6 +717,7 @@ pub(super) fn handle_text_input(app: &mut App, text: &str) -> bool { } insert_input_text(app, text); + app.reset_input_history_browse(); true } @@ -1789,6 +1790,7 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool { app.input.drain(prev..app.cursor_pos); app.cursor_pos = prev; app.reset_tab_completion(); + app.reset_input_history_browse(); app.sync_model_picker_preview_from_input(); } true @@ -1828,11 +1830,17 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool { true } KeyCode::Up | KeyCode::PageUp => { + if code == KeyCode::Up && app.input.is_empty() && app.input_history_up() { + return true; + } let inc = if code == KeyCode::PageUp { 10 } else { 1 }; app.scroll_up(inc); true } KeyCode::Down | KeyCode::PageDown => { + if code == KeyCode::Down && app.input_history_index.is_some() && app.input_history_down() { + return true; + } let dec = if code == KeyCode::PageDown { 10 } else { 1 }; app.scroll_down(dec); true @@ -1890,6 +1898,8 @@ pub(super) fn take_prepared_input(app: &mut App) -> PreparedInput { let images = std::mem::take(&mut app.pending_images); app.cursor_pos = 0; app.clear_input_undo_history(); + app.reset_input_history_browse(); + app.push_input_history(expanded.clone()); PreparedInput { raw_input, expanded, @@ -2382,6 +2392,8 @@ impl App { self.pasted_contents.clear(); self.cursor_pos = 0; self.clear_input_undo_history(); + self.reset_input_history_browse(); + self.push_input_history(input.clone()); self.follow_chat_bottom(); // Reset to bottom and resume auto-scroll on new input // If the previous assistant turn still has visible streamed text that has not yet been diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index 158731967..b05b6bc81 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -732,6 +732,7 @@ async fn handle_remote_key_internal( app.input.drain(prev..app.cursor_pos); app.cursor_pos = prev; app.reset_tab_completion(); + app.reset_input_history_browse(); app.sync_model_picker_preview_from_input(); } } @@ -2345,12 +2346,20 @@ async fn handle_remote_key_internal( } } KeyCode::Up | KeyCode::PageUp => { - let inc = if code == KeyCode::PageUp { 10 } else { 1 }; - app.scroll_up(inc); + if code == KeyCode::Up && app.input.is_empty() && app.input_history_up() { + // Input restored from history + } else { + let inc = if code == KeyCode::PageUp { 10 } else { 1 }; + app.scroll_up(inc); + } } KeyCode::Down | KeyCode::PageDown => { - let dec = if code == KeyCode::PageDown { 10 } else { 1 }; - app.scroll_down(dec); + if code == KeyCode::Down && app.input_history_index.is_some() && app.input_history_down() { + // Navigated down in history + } else { + let dec = if code == KeyCode::PageDown { 10 } else { 1 }; + app.scroll_down(dec); + } } KeyCode::Esc => { if app diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 760de0b62..3d2ec5efc 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -73,6 +73,7 @@ const REGISTERED_COMMANDS: &[RegisteredCommand] = &[ RegisteredCommand::public("/alignment", "Show/change default text alignment"), RegisteredCommand::public("/clear", "Clear conversation history"), RegisteredCommand::public("/rewind", "Rewind conversation to previous message"), + RegisteredCommand::public("/history", "Show input history, /history input N to load entry"), RegisteredCommand::public("/poke", "Poke model to resume with incomplete todos"), RegisteredCommand::public("/plan", "Create a plan-only response in the side panel"), RegisteredCommand::public("/improve", "Autonomously improve the repository"), diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index 9ae17a20a..32a38e6a3 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -386,4 +386,62 @@ impl App { self.set_status_notice("📋 Input stashed"); } } + + /// Maximum number of entries kept in input history. + const INPUT_HISTORY_MAX: usize = 100; + + /// Push a submitted input into history (called from `submit_input`). + pub(super) fn push_input_history(&mut self, text: String) { + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + return; + } + // Avoid consecutive duplicates + if self.input_history.last() == Some(&trimmed) { + return; + } + self.input_history.push(trimmed); + if self.input_history.len() > Self::INPUT_HISTORY_MAX { + self.input_history.remove(0); + } + } + + /// Navigate up (older) in input history. Returns `true` if the input was modified. + pub(super) fn input_history_up(&mut self) -> bool { + if self.input_history.is_empty() { + return false; + } + let new_idx = match self.input_history_index { + Some(idx) => idx.saturating_sub(1), + None => self.input_history.len() - 1, + }; + self.input_history_index = Some(new_idx); + self.input = self.input_history[new_idx].clone(); + self.cursor_pos = self.input.len(); + true + } + + /// Navigate down (newer) in input history. Returns `true` if the input was modified. + pub(super) fn input_history_down(&mut self) -> bool { + let Some(idx) = self.input_history_index else { + return false; + }; + let next = idx + 1; + if next < self.input_history.len() { + self.input_history_index = Some(next); + self.input = self.input_history[next].clone(); + self.cursor_pos = self.input.len(); + } else { + // Past the end: clear input and exit history browsing + self.input_history_index = None; + self.input.clear(); + self.cursor_pos = 0; + } + true + } + + /// Reset history browsing state (call when the user manually edits input). + pub(super) fn reset_input_history_browse(&mut self) { + self.input_history_index = None; + } } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index f3aa8a4dd..fd1a06aa8 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1075,3 +1075,193 @@ fn test_handle_input_shell_completed_renders_markdown_blocks() { Some("Shell command completed".to_string()) ); } + +#[test] +fn test_submit_input_records_input_history() { + let mut app = create_test_app(); + + // Submit first input + app.input = "first command".to_string(); + app.cursor_pos = app.input.len(); + app.submit_input(); + + assert!(app.input_history.contains(&"first command".to_string())); + assert_eq!(app.input_history.len(), 1); + + // Submit second input + app.input = "second command".to_string(); + app.cursor_pos = app.input.len(); + app.submit_input(); + + assert_eq!(app.input_history.len(), 2); + assert_eq!(app.input_history[0], "first command"); + assert_eq!(app.input_history[1], "second command"); +} + +#[test] +fn test_submit_input_deduplicates_consecutive_entries() { + let mut app = create_test_app(); + + app.input = "same command".to_string(); + app.cursor_pos = app.input.len(); + app.submit_input(); + + app.input = "same command".to_string(); + app.cursor_pos = app.input.len(); + app.submit_input(); + + assert_eq!(app.input_history.len(), 1); +} + +#[test] +fn test_submit_input_does_not_record_empty() { + let mut app = create_test_app(); + + app.input = " ".to_string(); + app.cursor_pos = app.input.len(); + app.submit_input(); + + assert!(app.input_history.is_empty()); +} + +#[test] +fn test_input_history_up_recalls_last_input() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + + assert!(app.input.is_empty()); + assert!(app.input_history_up()); + assert_eq!(app.input, "second"); + assert_eq!(app.input_history_index, Some(1)); + + assert!(app.input_history_up()); + assert_eq!(app.input, "first"); + assert_eq!(app.input_history_index, Some(0)); + + // At the top, pressing up again should stay at index 0 + assert!(app.input_history_up()); + assert_eq!(app.input, "first"); + assert_eq!(app.input_history_index, Some(0)); +} + +#[test] +fn test_input_history_down_navigates_forward() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + app.input_history_index = Some(0); + app.input = "first".to_string(); + + assert!(app.input_history_down()); + assert_eq!(app.input, "second"); + assert_eq!(app.input_history_index, Some(1)); + + // Past the end clears input and exits browse mode + assert!(app.input_history_down()); + assert!(app.input.is_empty()); + assert!(app.input_history_index.is_none()); +} + +#[test] +fn test_input_history_down_does_nothing_when_not_browsing() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + assert!(!app.input_history_down()); + assert!(app.input.is_empty()); +} + +#[test] +fn test_text_input_resets_history_browse() { + let mut app = create_test_app(); + + app.input_history.push("old".to_string()); + app.input_history_index = Some(0); + app.input = "old".to_string(); + app.cursor_pos = 3; + + app.handle_key(KeyCode::Char('x'), KeyModifiers::empty()) + .unwrap(); + + assert!(app.input_history_index.is_none()); +} + +#[test] +fn test_backspace_resets_history_browse() { + let mut app = create_test_app(); + + app.input_history.push("test".to_string()); + app.input_history_index = Some(0); + app.input = "test".to_string(); + app.cursor_pos = 4; + + app.handle_key(KeyCode::Backspace, KeyModifiers::empty()) + .unwrap(); + + assert!(app.input_history_index.is_none()); +} + +#[test] +fn test_history_command_lists_entries() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history"); + + let last = app.display_messages().last().expect("history message"); + assert!(last.content.contains("**Input history:**")); + assert!(last.content.contains("first")); + assert!(last.content.contains("second")); +} + +#[test] +fn test_history_command_empty() { + let mut app = create_test_app(); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history"); + + let last = app.display_messages().last().expect("empty message"); + assert!(last.content.contains("No input history yet")); +} + +#[test] +fn test_history_input_n_loads_entry() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history input 1"); + + assert_eq!(app.input(), "first"); + assert!(app.input_history_index.is_none()); +} + +#[test] +fn test_history_input_n_rejects_invalid_index() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history input 5"); + + let last = app.display_messages().last().expect("error message"); + assert!(last.content.contains("Invalid index")); +} + +#[test] +fn test_input_history_up_empty_history() { + let mut app = create_test_app(); + + assert!(!app.input_history_up()); + assert!(app.input.is_empty()); +} diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 37760d55f..207220e9c 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -501,6 +501,8 @@ impl App { scroll_bookmark: None, typing_scroll_lock: false, stashed_input: None, + input_history: Vec::new(), + input_history_index: None, input_undo_stack: Vec::new(), status_notice: None, experimental_feature_warnings_seen: HashSet::new(), @@ -882,6 +884,8 @@ impl App { scroll_bookmark: None, typing_scroll_lock: false, stashed_input: None, + input_history: Vec::new(), + input_history_index: None, input_undo_stack: Vec::new(), status_notice: None, experimental_feature_warnings_seen: HashSet::new(), diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index fc7d7c780..e60652c9c 100644 --- a/crates/jcode-tui/src/tui/ui_overlays.rs +++ b/crates/jcode-tui/src/tui/ui_overlays.rs @@ -186,6 +186,10 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap "/rewind", "Show numbered history, /rewind N to rewind", )); + lines.push(help_entry( + "/history [input N]", + "Show input history, load entry N into input", + )); lines.push(help_entry( "/fix", "Attempt recovery when model cannot continue", @@ -336,7 +340,7 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap lines.push(Line::from(Span::styled(" Navigation", section_style))); lines.push(Line::from("")); lines.push(key_entry("PageUp / PageDown", "Scroll history")); - lines.push(key_entry("Up / Down", "Scroll history (when input empty)")); + lines.push(key_entry("Up / Down (empty input)", "Recall previous input / scroll history")); lines.push(key_entry("Ctrl+[ / Ctrl+]", "Jump between user prompts")); lines.push(key_entry( "Cmd/Super+K / J", From 6442b5b6653124702f46c97524cb075e443447c6 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 16:10:08 -0300 Subject: [PATCH 02/14] fix(input-history): Up arrow multi-press navigation + history reset coverage - Up arrow now works when already browsing history (not just from empty input) - Same fix applied to both local and remote key handlers - reset_input_history_browse added to insert_input_text, undo_input_change, and remember_input_undo_state for comprehensive coverage - Removed redundant reset calls from individual key handlers - Added tests for multi-press Up navigation, Down when not browsing - 15 total tests passing --- crates/jcode-tui/src/tui/app/input.rs | 9 ++-- .../src/tui/app/remote/key_handling.rs | 3 +- .../src/tui/app/state_ui_input_helpers.rs | 3 ++ .../jcode-tui/src/tui/app/state_ui_runtime.rs | 1 + .../tests/remote_startup_input_02/part_01.rs | 47 +++++++++++++++++++ 5 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 936116c5d..820f39dff 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -681,6 +681,7 @@ pub(super) fn insert_input_text(app: &mut App, text: &str) { app.input.insert_str(app.cursor_pos, text); app.cursor_pos += text.len(); app.reset_tab_completion(); + app.reset_input_history_browse(); app.sync_model_picker_preview_from_input(); } @@ -717,7 +718,6 @@ pub(super) fn handle_text_input(app: &mut App, text: &str) -> bool { } insert_input_text(app, text); - app.reset_input_history_browse(); true } @@ -1790,7 +1790,6 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool { app.input.drain(prev..app.cursor_pos); app.cursor_pos = prev; app.reset_tab_completion(); - app.reset_input_history_browse(); app.sync_model_picker_preview_from_input(); } true @@ -1830,7 +1829,7 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool { true } KeyCode::Up | KeyCode::PageUp => { - if code == KeyCode::Up && app.input.is_empty() && app.input_history_up() { + if code == KeyCode::Up && (app.input.is_empty() || app.input_history_index.is_some()) && app.input_history_up() { return true; } let inc = if code == KeyCode::PageUp { 10 } else { 1 }; @@ -2393,7 +2392,6 @@ impl App { self.cursor_pos = 0; self.clear_input_undo_history(); self.reset_input_history_browse(); - self.push_input_history(input.clone()); self.follow_chat_bottom(); // Reset to bottom and resume auto-scroll on new input // If the previous assistant turn still has visible streamed text that has not yet been @@ -2417,6 +2415,9 @@ impl App { return; } + // Record in history only for chat messages and commands (not login keys, SSH targets, etc.) + self.push_input_history(input.clone()); + let trimmed = input.trim(); let handled = commands::handle_help_command(self, trimmed) || commands::handle_ssh_command(self, trimmed) diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index b05b6bc81..0ff3ce640 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -732,7 +732,6 @@ async fn handle_remote_key_internal( app.input.drain(prev..app.cursor_pos); app.cursor_pos = prev; app.reset_tab_completion(); - app.reset_input_history_browse(); app.sync_model_picker_preview_from_input(); } } @@ -2346,7 +2345,7 @@ async fn handle_remote_key_internal( } } KeyCode::Up | KeyCode::PageUp => { - if code == KeyCode::Up && app.input.is_empty() && app.input_history_up() { + if code == KeyCode::Up && (app.input.is_empty() || app.input_history_index.is_some()) && app.input_history_up() { // Input restored from history } else { let inc = if code == KeyCode::PageUp { 10 } else { 1 }; diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 3d2ec5efc..ceadb6723 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -1333,6 +1333,8 @@ impl App { self.input_undo_stack.remove(0); } self.input_undo_stack.push(snapshot); + // Any manual edit cancels history browsing + self.reset_input_history_browse(); } pub(super) fn clear_input_undo_history(&mut self) { @@ -1344,6 +1346,7 @@ impl App { self.input = input; self.cursor_pos = cursor_pos.min(self.input.len()); self.reset_tab_completion(); + self.reset_input_history_browse(); self.sync_model_picker_preview_from_input(); self.set_status_notice("↶ Input restored"); } else { diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index 32a38e6a3..d20a5b50a 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -370,6 +370,7 @@ impl App { } pub(super) fn toggle_input_stash(&mut self) { + self.reset_input_history_browse(); // Prevent stash from interacting with history browsing if let Some((stashed, stashed_cursor)) = self.stashed_input.take() { let current_input = std::mem::replace(&mut self.input, stashed); let current_cursor = std::mem::replace(&mut self.cursor_pos, stashed_cursor); diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index fd1a06aa8..df7d81076 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1265,3 +1265,50 @@ fn test_input_history_up_empty_history() { assert!(!app.input_history_up()); assert!(app.input.is_empty()); } + +#[test] +fn test_input_history_up_continues_while_browsing() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + app.input_history.push("third".to_string()); + + // Start browsing with empty input + app.input.clear(); + app.cursor_pos = 0; + + // First Up: loads "third" (most recent), index = Some(2) + assert!(app.input_history_up()); + assert_eq!(app.input, "third"); + assert_eq!(app.input_history_index, Some(2)); + + // Second Up: loads "second", index = Some(1) + // This should work even though input is now non-empty (we're browsing). + assert!(app.input_history_up()); + assert_eq!(app.input, "second"); + assert_eq!(app.input_history_index, Some(1)); + + // Third Up: loads "first", index = Some(0) + assert!(app.input_history_up()); + assert_eq!(app.input, "first"); + assert_eq!(app.input_history_index, Some(0)); + + // Fourth Up: already at oldest, stays at "first" + assert!(app.input_history_up()); + assert_eq!(app.input, "first"); + assert_eq!(app.input_history_index, Some(0)); +} + +#[test] +fn test_input_history_down_returns_false_when_not_browsing() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input = "typed text".to_string(); + app.input_history_index = None; + + // Down should not engage history when not browsing + assert!(!app.input_history_down()); + assert_eq!(app.input, "typed text"); +} From bf363f03ef98c60eea37103c430623f7043ce0c6 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 16:20:14 -0300 Subject: [PATCH 03/14] style: cargo fmt fixes for input-history related files --- crates/jcode-tui/src/tui/app/commands.rs | 8 ++------ crates/jcode-tui/src/tui/app/input.rs | 10 ++++++++-- crates/jcode-tui/src/tui/app/remote/key_handling.rs | 10 ++++++++-- crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs | 5 ++++- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 692a95d9c..17dba2634 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -1896,9 +1896,7 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool { if trimmed == "/history input" || trimmed == "/history" { if app.input_history.is_empty() { - app.push_display_message(DisplayMessage::system( - "No input history yet.".to_string(), - )); + app.push_display_message(DisplayMessage::system("No input history yet.".to_string())); return true; } let mut listing = String::from("**Input history:**\n\n"); @@ -1906,9 +1904,7 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool { let preview = crate::util::truncate_str(entry, 80); listing.push_str(&format!(" `{}` {}\n", i + 1, preview)); } - listing.push_str( - "\nUse `/history input N` to load entry N into the input box.", - ); + listing.push_str("\nUse `/history input N` to load entry N into the input box."); app.push_display_message(DisplayMessage::system(listing)); return true; } diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 820f39dff..12f865eb7 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1829,7 +1829,10 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool { true } KeyCode::Up | KeyCode::PageUp => { - if code == KeyCode::Up && (app.input.is_empty() || app.input_history_index.is_some()) && app.input_history_up() { + if code == KeyCode::Up + && (app.input.is_empty() || app.input_history_index.is_some()) + && app.input_history_up() + { return true; } let inc = if code == KeyCode::PageUp { 10 } else { 1 }; @@ -1837,7 +1840,10 @@ pub(super) fn handle_basic_key(app: &mut App, code: KeyCode) -> bool { true } KeyCode::Down | KeyCode::PageDown => { - if code == KeyCode::Down && app.input_history_index.is_some() && app.input_history_down() { + if code == KeyCode::Down + && app.input_history_index.is_some() + && app.input_history_down() + { return true; } let dec = if code == KeyCode::PageDown { 10 } else { 1 }; diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index 0ff3ce640..198f08f09 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -2345,7 +2345,10 @@ async fn handle_remote_key_internal( } } KeyCode::Up | KeyCode::PageUp => { - if code == KeyCode::Up && (app.input.is_empty() || app.input_history_index.is_some()) && app.input_history_up() { + if code == KeyCode::Up + && (app.input.is_empty() || app.input_history_index.is_some()) + && app.input_history_up() + { // Input restored from history } else { let inc = if code == KeyCode::PageUp { 10 } else { 1 }; @@ -2353,7 +2356,10 @@ async fn handle_remote_key_internal( } } KeyCode::Down | KeyCode::PageDown => { - if code == KeyCode::Down && app.input_history_index.is_some() && app.input_history_down() { + if code == KeyCode::Down + && app.input_history_index.is_some() + && app.input_history_down() + { // Navigated down in history } else { let dec = if code == KeyCode::PageDown { 10 } else { 1 }; diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index ceadb6723..63d5f32cb 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -73,7 +73,10 @@ const REGISTERED_COMMANDS: &[RegisteredCommand] = &[ RegisteredCommand::public("/alignment", "Show/change default text alignment"), RegisteredCommand::public("/clear", "Clear conversation history"), RegisteredCommand::public("/rewind", "Rewind conversation to previous message"), - RegisteredCommand::public("/history", "Show input history, /history input N to load entry"), + RegisteredCommand::public( + "/history", + "Show input history, /history input N to load entry", + ), RegisteredCommand::public("/poke", "Poke model to resume with incomplete todos"), RegisteredCommand::public("/plan", "Create a plan-only response in the side panel"), RegisteredCommand::public("/improve", "Autonomously improve the repository"), From 62b8a99562c48f6474ab346943d95b51d48f1c5b Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 16:47:39 -0300 Subject: [PATCH 04/14] feat(input-history): enhanced subcommands, dedup, persistence, status indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /history clear: wipe all entries - /history search : case-insensitive search with match count - /history delete N: remove specific entry - Non-consecutive dedup: re-inserting existing text moves it to the end - Persistent history: saved to ~/.jcode/input-history.json, loaded on startup - Status bar indicator: shows '📋 history N/M' while browsing - input_history_browse_status() added to TuiState trait - 20 unit tests passing (7 new) --- crates/jcode-tui/src/tui/app/commands.rs | 66 +++++++++++++ .../jcode-tui/src/tui/app/state_ui_runtime.rs | 82 ++++++++++++++++ .../tests/remote_startup_input_02/part_01.rs | 96 +++++++++++++++++++ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 6 +- crates/jcode-tui/src/tui/app/tui_state.rs | 9 ++ crates/jcode-tui/src/tui/mod.rs | 4 + crates/jcode-tui/src/tui/ui_input.rs | 8 ++ crates/jcode-tui/src/tui/ui_tests/mod.rs | 3 + 8 files changed, 272 insertions(+), 2 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 17dba2634..232620d31 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -1905,10 +1905,76 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool { listing.push_str(&format!(" `{}` {}\n", i + 1, preview)); } listing.push_str("\nUse `/history input N` to load entry N into the input box."); + listing.push_str("\nUse `/history search ` to search."); + listing.push_str("\nUse `/history delete N` to remove entry N."); + listing.push_str("\nUse `/history clear` to remove all entries."); app.push_display_message(DisplayMessage::system(listing)); return true; } + if trimmed == "/history clear" { + let count = app.input_history.len(); + app.clear_input_history(); + app.set_status_notice(format!("🗑 Cleared {} input history entries", count)); + return true; + } + + if let Some(term) = trimmed.strip_prefix("/history search ") { + let term = term.trim(); + if term.is_empty() { + app.push_display_message(DisplayMessage::system( + "Usage: `/history search `".to_string(), + )); + return true; + } + let term_lower = term.to_lowercase(); + let matches: Vec<(usize, &str)> = app + .input_history + .iter() + .enumerate() + .filter(|(_, entry)| entry.to_lowercase().contains(&term_lower)) + .map(|(i, e)| (i + 1, e.as_str())) + .collect(); + if matches.is_empty() { + app.push_display_message(DisplayMessage::system(format!( + "No history entries match \"{}\".", + term + ))); + } else { + let mut listing = format!("**History matches for \"{}\":**\n\n", term); + for (i, entry) in &matches { + let preview = crate::util::truncate_str(entry, 80); + listing.push_str(&format!(" `{}` {}\n", i, preview)); + } + listing.push_str(&format!( + "\n{} match{} found.", + matches.len(), + if matches.len() == 1 { "" } else { "es" } + )); + app.push_display_message(DisplayMessage::system(listing)); + } + return true; + } + + if let Some(num_str) = trimmed.strip_prefix("/history delete ") { + let num_str = num_str.trim(); + match num_str.parse::() { + Ok(n) if n >= 1 && n <= app.input_history.len() => { + let entry = app.input_history[n - 1].clone(); + let preview = crate::util::truncate_str(&entry, 40).to_string(); + app.delete_input_history_entry(n - 1); + app.set_status_notice(format!("🗑 Deleted history #{}: {}", n, preview)); + } + _ => { + app.push_display_message(DisplayMessage::system(format!( + "Invalid index. Use `/history delete N` where N is 1..{}.", + app.input_history.len() + ))); + } + } + return true; + } + if let Some(num_str) = trimmed.strip_prefix("/history input ") { let num_str = num_str.trim(); match num_str.parse::() { diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index d20a5b50a..61ad8bdbb 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -401,10 +401,16 @@ impl App { if self.input_history.last() == Some(&trimmed) { return; } + // Dedup: if the same text already exists, remove it first so the latest + // position wins (most-recently-used ordering). + if let Some(existing) = self.input_history.iter().position(|e| e == &trimmed) { + self.input_history.remove(existing); + } self.input_history.push(trimmed); if self.input_history.len() > Self::INPUT_HISTORY_MAX { self.input_history.remove(0); } + self.save_input_history(); } /// Navigate up (older) in input history. Returns `true` if the input was modified. @@ -445,4 +451,80 @@ impl App { pub(super) fn reset_input_history_browse(&mut self) { self.input_history_index = None; } + + /// Returns `Some((current, total))` if the user is browsing input history. + pub(super) fn input_history_browse_status(&self) -> Option<(usize, usize)> { + let idx = self.input_history_index?; + let total = self.input_history.len(); + if total == 0 { + return None; + } + Some((idx + 1, total)) + } + + /// Clear all input history entries. + pub(super) fn clear_input_history(&mut self) { + self.input_history.clear(); + self.reset_input_history_browse(); + self.save_input_history(); + } + + /// Delete a single input history entry by 0-based index. + pub(super) fn delete_input_history_entry(&mut self, idx: usize) -> bool { + if idx >= self.input_history.len() { + return false; + } + self.input_history.remove(idx); + // Reset browse if we deleted the entry being browsed or one before it + if let Some(browse_idx) = self.input_history_index { + if browse_idx == idx { + self.reset_input_history_browse(); + } else if browse_idx > idx { + self.input_history_index = Some(browse_idx - 1); + } + } + self.save_input_history(); + true + } + + /// Path to the global input-history file. + fn input_history_path() -> Option { + crate::storage::jcode_dir() + .ok() + .map(|dir| dir.join("input-history.json")) + } + + /// Save input history to disk (global, not session-specific). + pub(super) fn save_input_history(&self) { + if self.input_history.is_empty() { + return; + } + if let Some(path) = Self::input_history_path() { + let data = serde_json::json!({ + "history": self.input_history, + "version": 1, + }); + let _ = std::fs::write(&path, data.to_string()); + } + } + + /// Load input history from disk. Returns entries if available. + pub(super) fn load_input_history() -> Vec { + let Some(path) = Self::input_history_path() else { + return Vec::new(); + }; + let Ok(contents) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + let Ok(value) = serde_json::from_str::(&contents) else { + return Vec::new(); + }; + let Some(arr) = value.get("history").and_then(|v| v.as_array()) else { + return Vec::new(); + }; + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .take(Self::INPUT_HISTORY_MAX) + .collect() + } } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index df7d81076..cee139ab4 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1312,3 +1312,99 @@ fn test_input_history_down_returns_false_when_not_browsing() { assert!(!app.input_history_down()); assert_eq!(app.input, "typed text"); } + +#[test] +fn test_history_clear_removes_all_entries() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + app.input_history.push("third".to_string()); + + app.clear_input_history(); + assert!(app.input_history.is_empty()); +} + +#[test] +fn test_history_search_finds_matches() { + let mut app = create_test_app(); + + app.input_history.push("hello world".to_string()); + app.input_history.push("goodbye world".to_string()); + app.input_history.push("hello there".to_string()); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history search hello"); + + let last = app.display_messages().last().expect("search results"); + assert!(last.content.contains("hello world")); + assert!(last.content.contains("hello there")); + assert!(!last.content.contains("goodbye")); + assert!(last.content.contains("2 match")); +} + +#[test] +fn test_history_search_no_results() { + let mut app = create_test_app(); + + app.input_history.push("hello world".to_string()); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history search xyz"); + + let last = app.display_messages().last().expect("no match message"); + assert!(last.content.contains("No history entries match")); +} + +#[test] +fn test_history_delete_removes_entry() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + app.input_history.push("third".to_string()); + + use crate::tui::app::commands::handle_session_command; + handle_session_command(&mut app, "/history delete 2"); + + assert_eq!(app.input_history.len(), 2); + assert_eq!(app.input_history[0], "first"); + assert_eq!(app.input_history[1], "third"); +} + +#[test] +fn test_history_non_consecutive_dedup() { + let mut app = create_test_app(); + + app.push_input_history("hello".to_string()); + app.push_input_history("world".to_string()); + app.push_input_history("hello".to_string()); + + // "hello" should move to the end, not duplicate + assert_eq!(app.input_history.len(), 2); + assert_eq!(app.input_history[0], "world"); + assert_eq!(app.input_history[1], "hello"); +} + +#[test] +fn test_input_history_browse_status_none_when_not_browsing() { + let mut app = create_test_app(); + + app.input_history.push("test".to_string()); + app.input_history_index = None; + + assert!(app.input_history_browse_status().is_none()); +} + +#[test] +fn test_input_history_browse_status_some_when_browsing() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + app.input_history_index = Some(1); + + let (current, total) = app.input_history_browse_status().unwrap(); + assert_eq!(current, 2); // 1-based + assert_eq!(total, 2); +} diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 207220e9c..1fa46b920 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -501,7 +501,7 @@ impl App { scroll_bookmark: None, typing_scroll_lock: false, stashed_input: None, - input_history: Vec::new(), + input_history: App::load_input_history(), input_history_index: None, input_undo_stack: Vec::new(), status_notice: None, @@ -884,7 +884,7 @@ impl App { scroll_bookmark: None, typing_scroll_lock: false, stashed_input: None, - input_history: Vec::new(), + input_history: App::load_input_history(), input_history_index: None, input_undo_stack: Vec::new(), status_notice: None, @@ -959,6 +959,8 @@ impl App { app.runtime_mode = AppRuntimeMode::TestHarness; app.is_remote = false; app.is_replay = false; + app.input_history.clear(); + app.input_history_index = None; app } diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 1b81c5294..108b69ccb 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -713,6 +713,15 @@ impl crate::tui::TuiState for App { self.stashed_input.is_some() } + fn input_history_browse_status(&self) -> Option<(usize, usize)> { + let idx = self.input_history_index?; + let total = self.input_history.len(); + if total == 0 { + return None; + } + Some((idx + 1, total)) + } + fn context_snapshot(&self) -> crate::tui::ContextSnapshot { use crate::message::{ContentBlock, Role}; use std::time::Instant; diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index ee00d6773..5a79ee0ba 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -218,6 +218,10 @@ pub trait TuiState { } /// Whether there is a stashed input (saved via Ctrl+S) fn has_stashed_input(&self) -> bool; + /// Returns `Some((current, total))` if the user is browsing input history. + fn input_history_browse_status(&self) -> Option<(usize, usize)> { + None + } /// Context info (what's loaded in context window - static + dynamic) fn context_info(&self) -> crate::prompt::ContextInfo; /// Authoritative, freshness-tagged context snapshot used by widgets. diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index 52041fde3..cce7039ed 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -1433,6 +1433,14 @@ pub(super) fn build_notification_spans(app: &dyn TuiState) -> Vec> )); } + if let Some((current, total)) = app.input_history_browse_status() { + push_sep(&mut spans); + spans.push(Span::styled( + format!("📋 history {}/{}", current, total), + Style::default().fg(rgb(140, 180, 255)), + )); + } + spans } diff --git a/crates/jcode-tui/src/tui/ui_tests/mod.rs b/crates/jcode-tui/src/tui/ui_tests/mod.rs index 106acfe63..3bbb8dd38 100644 --- a/crates/jcode-tui/src/tui/ui_tests/mod.rs +++ b/crates/jcode-tui/src/tui/ui_tests/mod.rs @@ -304,6 +304,9 @@ impl crate::tui::TuiState for TestState { fn has_stashed_input(&self) -> bool { false } + fn input_history_browse_status(&self) -> Option<(usize, usize)> { + None + } fn context_info(&self) -> crate::prompt::ContextInfo { Default::default() } From 909dc494c56ad7608bf6cc636b474c957cc668d1 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 17:42:32 -0300 Subject: [PATCH 05/14] fix: adversarial review fixes - persist clear, update docs, complete /history input N Round 1: save_input_history now deletes file when history is empty so /clear doesn't leave stale data on disk. Round 2: Update help overlay, autocomplete description, and add /history to command_accepts_args. Remove duplicate inherent input_history_browse_status method in favor of trait impl. Round 3: Better error messages for /history delete and /history input when history is empty. Add catch-all for unknown /history subcommands. Remove double clone. Round 4: Add missing sync_model_picker_preview_from_input() call on /history input N. Round 5: Save undo state before /history input N replaces input. --- crates/jcode-tui/src/tui/app/commands.rs | 34 ++++++++++++++++--- .../src/tui/app/state_ui_input_helpers.rs | 3 +- .../jcode-tui/src/tui/app/state_ui_runtime.rs | 18 +++------- .../tests/remote_startup_input_02/part_01.rs | 4 +-- crates/jcode-tui/src/tui/ui_overlays.rs | 9 +++-- 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/commands.rs b/crates/jcode-tui/src/tui/app/commands.rs index 232620d31..beb5eb0de 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -1958,6 +1958,12 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool { if let Some(num_str) = trimmed.strip_prefix("/history delete ") { let num_str = num_str.trim(); + if app.input_history.is_empty() { + app.push_display_message(DisplayMessage::system( + "No input history to delete.".to_string(), + )); + return true; + } match num_str.parse::() { Ok(n) if n >= 1 && n <= app.input_history.len() => { let entry = app.input_history[n - 1].clone(); @@ -1980,21 +1986,39 @@ pub(super) fn handle_session_command(app: &mut App, trimmed: &str) -> bool { match num_str.parse::() { Ok(n) if n >= 1 && n <= app.input_history.len() => { let entry = app.input_history[n - 1].clone(); - app.input = entry.clone(); + if !app.input.is_empty() { + app.remember_input_undo_state(); + } + app.input = entry; app.cursor_pos = app.input.len(); + app.reset_tab_completion(); app.reset_input_history_browse(); + app.sync_model_picker_preview_from_input(); app.set_status_notice(format!("📋 Loaded input #{}", n)); } _ => { - app.push_display_message(DisplayMessage::system(format!( - "Invalid index. Use `/history input N` where N is 1..{}.", - app.input_history.len() - ))); + if app.input_history.is_empty() { + app.push_display_message(DisplayMessage::system( + "No input history yet.".to_string(), + )); + } else { + app.push_display_message(DisplayMessage::system(format!( + "Invalid index. Use `/history input N` where N is 1..{}.", + app.input_history.len() + ))); + } } } return true; } + if trimmed.starts_with("/history ") { + app.push_display_message(DisplayMessage::system( + "Unknown /history subcommand. Use: input N, search , delete N, clear".to_string(), + )); + return true; + } + if trimmed == "/rewind" { let visible_messages = app.session.visible_conversation_messages(); if visible_messages.is_empty() { diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 63d5f32cb..2441db8ca 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -75,7 +75,7 @@ const REGISTERED_COMMANDS: &[RegisteredCommand] = &[ RegisteredCommand::public("/rewind", "Rewind conversation to previous message"), RegisteredCommand::public( "/history", - "Show input history, /history input N to load entry", + "Input history: list, load N, search, delete N, clear", ), RegisteredCommand::public("/poke", "Poke model to resume with incomplete todos"), RegisteredCommand::public("/plan", "Create a plan-only response in the side panel"), @@ -1401,6 +1401,7 @@ impl App { | "/improve" | "/refactor" | "/rewind" + | "/history" | "/compact" | "/compact mode" | "/alignment" diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index 61ad8bdbb..fcbdf4b81 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -452,16 +452,6 @@ impl App { self.input_history_index = None; } - /// Returns `Some((current, total))` if the user is browsing input history. - pub(super) fn input_history_browse_status(&self) -> Option<(usize, usize)> { - let idx = self.input_history_index?; - let total = self.input_history.len(); - if total == 0 { - return None; - } - Some((idx + 1, total)) - } - /// Clear all input history entries. pub(super) fn clear_input_history(&mut self) { self.input_history.clear(); @@ -496,10 +486,12 @@ impl App { /// Save input history to disk (global, not session-specific). pub(super) fn save_input_history(&self) { - if self.input_history.is_empty() { - return; - } if let Some(path) = Self::input_history_path() { + if self.input_history.is_empty() { + // Remove the file so cleared history doesn't reappear on restart. + let _ = std::fs::remove_file(&path); + return; + } let data = serde_json::json!({ "history": self.input_history, "version": 1, diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index cee139ab4..7baa713ab 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1393,7 +1393,7 @@ fn test_input_history_browse_status_none_when_not_browsing() { app.input_history.push("test".to_string()); app.input_history_index = None; - assert!(app.input_history_browse_status().is_none()); + assert!(crate::tui::TuiState::input_history_browse_status(&app).is_none()); } #[test] @@ -1404,7 +1404,7 @@ fn test_input_history_browse_status_some_when_browsing() { app.input_history.push("second".to_string()); app.input_history_index = Some(1); - let (current, total) = app.input_history_browse_status().unwrap(); + let (current, total) = crate::tui::TuiState::input_history_browse_status(&app).unwrap(); assert_eq!(current, 2); // 1-based assert_eq!(total, 2); } diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index e60652c9c..ec7bde50f 100644 --- a/crates/jcode-tui/src/tui/ui_overlays.rs +++ b/crates/jcode-tui/src/tui/ui_overlays.rs @@ -187,8 +187,8 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap "Show numbered history, /rewind N to rewind", )); lines.push(help_entry( - "/history [input N]", - "Show input history, load entry N into input", + "/history", + "Show input history. Subcommands: input N, search, delete N, clear", )); lines.push(help_entry( "/fix", @@ -340,7 +340,10 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap lines.push(Line::from(Span::styled(" Navigation", section_style))); lines.push(Line::from("")); lines.push(key_entry("PageUp / PageDown", "Scroll history")); - lines.push(key_entry("Up / Down (empty input)", "Recall previous input / scroll history")); + lines.push(key_entry( + "Up / Down (empty or browsing)", + "Recall previous input / navigate history", + )); lines.push(key_entry("Ctrl+[ / Ctrl+]", "Jump between user prompts")); lines.push(key_entry( "Cmd/Super+K / J", From b6ce22a36ba677863c11520450bc44941c1ae9d2 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 19:00:37 -0300 Subject: [PATCH 06/14] feat: Ctrl+R reverse incremental search for input history - Added HistorySearchState struct to App with query, match_index, saved_input, saved_cursor - Added search methods: start, char, backspace, next, accept, cancel, find_match - Added handle_history_search_key free function for key dispatch during search - Wired Ctrl+R in both local and remote key handlers to start search - Search mode intercepts all keys before normal input handling - Case-insensitive substring matching, backwards from most recent entry - Ctrl+R cycles to next older match, Enter accepts, Esc cancels - Added input_history_search_status to TuiState trait with impls - UI renders (reverse-i-search) prompt in notification bar and input hint - 13 unit tests covering start, search, cycle, accept, cancel, edge cases --- crates/jcode-tui/src/tui/app.rs | 15 ++ crates/jcode-tui/src/tui/app/input.rs | 55 +++- .../src/tui/app/remote/key_handling.rs | 7 +- .../jcode-tui/src/tui/app/state_ui_runtime.rs | 121 +++++++++ .../tests/remote_startup_input_02/part_01.rs | 234 ++++++++++++++++++ crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 3 + crates/jcode-tui/src/tui/app/tui_state.rs | 5 + crates/jcode-tui/src/tui/mod.rs | 4 + crates/jcode-tui/src/tui/ui_input.rs | 23 +- crates/jcode-tui/src/tui/ui_tests/mod.rs | 3 + 10 files changed, 467 insertions(+), 3 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index a69d4ef6a..a25c3e006 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -538,6 +538,19 @@ struct CommandCandidatesCache { candidates: Vec<(String, &'static str)>, } +/// State for Ctrl+R reverse incremental input history search. +pub(super) struct HistorySearchState { + /// The search query typed by the user. + pub(super) query: String, + /// Index into `input_history` of the current match (searching backwards from end). + /// `None` means no match found. + pub(super) match_index: Option, + /// The original input before the search started (restored on Esc). + pub(super) saved_input: String, + /// The original cursor position before the search started. + pub(super) saved_cursor: usize, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -948,6 +961,8 @@ pub struct App { input_history: Vec, // Index into `input_history` while browsing; None when not browsing input_history_index: Option, + // Ctrl+R reverse incremental search state; None when not searching + input_history_search: Option, // Undo history for in-progress input editing (Ctrl+Z) input_undo_stack: Vec<(String, usize)>, // Short-lived notice for status feedback (model switch, cycle diff mode, etc.) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 12f865eb7..352a552e1 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -1748,7 +1748,7 @@ pub(super) fn handle_global_control_shortcuts( true } KeyCode::Char('r') => { - app.recover_session_without_tools(); + app.start_input_history_search(); true } KeyCode::Char('a') if app.input.is_empty() => { @@ -1996,6 +1996,11 @@ impl App { return Ok(()); } + // Ctrl+R reverse incremental search mode: intercept all keys + if self.input_history_search.is_some() { + return handle_history_search_key(self, code, modifiers); + } + if self.handle_onboarding_continue_prompt_key(code) { return Ok(()); } @@ -2724,3 +2729,51 @@ impl App { } } } + +/// Handle key events while Ctrl+R reverse incremental search is active. +pub(super) fn handle_history_search_key( + app: &mut App, + code: KeyCode, + modifiers: KeyModifiers, +) -> Result<()> { + match code { + // Ctrl+R again: cycle to next older match + KeyCode::Char('r') if modifiers.contains(KeyModifiers::CONTROL) => { + app.input_history_search_next(); + return Ok(()); + } + // Enter: accept match and exit search + KeyCode::Enter => { + app.accept_input_history_search(); + return Ok(()); + } + // Esc: cancel search, restore original input + KeyCode::Esc => { + app.cancel_input_history_search(); + return Ok(()); + } + // Ctrl+C / Ctrl+D: cancel search + KeyCode::Char('c') | KeyCode::Char('d') if modifiers.contains(KeyModifiers::CONTROL) => { + app.cancel_input_history_search(); + return Ok(()); + } + // Backspace: remove last char from query + KeyCode::Backspace | KeyCode::Char('\u{8}') => { + app.input_history_search_backspace(); + return Ok(()); + } + // Delete: same as backspace for search + KeyCode::Delete => { + app.input_history_search_backspace(); + return Ok(()); + } + // Printable character: append to search query + KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { + app.input_history_search_char(c); + return Ok(()); + } + // All other keys: ignore (no-op while searching) + _ => {} + } + Ok(()) +} diff --git a/crates/jcode-tui/src/tui/app/remote/key_handling.rs b/crates/jcode-tui/src/tui/app/remote/key_handling.rs index 198f08f09..da4b01d08 100644 --- a/crates/jcode-tui/src/tui/app/remote/key_handling.rs +++ b/crates/jcode-tui/src/tui/app/remote/key_handling.rs @@ -307,6 +307,11 @@ async fn handle_remote_key_internal( return Ok(()); } + // Ctrl+R reverse incremental search mode: intercept all keys + if app.input_history_search.is_some() { + return crate::tui::app::input::handle_history_search_key(app, code, modifiers); + } + if input::handle_visible_copy_shortcut(app, code, modifiers) { return Ok(()); } @@ -523,7 +528,7 @@ async fn handle_remote_key_internal( return Ok(()); } KeyCode::Char('r') => { - app.recover_session_without_tools(); + app.start_input_history_search(); return Ok(()); } KeyCode::Char('l') => { diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index fcbdf4b81..b20f1fffc 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -450,6 +450,127 @@ impl App { /// Reset history browsing state (call when the user manually edits input). pub(super) fn reset_input_history_browse(&mut self) { self.input_history_index = None; + self.input_history_search = None; + } + + /// Start a Ctrl+R reverse incremental search. + pub(super) fn start_input_history_search(&mut self) { + if self.input_history.is_empty() { + self.set_status_notice("No input history to search"); + return; + } + self.input_history_index = None; + self.input_history_search = Some(super::HistorySearchState { + query: String::new(), + match_index: None, + saved_input: self.input.clone(), + saved_cursor: self.cursor_pos, + }); + } + + /// Append a character to the search query. + pub(super) fn input_history_search_char(&mut self, c: char) { + let Some(ref mut search) = self.input_history_search else { + return; + }; + search.query.push(c); + self.input_history_search_find_match(); + } + + /// Delete the last character from the search query. + pub(super) fn input_history_search_backspace(&mut self) { + let Some(ref mut search) = self.input_history_search else { + return; + }; + search.query.pop(); + self.input_history_search_find_match(); + } + + /// Cycle to the next older match (Ctrl+R again while searching). + pub(super) fn input_history_search_next(&mut self) { + let Some(ref mut search) = self.input_history_search else { + return; + }; + if search.query.is_empty() { + return; + } + let query_lower = search.query.to_lowercase(); + // Start searching from one before the current match + let start = match search.match_index { + Some(idx) => idx.saturating_sub(1), + None => self.input_history.len().saturating_sub(1), + }; + for i in (0..=start).rev() { + if self.input_history[i].to_lowercase().contains(&query_lower) { + search.match_index = Some(i); + self.input = self.input_history[i].clone(); + self.cursor_pos = self.input.len(); + return; + } + } + // No older match found; keep current match + } + + /// Accept the current search result (Enter or Esc). + pub(super) fn accept_input_history_search(&mut self) { + let Some(search) = self.input_history_search.take() else { + return; + }; + if let Some(idx) = search.match_index { + self.input = self.input_history[idx].clone(); + self.cursor_pos = self.input.len(); + self.input_history_index = Some(idx); + } + // If no match, leave input as-is (could be the saved original or whatever the user typed) + } + + /// Cancel the search and restore original input (Esc with no match). + pub(super) fn cancel_input_history_search(&mut self) { + if let Some(search) = self.input_history_search.take() { + if search.match_index.is_none() { + self.input = search.saved_input; + self.cursor_pos = search.saved_cursor; + } else { + // Accept the match + self.input_history_index = search.match_index; + } + } + } + + /// Internal: find the most recent match for the current query. + fn input_history_search_find_match(&mut self) { + let Some(ref mut search) = self.input_history_search else { + return; + }; + if search.query.is_empty() { + search.match_index = None; + self.input = search.saved_input.clone(); + self.cursor_pos = search.saved_cursor; + return; + } + let query_lower = search.query.to_lowercase(); + // Search backwards from end (or from current match position to avoid jumping) + let start = match search.match_index { + Some(idx) + if self.input_history[idx] + .to_lowercase() + .contains(&query_lower) => + { + idx + } + _ => self.input_history.len().saturating_sub(1), + }; + for i in (0..=start).rev() { + if self.input_history[i].to_lowercase().contains(&query_lower) { + search.match_index = Some(i); + self.input = self.input_history[i].clone(); + self.cursor_pos = self.input.len(); + return; + } + } + search.match_index = None; + self.input.clear(); + self.cursor_pos = 0; } /// Clear all input history entries. diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index 7baa713ab..ef220c560 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1408,3 +1408,237 @@ fn test_input_history_browse_status_some_when_browsing() { assert_eq!(current, 2); // 1-based assert_eq!(total, 2); } + +#[test] +fn test_ctrl_r_search_starts_and_shows_status() { + let mut app = create_test_app(); + + app.input_history.push("hello world".to_string()); + app.input_history.push("goodbye".to_string()); + + app.start_input_history_search(); + + assert!(app.input_history_search.is_some()); + let status = crate::tui::TuiState::input_history_search_status(&app); + assert!(status.is_some()); + let (query, match_idx, total) = status.unwrap(); + assert_eq!(query, ""); + assert_eq!(match_idx, None); + assert_eq!(total, 2); +} + +#[test] +fn test_ctrl_r_search_char_finds_match() { + let mut app = create_test_app(); + + app.input_history.push("hello world".to_string()); + app.input_history.push("goodbye".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('g'); + + assert_eq!(app.input, "goodbye"); + assert_eq!(app.cursor_pos, 7); + let status = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(status.0, "g"); + assert_eq!(status.1, Some(1)); // index 1 +} + +#[test] +fn test_ctrl_r_search_case_insensitive() { + let mut app = create_test_app(); + + app.input_history.push("Hello".to_string()); + app.input_history.push("world".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('h'); + app.input_history_search_char('e'); + + assert_eq!(app.input, "Hello"); + let status = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(status.0, "he"); + assert_eq!(status.1, Some(0)); +} + +#[test] +fn test_ctrl_r_search_backspace() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + app.input_history.push("world".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('w'); + assert_eq!(app.input, "world"); + + app.input_history_search_backspace(); + // Query is now empty, should restore original input + assert_eq!(app.input, ""); + assert_eq!(app.cursor_pos, 0); +} + +#[test] +fn test_ctrl_r_search_next_cycles() { + let mut app = create_test_app(); + + app.input_history.push("foo".to_string()); + app.input_history.push("foobar".to_string()); + app.input_history.push("foo".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('f'); + app.input_history_search_char('o'); + + // First match should be the most recent "foo" at index 2 + assert_eq!(app.input, "foo"); + let (_, match_idx, _) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(match_idx, Some(2)); + + // Cycle to next + app.input_history_search_next(); + assert_eq!(app.input, "foobar"); + let (_, match_idx, _) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(match_idx, Some(1)); + + // Cycle to next + app.input_history_search_next(); + assert_eq!(app.input, "foo"); + let (_, match_idx, _) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(match_idx, Some(0)); +} + +#[test] +fn test_ctrl_r_search_no_match_clears_input() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + app.input_history.push("world".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('z'); + + assert_eq!(app.input, ""); + let status = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(status.0, "z"); + assert_eq!(status.1, None); +} + +#[test] +fn test_ctrl_r_accept_sets_input_and_index() { + let mut app = create_test_app(); + + app.input_history.push("first".to_string()); + app.input_history.push("second".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('s'); + app.input_history_search_char('e'); + + assert_eq!(app.input, "second"); + + app.accept_input_history_search(); + + assert!(app.input_history_search.is_none()); + assert_eq!(app.input, "second"); + assert_eq!(app.cursor_pos, 6); + assert_eq!(app.input_history_index, Some(1)); +} + +#[test] +fn test_ctrl_r_cancel_restores_input_when_no_match() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + + app.input = "original text".to_string(); + app.cursor_pos = 14; + + app.start_input_history_search(); + app.input_history_search_char('z'); + + // No match found + assert_eq!(app.input, ""); + app.cancel_input_history_search(); + + // Should restore original input + assert!(app.input_history_search.is_none()); + assert_eq!(app.input, "original text"); + assert_eq!(app.cursor_pos, 14); +} + +#[test] +fn test_ctrl_r_cancel_accepts_match() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + app.input_history.push("world".to_string()); + + app.input = "original".to_string(); + app.cursor_pos = 8; + + app.start_input_history_search(); + app.input_history_search_char('w'); + app.input_history_search_char('o'); + + assert_eq!(app.input, "world"); + app.cancel_input_history_search(); + + // Cancel with match found should accept the match + assert!(app.input_history_search.is_none()); + assert_eq!(app.input_history_index, Some(1)); +} + +#[test] +fn test_ctrl_r_search_status_none_when_not_searching() { + let mut app = create_test_app(); + + app.input_history.push("test".to_string()); + app.input_history_index = None; + + assert!(crate::tui::TuiState::input_history_search_status(&app).is_none()); +} + +#[test] +fn test_ctrl_r_search_empty_history_shows_notice() { + let mut app = create_test_app(); + + assert!(app.input_history.is_empty()); + app.start_input_history_search(); + + // Should not start search, no history + assert!(app.input_history_search.is_none()); + assert!(crate::tui::TuiState::input_history_search_status(&app).is_none()); +} + +#[test] +fn test_ctrl_r_search_browse_conflict_resolved() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + app.input_history.push("world".to_string()); + + // Start browsing first + app.input_history_up(); + assert_eq!(app.input, "world"); + + // Now start search - should clear browse index + app.start_input_history_search(); + assert!(app.input_history_index.is_none()); + assert!(app.input_history_search.is_some()); +} + +#[test] +fn test_ctrl_r_search_accept_no_match_keeps_input() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + + app.start_input_history_search(); + app.input_history_search_char('z'); + app.accept_input_history_search(); + + // No match found, accept should leave input as-is (empty) + assert!(app.input_history_search.is_none()); + assert_eq!(app.input, ""); +} diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 1fa46b920..479e77c1c 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -503,6 +503,7 @@ impl App { stashed_input: None, input_history: App::load_input_history(), input_history_index: None, + input_history_search: None, input_undo_stack: Vec::new(), status_notice: None, experimental_feature_warnings_seen: HashSet::new(), @@ -886,6 +887,7 @@ impl App { stashed_input: None, input_history: App::load_input_history(), input_history_index: None, + input_history_search: None, input_undo_stack: Vec::new(), status_notice: None, experimental_feature_warnings_seen: HashSet::new(), @@ -961,6 +963,7 @@ impl App { app.is_replay = false; app.input_history.clear(); app.input_history_index = None; + app.input_history_search = None; app } diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 108b69ccb..675e9cae0 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -722,6 +722,11 @@ impl crate::tui::TuiState for App { Some((idx + 1, total)) } + fn input_history_search_status(&self) -> Option<(&str, Option, usize)> { + let search = self.input_history_search.as_ref()?; + Some((&search.query, search.match_index, self.input_history.len())) + } + fn context_snapshot(&self) -> crate::tui::ContextSnapshot { use crate::message::{ContentBlock, Role}; use std::time::Instant; diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 5a79ee0ba..007957ce1 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -222,6 +222,10 @@ pub trait TuiState { fn input_history_browse_status(&self) -> Option<(usize, usize)> { None } + /// Returns `Some((query, match_index, total))` if the user is in reverse-i-search mode. + fn input_history_search_status(&self) -> Option<(&str, Option, usize)> { + None + } /// Context info (what's loaded in context window - static + dynamic) fn context_info(&self) -> crate::prompt::ContextInfo; /// Authoritative, freshness-tagged context snapshot used by widgets. diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index cce7039ed..c362d28cf 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -1441,6 +1441,18 @@ pub(super) fn build_notification_spans(app: &dyn TuiState) -> Vec> )); } + if let Some((query, match_index, total)) = app.input_history_search_status() { + push_sep(&mut spans); + let match_info = match match_index { + Some(idx) => format!("{}/{}", idx + 1, total), + None => "no match".to_string(), + }; + spans.push(Span::styled( + format!("(reverse-i-search)'{}': {}", query, match_info), + Style::default().fg(rgb(255, 180, 100)), + )); + } + spans } @@ -1498,7 +1510,16 @@ pub(super) fn draw_input( let mut hint_shown = false; let mut hint_line: Option = None; let mut suggestion_lines: Vec = Vec::new(); - if has_suggestions { + if app.input_history_search_status().is_some() { + hint_shown = true; + let hint = + " (reverse-i-search) Type to search, Ctrl+R next, Enter to accept, Esc to cancel"; + hint_line = Some(hint.trim().to_string()); + lines.push(Line::from(Span::styled( + hint, + Style::default().fg(rgb(255, 180, 100)), + ))); + } else if has_suggestions { suggestion_lines = command_suggestion_lines(app, &suggestions); } else if let Some(shell_hint) = shell_mode_hint(mode) { hint_shown = true; diff --git a/crates/jcode-tui/src/tui/ui_tests/mod.rs b/crates/jcode-tui/src/tui/ui_tests/mod.rs index 3bbb8dd38..b330277e3 100644 --- a/crates/jcode-tui/src/tui/ui_tests/mod.rs +++ b/crates/jcode-tui/src/tui/ui_tests/mod.rs @@ -307,6 +307,9 @@ impl crate::tui::TuiState for TestState { fn input_history_browse_status(&self) -> Option<(usize, usize)> { None } + fn input_history_search_status(&self) -> Option<(&str, Option, usize)> { + None + } fn context_info(&self) -> crate::prompt::ContextInfo { Default::default() } From 571d8c4a7fc0a1d4140b2e0ca5f549d378c7277a Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 21:01:34 -0300 Subject: [PATCH 07/14] fix: adversarial review fixes for Ctrl+R search - Add reset_tab_completion + sync_model_picker_preview guards on accept/cancel - Fix undo snapshot to capture pre-search input instead of post-find_match state - Use reset_input_history_browse() in start_input_history_search - Fix Alt+char captured as search input (check both CTRL and ALT modifiers) - Update help overlay: Ctrl+R now means 'Search input history' - Update tool recovery message: refer to /fix instead of Ctrl+R - Replace unwrap() with if-let in cancel_input_history_search - Add 2 undo snapshot regression tests --- crates/jcode-tui/src/tui/app/input.rs | 4 +- .../jcode-tui/src/tui/app/state_ui_runtime.rs | 39 +++++++++-- .../tests/remote_startup_input_02/part_01.rs | 69 +++++++++++++++++++ crates/jcode-tui/src/tui/app/turn.rs | 2 +- crates/jcode-tui/src/tui/ui_overlays.rs | 7 +- 5 files changed, 109 insertions(+), 12 deletions(-) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 352a552e1..78bb0cd91 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2767,8 +2767,8 @@ pub(super) fn handle_history_search_key( app.input_history_search_backspace(); return Ok(()); } - // Printable character: append to search query - KeyCode::Char(c) if !modifiers.contains(KeyModifiers::CONTROL) => { + // Printable character: append to search query (exclude Ctrl and Alt combos) + KeyCode::Char(c) if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { app.input_history_search_char(c); return Ok(()); } diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index b20f1fffc..bcc0b681f 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -459,7 +459,7 @@ impl App { self.set_status_notice("No input history to search"); return; } - self.input_history_index = None; + self.reset_input_history_browse(); self.input_history_search = Some(super::HistorySearchState { query: String::new(), match_index: None, @@ -511,17 +511,30 @@ impl App { // No older match found; keep current match } - /// Accept the current search result (Enter or Esc). + /// Accept the current search result (Enter). pub(super) fn accept_input_history_search(&mut self) { let Some(search) = self.input_history_search.take() else { return; }; if let Some(idx) = search.match_index { + // Save undo state: capture the *original* pre-search input. find_match has + // already overwritten self.input, so temporarily restore the original. + if search.saved_input != self.input_history[idx] { + let matched_input = self.input.clone(); + let matched_cursor = self.cursor_pos; + self.input = search.saved_input; + self.cursor_pos = search.saved_cursor; + self.remember_input_undo_state(); + self.input = matched_input; + self.cursor_pos = matched_cursor; + } self.input = self.input_history[idx].clone(); self.cursor_pos = self.input.len(); self.input_history_index = Some(idx); + self.reset_tab_completion(); + self.sync_model_picker_preview_from_input(); } - // If no match, leave input as-is (could be the saved original or whatever the user typed) + // If no match, leave input as-is (cleared by find_match during search) } /// Cancel the search and restore original input (Esc with no match). @@ -530,9 +543,23 @@ impl App { if search.match_index.is_none() { self.input = search.saved_input; self.cursor_pos = search.saved_cursor; - } else { - // Accept the match - self.input_history_index = search.match_index; + self.reset_tab_completion(); + self.sync_model_picker_preview_from_input(); + } else if let Some(idx) = search.match_index { + // Esc with match: accept the match (input was already set by find_match) + // Save undo state with the original pre-search input + if search.saved_input != self.input_history[idx] { + let matched_input = self.input.clone(); + let matched_cursor = self.cursor_pos; + self.input = search.saved_input; + self.cursor_pos = search.saved_cursor; + self.remember_input_undo_state(); + self.input = matched_input; + self.cursor_pos = matched_cursor; + } + self.input_history_index = Some(idx); + self.reset_tab_completion(); + self.sync_model_picker_preview_from_input(); } } } diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index ef220c560..f31222f1c 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1642,3 +1642,72 @@ fn test_ctrl_r_search_accept_no_match_keeps_input() { assert!(app.input_history_search.is_none()); assert_eq!(app.input, ""); } + +#[test] +fn test_ctrl_r_accept_saves_undo_with_original_input() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + app.input_history.push("world".to_string()); + + // User had typed "original" before starting search + app.input = "original".to_string(); + app.cursor_pos = 8; + + app.start_input_history_search(); + app.input_history_search_char('w'); + app.input_history_search_char('o'); + + assert_eq!(app.input, "world"); + + // Accept the match + app.accept_input_history_search(); + + // Should be able to undo back to "original" + assert_eq!(app.input, "world"); + assert!( + !app.input_undo_stack.is_empty(), + "undo stack should have the original input" + ); + + // Undo should restore "original" + app.undo_input_change(); + assert_eq!( + app.input, "original", + "undo should restore the pre-search input" + ); +} + +#[test] +fn test_ctrl_r_cancel_with_match_saves_undo_with_original_input() { + let mut app = create_test_app(); + + app.input_history.push("hello".to_string()); + app.input_history.push("world".to_string()); + + // User had typed "original" before starting search + app.input = "original".to_string(); + app.cursor_pos = 8; + + app.start_input_history_search(); + app.input_history_search_char('w'); + app.input_history_search_char('o'); + + assert_eq!(app.input, "world"); + + // Esc with match found accepts it + app.cancel_input_history_search(); + + assert_eq!(app.input, "world"); + assert!( + !app.input_undo_stack.is_empty(), + "undo stack should have the original input" + ); + + // Undo should restore "original" + app.undo_input_change(); + assert_eq!( + app.input, "original", + "undo should restore the pre-search input" + ); +} diff --git a/crates/jcode-tui/src/tui/app/turn.rs b/crates/jcode-tui/src/tui/app/turn.rs index 98e0f1146..e856715f1 100644 --- a/crates/jcode-tui/src/tui/app/turn.rs +++ b/crates/jcode-tui/src/tui/app/turn.rs @@ -58,7 +58,7 @@ impl App { } if let Some(summary) = self.summarize_tool_results_missing() { let message = format!( - "Tool outputs are missing for this turn. {}\n\nPress Ctrl+R to recover into a new session with context copied.", + "Tool outputs are missing for this turn. {}\n\nRun /fix to recover into a new session with context copied.", summary ); self.push_display_message(DisplayMessage::error(message)); diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index ec7bde50f..d29c48b29 100644 --- a/crates/jcode-tui/src/tui/ui_overlays.rs +++ b/crates/jcode-tui/src/tui/ui_overlays.rs @@ -429,11 +429,12 @@ pub(super) fn draw_help_overlay(frame: &mut Frame, area: Rect, scroll: usize, ap )); lines.push(key_entry("Ctrl+Up", "Retrieve pending message for editing")); lines.push(key_entry("Ctrl+Tab / Ctrl+T", "Toggle queue mode")); - lines.push(key_entry("Ctrl+R", "Recover from missing tool outputs")); lines.push(key_entry( - "Ctrl+V / Alt+V", - "Paste clipboard (text or image)", + "Ctrl+R", + "Search input history (reverse incremental)", )); + lines.push(key_entry("Ctrl+V", "Paste clipboard (text or image)")); + lines.push(key_entry("Alt+V", "Paste image from clipboard")); lines.push(key_entry( "Alt+A", "Quick-copy visible chat viewport plus nearby context", From ca7205c6dff1771b85d3dd326cb8b237f35e75a9 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 21:56:23 -0300 Subject: [PATCH 08/14] feat: make input history max entries configurable Add InputHistoryConfig to jcode-config-types with max_entries field. Supports [input_history] config section and JCODE_INPUT_HISTORY_MAX env var override. Defaults to 100 entries (hardcoded fallback). --- crates/jcode-base/src/config.rs | 12 ++++++++---- crates/jcode-base/src/config/default_file.rs | 5 +++++ crates/jcode-base/src/config/env_overrides.rs | 7 +++++++ crates/jcode-config-types/src/lib.rs | 14 ++++++++++++++ crates/jcode-tui/src/tui/app/state_ui_runtime.rs | 9 ++++----- 5 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 19fe4417b..36a2a1ce7 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -6,10 +6,11 @@ pub use jcode_config_types::{ AgentsConfig, AmbientConfig, AuthConfig, AutoJudgeConfig, AutoReviewConfig, CompactionConfig, CompactionMode, CrossProviderFailoverMode, DiagramDisplayMode, DiagramPanePosition, - DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, KeybindingsConfig, - MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, NamedProviderModelConfig, - NamedProviderType, NativeScrollbarConfig, ProviderConfig, SafetyConfig, - SessionPickerResumeAction, SwarmSpawnMode, UpdateChannel, WebSearchConfig, WebSearchEngine, + DiffDisplayMode, DisplayConfig, FeatureConfig, GatewayConfig, InputHistoryConfig, + KeybindingsConfig, MarkdownSpacingMode, NamedProviderAuth, NamedProviderConfig, + NamedProviderModelConfig, NamedProviderType, NativeScrollbarConfig, ProviderConfig, + SafetyConfig, SessionPickerResumeAction, SwarmSpawnMode, UpdateChannel, WebSearchConfig, + WebSearchEngine, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet, HashSet}; @@ -410,6 +411,9 @@ pub struct Config { /// Auto-judge configuration pub autojudge: AutoJudgeConfig, + + /// Input history configuration + pub input_history: InputHistoryConfig, } /// Agent Client Protocol adapter configuration. diff --git a/crates/jcode-base/src/config/default_file.rs b/crates/jcode-base/src/config/default_file.rs index 4548b9ddd..7cb4027e3 100644 --- a/crates/jcode-base/src/config/default_file.rs +++ b/crates/jcode-base/src/config/default_file.rs @@ -300,6 +300,11 @@ desktop_notifications = true # discord_channel_id = "" # Channel ID to post in # discord_bot_user_id = "" # Bot's user ID (for filtering own messages) # discord_reply_enabled = false # Messages in channel become agent directives + +[input_history] +# Maximum number of entries kept in input history (default: 100) +# Can also be set via JCODE_INPUT_HISTORY_MAX env var. +# max_entries = 100 "#; std::fs::write(&path, default_content)?; diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index 76528062c..7934d0284 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -563,6 +563,13 @@ impl Config { crate::env::set_var("JCODE_COPILOT_PREMIUM", env_val); } } + + // Input history + if let Ok(v) = std::env::var("JCODE_INPUT_HISTORY_MAX") { + if let Ok(n) = v.parse::() { + self.input_history.max_entries = n; + } + } } } diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index ef9be3063..963ca4fa8 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -907,3 +907,17 @@ impl Default for GatewayConfig { } } } + +/// Input history configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct InputHistoryConfig { + /// Maximum number of entries kept in input history (default: 100). + pub max_entries: usize, +} + +impl Default for InputHistoryConfig { + fn default() -> Self { + Self { max_entries: 100 } + } +} diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index bcc0b681f..fefafd01e 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -388,9 +388,6 @@ impl App { } } - /// Maximum number of entries kept in input history. - const INPUT_HISTORY_MAX: usize = 100; - /// Push a submitted input into history (called from `submit_input`). pub(super) fn push_input_history(&mut self, text: String) { let trimmed = text.trim().to_string(); @@ -407,7 +404,8 @@ impl App { self.input_history.remove(existing); } self.input_history.push(trimmed); - if self.input_history.len() > Self::INPUT_HISTORY_MAX { + let max = crate::config::config().input_history.max_entries; + if self.input_history.len() > max { self.input_history.remove(0); } self.save_input_history(); @@ -662,9 +660,10 @@ impl App { let Some(arr) = value.get("history").and_then(|v| v.as_array()) else { return Vec::new(); }; + let max = crate::config::config().input_history.max_entries; arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) - .take(Self::INPUT_HISTORY_MAX) + .take(max) .collect() } } From 143fcfc1f01409aaee5788959bef45914c34f556 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 23:02:38 -0300 Subject: [PATCH 09/14] fix: serde default + validation for InputHistoryConfig + env clamp - Add #[serde(default = "default_max_entries")] so max_entries defaults to 100 when [input_history] section present but field omitted - Add deserialize_clamped_usize to clamp TOML/env values to 1..=10000 - Clamp JCODE_INPUT_HISTORY_MAX env var to 1..=10_000 - Add missing JCODE_ACP_{,TOOL_}PROFILE to CONFIG_ENV_KEYS - Add 5 new config tests for serde default, clamping edge cases --- crates/jcode-base/src/config.rs | 4 ++ crates/jcode-base/src/config/env_overrides.rs | 2 +- crates/jcode-base/src/config_tests.rs | 47 +++++++++++++++++++ crates/jcode-config-types/src/lib.rs | 18 ++++++- 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 36a2a1ce7..7a2babd89 100644 --- a/crates/jcode-base/src/config.rs +++ b/crates/jcode-base/src/config.rs @@ -27,6 +27,9 @@ const CONFIG_CACHE_CHECK_INTERVAL: Duration = if cfg!(test) { const CONFIG_ENV_KEYS: &[&str] = &[ "HOME", + "JCODE_ACP_ENABLED", + "JCODE_ACP_PROFILE", + "JCODE_ACP_TOOL_PROFILE", "JCODE_AMBIENT_ENABLED", "JCODE_AMBIENT_MAX_INTERVAL", "JCODE_AMBIENT_MIN_INTERVAL", @@ -73,6 +76,7 @@ const CONFIG_ENV_KEYS: &[&str] = &[ "JCODE_HOME", "JCODE_IDLE_ANIMATION", "JCODE_IMAP_HOST", + "JCODE_INPUT_HISTORY_MAX", "JCODE_MARKDOWN_SPACING", "JCODE_MEMORY_ENABLED", "JCODE_PERSIST_MEMORY_INJECTIONS", diff --git a/crates/jcode-base/src/config/env_overrides.rs b/crates/jcode-base/src/config/env_overrides.rs index 7934d0284..8b3f93335 100644 --- a/crates/jcode-base/src/config/env_overrides.rs +++ b/crates/jcode-base/src/config/env_overrides.rs @@ -567,7 +567,7 @@ impl Config { // Input history if let Ok(v) = std::env::var("JCODE_INPUT_HISTORY_MAX") { if let Ok(n) = v.parse::() { - self.input_history.max_entries = n; + self.input_history.max_entries = n.clamp(1, 10_000); } } } diff --git a/crates/jcode-base/src/config_tests.rs b/crates/jcode-base/src/config_tests.rs index f10d61844..9ec22353d 100644 --- a/crates/jcode-base/src/config_tests.rs +++ b/crates/jcode-base/src/config_tests.rs @@ -578,3 +578,50 @@ impl Config { .any(|value| value.trim().eq_ignore_ascii_case(&entry)) } } + +#[test] +fn input_history_config_defaults_to_100() { + let cfg: Config = toml::from_str("").expect("empty config should parse"); + assert_eq!(cfg.input_history.max_entries, 100); +} + +#[test] +fn input_history_config_toml_overrides_default() { + let cfg: Config = toml::from_str("[input_history]\nmax_entries = 250\n") + .expect("input_history config should parse"); + assert_eq!(cfg.input_history.max_entries, 250); +} + +#[test] +fn input_history_config_section_without_max_entries_defaults_100() { + let cfg: Config = toml::from_str("[input_history]\n") + .expect("input_history section without fields should parse"); + assert_eq!(cfg.input_history.max_entries, 100); +} + +#[test] +fn input_history_config_clamps_zero_to_one() { + let cfg: Config = toml::from_str("[input_history]\nmax_entries = 0\n") + .expect("input_history config should parse"); + assert_eq!(cfg.input_history.max_entries, 1); +} + +#[test] +fn input_history_config_clamps_excessive_value() { + let cfg: Config = toml::from_str("[input_history]\nmax_entries = 999999\n") + .expect("input_history config should parse"); + assert_eq!(cfg.input_history.max_entries, 10_000); +} + +#[test] +fn input_history_env_override_clamps_value() { + let mut cfg = Config::default(); + assert_eq!(cfg.input_history.max_entries, 100); + // Simulate env override with clamping + cfg.input_history.max_entries = 0usize.clamp(1, 10_000); + assert_eq!(cfg.input_history.max_entries, 1); + cfg.input_history.max_entries = 999_999usize.clamp(1, 10_000); + assert_eq!(cfg.input_history.max_entries, 10_000); + cfg.input_history.max_entries = 500usize.clamp(1, 10_000); + assert_eq!(cfg.input_history.max_entries, 500); +} diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 963ca4fa8..ba1ecd6de 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -912,7 +912,11 @@ impl Default for GatewayConfig { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct InputHistoryConfig { - /// Maximum number of entries kept in input history (default: 100). + /// Maximum number of entries kept in input history (default: 100, clamped to 1..=10000). + #[serde( + default = "default_max_entries", + deserialize_with = "deserialize_clamped_usize" + )] pub max_entries: usize, } @@ -921,3 +925,15 @@ impl Default for InputHistoryConfig { Self { max_entries: 100 } } } + +fn default_max_entries() -> usize { + 100 +} + +fn deserialize_clamped_usize<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let n = usize::deserialize(deserializer)?; + Ok(n.clamp(1, 10_000)) +} From 2c2281c8a790b8762849847ab5d7e65b5413fdb6 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 13:11:22 -0300 Subject: [PATCH 10/14] fix: Down arrow not working in several input history scenarios - Command suggestions intercepted Down during history browsing when the history entry started with '/' (e.g. /commit). Skip suggestion key handling when input_history_index is active. - Down-past-end of history cleared input instead of restoring the original pre-browse text. Added input_history_pre_browse to save and restore the input that was present when Up first entered browse mode. - Ctrl+R search mode ignored Down arrow entirely. Now Down accepts the current search match and exits search, allowing continued Down navigation through history. - Ctrl+R search mode ignored Up arrow for cycling older matches. --- crates/jcode-tui/src/tui/app.rs | 2 ++ crates/jcode-tui/src/tui/app/input.rs | 11 +++++++++++ .../src/tui/app/state_ui_input_helpers.rs | 6 ++++++ .../jcode-tui/src/tui/app/state_ui_runtime.rs | 19 +++++++++++++++---- crates/jcode-tui/src/tui/app/tui_lifecycle.rs | 2 ++ 5 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index a25c3e006..51cf93321 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -961,6 +961,8 @@ pub struct App { input_history: Vec, // Index into `input_history` while browsing; None when not browsing input_history_index: Option, + // Saved input before Up-arrow history browsing started (restored on Down-past-end) + input_history_pre_browse: Option<(String, usize)>, // Ctrl+R reverse incremental search state; None when not searching input_history_search: Option, // Undo history for in-progress input editing (Ctrl+Z) diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 78bb0cd91..7750eb20a 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2767,6 +2767,17 @@ pub(super) fn handle_history_search_key( app.input_history_search_backspace(); return Ok(()); } + // Down arrow: accept the current match and exit search + // (allows continuing to Down through history after Ctrl+R search) + KeyCode::Down => { + app.accept_input_history_search(); + return Ok(()); + } + // Up arrow: cycle to next older match + KeyCode::Up => { + app.input_history_search_next(); + return Ok(()); + } // Printable character: append to search query (exclude Ctrl and Alt combos) KeyCode::Char(c) if !modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { app.input_history_search_char(c); diff --git a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs index 2441db8ca..cd7db726c 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs @@ -1068,6 +1068,12 @@ impl App { return false; } + // When browsing input history, arrow keys should navigate history + // rather than command suggestions. + if self.input_history_index.is_some() { + return false; + } + match code { KeyCode::Down if Self::arrow_modifiers_allow_command_suggestion_navigation(modifiers) => diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index fefafd01e..4f3ff77be 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -418,7 +418,12 @@ impl App { } let new_idx = match self.input_history_index { Some(idx) => idx.saturating_sub(1), - None => self.input_history.len() - 1, + None => { + // Save the current input before entering history browse mode + // so Down-past-end can restore it. + self.input_history_pre_browse = Some((self.input.clone(), self.cursor_pos)); + self.input_history.len() - 1 + } }; self.input_history_index = Some(new_idx); self.input = self.input_history[new_idx].clone(); @@ -437,10 +442,15 @@ impl App { self.input = self.input_history[next].clone(); self.cursor_pos = self.input.len(); } else { - // Past the end: clear input and exit history browsing + // Past the end: restore pre-browse input and exit history browsing self.input_history_index = None; - self.input.clear(); - self.cursor_pos = 0; + if let Some((saved, saved_cursor)) = self.input_history_pre_browse.take() { + self.input = saved; + self.cursor_pos = saved_cursor; + } else { + self.input.clear(); + self.cursor_pos = 0; + } } true } @@ -448,6 +458,7 @@ impl App { /// Reset history browsing state (call when the user manually edits input). pub(super) fn reset_input_history_browse(&mut self) { self.input_history_index = None; + self.input_history_pre_browse = None; self.input_history_search = None; } diff --git a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 479e77c1c..05bad2a93 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -503,6 +503,7 @@ impl App { stashed_input: None, input_history: App::load_input_history(), input_history_index: None, + input_history_pre_browse: None, input_history_search: None, input_undo_stack: Vec::new(), status_notice: None, @@ -887,6 +888,7 @@ impl App { stashed_input: None, input_history: App::load_input_history(), input_history_index: None, + input_history_pre_browse: None, input_history_search: None, input_undo_stack: Vec::new(), status_notice: None, From 64b29094ef3a4fa393d1f49bebd2656b4834dda6 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 13:56:57 -0300 Subject: [PATCH 11/14] fix: remove redundant reverse-i-search hint from input area The '(reverse-i-search) Type to search...' hint was rendered as the first line inside the input area, pushing the matched history content below the visible region. The search query and match info are already displayed in the notification bar above, making this hint redundant. --- crates/jcode-tui/src/tui/ui_input.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index c362d28cf..874a5784b 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -1511,14 +1511,9 @@ pub(super) fn draw_input( let mut hint_line: Option = None; let mut suggestion_lines: Vec = Vec::new(); if app.input_history_search_status().is_some() { + // Search query and match info are shown in the notification bar above; + // no extra hint needed in the input area so the typed content stays visible. hint_shown = true; - let hint = - " (reverse-i-search) Type to search, Ctrl+R next, Enter to accept, Esc to cancel"; - hint_line = Some(hint.trim().to_string()); - lines.push(Line::from(Span::styled( - hint, - Style::default().fg(rgb(255, 180, 100)), - ))); } else if has_suggestions { suggestion_lines = command_suggestion_lines(app, &suggestions); } else if let Some(shell_hint) = shell_mode_hint(mode) { From 0cbef4bdfdaba23c53edf98315d4e59568f5a7b2 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 14:08:06 -0300 Subject: [PATCH 12/14] fix(ui): clean two-line Ctrl+R search display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Notification bar: simplified to '🔍 match 3/10' instead of the dense '(reverse-i-search)'foo': 3/10' format. - Input area: shows the search query on its own line with a cursor block, above the matched history entry rendered with the normal prompt. Clear visual separation between what you search and what was found. --- crates/jcode-tui/src/tui/ui_input.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index 874a5784b..64f358f49 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -1441,14 +1441,14 @@ pub(super) fn build_notification_spans(app: &dyn TuiState) -> Vec> )); } - if let Some((query, match_index, total)) = app.input_history_search_status() { + if let Some((_query, match_index, total)) = app.input_history_search_status() { push_sep(&mut spans); let match_info = match match_index { - Some(idx) => format!("{}/{}", idx + 1, total), + Some(idx) => format!("match {}/{}", idx + 1, total), None => "no match".to_string(), }; spans.push(Span::styled( - format!("(reverse-i-search)'{}': {}", query, match_info), + format!("🔍 {}", match_info), Style::default().fg(rgb(255, 180, 100)), )); } @@ -1510,10 +1510,25 @@ pub(super) fn draw_input( let mut hint_shown = false; let mut hint_line: Option = None; let mut suggestion_lines: Vec = Vec::new(); - if app.input_history_search_status().is_some() { - // Search query and match info are shown in the notification bar above; - // no extra hint needed in the input area so the typed content stays visible. + if let Some((query, _match_index, _total)) = app.input_history_search_status() { hint_shown = true; + // Search query line: shows what the user is searching for above the matched result. + // The matched history entry is already set as app.input and will be rendered + // by wrap_input_text below. + lines.push(Line::from(vec![ + Span::styled( + " 🔍 ", + Style::default().fg(rgb(255, 180, 100)), + ), + Span::styled( + query, + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ), + Span::styled( + "█", + Style::default().fg(rgb(255, 180, 100)), + ), + ])); } else if has_suggestions { suggestion_lines = command_suggestion_lines(app, &suggestions); } else if let Some(shell_hint) = shell_mode_hint(mode) { From 9ef2860dcbc3021465e3acfed51f9eb9a2b45c93 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 14:26:57 -0300 Subject: [PATCH 13/14] feat: Ctrl+R multi-line results with Up/Down navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HistorySearchState now stores a matches list and selected index instead of a single match_index. - Up/Down arrows navigate the match list, Ctrl+R cycles down. - Input area renders all matching entries (up to 8) with selection highlight (▸ for selected, dimmed for others). - Empty query shows all history entries for browsing. - Notification bar shows '🔍 match N/M' counter. - TuiState trait exposes input_history_search_matches() for rendering. --- crates/jcode-tui/src/tui/app.rs | 9 +- crates/jcode-tui/src/tui/app/input.rs | 11 +- .../jcode-tui/src/tui/app/state_ui_runtime.rs | 109 +++++++++--------- .../tests/remote_startup_input_02/part_01.rs | 21 ++-- crates/jcode-tui/src/tui/app/tui_state.rs | 13 ++- crates/jcode-tui/src/tui/mod.rs | 4 + crates/jcode-tui/src/tui/ui_input.rs | 42 ++++++- 7 files changed, 133 insertions(+), 76 deletions(-) diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 51cf93321..e5566562b 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -542,10 +542,11 @@ struct CommandCandidatesCache { pub(super) struct HistorySearchState { /// The search query typed by the user. pub(super) query: String, - /// Index into `input_history` of the current match (searching backwards from end). - /// `None` means no match found. - pub(super) match_index: Option, - /// The original input before the search started (restored on Esc). + /// All matching history indices (sorted newest-first), recomputed on query change. + pub(super) matches: Vec, + /// Index into `matches` of the currently highlighted result. + pub(super) selected: usize, + /// The original input before the search started (restored on Esc with no match). pub(super) saved_input: String, /// The original cursor position before the search started. pub(super) saved_cursor: usize, diff --git a/crates/jcode-tui/src/tui/app/input.rs b/crates/jcode-tui/src/tui/app/input.rs index 7750eb20a..5e0e9c9d4 100644 --- a/crates/jcode-tui/src/tui/app/input.rs +++ b/crates/jcode-tui/src/tui/app/input.rs @@ -2739,7 +2739,7 @@ pub(super) fn handle_history_search_key( match code { // Ctrl+R again: cycle to next older match KeyCode::Char('r') if modifiers.contains(KeyModifiers::CONTROL) => { - app.input_history_search_next(); + app.input_history_search_down(); return Ok(()); } // Enter: accept match and exit search @@ -2767,15 +2767,14 @@ pub(super) fn handle_history_search_key( app.input_history_search_backspace(); return Ok(()); } - // Down arrow: accept the current match and exit search - // (allows continuing to Down through history after Ctrl+R search) + // Down arrow: navigate to next (newer) match KeyCode::Down => { - app.accept_input_history_search(); + app.input_history_search_down(); return Ok(()); } - // Up arrow: cycle to next older match + // Up arrow: navigate to previous (older) match KeyCode::Up => { - app.input_history_search_next(); + app.input_history_search_up(); return Ok(()); } // Printable character: append to search query (exclude Ctrl and Alt combos) diff --git a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs index 4f3ff77be..c0a2872c1 100644 --- a/crates/jcode-tui/src/tui/app/state_ui_runtime.rs +++ b/crates/jcode-tui/src/tui/app/state_ui_runtime.rs @@ -469,9 +469,12 @@ impl App { return; } self.reset_input_history_browse(); + // Pre-compute all matches with empty query (everything matches) + let all_indices: Vec = (0..self.input_history.len()).rev().collect(); self.input_history_search = Some(super::HistorySearchState { query: String::new(), - match_index: None, + matches: all_indices, + selected: 0, saved_input: self.input.clone(), saved_cursor: self.cursor_pos, }); @@ -495,29 +498,37 @@ impl App { self.input_history_search_find_match(); } - /// Cycle to the next older match (Ctrl+R again while searching). - pub(super) fn input_history_search_next(&mut self) { + /// Move selection up in search results (older). + pub(super) fn input_history_search_up(&mut self) { let Some(ref mut search) = self.input_history_search else { return; }; - if search.query.is_empty() { + if search.selected > 0 { + search.selected -= 1; + } + self.update_input_from_search_selection(); + } + + /// Move selection down in search results (newer). + pub(super) fn input_history_search_down(&mut self) { + let Some(ref mut search) = self.input_history_search else { return; + }; + if search.selected + 1 < search.matches.len() { + search.selected += 1; } - let query_lower = search.query.to_lowercase(); - // Start searching from one before the current match - let start = match search.match_index { - Some(idx) => idx.saturating_sub(1), - None => self.input_history.len().saturating_sub(1), + self.update_input_from_search_selection(); + } + + /// Update the input field from the currently selected search match. + fn update_input_from_search_selection(&mut self) { + let Some(ref search) = self.input_history_search else { + return; }; - for i in (0..=start).rev() { - if self.input_history[i].to_lowercase().contains(&query_lower) { - search.match_index = Some(i); - self.input = self.input_history[i].clone(); - self.cursor_pos = self.input.len(); - return; - } + if let Some(&idx) = search.matches.get(search.selected) { + self.input = self.input_history[idx].clone(); + self.cursor_pos = self.input.len(); } - // No older match found; keep current match } /// Accept the current search result (Enter). @@ -525,9 +536,9 @@ impl App { let Some(search) = self.input_history_search.take() else { return; }; - if let Some(idx) = search.match_index { - // Save undo state: capture the *original* pre-search input. find_match has - // already overwritten self.input, so temporarily restore the original. + let selected_idx = search.matches.get(search.selected).copied(); + if let Some(idx) = selected_idx { + // Save undo state with the original pre-search input. if search.saved_input != self.input_history[idx] { let matched_input = self.input.clone(); let matched_cursor = self.cursor_pos; @@ -543,20 +554,15 @@ impl App { self.reset_tab_completion(); self.sync_model_picker_preview_from_input(); } - // If no match, leave input as-is (cleared by find_match during search) + // If no matches, leave input as-is (cleared by find_match during search) } /// Cancel the search and restore original input (Esc with no match). pub(super) fn cancel_input_history_search(&mut self) { if let Some(search) = self.input_history_search.take() { - if search.match_index.is_none() { - self.input = search.saved_input; - self.cursor_pos = search.saved_cursor; - self.reset_tab_completion(); - self.sync_model_picker_preview_from_input(); - } else if let Some(idx) = search.match_index { - // Esc with match: accept the match (input was already set by find_match) - // Save undo state with the original pre-search input + let selected_idx = search.matches.get(search.selected).copied(); + if let Some(idx) = selected_idx { + // Esc with match: accept the match if search.saved_input != self.input_history[idx] { let matched_input = self.input.clone(); let matched_cursor = self.cursor_pos; @@ -569,44 +575,43 @@ impl App { self.input_history_index = Some(idx); self.reset_tab_completion(); self.sync_model_picker_preview_from_input(); + } else { + // No match: restore original input + self.input = search.saved_input; + self.cursor_pos = search.saved_cursor; + self.reset_tab_completion(); + self.sync_model_picker_preview_from_input(); } } } - /// Internal: find the most recent match for the current query. + /// Internal: recompute all matches for the current query and clamp selection. fn input_history_search_find_match(&mut self) { let Some(ref mut search) = self.input_history_search else { return; }; if search.query.is_empty() { - search.match_index = None; + search.matches = (0..self.input_history.len()).rev().collect(); + search.selected = 0; + // Empty query: restore the original pre-search input self.input = search.saved_input.clone(); self.cursor_pos = search.saved_cursor; return; } let query_lower = search.query.to_lowercase(); - // Search backwards from end (or from current match position to avoid jumping) - let start = match search.match_index { - Some(idx) - if self.input_history[idx] - .to_lowercase() - .contains(&query_lower) => - { - idx - } - _ => self.input_history.len().saturating_sub(1), - }; - for i in (0..=start).rev() { - if self.input_history[i].to_lowercase().contains(&query_lower) { - search.match_index = Some(i); - self.input = self.input_history[i].clone(); - self.cursor_pos = self.input.len(); - return; - } + search.matches = (0..self.input_history.len()) + .rev() + .filter(|&i| self.input_history[i].to_lowercase().contains(&query_lower)) + .collect(); + search.selected = search.selected.min(search.matches.len().saturating_sub(1)); + if let Some(&idx) = search.matches.get(search.selected) { + self.input = self.input_history[idx].clone(); + self.cursor_pos = self.input.len(); + } else { + search.selected = 0; + self.input.clear(); + self.cursor_pos = 0; } - search.match_index = None; - self.input.clear(); - self.cursor_pos = 0; } /// Clear all input history entries. diff --git a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs index f31222f1c..a2c9061bd 100644 --- a/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/crates/jcode-tui/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1423,7 +1423,7 @@ fn test_ctrl_r_search_starts_and_shows_status() { assert!(status.is_some()); let (query, match_idx, total) = status.unwrap(); assert_eq!(query, ""); - assert_eq!(match_idx, None); + assert_eq!(match_idx, Some(0)); // empty query shows all, selected=0 assert_eq!(total, 2); } @@ -1441,7 +1441,7 @@ fn test_ctrl_r_search_char_finds_match() { assert_eq!(app.cursor_pos, 7); let status = crate::tui::TuiState::input_history_search_status(&app).unwrap(); assert_eq!(status.0, "g"); - assert_eq!(status.1, Some(1)); // index 1 + assert_eq!(status.1, Some(0)); // selected=0 (first in matches list) } #[test] @@ -1490,22 +1490,23 @@ fn test_ctrl_r_search_next_cycles() { app.input_history_search_char('f'); app.input_history_search_char('o'); - // First match should be the most recent "foo" at index 2 + // First match should be the most recent "foo" at index 2 (selected=0 in matches list) assert_eq!(app.input, "foo"); - let (_, match_idx, _) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); - assert_eq!(match_idx, Some(2)); + let (_, match_idx, count) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); + assert_eq!(match_idx, Some(0)); // selected=0 (first in matches list) + assert_eq!(count, 3); // 3 matches - // Cycle to next - app.input_history_search_next(); + // Navigate down to next match + app.input_history_search_down(); assert_eq!(app.input, "foobar"); let (_, match_idx, _) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); assert_eq!(match_idx, Some(1)); - // Cycle to next - app.input_history_search_next(); + // Navigate down to next match + app.input_history_search_down(); assert_eq!(app.input, "foo"); let (_, match_idx, _) = crate::tui::TuiState::input_history_search_status(&app).unwrap(); - assert_eq!(match_idx, Some(0)); + assert_eq!(match_idx, Some(2)); } #[test] diff --git a/crates/jcode-tui/src/tui/app/tui_state.rs b/crates/jcode-tui/src/tui/app/tui_state.rs index 675e9cae0..c5f253fe2 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -724,7 +724,18 @@ impl crate::tui::TuiState for App { fn input_history_search_status(&self) -> Option<(&str, Option, usize)> { let search = self.input_history_search.as_ref()?; - Some((&search.query, search.match_index, self.input_history.len())) + let selected_display = if search.matches.is_empty() { None } else { Some(search.selected) }; + Some((&search.query, selected_display, search.matches.len())) + } + + fn input_history_search_matches(&self) -> Option<(Vec<&str>, usize)> { + let search = self.input_history_search.as_ref()?; + let texts: Vec<&str> = search + .matches + .iter() + .filter_map(|&idx| self.input_history.get(idx).map(|s| s.as_str())) + .collect(); + Some((texts, search.selected)) } fn context_snapshot(&self) -> crate::tui::ContextSnapshot { diff --git a/crates/jcode-tui/src/tui/mod.rs b/crates/jcode-tui/src/tui/mod.rs index 007957ce1..f36faa310 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -226,6 +226,10 @@ pub trait TuiState { fn input_history_search_status(&self) -> Option<(&str, Option, usize)> { None } + /// Returns `Some((match_texts, selected))` with all search result entries and the selected index. + fn input_history_search_matches(&self) -> Option<(Vec<&str>, usize)> { + None + } /// Context info (what's loaded in context window - static + dynamic) fn context_info(&self) -> crate::prompt::ContextInfo; /// Authoritative, freshness-tagged context snapshot used by widgets. diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index 64f358f49..679fc79b8 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -1512,9 +1512,7 @@ pub(super) fn draw_input( let mut suggestion_lines: Vec = Vec::new(); if let Some((query, _match_index, _total)) = app.input_history_search_status() { hint_shown = true; - // Search query line: shows what the user is searching for above the matched result. - // The matched history entry is already set as app.input and will be rendered - // by wrap_input_text below. + // Search query line: shows what the user is searching for lines.push(Line::from(vec![ Span::styled( " 🔍 ", @@ -1529,6 +1527,44 @@ pub(super) fn draw_input( Style::default().fg(rgb(255, 180, 100)), ), ])); + // Multi-line results: show all matching entries with selection highlight + if let Some((matches, selected)) = app.input_history_search_matches() { + let max_visible = (area.height as usize).saturating_sub(lines.len() + 1).min(8); + let total_matches = matches.len(); + // Calculate scroll window around selected item + let scroll_start = if total_matches <= max_visible { + 0 + } else { + selected.saturating_sub(max_visible / 2).min(total_matches - max_visible) + }; + for (display_idx, &text) in matches.iter().enumerate().skip(scroll_start).take(max_visible) { + let is_selected = display_idx == selected; + let truncated = if text.len() > line_width.saturating_sub(6) { + format!("{}…", &text[..line_width.saturating_sub(7)]) + } else { + text.to_string() + }; + let indicator = if is_selected { "▸ " } else { " " }; + let style = if is_selected { + Style::default().fg(Color::White).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(rgb(120, 140, 160)) + }; + lines.push(Line::from(vec![ + Span::styled(indicator, style), + Span::styled(truncated, style), + ])); + } + if total_matches > max_visible { + let remaining = total_matches - scroll_start - max_visible.min(total_matches - scroll_start); + if remaining > 0 { + lines.push(Line::from(Span::styled( + format!(" … {} more", remaining), + Style::default().fg(rgb(80, 100, 120)), + ))); + } + } + } } else if has_suggestions { suggestion_lines = command_suggestion_lines(app, &suggestions); } else if let Some(shell_hint) = shell_mode_hint(mode) { From 910658ce679c0066220dbb870f6cac89ac7272f6 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Tue, 26 May 2026 14:42:54 -0300 Subject: [PATCH 14/14] fix(ui): render Ctrl+R search results as floating overlay above input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The search results were rendered inside the input area at the bottom of the screen, getting cut off. Now they render as a bordered floating popup above the input area with: - Search query line with cursor block - Separator line - Up to 8 results with selection highlight (▸ for selected, dimmed rest) - Scroll window tracks the selected item - Rounded border with 'History Search' title --- crates/jcode-tui/src/tui/ui.rs | 3 + crates/jcode-tui/src/tui/ui_input.rs | 184 +++++++++++++++++++-------- 2 files changed, 132 insertions(+), 55 deletions(-) diff --git a/crates/jcode-tui/src/tui/ui.rs b/crates/jcode-tui/src/tui/ui.rs index d0b14d5b6..ebe9b74da 100644 --- a/crates/jcode-tui/src/tui/ui.rs +++ b/crates/jcode-tui/src/tui/ui.rs @@ -2376,6 +2376,9 @@ fn draw_inner(frame: &mut Frame, app: &dyn TuiState) { &mut debug_capture, ); + // Draw floating search overlay above input when Ctrl+R is active + input_ui::draw_search_overlay(frame, app, chunks[6], area.height); + if donut_height > 0 { animations::draw_idle_animation(frame, app, chunks[7]); } diff --git a/crates/jcode-tui/src/tui/ui_input.rs b/crates/jcode-tui/src/tui/ui_input.rs index 679fc79b8..42db40865 100644 --- a/crates/jcode-tui/src/tui/ui_input.rs +++ b/crates/jcode-tui/src/tui/ui_input.rs @@ -11,7 +11,7 @@ use crate::tui::color_support::rgb; use crate::tui::detect_kv_cache_problem; use crate::tui::info_widget::occasional_status_tip; use crate::tui::layout_utils; -use ratatui::{prelude::*, widgets::Paragraph}; +use ratatui::{prelude::*, widgets::{Block, Borders, BorderType, Clear, Padding, Paragraph, Wrap}}; fn shell_mode_color() -> Color { rgb(110, 214, 151) @@ -1510,61 +1510,10 @@ pub(super) fn draw_input( let mut hint_shown = false; let mut hint_line: Option = None; let mut suggestion_lines: Vec = Vec::new(); - if let Some((query, _match_index, _total)) = app.input_history_search_status() { + if app.input_history_search_status().is_some() { hint_shown = true; - // Search query line: shows what the user is searching for - lines.push(Line::from(vec![ - Span::styled( - " 🔍 ", - Style::default().fg(rgb(255, 180, 100)), - ), - Span::styled( - query, - Style::default().fg(Color::White).add_modifier(Modifier::BOLD), - ), - Span::styled( - "█", - Style::default().fg(rgb(255, 180, 100)), - ), - ])); - // Multi-line results: show all matching entries with selection highlight - if let Some((matches, selected)) = app.input_history_search_matches() { - let max_visible = (area.height as usize).saturating_sub(lines.len() + 1).min(8); - let total_matches = matches.len(); - // Calculate scroll window around selected item - let scroll_start = if total_matches <= max_visible { - 0 - } else { - selected.saturating_sub(max_visible / 2).min(total_matches - max_visible) - }; - for (display_idx, &text) in matches.iter().enumerate().skip(scroll_start).take(max_visible) { - let is_selected = display_idx == selected; - let truncated = if text.len() > line_width.saturating_sub(6) { - format!("{}…", &text[..line_width.saturating_sub(7)]) - } else { - text.to_string() - }; - let indicator = if is_selected { "▸ " } else { " " }; - let style = if is_selected { - Style::default().fg(Color::White).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(rgb(120, 140, 160)) - }; - lines.push(Line::from(vec![ - Span::styled(indicator, style), - Span::styled(truncated, style), - ])); - } - if total_matches > max_visible { - let remaining = total_matches - scroll_start - max_visible.min(total_matches - scroll_start); - if remaining > 0 { - lines.push(Line::from(Span::styled( - format!(" … {} more", remaining), - Style::default().fg(rgb(80, 100, 120)), - ))); - } - } - } + // Search results are rendered as a floating overlay above the input area + // (see draw_search_overlay). Only mark hint_shown here. } else if has_suggestions { suggestion_lines = command_suggestion_lines(app, &suggestions); } else if let Some(shell_hint) = shell_mode_hint(mode) { @@ -1969,3 +1918,128 @@ enum QueuedMsgType { Interleave, Queued, } + +/// Draw a floating search overlay above the input area during Ctrl+R search. +/// The overlay renders the search query line and all matching history entries +/// with selection highlight, positioned just above the input area. +pub(super) fn draw_search_overlay( + frame: &mut Frame, + app: &dyn TuiState, + input_area: Rect, + terminal_height: u16, +) { + let Some((query, _match_idx, _total)) = app.input_history_search_status() else { + return; + }; + let Some((matches, selected)) = app.input_history_search_matches() else { + return; + }; + + let area_width = frame.area().width; + let content_width = (area_width as usize).saturating_sub(4); // 2 border + 2 padding + + // Build overlay lines + let mut lines: Vec> = Vec::new(); + + // Search query line + lines.push(Line::from(vec![ + Span::styled( + "🔍 ", + Style::default().fg(rgb(255, 180, 100)), + ), + Span::styled( + query.to_string(), + Style::default().fg(Color::White).add_modifier(Modifier::BOLD), + ), + Span::styled( + "█", + Style::default().fg(rgb(255, 180, 100)), + ), + ])); + + // Separator + lines.push(Line::from(Span::styled( + "─".repeat(content_width.min(60)), + Style::default().fg(rgb(60, 60, 60)), + ))); + + // Result entries + let max_results = 8; + let total_matches = matches.len(); + let scroll_start = if total_matches <= max_results { + 0 + } else { + selected + .saturating_sub(max_results / 2) + .min(total_matches - max_results) + }; + + for (display_idx, &text) in matches + .iter() + .enumerate() + .skip(scroll_start) + .take(max_results) + { + let is_selected = display_idx == selected; + let truncated = if text.len() > content_width.saturating_sub(4) { + format!("{}…", &text[..content_width.saturating_sub(5)]) + } else { + text.to_string() + }; + let indicator = if is_selected { "▸ " } else { " " }; + let style = if is_selected { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) + .bg(rgb(40, 50, 70)) + } else { + Style::default().fg(rgb(120, 140, 160)) + }; + lines.push(Line::from(vec![ + Span::styled(indicator.to_string(), style), + Span::styled(truncated, style), + ])); + } + + if total_matches > max_results { + let shown = max_results.min(total_matches - scroll_start); + let remaining = total_matches - scroll_start - shown; + if remaining > 0 { + lines.push(Line::from(Span::styled( + format!(" … {} more", remaining), + Style::default().fg(rgb(80, 100, 120)), + ))); + } + } + + let overlay_height = (lines.len() as u16 + 2).min(terminal_height.saturating_sub(2)); // +2 for border + + // Position above the input area + let overlay_y = input_area.y.saturating_sub(overlay_height); + let overlay_area = Rect { + x: input_area.x, + y: overlay_y, + width: area_width, + height: overlay_height, + }; + + // Clear the overlay area first + frame.render_widget(Clear, overlay_area); + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(rgb(255, 180, 100))) + .title(Span::styled( + " History Search ", + Style::default() + .fg(rgb(255, 180, 100)) + .add_modifier(Modifier::BOLD), + )) + .padding(Padding::horizontal(1)); + + let inner = block.inner(overlay_area); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + frame.render_widget(block, overlay_area); + frame.render_widget(paragraph, inner); +}