Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/ralph-tui/examples/guidance_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@ 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);

// 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();

Expand Down
205 changes: 205 additions & 0 deletions crates/ralph-tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -340,6 +400,14 @@ impl<W: AsyncWrite + Unpin + Send + 'static> App<W> {
}
}

// 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();
Expand Down Expand Up @@ -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
// =========================================================================
Expand Down
5 changes: 4 additions & 1 deletion crates/ralph-tui/src/widgets/footer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
Loading