aboutsummaryrefslogtreecommitdiff
path: root/src/app/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/mod.rs')
-rw-r--r--src/app/mod.rs1166
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"
+ );
+ }
}