aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs28
-rw-r--r--src/app/api.rs709
-rw-r--r--src/app/api_helpers.rs92
-rw-r--r--src/app/config_io.rs45
-rw-r--r--src/app/creation.rs281
-rw-r--r--src/app/ids.rs82
-rw-r--r--src/app/input.rs5416
-rw-r--r--src/app/input/mod.rs321
-rw-r--r--src/app/input/modal.rs739
-rw-r--r--src/app/input/mouse.rs1258
-rw-r--r--src/app/input/navigate.rs780
-rw-r--r--src/app/input/overlays.rs483
-rw-r--r--src/app/input/selection.rs68
-rw-r--r--src/app/input/settings.rs352
-rw-r--r--src/app/input/sidebar.rs1219
-rw-r--r--src/app/input/terminal.rs440
-rw-r--r--src/app/mod.rs1166
-rw-r--r--src/app/runtime.rs261
-rw-r--r--src/app/runtime_commands.rs177
-rw-r--r--src/app/session.rs41
-rw-r--r--src/app/state.rs22
-rw-r--r--src/app/theme_sync.rs51
22 files changed, 7433 insertions, 6598 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 3617bb07..f9136b9c 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -15,7 +15,7 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS
&& matches!(prev_state, AgentState::Working | AgentState::Blocked)
}
-fn notification_sound_for_state_change(
+pub fn notification_sound_for_state_change(
is_active_tab: bool,
prev_state: AgentState,
new_state: AgentState,
@@ -89,7 +89,7 @@ pub struct PaneStateUpdate {
// ---------------------------------------------------------------------------
impl AppState {
- fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool {
+ pub(crate) fn pane_is_in_active_tab(&self, ws_idx: usize, pane_id: PaneId) -> bool {
let Some(active_ws_idx) = self.active else {
return false;
};
@@ -445,8 +445,8 @@ impl AppState {
if let Some(text) = text {
if !text.is_empty() {
- crate::selection::write_osc52(&text);
- info!(len = text.len(), "copied selection to clipboard");
+ self.request_clipboard_write = Some(text.into_bytes());
+ info!("copied selection to clipboard");
}
}
@@ -472,7 +472,7 @@ impl AppState {
self.toast = Some(ToastNotification {
kind: ToastKind::UpdateInstalled,
title: format!("v{version} available"),
- context: "run `herdr update`".to_string(),
+ context: "detach, then run `herdr update`".to_string(),
});
Vec::new()
}
@@ -663,6 +663,22 @@ mod tests {
}
#[test]
+ fn update_ready_sets_explicit_upgrade_toast() {
+ let mut state = AppState::test_new();
+
+ let updates = state.handle_app_event(crate::events::AppEvent::UpdateReady {
+ version: "0.5.0".into(),
+ });
+
+ assert!(updates.is_empty());
+ assert_eq!(state.update_available.as_deref(), Some("0.5.0"));
+ assert!(state.latest_release_notes_available);
+ let toast = state.toast.as_ref().expect("update toast");
+ assert_eq!(toast.title, "v0.5.0 available");
+ assert_eq!(toast.context, "detach, then run `herdr update`");
+ }
+
+ #[test]
fn switch_workspace_updates_active_and_selected() {
let mut state = app_with_workspaces(&["a", "b", "c"]);
state.switch_workspace(2);
@@ -1038,7 +1054,7 @@ mod tests {
let toast = state.toast.as_ref().expect("update toast");
assert_eq!(toast.kind, ToastKind::UpdateInstalled);
assert_eq!(toast.title, "v0.5.0 available");
- assert_eq!(toast.context, "run `herdr update`");
+ assert_eq!(toast.context, "detach, then run `herdr update`");
}
#[test]
diff --git a/src/app/api.rs b/src/app/api.rs
index 2ed224c7..e47eb452 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -1,131 +1,230 @@
-//! API request handling and response building for [`App`].
+use std::time::{Duration, Instant};
use super::{
- detect_state_from_api, encode_api_keys, encode_api_text, pane_agent_status,
- tab_attention_priority, App, Mode,
+ api_helpers::{
+ detect_state_from_api, encode_api_keys, encode_api_text, normalize_reported_agent_label,
+ pane_agent_status,
+ },
+ App, Mode, OverlayPaneState, ToastKind,
};
+use crate::events::AppEvent;
-fn normalize_reported_agent_label(agent: &str) -> Option<String> {
- let trimmed = agent.trim();
- if trimmed.is_empty() {
- return None;
- }
- if let Some(agent) = crate::detect::parse_agent_label(trimmed) {
- return Some(crate::detect::agent_label(agent).to_string());
- }
- Some(trimmed.to_string())
-}
+impl App {
+ pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) {
+ if let AppEvent::ClipboardWrite { content } = ev {
+ crate::selection::write_osc52_bytes(&content);
+ return;
+ }
-pub(super) fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
- use crate::api::schema::Method;
+ let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev {
+ self.overlay_panes.remove(pane_id)
+ } else {
+ None
+ };
- matches!(
- &request.method,
- Method::WorkspaceCreate(_)
- | Method::WorkspaceFocus(_)
- | Method::WorkspaceRename(_)
- | Method::WorkspaceClose(_)
- | Method::TabCreate(_)
- | Method::TabFocus(_)
- | Method::TabRename(_)
- | Method::TabClose(_)
- | Method::PaneSplit(_)
- | Method::PaneReportAgent(_)
- | Method::PaneClearAgentAuthority(_)
- | Method::PaneReleaseAgent(_)
- | Method::PaneClose(_)
- )
-}
+ 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),
+ },
+ });
+ }
+ }
+ }
-impl App {
- pub(super) fn handle_api_request_message(
- &mut self,
- msg: crate::api::ApiRequestMessage,
- ) -> bool {
- let changed = api_request_changes_ui(&msg.request);
- let response = self.handle_api_request(msg.request);
- let _ = msg.respond_to.send(response);
- changed
- }
+ let released_agent = if let AppEvent::HookAgentReleased {
+ pane_id,
+ known_agent,
+ ..
+ } = &ev
+ {
+ known_agent.map(|agent| (*pane_id, agent))
+ } else {
+ None
+ };
- pub(super) fn public_workspace_id(&self, ws_idx: usize) -> String {
- self.state.workspaces[ws_idx].id.clone()
+ 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);
}
- pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> {
- let ws = self.state.workspaces.get(ws_idx)?;
- ws.tabs.get(tab_idx)?;
- Some(format!("{}:{}", ws.id, tab_idx + 1))
- }
+ fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
+ let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
+ return;
+ };
+ if overlay.tab_idx >= ws.tabs.len() {
+ return;
+ }
- pub(super) fn public_pane_id(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<String> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let pane_number = ws.public_pane_number(pane_id)?;
- Some(format!("{}-{pane_number}", ws.id))
+ ws.active_tab = overlay.tab_idx;
+ let tab = &mut ws.tabs[overlay.tab_idx];
+ if tab.panes.contains_key(&overlay.previous_focus) {
+ tab.layout.focus_pane(overlay.previous_focus);
+ }
+ tab.zoomed = overlay.previous_zoomed;
+
+ if self.state.active == Some(overlay.ws_idx) {
+ self.state.mode = Mode::Terminal;
+ }
}
- pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> {
- self.state
- .workspaces
- .iter()
- .position(|workspace| workspace.id == id)
- .or_else(|| id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1))
- .or_else(|| id.parse::<usize>().ok()?.checked_sub(1))
+ 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,
+ },
+ });
+ }
}
- pub(super) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> {
- if let Some(rest) = id.strip_prefix("t_") {
- let (ws_raw, tab_raw) = rest.rsplit_once('_')?;
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?;
- self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?;
- return Some((ws_idx, tab_idx));
+ pub(super) 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
+ });
}
+ }
- let (ws_raw, tab_raw) = id.rsplit_once(':')?;
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?;
- self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?;
- Some((ws_idx, tab_idx))
+ pub(super) fn emit_event(&self, event: crate::api::schema::EventEnvelope) {
+ self.event_hub.push(event);
}
- pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
- if let Some(rest) = id.strip_prefix("p_") {
- if let Some((ws_raw, pane_raw)) = rest.rsplit_once('_') {
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?);
- return Some((ws_idx, pane_id));
- }
+ pub(crate) 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;
+ }
- let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?);
- return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id));
+ 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),
+ },
+ });
+ }
}
- let (ws_raw, pane_number_raw) = id.rsplit_once('-')?;
- let ws_idx = self.parse_workspace_id(ws_raw)?;
- let pane_number = pane_number_raw.parse::<usize>().ok()?;
- let ws = self.state.workspaces.get(ws_idx)?;
- let pane_id = ws
- .public_pane_numbers
- .iter()
- .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?;
- Some((ws_idx, pane_id))
+ 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);
}
- pub(super) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
+ pub(crate) fn handle_api_request(&mut self, request: crate::api::schema::Request) -> String {
self.drain_internal_events();
use bytes::Bytes;
use crate::api::schema::{
- ErrorBody, ErrorResponse, Method, PaneListParams, PaneReadResult, ReadSource,
+ ErrorBody, ErrorResponse, IntegrationInstallResult, IntegrationTarget,
+ IntegrationUninstallResult, Method, PaneListParams, PaneReadResult, ReadSource,
ResponseResult, SuccessResponse, TabListParams,
};
let response = match request.method {
+ Method::ServerStop(_) => {
+ self.state.should_quit = true;
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::Ok {},
+ }
+ }
Method::WorkspaceList(_) => SuccessResponse {
id: request.id,
result: ResponseResult::WorkspaceList {
@@ -1065,6 +1164,265 @@ impl App {
result: ResponseResult::Ok {},
}
}
+ Method::IntegrationInstall(params) => {
+ let target = params.target;
+ let messages = match target {
+ IntegrationTarget::Pi => {
+ let path = crate::integration::install_pi().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_install_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match path {
+ Ok(path) => {
+ vec![format!("installed pi integration to {}", path.display())]
+ }
+ Err(response) => return response,
+ }
+ }
+ IntegrationTarget::Claude => {
+ let installed = crate::integration::install_claude().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_install_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match installed {
+ Ok(installed) => vec![
+ format!(
+ "installed claude integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured claude settings at {}",
+ installed.settings_path.display()
+ ),
+ ],
+ Err(response) => return response,
+ }
+ }
+ IntegrationTarget::Codex => {
+ let installed = crate::integration::install_codex().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_install_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match installed {
+ Ok(installed) => vec![
+ format!(
+ "installed codex integration hook to {}",
+ installed.hook_path.display()
+ ),
+ format!(
+ "ensured codex hooks at {}",
+ installed.hooks_path.display()
+ ),
+ format!(
+ "ensured codex config at {}",
+ installed.config_path.display()
+ ),
+ ],
+ Err(response) => return response,
+ }
+ }
+ IntegrationTarget::Opencode => {
+ let installed = crate::integration::install_opencode().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_install_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match installed {
+ Ok(installed) => vec![format!(
+ "installed opencode integration plugin to {}",
+ installed.plugin_path.display()
+ )],
+ Err(response) => return response,
+ }
+ }
+ };
+
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::IntegrationInstall {
+ target,
+ details: IntegrationInstallResult { messages },
+ },
+ }
+ }
+ Method::IntegrationUninstall(params) => {
+ let target = params.target;
+ let messages = match target {
+ IntegrationTarget::Pi => {
+ let result = crate::integration::uninstall_pi().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_uninstall_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match result {
+ Ok(result) => {
+ if result.removed_extension {
+ vec![format!(
+ "removed pi integration extension at {}",
+ result.extension_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no pi integration extension found at {}",
+ result.extension_path.display()
+ )]
+ }
+ }
+ Err(response) => return response,
+ }
+ }
+ IntegrationTarget::Claude => {
+ let result = crate::integration::uninstall_claude().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_uninstall_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match result {
+ Ok(result) => {
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed claude hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no claude hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_settings {
+ messages.push(format!(
+ "removed herdr claude hook entries from {}",
+ result.settings_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr claude hook entries found in {}",
+ result.settings_path.display()
+ ));
+ }
+ messages
+ }
+ Err(response) => return response,
+ }
+ }
+ IntegrationTarget::Codex => {
+ let result = crate::integration::uninstall_codex().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_uninstall_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match result {
+ Ok(result) => {
+ let mut messages = Vec::new();
+ if result.removed_hook_file {
+ messages.push(format!(
+ "removed codex hook at {}",
+ result.hook_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no codex hook found at {}",
+ result.hook_path.display()
+ ));
+ }
+ if result.updated_hooks {
+ messages.push(format!(
+ "removed herdr codex hook entries from {}",
+ result.hooks_path.display()
+ ));
+ } else {
+ messages.push(format!(
+ "no herdr codex hook entries found in {}",
+ result.hooks_path.display()
+ ));
+ }
+ messages.push(format!(
+ "left codex config unchanged at {}",
+ result.config_path.display()
+ ));
+ messages
+ }
+ Err(response) => return response,
+ }
+ }
+ IntegrationTarget::Opencode => {
+ let result = crate::integration::uninstall_opencode().map_err(|err| {
+ serde_json::to_string(&ErrorResponse {
+ id: request.id.clone(),
+ error: ErrorBody {
+ code: "integration_uninstall_failed".into(),
+ message: err.to_string(),
+ },
+ })
+ .unwrap()
+ });
+ match result {
+ Ok(result) => {
+ if result.removed_plugin {
+ vec![format!(
+ "removed opencode integration plugin at {}",
+ result.plugin_path.display()
+ )]
+ } else {
+ vec![format!(
+ "no opencode integration plugin found at {}",
+ result.plugin_path.display()
+ )]
+ }
+ }
+ Err(response) => return response,
+ }
+ }
+ };
+
+ SuccessResponse {
+ id: request.id,
+ result: ResponseResult::IntegrationUninstall {
+ target,
+ details: IntegrationUninstallResult { messages },
+ },
+ }
+ }
_ => {
return serde_json::to_string(&ErrorResponse {
id: request.id,
@@ -1079,163 +1437,4 @@ impl App {
serde_json::to_string(&response).unwrap()
}
-
- pub(super) fn collect_panes_for_workspace(
- &self,
- workspace_id: Option<&str>,
- ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> {
- if let Some(workspace_id) = workspace_id {
- let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
- return Err((
- "workspace_not_found".into(),
- format!("workspace {workspace_id} not found"),
- ));
- };
- let Some(ws) = self.state.workspaces.get(ws_idx) else {
- return Err((
- "workspace_not_found".into(),
- format!("workspace {workspace_id} not found"),
- ));
- };
- Ok(ws
- .tabs
- .iter()
- .flat_map(|tab| tab.layout.pane_ids().into_iter())
- .filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
- .collect())
- } else {
- Ok(self
- .state
- .workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, ws)| {
- ws.tabs
- .iter()
- .flat_map(|tab| tab.layout.pane_ids().into_iter())
- .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
- })
- .collect())
- }
- }
-
- pub(super) fn tab_info(
- &self,
- ws_idx: usize,
- tab_idx: usize,
- ) -> Option<crate::api::schema::TabInfo> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let tab = ws.tabs.get(tab_idx)?;
- let (agg_state, seen) = tab
- .panes
- .values()
- .map(|pane| (pane.state, pane.seen))
- .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen))
- .unwrap_or((crate::detect::AgentState::Unknown, true));
- Some(crate::api::schema::TabInfo {
- tab_id: self.public_tab_id(ws_idx, tab_idx)?,
- workspace_id: self.public_workspace_id(ws_idx),
- number: tab_idx + 1,
- label: tab.display_name(),
- focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx,
- pane_count: tab.panes.len(),
- agent_status: pane_agent_status(agg_state, seen),
- })
- }
-
- pub(super) fn workspace_created_result(
- &self,
- ws_idx: usize,
- ) -> Option<crate::api::schema::ResponseResult> {
- Some(crate::api::schema::ResponseResult::WorkspaceCreated {
- workspace: self.workspace_info(ws_idx),
- tab: self.tab_info(ws_idx, 0)?,
- root_pane: self.root_pane_info(ws_idx, 0)?,
- })
- }
-
- pub(super) fn tab_created_result(
- &self,
- ws_idx: usize,
- tab_idx: usize,
- ) -> Option<crate::api::schema::ResponseResult> {
- Some(crate::api::schema::ResponseResult::TabCreated {
- tab: self.tab_info(ws_idx, tab_idx)?,
- root_pane: self.root_pane_info(ws_idx, tab_idx)?,
- })
- }
-
- pub(super) fn root_pane_info(
- &self,
- ws_idx: usize,
- tab_idx: usize,
- ) -> Option<crate::api::schema::PaneInfo> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let tab = ws.tabs.get(tab_idx)?;
- self.pane_info(ws_idx, tab.root_pane)
- }
-
- pub(super) fn pane_info(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<crate::api::schema::PaneInfo> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let pane = ws.pane_state(pane_id)?;
- let runtime = ws.runtime(pane_id);
- let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
- let focused = self.state.active == Some(ws_idx)
- && ws.active_tab == tab_idx
- && ws
- .focused_pane_id()
- .is_some_and(|focused| focused == pane_id);
- Some(crate::api::schema::PaneInfo {
- pane_id: self.public_pane_id(ws_idx, pane_id)?,
- workspace_id: self.public_workspace_id(ws_idx),
- tab_id: self.public_tab_id(ws_idx, tab_idx)?,
- focused,
- cwd: runtime
- .and_then(|rt| rt.cwd())
- .map(|cwd| cwd.display().to_string()),
- agent: pane.effective_agent_label().map(str::to_string),
- agent_status: pane_agent_status(pane.state, pane.seen),
- revision: 0,
- })
- }
-
- pub(super) fn lookup_runtime(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<(&crate::pane::PaneRuntime, String)> {
- let ws = self.state.workspaces.get(ws_idx)?;
- let runtime = ws.runtime(pane_id)?;
- Some((runtime, self.public_workspace_id(ws_idx)))
- }
-
- pub(super) fn lookup_runtime_sender(
- &self,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- ) -> Option<&crate::pane::PaneRuntime> {
- let ws = self.state.workspaces.get(ws_idx)?;
- ws.runtime(pane_id)
- }
-
- pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
- let ws = &self.state.workspaces[index];
- let (agg_state, seen) = ws.aggregate_state();
- crate::api::schema::WorkspaceInfo {
- workspace_id: self.public_workspace_id(index),
- number: index + 1,
- label: ws.display_name(),
- focused: self.state.active == Some(index),
- pane_count: ws.public_pane_numbers.len(),
- tab_count: ws.tabs.len(),
- active_tab_id: self
- .public_tab_id(index, ws.active_tab)
- .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)),
- agent_status: pane_agent_status(agg_state, seen),
- }
- }
}
diff --git a/src/app/api_helpers.rs b/src/app/api_helpers.rs
new file mode 100644
index 00000000..011e5ccb
--- /dev/null
+++ b/src/app/api_helpers.rs
@@ -0,0 +1,92 @@
+pub(super) 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,
+ }
+}
+
+pub(super) 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()
+ }
+}
+
+pub(super) 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)
+}
+
+pub(super) 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,
+ }
+}
+
+pub(super) 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,
+ }
+}
+
+pub(super) fn normalize_reported_agent_label(agent: &str) -> Option<String> {
+ let trimmed = agent.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ if let Some(agent) = crate::detect::parse_agent_label(trimmed) {
+ return Some(crate::detect::agent_label(agent).to_string());
+ }
+ Some(trimmed.to_string())
+}
diff --git a/src/app/config_io.rs b/src/app/config_io.rs
new file mode 100644
index 00000000..21d4365b
--- /dev/null
+++ b/src/app/config_io.rs
@@ -0,0 +1,45 @@
+use super::App;
+
+impl App {
+ pub(super) 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(std::time::Instant::now() + std::time::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(std::time::Instant::now() + std::time::Duration::from_secs(5));
+ }
+ }
+
+ pub(super) fn save_theme(&mut self, name: &str) {
+ self.update_config_file("theme", |content| {
+ crate::config::upsert_section_value(content, "theme", "name", &format!("\"{name}\""))
+ });
+ }
+
+ pub(super) fn save_sound(&mut self, enabled: bool) {
+ self.update_config_file("sound setting", |content| {
+ crate::config::upsert_section_bool(content, "ui.sound", "enabled", enabled)
+ });
+ }
+
+ pub(super) fn save_toast(&mut self, enabled: bool) {
+ self.update_config_file("toast setting", |content| {
+ crate::config::upsert_section_bool(content, "ui.toast", "enabled", enabled)
+ });
+ }
+}
diff --git a/src/app/creation.rs b/src/app/creation.rs
new file mode 100644
index 00000000..dd2833e0
--- /dev/null
+++ b/src/app/creation.rs
@@ -0,0 +1,281 @@
+use tracing::error;
+
+use super::{
+ api_helpers::{pane_agent_status, tab_attention_priority},
+ App, Mode,
+};
+use crate::workspace::Workspace;
+
+impl App {
+ pub(super) fn seed_cwd_from_workspace(&self, ws_idx: usize) -> Option<std::path::PathBuf> {
+ self.state.workspaces.get(ws_idx)?.resolved_identity_cwd()
+ }
+
+ pub(super) 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).
+ pub(crate) 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;
+ }
+ }
+
+ pub(crate) 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);
+ }
+ self.schedule_session_save();
+ }
+ }
+ }
+ Err(e) => {
+ error!(err = %e, "failed to create tab");
+ }
+ }
+ }
+
+ pub(super) 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)
+ }
+
+ pub(super) 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;
+ }
+ self.schedule_session_save();
+ Ok(idx)
+ }
+
+ pub(super) fn collect_panes_for_workspace(
+ &self,
+ workspace_id: Option<&str>,
+ ) -> Result<Vec<crate::api::schema::PaneInfo>, (String, String)> {
+ if let Some(workspace_id) = workspace_id {
+ let Some(ws_idx) = self.parse_workspace_id(workspace_id) else {
+ return Err((
+ "workspace_not_found".into(),
+ format!("workspace {workspace_id} not found"),
+ ));
+ };
+ let Some(ws) = self.state.workspaces.get(ws_idx) else {
+ return Err((
+ "workspace_not_found".into(),
+ format!("workspace {workspace_id} not found"),
+ ));
+ };
+ Ok(ws
+ .tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids().into_iter())
+ .filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
+ .collect())
+ } else {
+ Ok(self
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids().into_iter())
+ .filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
+ })
+ .collect())
+ }
+ }
+
+ pub(super) fn tab_info(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ ) -> Option<crate::api::schema::TabInfo> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ let (agg_state, seen) = tab
+ .panes
+ .values()
+ .map(|pane| (pane.state, pane.seen))
+ .max_by_key(|(state, seen)| tab_attention_priority(*state, *seen))
+ .unwrap_or((crate::detect::AgentState::Unknown, true));
+ Some(crate::api::schema::TabInfo {
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ workspace_id: self.public_workspace_id(ws_idx),
+ number: tab_idx + 1,
+ label: tab.display_name(),
+ focused: self.state.active == Some(ws_idx) && ws.active_tab == tab_idx,
+ pane_count: tab.panes.len(),
+ agent_status: pane_agent_status(agg_state, seen),
+ })
+ }
+
+ pub(super) fn workspace_created_result(
+ &self,
+ ws_idx: usize,
+ ) -> Option<crate::api::schema::ResponseResult> {
+ Some(crate::api::schema::ResponseResult::WorkspaceCreated {
+ workspace: self.workspace_info(ws_idx),
+ tab: self.tab_info(ws_idx, 0)?,
+ root_pane: self.root_pane_info(ws_idx, 0)?,
+ })
+ }
+
+ pub(super) fn tab_created_result(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ ) -> Option<crate::api::schema::ResponseResult> {
+ Some(crate::api::schema::ResponseResult::TabCreated {
+ tab: self.tab_info(ws_idx, tab_idx)?,
+ root_pane: self.root_pane_info(ws_idx, tab_idx)?,
+ })
+ }
+
+ pub(super) fn root_pane_info(
+ &self,
+ ws_idx: usize,
+ tab_idx: usize,
+ ) -> Option<crate::api::schema::PaneInfo> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let tab = ws.tabs.get(tab_idx)?;
+ self.pane_info(ws_idx, tab.root_pane)
+ }
+
+ pub(super) fn pane_info(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<crate::api::schema::PaneInfo> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane = ws.pane_state(pane_id)?;
+ let runtime = ws.runtime(pane_id);
+ let tab_idx = ws.find_tab_index_for_pane(pane_id)?;
+ let focused = self.state.active == Some(ws_idx)
+ && ws.active_tab == tab_idx
+ && ws
+ .focused_pane_id()
+ .is_some_and(|focused| focused == pane_id);
+ Some(crate::api::schema::PaneInfo {
+ pane_id: self.public_pane_id(ws_idx, pane_id)?,
+ workspace_id: self.public_workspace_id(ws_idx),
+ tab_id: self.public_tab_id(ws_idx, tab_idx)?,
+ focused,
+ cwd: runtime
+ .and_then(|rt| rt.cwd())
+ .map(|cwd| cwd.display().to_string()),
+ agent: pane.effective_agent_label().map(str::to_string),
+ agent_status: pane_agent_status(pane.state, pane.seen),
+ revision: 0,
+ })
+ }
+
+ pub(super) fn lookup_runtime(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<(&crate::pane::PaneRuntime, String)> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let runtime = ws.runtime(pane_id)?;
+ Some((runtime, self.public_workspace_id(ws_idx)))
+ }
+
+ pub(super) fn lookup_runtime_sender(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<&crate::pane::PaneRuntime> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ ws.runtime(pane_id)
+ }
+
+ pub(super) fn workspace_info(&self, index: usize) -> crate::api::schema::WorkspaceInfo {
+ let ws = &self.state.workspaces[index];
+ let (agg_state, seen) = ws.aggregate_state();
+ crate::api::schema::WorkspaceInfo {
+ workspace_id: self.public_workspace_id(index),
+ number: index + 1,
+ label: ws.display_name(),
+ focused: self.state.active == Some(index),
+ pane_count: ws.public_pane_numbers.len(),
+ tab_count: ws.tabs.len(),
+ active_tab_id: self
+ .public_tab_id(index, ws.active_tab)
+ .unwrap_or_else(|| format!("{}:{}", ws.id, ws.active_tab + 1)),
+ agent_status: pane_agent_status(agg_state, seen),
+ }
+ }
+}
diff --git a/src/app/ids.rs b/src/app/ids.rs
new file mode 100644
index 00000000..490e6051
--- /dev/null
+++ b/src/app/ids.rs
@@ -0,0 +1,82 @@
+use super::App;
+
+impl App {
+ pub(super) 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(super) fn public_workspace_id(&self, ws_idx: usize) -> String {
+ self.state.workspaces[ws_idx].id.clone()
+ }
+
+ pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ ws.tabs.get(tab_idx)?;
+ Some(format!("{}:{}", ws.id, tab_idx + 1))
+ }
+
+ pub(super) fn public_pane_id(
+ &self,
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<String> {
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane_number = ws.public_pane_number(pane_id)?;
+ Some(format!("{}-{pane_number}", ws.id))
+ }
+
+ pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> {
+ self.state
+ .workspaces
+ .iter()
+ .position(|workspace| workspace.id == id)
+ .or_else(|| id.strip_prefix("w_")?.parse::<usize>().ok()?.checked_sub(1))
+ .or_else(|| id.parse::<usize>().ok()?.checked_sub(1))
+ }
+
+ pub(super) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> {
+ if let Some(rest) = id.strip_prefix("t_") {
+ let (ws_raw, tab_raw) = rest.rsplit_once('_')?;
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?;
+ self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?;
+ return Some((ws_idx, tab_idx));
+ }
+
+ let (ws_raw, tab_raw) = id.rsplit_once(':')?;
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ let tab_idx = tab_raw.parse::<usize>().ok()?.checked_sub(1)?;
+ self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)?;
+ Some((ws_idx, tab_idx))
+ }
+
+ pub(super) fn parse_pane_id(&self, id: &str) -> Option<(usize, crate::layout::PaneId)> {
+ if let Some(rest) = id.strip_prefix("p_") {
+ if let Some((ws_raw, pane_raw)) = rest.rsplit_once('_') {
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ let pane_id = crate::layout::PaneId::from_raw(pane_raw.parse::<u32>().ok()?);
+ return Some((ws_idx, pane_id));
+ }
+
+ let pane_id = crate::layout::PaneId::from_raw(rest.parse::<u32>().ok()?);
+ return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id));
+ }
+
+ let (ws_raw, pane_number_raw) = id.rsplit_once('-')?;
+ let ws_idx = self.parse_workspace_id(ws_raw)?;
+ let pane_number = pane_number_raw.parse::<usize>().ok()?;
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane_id = ws
+ .public_pane_numbers
+ .iter()
+ .find_map(|(pane_id, number)| (*number == pane_number).then_some(*pane_id))?;
+ Some((ws_idx, pane_id))
+ }
+}
diff --git a/src/app/input.rs b/src/app/input.rs
deleted file mode 100644
index dbc2547d..00000000
--- a/src/app/input.rs
+++ /dev/null
@@ -1,5416 +0,0 @@
-//! Input handling — translates crossterm key/mouse events into state mutations.
-
-use bytes::Bytes;
-use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
-
-use crate::input::TerminalKey;
-use ratatui::layout::{Direction, Rect};
-use tracing::{debug, warn};
-
-use crate::layout::{NavDirection, PaneInfo, SplitBorder};
-use crate::selection::Selection;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ScrollbarClickTarget {
- Thumb { grab_row_offset: u16 },
- Track { offset_from_bottom: usize },
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-#[cfg(test)]
-enum WheelRouting {
- HostScroll,
- MouseReport,
- AlternateScroll,
-}
-
-const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
-const TAB_DRAG_THRESHOLD: u16 = 1;
-
-use super::state::{
- AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
- MenuListState, Mode, TabPressState, WorkspacePressState,
-};
-use super::App;
-
-// ---------------------------------------------------------------------------
-// Key handling
-// ---------------------------------------------------------------------------
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-struct AppKey {
- raw: KeyEvent,
- code: KeyCode,
- modifiers: crossterm::event::KeyModifiers,
-}
-
-impl AppKey {
- fn code(self) -> KeyCode {
- self.code
- }
-
- fn modifiers(self) -> crossterm::event::KeyModifiers {
- self.modifiers
- }
-
- fn raw_event(self) -> KeyEvent {
- self.raw
- }
-
- fn matches_binding(
- self,
- expected_code: KeyCode,
- expected_modifiers: crossterm::event::KeyModifiers,
- ) -> bool {
- let (expected_code, expected_modifiers) =
- crate::input::normalize_app_key_binding(expected_code, expected_modifiers, None);
- self.code == expected_code && self.modifiers == expected_modifiers
- }
-
- fn text_char(self) -> Option<char> {
- match self.code {
- KeyCode::Char(ch) if self.modifiers.is_empty() => Some(ch),
- _ => None,
- }
- }
-}
-
-impl From<TerminalKey> for AppKey {
- fn from(value: TerminalKey) -> Self {
- let raw = value.as_key_event();
- let (code, modifiers) = crate::input::normalize_app_key_binding(
- value.code,
- value.modifiers,
- value.shifted_codepoint,
- );
- Self {
- raw,
- code,
- modifiers,
- }
- }
-}
-
-impl From<KeyEvent> for AppKey {
- fn from(value: KeyEvent) -> Self {
- let (code, modifiers) =
- crate::input::normalize_app_key_binding(value.code, value.modifiers, None);
- Self {
- raw: value,
- code,
- modifiers,
- }
- }
-}
-
-fn is_modifier_only_key(code: &KeyCode) -> bool {
- matches!(code, KeyCode::Modifier(_))
-}
-
-fn terminal_direct_navigation_action(state: &AppState, key: &AppKey) -> Option<NavigateAction> {
- let kb = &state.keybinds;
- if kb
- .previous_workspace
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::PreviousWorkspace);
- }
- if kb
- .next_workspace
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::NextWorkspace);
- }
- if kb
- .previous_tab
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::PreviousTab);
- }
- if kb
- .next_tab
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::NextTab);
- }
- if kb
- .focus_pane_left
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::FocusPaneLeft);
- }
- if kb
- .focus_pane_down
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::FocusPaneDown);
- }
- if kb
- .focus_pane_up
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::FocusPaneUp);
- }
- if kb
- .focus_pane_right
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::FocusPaneRight);
- }
- None
-}
-
-impl App {
- pub(super) async fn handle_key(&mut self, key: TerminalKey) {
- match self.state.mode {
- Mode::Terminal => self.handle_terminal_key(key).await,
- Mode::Navigate => self.handle_navigate_key(key),
- _ => {
- let key = AppKey::from(key);
- match self.state.mode {
- Mode::Onboarding => self.handle_onboarding_key(key),
- Mode::ReleaseNotes => self.handle_release_notes_key(key),
- Mode::RenameWorkspace | Mode::RenameTab => {
- handle_rename_key(&mut self.state, key)
- }
- Mode::Resize => handle_resize_key(&mut self.state, key),
- Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
- Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
- Mode::Settings => self.handle_settings_key(key),
- Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key),
- Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key),
- Mode::Terminal | Mode::Navigate => unreachable!(),
- }
- }
- }
- }
-
- pub(super) async fn handle_paste(&mut self, text: String) {
- if self.state.mode != Mode::Terminal {
- return;
- }
- if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
- let _ = rt.send_paste(text).await;
- }
- }
- }
-
- fn handle_onboarding_key(&mut self, key: AppKey) {
- match self.state.onboarding_step {
- 0 => match key.code() {
- KeyCode::Right | KeyCode::Char('l') => {
- self.state.onboarding_step = 1;
- }
- _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) {
- Some(ModalAction::Continue) => self.state.onboarding_step = 1,
- _ => {}
- },
- },
- _ => match key.code() {
- KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(),
- KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4),
- KeyCode::Left | KeyCode::Char('h') => {
- self.state.onboarding_step = 0;
- }
- KeyCode::Char(c) if ('1'..='4').contains(&c) => {
- self.state
- .onboarding_list
- .select((c as usize) - ('1' as usize));
- }
- _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) {
- Some(ModalAction::Back) => self.state.onboarding_step = 0,
- Some(ModalAction::Save) => self.complete_onboarding(),
- _ => {}
- },
- },
- }
- }
-
- fn handle_release_notes_key(&mut self, key: AppKey) {
- match key.code() {
- KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
- KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
- KeyCode::PageUp => self.scroll_release_notes(-8),
- KeyCode::PageDown => self.scroll_release_notes(8),
- KeyCode::Home => {
- if let Some(notes) = &mut self.state.release_notes {
- notes.scroll = 0;
- }
- }
- KeyCode::End => {
- let max_scroll = self.state.release_notes_max_scroll();
- if let Some(notes) = &mut self.state.release_notes {
- notes.scroll = max_scroll;
- }
- }
- _ => match modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) {
- Some(ModalAction::Close) => self.dismiss_release_notes(),
- _ => {}
- },
- }
- }
-
- fn handle_settings_key(&mut self, key: AppKey) {
- if let Some(action) = update_settings_state(&mut self.state, key) {
- match action {
- SettingsAction::SaveTheme(name) => self.save_theme(&name),
- SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
- SettingsAction::SaveToast(enabled) => self.save_toast(enabled),
- }
- }
- }
-
- fn handle_navigate_key(&mut self, raw_key: TerminalKey) {
- let key = AppKey::from(raw_key);
- self.state.update_dismissed = true;
-
- if key.matches_binding(self.state.prefix_code, self.state.prefix_mods) {
- if !self.pass_through_key_to_focused_pane(raw_key) {
- leave_navigate_mode(&mut self.state);
- }
- return;
- }
-
- if key.code() == KeyCode::Esc {
- leave_navigate_mode(&mut self.state);
- return;
- }
-
- if let Some(action) = navigate_action_for_key(&self.state, &key) {
- execute_navigate_action(&mut self.state, action);
- return;
- }
-
- if handle_navigate_reserved_key(&mut self.state, key) {
- return;
- }
-
- if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) {
- self.launch_custom_command(binding);
- }
- }
-
- pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
- if self.state.mode == Mode::ReleaseNotes {
- match mouse.kind {
- MouseEventKind::Down(MouseButton::Left)
- if self
- .state
- .release_notes_close_button_at(mouse.column, mouse.row) =>
- {
- self.dismiss_release_notes();
- }
- MouseEventKind::Down(MouseButton::Left) => {
- if let Some(target) = self
- .state
- .release_notes_scrollbar_target_at(mouse.column, mouse.row)
- {
- match target {
- ScrollbarClickTarget::Thumb { grab_row_offset } => {
- self.state.drag = Some(DragState {
- target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
- });
- }
- ScrollbarClickTarget::Track { offset_from_bottom } => {
- self.state
- .set_release_notes_offset_from_bottom(offset_from_bottom);
- }
- }
- }
- }
- MouseEventKind::Drag(MouseButton::Left) => {
- if let Some(DragState {
- target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
- }) = &self.state.drag
- {
- if let Some(offset_from_bottom) = self
- .state
- .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset)
- {
- self.state
- .set_release_notes_offset_from_bottom(offset_from_bottom);
- }
- }
- }
- MouseEventKind::Up(MouseButton::Left) => {
- self.state.drag = None;
- }
- MouseEventKind::ScrollUp => self.scroll_release_notes(-3),
- MouseEventKind::ScrollDown => self.scroll_release_notes(3),
- _ => {}
- }
- return;
- }
-
- if self.state.mode == Mode::KeybindHelp {
- match mouse.kind {
- MouseEventKind::Down(MouseButton::Left)
- if self
- .state
- .keybind_help_close_button_at(mouse.column, mouse.row) =>
- {
- leave_modal(&mut self.state);
- }
- MouseEventKind::Down(MouseButton::Left) => {
- if let Some(target) = self
- .state
- .keybind_help_scrollbar_target_at(mouse.column, mouse.row)
- {
- match target {
- ScrollbarClickTarget::Thumb { grab_row_offset } => {
- self.state.drag = Some(DragState {
- target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
- });
- }
- ScrollbarClickTarget::Track { offset_from_bottom } => {
- self.state
- .set_keybind_help_offset_from_bottom(offset_from_bottom);
- }
- }
- } else {
- let rect = self.state.keybind_help_popup_rect();
- let inside = mouse.column >= rect.x
- && mouse.column < rect.x + rect.width
- && mouse.row >= rect.y
- && mouse.row < rect.y + rect.height;
- if !inside {
- leave_modal(&mut self.state);
- }
- }
- }
- MouseEventKind::Drag(MouseButton::Left) => {
- if let Some(DragState {
- target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
- }) = &self.state.drag
- {
- if let Some(offset_from_bottom) = self
- .state
- .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset)
- {
- self.state
- .set_keybind_help_offset_from_bottom(offset_from_bottom);
- }
- }
- }
- MouseEventKind::Up(MouseButton::Left) => {
- self.state.drag = None;
- }
- MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3),
- MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3),
- _ => {}
- }
- return;
- }
-
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
- && self.state.on_sidebar_divider(mouse.column, mouse.row)
- {
- let now = std::time::Instant::now();
- let is_double_click = self
- .last_sidebar_divider_click
- .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW);
- self.last_sidebar_divider_click = Some(now);
-
- if is_double_click {
- self.state.sidebar_width = self.state.default_sidebar_width;
- self.state.sidebar_width_auto = false;
- self.state.mark_session_dirty();
- self.state.drag = None;
- return;
- }
- }
-
- if let Some(action) = self.state.handle_mouse(mouse) {
- match action {
- SettingsAction::SaveTheme(name) => self.save_theme(&name),
- SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
- SettingsAction::SaveToast(enabled) => self.save_toast(enabled),
- }
- }
- }
-
- fn pass_through_key_to_focused_pane(&mut self, key: TerminalKey) -> bool {
- let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) else {
- return false;
- };
- let Some(rt) = ws.focused_runtime() else {
- return false;
- };
-
- let bytes = rt.encode_terminal_key(key);
- if bytes.is_empty() {
- return false;
- }
- if rt.try_send_bytes(Bytes::from(bytes)).is_err() {
- return false;
- }
-
- self.state.mode = Mode::Terminal;
- true
- }
-
- async fn handle_terminal_key(&mut self, key: TerminalKey) {
- self.state.clear_selection();
- self.state.update_dismissed = true;
-
- let app_key = AppKey::from(key);
- let key_event = app_key.raw_event();
-
- if let Some(action) = terminal_direct_navigation_action(&self.state, &app_key) {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- action = ?action,
- "intercepted terminal direct navigation key before forwarding to pane"
- );
- execute_navigate_action(&mut self.state, action);
- return;
- }
-
- if app_key.matches_binding(self.state.prefix_code, self.state.prefix_mods) {
- self.state.mode = Mode::Navigate;
- return;
- }
-
- if is_modifier_only_key(&key_event.code) {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- "dropping modifier-only terminal key event instead of forwarding it to pane"
- );
- return;
- }
-
- if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
- rt.scroll_reset();
- let protocol = rt.keyboard_protocol();
- let bytes = rt.encode_terminal_key(key);
- if matches!(key_event.code, KeyCode::Esc)
- || key_event
- .modifiers
- .contains(crossterm::event::KeyModifiers::ALT)
- {
- debug!(
- code = ?key_event.code,
- modifiers = ?key_event.modifiers,
- kind = ?key_event.kind,
- protocol = ?protocol,
- encoded = ?bytes,
- "forwarding potentially-ambiguous terminal key to pane"
- );
- }
- if bytes.is_empty() {
- if key.kind != crossterm::event::KeyEventKind::Release
- && !matches!(
- key.code,
- KeyCode::CapsLock
- | KeyCode::ScrollLock
- | KeyCode::NumLock
- | KeyCode::PrintScreen
- | KeyCode::Pause
- | KeyCode::Menu
- | KeyCode::KeypadBegin
- | KeyCode::Media(_)
- | KeyCode::Modifier(_)
- )
- {
- warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding");
- }
- } else {
- let _ = rt.send_bytes(Bytes::from(bytes)).await;
- }
- }
- }
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-enum SettingsAction {
- SaveTheme(String),
- SaveSound(bool),
- SaveToast(bool),
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ModalAction {
- Continue,
- Back,
- Save,
- Clear,
- Cancel,
- Confirm,
- Apply,
- Close,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum ModalKeyBinding {
- Enter,
- Esc,
- CtrlC,
-}
-
-impl ModalKeyBinding {
- fn matches(self, key: &AppKey) -> bool {
- match self {
- Self::Enter => key.code() == KeyCode::Enter,
- Self::Esc => key.code() == KeyCode::Esc,
- Self::CtrlC => {
- key.code() == KeyCode::Char('c')
- && key.modifiers() == crossterm::event::KeyModifiers::CONTROL
- }
- }
- }
-}
-
-#[derive(Debug, Clone, Copy)]
-struct ModalActionSpec<A> {
- action: A,
- bindings: &'static [ModalKeyBinding],
-}
-
-fn modal_action_from_key<A: Copy>(key: &AppKey, specs: &[ModalActionSpec<A>]) -> Option<A> {
- specs
- .iter()
- .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key)))
- .map(|spec| spec.action)
-}
-
-fn modal_action_from_buttons<A: Copy>(col: u16, row: u16, buttons: &[(Rect, A)]) -> Option<A> {
- buttons.iter().find_map(|(rect, action)| {
- (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height)
- .then_some(*action)
- })
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum GlobalMenuAction {
- Quit,
- WhatsNew,
- Keybinds,
- ReloadKeybinds,
- Settings,
-}
-
-fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
- let mut actions = vec![
- GlobalMenuAction::Settings,
- GlobalMenuAction::Keybinds,
- GlobalMenuAction::ReloadKeybinds,
- ];
- if state.latest_release_notes_available || state.update_available.is_some() {
- actions.push(GlobalMenuAction::WhatsNew);
- }
- actions.push(GlobalMenuAction::Quit);
- actions
-}
-
-fn normalize_theme_name(name: &str) -> String {
- name.to_lowercase().replace([' ', '_'], "-")
-}
-
-fn current_theme_index(theme_name: &str) -> usize {
- use crate::app::state::THEME_NAMES;
-
- let normalized = normalize_theme_name(theme_name);
- THEME_NAMES
- .iter()
- .position(|name| normalize_theme_name(name) == normalized)
- .unwrap_or(0)
-}
-
-fn preview_selected_theme(state: &mut AppState) {
- use crate::app::state::{Palette, THEME_NAMES};
-
- let name = THEME_NAMES[state.settings.list.selected];
- if let Some(palette) = Palette::from_name(name) {
- state.palette = palette;
- state.theme_name = name.to_string();
- }
-}
-
-fn cancel_settings(state: &mut AppState) {
- if let Some(palette) = state.settings.original_palette.take() {
- state.palette = palette;
- }
- if let Some(theme_name) = state.settings.original_theme.take() {
- state.theme_name = theme_name;
- }
- leave_modal(state);
-}
-
-fn apply_settings(state: &mut AppState) -> Option<SettingsAction> {
- match state.settings.section {
- crate::app::state::SettingsSection::Theme => {
- let theme_name = state.theme_name.clone();
- state.settings.original_palette = None;
- state.settings.original_theme = None;
- leave_modal(state);
- Some(SettingsAction::SaveTheme(theme_name))
- }
- _ => {
- leave_modal(state);
- None
- }
- }
-}
-
-fn update_settings_state(state: &mut AppState, key: impl Into<AppKey>) -> Option<SettingsAction> {
- use crate::app::state::SettingsSection;
-
- let key = key.into();
-
- match state.settings.section {
- SettingsSection::Theme => match key.code() {
- KeyCode::Up | KeyCode::Char('k') => {
- let previous = state.settings.list.selected;
- state.settings.list.move_prev();
- if state.settings.list.selected != previous {
- preview_selected_theme(state);
- }
- }
- KeyCode::Down | KeyCode::Char('j') => {
- let previous = state.settings.list.selected;
- state
- .settings
- .list
- .move_next(crate::app::state::THEME_NAMES.len());
- if state.settings.list.selected != previous {
- preview_selected_theme(state);
- }
- }
- KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
- state.settings.section = SettingsSection::Sound;
- state.settings.list.selected = usize::from(!state.sound_enabled());
- }
- _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) {
- Some(ModalAction::Apply) => return apply_settings(state),
- Some(ModalAction::Close) => cancel_settings(state),
- _ => {}
- },
- },
- SettingsSection::Sound => match key.code() {
- KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
- state.settings.list.selected = 1 - state.settings.list.selected.min(1);
- }
- KeyCode::Enter | KeyCode::Char(' ') => {
- let enabled = state.settings.list.selected == 0;
- state.sound.enabled = enabled;
- return Some(SettingsAction::SaveSound(enabled));
- }
- KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
- state.settings.section = SettingsSection::Toast;
- state.settings.list.selected = usize::from(!state.toast_config.enabled);
- }
- KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
- state.settings.section = SettingsSection::Theme;
- state.settings.list.selected = current_theme_index(&state.theme_name);
- }
- _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) {
- Some(ModalAction::Close) => cancel_settings(state),
- _ => {}
- },
- },
- SettingsSection::Toast => match key.code() {
- KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
- state.settings.list.selected = 1 - state.settings.list.selected.min(1);
- }
- KeyCode::Enter | KeyCode::Char(' ') => {
- let enabled = state.settings.list.selected == 0;
- state.toast_config.enabled = enabled;
- return Some(SettingsAction::SaveToast(enabled));
- }
- KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
- state.settings.section = SettingsSection::Sound;
- state.settings.list.selected = usize::from(!state.sound_enabled());
- }
- KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
- state.settings.section = SettingsSection::Theme;
- state.settings.list.selected = current_theme_index(&state.theme_name);
- }
- _ => match modal_action_from_key(&key, SETTINGS_ACTIONS) {
- Some(ModalAction::Close) => cancel_settings(state),
- _ => {}
- },
- },
- }
-
- None
-}
-
-fn open_global_menu(state: &mut AppState) {
- state.global_menu = MenuListState::new(0);
- state.mode = Mode::GlobalMenu;
-}
-
-fn open_keybind_help(state: &mut AppState) {
- state.keybind_help.scroll = 0;
- state.mode = Mode::KeybindHelp;
-}
-
-fn open_update_release_notes(state: &mut AppState) {
- let Some(notes) = crate::release_notes::load_latest() else {
- return;
- };
-
- state.release_notes = Some(crate::app::state::ReleaseNotesState {
- version: notes.version,
- body: notes.body,
- scroll: 0,
- preview: notes.preview,
- });
- state.mode = Mode::ReleaseNotes;
-}
-
-fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
- match action {
- GlobalMenuAction::Quit => state.should_quit = true,
- GlobalMenuAction::WhatsNew => open_update_release_notes(state),
- GlobalMenuAction::Keybinds => open_keybind_help(state),
- GlobalMenuAction::ReloadKeybinds => {
- state.request_reload_keybinds = true;
- leave_modal(state);
- }
- GlobalMenuAction::Settings => open_settings(state),
- }
-}
-
-fn handle_global_menu_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- let actions = global_menu_actions(state);
- match key.code() {
- KeyCode::Esc => leave_modal(state),
- KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
- KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()),
- KeyCode::Enter => {
- if let Some(action) = actions.get(state.global_menu.highlighted).copied() {
- apply_global_menu_action(state, action);
- }
- }
- _ => {}
- }
-}
-
-fn handle_keybind_help_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- match key.code() {
- KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1),
- KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1),
- KeyCode::PageUp => state.scroll_keybind_help(-8),
- KeyCode::PageDown => state.scroll_keybind_help(8),
- KeyCode::Home => state.keybind_help.scroll = 0,
- KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
- KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state),
- _ => {}
- }
-}
-
-fn navigate_custom_command_for_key(
- state: &AppState,
- key: &AppKey,
-) -> Option<crate::config::CustomCommandKeybind> {
- state
- .keybinds
- .custom_commands
- .iter()
- .find(|binding| key.matches_binding(binding.key.0, binding.key.1))
- .cloned()
-}
-
-fn handle_navigate_reserved_key(state: &mut AppState, key: impl Into<AppKey>) -> bool {
- let key = key.into();
- match key.code() {
- KeyCode::Char('q') => {
- state.should_quit = true;
- true
- }
- KeyCode::Enter => {
- if !state.workspaces.is_empty() {
- state.switch_workspace(state.selected);
- leave_navigate_mode(state);
- }
- true
- }
- KeyCode::Char(c @ '1'..='9') => {
- let idx = (c as usize) - ('1' as usize);
- if idx < state.workspaces.len() {
- state.switch_workspace(idx);
- leave_navigate_mode(state);
- }
- true
- }
- KeyCode::Char('s') => {
- open_settings(state);
- true
- }
- KeyCode::Char('?') => {
- open_keybind_help(state);
- true
- }
- KeyCode::Up => {
- if state.selected > 0 {
- state.selected -= 1;
- state.ensure_workspace_visible(state.selected);
- }
- true
- }
- KeyCode::Down => {
- if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 {
- state.selected += 1;
- state.ensure_workspace_visible(state.selected);
- }
- true
- }
- KeyCode::Char('h') | KeyCode::Left => {
- state.navigate_pane(NavDirection::Left);
- true
- }
- KeyCode::Char('j') => {
- state.navigate_pane(NavDirection::Down);
- true
- }
- KeyCode::Char('k') => {
- state.navigate_pane(NavDirection::Up);
- true
- }
- KeyCode::Char('l') | KeyCode::Right => {
- state.navigate_pane(NavDirection::Right);
- true
- }
- KeyCode::Tab => {
- state.cycle_pane(false);
- true
- }
- KeyCode::BackTab => {
- state.cycle_pane(true);
- true
- }
- _ => false,
- }
-}
-
-#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
-fn handle_navigate_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- state.update_dismissed = true;
-
- if key.matches_binding(state.prefix_code, state.prefix_mods) || key.code() == KeyCode::Esc {
- leave_navigate_mode(state);
- return;
- }
-
- if let Some(action) = navigate_action_for_key(state, &key) {
- execute_navigate_action(state, action);
- return;
- }
-
- let _ = handle_navigate_reserved_key(state, key);
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum NavigateAction {
- NewWorkspace,
- RenameWorkspace,
- CloseWorkspace,
- PreviousWorkspace,
- NextWorkspace,
- NewTab,
- RenameTab,
- PreviousTab,
- NextTab,
- CloseTab,
- FocusPaneLeft,
- FocusPaneDown,
- FocusPaneUp,
- FocusPaneRight,
- SplitVertical,
- SplitHorizontal,
- ClosePane,
- Fullscreen,
- EnterResizeMode,
- ToggleSidebar,
-}
-
-fn navigate_action_for_key(state: &AppState, key: &AppKey) -> Option<NavigateAction> {
- let kb = &state.keybinds;
- if key.matches_binding(kb.new_workspace.0, kb.new_workspace.1) {
- return Some(NavigateAction::NewWorkspace);
- }
- if key.matches_binding(kb.rename_workspace.0, kb.rename_workspace.1) {
- return Some(NavigateAction::RenameWorkspace);
- }
- if key.matches_binding(kb.close_workspace.0, kb.close_workspace.1) {
- return Some(NavigateAction::CloseWorkspace);
- }
- if kb
- .previous_workspace
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::PreviousWorkspace);
- }
- if kb
- .next_workspace
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::NextWorkspace);
- }
- if key.matches_binding(kb.new_tab.0, kb.new_tab.1) {
- return Some(NavigateAction::NewTab);
- }
- if kb
- .rename_tab
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::RenameTab);
- }
- if kb
- .previous_tab
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::PreviousTab);
- }
- if kb
- .next_tab
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::NextTab);
- }
- if kb
- .close_tab
- .is_some_and(|(code, mods)| key.matches_binding(code, mods))
- {
- return Some(NavigateAction::CloseTab);
- }
- if key.matches_binding(kb.split_vertical.0, kb.split_vertical.1) {
- return Some(NavigateAction::SplitVertical);
- }
- if key.matches_binding(kb.split_horizontal.0, kb.split_horizontal.1) {
- return Some(NavigateAction::SplitHorizontal);
- }
- if key.matches_binding(kb.close_pane.0, kb.close_pane.1) {
- return Some(NavigateAction::ClosePane);
- }
- if key.matches_binding(kb.fullscreen.0, kb.fullscreen.1) {
- return Some(NavigateAction::Fullscreen);
- }
- if key.matches_binding(kb.resize_mode.0, kb.resize_mode.1) {
- return Some(NavigateAction::EnterResizeMode);
- }
- if key.matches_binding(kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
- return Some(NavigateAction::ToggleSidebar);
- }
- None
-}
-
-fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
- match action {
- NavigateAction::NewWorkspace => {
- state.request_new_workspace = true;
- leave_navigate_mode(state);
- }
- NavigateAction::RenameWorkspace => {
- if !state.workspaces.is_empty() {
- open_rename_workspace(state, state.selected);
- }
- }
- NavigateAction::CloseWorkspace => {
- if !state.workspaces.is_empty() {
- if state.confirm_close {
- open_confirm_close(state);
- } else {
- state.close_selected_workspace();
- leave_navigate_mode(state);
- }
- }
- }
- NavigateAction::PreviousWorkspace => {
- state.previous_workspace();
- leave_navigate_mode(state);
- }
- NavigateAction::NextWorkspace => {
- state.next_workspace();
- leave_navigate_mode(state);
- }
- NavigateAction::NewTab => open_new_tab_dialog(state),
- NavigateAction::RenameTab => open_rename_active_tab(state, false),
- NavigateAction::PreviousTab => {
- state.previous_tab();
- leave_navigate_mode(state);
- }
- NavigateAction::NextTab => {
- state.next_tab();
- leave_navigate_mode(state);
- }
- NavigateAction::CloseTab => {
- state.close_tab();
- leave_navigate_mode(state);
- }
- NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left),
- NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down),
- NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up),
- NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right),
- NavigateAction::SplitVertical => {
- state.split_pane(Direction::Horizontal);
- leave_navigate_mode(state);
- }
- NavigateAction::SplitHorizontal => {
- state.split_pane(Direction::Vertical);
- leave_navigate_mode(state);
- }
- NavigateAction::ClosePane => {
- state.close_pane();
- leave_navigate_mode(state);
- }
- NavigateAction::Fullscreen => {
- state.toggle_fullscreen();
- leave_navigate_mode(state);
- }
- NavigateAction::EnterResizeMode => state.mode = Mode::Resize,
- NavigateAction::ToggleSidebar => {
- state.sidebar_collapsed = !state.sidebar_collapsed;
- leave_navigate_mode(state);
- }
- }
-}
-
-fn leave_navigate_mode(state: &mut AppState) {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- }
-}
-
-fn open_rename_workspace(state: &mut AppState, ws_idx: usize) {
- state.selected = ws_idx;
- state.name_input = state.workspaces[ws_idx].display_name();
- state.name_input_replace_on_type = false;
- state.mode = Mode::RenameWorkspace;
-}
-
-fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) {
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) {
- if let Some(name) = ws.active_tab_display_name() {
- state.name_input = name;
- state.name_input_replace_on_type = replace_on_type;
- state.mode = Mode::RenameTab;
- }
- }
-}
-
-fn next_new_tab_default_name(state: &AppState) -> String {
- state
- .active
- .and_then(|i| state.workspaces.get(i))
- .map(|ws| (ws.tabs.len() + 1).to_string())
- .unwrap_or_else(|| "1".to_string())
-}
-
-fn open_new_tab_dialog(state: &mut AppState) {
- state.creating_new_tab = true;
- state.requested_new_tab_name = None;
- state.name_input = next_new_tab_default_name(state);
- state.name_input_replace_on_type = true;
- state.mode = Mode::RenameTab;
-}
-
-/// Return to the appropriate mode after completing a modal action.
-/// Goes to Terminal if a workspace is active, otherwise Navigate.
-fn leave_modal(state: &mut AppState) {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- } else {
- state.mode = Mode::Navigate;
- }
-}
-
-const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
- action: ModalAction::Continue,
- bindings: &[ModalKeyBinding::Enter],
-}];
-
-const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Back,
- bindings: &[ModalKeyBinding::Esc],
- },
- ModalActionSpec {
- action: ModalAction::Save,
- bindings: &[ModalKeyBinding::Enter],
- },
-];
-
-const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
- action: ModalAction::Close,
- bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc],
-}];
-
-const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Save,
- bindings: &[ModalKeyBinding::Enter],
- },
- ModalActionSpec {
- action: ModalAction::Clear,
- bindings: &[ModalKeyBinding::CtrlC],
- },
- ModalActionSpec {
- action: ModalAction::Cancel,
- bindings: &[ModalKeyBinding::Esc],
- },
-];
-
-const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Confirm,
- bindings: &[ModalKeyBinding::Enter],
- },
- ModalActionSpec {
- action: ModalAction::Cancel,
- bindings: &[ModalKeyBinding::Esc],
- },
-];
-
-const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
- ModalActionSpec {
- action: ModalAction::Apply,
- bindings: &[ModalKeyBinding::Enter],
- },
- ModalActionSpec {
- action: ModalAction::Close,
- bindings: &[ModalKeyBinding::Esc],
- },
-];
-
-fn open_settings(state: &mut AppState) {
- use crate::app::state::SettingsSection;
-
- // Save current state for cancel
- state.settings.original_palette = Some(state.palette.clone());
- state.settings.original_theme = Some(state.theme_name.clone());
- state.settings.section = SettingsSection::Theme;
- state.settings.list.selected = current_theme_index(&state.theme_name);
- state.mode = Mode::Settings;
-}
-
-fn apply_rename_action(state: &mut AppState, action: ModalAction) {
- match action {
- ModalAction::Save => {
- let new_name = if state.name_input.trim().is_empty() {
- state.name_input.clone()
- } else {
- state.name_input.trim().to_string()
- };
- match state.mode {
- Mode::RenameWorkspace if !state.workspaces.is_empty() => {
- if !new_name.is_empty() {
- state.workspaces[state.selected].set_custom_name(new_name);
- state.mark_session_dirty();
- }
- }
- Mode::RenameTab if state.creating_new_tab => {
- state.request_new_tab = true;
- let default_name = next_new_tab_default_name(state);
- state.requested_new_tab_name =
- if new_name.is_empty() || new_name == default_name {
- None
- } else {
- Some(new_name)
- };
- }
- Mode::RenameTab => {
- if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) {
- if let Some(tab) = ws.active_tab_mut() {
- let keep_auto_name =
- tab.is_auto_named() && new_name == tab.number.to_string();
- if !new_name.is_empty() && !keep_auto_name {
- tab.set_custom_name(new_name);
- state.mark_session_dirty();
- }
- }
- }
- }
- _ => {}
- }
- state.creating_new_tab = false;
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- leave_modal(state);
- }
- ModalAction::Clear => {
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- }
- ModalAction::Cancel => {
- state.creating_new_tab = false;
- state.requested_new_tab_name = None;
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- leave_modal(state);
- }
- _ => {}
- }
-}
-
-fn handle_rename_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) {
- apply_rename_action(state, action);
- return;
- }
-
- match key.code() {
- KeyCode::Backspace => {
- if state.name_input_replace_on_type {
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- } else {
- state.name_input.pop();
- }
- }
- _ => {
- if let Some(c) = key.text_char() {
- if state.name_input_replace_on_type {
- state.name_input.clear();
- state.name_input_replace_on_type = false;
- }
- state.name_input.push(c);
- }
- }
- }
-}
-
-fn handle_resize_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- if key.code() == KeyCode::Esc
- || key.code() == KeyCode::Enter
- || key.matches_binding(state.keybinds.resize_mode.0, state.keybinds.resize_mode.1)
- {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- } else {
- state.mode = Mode::Navigate;
- }
- return;
- }
-
- match key.code() {
- KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left),
- KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right),
- KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down),
- KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up),
- _ => {}
- }
-}
-
-fn open_confirm_close(state: &mut AppState) {
- state.mode = Mode::ConfirmClose;
-}
-
-fn confirm_close_accept(state: &mut AppState) {
- state.close_selected_workspace();
- if state.workspaces.is_empty() {
- state.mode = Mode::Navigate;
- } else {
- state.mode = Mode::Terminal;
- }
-}
-
-fn confirm_close_cancel(state: &mut AppState) {
- state.mode = Mode::Navigate;
-}
-
-fn handle_confirm_close_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) {
- Some(ModalAction::Confirm) => confirm_close_accept(state),
- Some(ModalAction::Cancel) => confirm_close_cancel(state),
- _ => {}
- }
-}
-
-fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) {
- let item = menu.items().get(idx).copied();
- match (menu.kind, item) {
- (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
- open_rename_workspace(state, ws_idx);
- }
- (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => {
- state.selected = ws_idx;
- if state.confirm_close {
- open_confirm_close(state);
- } else {
- state.close_selected_workspace();
- state.mode = Mode::Navigate;
- }
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- open_new_tab_dialog(state);
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- open_rename_active_tab(state, false);
- }
- (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
- state.selected = ws_idx;
- state.active = Some(ws_idx);
- state.switch_tab(tab_idx);
- state.close_tab();
- state.mode = if state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- (ContextMenuKind::Pane, Some("Split vertical")) => {
- state.split_pane(Direction::Horizontal);
- state.mode = Mode::Terminal;
- }
- (ContextMenuKind::Pane, Some("Split horizontal")) => {
- state.split_pane(Direction::Vertical);
- state.mode = Mode::Terminal;
- }
- (ContextMenuKind::Pane, Some("Fullscreen")) => {
- state.toggle_fullscreen();
- state.mode = Mode::Terminal;
- }
- (ContextMenuKind::Pane, Some("Close pane")) => {
- state.close_pane();
- state.mode = if state.active.is_some() {
- Mode::Terminal
- } else {
- Mode::Navigate
- };
- }
- _ => leave_modal(state),
- }
-}
-
-fn handle_context_menu_key(state: &mut AppState, key: impl Into<AppKey>) {
- let key = key.into();
- match key.code() {
- KeyCode::Esc => {
- state.context_menu = None;
- leave_modal(state);
- }
- KeyCode::Up => {
- if let Some(menu) = &mut state.context_menu {
- menu.list.move_prev();
- }
- }
- KeyCode::Down => {
- if let Some(menu) = &mut state.context_menu {
- menu.list.move_next(menu.items().len());
- }
- }
- KeyCode::Enter => {
- if let Some(menu) = state.context_menu.take() {
- let idx = menu.list.highlighted;
- apply_context_menu_action(state, menu, idx);
- }
- }
- _ => {}
- }
-}
-
-// ---------------------------------------------------------------------------
-// Mouse handling
-// ---------------------------------------------------------------------------
-
-impl AppState {
- fn onboarding_full_area(&self) -> ratatui::layout::Rect {
- self.view.sidebar_rect.union(self.view.terminal_area)
- }
-
- fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<ratatui::layout::Rect> {
- let area = self.onboarding_full_area();
- let popup_w = popup_w.min(area.width.saturating_sub(4));
- let popup_h = popup_h.min(area.height.saturating_sub(2));
- if popup_w < 4 || popup_h < 4 {
- return None;
- }
- let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
- let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
- let popup = ratatui::layout::Rect::new(popup_x, popup_y, popup_w, popup_h);
- let block = ratatui::widgets::Block::default().borders(ratatui::widgets::Borders::ALL);
- Some(block.inner(popup))
- }
-
- fn release_notes_modal_inner(&self) -> Option<ratatui::layout::Rect> {
- self.onboarding_modal_inner(
- crate::ui::RELEASE_NOTES_MODAL_SIZE.0,
- crate::ui::RELEASE_NOTES_MODAL_SIZE.1,
- )
- }
-
- fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool {
- let Some(inner) = self.release_notes_modal_inner() else {
- return false;
- };
- if inner.height < 4 || inner.width < 12 {
- return false;
- }
- let button =
- crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
- col >= button.x
- && col < button.x + button.width
- && row >= button.y
- && row < button.y + button.height
- }
-
- fn rename_modal_inner(&self) -> Option<Rect> {
- self.onboarding_modal_inner(56, 7)
- }
-
- fn release_notes_body_rect(&self) -> Option<Rect> {
- let inner = self.release_notes_modal_inner()?;
- if inner.height < 8 || inner.width < 4 {
- return None;
- }
- let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content;
- let preview = self
- .release_notes
- .as_ref()
- .is_some_and(|notes| notes.preview);
- Some(crate::ui::release_notes_sections(body, preview).notes_body)
- }
-
- fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
- let notes = self.release_notes.as_ref()?;
- let body = self.release_notes_body_rect()?;
- let viewport_rows = body.height.max(1) as usize;
- let lines = crate::ui::release_notes_lines(&notes.body, &self.palette);
-
- let rows_for_width = |wrap_width: usize| {
- lines
- .iter()
- .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1)))
- .sum::<usize>()
- };
-
- let full_width = body.width.max(1) as usize;
- let mut total_rows = rows_for_width(full_width);
- let wrap_width = if total_rows > viewport_rows && full_width > 1 {
- body.width.saturating_sub(1).max(1) as usize
- } else {
- full_width
- };
- total_rows = rows_for_width(wrap_width);
-
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- Some(crate::pane::ScrollMetrics {
- offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize),
- max_offset_from_bottom,
- viewport_rows,
- })
- }
-
- pub(crate) fn release_notes_max_scroll(&self) -> u16 {
- self.release_notes_scroll_metrics()
- .map(|metrics| metrics.max_offset_from_bottom as u16)
- .unwrap_or(0)
- }
-
- fn release_notes_scrollbar_target_at(
- &self,
- col: u16,
- row: u16,
- ) -> Option<ScrollbarClickTarget> {
- let body = self.release_notes_body_rect()?;
- let metrics = self.release_notes_scroll_metrics()?;
- let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
- if !(col >= track.x
- && col < track.x + track.width
- && row >= track.y
- && row < track.y + track.height)
- {
- return None;
- }
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some(ScrollbarClickTarget::Thumb { grab_row_offset })
- } else {
- Some(ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- })
- }
- }
-
- fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
- let body = self.release_notes_body_rect()?;
- let metrics = self.release_notes_scroll_metrics()?;
- let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-
- fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) {
- let max_scroll = self.release_notes_max_scroll() as usize;
- if let Some(notes) = &mut self.release_notes {
- notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
- }
- }
-
- fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
- if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
- return;
- }
-
- match self.onboarding_step {
- 0 => {
- let Some(inner) = self.onboarding_modal_inner(64, 16) else {
- return;
- };
- let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1)
- .actions
- .unwrap_or_default();
- let button = crate::ui::onboarding_welcome_continue_rect(actions);
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
- && modal_action_from_buttons(
- mouse.column,
- mouse.row,
- &[(button, ModalAction::Continue)],
- ) == Some(ModalAction::Continue)
- {
- self.onboarding_step = 1;
- }
- }
- _ => {
- let Some(inner) = self.onboarding_modal_inner(56, 14) else {
- return;
- };
- let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1);
- if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 {
- self.onboarding_list
- .select((mouse.row - stack.content.y) as usize);
- return;
- }
-
- let (back, save) = crate::ui::onboarding_notification_button_rects(
- stack.actions.unwrap_or_default(),
- );
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
- match modal_action_from_buttons(
- mouse.column,
- mouse.row,
- &[(back, ModalAction::Back), (save, ModalAction::Save)],
- ) {
- Some(ModalAction::Back) => self.onboarding_step = 0,
- Some(ModalAction::Save) => self.request_complete_onboarding = true,
- _ => {}
- }
- }
- }
- }
- }
-
- fn workspace_list_rect(&self) -> Rect {
- let sidebar = self.view.sidebar_rect;
- if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
- return Rect::default();
- }
- crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split)
- }
-
- fn agent_panel_rect(&self) -> Rect {
- let sidebar = self.view.sidebar_rect;
- if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
- return Rect::default();
- }
- let (_, detail_area) =
- crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split);
- detail_area
- }
-
- fn workspace_list_scrollbar_target_at(
- &self,
- col: u16,
- row: u16,
- ) -> Option<ScrollbarClickTarget> {
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
- if col < track.x
- || col >= track.x + track.width
- || row < track.y
- || row >= track.y + track.height
- {
- return None;
- }
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some(ScrollbarClickTarget::Thumb { grab_row_offset })
- } else {
- Some(ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- })
- }
- }
-
- fn workspace_list_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-
- fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) {
- let area = self.workspace_list_rect();
- let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
- self.workspace_scroll = metrics
- .max_offset_from_bottom
- .saturating_sub(offset_from_bottom);
- }
-
- fn scroll_workspace_list(&mut self, delta: i16) {
- if delta.is_negative() {
- self.workspace_scroll = self
- .workspace_scroll
- .saturating_sub(delta.unsigned_abs() as usize);
- return;
- }
-
- for _ in 0..delta as usize {
- let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
- let Some(last) = cards.last() else {
- break;
- };
- if last.ws_idx + 1 >= self.workspaces.len() {
- break;
- }
- self.workspace_scroll = self.workspace_scroll.saturating_add(1);
- }
- }
-
- fn agent_panel_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> {
- let area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
- let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
- if col < track.x
- || col >= track.x + track.width
- || row < track.y
- || row >= track.y + track.height
- {
- return None;
- }
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some(ScrollbarClickTarget::Thumb { grab_row_offset })
- } else {
- Some(ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- })
- }
- }
-
- fn agent_panel_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
- let area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
- let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-
- fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) {
- let area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
- self.agent_panel_scroll = metrics
- .max_offset_from_bottom
- .saturating_sub(offset_from_bottom);
- }
-
- fn scroll_agent_panel(&mut self, delta: i16) {
- let area = self.agent_panel_rect();
- let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom;
- if delta.is_negative() {
- self.agent_panel_scroll = self
- .agent_panel_scroll
- .saturating_sub(delta.unsigned_abs() as usize);
- } else {
- self.agent_panel_scroll = self
- .agent_panel_scroll
- .saturating_add(delta as usize)
- .min(max_scroll);
- }
- }
-
- pub(crate) fn sidebar_footer_rect(&self) -> Rect {
- let ws_area = self.workspace_list_rect();
- if ws_area == Rect::default() {
- return Rect::default();
- }
- let y = ws_area.y + ws_area.height.saturating_sub(1);
- Rect::new(ws_area.x, y, ws_area.width, 1)
- }
-
- pub(crate) fn sidebar_new_button_rect(&self) -> Rect {
- let footer = self.sidebar_footer_rect();
- let width = 5u16.min(footer.width.max(1));
- Rect::new(footer.x, footer.y, width, footer.height)
- }
-
- pub(crate) fn global_launcher_rect(&self) -> Rect {
- let footer = self.sidebar_footer_rect();
- let width = if self.update_available.is_some() {
- 8
- } else {
- 6
- }
- .min(footer.width.max(1));
- let x = footer.x + footer.width.saturating_sub(width);
- Rect::new(x, footer.y, width, footer.height)
- }
-
- pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
- let mut labels = vec!["settings", "keybinds", "reload keybinds"];
- if self.update_available.is_some() {
- labels.push("update available");
- } else if self.latest_release_notes_available {
- labels.push("what's new");
- }
- labels.push("quit");
- labels
- }
-
- pub(crate) fn global_menu_rect(&self) -> Rect {
- let screen = self.screen_rect();
- let launcher = self.global_launcher_rect();
- let labels = self.global_menu_labels();
- let content_width = labels
- .iter()
- .map(|label| {
- let extra = if *label == "update available" { 2 } else { 0 };
- label.chars().count() as u16 + extra
- })
- .max()
- .unwrap_or(8)
- .saturating_add(2);
- let menu_w = content_width.saturating_add(2).min(screen.width.max(1));
- let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1));
- let max_x = screen.x + screen.width.saturating_sub(menu_w);
- let desired_x = launcher.x + launcher.width.saturating_sub(menu_w);
- let x = desired_x.min(max_x);
- let y = launcher.y.saturating_sub(menu_h);
- Rect::new(x, y, menu_w, menu_h)
- }
-
- fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> {
- let rect = self.global_menu_rect();
- if col <= rect.x
- || col >= rect.x + rect.width.saturating_sub(1)
- || row <= rect.y
- || row >= rect.y + rect.height.saturating_sub(1)
- {
- return None;
- }
- let idx = (row - rect.y - 1) as usize;
- global_menu_actions(self).get(idx).copied()
- }
-
- pub(crate) fn keybind_help_popup_rect(&self) -> Rect {
- crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default()
- }
-
- fn keybind_help_modal_inner(&self) -> Option<Rect> {
- self.onboarding_modal_inner(76, 22)
- }
-
- fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool {
- let Some(inner) = self.keybind_help_modal_inner() else {
- return false;
- };
- if inner.height < 4 || inner.width < 12 {
- return false;
- }
- let button =
- crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
- col >= button.x
- && col < button.x + button.width
- && row >= button.y
- && row < button.y + button.height
- }
-
- fn keybind_help_body_rect(&self) -> Option<Rect> {
- let inner = self.keybind_help_modal_inner()?;
- if inner.height < 6 || inner.width < 4 {
- return None;
- }
- Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
- }
-
- fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
- let body = self.keybind_help_body_rect()?;
- let viewport_rows = body.height.max(1) as usize;
- let wrap_width = body.width.max(1) as usize;
- let total_rows = crate::ui::keybind_help_lines(self)
- .into_iter()
- .map(|(width, _)| width.max(1).div_ceil(wrap_width))
- .sum::<usize>();
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- Some(crate::pane::ScrollMetrics {
- offset_from_bottom: max_offset_from_bottom
- .saturating_sub(self.keybind_help.scroll as usize),
- max_offset_from_bottom,
- viewport_rows,
- })
- }
-
- fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> {
- let body = self.keybind_help_body_rect()?;
- let metrics = self.keybind_help_scroll_metrics()?;
- let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
- if !(col >= track.x
- && col < track.x + track.width
- && row >= track.y
- && row < track.y + track.height)
- {
- return None;
- }
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some(ScrollbarClickTarget::Thumb { grab_row_offset })
- } else {
- Some(ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- })
- }
- }
-
- fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
- let body = self.keybind_help_body_rect()?;
- let metrics = self.keybind_help_scroll_metrics()?;
- let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-
- pub(crate) fn keybind_help_max_scroll(&self) -> u16 {
- self.keybind_help_scroll_metrics()
- .map(|metrics| metrics.max_offset_from_bottom as u16)
- .unwrap_or(0)
- }
-
- fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) {
- let max_scroll = self.keybind_help_max_scroll() as usize;
- self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
- }
-
- fn scroll_keybind_help(&mut self, delta: i16) {
- let max_scroll = self.keybind_help_max_scroll();
- let current = self.keybind_help.scroll as i16;
- self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16;
- }
-
- fn settings_popup_rect(&self) -> Rect {
- crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default()
- }
-
- fn settings_inner_rect(&self) -> Rect {
- let popup = self.settings_popup_rect();
- Rect::new(
- popup.x + 1,
- popup.y + 1,
- popup.width.saturating_sub(2),
- popup.height.saturating_sub(2),
- )
- }
-
- fn settings_tab_at(&self, col: u16, row: u16) -> Option<crate::app::state::SettingsSection> {
- use crate::app::state::SettingsSection;
-
- let inner = self.settings_inner_rect();
- let tab_y = inner.y + 1;
- if row != tab_y {
- return None;
- }
- let mut x = inner.x;
- for section in SettingsSection::ALL {
- let width = section.label().len() as u16 + 2;
- if col >= x && col < x + width {
- return Some(*section);
- }
- x += width + 1;
- }
- None
- }
-
- fn settings_content_rect(&self) -> Rect {
- let inner = self.settings_inner_rect();
- crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content
- }
-
- fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> {
- let area = self.settings_content_rect();
- if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width
- {
- return None;
- }
-
- match self.settings.section {
- crate::app::state::SettingsSection::Theme => {
- let max_visible = area.height as usize;
- let scroll = if self.settings.list.selected >= max_visible {
- self.settings.list.selected - max_visible + 1
- } else {
- 0
- };
- let idx = scroll + (row - area.y) as usize;
- (idx < crate::app::state::THEME_NAMES.len()).then_some(idx)
- }
- crate::app::state::SettingsSection::Sound
- | crate::app::state::SettingsSection::Toast => {
- let list_y = area.y + 2;
- if row >= list_y && row < list_y + 2 {
- Some((row - list_y) as usize)
- } else {
- None
- }
- }
- }
- }
-
- fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
- use crate::app::state::SettingsSection;
-
- match mouse.kind {
- MouseEventKind::Down(MouseButton::Left) => {
- if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) {
- self.settings.section = section;
- self.settings.list.select(match section {
- SettingsSection::Theme => current_theme_index(&self.theme_name),
- SettingsSection::Sound => usize::from(!self.sound_enabled()),
- SettingsSection::Toast => usize::from(!self.toast_config.enabled),
- });
- return None;
- }
- if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) {
- self.settings.list.select(idx);
- return match self.settings.section {
- SettingsSection::Theme => {
- preview_selected_theme(self);
- None
- }
- SettingsSection::Sound => {
- let enabled = idx == 0;
- self.sound.enabled = enabled;
- Some(SettingsAction::SaveSound(enabled))
- }
- SettingsSection::Toast => {
- let enabled = idx == 0;
- self.toast_config.enabled = enabled;
- Some(SettingsAction::SaveToast(enabled))
- }
- };
- }
-
- let inner = self.settings_inner_rect();
- let (apply, close) = crate::ui::settings_button_rects(inner);
- match modal_action_from_buttons(
- mouse.column,
- mouse.row,
- &[(apply, ModalAction::Apply), (close, ModalAction::Close)],
- ) {
- Some(ModalAction::Apply) => apply_settings(self),
- Some(ModalAction::Close) => {
- cancel_settings(self);
- None
- }
- _ => {
- cancel_settings(self);
- None
- }
- }
- }
- _ => None,
- }
- }
-
- fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
- if self.mode == Mode::Onboarding {
- self.handle_onboarding_mouse(mouse);
- return None;
- }
-
- if self.mode == Mode::Settings {
- return self.handle_settings_mouse(mouse);
- }
-
- let launcher_enabled = !self.sidebar_collapsed
- && matches!(
- self.mode,
- Mode::Terminal
- | Mode::Navigate
- | Mode::Resize
- | Mode::GlobalMenu
- | Mode::KeybindHelp
- );
- let launcher = self.global_launcher_rect();
- let launcher_hit = launcher_enabled
- && mouse.column >= launcher.x
- && mouse.column < launcher.x + launcher.width
- && mouse.row >= launcher.y
- && mouse.row < launcher.y + launcher.height;
-
- if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
- let actions = global_menu_actions(self);
- let hovered = self
- .global_menu_item_at(mouse.column, mouse.row)
- .and_then(|action| actions.iter().position(|item| *item == action));
- self.global_menu.hover(hovered);
- return None;
- }
-
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit {
- if self.mode == Mode::GlobalMenu {
- leave_modal(self);
- } else {
- open_global_menu(self);
- }
- return None;
- }
-
- if self.mode == Mode::GlobalMenu {
- if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
- if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) {
- apply_global_menu_action(self, action);
- } else {
- leave_modal(self);
- }
- }
- return None;
- }
-
- if self.mode == Mode::KeybindHelp {
- return None;
- }
-
- let sidebar = self.view.sidebar_rect;
- let in_sidebar = mouse.column >= sidebar.x
- && mouse.column < sidebar.x + sidebar.width
- && mouse.row >= sidebar.y
- && mouse.row < sidebar.y + sidebar.height;
-
- match mouse.kind {
- MouseEventKind::Down(MouseButton::Left) => {
- self.selection = None;
- self.workspace_press = None;
-
- if self.mode == Mode::ConfirmClose {
- let popup = self.confirm_close_rect();
- let inner = Rect::new(
- popup.x + 1,
- popup.y + 1,
- popup.width.saturating_sub(2),
- popup.height.saturating_sub(2),
- );
- let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner);
- match modal_action_from_buttons(
- mouse.column,
- mouse.row,
- &[
- (confirm, ModalAction::Confirm),
- (cancel, ModalAction::Cancel),
- ],
- ) {
- Some(ModalAction::Confirm) => confirm_close_accept(self),
- Some(ModalAction::Cancel) | None => confirm_close_cancel(self),
- _ => {}
- }
- return None;
- }
-
- if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) {
- let action = self
- .rename_modal_inner()
- .map(crate::ui::rename_button_rects)
- .and_then(|(save, clear, cancel)| {
- modal_action_from_buttons(
- mouse.column,
- mouse.row,
- &[
- (save, ModalAction::Save),
- (clear, ModalAction::Clear),
- (cancel, ModalAction::Cancel),
- ],
- )
- })
- .unwrap_or(ModalAction::Cancel);
- apply_rename_action(self, action);
- return None;
- }
-
- if self.mode == Mode::ContextMenu {
- let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
- if let Some(menu) = self.context_menu.take() {
- if let Some(idx) = item_idx {
- apply_context_menu_action(self, menu, idx);
- } else {
- leave_modal(self);
- }
- }
- return None;
- }
-
- if self.on_sidebar_divider(mouse.column, mouse.row) {
- self.drag = Some(DragState {
- target: DragTarget::SidebarDivider,
- });
- self.set_manual_sidebar_width(mouse.column);
- return None;
- }
-
- if self.on_sidebar_section_divider(mouse.column, mouse.row) {
- self.drag = Some(DragState {
- target: DragTarget::SidebarSectionDivider,
- });
- self.set_sidebar_section_split(mouse.row);
- return None;
- }
-
- if !in_sidebar {
- if let Some(border) = self.find_border_at(mouse.column, mouse.row) {
- self.drag = Some(DragState {
- target: DragTarget::PaneSplit {
- path: border.path.clone(),
- direction: border.direction,
- area: border.area,
- },
- });
- return None;
- }
-
- if let Some((pane_id, target)) =
- self.scrollbar_target_at(mouse.column, mouse.row)
- {
- self.focus_pane(pane_id);
- match target {
- ScrollbarClickTarget::Thumb { grab_row_offset } => {
- self.drag = Some(DragState {
- target: DragTarget::PaneScrollbar {
- pane_id,
- grab_row_offset,
- },
- });
- }
- ScrollbarClickTarget::Track { offset_from_bottom } => {
- self.set_pane_scroll_offset(pane_id, offset_from_bottom);
- }
- }
- if self.mode != Mode::Terminal {
- self.mode = Mode::Terminal;
- }
- return None;
- }
- }
-
- if self.on_tab_scroll_left_button(mouse.column, mouse.row) {
- self.scroll_tabs_left();
- return None;
- }
- if self.on_tab_scroll_right_button(mouse.column, mouse.row) {
- self.scroll_tabs_right();
- return None;
- }
- if let (Some(ws_idx), Some(tab_idx)) =
- (self.active, self.tab_at(mouse.column, mouse.row))
- {
- self.tab_press = Some(TabPressState {
- ws_idx,
- tab_idx,
- start_col: mouse.column,
- start_row: mouse.row,
- });
- return None;
- }
- if self.on_new_tab_button(mouse.column, mouse.row) {
- open_new_tab_dialog(self);
- return None;
- }
-
- if in_sidebar {
- if self.sidebar_collapsed {
- if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) {
- self.sidebar_collapsed = false;
- return None;
- }
-
- if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) {
- self.switch_workspace(idx);
- self.mode = Mode::Terminal;
- return None;
- }
-
- if let Some((ws_idx, tab_idx, pane_id)) =
- self.collapsed_agent_detail_target_at(mouse.row)
- {
- self.switch_workspace(ws_idx);
- self.switch_tab(tab_idx);
- self.focus_pane(pane_id);
- self.mode = Mode::Terminal;
- }
- return None;
- }
-
- let new_button = self.sidebar_new_button_rect();
- let on_new_button = mouse.row >= new_button.y
- && mouse.row < new_button.y + new_button.height
- && mouse.column >= new_button.x
- && mouse.column < new_button.x + new_button.width;
- if on_new_button {
- self.request_new_workspace = true;
- return None;
- }
-
- if let Some(target) =
- self.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
- {
- match target {
- ScrollbarClickTarget::Thumb { grab_row_offset } => {
- self.drag = Some(DragState {
- target: DragTarget::WorkspaceListScrollbar { grab_row_offset },
- });
- }
- ScrollbarClickTarget::Track { offset_from_bottom } => {
- self.set_workspace_list_offset_from_bottom(offset_from_bottom);
- }
- }
- return None;
- }
-
- if let Some(idx) = self.workspace_at_row(mouse.row) {
- self.workspace_press = Some(WorkspacePressState {
- ws_idx: idx,
- start_col: mouse.column,
- start_row: mouse.row,
- });
- return None;
- }
-
- if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) {
- self.agent_panel_scope = match self.agent_panel_scope {
- AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
- AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
- };
- self.agent_panel_scroll = 0;
- self.mark_session_dirty();
- return None;
- }
-
- if let Some(target) =
- self.agent_panel_scrollbar_target_at(mouse.column, mouse.row)
- {
- match target {
- ScrollbarClickTarget::Thumb { grab_row_offset } => {
- self.drag = Some(DragState {
- target: DragTarget::AgentPanelScrollbar { grab_row_offset },
- });
- }
- ScrollbarClickTarget::Track { offset_from_bottom } => {
- self.set_agent_panel_offset_from_bottom(offset_from_bottom);
- }
- }
- return None;
- }
-
- if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
- {
- self.switch_workspace(ws_idx);
- self.switch_tab(tab_idx);
- self.focus_pane(pane_id);
- self.mode = Mode::Terminal;
- return None;
- }
- } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- if self.mode != Mode::Terminal {
- self.mode = Mode::Terminal;
- }
-
- if self.forward_pane_mouse_button(&info, mouse) {
- self.selection = None;
- return None;
- }
-
- let (row, col) = (
- mouse.row - info.inner_rect.y,
- mouse.column - info.inner_rect.x,
- );
- self.selection = Some(Selection::anchor(
- info.id,
- row,
- col,
- self.pane_scroll_metrics(info.id),
- ));
- } else if let Some(info) = self.view.pane_infos.iter().find(|p| {
- mouse.column >= p.rect.x
- && mouse.column < p.rect.x + p.rect.width
- && mouse.row >= p.rect.y
- && mouse.row < p.rect.y + p.rect.height
- }) {
- let id = info.id;
- self.focus_pane(id);
- if self.mode != Mode::Terminal {
- self.mode = Mode::Terminal;
- }
- }
- }
-
- MouseEventKind::Drag(MouseButton::Left) => {
- if self.selection.is_some() {
- self.update_selection_drag(mouse.column, mouse.row);
- return None;
- }
-
- if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- if self.forward_pane_mouse_button(&info, mouse) {
- self.selection = None;
- return None;
- }
- }
-
- let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row);
- let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row);
- if self.drag.is_none() {
- if let Some(press) = &self.workspace_press {
- let delta_col = mouse.column.abs_diff(press.start_col);
- let delta_row = mouse.row.abs_diff(press.start_row);
- if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
- self.drag = Some(DragState {
- target: DragTarget::WorkspaceReorder {
- source_ws_idx: press.ws_idx,
- insert_idx: workspace_drop_index,
- },
- });
- }
- } else if let Some(press) = &self.tab_press {
- let delta_col = mouse.column.abs_diff(press.start_col);
- let delta_row = mouse.row.abs_diff(press.start_row);
- if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD {
- self.drag = Some(DragState {
- target: DragTarget::TabReorder {
- ws_idx: press.ws_idx,
- source_tab_idx: press.tab_idx,
- insert_idx: tab_drop_index,
- },
- });
- }
- }
- }
-
- if let Some(DragState {
- target: DragTarget::WorkspaceReorder { insert_idx, .. },
- }) = &mut self.drag
- {
- *insert_idx = workspace_drop_index;
- } else if let Some(DragState {
- target:
- DragTarget::TabReorder {
- ws_idx, insert_idx, ..
- },
- }) = &mut self.drag
- {
- if self.active == Some(*ws_idx) {
- *insert_idx = tab_drop_index;
- }
- } else if let Some(drag) = &self.drag {
- match &drag.target {
- DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {}
- DragTarget::WorkspaceListScrollbar { grab_row_offset } => {
- if let Some(offset_from_bottom) =
- self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset)
- {
- self.set_workspace_list_offset_from_bottom(offset_from_bottom);
- }
- }
- DragTarget::AgentPanelScrollbar { grab_row_offset } => {
- if let Some(offset_from_bottom) =
- self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset)
- {
- self.set_agent_panel_offset_from_bottom(offset_from_bottom);
- }
- }
- DragTarget::PaneSplit {
- path,
- direction,
- area,
- } => {
- let ratio = match direction {
- Direction::Horizontal => {
- (mouse.column.saturating_sub(area.x)) as f32
- / area.width.max(1) as f32
- }
- Direction::Vertical => {
- (mouse.row.saturating_sub(area.y)) as f32
- / area.height.max(1) as f32
- }
- };
- let ratio = ratio.clamp(0.1, 0.9);
- let path = path.clone();
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- ws.layout.set_ratio_at(&path, ratio);
- self.mark_session_dirty();
- }
- }
- DragTarget::PaneScrollbar {
- pane_id,
- grab_row_offset,
- } => {
- if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
- *pane_id,
- mouse.row,
- *grab_row_offset,
- ) {
- self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
- }
- }
- DragTarget::SidebarDivider => {
- self.set_manual_sidebar_width(mouse.column);
- }
- DragTarget::SidebarSectionDivider => {
- self.set_sidebar_section_split(mouse.row);
- }
- DragTarget::ReleaseNotesScrollbar { .. }
- | DragTarget::KeybindHelpScrollbar { .. } => {}
- }
- }
- }
-
- MouseEventKind::Up(MouseButton::Left) => {
- if self.selection.is_some() {
- self.workspace_press = None;
- self.tab_press = None;
- self.drag = None;
- let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
- if was_click {
- self.selection = None;
- } else {
- self.copy_selection();
- }
- return None;
- }
-
- if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- if self.forward_pane_mouse_button(&info, mouse) {
- self.selection = None;
- self.workspace_press = None;
- self.tab_press = None;
- self.drag = None;
- return None;
- }
- }
-
- let workspace_press = self.workspace_press.take();
- let tab_press = self.tab_press.take();
- match self.drag.take() {
- Some(DragState {
- target:
- DragTarget::WorkspaceReorder {
- source_ws_idx,
- insert_idx: Some(insert_idx),
- },
- }) => {
- self.move_workspace(source_ws_idx, insert_idx);
- }
- Some(DragState {
- target:
- DragTarget::TabReorder {
- ws_idx,
- source_tab_idx,
- insert_idx: Some(insert_idx),
- },
- }) => {
- if self.active == Some(ws_idx) {
- self.move_tab(source_tab_idx, insert_idx);
- self.mode = Mode::Terminal;
- }
- }
- Some(_) => {}
- None => {
- if let Some(press) = workspace_press {
- self.switch_workspace(press.ws_idx);
- self.mode = Mode::Terminal;
- return None;
- }
- if let Some(press) = tab_press {
- if self.active == Some(press.ws_idx) {
- self.switch_tab(press.tab_idx);
- self.mode = Mode::Terminal;
- return None;
- }
- }
- let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
- if was_click {
- self.selection = None;
- } else {
- self.copy_selection();
- }
- }
- }
- }
-
- MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle)
- if !in_sidebar =>
- {
- if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- let _ = self.forward_pane_mouse_button(&info, mouse);
- }
- }
-
- MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
- if self.on_tab_bar(mouse.column, mouse.row) {
- match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_tabs_left(),
- MouseEventKind::ScrollDown => self.scroll_tabs_right(),
- _ => {}
- }
- } else if !self.scroll_selection_with_wheel(mouse) {
- self.selection = None;
- self.handle_terminal_wheel(mouse);
- }
- }
-
- MouseEventKind::ScrollUp if in_sidebar => {
- let agent_area = self.agent_panel_rect();
- let over_agent_panel = agent_area != Rect::default()
- && mouse.row >= agent_area.y
- && mouse.row < agent_area.y + agent_area.height;
- if over_agent_panel {
- if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
- self, agent_area,
- )) {
- self.scroll_agent_panel(-1);
- }
- } else if crate::ui::should_show_scrollbar(
- crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
- ) {
- self.scroll_workspace_list(-1);
- } else if self.selected > 0 {
- self.selected -= 1;
- self.ensure_workspace_visible(self.selected);
- }
- }
- MouseEventKind::ScrollDown if in_sidebar => {
- let agent_area = self.agent_panel_rect();
- let over_agent_panel = agent_area != Rect::default()
- && mouse.row >= agent_area.y
- && mouse.row < agent_area.y + agent_area.height;
- if over_agent_panel {
- if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
- self, agent_area,
- )) {
- self.scroll_agent_panel(1);
- }
- } else if crate::ui::should_show_scrollbar(
- crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
- ) {
- self.scroll_workspace_list(1);
- } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
- self.selected += 1;
- self.ensure_workspace_visible(self.selected);
- }
- }
-
- MouseEventKind::Moved if self.mode == Mode::ContextMenu => {
- let hovered = self.context_menu_item_at(mouse.column, mouse.row);
- if let Some(menu) = &mut self.context_menu {
- menu.list.hover(hovered);
- }
- }
-
- MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
- if self
- .workspace_list_scrollbar_target_at(mouse.column, mouse.row)
- .is_some()
- {
- return None;
- }
- if let Some(idx) = self.workspace_at_row(mouse.row) {
- self.selected = idx;
- self.context_menu = Some(ContextMenuState {
- kind: ContextMenuKind::Workspace { ws_idx: idx },
- x: mouse.column,
- y: mouse.row,
- list: MenuListState::new(0),
- });
- self.mode = Mode::ContextMenu;
- }
- }
-
- MouseEventKind::Down(MouseButton::Right)
- if self.tab_at(mouse.column, mouse.row).is_some() =>
- {
- if let (Some(ws_idx), Some(tab_idx)) =
- (self.active, self.tab_at(mouse.column, mouse.row))
- {
- self.switch_tab(tab_idx);
- self.context_menu = Some(ContextMenuState {
- kind: ContextMenuKind::Tab { ws_idx, tab_idx },
- x: mouse.column,
- y: mouse.row,
- list: MenuListState::new(0),
- });
- self.mode = Mode::ContextMenu;
- }
- }
-
- MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
- if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- self.context_menu = Some(ContextMenuState {
- kind: ContextMenuKind::Pane,
- x: mouse.column,
- y: mouse.row,
- list: MenuListState::new(0),
- });
- self.mode = Mode::ContextMenu;
- }
- }
-
- _ => {}
- }
-
- None
- }
-
- fn on_sidebar_divider(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed {
- return false;
- }
- let sidebar = self.view.sidebar_rect;
- sidebar.width > 0
- && col == sidebar.x + sidebar.width.saturating_sub(1)
- && row >= sidebar.y
- && row < sidebar.y + sidebar.height
- }
-
- fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool {
- if !self.sidebar_collapsed {
- return false;
- }
- let rect = crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect);
- rect.width > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
- }
-
- fn set_manual_sidebar_width(&mut self, divider_col: u16) {
- let sidebar = self.view.sidebar_rect;
- let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
- self.sidebar_width =
- width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
- self.mark_session_dirty();
- }
-
- fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed {
- return false;
- }
- let rect = crate::ui::sidebar_section_divider_rect(
- self.view.sidebar_rect,
- self.sidebar_section_split,
- );
- rect.width > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
- }
-
- fn set_sidebar_section_split(&mut self, row: u16) {
- let sidebar = self.view.sidebar_rect;
- let content_height = sidebar.height;
- if content_height < 6 {
- return;
- }
- let relative_y = row.saturating_sub(sidebar.y);
- let ratio = (relative_y as f32) / (content_height as f32);
- self.sidebar_section_split = ratio.clamp(0.1, 0.9);
- self.mark_session_dirty();
- }
-
- /// Find which workspace index a sidebar row belongs to (two-section layout).
- fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
- self.view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find_map(|(idx, area)| {
- (area.width > 0
- && row >= area.y
- && row < area.y + area.height
- && col >= area.x
- && col < area.x + area.width)
- .then_some(idx)
- })
- }
-
- fn on_tab_bar(&self, col: u16, row: u16) -> bool {
- let area = self.view.tab_bar_rect;
- area.width > 0
- && row >= area.y
- && row < area.y + area.height
- && col >= area.x
- && col < area.x + area.width
- }
-
- fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool {
- let area = self.view.tab_scroll_left_hit_area;
- area.width > 0
- && row >= area.y
- && row < area.y + area.height
- && col >= area.x
- && col < area.x + area.width
- }
-
- fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool {
- let area = self.view.tab_scroll_right_hit_area;
- area.width > 0
- && row >= area.y
- && row < area.y + area.height
- && col >= area.x
- && col < area.x + area.width
- }
-
- fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> {
- if !self.on_tab_bar(col, row) {
- return None;
- }
-
- let visible_tabs: Vec<_> = self
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .filter(|(_, rect)| rect.width > 0)
- .collect();
- let (first_idx, first_rect) = *visible_tabs.first()?;
- let (last_idx, last_rect) = *visible_tabs.last()?;
-
- if self.on_tab_scroll_left_button(col, row) {
- return Some(0);
- }
- if self.on_tab_scroll_right_button(col, row) {
- return self
- .active
- .and_then(|idx| self.workspaces.get(idx))
- .map(|ws| ws.tabs.len());
- }
-
- let left_edge = if first_idx == 0 {
- first_rect.x
- } else {
- self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width
- };
- let right_edge = if self
- .active
- .and_then(|idx| self.workspaces.get(idx))
- .is_some_and(|ws| last_idx + 1 >= ws.tabs.len())
- {
- last_rect.x + last_rect.width
- } else {
- self.view.tab_scroll_right_hit_area.x.saturating_sub(1)
- };
-
- if col <= left_edge {
- return Some(first_idx);
- }
- if col >= right_edge {
- return Some(last_idx + 1);
- }
-
- for (idx, rect) in visible_tabs {
- let midpoint = rect.x + rect.width / 2;
- if col < midpoint {
- return Some(idx);
- }
- if col < rect.x + rect.width {
- return Some(idx + 1);
- }
- }
-
- Some(last_idx + 1)
- }
-
- fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
- let area = self.view.new_tab_hit_area;
- area.width > 0
- && row >= area.y
- && row < area.y + area.height
- && col >= area.x
- && col < area.x + area.width
- }
-
- fn workspace_at_row(&self, row: u16) -> Option<usize> {
- let footer = self.sidebar_footer_rect();
- if footer == Rect::default() {
- return None;
- }
-
- let cards = if self.view.workspace_card_areas.is_empty() {
- crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
- } else {
- self.view.workspace_card_areas.clone()
- };
-
- cards.iter().find_map(|card| {
- (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx)
- })
- }
-
- fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> {
- if !self.sidebar_collapsed {
- return None;
- }
-
- let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
- if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height {
- return None;
- }
-
- let idx = (row - ws_area.y) as usize;
- (idx < self.workspaces.len()).then_some(idx)
- }
-
- fn collapsed_detail_workspace_idx(&self) -> Option<usize> {
- if matches!(
- self.mode,
- Mode::Navigate
- | Mode::RenameWorkspace
- | Mode::Resize
- | Mode::ConfirmClose
- | Mode::ContextMenu
- | Mode::Settings
- | Mode::GlobalMenu
- | Mode::KeybindHelp
- ) {
- Some(self.selected)
- } else {
- self.active
- }
- }
-
- fn collapsed_agent_detail_target_at(
- &self,
- row: u16,
- ) -> Option<(usize, usize, crate::layout::PaneId)> {
- if !self.sidebar_collapsed {
- return None;
- }
-
- let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
- let detail_content_area = Rect::new(
- detail_area.x,
- detail_area.y,
- detail_area.width,
- detail_area.height.saturating_sub(1),
- );
- if detail_content_area == Rect::default()
- || row < detail_content_area.y
- || row >= detail_content_area.y + detail_content_area.height
- {
- return None;
- }
-
- let ws_idx = self.collapsed_detail_workspace_idx()?;
- let ws = self.workspaces.get(ws_idx)?;
- let detail_idx = (row - detail_content_area.y) as usize;
- let details = ws.pane_details();
- let detail = details.get(detail_idx)?;
- Some((ws_idx, detail.tab_idx, detail.pane_id))
- }
-
- fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> {
- let area = self.workspace_list_rect();
- let footer = self.sidebar_footer_rect();
- if area == Rect::default() || row < area.y || row >= footer.y {
- return None;
- }
-
- let cards = if self.view.workspace_card_areas.is_empty() {
- crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
- } else {
- self.view.workspace_card_areas.clone()
- };
- if cards.is_empty() {
- return Some(0);
- }
-
- let mut insert_indices = Vec::with_capacity(cards.len() + 1);
- insert_indices.push(cards[0].ws_idx);
- insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx));
- insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0));
-
- let mut best: Option<(usize, u16)> = None;
- for insert_idx in insert_indices {
- let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx)
- else {
- continue;
- };
- let distance = row.abs_diff(slot_row);
- match best {
- Some((best_idx, best_distance))
- if distance > best_distance
- || (distance == best_distance && insert_idx < best_idx) => {}
- _ => best = Some((insert_idx, distance)),
- }
- }
-
- best.map(|(insert_idx, _)| insert_idx)
- }
-
- fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool {
- if self.sidebar_collapsed {
- return false;
- }
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- self.view.sidebar_rect,
- self.sidebar_section_split,
- );
- let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope);
- rect.width > 0
- && col >= rect.x
- && col < rect.x + rect.width
- && row >= rect.y
- && row < rect.y + rect.height
- }
-
- fn agent_detail_target_at(&self, row: u16) -> Option<(usize, usize, crate::layout::PaneId)> {
- if self.sidebar_collapsed {
- return None;
- }
-
- let detail_area = self.agent_panel_rect();
- let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area);
- let body = crate::ui::agent_panel_body_rect(
- detail_area,
- crate::ui::should_show_scrollbar(metrics),
- );
- if body.height < 2 || row < body.y || row >= body.y + body.height {
- return None;
- }
-
- let mut row_y = body.y;
- for detail in crate::ui::agent_panel_entries(self)
- .into_iter()
- .skip(self.agent_panel_scroll)
- {
- if row_y.saturating_add(1) >= body.y + body.height {
- break;
- }
- if row == row_y || row == row_y + 1 {
- return Some((detail.ws_idx, detail.tab_idx, detail.pane_id));
- }
- row_y = row_y.saturating_add(2);
- if row_y < body.y + body.height {
- row_y = row_y.saturating_add(1);
- }
- }
- None
- }
-
- fn screen_rect(&self) -> Rect {
- let sidebar = self.view.sidebar_rect;
- let terminal = self.view.terminal_area;
- let x = sidebar.x.min(terminal.x);
- let y = sidebar.y.min(terminal.y);
- let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width);
- let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height);
- Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
- }
-
- pub(crate) fn context_menu_rect(&self) -> Option<Rect> {
- let menu = self.context_menu.as_ref()?;
- let screen = self.screen_rect();
- let max_item_w = menu
- .items()
- .iter()
- .map(|item| item.len() as u16)
- .max()
- .unwrap_or(0);
- let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1));
- let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1));
- let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w));
- let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h));
- Some(Rect::new(x, y, menu_w, menu_h))
- }
-
- pub(crate) fn confirm_close_rect(&self) -> Rect {
- crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default()
- }
-
- fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
- let menu_rect = self.context_menu_rect()?;
- let inner_x = menu_rect.x + 1;
- let inner_y = menu_rect.y + 1;
- let inner_w = menu_rect.width.saturating_sub(2);
- let inner_h = menu_rect.height.saturating_sub(2);
- let item_count = self
- .context_menu
- .as_ref()
- .map(|menu| menu.items().len() as u16)
- .unwrap_or(0);
- if col >= inner_x
- && col < inner_x + inner_w
- && row >= inner_y
- && row < inner_y + inner_h.min(item_count)
- {
- Some((row - inner_y) as usize)
- } else {
- None
- }
- }
-
- fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> {
- self.view.split_borders.iter().find(|b| match b.direction {
- Direction::Horizontal => {
- (col as i32 - b.pos as i32).unsigned_abs() <= 1
- && row >= b.area.y
- && row < b.area.y + b.area.height
- }
- Direction::Vertical => {
- (row as i32 - b.pos as i32).unsigned_abs() <= 1
- && col >= b.area.x
- && col < b.area.x + b.area.width
- }
- })
- }
-
- fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
- self.view.pane_infos.iter().find(|p| {
- col >= p.inner_rect.x
- && col < p.inner_rect.x + p.inner_rect.width
- && row >= p.inner_rect.y
- && row < p.inner_rect.y + p.inner_rect.height
- })
- }
-
- fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> {
- self.pane_at(col, row)
- .or_else(|| self.pane_frame_at(col, row))
- }
-
- fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> {
- self.view.pane_infos.iter().find(|info| info.id == pane_id)
- }
-
- fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
- self.view.pane_infos.iter().find(|p| {
- col >= p.rect.x
- && col < p.rect.x + p.rect.width
- && row >= p.rect.y
- && row < p.rect.y + p.rect.height
- })
- }
-
- fn focus_pane(&mut self, pane_id: crate::layout::PaneId) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- if ws.layout.focused() != pane_id {
- ws.layout.focus_pane(pane_id);
- self.mark_session_dirty();
- }
- }
- }
-
- fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.runtimes.get(&pane_id) {
- rt.scroll_up(lines);
- }
- }
- }
-
- fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.runtimes.get(&pane_id) {
- rt.scroll_down(lines);
- }
- }
- }
-
- fn pane_scroll_metrics(
- &self,
- pane_id: crate::layout::PaneId,
- ) -> Option<crate::pane::ScrollMetrics> {
- self.active
- .and_then(|i| self.workspaces.get(i))
- .and_then(|ws| ws.runtime(pane_id))
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
- }
-
- fn update_selection_cursor(
- &mut self,
- pane_id: crate::layout::PaneId,
- screen_col: u16,
- screen_row: u16,
- ) {
- let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
- return;
- };
- let metrics = self.pane_scroll_metrics(pane_id);
- if let Some(selection) = self.selection.as_mut() {
- selection.drag(screen_col, screen_row, info.inner_rect, metrics);
- }
- }
-
- fn selection_edge_scroll_lines(distance: u16) -> usize {
- usize::from(distance).saturating_mul(3).clamp(3, 15)
- }
-
- fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) {
- let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else {
- return;
- };
- let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
- return;
- };
-
- let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1);
- if screen_row < info.inner_rect.y {
- self.scroll_pane_up(
- pane_id,
- Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row),
- );
- } else if screen_row > bottom {
- self.scroll_pane_down(
- pane_id,
- Self::selection_edge_scroll_lines(screen_row - bottom),
- );
- }
-
- self.update_selection_cursor(pane_id, screen_col, screen_row);
- }
-
- fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool {
- const LINES_PER_NOTCH: usize = 3;
-
- let Some(selection) = self.selection.as_ref() else {
- return false;
- };
- if !selection.is_in_progress() {
- return false;
- }
- let pane_id = selection.pane_id;
- self.focus_pane(pane_id);
- match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH),
- _ => return false,
- }
- self.update_selection_cursor(pane_id, mouse.column, mouse.row);
- true
- }
-
- fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
- const LINES_PER_NOTCH: usize = 3;
-
- if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- if self.forward_pane_wheel(&info, mouse) {
- return;
- }
- match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
- _ => {}
- }
- return;
- }
-
- if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- match mouse.kind {
- MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
- _ => {}
- }
- return;
- }
-
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
- match mouse.kind {
- MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
- MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
- _ => {}
- }
- }
- }
- }
-
- fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
- let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
- return false;
- };
- let Some(rt) = ws.runtimes.get(&info.id) else {
- return false;
- };
- let column = mouse.column.saturating_sub(info.inner_rect.x);
- let row = mouse.row.saturating_sub(info.inner_rect.y);
- let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else {
- return false;
- };
- rt.scroll_reset();
- if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
- warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event");
- }
- true
- }
-
- fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
- let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
- return false;
- };
- let Some(rt) = ws.runtimes.get(&info.id) else {
- return false;
- };
- match rt.wheel_routing() {
- Some(crate::pane::WheelRouting::HostScroll) | None => false,
- Some(crate::pane::WheelRouting::MouseReport) => {
- rt.scroll_reset();
- let column = mouse.column.saturating_sub(info.inner_rect.x);
- let row = mouse.row.saturating_sub(info.inner_rect.y);
- let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
- else {
- warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
- return true;
- };
- if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
- warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
- }
- true
- }
- Some(crate::pane::WheelRouting::AlternateScroll) => {
- rt.scroll_reset();
- let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else {
- return true;
- };
- if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
- warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key");
- }
- true
- }
- }
- }
-
- fn set_pane_scroll_offset(&self, pane_id: crate::layout::PaneId, offset_from_bottom: usize) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.runtimes.get(&pane_id) {
- rt.set_scroll_offset_from_bottom(offset_from_bottom);
- }
- }
- }
-
- fn scrollbar_target_at(
- &self,
- col: u16,
- row: u16,
- ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
- let ws = self.active.and_then(|i| self.workspaces.get(i))?;
- let info = self.view.pane_infos.iter().find(|info| {
- crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
- col >= track.x
- && col < track.x + track.width
- && row >= track.y
- && row < track.y + track.height
- })
- })?;
- let rt = ws.runtimes.get(&info.id)?;
- let metrics = rt.scroll_metrics()?;
- if metrics.max_offset_from_bottom == 0 {
- return None;
- }
- let track = crate::ui::pane_scrollbar_rect(info)?;
- if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
- Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset }))
- } else {
- Some((
- info.id,
- ScrollbarClickTarget::Track {
- offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
- },
- ))
- }
- }
-
- fn scrollbar_offset_for_pane_row(
- &self,
- pane_id: crate::layout::PaneId,
- row: u16,
- grab_row_offset: u16,
- ) -> Option<usize> {
- let ws = self.active.and_then(|i| self.workspaces.get(i))?;
- let info = self
- .view
- .pane_infos
- .iter()
- .find(|info| info.id == pane_id)?;
- let track = crate::ui::pane_scrollbar_rect(info)?;
- let rt = ws.runtimes.get(&pane_id)?;
- let metrics = rt.scroll_metrics()?;
- if metrics.max_offset_from_bottom == 0 {
- return None;
- }
- Some(crate::ui::scrollbar_offset_from_drag_row(
- metrics,
- track,
- row,
- grab_row_offset,
- ))
- }
-}
-
-#[cfg(test)]
-fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting {
- if input_state.mouse_protocol_mode.reporting_enabled() {
- WheelRouting::MouseReport
- } else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
- WheelRouting::AlternateScroll
- } else {
- WheelRouting::HostScroll
- }
-}
-
-// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
-impl AppState {
- pub(crate) fn split_pane(&mut self, direction: Direction) {
- // Actual PTY spawning happens in Workspace::split_focused
- // which needs events channel — this is called from navigate_key
- // where we don't have async context, so the workspace handles it
- let (rows, cols) = self.estimate_pane_size();
- let new_rows = (rows / 2).max(4);
- let new_cols = (cols / 2).max(10);
-
- let cwd = self
- .active
- .and_then(|i| self.workspaces.get(i))
- .and_then(|ws| ws.focused_runtime())
- .and_then(|rt| rt.cwd());
-
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- if let Ok(new_id) = ws.split_focused(
- direction,
- new_rows,
- new_cols,
- cwd,
- self.pane_scrollback_limit_bytes,
- self.host_terminal_theme,
- ) {
- ws.layout.focus_pane(new_id);
- self.mark_session_dirty();
- self.mode = Mode::Terminal;
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::{
- fs,
- path::Path,
- time::{Duration, SystemTime, UNIX_EPOCH},
- };
-
- use crate::{config::Config, detect::Agent, workspace::Workspace};
- use crossterm::event::{KeyModifiers, MouseEvent};
- use ratatui::layout::Rect;
-
- fn state_with_workspaces(names: &[&str]) -> AppState {
- let mut state = AppState::test_new();
- state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect();
- if !state.workspaces.is_empty() {
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Navigate;
- }
- state
- }
-
- fn app_for_mouse_test() -> App {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.mode = Mode::Terminal;
- app.state.update_available = None;
- app.state.latest_release_notes_available = false;
- app.state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
- app.state.view.terminal_area = Rect::new(26, 0, 80, 20);
- app
- }
-
- fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
- MouseEvent {
- kind,
- column: col,
- row,
- modifiers: KeyModifiers::empty(),
- }
- }
-
- fn numbered_lines_bytes(count: usize) -> Vec<u8> {
- (0..count)
- .map(|i| format!("{i:06}\r\n"))
- .collect::<String>()
- .into_bytes()
- }
-
- fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
- crate::persist::capture(
- &state.workspaces,
- state.active,
- state.selected,
- state.agent_panel_scope,
- state.sidebar_width,
- state.sidebar_section_split,
- )
- }
-
- fn unique_temp_path(name: &str) -> std::path::PathBuf {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|d| d.as_nanos())
- .unwrap_or(0);
- std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
- }
-
- fn wait_for_file(path: &Path) -> String {
- let deadline = std::time::Instant::now() + Duration::from_secs(2);
- while std::time::Instant::now() < deadline {
- if let Ok(content) = fs::read_to_string(path) {
- return content;
- }
- std::thread::sleep(Duration::from_millis(20));
- }
- panic!("timed out waiting for {}", path.display());
- }
-
- fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> {
- match &snapshot.workspaces.first()?.tabs.first()?.layout {
- crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
- crate::persist::LayoutSnapshot::Pane(_) => None,
- }
- }
-
- #[test]
- fn custom_rename_key_enters_rename_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.rename_workspace_label = "g".into();
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::RenameWorkspace);
- assert_eq!(state.name_input, "test");
- }
-
- #[test]
- fn custom_new_workspace_key_requests_and_exits_navigate() {
- let mut state = state_with_workspaces(&["test"]);
- state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.new_workspace_label = "g".into();
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert!(state.request_new_workspace);
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
- let mut state = state_with_workspaces(&["test"]);
- state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.toggle_sidebar_label = "g".into();
- assert!(!state.sidebar_collapsed);
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert!(state.sidebar_collapsed);
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn custom_resize_key_enters_resize_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.resize_mode_label = "g".into();
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Resize);
- }
-
- #[test]
- fn movement_action_stays_in_navigate_mode() {
- let mut state = state_with_workspaces(&["a", "b"]);
- state.selected = 0;
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
- );
-
- assert_eq!(state.selected, 1);
- assert_eq!(state.mode, Mode::Navigate);
- }
-
- #[test]
- fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
- let mut state = state_with_workspaces(&["test"]);
- state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
- state.keybinds.focus_pane_left_label = Some("alt+left".into());
-
- let action = terminal_direct_navigation_action(
- &state,
- &AppKey::from(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)),
- );
-
- assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
- }
-
- #[tokio::test]
- async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.workspaces[0].test_split(Direction::Horizontal);
- app.state.view.pane_infos = app.state.workspaces[0]
- .active_tab()
- .unwrap()
- .layout
- .panes(Rect::new(0, 0, 80, 24));
- let focused_before = app.state.workspaces[0].layout.focused();
- app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT));
- app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
-
- app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
- .await;
-
- assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[tokio::test]
- async fn double_prefix_passes_prefix_through_to_focused_pane() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- let mut workspace = Workspace::test_new("test");
- let pane_id = workspace.tabs[0].root_pane;
- let (runtime, mut rx) =
- crate::pane::PaneRuntime::test_with_screen_bytes_and_receiver(20, 5, b"");
- let expected = runtime
- .encode_terminal_key(TerminalKey::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
- workspace.tabs[0].runtimes.insert(pane_id, runtime);
-
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.prefix_code = KeyCode::Char('a');
- app.state.prefix_mods = KeyModifiers::CONTROL;
-
- app.handle_key(TerminalKey::new(KeyCode::Char('a'), KeyModifiers::CONTROL))
- .await;
- assert_eq!(app.state.mode, Mode::Navigate);
-
- app.handle_key(TerminalKey::new(KeyCode::Char('a'), KeyModifiers::CONTROL))
- .await;
- assert_eq!(app.state.mode, Mode::Terminal);
-
- let forwarded = rx.try_recv().expect("prefix bytes should be forwarded");
- assert_eq!(forwarded.as_ref(), expected.as_slice());
- }
-
- #[tokio::test]
- async fn double_shifted_prefix_preserves_shifted_codepoint_when_forwarded() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- let mut workspace = Workspace::test_new("test");
- let pane_id = workspace.tabs[0].root_pane;
- let (runtime, mut rx) =
- crate::pane::PaneRuntime::test_with_screen_bytes_and_receiver(20, 5, b"");
- let prefix = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('?' as u32);
- let expected = runtime.encode_terminal_key(prefix);
- workspace.tabs[0].runtimes.insert(pane_id, runtime);
-
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.prefix_code = KeyCode::Char('?');
- app.state.prefix_mods = KeyModifiers::empty();
-
- app.handle_key(prefix).await;
- assert_eq!(app.state.mode, Mode::Navigate);
-
- app.handle_key(prefix).await;
- assert_eq!(app.state.mode, Mode::Terminal);
-
- let forwarded = rx
- .try_recv()
- .expect("shifted prefix bytes should be forwarded");
- assert_eq!(forwarded.as_ref(), expected.as_slice());
- }
-
- #[tokio::test]
- async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let output_path = unique_temp_path("custom-command-keybind");
- let command = format!(
- "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
- output_path.display()
- );
- app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- key: (KeyCode::Char('g'), KeyModifiers::empty()),
- label: "g".into(),
- command,
- action: crate::config::CustomCommandAction::Shell,
- }];
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- assert_eq!(app.state.mode, Mode::Navigate);
-
- app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
- .await;
-
- let content = wait_for_file(&output_path);
- let lines: Vec<&str> = content.lines().collect();
- assert_eq!(lines.len(), 3);
- assert_eq!(lines[0], app.state.workspaces[0].id);
- assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id));
- assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id));
- assert_eq!(app.state.mode, Mode::Terminal);
-
- let _ = fs::remove_file(output_path);
- }
-
- #[tokio::test]
- async fn pane_overlay_command_opens_and_closes_after_exit() {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let mut app = App::new(
- &Config::default(),
- true,
- None,
- None,
- api_rx,
- crate::api::EventHub::default(),
- );
- let workspace = Workspace::new(
- std::env::current_dir().unwrap_or_else(|_| "/".into()),
- 24,
- 80,
- app.state.pane_scrollback_limit_bytes,
- app.state.host_terminal_theme,
- app.event_tx.clone(),
- app.render_notify.clone(),
- app.render_dirty.clone(),
- )
- .expect("workspace should spawn");
- app.state.workspaces = vec![workspace];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let output_path = unique_temp_path("custom-pane-command");
- let command = format!("printf done > '{}'", output_path.display());
- app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
- key: (KeyCode::Char('g'), KeyModifiers::empty()),
- label: "g".into(),
- command,
- action: crate::config::CustomCommandAction::Pane,
- }];
-
- app.handle_key(TerminalKey::new(
- app.state.prefix_code,
- app.state.prefix_mods,
- ))
- .await;
- app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
- .await;
-
- assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
- assert!(app.state.workspaces[0].tabs[0].zoomed);
-
- let _ = wait_for_file(&output_path);
- let deadline = std::time::Instant::now() + Duration::from_secs(2);
- while std::time::Instant::now() < deadline {
- if app.drain_internal_events()
- && app.state.workspaces[0].tabs[0].layout.pane_count() == 1
- {
- break;
- }
- std::thread::sleep(Duration::from_millis(20));
- }
-
- assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
- assert!(!app.state.workspaces[0].tabs[0].zoomed);
- assert_eq!(app.state.mode, Mode::Terminal);
- let _ = fs::remove_file(output_path);
- }
-
- #[test]
- fn fullscreen_action_exits_navigate_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.workspaces[0].test_split(Direction::Horizontal);
- state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.fullscreen_label = "g".into();
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert!(state.workspaces[0].zoomed);
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn custom_resize_key_exits_resize_mode() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::Resize;
- state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
- state.keybinds.resize_mode_label = "g".into();
-
- handle_resize_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn settings_cancel_restores_previewed_theme_from_other_sections() {
- let mut state = state_with_workspaces(&["test"]);
- let original_palette = state.palette.clone();
- let original_theme = state.theme_name.clone();
-
- open_settings(&mut state);
- update_settings_state(
- &mut state,
- KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
- );
- assert_ne!(state.theme_name, original_theme);
-
- update_settings_state(
- &mut state,
- KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
- );
- assert_eq!(
- state.settings.section,
- crate::app::state::SettingsSection::Sound
- );
-
- update_settings_state(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert_eq!(state.theme_name, original_theme);
- assert_eq!(state.palette.accent, original_palette.accent);
- assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
- }
-
- #[test]
- fn settings_sound_toggle_returns_save_action() {
- let mut state = state_with_workspaces(&["test"]);
- open_settings(&mut state);
- state.settings.section = crate::app::state::SettingsSection::Sound;
- state.settings.list.selected = 0;
-
- let action = update_settings_state(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(action, Some(SettingsAction::SaveSound(true)));
- assert!(state.sound.enabled);
- assert_eq!(state.mode, Mode::Settings);
- }
-
- #[test]
- fn question_mark_opens_keybind_help_from_navigate() {
- let mut state = state_with_workspaces(&["test"]);
-
- handle_navigate_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT),
- );
-
- assert_eq!(state.mode, Mode::KeybindHelp);
- }
-
- #[test]
- fn shifted_slash_terminal_key_opens_keybind_help_from_navigate() {
- let mut state = state_with_workspaces(&["test"]);
-
- handle_navigate_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('?' as u32),
- );
-
- assert_eq!(state.mode, Mode::KeybindHelp);
- }
-
- #[test]
- fn rename_modal_keyboard_and_mouse_share_actions() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameWorkspace;
- state.name_input = "hello".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
- );
- assert!(state.name_input.is_empty());
-
- state.name_input = "renamed".into();
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
- assert_eq!(state.mode, Mode::Terminal);
- assert_eq!(state.workspaces[0].display_name(), "renamed");
- let snapshot = capture_snapshot(&state);
- assert_eq!(
- snapshot.workspaces[0].custom_name.as_deref(),
- Some("renamed")
- );
-
- state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
- state.view.terminal_area = Rect::new(26, 0, 80, 20);
- state.mode = Mode::RenameWorkspace;
- state.name_input = "mouse".into();
- let inner = state.rename_modal_inner().unwrap();
- let (save, _, _) = crate::ui::rename_button_rects(inner);
- let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]);
- assert_eq!(action, Some(ModalAction::Save));
- }
-
- #[test]
- fn tab_rename_updates_captured_snapshot() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "logs".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- let snapshot = capture_snapshot(&state);
- assert_eq!(
- snapshot.workspaces[0].tabs[0].custom_name.as_deref(),
- Some("logs")
- );
- }
-
- #[test]
- fn rename_cancel_returns_to_terminal_when_workspace_is_active() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "test".into();
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(state.name_input.is_empty());
- }
-
- #[test]
- fn rename_modal_replaces_prefilled_text_on_first_type() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
- state.name_input = "2".into();
- state.name_input_replace_on_type = true;
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()),
- );
- assert_eq!(state.name_input, "n");
- assert!(!state.name_input_replace_on_type);
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()),
- );
- assert_eq!(state.name_input, "ne");
- }
-
- #[test]
- fn rename_modal_uses_shifted_terminal_text_for_app_input() {
- let mut state = state_with_workspaces(&["test"]);
- state.mode = Mode::RenameTab;
-
- handle_rename_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('l'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('L' as u32),
- );
- handle_rename_key(
- &mut state,
- TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('?' as u32),
- );
-
- assert_eq!(state.name_input, "L?");
- }
-
- #[test]
- fn open_rename_active_tab_can_prefill_default_new_tab_name() {
- let mut state = state_with_workspaces(&["test"]);
- state.workspaces[0].test_add_tab(None);
- state.workspaces[0].switch_tab(1);
-
- open_rename_active_tab(&mut state, true);
-
- assert_eq!(state.mode, Mode::RenameTab);
- assert_eq!(state.name_input, "2");
- assert!(state.name_input_replace_on_type);
- }
-
- #[test]
- fn new_tab_action_opens_dialog_without_creating_tab() {
- let mut state = state_with_workspaces(&["test"]);
-
- execute_navigate_action(&mut state, NavigateAction::NewTab);
-
- assert_eq!(state.mode, Mode::RenameTab);
- assert!(state.creating_new_tab);
- assert_eq!(state.name_input, "2");
- assert!(state.name_input_replace_on_type);
- assert!(!state.request_new_tab);
- assert_eq!(state.workspaces[0].tabs.len(), 1);
- }
-
- #[test]
- fn cancel_new_tab_dialog_leaves_workspace_unchanged() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(!state.creating_new_tab);
- assert!(!state.request_new_tab);
- assert!(state.requested_new_tab_name.is_none());
- assert_eq!(state.workspaces[0].tabs.len(), 1);
- }
-
- #[test]
- fn saving_new_tab_dialog_requests_creation_with_name() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
- state.name_input = "logs".into();
- state.name_input_replace_on_type = false;
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(!state.creating_new_tab);
- assert!(state.request_new_tab);
- assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs"));
- }
-
- #[test]
- fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
-
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(!state.creating_new_tab);
- assert!(state.request_new_tab);
- assert!(state.requested_new_tab_name.is_none());
- }
-
- #[test]
- fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() {
- let mut state = state_with_workspaces(&["test"]);
- open_new_tab_dialog(&mut state);
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref());
- state.request_new_tab = false;
- state.requested_new_tab_name = None;
-
- state.workspaces[0].close_tab(0);
- state.workspaces[0].switch_tab(0);
-
- assert_eq!(state.workspaces[0].tabs[0].display_name(), "1");
- assert!(state.workspaces[0].tabs[0].custom_name.is_none());
-
- open_new_tab_dialog(&mut state);
- assert_eq!(state.name_input, "2");
- }
-
- #[test]
- fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() {
- let mut state = state_with_workspaces(&["test"]);
- state.workspaces[0].test_add_tab(None);
- state.workspaces[0].switch_tab(1);
-
- open_rename_active_tab(&mut state, false);
- handle_rename_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
-
- assert_eq!(state.mode, Mode::Terminal);
- assert!(state.workspaces[0].tabs[1].custom_name.is_none());
- assert_eq!(state.workspaces[0].tabs[1].display_name(), "2");
- }
-
- #[test]
- fn confirm_close_keyboard_actions_are_direct_not_focused() {
- let mut state = state_with_workspaces(&["a", "b"]);
- state.mode = Mode::ConfirmClose;
- state.selected = 1;
-
- handle_confirm_close_key(
- &mut state,
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- );
- assert_eq!(state.mode, Mode::Navigate);
- assert_eq!(state.workspaces.len(), 2);
-
- state.mode = Mode::ConfirmClose;
- handle_confirm_close_key(
- &mut state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
- assert_eq!(state.workspaces.len(), 1);
- }
-
- #[test]
- fn clicking_launcher_opens_global_menu() {
- let mut app = app_for_mouse_test();
- let rect = app.state.global_launcher_rect();
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- rect.x + rect.width.saturating_sub(1),
- rect.y,
- ));
-
- assert_eq!(app.state.mode, Mode::GlobalMenu);
- }
-
- #[test]
- fn hovering_global_menu_updates_highlight() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
-
- assert_eq!(app.state.global_menu.highlighted, 1);
- }
-
- #[test]
- fn clicking_keybinds_menu_item_opens_help() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 2,
- ));
-
- assert_eq!(app.state.mode, Mode::KeybindHelp);
- }
-
- #[test]
- fn clicking_settings_menu_item_opens_settings() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 1,
- ));
-
- assert_eq!(app.state.mode, Mode::Settings);
- }
-
- #[test]
- fn clicking_reload_keybinds_menu_item_requests_reload() {
- let mut app = app_for_mouse_test();
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 3,
- ));
-
- assert!(app.state.request_reload_keybinds);
- assert_eq!(app.state.mode, Mode::Navigate);
- }
-
- #[test]
- fn update_available_menu_surfaces_update_status_without_apply_action() {
- let mut app = app_for_mouse_test();
- app.state.update_available = Some("0.3.2".into());
- app.state.latest_release_notes_available = true;
-
- let launcher = app.state.global_launcher_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- launcher.x,
- launcher.y,
- ));
-
- assert_eq!(
- app.state.global_menu_labels(),
- vec![
- "settings",
- "keybinds",
- "reload keybinds",
- "update available",
- "quit"
- ]
- );
-
- let menu = app.state.global_menu_rect();
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- menu.x + 2,
- menu.y + 5,
- ));
-
- assert!(app.state.should_quit);
- }
-
- #[test]
- fn whats_new_remains_in_menu_for_latest_installed_release_notes() {
- let mut app = app_for_mouse_test();
- app.state.latest_release_notes_available = true;
-
- assert_eq!(
- app.state.global_menu_labels(),
- vec![
- "settings",
- "keybinds",
- "reload keybinds",
- "what's new",
- "quit"
- ]
- );
- }
-
- #[test]
- fn clicking_keybind_help_close_button_closes_overlay() {
- let mut app = app_for_mouse_test();
- app.state.mode = Mode::KeybindHelp;
-
- let rect = app.state.keybind_help_popup_rect();
- let inner = Rect::new(
- rect.x + 1,
- rect.y + 1,
- rect.width.saturating_sub(2),
- rect.height.saturating_sub(2),
- );
- let close =
- crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- close.x,
- close.y,
- ));
-
- assert_eq!(app.state.mode, Mode::Navigate);
- }
-
- #[test]
- fn hovering_context_menu_updates_highlight() {
- let mut app = app_for_mouse_test();
- app.state.context_menu = Some(ContextMenuState {
- kind: ContextMenuKind::Workspace { ws_idx: 0 },
- x: 2,
- y: 2,
- list: MenuListState::new(0),
- });
- app.state.mode = Mode::ContextMenu;
-
- let menu = app.state.context_menu_rect().unwrap();
- app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
-
- assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1);
- }
-
- #[test]
- fn onboarding_hover_does_not_change_selection() {
- let mut app = app_for_mouse_test();
- app.state.mode = Mode::Onboarding;
- app.state.onboarding_step = 1;
- app.state.onboarding_list.select(1);
-
- let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
- let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
- app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y));
-
- assert_eq!(app.state.onboarding_list.selected, 1);
- }
-
- #[test]
- fn onboarding_click_selects_notification_option() {
- let mut app = app_for_mouse_test();
- app.state.mode = Mode::Onboarding;
- app.state.onboarding_step = 1;
- app.state.onboarding_list.select(0);
-
- let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
- let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- content.x + 2,
- content.y + 2,
- ));
-
- assert_eq!(app.state.onboarding_list.selected, 2);
- }
-
- #[test]
- fn settings_hover_does_not_change_selection() {
- let mut app = app_for_mouse_test();
- open_settings(&mut app.state);
- app.state.settings.list.select(0);
-
- let area = app.state.settings_content_rect();
- app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2));
-
- assert_eq!(app.state.settings.list.selected, 0);
- }
-
- #[test]
- fn clicking_confirm_close_accepts_workspace_close() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
- app.state.active = Some(0);
- app.state.selected = 1;
- app.state.mode = Mode::ConfirmClose;
-
- let popup = app.state.confirm_close_rect();
- let inner = Rect::new(
- popup.x + 1,
- popup.y + 1,
- popup.width.saturating_sub(2),
- popup.height.saturating_sub(2),
- );
- let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- confirm.x,
- confirm.y,
- ));
-
- assert_eq!(app.state.workspaces.len(), 1);
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[test]
- fn clicking_confirm_close_accepts_after_workspace_context_menu_close() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.state.context_menu = Some(ContextMenuState {
- kind: ContextMenuKind::Workspace { ws_idx: 1 },
- x: 2,
- y: 2,
- list: MenuListState::new(1),
- });
- app.state.mode = Mode::ContextMenu;
- handle_context_menu_key(
- &mut app.state,
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- );
- assert_eq!(app.state.mode, Mode::ConfirmClose);
- assert_eq!(app.state.selected, 1);
-
- let popup = app.state.confirm_close_rect();
- let inner = Rect::new(
- popup.x + 1,
- popup.y + 1,
- popup.width.saturating_sub(2),
- popup.height.saturating_sub(2),
- );
- let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- confirm.x + 1,
- confirm.y,
- ));
-
- assert_eq!(app.state.workspaces.len(), 1);
- assert_eq!(app.state.workspaces[0].display_name(), "a");
- }
-
- #[test]
- fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("main".into());
- let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let second_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[second_tab].root_pane;
- ws.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16));
-
- assert_eq!(app.state.workspaces[0].active_tab, 1);
- assert_eq!(
- app.state.workspaces[0].tabs[1].layout.focused(),
- second_pane
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.workspaces[0].active_tab, second_tab);
- assert_eq!(
- snapshot.workspaces[0].tabs[second_tab].focused,
- Some(second_pane.raw())
- );
- }
-
- #[test]
- fn clicking_agent_panel_toggle_switches_scope() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.agent_panel_scroll = 3;
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- app.state.view.sidebar_rect,
- app.state.sidebar_section_split,
- );
- let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- toggle.x,
- toggle.y,
- ));
-
- assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces);
- assert_eq!(app.state.agent_panel_scroll, 0);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
- }
-
- #[test]
- fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() {
- let mut app = app_for_mouse_test();
- let mut first = Workspace::test_new("one");
- let first_pane = first.tabs[0].root_pane;
- first.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
-
- let mut second = Workspace::test_new("two");
- let second_pane = second.tabs[0].root_pane;
- second.tabs[0]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- app.state.workspaces = vec![first, second];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- app.state.view.sidebar_rect,
- app.state.sidebar_section_split,
- );
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- detail_area.x + 2,
- detail_area.y + 6,
- ));
-
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.selected, 1);
- assert_eq!(app.state.workspaces[1].active_tab, 0);
- assert_eq!(
- app.state.workspaces[1].tabs[0].layout.focused(),
- second_pane
- );
- }
-
- #[test]
- fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
-
- for (tab_name, agent) in [
- ("logs", Agent::Claude),
- ("review", Agent::Codex),
- ("ops", Agent::Gemini),
- ] {
- let tab_idx = ws.test_add_tab(Some(tab_name));
- let pane_id = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&pane_id)
- .unwrap()
- .detected_agent = Some(agent);
- }
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
-
- let detail_area = app.state.agent_panel_rect();
- assert!(crate::ui::should_show_scrollbar(
- crate::ui::agent_panel_scroll_metrics(&app.state, detail_area)
- ));
-
- app.handle_mouse(mouse(
- MouseEventKind::ScrollDown,
- detail_area.x + 1,
- detail_area.y + 4,
- ));
-
- assert_eq!(app.state.agent_panel_scroll, 1);
- assert_eq!(app.state.selected, 0);
- }
-
- #[test]
- fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
-
- let second_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[second_tab].root_pane;
- ws.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] {
- let tab_idx = ws.test_add_tab(Some(tab_name));
- let pane_id = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&pane_id)
- .unwrap()
- .detected_agent = Some(agent);
- }
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.agent_panel_scroll = 1;
-
- let detail_area = app.state.agent_panel_rect();
- let body = crate::ui::agent_panel_body_rect(detail_area, true);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- body.x + 1,
- body.y,
- ));
-
- assert_eq!(app.state.workspaces[0].active_tab, second_tab);
- assert_eq!(
- app.state.workspaces[0].tabs[second_tab].layout.focused(),
- second_pane
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[test]
- fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&first_pane)
- .unwrap()
- .detected_agent = Some(Agent::Pi);
- let second_tab = ws.test_add_tab(Some("logs"));
- let second_pane = ws.tabs[second_tab].root_pane;
- ws.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.sidebar_collapsed = true;
- app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
- app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
-
- let (_, _, detail_area) =
- crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- detail_area.x,
- detail_area.y + 1,
- ));
-
- assert_eq!(app.state.workspaces[0].active_tab, 1);
- assert_eq!(
- app.state.workspaces[0].tabs[1].layout.focused(),
- second_pane
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[test]
- fn clicking_collapsed_sidebar_toggle_expands_sidebar() {
- let mut app = app_for_mouse_test();
- app.state.sidebar_collapsed = true;
- app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
- app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
-
- let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect);
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- toggle.x,
- toggle.y,
- ));
-
- assert!(!app.state.sidebar_collapsed);
- }
-
- #[tokio::test]
- async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
- pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let start_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
- .expect("initial scroll metrics");
- let start_row = info.inner_rect.y;
- let start_col = info.inner_rect.x + 2;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- start_row,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- start_col,
- info.inner_rect.y.saturating_sub(1),
- ));
-
- let end_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
- .expect("scroll metrics after drag");
- assert_eq!(
- end_metrics.offset_from_bottom,
- start_metrics.offset_from_bottom + 3
- );
-
- let selection = app.state.selection.as_ref().expect("selection after drag");
- assert!(selection.is_visible());
- assert_eq!(
- selection.ordered_cells(),
- (
- (
- (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
- 2,
- ),
- (start_metrics.max_offset_from_bottom as u32, 2),
- )
- );
- }
-
- #[tokio::test]
- async fn releasing_dragged_selection_clears_highlight_after_copy() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
- pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let row = info.inner_rect.y;
- let start_col = info.inner_rect.x + 1;
- let end_col = info.inner_rect.x + 4;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- start_col,
- row,
- ));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
- assert!(app.state.selection.is_some());
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
-
- assert!(app.state.selection.is_none());
- }
-
- #[tokio::test]
- async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let pane_id = ws.tabs[0].root_pane;
- let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
- let info = pane_infos[0].clone();
- ws.tabs[0].runtimes.insert(
- pane_id,
- crate::pane::PaneRuntime::test_with_scrollback_bytes(
- info.inner_rect.width,
- info.inner_rect.height,
- 16 * 1024,
- &numbered_lines_bytes(64),
- ),
- );
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- let start_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
- .expect("initial scroll metrics");
- let top_row = info.inner_rect.y;
- let col = info.inner_rect.x + 2;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row));
- app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row));
-
- let end_metrics = app.state.workspaces[0]
- .runtime(pane_id)
- .and_then(crate::pane::PaneRuntime::scroll_metrics)
- .expect("scroll metrics after wheel");
- assert_eq!(
- end_metrics.offset_from_bottom,
- start_metrics.offset_from_bottom + 3
- );
-
- let selection = app.state.selection.as_ref().expect("selection after wheel");
- assert!(selection.is_visible());
- assert_eq!(
- selection.ordered_cells(),
- (
- (
- (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
- 2,
- ),
- (start_metrics.max_offset_from_bottom as u32, 2),
- )
- );
- }
-
- #[test]
- fn clicking_workspace_switches_on_mouse_up() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let target_row = app.state.view.workspace_card_areas[1].rect.y;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- 2,
- target_row,
- ));
- assert_eq!(app.state.active, Some(0));
- assert!(app.state.workspace_press.is_some());
-
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
- assert_eq!(app.state.active, Some(1));
- assert_eq!(app.state.selected, 1);
- assert!(app.state.workspace_press.is_none());
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.active, Some(1));
- assert_eq!(snapshot.selected, 1);
- }
-
- #[test]
- fn dragging_workspace_reorders_without_changing_identity() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- Workspace::test_new("a"),
- Workspace::test_new("b"),
- Workspace::test_new("c"),
- ];
- let active_id = app.state.workspaces[1].id.clone();
- let selected_id = app.state.workspaces[2].id.clone();
- app.state.active = Some(1);
- app.state.selected = 2;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let source_row = app.state.view.workspace_card_areas[1].rect.y;
- let target_row = crate::ui::workspace_drop_indicator_row(
- &app.state.view.workspace_card_areas,
- app.state.workspace_list_rect(),
- 0,
- )
- .unwrap();
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- 2,
- source_row,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- 2,
- target_row,
- ));
- assert!(matches!(
- app.state.drag.as_ref().map(|drag| &drag.target),
- Some(DragTarget::WorkspaceReorder {
- source_ws_idx: 1,
- insert_idx: Some(0),
- })
- ));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
-
- let names: Vec<_> = app
- .state
- .workspaces
- .iter()
- .map(|ws| ws.display_name())
- .collect();
- assert_eq!(names, vec!["b", "a", "c"]);
- assert_eq!(app.state.active, Some(0));
- assert_eq!(app.state.selected, 2);
- assert_eq!(app.state.workspaces[0].id, active_id);
- assert_eq!(app.state.workspaces[2].id, selected_id);
- let snapshot = capture_snapshot(&app.state);
- let captured_names: Vec<_> = snapshot
- .workspaces
- .iter()
- .map(|ws| ws.custom_name.clone().unwrap())
- .collect();
- assert_eq!(captured_names, vec!["b", "a", "c"]);
- }
-
- #[test]
- fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- ws.test_add_tab(Some("logs"));
- ws.test_add_tab(Some("review"));
- ws.test_add_tab(Some("ops"));
- ws.test_add_tab(Some("notes"));
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
-
- let right = app.state.view.tab_scroll_right_hit_area;
- assert!(right.width > 0);
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- right.x + 1,
- right.y,
- ));
-
- assert_eq!(app.state.tab_scroll, 1);
- assert!(!app.state.tab_scroll_follow_active);
- assert_eq!(app.state.workspaces[0].active_tab, 0);
- assert_eq!(app.state.view.tab_hit_areas[0].width, 0);
- assert!(app.state.workspaces[0].tabs[0].custom_name.is_none());
- assert_eq!(
- app.state.workspaces[0].tabs[1].custom_name.as_deref(),
- Some("logs")
- );
- }
-
- #[test]
- fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- for name in [
- "one", "two", "three", "four", "five", "six", "seven", "eight",
- ] {
- ws.test_add_tab(Some(name));
- }
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.tab_scroll = usize::MAX;
- app.state.tab_scroll_follow_active = false;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
-
- let last_idx = app.state.workspaces[0].tabs.len() - 1;
- let target = app.state.view.tab_hit_areas[last_idx];
- let clamped_scroll = app.state.tab_scroll;
- assert!(target.width > 0, "last tab should already be visible");
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- target.x + 1,
- target.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Up(MouseButton::Left),
- target.x + 1,
- target.y,
- ));
-
- assert_eq!(app.state.workspaces[0].active_tab, last_idx);
- assert_eq!(app.state.tab_scroll, clamped_scroll);
- assert!(app.state.view.tab_hit_areas[last_idx].width > 0);
- }
-
- #[test]
- fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- ws.test_add_tab(Some("foo"));
- ws.test_add_tab(None);
- let moved_root = ws.tabs[0].root_pane;
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
-
- let source = app.state.view.tab_hit_areas[0];
- let last = app.state.view.tab_hit_areas[2];
- let drop_col = last.x + last.width;
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- source.x + 1,
- source.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- drop_col,
- source.y,
- ));
- assert!(matches!(
- app.state.drag.as_ref().map(|drag| &drag.target),
- Some(DragTarget::TabReorder {
- ws_idx: 0,
- source_tab_idx: 0,
- insert_idx: Some(3),
- })
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Up(MouseButton::Left),
- drop_col,
- source.y,
- ));
-
- let labels: Vec<_> = app.state.workspaces[0]
- .tabs
- .iter()
- .map(|tab| tab.display_name())
- .collect();
- assert_eq!(labels, vec!["foo", "2", "3"]);
- assert_eq!(
- app.state.workspaces[0].tabs[0].custom_name.as_deref(),
- Some("foo")
- );
- assert!(app.state.workspaces[0].tabs[1].custom_name.is_none());
- assert!(app.state.workspaces[0].tabs[2].custom_name.is_none());
- assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
- assert_eq!(app.state.workspaces[0].active_tab, 2);
- }
-
- #[test]
- fn top_drop_slot_is_distinct_from_gap_below_first_workspace() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
-
- assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0));
- assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0));
- assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0));
- assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1));
- }
-
- #[test]
- fn bottom_drop_slot_stays_below_last_workspace_not_footer() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![
- Workspace::test_new("a"),
- Workspace::test_new("b"),
- Workspace::test_new("c"),
- ];
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
-
- let cards = &app.state.view.workspace_card_areas;
- let bottom_slot = crate::ui::workspace_drop_indicator_row(
- cards,
- app.state.workspace_list_rect(),
- cards.len(),
- )
- .unwrap();
-
- let last = cards.last().unwrap().rect;
- assert_eq!(bottom_slot, last.y + last.height);
- assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1));
- }
-
- #[test]
- fn dragging_sidebar_divider_sets_manual_width() {
- let mut app = app_for_mouse_test();
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5));
-
- assert_eq!(app.state.sidebar_width, 31);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.sidebar_width, Some(31));
- }
-
- #[test]
- fn dragging_sidebar_section_divider_sets_split_ratio() {
- let mut app = app_for_mouse_test();
- let divider = crate::ui::sidebar_section_divider_rect(
- app.state.view.sidebar_rect,
- app.state.sidebar_section_split,
- );
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- divider.x + 1,
- divider.y,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- divider.x + 1,
- divider.y + 4,
- ));
-
- assert!(app.state.sidebar_section_split > 0.5);
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(
- snapshot.sidebar_section_split,
- Some(app.state.sidebar_section_split)
- );
- }
-
- #[test]
- fn dragging_pane_split_updates_captured_layout_ratio() {
- let mut app = app_for_mouse_test();
- app.state.workspaces = vec![Workspace::test_new("test")];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.workspaces[0].test_split(Direction::Horizontal);
- crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
- let border = app.state.view.split_borders[0].clone();
- let before = capture_snapshot(&app.state);
- let drag_row = border.area.y.saturating_add(1);
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- border.pos,
- drag_row,
- ));
- app.handle_mouse(mouse(
- MouseEventKind::Drag(MouseButton::Left),
- border.pos.saturating_add(6),
- drag_row,
- ));
-
- let after = capture_snapshot(&app.state);
- assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
- }
-
- #[test]
- fn double_clicking_sidebar_divider_resets_default_width() {
- let mut app = app_for_mouse_test();
- app.state.default_sidebar_width = 26;
- app.state.sidebar_width = 30;
-
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5));
- app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
-
- assert_eq!(app.state.sidebar_width, 26);
- assert!(app.state.drag.is_none());
- let snapshot = capture_snapshot(&app.state);
- assert_eq!(snapshot.sidebar_width, Some(26));
- }
-
- #[test]
- fn wheel_routing_prefers_mouse_reporting() {
- let input_state = crate::pane::InputState {
- alternate_screen: true,
- application_cursor: false,
- bracketed_paste: false,
- focus_reporting: false,
- mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
- mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
- mouse_alternate_scroll: true,
- };
-
- assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
- }
-
- #[test]
- fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
- let input_state = crate::pane::InputState {
- alternate_screen: true,
- application_cursor: false,
- bracketed_paste: false,
- focus_reporting: false,
- mouse_protocol_mode: crate::input::MouseProtocolMode::None,
- mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
- mouse_alternate_scroll: true,
- };
-
- assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll);
- }
-
- #[test]
- fn wheel_routing_falls_back_to_host_scrollback() {
- let input_state = crate::pane::InputState {
- alternate_screen: false,
- application_cursor: false,
- bracketed_paste: false,
- focus_reporting: false,
- mouse_protocol_mode: crate::input::MouseProtocolMode::None,
- mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
- mouse_alternate_scroll: true,
- };
-
- assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
- }
-
- #[tokio::test]
- async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
-
- let terminal_area = Rect::new(26, 2, 80, 18);
- let pane_infos = ws.tabs[0].layout.panes(terminal_area);
- let first_info = pane_infos
- .iter()
- .find(|p| p.id == first_pane)
- .expect("first pane info")
- .clone();
- let second_info = pane_infos
- .iter()
- .find(|p| p.id == second_pane)
- .expect("second pane info")
- .clone();
-
- ws.tabs[0].runtimes.insert(
- first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
- first_info.inner_rect.width.max(1),
- first_info.inner_rect.height.max(1),
- b"",
- ),
- );
- ws.tabs[0].runtimes.insert(
- second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
- second_info.inner_rect.width.max(1),
- second_info.inner_rect.height.max(1),
- b"\x1b[?1002h",
- ),
- );
-
- ws.tabs[0].layout.focus_pane(first_pane);
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- first_pane,
- "first pane should be focused before click"
- );
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Left),
- second_info.inner_rect.x + 2,
- second_info.inner_rect.y + 2,
- ));
-
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- second_pane,
- "left-clicking a pane with mouse reporting should move focus to it"
- );
- assert_eq!(app.state.mode, Mode::Terminal);
- }
-
- #[tokio::test]
- async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() {
- let mut app = app_for_mouse_test();
- let mut ws = Workspace::test_new("test");
- let first_pane = ws.tabs[0].root_pane;
- let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
-
- let terminal_area = Rect::new(26, 2, 80, 18);
- let pane_infos = ws.tabs[0].layout.panes(terminal_area);
- let first_info = pane_infos
- .iter()
- .find(|p| p.id == first_pane)
- .expect("first pane info")
- .clone();
- let second_info = pane_infos
- .iter()
- .find(|p| p.id == second_pane)
- .expect("second pane info")
- .clone();
-
- ws.tabs[0].runtimes.insert(
- first_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
- first_info.inner_rect.width.max(1),
- first_info.inner_rect.height.max(1),
- b"",
- ),
- );
- ws.tabs[0].runtimes.insert(
- second_pane,
- crate::pane::PaneRuntime::test_with_screen_bytes(
- second_info.inner_rect.width.max(1),
- second_info.inner_rect.height.max(1),
- b"\x1b[?1002h",
- ),
- );
-
- ws.tabs[0].layout.focus_pane(first_pane);
-
- app.state.workspaces = vec![ws];
- app.state.active = Some(0);
- app.state.selected = 0;
- app.state.mode = Mode::Terminal;
- app.state.view.pane_infos = pane_infos;
-
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- first_pane,
- "first pane should be focused before click"
- );
-
- app.handle_mouse(mouse(
- MouseEventKind::Down(MouseButton::Right),
- second_info.inner_rect.x + 2,
- second_info.inner_rect.y + 2,
- ));
-
- assert_eq!(
- app.state.workspaces[0].tabs[0].layout.focused(),
- second_pane,
- "right-clicking a pane with mouse reporting should move focus to it"
- );
- assert_eq!(
- app.state.mode,
- Mode::ContextMenu,
- "right-click should enter ContextMenu mode"
- );
- assert!(
- app.state.context_menu.is_some(),
- "right-click should populate context_menu state"
- );
- }
-}
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
new file mode 100644
index 00000000..2f069fea
--- /dev/null
+++ b/src/app/input/mod.rs
@@ -0,0 +1,321 @@
+//! Input handling — translates crossterm key/mouse events into state mutations.
+
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+
+use crate::input::TerminalKey;
+use ratatui::layout::Direction;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ScrollbarClickTarget {
+ Thumb { grab_row_offset: u16 },
+ Track { offset_from_bottom: usize },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+#[cfg(test)]
+enum WheelRouting {
+ HostScroll,
+ MouseReport,
+ AlternateScroll,
+}
+
+const WORKSPACE_DRAG_THRESHOLD: u16 = 1;
+const TAB_DRAG_THRESHOLD: u16 = 1;
+
+mod modal;
+mod mouse;
+mod navigate;
+mod overlays;
+mod selection;
+mod settings;
+mod sidebar;
+mod terminal;
+
+pub(crate) use self::{
+ modal::{
+ handle_confirm_close_key, handle_context_menu_key, handle_global_menu_key,
+ handle_keybind_help_key, handle_rename_key, handle_resize_key,
+ },
+ navigate::{handle_navigate_key, terminal_direct_navigation_action},
+};
+use self::{
+ modal::{
+ modal_action_from_key, ModalAction, ONBOARDING_NOTIFICATION_ACTIONS,
+ ONBOARDING_WELCOME_ACTIONS, RELEASE_NOTES_ACTIONS,
+ },
+ settings::SettingsAction,
+};
+use super::state::{AppState, Mode};
+use super::App;
+
+// ---------------------------------------------------------------------------
+// Key handling
+// ---------------------------------------------------------------------------
+
+impl App {
+ pub(super) async fn handle_key(&mut self, key: TerminalKey) {
+ match self.state.mode {
+ Mode::Terminal => self.handle_terminal_key(key).await,
+ _ => {
+ let key = key.as_key_event();
+ match self.state.mode {
+ Mode::Onboarding => self.handle_onboarding_key(key),
+ Mode::ReleaseNotes => self.handle_release_notes_key(key),
+ Mode::Navigate => self.handle_navigate_key(key),
+ Mode::RenameWorkspace | Mode::RenameTab => {
+ handle_rename_key(&mut self.state, key)
+ }
+ Mode::Resize => handle_resize_key(&mut self.state, key),
+ Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
+ Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
+ Mode::Settings => self.handle_settings_key(key),
+ Mode::GlobalMenu => handle_global_menu_key(&mut self.state, key),
+ Mode::KeybindHelp => handle_keybind_help_key(&mut self.state, key),
+ Mode::Terminal => unreachable!(),
+ }
+ }
+ }
+ }
+
+ pub(super) async fn handle_paste(&mut self, text: String) {
+ if self.state.mode != Mode::Terminal {
+ return;
+ }
+ if let Some(ws) = self.state.active.and_then(|i| self.state.workspaces.get(i)) {
+ if let Some(rt) = ws.focused_runtime() {
+ let _ = rt.send_paste(text).await;
+ }
+ }
+ }
+
+ pub(crate) fn handle_onboarding_key(&mut self, key: KeyEvent) {
+ match self.state.onboarding_step {
+ 0 => match key.code {
+ KeyCode::Right | KeyCode::Char('l') => {
+ self.state.onboarding_step = 1;
+ }
+ _ => match modal_action_from_key(&key, ONBOARDING_WELCOME_ACTIONS) {
+ Some(ModalAction::Continue) => self.state.onboarding_step = 1,
+ _ => {}
+ },
+ },
+ _ => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => self.state.onboarding_list.move_prev(),
+ KeyCode::Down | KeyCode::Char('j') => self.state.onboarding_list.move_next(4),
+ KeyCode::Left | KeyCode::Char('h') => {
+ self.state.onboarding_step = 0;
+ }
+ KeyCode::Char(c) if ('1'..='4').contains(&c) => {
+ self.state
+ .onboarding_list
+ .select((c as usize) - ('1' as usize));
+ }
+ _ => match modal_action_from_key(&key, ONBOARDING_NOTIFICATION_ACTIONS) {
+ Some(ModalAction::Back) => self.state.onboarding_step = 0,
+ Some(ModalAction::Save) => self.complete_onboarding(),
+ _ => {}
+ },
+ },
+ }
+ }
+
+ pub(crate) fn handle_release_notes_key(&mut self, key: KeyEvent) {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => self.scroll_release_notes(-1),
+ KeyCode::Down | KeyCode::Char('j') => self.scroll_release_notes(1),
+ KeyCode::PageUp => self.scroll_release_notes(-8),
+ KeyCode::PageDown => self.scroll_release_notes(8),
+ KeyCode::Home => {
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = 0;
+ }
+ }
+ KeyCode::End => {
+ let max_scroll = self.state.release_notes_max_scroll();
+ if let Some(notes) = &mut self.state.release_notes {
+ notes.scroll = max_scroll;
+ }
+ }
+ _ => match modal_action_from_key(&key, RELEASE_NOTES_ACTIONS) {
+ Some(ModalAction::Close) => self.dismiss_release_notes(),
+ _ => {}
+ },
+ }
+ }
+
+ pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) {
+ if self.handle_overlay_mouse(mouse) {
+ return;
+ }
+
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ && self.state.on_sidebar_divider(mouse.column, mouse.row)
+ {
+ let now = std::time::Instant::now();
+ let is_double_click = self
+ .last_sidebar_divider_click
+ .is_some_and(|last| now.duration_since(last) <= super::SIDEBAR_DOUBLE_CLICK_WINDOW);
+ self.last_sidebar_divider_click = Some(now);
+
+ if is_double_click {
+ self.state.sidebar_width = self.state.default_sidebar_width;
+ self.state.sidebar_width_auto = false;
+ self.state.mark_session_dirty();
+ self.state.drag = None;
+ return;
+ }
+ }
+
+ if let Some(action) = self.state.handle_mouse(mouse) {
+ match action {
+ SettingsAction::SaveTheme(name) => self.save_theme(&name),
+ SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
+ SettingsAction::SaveToast(enabled) => self.save_toast(enabled),
+ }
+ }
+
+ if let Some(content) = self.state.request_clipboard_write.take() {
+ if self
+ .event_tx
+ .try_send(crate::events::AppEvent::ClipboardWrite { content })
+ .is_err()
+ {
+ tracing::warn!("failed to queue clipboard write event");
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Mouse handling
+// ---------------------------------------------------------------------------
+
+// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
+impl AppState {
+ pub(crate) fn split_pane(&mut self, direction: Direction) {
+ // Actual PTY spawning happens in Workspace::split_focused
+ // which needs events channel — this is called from navigate_key
+ // where we don't have async context, so the workspace handles it
+ let (rows, cols) = self.estimate_pane_size();
+ let new_rows = (rows / 2).max(4);
+ let new_cols = (cols / 2).max(10);
+
+ let cwd = self
+ .active
+ .and_then(|i| self.workspaces.get(i))
+ .and_then(|ws| ws.focused_runtime())
+ .and_then(|rt| rt.cwd());
+
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if let Ok(new_id) = ws.split_focused(
+ direction,
+ new_rows,
+ new_cols,
+ cwd,
+ self.pane_scrollback_limit_bytes,
+ self.host_terminal_theme,
+ ) {
+ ws.layout.focus_pane(new_id);
+ self.mark_session_dirty();
+ self.mode = Mode::Terminal;
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+fn state_with_workspaces(names: &[&str]) -> AppState {
+ let mut state = AppState::test_new();
+ state.workspaces = names
+ .iter()
+ .map(|name| crate::workspace::Workspace::test_new(name))
+ .collect();
+ if !state.workspaces.is_empty() {
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Navigate;
+ }
+ state
+}
+
+#[cfg(test)]
+fn app_for_mouse_test() -> App {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &crate::config::Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.mode = Mode::Terminal;
+ app.state.update_available = None;
+ app.state.latest_release_notes_available = false;
+ app.state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 26, 20);
+ app.state.view.terminal_area = ratatui::layout::Rect::new(26, 0, 80, 20);
+ app
+}
+
+#[cfg(test)]
+fn mouse(
+ kind: crossterm::event::MouseEventKind,
+ col: u16,
+ row: u16,
+) -> crossterm::event::MouseEvent {
+ crossterm::event::MouseEvent {
+ kind,
+ column: col,
+ row,
+ modifiers: crossterm::event::KeyModifiers::empty(),
+ }
+}
+
+#[cfg(test)]
+fn numbered_lines_bytes(count: usize) -> Vec<u8> {
+ (0..count)
+ .map(|i| format!("{i:06}\r\n"))
+ .collect::<String>()
+ .into_bytes()
+}
+
+#[cfg(test)]
+fn capture_snapshot(state: &AppState) -> crate::persist::SessionSnapshot {
+ crate::persist::capture(
+ &state.workspaces,
+ state.active,
+ state.selected,
+ state.agent_panel_scope,
+ state.sidebar_width,
+ state.sidebar_section_split,
+ )
+}
+
+#[cfg(test)]
+fn root_layout_ratio(snapshot: &crate::persist::SessionSnapshot) -> Option<f32> {
+ match &snapshot.workspaces.first()?.tabs.first()?.layout {
+ crate::persist::LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
+ crate::persist::LayoutSnapshot::Pane(_) => None,
+ }
+}
+
+#[cfg(test)]
+fn unique_temp_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0);
+ std::env::temp_dir().join(format!("herdr-{name}-{}-{nanos}", std::process::id()))
+}
+
+#[cfg(test)]
+fn wait_for_file(path: &std::path::Path) -> String {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if let Ok(content) = std::fs::read_to_string(path) {
+ return content;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(20));
+ }
+ panic!("timed out waiting for {}", path.display());
+}
diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs
new file mode 100644
index 00000000..2ba9b6f4
--- /dev/null
+++ b/src/app/input/modal.rs
@@ -0,0 +1,739 @@
+use crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::{Direction, Rect};
+
+use crate::{
+ app::state::{key_matches, AppState, ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ layout::NavDirection,
+};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum ModalAction {
+ Continue,
+ Back,
+ Save,
+ Clear,
+ Cancel,
+ Confirm,
+ Apply,
+ Close,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum ModalKeyBinding {
+ Enter,
+ Esc,
+ CtrlC,
+}
+
+impl ModalKeyBinding {
+ fn matches(self, key: &KeyEvent) -> bool {
+ match self {
+ Self::Enter => key.code == KeyCode::Enter,
+ Self::Esc => key.code == KeyCode::Esc,
+ Self::CtrlC => {
+ key.code == KeyCode::Char('c')
+ && key.modifiers == crossterm::event::KeyModifiers::CONTROL
+ }
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub(super) struct ModalActionSpec<A> {
+ pub action: A,
+ pub bindings: &'static [ModalKeyBinding],
+}
+
+pub(super) fn modal_action_from_key<A: Copy>(
+ key: &KeyEvent,
+ specs: &[ModalActionSpec<A>],
+) -> Option<A> {
+ specs
+ .iter()
+ .find(|spec| spec.bindings.iter().any(|binding| binding.matches(key)))
+ .map(|spec| spec.action)
+}
+
+pub(super) fn modal_action_from_buttons<A: Copy>(
+ col: u16,
+ row: u16,
+ buttons: &[(Rect, A)],
+) -> Option<A> {
+ buttons.iter().find_map(|(rect, action)| {
+ (col >= rect.x && col < rect.x + rect.width && row >= rect.y && row < rect.y + rect.height)
+ .then_some(*action)
+ })
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum GlobalMenuAction {
+ Quit,
+ WhatsNew,
+ Keybinds,
+ ReloadKeybinds,
+ Settings,
+}
+
+pub(super) fn global_menu_actions(state: &AppState) -> Vec<GlobalMenuAction> {
+ let mut actions = vec![
+ GlobalMenuAction::Settings,
+ GlobalMenuAction::Keybinds,
+ GlobalMenuAction::ReloadKeybinds,
+ ];
+ if state.update_available.is_some() || state.latest_release_notes_available {
+ actions.push(GlobalMenuAction::WhatsNew);
+ }
+ actions.push(GlobalMenuAction::Quit);
+ actions
+}
+
+pub(super) fn open_global_menu(state: &mut AppState) {
+ state.global_menu = MenuListState::new(0);
+ state.mode = Mode::GlobalMenu;
+}
+
+pub(super) fn open_keybind_help(state: &mut AppState) {
+ state.keybind_help.scroll = 0;
+ state.mode = Mode::KeybindHelp;
+}
+
+fn open_update_release_notes(state: &mut AppState) {
+ let Some(notes) = crate::release_notes::load_latest() else {
+ return;
+ };
+
+ state.release_notes = Some(crate::app::state::ReleaseNotesState {
+ version: notes.version,
+ body: notes.body,
+ scroll: 0,
+ preview: notes.preview,
+ });
+ state.mode = Mode::ReleaseNotes;
+}
+
+pub(super) fn request_quit_or_detach(state: &mut AppState) {
+ if state.quit_detaches {
+ state.detach_requested = true;
+ } else {
+ state.should_quit = true;
+ }
+}
+
+pub(super) fn apply_global_menu_action(state: &mut AppState, action: GlobalMenuAction) {
+ match action {
+ GlobalMenuAction::Quit => request_quit_or_detach(state),
+ GlobalMenuAction::WhatsNew => open_update_release_notes(state),
+ GlobalMenuAction::Keybinds => open_keybind_help(state),
+ GlobalMenuAction::ReloadKeybinds => {
+ state.request_reload_keybinds = true;
+ leave_modal(state);
+ }
+ GlobalMenuAction::Settings => super::settings::open_settings(state),
+ }
+}
+
+pub(crate) fn handle_global_menu_key(state: &mut AppState, key: KeyEvent) {
+ let actions = global_menu_actions(state);
+ match key.code {
+ KeyCode::Esc => leave_modal(state),
+ KeyCode::Up | KeyCode::Char('k') => state.global_menu.move_prev(),
+ KeyCode::Down | KeyCode::Char('j') => state.global_menu.move_next(actions.len()),
+ KeyCode::Enter => {
+ if let Some(action) = actions.get(state.global_menu.highlighted).copied() {
+ apply_global_menu_action(state, action);
+ }
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) {
+ match key.code {
+ KeyCode::Up | KeyCode::Char('k') => state.scroll_keybind_help(-1),
+ KeyCode::Down | KeyCode::Char('j') => state.scroll_keybind_help(1),
+ KeyCode::PageUp => state.scroll_keybind_help(-8),
+ KeyCode::PageDown => state.scroll_keybind_help(8),
+ KeyCode::Home => state.keybind_help.scroll = 0,
+ KeyCode::End => state.keybind_help.scroll = state.keybind_help_max_scroll(),
+ KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => leave_modal(state),
+ _ => {}
+ }
+}
+
+pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) {
+ state.selected = ws_idx;
+ state.name_input = state.workspaces[ws_idx].display_name();
+ state.name_input_replace_on_type = false;
+ state.mode = Mode::RenameWorkspace;
+}
+
+pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) {
+ state.creating_new_tab = false;
+ state.requested_new_tab_name = None;
+ if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) {
+ if let Some(name) = ws.active_tab_display_name() {
+ state.name_input = name;
+ state.name_input_replace_on_type = replace_on_type;
+ state.mode = Mode::RenameTab;
+ }
+ }
+}
+
+fn next_new_tab_default_name(state: &AppState) -> String {
+ state
+ .active
+ .and_then(|i| state.workspaces.get(i))
+ .map(|ws| (ws.tabs.len() + 1).to_string())
+ .unwrap_or_else(|| "1".to_string())
+}
+
+pub(super) fn open_new_tab_dialog(state: &mut AppState) {
+ state.creating_new_tab = true;
+ state.requested_new_tab_name = None;
+ state.name_input = next_new_tab_default_name(state);
+ state.name_input_replace_on_type = true;
+ state.mode = Mode::RenameTab;
+}
+
+pub(super) fn leave_modal(state: &mut AppState) {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ } else {
+ state.mode = Mode::Navigate;
+ }
+}
+
+pub(super) const ONBOARDING_WELCOME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
+ action: ModalAction::Continue,
+ bindings: &[ModalKeyBinding::Enter],
+}];
+
+pub(super) const ONBOARDING_NOTIFICATION_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Back,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+ ModalActionSpec {
+ action: ModalAction::Save,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+];
+
+pub(super) const RELEASE_NOTES_ACTIONS: &[ModalActionSpec<ModalAction>] = &[ModalActionSpec {
+ action: ModalAction::Close,
+ bindings: &[ModalKeyBinding::Enter, ModalKeyBinding::Esc],
+}];
+
+pub(super) const RENAME_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Save,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+ ModalActionSpec {
+ action: ModalAction::Clear,
+ bindings: &[ModalKeyBinding::CtrlC],
+ },
+ ModalActionSpec {
+ action: ModalAction::Cancel,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+];
+
+pub(super) const CONFIRM_CLOSE_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Confirm,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+ ModalActionSpec {
+ action: ModalAction::Cancel,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+];
+
+pub(super) const SETTINGS_ACTIONS: &[ModalActionSpec<ModalAction>] = &[
+ ModalActionSpec {
+ action: ModalAction::Apply,
+ bindings: &[ModalKeyBinding::Enter],
+ },
+ ModalActionSpec {
+ action: ModalAction::Close,
+ bindings: &[ModalKeyBinding::Esc],
+ },
+];
+
+pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) {
+ match action {
+ ModalAction::Save => {
+ let new_name = if state.name_input.trim().is_empty() {
+ state.name_input.clone()
+ } else {
+ state.name_input.trim().to_string()
+ };
+ match state.mode {
+ Mode::RenameWorkspace if !state.workspaces.is_empty() => {
+ if !new_name.is_empty() {
+ state.workspaces[state.selected].set_custom_name(new_name);
+ state.mark_session_dirty();
+ }
+ }
+ Mode::RenameTab if state.creating_new_tab => {
+ state.request_new_tab = true;
+ let default_name = next_new_tab_default_name(state);
+ state.requested_new_tab_name =
+ if new_name.is_empty() || new_name == default_name {
+ None
+ } else {
+ Some(new_name)
+ };
+ }
+ Mode::RenameTab => {
+ if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) {
+ if let Some(tab) = ws.active_tab_mut() {
+ let keep_auto_name =
+ tab.is_auto_named() && new_name == tab.number.to_string();
+ if !new_name.is_empty() && !keep_auto_name {
+ tab.set_custom_name(new_name);
+ state.mark_session_dirty();
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ state.creating_new_tab = false;
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ leave_modal(state);
+ }
+ ModalAction::Clear => {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ }
+ ModalAction::Cancel => {
+ state.creating_new_tab = false;
+ state.requested_new_tab_name = None;
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ leave_modal(state);
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
+ if let Some(action) = modal_action_from_key(&key, RENAME_ACTIONS) {
+ apply_rename_action(state, action);
+ return;
+ }
+
+ match key.code {
+ KeyCode::Backspace => {
+ if state.name_input_replace_on_type {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ } else {
+ state.name_input.pop();
+ }
+ }
+ KeyCode::Char(c) => {
+ if state.name_input_replace_on_type {
+ state.name_input.clear();
+ state.name_input_replace_on_type = false;
+ }
+ state.name_input.push(c);
+ }
+ _ => {}
+ }
+}
+
+pub(crate) fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
+ if key.code == KeyCode::Esc
+ || key.code == KeyCode::Enter
+ || key_matches(
+ &key,
+ state.keybinds.resize_mode.0,
+ state.keybinds.resize_mode.1,
+ )
+ {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ } else {
+ state.mode = Mode::Navigate;
+ }
+ return;
+ }
+
+ match key.code {
+ KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left),
+ KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right),
+ KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down),
+ KeyCode::Char('k') | KeyCode::Up => state.resize_pane(NavDirection::Up),
+ _ => {}
+ }
+}
+
+pub(super) fn open_confirm_close(state: &mut AppState) {
+ state.mode = Mode::ConfirmClose;
+}
+
+pub(super) fn confirm_close_accept(state: &mut AppState) {
+ state.close_selected_workspace();
+ if state.workspaces.is_empty() {
+ state.mode = Mode::Navigate;
+ } else {
+ state.mode = Mode::Terminal;
+ }
+}
+
+pub(super) fn confirm_close_cancel(state: &mut AppState) {
+ state.mode = Mode::Navigate;
+}
+
+pub(crate) fn handle_confirm_close_key(state: &mut AppState, key: KeyEvent) {
+ match modal_action_from_key(&key, CONFIRM_CLOSE_ACTIONS) {
+ Some(ModalAction::Confirm) => confirm_close_accept(state),
+ Some(ModalAction::Cancel) => confirm_close_cancel(state),
+ _ => {}
+ }
+}
+
+pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx: usize) {
+ let item = menu.items().get(idx).copied();
+ match (menu.kind, item) {
+ (ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
+ open_rename_workspace(state, ws_idx);
+ }
+ (ContextMenuKind::Workspace { ws_idx }, Some("Close")) => {
+ state.selected = ws_idx;
+ if state.confirm_close {
+ open_confirm_close(state);
+ } else {
+ state.close_selected_workspace();
+ state.mode = Mode::Navigate;
+ }
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ open_new_tab_dialog(state);
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ open_rename_active_tab(state, false);
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.close_tab();
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ (ContextMenuKind::Pane, Some("Split vertical")) => {
+ state.split_pane(Direction::Horizontal);
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Pane, Some("Split horizontal")) => {
+ state.split_pane(Direction::Vertical);
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Pane, Some("Fullscreen")) => {
+ state.toggle_fullscreen();
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Pane, Some("Close pane")) => {
+ state.close_pane();
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
+ _ => leave_modal(state),
+ }
+}
+
+pub(crate) fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
+ match key.code {
+ KeyCode::Esc => {
+ state.context_menu = None;
+ leave_modal(state);
+ }
+ KeyCode::Up => {
+ if let Some(menu) = &mut state.context_menu {
+ menu.list.move_prev();
+ }
+ }
+ KeyCode::Down => {
+ if let Some(menu) = &mut state.context_menu {
+ menu.list.move_next(menu.items().len());
+ }
+ }
+ KeyCode::Enter => {
+ if let Some(menu) = state.context_menu.take() {
+ let idx = menu.list.highlighted;
+ apply_context_menu_action(state, menu, idx);
+ }
+ }
+ _ => {}
+ }
+}
+
+impl AppState {
+ pub(super) fn global_menu_item_at(&self, col: u16, row: u16) -> Option<GlobalMenuAction> {
+ let rect = self.global_menu_rect();
+ if col <= rect.x
+ || col >= rect.x + rect.width.saturating_sub(1)
+ || row <= rect.y
+ || row >= rect.y + rect.height.saturating_sub(1)
+ {
+ return None;
+ }
+ let idx = (row - rect.y - 1) as usize;
+ global_menu_actions(self).get(idx).copied()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+ use ratatui::layout::Rect;
+
+ use super::super::{capture_snapshot, state_with_workspaces};
+ use super::*;
+
+ #[test]
+ fn custom_resize_key_exits_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.resize_mode_label = "g".into();
+
+ handle_resize_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn rename_modal_keyboard_and_mouse_share_actions() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameWorkspace;
+ state.name_input = "hello".into();
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
+ );
+ assert!(state.name_input.is_empty());
+
+ state.name_input = "renamed".into();
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(state.mode, Mode::Terminal);
+ assert_eq!(state.workspaces[0].display_name(), "renamed");
+ let snapshot = capture_snapshot(&state);
+ assert_eq!(
+ snapshot.workspaces[0].custom_name.as_deref(),
+ Some("renamed")
+ );
+
+ state.view.sidebar_rect = Rect::new(0, 0, 26, 20);
+ state.view.terminal_area = Rect::new(26, 0, 80, 20);
+ state.mode = Mode::RenameWorkspace;
+ state.name_input = "mouse".into();
+ let inner = state.rename_modal_inner().unwrap();
+ let (save, _, _) = crate::ui::rename_button_rects(inner);
+ let action = modal_action_from_buttons(save.x, save.y, &[(save, ModalAction::Save)]);
+ assert_eq!(action, Some(ModalAction::Save));
+ }
+
+ #[test]
+ fn tab_rename_updates_captured_snapshot() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+ state.name_input = "logs".into();
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ let snapshot = capture_snapshot(&state);
+ assert_eq!(
+ snapshot.workspaces[0].tabs[0].custom_name.as_deref(),
+ Some("logs")
+ );
+ }
+
+ #[test]
+ fn rename_cancel_returns_to_terminal_when_workspace_is_active() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+ state.name_input = "test".into();
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(state.name_input.is_empty());
+ }
+
+ #[test]
+ fn rename_modal_replaces_prefilled_text_on_first_type() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::RenameTab;
+ state.name_input = "2".into();
+ state.name_input_replace_on_type = true;
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('n'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.name_input, "n");
+ assert!(!state.name_input_replace_on_type);
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()),
+ );
+ assert_eq!(state.name_input, "ne");
+ }
+
+ #[test]
+ fn open_rename_active_tab_can_prefill_default_new_tab_name() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_add_tab(None);
+ state.workspaces[0].switch_tab(1);
+
+ open_rename_active_tab(&mut state, true);
+
+ assert_eq!(state.mode, Mode::RenameTab);
+ assert_eq!(state.name_input, "2");
+ assert!(state.name_input_replace_on_type);
+ }
+
+ #[test]
+ fn cancel_new_tab_dialog_leaves_workspace_unchanged() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(!state.creating_new_tab);
+ assert!(!state.request_new_tab);
+ assert!(state.requested_new_tab_name.is_none());
+ assert_eq!(state.workspaces[0].tabs.len(), 1);
+ }
+
+ #[test]
+ fn saving_new_tab_dialog_requests_creation_with_name() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+ state.name_input = "logs".into();
+ state.name_input_replace_on_type = false;
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(!state.creating_new_tab);
+ assert!(state.request_new_tab);
+ assert_eq!(state.requested_new_tab_name.as_deref(), Some("logs"));
+ }
+
+ #[test]
+ fn saving_new_tab_dialog_with_default_name_keeps_tab_auto_named() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(!state.creating_new_tab);
+ assert!(state.request_new_tab);
+ assert!(state.requested_new_tab_name.is_none());
+ }
+
+ #[test]
+ fn closing_first_auto_tab_resets_remaining_auto_tab_and_next_prompt() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_new_tab_dialog(&mut state);
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ state.workspaces[0].test_add_tab(state.requested_new_tab_name.as_deref());
+ state.request_new_tab = false;
+ state.requested_new_tab_name = None;
+
+ state.workspaces[0].close_tab(0);
+ state.workspaces[0].switch_tab(0);
+
+ assert_eq!(state.workspaces[0].tabs[0].display_name(), "1");
+ assert!(state.workspaces[0].tabs[0].custom_name.is_none());
+
+ open_new_tab_dialog(&mut state);
+ assert_eq!(state.name_input, "2");
+ }
+
+ #[test]
+ fn renaming_auto_tab_to_its_default_number_keeps_it_auto_named() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_add_tab(None);
+ state.workspaces[0].switch_tab(1);
+
+ open_rename_active_tab(&mut state, false);
+ handle_rename_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert!(state.workspaces[0].tabs[1].custom_name.is_none());
+ assert_eq!(state.workspaces[0].tabs[1].display_name(), "2");
+ }
+
+ #[test]
+ fn confirm_close_keyboard_actions_are_direct_not_focused() {
+ let mut state = state_with_workspaces(&["a", "b"]);
+ state.mode = Mode::ConfirmClose;
+ state.selected = 1;
+
+ handle_confirm_close_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+ assert_eq!(state.mode, Mode::Navigate);
+ assert_eq!(state.workspaces.len(), 2);
+
+ state.mode = Mode::ConfirmClose;
+ handle_confirm_close_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(state.workspaces.len(), 1);
+ }
+}
diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs
new file mode 100644
index 00000000..0349d236
--- /dev/null
+++ b/src/app/input/mouse.rs
@@ -0,0 +1,1258 @@
+use bytes::Bytes;
+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::{Direction, Rect};
+use tracing::warn;
+
+use crate::{
+ app::state::{
+ AgentPanelScope, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget,
+ MenuListState, Mode, TabPressState, WorkspacePressState,
+ },
+ layout::{PaneInfo, SplitBorder},
+ selection::Selection,
+};
+
+#[cfg(test)]
+use super::WheelRouting;
+use super::{
+ modal::{
+ apply_context_menu_action, apply_global_menu_action, apply_rename_action,
+ confirm_close_accept, confirm_close_cancel, global_menu_actions, leave_modal,
+ modal_action_from_buttons, open_global_menu, open_new_tab_dialog, ModalAction,
+ },
+ settings::SettingsAction,
+ ScrollbarClickTarget, TAB_DRAG_THRESHOLD, WORKSPACE_DRAG_THRESHOLD,
+};
+
+impl AppState {
+ pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
+ if self.mode == Mode::Onboarding {
+ self.handle_onboarding_mouse(mouse);
+ return None;
+ }
+
+ if self.mode == Mode::Settings {
+ return self.handle_settings_mouse(mouse);
+ }
+
+ let launcher_enabled = !self.sidebar_collapsed
+ && matches!(
+ self.mode,
+ Mode::Terminal
+ | Mode::Navigate
+ | Mode::Resize
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ );
+ let launcher = self.global_launcher_rect();
+ let launcher_hit = launcher_enabled
+ && mouse.column >= launcher.x
+ && mouse.column < launcher.x + launcher.width
+ && mouse.row >= launcher.y
+ && mouse.row < launcher.y + launcher.height;
+
+ if matches!(mouse.kind, MouseEventKind::Moved) && self.mode == Mode::GlobalMenu {
+ let actions = global_menu_actions(self);
+ let hovered = self
+ .global_menu_item_at(mouse.column, mouse.row)
+ .and_then(|action| actions.iter().position(|item| *item == action));
+ self.global_menu.hover(hovered);
+ return None;
+ }
+
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && launcher_hit {
+ if self.mode == Mode::GlobalMenu {
+ leave_modal(self);
+ } else {
+ open_global_menu(self);
+ }
+ return None;
+ }
+
+ if self.mode == Mode::GlobalMenu {
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ if let Some(action) = self.global_menu_item_at(mouse.column, mouse.row) {
+ apply_global_menu_action(self, action);
+ } else {
+ leave_modal(self);
+ }
+ }
+ return None;
+ }
+
+ if self.mode == Mode::KeybindHelp {
+ return None;
+ }
+
+ let sidebar = self.view.sidebar_rect;
+ let in_sidebar = mouse.column >= sidebar.x
+ && mouse.column < sidebar.x + sidebar.width
+ && mouse.row >= sidebar.y
+ && mouse.row < sidebar.y + sidebar.height;
+
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ self.selection = None;
+ self.workspace_press = None;
+
+ if self.mode == Mode::ConfirmClose {
+ let popup = self.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, cancel) = crate::ui::confirm_close_button_rects(inner);
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (confirm, ModalAction::Confirm),
+ (cancel, ModalAction::Cancel),
+ ],
+ ) {
+ Some(ModalAction::Confirm) => confirm_close_accept(self),
+ Some(ModalAction::Cancel) | None => confirm_close_cancel(self),
+ _ => {}
+ }
+ return None;
+ }
+
+ if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) {
+ let action = self
+ .rename_modal_inner()
+ .map(crate::ui::rename_button_rects)
+ .and_then(|(save, clear, cancel)| {
+ modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (save, ModalAction::Save),
+ (clear, ModalAction::Clear),
+ (cancel, ModalAction::Cancel),
+ ],
+ )
+ })
+ .unwrap_or(ModalAction::Cancel);
+ apply_rename_action(self, action);
+ return None;
+ }
+
+ if self.mode == Mode::ContextMenu {
+ let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = self.context_menu.take() {
+ if let Some(idx) = item_idx {
+ apply_context_menu_action(self, menu, idx);
+ } else {
+ leave_modal(self);
+ }
+ }
+ return None;
+ }
+
+ if self.on_sidebar_divider(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::SidebarDivider,
+ });
+ self.set_manual_sidebar_width(mouse.column);
+ return None;
+ }
+
+ if self.on_sidebar_section_divider(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::SidebarSectionDivider,
+ });
+ self.set_sidebar_section_split(mouse.row);
+ return None;
+ }
+
+ if !in_sidebar {
+ if let Some(border) = self.find_border_at(mouse.column, mouse.row) {
+ self.drag = Some(DragState {
+ target: DragTarget::PaneSplit {
+ path: border.path.clone(),
+ direction: border.direction,
+ area: border.area,
+ },
+ });
+ return None;
+ }
+
+ if let Some((pane_id, target)) =
+ self.scrollbar_target_at(mouse.column, mouse.row)
+ {
+ self.focus_pane(pane_id);
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_pane_scroll_offset(pane_id, offset_from_bottom);
+ }
+ }
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+ return None;
+ }
+ }
+
+ if self.on_tab_scroll_left_button(mouse.column, mouse.row) {
+ self.scroll_tabs_left();
+ return None;
+ }
+ if self.on_tab_scroll_right_button(mouse.column, mouse.row) {
+ self.scroll_tabs_right();
+ return None;
+ }
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.tab_press = Some(TabPressState {
+ ws_idx,
+ tab_idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ });
+ return None;
+ }
+ if self.on_new_tab_button(mouse.column, mouse.row) {
+ open_new_tab_dialog(self);
+ return None;
+ }
+
+ if in_sidebar {
+ if self.sidebar_collapsed {
+ if self.on_collapsed_sidebar_toggle(mouse.column, mouse.row) {
+ self.sidebar_collapsed = false;
+ return None;
+ }
+
+ if let Some(idx) = self.collapsed_workspace_at_row(mouse.row) {
+ self.switch_workspace(idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+
+ if let Some((ws_idx, tab_idx, pane_id)) =
+ self.collapsed_agent_detail_target_at(mouse.row)
+ {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ }
+ return None;
+ }
+
+ let new_button = self.sidebar_new_button_rect();
+ let on_new_button = mouse.row >= new_button.y
+ && mouse.row < new_button.y + new_button.height
+ && mouse.column >= new_button.x
+ && mouse.column < new_button.x + new_button.width;
+ if on_new_button {
+ self.request_new_workspace = true;
+ return None;
+ }
+
+ if let Some(target) =
+ self.workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceListScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ return None;
+ }
+
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.workspace_press = Some(WorkspacePressState {
+ ws_idx: idx,
+ start_col: mouse.column,
+ start_row: mouse.row,
+ });
+ return None;
+ }
+
+ if self.on_agent_panel_scope_toggle(mouse.column, mouse.row) {
+ self.agent_panel_scope = match self.agent_panel_scope {
+ AgentPanelScope::CurrentWorkspace => AgentPanelScope::AllWorkspaces,
+ AgentPanelScope::AllWorkspaces => AgentPanelScope::CurrentWorkspace,
+ };
+ self.agent_panel_scroll = 0;
+ self.mark_session_dirty();
+ return None;
+ }
+
+ if let Some(target) =
+ self.agent_panel_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.drag = Some(DragState {
+ target: DragTarget::AgentPanelScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.set_agent_panel_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ return None;
+ }
+
+ if let Some((ws_idx, tab_idx, pane_id)) = self.agent_detail_target_at(mouse.row)
+ {
+ self.switch_workspace(ws_idx);
+ self.switch_tab(tab_idx);
+ self.focus_pane(pane_id);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ } else if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ return None;
+ }
+
+ let (row, col) = (
+ mouse.row - info.inner_rect.y,
+ mouse.column - info.inner_rect.x,
+ );
+ self.selection = Some(Selection::anchor(
+ info.id,
+ row,
+ col,
+ self.pane_scroll_metrics(info.id),
+ ));
+ } else if let Some(info) = self.view.pane_infos.iter().find(|p| {
+ mouse.column >= p.rect.x
+ && mouse.column < p.rect.x + p.rect.width
+ && mouse.row >= p.rect.y
+ && mouse.row < p.rect.y + p.rect.height
+ }) {
+ let id = info.id;
+ self.focus_pane(id);
+ if self.mode != Mode::Terminal {
+ self.mode = Mode::Terminal;
+ }
+ }
+ }
+
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if self.selection.is_some() {
+ self.update_selection_drag(mouse.column, mouse.row);
+ return None;
+ }
+
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ return None;
+ }
+ }
+
+ let workspace_drop_index = self.workspace_drop_index_at_row(mouse.row);
+ let tab_drop_index = self.tab_drop_index_at(mouse.column, mouse.row);
+ if self.drag.is_none() {
+ if let Some(press) = &self.workspace_press {
+ let delta_col = mouse.column.abs_diff(press.start_col);
+ let delta_row = mouse.row.abs_diff(press.start_row);
+ if delta_col.max(delta_row) >= WORKSPACE_DRAG_THRESHOLD {
+ self.drag = Some(DragState {
+ target: DragTarget::WorkspaceReorder {
+ source_ws_idx: press.ws_idx,
+ insert_idx: workspace_drop_index,
+ },
+ });
+ }
+ } else if let Some(press) = &self.tab_press {
+ let delta_col = mouse.column.abs_diff(press.start_col);
+ let delta_row = mouse.row.abs_diff(press.start_row);
+ if delta_col.max(delta_row) >= TAB_DRAG_THRESHOLD {
+ self.drag = Some(DragState {
+ target: DragTarget::TabReorder {
+ ws_idx: press.ws_idx,
+ source_tab_idx: press.tab_idx,
+ insert_idx: tab_drop_index,
+ },
+ });
+ }
+ }
+ }
+
+ if let Some(DragState {
+ target: DragTarget::WorkspaceReorder { insert_idx, .. },
+ }) = &mut self.drag
+ {
+ *insert_idx = workspace_drop_index;
+ } else if let Some(DragState {
+ target:
+ DragTarget::TabReorder {
+ ws_idx, insert_idx, ..
+ },
+ }) = &mut self.drag
+ {
+ if self.active == Some(*ws_idx) {
+ *insert_idx = tab_drop_index;
+ }
+ } else if let Some(drag) = &self.drag {
+ match &drag.target {
+ DragTarget::WorkspaceReorder { .. } | DragTarget::TabReorder { .. } => {}
+ DragTarget::WorkspaceListScrollbar { grab_row_offset } => {
+ if let Some(offset_from_bottom) =
+ self.workspace_list_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.set_workspace_list_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ DragTarget::AgentPanelScrollbar { grab_row_offset } => {
+ if let Some(offset_from_bottom) =
+ self.agent_panel_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.set_agent_panel_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ DragTarget::PaneSplit {
+ path,
+ direction,
+ area,
+ } => {
+ let ratio = match direction {
+ Direction::Horizontal => {
+ (mouse.column.saturating_sub(area.x)) as f32
+ / area.width.max(1) as f32
+ }
+ Direction::Vertical => {
+ (mouse.row.saturating_sub(area.y)) as f32
+ / area.height.max(1) as f32
+ }
+ };
+ let ratio = ratio.clamp(0.1, 0.9);
+ let path = path.clone();
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.layout.set_ratio_at(&path, ratio);
+ self.mark_session_dirty();
+ }
+ }
+ DragTarget::PaneScrollbar {
+ pane_id,
+ grab_row_offset,
+ } => {
+ if let Some(offset_from_bottom) = self.scrollbar_offset_for_pane_row(
+ *pane_id,
+ mouse.row,
+ *grab_row_offset,
+ ) {
+ self.set_pane_scroll_offset(*pane_id, offset_from_bottom);
+ }
+ }
+ DragTarget::SidebarDivider => {
+ self.set_manual_sidebar_width(mouse.column);
+ }
+ DragTarget::SidebarSectionDivider => {
+ self.set_sidebar_section_split(mouse.row);
+ }
+ DragTarget::ReleaseNotesScrollbar { .. }
+ | DragTarget::KeybindHelpScrollbar { .. } => {}
+ }
+ }
+ }
+
+ MouseEventKind::Up(MouseButton::Left) => {
+ if self.selection.is_some() {
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
+ if was_click {
+ self.selection = None;
+ } else {
+ self.copy_selection();
+ }
+ return None;
+ }
+
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ if self.forward_pane_mouse_button(&info, mouse) {
+ self.selection = None;
+ self.workspace_press = None;
+ self.tab_press = None;
+ self.drag = None;
+ return None;
+ }
+ }
+
+ let workspace_press = self.workspace_press.take();
+ let tab_press = self.tab_press.take();
+ match self.drag.take() {
+ Some(DragState {
+ target:
+ DragTarget::WorkspaceReorder {
+ source_ws_idx,
+ insert_idx: Some(insert_idx),
+ },
+ }) => {
+ self.move_workspace(source_ws_idx, insert_idx);
+ }
+ Some(DragState {
+ target:
+ DragTarget::TabReorder {
+ ws_idx,
+ source_tab_idx,
+ insert_idx: Some(insert_idx),
+ },
+ }) => {
+ if self.active == Some(ws_idx) {
+ self.move_tab(source_tab_idx, insert_idx);
+ self.mode = Mode::Terminal;
+ }
+ }
+ Some(_) => {}
+ None => {
+ if let Some(press) = workspace_press {
+ self.switch_workspace(press.ws_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ if let Some(press) = tab_press {
+ if self.active == Some(press.ws_idx) {
+ self.switch_tab(press.tab_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ }
+ let was_click = self.selection.as_ref().is_some_and(|s| s.was_just_click());
+ if was_click {
+ self.selection = None;
+ } else {
+ self.copy_selection();
+ }
+ }
+ }
+ }
+
+ MouseEventKind::Up(MouseButton::Middle) | MouseEventKind::Drag(MouseButton::Middle)
+ if !in_sidebar =>
+ {
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ let _ = self.forward_pane_mouse_button(&info, mouse);
+ }
+ }
+
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
+ if self.on_tab_bar(mouse.column, mouse.row) {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_tabs_left(),
+ MouseEventKind::ScrollDown => self.scroll_tabs_right(),
+ _ => {}
+ }
+ } else if !self.scroll_selection_with_wheel(mouse) {
+ self.selection = None;
+ self.handle_terminal_wheel(mouse);
+ }
+ }
+
+ MouseEventKind::ScrollUp if in_sidebar => {
+ let agent_area = self.agent_panel_rect();
+ let over_agent_panel = agent_area != Rect::default()
+ && mouse.row >= agent_area.y
+ && mouse.row < agent_area.y + agent_area.height;
+ if over_agent_panel {
+ if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
+ self, agent_area,
+ )) {
+ self.scroll_agent_panel(-1);
+ }
+ } else if crate::ui::should_show_scrollbar(
+ crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
+ ) {
+ self.scroll_workspace_list(-1);
+ } else if self.selected > 0 {
+ self.selected -= 1;
+ self.ensure_workspace_visible(self.selected);
+ }
+ }
+ MouseEventKind::ScrollDown if in_sidebar => {
+ let agent_area = self.agent_panel_rect();
+ let over_agent_panel = agent_area != Rect::default()
+ && mouse.row >= agent_area.y
+ && mouse.row < agent_area.y + agent_area.height;
+ if over_agent_panel {
+ if crate::ui::should_show_scrollbar(crate::ui::agent_panel_scroll_metrics(
+ self, agent_area,
+ )) {
+ self.scroll_agent_panel(1);
+ }
+ } else if crate::ui::should_show_scrollbar(
+ crate::ui::workspace_list_scroll_metrics(self, self.workspace_list_rect()),
+ ) {
+ self.scroll_workspace_list(1);
+ } else if !self.workspaces.is_empty() && self.selected < self.workspaces.len() - 1 {
+ self.selected += 1;
+ self.ensure_workspace_visible(self.selected);
+ }
+ }
+
+ MouseEventKind::Moved if self.mode == Mode::ContextMenu => {
+ let hovered = self.context_menu_item_at(mouse.column, mouse.row);
+ if let Some(menu) = &mut self.context_menu {
+ menu.list.hover(hovered);
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right) if in_sidebar && !self.sidebar_collapsed => {
+ if self
+ .workspace_list_scrollbar_target_at(mouse.column, mouse.row)
+ .is_some()
+ {
+ return None;
+ }
+ if let Some(idx) = self.workspace_at_row(mouse.row) {
+ self.selected = idx;
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: idx },
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right)
+ if self.tab_at(mouse.column, mouse.row).is_some() =>
+ {
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.switch_tab(tab_idx);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Tab { ws_idx, tab_idx },
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
+ if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Pane,
+ x: mouse.column,
+ y: mouse.row,
+ list: MenuListState::new(0),
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
+ _ => {}
+ }
+
+ None
+ }
+
+ pub(super) fn screen_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ let terminal = self.view.terminal_area;
+ let x = sidebar.x.min(terminal.x);
+ let y = sidebar.y.min(terminal.y);
+ let right = (sidebar.x + sidebar.width).max(terminal.x + terminal.width);
+ let bottom = (sidebar.y + sidebar.height).max(terminal.y + terminal.height);
+ Rect::new(x, y, right.saturating_sub(x), bottom.saturating_sub(y))
+ }
+
+ pub(crate) fn context_menu_rect(&self) -> Option<Rect> {
+ let menu = self.context_menu.as_ref()?;
+ let screen = self.screen_rect();
+ let max_item_w = menu
+ .items()
+ .iter()
+ .map(|item| item.len() as u16)
+ .max()
+ .unwrap_or(0);
+ let menu_w = (max_item_w + 4).max(14).min(screen.width.max(1));
+ let menu_h = (menu.items().len() as u16 + 2).min(screen.height.max(1));
+ let x = menu.x.min(screen.x + screen.width.saturating_sub(menu_w));
+ let y = menu.y.min(screen.y + screen.height.saturating_sub(menu_h));
+ Some(Rect::new(x, y, menu_w, menu_h))
+ }
+
+ pub(crate) fn confirm_close_rect(&self) -> Rect {
+ crate::ui::confirm_close_popup_rect(self.view.terminal_area).unwrap_or_default()
+ }
+
+ fn context_menu_item_at(&self, col: u16, row: u16) -> Option<usize> {
+ let menu_rect = self.context_menu_rect()?;
+ let inner_x = menu_rect.x + 1;
+ let inner_y = menu_rect.y + 1;
+ let inner_w = menu_rect.width.saturating_sub(2);
+ let inner_h = menu_rect.height.saturating_sub(2);
+ let item_count = self
+ .context_menu
+ .as_ref()
+ .map(|menu| menu.items().len() as u16)
+ .unwrap_or(0);
+ if col >= inner_x
+ && col < inner_x + inner_w
+ && row >= inner_y
+ && row < inner_y + inner_h.min(item_count)
+ {
+ Some((row - inner_y) as usize)
+ } else {
+ None
+ }
+ }
+
+ pub(super) fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
+ self.view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find_map(|(idx, area)| {
+ (area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width)
+ .then_some(idx)
+ })
+ }
+
+ pub(super) fn on_tab_bar(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_bar_rect;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn on_tab_scroll_left_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_scroll_left_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn on_tab_scroll_right_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.tab_scroll_right_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn tab_drop_index_at(&self, col: u16, row: u16) -> Option<usize> {
+ if !self.on_tab_bar(col, row) {
+ return None;
+ }
+
+ let visible_tabs: Vec<_> = self
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .filter(|(_, rect)| rect.width > 0)
+ .collect();
+ let (first_idx, first_rect) = *visible_tabs.first()?;
+ let (last_idx, last_rect) = *visible_tabs.last()?;
+
+ if self.on_tab_scroll_left_button(col, row) {
+ return Some(0);
+ }
+ if self.on_tab_scroll_right_button(col, row) {
+ return self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .map(|ws| ws.tabs.len());
+ }
+
+ let left_edge = if first_idx == 0 {
+ first_rect.x
+ } else {
+ self.view.tab_scroll_left_hit_area.x + self.view.tab_scroll_left_hit_area.width
+ };
+ let right_edge = if self
+ .active
+ .and_then(|idx| self.workspaces.get(idx))
+ .is_some_and(|ws| last_idx + 1 >= ws.tabs.len())
+ {
+ last_rect.x + last_rect.width
+ } else {
+ self.view.tab_scroll_right_hit_area.x.saturating_sub(1)
+ };
+
+ if col <= left_edge {
+ return Some(first_idx);
+ }
+ if col >= right_edge {
+ return Some(last_idx + 1);
+ }
+
+ for (idx, rect) in visible_tabs {
+ let midpoint = rect.x + rect.width / 2;
+ if col < midpoint {
+ return Some(idx);
+ }
+ if col < rect.x + rect.width {
+ return Some(idx + 1);
+ }
+ }
+
+ Some(last_idx + 1)
+ }
+
+ pub(super) fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.new_tab_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
+ pub(super) fn find_border_at(&self, col: u16, row: u16) -> Option<&SplitBorder> {
+ self.view.split_borders.iter().find(|b| match b.direction {
+ Direction::Horizontal => {
+ (col as i32 - b.pos as i32).unsigned_abs() <= 1
+ && row >= b.area.y
+ && row < b.area.y + b.area.height
+ }
+ Direction::Vertical => {
+ (row as i32 - b.pos as i32).unsigned_abs() <= 1
+ && col >= b.area.x
+ && col < b.area.x + b.area.width
+ }
+ })
+ }
+
+ pub(super) fn pane_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|p| {
+ col >= p.inner_rect.x
+ && col < p.inner_rect.x + p.inner_rect.width
+ && row >= p.inner_rect.y
+ && row < p.inner_rect.y + p.inner_rect.height
+ })
+ }
+
+ pub(super) fn pane_mouse_target(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.pane_at(col, row)
+ .or_else(|| self.pane_frame_at(col, row))
+ }
+
+ pub(super) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|info| info.id == pane_id)
+ }
+
+ pub(super) fn pane_frame_at(&self, col: u16, row: u16) -> Option<&PaneInfo> {
+ self.view.pane_infos.iter().find(|p| {
+ col >= p.rect.x
+ && col < p.rect.x + p.rect.width
+ && row >= p.rect.y
+ && row < p.rect.y + p.rect.height
+ })
+ }
+
+ pub(super) fn focus_pane(&mut self, pane_id: crate::layout::PaneId) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if ws.layout.focused() != pane_id {
+ ws.layout.focus_pane(pane_id);
+ self.mark_session_dirty();
+ }
+ }
+ }
+
+ pub(super) fn scroll_pane_up(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.scroll_up(lines);
+ }
+ }
+ }
+
+ pub(super) fn scroll_pane_down(&self, pane_id: crate::layout::PaneId, lines: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.scroll_down(lines);
+ }
+ }
+ }
+
+ pub(super) fn pane_scroll_metrics(
+ &self,
+ pane_id: crate::layout::PaneId,
+ ) -> Option<crate::pane::ScrollMetrics> {
+ self.active
+ .and_then(|i| self.workspaces.get(i))
+ .and_then(|ws| ws.runtime(pane_id))
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ }
+
+ pub(super) fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
+ const LINES_PER_NOTCH: usize = 3;
+
+ if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.forward_pane_wheel(&info, mouse) {
+ return;
+ }
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.focused_runtime() {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
+ _ => {}
+ }
+ }
+ }
+ }
+
+ pub(super) fn forward_pane_mouse_button(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ return false;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = rt.encode_mouse_button(mouse.kind, column, row, mouse.modifiers) else {
+ return false;
+ };
+ rt.scroll_reset();
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, kind = ?mouse.kind, "failed to forward mouse button event");
+ }
+ true
+ }
+
+ pub(super) fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ return false;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ match rt.wheel_routing() {
+ Some(crate::pane::WheelRouting::HostScroll) | None => false,
+ Some(crate::pane::WheelRouting::MouseReport) => {
+ rt.scroll_reset();
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = rt.encode_mouse_wheel(mouse.kind, column, row, mouse.modifiers)
+ else {
+ warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
+ return true;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
+ }
+ true
+ }
+ Some(crate::pane::WheelRouting::AlternateScroll) => {
+ rt.scroll_reset();
+ let Some(bytes) = rt.encode_alternate_scroll(mouse.kind) else {
+ return true;
+ };
+ if let Err(err) = rt.try_send_bytes(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key");
+ }
+ true
+ }
+ }
+ }
+
+ pub(super) fn set_pane_scroll_offset(
+ &self,
+ pane_id: crate::layout::PaneId,
+ offset_from_bottom: usize,
+ ) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.runtimes.get(&pane_id) {
+ rt.set_scroll_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+
+ pub(super) fn scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<(crate::layout::PaneId, ScrollbarClickTarget)> {
+ let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let info = self.view.pane_infos.iter().find(|info| {
+ crate::ui::pane_scrollbar_rect(info).is_some_and(|track| {
+ col >= track.x
+ && col < track.x + track.width
+ && row >= track.y
+ && row < track.y + track.height
+ })
+ })?;
+ let rt = ws.runtimes.get(&info.id)?;
+ let metrics = rt.scroll_metrics()?;
+ if metrics.max_offset_from_bottom == 0 {
+ return None;
+ }
+ let track = crate::ui::pane_scrollbar_rect(info)?;
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some((info.id, ScrollbarClickTarget::Thumb { grab_row_offset }))
+ } else {
+ Some((
+ info.id,
+ ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ },
+ ))
+ }
+ }
+
+ pub(super) fn scrollbar_offset_for_pane_row(
+ &self,
+ pane_id: crate::layout::PaneId,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let ws = self.active.and_then(|i| self.workspaces.get(i))?;
+ let info = self
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)?;
+ let track = crate::ui::pane_scrollbar_rect(info)?;
+ let rt = ws.runtimes.get(&pane_id)?;
+ let metrics = rt.scroll_metrics()?;
+ if metrics.max_offset_from_bottom == 0 {
+ return None;
+ }
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+}
+
+#[cfg(test)]
+pub(super) fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting {
+ if input_state.mouse_protocol_mode.reporting_enabled() {
+ WheelRouting::MouseReport
+ } else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
+ WheelRouting::AlternateScroll
+ } else {
+ WheelRouting::HostScroll
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEventKind};
+ use ratatui::layout::{Direction, Rect};
+
+ use super::super::{
+ app_for_mouse_test, capture_snapshot, handle_context_menu_key, mouse, root_layout_ratio,
+ };
+ use super::*;
+ use crate::{
+ app::state::{ContextMenuKind, ContextMenuState, MenuListState, Mode},
+ workspace::Workspace,
+ };
+
+ #[test]
+ fn hovering_context_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 0 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(0),
+ });
+ app.state.mode = Mode::ContextMenu;
+
+ let menu = app.state.context_menu_rect().unwrap();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.context_menu.unwrap().list.highlighted, 1);
+ }
+
+ #[test]
+ fn clicking_confirm_close_accepts_workspace_close() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 1;
+ app.state.mode = Mode::ConfirmClose;
+
+ let popup = app.state.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ confirm.x,
+ confirm.y,
+ ));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn clicking_confirm_close_accepts_after_workspace_context_menu_close() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.state.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Workspace { ws_idx: 1 },
+ x: 2,
+ y: 2,
+ list: MenuListState::new(1),
+ });
+ app.state.mode = Mode::ContextMenu;
+ handle_context_menu_key(
+ &mut app.state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+ assert_eq!(app.state.mode, Mode::ConfirmClose);
+ assert_eq!(app.state.selected, 1);
+
+ let popup = app.state.confirm_close_rect();
+ let inner = Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ );
+ let (confirm, _) = crate::ui::confirm_close_button_rects(inner);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ confirm.x + 1,
+ confirm.y,
+ ));
+
+ assert_eq!(app.state.workspaces.len(), 1);
+ assert_eq!(app.state.workspaces[0].display_name(), "a");
+ }
+
+ #[test]
+ fn dragging_pane_split_updates_captured_layout_ratio() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.workspaces[0].test_split(Direction::Horizontal);
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let border = app.state.view.split_borders[0].clone();
+ let before = capture_snapshot(&app.state);
+ let drag_row = border.area.y.saturating_add(1);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ border.pos,
+ drag_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ border.pos.saturating_add(6),
+ drag_row,
+ ));
+
+ let after = capture_snapshot(&app.state);
+ assert_ne!(root_layout_ratio(&before), root_layout_ratio(&after));
+ }
+
+ #[test]
+ fn wheel_routing_prefers_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
+ }
+
+ #[test]
+ fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::None,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll);
+ }
+
+ #[test]
+ fn wheel_routing_falls_back_to_host_scrollback() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: false,
+ application_cursor: false,
+ bracketed_paste: false,
+ focus_reporting: false,
+ mouse_protocol_mode: crate::input::MouseProtocolMode::None,
+ mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
+ }
+}
diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs
new file mode 100644
index 00000000..26769dfe
--- /dev/null
+++ b/src/app/input/navigate.rs
@@ -0,0 +1,780 @@
+use std::process::{Command, Stdio};
+
+use crossterm::event::{KeyCode, KeyEvent};
+use ratatui::layout::Direction;
+
+use crate::{
+ app::{
+ state::{key_matches, AppState, Mode},
+ App,
+ },
+ layout::NavDirection,
+};
+
+pub(crate) fn terminal_direct_navigation_action(
+ state: &AppState,
+ key: &KeyEvent,
+) -> Option<NavigateAction> {
+ let kb = &state.keybinds;
+ if kb
+ .previous_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousWorkspace);
+ }
+ if kb
+ .next_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextWorkspace);
+ }
+ if kb
+ .previous_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousTab);
+ }
+ if kb
+ .next_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextTab);
+ }
+ if kb
+ .focus_pane_left
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneLeft);
+ }
+ if kb
+ .focus_pane_down
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneDown);
+ }
+ if kb
+ .focus_pane_up
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneUp);
+ }
+ if kb
+ .focus_pane_right
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::FocusPaneRight);
+ }
+ None
+}
+
+impl App {
+ pub(super) fn handle_navigate_key(&mut self, key: KeyEvent) {
+ self.state.update_dismissed = true;
+
+ if self.state.is_prefix(&key) || key.code == KeyCode::Esc {
+ leave_navigate_mode(&mut self.state);
+ return;
+ }
+
+ if let Some(action) = navigate_action_for_key(&self.state, &key) {
+ execute_navigate_action(&mut self.state, action);
+ return;
+ }
+
+ if handle_navigate_reserved_key(&mut self.state, key) {
+ return;
+ }
+
+ if let Some(binding) = navigate_custom_command_for_key(&self.state, &key) {
+ self.launch_custom_command(binding);
+ }
+ }
+
+ fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
+ let previous_toast = self.state.toast.clone();
+ let result = match binding.action {
+ crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
+ crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command),
+ };
+ match result {
+ Ok(()) => leave_navigate_mode(&mut self.state),
+ Err(err) => {
+ self.state.toast = Some(crate::app::state::ToastNotification {
+ kind: crate::app::state::ToastKind::NeedsAttention,
+ title: "custom command failed".to_string(),
+ context: err.to_string(),
+ });
+ self.sync_toast_deadline(previous_toast);
+ }
+ }
+ }
+
+ fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
+ let mut env = vec![(
+ crate::api::SOCKET_PATH_ENV_VAR.to_string(),
+ crate::api::socket_path().display().to_string(),
+ )];
+ if let Ok(current_exe) = std::env::current_exe() {
+ env.push((
+ "HERDR_BIN_PATH".to_string(),
+ current_exe.display().to_string(),
+ ));
+ }
+
+ let mut cwd = None;
+ if let Some(ws_idx) = self.state.active {
+ env.push((
+ "HERDR_ACTIVE_WORKSPACE_ID".to_string(),
+ self.public_workspace_id(ws_idx),
+ ));
+ if let Some(workspace) = self.state.workspaces.get(ws_idx) {
+ let tab_idx = workspace.active_tab_index();
+ if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
+ env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
+ }
+ if let Some(pane_id) = workspace.focused_pane_id() {
+ if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
+ env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
+ }
+ if let Some(pane_cwd) = workspace
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(pane_id))
+ {
+ env.push((
+ "HERDR_ACTIVE_PANE_CWD".to_string(),
+ pane_cwd.display().to_string(),
+ ));
+ if pane_cwd.is_dir() {
+ cwd = Some(pane_cwd);
+ }
+ }
+ }
+ }
+ }
+ (env, cwd)
+ }
+
+ fn spawn_custom_command(
+ &self,
+ binding: &crate::config::CustomCommandKeybind,
+ ) -> std::io::Result<()> {
+ let mut command = Command::new("/bin/sh");
+ command
+ .arg("-lc")
+ .arg(&binding.command)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ let (env, cwd) = self.custom_command_env();
+ command.envs(env);
+ if let Some(cwd) = cwd {
+ command.current_dir(cwd);
+ }
+ command.spawn()?;
+ Ok(())
+ }
+
+ fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
+ let Some(ws_idx) = self.state.active else {
+ return Err(std::io::Error::other("no active workspace"));
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let new_rows = rows.max(4);
+ let new_cols = cols.max(10);
+ let (env, _) = self.custom_command_env();
+
+ let ws = self
+ .state
+ .workspaces
+ .get_mut(ws_idx)
+ .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
+ let tab_idx = ws.active_tab_index();
+ let previous_focus = ws
+ .focused_pane_id()
+ .ok_or_else(|| std::io::Error::other("no focused pane"))?;
+ let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
+ let cwd = ws
+ .active_tab()
+ .and_then(|tab| tab.cwd_for_pane(previous_focus));
+ let new_pane_id = ws.split_focused_command(
+ Direction::Horizontal,
+ new_rows,
+ new_cols,
+ cwd,
+ command,
+ &env,
+ self.state.pane_scrollback_limit_bytes,
+ self.state.host_terminal_theme,
+ )?;
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .layout
+ .focus_pane(new_pane_id);
+ ws.active_tab_mut()
+ .expect("workspace must have an active tab")
+ .zoomed = true;
+ self.overlay_panes.insert(
+ new_pane_id,
+ super::super::OverlayPaneState {
+ ws_idx,
+ tab_idx,
+ previous_focus,
+ previous_zoomed,
+ },
+ );
+ self.state.mode = Mode::Terminal;
+ Ok(())
+ }
+}
+
+fn navigate_custom_command_for_key(
+ state: &AppState,
+ key: &KeyEvent,
+) -> Option<crate::config::CustomCommandKeybind> {
+ state
+ .keybinds
+ .custom_commands
+ .iter()
+ .find(|binding| key_matches(key, binding.key.0, binding.key.1))
+ .cloned()
+}
+
+pub(super) fn handle_navigate_reserved_key(state: &mut AppState, key: KeyEvent) -> bool {
+ match key.code {
+ KeyCode::Char('q') => {
+ super::modal::request_quit_or_detach(state);
+ leave_navigate_mode(state);
+ true
+ }
+ KeyCode::Enter => {
+ if !state.workspaces.is_empty() {
+ state.switch_workspace(state.selected);
+ leave_navigate_mode(state);
+ }
+ true
+ }
+ KeyCode::Char(c @ '1'..='9') => {
+ let idx = (c as usize) - ('1' as usize);
+ if idx < state.workspaces.len() {
+ state.switch_workspace(idx);
+ leave_navigate_mode(state);
+ }
+ true
+ }
+ KeyCode::Char('s') => {
+ super::settings::open_settings(state);
+ true
+ }
+ KeyCode::Char('?') => {
+ super::modal::open_keybind_help(state);
+ true
+ }
+ KeyCode::Up => {
+ if state.selected > 0 {
+ state.selected -= 1;
+ state.ensure_workspace_visible(state.selected);
+ }
+ true
+ }
+ KeyCode::Down => {
+ if !state.workspaces.is_empty() && state.selected < state.workspaces.len() - 1 {
+ state.selected += 1;
+ state.ensure_workspace_visible(state.selected);
+ }
+ true
+ }
+ KeyCode::Char('h') | KeyCode::Left => {
+ state.navigate_pane(NavDirection::Left);
+ true
+ }
+ KeyCode::Char('j') => {
+ state.navigate_pane(NavDirection::Down);
+ true
+ }
+ KeyCode::Char('k') => {
+ state.navigate_pane(NavDirection::Up);
+ true
+ }
+ KeyCode::Char('l') | KeyCode::Right => {
+ state.navigate_pane(NavDirection::Right);
+ true
+ }
+ KeyCode::Tab => {
+ state.cycle_pane(false);
+ true
+ }
+ KeyCode::BackTab => {
+ state.cycle_pane(true);
+ true
+ }
+ _ => false,
+ }
+}
+
+#[allow(dead_code)] // exercised in input unit tests; production uses App::handle_navigate_key
+pub(crate) fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
+ state.update_dismissed = true;
+
+ if state.is_prefix(&key) || key.code == KeyCode::Esc {
+ leave_navigate_mode(state);
+ return;
+ }
+
+ if let Some(action) = navigate_action_for_key(state, &key) {
+ execute_navigate_action(state, action);
+ return;
+ }
+
+ let _ = handle_navigate_reserved_key(state, key);
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum NavigateAction {
+ NewWorkspace,
+ RenameWorkspace,
+ CloseWorkspace,
+ PreviousWorkspace,
+ NextWorkspace,
+ NewTab,
+ RenameTab,
+ PreviousTab,
+ NextTab,
+ CloseTab,
+ FocusPaneLeft,
+ FocusPaneDown,
+ FocusPaneUp,
+ FocusPaneRight,
+ SplitVertical,
+ SplitHorizontal,
+ ClosePane,
+ Fullscreen,
+ EnterResizeMode,
+ ToggleSidebar,
+ Detach,
+}
+
+fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+ let kb = &state.keybinds;
+ if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
+ return Some(NavigateAction::NewWorkspace);
+ }
+ if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) {
+ return Some(NavigateAction::RenameWorkspace);
+ }
+ if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
+ return Some(NavigateAction::CloseWorkspace);
+ }
+ if kb
+ .previous_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousWorkspace);
+ }
+ if kb
+ .next_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextWorkspace);
+ }
+ if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
+ return Some(NavigateAction::NewTab);
+ }
+ if kb
+ .rename_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::RenameTab);
+ }
+ if kb
+ .previous_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousTab);
+ }
+ if kb
+ .next_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextTab);
+ }
+ if kb
+ .close_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::CloseTab);
+ }
+ if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
+ return Some(NavigateAction::SplitVertical);
+ }
+ if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) {
+ return Some(NavigateAction::SplitHorizontal);
+ }
+ if key_matches(key, kb.close_pane.0, kb.close_pane.1) {
+ return Some(NavigateAction::ClosePane);
+ }
+ if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) {
+ return Some(NavigateAction::Fullscreen);
+ }
+ if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) {
+ return Some(NavigateAction::EnterResizeMode);
+ }
+ if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
+ return Some(NavigateAction::ToggleSidebar);
+ }
+ if kb
+ .detach
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::Detach);
+ }
+ None
+}
+
+pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
+ match action {
+ NavigateAction::NewWorkspace => {
+ state.request_new_workspace = true;
+ leave_navigate_mode(state);
+ }
+ NavigateAction::RenameWorkspace => {
+ if !state.workspaces.is_empty() {
+ super::modal::open_rename_workspace(state, state.selected);
+ }
+ }
+ NavigateAction::CloseWorkspace => {
+ if !state.workspaces.is_empty() {
+ if state.confirm_close {
+ super::modal::open_confirm_close(state);
+ } else {
+ state.close_selected_workspace();
+ leave_navigate_mode(state);
+ }
+ }
+ }
+ NavigateAction::PreviousWorkspace => {
+ state.previous_workspace();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextWorkspace => {
+ state.next_workspace();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NewTab => super::modal::open_new_tab_dialog(state),
+ NavigateAction::RenameTab => super::modal::open_rename_active_tab(state, false),
+ NavigateAction::PreviousTab => {
+ state.previous_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextTab => {
+ state.next_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::CloseTab => {
+ state.close_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left),
+ NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down),
+ NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up),
+ NavigateAction::FocusPaneRight => state.navigate_pane(NavDirection::Right),
+ NavigateAction::SplitVertical => {
+ state.split_pane(Direction::Horizontal);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::SplitHorizontal => {
+ state.split_pane(Direction::Vertical);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::ClosePane => {
+ state.close_pane();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Fullscreen => {
+ state.toggle_fullscreen();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::EnterResizeMode => state.mode = Mode::Resize,
+ NavigateAction::ToggleSidebar => {
+ state.sidebar_collapsed = !state.sidebar_collapsed;
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Detach => {
+ state.detach_requested = true;
+ leave_navigate_mode(state);
+ }
+ }
+}
+
+fn leave_navigate_mode(state: &mut AppState) {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::Duration;
+
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+ use ratatui::layout::{Direction, Rect};
+
+ use super::super::{state_with_workspaces, unique_temp_path, wait_for_file};
+ use super::*;
+ use crate::{app::App, config::Config, input::TerminalKey, workspace::Workspace};
+
+ #[test]
+ fn custom_rename_key_enters_rename_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.rename_workspace_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::RenameWorkspace);
+ assert_eq!(state.name_input, "test");
+ }
+
+ #[test]
+ fn custom_new_workspace_key_requests_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.new_workspace_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.request_new_workspace);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.toggle_sidebar_label = "g".into();
+ assert!(!state.sidebar_collapsed);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.sidebar_collapsed);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_resize_key_enters_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.resize_mode_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Resize);
+ }
+
+ #[test]
+ fn movement_action_stays_in_navigate_mode() {
+ let mut state = state_with_workspaces(&["a", "b"]);
+ state.selected = 0;
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn terminal_direct_focus_pane_shortcut_maps_to_navigation_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.focus_pane_left = Some((KeyCode::Left, KeyModifiers::ALT));
+ state.keybinds.focus_pane_left_label = Some("alt+left".into());
+
+ let action = terminal_direct_navigation_action(
+ &state,
+ &KeyEvent::new(KeyCode::Left, KeyModifiers::ALT),
+ );
+
+ assert_eq!(action, Some(NavigateAction::FocusPaneLeft));
+ }
+
+ #[tokio::test]
+ async fn custom_command_runs_from_prefix_key_in_navigate_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("custom-command-keybind");
+ let command = format!(
+ "printf '%s\\n%s\\n%s\\n' \"$HERDR_ACTIVE_WORKSPACE_ID\" \"$HERDR_ACTIVE_TAB_ID\" \"$HERDR_ACTIVE_PANE_ID\" > '{}'",
+ output_path.display()
+ );
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ key: (KeyCode::Char('g'), KeyModifiers::empty()),
+ label: "g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Shell,
+ }];
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ assert_eq!(app.state.mode, Mode::Navigate);
+
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
+
+ let content = wait_for_file(&output_path);
+ let lines: Vec<&str> = content.lines().collect();
+ assert_eq!(lines.len(), 3);
+ assert_eq!(lines[0], app.state.workspaces[0].id);
+ assert_eq!(lines[1], format!("{}:1", app.state.workspaces[0].id));
+ assert_eq!(lines[2], format!("{}-1", app.state.workspaces[0].id));
+ assert_eq!(app.state.mode, Mode::Terminal);
+
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[tokio::test]
+ async fn pane_overlay_command_opens_and_closes_after_exit() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ let workspace = Workspace::new(
+ std::env::current_dir().unwrap_or_else(|_| "/".into()),
+ 24,
+ 80,
+ app.state.pane_scrollback_limit_bytes,
+ app.state.host_terminal_theme,
+ app.event_tx.clone(),
+ app.render_notify.clone(),
+ app.render_dirty.clone(),
+ )
+ .expect("workspace should spawn");
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let output_path = unique_temp_path("custom-pane-command");
+ let command = format!("printf done > '{}'", output_path.display());
+ app.state.keybinds.custom_commands = vec![crate::config::CustomCommandKeybind {
+ key: (KeyCode::Char('g'), KeyModifiers::empty()),
+ label: "g".into(),
+ command,
+ action: crate::config::CustomCommandAction::Pane,
+ }];
+
+ app.handle_key(TerminalKey::new(
+ app.state.prefix_code,
+ app.state.prefix_mods,
+ ))
+ .await;
+ app.handle_key(TerminalKey::new(KeyCode::Char('g'), KeyModifiers::empty()))
+ .await;
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 2);
+ assert!(app.state.workspaces[0].tabs[0].zoomed);
+
+ let _ = wait_for_file(&output_path);
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ while std::time::Instant::now() < deadline {
+ if app.drain_internal_events()
+ && app.state.workspaces[0].tabs[0].layout.pane_count() == 1
+ {
+ break;
+ }
+ std::thread::sleep(Duration::from_millis(20));
+ }
+
+ assert_eq!(app.state.workspaces[0].tabs[0].layout.pane_count(), 1);
+ assert!(!app.state.workspaces[0].tabs[0].zoomed);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let _ = std::fs::remove_file(output_path);
+ }
+
+ #[test]
+ fn fullscreen_action_exits_navigate_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+ state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.fullscreen_label = "g".into();
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
+ );
+
+ assert!(state.workspaces[0].zoomed);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn question_mark_opens_keybind_help_from_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+
+ handle_navigate_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT),
+ );
+
+ assert_eq!(state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn new_tab_action_opens_dialog_without_creating_tab() {
+ let mut state = state_with_workspaces(&["test"]);
+
+ execute_navigate_action(&mut state, NavigateAction::NewTab);
+
+ assert_eq!(state.mode, Mode::RenameTab);
+ assert!(state.creating_new_tab);
+ assert_eq!(state.name_input, "2");
+ assert!(state.name_input_replace_on_type);
+ assert!(!state.request_new_tab);
+ assert_eq!(state.workspaces[0].tabs.len(), 1);
+ }
+
+ #[test]
+ fn persistence_mode_navigate_q_detaches_instead_of_quitting_server() {
+ let mut state = crate::app::state::AppState::test_new();
+ state.quit_detaches = true;
+
+ assert!(handle_navigate_reserved_key(
+ &mut state,
+ KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty())
+ ));
+ assert!(state.detach_requested);
+ assert!(!state.should_quit);
+ }
+}
diff --git a/src/app/input/overlays.rs b/src/app/input/overlays.rs
new file mode 100644
index 00000000..6475895e
--- /dev/null
+++ b/src/app/input/overlays.rs
@@ -0,0 +1,483 @@
+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
+use ratatui::{
+ layout::Rect,
+ widgets::{Block, Borders},
+};
+
+use crate::app::{
+ state::{AppState, DragState, DragTarget, Mode},
+ App,
+};
+
+use super::{
+ modal::{leave_modal, modal_action_from_buttons, ModalAction},
+ ScrollbarClickTarget,
+};
+
+impl App {
+ pub(super) fn handle_overlay_mouse(&mut self, mouse: MouseEvent) -> bool {
+ if self.state.mode == Mode::ReleaseNotes {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left)
+ if self
+ .state
+ .release_notes_close_button_at(mouse.column, mouse.row) =>
+ {
+ self.dismiss_release_notes();
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(target) = self
+ .state
+ .release_notes_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.state.drag = Some(DragState {
+ target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.state
+ .set_release_notes_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ }
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if let Some(DragState {
+ target: DragTarget::ReleaseNotesScrollbar { grab_row_offset },
+ }) = &self.state.drag
+ {
+ if let Some(offset_from_bottom) = self
+ .state
+ .release_notes_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.state
+ .set_release_notes_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ self.state.drag = None;
+ }
+ MouseEventKind::ScrollUp => self.scroll_release_notes(-3),
+ MouseEventKind::ScrollDown => self.scroll_release_notes(3),
+ _ => {}
+ }
+ return true;
+ }
+
+ if self.state.mode == Mode::KeybindHelp {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left)
+ if self
+ .state
+ .keybind_help_close_button_at(mouse.column, mouse.row) =>
+ {
+ leave_modal(&mut self.state);
+ }
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(target) = self
+ .state
+ .keybind_help_scrollbar_target_at(mouse.column, mouse.row)
+ {
+ match target {
+ ScrollbarClickTarget::Thumb { grab_row_offset } => {
+ self.state.drag = Some(DragState {
+ target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
+ });
+ }
+ ScrollbarClickTarget::Track { offset_from_bottom } => {
+ self.state
+ .set_keybind_help_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ } else {
+ let rect = self.state.keybind_help_popup_rect();
+ let inside = mouse.column >= rect.x
+ && mouse.column < rect.x + rect.width
+ && mouse.row >= rect.y
+ && mouse.row < rect.y + rect.height;
+ if !inside {
+ leave_modal(&mut self.state);
+ }
+ }
+ }
+ MouseEventKind::Drag(MouseButton::Left) => {
+ if let Some(DragState {
+ target: DragTarget::KeybindHelpScrollbar { grab_row_offset },
+ }) = &self.state.drag
+ {
+ if let Some(offset_from_bottom) = self
+ .state
+ .keybind_help_offset_for_drag_row(mouse.row, *grab_row_offset)
+ {
+ self.state
+ .set_keybind_help_offset_from_bottom(offset_from_bottom);
+ }
+ }
+ }
+ MouseEventKind::Up(MouseButton::Left) => {
+ self.state.drag = None;
+ }
+ MouseEventKind::ScrollUp => self.state.scroll_keybind_help(-3),
+ MouseEventKind::ScrollDown => self.state.scroll_keybind_help(3),
+ _ => {}
+ }
+ return true;
+ }
+
+ false
+ }
+}
+
+impl AppState {
+ pub(super) fn onboarding_full_area(&self) -> Rect {
+ self.view.sidebar_rect.union(self.view.terminal_area)
+ }
+
+ pub(super) fn onboarding_modal_inner(&self, popup_w: u16, popup_h: u16) -> Option<Rect> {
+ let area = self.onboarding_full_area();
+ let popup_w = popup_w.min(area.width.saturating_sub(4));
+ let popup_h = popup_h.min(area.height.saturating_sub(2));
+ if popup_w < 4 || popup_h < 4 {
+ return None;
+ }
+ let popup_x = area.x + (area.width.saturating_sub(popup_w)) / 2;
+ let popup_y = area.y + (area.height.saturating_sub(popup_h)) / 2;
+ let popup = Rect::new(popup_x, popup_y, popup_w, popup_h);
+ Some(Block::default().borders(Borders::ALL).inner(popup))
+ }
+
+ fn release_notes_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(
+ crate::ui::RELEASE_NOTES_MODAL_SIZE.0,
+ crate::ui::RELEASE_NOTES_MODAL_SIZE.1,
+ )
+ }
+
+ fn release_notes_close_button_at(&self, col: u16, row: u16) -> bool {
+ let Some(inner) = self.release_notes_modal_inner() else {
+ return false;
+ };
+ if inner.height < 4 || inner.width < 12 {
+ return false;
+ }
+ let button =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ col >= button.x
+ && col < button.x + button.width
+ && row >= button.y
+ && row < button.y + button.height
+ }
+
+ pub(super) fn rename_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(56, 7)
+ }
+
+ fn release_notes_body_rect(&self) -> Option<Rect> {
+ let inner = self.release_notes_modal_inner()?;
+ if inner.height < 8 || inner.width < 4 {
+ return None;
+ }
+ let body = crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content;
+ let preview = self
+ .release_notes
+ .as_ref()
+ .is_some_and(|notes| notes.preview);
+ Some(crate::ui::release_notes_sections(body, preview).notes_body)
+ }
+
+ fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
+ let notes = self.release_notes.as_ref()?;
+ let body = self.release_notes_body_rect()?;
+ let viewport_rows = body.height.max(1) as usize;
+ let lines = crate::ui::release_notes_display_lines(notes, &self.palette);
+
+ let rows_for_width = |wrap_width: usize| {
+ lines
+ .iter()
+ .map(|(width, _)| width.max(&1).div_ceil(wrap_width.max(1)))
+ .sum::<usize>()
+ };
+
+ let full_width = body.width.max(1) as usize;
+ let mut total_rows = rows_for_width(full_width);
+ let wrap_width = if total_rows > viewport_rows && full_width > 1 {
+ body.width.saturating_sub(1).max(1) as usize
+ } else {
+ full_width
+ };
+ total_rows = rows_for_width(wrap_width);
+
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ Some(crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom.saturating_sub(notes.scroll as usize),
+ max_offset_from_bottom,
+ viewport_rows,
+ })
+ }
+
+ pub(crate) fn release_notes_max_scroll(&self) -> u16 {
+ self.release_notes_scroll_metrics()
+ .map(|metrics| metrics.max_offset_from_bottom as u16)
+ .unwrap_or(0)
+ }
+
+ fn release_notes_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let body = self.release_notes_body_rect()?;
+ let metrics = self.release_notes_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ if !(col >= track.x
+ && col < track.x + track.width
+ && row >= track.y
+ && row < track.y + track.height)
+ {
+ return None;
+ }
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some(ScrollbarClickTarget::Thumb { grab_row_offset })
+ } else {
+ Some(ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ })
+ }
+ }
+
+ fn release_notes_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
+ let body = self.release_notes_body_rect()?;
+ let metrics = self.release_notes_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ fn set_release_notes_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let max_scroll = self.release_notes_max_scroll() as usize;
+ if let Some(notes) = &mut self.release_notes {
+ notes.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
+ }
+ }
+
+ pub(super) fn handle_onboarding_mouse(&mut self, mouse: MouseEvent) {
+ if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ return;
+ }
+
+ match self.onboarding_step {
+ 0 => {
+ let Some(inner) = self.onboarding_modal_inner(64, 16) else {
+ return;
+ };
+ let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1)
+ .actions
+ .unwrap_or_default();
+ let button = crate::ui::onboarding_welcome_continue_rect(actions);
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
+ && modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[(button, ModalAction::Continue)],
+ ) == Some(ModalAction::Continue)
+ {
+ self.onboarding_step = 1;
+ }
+ }
+ _ => {
+ let Some(inner) = self.onboarding_modal_inner(56, 14) else {
+ return;
+ };
+ let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1);
+ if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 {
+ self.onboarding_list
+ .select((mouse.row - stack.content.y) as usize);
+ return;
+ }
+
+ let (back, save) = crate::ui::onboarding_notification_button_rects(
+ stack.actions.unwrap_or_default(),
+ );
+ if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
+ match modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[(back, ModalAction::Back), (save, ModalAction::Save)],
+ ) {
+ Some(ModalAction::Back) => self.onboarding_step = 0,
+ Some(ModalAction::Save) => self.request_complete_onboarding = true,
+ _ => {}
+ }
+ }
+ }
+ }
+ }
+
+ pub(super) fn keybind_help_popup_rect(&self) -> Rect {
+ crate::ui::centered_popup_rect(self.screen_rect(), 76, 22).unwrap_or_default()
+ }
+
+ fn keybind_help_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(76, 22)
+ }
+
+ fn keybind_help_close_button_at(&self, col: u16, row: u16) -> bool {
+ let Some(inner) = self.keybind_help_modal_inner() else {
+ return false;
+ };
+ if inner.height < 4 || inner.width < 12 {
+ return false;
+ }
+ let button =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ col >= button.x
+ && col < button.x + button.width
+ && row >= button.y
+ && row < button.y + button.height
+ }
+
+ fn keybind_help_body_rect(&self) -> Option<Rect> {
+ let inner = self.keybind_help_modal_inner()?;
+ if inner.height < 6 || inner.width < 4 {
+ return None;
+ }
+ Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
+ }
+
+ fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
+ let body = self.keybind_help_body_rect()?;
+ let viewport_rows = body.height.max(1) as usize;
+ let wrap_width = body.width.max(1) as usize;
+ let total_rows = crate::ui::keybind_help_lines(self)
+ .into_iter()
+ .map(|(width, _)| width.max(1).div_ceil(wrap_width))
+ .sum::<usize>();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ Some(crate::pane::ScrollMetrics {
+ offset_from_bottom: max_offset_from_bottom
+ .saturating_sub(self.keybind_help.scroll as usize),
+ max_offset_from_bottom,
+ viewport_rows,
+ })
+ }
+
+ fn keybind_help_scrollbar_target_at(&self, col: u16, row: u16) -> Option<ScrollbarClickTarget> {
+ let body = self.keybind_help_body_rect()?;
+ let metrics = self.keybind_help_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ if !(col >= track.x
+ && col < track.x + track.width
+ && row >= track.y
+ && row < track.y + track.height)
+ {
+ return None;
+ }
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some(ScrollbarClickTarget::Thumb { grab_row_offset })
+ } else {
+ Some(ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ })
+ }
+ }
+
+ fn keybind_help_offset_for_drag_row(&self, row: u16, grab_row_offset: u16) -> Option<usize> {
+ let body = self.keybind_help_body_rect()?;
+ let metrics = self.keybind_help_scroll_metrics()?;
+ let track = crate::ui::release_notes_scrollbar_rect(body, metrics)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ pub(crate) fn keybind_help_max_scroll(&self) -> u16 {
+ self.keybind_help_scroll_metrics()
+ .map(|metrics| metrics.max_offset_from_bottom as u16)
+ .unwrap_or(0)
+ }
+
+ fn set_keybind_help_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let max_scroll = self.keybind_help_max_scroll() as usize;
+ self.keybind_help.scroll = max_scroll.saturating_sub(offset_from_bottom) as u16;
+ }
+
+ pub(super) fn scroll_keybind_help(&mut self, delta: i16) {
+ let max_scroll = self.keybind_help_max_scroll();
+ let current = self.keybind_help.scroll as i16;
+ self.keybind_help.scroll = current.saturating_add(delta).clamp(0, max_scroll as i16) as u16;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, mouse};
+ use super::*;
+
+ #[test]
+ fn clicking_keybind_help_close_button_closes_overlay() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::KeybindHelp;
+
+ let rect = app.state.keybind_help_popup_rect();
+ let inner = Rect::new(
+ rect.x + 1,
+ rect.y + 1,
+ rect.width.saturating_sub(2),
+ rect.height.saturating_sub(2),
+ );
+ let close =
+ crate::ui::release_notes_close_button_rect(Rect::new(inner.x, inner.y, inner.width, 1));
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ close.x,
+ close.y,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn onboarding_hover_does_not_change_selection() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::Onboarding;
+ app.state.onboarding_step = 1;
+ app.state.onboarding_list.select(1);
+
+ let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
+ let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
+ app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y));
+
+ assert_eq!(app.state.onboarding_list.selected, 1);
+ }
+
+ #[test]
+ fn onboarding_click_selects_notification_option() {
+ let mut app = app_for_mouse_test();
+ app.state.mode = Mode::Onboarding;
+ app.state.onboarding_step = 1;
+ app.state.onboarding_list.select(0);
+
+ let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
+ let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ content.x + 2,
+ content.y + 2,
+ ));
+
+ assert_eq!(app.state.onboarding_list.selected, 2);
+ }
+}
diff --git a/src/app/input/selection.rs b/src/app/input/selection.rs
new file mode 100644
index 00000000..953e9b26
--- /dev/null
+++ b/src/app/input/selection.rs
@@ -0,0 +1,68 @@
+use crossterm::event::{MouseEvent, MouseEventKind};
+
+use crate::app::state::AppState;
+
+impl AppState {
+ fn update_selection_cursor(
+ &mut self,
+ pane_id: crate::layout::PaneId,
+ screen_col: u16,
+ screen_row: u16,
+ ) {
+ let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
+ return;
+ };
+ let metrics = self.pane_scroll_metrics(pane_id);
+ if let Some(selection) = self.selection.as_mut() {
+ selection.drag(screen_col, screen_row, info.inner_rect, metrics);
+ }
+ }
+
+ fn selection_edge_scroll_lines(distance: u16) -> usize {
+ usize::from(distance).saturating_mul(3).clamp(3, 15)
+ }
+
+ pub(super) fn update_selection_drag(&mut self, screen_col: u16, screen_row: u16) {
+ let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else {
+ return;
+ };
+ let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
+ return;
+ };
+
+ let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1);
+ if screen_row < info.inner_rect.y {
+ self.scroll_pane_up(
+ pane_id,
+ Self::selection_edge_scroll_lines(info.inner_rect.y - screen_row),
+ );
+ } else if screen_row > bottom {
+ self.scroll_pane_down(
+ pane_id,
+ Self::selection_edge_scroll_lines(screen_row - bottom),
+ );
+ }
+
+ self.update_selection_cursor(pane_id, screen_col, screen_row);
+ }
+
+ pub(super) fn scroll_selection_with_wheel(&mut self, mouse: MouseEvent) -> bool {
+ const LINES_PER_NOTCH: usize = 3;
+
+ let Some(selection) = self.selection.as_ref() else {
+ return false;
+ };
+ if !selection.is_in_progress() {
+ return false;
+ }
+ let pane_id = selection.pane_id;
+ self.focus_pane(pane_id);
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(pane_id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(pane_id, LINES_PER_NOTCH),
+ _ => return false,
+ }
+ self.update_selection_cursor(pane_id, mouse.column, mouse.row);
+ true
+ }
+}
diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs
new file mode 100644
index 00000000..9433b7d8
--- /dev/null
+++ b/src/app/input/settings.rs
@@ -0,0 +1,352 @@
+use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
+use ratatui::layout::Rect;
+
+use crate::app::{
+ state::{AppState, SettingsSection, THEME_NAMES},
+ App, Mode,
+};
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum SettingsAction {
+ SaveTheme(String),
+ SaveSound(bool),
+ SaveToast(bool),
+}
+
+impl App {
+ pub(crate) fn handle_settings_key(&mut self, key: KeyEvent) {
+ if let Some(action) = update_settings_state(&mut self.state, key) {
+ match action {
+ SettingsAction::SaveTheme(name) => self.save_theme(&name),
+ SettingsAction::SaveSound(enabled) => self.save_sound(enabled),
+ SettingsAction::SaveToast(enabled) => self.save_toast(enabled),
+ }
+ }
+ }
+}
+
+fn normalize_theme_name(name: &str) -> String {
+ name.to_lowercase().replace([' ', '_'], "-")
+}
+
+fn current_theme_index(theme_name: &str) -> usize {
+ let normalized = normalize_theme_name(theme_name);
+ THEME_NAMES
+ .iter()
+ .position(|name| normalize_theme_name(name) == normalized)
+ .unwrap_or(0)
+}
+
+fn preview_selected_theme(state: &mut AppState) {
+ use crate::app::state::Palette;
+
+ let name = THEME_NAMES[state.settings.list.selected];
+ if let Some(palette) = Palette::from_name(name) {
+ state.palette = palette;
+ state.theme_name = name.to_string();
+ }
+}
+
+fn cancel_settings(state: &mut AppState) {
+ if let Some(palette) = state.settings.original_palette.take() {
+ state.palette = palette;
+ }
+ if let Some(theme_name) = state.settings.original_theme.take() {
+ state.theme_name = theme_name;
+ }
+ super::modal::leave_modal(state);
+}
+
+fn apply_settings(state: &mut AppState) -> Option<SettingsAction> {
+ match state.settings.section {
+ SettingsSection::Theme => {
+ let theme_name = state.theme_name.clone();
+ state.settings.original_palette = None;
+ state.settings.original_theme = None;
+ super::modal::leave_modal(state);
+ Some(SettingsAction::SaveTheme(theme_name))
+ }
+ _ => {
+ super::modal::leave_modal(state);
+ None
+ }
+ }
+}
+
+pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Option<SettingsAction> {
+ match state.settings.section {
+ SettingsSection::Theme => match key.code {
+ KeyCode::Up | KeyCode::Char('k') => {
+ let previous = state.settings.list.selected;
+ state.settings.list.move_prev();
+ if state.settings.list.selected != previous {
+ preview_selected_theme(state);
+ }
+ }
+ KeyCode::Down | KeyCode::Char('j') => {
+ let previous = state.settings.list.selected;
+ state.settings.list.move_next(THEME_NAMES.len());
+ if state.settings.list.selected != previous {
+ preview_selected_theme(state);
+ }
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Sound;
+ state.settings.list.selected = usize::from(!state.sound_enabled());
+ }
+ _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
+ Some(super::modal::ModalAction::Apply) => return apply_settings(state),
+ Some(super::modal::ModalAction::Close) => cancel_settings(state),
+ _ => {}
+ },
+ },
+ SettingsSection::Sound => match key.code {
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.list.selected = 1 - state.settings.list.selected.min(1);
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let enabled = state.settings.list.selected == 0;
+ state.sound.enabled = enabled;
+ return Some(SettingsAction::SaveSound(enabled));
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Toast;
+ state.settings.list.selected = usize::from(!state.toast_config.enabled);
+ }
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ }
+ _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
+ Some(super::modal::ModalAction::Close) => cancel_settings(state),
+ _ => {}
+ },
+ },
+ SettingsSection::Toast => match key.code {
+ KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
+ state.settings.list.selected = 1 - state.settings.list.selected.min(1);
+ }
+ KeyCode::Enter | KeyCode::Char(' ') => {
+ let enabled = state.settings.list.selected == 0;
+ state.toast_config.enabled = enabled;
+ return Some(SettingsAction::SaveToast(enabled));
+ }
+ KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => {
+ state.settings.section = SettingsSection::Sound;
+ state.settings.list.selected = usize::from(!state.sound_enabled());
+ }
+ KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => {
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ }
+ _ => match super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) {
+ Some(super::modal::ModalAction::Close) => cancel_settings(state),
+ _ => {}
+ },
+ },
+ }
+
+ None
+}
+
+pub(super) fn open_settings(state: &mut AppState) {
+ state.settings.original_palette = Some(state.palette.clone());
+ state.settings.original_theme = Some(state.theme_name.clone());
+ state.settings.section = SettingsSection::Theme;
+ state.settings.list.selected = current_theme_index(&state.theme_name);
+ state.mode = Mode::Settings;
+}
+
+impl AppState {
+ fn settings_popup_rect(&self) -> Rect {
+ crate::ui::centered_popup_rect(self.screen_rect(), 56, 20).unwrap_or_default()
+ }
+
+ fn settings_inner_rect(&self) -> Rect {
+ let popup = self.settings_popup_rect();
+ Rect::new(
+ popup.x + 1,
+ popup.y + 1,
+ popup.width.saturating_sub(2),
+ popup.height.saturating_sub(2),
+ )
+ }
+
+ fn settings_tab_at(&self, col: u16, row: u16) -> Option<SettingsSection> {
+ let inner = self.settings_inner_rect();
+ let tab_y = inner.y + 1;
+ if row != tab_y {
+ return None;
+ }
+ let mut x = inner.x;
+ for section in SettingsSection::ALL {
+ let width = section.label().len() as u16 + 2;
+ if col >= x && col < x + width {
+ return Some(*section);
+ }
+ x += width + 1;
+ }
+ None
+ }
+
+ pub(crate) fn settings_content_rect(&self) -> Rect {
+ let inner = self.settings_inner_rect();
+ crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content
+ }
+
+ fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> {
+ let area = self.settings_content_rect();
+ if row < area.y || row >= area.y + area.height || col < area.x || col >= area.x + area.width
+ {
+ return None;
+ }
+
+ match self.settings.section {
+ SettingsSection::Theme => {
+ let max_visible = area.height as usize;
+ let scroll = if self.settings.list.selected >= max_visible {
+ self.settings.list.selected - max_visible + 1
+ } else {
+ 0
+ };
+ let idx = scroll + (row - area.y) as usize;
+ (idx < THEME_NAMES.len()).then_some(idx)
+ }
+ SettingsSection::Sound | SettingsSection::Toast => {
+ let list_y = area.y + 2;
+ if row >= list_y && row < list_y + 2 {
+ Some((row - list_y) as usize)
+ } else {
+ None
+ }
+ }
+ }
+ }
+
+ pub(super) fn handle_settings_mouse(&mut self, mouse: MouseEvent) -> Option<SettingsAction> {
+ match mouse.kind {
+ MouseEventKind::Down(MouseButton::Left) => {
+ if let Some(section) = self.settings_tab_at(mouse.column, mouse.row) {
+ self.settings.section = section;
+ self.settings.list.select(match section {
+ SettingsSection::Theme => current_theme_index(&self.theme_name),
+ SettingsSection::Sound => usize::from(!self.sound_enabled()),
+ SettingsSection::Toast => usize::from(!self.toast_config.enabled),
+ });
+ return None;
+ }
+ if let Some(idx) = self.settings_list_index_at(mouse.column, mouse.row) {
+ self.settings.list.select(idx);
+ return match self.settings.section {
+ SettingsSection::Theme => {
+ preview_selected_theme(self);
+ None
+ }
+ SettingsSection::Sound => {
+ let enabled = idx == 0;
+ self.sound.enabled = enabled;
+ Some(SettingsAction::SaveSound(enabled))
+ }
+ SettingsSection::Toast => {
+ let enabled = idx == 0;
+ self.toast_config.enabled = enabled;
+ Some(SettingsAction::SaveToast(enabled))
+ }
+ };
+ }
+
+ let inner = self.settings_inner_rect();
+ let (apply, close) = crate::ui::settings_button_rects(inner);
+ match super::modal::modal_action_from_buttons(
+ mouse.column,
+ mouse.row,
+ &[
+ (apply, super::modal::ModalAction::Apply),
+ (close, super::modal::ModalAction::Close),
+ ],
+ ) {
+ Some(super::modal::ModalAction::Apply) => apply_settings(self),
+ Some(super::modal::ModalAction::Close) => {
+ cancel_settings(self);
+ None
+ }
+ _ => {
+ cancel_settings(self);
+ None
+ }
+ }
+ }
+ _ => None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
+
+ use super::super::{app_for_mouse_test, mouse, state_with_workspaces};
+ use super::*;
+
+ #[test]
+ fn settings_cancel_restores_previewed_theme_from_other_sections() {
+ let mut state = state_with_workspaces(&["test"]);
+ let original_palette = state.palette.clone();
+ let original_theme = state.theme_name.clone();
+
+ open_settings(&mut state);
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ );
+ assert_ne!(state.theme_name, original_theme);
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ );
+ assert_eq!(
+ state.settings.section,
+ crate::app::state::SettingsSection::Sound
+ );
+
+ update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ );
+
+ assert_eq!(state.mode, Mode::Terminal);
+ assert_eq!(state.theme_name, original_theme);
+ assert_eq!(state.palette.accent, original_palette.accent);
+ assert_eq!(state.palette.panel_bg, original_palette.panel_bg);
+ }
+
+ #[test]
+ fn settings_sound_toggle_returns_save_action() {
+ let mut state = state_with_workspaces(&["test"]);
+ open_settings(&mut state);
+ state.settings.section = crate::app::state::SettingsSection::Sound;
+ state.settings.list.selected = 0;
+
+ let action = update_settings_state(
+ &mut state,
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ );
+
+ assert_eq!(action, Some(SettingsAction::SaveSound(true)));
+ assert!(state.sound.enabled);
+ assert_eq!(state.mode, Mode::Settings);
+ }
+
+ #[test]
+ fn settings_hover_does_not_change_selection() {
+ let mut app = app_for_mouse_test();
+ open_settings(&mut app.state);
+ app.state.settings.list.select(0);
+
+ let area = app.state.settings_content_rect();
+ app.handle_mouse(mouse(MouseEventKind::Moved, area.x + 2, area.y + 2));
+
+ assert_eq!(app.state.settings.list.selected, 0);
+ }
+}
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
new file mode 100644
index 00000000..34845861
--- /dev/null
+++ b/src/app/input/sidebar.rs
@@ -0,0 +1,1219 @@
+use ratatui::layout::Rect;
+
+use crate::app::state::{AppState, Mode};
+
+use super::ScrollbarClickTarget;
+
+impl AppState {
+ pub(super) fn workspace_list_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
+ return Rect::default();
+ }
+ crate::ui::workspace_list_rect(sidebar, self.sidebar_section_split)
+ }
+
+ pub(super) fn agent_panel_rect(&self) -> Rect {
+ let sidebar = self.view.sidebar_rect;
+ if self.sidebar_collapsed || sidebar.width <= 1 || sidebar.height == 0 {
+ return Rect::default();
+ }
+ let (_, detail_area) =
+ crate::ui::expanded_sidebar_sections(sidebar, self.sidebar_section_split);
+ detail_area
+ }
+
+ pub(super) fn workspace_list_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
+ if col < track.x
+ || col >= track.x + track.width
+ || row < track.y
+ || row >= track.y + track.height
+ {
+ return None;
+ }
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some(ScrollbarClickTarget::Thumb { grab_row_offset })
+ } else {
+ Some(ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ })
+ }
+ }
+
+ pub(super) fn workspace_list_offset_for_drag_row(
+ &self,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ let track = crate::ui::workspace_list_scrollbar_rect(self, area)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ pub(super) fn set_workspace_list_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let area = self.workspace_list_rect();
+ let metrics = crate::ui::workspace_list_scroll_metrics(self, area);
+ self.workspace_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(offset_from_bottom);
+ }
+
+ pub(super) fn scroll_workspace_list(&mut self, delta: i16) {
+ if delta.is_negative() {
+ self.workspace_scroll = self
+ .workspace_scroll
+ .saturating_sub(delta.unsigned_abs() as usize);
+ return;
+ }
+
+ for _ in 0..delta as usize {
+ let cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
+ let Some(last) = cards.last() else {
+ break;
+ };
+ if last.ws_idx + 1 >= self.workspaces.len() {
+ break;
+ }
+ self.workspace_scroll = self.workspace_scroll.saturating_add(1);
+ }
+ }
+
+ pub(super) fn agent_panel_scrollbar_target_at(
+ &self,
+ col: u16,
+ row: u16,
+ ) -> Option<ScrollbarClickTarget> {
+ let area = self.agent_panel_rect();
+ let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
+ let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
+ if col < track.x
+ || col >= track.x + track.width
+ || row < track.y
+ || row >= track.y + track.height
+ {
+ return None;
+ }
+ if let Some(grab_row_offset) = crate::ui::scrollbar_thumb_grab_offset(metrics, track, row) {
+ Some(ScrollbarClickTarget::Thumb { grab_row_offset })
+ } else {
+ Some(ScrollbarClickTarget::Track {
+ offset_from_bottom: crate::ui::scrollbar_offset_from_row(metrics, track, row),
+ })
+ }
+ }
+
+ pub(super) fn agent_panel_offset_for_drag_row(
+ &self,
+ row: u16,
+ grab_row_offset: u16,
+ ) -> Option<usize> {
+ let area = self.agent_panel_rect();
+ let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
+ let track = crate::ui::agent_panel_scrollbar_rect(self, area)?;
+ Some(crate::ui::scrollbar_offset_from_drag_row(
+ metrics,
+ track,
+ row,
+ grab_row_offset,
+ ))
+ }
+
+ pub(super) fn set_agent_panel_offset_from_bottom(&mut self, offset_from_bottom: usize) {
+ let area = self.agent_panel_rect();
+ let metrics = crate::ui::agent_panel_scroll_metrics(self, area);
+ self.agent_panel_scroll = metrics
+ .max_offset_from_bottom
+ .saturating_sub(offset_from_bottom);
+ }
+
+ pub(super) fn scroll_agent_panel(&mut self, delta: i16) {
+ let area = self.agent_panel_rect();
+ let max_scroll = crate::ui::agent_panel_scroll_metrics(self, area).max_offset_from_bottom;
+ if delta.is_negative() {
+ self.agent_panel_scroll = self
+ .agent_panel_scroll
+ .saturating_sub(delta.unsigned_abs() as usize);
+ } else {
+ self.agent_panel_scroll = self
+ .agent_panel_scroll
+ .saturating_add(delta as usize)
+ .min(max_scroll);
+ }
+ }
+
+ pub(crate) fn sidebar_footer_rect(&self) -> Rect {
+ let ws_area = self.workspace_list_rect();
+ if ws_area == Rect::default() {
+ return Rect::default();
+ }
+ let y = ws_area.y + ws_area.height.saturating_sub(1);
+ Rect::new(ws_area.x, y, ws_area.width, 1)
+ }
+
+ pub(crate) fn sidebar_new_button_rect(&self) -> Rect {
+ let footer = self.sidebar_footer_rect();
+ let width = 5u16.min(footer.width.max(1));
+ Rect::new(footer.x, footer.y, width, footer.height)
+ }
+
+ pub(crate) fn global_launcher_rect(&self) -> Rect {
+ let footer = self.sidebar_footer_rect();
+ let width = if self.update_available.is_some() {
+ 8
+ } else {
+ 6
+ }
+ .min(footer.width.max(1));
+ let x = footer.x + footer.width.saturating_sub(width);
+ Rect::new(x, footer.y, width, footer.height)
+ }
+
+ pub(crate) fn global_menu_labels(&self) -> Vec<&'static str> {
+ let mut labels = vec!["settings", "keybinds", "reload keybinds"];
+ if self.update_available.is_some() {
+ labels.push("update ready");
+ } else if self.latest_release_notes_available {
+ labels.push("what's new");
+ }
+ labels.push(if self.quit_detaches { "detach" } else { "quit" });
+ labels
+ }
+
+ pub(crate) fn global_menu_rect(&self) -> Rect {
+ let screen = self.screen_rect();
+ let launcher = self.global_launcher_rect();
+ let labels = self.global_menu_labels();
+ let content_width = labels
+ .iter()
+ .map(|label| {
+ let extra = if *label == "update ready" { 2 } else { 0 };
+ label.chars().count() as u16 + extra
+ })
+ .max()
+ .unwrap_or(8)
+ .saturating_add(2);
+ let menu_w = content_width.saturating_add(2).min(screen.width.max(1));
+ let menu_h = (labels.len() as u16 + 2).min(screen.height.max(1));
+ let max_x = screen.x + screen.width.saturating_sub(menu_w);
+ let desired_x = launcher.x + launcher.width.saturating_sub(menu_w);
+ let x = desired_x.min(max_x);
+ let y = launcher.y.saturating_sub(menu_h);
+ Rect::new(x, y, menu_w, menu_h)
+ }
+
+ pub(super) fn on_sidebar_divider(&self, col: u16, row: u16) -> bool {
+ if self.sidebar_collapsed {
+ return false;
+ }
+ let sidebar = self.view.sidebar_rect;
+ sidebar.width > 0
+ && col == sidebar.x + sidebar.width.saturating_sub(1)
+ && row >= sidebar.y
+ && row < sidebar.y + sidebar.height
+ }
+
+ pub(super) fn on_collapsed_sidebar_toggle(&self, col: u16, row: u16) -> bool {
+ if !self.sidebar_collapsed {
+ return false;
+ }
+ let rect = crate::ui::collapsed_sidebar_toggle_rect(self.view.sidebar_rect);
+ rect.width > 0
+ && col >= rect.x
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+ }
+
+ pub(super) fn set_manual_sidebar_width(&mut self, divider_col: u16) {
+ let sidebar = self.view.sidebar_rect;
+ let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
+ self.sidebar_width =
+ width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
+ self.mark_session_dirty();
+ }
+
+ pub(super) fn on_sidebar_section_divider(&self, col: u16, row: u16) -> bool {
+ if self.sidebar_collapsed {
+ return false;
+ }
+ let rect = crate::ui::sidebar_section_divider_rect(
+ self.view.sidebar_rect,
+ self.sidebar_section_split,
+ );
+ rect.width > 0
+ && col >= rect.x
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+ }
+
+ pub(super) fn set_sidebar_section_split(&mut self, row: u16) {
+ let sidebar = self.view.sidebar_rect;
+ let content_height = sidebar.height;
+ if content_height < 6 {
+ return;
+ }
+ let relative_y = row.saturating_sub(sidebar.y);
+ let ratio = (relative_y as f32) / (content_height as f32);
+ self.sidebar_section_split = ratio.clamp(0.1, 0.9);
+ self.mark_session_dirty();
+ }
+
+ pub(super) fn workspace_at_row(&self, row: u16) -> Option<usize> {
+ let footer = self.sidebar_footer_rect();
+ if footer == Rect::default() {
+ return None;
+ }
+
+ let cards = if self.view.workspace_card_areas.is_empty() {
+ crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
+ } else {
+ self.view.workspace_card_areas.clone()
+ };
+
+ cards.iter().find_map(|card| {
+ (row >= card.rect.y && row < card.rect.y + card.rect.height).then_some(card.ws_idx)
+ })
+ }
+
+ pub(super) fn collapsed_workspace_at_row(&self, row: u16) -> Option<usize> {
+ if !self.sidebar_collapsed {
+ return None;
+ }
+
+ let (ws_area, _, _) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
+ if ws_area == Rect::default() || row < ws_area.y || row >= ws_area.y + ws_area.height {
+ return None;
+ }
+
+ let idx = (row - ws_area.y) as usize;
+ (idx < self.workspaces.len()).then_some(idx)
+ }
+
+ fn collapsed_detail_workspace_idx(&self) -> Option<usize> {
+ if matches!(
+ self.mode,
+ Mode::Navigate
+ | Mode::RenameWorkspace
+ | Mode::Resize
+ | Mode::ConfirmClose
+ | Mode::ContextMenu
+ | Mode::Settings
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ ) {
+ Some(self.selected)
+ } else {
+ self.active
+ }
+ }
+
+ pub(super) fn collapsed_agent_detail_target_at(
+ &self,
+ row: u16,
+ ) -> Option<(usize, usize, crate::layout::PaneId)> {
+ if !self.sidebar_collapsed {
+ return None;
+ }
+
+ let (_, _, detail_area) = crate::ui::collapsed_sidebar_sections(self.view.sidebar_rect);
+ let detail_content_area = Rect::new(
+ detail_area.x,
+ detail_area.y,
+ detail_area.width,
+ detail_area.height.saturating_sub(1),
+ );
+ if detail_content_area == Rect::default()
+ || row < detail_content_area.y
+ || row >= detail_content_area.y + detail_content_area.height
+ {
+ return None;
+ }
+
+ let ws_idx = self.collapsed_detail_workspace_idx()?;
+ let ws = self.workspaces.get(ws_idx)?;
+ let detail_idx = (row - detail_content_area.y) as usize;
+ let details = ws.pane_details();
+ let detail = details.get(detail_idx)?;
+ Some((ws_idx, detail.tab_idx, detail.pane_id))
+ }
+
+ pub(super) fn workspace_drop_index_at_row(&self, row: u16) -> Option<usize> {
+ let area = self.workspace_list_rect();
+ let footer = self.sidebar_footer_rect();
+ if area == Rect::default() || row < area.y || row >= footer.y {
+ return None;
+ }
+
+ let cards = if self.view.workspace_card_areas.is_empty() {
+ crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect)
+ } else {
+ self.view.workspace_card_areas.clone()
+ };
+ if cards.is_empty() {
+ return Some(0);
+ }
+
+ let mut insert_indices = Vec::with_capacity(cards.len() + 1);
+ insert_indices.push(cards[0].ws_idx);
+ insert_indices.extend(cards.iter().skip(1).map(|card| card.ws_idx));
+ insert_indices.push(cards.last().map(|card| card.ws_idx + 1).unwrap_or(0));
+
+ let mut best: Option<(usize, u16)> = None;
+ for insert_idx in insert_indices {
+ let Some(slot_row) = crate::ui::workspace_drop_indicator_row(&cards, area, insert_idx)
+ else {
+ continue;
+ };
+ let distance = row.abs_diff(slot_row);
+ match best {
+ Some((best_idx, best_distance))
+ if distance > best_distance
+ || (distance == best_distance && insert_idx < best_idx) => {}
+ _ => best = Some((insert_idx, distance)),
+ }
+ }
+
+ best.map(|(insert_idx, _)| insert_idx)
+ }
+
+ pub(super) fn on_agent_panel_scope_toggle(&self, col: u16, row: u16) -> bool {
+ if self.sidebar_collapsed {
+ return false;
+ }
+
+ let (_, detail_area) = crate::ui::expanded_sidebar_sections(
+ self.view.sidebar_rect,
+ self.sidebar_section_split,
+ );
+ let rect = crate::ui::agent_panel_toggle_rect(detail_area, self.agent_panel_scope);
+ rect.width > 0
+ && col >= rect.x
+ && col < rect.x + rect.width
+ && row >= rect.y
+ && row < rect.y + rect.height
+ }
+
+ pub(super) fn agent_detail_target_at(
+ &self,
+ row: u16,
+ ) -> Option<(usize, usize, crate::layout::PaneId)> {
+ if self.sidebar_collapsed {
+ return None;
+ }
+
+ let detail_area = self.agent_panel_rect();
+ let metrics = crate::ui::agent_panel_scroll_metrics(self, detail_area);
+ let body = crate::ui::agent_panel_body_rect(
+ detail_area,
+ crate::ui::should_show_scrollbar(metrics),
+ );
+ if body.height < 2 || row < body.y || row >= body.y + body.height {
+ return None;
+ }
+
+ let mut row_y = body.y;
+ for detail in crate::ui::agent_panel_entries(self)
+ .into_iter()
+ .skip(self.agent_panel_scroll)
+ {
+ if row_y.saturating_add(1) >= body.y + body.height {
+ break;
+ }
+ if row == row_y || row == row_y + 1 {
+ return Some((detail.ws_idx, detail.tab_idx, detail.pane_id));
+ }
+ row_y = row_y.saturating_add(2);
+ if row_y < body.y + body.height {
+ row_y = row_y.saturating_add(1);
+ }
+ }
+ None
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::fs;
+
+ use crossterm::event::{MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, capture_snapshot, mouse, unique_temp_path};
+ use crate::{
+ app::state::{AgentPanelScope, DragTarget, Mode},
+ detect::Agent,
+ workspace::Workspace,
+ };
+
+ #[test]
+ fn clicking_launcher_opens_global_menu() {
+ let mut app = app_for_mouse_test();
+ let rect = app.state.global_launcher_rect();
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ rect.x + rect.width.saturating_sub(1),
+ rect.y,
+ ));
+
+ assert_eq!(app.state.mode, Mode::GlobalMenu);
+ }
+
+ #[test]
+ fn hovering_global_menu_updates_highlight() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(MouseEventKind::Moved, menu.x + 2, menu.y + 2));
+
+ assert_eq!(app.state.global_menu.highlighted, 1);
+ }
+
+ #[test]
+ fn clicking_keybinds_menu_item_opens_help() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 2,
+ ));
+
+ assert_eq!(app.state.mode, Mode::KeybindHelp);
+ }
+
+ #[test]
+ fn clicking_settings_menu_item_opens_settings() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 1,
+ ));
+
+ assert_eq!(app.state.mode, Mode::Settings);
+ }
+
+ #[test]
+ fn clicking_reload_keybinds_menu_item_requests_reload() {
+ let mut app = app_for_mouse_test();
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 3,
+ ));
+
+ assert!(app.state.request_reload_keybinds);
+ assert_eq!(app.state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn update_pending_menu_surfaces_update_ready_entry() {
+ let mut app = app_for_mouse_test();
+ app.state.update_available = Some("0.3.2".into());
+ app.state.latest_release_notes_available = true;
+
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec![
+ "settings",
+ "keybinds",
+ "reload keybinds",
+ "update ready",
+ "quit"
+ ]
+ );
+ assert!(!app.state.should_quit);
+ }
+
+ #[test]
+ fn persistence_mode_menu_surfaces_detach_action() {
+ let mut app = app_for_mouse_test();
+ app.state.quit_detaches = true;
+
+ let launcher = app.state.global_launcher_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ launcher.x,
+ launcher.y,
+ ));
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec!["settings", "keybinds", "reload keybinds", "detach"]
+ );
+
+ let menu = app.state.global_menu_rect();
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ menu.x + 2,
+ menu.y + 4,
+ ));
+
+ assert!(app.state.detach_requested);
+ assert!(!app.state.should_quit);
+ }
+
+ #[test]
+ fn whats_new_remains_in_menu_for_latest_installed_release_notes() {
+ let mut app = app_for_mouse_test();
+ app.state.latest_release_notes_available = true;
+
+ assert_eq!(
+ app.state.global_menu_labels(),
+ vec![
+ "settings",
+ "keybinds",
+ "reload keybinds",
+ "what's new",
+ "quit"
+ ]
+ );
+ }
+
+ #[test]
+ fn clicking_agent_detail_row_switches_to_correct_tab_and_pane() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.tabs[0].set_custom_name("main".into());
+ let first_pane = ws.tabs[0].root_pane;
+ ws.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ let second_tab = ws.test_add_tab(Some("logs"));
+ let second_pane = ws.tabs[second_tab].root_pane;
+ ws.tabs[second_tab]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2, 16));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.workspaces[0].active_tab, second_tab);
+ assert_eq!(
+ snapshot.workspaces[0].tabs[second_tab].focused,
+ Some(second_pane.raw())
+ );
+ }
+
+ #[test]
+ fn clicking_agent_panel_toggle_switches_scope() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.agent_panel_scroll = 3;
+
+ let (_, detail_area) = crate::ui::expanded_sidebar_sections(
+ app.state.view.sidebar_rect,
+ app.state.sidebar_section_split,
+ );
+ let toggle = crate::ui::agent_panel_toggle_rect(detail_area, app.state.agent_panel_scope);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ toggle.x,
+ toggle.y,
+ ));
+
+ assert_eq!(app.state.agent_panel_scope, AgentPanelScope::AllWorkspaces);
+ assert_eq!(app.state.agent_panel_scroll, 0);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
+ }
+
+ #[test]
+ fn clicking_all_workspaces_agent_row_switches_to_correct_workspace() {
+ let mut app = app_for_mouse_test();
+ let mut first = Workspace::test_new("one");
+ let first_pane = first.tabs[0].root_pane;
+ first.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+
+ let mut second = Workspace::test_new("two");
+ let second_pane = second.tabs[0].root_pane;
+ second.tabs[0]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+
+ app.state.workspaces = vec![first, second];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
+
+ let (_, detail_area) = crate::ui::expanded_sidebar_sections(
+ app.state.view.sidebar_rect,
+ app.state.sidebar_section_split,
+ );
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ detail_area.x + 2,
+ detail_area.y + 6,
+ ));
+
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert_eq!(app.state.workspaces[1].active_tab, 0);
+ assert_eq!(
+ app.state.workspaces[1].tabs[0].layout.focused(),
+ second_pane
+ );
+ }
+
+ #[test]
+ fn scrolling_agent_panel_with_wheel_updates_agent_panel_scroll() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ ws.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+
+ for (tab_name, agent) in [
+ ("logs", Agent::Claude),
+ ("review", Agent::Codex),
+ ("ops", Agent::Gemini),
+ ] {
+ let tab_idx = ws.test_add_tab(Some(tab_name));
+ let pane_id = ws.tabs[tab_idx].root_pane;
+ ws.tabs[tab_idx]
+ .panes
+ .get_mut(&pane_id)
+ .unwrap()
+ .detected_agent = Some(agent);
+ }
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ let detail_area = app.state.agent_panel_rect();
+ assert!(crate::ui::should_show_scrollbar(
+ crate::ui::agent_panel_scroll_metrics(&app.state, detail_area)
+ ));
+
+ app.handle_mouse(mouse(
+ MouseEventKind::ScrollDown,
+ detail_area.x + 1,
+ detail_area.y + 4,
+ ));
+
+ assert_eq!(app.state.agent_panel_scroll, 1);
+ assert_eq!(app.state.selected, 0);
+ }
+
+ #[test]
+ fn clicking_scrolled_agent_detail_row_switches_to_correct_tab_and_pane() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ ws.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+
+ let second_tab = ws.test_add_tab(Some("logs"));
+ let second_pane = ws.tabs[second_tab].root_pane;
+ ws.tabs[second_tab]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+
+ for (tab_name, agent) in [("review", Agent::Codex), ("ops", Agent::Gemini)] {
+ let tab_idx = ws.test_add_tab(Some(tab_name));
+ let pane_id = ws.tabs[tab_idx].root_pane;
+ ws.tabs[tab_idx]
+ .panes
+ .get_mut(&pane_id)
+ .unwrap()
+ .detected_agent = Some(agent);
+ }
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.agent_panel_scroll = 1;
+
+ let detail_area = app.state.agent_panel_rect();
+ let body = crate::ui::agent_panel_body_rect(detail_area, true);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ body.x + 1,
+ body.y,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, second_tab);
+ assert_eq!(
+ app.state.workspaces[0].tabs[second_tab].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn clicking_collapsed_agent_row_switches_to_correct_tab_and_pane() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ ws.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+ let second_tab = ws.test_add_tab(Some("logs"));
+ let second_pane = ws.tabs[second_tab].root_pane;
+ ws.tabs[second_tab]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.sidebar_collapsed = true;
+ app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
+ app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
+
+ let (_, _, detail_area) =
+ crate::ui::collapsed_sidebar_sections(app.state.view.sidebar_rect);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ detail_area.x,
+ detail_area.y + 1,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, 1);
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn clicking_collapsed_sidebar_toggle_expands_sidebar() {
+ let mut app = app_for_mouse_test();
+ app.state.sidebar_collapsed = true;
+ app.state.view.sidebar_rect = Rect::new(0, 0, 4, 20);
+ app.state.view.terminal_area = Rect::new(4, 0, 80, 20);
+
+ let toggle = crate::ui::collapsed_sidebar_toggle_rect(app.state.view.sidebar_rect);
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ toggle.x,
+ toggle.y,
+ ));
+
+ assert!(!app.state.sidebar_collapsed);
+ }
+
+ #[test]
+ fn clicking_workspace_switches_on_mouse_up() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![Workspace::test_new("a"), Workspace::test_new("b")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let target_row = app.state.view.workspace_card_areas[1].rect.y;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ 2,
+ target_row,
+ ));
+ assert_eq!(app.state.active, Some(0));
+ assert!(app.state.workspace_press.is_some());
+
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
+ assert_eq!(app.state.active, Some(1));
+ assert_eq!(app.state.selected, 1);
+ assert!(app.state.workspace_press.is_none());
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.active, Some(1));
+ assert_eq!(snapshot.selected, 1);
+ }
+
+ #[test]
+ fn dragging_workspace_reorders_without_changing_identity() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ Workspace::test_new("a"),
+ Workspace::test_new("b"),
+ Workspace::test_new("c"),
+ ];
+ let active_id = app.state.workspaces[1].id.clone();
+ let selected_id = app.state.workspaces[2].id.clone();
+ app.state.active = Some(1);
+ app.state.selected = 2;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+ let source_row = app.state.view.workspace_card_areas[1].rect.y;
+ let target_row = crate::ui::workspace_drop_indicator_row(
+ &app.state.view.workspace_card_areas,
+ app.state.workspace_list_rect(),
+ 0,
+ )
+ .unwrap();
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ 2,
+ source_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ 2,
+ target_row,
+ ));
+ assert!(matches!(
+ app.state.drag.as_ref().map(|drag| &drag.target),
+ Some(DragTarget::WorkspaceReorder {
+ source_ws_idx: 1,
+ insert_idx: Some(0),
+ })
+ ));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 2, target_row));
+
+ let names: Vec<_> = app
+ .state
+ .workspaces
+ .iter()
+ .map(|ws| ws.display_name())
+ .collect();
+ assert_eq!(names, vec!["b", "a", "c"]);
+ assert_eq!(app.state.active, Some(0));
+ assert_eq!(app.state.selected, 2);
+ assert_eq!(app.state.workspaces[0].id, active_id);
+ assert_eq!(app.state.workspaces[2].id, selected_id);
+ let snapshot = capture_snapshot(&app.state);
+ let captured_names: Vec<_> = snapshot
+ .workspaces
+ .iter()
+ .map(|ws| ws.custom_name.clone().unwrap())
+ .collect();
+ assert_eq!(captured_names, vec!["b", "a", "c"]);
+ }
+
+ #[test]
+ fn clicking_tab_scroll_button_reveals_hidden_tabs_without_renaming() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("logs"));
+ ws.test_add_tab(Some("review"));
+ ws.test_add_tab(Some("ops"));
+ ws.test_add_tab(Some("notes"));
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+
+ let right = app.state.view.tab_scroll_right_hit_area;
+ assert!(right.width > 0);
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ right.x + 1,
+ right.y,
+ ));
+
+ assert_eq!(app.state.tab_scroll, 1);
+ assert!(!app.state.tab_scroll_follow_active);
+ assert_eq!(app.state.workspaces[0].active_tab, 0);
+ assert_eq!(app.state.view.tab_hit_areas[0].width, 0);
+ assert!(app.state.workspaces[0].tabs[0].custom_name.is_none());
+ assert_eq!(
+ app.state.workspaces[0].tabs[1].custom_name.as_deref(),
+ Some("logs")
+ );
+ }
+
+ #[test]
+ fn clicking_last_visible_tab_at_right_edge_does_not_overscroll() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ for name in [
+ "one", "two", "three", "four", "five", "six", "seven", "eight",
+ ] {
+ ws.test_add_tab(Some(name));
+ }
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.tab_scroll = usize::MAX;
+ app.state.tab_scroll_follow_active = false;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 52, 20));
+
+ let last_idx = app.state.workspaces[0].tabs.len() - 1;
+ let target = app.state.view.tab_hit_areas[last_idx];
+ let clamped_scroll = app.state.tab_scroll;
+ assert!(target.width > 0, "last tab should already be visible");
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ target.x + 1,
+ target.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ target.x + 1,
+ target.y,
+ ));
+
+ assert_eq!(app.state.workspaces[0].active_tab, last_idx);
+ assert_eq!(app.state.tab_scroll, clamped_scroll);
+ assert!(app.state.view.tab_hit_areas[last_idx].width > 0);
+ }
+
+ #[test]
+ fn dragging_tab_reorders_auto_and_custom_names_without_materializing_numbers() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ ws.test_add_tab(Some("foo"));
+ ws.test_add_tab(None);
+ let moved_root = ws.tabs[0].root_pane;
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let source = app.state.view.tab_hit_areas[0];
+ let last = app.state.view.tab_hit_areas[2];
+ let drop_col = last.x + last.width;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ source.x + 1,
+ source.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ drop_col,
+ source.y,
+ ));
+ assert!(matches!(
+ app.state.drag.as_ref().map(|drag| &drag.target),
+ Some(DragTarget::TabReorder {
+ ws_idx: 0,
+ source_tab_idx: 0,
+ insert_idx: Some(3),
+ })
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Up(MouseButton::Left),
+ drop_col,
+ source.y,
+ ));
+
+ let labels: Vec<_> = app.state.workspaces[0]
+ .tabs
+ .iter()
+ .map(|tab| tab.display_name())
+ .collect();
+ assert_eq!(labels, vec!["foo", "2", "3"]);
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].custom_name.as_deref(),
+ Some("foo")
+ );
+ assert!(app.state.workspaces[0].tabs[1].custom_name.is_none());
+ assert!(app.state.workspaces[0].tabs[2].custom_name.is_none());
+ assert_eq!(app.state.workspaces[0].tabs[2].root_pane, moved_root);
+ assert_eq!(app.state.workspaces[0].active_tab, 2);
+ }
+
+ fn temp_git_repo(branch: &str) -> std::path::PathBuf {
+ let repo = unique_temp_path("sidebar-drop-slot-repo");
+ fs::create_dir_all(repo.join(".git")).unwrap();
+ fs::write(
+ repo.join(".git/HEAD"),
+ format!("ref: refs/heads/{branch}\n"),
+ )
+ .unwrap();
+ repo
+ }
+
+ #[test]
+ fn top_drop_slot_is_distinct_from_gap_below_first_workspace() {
+ let mut app = app_for_mouse_test();
+ let first_repo = temp_git_repo("main");
+ let second_repo = temp_git_repo("main");
+
+ let mut first = Workspace::test_new("a");
+ let first_root = first.tabs[0].root_pane;
+ first.identity_cwd = first_repo.clone();
+ first.tabs[0]
+ .pane_cwds
+ .insert(first_root, first_repo.clone());
+
+ let mut second = Workspace::test_new("b");
+ let second_root = second.tabs[0].root_pane;
+ second.identity_cwd = second_repo.clone();
+ second.tabs[0]
+ .pane_cwds
+ .insert(second_root, second_repo.clone());
+
+ app.state.workspaces = vec![first, second];
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ assert_eq!(app.state.workspace_drop_index_at_row(0), Some(0));
+ assert_eq!(app.state.workspace_drop_index_at_row(1), Some(0));
+ assert_eq!(app.state.workspace_drop_index_at_row(2), Some(0));
+ assert_eq!(app.state.workspace_drop_index_at_row(3), Some(1));
+
+ let _ = fs::remove_dir_all(first_repo);
+ let _ = fs::remove_dir_all(second_repo);
+ }
+
+ #[test]
+ fn bottom_drop_slot_stays_below_last_workspace_not_footer() {
+ let mut app = app_for_mouse_test();
+ app.state.workspaces = vec![
+ Workspace::test_new("a"),
+ Workspace::test_new("b"),
+ Workspace::test_new("c"),
+ ];
+ crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
+
+ let cards = &app.state.view.workspace_card_areas;
+ let bottom_slot = crate::ui::workspace_drop_indicator_row(
+ cards,
+ app.state.workspace_list_rect(),
+ cards.len(),
+ )
+ .unwrap();
+
+ let last = cards.last().unwrap().rect;
+ assert_eq!(bottom_slot, last.y + last.height);
+ assert!(bottom_slot < app.state.sidebar_footer_rect().y.saturating_sub(1));
+ }
+
+ #[test]
+ fn dragging_sidebar_divider_sets_manual_width() {
+ let mut app = app_for_mouse_test();
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), 30, 5));
+
+ assert_eq!(app.state.sidebar_width, 31);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.sidebar_width, Some(31));
+ }
+
+ #[test]
+ fn dragging_sidebar_section_divider_sets_split_ratio() {
+ let mut app = app_for_mouse_test();
+ let divider = crate::ui::sidebar_section_divider_rect(
+ app.state.view.sidebar_rect,
+ app.state.sidebar_section_split,
+ );
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ divider.x + 1,
+ divider.y,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ divider.x + 1,
+ divider.y + 4,
+ ));
+
+ assert!(app.state.sidebar_section_split > 0.5);
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(
+ snapshot.sidebar_section_split,
+ Some(app.state.sidebar_section_split)
+ );
+ }
+
+ #[test]
+ fn double_clicking_sidebar_divider_resets_default_width() {
+ let mut app = app_for_mouse_test();
+ app.state.default_sidebar_width = 26;
+ app.state.sidebar_width = 30;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), 25, 5));
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 25, 5));
+
+ assert_eq!(app.state.sidebar_width, 26);
+ assert!(app.state.drag.is_none());
+ let snapshot = capture_snapshot(&app.state);
+ assert_eq!(snapshot.sidebar_width, Some(26));
+ }
+}
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
new file mode 100644
index 00000000..910a49bd
--- /dev/null
+++ b/src/app/input/terminal.rs
@@ -0,0 +1,440 @@
+use bytes::Bytes;
+use crossterm::event::KeyCode;
+use tracing::{debug, warn};
+
+use crate::{
+ app::{App, Mode},
+ input::TerminalKey,
+};
+
+struct PreparedPaneInput {
+ ws_idx: usize,
+ pane_id: crate::layout::PaneId,
+ bytes: Bytes,
+}
+
+fn is_modifier_only_key(code: &KeyCode) -> bool {
+ matches!(code, KeyCode::Modifier(_))
+}
+
+impl App {
+ pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) {
+ let Some(input) = self.prepare_terminal_key_forward(key) else {
+ return;
+ };
+ if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ let _ = runtime.try_send_bytes(input.bytes);
+ }
+ }
+
+ fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> {
+ self.state.clear_selection();
+ self.state.update_dismissed = true;
+
+ let key_event = key.as_key_event();
+
+ if let Some(action) = super::terminal_direct_navigation_action(&self.state, &key_event) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ action = ?action,
+ "intercepted terminal direct navigation key before forwarding to pane"
+ );
+ super::navigate::execute_navigate_action(&mut self.state, action);
+ return None;
+ }
+
+ if self.state.is_prefix(&key_event) {
+ self.state.mode = Mode::Navigate;
+ return None;
+ }
+
+ if is_modifier_only_key(&key_event.code) {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ "dropping modifier-only terminal key event instead of forwarding it to pane"
+ );
+ return None;
+ }
+
+ let ws_idx = self.state.active?;
+ let ws = self.state.workspaces.get(ws_idx)?;
+ let pane_id = ws.focused_pane_id()?;
+ let rt = ws.runtimes.get(&pane_id)?;
+ rt.scroll_reset();
+ let protocol = rt.keyboard_protocol();
+ let bytes = rt.encode_terminal_key(key);
+
+ if matches!(key_event.code, KeyCode::Esc)
+ || key_event
+ .modifiers
+ .contains(crossterm::event::KeyModifiers::ALT)
+ {
+ debug!(
+ code = ?key_event.code,
+ modifiers = ?key_event.modifiers,
+ kind = ?key_event.kind,
+ protocol = ?protocol,
+ encoded = ?bytes,
+ "forwarding potentially-ambiguous terminal key to pane"
+ );
+ }
+
+ if bytes.is_empty() {
+ if key.kind != crossterm::event::KeyEventKind::Release
+ && !matches!(
+ key.code,
+ KeyCode::CapsLock
+ | KeyCode::ScrollLock
+ | KeyCode::NumLock
+ | KeyCode::PrintScreen
+ | KeyCode::Pause
+ | KeyCode::Menu
+ | KeyCode::KeypadBegin
+ | KeyCode::Media(_)
+ | KeyCode::Modifier(_)
+ )
+ {
+ warn!(code = ?key_event.code, mods = ?key_event.modifiers, state = ?key_event.state, "key produced empty encoding");
+ }
+ return None;
+ }
+
+ Some(PreparedPaneInput {
+ ws_idx,
+ pane_id,
+ bytes: Bytes::from(bytes),
+ })
+ }
+
+ pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) {
+ let Some(input) = self.prepare_terminal_key_forward(key) else {
+ return;
+ };
+ if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ let _ = runtime.send_bytes(input.bytes).await;
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
+ use ratatui::layout::Rect;
+
+ use super::super::{app_for_mouse_test, mouse, numbered_lines_bytes};
+ use super::*;
+ use crate::{config::Config, workspace::Workspace};
+
+ #[tokio::test]
+ async fn dragging_selection_above_pane_autoscrolls_and_extends_into_scrollback() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 16 * 1024,
+ &numbered_lines_bytes(64),
+ ),
+ );
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ let start_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("initial scroll metrics");
+ let start_row = info.inner_rect.y;
+ let start_col = info.inner_rect.x + 2;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ start_row,
+ ));
+ app.handle_mouse(mouse(
+ MouseEventKind::Drag(MouseButton::Left),
+ start_col,
+ info.inner_rect.y.saturating_sub(1),
+ ));
+
+ let end_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("scroll metrics after drag");
+ assert_eq!(
+ end_metrics.offset_from_bottom,
+ start_metrics.offset_from_bottom + 3
+ );
+
+ let selection = app.state.selection.as_ref().expect("selection after drag");
+ assert!(selection.is_visible());
+ assert_eq!(
+ selection.ordered_cells(),
+ (
+ (
+ (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
+ 2,
+ ),
+ (start_metrics.max_offset_from_bottom as u32, 2),
+ )
+ );
+ }
+
+ #[tokio::test]
+ async fn releasing_dragged_selection_clears_highlight_after_copy() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 16 * 1024,
+ &numbered_lines_bytes(64),
+ ),
+ );
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ let row = info.inner_rect.y;
+ let start_col = info.inner_rect.x + 1;
+ let end_col = info.inner_rect.x + 4;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ start_col,
+ row,
+ ));
+ app.handle_mouse(mouse(MouseEventKind::Drag(MouseButton::Left), end_col, row));
+ assert!(app.state.selection.is_some());
+
+ app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), end_col, row));
+
+ assert!(app.state.selection.is_none());
+ }
+
+ #[tokio::test]
+ async fn wheel_scroll_keeps_in_progress_selection_and_extends_it() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
+ let info = pane_infos[0].clone();
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ info.inner_rect.width,
+ info.inner_rect.height,
+ 16 * 1024,
+ &numbered_lines_bytes(64),
+ ),
+ );
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ let start_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("initial scroll metrics");
+ let top_row = info.inner_rect.y;
+ let col = info.inner_rect.x + 2;
+
+ app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, top_row));
+ app.handle_mouse(mouse(MouseEventKind::ScrollUp, col, top_row));
+
+ let end_metrics = app.state.workspaces[0]
+ .runtime(pane_id)
+ .and_then(crate::pane::PaneRuntime::scroll_metrics)
+ .expect("scroll metrics after wheel");
+ assert_eq!(
+ end_metrics.offset_from_bottom,
+ start_metrics.offset_from_bottom + 3
+ );
+
+ let selection = app.state.selection.as_ref().expect("selection after wheel");
+ assert!(selection.is_visible());
+ assert_eq!(
+ selection.ordered_cells(),
+ (
+ (
+ (start_metrics.max_offset_from_bottom - end_metrics.offset_from_bottom) as u32,
+ 2,
+ ),
+ (start_metrics.max_offset_from_bottom as u32, 2),
+ )
+ );
+ }
+
+ #[tokio::test]
+ async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
+
+ let terminal_area = Rect::new(26, 2, 80, 18);
+ let pane_infos = ws.tabs[0].layout.panes(terminal_area);
+ let first_info = pane_infos
+ .iter()
+ .find(|p| p.id == first_pane)
+ .unwrap()
+ .clone();
+ let second_info = pane_infos
+ .iter()
+ .find(|p| p.id == second_pane)
+ .unwrap()
+ .clone();
+
+ ws.tabs[0].runtimes.insert(
+ first_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ first_info.inner_rect.width.max(1),
+ first_info.inner_rect.height.max(1),
+ b"",
+ ),
+ );
+ ws.tabs[0].runtimes.insert(
+ second_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ second_info.inner_rect.width.max(1),
+ second_info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+
+ ws.tabs[0].layout.focus_pane(first_pane);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Left),
+ second_info.inner_rect.x + 2,
+ second_info.inner_rect.y + 2,
+ ));
+
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+
+ #[tokio::test]
+ async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_right_button() {
+ let mut app = app_for_mouse_test();
+ let mut ws = Workspace::test_new("test");
+ let first_pane = ws.tabs[0].root_pane;
+ let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
+
+ let terminal_area = Rect::new(26, 2, 80, 18);
+ let pane_infos = ws.tabs[0].layout.panes(terminal_area);
+ let first_info = pane_infos
+ .iter()
+ .find(|p| p.id == first_pane)
+ .unwrap()
+ .clone();
+ let second_info = pane_infos
+ .iter()
+ .find(|p| p.id == second_pane)
+ .unwrap()
+ .clone();
+
+ ws.tabs[0].runtimes.insert(
+ first_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ first_info.inner_rect.width.max(1),
+ first_info.inner_rect.height.max(1),
+ b"",
+ ),
+ );
+ ws.tabs[0].runtimes.insert(
+ second_pane,
+ crate::pane::PaneRuntime::test_with_screen_bytes(
+ second_info.inner_rect.width.max(1),
+ second_info.inner_rect.height.max(1),
+ b"\x1b[?1002h",
+ ),
+ );
+
+ ws.tabs[0].layout.focus_pane(first_pane);
+
+ app.state.workspaces = vec![ws];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.view.pane_infos = pane_infos;
+
+ app.handle_mouse(mouse(
+ MouseEventKind::Down(MouseButton::Right),
+ second_info.inner_rect.x + 2,
+ second_info.inner_rect.y + 2,
+ ));
+
+ assert_eq!(
+ app.state.workspaces[0].tabs[0].layout.focused(),
+ second_pane
+ );
+ assert_eq!(app.state.mode, Mode::ContextMenu);
+ assert!(app.state.context_menu.is_some());
+ }
+
+ #[tokio::test]
+ async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(
+ &Config::default(),
+ true,
+ None,
+ None,
+ api_rx,
+ crate::api::EventHub::default(),
+ );
+ app.state.workspaces = vec![Workspace::test_new("test")];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.view.pane_infos = app.state.workspaces[0]
+ .active_tab()
+ .unwrap()
+ .layout
+ .panes(Rect::new(0, 0, 80, 24));
+ let focused_before = app.state.workspaces[0].layout.focused();
+ app.state.keybinds.focus_pane_left = Some((KeyCode::Char('h'), KeyModifiers::ALT));
+ app.state.keybinds.focus_pane_left_label = Some("alt+h".into());
+
+ app.handle_terminal_key(TerminalKey::new(KeyCode::Char('h'), KeyModifiers::ALT))
+ .await;
+
+ assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
+ assert_eq!(app.state.mode, Mode::Terminal);
+ }
+}
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"
+ );
+ }
}
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
new file mode 100644
index 00000000..d82f6440
--- /dev/null
+++ b/src/app/runtime.rs
@@ -0,0 +1,261 @@
+use std::time::Instant;
+
+use crossterm::terminal;
+
+use super::{
+ auto_updates_enabled, repeat_key_identity, App, Mode, ANIMATION_INTERVAL,
+ AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL,
+ RESIZE_POLL_INTERVAL,
+};
+use crate::workspace::Workspace;
+
+impl App {
+ pub(crate) 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
+ }
+
+ pub(super) fn handle_api_request_message(
+ &mut self,
+ msg: crate::api::ApiRequestMessage,
+ ) -> bool {
+ let changed = crate::api::request_changes_ui(&msg.request);
+ let response = self.handle_api_request(msg.request);
+ let _ = msg.respond_to.send(response);
+ changed
+ }
+
+ pub(super) 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
+ }
+
+ pub(super) 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 } => {
+ self.update_host_terminal_theme(kind, color)
+ }
+ crate::raw_input::RawInputEvent::Unsupported => false,
+ }
+ }
+
+ 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
+ }
+
+ pub(crate) 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
+ }
+
+ pub(crate) 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),
+ }
+ }
+
+ pub(crate) 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,
+ }
+ }
+
+ pub(crate) fn run_auto_update_check(&mut self) {
+ if !auto_updates_enabled(self.no_session) {
+ self.next_auto_update_check = None;
+ return;
+ }
+
+ 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));
+ }
+
+ pub(crate) 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)
+ }
+
+ pub(crate) 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()
+ }
+
+ pub(crate) 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
+ }
+}
diff --git a/src/app/runtime_commands.rs b/src/app/runtime_commands.rs
deleted file mode 100644
index ad9b85bc..00000000
--- a/src/app/runtime_commands.rs
+++ /dev/null
@@ -1,177 +0,0 @@
-//! Runtime command launching and overlay pane lifecycle helpers for [`App`].
-
-use std::process::{Command, Stdio};
-
-use ratatui::layout::Direction;
-
-use super::{App, Mode};
-
-#[derive(Debug, Clone, Copy)]
-pub(super) struct OverlayPaneState {
- ws_idx: usize,
- tab_idx: usize,
- previous_focus: crate::layout::PaneId,
- previous_zoomed: bool,
-}
-
-impl App {
- pub(super) fn launch_custom_command(&mut self, binding: crate::config::CustomCommandKeybind) {
- let previous_toast = self.state.toast.clone();
- let result = match binding.action {
- crate::config::CustomCommandAction::Shell => self.spawn_custom_command(&binding),
- crate::config::CustomCommandAction::Pane => self.spawn_pane_command(&binding.command),
- };
- match result {
- Ok(()) => {
- if self.state.active.is_some() {
- self.state.mode = Mode::Terminal;
- }
- }
- Err(err) => {
- self.state.toast = Some(crate::app::state::ToastNotification {
- kind: crate::app::state::ToastKind::NeedsAttention,
- title: "custom command failed".to_string(),
- context: err.to_string(),
- });
- self.sync_toast_deadline(previous_toast);
- }
- }
- }
-
- fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
- let mut env = vec![(
- crate::api::SOCKET_PATH_ENV_VAR.to_string(),
- crate::api::socket_path().display().to_string(),
- )];
- if let Ok(current_exe) = std::env::current_exe() {
- env.push((
- "HERDR_BIN_PATH".to_string(),
- current_exe.display().to_string(),
- ));
- }
-
- let mut cwd = None;
- if let Some(ws_idx) = self.state.active {
- env.push((
- "HERDR_ACTIVE_WORKSPACE_ID".to_string(),
- self.public_workspace_id(ws_idx),
- ));
- if let Some(workspace) = self.state.workspaces.get(ws_idx) {
- let tab_idx = workspace.active_tab_index();
- if let Some(tab_id) = self.public_tab_id(ws_idx, tab_idx) {
- env.push(("HERDR_ACTIVE_TAB_ID".to_string(), tab_id));
- }
- if let Some(pane_id) = workspace.focused_pane_id() {
- if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) {
- env.push(("HERDR_ACTIVE_PANE_ID".to_string(), public_pane_id));
- }
- if let Some(pane_cwd) = workspace
- .active_tab()
- .and_then(|tab| tab.cwd_for_pane(pane_id))
- {
- env.push((
- "HERDR_ACTIVE_PANE_CWD".to_string(),
- pane_cwd.display().to_string(),
- ));
- if pane_cwd.is_dir() {
- cwd = Some(pane_cwd);
- }
- }
- }
- }
- }
- (env, cwd)
- }
-
- fn spawn_custom_command(
- &self,
- binding: &crate::config::CustomCommandKeybind,
- ) -> std::io::Result<()> {
- let mut command = Command::new("/bin/sh");
- command
- .arg("-lc")
- .arg(&binding.command)
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null());
- let (env, cwd) = self.custom_command_env();
- command.envs(env);
- if let Some(cwd) = cwd {
- command.current_dir(cwd);
- }
- command.spawn()?;
- Ok(())
- }
-
- fn spawn_pane_command(&mut self, command: &str) -> std::io::Result<()> {
- let Some(ws_idx) = self.state.active else {
- return Err(std::io::Error::other("no active workspace"));
- };
- let (rows, cols) = self.state.estimate_pane_size();
- let new_rows = rows.max(4);
- let new_cols = cols.max(10);
- let (env, _) = self.custom_command_env();
-
- let ws = self
- .state
- .workspaces
- .get_mut(ws_idx)
- .ok_or_else(|| std::io::Error::other("active workspace disappeared"))?;
- let tab_idx = ws.active_tab_index();
- let previous_focus = ws
- .focused_pane_id()
- .ok_or_else(|| std::io::Error::other("no focused pane"))?;
- let previous_zoomed = ws.active_tab().map(|tab| tab.zoomed).unwrap_or(false);
- let cwd = ws
- .active_tab()
- .and_then(|tab| tab.cwd_for_pane(previous_focus));
- let new_pane_id = ws.split_focused_command(
- Direction::Horizontal,
- new_rows,
- new_cols,
- cwd,
- command,
- &env,
- self.state.pane_scrollback_limit_bytes,
- self.state.host_terminal_theme,
- )?;
- ws.active_tab_mut()
- .expect("workspace must have an active tab")
- .layout
- .focus_pane(new_pane_id);
- ws.active_tab_mut()
- .expect("workspace must have an active tab")
- .zoomed = true;
- self.overlay_panes.insert(
- new_pane_id,
- OverlayPaneState {
- ws_idx,
- tab_idx,
- previous_focus,
- previous_zoomed,
- },
- );
- self.state.mode = Mode::Terminal;
- Ok(())
- }
-
- pub(super) fn restore_overlay_after_exit(&mut self, overlay: OverlayPaneState) {
- let Some(ws) = self.state.workspaces.get_mut(overlay.ws_idx) else {
- return;
- };
- if overlay.tab_idx >= ws.tabs.len() {
- return;
- }
-
- ws.active_tab = overlay.tab_idx;
- let tab = &mut ws.tabs[overlay.tab_idx];
- if tab.panes.contains_key(&overlay.previous_focus) {
- tab.layout.focus_pane(overlay.previous_focus);
- }
- tab.zoomed = overlay.previous_zoomed;
-
- if self.state.active == Some(overlay.ws_idx) {
- self.state.mode = Mode::Terminal;
- }
- }
-}
diff --git a/src/app/session.rs b/src/app/session.rs
new file mode 100644
index 00000000..c075fd4c
--- /dev/null
+++ b/src/app/session.rs
@@ -0,0 +1,41 @@
+use std::time::Instant;
+
+use super::{App, SESSION_SAVE_DEBOUNCE};
+
+impl App {
+ pub(super) fn schedule_session_save(&mut self) {
+ if !self.no_session {
+ self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE);
+ }
+ }
+
+ pub(crate) fn sync_session_save_schedule(&mut self) {
+ if self.state.session_dirty {
+ self.state.session_dirty = false;
+ self.schedule_session_save();
+ }
+ }
+
+ pub(crate) 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;
+ }
+}
diff --git a/src/app/state.rs b/src/app/state.rs
index b1e69098..67f89606 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -586,9 +586,17 @@ pub struct AppState {
pub selected: usize,
pub mode: Mode,
pub should_quit: bool,
+ /// In persistence mode, client quit actions detach instead of stopping the server.
+ pub quit_detaches: bool,
+ /// Set when the current client should detach from the persistent session.
+ /// The server's event loop checks this and handles client detach.
+ pub detach_requested: bool,
pub request_new_workspace: bool,
pub request_new_tab: bool,
pub request_reload_keybinds: bool,
+ /// Set when UI interaction requested a clipboard write that must be
+ /// handled by the outer App/event loop instead of directly from AppState.
+ pub request_clipboard_write: Option<Vec<u8>>,
pub creating_new_tab: bool,
pub requested_new_tab_name: Option<String>,
pub request_complete_onboarding: bool,
@@ -657,6 +665,10 @@ impl AppState {
self.sound.enabled
}
+ pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool {
+ key_matches(key, self.prefix_code, self.prefix_mods)
+ }
+
pub fn estimate_pane_size(&self) -> (u16, u16) {
if let Some(info) = self.view.pane_infos.first() {
(info.rect.height, info.rect.width)
@@ -666,7 +678,6 @@ impl AppState {
}
}
-#[cfg(test)]
pub fn key_matches(
key: &crossterm::event::KeyEvent,
expected_code: KeyCode,
@@ -700,9 +711,12 @@ impl AppState {
selected: 0,
mode: Mode::Navigate,
should_quit: false,
+ quit_detaches: false,
+ 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,
@@ -759,8 +773,10 @@ impl AppState {
new_workspace_label: "n".into(),
rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT),
rename_workspace_label: "shift+n".into(),
- close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()),
- close_workspace_label: "d".into(),
+ close_workspace: (KeyCode::Char('d'), KeyModifiers::SHIFT),
+ close_workspace_label: "shift+d".into(),
+ detach: None,
+ detach_label: None,
previous_workspace: None,
previous_workspace_label: None,
next_workspace: None,
diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs
new file mode 100644
index 00000000..df47f6a3
--- /dev/null
+++ b/src/app/theme_sync.rs
@@ -0,0 +1,51 @@
+use std::sync::atomic::Ordering;
+
+use super::App;
+
+impl App {
+ pub(super) 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();
+ }
+
+ pub(super) fn update_host_terminal_theme(
+ &mut self,
+ kind: crate::terminal_theme::DefaultColorKind,
+ color: crate::terminal_theme::RgbColor,
+ ) -> bool {
+ let next_theme = self.state.host_terminal_theme.with_color(kind, color);
+ self.set_host_terminal_theme(next_theme)
+ }
+
+ pub(crate) fn set_host_terminal_theme(
+ &mut self,
+ theme: crate::terminal_theme::TerminalTheme,
+ ) -> bool {
+ if theme.is_empty() || theme == self.state.host_terminal_theme {
+ return false;
+ }
+ self.state.host_terminal_theme = theme;
+ self.apply_host_terminal_theme_to_panes();
+ true
+ }
+
+ 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();
+ }
+}