diff --git a/crates/ralph-tui/examples/guidance_test.rs b/crates/ralph-tui/examples/guidance_test.rs index 334b74cc2..de2464b75 100644 --- a/crates/ralph-tui/examples/guidance_test.rs +++ b/crates/ralph-tui/examples/guidance_test.rs @@ -19,8 +19,10 @@ async fn main() -> anyhow::Result<()> { let test_dir = PathBuf::from(home).join(".ralph-guidance-test"); std::fs::create_dir_all(&test_dir)?; let events_path = test_dir.join("events.jsonl"); + let urgent_steer_path = test_dir.join("urgent-steer.json"); // Clean previous test data let _ = std::fs::remove_file(&events_path); + let _ = std::fs::remove_file(&urgent_steer_path); // Create termination channel let (_terminated_tx, terminated_rx) = tokio::sync::watch::channel(false); @@ -28,7 +30,8 @@ async fn main() -> anyhow::Result<()> { // Build TUI let tui = Tui::new() .with_termination_signal(terminated_rx) - .with_events_path(events_path.clone()); + .with_events_path(events_path.clone()) + .with_urgent_steer_path(urgent_steer_path.clone()); let state = tui.state(); diff --git a/crates/ralph-tui/src/app.rs b/crates/ralph-tui/src/app.rs index bf1caefae..7a6a03749 100644 --- a/crates/ralph-tui/src/app.rs +++ b/crates/ralph-tui/src/app.rs @@ -101,7 +101,13 @@ pub fn dispatch_action(action: Action, state: &mut TuiState, viewport_height: us } } Action::StartSearch => { + // Enter input mode with an empty query so the footer shows the + // "Search: " prompt immediately; characters are captured by + // `handle_search_input` while `search_mode` is active. state.search_state.search_mode = true; + state.search_state.query = Some(String::new()); + state.search_state.matches.clear(); + state.search_state.current_match = 0; } Action::SearchNext => { state.next_match(); @@ -132,6 +138,60 @@ pub fn dispatch_action(action: Action, state: &mut TuiState, viewport_height: us false } +/// Handles a key press while search input mode is active. +/// +/// While `search_state.search_mode` is true, every key is captured here +/// (the caller must `continue` instead of mapping the key to an action) so +/// that typing a query does not leak through to normal keybindings. +/// +/// - `Char`/`Backspace`: edit the query and live-update matches. +/// - `Enter`: commit a non-empty query (leaving `query`/`matches` intact so +/// `n`/`N` can navigate); an empty query clears the search. +/// - `Esc`: cancel and clear the search. +/// +/// Returns `true` when the key was consumed (search mode was active). +fn handle_search_input(state: &mut TuiState, code: crossterm::event::KeyCode) -> bool { + use crossterm::event::KeyCode; + + if !state.search_state.search_mode { + return false; + } + + match code { + KeyCode::Esc => { + state.clear_search(); + } + KeyCode::Enter => { + let has_query = state + .search_state + .query + .as_deref() + .is_some_and(|q| !q.is_empty()); + if has_query { + // Commit the search: leave the input mode but keep the query + // and matches so `n`/`N` navigate the highlighted results. + state.search_state.search_mode = false; + } else { + state.clear_search(); + } + } + KeyCode::Backspace => { + // `search` preserves `search_mode`, so the user stays in input mode. + let mut query = state.search_state.query.clone().unwrap_or_default(); + query.pop(); + state.search(&query); + } + KeyCode::Char(c) => { + let mut query = state.search_state.query.clone().unwrap_or_default(); + query.push(c); + state.search(&query); + } + _ => {} + } + + true +} + fn set_mouse_capture(enabled: bool) -> Result<()> { if enabled { execute!(io::stdout(), EnableMouseCapture)?; @@ -340,6 +400,14 @@ impl App { } } + // Search input mode: capture all keys into the query + { + let mut state = self.state.lock().unwrap(); + if handle_search_input(&mut state, key.code) { + continue; + } + } + // Dismiss help on any key when help is showing { let mut state = self.state.lock().unwrap(); @@ -637,6 +705,143 @@ mod tests { assert_eq!(state.search_state.current_match, 0); } + // ========================================================================= + // Search input mode: keys must be captured into the query, not dispatched + // as normal keybindings (regression: typing a query containing 'e' fired + // the export action because no interception block existed). + // ========================================================================= + + fn seed_search_state() -> TuiState { + let mut state = TuiState::new(); + state.start_new_iteration(); + let buffer = state.current_iteration_mut().unwrap(); + buffer.append_line(Line::from("reviewing changes")); + buffer.append_line(Line::from("reviewing more")); + buffer.append_line(Line::from("done")); + state + } + + #[test] + fn start_search_enters_input_mode_with_empty_query() { + let mut state = seed_search_state(); + + dispatch_action(Action::StartSearch, &mut state, 10); + + assert!(state.search_state.search_mode, "'/' must enter input mode"); + assert_eq!( + state.search_state.query.as_deref(), + Some(""), + "query prompt should start empty so the footer shows 'Search: '" + ); + } + + #[test] + fn typed_chars_build_query_and_do_not_dispatch_actions() { + let mut state = seed_search_state(); + dispatch_action(Action::StartSearch, &mut state, 10); + + // Type "review" — note this includes 'e', which previously fell through + // to Action::ExportCurrentIteration. + for c in "review".chars() { + assert!( + handle_search_input(&mut state, KeyCode::Char(c)), + "search mode must consume '{c}'" + ); + } + + assert_eq!(state.search_state.query.as_deref(), Some("review")); + assert!( + !state.search_state.matches.is_empty(), + "live search should find 'review' matches while typing" + ); + assert!( + state.export_flash.is_none(), + "typing a query must NOT trigger the export action" + ); + } + + #[test] + fn backspace_edits_query_live() { + let mut state = seed_search_state(); + dispatch_action(Action::StartSearch, &mut state, 10); + for c in "reviewx".chars() { + handle_search_input(&mut state, KeyCode::Char(c)); + } + assert!( + state.search_state.matches.is_empty(), + "'reviewx' has no match" + ); + + handle_search_input(&mut state, KeyCode::Backspace); + + assert_eq!(state.search_state.query.as_deref(), Some("review")); + assert!( + !state.search_state.matches.is_empty(), + "backspace should re-run search and restore matches" + ); + } + + #[test] + fn enter_commits_query_and_keeps_results_for_navigation() { + let mut state = seed_search_state(); + dispatch_action(Action::StartSearch, &mut state, 10); + for c in "reviewing".chars() { + handle_search_input(&mut state, KeyCode::Char(c)); + } + + assert!(handle_search_input(&mut state, KeyCode::Enter)); + + assert!( + !state.search_state.search_mode, + "Enter exits input mode so n/N dispatch as navigation" + ); + assert_eq!( + state.search_state.query.as_deref(), + Some("reviewing"), + "committed query is kept for the footer display" + ); + let matches = state.search_state.matches.len(); + assert!(matches >= 2, "two lines contain 'reviewing'"); + + // n/N now dispatch normally and navigate the committed results. + assert!(!handle_search_input(&mut state, KeyCode::Char('n'))); + dispatch_action(Action::SearchNext, &mut state, 10); + assert_eq!(state.search_state.current_match, 1); + } + + #[test] + fn enter_on_empty_query_clears_search() { + let mut state = seed_search_state(); + dispatch_action(Action::StartSearch, &mut state, 10); + + handle_search_input(&mut state, KeyCode::Enter); + + assert!(!state.search_state.search_mode); + assert!(state.search_state.query.is_none(), "empty query is cleared"); + } + + #[test] + fn esc_cancels_search_input() { + let mut state = seed_search_state(); + dispatch_action(Action::StartSearch, &mut state, 10); + for c in "review".chars() { + handle_search_input(&mut state, KeyCode::Char(c)); + } + + assert!(handle_search_input(&mut state, KeyCode::Esc)); + + assert!(!state.search_state.search_mode); + assert!(state.search_state.query.is_none()); + assert!(state.search_state.matches.is_empty()); + } + + #[test] + fn handle_search_input_is_noop_when_not_searching() { + let mut state = seed_search_state(); + // Not in search mode: the helper must not consume the key. + assert!(!handle_search_input(&mut state, KeyCode::Char('e'))); + } + // ========================================================================= // AC5: Quit Returns True to Exit Loop // ========================================================================= diff --git a/crates/ralph-tui/src/widgets/footer.rs b/crates/ralph-tui/src/widgets/footer.rs index 32e6b4ad6..d53e6dca4 100644 --- a/crates/ralph-tui/src/widgets/footer.rs +++ b/crates/ralph-tui/src/widgets/footer.rs @@ -91,7 +91,10 @@ impl Widget for Footer<'_> { // If search state has an active query, render search display if let Some(query) = &self.state.search_state.query { - let match_info = if self.state.search_state.matches.is_empty() { + let match_info = if query.is_empty() { + // Still typing the query; no count to show yet. + String::new() + } else if self.state.search_state.matches.is_empty() { "no matches".to_string() } else { format!(