//! TUI-perspective application state: `AppStateRest` and all the types it //! owns. This is the single source-of-truth struct for the TUI interface, //! mutated from `controller/input.rs` and read by `view/` every render frame. //! //! Infrastructure types (SessionRuntime, DirCache, Toast, etc.) are imported //! from `zesdex_infrastructure`; domain types (Settings, AppConfig, Role) //! come from `zesdex_domain`. //! //! # Flow //! Construction in `lib.rs::create_tui_state` → mutated by key events in //! `controller/input.rs::handle_key` → read-only in every `view/*::draw*` //! function. use std::collections::VecDeque; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use tracing::warn; use zesdex_domain::cms::{AppConfig, Settings}; use zesdex_infrastructure::{DirCache, MentionIndex, SessionRuntime, Toast, TurnEvent}; // --------------------------------------------------------------------------- // Transcript display type // --------------------------------------------------------------------------- /// A single transcript entry rendered in the TUI chat pane. #[derive(Debug, Clone, PartialEq)] pub struct ChatMessageDisplay { /// Message author: User or Assistant. pub role: zesdex_domain::core::Role, /// Rendered text content (plain text, no markdown). pub content: String, /// Millisecond timestamp when this display entry was created. pub timestamp: i64, } impl ChatMessageDisplay { /// Build a display entry, stamping it with the current time. pub fn new(role: zesdex_domain::core::Role, content: String) -> Self { ChatMessageDisplay { role, content, timestamp: chrono::Utc::now().timestamp_millis(), } } } // --------------------------------------------------------------------------- // Bounded ring-buffer transcript cache // --------------------------------------------------------------------------- /// Bounded ring of recent chat messages used to render the transcript view. #[derive(Debug, Clone)] pub struct TranscriptCache { /// Ordered display messages (newest appended, oldest evicted when full). pub messages: Vec, /// Maximum messages to retain before evicting the oldest. pub max_lines: usize, /// Whether the cache has changed since the last render sweep. pub dirty: bool, } impl TranscriptCache { /// Create an empty transcript cache holding at most `max_lines` messages. pub fn new(max_lines: usize) -> Self { TranscriptCache { messages: Vec::new(), max_lines, dirty: true, } } } // --------------------------------------------------------------------------- // Scroll state // --------------------------------------------------------------------------- /// Viewport scroll state: current offset and visible-line count. #[derive(Debug, Clone)] pub struct ScrollState { /// Current scroll offset (how many lines have been scrolled past). pub offset: usize, /// Maximum number of lines that fit in the visible viewport area. pub max_visible: usize, } impl ScrollState { /// Create a `ScrollState` with zero offset and 30 rows visible. pub fn new() -> Self { ScrollState { offset: 0, max_visible: 30, } } /// Scroll the viewport up by `amount` lines (increasing the offset). pub fn scroll_up(&mut self, amount: usize) { self.offset = self.offset.saturating_add(amount); } /// Scroll the viewport down by `amount` lines (decreasing the offset). pub fn scroll_down(&mut self, amount: usize) { self.offset = self.offset.saturating_sub(amount); } } impl Default for ScrollState { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Input state (buffer, cursor, history, autocomplete) // --------------------------------------------------------------------------- /// Which source populated the autocomplete dropdown. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AutocompleteKind { /// Builtin slash-command (e.g. `/model`, `/help`). Command, /// `@file` mention from the workspace file index. FileMention, } /// Builtin slash-commands recognised by the chat input autocomplete. const COMMANDS: &[&str] = &[ "/help", "/quit", "/clear", "/login", "/login zen", "/login openai", "/edit", "/mcp add", "/model", "/model ls", "/model add", "/todo", "/usage", "/compact", ]; /// The user's input buffer, cursor position, history, and autocomplete /// state for the chat prompt. #[derive(Debug, Clone)] pub struct InputState { /// Raw UTF-8 input buffer content. pub buffer: String, /// Byte offset of the cursor within `buffer`. pub cursor: usize, /// Previously submitted input lines, oldest-first. pub history: Vec, /// Index into `history` when browsing (None = at the current input). pub history_idx: Option, /// Current autocomplete candidate list. pub autocomplete_candidates: Vec, /// Focused index within `autocomplete_candidates`. pub autocomplete_idx: usize, /// Whether the autocomplete dropdown is visible. pub autocomplete_visible: bool, /// Which kind of autocomplete is active. pub autocomplete_kind: AutocompleteKind, /// Byte offset of the `@` character that triggered file mention autocomplete. pub mention_start: usize, /// Optional path to a persistent history file. pub history_file: Option, } impl InputState { /// Create an empty input state. pub fn new() -> Self { InputState { buffer: String::new(), cursor: 0, history: Vec::new(), history_idx: None, autocomplete_candidates: Vec::new(), autocomplete_idx: 0, autocomplete_visible: false, autocomplete_kind: AutocompleteKind::Command, mention_start: 0, history_file: None, } } /// Hide the autocomplete dropdown and clear its state. pub fn close_autocomplete(&mut self) { self.autocomplete_visible = false; self.autocomplete_candidates.clear(); self.autocomplete_idx = 0; self.autocomplete_kind = AutocompleteKind::Command; self.mention_start = 0; } /// Open or refresh the autocomplete dropdown by filtering `COMMANDS`. pub fn open_autocomplete(&mut self) { let trimmed = self.buffer.trim().to_string(); if trimmed.is_empty() || !trimmed.starts_with('/') { self.close_autocomplete(); return; } let prefix = trimmed.to_lowercase(); self.autocomplete_candidates = COMMANDS .iter() .filter(|c| c.starts_with(&prefix)) .map(std::string::ToString::to_string) .collect(); self.autocomplete_kind = AutocompleteKind::Command; self.autocomplete_idx = 0; self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); } /// Find the `@mention` token (if any) immediately before the cursor. pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> { let before_cursor = &self.buffer[..self.cursor]; let at_pos = before_cursor.rfind('@')?; let between = &before_cursor[at_pos + 1..]; if between.chars().any(char::is_whitespace) { return None; } let boundary_ok = at_pos == 0 || before_cursor[..at_pos] .chars() .next_back() .is_some_and(char::is_whitespace); if !boundary_ok { return None; } Some((at_pos, between.to_string())) } /// Open or refresh the `@file` mention dropdown from `files`. pub fn open_mention_autocomplete(&mut self, files: &[String]) { use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern}; use nucleo_matcher::{Config, Matcher}; let Some((start, query)) = self.mention_query_at_cursor() else { self.close_autocomplete(); return; }; let mut matcher = Matcher::new(Config::DEFAULT.match_paths()); let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart); let matched_files = pattern.match_list(files.iter(), &mut matcher); self.autocomplete_candidates = matched_files .into_iter() .take(10) .map(|(f, _)| f.clone()) .collect(); self.autocomplete_kind = AutocompleteKind::FileMention; self.mention_start = start; self.autocomplete_idx = 0; self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); } /// Move the autocomplete selection up (forward=false) or down (forward=true). pub fn cycle_autocomplete(&mut self, forward: bool) { let n = self.autocomplete_candidates.len(); if n == 0 { return; } if forward { self.autocomplete_idx = (self.autocomplete_idx + 1) % n; } else { self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 }; } } /// Accept the currently selected autocomplete candidate. pub fn select_autocomplete(&mut self) -> bool { let Some(candidate) = self .autocomplete_candidates .get(self.autocomplete_idx) .cloned() else { return false; }; match self.autocomplete_kind { AutocompleteKind::Command => { self.buffer = candidate; self.cursor = self.buffer.len(); } AutocompleteKind::FileMention => { if self.cursor < self.mention_start || self.mention_start > self.buffer.len() { self.close_autocomplete(); return false; } let replacement = format!("@{candidate} "); self.buffer .replace_range(self.mention_start..self.cursor, &replacement); self.cursor = self.mention_start + replacement.len(); } } self.close_autocomplete(); true } /// Tab-complete: open dropdown or cycle forward. pub fn tab_complete(&mut self) { if self.autocomplete_visible { self.cycle_autocomplete(true); } else { self.open_autocomplete(); } } /// Insert a character at the cursor position. pub fn insert(&mut self, c: char) { self.buffer.insert(self.cursor, c); self.cursor += c.len_utf8(); } /// Delete the character to the left of the cursor (backspace). pub fn delete_left(&mut self) { if self.cursor > 0 { self.cursor -= 1; self.buffer.remove(self.cursor); } } /// Delete the character at the cursor position (forward delete). pub fn delete_right(&mut self) { if self.cursor < self.buffer.len() { self.buffer.remove(self.cursor); } } /// Submit the current buffer and return the submitted text. pub fn submit(&mut self) -> String { let result = self.buffer.clone(); if !result.is_empty() { if self.history.last() != Some(&result) { self.history.push(result.clone()); if let Some(ref path) = self.history_file { if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) .append(true) .open(path) { use std::io::Write; let _ = writeln!(file, "{result}"); } } } self.history_idx = None; } self.buffer.clear(); self.cursor = 0; result } /// Navigate backward through input history. pub fn history_up(&mut self) { if self.history.is_empty() { return; } let idx = match self.history_idx { Some(i) if i > 0 => i - 1, None => self.history.len() - 1, Some(_) => return, }; self.history_idx = Some(idx); self.buffer = self.history[idx].clone(); self.cursor = self.buffer.len(); } /// Navigate forward through input history. pub fn history_down(&mut self) { match self.history_idx { Some(i) if i < self.history.len() - 1 => { let idx = i + 1; self.history_idx = Some(idx); self.buffer = self.history[idx].clone(); self.cursor = self.buffer.len(); } Some(_) => { self.history_idx = None; self.buffer.clear(); self.cursor = 0; } None => {} } } } impl Default for InputState { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Overlay enum // --------------------------------------------------------------------------- /// Which modal overlay, if any, is currently shown over the main TUI view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Overlay { /// No overlay; the main chat view is shown. None, /// Key bindings help screen. Help, /// Settings/configuration panel. Settings, /// Background bash job viewer. Bash, /// "Are you sure you want to quit?" confirmation. QuitConfirm, /// Raw key-code input capture (for binding custom keys). KeyInput, /// Inline editor (opened via `/edit`). Editor, /// Reasoning effort level selector. Effort, /// MCP server management panel. Mcp, /// TODO list overlay. Todo, /// Session rewind / history scrubber. Rewind, /// Learning / lesson management panel. Learning, /// Token usage statistics panel. Usage, /// Generic loading spinner overlay. Loading, /// Model selector dropdown. ModelSelector, /// "Clear conversation?" confirmation. ClearConfirm, } impl Overlay { /// Human-readable name for this overlay variant. pub fn as_str(&self) -> &'static str { match self { Overlay::None => "none", Overlay::Help => "help", Overlay::Settings => "settings", Overlay::Bash => "bash", Overlay::QuitConfirm => "quit_confirm", Overlay::KeyInput => "key_input", Overlay::Editor => "editor", Overlay::Effort => "effort", Overlay::Mcp => "mcp", Overlay::Todo => "todo", Overlay::Rewind => "rewind", Overlay::Learning => "learning", Overlay::Usage => "usage", Overlay::Loading => "loading", Overlay::ModelSelector => "model_selector", Overlay::ClearConfirm => "clear_confirm", } } /// Whether any overlay (i.e. anything other than `None`) is active. pub fn is_active(self) -> bool { !matches!(self, Overlay::None) } } impl std::fmt::Display for Overlay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } // --------------------------------------------------------------------------- // MiscState — overlay, toasts, flags, tick, editor // --------------------------------------------------------------------------- /// The "miscellaneous" slice of app state. #[derive(Debug, Clone)] pub struct MiscState { /// Currently active modal overlay (None = main chat view). pub overlay: Overlay, /// Active toast notifications. pub toasts: Vec, /// Timestamp (ms) of the last staleness sweep for lesson cache. pub last_staleness_sweep_ms: i64, /// Whether the agent is currently "thinking". pub thinking: bool, /// Current LLM reasoning effort level (1-5). pub effort_level: usize, /// Currently focused index in list-type overlays. pub selected_index: usize, /// Optional inline editor state. pub editor: Option, /// Whether the API connection is established. pub api_connected: bool, /// Monotonically increasing tick count, incremented each render frame. pub tick_count: u64, /// Cached content of the TODO file. pub todo_content: String, /// Whether a lesson background task is currently running. pub lesson_running: bool, /// Text waiting to be written to the system clipboard. pub pending_clipboard_copy: Option, } impl MiscState { /// Create a fresh `MiscState` with no overlay, no toasts. pub fn new() -> Self { MiscState { overlay: Overlay::None, toasts: Vec::new(), last_staleness_sweep_ms: 0, thinking: false, effort_level: 1, selected_index: 0, editor: None, api_connected: false, tick_count: 0, todo_content: String::new(), lesson_running: false, pending_clipboard_copy: None, } } /// Append a toast notification to the active list. pub fn push_toast(&mut self, toast: Toast) { self.toasts.push(toast); } /// Remove and return all toasts whose lifetime has expired at `now_ms`. pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect(); self.toasts.retain(|t| !t.expired(now_ms)); expired } } impl Default for MiscState { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // EditorState (simplified — used by the Editor overlay) // --------------------------------------------------------------------------- /// Simple inline editor state for the TUI. #[derive(Debug, Clone)] pub struct EditorState { /// Path to the file being edited. pub path: PathBuf, /// Current buffer content. pub content: String, /// Cursor position (byte offset). pub cursor: usize, } impl EditorState { /// Create a new editor state for the given path. pub fn new(path: PathBuf, content: String) -> Self { let cursor = content.len(); EditorState { path, content, cursor, } } /// Return the full buffer content. pub fn as_string(&self) -> String { self.content.clone() } /// Delete one character to the left of the cursor. pub fn delete_left(&mut self) { if self.cursor > 0 { self.cursor -= 1; self.content.remove(self.cursor); } } } // --------------------------------------------------------------------------- // AgentState + SimpleAgent + SimpleWorkflowEngine (workflow display) // --------------------------------------------------------------------------- /// Simplified agent lifecycle state for TUI display. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AgentState { Idle, Running, Completed, Failed, } /// A single agent entry in the workflow sidebar. #[derive(Debug, Clone)] pub struct SimpleAgent { /// Agent display name. pub name: String, /// Current lifecycle state. pub state: AgentState, /// Millisecond timestamp when the agent started. pub started_at: Option, /// Millisecond timestamp when the agent completed. pub completed_at: Option, /// Optional error message if the agent failed. pub error: Option, /// Optional progress text (current tool, step description). pub progress: Option, } impl SimpleAgent { /// Create a new agent with the given name. pub fn new(name: String) -> Self { SimpleAgent { name, state: AgentState::Idle, started_at: None, completed_at: None, error: None, progress: None, } } } /// Simplified workflow engine state for TUI display. #[derive(Debug, Clone)] pub struct SimpleWorkflowEngine { /// Active agents in the workflow. pub agents: Vec, /// Summary findings produced by completed agents. pub findings: Vec, } impl SimpleWorkflowEngine { /// Create an empty workflow engine state. pub fn new() -> Self { SimpleWorkflowEngine { agents: Vec::new(), findings: Vec::new(), } } } impl Default for SimpleWorkflowEngine { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Effort levels (for effort overlay) // --------------------------------------------------------------------------- /// Name of each reasoning-effort tier. pub const EFFORT_LEVELS: &[&str] = &[ "Auto — let the provider decide", "Low — fast, minimal reasoning", "Medium — balanced speed & reasoning", "High — thorough reasoning", "Maximum — deep analysis", ]; /// Return the current effort index from state. pub fn current_effort(state: &AppStateRest) -> usize { state.misc.effort_level.saturating_sub(1).min(EFFORT_LEVELS.len().saturating_sub(1)) } /// Cycle effort level up or down. pub fn cycle_effort(state: &mut AppStateRest, _forward: bool) { // Simplified: cycle through levels let n = EFFORT_LEVELS.len(); state.misc.effort_level = (state.misc.effort_level % n) + 1; state.mark_dirty(); } // --------------------------------------------------------------------------- // Learning item types (for learning overlay) // --------------------------------------------------------------------------- /// A lesson entry displayed in the Learning overlay. #[derive(Debug, Clone)] pub enum LearningItem { /// A newly-generated lesson pending user approval. Pending { name: String, content: String, scope: String, confidence: f64, }, /// A lesson that has been accepted and stored. Stored { name: String, content: String, lifecycle: String, scope: String, description: String, }, } /// Return learning items from state (simplified — uses session_runtime data). pub fn get_learning_items(_state: &AppStateRest) -> Vec { Vec::new() } /// Cycle the selected index within bounds. pub fn cycle_selected_index(current: usize, n: usize, forward: bool) -> usize { if n == 0 { return 0; } if forward { (current + 1) % n } else { if current == 0 { n - 1 } else { current - 1 } } } // --------------------------------------------------------------------------- // Rewind helpers // --------------------------------------------------------------------------- /// Return the number of rewind points available. pub fn rewind_count(state: &AppStateRest) -> usize { state.transcript_cache.messages.len() } // --------------------------------------------------------------------------- // Context window helpers (stubs for status bar) // --------------------------------------------------------------------------- /// Resolve the window size for context window management. pub fn resolve_context_window( _app_config: &zesdex_domain::cms::AppConfig, _settings: &zesdex_domain::cms::Settings, ) -> usize { // Default to 128k for most modern models 128_000 } /// Count tokens using tiktoken, fall back to character estimation. pub fn count_tokens(text: &str) -> usize { // Try tiktoken for accurate counting if let Ok(bpe) = tiktoken_rs::cl100k_base() { return bpe.encode_with_special_tokens(text).len(); } // Fallback: ~4 chars per token (text.len() + 3) / 4 } // --------------------------------------------------------------------------- // AppStateRest — the single source-of-truth TUI state // --------------------------------------------------------------------------- /// The single source-of-truth state struct for the TUI interface. /// /// Mutated from `controller/input.rs` and `actions/mod.rs` (via `Action`). /// Read-only from every `view/*` render function. #[derive(Clone)] pub struct AppStateRest { /// Persistent user settings. pub settings: Settings, /// Per-project app configuration. pub app_config: AppConfig, /// Absolute paths to each open workspace root directory. pub workspace_roots: Vec, /// Unique session identifier. pub session_id: String, /// Path to the session's data directory. pub session_dir: PathBuf, /// Path to the session memory directory. pub memory_dir: PathBuf, /// Path to the git worktrees directory. pub worktrees_dir: PathBuf, /// Shared async cache of directory listings. pub dir_cache: Arc>, /// Shared workspace file-path index for `@file` mention autocomplete. pub mention_index: MentionIndex, /// Optional per-session runtime state. pub session_runtime: Option, /// Ring buffer of recent chat messages for the transcript pane. pub transcript_cache: TranscriptCache, /// Viewport scroll offset tracker. pub scroll: ScrollState, /// Chat input buffer, cursor, history, and autocomplete. pub input: InputState, /// Miscellaneous state: overlay, toasts, flags, editor, tick. pub misc: MiscState, /// Queue of events emitted by the running agent turn. pub turn_events: Arc>>, /// Whether an agent turn is currently in flight. pub turn_in_flight_flag: Arc>, /// Atomic flag set when the user aborts the current turn. pub abort_flag: Arc, /// Simplified workflow engine state for display. pub workflow_engine: SimpleWorkflowEngine, /// Whether the state has been modified since the last render sweep. pub dirty: bool, /// Whether the application has been requested to quit. pub quit: bool, /// Cached help text content. pub help_text: &'static str, } /// Default help text shown in the Help overlay. pub const DEFAULT_HELP_TEXT: &str = r#" Zesdex TUI — Keyboard Shortcuts ─── General ─── Ctrl+C Quit confirm Ctrl+D Close overlay Ctrl+Y Copy last assistant message Esc Abort turn / Close overlay Tab Autocomplete ─── Navigation ─── ↑ / ↓ History browse / Overlay navigate Ctrl+↑/↓ Scroll transcript PgUp / PgDown Scroll transcript Enter Submit / Select autocomplete ─── Overlays ─── /help Show this help /settings Open settings overlay /todo Open tasks (todo) overlay /usage Open usage statistics /bash Open bash jobs overlay /mcp Open MCP server management /model Open model selector /compact Compact conversation /clear Clear transcript /rewind Rewind conversation history ─── Editor Mode ─── /edit Open file for inline editing Ctrl+S Save changes Esc Dismiss editor "#; impl AppStateRest { /// Construct initial TUI state. pub fn new( workspace_roots: Vec, session_dir: &std::path::Path, memory_dir: PathBuf, ) -> Self { let settings = Settings::default(); let app_config = AppConfig::default(); let worktrees_dir = memory_dir .parent() .unwrap_or(&memory_dir) .join("worktrees"); let session_id = session_dir.file_name().map_or_else( || { warn!("[state] session_dir has no file_name, using empty session_id"); String::new() }, |n| n.to_string_lossy().to_string(), ); AppStateRest { settings, app_config, workspace_roots, session_id, session_dir: session_dir.to_path_buf(), memory_dir: memory_dir.clone(), worktrees_dir, turn_events: Arc::new(Mutex::new(VecDeque::new())), turn_in_flight_flag: Arc::new(Mutex::new(false)), abort_flag: Arc::new(AtomicBool::new(false)), dir_cache: Arc::new(tokio::sync::RwLock::new(DirCache::new())), mention_index: MentionIndex::new(), session_runtime: None, workflow_engine: SimpleWorkflowEngine::new(), transcript_cache: TranscriptCache::new(200), scroll: ScrollState::new(), input: InputState::new(), misc: MiscState::new(), dirty: true, quit: false, help_text: DEFAULT_HELP_TEXT, } } /// Whether an agent turn is currently running. pub fn turn_in_flight(&self) -> bool { self.turn_in_flight_flag.lock().map_or_else( |_| { warn!("[state] turn_in_flight mutex poisoned"); false }, |g| *g, ) } /// Append a message to the transcript. pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { self.transcript_cache.messages.push(msg); if self.transcript_cache.messages.len() > self.transcript_cache.max_lines { self.transcript_cache.messages.remove(0); } self.transcript_cache.dirty = true; self.dirty = true; } /// Mark the app state as dirty, triggering a TUI re-render. pub fn mark_dirty(&mut self) { self.dirty = true; } /// Queue a toast notification. pub fn push_toast(&mut self, toast: Toast) { self.misc.push_toast(toast); self.mark_dirty(); } /// Push an info toast. pub fn toast_info(&mut self, msg: impl Into) { self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Info, msg.into())); } /// Push a success toast. pub fn toast_success(&mut self, msg: impl Into) { self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Success, msg.into())); } /// Push a warning toast. pub fn toast_warning(&mut self, msg: impl Into) { self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Warning, msg.into())); } /// Push an error toast. pub fn toast_error(&mut self, msg: impl Into) { self.push_toast(Toast::new(zesdex_infrastructure::ToastKind::Error, msg.into())); } /// Persist settings to disk. pub fn save_settings(&self) { if let Ok(store_dir) = std::fs::canonicalize(self.store_base_dir()) { let repo = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new(); use zesdex_domain::SettingsRepository; if let Err(e) = repo.save(&store_dir, &self.settings) { tracing::warn!("Failed to save settings: {e}"); } } } /// Resolve the base directory for session stores. pub fn store_base_dir(&self) -> PathBuf { self.session_dir .parent() .and_then(|p| p.parent()) .map_or_else( || { warn!("[state] no grandparent, using session_dir"); self.session_dir.clone() }, std::path::Path::to_path_buf, ) } }