2026-07-20 09:04:57 +07:00
|
|
|
//! 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;
|
2026-08-27 22:10:28 +07:00
|
|
|
use crossterm::event::{
|
|
|
|
|
DisableBracketedPaste, DisableMouseCapture, Event, KeyEventKind, MouseEventKind,
|
|
|
|
|
};
|
2026-07-20 09:04:57 +07:00
|
|
|
use crossterm::execute;
|
2026-08-27 22:10:28 +07:00
|
|
|
use crossterm::terminal::{
|
|
|
|
|
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
|
|
|
|
};
|
2026-07-20 09:04:57 +07:00
|
|
|
use ratatui::backend::CrosstermBackend;
|
|
|
|
|
use ratatui::Terminal;
|
|
|
|
|
use std::io::{self, Write};
|
|
|
|
|
use std::time::Duration;
|
|
|
|
|
|
2026-07-20 15:53:20 +07:00
|
|
|
use tracing::{debug, info};
|
|
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
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.
|
2026-07-20 15:53:20 +07:00
|
|
|
#[tracing::instrument]
|
2026-07-20 09:04:57 +07:00
|
|
|
pub fn run_single_process() -> Result<()> {
|
2026-07-21 07:00:15 +07:00
|
|
|
let rt = tokio::runtime::Runtime::new()?;
|
|
|
|
|
let _guard = rt.enter();
|
|
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
// Create session state
|
2026-07-20 15:53:20 +07:00
|
|
|
info!("starting single-process TUI");
|
2026-07-20 10:55:09 +07:00
|
|
|
let (_store, mut state) = create_local_session()?;
|
2026-07-20 09:04:57 +07:00
|
|
|
|
|
|
|
|
// 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.
|
2026-07-20 15:53:20 +07:00
|
|
|
///
|
|
|
|
|
/// 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))]
|
2026-07-20 09:04:57 +07:00
|
|
|
fn run_loop(
|
|
|
|
|
state: &mut AppStateRest,
|
|
|
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
) -> Result<()> {
|
2026-07-20 15:53:20 +07:00
|
|
|
debug!("entering run_loop");
|
2026-07-20 09:04:57 +07:00
|
|
|
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.
|
2026-07-20 13:52:20 +07:00
|
|
|
///
|
|
|
|
|
/// 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
|
2026-07-20 15:53:20 +07:00
|
|
|
#[tracing::instrument(skip(state, terminal))]
|
2026-07-20 09:04:57 +07:00
|
|
|
fn run_loop_inner(
|
|
|
|
|
state: &mut AppStateRest,
|
|
|
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
) -> Result<()> {
|
2026-07-20 15:53:20 +07:00
|
|
|
info!("TUI event loop started");
|
2026-07-20 09:04:57 +07:00
|
|
|
loop {
|
|
|
|
|
if state.quit {
|
2026-07-20 15:53:20 +07:00
|
|
|
info!("TUI event loop exiting (quit=true)");
|
2026-07-20 09:04:57 +07:00
|
|
|
break;
|
|
|
|
|
}
|
2026-07-20 13:52:20 +07:00
|
|
|
|
|
|
|
|
// Drain expired toasts exactly once per loop iteration (was being
|
|
|
|
|
// done here AND in Action::Tick before this fix).
|
2026-07-20 09:04:57 +07:00
|
|
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
|
|
|
|
state.misc.drain_expired_toasts(now_ms);
|
|
|
|
|
|
2026-07-20 13:52:20 +07:00
|
|
|
// 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() {
|
2026-08-27 22:10:28 +07:00
|
|
|
Duration::from_millis(50) // smooth spinner @ ~20fps
|
2026-07-20 13:52:20 +07:00
|
|
|
} else {
|
2026-08-27 22:10:28 +07:00
|
|
|
Duration::from_millis(200) // idle: 5fps, saves CPU
|
2026-07-20 13:52:20 +07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if crossterm::event::poll(poll_timeout)? {
|
2026-07-20 09:04:57 +07:00
|
|
|
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() {
|
2026-08-27 22:10:28 +07:00
|
|
|
let _ =
|
|
|
|
|
zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
|
2026-07-20 09:04:57 +07:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
/// Create session state with real infrastructure wired in.
|
2026-07-20 15:53:20 +07:00
|
|
|
///
|
|
|
|
|
/// Flow: initialise `Store` → ensure data directories → generate session ID →
|
|
|
|
|
/// load `Settings` and `AppConfig` from disk → construct `AppStateRest`.
|
|
|
|
|
#[tracing::instrument]
|
2026-07-20 10:55:09 +07:00
|
|
|
fn create_local_session() -> Result<(zesdex_domain::core::Store, AppStateRest)> {
|
2026-07-20 09:04:57 +07:00
|
|
|
let store = zesdex_domain::core::Store::new();
|
2026-07-20 15:53:20 +07:00
|
|
|
debug!("store created at {:?}", store.base_dir);
|
2026-07-20 09:04:57 +07:00
|
|
|
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)?;
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
// Load real settings from disk
|
2026-07-20 14:30:36 +07:00
|
|
|
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
2026-08-27 22:10:28 +07:00
|
|
|
let settings =
|
|
|
|
|
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository::new()
|
|
|
|
|
.load(&store.base_dir)
|
|
|
|
|
.unwrap_or_default();
|
2026-07-20 10:55:09 +07:00
|
|
|
|
2026-08-27 22:10:28 +07:00
|
|
|
let app_config =
|
|
|
|
|
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository::new()
|
|
|
|
|
.load(&store.base_dir)
|
|
|
|
|
.unwrap_or_default();
|
2026-07-20 14:30:36 +07:00
|
|
|
|
2026-07-20 09:04:57 +07:00
|
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
2026-07-20 10:55:09 +07:00
|
|
|
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
|
|
|
|
state.settings = settings;
|
2026-07-20 14:30:36 +07:00
|
|
|
state.app_config = app_config;
|
2026-07-20 09:04:57 +07:00
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
Ok((store, state))
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|