//! TUI event loop — single-process mode entry point. //! //! Provides `run_single_process()` which sets up the terminal, //! creates a session, and enters the render/input loop. //! //! Flow: create session + lock → enable raw mode + alternate screen → //! run_loop (render → poll events → handle key → tick) → //! restore terminal → save settings → release lock. use anyhow::Result; use crossterm::execute; use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use std::io::{self, Write}; use std::time::Duration; use tracing::{debug, info}; use crate::action::{apply_action, Action}; use crate::controller::input::handle_key; use crate::state::AppStateRest; use crate::view; /// Run zesdex as a self-contained TUI + agent loop in one process. /// /// Flow: build `AppStateRest` → enter raw mode / alternate screen → /// run the event loop → always restore the terminal (even on error) → /// save settings. #[tracing::instrument] pub fn run_single_process() -> Result<()> { let rt = tokio::runtime::Runtime::new()?; let _guard = rt.enter(); // Create session state info!("starting single-process TUI"); let (_store, mut state) = create_local_session()?; // Enter raw mode and alternate screen for the TUI enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; execute!(stdout, crossterm::event::EnableBracketedPaste)?; execute!(stdout, crossterm::event::EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let run_result = run_loop(&mut state, &mut terminal); let mut restore_stdout = io::stdout(); let _ = execute!(restore_stdout, DisableBracketedPaste); let _ = execute!(restore_stdout, DisableMouseCapture); let _ = execute!(restore_stdout, LeaveAlternateScreen); let _ = disable_raw_mode(); if let Err(e) = run_result { let _ = writeln!(restore_stdout, "error: {e}"); let _ = restore_stdout.flush(); } // Save settings state.save_settings(); Ok(()) } /// Run the event loop, guaranteeing terminal restoration on error. /// /// Wraps `run_loop_inner` so that if it panics or returns an error the /// terminal is restored to a usable state before propagating the error. #[tracing::instrument(skip(state, terminal))] fn run_loop( state: &mut AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { debug!("entering run_loop"); let result = run_loop_inner(state, terminal); if let Err(ref _e) = result { let _ = terminal.clear(); let _ = disable_raw_mode(); let _ = execute!(io::stdout(), DisableBracketedPaste); let _ = execute!(io::stdout(), DisableMouseCapture); let _ = execute!(io::stdout(), LeaveAlternateScreen); } result } /// The core single-process render/input loop. /// /// Performance optimizations applied here: /// - Skip `terminal.draw()` when `state.dirty == false` (nothing changed) /// - Adaptive poll timeout: 50ms when a turn is in-flight (spinner needs /// smooth updates), 200ms when idle (no reason to busy-loop) /// - Toast expiry is drained once here instead of in two places /// - Chat display lines are cached and rebuilt only when content changes #[tracing::instrument(skip(state, terminal))] fn run_loop_inner( state: &mut AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { info!("TUI event loop started"); loop { if state.quit { info!("TUI event loop exiting (quit=true)"); break; } // Drain expired toasts exactly once per loop iteration (was being // done here AND in Action::Tick before this fix). let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); // Skip render when nothing has changed — avoids expensive markdown // re-parse and layout recalculation every cycle while idle. if state.dirty { // Pre-warm display caches before entering the terminal.draw closure. // This lets view::draw work with &AppStateRest (no mutation inside draw). view::pre_render(state); terminal.draw(|f| { view::draw(f, state); state.dirty = false; })?; } // Adaptive poll: fast when animating, slow when idle. let poll_timeout = if state.turn_in_flight() { Duration::from_millis(50) // smooth spinner @ ~20fps } else { Duration::from_millis(200) // idle: 5fps, saves CPU }; if crossterm::event::poll(poll_timeout)? { match crossterm::event::read()? { Event::Key(key) => { if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { let actions = handle_key(key, state); for action in actions { apply_action(state, action); } if let Some(text) = state.misc.pending_clipboard_copy.take() { let _ = zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text); state.push_toast(zesdex_infrastructure::Toast::new( zesdex_infrastructure::ToastKind::Success, "Copied to clipboard".to_string(), )); } } } Event::Paste(text) => { if state.input.autocomplete_visible { state.input.close_autocomplete(); } state.input.buffer.insert_str(state.input.cursor, &text); state.input.cursor += text.len(); if state.input.buffer.starts_with('/') { state.input.open_autocomplete(); } state.dirty = true; } Event::Resize(w, h) => { apply_action(state, Action::Resize(w, h)); } Event::Mouse(mouse_event) => { if mouse_event.kind == MouseEventKind::ScrollUp { apply_action(state, Action::ScrollUp); } else if mouse_event.kind == MouseEventKind::ScrollDown { apply_action(state, Action::ScrollDown); } } _ => {} } } // Tick always fires each iteration apply_action(state, Action::Tick); } terminal.clear()?; Ok(()) } /// Create session state with real infrastructure wired in. /// /// Flow: initialise `Store` → ensure data directories → generate session ID → /// load `Settings` and `AppConfig` from disk → construct `AppStateRest`. #[tracing::instrument] fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> { let store = zesdex_domain::core::Store::new(); debug!("store created at {:?}", store.base_dir); store.ensure_dirs()?; let session_id = uuid::Uuid::new_v4().to_string(); let session_dir = store.base_dir.join("sessions").join(&session_id); std::fs::create_dir_all(&session_dir)?; // Load real settings from disk use zesdex_domain::cms::{AppConfigRepository, SettingsRepository}; let settings = zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new() .load(&store.base_dir) .unwrap_or_default(); let app_config = zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository::new() .load(&store.base_dir) .unwrap_or_default(); let workspace_roots = vec![std::env::current_dir()?]; let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone()); state.settings = settings; state.app_config = app_config; Ok((store, state)) }