//! Attach mode — TUI-only client that connects to an existing daemon session //! over a Unix socket, forwarding key events and rendering state updates. use anyhow::Result; use app::state::rest::AppStateRest; use app::state::types::{Overlay, Toast, ToastKind}; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use ipc::protocol::{ClientRequest, DaemonFrame, StatePayload}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use std::io; use zesdex_cms::domain::repository::SettingsRepository; use zesdex_utils::clipboard::write_osc52; use crate::app; use crate::daemon::key_code_to_action; use crate::ipc; use crate::model; use crate::view; /// Apply a `StatePayload` received from the daemon onto the client's /// local `AppStateRest`, so the attach-mode TUI can render it. /// /// Flow: copy scalar fields directly → rebuild the transcript cache from /// `MessageEntry`s (mapping role strings back to the `Role` enum) → /// resolve the overlay name string to an `Overlay` variant → rebuild /// toasts from `ToastEntry`s. /// /// Why: unrecognized role/overlay/toast-kind strings fall back to a safe /// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than /// panicking, so a protocol/version mismatch degrades gracefully. fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) { state.session_id = payload.session_id; state.dirty = payload.dirty; state.transcript_cache.messages = payload .messages .into_iter() .map(|m| app::state::rest::ChatMessageDisplay { role: match m.role.as_str() { "Assistant" => crate::dto::chat::message::Role::Assistant, "System" => crate::dto::chat::message::Role::System, "Tool" => crate::dto::chat::message::Role::Tool, _ => crate::dto::chat::message::Role::User, }, content: m.content, timestamp: m.timestamp, }) .collect(); state.transcript_cache.dirty = true; state.misc.overlay = match payload.overlay.as_deref() { Some("Help") => Overlay::Help, Some("Settings") => Overlay::Settings, Some("Bash") => Overlay::Bash, Some("QuitConfirm") => Overlay::QuitConfirm, Some("KeyInput") => Overlay::KeyInput, Some("Editor") => Overlay::Editor, Some("Effort") => Overlay::Effort, Some("Mcp") => Overlay::Mcp, Some("Todo") => Overlay::Todo, Some("Rewind") => Overlay::Rewind, Some("Learning") => Overlay::Learning, Some("Usage") => Overlay::Usage, Some("Loading") => Overlay::Loading, Some("ModelSelector") => Overlay::ModelSelector, Some("ClearConfirm") => Overlay::ClearConfirm, _ => Overlay::None, }; state.misc.toasts = payload .toasts .into_iter() .map(|t| Toast { kind: match t.kind.as_str() { "Success" => ToastKind::Success, "Warning" => ToastKind::Warning, "Error" => ToastKind::Error, "Lesson" => ToastKind::Lesson, _ => ToastKind::Info, }, message: t.message, created_at: t.created_at, lifetime_ms: t.lifetime_ms, }) .collect(); state.input.buffer = payload.input_buffer; state.input.cursor = payload.input_cursor; } /// Set up the IPC client connection, terminal, and initial state for attach mode. /// /// Flow: resolve socket path → connect → enable raw/alt mode → create state. /// /// Return: (client, terminal, `client_state`) on success. fn setup_attach_client( session_id: &str, ) -> Result<( ipc::client::IpcClient, Terminal>, AppStateRest, )> { let store = model::store::Store::new(); let socket_path = store .base_dir .join("run") .join(format!("{session_id}.sock")); let addr = socket_path.to_string_lossy().to_string(); let client = ipc::client::IpcClient::connect_unix(&addr)?; enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; execute!(stdout, crossterm::event::EnableBracketedPaste)?; execute!(stdout, crossterm::event::EnableMouseCapture)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; let workspace_roots = vec![std::env::current_dir()?]; let session_dir = store.base_dir.join("sessions").join(session_id); std::fs::create_dir_all(&session_dir)?; let mut client_state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir); client_state.session_id = session_id.to_string(); Ok((client, terminal, client_state)) } /// Process a single daemon frame from the IPC channel, updating state accordingly. fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option) { match frame { Some(DaemonFrame::StateUpdate(payload)) => { apply_client_update(client_state, *payload); } Some(DaemonFrame::StreamToken(_token)) => {} Some(DaemonFrame::SystemNote { kind: _, message }) => { client_state.push_toast(Toast::new(ToastKind::Info, message)); } Some(DaemonFrame::ClipboardCopy(text)) => { let _ = write_osc52(&mut io::stdout(), &text); client_state.push_toast(Toast::new( ToastKind::Success, "Copied to clipboard".to_string(), )); } Some(DaemonFrame::Closed) | None => { client_state.quit = true; } } } /// Run zesdex as a TUI-only client attached to an existing daemon session. /// /// Flow: connect to the daemon's Unix socket → enter raw mode/alternate /// screen → build a local `AppStateRest` mirror (only used for rendering /// and toast/overlay bookkeeping, not agent logic) → loop: poll for a /// terminal event (key/resize) and forward it as a `ClientRequest`, or /// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and /// apply it via `apply_client_update` → redraw → exit when the daemon /// closes or the user quits (sending `ClientRequest::Close` first). /// /// Why: Ctrl+C is intercepted locally to quit the client without going /// through the daemon, since the daemon has no notion of "this client /// wants to leave" beyond the explicit `Close` request. pub fn run_attach(session_id: &str) -> Result<()> { use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind}; let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?; let _rt = tokio::runtime::Runtime::new()?; loop { if client_state.quit { let _ = client.send(&ClientRequest::Close); break; } let now_ms = chrono::Utc::now().timestamp_millis(); client_state.misc.drain_expired_toasts(now_ms); if crossterm::event::poll(std::time::Duration::from_millis(50))? { match crossterm::event::read()? { Event::Key(key) => { if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let alt = key.modifiers.contains(KeyModifiers::ALT); let shift = key.modifiers.contains(KeyModifiers::SHIFT); if key.code == KeyCode::Char('c') && ctrl { client_state.quit = true; continue; } if let Some(key_action) = key_code_to_action(key.code) { client.send(&ClientRequest::KeyPress { key: key_action, ctrl, alt, shift, })?; } } } Event::Paste(text) => { client.send(&ClientRequest::Paste(text))?; } Event::Resize(w, h) => { client.send(&ClientRequest::Resize(w, h))?; } Event::Mouse(mouse_event) => { if mouse_event.kind == MouseEventKind::ScrollUp { client.send(&ClientRequest::ScrollUp)?; } else if mouse_event.kind == MouseEventKind::ScrollDown { client.send(&ClientRequest::ScrollDown)?; } } _ => {} } } else { client.send(&ClientRequest::Tick)?; } handle_daemon_frame( &mut client_state, client.receive::()?, ); terminal.draw(|f| { view::draw(f, &client_state); })?; } let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste); let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture); let _ = execute!(io::stdout(), LeaveAlternateScreen); let _ = disable_raw_mode(); let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() .save(&client_state.store_base_dir(), &client_state.settings); Ok(()) }