//! Attach mode — TUI-only client that connects to an existing daemon session //! over a Unix socket, forwarding key events and rendering state updates. //! //! Flow: `run_attach(session_id)` resolves the daemon's socket path from //! the store → `setup_attach_client()` connects and enters raw mode → //! enters a render loop: polls for local terminal events (key/resize/paste/ //! scroll) → forwards them as `ClientRequest`s to the daemon via IPC → //! receives a `DaemonFrame` reply → `handle_daemon_frame()` / //! `apply_client_update()` applies the state snapshot onto a local //! `AppStateRest` mirror → `draw()` renders the TUI → on quit, //! sends `ClientRequest::Close`, cleans up terminal, and saves settings. //! //! The client has no agent logic — it is a pure render frontend. use std::io::{self}; use anyhow::Result; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Terminal; use zesdex_domain::SettingsRepository; use zesdex_infrastructure::ipc::client::IpcClient; use zesdex_infrastructure::ipc::protocol::{ClientRequest, DaemonFrame, StatePayload}; use zesdex_infrastructure::Toast; use zesdex_infrastructure::ToastKind; use crate::key_code::key_code_to_action; use crate::state::{AppStateRest, ChatMessageDisplay, Overlay, RoleWrapper}; // --------------------------------------------------------------------------- // apply_client_update — apply a StatePayload onto the local AppStateRest // --------------------------------------------------------------------------- /// 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 role variants) → /// resolve the overlay name string to an `Overlay` variant → rebuild /// toasts from `ToastEntry`s. /// /// Why: unrecognised role/overlay/toast-kind strings fall back to a safe /// default (`User`, `Overlay::None`, `ToastKind::Info`) rather than /// panicking, so a protocol/version mismatch degrades gracefully. fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) { tracing::debug!("applying state update from daemon"); state.session_id = payload.session_id; state.dirty = payload.dirty; state.transcript_cache.messages = payload .messages .into_iter() .map(|m| ChatMessageDisplay { role: match m.role.as_str() { "Assistant" => RoleWrapper::Assistant, "System" => RoleWrapper::System, "Tool" => RoleWrapper::Tool, _ => RoleWrapper::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; } // --------------------------------------------------------------------------- // setup_attach_client — connect to daemon and set up terminal // --------------------------------------------------------------------------- /// 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<( IpcClient, Terminal>, AppStateRest, )> { tracing::debug!("setting up attach client for session {session_id}"); let store = zesdex_infrastructure::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 = 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)) } // --------------------------------------------------------------------------- // handle_daemon_frame — process a single DaemonFrame from the daemon // --------------------------------------------------------------------------- /// Process a single daemon frame from the IPC channel, updating state accordingly. fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option) { tracing::debug!("received daemon frame"); 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 _ = zesdex_infrastructure::utils::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; } } } // --------------------------------------------------------------------------- // draw — minimal TUI render function // --------------------------------------------------------------------------- /// Render the current application state onto the terminal. /// /// Layout: /// - Top: title bar with session ID and overlay status /// - Middle: transcript message list (scrollable) /// - Bottom: input line with cursor /// - Overlay name shown when an overlay is active fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) { let area = frame.area(); // Vertical layout: main content + input line let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Min(1), // main content (transcript + toasts) Constraint::Length(3), // input line ]) .split(area); // ── Main content area ─────────────────────────────────────────────── let title = format!( " Zesdex — {} {}", &state.session_id[..state.session_id.len().min(8)], if state.misc.overlay.is_active() { format!("[{}]", state.misc.overlay) } else { String::new() } ); let mut content_lines: Vec = Vec::new(); // Show toasts at the top if present. for toast in &state.misc.toasts { content_lines.push(format!("[{:?}] {}", toast.kind, toast.message)); } // Show active overlay name. if state.misc.overlay.is_active() { content_lines.push(String::new()); content_lines.push(format!("=== {} ===", state.misc.overlay)); content_lines.push(String::new()); } // Transcript messages. for msg in &state.transcript_cache.messages { let prefix = match msg.role { RoleWrapper::User => "You", RoleWrapper::Assistant => "AI", RoleWrapper::System => "System", RoleWrapper::Tool => "Tool", }; content_lines.push(format!("{}: {}", prefix, msg.content)); } // Scroll offset indicator. if state.scroll.offset > 0 { content_lines.push(format!("--- scrolled up {} lines ---", state.scroll.offset)); } let content = content_lines.join("\n"); let main_block = Block::default().title(title).borders(Borders::TOP); let paragraph = Paragraph::new(content) .block(main_block) .wrap(Wrap { trim: false }) .scroll((state.scroll.offset as u16, 0)); frame.render_widget(paragraph, chunks[0]); // ── Input line ────────────────────────────────────────────────────── let input_block = Block::default().borders(Borders::TOP); let input_display = if state.input.buffer.is_empty() { "Type a message...".to_string() } else { state.input.buffer.clone() }; let input_paragraph = Paragraph::new(input_display).block(input_block); frame.render_widget(input_paragraph, chunks[1]); // Set cursor position for the input line. use ratatui::layout::Position; frame.set_cursor_position(Position::new( chunks[1].x + state.input.cursor as u16 + 1, chunks[1].y + 1, )); } // --------------------------------------------------------------------------- // run_attach — main attach-mode entry point // --------------------------------------------------------------------------- /// 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| { 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_infrastructure::persistence::JsonSettingsRepository::new() .save(&client_state.store_base_dir(), &client_state.settings); Ok(()) }