//! Zesdex binary entry point. //! //! Parses `--daemon` / `--attach ` flags to select one of three //! process modes (single-process TUI+agent, background daemon, or //! attach-only TUI client), sets up file logging, and runs the //! corresponding event loop. use std::io; use std::io::Write; use std::sync::Mutex; use anyhow::Result; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::event::{EnableMouseCapture, DisableMouseCapture}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; mod app; mod controller; mod dto; mod ipc; mod model; mod service; mod tool; mod resources; mod view; /// Process entry point: parse CLI flags, initialize logging, then dispatch /// to single-process, daemon, or attach mode. /// /// Flow: parse `--daemon`/`--attach ` from argv → create/open the log /// file under the platform data dir (falling back to `/dev/null` if that /// fails, so a broken log path can't crash the TUI) → init tracing → /// reject `--daemon` + `--attach` together → dispatch. /// /// Why: logging is routed to a file (never stderr/stdout) because writing /// to the terminal while ratatui owns the alternate screen corrupts the UI. fn main() -> Result<()> { let args: Vec = std::env::args().collect(); let is_daemon = args.iter().any(|a| a == "--daemon"); let attach_session = args.iter() .position(|a| a == "--attach") .and_then(|i| args.get(i + 1).cloned()); let log_dir = dirs::data_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("zesdex"); let _ = std::fs::create_dir_all(&log_dir); let log_path = log_dir.join("zesdex.log"); let log_file = std::fs::OpenOptions::new() .create(true).append(true).open(&log_path) .unwrap_or_else(|_| { // Fallback: /dev/null so the TUI isn't corrupted by stderr writes std::fs::OpenOptions::new() .write(true).open("/dev/null") .expect("cannot open /dev/null") }); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .with_writer(Mutex::new(log_file)) .init(); if is_daemon && attach_session.is_some() { anyhow::bail!("--daemon and --attach are mutually exclusive"); } if is_daemon { return run_daemon(); } if let Some(session_id) = attach_session { return run_attach(&session_id); } run_single_process() } /// Run zesdex as a self-contained TUI + agent loop in one process. /// /// Flow: create the store, a fresh session dir, and take an exclusive /// session lock → build `AppStateRest` → enter raw mode / alternate /// screen → run the event loop → always restore the terminal (even on /// error) → save settings and release the session lock. /// /// Why: the session lock prevents two zesdex processes from concurrently /// writing the same session directory. Terminal restoration happens /// outside `run_loop`'s `Result` so a panicking/erroring loop still /// leaves the user's terminal usable. fn run_single_process() -> Result<()> { let store = model::store::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)?; let session_lock = model::session_lock::SessionLock::new(&session_dir); if !session_lock.try_lock()? { anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)"); } let workspace_roots = vec![std::env::current_dir()?]; let mut state = app::state::rest::AppStateRest::new( workspace_roots.clone(), session_dir, store.memory_dir, ); state.sessions = model::session::Session::list(&store.base_dir); let _rt = tokio::runtime::Runtime::new()?; enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, 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, LeaveAlternateScreen, DisableMouseCapture); let _ = disable_raw_mode(); if let Err(e) = run_result { let _ = writeln!(restore_stdout, "error: {}", e); let _ = restore_stdout.flush(); } let _ = state.settings.save(); session_lock.unlock(); Ok(()) } /// Map a `crossterm` key code to the wire-serializable `KeyAction`, for /// sending key input from an attached client to the daemon. /// /// Return: `None` for key codes with no `KeyAction` equivalent (e.g. /// media keys), which are silently dropped. fn key_code_to_action(code: crossterm::event::KeyCode) -> Option { use crossterm::event::KeyCode; match code { KeyCode::Char(c) => Some(ipc::protocol::KeyAction::Char(c)), KeyCode::Enter => Some(ipc::protocol::KeyAction::Enter), KeyCode::Esc => Some(ipc::protocol::KeyAction::Escape), KeyCode::Backspace => Some(ipc::protocol::KeyAction::Backspace), KeyCode::Delete => Some(ipc::protocol::KeyAction::Delete), KeyCode::Tab => Some(ipc::protocol::KeyAction::Tab), KeyCode::Up => Some(ipc::protocol::KeyAction::Up), KeyCode::Down => Some(ipc::protocol::KeyAction::Down), KeyCode::Left => Some(ipc::protocol::KeyAction::Left), KeyCode::Right => Some(ipc::protocol::KeyAction::Right), KeyCode::Home => Some(ipc::protocol::KeyAction::Home), KeyCode::End => Some(ipc::protocol::KeyAction::End), KeyCode::PageUp => Some(ipc::protocol::KeyAction::PageUp), KeyCode::PageDown => Some(ipc::protocol::KeyAction::PageDown), KeyCode::F(n) => Some(ipc::protocol::KeyAction::Function(n)), _ => None, } } /// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode` /// from a `KeyAction` received over IPC, for replaying it into the /// daemon's normal key-handling path. fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode { use crossterm::event::KeyCode; match action { ipc::protocol::KeyAction::Char(c) => KeyCode::Char(*c), ipc::protocol::KeyAction::Enter => KeyCode::Enter, ipc::protocol::KeyAction::Escape => KeyCode::Esc, ipc::protocol::KeyAction::Backspace => KeyCode::Backspace, ipc::protocol::KeyAction::Delete => KeyCode::Delete, ipc::protocol::KeyAction::Tab => KeyCode::Tab, ipc::protocol::KeyAction::Up => KeyCode::Up, ipc::protocol::KeyAction::Down => KeyCode::Down, ipc::protocol::KeyAction::Left => KeyCode::Left, ipc::protocol::KeyAction::Right => KeyCode::Right, ipc::protocol::KeyAction::Home => KeyCode::Home, ipc::protocol::KeyAction::End => KeyCode::End, ipc::protocol::KeyAction::PageUp => KeyCode::PageUp, ipc::protocol::KeyAction::PageDown => KeyCode::PageDown, ipc::protocol::KeyAction::Function(n) => KeyCode::F(*n), } } /// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it /// to the attached client as a `DaemonFrame::StateUpdate`. /// /// Flow: map transcript messages/toasts to their wire DTOs → derive the /// active overlay name (or `None` if no overlay is active) → build and /// send one `DaemonFrame`. /// /// Why: the client never shares memory with the daemon, so every action /// on the daemon side is followed by a full state push rather than a diff. fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> { use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload}; let messages: Vec = state.transcript_cache.messages.iter().map(|m| { MessageEntry { role: format!("{:?}", m.role), content: m.content.clone(), timestamp: m.timestamp, } }).collect(); let toasts: Vec = state.misc.toasts.iter().map(|t| { ToastEntry { kind: format!("{:?}", t.kind), message: t.message.clone(), created_at: t.created_at, lifetime_ms: t.lifetime_ms, } }).collect(); let overlay = if state.misc.overlay.is_active() { Some(format!("{:?}", state.misc.overlay)) } else { None }; let frame = DaemonFrame::StateUpdate(Box::new(StatePayload { session_id: state.session_id.clone(), messages, edit_count: state.edit_log.len() as u32, message_count: state.transcript_cache.messages.len(), overlay, toasts, dirty: state.dirty, input_buffer: state.input.buffer.clone(), input_cursor: state.input.cursor, })); conn.send(&frame) } /// Apply a `StatePayload` received from the daemon onto the client's /// local `AppStateRest`, so the attach-mode TUI can render it. /// /// Flow: copy scalar fields directly → rebuild the transcript cache from /// `MessageEntry`s (mapping role strings back to the `Role` enum) → /// resolve the overlay name string to an `Overlay` variant → rebuild /// toasts from `ToastEntry`s. /// /// Why: unrecognized role/overlay/toast-kind strings fall back to a safe /// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than /// panicking, so a protocol/version mismatch degrades gracefully. fn apply_client_update( state: &mut app::state::rest::AppStateRest, payload: ipc::protocol::StatePayload, ) { use app::state::types::{Overlay, Toast, ToastKind}; state.session_id = payload.session_id; state.dirty = payload.dirty; state.transcript_cache.messages = payload.messages.into_iter().map(|m| { app::state::rest::ChatMessageDisplay { role: match m.role.as_str() { "User" => crate::dto::chat::message::Role::User, "Assistant" => crate::dto::chat::message::Role::Assistant, "System" => crate::dto::chat::message::Role::System, "Tool" => crate::dto::chat::message::Role::Tool, _ => crate::dto::chat::message::Role::User, }, content: m.content, timestamp: m.timestamp, } }).collect(); state.transcript_cache.dirty = true; state.misc.overlay = match payload.overlay.as_deref() { Some("Help") => Overlay::Help, Some("Settings") => Overlay::Settings, Some("Bash") => Overlay::Bash, Some("QuitConfirm") => Overlay::QuitConfirm, Some("Workflow") => Overlay::Workflow, Some("KeyInput") => Overlay::KeyInput, Some("Editor") => Overlay::Editor, Some("Effort") => Overlay::Effort, Some("Mcp") => Overlay::Mcp, Some("Todo") => Overlay::Todo, Some("Rewind") => Overlay::Rewind, Some("Learning") => Overlay::Learning, Some("Usage") => Overlay::Usage, Some("Loading") => Overlay::Loading, Some("ModelSelector") => Overlay::ModelSelector, Some("ClearConfirm") => Overlay::ClearConfirm, _ => Overlay::None, }; state.misc.toasts = payload.toasts.into_iter().map(|t| { Toast { kind: match t.kind.as_str() { "Info" => ToastKind::Info, "Success" => ToastKind::Success, "Warning" => ToastKind::Warning, "Error" => ToastKind::Error, "Lesson" => ToastKind::Lesson, _ => ToastKind::Info, }, message: t.message, created_at: t.created_at, lifetime_ms: t.lifetime_ms, } }).collect(); state.input.buffer = payload.input_buffer; state.input.cursor = payload.input_cursor; } /// Run zesdex as a background daemon: owns the agent state, listens on a /// per-session Unix socket, and drives one attached client. /// /// Flow: create session + lock it → bind a Unix socket under /// `/run/.sock` → block for a single client to /// `accept()` → loop reading `ClientRequest`s, translating each into /// `Action`(s) via the same `controller::input`/`apply_action` path the /// single-process mode uses, then pushing a full state update back → /// on `Close` or client disconnect, clean up the socket file, save /// settings, and release the lock. /// /// Why: reuses `controller::input::handle_key` by synthesizing a /// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and /// single-process modes share identical key-handling logic. fn run_daemon() -> Result<()> { use app::runtime::actions::{Action, apply_action}; use ipc::protocol::ClientRequest; let store = model::store::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)?; let session_lock = model::session_lock::SessionLock::new(&session_dir); if !session_lock.try_lock()? { anyhow::bail!("session already active (another zesdex process holds the lock for this session directory)"); } let workspace_roots = vec![std::env::current_dir()?]; let mut state = app::state::rest::AppStateRest::new( workspace_roots.clone(), session_dir, store.memory_dir, ); state.sessions = model::session::Session::list(&store.base_dir); let _rt = tokio::runtime::Runtime::new()?; let run_dir = store.base_dir.join("run"); std::fs::create_dir_all(&run_dir)?; let socket_path = run_dir.join(format!("{}.sock", session_id)); let addr = socket_path.to_string_lossy().to_string(); let server = ipc::server::IpcServer::bind_unix(&addr)?; eprintln!("daemon: listening on {}", addr); loop { let mut conn = match server.accept() { Ok(c) => c, Err(e) => { eprintln!("daemon: accept error: {}", e); break; } }; eprintln!("daemon: client connected"); let mut running = true; while running { match conn.receive::()? { Some(req) => { match req { ClientRequest::Tick => { apply_action(&mut state, Action::Tick); } ClientRequest::KeyPress { key, ctrl, alt, shift } => { let mut modifiers = crossterm::event::KeyModifiers::NONE; if ctrl { modifiers |= crossterm::event::KeyModifiers::CONTROL; } if alt { modifiers |= crossterm::event::KeyModifiers::ALT; } if shift { modifiers |= crossterm::event::KeyModifiers::SHIFT; } let key_event = crossterm::event::KeyEvent::new( key_action_to_code(&key), modifiers, ); let actions = controller::input::handle_key(key_event, &mut state); for action in actions { apply_action(&mut state, action); } apply_action(&mut state, Action::Tick); } ClientRequest::Submit(text) => { state.input.buffer = text; let enter_event = crossterm::event::KeyEvent::new( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::NONE, ); let actions = controller::input::handle_key(enter_event, &mut state); for action in actions { apply_action(&mut state, action); } apply_action(&mut state, Action::Tick); } ClientRequest::Resize(w, h) => { apply_action(&mut state, Action::Resize(w, h)); apply_action(&mut state, Action::Tick); } ClientRequest::ScrollUp => { apply_action(&mut state, Action::ScrollUp); apply_action(&mut state, Action::Tick); } ClientRequest::ScrollDown => { apply_action(&mut state, Action::ScrollDown); apply_action(&mut state, Action::Tick); } ClientRequest::Close => { running = false; } } send_daemon_update(&mut conn, &state)?; } None => { running = false; } } } eprintln!("daemon: client disconnected, waiting for next connection..."); let _ = state.settings.save(); } let _ = std::fs::remove_file(&socket_path); session_lock.unlock(); Ok(()) } /// Run zesdex as a TUI-only client attached to an existing daemon session. /// /// Flow: connect to the daemon's Unix socket → enter raw mode/alternate /// screen → build a local `AppStateRest` mirror (only used for rendering /// and toast/overlay bookkeeping, not agent logic) → loop: poll for a /// terminal event (key/resize) and forward it as a `ClientRequest`, or /// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and /// apply it via `apply_client_update` → redraw → exit when the daemon /// closes or the user quits (sending `ClientRequest::Close` first). /// /// Why: Ctrl+C is intercepted locally to quit the client without going /// through the daemon, since the daemon has no notion of "this client /// wants to leave" beyond the explicit `Close` request. fn run_attach(session_id: &str) -> Result<()> { use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; use ipc::protocol::ClientRequest; let store = model::store::Store::new(); let socket_path = store.base_dir.join("run").join(format!("{}.sock", session_id)); let addr = socket_path.to_string_lossy().to_string(); let mut client = ipc::client::IpcClient::connect_unix(&addr)?; enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let workspace_roots = vec![std::env::current_dir()?]; let session_dir = store.base_dir.join("sessions").join(session_id); std::fs::create_dir_all(&session_dir)?; let mut client_state = app::state::rest::AppStateRest::new( workspace_roots, session_dir, store.memory_dir, ); client_state.session_id = session_id.to_string(); let _rt = tokio::runtime::Runtime::new()?; loop { if client_state.quit { let _ = client.send(&ClientRequest::Close); break; } let now_ms = chrono::Utc::now().timestamp_millis(); client_state.misc.drain_expired_toasts(now_ms); if crossterm::event::poll(std::time::Duration::from_millis(50))? { match crossterm::event::read()? { Event::Key(key) => { if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); let shift = key.modifiers.contains(KeyModifiers::SHIFT); if key.code == KeyCode::Char('c') && ctrl { client_state.quit = true; continue; } if let Some(key_action) = key_code_to_action(key.code) { client.send(&ClientRequest::KeyPress { key: key_action, ctrl, alt, shift, })?; } } } Event::Resize(w, h) => { client.send(&ClientRequest::Resize(w, h))?; } Event::Mouse(mouse_event) => { if mouse_event.kind == MouseEventKind::ScrollUp { client.send(&ClientRequest::ScrollUp)?; } else if mouse_event.kind == MouseEventKind::ScrollDown { client.send(&ClientRequest::ScrollDown)?; } } _ => {} } } else { client.send(&ClientRequest::Tick)?; } match client.receive::()? { Some(ipc::protocol::DaemonFrame::StateUpdate(payload)) => { apply_client_update(&mut client_state, *payload); } Some(ipc::protocol::DaemonFrame::StreamToken(_token)) => {} Some(ipc::protocol::DaemonFrame::SystemNote { kind: _, message }) => { client_state.push_toast( app::state::types::Toast::new( app::state::types::ToastKind::Info, message, ), ); } Some(ipc::protocol::DaemonFrame::Closed) => { client_state.quit = true; } None => { client_state.quit = true; } } terminal.draw(|f| { view::draw(f, &client_state); })?; } let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); let _ = disable_raw_mode(); let _ = client_state.settings.save(); Ok(()) } /// Run the single-process event loop, guaranteeing terminal restoration /// on error. /// /// Flow: delegate to `run_loop_inner` → if it errors, clear the screen /// and tear down raw mode / alternate screen before propagating the error. /// /// Why: without this wrapper, an error inside the loop would leave the /// user's terminal in raw/alternate-screen mode after the process exits. fn run_loop( state: &mut app::state::rest::AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { let result = run_loop_inner(state, terminal); if let Err(ref _e) = result { let _ = terminal.clear(); let _ = execute!(io::stdout(), DisableMouseCapture); let _ = disable_raw_mode(); let _ = execute!(io::stdout(), LeaveAlternateScreen); } result } /// The core single-process render/input loop. /// /// Flow: until `state.quit` → drain expired toasts → draw the frame → /// poll for a terminal event with a 50ms timeout (keys go through /// `handle_key` → `apply_action`; resize and scroll map to `Action` /// variants directly) → always fire `Action::Tick` each iteration /// (drives streaming/background progress) → on exit, clear the terminal. /// /// Why: the 50ms poll timeout bounds input latency while still yielding /// regularly for the `Tick` action, which drives async work like LLM /// streaming without a separate polling thread. fn run_loop_inner( state: &mut app::state::rest::AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { use std::time::Duration; use crossterm::event::{Event, KeyEventKind, MouseEventKind}; use controller::input::handle_key; use app::runtime::actions::{Action, apply_action}; loop { if state.quit { break; } let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); terminal.draw(|f| { view::draw(f, state); state.dirty = false; })?; if crossterm::event::poll(Duration::from_millis(50))? { 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); } } } 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); } } _ => {} } } apply_action(state, Action::Tick); } terminal.clear()?; Ok(()) }