Files
zesdex/apps/interfaces/daemon/src/handler.rs
T

862 lines
32 KiB
Rust
Raw Normal View History

//! 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 tracing::info;
use webbrowser;
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) {
tracing::debug!("terminal resize to width={}", w);
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_else(|e| {
tracing::error!("turn_events mutex poisoned, recovering");
e.into_inner().drain(..).collect()
});
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) => {
// AssistantMessage is handled via StreamDone to avoid duplicates.
state.dirty = true;
}
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 = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
rt.usage.last_tokens_in = tokens_in;
rt.usage.last_tokens_out = tokens_out;
}
}
TurnEvent::ReviewUsage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
}
}
TurnEvent::Done => {
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
*in_flight = false;
}
state.dirty = true;
}
TurnEvent::ToolResult { .. } => {
// Tool result events are logged but the content is already in
// the session runtime messages — no transcript push needed here.
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;
// Resolve LLM provider credentials
let provider_name = &state.settings.provider;
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
let mut api_key = String::new();
if let Some(key) = state.settings.api_keys.get(provider_name) {
api_key = key.clone();
} else if let Some(ref cfg) = provider_cfg {
if let Some(ref default_key) = cfg.default_api_key {
api_key = default_key.clone();
}
if api_key.is_empty() {
if let Some(ref env_name) = cfg.api_key_env {
if let Ok(val) = std::env::var(env_name) {
api_key = val;
}
}
}
}
let messages = state
.session_runtime
.as_ref()
.map(|rt| rt.messages.clone())
.unwrap_or_default();
let params = zesdex_domain::agent::AgentTurnParams {
messages,
session_dir: state.session_dir.clone(),
workspace_roots: state.workspace_roots.clone(),
turn_events: state.turn_events.clone(),
in_flight: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
abort: state.abort_flag.clone(),
api_key: api_key.clone(),
model: zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()),
};
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
api_key,
zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
provider_cfg.map(|cfg| cfg.api_base.clone()),
));
let tool_ctx = zesdex_infrastructure::tools::ToolCtx::builder()
.session_dir(state.session_dir.clone())
.workspaces(state.workspace_roots.clone())
.turn_events(state.turn_events.clone())
.build();
let tool_executor = std::sync::Arc::new(
zesdex_infrastructure::tools::executor::InfrastructureToolExecutor::new(tool_ctx),
);
let tools = zesdex_infrastructure::tools::all_tools();
let defs = zesdex_infrastructure::tools::tool_defs(&tools);
let turn_service = zesdex_application::agent::turn_service::AgentTurnServiceImpl::new(
client,
tool_executor,
defs,
);
use zesdex_application::agent::AgentTurnService;
tokio::spawn(async move {
let _ = turn_service.run_turn(params).await;
});
}
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) {
info!("opening model selector");
state.misc.overlay = Overlay::ModelSelector;
state.dirty = true;
}
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) {
tracing::info!("compacting conversation with AI summarization");
let provider_name = &state.settings.provider;
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
let api_key = state
.settings
.api_keys
.get(provider_name)
.cloned()
.unwrap_or_default();
let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base);
if let Some(ref mut rt) = state.session_runtime {
let tokio_rt = zesdex_infrastructure::runtime::runtime();
if let Ok(()) = tokio_rt.block_on(
zesdex_application::agent::turn_service::compact_messages_with_ai(
&mut rt.messages,
&client,
),
) {
let msg_count = rt.messages.len();
state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::System,
format!("Conversation compacted via AI Summarizer to {msg_count} messages."),
));
}
}
state.dirty = true;
}
fn handle_open_editor(state: &mut AppStateRest, path: String) {
tracing::info!("opening editor for: {path}");
state.misc.editor = Some(crate::state::EditorState::new(
std::path::PathBuf::from(&path),
std::fs::read_to_string(&path).unwrap_or_default(),
));
handle_open_overlay(state, Overlay::Editor);
}
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
tracing::info!("adding MCP server: {name}");
match state.mcp_manager.register(&name, &command) {
Ok(()) => {
state.toast_success(format!("MCP server '{name}' added with command: {command}"));
state.dirty = true;
}
Err(e) => {
state.toast_error(format!("Failed to add MCP server '{name}': {e}"));
}
}
}
fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
tracing::info!("starting OAuth for provider: {provider}");
state.toast_info(format!("OAuth flow started for {provider}..."));
// Construct provider-specific OAuth authorization URL.
let auth_url_owned;
let url_to_open = match provider.as_str() {
"github" => "https://github.com/login/oauth/authorize",
"google" => "https://accounts.google.com/o/oauth2/v2/auth",
"anthropic" => "https://anthropic.com/api/oauth/authorize",
other => {
auth_url_owned = format!("https://{other}.com/auth");
&auth_url_owned
}
};
if let Err(e) = webbrowser::open(url_to_open) {
tracing::warn!("Failed to open browser for OAuth: {e}");
state.toast_error(format!("Failed to open browser: {e}"));
}
state.dirty = true;
}
fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
tracing::info!("lesson accepted: {name}");
state.toast_success(format!("Lesson accepted: {name}"));
state.dirty = true;
}
fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
tracing::info!("lesson rejected: {name}");
state.toast_info(format!("Lesson rejected: {name}"));
state.dirty = true;
}
fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
tracing::info!("lesson deleted: {name}");
state.toast_warning(format!("Lesson deleted: {name}"));
state.dirty = true;
}
// ---------------------------------------------------------------------------
// handle_key — translate crossterm KeyEvent into Vec<Action>
// ---------------------------------------------------------------------------
/// Handle a crossterm key event and produce a list of actions.
///
/// Maps key codes + modifiers to Action variants. Mirrors the same
/// dispatch logic used by the single-process TUI controller.
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) => {
// Always show quit-confirm, regardless of overlay state.
vec![Action::QuitConfirm]
}
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() {
match state.misc.overlay {
Overlay::QuitConfirm => vec![Action::ForceQuit],
Overlay::ClearConfirm => vec![Action::SystemNote {
kind: "clear".to_string(),
message: "cleared".to_string(),
}],
_ => 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))?;
}
// Only send state update for valid requests, not on EOF/disconnect.
if running {
send_daemon_update(&mut conn, state)?;
}
}
None => {
running = false;
// Don't call send_daemon_update — connection is dead.
}
}
}
Ok(())
}