diff --git a/crates/jcode-base/src/config.rs b/crates/jcode-base/src/config.rs index 19fe4417b..7a2babd89 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}; @@ -26,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", @@ -72,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", @@ -410,6 +415,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..8b3f93335 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.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 ef9be3063..ba1ecd6de 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -907,3 +907,33 @@ 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, clamped to 1..=10000). + #[serde( + default = "default_max_entries", + deserialize_with = "deserialize_clamped_usize" + )] + pub max_entries: usize, +} + +impl Default for InputHistoryConfig { + fn default() -> Self { + 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)) +} diff --git a/crates/jcode-tui/src/tui/app.rs b/crates/jcode-tui/src/tui/app.rs index 474f9be30..e5566562b 100644 --- a/crates/jcode-tui/src/tui/app.rs +++ b/crates/jcode-tui/src/tui/app.rs @@ -538,6 +538,20 @@ 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, + /// 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, +} + /// State for an in-progress OAuth/API-key login flow triggered by `/login`. /// TUI Application state pub struct App { @@ -944,6 +958,14 @@ 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, + // 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) 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..beb5eb0de 100644 --- a/crates/jcode-tui/src/tui/app/commands.rs +++ b/crates/jcode-tui/src/tui/app/commands.rs @@ -1894,6 +1894,131 @@ 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."); + 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(); + 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(); + 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::() { + Ok(n) if n >= 1 && n <= app.input_history.len() => { + let entry = app.input_history[n - 1].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)); + } + _ => { + 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/input.rs b/crates/jcode-tui/src/tui/app/input.rs index fe7087722..5e0e9c9d4 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(); } @@ -1747,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() => { @@ -1828,11 +1829,23 @@ 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() + { + 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 +1903,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, @@ -1981,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(()); } @@ -2382,6 +2402,7 @@ impl App { self.pasted_contents.clear(); self.cursor_pos = 0; self.clear_input_undo_history(); + self.reset_input_history_browse(); 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 @@ -2405,6 +2426,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) @@ -2705,3 +2729,61 @@ 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_down(); + 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(()); + } + // Down arrow: navigate to next (newer) match + KeyCode::Down => { + app.input_history_search_down(); + return Ok(()); + } + // Up arrow: navigate to previous (older) match + KeyCode::Up => { + app.input_history_search_up(); + 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); + 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 158731967..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') => { @@ -2345,12 +2350,26 @@ 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_index.is_some()) + && 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..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 @@ -73,6 +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", + "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"), RegisteredCommand::public("/improve", "Autonomously improve the repository"), @@ -1064,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) => @@ -1332,6 +1342,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) { @@ -1343,6 +1355,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 { @@ -1394,6 +1407,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 9ae17a20a..c0a2872c1 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); @@ -386,4 +387,299 @@ impl App { self.set_status_notice("📋 Input stashed"); } } + + /// 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; + } + // 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); + let max = crate::config::config().input_history.max_entries; + if self.input_history.len() > max { + self.input_history.remove(0); + } + self.save_input_history(); + } + + /// 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 => { + // 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(); + 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: restore pre-browse input and exit history browsing + self.input_history_index = None; + 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 + } + + /// 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; + } + + /// 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.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(), + matches: all_indices, + selected: 0, + 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(); + } + + /// 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.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; + } + 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; + }; + if let Some(&idx) = search.matches.get(search.selected) { + self.input = self.input_history[idx].clone(); + self.cursor_pos = self.input.len(); + } + } + + /// 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; + }; + 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; + 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 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() { + 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; + 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(); + } 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: 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.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.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; + } + } + + /// 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 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, + }); + 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(); + }; + let max = crate::config::config().input_history.max_entries; + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .take(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 f3aa8a4dd..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 @@ -1075,3 +1075,640 @@ 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()); +} + +#[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"); +} + +#[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!(crate::tui::TuiState::input_history_browse_status(&app).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) = crate::tui::TuiState::input_history_browse_status(&app).unwrap(); + 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, Some(0)); // empty query shows all, selected=0 + 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(0)); // selected=0 (first in matches list) +} + +#[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 (selected=0 in matches list) + assert_eq!(app.input, "foo"); + 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 + + // 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)); + + // 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(2)); +} + +#[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, ""); +} + +#[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/tui_lifecycle.rs b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs index 37760d55f..05bad2a93 100644 --- a/crates/jcode-tui/src/tui/app/tui_lifecycle.rs +++ b/crates/jcode-tui/src/tui/app/tui_lifecycle.rs @@ -501,6 +501,10 @@ impl App { scroll_bookmark: None, typing_scroll_lock: false, 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, experimental_feature_warnings_seen: HashSet::new(), @@ -882,6 +886,10 @@ impl App { scroll_bookmark: None, typing_scroll_lock: false, 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, experimental_feature_warnings_seen: HashSet::new(), @@ -955,6 +963,9 @@ 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.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 1b81c5294..c5f253fe2 100644 --- a/crates/jcode-tui/src/tui/app/tui_state.rs +++ b/crates/jcode-tui/src/tui/app/tui_state.rs @@ -713,6 +713,31 @@ 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 input_history_search_status(&self) -> Option<(&str, Option, usize)> { + let search = self.input_history_search.as_ref()?; + 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 { use crate::message::{ContentBlock, Role}; use std::time::Instant; 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/mod.rs b/crates/jcode-tui/src/tui/mod.rs index ee00d6773..f36faa310 100644 --- a/crates/jcode-tui/src/tui/mod.rs +++ b/crates/jcode-tui/src/tui/mod.rs @@ -218,6 +218,18 @@ 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 + } + /// 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 + } + /// 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.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 52041fde3..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) @@ -1433,6 +1433,26 @@ 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)), + )); + } + + 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!("match {}/{}", idx + 1, total), + None => "no match".to_string(), + }; + spans.push(Span::styled( + format!("🔍 {}", match_info), + Style::default().fg(rgb(255, 180, 100)), + )); + } + spans } @@ -1490,7 +1510,11 @@ 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; + // 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) { hint_shown = true; @@ -1894,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); +} diff --git a/crates/jcode-tui/src/tui/ui_overlays.rs b/crates/jcode-tui/src/tui/ui_overlays.rs index fc7d7c780..d29c48b29 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", + "Show input history. Subcommands: input N, search, delete N, clear", + )); lines.push(help_entry( "/fix", "Attempt recovery when model cannot continue", @@ -336,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", "Scroll history (when input empty)")); + 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", @@ -422,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", diff --git a/crates/jcode-tui/src/tui/ui_tests/mod.rs b/crates/jcode-tui/src/tui/ui_tests/mod.rs index 106acfe63..b330277e3 100644 --- a/crates/jcode-tui/src/tui/ui_tests/mod.rs +++ b/crates/jcode-tui/src/tui/ui_tests/mod.rs @@ -304,6 +304,12 @@ impl crate::tui::TuiState for TestState { fn has_stashed_input(&self) -> bool { false } + 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() }