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

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+738
View File
@@ -0,0 +1,738 @@
//! Daemon request handler — processes IPC `ClientRequest` messages,
//! applies `Action`s to application state, and pushes state updates back
//! to the attached client.
//!
//! Also defines the [`Action`] enum and the [`apply_action`] dispatcher,
//! as well as the [`handle_key`] function that translates `crossterm`
//! key events into actions — adapting `controller::input::handle_key`
//! from the legacy single-process backend.
//!
//! Flow:
//! 1. `handle_daemon_client(conn, state)` loops reading `ClientRequest`s
//! 2. Each request is translated into `Action`(s) via `handle_key` /
//! direct action invocation
//! 3. `apply_action` mutates `AppStateRest` in place
//! 4. After each request, `send_daemon_update` pushes a full state
//! snapshot back to the client
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use zesdex_infrastructure::ipc::conn::Connection;
use zesdex_infrastructure::ipc::protocol::{
ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry,
};
use zesdex_infrastructure::utils::CastOr;
use crate::key_code::key_action_to_code;
use crate::state::{
AppStateRest, AutocompleteKind, ChatMessageDisplay, Overlay, RoleWrapper,
};
// ---------------------------------------------------------------------------
// Action enum
// ---------------------------------------------------------------------------
/// A single well-typed event that mutates `AppStateRest` when applied via
/// [`apply_action`].
///
/// Produced by `handle_key` (key event → actions) or directly by the
/// daemon's IPC handler.
#[derive(Debug, Clone)]
pub enum Action {
/// Hard exit — immediately terminates the process.
ForceQuit,
/// Submit a user message to the LLM, starting a new agent turn.
SubmitInput(String),
/// Delete one character before the cursor in the input buffer.
DeleteChar,
/// Delete one character after the cursor in the input buffer.
DeleteCharRight,
/// Move the cursor one position left in the input buffer.
CursorLeft,
/// Move the cursor one position right in the input buffer.
CursorRight,
/// Navigate up through command history.
HistoryUp,
/// Navigate down through command history.
HistoryDown,
/// Scroll the transcript pane up.
ScrollUp,
/// Scroll the transcript pane down.
ScrollDown,
/// Open a named overlay.
OpenOverlay(Overlay),
/// Close the currently active overlay.
CloseOverlay,
/// Insert a system-generated note into the transcript.
SystemNote {
/// Note category: "error", "info", "clear", etc.
kind: String,
/// The message text to display.
message: String,
},
/// Show the quit-confirmation overlay.
QuitConfirm,
/// Terminal resize event — carries the new dimensions.
Resize(u16, u16),
/// Periodic timer tick — drains queued events and runs side jobs.
Tick,
/// Accept a lesson by name.
LessonAccept {
name: String,
},
/// Reject a lesson by name.
LessonReject {
name: String,
},
/// Delete a previously stored lesson by name.
LessonDelete {
name: String,
},
/// Start the OAuth device-code login flow for a named provider.
StartOAuth {
provider: String,
},
/// Open the inline file editor for `path`.
OpenEditor {
path: String,
},
/// Register a new MCP server by name and shell command.
McpAdd {
name: String,
command: String,
},
/// Open the model-picker overlay.
ModelList,
/// Set the abort flag on the currently running turn.
AbortTurn,
/// Request AI-summary compaction of the conversation history.
Compact,
}
// ---------------------------------------------------------------------------
// apply_action
// ---------------------------------------------------------------------------
/// Apply an `Action` to the application state.
///
/// Flow: pattern-match the variant → delegate to the corresponding handler
/// → handler mutates `state` (input buffer, scroll position, overlay,
/// transcript, toasts, dirty flag, etc.).
///
/// Why: the single chokepoint that turns every typed key and async event
/// into a state change.
pub fn apply_action(state: &mut AppStateRest, action: Action) {
tracing::debug!("apply_action: {:?}", action);
match action {
// ── Lifecycle ─────────────────────────────────────────────────
Action::ForceQuit => handle_force_quit(state),
Action::QuitConfirm => handle_quit_confirm(state),
Action::Resize(w, _h) => handle_resize(state, w),
Action::Tick => handle_tick(state),
// ── Input / editing ───────────────────────────────────────────
Action::SubmitInput(text) => handle_submit_input(state, text),
Action::DeleteChar => handle_delete_char(state),
Action::DeleteCharRight => handle_delete_char_right(state),
Action::CursorLeft => handle_cursor_left(state),
Action::CursorRight => handle_cursor_right(state),
Action::HistoryUp => handle_history_up(state),
Action::HistoryDown => handle_history_down(state),
// ── Scroll / navigation ───────────────────────────────────────
Action::ScrollUp => handle_scroll_up(state),
Action::ScrollDown => handle_scroll_down(state),
Action::OpenOverlay(overlay) => handle_open_overlay(state, overlay),
Action::CloseOverlay => handle_close_overlay(state),
// ── System / info ─────────────────────────────────────────────
Action::SystemNote { kind: _kind, message } => {
handle_system_note(state, message)
}
Action::ModelList => handle_model_list(state),
Action::AbortTurn => handle_abort_turn(state),
Action::Compact => handle_compact(state),
// ── Editor / MCP / OAuth ──────────────────────────────────────
Action::OpenEditor { path } => handle_open_editor(state, path),
Action::McpAdd { name, command } => handle_mcp_add(state, name, command),
Action::StartOAuth { provider } => handle_start_oauth(state, provider),
// ── Lessons ───────────────────────────────────────────────────
Action::LessonAccept { name } => handle_lesson_accept(state, name),
Action::LessonReject { name } => handle_lesson_reject(state, name),
Action::LessonDelete { name } => handle_lesson_delete(state, name),
}
}
// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------
fn handle_force_quit(state: &mut AppStateRest) {
state.quit = true;
}
fn handle_quit_confirm(state: &mut AppStateRest) {
state.misc.overlay = Overlay::QuitConfirm;
state.dirty = true;
}
fn handle_resize(state: &mut AppStateRest, _w: u16) {
state.dirty = true;
}
fn handle_tick(state: &mut AppStateRest) {
let now_ms = chrono::Utc::now().timestamp_millis();
state.misc.drain_expired_toasts(now_ms);
// Drain queued turn events FIRST (while holding the lock), then release
// the lock and process events with mutable state access.
let drained: Vec<_> = state
.turn_events
.lock()
.map(|mut events| events.drain(..).collect())
.unwrap_or_default();
for event in drained {
use zesdex_infrastructure::TurnEvent;
match event {
TurnEvent::SystemNote { kind, message } => {
if kind == "hive_mind_converged" {
if let Some(ref mut rt) = state.session_runtime {
rt.hive_mind_converged = true;
}
}
handle_system_note(state, message);
}
TurnEvent::AssistantMessage(msg) => {
state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::Assistant,
msg.content.unwrap_or_default(),
));
}
TurnEvent::StreamToken(_token) => {
state.dirty = true;
}
TurnEvent::StreamDone(msg) => {
state.push_transcript(ChatMessageDisplay::new(
RoleWrapper::Assistant,
msg.content.unwrap_or_default(),
));
}
TurnEvent::Error(e) => {
state.toast_error(e);
}
TurnEvent::Usage { tokens_in, tokens_out } => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
}
}
TurnEvent::ReviewUsage {
tokens_in,
tokens_out,
} => {
if let Some(ref mut rt) = state.session_runtime {
rt.usage.tokens_in += tokens_in;
rt.usage.tokens_out += tokens_out;
}
}
TurnEvent::Done => {
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
*in_flight = false;
}
state.dirty = true;
}
_ => {
state.dirty = true;
}
}
}
state.misc.tick_count += 1;
state.dirty = true;
}
fn handle_submit_input(state: &mut AppStateRest, text: String) {
// Push the user message to the transcript.
state.push_transcript(ChatMessageDisplay::new(RoleWrapper::User, text.clone()));
// Save the input to history.
if !text.is_empty() {
state.input.history.push(text.clone());
if let Some(ref path) = state.input.history_file {
let _ = std::fs::write(path, state.input.history.join("\n"));
}
}
// Set up the session runtime for the turn.
if let Some(ref mut rt) = state.session_runtime {
rt.push_message(zesdex_infrastructure::ChatMessage {
role: zesdex_infrastructure::Role::User,
content: Some(text.clone()),
tool_calls: None,
tool_call_id: None,
name: None,
});
}
// Clear the input buffer.
state.input.buffer.clear();
state.input.cursor = 0;
state.input.history_idx = None;
state.dirty = true;
}
fn handle_delete_char(state: &mut AppStateRest) {
if state.input.cursor > 0 {
state.input.buffer.remove(state.input.cursor - 1);
state.input.cursor -= 1;
state.dirty = true;
}
}
fn handle_delete_char_right(state: &mut AppStateRest) {
if state.input.cursor < state.input.buffer.len() {
state.input.buffer.remove(state.input.cursor);
state.dirty = true;
}
}
fn handle_cursor_left(state: &mut AppStateRest) {
if state.input.cursor > 0 {
state.input.cursor = state.input.cursor.saturating_sub(1);
state.dirty = true;
}
}
fn handle_cursor_right(state: &mut AppStateRest) {
if state.input.cursor < state.input.buffer.len() {
state.input.cursor += 1;
state.dirty = true;
}
}
fn handle_history_up(state: &mut AppStateRest) {
if state.input.history.is_empty() {
return;
}
let idx = match state.input.history_idx {
Some(i) if i > 0 => i - 1,
None => state.input.history.len() - 1,
_ => return,
};
state.input.history_idx = Some(idx);
state.input.buffer = state.input.history[idx].clone();
state.input.cursor = state.input.buffer.len();
state.dirty = true;
}
fn handle_history_down(state: &mut AppStateRest) {
match state.input.history_idx {
Some(i) if i + 1 < state.input.history.len() => {
state.input.history_idx = Some(i + 1);
state.input.buffer = state.input.history[i + 1].clone();
state.input.cursor = state.input.buffer.len();
state.dirty = true;
}
Some(_) => {
state.input.history_idx = None;
state.input.buffer.clear();
state.input.cursor = 0;
state.dirty = true;
}
None => {}
}
}
fn handle_scroll_up(state: &mut AppStateRest) {
state.scroll.scroll_up(1);
state.dirty = true;
}
fn handle_scroll_down(state: &mut AppStateRest) {
state.scroll.scroll_down(1);
state.dirty = true;
}
fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) {
state.misc.overlay = overlay;
state.dirty = true;
}
fn handle_close_overlay(state: &mut AppStateRest) {
if state.misc.overlay.is_active() {
state.misc.overlay = Overlay::None;
state.dirty = true;
}
}
fn handle_system_note(state: &mut AppStateRest, message: String) {
state.push_transcript(ChatMessageDisplay::new(RoleWrapper::System, message));
}
fn handle_model_list(state: &mut AppStateRest) {
handle_open_overlay(state, Overlay::ModelSelector);
}
fn handle_abort_turn(state: &mut AppStateRest) {
state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst);
if let Ok(mut in_flight) = state.turn_in_flight.lock() {
*in_flight = false;
}
state.dirty = true;
}
fn handle_compact(state: &mut AppStateRest) {
// Placeholder — compaction logic is delegated to the agent runtime.
state.toast_info("Compacting conversation...");
state.dirty = true;
}
fn handle_open_editor(state: &mut AppStateRest, _path: String) {
handle_open_overlay(state, Overlay::Editor);
}
fn handle_mcp_add(state: &mut AppStateRest, _name: String, _command: String) {
// Placeholder — MCP registration happens via the MCP manager.
state.toast_info("MCP server registration not yet supported in daemon mode.");
state.dirty = true;
}
fn handle_start_oauth(state: &mut AppStateRest, _provider: String) {
// Placeholder — OAuth flow happens asynchronously.
state.toast_info("OAuth not yet supported in daemon mode.");
state.dirty = true;
}
fn handle_lesson_accept(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
fn handle_lesson_reject(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
fn handle_lesson_delete(state: &mut AppStateRest, _name: String) {
state.dirty = true;
}
// ---------------------------------------------------------------------------
// handle_key — translate crossterm KeyEvent into Vec<Action>
// ---------------------------------------------------------------------------
/// Translate a terminal `KeyEvent` into zero or more `Action` values
/// based on the current application state.
///
/// This is a simplified version of the legacy `controller::input::handle_key`.
/// It handles the most common key combinations for the TUI chat interface.
///
/// Flow:
/// 1. If `Overlay::Editor` is active → route keys to the editor.
/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys.
/// 3. Fallthrough: match on `key.code` and modifiers for normal mode.
///
/// Return: `Vec<Action>` so a single key (e.g. Ctrl+C) can produce multiple
/// queued actions.
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
tracing::debug!(
code = ?key.code,
mods = ?key.modifiers,
overlay = ?state.misc.overlay,
"handle_key"
);
// ── Editor overlay ───────────────────────────────────────────────────
if state.misc.overlay == Overlay::Editor {
return match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
}
KeyCode::Esc => {
vec![Action::CloseOverlay]
}
_ => vec![],
};
}
// ── Learning overlay ──────────────────────────────────────────────────
if state.misc.overlay == Overlay::Learning {
return match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::QuitConfirm]
}
KeyCode::Esc => vec![Action::CloseOverlay],
KeyCode::Up => {
state.misc.selected_index = state.misc.selected_index.saturating_sub(1);
state.dirty = true;
vec![]
}
KeyCode::Down => {
state.misc.selected_index = state.misc.selected_index.saturating_add(1);
state.dirty = true;
vec![]
}
_ => vec![],
};
}
// ── Normal mode ───────────────────────────────────────────────────────
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
if state.misc.overlay.is_active() {
vec![Action::QuitConfirm]
} else {
vec![Action::ForceQuit]
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay]
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Yank: handled separately via clipboard — produce no action
state.dirty = true;
vec![]
}
KeyCode::Tab => {
// Cycle autocomplete
if !state.input.autocomplete_visible {
state.input.open_autocomplete();
state.dirty = true;
} else {
state.input.autocomplete_idx =
(state.input.autocomplete_idx + 1) % state.input.autocomplete_candidates.len().max(1);
state.dirty = true;
}
vec![]
}
KeyCode::Enter => {
if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
} else if state.input.autocomplete_visible {
// Select the current autocomplete candidate
if !state.input.autocomplete_candidates.is_empty() {
let idx = state.input.autocomplete_idx
.min(state.input.autocomplete_candidates.len().saturating_sub(1));
if state.input.autocomplete_kind == AutocompleteKind::Command {
state.input.buffer =
state.input.autocomplete_candidates[idx].clone();
state.input.cursor = state.input.buffer.len();
}
state.input.close_autocomplete();
state.dirty = true;
}
vec![]
} else if !state.input.buffer.is_empty() {
vec![Action::SubmitInput(state.input.buffer.clone())]
} else {
vec![]
}
}
KeyCode::Esc => {
if state.misc.overlay.is_active() {
vec![Action::CloseOverlay]
} else if state.input.autocomplete_visible {
state.input.close_autocomplete();
state.dirty = true;
vec![]
} else {
vec![Action::AbortTurn]
}
}
KeyCode::Up => {
if state.misc.overlay.is_active() {
vec![Action::ScrollUp]
} else {
vec![Action::HistoryUp]
}
}
KeyCode::Down => {
if state.misc.overlay.is_active() {
vec![Action::ScrollDown]
} else {
vec![Action::HistoryDown]
}
}
KeyCode::PageUp => vec![Action::ScrollUp],
KeyCode::PageDown => vec![Action::ScrollDown],
KeyCode::Home => {
state.scroll.offset = 0;
state.dirty = true;
vec![]
}
KeyCode::End => {
state.scroll.offset = usize::MAX;
state.dirty = true;
vec![]
}
KeyCode::Backspace => vec![Action::DeleteChar],
KeyCode::Delete => vec![Action::DeleteCharRight],
KeyCode::Left => vec![Action::CursorLeft],
KeyCode::Right => vec![Action::CursorRight],
KeyCode::Char(c) => {
// Regular character input
if state.input.autocomplete_visible {
state.input.close_autocomplete();
}
state.input.buffer.insert(state.input.cursor, c);
state.input.cursor += c.len_utf8();
state.dirty = true;
vec![]
}
_ => {
// Unhandled key
vec![]
}
}
}
// ---------------------------------------------------------------------------
// send_daemon_update — push full state snapshot to the client
// ---------------------------------------------------------------------------
/// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it
/// to the attached client as a `DaemonFrame::StateUpdate`.
///
/// Flow: map transcript messages/toasts to their wire DTOs → derive the
/// active overlay name (or `None` if no overlay is active) → build and
/// send one `DaemonFrame`.
pub fn send_daemon_update(conn: &mut Connection, state: &AppStateRest) -> Result<()> {
tracing::debug!("sending state update to attached client");
let messages: Vec<MessageEntry> = state
.transcript_cache
.messages
.iter()
.map(|m| MessageEntry {
role: format!("{:?}", m.role),
content: m.content.clone(),
timestamp: m.timestamp,
})
.collect();
let toasts: Vec<ToastEntry> = state
.misc
.toasts
.iter()
.map(|t| ToastEntry {
kind: format!("{:?}", t.kind),
message: t.message.clone(),
created_at: t.created_at,
lifetime_ms: t.lifetime_ms,
})
.collect();
let overlay = if state.misc.overlay.is_active() {
Some(format!("{:?}", state.misc.overlay))
} else {
None
};
let frame = DaemonFrame::StateUpdate(Box::new(StatePayload {
session_id: state.session_id.clone(),
messages,
edit_count: state.edit_log.len().cast_or(0u32),
message_count: state.transcript_cache.messages.len(),
overlay,
toasts,
dirty: state.dirty,
input_buffer: state.input.buffer.clone(),
input_cursor: state.input.cursor,
}));
conn.send(&frame)?;
Ok(())
}
// ---------------------------------------------------------------------------
// handle_daemon_client — process an attached client's IPC messages
// ---------------------------------------------------------------------------
/// Handle an incoming client connection for the daemon.
///
/// Flow: loop reading requests, modifying state, and sending updates back.
pub fn handle_daemon_client(
mut conn: Connection,
state: &mut AppStateRest,
) -> Result<()> {
tracing::debug!("handling daemon client connection");
let mut running = true;
while running {
match conn.receive::<ClientRequest>()? {
Some(req) => {
match req {
ClientRequest::Tick => {
apply_action(state, Action::Tick);
}
ClientRequest::KeyPress {
key,
ctrl,
alt,
shift,
} => {
let mut modifiers = KeyModifiers::NONE;
if ctrl {
modifiers |= KeyModifiers::CONTROL;
}
if alt {
modifiers |= KeyModifiers::ALT;
}
if shift {
modifiers |= KeyModifiers::SHIFT;
}
let key_event =
KeyEvent::new(key_action_to_code(&key), modifiers);
let actions = handle_key(key_event, state);
for action in actions {
apply_action(state, action);
}
apply_action(state, Action::Tick);
}
ClientRequest::Submit(text) => {
state.input.buffer = text;
let enter_event = KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE,
);
let actions = handle_key(enter_event, state);
for action in actions {
apply_action(state, action);
}
apply_action(state, Action::Tick);
}
ClientRequest::Paste(text) => {
state.input.buffer.insert_str(state.input.cursor, &text);
state.input.cursor += text.len();
state.dirty = true;
apply_action(state, Action::Tick);
}
ClientRequest::Resize(w, h) => {
apply_action(state, Action::Resize(w, h));
apply_action(state, Action::Tick);
}
ClientRequest::ScrollUp => {
apply_action(state, Action::ScrollUp);
apply_action(state, Action::Tick);
}
ClientRequest::ScrollDown => {
apply_action(state, Action::ScrollDown);
apply_action(state, Action::Tick);
}
ClientRequest::Close => {
running = false;
}
}
if let Some(text) = state.misc.pending_clipboard_copy.take() {
conn.send(&DaemonFrame::ClipboardCopy(text))?;
}
send_daemon_update(&mut conn, state)?;
}
None => {
running = false;
}
}
}
Ok(())
}