feat(claudbg-gf58): TUI filter panel with persistent query history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 403762400e
commit 5bc207c455

@ -0,0 +1,59 @@
---
# claudbg-gf58
title: 'TUI: filter panel with persistent history'
status: completed
type: task
priority: normal
created_at: 2026-03-31T00:33:21Z
updated_at: 2026-03-31T16:49:05Z
parent: claudbg-2vwx
blocked_by:
- claudbg-4bms
---
Add a filter text box panel at the bottom of the TUI screen (visible on sessions list and agents list screens).
**Behavior:**
- Press `t` to focus the filter input directly; Tab cycles to it among panels
- Type a filter query and press Enter to apply it to the current list
- Escape clears the text input
- Up/Down arrow keys cycle through scroll-back history of previous queries
- After pressing Enter, focus returns to the main list panel
- The filter does not apply to transcript screens (transcripts cannot be filtered)
**History persistence:**
- History is saved to and loaded from `~/.claude/claudbg.tui.history` (one query per line)
- History persists across TUI sessions
Depends on the filter query parser.
## Summary of Changes
### Files Modified
**`src/tui/state.rs`**
- Added `FilterInput` variant to the `Focus` enum
- Added filter state fields to `AppState`: `filter_input`, `filter_active`, `filter_history`, `filter_history_pos`
- Added `history_file_path()` helper (resolves to `~/.claude/claudbg.tui.history`)
- Added `load_history()` to load history lines from disk at TUI startup
- Added `append_history_to_disk()` to append a single query in append mode (no full rewrite)
- Updated `AppState::new()` to load history and initialize filter fields
**`src/tui/screens/session_list.rs`**
- Added imports for `Direction`, `Layout`, `Color`, `Span`, `Line`, `Paragraph`, `Focus`, `Filter`
- Rewrote `render_session_list` to split area: top for table, bottom 3 rows for filter bar
- Added `render_filter_bar` function: shows "Filter: " label + input text + cursor when focused
- Filter bar highlights border in yellow when focused, shows placeholder hint when unfocused/empty
- Sessions table applies active filter via `Filter::parse` + `Filter::matches`
- Table title shows active filter query when one is set
- Added `filtered_session_indices` helper for navigation with filtered rows
- Updated `handle_session_list_event`: when `Focus::FilterInput`, dispatches to `handle_filter_input_event`
- Added `t` key and Tab to focus filter input; Tab from filter returns focus to list
- Updated Down/Enter to work with filtered session indices
- Added `handle_filter_input_event` handling: char input, Backspace, Enter (apply + return focus), Escape (clear input, stay focused), Up/Down for history browsing, Tab (cycle focus)
**`src/tui/screens/transcript.rs`**
- Added `Focus::FilterInput` to Tab and Up/Down match arms (treated same as ChatLog on transcript screen)
**`src/cli.rs`**
- Fixed pre-existing clippy warning: replaced `map_or(false, ...)` with `is_ok_and(...)`

