2026-07-13 08:12:02 +07:00
|
|
|
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
|
2026-07-12 11:28:39 +07:00
|
|
|
//! 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.
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use std::io;
|
|
|
|
|
use std::io::Write;
|
2026-07-12 10:57:32 +07:00
|
|
|
use std::sync::Mutex;
|
2026-07-11 13:16:10 +07:00
|
|
|
use anyhow::Result;
|
|
|
|
|
use crossterm::execute;
|
|
|
|
|
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen};
|
|
|
|
|
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;
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
fn main() -> Result<()> {
|
2026-07-11 20:21:59 +07:00
|
|
|
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());
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 10:57:32 +07:00
|
|
|
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")
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
tracing_subscriber::fmt()
|
|
|
|
|
.with_env_filter(
|
|
|
|
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
|
|
|
|
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
|
|
|
|
)
|
2026-07-12 10:57:32 +07:00
|
|
|
.with_writer(Mutex::new(log_file))
|
2026-07-11 13:16:10 +07:00
|
|
|
.init();
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
fn run_single_process() -> Result<()> {
|
2026-07-11 13:16:10 +07:00
|
|
|
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)?;
|
|
|
|
|
|
2026-07-12 01:25:52 +07:00
|
|
|
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)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
|
|
|
|
let mut state = app::state::rest::AppStateRest::new(
|
|
|
|
|
workspace_roots.clone(),
|
2026-07-13 08:12:02 +07:00
|
|
|
&session_dir,
|
2026-07-11 13:16:10 +07:00
|
|
|
store.memory_dir,
|
|
|
|
|
);
|
2026-07-11 20:21:59 +07:00
|
|
|
state.sessions = model::session::Session::list(&store.base_dir);
|
2026-07-12 03:14:52 +07:00
|
|
|
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
let _rt = tokio::runtime::Runtime::new()?;
|
|
|
|
|
|
|
|
|
|
enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
2026-07-13 14:39:29 +07:00
|
|
|
execute!(stdout, EnterAlternateScreen)?;
|
2026-07-15 01:54:36 +07:00
|
|
|
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
2026-07-15 04:30:49 +07:00
|
|
|
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
2026-07-11 13:16:10 +07:00
|
|
|
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();
|
2026-07-15 01:54:36 +07:00
|
|
|
let _ = execute!(restore_stdout, crossterm::event::DisableBracketedPaste);
|
2026-07-15 04:30:49 +07:00
|
|
|
let _ = execute!(restore_stdout, crossterm::event::DisableMouseCapture);
|
2026-07-13 14:39:29 +07:00
|
|
|
let _ = execute!(restore_stdout, LeaveAlternateScreen);
|
2026-07-11 13:16:10 +07:00
|
|
|
let _ = disable_raw_mode();
|
|
|
|
|
|
|
|
|
|
if let Err(e) = run_result {
|
2026-07-13 08:12:02 +07:00
|
|
|
let _ = writeln!(restore_stdout, "error: {e}");
|
2026-07-11 13:16:10 +07:00
|
|
|
let _ = restore_stdout.flush();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let _ = state.settings.save();
|
2026-07-12 01:25:52 +07:00
|
|
|
session_lock.unlock();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
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,
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
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),
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &app::state::rest::AppStateRest) -> Result<()> {
|
|
|
|
|
use ipc::protocol::{DaemonFrame, MessageEntry, ToastEntry, StatePayload};
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let messages: Vec<MessageEntry> = state.transcript_cache.messages.iter().map(|m| {
|
|
|
|
|
MessageEntry {
|
|
|
|
|
role: format!("{:?}", m.role),
|
|
|
|
|
content: m.content.clone(),
|
|
|
|
|
timestamp: m.timestamp,
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
}).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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
fn apply_client_update(
|
|
|
|
|
state: &mut app::state::rest::AppStateRest,
|
|
|
|
|
payload: ipc::protocol::StatePayload,
|
|
|
|
|
) {
|
2026-07-12 03:14:52 +07:00
|
|
|
use app::state::types::{Overlay, Toast, ToastKind};
|
2026-07-11 20:21:59 +07:00
|
|
|
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() {
|
|
|
|
|
"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,
|
2026-07-12 03:22:55 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
Some("Bash") => Overlay::Bash,
|
|
|
|
|
Some("QuitConfirm") => Overlay::QuitConfirm,
|
2026-07-15 03:26:59 +07:00
|
|
|
|
2026-07-12 03:14:52 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
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,
|
2026-07-12 02:06:46 +07:00
|
|
|
Some("ModelSelector") => Overlay::ModelSelector,
|
|
|
|
|
Some("ClearConfirm") => Overlay::ClearConfirm,
|
2026-07-12 03:14:52 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
_ => Overlay::None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
state.misc.toasts = payload.toasts.into_iter().map(|t| {
|
|
|
|
|
Toast {
|
|
|
|
|
kind: match t.kind.as_str() {
|
|
|
|
|
"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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 03:11:49 +07:00
|
|
|
/// 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.
|
|
|
|
|
/// Handle an incoming client connection for the daemon.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: loop reading requests, modifying state, and sending updates back.
|
|
|
|
|
fn handle_daemon_client(
|
|
|
|
|
mut conn: ipc::conn::Connection,
|
|
|
|
|
state: &mut app::state::rest::AppStateRest,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
use app::runtime::actions::{Action, apply_action};
|
|
|
|
|
use ipc::protocol::ClientRequest;
|
|
|
|
|
|
|
|
|
|
let mut running = true;
|
|
|
|
|
while running {
|
|
|
|
|
match conn.receive::<ClientRequest>()? {
|
|
|
|
|
Some(req) => {
|
|
|
|
|
match req {
|
|
|
|
|
ClientRequest::Tick => {
|
|
|
|
|
apply_action(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, state);
|
|
|
|
|
for action in actions {
|
|
|
|
|
apply_action(state, action);
|
|
|
|
|
}
|
|
|
|
|
apply_action(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, state);
|
|
|
|
|
for action in actions {
|
|
|
|
|
apply_action(state, action);
|
|
|
|
|
}
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::Paste(text) => {
|
|
|
|
|
state.input.buffer.insert_str(state.input.cursor, &text);
|
|
|
|
|
state.input.cursor += text.len();
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::Resize(w, h) => {
|
|
|
|
|
apply_action(state, Action::Resize(w, h));
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::ScrollUp => {
|
|
|
|
|
apply_action(state, Action::ScrollUp);
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::ScrollDown => {
|
|
|
|
|
apply_action(state, Action::ScrollDown);
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
ClientRequest::Close => {
|
|
|
|
|
running = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
send_daemon_update(&mut conn, state)?;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
running = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
fn run_daemon() -> 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)?;
|
|
|
|
|
|
2026-07-12 01:25:52 +07:00
|
|
|
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)");
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
|
|
|
|
let mut state = app::state::rest::AppStateRest::new(
|
|
|
|
|
workspace_roots.clone(),
|
2026-07-13 08:12:02 +07:00
|
|
|
&session_dir,
|
2026-07-11 20:21:59 +07:00
|
|
|
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)?;
|
2026-07-13 06:42:58 +07:00
|
|
|
let socket_path = run_dir.join(format!("{session_id}.sock"));
|
2026-07-11 20:21:59 +07:00
|
|
|
let addr = socket_path.to_string_lossy().to_string();
|
|
|
|
|
|
|
|
|
|
let server = ipc::server::IpcServer::bind_unix(&addr)?;
|
2026-07-13 06:42:58 +07:00
|
|
|
eprintln!("daemon: listening on {addr}");
|
2026-07-11 20:21:59 +07:00
|
|
|
|
2026-07-12 11:55:02 +07:00
|
|
|
loop {
|
2026-07-15 03:11:49 +07:00
|
|
|
let conn = match server.accept() {
|
2026-07-12 11:55:02 +07:00
|
|
|
Ok(c) => c,
|
|
|
|
|
Err(e) => {
|
2026-07-13 06:42:58 +07:00
|
|
|
eprintln!("daemon: accept error: {e}");
|
2026-07-12 11:55:02 +07:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
eprintln!("daemon: client connected");
|
2026-07-11 20:21:59 +07:00
|
|
|
|
2026-07-15 03:11:49 +07:00
|
|
|
if let Err(e) = handle_daemon_client(conn, &mut state) {
|
|
|
|
|
eprintln!("daemon: error handling client: {e}");
|
2026-07-11 20:21:59 +07:00
|
|
|
}
|
2026-07-12 11:55:02 +07:00
|
|
|
|
|
|
|
|
eprintln!("daemon: client disconnected, waiting for next connection...");
|
|
|
|
|
let _ = state.settings.save();
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _ = std::fs::remove_file(&socket_path);
|
2026-07-12 01:25:52 +07:00
|
|
|
session_lock.unlock();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
Ok(())
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 20:21:59 +07:00
|
|
|
fn run_attach(session_id: &str) -> Result<()> {
|
2026-07-12 18:09:03 +07:00
|
|
|
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
2026-07-11 20:21:59 +07:00
|
|
|
use ipc::protocol::ClientRequest;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let store = model::store::Store::new();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-13 06:42:58 +07:00
|
|
|
let socket_path = store.base_dir.join("run").join(format!("{session_id}.sock"));
|
2026-07-11 20:21:59 +07:00
|
|
|
let addr = socket_path.to_string_lossy().to_string();
|
|
|
|
|
let mut client = ipc::client::IpcClient::connect_unix(&addr)?;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
enable_raw_mode()?;
|
|
|
|
|
let mut stdout = io::stdout();
|
2026-07-13 14:39:29 +07:00
|
|
|
execute!(stdout, EnterAlternateScreen)?;
|
2026-07-15 01:54:36 +07:00
|
|
|
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
2026-07-15 04:30:49 +07:00
|
|
|
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
2026-07-11 20:21:59 +07:00
|
|
|
let backend = CrosstermBackend::new(stdout);
|
|
|
|
|
let mut terminal = Terminal::new(backend)?;
|
|
|
|
|
terminal.clear()?;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
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,
|
2026-07-13 08:12:02 +07:00
|
|
|
&session_dir,
|
2026-07-11 20:21:59 +07:00
|
|
|
store.memory_dir,
|
|
|
|
|
);
|
|
|
|
|
client_state.session_id = session_id.to_string();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _rt = tokio::runtime::Runtime::new()?;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
loop {
|
|
|
|
|
if client_state.quit {
|
|
|
|
|
let _ = client.send(&ClientRequest::Close);
|
|
|
|
|
break;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 01:54:36 +07:00
|
|
|
Event::Paste(text) => {
|
|
|
|
|
client.send(&ClientRequest::Paste(text))?;
|
|
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
Event::Resize(w, h) => {
|
|
|
|
|
client.send(&ClientRequest::Resize(w, h))?;
|
|
|
|
|
}
|
2026-07-12 18:09:03 +07:00
|
|
|
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)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 20:21:59 +07:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
} 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,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-13 06:42:58 +07:00
|
|
|
Some(ipc::protocol::DaemonFrame::Closed) | None => {
|
2026-07-11 20:21:59 +07:00
|
|
|
client_state.quit = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
terminal.draw(|f| {
|
|
|
|
|
view::draw(f, &client_state);
|
|
|
|
|
})?;
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 01:54:36 +07:00
|
|
|
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
2026-07-15 04:30:49 +07:00
|
|
|
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
2026-07-13 14:39:29 +07:00
|
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
2026-07-11 20:21:59 +07:00
|
|
|
let _ = disable_raw_mode();
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-11 20:21:59 +07:00
|
|
|
let _ = client_state.settings.save();
|
|
|
|
|
|
|
|
|
|
Ok(())
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
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();
|
2026-07-15 01:54:36 +07:00
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
let _ = disable_raw_mode();
|
2026-07-15 01:54:36 +07:00
|
|
|
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
2026-07-15 04:30:49 +07:00
|
|
|
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
2026-07-12 12:43:50 +07:00
|
|
|
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// 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.
|
2026-07-11 13:16:10 +07:00
|
|
|
fn run_loop_inner(
|
|
|
|
|
state: &mut app::state::rest::AppStateRest,
|
|
|
|
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
use std::time::Duration;
|
2026-07-12 03:14:52 +07:00
|
|
|
use crossterm::event::{Event, KeyEventKind, MouseEventKind};
|
2026-07-11 13:16:10 +07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-15 01:54:36 +07:00
|
|
|
Event::Paste(text) => {
|
|
|
|
|
// Insert pasted text as a single bulk operation instead of
|
|
|
|
|
// character-by-character, avoiding O(n^2) String::insert()
|
|
|
|
|
// and preventing stray newline/control-byte misinterpretation.
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
Event::Resize(w, h) => {
|
|
|
|
|
apply_action(state, Action::Resize(w, h));
|
|
|
|
|
}
|
2026-07-12 03:14:52 +07:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
apply_action(state, Action::Tick);
|
|
|
|
|
}
|
|
|
|
|
terminal.clear()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|