From 9c14dff63a20cc7607eb6d0b5e2eebbaa2eca9f8 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 15:33:23 -0300 Subject: [PATCH 1/5] 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 --- src/tui/app.rs | 4 + src/tui/app/commands.rs | 39 ++++ src/tui/app/input.rs | 12 ++ src/tui/app/remote/key_handling.rs | 17 +- src/tui/app/state_ui_input_helpers.rs | 1 + src/tui/app/state_ui_runtime.rs | 58 ++++++ .../tests/remote_startup_input_02/part_01.rs | 190 ++++++++++++++++++ src/tui/app/tui_lifecycle.rs | 4 + src/tui/ui_overlays.rs | 6 +- 9 files changed, 326 insertions(+), 5 deletions(-) diff --git a/src/tui/app.rs b/src/tui/app.rs index b99d1d5c6..db3035d3b 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -901,6 +901,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/src/tui/app/commands.rs b/src/tui/app/commands.rs index f3e253737..87e302068 100644 --- a/src/tui/app/commands.rs +++ b/src/tui/app/commands.rs @@ -2062,6 +2062,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/src/tui/app/input.rs b/src/tui/app/input.rs index e030ba9b0..e14968ee5 100644 --- a/src/tui/app/input.rs +++ b/src/tui/app/input.rs @@ -829,6 +829,7 @@ pub(super) fn handle_text_input(app: &mut App, text: &str) -> bool { } insert_input_text(app, text); + app.reset_input_history_browse(); true } @@ -2201,6 +2202,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 @@ -2240,11 +2242,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 @@ -2302,6 +2310,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, @@ -2746,6 +2756,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/src/tui/app/remote/key_handling.rs b/src/tui/app/remote/key_handling.rs index d5b94075a..0db6f9ed2 100644 --- a/src/tui/app/remote/key_handling.rs +++ b/src/tui/app/remote/key_handling.rs @@ -670,6 +670,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(); } } @@ -2260,12 +2261,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/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 18669f80b..48c4a9d1e 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -75,6 +75,7 @@ pub(super) 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("/improve", "Autonomously improve the repository"), RegisteredCommand::public("/refactor", "Run a safe refactor loop"), diff --git a/src/tui/app/state_ui_runtime.rs b/src/tui/app/state_ui_runtime.rs index 9ae17a20a..32a38e6a3 100644 --- a/src/tui/app/state_ui_runtime.rs +++ b/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/src/tui/app/tests/remote_startup_input_02/part_01.rs b/src/tui/app/tests/remote_startup_input_02/part_01.rs index 99fbe97da..06238538d 100644 --- a/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -972,3 +972,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/src/tui/app/tui_lifecycle.rs b/src/tui/app/tui_lifecycle.rs index 4d358f97b..aed30b807 100644 --- a/src/tui/app/tui_lifecycle.rs +++ b/src/tui/app/tui_lifecycle.rs @@ -492,6 +492,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(), @@ -862,6 +864,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/src/tui/ui_overlays.rs b/src/tui/ui_overlays.rs index 7f7e0709f..5bb7991b0 100644 --- a/src/tui/ui_overlays.rs +++ b/src/tui/ui_overlays.rs @@ -182,6 +182,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", @@ -321,7 +325,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("Ctrl+1..4", "Resize side panel to 25/50/75/100%")); lines.push(key_entry( From 9bd8476baed818ef3fadf65f11ccdc97561da795 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 16:10:08 -0300 Subject: [PATCH 2/5] 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 --- src/tui/app/input.rs | 14 ++++-- src/tui/app/remote/key_handling.rs | 3 +- src/tui/app/state_ui_input_helpers.rs | 3 ++ src/tui/app/state_ui_runtime.rs | 1 + .../tests/remote_startup_input_02/part_01.rs | 47 +++++++++++++++++++ 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/tui/app/input.rs b/src/tui/app/input.rs index e14968ee5..261b01b67 100644 --- a/src/tui/app/input.rs +++ b/src/tui/app/input.rs @@ -800,6 +800,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(); } @@ -829,7 +830,6 @@ pub(super) fn handle_text_input(app: &mut App, text: &str) -> bool { } insert_input_text(app, text); - app.reset_input_history_browse(); true } @@ -2202,7 +2202,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 @@ -2242,7 +2241,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 }; @@ -2757,7 +2756,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 @@ -2781,6 +2779,9 @@ impl App { return; } + return; + } + // Issue #4 follow-up: if the user typed `/` (or `/ `) // and `` is a discovered prompt template, expand the template // body in-place so the rest of the submit flow treats it as a normal @@ -2789,6 +2790,11 @@ impl App { // templates. let mut input = expand_prompt_template_invocation(&input).unwrap_or(input); + // Issue #265 (input history): record submitted input for Up/Down recall. + // Done after template expansion so the recall menu shows the + // user-typed string, not the expanded body. + 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/src/tui/app/remote/key_handling.rs b/src/tui/app/remote/key_handling.rs index 0db6f9ed2..3b364c1d4 100644 --- a/src/tui/app/remote/key_handling.rs +++ b/src/tui/app/remote/key_handling.rs @@ -670,7 +670,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(); } } @@ -2261,7 +2260,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/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 48c4a9d1e..926713005 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -1711,6 +1711,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) { @@ -1722,6 +1724,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/src/tui/app/state_ui_runtime.rs b/src/tui/app/state_ui_runtime.rs index 32a38e6a3..d20a5b50a 100644 --- a/src/tui/app/state_ui_runtime.rs +++ b/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/src/tui/app/tests/remote_startup_input_02/part_01.rs b/src/tui/app/tests/remote_startup_input_02/part_01.rs index 06238538d..ca839b6f3 100644 --- a/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1162,3 +1162,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 333345a66129f31a8290d007511cfec0800fa78c Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 16:20:14 -0300 Subject: [PATCH 3/5] style: cargo fmt fixes for input-history related files --- src/tui/app/commands.rs | 8 ++------ src/tui/app/input.rs | 10 ++++++++-- src/tui/app/remote/key_handling.rs | 10 ++++++++-- src/tui/app/state_ui_input_helpers.rs | 5 ++++- .../app/tests/remote_startup_input_02/part_01.rs | 15 +++++++-------- 5 files changed, 29 insertions(+), 19 deletions(-) diff --git a/src/tui/app/commands.rs b/src/tui/app/commands.rs index 87e302068..82a45e32b 100644 --- a/src/tui/app/commands.rs +++ b/src/tui/app/commands.rs @@ -2064,9 +2064,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"); @@ -2074,9 +2072,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/src/tui/app/input.rs b/src/tui/app/input.rs index 261b01b67..565416cb9 100644 --- a/src/tui/app/input.rs +++ b/src/tui/app/input.rs @@ -2241,7 +2241,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 }; @@ -2249,7 +2252,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/src/tui/app/remote/key_handling.rs b/src/tui/app/remote/key_handling.rs index 3b364c1d4..fcba59a16 100644 --- a/src/tui/app/remote/key_handling.rs +++ b/src/tui/app/remote/key_handling.rs @@ -2260,7 +2260,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 }; @@ -2268,7 +2271,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/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 926713005..1456b43a7 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -75,7 +75,10 @@ pub(super) 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("/improve", "Autonomously improve the repository"), RegisteredCommand::public("/refactor", "Run a safe refactor loop"), diff --git a/src/tui/app/tests/remote_startup_input_02/part_01.rs b/src/tui/app/tests/remote_startup_input_02/part_01.rs index ca839b6f3..0a5ac3bcb 100644 --- a/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -153,8 +153,7 @@ fn test_model_picker_bedrock_selection_prefixes_model() { fn test_model_picker_bedrock_arn_selection_prefixes_model() { let mut app = create_test_app(); app.is_remote = true; - let model = - "arn:aws:bedrock:us-east-2:302154194530:inference-profile/us.deepseek.r1-v1:0"; + let model = "arn:aws:bedrock:us-east-2:302154194530:inference-profile/us.deepseek.r1-v1:0"; app.remote_available_entries = vec![model.to_string()]; app.remote_model_options = vec![crate::provider::ModelRoute { model: model.to_string(), @@ -195,8 +194,7 @@ fn test_model_picker_bedrock_arn_selection_prefixes_model() { fn test_remote_fallback_bedrock_arn_does_not_create_openrouter_route() { let mut app = create_test_app(); app.is_remote = true; - let model = - "arn:aws:bedrock:us-east-2:302154194530:inference-profile/us.deepseek.r1-v1:0"; + let model = "arn:aws:bedrock:us-east-2:302154194530:inference-profile/us.deepseek.r1-v1:0"; app.remote_available_entries = vec![model.to_string()]; app.remote_model_options.clear(); @@ -205,9 +203,11 @@ fn test_remote_fallback_bedrock_arn_does_not_create_openrouter_route() { assert!(routes.iter().any(|route| { route.model == model && route.api_method == "bedrock" && route.provider == "AWS Bedrock" })); - assert!(!routes - .iter() - .any(|route| route.model == model && route.api_method == "openrouter")); + assert!( + !routes + .iter() + .any(|route| route.model == model && route.api_method == "openrouter") + ); } #[test] @@ -675,7 +675,6 @@ fn test_shift_enter_inserts_newline() { assert_eq!(app.interleave_message.as_deref(), None); } - #[test] fn test_alt_enter_inserts_newline() { let mut app = create_test_app(); From 9051b6e4a86a2f842c2ec371069ab5cb673a151f Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 16:47:39 -0300 Subject: [PATCH 4/5] 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) --- src/tui/app/commands.rs | 66 +++++++++++++ src/tui/app/state_ui_runtime.rs | 82 ++++++++++++++++ .../tests/remote_startup_input_02/part_01.rs | 96 +++++++++++++++++++ src/tui/app/tui_lifecycle.rs | 6 +- src/tui/app/tui_state.rs | 9 ++ src/tui/mod.rs | 4 + src/tui/ui_input.rs | 8 ++ src/tui/ui_tests/mod.rs | 3 + 8 files changed, 272 insertions(+), 2 deletions(-) diff --git a/src/tui/app/commands.rs b/src/tui/app/commands.rs index 82a45e32b..1b725885a 100644 --- a/src/tui/app/commands.rs +++ b/src/tui/app/commands.rs @@ -2073,10 +2073,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/src/tui/app/state_ui_runtime.rs b/src/tui/app/state_ui_runtime.rs index d20a5b50a..61ad8bdbb 100644 --- a/src/tui/app/state_ui_runtime.rs +++ b/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/src/tui/app/tests/remote_startup_input_02/part_01.rs b/src/tui/app/tests/remote_startup_input_02/part_01.rs index 0a5ac3bcb..67053952b 100644 --- a/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1208,3 +1208,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/src/tui/app/tui_lifecycle.rs b/src/tui/app/tui_lifecycle.rs index aed30b807..ea98d851e 100644 --- a/src/tui/app/tui_lifecycle.rs +++ b/src/tui/app/tui_lifecycle.rs @@ -492,7 +492,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, @@ -864,7 +864,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, @@ -937,6 +937,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/src/tui/app/tui_state.rs b/src/tui/app/tui_state.rs index 49f56c03d..b8453d410 100644 --- a/src/tui/app/tui_state.rs +++ b/src/tui/app/tui_state.rs @@ -623,6 +623,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_info(&self) -> crate::prompt::ContextInfo { use crate::message::{ContentBlock, Role}; use std::time::Instant; diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 969043289..ba0ea7677 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -199,6 +199,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; /// Context window limit in tokens (if known) diff --git a/src/tui/ui_input.rs b/src/tui/ui_input.rs index 3b43b2890..89f0007f3 100644 --- a/src/tui/ui_input.rs +++ b/src/tui/ui_input.rs @@ -1350,6 +1350,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/src/tui/ui_tests/mod.rs b/src/tui/ui_tests/mod.rs index f7dcc2cb7..6f04493eb 100644 --- a/src/tui/ui_tests/mod.rs +++ b/src/tui/ui_tests/mod.rs @@ -299,6 +299,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 03a46a1b9857c4f226a4087ece27ff69a12917c4 Mon Sep 17 00:00:00 2001 From: mmacedoeu Date: Sun, 24 May 2026 17:42:32 -0300 Subject: [PATCH 5/5] 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. --- src/tui/app/commands.rs | 34 ++++++++++++++++--- src/tui/app/input.rs | 3 -- src/tui/app/state_ui_input_helpers.rs | 3 +- src/tui/app/state_ui_runtime.rs | 18 +++------- .../tests/remote_startup_input_02/part_01.rs | 4 +-- src/tui/ui_overlays.rs | 9 +++-- 6 files changed, 44 insertions(+), 27 deletions(-) diff --git a/src/tui/app/commands.rs b/src/tui/app/commands.rs index 1b725885a..95e9371df 100644 --- a/src/tui/app/commands.rs +++ b/src/tui/app/commands.rs @@ -2126,6 +2126,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(); @@ -2148,21 +2154,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/src/tui/app/input.rs b/src/tui/app/input.rs index 565416cb9..aa9d0e1a4 100644 --- a/src/tui/app/input.rs +++ b/src/tui/app/input.rs @@ -2785,9 +2785,6 @@ impl App { return; } - return; - } - // Issue #4 follow-up: if the user typed `/` (or `/ `) // and `` is a discovered prompt template, expand the template // body in-place so the rest of the submit flow treats it as a normal diff --git a/src/tui/app/state_ui_input_helpers.rs b/src/tui/app/state_ui_input_helpers.rs index 1456b43a7..310c88ad5 100644 --- a/src/tui/app/state_ui_input_helpers.rs +++ b/src/tui/app/state_ui_input_helpers.rs @@ -77,7 +77,7 @@ pub(super) 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("/improve", "Autonomously improve the repository"), @@ -1775,6 +1775,7 @@ impl App { | "/improve" | "/refactor" | "/rewind" + | "/history" | "/compact" | "/compact mode" | "/alignment" diff --git a/src/tui/app/state_ui_runtime.rs b/src/tui/app/state_ui_runtime.rs index 61ad8bdbb..fcbdf4b81 100644 --- a/src/tui/app/state_ui_runtime.rs +++ b/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/src/tui/app/tests/remote_startup_input_02/part_01.rs b/src/tui/app/tests/remote_startup_input_02/part_01.rs index 67053952b..ad958ee49 100644 --- a/src/tui/app/tests/remote_startup_input_02/part_01.rs +++ b/src/tui/app/tests/remote_startup_input_02/part_01.rs @@ -1289,7 +1289,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] @@ -1300,7 +1300,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/src/tui/ui_overlays.rs b/src/tui/ui_overlays.rs index 5bb7991b0..f2d1b76c0 100644 --- a/src/tui/ui_overlays.rs +++ b/src/tui/ui_overlays.rs @@ -183,8 +183,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", @@ -325,7 +325,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("Ctrl+1..4", "Resize side panel to 25/50/75/100%")); lines.push(key_entry(