feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "zesdex-daemon"
version.workspace = true
edition.workspace = true
authors.workspace = true
# Daemon interface — background process that owns agent state and
# communicates with TUI clients over a Unix-socket IPC protocol.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
crossterm.workspace = true
ratatui.workspace = true
ignore.workspace = true
sha2.workspace = true
hex.workspace = true
base64.workspace = true
dirs.workspace = true
+369
View File
@@ -0,0 +1,369 @@
//! Attach mode — TUI-only client that connects to an existing daemon session
//! over a Unix socket, forwarding key events and rendering state updates.
//!
//! Flow: `run_attach(session_id)` resolves the daemon's socket path from
//! the store → `setup_attach_client()` connects and enters raw mode →
//! enters a render loop: polls for local terminal events (key/resize/paste/
//! scroll) → forwards them as `ClientRequest`s to the daemon via IPC →
//! receives a `DaemonFrame` reply → `handle_daemon_frame()` /
//! `apply_client_update()` applies the state snapshot onto a local
//! `AppStateRest` mirror → `draw()` renders the TUI → on quit,
//! sends `ClientRequest::Close`, cleans up terminal, and saves settings.
//!
//! The client has no agent logic — it is a pure render frontend.
use std::io::{self};
use anyhow::Result;
use zesdex_domain::SettingsRepository;
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Terminal;
use zesdex_infrastructure::ipc::client::IpcClient;
use zesdex_infrastructure::ipc::protocol::{ClientRequest, DaemonFrame, StatePayload};
use zesdex_infrastructure::Toast;
use zesdex_infrastructure::ToastKind;
use crate::key_code::key_code_to_action;
use crate::state::{AppStateRest, ChatMessageDisplay, Overlay, RoleWrapper};
// ---------------------------------------------------------------------------
// apply_client_update — apply a StatePayload onto the local AppStateRest
// ---------------------------------------------------------------------------
/// 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 role variants) →
/// resolve the overlay name string to an `Overlay` variant → rebuild
/// toasts from `ToastEntry`s.
///
/// Why: unrecognised role/overlay/toast-kind strings fall back to a safe
/// default (`User`, `Overlay::None`, `ToastKind::Info`) rather than
/// panicking, so a protocol/version mismatch degrades gracefully.
fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) {
tracing::debug!("applying state update from daemon");
state.session_id = payload.session_id;
state.dirty = payload.dirty;
state.transcript_cache.messages = payload
.messages
.into_iter()
.map(|m| ChatMessageDisplay {
role: match m.role.as_str() {
"Assistant" => RoleWrapper::Assistant,
"System" => RoleWrapper::System,
"Tool" => RoleWrapper::Tool,
_ => RoleWrapper::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("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() {
"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;
}
// ---------------------------------------------------------------------------
// setup_attach_client — connect to daemon and set up terminal
// ---------------------------------------------------------------------------
/// Set up the IPC client connection, terminal, and initial state for attach mode.
///
/// Flow: resolve socket path → connect → enable raw/alt mode → create state.
///
/// Return: (client, terminal, `client_state`) on success.
fn setup_attach_client(
session_id: &str,
) -> Result<(
IpcClient,
Terminal<CrosstermBackend<io::Stdout>>,
AppStateRest,
)> {
tracing::debug!("setting up attach client for session {session_id}");
let store = zesdex_infrastructure::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 client = IpcClient::connect_unix(&addr)?;
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 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 = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir);
client_state.session_id = session_id.to_string();
Ok((client, terminal, client_state))
}
// ---------------------------------------------------------------------------
// handle_daemon_frame — process a single DaemonFrame from the daemon
// ---------------------------------------------------------------------------
/// Process a single daemon frame from the IPC channel, updating state accordingly.
fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option<DaemonFrame>) {
tracing::debug!("received daemon frame");
match frame {
Some(DaemonFrame::StateUpdate(payload)) => {
apply_client_update(client_state, *payload);
}
Some(DaemonFrame::StreamToken(_token)) => {}
Some(DaemonFrame::SystemNote { kind: _, message }) => {
client_state.push_toast(Toast::new(ToastKind::Info, message));
}
Some(DaemonFrame::ClipboardCopy(text)) => {
let _ = zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
client_state.push_toast(Toast::new(
ToastKind::Success,
"Copied to clipboard".to_string(),
));
}
Some(DaemonFrame::Closed) | None => {
client_state.quit = true;
}
}
}
// ---------------------------------------------------------------------------
// draw — minimal TUI render function
// ---------------------------------------------------------------------------
/// Render the current application state onto the terminal.
///
/// Layout:
/// - Top: title bar with session ID and overlay status
/// - Middle: transcript message list (scrollable)
/// - Bottom: input line with cursor
/// - Overlay name shown when an overlay is active
fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) {
let area = frame.area();
// Vertical layout: main content + input line
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), // main content (transcript + toasts)
Constraint::Length(3), // input line
])
.split(area);
// ── Main content area ───────────────────────────────────────────────
let title = format!(
" Zesdex — {} {}",
&state.session_id[..state.session_id.len().min(8)],
if state.misc.overlay.is_active() {
format!("[{}]", state.misc.overlay)
} else {
String::new()
}
);
let mut content_lines: Vec<String> = Vec::new();
// Show toasts at the top if present.
for toast in &state.misc.toasts {
content_lines.push(format!("[{}] {}", format!("{:?}", toast.kind), toast.message));
}
// Show active overlay name.
if state.misc.overlay.is_active() {
content_lines.push(String::new());
content_lines.push(format!("=== {} ===", state.misc.overlay));
content_lines.push(String::new());
}
// Transcript messages.
for msg in &state.transcript_cache.messages {
let prefix = match msg.role {
RoleWrapper::User => "You",
RoleWrapper::Assistant => "AI",
RoleWrapper::System => "System",
RoleWrapper::Tool => "Tool",
};
content_lines.push(format!("{}: {}", prefix, msg.content));
}
// Scroll offset indicator.
if state.scroll.offset > 0 {
content_lines.push(format!("--- scrolled up {} lines ---", state.scroll.offset));
}
let content = content_lines.join("\n");
let main_block = Block::default()
.title(title)
.borders(Borders::TOP);
let paragraph = Paragraph::new(content)
.block(main_block)
.wrap(Wrap { trim: false })
.scroll((state.scroll.offset as u16, 0));
frame.render_widget(paragraph, chunks[0]);
// ── Input line ──────────────────────────────────────────────────────
let input_block = Block::default().borders(Borders::TOP);
let input_display = if state.input.buffer.is_empty() {
"Type a message...".to_string()
} else {
state.input.buffer.clone()
};
let input_paragraph = Paragraph::new(input_display)
.block(input_block);
frame.render_widget(input_paragraph, chunks[1]);
// Set cursor position for the input line.
use ratatui::layout::Position;
frame.set_cursor_position(Position::new(
chunks[1].x + state.input.cursor as u16 + 1,
chunks[1].y + 1,
));
}
// ---------------------------------------------------------------------------
// run_attach — main attach-mode entry point
// ---------------------------------------------------------------------------
/// 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.
pub fn run_attach(session_id: &str) -> Result<()> {
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?;
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::Paste(text) => {
client.send(&ClientRequest::Paste(text))?;
}
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)?;
}
handle_daemon_frame(
&mut client_state,
client.receive::<DaemonFrame>()?,
);
terminal.draw(|f| {
draw(f, &client_state);
})?;
}
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
let _ = execute!(io::stdout(), LeaveAlternateScreen);
let _ = disable_raw_mode();
let _ = zesdex_infrastructure::persistence::JsonSettingsRepository::new()
.save(&client_state.store_base_dir(), &client_state.settings);
Ok(())
}
+738
View File
@@ -0,0 +1,738 @@
//! Daemon request handler — processes IPC `ClientRequest` messages,
//! applies `Action`s to application state, and pushes state updates back
//! to the attached client.
//!
//! Also defines the [`Action`] enum and the [`apply_action`] dispatcher,
//! as well as the [`handle_key`] function that translates `crossterm`
//! key events into actions — adapting `controller::input::handle_key`
//! from the legacy single-process backend.
//!
//! Flow:
//! 1. `handle_daemon_client(conn, state)` loops reading `ClientRequest`s
//! 2. Each request is translated into `Action`(s) via `handle_key` /
//! direct action invocation
//! 3. `apply_action` mutates `AppStateRest` in place
//! 4. After each request, `send_daemon_update` pushes a full state
//! snapshot back to the client
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use zesdex_infrastructure::ipc::conn::Connection;
use zesdex_infrastructure::ipc::protocol::{
ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry,
};
use zesdex_infrastructure::utils::CastOr;
use crate::key_code::key_action_to_code;
use crate::state::{
AppStateRest, AutocompleteKind, ChatMessageDisplay, Overlay, RoleWrapper,
};
// ---------------------------------------------------------------------------
// Action enum
// ---------------------------------------------------------------------------
/// A single well-typed event that mutates `AppStateRest` when applied via
/// [`apply_action`].
///
/// Produced by `handle_key` (key event → actions) or directly by the
/// daemon's IPC handler.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay.
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event — carries the new dimensions.
Resize(u16, u16),
/// Periodic timer tick — drains queued events and runs side jobs.
Tick,
/// Accept a lesson by name.
LessonAccept {
name: String,
},
/// Reject a lesson by name.
LessonReject {
name: String,
},
/// Delete a previously stored lesson by name.
LessonDelete {
name: String,
},
/// Start the OAuth device-code login flow for a named provider.
StartOAuth {
provider: String,
},
/// Open the inline file editor for `path`.
OpenEditor {
path: String,
},
/// Register a new MCP server by name and shell command.
McpAdd {
name: String,
command: String,
},
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
}
// ---------------------------------------------------------------------------
// apply_action
// ---------------------------------------------------------------------------
/// Apply an `Action` to the application state.
///
/// Flow: pattern-match the variant → delegate to the corresponding handler
/// → handler mutates `state` (input buffer, scroll position, overlay,
/// transcript, toasts, dirty flag, etc.).
///
/// Why: the single chokepoint that turns every typed key and async event
/// into a state change.
pub fn apply_action(state: &mut AppStateRest, action: Action) {
tracing::debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => handle_force_quit(state),
Action::QuitConfirm => handle_quit_confirm(state),
Action::Resize(w, _h) => handle_resize(state, w),
Action::Tick => handle_tick(state),
// ── Input / editing ───────────────────────────────────────────
Action::SubmitInput(text) => handle_submit_input(state, text),
Action::DeleteChar => handle_delete_char(state),
Action::DeleteCharRight => handle_delete_char_right(state),
Action::CursorLeft => handle_cursor_left(state),
Action::CursorRight => handle_cursor_right(state),
Action::HistoryUp => handle_history_up(state),
Action::HistoryDown => handle_history_down(state),
// ── Scroll / navigation ───────────────────────────────────────
Action::ScrollUp => handle_scroll_up(state),
Action::ScrollDown => handle_scroll_down(state),
Action::OpenOverlay(overlay) => handle_open_overlay(state, overlay),
Action::CloseOverlay => handle_close_overlay(state),
// ── System / info ─────────────────────────────────────────────
Action::SystemNote { kind: _kind, message } => {
handle_system_note(state, message)
}
Action::ModelList => handle_model_list(state),
Action::AbortTurn => handle_abort_turn(state),
Action::Compact => handle_compact(state),
// ── Editor / MCP / OAuth ──────────────────────────────────────
Action::OpenEditor { path } => handle_open_editor(state, path),
Action::McpAdd { name, command } => handle_mcp_add(state, name, command),
Action::StartOAuth { provider } => handle_start_oauth(state, provider),
// ── Lessons ───────────────────────────────────────────────────
Action::LessonAccept { name } => handle_lesson_accept(state, name),
Action::LessonReject { name } => handle_lesson_reject(state, name),
Action::LessonDelete { name } => handle_lesson_delete(state, name),
}
}
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------
fn handle_force_quit(state: &mut AppStateRest) {
state.quit = true;
}
fn handle_quit_confirm(state: &mut AppStateRest) {
state.misc.overlay = Overlay::QuitConfirm;
state.dirty = true;
}
fn handle_resize(state: &mut AppStateRest, _w: u16) {
state.dirty = true;
}
fn handle_tick(state: &mut AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms);
// Drain queued turn events FIRST (while holding the lock), then release
// the lock and process events with mutable state access.
let drained: Vec<_> = state
.turn_events
.lock()
.map(|mut events| events.drain(..).collect())
.unwrap_or_default();
for event in drained {
use zesdex_infrastructure::TurnEvent;
match event {
TurnEvent::SystemNote { kind, message } => {
if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
}
handle_system_note(state, message);
}
TurnEvent::AssistantMessage(msg) => {
state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::Assistant,
msg.content.unwrap_or_default(),
));
}
TurnEvent::StreamToken(_token) => {
state.dirty = true;
}
TurnEvent::StreamDone(msg) => {
state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::Assistant,
msg.content.unwrap_or_default(),
));
}
TurnEvent::Error(e) => {
state.toast_error(e);
}
TurnEvent::Usage { tokens_in, tokens_out } => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
}
}
TurnEvent::ReviewUsage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
}
}
TurnEvent::Done => {
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
*in_flight = false;
}
state.dirty = true;
}
_ => {
state.dirty = true;
}
}
}
state.misc.tick_count += 1;
state.dirty = true;
}
fn handle_submit_input(state: &mut AppStateRest, text: String) {
// Push the user message to the transcript.
state.push_transcript(ChatMessageDisplay::new(RoleWrapper::User, text.clone()));
// Save the input to history.
if !text.is_empty() {
state.input.history.push(text.clone());
if let Some(ref path) = state.input.history_file {
let _ = std::fs::write(path, state.input.history.join("\n"));
}
}
// Set up the session runtime for the turn.
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(zesdex_infrastructure::ChatMessage {
role: zesdex_infrastructure::Role::User,
content: Some(text.clone()),
tool_calls: None,
tool_call_id: None,
name: None,
});
}
// Clear the input buffer.
state.input.buffer.clear();
state.input.cursor = 0;
state.input.history_idx = None;
state.dirty = true;
}
fn handle_delete_char(state: &mut AppStateRest) {
if state.input.cursor > 0 {
state.input.buffer.remove(state.input.cursor - 1);
state.input.cursor -= 1;
state.dirty = true;
}
}
fn handle_delete_char_right(state: &mut AppStateRest) {
if state.input.cursor < state.input.buffer.len() {
state.input.buffer.remove(state.input.cursor);
state.dirty = true;
}
}
fn handle_cursor_left(state: &mut AppStateRest) {
if state.input.cursor > 0 {
state.input.cursor = state.input.cursor.saturating_sub(1);
state.dirty = true;
}
}
fn handle_cursor_right(state: &mut AppStateRest) {
if state.input.cursor < state.input.buffer.len() {
state.input.cursor += 1;
state.dirty = true;
}
}
fn handle_history_up(state: &mut AppStateRest) {
if state.input.history.is_empty() {
return;
}
let idx = match state.input.history_idx {
Some(i) if i > 0 => i - 1,
None => state.input.history.len() - 1,
_ => return,
};
state.input.history_idx = Some(idx);
state.input.buffer = state.input.history[idx].clone();
state.input.cursor = state.input.buffer.len();
state.dirty = true;
}
fn handle_history_down(state: &mut AppStateRest) {
match state.input.history_idx {
Some(i) if i + 1 < state.input.history.len() => {
state.input.history_idx = Some(i + 1);
state.input.buffer = state.input.history[i + 1].clone();
state.input.cursor = state.input.buffer.len();
state.dirty = true;
}
Some(_) => {
state.input.history_idx = None;
state.input.buffer.clear();
state.input.cursor = 0;
state.dirty = true;
}
None => {}
}
}
fn handle_scroll_up(state: &mut AppStateRest) {
state.scroll.scroll_up(1);
state.dirty = true;
}
fn handle_scroll_down(state: &mut AppStateRest) {
state.scroll.scroll_down(1);
state.dirty = true;
}
fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
state.misc.overlay = overlay;
state.dirty = true;
}
fn handle_close_overlay(state: &mut AppStateRest) {
if state.misc.overlay.is_active() {
state.misc.overlay = Overlay::None;
state.dirty = true;
}
}
fn handle_system_note(state: &mut AppStateRest, message: String) {
state.push_transcript(ChatMessageDisplay::new(RoleWrapper::System, message));
}
fn handle_model_list(state: &mut AppStateRest) {
handle_open_overlay(state, Overlay::ModelSelector);
}
fn handle_abort_turn(state: &mut AppStateRest) {
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
*in_flight = false;
}
state.dirty = true;
}
fn handle_compact(state: &mut AppStateRest) {
// Placeholder — compaction logic is delegated to the agent runtime.
state.toast_info("Compacting conversation...");
state.dirty = true;
}
fn handle_open_editor(state: &mut AppStateRest, _path: String) {
handle_open_overlay(state, Overlay::Editor);
}
fn handle_mcp_add(state: &mut AppStateRest, _name: String, _command: String) {
// Placeholder — MCP registration happens via the MCP manager.
state.toast_info("MCP server registration not yet supported in daemon mode.");
state.dirty = true;
}
fn handle_start_oauth(state: &mut AppStateRest, _provider: String) {
// Placeholder — OAuth flow happens asynchronously.
state.toast_info("OAuth not yet supported in daemon mode.");
state.dirty = true;
}
fn handle_lesson_accept(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
fn handle_lesson_reject(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
// ---------------------------------------------------------------------------
// handle_key — translate crossterm KeyEvent into Vec<Action>
// ---------------------------------------------------------------------------
/// Translate a terminal `KeyEvent` into zero or more `Action` values
/// based on the current application state.
///
/// This is a simplified version of the legacy `controller::input::handle_key`.
/// It handles the most common key combinations for the TUI chat interface.
///
/// Flow:
/// 1. If `Overlay::Editor` is active → route keys to the editor.
/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys.
/// 3. Fallthrough: match on `key.code` and modifiers for normal mode.
///
/// Return: `Vec<Action>` so a single key (e.g. Ctrl+C) can produce multiple
/// queued actions.
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
tracing::debug!(
code = ?key.code,
mods = ?key.modifiers,
overlay = ?state.misc.overlay,
"handle_key"
);
// ── Editor overlay ───────────────────────────────────────────────────
if state.misc.overlay == Overlay::Editor {
return match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
}
KeyCode::Esc => {
vec![Action::CloseOverlay]
}
_ => vec![],
};
}
// ── Learning overlay ──────────────────────────────────────────────────
if state.misc.overlay == Overlay::Learning {
return match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
}
KeyCode::Esc => vec![Action::CloseOverlay],
KeyCode::Up => {
state.misc.selected_index = state.misc.selected_index.saturating_sub(1);
state.dirty = true;
vec![]
}
KeyCode::Down => {
state.misc.selected_index = state.misc.selected_index.saturating_add(1);
state.dirty = true;
vec![]
}
_ => vec![],
};
}
// ── Normal mode ───────────────────────────────────────────────────────
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.misc.overlay.is_active() {
vec![Action::QuitConfirm]
} else {
vec![Action::ForceQuit]
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay]
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Yank: handled separately via clipboard — produce no action
state.dirty = true;
vec![]
}
KeyCode::Tab => {
// Cycle autocomplete
if !state.input.autocomplete_visible {
state.input.open_autocomplete();
state.dirty = true;
} else {
state.input.autocomplete_idx =
(state.input.autocomplete_idx + 1) % state.input.autocomplete_candidates.len().max(1);
state.dirty = true;
}
vec![]
}
KeyCode::Enter => {
if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
} else if state.input.autocomplete_visible {
// Select the current autocomplete candidate
if !state.input.autocomplete_candidates.is_empty() {
let idx = state.input.autocomplete_idx
.min(state.input.autocomplete_candidates.len().saturating_sub(1));
if state.input.autocomplete_kind == AutocompleteKind::Command {
state.input.buffer =
state.input.autocomplete_candidates[idx].clone();
state.input.cursor = state.input.buffer.len();
}
state.input.close_autocomplete();
state.dirty = true;
}
vec![]
} else if !state.input.buffer.is_empty() {
vec![Action::SubmitInput(state.input.buffer.clone())]
} else {
vec![]
}
}
KeyCode::Esc => {
if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
} else if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.dirty = true;
vec![]
} else {
vec![Action::AbortTurn]
}
}
KeyCode::Up => {
if state.misc.overlay.is_active() {
vec![Action::ScrollUp]
} else {
vec![Action::HistoryUp]
}
}
KeyCode::Down => {
if state.misc.overlay.is_active() {
vec![Action::ScrollDown]
} else {
vec![Action::HistoryDown]
}
}
KeyCode::PageUp => vec![Action::ScrollUp],
KeyCode::PageDown => vec![Action::ScrollDown],
KeyCode::Home => {
state.scroll.offset = 0;
state.dirty = true;
vec![]
}
KeyCode::End => {
state.scroll.offset = usize::MAX;
state.dirty = true;
vec![]
}
KeyCode::Backspace => vec![Action::DeleteChar],
KeyCode::Delete => vec![Action::DeleteCharRight],
KeyCode::Left => vec![Action::CursorLeft],
KeyCode::Right => vec![Action::CursorRight],
KeyCode::Char(c) => {
// Regular character input
if state.input.autocomplete_visible {
state.input.close_autocomplete();
}
state.input.buffer.insert(state.input.cursor, c);
state.input.cursor += c.len_utf8();
state.dirty = true;
vec![]
}
_ => {
// Unhandled key
vec![]
}
}
}
// ---------------------------------------------------------------------------
// send_daemon_update — push full state snapshot to the client
// ---------------------------------------------------------------------------
/// 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`.
pub fn send_daemon_update(conn: &mut Connection, state: &AppStateRest) -> Result<()> {
tracing::debug!("sending state update to attached client");
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().cast_or(0u32),
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)?;
Ok(())
}
// ---------------------------------------------------------------------------
// handle_daemon_client — process an attached client's IPC messages
// ---------------------------------------------------------------------------
/// Handle an incoming client connection for the daemon.
///
/// Flow: loop reading requests, modifying state, and sending updates back.
pub fn handle_daemon_client(
mut conn: Connection,
state: &mut AppStateRest,
) -> Result<()> {
tracing::debug!("handling daemon client connection");
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 = KeyModifiers::NONE;
if ctrl {
modifiers |= KeyModifiers::CONTROL;
}
if alt {
modifiers |= KeyModifiers::ALT;
}
if shift {
modifiers |= KeyModifiers::SHIFT;
}
let key_event =
KeyEvent::new(key_action_to_code(&key), modifiers);
let actions = 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 = KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
);
let actions = 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;
}
}
if let Some(text) = state.misc.pending_clipboard_copy.take() {
conn.send(&DaemonFrame::ClipboardCopy(text))?;
}
send_daemon_update(&mut conn, state)?;
}
None => {
running = false;
}
}
}
Ok(())
}
+65
View File
@@ -0,0 +1,65 @@
//! Key code <-> wire-serializable `KeyAction` conversion functions.
//!
//! Map `crossterm::event::KeyCode` to and from the IPC protocol's `KeyAction`
//! enum. Both directions are total (every `KeyAction` has a `KeyCode`), but
//! `key_code_to_action` returns `None` for key codes with no IPC equivalent
//! (e.g. media keys), which are silently dropped by the caller.
//!
//! Flow: daemon receives `KeyAction` over IPC → `key_action_to_code` →
//! reconstructs `crossterm::KeyEvent` → feeds into `controller::input::handle_key`.
//! The inverse (`key_code_to_action`) is used by the attach-mode client to
//! serialise a local terminal key press before sending it over the socket.
use crossterm::event::KeyCode;
use zesdex_infrastructure::ipc::protocol::KeyAction;
/// 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.
pub fn key_code_to_action(code: KeyCode) -> Option<KeyAction> {
tracing::debug!("converting key code to action: {:?}", code);
match code {
KeyCode::Char(c) => Some(KeyAction::Char(c)),
KeyCode::Enter => Some(KeyAction::Enter),
KeyCode::Esc => Some(KeyAction::Escape),
KeyCode::Backspace => Some(KeyAction::Backspace),
KeyCode::Delete => Some(KeyAction::Delete),
KeyCode::Tab => Some(KeyAction::Tab),
KeyCode::Up => Some(KeyAction::Up),
KeyCode::Down => Some(KeyAction::Down),
KeyCode::Left => Some(KeyAction::Left),
KeyCode::Right => Some(KeyAction::Right),
KeyCode::Home => Some(KeyAction::Home),
KeyCode::End => Some(KeyAction::End),
KeyCode::PageUp => Some(KeyAction::PageUp),
KeyCode::PageDown => Some(KeyAction::PageDown),
KeyCode::F(n) => Some(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.
pub fn key_action_to_code(action: &KeyAction) -> KeyCode {
tracing::debug!("converting key action to code: {:?}", action);
match action {
KeyAction::Char(c) => KeyCode::Char(*c),
KeyAction::Enter => KeyCode::Enter,
KeyAction::Escape => KeyCode::Esc,
KeyAction::Backspace => KeyCode::Backspace,
KeyAction::Delete => KeyCode::Delete,
KeyAction::Tab => KeyCode::Tab,
KeyAction::Up => KeyCode::Up,
KeyAction::Down => KeyCode::Down,
KeyAction::Left => KeyCode::Left,
KeyAction::Right => KeyCode::Right,
KeyAction::Home => KeyCode::Home,
KeyAction::End => KeyCode::End,
KeyAction::PageUp => KeyCode::PageUp,
KeyAction::PageDown => KeyCode::PageDown,
KeyAction::Function(n) => KeyCode::F(*n),
}
}
+33
View File
@@ -0,0 +1,33 @@
//! # Daemon Interface
//!
//! Background process that owns agent state and communicates with TUI
//! clients over a Unix-socket IPC protocol.
//!
//! ## Architecture
//!
//! ```text
//! src/
//! ├── lib.rs — Crate root, module declarations, re-exports
//! ├── server.rs — Daemon server: session creation, socket bind, client loop
//! ├── client.rs — IPC client for attaching to daemon (moved from attach mode)
//! ├── key_code.rs — KeyCode <-> KeyAction conversions
//! ├── state.rs — AppStateRest, DaemonState, supporting types, create_session
//! └── handler.rs — Action, apply_action, handle_key, IPC request handler
//! ```
//!
//! ## Modes
//!
//! - **Server** (`run_daemon`): creates a session, binds a Unix socket, accepts
//! incoming clients, and processes their IPC `ClientRequest`s.
//! - **Client** (`run_attach`): connects to a running daemon over its Unix socket,
//! enters raw TUI mode, forwards keystrokes, and renders state updates.
pub mod client;
pub mod handler;
pub mod key_code;
pub mod server;
pub mod state;
// Re-export key types for convenience.
pub use handler::{apply_action, Action};
pub use state::{AppStateRest, DaemonState, Overlay};
+63
View File
@@ -0,0 +1,63 @@
//! Daemon server — owns the agent state, listens on a per-session Unix
//! socket, and drives one attached client at a time.
//!
//! Flow: `run_daemon()` creates a session + lock → binds a Unix socket
//! under `<store>/run/<session_id>.sock` → blocks for a single client to
//! `accept()` → loops reading `ClientRequest`s, translating each into
//! `Action`(s) via the same `handle_key`/`apply_action` path the
//! single-process mode uses, then pushes a full state update back →
//! on `Close` or client disconnect, cleans up the socket file, saves
//! settings, and releases the lock.
//!
//! Why: reuses `crate::handler::handle_key` by synthesising a
//! `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and
//! single-process modes share identical key-handling logic.
use anyhow::Result;
use zesdex_infrastructure::ipc::server::IpcServer;
use crate::handler::handle_daemon_client;
use crate::state::create_session;
/// 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) → on `Close` or client disconnect, clean up the socket file,
/// save settings, and release the lock.
pub fn run_daemon() -> Result<()> {
tracing::info!("starting daemon process");
let (store, _session_lock_guard, mut state, _rt) = create_session()?;
let run_dir = store.base_dir.join("run");
std::fs::create_dir_all(&run_dir)?;
let socket_path = run_dir.join(format!("{}.sock", state.session_id));
let addr = socket_path.to_string_lossy().to_string();
let server = IpcServer::bind_unix(&addr)?;
eprintln!("daemon: listening on {addr}");
loop {
let conn = match server.accept() {
Ok(c) => c,
Err(e) => {
eprintln!("daemon: accept error: {e}");
break;
}
};
eprintln!("daemon: client connected");
if let Err(e) = handle_daemon_client(conn, &mut state) {
eprintln!("daemon: error handling client: {e}");
}
eprintln!("daemon: client disconnected, waiting for next connection...");
state.save_settings();
}
let _ = std::fs::remove_file(&socket_path);
Ok(())
}
+788
View File
@@ -0,0 +1,788 @@
//! Daemon state types — `AppStateRest`, `DaemonState`, and all supporting
//! data structures for the background daemon session.
//!
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
//! [`handler::apply_action`](crate::handler::apply_action) and the IPC handler.
//! `DaemonState` wraps it with IPC socket metadata.
//!
//! Also contains [`create_session()`] adapted from the legacy `main.rs`.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use tokio::sync::RwLock;
use zesdex_domain::cms::EditLog;
use zesdex_domain::Session;
use zesdex_domain::Settings;
use zesdex_domain::AppConfigRepository;
use zesdex_domain::EditLogRepository;
use zesdex_domain::SessionLockRepository;
use zesdex_domain::SessionRepository;
use zesdex_domain::SettingsRepository;
use zesdex_infrastructure::lsp::manager::LspManager;
use zesdex_infrastructure::mcp::manager::McpManager;
use zesdex_infrastructure::persistence::FileSystemSessionLockRepository;
use zesdex_infrastructure::persistence::JsonAppConfigRepository;
use zesdex_infrastructure::persistence::JsonlEditLogRepository;
use zesdex_infrastructure::persistence::JsonSettingsRepository;
use zesdex_infrastructure::AppConfig;
use zesdex_infrastructure::DirCache;
use zesdex_infrastructure::MentionIndex;
use zesdex_infrastructure::SessionRuntime;
use zesdex_infrastructure::Toast;
use zesdex_infrastructure::ToastKind;
use zesdex_infrastructure::TurnEvent;
// ---------------------------------------------------------------------------
// Supporting types
// ---------------------------------------------------------------------------
/// A single transcript entry rendered in the TUI chat pane.
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
/// Message author: User, Assistant, System, or Tool.
pub role: RoleWrapper,
/// Rendered text content (plain text, no markdown).
pub content: String,
/// Millisecond timestamp when this display entry was created.
pub timestamp: i64,
}
/// Simple string-backed role wrapper for transcript display (avoids a direct
/// dependency on the domain's `Role` enum which may not round-trip all wire
/// strings).
#[derive(Debug, Clone, PartialEq)]
pub enum RoleWrapper {
User,
Assistant,
System,
Tool,
}
impl ChatMessageDisplay {
/// Build a display entry, stamping it with the current time.
pub fn new(role: RoleWrapper, content: String) -> Self {
tracing::debug!(
"ChatMessageDisplay::new — role={:?}, content_len={}",
role,
content.len()
);
ChatMessageDisplay {
role,
content,
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
/// Which modal overlay, if any, is currently shown over the main TUI view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
/// No overlay; the main chat view is shown.
None,
/// Key bindings help screen.
Help,
/// Settings/configuration panel.
Settings,
/// Background bash job viewer.
Bash,
/// "Are you sure you want to quit?" confirmation.
QuitConfirm,
/// Raw key-code input capture (for binding custom keys).
KeyInput,
/// Inline editor (opened via `/edit`).
Editor,
/// Reasoning effort level selector.
Effort,
/// MCP server management panel.
Mcp,
/// TODO list overlay.
Todo,
/// Session rewind / history scrubber.
Rewind,
/// Learning / lesson management panel.
Learning,
/// Token usage statistics panel.
Usage,
/// Generic loading spinner overlay.
Loading,
/// Model selector dropdown.
ModelSelector,
/// "Clear conversation?" confirmation (distinct from QuitConfirm).
ClearConfirm,
}
impl Overlay {
/// Human-readable name for this overlay variant.
pub fn as_str(self) -> &'static str {
match self {
Overlay::None => "none",
Overlay::Help => "help",
Overlay::Settings => "settings",
Overlay::Bash => "bash",
Overlay::QuitConfirm => "quit_confirm",
Overlay::KeyInput => "key_input",
Overlay::Editor => "editor",
Overlay::Effort => "effort",
Overlay::Mcp => "mcp",
Overlay::Todo => "todo",
Overlay::Rewind => "rewind",
Overlay::Learning => "learning",
Overlay::Usage => "usage",
Overlay::Loading => "loading",
Overlay::ModelSelector => "model_selector",
Overlay::ClearConfirm => "clear_confirm",
}
}
/// Whether any overlay (i.e. anything other than `None`) is active.
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
impl std::fmt::Display for Overlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Bounded ring of recent chat messages used to render the transcript view.
#[derive(Debug, Clone)]
pub struct TranscriptCache {
/// Ordered display messages (newest appended, oldest evicted when full).
pub messages: Vec<ChatMessageDisplay>,
/// Maximum messages to retain before evicting the oldest.
pub max_lines: usize,
/// Whether the cache has changed since the last render sweep.
pub dirty: bool,
}
impl TranscriptCache {
/// Create an empty transcript cache holding at most `max_lines` messages.
pub fn new(max_lines: usize) -> Self {
TranscriptCache {
messages: Vec::new(),
max_lines,
dirty: true,
}
}
}
/// Which source populated the autocomplete dropdown.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AutocompleteKind {
/// Builtin slash-command (e.g. `/model`, `/help`).
Command,
/// `@file` mention from the workspace file index.
FileMention,
}
/// The user's input buffer, cursor position, history, and autocomplete state.
#[derive(Debug, Clone)]
pub struct InputState {
/// Raw UTF-8 input buffer content.
pub buffer: String,
/// Byte offset of the cursor within `buffer`.
pub cursor: usize,
/// Previously submitted input lines, oldest-first.
pub history: Vec<String>,
/// Index into `history` when browsing (None = at the current input).
pub history_idx: Option<usize>,
/// The prefix string used to filter candidates for autocomplete.
pub autocomplete_prefix: String,
/// Current autocomplete candidate list.
pub autocomplete_candidates: Vec<String>,
/// Focused index within `autocomplete_candidates`.
pub autocomplete_idx: usize,
/// Whether the autocomplete dropdown is visible.
pub autocomplete_visible: bool,
/// Which kind of autocomplete is active.
pub autocomplete_kind: AutocompleteKind,
/// Byte offset of the `@` character that triggered file mention autocomplete.
pub mention_start: usize,
/// Optional path to a persistent history file.
pub history_file: Option<PathBuf>,
}
impl InputState {
/// Create an empty input state with no buffer, no history, and no autocomplete.
pub fn new() -> Self {
InputState {
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
autocomplete_prefix: String::new(),
autocomplete_candidates: Vec::new(),
autocomplete_idx: 0,
autocomplete_visible: false,
autocomplete_kind: AutocompleteKind::Command,
mention_start: 0,
history_file: None,
}
}
/// Close the autocomplete dropdown.
pub fn close_autocomplete(&mut self) {
self.autocomplete_visible = false;
self.autocomplete_candidates.clear();
self.autocomplete_prefix.clear();
}
/// Open the command-autocomplete dropdown.
pub fn open_autocomplete(&mut self) {
self.autocomplete_kind = AutocompleteKind::Command;
self.autocomplete_visible = true;
}
}
impl Default for InputState {
fn default() -> Self {
Self::new()
}
}
/// Viewport scroll state: current offset and visible-line count.
#[derive(Debug, Clone)]
pub struct ScrollState {
/// Current scroll offset (how many lines have been scrolled past).
pub offset: usize,
/// Maximum number of lines that fit in the visible viewport area.
pub max_visible: usize,
}
impl ScrollState {
/// Create a `ScrollState` with zero offset and 30 rows visible.
pub fn new() -> Self {
ScrollState {
offset: 0,
max_visible: 30,
}
}
/// Scroll the viewport up by `amount` lines (increasing the offset).
pub fn scroll_up(&mut self, amount: usize) {
self.offset = self.offset.saturating_add(amount);
}
/// Scroll the viewport down by `amount` lines (decreasing the offset).
pub fn scroll_down(&mut self, amount: usize) {
self.offset = self.offset.saturating_sub(amount);
}
/// Update the maximum number of visible lines in the viewport.
pub fn set_max_visible(&mut self, max: usize) {
self.max_visible = max;
}
}
impl Default for ScrollState {
fn default() -> Self {
Self::new()
}
}
/// The "miscellaneous" slice of app state: which overlay is showing,
/// toasts, thinking flags, editor state, and tick.
#[derive(Debug, Clone)]
pub struct MiscState {
/// Currently active modal overlay (None = main chat view).
pub overlay: Overlay,
/// Active toast notifications (expired ones removed on each tick).
pub toasts: Vec<Toast>,
/// Whether the agent is currently "thinking".
pub thinking: bool,
/// Current LLM reasoning effort level (1-5).
pub effort_level: usize,
/// Whether the API connection is established.
pub api_connected: bool,
/// Currently focused index in list-type overlays.
pub selected_index: usize,
/// Monotonically increasing tick count, incremented each render frame.
pub tick_count: u64,
/// Cached content of the TODO file, shown in the overlay.
pub todo_content: String,
/// Whether a lesson background task is currently running.
pub lesson_running: bool,
/// Text waiting to be written to the system clipboard.
pub pending_clipboard_copy: Option<String>,
}
impl MiscState {
/// Create a fresh `MiscState` with no overlay, no toasts, and default effort level 1.
pub fn new() -> Self {
MiscState {
overlay: Overlay::None,
toasts: Vec::new(),
thinking: false,
effort_level: 1,
api_connected: false,
selected_index: 0,
tick_count: 0,
todo_content: String::new(),
lesson_running: false,
pending_clipboard_copy: None,
}
}
/// Append a toast notification to the active list.
pub fn push_toast(&mut self, toast: Toast) {
self.toasts.push(toast);
}
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
let expired: Vec<_> = self
.toasts
.iter()
.filter(|t| t.expired(now_ms))
.cloned()
.collect();
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
}
impl Default for MiscState {
fn default() -> Self {
Self::new()
}
}
/// State of a single workflow agent.
#[derive(Debug, Clone, Default)]
pub struct AgentState {
pub id: String,
pub status: String,
pub current_tool: String,
}
/// Minimal workflow-engine placeholder for hive-mind orchestration state.
#[derive(Debug, Clone, Default)]
pub struct WorkflowEngine {
/// List of running workflow agent states.
pub agents: Vec<AgentState>,
}
impl WorkflowEngine {
/// Create an empty workflow engine.
pub fn new() -> Self {
Self {
agents: Vec::new(),
}
}
}
// ---------------------------------------------------------------------------
// AppStateRest — single source-of-truth application state
// ---------------------------------------------------------------------------
/// The single source-of-truth state struct for the daemon.
///
/// Mutated in-place from two locations: `handler::apply_action`
/// and the IPC client handler in `handler::handle_daemon_client`.
/// Read-only from every other module.
#[derive(Clone)]
pub struct AppStateRest {
/// Persistent user settings (loaded from JSON store at startup).
pub settings: Settings,
/// Per-project app configuration (loaded from JSON store at startup).
pub app_config: AppConfig,
/// Absolute paths to each open workspace root directory.
pub workspace_roots: Vec<PathBuf>,
/// Unique session identifier.
pub session_id: String,
/// Path to the session's data directory.
pub session_dir: PathBuf,
/// Path to the session memory directory (lessons, review history).
pub memory_dir: PathBuf,
/// Path to the git worktrees directory (for sandboxed agent experiments).
pub worktrees_dir: PathBuf,
/// Shared async cache of directory listings.
pub dir_cache: Arc<RwLock<DirCache>>,
/// Shared workspace file-path index for `@file` mention autocomplete.
pub mention_index: MentionIndex,
/// Persistent edit history log (appended on every tool write).
pub edit_log: EditLog,
/// Optional per-session runtime state.
pub session_runtime: Option<SessionRuntime>,
/// Active IAM sessions linked to this app instance.
pub sessions: Vec<Session>,
/// Ring buffer of recent chat messages for the TUI transcript pane.
pub transcript_cache: TranscriptCache,
/// Viewport scroll offset tracker.
pub scroll: ScrollState,
/// Chat input buffer, cursor, history, and autocomplete.
pub input: InputState,
/// Miscellaneous state: overlay, toasts, flags, tick.
pub misc: MiscState,
/// Queue of events emitted by the running agent turn.
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
/// Whether an agent turn is currently in flight.
pub turn_in_flight: Arc<Mutex<bool>>,
/// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape).
pub abort_flag: Arc<AtomicBool>,
/// Workflow engine state for multi-agent hive-mind orchestration.
pub workflow_engine: WorkflowEngine,
/// MCP server manager.
pub mcp_manager: McpManager,
/// LSP server manager, shared with tool context.
pub lsp_manager: Arc<Mutex<LspManager>>,
/// Shared queue for LSP provisioning messages.
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
/// Whether the state has been modified since the last render sweep.
pub dirty: bool,
/// Whether the application has been requested to quit.
pub quit: bool,
}
impl AppStateRest {
/// Construct the initial application state for a session.
///
/// Flow: load settings/config → derive download/worktree dirs from
/// `memory_dir`'s parent → derive `session_id` from the session dir's
/// file name → build the sub-state structs.
///
/// Why: falls back to `memory_dir` itself (with a warning) when it has
/// no parent, and to an empty session id when the dir name can't be
/// read, so construction never fails.
pub fn new(
workspace_roots: Vec<PathBuf>,
session_dir: &std::path::Path,
memory_dir: PathBuf,
) -> Self {
let store_base_dir =
zesdex_infrastructure::Store::new().base_dir;
let settings = JsonSettingsRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let app_config = JsonAppConfigRepository::new()
.load(&store_base_dir)
.unwrap_or_default();
let worktrees_dir = memory_dir
.parent()
.unwrap_or_else(|| {
tracing::warn!(
"[state] memory_dir '{}' has no parent, using it for worktrees",
memory_dir.display()
);
&memory_dir
})
.join("worktrees");
let dir_cache = DirCache::new();
let session_id = session_dir.file_name().map_or_else(
|| {
tracing::warn!(
"[state] session_dir has no file_name component, using empty session_id"
);
String::new()
},
|n| n.to_string_lossy().to_string(),
);
let mut state = AppStateRest {
settings,
app_config,
workspace_roots,
session_id,
session_dir: session_dir.to_path_buf(),
memory_dir: memory_dir.clone(),
worktrees_dir,
turn_events: Arc::new(Mutex::new(VecDeque::new())),
turn_in_flight: Arc::new(Mutex::new(false)),
abort_flag: Arc::new(AtomicBool::new(false)),
dir_cache: Arc::new(RwLock::new(dir_cache)),
mention_index: MentionIndex::new(),
edit_log: JsonlEditLogRepository::new()
.open(session_dir)
.unwrap_or_else(|e| {
tracing::warn!(
"[state] failed to open edit log at '{}': {e}",
session_dir.display()
);
EditLog::new()
}),
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
workflow_engine: WorkflowEngine::new(),
mcp_manager: McpManager::new(),
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
lsp_manager: Arc::new(Mutex::new(LspManager::new())),
sessions: Vec::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
dirty: true,
quit: false,
};
// Load project-specific input-line history from a file keyed by
// the first workspace root's SHA256 hash.
let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir);
if let Some(root) = state.workspace_roots.first() {
if let Ok(abs_root) = std::fs::canonicalize(root) {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(abs_root.to_string_lossy().as_bytes());
let hash_hex = hex::encode(hasher.finalize());
let folder_name = abs_root
.file_name()
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
let history_dir = base_dir.join("history");
let _ = std::fs::create_dir_all(&history_dir);
let history_file = history_dir.join(history_filename);
if let Ok(content) = std::fs::read_to_string(&history_file) {
let history: Vec<String> = content
.lines()
.map(std::string::ToString::to_string)
.filter(|s| !s.is_empty())
.collect();
state.input.history = history;
}
state.input.history_file = Some(history_file);
}
}
state
}
/// Spawn the background thread that walks every workspace root and
/// populates `mention_index` for `@file` mention autocomplete.
///
/// Callers that DO need the index (single-process mode, the daemon)
/// call this explicitly after construction.
pub fn spawn_mention_index_build(&self) {
let mention_index = self.mention_index.clone();
let roots = self.workspace_roots.clone();
std::thread::spawn(move || {
const MAX_MENTION_ENTRIES: usize = 50_000;
let mut paths = Vec::new();
'roots: for (i, root) in roots.iter().enumerate() {
for entry in ignore::Walk::new(root).flatten() {
if !entry.path().is_file() {
continue;
}
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
let rel_str = rel.display().to_string();
let formatted = if i == 0 {
rel_str
} else {
format!("[{i}]{rel_str}")
};
paths.push(formatted);
if paths.len() >= MAX_MENTION_ENTRIES {
break 'roots;
}
}
}
mention_index.set(paths);
});
}
/// Whether an agent turn is currently running.
pub fn turn_in_flight(&self) -> bool {
self.turn_in_flight.lock().map_or_else(
|_| {
tracing::warn!("[state] turn_in_flight mutex poisoned");
false
},
|g| *g,
)
}
/// Shut down every running LSP server process.
pub fn shutdown_lsp(&mut self) {
if let Ok(mut mgr) = self.lsp_manager.lock() {
mgr.shutdown_all();
}
}
/// Append a message to the transcript, evicting the oldest entry once
/// `max_lines` is exceeded.
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
self.transcript_cache.messages.remove(0);
}
self.transcript_cache.dirty = true;
self.dirty = true;
}
/// Mark the app state as dirty, triggering a TUI re-render on the next frame.
pub fn mark_dirty(&mut self) {
self.dirty = true;
}
/// Queue a toast notification for display and mark the app dirty.
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.mark_dirty();
}
/// Push an info toast with the given message.
pub fn toast_info(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(ToastKind::Info, msg.into()));
}
/// Push a success toast with the given message.
pub fn toast_success(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(ToastKind::Success, msg.into()));
}
/// Push a warning toast with the given message.
pub fn toast_warning(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(ToastKind::Warning, msg.into()));
}
/// Push an error toast with the given message.
pub fn toast_error(&mut self, msg: impl Into<String>) {
self.push_toast(Toast::new(ToastKind::Error, msg.into()));
}
/// Resolve the base directory that stores this session (grandparent of
/// `session_dir`).
pub fn store_base_dir(&self) -> PathBuf {
self.session_dir
.parent()
.and_then(|p| p.parent())
.map_or_else(
|| {
tracing::warn!(
"[state] session_dir '{}' has no grandparent, using parent",
self.session_dir.display()
);
self.session_dir.parent().map_or_else(
|| {
tracing::warn!(
"[state] session_dir '{}' has no parent at all, using itself",
self.session_dir.display()
);
self.session_dir.clone()
},
std::path::Path::to_path_buf,
)
},
std::path::Path::to_path_buf,
)
}
/// Persist the current settings to the store and swallow any error.
pub fn save_settings(&self) {
let _ = JsonSettingsRepository::new()
.save(&self.store_base_dir(), &self.settings);
}
}
// ---------------------------------------------------------------------------
// SessionLockGuard — RAII guard that releases a session lock on drop
// ---------------------------------------------------------------------------
/// RAII guard that releases a session lock on drop.
pub struct SessionLockGuard {
lock_repo: FileSystemSessionLockRepository,
session_dir: PathBuf,
}
impl SessionLockGuard {
/// Create a new guard. Caller must have already acquired the lock.
pub fn new(lock_repo: FileSystemSessionLockRepository, session_dir: PathBuf) -> Self {
tracing::debug!("acquired session lock for {:?}", session_dir);
Self {
lock_repo,
session_dir,
}
}
}
impl Drop for SessionLockGuard {
fn drop(&mut self) {
tracing::debug!("releasing session lock for {:?}", self.session_dir);
let _ = self.lock_repo.unlock(&self.session_dir);
}
}
// ---------------------------------------------------------------------------
// DaemonState — wraps AppStateRest with IPC socket metadata
// ---------------------------------------------------------------------------
/// The daemon's overall state: owns the application state and the IPC socket
/// metadata for client connections.
pub struct DaemonState {
/// The canonical application state for this daemon session.
pub app_state: AppStateRest,
/// The daemon session's unique identifier (same as `app_state.session_id`).
pub session_id: String,
/// Path to the bound Unix socket, if any.
pub socket_path: Option<String>,
}
impl DaemonState {
/// Wrap an `AppStateRest` into a `DaemonState`.
pub fn new(app_state: AppStateRest) -> Self {
let session_id = app_state.session_id.clone();
DaemonState {
app_state,
session_id,
socket_path: None,
}
}
/// Set the socket path after binding.
pub fn set_socket_path(&mut self, path: String) {
self.socket_path = Some(path);
}
}
// ---------------------------------------------------------------------------
// Session creation
// ---------------------------------------------------------------------------
/// Create a new daemon session: store, session directory, exclusive lock,
/// application state, and tokio runtime.
///
/// Flow: create the store → create a new session directory → attempt an
/// exclusive lock → build `AppStateRest` → spawn mention-index builder →
/// load session list → start a tokio runtime.
///
/// Return: (store, lock guard, app_state, tokio_runtime).
pub fn create_session() -> Result<(
zesdex_infrastructure::Store,
SessionLockGuard,
AppStateRest,
tokio::runtime::Runtime,
)> {
tracing::info!("creating new daemon session");
let store = zesdex_infrastructure::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 lock_repo = FileSystemSessionLockRepository::new();
if !lock_repo.try_lock(&session_dir)? {
anyhow::bail!(
"session already active (another zesdex process holds the lock for this session directory)"
);
}
let session_lock_guard = SessionLockGuard::new(lock_repo, session_dir.clone());
let workspace_roots = vec![std::env::current_dir()?];
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
state.spawn_mention_index_build();
let session_repo =
zesdex_infrastructure::persistence::FileSystemSessionRepository::new();
state.sessions = session_repo
.list_sessions(&store.base_dir)
.unwrap_or_default();
let rt = tokio::runtime::Runtime::new()?;
Ok((store, session_lock_guard, state, rt))
}