Compare commits

..

12 Commits

Author SHA1 Message Date
Elijah Voigt ddaa772da4 chore: update bean statuses for completed tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 8af7089734 feat(claudbg-28ao): move db and tui history into ~/.claude/claudbg/ subdirectory
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt b4c22d905c feat(claudbg-vcxz): document filter syntax in help modal
Add wildcard, AND/OR logic, and date format to the Filter fields section;
widen the help dialog to 40 cols to accommodate the new entries cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 41bf6e2a38 chore: mark claudbg-pow4 as completed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 3e5125b90b 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>
2 months ago
Elijah Voigt b710a775fa chore(claudbg-wfoe): mark bean completed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 02e86e5062 feat(claudbg-wfoe): Ctrl+C quits TUI immediately without confirmation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 8a0ddf8ccd chore(claudbg-wxdt): mark bean completed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 9abd18ab45 feat(claudbg-wxdt): left-truncate project path in TUI session list
Pass project_max directly to truncate_project instead of project_max+30,
so the function (not ratatui's right-clip) controls truncation — showing
the end of the path (e.g. …/foo/bar) rather than the beginning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt 590e9e209e feat(claudbg-ysj0): prevent transcript overflow and add Ctrl+L redraw
Switch chat-log layout constraint from Min(0) to Fill(1) to ensure the
transcript panel never bleeds into the sub-agents panel. Add Ctrl+L
keybinding (global + transcript screen) that sets needs_clear, causing
terminal.clear() before the next draw to flush rendering artifacts. Update
help modal to document Ctrl+L.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt fd54cebd57 feat(claudbg-xokw): accept 'msgs' as alias for 'messages' in filter fields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago
Elijah Voigt f6a4bd0535 chore: ticket all TODO.md items and clear the list
Created 7 new beans for untracked TODO items:
- claudbg-wfoe: Ctrl+C quits without confirmation
- claudbg-xokw: accept msgs/messages as filter aliases
- claudbg-vcxz: document filter syntax in help text
- claudbg-pow4: cursor movement in filter/search bar
- claudbg-ysj0: prevent transcript overflow + Ctrl+L redraw
- claudbg-28ao: move db/history files to ~/.claude/claudbg/
- claudbg-wxdt: truncate project path from start in TUI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2 months ago

@ -0,0 +1,13 @@
---
# claudbg-28ao
title: Move claudbg.db and claudbg.tui.history to ~/.claude/claudbg/
status: completed
type: task
priority: normal
created_at: 2026-04-01T16:47:11Z
updated_at: 2026-04-01T17:13:41Z
---
Move the claudbg.db database file and claudbg.tui.history filter history file from ~/.claude/ into a dedicated ~/.claude/claudbg/ subdirectory to reduce clutter in the ~/.claude/ root.
Moved default_db_path() from ~/.claude/claudbg.db to ~/.claude/claudbg/claudbg.db and history_file_path() from ~/.claude/claudbg.tui.history to ~/.claude/claudbg/claudbg.tui.history. Both functions already call create_dir_all on the parent so the new subdirectory is created automatically. Updated the default_db_path_suffix test to match the new path. All 297 tests pass.

@ -0,0 +1,13 @@
---
# claudbg-pow4
title: 'TUI: cursor movement in filter/search bar (left/right arrow keys)'
status: completed
type: feature
priority: normal
created_at: 2026-04-01T16:47:09Z
updated_at: 2026-04-01T17:06:22Z
---
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.
Added filter_cursor and search_cursor (byte-offset) fields to AppState. Updated handle_filter_input_event and handle_search_input_event to handle Left/Right (move one char), Home/End (jump to start/end), cursor-aware Backspace (delete char before cursor), and cursor-aware Char insertion (insert at cursor). Updated render_filter_bar and render_search_bar to split input at cursor and show a highlighted block cursor glyph at the correct mid-text position. Updated help modal to document the new bindings. 23 new tests added; all 297 pass.

@ -0,0 +1,13 @@
---
# claudbg-vcxz
title: Document filter syntax in help text
status: completed
type: task
priority: normal
created_at: 2026-04-01T16:47:07Z
updated_at: 2026-04-01T17:12:03Z
---
Add filter field documentation to the help text shown in the TUI (e.g. '?' help modal). Users should be able to discover available filter fields and operators without reading source code.
Added wildcard (:*), AND/OR logic, and date format (YYYY-MM) entries to the Filter fields section of the help modal. Widened dialog from 36 to 40 cols to accommodate the new entries. All filter fields (model, project, id, agents, messages/msgs, in, out, tokens, date) are now documented with operators and notes.

@ -0,0 +1,13 @@
---
# claudbg-wfoe
title: 'TUI: Ctrl+C immediately quits without confirmation'
status: completed
type: feature
priority: normal
created_at: 2026-04-01T16:47:04Z
updated_at: 2026-04-01T16:59:41Z
---
Support Ctrl+C to immediately quit without confirmation. Currently quit requires a confirmation dialog; Ctrl+C should bypass this and exit immediately.
Added Ctrl+C handling at the top of handle_event in src/tui/run.rs, before any modal checks, so it sets should_quit=true immediately regardless of which dialog/screen is active. Updated help modal text (src/tui/modals/help_modal.rs) to document both 'q/Q quit (confirm)' and 'Ctrl+C quit immediately'. Added 3 tests: ctrl_c_quits_immediately, ctrl_c_quits_through_quit_dialog, ctrl_c_quits_through_help_modal.

@ -0,0 +1,13 @@
---
# claudbg-wxdt
title: 'TUI: truncate project path from start when it overflows (show end, not beginning)'
status: completed
type: bug
priority: normal
created_at: 2026-04-01T16:47:14Z
updated_at: 2026-04-01T16:57:08Z
---
In the TUI session list, when the project path is too long to fit in the column, the beginning is shown and the end is cut off. Instead, the end of the path should be shown (right-justified or left-truncated) since the unique/distinguishing part of the path is usually at the end.
Fixed left-truncation of project path in TUI session list. The truncate_project function already implemented correct left-truncation, but it was called with project_max+30 chars, causing ratatui to do right-clipping instead. Removed the +30 so truncate_project runs first and shows the end of the path (e.g. …/foo/bar).

@ -0,0 +1,13 @@
---
# claudbg-xokw
title: 'Filter: accept ''msgs''/''messages'' as aliases for message count filter'
status: completed
type: bug
priority: normal
created_at: 2026-04-01T16:47:06Z
updated_at: 2026-04-01T16:51:12Z
---
For filtering, both 'msgs' and 'messages' should be accepted as field names when filtering on message count, since the UI displays the column as 'msgs'.
## Summary of Changes\n\nAdded 'messages' as alias for 'msgs' in filter field matching so both forms work for filtering by message count.

@ -0,0 +1,13 @@
---
# claudbg-ysj0
title: 'TUI: prevent transcript overflow into sub-agents panel; add Ctrl+L to redraw'
status: completed
type: bug
priority: normal
created_at: 2026-04-01T16:47:10Z
updated_at: 2026-04-01T16:55:12Z
---
Occasionally the Transcript widget overflows into the Sub-Agents panel, making the UI look broken. Prevent this overflow from happening. Also add a Ctrl+L keybinding to force a full screen redraw/reset to recover from any rendering artifacts.
Fixed transcript overflow by switching chat-log layout constraint from Constraint::Min(0) to Constraint::Fill(1), ensuring the panel never bleeds into the sub-agents column. Added Ctrl+L keybinding: the transcript event handler passes it through so the global handler sets state.needs_clear=true; the event loop calls terminal.clear() before the next draw. Updated help modal to document Ctrl+L.

@ -1,7 +1 @@
# TODO
* TUI: `/` should move the selected pane to the "search" bar (similar to less searching) — claudbg-vqhj
* TUI: Model is not showing up in the sessions list... — claudbg-j9az
* TUI: Add the ability to filter by tokens in and tokens out (`in:<int>`, `out:<int>`, `tokens:<int>`, supports < and > too) — claudbg-6m2c
* TUI: In addition to G/gg for navigating to the top/bottom of the transcript, support Home (top) and End (bottom). — claudbg-e49f
* TUI: For the currently selected agent, color code the selected agent instead of the `>` cursor. — claudbg-8bs3

@ -33,10 +33,13 @@ pub async fn open_db(path: &PathBuf, clear: bool) -> crate::error::Result<DbHand
Ok(handle)
}
/// Returns the default database path: `~/.claude/claudbg.db`.
/// Returns the default database path: `~/.claude/claudbg/claudbg.db`.
pub fn default_db_path() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".claude").join("claudbg.db")
PathBuf::from(home)
.join(".claude")
.join("claudbg")
.join("claudbg.db")
}
#[cfg(test)]
@ -78,11 +81,14 @@ mod tests {
assert!(path.exists(), "db file should exist after clear+open");
}
/// `default_db_path` returns a path ending in `.claude/claudbg.db`.
/// `default_db_path` returns a path ending in `.claude/claudbg/claudbg.db`.
#[test]
fn default_db_path_suffix() {
let p = default_db_path();
let s = p.to_string_lossy();
assert!(s.ends_with(".claude/claudbg.db"), "unexpected path: {s}");
assert!(
s.ends_with(".claude/claudbg/claudbg.db"),
"unexpected path: {s}"
);
}
}

