2026-07-20 09:04:57 +07:00
|
|
|
//! 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};
|
2026-07-20 10:55:09 +07:00
|
|
|
use tracing::info;
|
|
|
|
|
use webbrowser;
|
2026-07-20 09:04:57 +07:00
|
|
|
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;
|
2026-08-27 22:10:28 +07:00
|
|
|
use crate::state::{AppStateRest, AutocompleteKind, ChatMessageDisplay, Overlay, RoleWrapper};
|
2026-07-20 09:04:57 +07:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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.
|
2026-08-27 22:10:28 +07:00
|
|
|
LessonAccept { name: String },
|
2026-07-20 09:04:57 +07:00
|
|
|
/// Reject a lesson by name.
|
2026-08-27 22:10:28 +07:00
|
|
|
LessonReject { name: String },
|
2026-07-20 09:04:57 +07:00
|
|
|
/// Delete a previously stored lesson by name.
|
2026-08-27 22:10:28 +07:00
|
|
|
LessonDelete { name: String },
|
2026-07-20 09:04:57 +07:00
|
|
|
/// Start the OAuth device-code login flow for a named provider.
|
2026-08-27 22:10:28 +07:00
|
|
|
StartOAuth { provider: String },
|
2026-07-20 09:04:57 +07:00
|
|
|
/// Open the inline file editor for `path`.
|
2026-08-27 22:10:28 +07:00
|
|
|
OpenEditor { path: String },
|
2026-07-20 09:04:57 +07:00
|
|
|
/// Register a new MCP server by name and shell command.
|
2026-08-27 22:10:28 +07:00
|
|
|
McpAdd { name: String, command: String },
|
2026-07-20 09:04:57 +07:00
|
|
|
/// 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 ─────────────────────────────────────────────
|
2026-08-27 22:10:28 +07:00
|
|
|
Action::SystemNote {
|
|
|
|
|
kind: _kind,
|
|
|
|
|
message,
|
|
|
|
|
} => handle_system_note(state, message),
|
2026-07-20 09:04:57 +07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
fn handle_resize(state: &mut AppStateRest, w: u16) {
|
|
|
|
|
tracing::debug!("terminal resize to width={}", w);
|
2026-07-20 09:04:57 +07:00
|
|
|
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())
|
2026-07-20 12:02:48 +07:00
|
|
|
.unwrap_or_else(|e| {
|
|
|
|
|
tracing::error!("turn_events mutex poisoned, recovering");
|
|
|
|
|
e.into_inner().drain(..).collect()
|
|
|
|
|
});
|
2026-07-20 09:04:57 +07:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-07-20 12:02:48 +07:00
|
|
|
TurnEvent::AssistantMessage(_msg) => {
|
|
|
|
|
// AssistantMessage is handled via StreamDone to avoid duplicates.
|
|
|
|
|
state.dirty = true;
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-08-27 22:10:28 +07:00
|
|
|
TurnEvent::Usage {
|
|
|
|
|
tokens_in,
|
|
|
|
|
tokens_out,
|
|
|
|
|
} => {
|
2026-07-20 09:04:57 +07:00
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
2026-07-20 12:26:08 +07:00
|
|
|
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
|
|
|
|
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
2026-07-20 16:38:19 +07:00
|
|
|
rt.usage.last_tokens_in = tokens_in;
|
|
|
|
|
rt.usage.last_tokens_out = tokens_out;
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TurnEvent::ReviewUsage {
|
|
|
|
|
tokens_in,
|
|
|
|
|
tokens_out,
|
|
|
|
|
} => {
|
|
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
2026-07-20 12:26:08 +07:00
|
|
|
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
|
|
|
|
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
TurnEvent::Done => {
|
|
|
|
|
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
|
|
|
|
|
*in_flight = false;
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
2026-07-20 12:02:48 +07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-20 09:04:57 +07:00
|
|
|
_ => {
|
|
|
|
|
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;
|
2026-07-20 16:29:32 +07:00
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
2026-07-21 06:24:20 +07:00
|
|
|
let params = zesdex_domain::agent::AgentTurnParams {
|
2026-07-20 16:29:32 +07:00
|
|
|
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(),
|
2026-07-21 06:24:20 +07:00
|
|
|
api_key: api_key.clone(),
|
2026-08-28 00:00:14 +07:00
|
|
|
model: zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
|
2026-07-21 06:24:20 +07:00
|
|
|
api_base: provider_cfg.as_ref().map(|cfg| cfg.api_base.clone()),
|
2026-07-20 16:29:32 +07:00
|
|
|
};
|
|
|
|
|
|
2026-07-21 06:24:20 +07:00
|
|
|
let client = std::sync::Arc::new(zesdex_infrastructure::llm::provider::LlmClient::new(
|
|
|
|
|
api_key,
|
2026-08-28 00:00:14 +07:00
|
|
|
zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config),
|
2026-07-21 06:24:20 +07:00
|
|
|
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;
|
|
|
|
|
});
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-07-20 10:55:09 +07:00
|
|
|
info!("opening model selector");
|
|
|
|
|
state.misc.overlay = Overlay::ModelSelector;
|
|
|
|
|
state.dirty = true;
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_abort_turn(state: &mut AppStateRest) {
|
2026-08-27 22:10:28 +07:00
|
|
|
state
|
|
|
|
|
.abort_flag
|
|
|
|
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
2026-07-20 09:04:57 +07:00
|
|
|
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
|
|
|
|
|
*in_flight = false;
|
|
|
|
|
}
|
|
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_compact(state: &mut AppStateRest) {
|
2026-07-20 16:38:19 +07:00
|
|
|
tracing::info!("compacting conversation with AI summarization");
|
|
|
|
|
let provider_name = &state.settings.provider;
|
|
|
|
|
let provider_cfg = state.app_config.providers.get(provider_name).cloned();
|
2026-08-27 22:10:28 +07:00
|
|
|
let api_key = state
|
|
|
|
|
.settings
|
|
|
|
|
.api_keys
|
|
|
|
|
.get(provider_name)
|
|
|
|
|
.cloned()
|
|
|
|
|
.unwrap_or_default();
|
2026-08-28 00:00:14 +07:00
|
|
|
let model = zesdex_domain::cms::resolve_effective_model(&state.settings, &state.app_config);
|
2026-07-20 16:38:19 +07:00
|
|
|
let api_base = provider_cfg.map(|cfg| cfg.api_base.clone());
|
|
|
|
|
|
2026-07-21 06:24:20 +07:00
|
|
|
let client = zesdex_infrastructure::llm::provider::LlmClient::new(api_key, model, api_base);
|
2026-07-20 16:38:19 +07:00
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
if let Some(ref mut rt) = state.session_runtime {
|
2026-08-27 23:25:35 +07:00
|
|
|
let tokio_rt = zesdex_infrastructure::runtime::runtime();
|
2026-08-27 22:10:28 +07:00
|
|
|
if let Ok(()) = tokio_rt.block_on(
|
|
|
|
|
zesdex_application::agent::turn_service::compact_messages_with_ai(
|
|
|
|
|
&mut rt.messages,
|
|
|
|
|
&client,
|
|
|
|
|
),
|
|
|
|
|
) {
|
2026-07-20 10:55:09 +07:00
|
|
|
let msg_count = rt.messages.len();
|
|
|
|
|
state.push_transcript(ChatMessageDisplay::new(
|
|
|
|
|
RoleWrapper::System,
|
2026-07-20 16:38:19 +07:00
|
|
|
format!("Conversation compacted via AI Summarizer to {msg_count} messages."),
|
2026-07-20 10:55:09 +07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 09:04:57 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
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(),
|
|
|
|
|
));
|
2026-07-20 09:04:57 +07:00
|
|
|
handle_open_overlay(state, Overlay::Editor);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
|
|
|
|
|
tracing::info!("adding MCP server: {name}");
|
2026-07-20 12:42:53 +07:00
|
|
|
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}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
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}..."));
|
2026-07-20 12:02:48 +07:00
|
|
|
// 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) {
|
2026-07-20 10:55:09 +07:00
|
|
|
tracing::warn!("Failed to open browser for OAuth: {e}");
|
|
|
|
|
state.toast_error(format!("Failed to open browser: {e}"));
|
|
|
|
|
}
|
2026-07-20 09:04:57 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
fn handle_lesson_accept(state: &mut AppStateRest, name: String) {
|
|
|
|
|
tracing::info!("lesson accepted: {name}");
|
|
|
|
|
state.toast_success(format!("Lesson accepted: {name}"));
|
2026-07-20 09:04:57 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
fn handle_lesson_reject(state: &mut AppStateRest, name: String) {
|
|
|
|
|
tracing::info!("lesson rejected: {name}");
|
|
|
|
|
state.toast_info(format!("Lesson rejected: {name}"));
|
2026-07-20 09:04:57 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
fn handle_lesson_delete(state: &mut AppStateRest, name: String) {
|
|
|
|
|
tracing::info!("lesson deleted: {name}");
|
|
|
|
|
state.toast_warning(format!("Lesson deleted: {name}"));
|
2026-07-20 09:04:57 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// handle_key — translate crossterm KeyEvent into Vec<Action>
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-07-20 10:55:09 +07:00
|
|
|
/// Handle a crossterm key event and produce a list of actions.
|
2026-07-20 09:04:57 +07:00
|
|
|
///
|
2026-07-20 10:55:09 +07:00
|
|
|
/// Maps key codes + modifiers to Action variants. Mirrors the same
|
|
|
|
|
/// dispatch logic used by the single-process TUI controller.
|
2026-07-20 09:04:57 +07:00
|
|
|
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) => {
|
2026-07-20 12:26:08 +07:00
|
|
|
// Always show quit-confirm, regardless of overlay state.
|
|
|
|
|
vec![Action::QuitConfirm]
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
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 {
|
2026-08-27 22:10:28 +07:00
|
|
|
state.input.autocomplete_idx = (state.input.autocomplete_idx + 1)
|
|
|
|
|
% state.input.autocomplete_candidates.len().max(1);
|
2026-07-20 09:04:57 +07:00
|
|
|
state.dirty = true;
|
|
|
|
|
}
|
|
|
|
|
vec![]
|
|
|
|
|
}
|
|
|
|
|
KeyCode::Enter => {
|
|
|
|
|
if state.misc.overlay.is_active() {
|
2026-07-20 12:26:08 +07:00
|
|
|
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],
|
|
|
|
|
}
|
2026-07-20 09:04:57 +07:00
|
|
|
} else if state.input.autocomplete_visible {
|
|
|
|
|
// Select the current autocomplete candidate
|
|
|
|
|
if !state.input.autocomplete_candidates.is_empty() {
|
2026-08-27 22:10:28 +07:00
|
|
|
let idx = state
|
|
|
|
|
.input
|
|
|
|
|
.autocomplete_idx
|
2026-07-20 09:04:57 +07:00
|
|
|
.min(state.input.autocomplete_candidates.len().saturating_sub(1));
|
|
|
|
|
if state.input.autocomplete_kind == AutocompleteKind::Command {
|
2026-08-27 22:10:28 +07:00
|
|
|
state.input.buffer = state.input.autocomplete_candidates[idx].clone();
|
2026-07-20 09:04:57 +07:00
|
|
|
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.
|
2026-08-27 22:10:28 +07:00
|
|
|
pub fn handle_daemon_client(mut conn: Connection, state: &mut AppStateRest) -> Result<()> {
|
2026-07-20 09:04:57 +07:00
|
|
|
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;
|
|
|
|
|
}
|
2026-08-27 22:10:28 +07:00
|
|
|
let key_event = KeyEvent::new(key_action_to_code(&key), modifiers);
|
2026-07-20 09:04:57 +07:00
|
|
|
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;
|
2026-08-27 22:10:28 +07:00
|
|
|
let enter_event = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
|
2026-07-20 09:04:57 +07:00
|
|
|
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))?;
|
|
|
|
|
}
|
2026-07-20 12:02:48 +07:00
|
|
|
// Only send state update for valid requests, not on EOF/disconnect.
|
|
|
|
|
if running {
|
|
|
|
|
send_daemon_update(&mut conn, state)?;
|
|
|
|
|
}
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
running = false;
|
2026-07-20 12:02:48 +07:00
|
|
|
// Don't call send_daemon_update — connection is dead.
|
2026-07-20 09:04:57 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|