You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

431 lines
17 KiB
Rust

//! 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<SessionListItem>,
/// 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<RawEntry>,
/// 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<AgentRef>,
/// 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<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 ─────────────────────────────────────────────────────────────
/// 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<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.
///
/// 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<String>) {
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<String>,
agent_id: impl Into<String>,
) {
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);
}
}