//! TUI application state model. //! //! Defines the central [`AppState`] struct and the [`Screen`] / [`Focus`] enums //! 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; // --------------------------------------------------------------------------- // Lightweight display types // --------------------------------------------------------------------------- /// Lightweight summary of a session, used in the session-list screen. /// /// Carries only the fields needed to render the list rows; heavier data /// (transcript entries, sub-agent details) is loaded on demand when the user /// navigates into a session. #[derive(Debug, Clone)] pub struct SessionListItem { /// 8-character prefix of the session UUID (display form). pub short_id: String, /// Full session UUID (used when resolving to load transcript entries). pub full_id: String, /// Human-readable date string (e.g. `"2025-03-30 14:22:01"`). pub date: String, /// Project path recovered from the `cwd` field of the first JSONL line. /// Falls back to an empty string when unavailable. pub project: String, /// Model identifier from the first assistant message (e.g. `"claude-sonnet-4-6"`). /// Empty string when no assistant message is present. pub model: String, /// Total number of user + assistant messages in the session. pub msg_count: usize, /// Number of sub-agent runs attached to this session. pub agent_count: usize, } // --------------------------------------------------------------------------- // Screen enum // --------------------------------------------------------------------------- /// The active screen (navigation state) of the TUI. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Screen { /// The session list overview — the TUI's home screen. SessionList, /// A session transcript view for the given session UUID. Transcript { /// Full UUID of the session being viewed. session_id: String, }, /// A sub-agent transcript view nested under a parent session. SubagentTranscript { /// Full UUID of the parent session. parent_session_id: String, /// The agent's UUID (as returned by [`AgentRef::agent_id`]). agent_id: String, }, } // --------------------------------------------------------------------------- // Focus enum // --------------------------------------------------------------------------- /// Which panel currently holds keyboard focus on the transcript screen. /// /// On the session-list screen `Focus` is not meaningful; it is tracked here /// so the value is preserved when the user navigates back and forth. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Focus { /// The main chat-log pane (default). #[default] ChatLog, /// The sub-agents side panel (shown when sub-agents are present). SubagentsPanel, /// The filter input bar at the bottom of the session-list screen. FilterInput, } // --------------------------------------------------------------------------- // AppState // --------------------------------------------------------------------------- /// Central application state for the TUI. /// /// All mutable state that drives rendering and event handling lives here. /// Rendering code reads this struct; event handlers mutate it. #[derive(Debug)] pub struct AppState { // ── Navigation ────────────────────────────────────────────────────────── /// Currently displayed screen. pub screen: Screen, // ── Session list ──────────────────────────────────────────────────────── /// All sessions, sorted most-recent-first. Loaded once at startup. pub sessions: Vec, /// Index of the highlighted row in the session list. pub list_selected: usize, // ── Transcript ────────────────────────────────────────────────────────── /// Parsed JSONL entries for the currently viewed session or sub-agent. pub transcript_entries: Vec, /// Vertical scroll offset (lines from the top of the chat log). pub transcript_scroll: usize, /// Horizontal scroll offset (columns from the left edge of the chat log). pub transcript_h_scroll: usize, // ── Sub-agents panel ──────────────────────────────────────────────────── /// Sub-agent references for the currently viewed session. pub subagents: Vec, /// Index of the highlighted row in the sub-agents panel. pub subagent_selected: usize, // ── Focus ─────────────────────────────────────────────────────────────── /// Which panel currently holds keyboard focus. pub focus: Focus, // ── Modals ────────────────────────────────────────────────────────────── /// Whether the "quit?" confirmation dialog is visible. pub show_quit_dialog: bool, /// 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. /// /// Initialised from the `NO_COLOR` environment variable: `false` when /// `NO_COLOR` is set and non-empty, `true` otherwise. Can be toggled at /// runtime with the `c` key. pub color_enabled: bool, // ── Lifecycle ─────────────────────────────────────────────────────────── /// Set to `true` to signal the event loop to exit. pub should_quit: bool, } 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 /// immediately; the caller is responsible for populating [`AppState::sessions`] /// before the first frame is drawn. pub fn new() -> Self { // Color is on by default; disabled when NO_COLOR is set and non-empty. 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(), list_selected: 0, transcript_entries: Vec::new(), transcript_scroll: 0, transcript_h_scroll: 0, subagents: Vec::new(), subagent_selected: 0, 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, } } /// Transition to the transcript screen for a session. /// /// Resets all transcript / sub-agent scroll and selection state so that /// the new view starts from the top. The caller is responsible for /// loading [`AppState::transcript_entries`] and [`AppState::subagents`] /// after calling this method. pub fn enter_transcript(&mut self, session_id: impl Into) { self.screen = Screen::Transcript { session_id: session_id.into(), }; self.transcript_entries.clear(); self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.subagents.clear(); self.subagent_selected = 0; self.focus = Focus::ChatLog; } /// Transition to the sub-agent transcript screen. /// /// Resets transcript scroll state. The caller is responsible for loading /// [`AppState::transcript_entries`] for the agent after calling this method. pub fn enter_subagent_transcript( &mut self, parent_session_id: impl Into, agent_id: impl Into, ) { self.screen = Screen::SubagentTranscript { parent_session_id: parent_session_id.into(), agent_id: agent_id.into(), }; self.transcript_entries.clear(); self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.focus = Focus::ChatLog; } /// Return to the session-list screen. /// /// Clears transcript and sub-agent data. The session list and the /// previously selected row are preserved so the cursor position is /// restored after navigating back. pub fn go_back(&mut self) { self.screen = Screen::SessionList; self.transcript_entries.clear(); self.transcript_scroll = 0; self.transcript_h_scroll = 0; self.subagents.clear(); self.subagent_selected = 0; self.focus = Focus::ChatLog; } } impl Default for AppState { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; /// `AppState::new()` starts on `Screen::SessionList`. #[test] fn new_starts_on_session_list() { let state = AppState::new(); assert_eq!(state.screen, Screen::SessionList); } /// `AppState::new()` starts with empty sessions and zero selection. #[test] fn new_starts_with_empty_sessions() { let state = AppState::new(); assert!(state.sessions.is_empty()); assert_eq!(state.list_selected, 0); } /// `AppState::new()` starts with modals closed. #[test] fn new_starts_with_modals_closed() { let state = AppState::new(); assert!(!state.show_quit_dialog); assert!(!state.show_help); } /// `enter_transcript` switches to `Screen::Transcript` and resets scroll. #[test] fn enter_transcript_switches_screen() { let mut state = AppState::new(); state.transcript_scroll = 42; state.transcript_h_scroll = 7; state.enter_transcript("abc12345-0000-0000-0000-000000000000"); assert_eq!( state.screen, Screen::Transcript { session_id: "abc12345-0000-0000-0000-000000000000".to_string(), } ); assert_eq!(state.transcript_scroll, 0); assert_eq!(state.transcript_h_scroll, 0); } /// `enter_transcript` clears previous transcript entries. #[test] fn enter_transcript_clears_entries() { let mut state = AppState::new(); // Insert a dummy entry via direct field access (simplest approach). state.transcript_entries.push(RawEntry { entry_type: Some("user".to_string()), session_id: None, parent_session_id: None, message: None, system_message: None, cwd: None, timestamp: None, duration_ms: None, extra: Default::default(), }); state.enter_transcript("new-session"); assert!(state.transcript_entries.is_empty()); } /// `enter_subagent_transcript` sets `Screen::SubagentTranscript`. #[test] fn enter_subagent_transcript_sets_screen() { let mut state = AppState::new(); state.enter_subagent_transcript("parent-session", "agent-001"); assert_eq!( state.screen, Screen::SubagentTranscript { parent_session_id: "parent-session".to_string(), agent_id: "agent-001".to_string(), } ); } /// `go_back` returns to `Screen::SessionList` and clears transcript state. #[test] fn go_back_returns_to_session_list() { let mut state = AppState::new(); state.enter_transcript("some-session"); state.transcript_scroll = 10; state.go_back(); assert_eq!(state.screen, Screen::SessionList); assert_eq!(state.transcript_scroll, 0); assert!(state.transcript_entries.is_empty()); } /// `go_back` preserves `list_selected`. #[test] fn go_back_preserves_list_selected() { let mut state = AppState::new(); state.list_selected = 5; state.enter_transcript("some-session"); state.go_back(); assert_eq!(state.list_selected, 5); } /// `Focus::default()` is `ChatLog`. #[test] fn focus_default_is_chat_log() { assert_eq!(Focus::default(), Focus::ChatLog); } /// `SessionListItem` implements `Debug` and `Clone`. #[test] fn session_list_item_debug_clone() { let item = SessionListItem { short_id: "abc12345".to_string(), full_id: "abc12345-0000-0000-0000-000000000000".to_string(), date: "2025-03-30 14:00:00".to_string(), project: "/home/user/project".to_string(), model: "claude-sonnet-4-6".to_string(), msg_count: 10, agent_count: 2, }; let cloned = item.clone(); assert_eq!(cloned.short_id, item.short_id); let _ = format!("{item:?}"); } /// `AppState::new()` enables color when `NO_COLOR` is unset. #[test] fn new_color_enabled_when_no_color_unset() { // Remove NO_COLOR from the environment for this test. // SAFETY: single-threaded test; no other threads reading the env. unsafe { std::env::remove_var("NO_COLOR") }; let state = AppState::new(); assert!(state.color_enabled); } /// `AppState::new()` disables color when `NO_COLOR` is set to a non-empty value. #[test] fn new_color_disabled_when_no_color_set() { // SAFETY: single-threaded test; no other threads reading the env. unsafe { std::env::set_var("NO_COLOR", "1") }; let state = AppState::new(); // Restore the environment before asserting so other tests are unaffected. unsafe { std::env::remove_var("NO_COLOR") }; assert!(!state.color_enabled); } }