diff --git a/.beans/claudbg-nq36--tui-app-state-model-and-screen-enum.md b/.beans/claudbg-nq36--tui-app-state-model-and-screen-enum.md index 53259ac..f5bdb94 100644 --- a/.beans/claudbg-nq36--tui-app-state-model-and-screen-enum.md +++ b/.beans/claudbg-nq36--tui-app-state-model-and-screen-enum.md @@ -1,11 +1,11 @@ --- # claudbg-nq36 title: 'TUI: app state model and screen enum' -status: todo +status: in-progress type: task priority: normal created_at: 2026-03-30T04:45:19Z -updated_at: 2026-03-30T04:49:03Z +updated_at: 2026-03-30T16:37:25Z parent: claudbg-i6l2 blocked_by: - claudbg-78xt diff --git a/src/lib.rs b/src/lib.rs index 3f44620..909ccbc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod error; pub mod models; pub mod output; pub mod parser; +pub mod tui; pub mod util; #[cfg(test)] diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..ddf67f5 --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,6 @@ +//! TUI module — terminal user interface for claudbg. +//! +//! This module will grow to include rendering and event-handling logic. +//! For now it exposes the application state model used by all TUI screens. + +pub mod state; diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..2bba5e2 --- /dev/null +++ b/src/tui/state.rs @@ -0,0 +1,336 @@ +//! 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 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, PartialEq, Eq)] +pub enum Focus { + /// The main chat-log pane (default). + ChatLog, + /// The sub-agents side panel (shown when sub-agents are present). + SubagentsPanel, +} + +impl Default for Focus { + fn default() -> Self { + Self::ChatLog + } +} + +// --------------------------------------------------------------------------- +// 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, +} + +impl AppState { + /// 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 { + 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, + } + } + + /// 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:?}"); + } +}