@ -1,11 +1,11 @@
--- ---
# claudbg-zi1d # claudbg-zi1d
title: TUI session list shows no sessions — state.sessions never populated on startup title: TUI session list shows no sessions — state.sessions never populated on startup
status: in-progress status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-30T17:05:02Z created_at: 2026-03-30T17:05:02Z
updated_at: 2026-03-30T17:05:05Z updated_at: 2026-03-30T21:02:18Z
parent: claudbg-i6l2 parent: claudbg-i6l2
--- ---
@ -39,3 +39,5 @@ If `discover_sessions()` fails, start with an empty list (don't crash). Log the
- `src/tui/run.rs``run_tui()` function, add session loading before the event loop - `src/tui/run.rs``run_tui()` function, add session loading before the event loop
- `src/parser/discovery.rs``discover_sessions()` and `discover_agents_for_session()` - `src/parser/discovery.rs``discover_sessions()` and `discover_agents_for_session()`
- `src/tui/state.rs``SessionListItem` struct - `src/tui/state.rs``SessionListItem` struct
## Summary of Changes\n\nIn run_tui(), before the event loop: call discover_sessions() (silently empty on error), sort by modified_at descending, map each SessionRef to SessionListItem (short_id=8 chars, full_id, date=formatted timestamp, project=cwd, model/msg_count=empty/0, agent_count from discover_agents_for_session). Assign to state.sessions.

@ -0,0 +1,15 @@
# TODO
* Running `claudbg sessions` with no sub-command should list sessions, like `claudbg sessions list`
* Running listing sessions and agent sessions should only show the 10 latest ones.
* The flag `--limit=<int|all>` allows setting that limit to a custom integer, or the keyword `all` to show all
* Session and agent session transcripts should color-code tool use and output to make it visually clearer
* `[assistant]` should be orange
* `[user]` should be grey
* `[tool: Foo]` should be blue
* `[tool_result]` should be green
* `[tool_result (error)]` should be red
* Accept the `--[no-]color` flag to enable/disable color (enabled by default in interactive terminals)
* Color coding should be enabled by default in the TUI with `c` toggling it off/on
* Honor the NO_COLOR environment variable for both the CLI and TUI
* Plan and ticket the new feature @./specs/FILTER.md

@ -70,6 +70,7 @@
clang clang
beans beans
jq jq
rtk
]; ];
}; };
}); });

@ -25,3 +25,20 @@ Examples:
* `date>2026-03-15 AND date<2026-03-20` sessions between 03-15 and 03-20. * `date>2026-03-15 AND date<2026-03-20` sessions between 03-15 and 03-20.
When a query uses a malformed syntax or a key which is not found, the command should fail to evaluate with an error displayed to the user. When a query uses a malformed syntax or a key which is not found, the command should fail to evaluate with an error displayed to the user.
<cli>
This integrates into the CLI by adding a `--filter` flag which accepts a string filter query.
example: `claudbg sessions list --filter 'agents>0'` lists all session with sub-agents.
The `--filter` query can be passed multiple times, resulting in the queryies being combined into with AND
example: `caudbg sesions list --filter 'agents>0' --filter 'messages>100'` is equivalent to `claudbg sessions list --filter 'agents>0 AND messages>100'`
</cli>
<tui>
The TUI should include a text box panel at the bottom of the sceen wich accepts a filter.
When the user presses enter this query is run on the current main panel (sessions or agents).
Pressing escape clears the text input.
Pressing up/down arrows cycles through a scroll-back history.
Users cycle through panels with Tab to get to this, or press `t` to navigate directly to the text input.
When users press enter they are navigated to the main panel so they can interact with the filtered data.
This text box does not (yet) interact with session/sub-agent transcripts as these objects cannot be filtered.
</tui>

