diff --git a/.beans/claudbg-gf58--tui-filter-panel-with-persistent-history.md b/.beans/claudbg-gf58--tui-filter-panel-with-persistent-history.md new file mode 100644 index 0000000..2f685f8 --- /dev/null +++ b/.beans/claudbg-gf58--tui-filter-panel-with-persistent-history.md @@ -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(...)` diff --git a/.beans/claudbg-zi1d--tui-session-list-shows-no-sessions-statesessions-n.md b/.beans/claudbg-zi1d--tui-session-list-shows-no-sessions-statesessions-n.md index f669ed4..e0b9950 100644 --- a/.beans/claudbg-zi1d--tui-session-list-shows-no-sessions-statesessions-n.md +++ b/.beans/claudbg-zi1d--tui-session-list-shows-no-sessions-statesessions-n.md @@ -1,11 +1,11 @@ --- # claudbg-zi1d title: TUI session list shows no sessions — state.sessions never populated on startup -status: in-progress +status: completed type: bug priority: high created_at: 2026-03-30T17:05:02Z -updated_at: 2026-03-30T17:05:05Z +updated_at: 2026-03-30T21:02:18Z 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/parser/discovery.rs` — `discover_sessions()` and `discover_agents_for_session()` - `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. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..55cffbf --- /dev/null +++ b/TODO.md @@ -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=` 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 diff --git a/flake.nix b/flake.nix index ee71b9d..35482a6 100644 --- a/flake.nix +++ b/flake.nix @@ -70,6 +70,7 @@ clang beans jq + rtk ]; }; }); diff --git a/specs/FILTER.md b/specs/FILTER.md index aa7af9a..a7f562d 100644 --- a/specs/FILTER.md +++ b/specs/FILTER.md @@ -25,3 +25,20 @@ Examples: * `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. + + +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'` + + + +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. + diff --git a/src/tui/screens/session_list.rs b/src/tui/screens/session_list.rs index 1a72fbc..daf3522 100644 --- a/src/tui/screens/session_list.rs +++ b/src/tui/screens/session_list.rs @@ -4,12 +4,13 @@ use ratatui::Frame; use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind}; -use ratatui::layout::{Constraint, Rect}; -use ratatui::style::{Modifier, Style}; -use ratatui::text::Text; -use ratatui::widgets::{Block, Borders, Row, Table, TableState}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +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 @@ -39,6 +40,18 @@ fn truncate_project(path: &str, max_chars: usize) -> String { /// Draw the session-list screen onto `area` of the given frame. 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: // 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 @@ -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. // 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. - let project_max: usize = area + 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 - let rows: Vec = state + // Apply active filter to the session rows. + let active_filter = Filter::parse(&state.filter_active).ok(); + let filtered_sessions: Vec<_> = state .sessions + .iter() + .filter(|s| { + active_filter + .as_ref() + .map(|f| f.matches(*s)) + .unwrap_or(true) + }) + .collect(); + + let rows: Vec = filtered_sessions .iter() .map(|s| { 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(); + let table_title = if state.filter_active.is_empty() { + " Sessions ".to_string() + } else { + format!(" Sessions [filter: {}] ", state.filter_active) + }; + let block = Block::default() - .title(" Sessions ") + .title(table_title) .borders(Borders::ALL); 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. let mut table_state = TableState::default(); - if !state.sessions.is_empty() { - table_state.select(Some(state.list_selected)); + if !filtered_sessions.is_empty() { + 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. - if state.sessions.is_empty() { - let hint = ratatui::widgets::Paragraph::new(Text::raw("No sessions found.")) - .alignment(ratatui::layout::Alignment::Center); + if filtered_sessions.is_empty() { + let hint = Paragraph::new(Text::raw(if state.filter_active.is_empty() { + "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). let inner = Rect { - x: area.x + 1, - y: area.y + area.height / 2, - width: area.width.saturating_sub(2), + x: table_area.x + 1, + y: table_area.y + table_area.height / 2, + width: table_area.width.saturating_sub(2), height: 1, }; 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 // --------------------------------------------------------------------------- +/// 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 { + 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. /// /// 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; } + // 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 { // Navigate up. KeyCode::Up | KeyCode::Char('k') => { @@ -138,20 +240,33 @@ pub fn handle_session_list_event(event: Event, state: &mut AppState) -> bool { } // Navigate down. 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 + 1).min(state.sessions.len() - 1); + (state.list_selected + 1).min(visible.len() - 1); } true } // Enter session transcript. KeyCode::Enter => { - if let Some(item) = state.sessions.get(state.list_selected) { - let full_id = item.full_id.clone(); + let visible = filtered_session_indices(state); + 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); } 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. KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => { 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 // --------------------------------------------------------------------------- @@ -368,4 +563,135 @@ mod tests { let consumed = handle_session_list_event(Event::FocusGained, &mut state); 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); + } } diff --git a/src/tui/state.rs b/src/tui/state.rs index fb8defb..de6503f 100644 --- a/src/tui/state.rs +++ b/src/tui/state.rs @@ -4,6 +4,10 @@ //! that drive the TUI navigation model. This module holds pure data — no //! 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::parser::discovery::AgentRef; @@ -122,6 +126,18 @@ pub struct AppState { /// Whether the keyboard-shortcut help overlay is visible. 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, + /// When browsing history via Up/Down, the current history index. + /// `None` means the user is not currently browsing history. + pub filter_history_pos: Option, + // ── Display ───────────────────────────────────────────────────────────── /// Whether color coding is enabled in transcript views. /// @@ -136,6 +152,43 @@ pub struct AppState { } impl AppState { + /// Return the path to the filter history file: `~/.claude/claudbg.tui.history`. + fn history_file_path() -> Option { + 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 { + 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. /// /// 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") .map(|v| v.is_empty()) .unwrap_or(true); + let filter_history = Self::load_history(); Self { screen: Screen::SessionList, sessions: Vec::new(), @@ -158,6 +212,10 @@ impl AppState { focus: Focus::default(), show_quit_dialog: false, show_help: false, + filter_input: String::new(), + filter_active: String::new(), + filter_history, + filter_history_pos: None, color_enabled, should_quit: false, }