diff --git a/.beans/claudbg-pow4--tui-cursor-movement-in-filtersearch-bar-leftright.md b/.beans/claudbg-pow4--tui-cursor-movement-in-filtersearch-bar-leftright.md index 041466b..deeaaf4 100644 --- a/.beans/claudbg-pow4--tui-cursor-movement-in-filtersearch-bar-leftright.md +++ b/.beans/claudbg-pow4--tui-cursor-movement-in-filtersearch-bar-leftright.md @@ -1,10 +1,11 @@ --- # claudbg-pow4 title: 'TUI: cursor movement in filter/search bar (left/right arrow keys)' -status: todo +status: in-progress type: feature +priority: normal created_at: 2026-04-01T16:47:09Z -updated_at: 2026-04-01T16:47:09Z +updated_at: 2026-04-01T17:00:18Z --- Support moving the cursor left and right within the Filter/Search bar text input so users can edit text in the middle of the input without having to delete everything first. diff --git a/src/tui/modals/help_modal.rs b/src/tui/modals/help_modal.rs index 73979c4..fdd02e2 100644 --- a/src/tui/modals/help_modal.rs +++ b/src/tui/modals/help_modal.rs @@ -16,7 +16,7 @@ use crate::tui::state::AppState; /// Dialog dimensions. const DIALOG_WIDTH: u16 = 36; -const DIALOG_HEIGHT: u16 = 38; +const DIALOG_HEIGHT: u16 = 42; /// Compute a centered [`Rect`] of the given size within `area`. fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { @@ -48,6 +48,8 @@ const HELP_TEXT: &str = "\ \n\ Filter (sessions)\n\ t / / open filter\n\ + ←/→ move cursor\n\ + Home/End jump to start/end\n\ Enter apply & close\n\ Esc clear input\n\ \n\ @@ -64,6 +66,8 @@ const HELP_TEXT: &str = "\ \n\ Search (transcript)\n\ t / / open search\n\ + ←/→ move cursor\n\ + Home/End jump to start/end\n\ n / N next/prev match\n\ Enter apply & close\n\ Esc clear & close\n\ diff --git a/src/tui/screens/session_list.rs b/src/tui/screens/session_list.rs index 40682fa..dbbf9f8 100644 --- a/src/tui/screens/session_list.rs +++ b/src/tui/screens/session_list.rs @@ -175,9 +175,30 @@ fn render_filter_bar(f: &mut Frame, area: Rect, state: &AppState) { // Build the content line: label + input text (+ cursor when focused). let label = Span::styled("Filter: ", Style::default().add_modifier(Modifier::BOLD)); let input_text = if focused { - // Show a block cursor at end of input. - let mut spans = vec![label, Span::raw(state.filter_input.clone())]; - spans.push(Span::styled("█", Style::default().fg(Color::Yellow))); + // Split the input at the cursor position and insert a block cursor glyph. + let cursor = state.filter_cursor.min(state.filter_input.len()); + let before = &state.filter_input[..cursor]; + let after = &state.filter_input[cursor..]; + // The cursor glyph replaces the character under it (or is appended when at end). + let (cursor_char, after_rest) = if after.is_empty() { + ("█", "") + } else { + let first_char_end = after + .char_indices() + .nth(1) + .map(|(i, _)| i) + .unwrap_or(after.len()); + (&after[..first_char_end], &after[first_char_end..]) + }; + let spans = vec![ + label, + Span::raw(before.to_string()), + Span::styled( + cursor_char, + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(after_rest.to_string()), + ]; Line::from(spans) } else if state.filter_input.is_empty() && state.filter_active.is_empty() { Line::from(vec![ @@ -262,11 +283,13 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool { // Focus the filter input directly. KeyCode::Char('t') | KeyCode::Char('/') => { state.focus = Focus::FilterInput; + state.filter_cursor = state.filter_input.len(); true } // Tab cycles focus: list → filter → list. KeyCode::Tab => { state.focus = Focus::FilterInput; + state.filter_cursor = state.filter_input.len(); true } // Quit — show confirmation dialog rather than exiting immediately. @@ -288,18 +311,66 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool { /// Returns `true` when the event is consumed. fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool { match code { - // Character input: append to filter_input and reset history browsing. + // Character input: insert at cursor position and reset history browsing. KeyCode::Char(c) => { - state.filter_input.push(c); + let pos = state.filter_cursor; + state.filter_input.insert(pos, c); + state.filter_cursor += c.len_utf8(); state.filter_history_pos = None; true } - // Backspace: remove last char. + // Backspace: remove the character *before* the cursor. KeyCode::Backspace => { - state.filter_input.pop(); + if state.filter_cursor > 0 { + // Find the start of the previous UTF-8 character. + let pos = state.filter_cursor; + let prev = state.filter_input[..pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.filter_input.remove(prev); + state.filter_cursor = prev; + } state.filter_history_pos = None; true } + // Left arrow: move cursor one character to the left. + KeyCode::Left => { + if state.filter_cursor > 0 { + let pos = state.filter_cursor; + let prev = state.filter_input[..pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.filter_cursor = prev; + } + true + } + // Right arrow: move cursor one character to the right. + KeyCode::Right => { + let pos = state.filter_cursor; + if pos < state.filter_input.len() { + let next = state.filter_input[pos..] + .char_indices() + .nth(1) + .map(|(i, _)| pos + i) + .unwrap_or(state.filter_input.len()); + state.filter_cursor = next; + } + true + } + // Home: jump cursor to start of input. + KeyCode::Home => { + state.filter_cursor = 0; + true + } + // End: jump cursor to end of input. + KeyCode::End => { + state.filter_cursor = state.filter_input.len(); + true + } // Enter: apply the current input as the active filter and return focus to the list. KeyCode::Enter => { let query = state.filter_input.trim().to_string(); @@ -326,6 +397,7 @@ fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool { // Escape: clear the text input (but keep the panel visible). KeyCode::Esc => { state.filter_input.clear(); + state.filter_cursor = 0; state.filter_history_pos = None; true } @@ -340,6 +412,7 @@ fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool { }; state.filter_history_pos = Some(new_pos); state.filter_input = state.filter_history[new_pos].clone(); + state.filter_cursor = state.filter_input.len(); true } // Down arrow: browse forward through history (or clear when past the end). @@ -351,10 +424,12 @@ fn handle_filter_input_event(code: KeyCode, state: &mut AppState) -> bool { let new_pos = pos + 1; state.filter_history_pos = Some(new_pos); state.filter_input = state.filter_history[new_pos].clone(); + state.filter_cursor = state.filter_input.len(); } else { // Past the end: clear input and stop browsing history. state.filter_history_pos = None; state.filter_input.clear(); + state.filter_cursor = 0; } true } @@ -642,8 +717,10 @@ mod tests { let mut state = AppState::new(); state.focus = Focus::FilterInput; state.filter_input = "mo".to_string(); + state.filter_cursor = 2; // cursor at end handle_session_list_event(press(KeyCode::Backspace), &mut state); assert_eq!(state.filter_input, "m"); + assert_eq!(state.filter_cursor, 1); } #[test] @@ -729,4 +806,110 @@ mod tests { assert_eq!(state.filter_input, ""); assert_eq!(state.filter_history_pos, None); } + + // ── Filter cursor movement ─────────────────────────────────────────────── + + #[test] + fn char_inserts_at_cursor_not_end() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "ac".to_string(); + state.filter_cursor = 1; // cursor between 'a' and 'c' + handle_session_list_event(press(KeyCode::Char('b')), &mut state); + assert_eq!(state.filter_input, "abc"); + assert_eq!(state.filter_cursor, 2); + } + + #[test] + fn backspace_deletes_before_cursor_not_end() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 2; // cursor between 'b' and 'c' + handle_session_list_event(press(KeyCode::Backspace), &mut state); + assert_eq!(state.filter_input, "ac"); + assert_eq!(state.filter_cursor, 1); + } + + #[test] + fn backspace_at_start_does_nothing() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 0; + handle_session_list_event(press(KeyCode::Backspace), &mut state); + assert_eq!(state.filter_input, "abc"); + assert_eq!(state.filter_cursor, 0); + } + + #[test] + fn left_moves_cursor_back() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 2; + handle_session_list_event(press(KeyCode::Left), &mut state); + assert_eq!(state.filter_cursor, 1); + } + + #[test] + fn left_clamps_at_zero() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 0; + handle_session_list_event(press(KeyCode::Left), &mut state); + assert_eq!(state.filter_cursor, 0); + } + + #[test] + fn right_moves_cursor_forward() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 1; + handle_session_list_event(press(KeyCode::Right), &mut state); + assert_eq!(state.filter_cursor, 2); + } + + #[test] + fn right_clamps_at_end() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 3; + handle_session_list_event(press(KeyCode::Right), &mut state); + assert_eq!(state.filter_cursor, 3); + } + + #[test] + fn home_jumps_to_start() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 3; + handle_session_list_event(press(KeyCode::Home), &mut state); + assert_eq!(state.filter_cursor, 0); + } + + #[test] + fn end_jumps_to_end() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 0; + handle_session_list_event(press(KeyCode::End), &mut state); + assert_eq!(state.filter_cursor, 3); + } + + #[test] + fn esc_resets_cursor_to_zero() { + let mut state = AppState::new(); + state.focus = Focus::FilterInput; + state.filter_input = "abc".to_string(); + state.filter_cursor = 2; + handle_session_list_event(press(KeyCode::Esc), &mut state); + assert_eq!(state.filter_input, ""); + assert_eq!(state.filter_cursor, 0); + } } diff --git a/src/tui/screens/transcript.rs b/src/tui/screens/transcript.rs index e4a7ad1..e6a6c68 100644 --- a/src/tui/screens/transcript.rs +++ b/src/tui/screens/transcript.rs @@ -379,8 +379,29 @@ fn render_search_bar(f: &mut Frame, area: Rect, state: &AppState) { let label = Span::styled("Search: ", Style::default().add_modifier(Modifier::BOLD)); let input_line = if focused { - let mut spans = vec![label, Span::raw(state.search_input.clone())]; - spans.push(Span::styled("█", Style::default().fg(Color::Yellow))); + // Split the input at the cursor position and insert a block cursor glyph. + let cursor = state.search_cursor.min(state.search_input.len()); + let before = &state.search_input[..cursor]; + let after = &state.search_input[cursor..]; + let (cursor_char, after_rest) = if after.is_empty() { + ("█", "") + } else { + let first_char_end = after + .char_indices() + .nth(1) + .map(|(i, _)| i) + .unwrap_or(after.len()); + (&after[..first_char_end], &after[first_char_end..]) + }; + let spans = vec![ + label, + Span::raw(before.to_string()), + Span::styled( + cursor_char, + Style::default().fg(Color::Black).bg(Color::Yellow), + ), + Span::raw(after_rest.to_string()), + ]; Line::from(spans) } else if state.search_input.is_empty() && state.search_active.is_empty() { Line::from(vec![ @@ -568,6 +589,10 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool { Focus::SubagentsPanel => Focus::SearchInput, Focus::SearchInput | Focus::FilterInput => Focus::ChatLog, }; + // When switching into the search bar, position the cursor at the end. + if state.focus == Focus::SearchInput { + state.search_cursor = state.search_input.len(); + } true } // Navigate back: from SubagentTranscript → parent session transcript; @@ -596,6 +621,7 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool { // Jump directly to the search input. KeyCode::Char('t') | KeyCode::Char('/') => { state.focus = Focus::SearchInput; + state.search_cursor = state.search_input.len(); true } // Next match. @@ -726,14 +752,61 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool { /// Returns `true` when the event is consumed. fn handle_search_input_event(code: KeyCode, state: &mut AppState) -> bool { match code { - // Character input: append to search_input. + // Character input: insert at cursor position. KeyCode::Char(c) => { - state.search_input.push(c); + let pos = state.search_cursor; + state.search_input.insert(pos, c); + state.search_cursor += c.len_utf8(); true } - // Backspace: remove last char. + // Backspace: remove the character *before* the cursor. KeyCode::Backspace => { - state.search_input.pop(); + if state.search_cursor > 0 { + let pos = state.search_cursor; + let prev = state.search_input[..pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.search_input.remove(prev); + state.search_cursor = prev; + } + true + } + // Left arrow: move cursor one character to the left. + KeyCode::Left => { + if state.search_cursor > 0 { + let pos = state.search_cursor; + let prev = state.search_input[..pos] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.search_cursor = prev; + } + true + } + // Right arrow: move cursor one character to the right. + KeyCode::Right => { + let pos = state.search_cursor; + if pos < state.search_input.len() { + let next = state.search_input[pos..] + .char_indices() + .nth(1) + .map(|(i, _)| pos + i) + .unwrap_or(state.search_input.len()); + state.search_cursor = next; + } + true + } + // Home: jump cursor to start of input. + KeyCode::Home => { + state.search_cursor = 0; + true + } + // End: jump cursor to end of input. + KeyCode::End => { + state.search_cursor = state.search_input.len(); true } // Enter: apply the search, compute match lines, return focus to ChatLog. @@ -753,6 +826,7 @@ fn handle_search_input_event(code: KeyCode, state: &mut AppState) -> bool { // Escape: clear search and return focus to ChatLog. KeyCode::Esc => { state.search_input.clear(); + state.search_cursor = 0; state.search_active.clear(); state.search_match_lines.clear(); state.search_current_match = 0; @@ -1337,8 +1411,10 @@ mod tests { let mut state = transcript_state(); state.focus = Focus::SearchInput; state.search_input = "hi".to_string(); + state.search_cursor = 2; // cursor at end handle_transcript_event(press(KeyCode::Backspace), &mut state); assert_eq!(state.search_input, "h"); + assert_eq!(state.search_cursor, 1); } #[test] @@ -1492,4 +1568,110 @@ mod tests { let consumed = handle_transcript_event(Event::FocusGained, &mut state); assert!(!consumed); } + + // ── Search cursor movement ──────────────────────────────────────────────── + + #[test] + fn search_char_inserts_at_cursor_not_end() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "ac".to_string(); + state.search_cursor = 1; // between 'a' and 'c' + handle_transcript_event(press(KeyCode::Char('b')), &mut state); + assert_eq!(state.search_input, "abc"); + assert_eq!(state.search_cursor, 2); + } + + #[test] + fn search_backspace_deletes_before_cursor_not_end() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 2; // between 'b' and 'c' + handle_transcript_event(press(KeyCode::Backspace), &mut state); + assert_eq!(state.search_input, "ac"); + assert_eq!(state.search_cursor, 1); + } + + #[test] + fn search_backspace_at_start_does_nothing() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 0; + handle_transcript_event(press(KeyCode::Backspace), &mut state); + assert_eq!(state.search_input, "abc"); + assert_eq!(state.search_cursor, 0); + } + + #[test] + fn search_left_moves_cursor_back() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 2; + handle_transcript_event(press(KeyCode::Left), &mut state); + assert_eq!(state.search_cursor, 1); + } + + #[test] + fn search_left_clamps_at_zero() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 0; + handle_transcript_event(press(KeyCode::Left), &mut state); + assert_eq!(state.search_cursor, 0); + } + + #[test] + fn search_right_moves_cursor_forward() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 1; + handle_transcript_event(press(KeyCode::Right), &mut state); + assert_eq!(state.search_cursor, 2); + } + + #[test] + fn search_right_clamps_at_end() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 3; + handle_transcript_event(press(KeyCode::Right), &mut state); + assert_eq!(state.search_cursor, 3); + } + + #[test] + fn search_home_jumps_to_start() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 3; + handle_transcript_event(press(KeyCode::Home), &mut state); + assert_eq!(state.search_cursor, 0); + } + + #[test] + fn search_end_jumps_to_end() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 0; + handle_transcript_event(press(KeyCode::End), &mut state); + assert_eq!(state.search_cursor, 3); + } + + #[test] + fn search_esc_resets_cursor_to_zero() { + let mut state = transcript_state(); + state.focus = Focus::SearchInput; + state.search_input = "abc".to_string(); + state.search_cursor = 2; + handle_transcript_event(press(KeyCode::Esc), &mut state); + assert_eq!(state.search_input, ""); + assert_eq!(state.search_cursor, 0); + } } diff --git a/src/tui/state.rs b/src/tui/state.rs index 53b4ec6..4f33eab 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -132,6 +132,11 @@ pub struct AppState { // ── Search ────────────────────────────────────────────────────────────── /// Text currently being typed in the transcript search box. pub search_input: String, + /// Cursor position (byte offset) within `search_input`. + /// + /// Always points to a valid UTF-8 char boundary. Ranges from `0` + /// (before the first character) to `search_input.len()` (after the last). + pub search_cursor: usize, /// The last applied search query (highlights all case-insensitive matches). /// Empty string means no search is active. pub search_active: String, @@ -154,6 +159,11 @@ pub struct AppState { // ── Filter ────────────────────────────────────────────────────────────── /// Text currently being typed in the filter input box. pub filter_input: String, + /// Cursor position (byte offset) within `filter_input`. + /// + /// Always points to a valid UTF-8 char boundary. Ranges from `0` + /// (before the first character) to `filter_input.len()` (after the last). + pub filter_cursor: usize, /// The last successfully applied filter query string. /// Empty string means no filter is active. pub filter_active: String, @@ -248,6 +258,7 @@ impl AppState { transcript_h_scroll: 0, transcript_page_height: 0, search_input: String::new(), + search_cursor: 0, search_active: String::new(), search_match_lines: Vec::new(), search_current_match: 0, @@ -257,6 +268,7 @@ impl AppState { show_quit_dialog: false, show_help: false, filter_input: String::new(), + filter_cursor: 0, filter_active: String::new(), filter_history, filter_history_pos: None, @@ -281,6 +293,7 @@ impl AppState { self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.search_input.clear(); + self.search_cursor = 0; self.search_active.clear(); self.search_match_lines.clear(); self.search_current_match = 0; @@ -306,6 +319,7 @@ impl AppState { self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.search_input.clear(); + self.search_cursor = 0; self.search_active.clear(); self.search_match_lines.clear(); self.search_current_match = 0; @@ -325,6 +339,7 @@ impl AppState { self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.search_input.clear(); + self.search_cursor = 0; self.search_active.clear(); self.search_match_lines.clear(); self.search_current_match = 0; @@ -344,6 +359,7 @@ impl AppState { self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.search_input.clear(); + self.search_cursor = 0; self.search_active.clear(); self.search_match_lines.clear(); self.search_current_match = 0;