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