@ -4,12 +4,13 @@
use ratatui::Frame; use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind}; use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Rect}; use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Text; use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Row, Table, TableState}; use ratatui::widgets::{Block, Borders, Paragraph, Row, Table, TableState};
use crate::tui::state::AppState; use crate::filter::Filter;
use crate::tui::state::{AppState, Focus};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Project path truncation // Project path truncation
@ -39,6 +40,18 @@ fn truncate_project(path: &str, max_chars: usize) -> String {
/// Draw the session-list screen onto `area` of the given frame. /// Draw the session-list screen onto `area` of the given frame.
pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) { pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Split the screen: top portion for the sessions table, bottom 3 rows for the
// filter bar (border top + content + border bottom).
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)])
.split(area);
let table_area = chunks[0];
let filter_area = chunks[1];
// ── Sessions table ───────────────────────────────────────────────────────
// Column constraints: // Column constraints:
// ID (8) | Date (20) | Project (dynamic) | Model (20) | Msgs (6) | Agents (7) // ID (8) | Date (20) | Project (dynamic) | Model (20) | Msgs (6) | Agents (7)
// Fixed columns total = 8 + 1 + 20 + 1 + 20 + 1 + 6 + 1 + 7 = 65 chars + borders // Fixed columns total = 8 + 1 + 20 + 1 + 20 + 1 + 6 + 1 + 7 = 65 chars + borders
@ -57,14 +70,26 @@ 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. // 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. // 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 30 chars as a safe default; the Min constraint will expand it.
let project_max: usize = area let project_max: usize = table_area
.width .width
.saturating_sub(68) // subtract fixed columns + separators + borders .saturating_sub(68) // subtract fixed columns + separators + borders
.max(10) as usize; .max(10) as usize;
let project_display_max = project_max + 30; // generous — actual render clips let project_display_max = project_max + 30; // generous — actual render clips
let rows: Vec<Row> = state // Apply active filter to the session rows.
let active_filter = Filter::parse(&state.filter_active).ok();
let filtered_sessions: Vec<_> = state
.sessions .sessions
.iter()
.filter(|s| {
active_filter
.as_ref()
.map(|f| f.matches(*s))
.unwrap_or(true)
})
.collect();
let rows: Vec<Row> = filtered_sessions
.iter() .iter()
.map(|s| { .map(|s| {
let project = truncate_project(&s.project, project_display_max); let project = truncate_project(&s.project, project_display_max);
@ -79,8 +104,14 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
}) })
.collect(); .collect();
let table_title = if state.filter_active.is_empty() {
" Sessions ".to_string()
} else {
format!(" Sessions [filter: {}] ", state.filter_active)
};
let block = Block::default() let block = Block::default()
.title(" Sessions ") .title(table_title)
.borders(Borders::ALL); .borders(Borders::ALL);
let highlight_style = Style::default().add_modifier(Modifier::REVERSED); let highlight_style = Style::default().add_modifier(Modifier::REVERSED);
@ -93,31 +124,97 @@ pub fn render_session_list(f: &mut Frame, area: Rect, state: &AppState) {
// Sync ratatui's TableState with our AppState selection. // Sync ratatui's TableState with our AppState selection.
let mut table_state = TableState::default(); let mut table_state = TableState::default();
if !state.sessions.is_empty() { if !filtered_sessions.is_empty() {
table_state.select(Some(state.list_selected)); let clamped = state.list_selected.min(filtered_sessions.len() - 1);
table_state.select(Some(clamped));
} }
f.render_stateful_widget(table, area, &mut table_state); f.render_stateful_widget(table, table_area, &mut table_state);
// Render an "empty" hint when there are no sessions. // Render an "empty" hint when there are no sessions.
if state.sessions.is_empty() { if filtered_sessions.is_empty() {
let hint = ratatui::widgets::Paragraph::new(Text::raw("No sessions found.")) let hint = Paragraph::new(Text::raw(if state.filter_active.is_empty() {
.alignment(ratatui::layout::Alignment::Center); "No sessions found."
} else {
"No sessions match the current filter."
}))
.alignment(ratatui::layout::Alignment::Center);
// Place the hint in the inner area (inside the block border). // Place the hint in the inner area (inside the block border).
let inner = Rect { let inner = Rect {
x: area.x + 1, x: table_area.x + 1,
y: area.y + area.height / 2, y: table_area.y + table_area.height / 2,
width: area.width.saturating_sub(2), width: table_area.width.saturating_sub(2),
height: 1, height: 1,
}; };
f.render_widget(hint, inner); f.render_widget(hint, inner);
} }
// ── Filter bar ───────────────────────────────────────────────────────────
render_filter_bar(f, filter_area, state);
}
/// Draw the filter input bar at the bottom of the screen.
fn render_filter_bar(f: &mut Frame, area: Rect, state: &AppState) {
let focused = state.focus == Focus::FilterInput;
let border_style = if focused {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let block = Block::default()
.title(" Filter ")
.borders(Borders::ALL)
.border_style(border_style);
// 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)));
Line::from(spans)
} else if state.filter_input.is_empty() && state.filter_active.is_empty() {
Line::from(vec![
label,
Span::styled(
"Press 't' or Tab to focus — type a query and press Enter",
Style::default().fg(Color::DarkGray),
),
])
} else {
Line::from(vec![label, Span::raw(state.filter_input.clone())])
};
let paragraph = Paragraph::new(input_text).block(block);
f.render_widget(paragraph, area);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Event handling // Event handling
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Return the sessions that match the current active filter.
///
/// If no filter is active, all sessions are returned.
fn filtered_session_indices(state: &AppState) -> Vec<usize> {
let active_filter = Filter::parse(&state.filter_active).ok();
state
.sessions
.iter()
.enumerate()
.filter(|(_, s)| {
active_filter
.as_ref()
.map(|f| f.matches(*s))
.unwrap_or(true)
})
.map(|(i, _)| i)
.collect()
}
/// Handle a crossterm [`Event`] for the session-list screen. /// Handle a crossterm [`Event`] for the session-list screen.
/// ///
/// Returns `true` if the event was consumed (the caller should not process it /// Returns `true` if the event was consumed (the caller should not process it
@ -130,6 +227,11 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
return false; return false;
} }
// When the filter input is focused, handle filter-specific keys first.
if state.focus == Focus::FilterInput {
return handle_filter_input_event(key.code, state);
}
match key.code { match key.code {
// Navigate up. // Navigate up.
KeyCode::Up | KeyCode::Char('k') => { KeyCode::Up | KeyCode::Char('k') => {
@ -138,20 +240,33 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
} }
// Navigate down. // Navigate down.
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
if !state.sessions.is_empty() { let visible = filtered_session_indices(state);
if !visible.is_empty() {
state.list_selected = state.list_selected =
(state.list_selected + 1).min(state.sessions.len() - 1); (state.list_selected + 1).min(visible.len() - 1);
} }
true true
} }
// Enter session transcript. // Enter session transcript.
KeyCode::Enter => { KeyCode::Enter => {
if let Some(item) = state.sessions.get(state.list_selected) { let visible = filtered_session_indices(state);
let full_id = item.full_id.clone(); let clamped = state.list_selected.min(visible.len().saturating_sub(1));
if let Some(&real_idx) = visible.get(clamped) {
let full_id = state.sessions[real_idx].full_id.clone();
state.enter_transcript(full_id); state.enter_transcript(full_id);
} }
true true
} }
// Focus the filter input directly.
KeyCode::Char('t') => {
state.focus = Focus::FilterInput;
true
}
// Tab cycles focus: list → filter → list.
KeyCode::Tab => {
state.focus = Focus::FilterInput;
true
}
// Quit — show confirmation dialog rather than exiting immediately. // Quit — show confirmation dialog rather than exiting immediately.
KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
state.show_quit_dialog = true; state.show_quit_dialog = true;
@ -166,6 +281,86 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool {
} }
} }
/// Handle a key event while the filter input bar is focused.
///
/// 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.
KeyCode::Char(c) => {
state.filter_input.push(c);
state.filter_history_pos = None;
true
}
// Backspace: remove last char.
KeyCode::Backspace => {
state.filter_input.pop();
state.filter_history_pos = None;
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();
// Record in history (skip duplicate consecutive entries).
if !query.is_empty() {
let is_dup = state.filter_history.last().map(|l| l == &query).unwrap_or(false);
if !is_dup {
state.filter_history.push(query.clone());
AppState::append_history_to_disk(&query);
}
}
state.filter_active = query;
state.filter_history_pos = None;
// Reset list selection when filter changes.
state.list_selected = 0;
// Return focus to the main list.
state.focus = Focus::ChatLog;
true
}
// Escape: clear the text input (but keep the panel visible).
KeyCode::Esc => {
state.filter_input.clear();
state.filter_history_pos = None;
true
}
// Up arrow: browse back through history.
KeyCode::Up => {
if state.filter_history.is_empty() {
return true;
}
let new_pos = match state.filter_history_pos {
None => state.filter_history.len() - 1,
Some(p) => p.saturating_sub(1),
};
state.filter_history_pos = Some(new_pos);
state.filter_input = state.filter_history[new_pos].clone();
true
}
// Down arrow: browse forward through history (or clear when past the end).
KeyCode::Down => {
let Some(pos) = state.filter_history_pos else {
return true;
};
if pos + 1 < state.filter_history.len() {
let new_pos = pos + 1;
state.filter_history_pos = Some(new_pos);
state.filter_input = state.filter_history[new_pos].clone();
} else {
// Past the end: clear input and stop browsing history.
state.filter_history_pos = None;
state.filter_input.clear();
}
true
}
// Tab: cycle focus back to the list.
KeyCode::Tab => {
state.focus = Focus::ChatLog;
true
}
_ => true, // consume all other keys while filter is focused
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -368,4 +563,135 @@ mod tests {
let consumed = handle_session_list_event(Event::FocusGained, &mut state); let consumed = handle_session_list_event(Event::FocusGained, &mut state);
assert!(!consumed); assert!(!consumed);
} }
// ── Filter input focus ───────────────────────────────────────────────────
#[test]
fn t_key_focuses_filter_input() {
let mut state = AppState::new();
assert_eq!(state.focus, Focus::ChatLog);
let consumed = handle_session_list_event(press(KeyCode::Char('t')), &mut state);
assert!(consumed);
assert_eq!(state.focus, Focus::FilterInput);
}
#[test]
fn tab_focuses_filter_input() {
let mut state = AppState::new();
let consumed = handle_session_list_event(press(KeyCode::Tab), &mut state);
assert!(consumed);
assert_eq!(state.focus, Focus::FilterInput);
}
#[test]
fn tab_from_filter_cycles_back_to_list() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
handle_session_list_event(press(KeyCode::Tab), &mut state);
assert_eq!(state.focus, Focus::ChatLog);
}
// ── Filter character input ───────────────────────────────────────────────
#[test]
fn char_input_appends_to_filter_input() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
handle_session_list_event(press(KeyCode::Char('m')), &mut state);
handle_session_list_event(press(KeyCode::Char('o')), &mut state);
assert_eq!(state.filter_input, "mo");
}
#[test]
fn backspace_removes_last_char() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "mo".to_string();
handle_session_list_event(press(KeyCode::Backspace), &mut state);
assert_eq!(state.filter_input, "m");
}
#[test]
fn esc_clears_filter_input_stays_focused() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "foo".to_string();
handle_session_list_event(press(KeyCode::Esc), &mut state);
assert_eq!(state.filter_input, "");
assert_eq!(state.focus, Focus::FilterInput);
}
#[test]
fn enter_applies_filter_and_returns_focus_to_list() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "model:haiku".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.filter_active, "model:haiku");
assert_eq!(state.focus, Focus::ChatLog);
// Should be in history now.
assert!(state.filter_history.contains(&"model:haiku".to_string()));
}
#[test]
fn enter_empty_clears_active_filter() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_active = "model:haiku".to_string();
state.filter_input = "".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.filter_active, "");
}
#[test]
fn duplicate_consecutive_entry_not_added_to_history() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_input = "model:haiku".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
let count_before = state.filter_history.len();
state.focus = Focus::FilterInput;
state.filter_input = "model:haiku".to_string();
handle_session_list_event(press(KeyCode::Enter), &mut state);
assert_eq!(state.filter_history.len(), count_before);
}
// ── Filter history navigation ────────────────────────────────────────────
#[test]
fn up_cycles_through_history() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_history = vec!["first".to_string(), "second".to_string()];
handle_session_list_event(press(KeyCode::Up), &mut state);
assert_eq!(state.filter_input, "second");
assert_eq!(state.filter_history_pos, Some(1));
handle_session_list_event(press(KeyCode::Up), &mut state);
assert_eq!(state.filter_input, "first");
assert_eq!(state.filter_history_pos, Some(0));
}
#[test]
fn down_goes_forward_in_history() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_history = vec!["first".to_string(), "second".to_string()];
state.filter_history_pos = Some(0);
state.filter_input = "first".to_string();
handle_session_list_event(press(KeyCode::Down), &mut state);
assert_eq!(state.filter_input, "second");
assert_eq!(state.filter_history_pos, Some(1));
}
#[test]
fn down_past_end_clears_input() {
let mut state = AppState::new();
state.focus = Focus::FilterInput;
state.filter_history = vec!["first".to_string()];
state.filter_history_pos = Some(0);
state.filter_input = "first".to_string();
handle_session_list_event(press(KeyCode::Down), &mut state);
assert_eq!(state.filter_input, "");
assert_eq!(state.filter_history_pos, None);
}
} }

