Files
zesdex/src/main.rs
T

649 lines
25 KiB
Rust
Raw Normal View History

//! Zesdex binary entry point.
//!
//! Parses `--daemon` / `--attach <id>` 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 <id>` 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<String> = 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<ipc::protocol::KeyAction> {
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<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
MessageEntry {
role: format!("{:?}", m.role),
content: m.content.clone(),
timestamp: m.timestamp,
}
}).collect();
let toasts: Vec<ToastEntry> = 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
/// `<store>/run/<session_id>.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!("{session_id}.sock"));
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::<ClientRequest>()? {
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!("{session_id}.sock"));
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::<ipc::protocol::DaemonFrame>()? {
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) | 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<CrosstermBackend<io::Stdout>>,
) -> 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<CrosstermBackend<io::Stdout>>,
) -> 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(())
}