diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-21 16:30:04 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-21 16:30:04 +0300 |
| commit | 3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch) | |
| tree | c457a7cf0af271dea1a412474fad5a33c1b60be8 /src/app/mod.rs | |
| parent | 2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff) | |
feat: add persistent server-client sessions
Make herdr persistent by default.
Launching herdr now starts or reattaches to a background session
server. Clients can detach and reattach while panes and agent
processes keep running. Session mode now supports multi-client attach,
auto-detect startup, and a thin-client/headless-server split.
This also refactors the large app, ui, input, pane, config,
workspace, and persistence modules into smaller focused submodules,
while preserving behavior and colocating tests with the code they
exercise.
Upgrade notes:
- persistence mode is now the default
- in-app quit detaches the current client instead of stopping the server
- use `herdr server stop` to stop the background session
- use `--no-session` for the old single-process behavior
- default socket paths now live under the config directory
Diffstat (limited to 'src/app/mod.rs')
| -rw-r--r-- | src/app/mod.rs | 1166 |
1 files changed, 425 insertions, 741 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 7f821da2..51a6678e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2,15 +2,19 @@ //! //! - `state.rs` — AppState, Mode, and pure data structs //! - `actions.rs` — state mutations (testable without PTYs/async) -//! - `api.rs` — API request handling and response building //! - `input.rs` — key/mouse → action translation -//! - `runtime_commands.rs` — custom command launching and overlay restoration -mod actions; +pub(crate) mod actions; mod api; +mod api_helpers; +mod config_io; +mod creation; +mod ids; mod input; -mod runtime_commands; +mod runtime; +mod session; pub mod state; +mod theme_sync; use std::collections::{HashMap, HashSet}; use std::future::pending; @@ -20,7 +24,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); -const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); +pub(crate) const ANIMATION_INTERVAL: Duration = Duration::from_millis(16); const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100); const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500); const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60); @@ -31,44 +35,49 @@ use crossterm::terminal; use ratatui::layout::Rect; use ratatui::DefaultTerminal; use tokio::sync::{mpsc, Notify}; -use tracing::{error, info}; +use tracing::info; use crate::config::Config; use crate::events::AppEvent; -use crate::workspace::Workspace; - -#[cfg(test)] -use self::api::api_request_changes_ui; pub use state::{AppState, Mode, ToastKind, ViewState}; /// Full application: AppState + runtime concerns (event channels, async I/O). +#[derive(Debug, Clone, Copy)] +pub(crate) struct OverlayPaneState { + ws_idx: usize, + tab_idx: usize, + previous_focus: crate::layout::PaneId, + previous_zoomed: bool, +} + pub struct App { pub state: AppState, pub event_tx: mpsc::Sender<AppEvent>, - event_rx: mpsc::Receiver<AppEvent>, - api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, - event_hub: crate::api::EventHub, - last_focus: Option<(usize, crate::layout::PaneId)>, - no_session: bool, - input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, - last_terminal_size: Option<(u16, u16)>, - config_diagnostic_deadline: Option<Instant>, - toast_deadline: Option<Instant>, - last_git_remote_status_refresh: Instant, - last_sidebar_divider_click: Option<Instant>, - next_resize_poll: Instant, - next_animation_tick: Option<Instant>, - next_auto_update_check: Option<Instant>, - session_save_deadline: Option<Instant>, - last_render_at: Option<Instant>, - suppressed_repeat_keys: HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, - render_notify: Arc<Notify>, - render_dirty: Arc<AtomicBool>, - overlay_panes: HashMap<crate::layout::PaneId, runtime_commands::OverlayPaneState>, + pub(crate) event_rx: mpsc::Receiver<AppEvent>, + pub(crate) api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>, + pub(crate) event_hub: crate::api::EventHub, + pub(crate) last_focus: Option<(usize, crate::layout::PaneId)>, + pub(crate) no_session: bool, + pub(crate) input_rx: Option<mpsc::Receiver<crate::raw_input::RawInputEvent>>, + pub(crate) last_terminal_size: Option<(u16, u16)>, + pub(crate) config_diagnostic_deadline: Option<Instant>, + pub(crate) toast_deadline: Option<Instant>, + pub(crate) last_git_remote_status_refresh: Instant, + pub(crate) last_sidebar_divider_click: Option<Instant>, + pub(crate) next_resize_poll: Instant, + pub(crate) next_animation_tick: Option<Instant>, + pub(crate) next_auto_update_check: Option<Instant>, + pub(crate) session_save_deadline: Option<Instant>, + pub(crate) last_render_at: Option<Instant>, + pub(crate) suppressed_repeat_keys: + HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>, + pub render_notify: Arc<Notify>, + pub render_dirty: Arc<AtomicBool>, + pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>, } -enum LoopEvent { +pub(crate) enum LoopEvent { Timer, Internal(AppEvent), Api(crate::api::ApiRequestMessage), @@ -99,6 +108,10 @@ fn repeat_key_identity( (key.code, key.modifiers) } +fn auto_updates_enabled(no_session: bool) -> bool { + !no_session && !cfg!(debug_assertions) +} + /// Resolve the palette from config: base theme + optional custom overrides. fn resolve_palette(config: &crate::config::Config) -> state::Palette { // Start with the named theme (default: catppuccin) @@ -228,9 +241,12 @@ impl App { selected, mode, should_quit: false, + quit_detaches: !no_session, + detach_requested: false, request_new_workspace: false, request_new_tab: false, request_reload_keybinds: false, + request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, request_complete_onboarding: false, @@ -307,9 +323,10 @@ impl App { ws.refresh_git_ahead_behind(); } - // Background auto-update (skipped in --no-session / test mode) - // Check once at startup, then periodically from the main loop. - if !no_session { + // Background auto-update is disabled in monolithic no-session mode + // and in debug/test builds so local development never mutates the + // running binary out from under spawned test processes. + if auto_updates_enabled(no_session) { let update_tx = event_tx.clone(); std::thread::spawn(move || crate::update::auto_update(update_tx)); } @@ -334,7 +351,7 @@ impl App { last_sidebar_divider_click: None, next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL, next_animation_tick: None, - next_auto_update_check: (!no_session) + next_auto_update_check: auto_updates_enabled(no_session) .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL), session_save_deadline: None, last_render_at: None, @@ -351,42 +368,6 @@ impl App { } } - fn schedule_session_save(&mut self) { - if !self.no_session { - self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE); - } - } - - fn sync_session_save_schedule(&mut self) { - if self.state.session_dirty { - self.state.session_dirty = false; - self.schedule_session_save(); - } - } - - fn save_session_now(&mut self) { - if self.no_session { - self.session_save_deadline = None; - return; - } - - if self.state.workspaces.is_empty() { - crate::persist::clear(); - } else { - let snap = crate::persist::capture( - &self.state.workspaces, - self.state.active, - self.state.selected, - self.state.agent_panel_scope, - self.state.sidebar_width, - self.state.sidebar_section_split, - ); - crate::persist::save(&snap); - } - - self.session_save_deadline = None; - } - pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> { if self.input_rx.is_none() { self.input_rx = Some(crate::raw_input::spawn_input_reader()); @@ -510,453 +491,6 @@ impl App { Ok(()) } - fn drain_api_requests(&mut self) -> bool { - let mut changed = false; - while let Ok(msg) = self.api_rx.try_recv() { - changed |= self.handle_api_request_message(msg); - } - changed - } - - async fn handle_raw_input_batch(&mut self, first: crate::raw_input::RawInputEvent) -> bool { - let mut changed = self.handle_raw_input_event(first).await; - - while let Some(rx) = self.input_rx.as_mut() { - match rx.try_recv() { - Ok(event) => changed |= self.handle_raw_input_event(event).await, - Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break, - Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => { - self.input_rx = None; - break; - } - } - } - - changed - } - - async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) -> bool { - match event { - crate::raw_input::RawInputEvent::Key(key) => { - let key_id = repeat_key_identity(&key); - match key.kind { - crossterm::event::KeyEventKind::Press => { - if self.state.mode == Mode::Terminal { - self.suppressed_repeat_keys.remove(&key_id); - } else { - self.suppressed_repeat_keys.insert(key_id); - } - self.handle_key(key).await; - true - } - crossterm::event::KeyEventKind::Repeat => { - if self.state.mode == Mode::Terminal - && !self.suppressed_repeat_keys.contains(&key_id) - { - self.handle_key(key).await; - true - } else { - false - } - } - crossterm::event::KeyEventKind::Release => { - self.suppressed_repeat_keys.remove(&key_id); - false - } - } - } - crate::raw_input::RawInputEvent::Paste(text) => { - self.handle_paste(text).await; - true - } - crate::raw_input::RawInputEvent::Mouse(mouse) => { - self.handle_mouse(mouse); - true - } - crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { - let next_theme = self.state.host_terminal_theme.with_color(kind, color); - if next_theme == self.state.host_terminal_theme { - return false; - } - self.state.host_terminal_theme = next_theme; - self.apply_host_terminal_theme_to_panes(); - true - } - crate::raw_input::RawInputEvent::Unsupported => false, - } - } - - fn query_host_terminal_theme(&self) { - use std::io::Write; - - let _ = std::io::stdout() - .write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()); - let _ = std::io::stdout().flush(); - } - - fn apply_host_terminal_theme_to_panes(&self) { - if self.state.host_terminal_theme.is_empty() { - return; - } - - for workspace in &self.state.workspaces { - for tab in &workspace.tabs { - for runtime in tab.runtimes.values() { - runtime.apply_host_terminal_theme(self.state.host_terminal_theme); - } - } - } - - self.render_dirty.store(true, Ordering::Release); - self.render_notify.notify_one(); - } - - fn handle_resize_poll(&mut self) -> bool { - let Ok(size) = terminal::size() else { - return false; - }; - if self.last_terminal_size != Some(size) { - self.last_terminal_size = Some(size); - return true; - } - false - } - - fn handle_scheduled_tasks(&mut self, now: Instant) -> bool { - let mut changed = false; - - self.sync_animation_timer(now); - - if now >= self.next_resize_poll { - changed |= self.handle_resize_poll(); - self.next_resize_poll = now + RESIZE_POLL_INTERVAL; - } - - if self - .config_diagnostic_deadline - .is_some_and(|deadline| now >= deadline) - { - self.config_diagnostic_deadline = None; - self.state.config_diagnostic = None; - changed = true; - } - - if self.toast_deadline.is_some_and(|deadline| now >= deadline) { - self.toast_deadline = None; - self.state.toast = None; - changed = true; - } - - if self - .next_animation_tick - .is_some_and(|deadline| now >= deadline) - { - self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1); - self.next_animation_tick = Some(now + ANIMATION_INTERVAL); - changed = true; - } - - if self - .git_refresh_deadline() - .is_some_and(|deadline| now >= deadline) - { - for ws in &mut self.state.workspaces { - ws.refresh_git_ahead_behind(); - } - self.last_git_remote_status_refresh = now; - changed = true; - } - - if self - .next_auto_update_check - .is_some_and(|deadline| now >= deadline) - { - self.run_auto_update_check(); - } - - if self - .session_save_deadline - .is_some_and(|deadline| now >= deadline) - { - self.save_session_now(); - } - - self.sync_animation_timer(now); - changed - } - - fn sync_animation_timer(&mut self, now: Instant) { - if self.agent_panel_has_animation() { - self.next_animation_tick - .get_or_insert(now + ANIMATION_INTERVAL); - } else { - self.next_animation_tick = None; - } - } - - fn agent_panel_has_animation(&self) -> bool { - match self.state.agent_panel_scope { - crate::app::state::AgentPanelScope::CurrentWorkspace => self - .state - .active - .and_then(|idx| self.state.workspaces.get(idx)) - .is_some_and(Workspace::has_working_pane), - crate::app::state::AgentPanelScope::AllWorkspaces => self - .state - .workspaces - .iter() - .any(Workspace::has_working_pane), - } - } - - fn can_render_now(&self, now: Instant) -> bool { - match self.last_render_at { - Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL, - None => true, - } - } - - fn run_auto_update_check(&mut self) { - self.next_auto_update_check = self - .state - .update_available - .is_none() - .then_some(Instant::now() + AUTO_UPDATE_CHECK_INTERVAL); - - if self.state.update_available.is_some() { - return; - } - - let update_tx = self.event_tx.clone(); - std::thread::spawn(move || crate::update::auto_update(update_tx)); - } - - fn git_refresh_deadline(&self) -> Option<Instant> { - (!self.state.workspaces.is_empty()) - .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL) - } - - fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> { - let render_deadline = if needs_render { - self.last_render_at - .map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL) - .filter(|deadline| *deadline > now) - } else { - None - }; - - [ - Some(self.next_resize_poll), - self.config_diagnostic_deadline, - self.toast_deadline, - self.next_animation_tick, - self.git_refresh_deadline(), - self.next_auto_update_check, - self.session_save_deadline, - render_deadline, - ] - .into_iter() - .flatten() - .min() - } - - fn drain_internal_events(&mut self) -> bool { - let mut had_event = false; - while let Ok(ev) = self.event_rx.try_recv() { - had_event = true; - self.handle_internal_event(ev); - } - had_event - } - - fn handle_internal_event(&mut self, ev: AppEvent) { - if let AppEvent::ClipboardWrite { content } = ev { - crate::selection::write_osc52_bytes(&content); - return; - } - - let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { - self.overlay_panes.remove(pane_id) - } else { - None - }; - - if let AppEvent::PaneDied { pane_id } = &ev { - if let Some((ws_idx, _)) = self.find_pane(*pane_id) { - if let Some(public_pane_id) = self.public_pane_id(ws_idx, *pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneExited, - data: crate::api::schema::EventData::PaneExited { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - } - } - - let released_agent = if let AppEvent::HookAgentReleased { - pane_id, - known_agent, - .. - } = &ev - { - known_agent.map(|agent| (*pane_id, agent)) - } else { - None - }; - - let previous_toast = self.state.toast.clone(); - let pane_updates = self.state.handle_app_event(ev); - for update in &pane_updates { - self.emit_pane_state_update(update); - } - if let Some((pane_id, agent)) = released_agent { - if pane_updates.iter().any(|update| update.pane_id == pane_id) { - if let Some((ws_idx, _)) = self.find_pane(pane_id) { - if let Some(runtime) = self.state.workspaces[ws_idx].runtimes.get(&pane_id) { - runtime.begin_graceful_release(agent); - } - } - } - } - if let Some(overlay) = overlay_state { - self.restore_overlay_after_exit(overlay); - } - self.sync_toast_deadline(previous_toast); - } - - fn emit_pane_state_update(&self, update: &crate::app::actions::PaneStateUpdate) { - let Some(pane_id) = self.public_pane_id(update.ws_idx, update.pane_id) else { - return; - }; - let workspace_id = self.public_workspace_id(update.ws_idx); - - if update.previous_agent_label != update.agent_label { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentDetected, - data: crate::api::schema::EventData::PaneAgentDetected { - pane_id: pane_id.clone(), - workspace_id: workspace_id.clone(), - agent: update.agent_label.clone(), - }, - }); - } - - if update.previous_state != update.state { - let agent_status = self - .state - .workspaces - .get(update.ws_idx) - .and_then(|ws| ws.pane_state(update.pane_id)) - .map(|pane| pane_agent_status(pane.state, pane.seen)) - .unwrap_or_else(|| pane_agent_status(update.state, true)); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneAgentStatusChanged, - data: crate::api::schema::EventData::PaneAgentStatusChanged { - pane_id, - workspace_id, - agent_status, - }, - }); - } - } - - fn sync_toast_deadline( - &mut self, - previous_toast: Option<crate::app::state::ToastNotification>, - ) { - if self.state.toast != previous_toast { - self.toast_deadline = self.state.toast.as_ref().map(|toast| { - let duration = match toast.kind { - ToastKind::NeedsAttention => Duration::from_secs(8), - ToastKind::Finished => Duration::from_secs(5), - ToastKind::UpdateInstalled => Duration::from_secs(3), - }; - Instant::now() + duration - }); - } - } - - fn emit_event(&self, event: crate::api::schema::EventEnvelope) { - self.event_hub.push(event); - } - - fn sync_focus_events(&mut self) { - let current_focus = self.state.active.and_then(|idx| { - self.state - .workspaces - .get(idx) - .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) - }); - if current_focus == self.last_focus { - return; - } - - if let Some((ws_idx, pane_id)) = self.last_focus { - self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Lost); - } - if let Some((ws_idx, pane_id)) = current_focus { - self.send_pane_focus_event(ws_idx, pane_id, crate::ghostty::FocusEvent::Gained); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceFocused, - data: crate::api::schema::EventData::WorkspaceFocused { - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - if let Some(tab_id) = - self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) - { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabFocused, - data: crate::api::schema::EventData::TabFocused { - tab_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneFocused, - data: crate::api::schema::EventData::PaneFocused { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - } - - self.last_focus = current_focus; - } - - fn send_pane_focus_event( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - event: crate::ghostty::FocusEvent, - ) { - let Some(runtime) = self - .state - .workspaces - .get(ws_idx) - .and_then(|ws| ws.runtime(pane_id)) - else { - return; - }; - runtime.try_send_focus_event(event); - } - - fn find_pane( - &self, - pane_id: crate::layout::PaneId, - ) -> Option<(usize, &crate::pane::PaneState)> { - self.state - .workspaces - .iter() - .enumerate() - .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) - } - pub(crate) fn dismiss_release_notes(&mut self) { let preview = self .state @@ -1018,29 +552,7 @@ impl App { } } - fn update_config_file<F>(&mut self, error_context: &str, update: F) - where - F: FnOnce(&str) -> String, - { - let path = crate::config::config_path(); - if let Some(parent) = path.parent() { - if let Err(err) = std::fs::create_dir_all(parent) { - self.state.config_diagnostic = - Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); - return; - } - } - - let content = std::fs::read_to_string(&path).unwrap_or_default(); - let new_content = update(&content); - if let Err(err) = std::fs::write(&path, new_content) { - self.state.config_diagnostic = Some(format!("failed to save {error_context}: {err}")); - self.config_diagnostic_deadline = Some(Instant::now() + Duration::from_secs(5)); - } - } - - fn reload_keybinds(&mut self) { + pub(crate) fn reload_keybinds(&mut self) { let previous_toast = self.state.toast.clone(); match crate::config::load_live_keybinds() { Ok(live) => { @@ -1067,216 +579,145 @@ impl App { } self.sync_toast_deadline(previous_toast); } +} - fn save_theme(&mut self, name: &str) { - self.update_config_file("theme", |content| { - crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\"")) - }); - } - - fn save_sound(&mut self, enabled: bool) { - self.update_config_file("sound setting", |content| { - crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled) - }); - } - - fn save_toast(&mut self, enabled: bool) { - self.update_config_file("toast setting", |content| { - crate::config::upsert_section_bool(content, "ui.toast", "enabled", enabled) - }); - } - - fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<std::path::PathBuf> { - self.state.workspaces.get(ws_idx)?.resolved_identity_cwd() - } - - fn workspace_creation_source(&self) -> Option<usize> { - if self.state.mode == Mode::Navigate - && self.state.workspaces.get(self.state.selected).is_some() - { - return Some(self.state.selected); - } - - self.state.active.or_else(|| { - self.state - .workspaces - .get(self.state.selected) - .map(|_| self.state.selected) - }) - } - - /// Create a workspace with a real PTY (needs event_tx). - fn create_workspace(&mut self) { - let initial_cwd = self - .workspace_creation_source() - .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - if let Err(e) = self.create_workspace_with_options(initial_cwd, true) { - error!(err = %e, "failed to create workspace"); - self.state.mode = Mode::Navigate; - } - } +// --------------------------------------------------------------------------- +// Input routing for headless server mode +// --------------------------------------------------------------------------- - fn create_tab(&mut self) { - let custom_name = self.state.requested_new_tab_name.take(); - let initial_cwd = self - .state - .active - .and_then(|ws_idx| self.seed_cwd_from_workspace(ws_idx)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); - match self.create_tab_with_options(initial_cwd, true) { - Ok(tab_idx) => { - if let Some(name) = custom_name { - if let Some(ws) = self - .state - .active - .and_then(|ws_idx| self.state.workspaces.get_mut(ws_idx)) - { - if let Some(tab) = ws.tabs.get_mut(tab_idx) { - tab.set_custom_name(name); +impl App { + /// Routes raw input bytes from a client through the existing input pipeline. + /// + /// The input bytes are parsed into `RawInputEvent`s and then processed. + /// In terminal mode, keys are routed through the same semantic + /// key-handling path as monolithic herdr so they are re-encoded for the + /// focused pane's negotiated keyboard protocol instead of passing host + /// terminal escape sequences through unchanged. + #[cfg(test)] + pub(crate) fn route_client_input(&mut self, data: Vec<u8>) { + let events = crate::raw_input::parse_raw_input_bytes_sync(&data); + self.route_client_events(events, true); + } + + pub(crate) fn route_client_events( + &mut self, + events: Vec<crate::raw_input::RawInputEvent>, + apply_host_terminal_theme: bool, + ) { + for event in events { + match event { + crate::raw_input::RawInputEvent::Key(key) => { + let key_id = repeat_key_identity(&key); + match key.kind { + crossterm::event::KeyEventKind::Press => { + if self.state.mode == Mode::Terminal { + self.suppressed_repeat_keys.remove(&key_id); + self.handle_terminal_key_headless(key); + } else { + self.suppressed_repeat_keys.insert(key_id); + self.handle_non_terminal_key(key); + } + } + crossterm::event::KeyEventKind::Repeat => { + if self.state.mode == Mode::Terminal + && !self.suppressed_repeat_keys.contains(&key_id) + { + self.handle_terminal_key_headless(key); + } + // Repeats in non-terminal modes are ignored + // (same as monolithic behavior). + } + crossterm::event::KeyEventKind::Release => { + self.suppressed_repeat_keys.remove(&key_id); } - self.schedule_session_save(); } } + crate::raw_input::RawInputEvent::Mouse(mouse) => { + self.handle_mouse_event_headless(mouse); + } + crate::raw_input::RawInputEvent::Paste(text) => { + if self.state.mode == Mode::Terminal { + if let Some(ws_idx) = self.state.active { + if let Some(ws) = self.state.workspaces.get(ws_idx) { + if let Some(focused) = ws.focused_pane_id() { + if let Some(runtime) = ws.runtimes.get(&focused) { + let _ = runtime.try_send_bytes(bytes::Bytes::from( + if runtime + .input_state() + .map(|s| s.bracketed_paste) + .unwrap_or(false) + { + format!("\x1b[200~{text}\x1b[201~") + } else { + text + }, + )); + } + } + } + } + } + } + crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => { + if apply_host_terminal_theme { + self.update_host_terminal_theme(kind, color); + } + } + crate::raw_input::RawInputEvent::Unsupported => {} } - Err(e) => { - error!(err = %e, "failed to create tab"); - } - } - } - - fn create_tab_with_options( - &mut self, - initial_cwd: std::path::PathBuf, - focus: bool, - ) -> std::io::Result<usize> { - let Some(ws_idx) = self.state.active else { - return self.create_workspace_with_options(initial_cwd, focus); - }; - let (rows, cols) = self.state.estimate_pane_size(); - let ws = &mut self.state.workspaces[ws_idx]; - let idx = ws.create_tab( - rows, - cols, - initial_cwd, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - )?; - if focus { - ws.switch_tab(idx); - self.state.mode = Mode::Terminal; } - self.schedule_session_save(); - Ok(idx) } - fn create_workspace_with_options( - &mut self, - initial_cwd: std::path::PathBuf, - focus: bool, - ) -> std::io::Result<usize> { - let (rows, cols) = self.state.estimate_pane_size(); - let mut ws = Workspace::new( - initial_cwd, - rows, - cols, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - self.event_tx.clone(), - self.render_notify.clone(), - self.render_dirty.clone(), - )?; - ws.refresh_git_ahead_behind(); - self.state.workspaces.push(ws); - let idx = self.state.workspaces.len() - 1; - if focus || self.state.active.is_none() { - self.state.switch_workspace(idx); - self.state.mode = Mode::Terminal; + /// Handles a key event in non-terminal mode for the headless server. + /// + /// Uses the standalone handler functions that work on `&mut AppState` + /// since the server doesn't have the async context of the monolithic App. + fn handle_non_terminal_key(&mut self, key: crate::input::TerminalKey) { + let key_event = key.as_key_event(); + match self.state.mode { + Mode::Navigate => { + input::handle_navigate_key(&mut self.state, key_event); + } + Mode::RenameWorkspace | Mode::RenameTab => { + input::handle_rename_key(&mut self.state, key_event); + } + Mode::Resize => { + input::handle_resize_key(&mut self.state, key_event); + } + Mode::ConfirmClose => { + input::handle_confirm_close_key(&mut self.state, key_event); + } + Mode::ContextMenu => { + input::handle_context_menu_key(&mut self.state, key_event); + } + Mode::KeybindHelp => { + input::handle_keybind_help_key(&mut self.state, key_event); + } + Mode::GlobalMenu => { + input::handle_global_menu_key(&mut self.state, key_event); + } + Mode::Onboarding => { + self.handle_onboarding_key(key_event); + } + Mode::ReleaseNotes => { + self.handle_release_notes_key(key_event); + } + Mode::Settings => { + self.handle_settings_key(key_event); + } + Mode::Terminal => { + // Should not be called in terminal mode. + } } - self.schedule_session_save(); - Ok(idx) - } -} - -fn tab_attention_priority(state: crate::detect::AgentState, seen: bool) -> u8 { - match (state, seen) { - (crate::detect::AgentState::Blocked, _) => 4, - (crate::detect::AgentState::Idle, false) => 3, - (crate::detect::AgentState::Working, _) => 2, - (crate::detect::AgentState::Idle, true) => 1, - (crate::detect::AgentState::Unknown, _) => 0, - } -} - -fn parse_api_key(key: &str) -> Option<crossterm::event::KeyEvent> { - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - let normalized = key.trim(); - match normalized { - "Enter" | "enter" => Some(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())), - "Tab" | "tab" => Some(KeyEvent::new(KeyCode::Tab, KeyModifiers::empty())), - "Esc" | "esc" => Some(KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())), - "Backspace" | "backspace" => Some(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty())), - "Up" | "up" => Some(KeyEvent::new(KeyCode::Up, KeyModifiers::empty())), - "Down" | "down" => Some(KeyEvent::new(KeyCode::Down, KeyModifiers::empty())), - "Left" | "left" => Some(KeyEvent::new(KeyCode::Left, KeyModifiers::empty())), - "Right" | "right" => Some(KeyEvent::new(KeyCode::Right, KeyModifiers::empty())), - "C-c" | "c-c" | "ctrl+c" => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), - _ if normalized.len() == 1 => normalized - .chars() - .next() - .map(|ch| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty())), - _ => None, - } -} - -fn encode_api_text(runtime: &crate::pane::PaneRuntime, text: &str) -> Vec<u8> { - let bracketed = runtime - .input_state() - .map(|state| state.bracketed_paste) - .unwrap_or(false); - if bracketed { - format!("\x1b[200~{text}\x1b[201~").into_bytes() - } else { - text.as_bytes().to_vec() - } -} - -fn encode_api_keys( - runtime: &crate::pane::PaneRuntime, - keys: &[String], -) -> Result<Vec<Vec<u8>>, String> { - let mut encoded_keys = Vec::with_capacity(keys.len()); - for key in keys { - let Some(key_event) = parse_api_key(key) else { - return Err(key.clone()); - }; - encoded_keys.push(runtime.encode_terminal_key(key_event.into())); - } - Ok(encoded_keys) -} - -fn detect_state_from_api(state: crate::api::schema::PaneAgentState) -> crate::detect::AgentState { - match state { - crate::api::schema::PaneAgentState::Idle => crate::detect::AgentState::Idle, - crate::api::schema::PaneAgentState::Working => crate::detect::AgentState::Working, - crate::api::schema::PaneAgentState::Blocked => crate::detect::AgentState::Blocked, - crate::api::schema::PaneAgentState::Unknown => crate::detect::AgentState::Unknown, } -} -fn pane_agent_status( - state: crate::detect::AgentState, - seen: bool, -) -> crate::api::schema::AgentStatus { - match (state, seen) { - (crate::detect::AgentState::Idle, false) => crate::api::schema::AgentStatus::Done, - (crate::detect::AgentState::Idle, true) => crate::api::schema::AgentStatus::Idle, - (crate::detect::AgentState::Working, _) => crate::api::schema::AgentStatus::Working, - (crate::detect::AgentState::Blocked, _) => crate::api::schema::AgentStatus::Blocked, - (crate::detect::AgentState::Unknown, _) => crate::api::schema::AgentStatus::Unknown, + /// Handles a mouse event for the headless server. + /// + /// Delegates to the same mouse handling logic used in the monolithic + /// mode (hit-testing against the rendered UI), which works because + /// the server's AppState maintains view geometry from virtual rendering. + fn handle_mouse_event_headless(&mut self, mouse: crossterm::event::MouseEvent) { + self.handle_mouse(mouse); } } @@ -1285,6 +726,7 @@ mod tests { use super::*; use crate::config::Config; use crate::detect::{Agent, AgentState}; + use crate::pane::PaneRuntime; use crate::workspace::Workspace; use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use std::sync::{Mutex, OnceLock}; @@ -1541,8 +983,8 @@ mod tests { ), }; - assert!(!api_request_changes_ui(&read_only)); - assert!(api_request_changes_ui(&mutating)); + assert!(!crate::api::request_changes_ui(&read_only)); + assert!(crate::api::request_changes_ui(&mutating)); } #[test] @@ -1614,6 +1056,22 @@ mod tests { } #[test] + fn server_stop_request_sets_should_quit_flag() { + let mut app = test_app(); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_server_stop".into(), + method: crate::api::schema::Method::ServerStop( + crate::api::schema::EmptyParams::default(), + ), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "ok"); + assert!(app.state.should_quit); + } + + #[test] fn pane_close_request_closes_only_the_target_tab_when_other_tabs_exist() { let mut app = test_app(); let mut workspace = Workspace::test_new("api-pane-close"); @@ -1758,4 +1216,230 @@ mod tests { "Working→Idle should still apply after temporary queue pressure" ); } + + #[test] + fn route_client_input_dispatches_navigate_mode_keybinds() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + + // Start in navigate mode. + app.state.mode = Mode::Navigate; + + // Send Ctrl+B then Esc (prefix → leave navigate mode). + // Ctrl+B is 0x02 in raw terminal input. + // After entering navigate mode and pressing Esc, we should leave navigate mode. + let esc_bytes = vec![0x1b]; // Esc + app.route_client_input(esc_bytes); + // Esc in navigate mode should leave navigate mode. + assert_eq!( + app.state.mode, + Mode::Terminal, + "Esc should leave navigate mode and return to Terminal mode" + ); + } + + #[test] + fn route_client_input_q_detaches_in_persistence_mode() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.quit_detaches = true; + + // Start in navigate mode. + app.state.mode = Mode::Navigate; + assert!(!app.state.detach_requested); + + let q_bytes = b"q".to_vec(); + app.route_client_input(q_bytes); + + assert!( + app.state.detach_requested, + "q should detach in persistence mode" + ); + assert_eq!( + app.state.mode, + Mode::Terminal, + "q should leave navigate mode" + ); + } + + #[test] + fn route_client_input_prefix_then_q_detaches_in_persistence_mode() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.quit_detaches = true; + + // Start in terminal mode (default after workspace creation). + app.state.mode = Mode::Terminal; + assert!(!app.state.detach_requested); + + // Send Ctrl+B (prefix key, raw byte 0x02). + let prefix_bytes = vec![0x02]; + app.route_client_input(prefix_bytes); + + assert_eq!( + app.state.mode, + Mode::Navigate, + "prefix key should enter navigate mode" + ); + assert!( + !app.state.detach_requested, + "prefix key should not set detach flag" + ); + + let q_bytes = b"q".to_vec(); + app.route_client_input(q_bytes); + + assert!( + app.state.detach_requested, + "q should detach in persistence mode" + ); + assert_eq!( + app.state.mode, + Mode::Terminal, + "q should leave navigate mode" + ); + } + + #[tokio::test] + async fn route_client_input_reencodes_terminal_keys_for_focused_pane_protocol() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + // Ghostty/kitty-style Ctrl-C should be normalized back to the pane's + // negotiated encoding instead of being forwarded verbatim. + app.route_client_input(b"\x1b[99;5u".to_vec()); + + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from(vec![3])); + } + + #[tokio::test] + async fn route_client_input_splits_multi_event_payloads_before_forwarding() { + let mut app = test_app(); + let mut workspace = Workspace::test_new("test"); + let focused = workspace.focused_pane_id().unwrap(); + let (runtime, mut rx) = PaneRuntime::test_with_channel(80, 24); + workspace.tabs[0].runtimes.insert(focused, runtime); + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Terminal; + + app.route_client_input(b"ab".to_vec()); + + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"a")); + assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"b")); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn route_client_input_handles_mouse_events() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + + // Send a mouse scroll-up event via SGR encoding. + let mouse_bytes = b"\x1b[<64;10;5M".to_vec(); + // This should not panic even though mouse handling is simplified + // in headless mode. + app.route_client_input(mouse_bytes); + // No assertions on specific behavior — just no panic. + } + + #[test] + fn route_client_input_advances_onboarding_modal() { + let mut app = test_app(); + app.state.mode = Mode::Onboarding; + app.state.onboarding_step = 0; + + app.route_client_input(b"\r".to_vec()); + + assert_eq!(app.state.onboarding_step, 1); + assert_eq!(app.state.mode, Mode::Onboarding); + } + + #[test] + fn route_client_input_closes_release_notes_modal() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::ReleaseNotes; + app.state.release_notes = Some(release_notes_state()); + + app.route_client_input(b"\x1b".to_vec()); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app.state.release_notes.is_none()); + } + + #[test] + fn route_client_input_closes_settings_modal() { + let mut app = test_app(); + app.state.workspaces = vec![Workspace::test_new("test")]; + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = Mode::Settings; + app.state.settings.original_theme = Some(app.state.theme_name.clone()); + app.state.settings.original_palette = Some(app.state.palette.clone()); + + app.route_client_input(b"\x1b".to_vec()); + + assert_eq!(app.state.mode, Mode::Terminal); + } + + #[test] + fn route_client_input_updates_host_terminal_theme_from_osc_response() { + let mut app = test_app(); + + app.route_client_input(b"\x1b]11;#123456\x07".to_vec()); + + assert_eq!( + app.state.host_terminal_theme.background, + Some(crate::terminal_theme::RgbColor { + r: 0x12, + g: 0x34, + b: 0x56, + }) + ); + } + + #[test] + fn parse_raw_input_bytes_with_ranges_tracks_offsets() { + // Verify that the range-aware parser correctly tracks byte offsets + // for events within a multi-event input buffer. + let input = b"\x1b[Aa".to_vec(); // Up arrow + 'a' + let events = crate::raw_input::parse_raw_input_bytes_with_ranges(&input); + + assert_eq!(events.len(), 2, "should parse Up arrow and 'a'"); + // Up arrow: \x1b[A = 3 bytes starting at offset 0 + assert_eq!(events[0].start, 0); + assert_eq!(events[0].len, 3); + // 'a': 1 byte starting at offset 3 + assert_eq!(events[1].start, 3); + assert_eq!(events[1].len, 1); + + // Verify the raw bytes for each event are correct. + assert_eq!( + &input[events[0].start..events[0].start + events[0].len], + b"\x1b[A" + ); + assert_eq!( + &input[events[1].start..events[1].start + events[1].len], + b"a" + ); + } } |