@ -138,7 +138,7 @@ impl Key {
"project" => Some(Key::Project),
"id" => Some(Key::Id),
"agents" => Some(Key::Agents),
"messages" => Some(Key::Messages),
"messages" | "msgs" => Some(Key::Messages),
"in" => Some(Key::In),
"out" => Some(Key::Out),
"tokens" => Some(Key::Tokens),
@ -230,7 +230,7 @@ fn parse_atom(atom: &str) -> Result<Predicate> {
let key = Key::parse(key_str).ok_or_else(|| {
AppError::Parse(format!(
"unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, in, out, tokens, date"
"unknown filter key '{key_str}' — valid keys: model, project, id, agents, messages, msgs, in, out, tokens, date"
))
})?;
@ -509,7 +509,11 @@ mod tests {
}
/// Build a row with the given token counts; all other fields use fixed defaults.
fn make_row_with_tokens(full_id: &str, input_tokens: u64, output_tokens: u64) -> SessionListItem {
fn make_row_with_tokens(
full_id: &str,
input_tokens: u64,
output_tokens: u64,
) -> SessionListItem {
SessionListItem {
short_id: full_id.get(..8).unwrap_or(full_id).to_string(),
full_id: full_id.to_string(),
@ -1071,7 +1075,8 @@ mod tests {
fn in_and_out_combined() {
// Large input AND small output — very "reading-heavy" sessions.
let f = Filter::parse("in>10000 AND out<500").unwrap();
let reading_heavy = make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 50000, 200);
let reading_heavy =
make_row_with_tokens("aaaaaaaa-0000-0000-0000-000000000000", 50000, 200);
let balanced = make_row_with_tokens("bbbbbbbb-0000-0000-0000-000000000000", 50000, 2000);
assert!(f.matches(&reading_heavy));
assert!(!f.matches(&balanced));

@ -15,8 +15,8 @@ use crate::tui::state::AppState;
// ---------------------------------------------------------------------------
/// Dialog dimensions.
const DIALOG_WIDTH: u16 = 36;
const DIALOG_HEIGHT: u16 = 36;
const DIALOG_WIDTH: u16 = 40;
const DIALOG_HEIGHT: u16 = 48;
/// 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\
@ -56,22 +58,29 @@ const HELP_TEXT: &str = "\
project:foo substring\n\
id:abc substring\n\
agents>0 numeric\n\
messages<50 numeric\n\
messages<50 numeric (msgs=alias)\n\
in>5000 input tokens\n\
out<1000 output tokens\n\
tokens>50000 total tokens\n\
date>2026-01 date\n\
date>2026-01 date (YYYY-MM)\n\
model:* non-empty (wildcard)\n\
Combine: expr AND expr\n\
expr OR expr\n\
\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\
\n\
Global\n\
q/Q quit\n\
q/Q quit (confirm)\n\
Ctrl+C quit immediately\n\
? this help\n\
c toggle color";
c toggle color\n\
Ctrl+L redraw screen";
// ---------------------------------------------------------------------------
// Rendering

@ -15,7 +15,7 @@ use std::time::Duration;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
@ -119,6 +119,16 @@ fn render(frame: &mut ratatui::Frame, state: &AppState) {
/// then falls back to global keybindings for any event not consumed by
/// the screen.
fn handle_event(event: Event, state: &mut AppState) {
// Ctrl+C quits immediately, bypassing any modal dialogs.
if let Event::Key(key) = &event
&& key.kind == KeyEventKind::Press
&& key.code == KeyCode::Char('c')
&& key.modifiers.contains(KeyModifiers::CONTROL)
{
state.should_quit = true;
return;
}
// Modal dialogs intercept all input while visible.
if state.show_quit_dialog {
handle_quit_dialog_event(event, state);
@ -145,6 +155,11 @@ fn handle_event(event: Event, state: &mut AppState) {
if key.kind != KeyEventKind::Press {
return;
}
// Ctrl+L: force a full terminal redraw to clear any rendering artifacts.
if key.code == KeyCode::Char('l') && key.modifiers.contains(KeyModifiers::CONTROL) {
state.needs_clear = true;
return;
}
match key.code {
KeyCode::Char('q') => state.should_quit = true,
KeyCode::Char('c') => state.color_enabled = !state.color_enabled,
@ -297,6 +312,12 @@ pub fn run_tui() -> Result<()> {
// Load transcript data lazily when entering a transcript screen.
maybe_load_transcript(&mut state);
// Ctrl+L: clear all terminal cells before the next draw to remove artifacts.
if state.needs_clear {
guard.terminal.clear()?;
state.needs_clear = false;
}
// Record the visible chat-log height before rendering so that
// Space / PageDown / PageUp can scroll by exactly one page.
// Layout: 4-row stats header + 3-row search bar + 2 border rows = 9 fixed rows.
@ -394,4 +415,40 @@ mod tests {
handle_event(key_press(KeyCode::Char('c')), &mut state);
assert!(state.color_enabled, "color toggled back on");
}
fn ctrl_key_press(code: KeyCode) -> Event {
Event::Key(KeyEvent {
code,
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
})
}
/// Ctrl+C quits immediately without showing the quit dialog.
#[test]
fn ctrl_c_quits_immediately() {
let mut state = AppState::new();
handle_event(ctrl_key_press(KeyCode::Char('c')), &mut state);
assert!(state.should_quit);
assert!(!state.show_quit_dialog);
}
/// Ctrl+C quits immediately even when the quit dialog is already open.
#[test]
fn ctrl_c_quits_through_quit_dialog() {
let mut state = AppState::new();
state.show_quit_dialog = true;
handle_event(ctrl_key_press(KeyCode::Char('c')), &mut state);
assert!(state.should_quit);
}
/// Ctrl+C quits immediately even when the help modal is open.
#[test]
fn ctrl_c_quits_through_help_modal() {
let mut state = AppState::new();
state.show_help = true;
handle_event(ctrl_key_press(KeyCode::Char('c')), &mut state);
assert!(state.should_quit);
}
}

@ -73,12 +73,13 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Compute the max width available for the project column so we can truncate.
// Fixed widths: 8+20+20+6+7 = 61, plus 5 separators = 66, plus 2 borders = 68.
// Use 30 chars as a safe default; the Min constraint will expand it.
// Use 10 chars as a minimum; the Min constraint will expand it.
// We pass this exact value to truncate_project so it left-truncates before
// ratatui ever sees the string — ensuring the *end* of the path is shown.
let project_max: usize = table_area
.width
.saturating_sub(68) // subtract fixed columns + separators + borders
.max(10) as usize;
let project_display_max = project_max + 30; // generous — actual render clips
// Apply active filter to the session rows.
let active_filter = Filter::parse(&state.filter_active).ok();
@ -96,7 +97,7 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
let rows: Vec<Row> = filtered_sessions
.iter()
.map(|s| {
let project = truncate_project(&s.project, project_display_max);
let project = truncate_project(&s.project, project_max);
Row::new(vec![
s.short_id.clone(),
s.date.clone(),
@ -174,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![
@ -261,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.
@ -287,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();
@ -325,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
}
@ -339,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).
@ -350,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
}
@ -641,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]
@ -728,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![
@ -432,9 +453,13 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
f.render_widget(header_paragraph, chunks[0]);
// ── Chat log + sub-agents panel (horizontal split) ──────────────────────
// Use Fill(1) + Length(30) so the chat log gets all remaining space after
// the sub-agents panel is allocated its fixed 30 columns. Fill is
// preferred over Min(0) here because it avoids any layout solver edge
// cases where Min(0) could allow content to escape its allocated rect.
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(30)])
.constraints([Constraint::Fill(1), Constraint::Length(30)])
.split(chunks[1]);
// ── Chat log ─────────────────────────────────────────────────────────────
@ -551,6 +576,11 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state.pending_g = false;
}
// Ctrl+L: request a full terminal redraw; pass through to global handler.
if key.code == KeyCode::Char('l') && key.modifiers.contains(KeyModifiers::CONTROL) {
return false;
}
match key.code {
// Toggle focus: ChatLog → SubagentsPanel → SearchInput → ChatLog.
KeyCode::Tab => {
@ -559,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;
@ -587,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.
@ -717,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.
@ -744,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;
@ -1328,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]
@ -1483,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,
@ -179,15 +189,21 @@ pub struct AppState {
// ── Lifecycle ───────────────────────────────────────────────────────────
/// Set to `true` to signal the event loop to exit.
pub should_quit: bool,
/// Set to `true` to request a full terminal clear before the next draw.
///
/// Cleared automatically by the event loop after `terminal.clear()` is called.
/// Used by Ctrl+L to recover from rendering artifacts.
pub needs_clear: bool,
}
impl AppState {
/// Return the path to the filter history file: `~/.claude/claudbg.tui.history`.
/// Return the path to the filter history file: `~/.claude/claudbg/claudbg.tui.history`.
fn history_file_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(
PathBuf::from(home)
.join(".claude")
.join("claudbg")
.join("claudbg.tui.history"),
)
}
@ -243,6 +259,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,
@ -252,12 +269,14 @@ 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,
pending_g: false,
color_enabled,
should_quit: false,
needs_clear: false,
}
}
@ -275,6 +294,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;
@ -300,6 +320,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;
@ -319,6 +340,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;
@ -338,6 +360,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