feat(tui): add app state model and screen enum
Defines Screen, Focus, AppState, SessionListItem with state transition methods (new, enter_transcript, go_back). Reuses existing RawEntry and AgentRef types. Includes 11 unit tests. Closes claudbg-nq36 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
3e9e6f7ced
commit
c3721bc9ef
@ -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;
|
||||||
@ -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<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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue