Files
zesdex/apps/interfaces/tui/src/run.rs
T

193 lines
7.4 KiB
Rust
Raw Normal View History

//! 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 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.
pub fn run_single_process() -> Result<()> {
// Create session state
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.
fn run_loop(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
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
fn run_loop_inner(
state: &mut AppStateRest,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
loop {
if state.quit {
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.
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> {
let store = zesdex_domain::core::Store::new();
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))
}