feat(claudbg-pow4): add cursor movement to filter/search bar input

Add filter_cursor and search_cursor fields to AppState; handle Left/Right
to move cursor, Home/End to jump, cursor-aware Backspace (deletes before
cursor), and cursor-aware character insertion. Render the cursor at the
correct mid-text position. Update help modal to document new bindings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent b710a775fa
commit 3e5125b90b

@ -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.

@ -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\

@ -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);
}
}

@ -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);
}
}

@ -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;

Loading…
Cancel
Save