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:
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user