@ -4,6 +4,10 @@
//! that drive the TUI navigation model. This module holds pure data — no //! that drive the TUI navigation model. This module holds pure data — no
//! terminal I/O or rendering logic lives here. //! terminal I/O or rendering logic lives here.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use crate::models::session::RawEntry; use crate::models::session::RawEntry;
use crate::parser::discovery::AgentRef; use crate::parser::discovery::AgentRef;
@ -122,6 +126,18 @@ pub struct AppState {
/// Whether the keyboard-shortcut help overlay is visible. /// Whether the keyboard-shortcut help overlay is visible.
pub show_help: bool, pub show_help: bool,
// ── Filter ──────────────────────────────────────────────────────────────
/// Text currently being typed in the filter input box.
pub filter_input: String,
/// The last successfully applied filter query string.
/// Empty string means no filter is active.
pub filter_active: String,
/// History of previously applied filter queries (most-recent last).
pub filter_history: Vec<String>,
/// When browsing history via Up/Down, the current history index.
/// `None` means the user is not currently browsing history.
pub filter_history_pos: Option<usize>,
// ── Display ───────────────────────────────────────────────────────────── // ── Display ─────────────────────────────────────────────────────────────
/// Whether color coding is enabled in transcript views. /// Whether color coding is enabled in transcript views.
/// ///
@ -136,6 +152,43 @@ pub struct AppState {
} }
impl AppState { impl AppState {
/// Return the path to the filter history file: `~/.claude/claudbg.tui.history`.
fn history_file_path() -> Option<PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(PathBuf::from(home).join(".claude").join("claudbg.tui.history"))
}
/// Load filter history lines from disk.
///
/// Returns an empty vec if the file does not exist or cannot be read.
fn load_history() -> Vec<String> {
let Some(path) = Self::history_file_path() else {
return Vec::new();
};
std::fs::read_to_string(&path)
.unwrap_or_default()
.lines()
.filter(|l| !l.trim().is_empty())
.map(str::to_string)
.collect()
}
/// Append a single query to the history file (open in append mode).
///
/// Does nothing if the history file path cannot be determined.
pub fn append_history_to_disk(query: &str) {
let Some(path) = Self::history_file_path() else {
return;
};
// Create parent directory if it doesn't exist.
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{query}");
}
}
/// Create a fresh [`AppState`] ready for the session-list screen. /// Create a fresh [`AppState`] ready for the session-list screen.
/// ///
/// All data fields are initialised to empty / zero so the TUI can render /// All data fields are initialised to empty / zero so the TUI can render
@ -146,6 +199,7 @@ impl AppState {
let color_enabled = std::env::var("NO_COLOR") let color_enabled = std::env::var("NO_COLOR")
.map(|v| v.is_empty()) .map(|v| v.is_empty())
.unwrap_or(true); .unwrap_or(true);
let filter_history = Self::load_history();
Self { Self {
screen: Screen::SessionList, screen: Screen::SessionList,
sessions: Vec::new(), sessions: Vec::new(),
@ -158,6 +212,10 @@ impl AppState {
focus: Focus::default(), focus: Focus::default(),
show_quit_dialog: false, show_quit_dialog: false,
show_help: false, show_help: false,
filter_input: String::new(),
filter_active: String::new(),
filter_history,
filter_history_pos: None,
color_enabled, color_enabled,
should_quit: false, should_quit: false,
} }

Loading…
Cancel
Save