aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
Diffstat (limited to 'src')
-rw-r--r--src/api/mod.rs101
-rw-r--r--src/api/schema.rs56
-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
-rw-r--r--src/cli.rs234
-rw-r--r--src/client/blit.rs793
-rw-r--r--src/client/input.rs135
-rw-r--r--src/client/mod.rs741
-rw-r--r--src/config.rs1960
-rw-r--r--src/config/io.rs226
-rw-r--r--src/config/keybinds.rs1089
-rw-r--r--src/config/model.rs216
-rw-r--r--src/config/sound.rs249
-rw-r--r--src/config/theme.rs163
-rw-r--r--src/detect.rs36
-rw-r--r--src/events.rs2
-rw-r--r--src/input/encode.rs363
-rw-r--r--src/input/mod.rs8
-rw-r--r--src/input/model.rs73
-rw-r--r--src/input/parse.rs391
-rw-r--r--src/input/tests.rs752
-rw-r--r--src/ipc.rs46
-rw-r--r--src/logging.rs29
-rw-r--r--src/main.rs85
-rw-r--r--src/pane.rs2706
-rw-r--r--src/pane/input.rs293
-rw-r--r--src/pane/osc.rs706
-rw-r--r--src/pane/state.rs313
-rw-r--r--src/pane/terminal.rs1307
-rw-r--r--src/persist.rs1060
-rw-r--r--src/persist/io.rs130
-rw-r--r--src/persist/restore.rs353
-rw-r--r--src/persist/snapshot.rs744
-rw-r--r--src/platform/linux.rs8
-rw-r--r--src/raw_input.rs239
-rw-r--r--src/selection.rs33
-rw-r--r--src/server/autodetect.rs296
-rw-r--r--src/server/headless.rs2272
-rw-r--r--src/server/mod.rs3
-rw-r--r--src/server/protocol.rs1212
-rw-r--r--src/ui.rs3616
-rw-r--r--src/ui/dialogs.rs213
-rw-r--r--src/ui/keybind_help.rs240
-rw-r--r--src/ui/menus.rs210
-rw-r--r--src/ui/onboarding.rs215
-rw-r--r--src/ui/panes.rs252
-rw-r--r--src/ui/release_notes.rs345
-rw-r--r--src/ui/scrollbar.rs191
-rw-r--r--src/ui/settings.rs238
-rw-r--r--src/ui/sidebar.rs940
-rw-r--r--src/ui/status.rs135
-rw-r--r--src/ui/tabs.rs356
-rw-r--r--src/ui/widgets.rs209
-rw-r--r--src/update.rs329
-rw-r--r--src/workspace.rs596
-rw-r--r--src/workspace/aggregate.rs193
-rw-r--r--src/workspace/git.rs149
-rw-r--r--src/workspace/tab.rs265
78 files changed, 24862 insertions, 17284 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 1b81e09c..437a616e 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -2,7 +2,6 @@ pub mod schema;
use std::fs;
use std::io::{BufRead, BufReader, Read, Write};
-use std::os::unix::fs::PermissionsExt;
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -28,6 +27,25 @@ const APP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
const INITIAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const STREAM_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
+pub(crate) fn request_changes_ui(request: &Request) -> bool {
+ 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(_)
+ )
+}
+
pub struct ApiRequestMessage {
pub request: Request,
pub respond_to: std::sync::mpsc::Sender<String>,
@@ -80,16 +98,6 @@ pub fn socket_path() -> PathBuf {
return PathBuf::from(path);
}
- let socket_name = if crate::config::app_dir_name() == "herdr" {
- "herdr.sock"
- } else {
- "herdr-dev.sock"
- };
-
- if let Ok(dir) = std::env::var("XDG_RUNTIME_DIR") {
- return PathBuf::from(dir).join(socket_name);
- }
-
crate::config::config_dir().join("herdr.sock")
}
@@ -156,47 +164,16 @@ pub fn start_server(
}
fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent)?;
- }
-
- if !path.exists() {
- return Ok(());
- }
-
- match UnixStream::connect(path) {
- Ok(_) => {
- return Err(std::io::Error::new(
- std::io::ErrorKind::AddrInUse,
- format!(
- "herdr is already running (socket busy at {})",
- path.display()
- ),
- ));
- }
- Err(err)
- if matches!(
- err.kind(),
- std::io::ErrorKind::ConnectionRefused
- | std::io::ErrorKind::NotFound
- | std::io::ErrorKind::TimedOut
- ) => {}
- Err(err) => return Err(err),
- }
-
- if let Err(err) = fs::remove_file(path) {
- if err.kind() != std::io::ErrorKind::NotFound {
- return Err(err);
- }
- }
-
- Ok(())
+ crate::ipc::prepare_socket_path(path, |path| {
+ format!(
+ "herdr is already running (socket busy at {})",
+ path.display()
+ )
+ })
}
fn restrict_socket_permissions(path: &Path) -> std::io::Result<()> {
- let mut permissions = fs::metadata(path)?.permissions();
- permissions.set_mode(SOCKET_PERMISSION_MODE);
- fs::set_permissions(path, permissions)
+ crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE)
}
fn handle_connection(
@@ -947,6 +924,13 @@ fn error_response_json(id: String, code: &str, message: String) -> String {
#[cfg(test)]
mod tests {
use super::*;
+ use std::os::unix::fs::PermissionsExt;
+ use std::sync::{Mutex, OnceLock};
+
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
fn unique_test_path(name: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
@@ -965,6 +949,7 @@ mod tests {
#[test]
fn socket_path_prefers_explicit_env_override() {
+ let _guard = env_lock().lock().unwrap();
let unique = format!("/tmp/herdr-test-{}.sock", std::process::id());
std::env::set_var(SOCKET_PATH_ENV_VAR, &unique);
assert_eq!(socket_path(), PathBuf::from(&unique));
@@ -972,6 +957,24 @@ mod tests {
}
#[test]
+ fn socket_path_defaults_to_config_dir_even_when_xdg_runtime_dir_is_set() {
+ let _guard = env_lock().lock().unwrap();
+ let config_home = unique_test_path("socket-default-config-home");
+ let runtime_dir = unique_test_path("socket-default-runtime");
+ std::env::remove_var(SOCKET_PATH_ENV_VAR);
+ std::env::set_var("XDG_CONFIG_HOME", &config_home);
+ std::env::set_var("XDG_RUNTIME_DIR", &runtime_dir);
+
+ let expected = config_home
+ .join(crate::config::app_dir_name())
+ .join("herdr.sock");
+ assert_eq!(socket_path(), expected);
+
+ std::env::remove_var("XDG_CONFIG_HOME");
+ std::env::remove_var("XDG_RUNTIME_DIR");
+ }
+
+ #[test]
fn restrict_socket_permissions_sets_user_only_mode() {
let dir = unique_test_path("socket-perms");
fs::create_dir_all(&dir).unwrap();
diff --git a/src/api/schema.rs b/src/api/schema.rs
index 38393141..6cb81c12 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -12,6 +12,8 @@ pub struct Request {
pub enum Method {
#[serde(rename = "ping")]
Ping(PingParams),
+ #[serde(rename = "server.stop")]
+ ServerStop(EmptyParams),
#[serde(rename = "workspace.create")]
WorkspaceCreate(WorkspaceCreateParams),
#[serde(rename = "workspace.list")]
@@ -64,6 +66,10 @@ pub enum Method {
EventsWait(EventsWaitParams),
#[serde(rename = "pane.wait_for_output")]
PaneWaitForOutput(PaneWaitForOutputParams),
+ #[serde(rename = "integration.install")]
+ IntegrationInstall(IntegrationInstallParams),
+ #[serde(rename = "integration.uninstall")]
+ IntegrationUninstall(IntegrationUninstallParams),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -286,6 +292,25 @@ pub struct PaneWaitForOutputParams {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationInstallParams {
+ pub target: IntegrationTarget,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationUninstallParams {
+ pub target: IntegrationTarget,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum IntegrationTarget {
+ Pi,
+ Claude,
+ Codex,
+ Opencode,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum OutputMatch {
Substring { value: String },
@@ -442,6 +467,14 @@ pub enum ResponseResult {
matched_line: Option<String>,
read: PaneReadResult,
},
+ IntegrationInstall {
+ target: IntegrationTarget,
+ details: IntegrationInstallResult,
+ },
+ IntegrationUninstall {
+ target: IntegrationTarget,
+ details: IntegrationUninstallResult,
+ },
Ok {},
}
@@ -494,6 +527,16 @@ pub struct PaneReadResult {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationInstallResult {
+ pub messages: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct IntegrationUninstallResult {
+ pub messages: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventEnvelope {
pub event: EventKind,
pub data: EventData,
@@ -710,6 +753,19 @@ mod tests {
}
#[test]
+ fn request_round_trips_for_server_stop() {
+ let request = Request {
+ id: "req_stop".into(),
+ method: Method::ServerStop(EmptyParams::default()),
+ };
+
+ let json = serde_json::to_value(&request).unwrap();
+ assert_eq!(json["method"], "server.stop");
+ let restored: Request = serde_json::from_value(json).unwrap();
+ assert_eq!(restored, request);
+ }
+
+ #[test]
fn unknown_method_is_rejected() {
let json = r#"{"id":"req_1","method":"nope","params":{}}"#;
let err = serde_json::from_str::<Request>(json)
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();
+ }
+}
diff --git a/src/cli.rs b/src/cli.rs
index 620a592f..feb24c70 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -6,7 +6,8 @@ use serde::Serialize;
use crate::api;
use crate::api::schema::{
- AgentStatus, EmptyParams, Method, OutputMatch, PaneListParams, PaneReadParams,
+ AgentStatus, EmptyParams, IntegrationInstallParams, IntegrationTarget,
+ IntegrationUninstallParams, Method, OutputMatch, PaneListParams, PaneReadParams,
PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, PaneTarget,
PaneWaitForOutputParams, ReadSource, Request, SplitDirection, Subscription, TabCreateParams,
TabListParams, TabRenameParams, TabTarget, WorkspaceCreateParams, WorkspaceRenameParams,
@@ -24,6 +25,12 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
};
let exit_code = match command {
+ "server" => {
+ let Some(exit_code) = run_server_command(&args[2..])? else {
+ return Ok(CommandOutcome::NotCli);
+ };
+ exit_code
+ }
"workspace" => run_workspace_command(&args[2..])?,
"tab" => run_tab_command(&args[2..])?,
"pane" => run_pane_command(&args[2..])?,
@@ -35,6 +42,24 @@ pub fn maybe_run(args: &[String]) -> std::io::Result<CommandOutcome> {
Ok(CommandOutcome::Handled(exit_code))
}
+fn run_server_command(args: &[String]) -> std::io::Result<Option<i32>> {
+ let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
+ return Ok(None);
+ };
+
+ match subcommand {
+ "stop" => server_stop(&args[1..]).map(Some),
+ "help" | "--help" | "-h" => {
+ print_server_help();
+ Ok(Some(0))
+ }
+ _ => {
+ print_server_help();
+ Ok(Some(2))
+ }
+ }
+}
+
fn run_workspace_command(args: &[String]) -> std::io::Result<i32> {
let Some(subcommand) = args.first().map(|arg| arg.as_str()) else {
print_workspace_help();
@@ -129,6 +154,15 @@ fn run_integration_command(args: &[String]) -> std::io::Result<i32> {
}
}
+fn server_stop(args: &[String]) -> std::io::Result<i32> {
+ if !args.is_empty() {
+ eprintln!("usage: herdr server stop");
+ return Ok(2);
+ }
+
+ send_ok_request(Method::ServerStop(EmptyParams::default()))
+}
+
fn workspace_list(args: &[String]) -> std::io::Result<i32> {
if !args.is_empty() {
eprintln!("usage: herdr workspace list");
@@ -625,153 +659,85 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
}
fn integration_install(args: &[String]) -> std::io::Result<i32> {
- let Some(target) = args.first().map(|arg| arg.as_str()) else {
- eprintln!("usage: herdr integration install <pi|claude|codex|opencode>");
+ let Some(target) = parse_integration_target(args, "install")? else {
return Ok(2);
};
- if args.len() != 1 {
- eprintln!("usage: herdr integration install <pi|claude|codex|opencode>");
- return Ok(2);
+
+ let response = send_request(&Request {
+ id: "cli:integration:install".into(),
+ method: Method::IntegrationInstall(IntegrationInstallParams { target }),
+ })?;
+
+ if let Some(error) = response.get("error") {
+ eprintln!("{}", serde_json::to_string(error).unwrap());
+ return Ok(1);
}
- match target {
- "pi" => {
- let path = crate::integration::install_pi()?;
- println!("installed pi integration to {}", path.display());
- Ok(0)
- }
- "claude" => {
- let installed = crate::integration::install_claude()?;
- println!(
- "installed claude integration hook to {}",
- installed.hook_path.display()
- );
- println!(
- "ensured claude settings at {}",
- installed.settings_path.display()
- );
- Ok(0)
- }
- "codex" => {
- let installed = crate::integration::install_codex()?;
- println!(
- "installed codex integration hook to {}",
- installed.hook_path.display()
- );
- println!("ensured codex hooks at {}", installed.hooks_path.display());
- println!(
- "ensured codex config at {}",
- installed.config_path.display()
- );
- Ok(0)
- }
- "opencode" => {
- let installed = crate::integration::install_opencode()?;
- println!(
- "installed opencode integration plugin to {}",
- installed.plugin_path.display()
- );
- Ok(0)
- }
- _ => {
- eprintln!("unknown integration target: {target}");
- eprintln!("currently supported: pi, claude, codex, opencode");
- Ok(2)
- }
+ let Some(messages) = response["result"]["details"]["messages"].as_array() else {
+ eprintln!("invalid integration install response");
+ return Ok(1);
+ };
+
+ for message in messages.iter().filter_map(|entry| entry.as_str()) {
+ println!("{message}");
}
+
+ Ok(0)
}
fn integration_uninstall(args: &[String]) -> std::io::Result<i32> {
- let Some(target) = args.first().map(|arg| arg.as_str()) else {
- eprintln!("usage: herdr integration uninstall <pi|claude|codex|opencode>");
+ let Some(target) = parse_integration_target(args, "uninstall")? else {
return Ok(2);
};
+
+ let response = send_request(&Request {
+ id: "cli:integration:uninstall".into(),
+ method: Method::IntegrationUninstall(IntegrationUninstallParams { target }),
+ })?;
+
+ if let Some(error) = response.get("error") {
+ eprintln!("{}", serde_json::to_string(error).unwrap());
+ return Ok(1);
+ }
+
+ let Some(messages) = response["result"]["details"]["messages"].as_array() else {
+ eprintln!("invalid integration uninstall response");
+ return Ok(1);
+ };
+
+ for message in messages.iter().filter_map(|entry| entry.as_str()) {
+ println!("{message}");
+ }
+
+ Ok(0)
+}
+
+fn parse_integration_target(
+ args: &[String],
+ action: &str,
+) -> std::io::Result<Option<IntegrationTarget>> {
+ let Some(target) = args.first().map(|arg| arg.as_str()) else {
+ eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode>");
+ return Ok(None);
+ };
if args.len() != 1 {
- eprintln!("usage: herdr integration uninstall <pi|claude|codex|opencode>");
- return Ok(2);
+ eprintln!("usage: herdr integration {action} <pi|claude|codex|opencode>");
+ return Ok(None);
}
- match target {
- "pi" => {
- let result = crate::integration::uninstall_pi()?;
- if result.removed_extension {
- println!(
- "removed pi integration extension at {}",
- result.extension_path.display()
- );
- } else {
- println!(
- "no pi integration extension found at {}",
- result.extension_path.display()
- );
- }
- Ok(0)
- }
- "claude" => {
- let result = crate::integration::uninstall_claude()?;
- if result.removed_hook_file {
- println!("removed claude hook at {}", result.hook_path.display());
- } else {
- println!("no claude hook found at {}", result.hook_path.display());
- }
- if result.updated_settings {
- println!(
- "removed herdr claude hook entries from {}",
- result.settings_path.display()
- );
- } else {
- println!(
- "no herdr claude hook entries found in {}",
- result.settings_path.display()
- );
- }
- Ok(0)
- }
- "codex" => {
- let result = crate::integration::uninstall_codex()?;
- if result.removed_hook_file {
- println!("removed codex hook at {}", result.hook_path.display());
- } else {
- println!("no codex hook found at {}", result.hook_path.display());
- }
- if result.updated_hooks {
- println!(
- "removed herdr codex hook entries from {}",
- result.hooks_path.display()
- );
- } else {
- println!(
- "no herdr codex hook entries found in {}",
- result.hooks_path.display()
- );
- }
- println!(
- "left codex config unchanged at {}",
- result.config_path.display()
- );
- Ok(0)
- }
- "opencode" => {
- let result = crate::integration::uninstall_opencode()?;
- if result.removed_plugin {
- println!(
- "removed opencode integration plugin at {}",
- result.plugin_path.display()
- );
- } else {
- println!(
- "no opencode integration plugin found at {}",
- result.plugin_path.display()
- );
- }
- Ok(0)
- }
+ let parsed = match target {
+ "pi" => IntegrationTarget::Pi,
+ "claude" => IntegrationTarget::Claude,
+ "codex" => IntegrationTarget::Codex,
+ "opencode" => IntegrationTarget::Opencode,
_ => {
eprintln!("unknown integration target: {target}");
eprintln!("currently supported: pi, claude, codex, opencode");
- Ok(2)
+ return Ok(None);
}
- }
+ };
+
+ Ok(Some(parsed))
}
fn wait_output(args: &[String]) -> std::io::Result<i32> {
@@ -1071,6 +1037,12 @@ fn parse_u64_flag(flag: &str, value: &str) -> std::io::Result<u64> {
.map_err(|_| std::io::Error::other(format!("invalid value for {flag}: {value}")))
}
+fn print_server_help() {
+ eprintln!("herdr server commands:");
+ eprintln!(" herdr server run as headless server");
+ eprintln!(" herdr server stop stop the running server via the api socket");
+}
+
fn print_workspace_help() {
eprintln!("herdr workspace commands:");
eprintln!(" herdr workspace list");
diff --git a/src/client/blit.rs b/src/client/blit.rs
new file mode 100644
index 00000000..aa443a7b
--- /dev/null
+++ b/src/client/blit.rs
@@ -0,0 +1,793 @@
+//! Frame blitting — renders FrameData to the terminal using diff-based updates.
+//!
+//! The blitting strategy:
+//! 1. On the first frame, write the entire buffer (full redraw).
+//! 2. On subsequent frames, diff against the last frame and only write
+//! the cells that changed.
+//! 3. Before writing any cells, hide the cursor to avoid stray cursor
+//! artifacts on terminals that render the hardware cursor at intermediate
+//! `CUP` positions during the frame stream.
+//! 4. After writing all changed cells, restore the final cursor visibility
+//! and position from `frame.cursor`.
+//!
+//! Escape sequences used:
+//! - `CSI H` (CUP) — move cursor to (row, col)
+//! - `CSI m` (SGR) — set graphic rendition (colors, bold, etc.)
+//! - `ESC ] 52 ; c ; <base64> BEL` — OSC 52 clipboard write
+//!
+//! The goal is minimal output: skip unchanged cells, batch adjacent changes,
+//! and minimize cursor movement.
+
+use std::cmp;
+use std::io::{self, Write};
+
+use unicode_width::UnicodeWidthStr;
+
+use crate::server::protocol::{CellData, FrameData};
+
+// ---------------------------------------------------------------------------
+// Color → escape sequence
+// ---------------------------------------------------------------------------
+
+/// Converts a packed u32 color to an SGR escape sequence fragment.
+///
+/// Returns a string like `38;5;123` (indexed) or `38;2;255;128;64` (RGB)
+/// or `39` (reset), without the leading `\x1b[` or trailing `m`.
+fn color_to_sgr_fg(val: u32) -> String {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => "39".to_owned(), // Reset
+ 0x01 => "30".to_owned(), // Black
+ 0x02 => "31".to_owned(), // Red
+ 0x03 => "32".to_owned(), // Green
+ 0x04 => "33".to_owned(), // Yellow
+ 0x05 => "34".to_owned(), // Blue
+ 0x06 => "35".to_owned(), // Magenta
+ 0x07 => "36".to_owned(), // Cyan
+ 0x08 => "37".to_owned(), // Gray (light gray)
+ 0x09 => "90".to_owned(), // DarkGray
+ 0x0A => "91".to_owned(), // LightRed
+ 0x0B => "92".to_owned(), // LightGreen
+ 0x0C => "93".to_owned(), // LightYellow
+ 0x0D => "94".to_owned(), // LightBlue
+ 0x0E => "95".to_owned(), // LightMagenta
+ 0x0F => "96".to_owned(), // LightCyan
+ 0x10 => "97".to_owned(), // White
+ _ => "39".to_owned(), // Unknown → Reset
+ },
+ 0x01 => format!("38;5;{}", val & 0xFF), // Indexed
+ 0x02 => {
+ // RGB
+ let r = (val >> 16) & 0xFF;
+ let g = (val >> 8) & 0xFF;
+ let b = val & 0xFF;
+ format!("38;2;{r};{g};{b}")
+ }
+ _ => "39".to_owned(), // Unknown → Reset
+ }
+}
+
+/// Converts a packed u32 color to a background SGR fragment.
+fn color_to_sgr_bg(val: u32) -> String {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => "49".to_owned(), // Reset
+ 0x01 => "40".to_owned(), // Black
+ 0x02 => "41".to_owned(), // Red
+ 0x03 => "42".to_owned(), // Green
+ 0x04 => "43".to_owned(), // Yellow
+ 0x05 => "44".to_owned(), // Blue
+ 0x06 => "45".to_owned(), // Magenta
+ 0x07 => "46".to_owned(), // Cyan
+ 0x08 => "47".to_owned(), // Gray (light gray)
+ 0x09 => "100".to_owned(), // DarkGray
+ 0x0A => "101".to_owned(), // LightRed
+ 0x0B => "102".to_owned(), // LightGreen
+ 0x0C => "103".to_owned(), // LightYellow
+ 0x0D => "104".to_owned(), // LightBlue
+ 0x0E => "105".to_owned(), // LightMagenta
+ 0x0F => "106".to_owned(), // LightCyan
+ 0x10 => "107".to_owned(), // White
+ _ => "49".to_owned(), // Unknown → Reset
+ },
+ 0x01 => format!("48;5;{}", val & 0xFF), // Indexed
+ 0x02 => {
+ let r = (val >> 16) & 0xFF;
+ let g = (val >> 8) & 0xFF;
+ let b = val & 0xFF;
+ format!("48;2;{r};{g};{b}")
+ }
+ _ => "49".to_owned(),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Modifier → SGR
+// ---------------------------------------------------------------------------
+
+/// Converts a u16 modifier bitmask to SGR escape sequence fragments.
+///
+/// Returns a Vec of SGR parameter strings (e.g., "1" for bold, "3" for italic).
+fn modifier_to_sgr_parts(val: u16) -> Vec<&'static str> {
+ let mut parts = Vec::new();
+
+ // ratatui::Modifier bits (from bitflags)
+ const BOLD: u16 = 1 << 0; // 0x01
+ const DIM: u16 = 1 << 1; // 0x02
+ const ITALIC: u16 = 1 << 2; // 0x04
+ const UNDERLINED: u16 = 1 << 3; // 0x08
+ const SLOW_BLINK: u16 = 1 << 4; // 0x10
+ const RAPID_BLINK: u16 = 1 << 5; // 0x20
+ const REVERSED: u16 = 1 << 6; // 0x40
+ const HIDDEN: u16 = 1 << 7; // 0x80
+ const CROSSED_OUT: u16 = 1 << 8; // 0x100
+
+ if val & BOLD != 0 {
+ parts.push("1");
+ }
+ if val & DIM != 0 {
+ parts.push("2");
+ }
+ if val & ITALIC != 0 {
+ parts.push("3");
+ }
+ if val & UNDERLINED != 0 {
+ parts.push("4");
+ }
+ if val & SLOW_BLINK != 0 {
+ parts.push("5");
+ }
+ if val & RAPID_BLINK != 0 {
+ parts.push("6");
+ }
+ if val & REVERSED != 0 {
+ parts.push("7");
+ }
+ if val & HIDDEN != 0 {
+ parts.push("8");
+ }
+ if val & CROSSED_OUT != 0 {
+ parts.push("9");
+ }
+
+ parts
+}
+
+/// Builds a complete SGR escape sequence for a cell's style.
+fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String {
+ let mut parts = vec!["0".to_owned()];
+ parts.extend(
+ modifier_to_sgr_parts(modifier)
+ .into_iter()
+ .map(str::to_owned),
+ );
+ parts.push(color_to_sgr_fg(fg));
+ parts.push(color_to_sgr_bg(bg));
+ format!("\x1b[{}m", parts.join(";"))
+}
+
+// ---------------------------------------------------------------------------
+// Cell comparison
+// ---------------------------------------------------------------------------
+
+/// Checks if two cells are visually identical.
+#[cfg(test)]
+fn cells_equal(a: &CellData, b: &CellData) -> bool {
+ a.symbol == b.symbol && a.fg == b.fg && a.bg == b.bg && a.modifier == b.modifier
+ // Skip flag is only for ratatui internal use, not visual.
+}
+
+// ---------------------------------------------------------------------------
+// Blitting
+// ---------------------------------------------------------------------------
+
+/// Blits a frame to the terminal, diffing against the previous frame.
+///
+/// On the first frame (no previous), does a full redraw.
+/// On subsequent frames, only writes cells that changed.
+/// After writing cells, positions the cursor.
+pub fn blit_frame(frame: &FrameData, prev: Option<&FrameData>) {
+ let mut stdout = io::stdout();
+ blit_frame_to(&mut stdout, frame, prev);
+}
+
+/// Blits a frame to a writer, diffing against the previous frame.
+fn blit_frame_to(mut writer: impl Write, frame: &FrameData, prev: Option<&FrameData>) {
+ // On first frame or size change, do a full redraw.
+ let full_redraw =
+ prev.is_none() || prev.is_some_and(|p| p.width != frame.width || p.height != frame.height);
+
+ // Hide cursor before any cell writes to avoid stray cursor artifacts
+ // on terminals that render the hardware cursor at intermediate CUP positions.
+ let _ = writer.write_all(b"\x1b[?25l");
+
+ if full_redraw {
+ // Clear the screen and write all cells.
+ let _ = writer.write_all(b"\x1b[2J\x1b[H");
+ write_all_cells(&mut writer, frame);
+ } else {
+ // Diff-based update: only write changed cells.
+ let prev = prev.unwrap();
+ write_changed_cells(&mut writer, frame, prev);
+ }
+
+ // Position the cursor and set visibility.
+ if let Some(cursor) = &frame.cursor {
+ if cursor.visible {
+ // Show cursor (restore visibility if it was hidden on a previous frame).
+ let _ = writer.write_all(b"\x1b[?25h");
+ // CUP: move cursor to (row+1, col+1) — 1-based.
+ let _ = write!(writer, "\x1b[{};{}H", cursor.y + 1, cursor.x + 1);
+ } else {
+ // Hide cursor.
+ let _ = writer.write_all(b"\x1b[?25l");
+ }
+ } else {
+ // No cursor info — hide cursor.
+ let _ = writer.write_all(b"\x1b[?25l");
+ }
+}
+
+/// Writes all cells in the frame (full redraw).
+fn cell_width(cell: &CellData) -> usize {
+ cell.symbol.width()
+}
+
+fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
+ for row in 0..frame.height {
+ let mut to_skip = 0usize;
+ for col in 0..frame.width {
+ if to_skip > 0 {
+ to_skip -= 1;
+ continue;
+ }
+
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+
+ if cell.skip {
+ continue;
+ }
+
+ // Move cursor to position (1-based).
+ let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1);
+
+ // Set style.
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ let _ = writer.write_all(sgr.as_bytes());
+
+ // Write the symbol.
+ let _ = writer.write_all(cell.symbol.as_bytes());
+ to_skip = cell_width(cell).saturating_sub(1);
+ }
+ }
+
+ // Reset style at the end.
+ let _ = writer.write_all(b"\x1b[0m");
+}
+
+fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last_sgr: &mut String) {
+ if cell.skip {
+ return;
+ }
+
+ let _ = write!(writer, "\x1b[{};{}H", row + 1, col + 1);
+
+ let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
+ if sgr != *last_sgr {
+ let _ = writer.write_all(sgr.as_bytes());
+ *last_sgr = sgr;
+ }
+
+ let _ = writer.write_all(cell.symbol.as_bytes());
+}
+
+/// Writes only the cells that changed between the previous and current frame.
+fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) {
+ let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes.
+
+ for row in 0..frame.height {
+ let mut invalidated = 0usize;
+ let mut to_skip = 0usize;
+
+ for col in 0..frame.width {
+ let idx = (row as usize) * (frame.width as usize) + (col as usize);
+ let cell = &frame.cells[idx];
+ let prev_cell = &prev.cells[idx];
+
+ if !cell.skip && (cell != prev_cell || invalidated > 0) && to_skip == 0 {
+ write_cell(writer, row, col, cell, &mut last_sgr);
+ }
+
+ to_skip = cell_width(cell).saturating_sub(1);
+ let affected_width = cmp::max(cell_width(cell), cell_width(prev_cell));
+ invalidated = cmp::max(affected_width, invalidated).saturating_sub(1);
+ }
+ }
+
+ // Reset style if we wrote anything.
+ if !last_sgr.is_empty() {
+ let _ = writer.write_all(b"\x1b[0m");
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::server::protocol::{CellData, CursorState};
+
+ const WIDE_GRAPHEME: &str = "💡";
+
+ fn make_cell(symbol: &str, fg: u32, bg: u32, modifier: u16) -> CellData {
+ CellData {
+ symbol: symbol.to_owned(),
+ fg,
+ bg,
+ modifier,
+ skip: false,
+ }
+ }
+
+ fn make_frame(width: u16, height: u16, cells: Vec<CellData>) -> FrameData {
+ FrameData {
+ cells,
+ width,
+ height,
+ cursor: None,
+ }
+ }
+
+ #[test]
+ fn color_to_sgr_fg_named_colors() {
+ assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset
+ assert_eq!(color_to_sgr_fg(0x00_00_00_01), "30"); // Black
+ assert_eq!(color_to_sgr_fg(0x00_00_00_02), "31"); // Red
+ assert_eq!(color_to_sgr_fg(0x00_00_00_10), "97"); // White
+ }
+
+ #[test]
+ fn color_to_sgr_fg_indexed() {
+ assert_eq!(color_to_sgr_fg(0x01_00_00_AB), "38;5;171");
+ }
+
+ #[test]
+ fn color_to_sgr_fg_rgb() {
+ assert_eq!(color_to_sgr_fg(0x02_FF_80_40), "38;2;255;128;64");
+ }
+
+ #[test]
+ fn color_to_sgr_bg_named_colors() {
+ assert_eq!(color_to_sgr_bg(0x00_00_00_00), "49"); // Reset
+ assert_eq!(color_to_sgr_bg(0x00_00_00_01), "40"); // Black
+ assert_eq!(color_to_sgr_bg(0x00_00_00_10), "107"); // White
+ }
+
+ #[test]
+ fn color_to_sgr_bg_rgb() {
+ assert_eq!(color_to_sgr_bg(0x02_FF_80_40), "48;2;255;128;64");
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_bold() {
+ let parts = modifier_to_sgr_parts(1); // BOLD
+ assert!(parts.contains(&"1"));
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_italic() {
+ let parts = modifier_to_sgr_parts(4); // ITALIC
+ assert!(parts.contains(&"3"));
+ }
+
+ #[test]
+ fn modifier_to_sgr_parts_empty() {
+ let parts = modifier_to_sgr_parts(0);
+ assert!(parts.is_empty());
+ }
+
+ #[test]
+ fn build_sgr_produces_valid_sequence() {
+ let sgr = build_sgr(0x00_00_00_02, 0x00_00_00_01, 1); // fg=Red, bg=Black, bold
+ assert!(sgr.starts_with("\x1b["));
+ assert!(sgr.ends_with("m"));
+ assert!(sgr.contains("0")); // reset existing style first
+ assert!(sgr.contains("1")); // bold
+ assert!(sgr.contains("31")); // fg red
+ assert!(sgr.contains("40")); // bg black
+ }
+
+ #[test]
+ fn build_sgr_resets_previous_modifiers_when_cell_is_plain() {
+ assert_eq!(build_sgr(0x00_00_00_00, 0x00_00_00_00, 0), "\x1b[0;39;49m");
+ }
+
+ #[test]
+ fn cells_equal_identical() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("A", 2, 1, 0);
+ assert!(cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn cells_equal_different_symbol() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("B", 2, 1, 0);
+ assert!(!cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn cells_equal_different_color() {
+ let a = make_cell("A", 2, 1, 0);
+ let b = make_cell("A", 3, 1, 0);
+ assert!(!cells_equal(&a, &b));
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_before_full_redraw_writes() {
+ let frame = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?25l"),
+ "should hide cursor before any cell writes during full redraw"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_before_diff_writes() {
+ let prev = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let curr = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("X", 0, 0, 0), // Changed
+ make_cell("i", 0, 0, 0), // Same
+ make_cell("!", 0, 0, 0), // Same
+ make_cell(" ", 0, 0, 0), // Same
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.starts_with("\x1b[?25l"),
+ "should hide cursor before any cell writes during diff"
+ );
+ }
+
+ #[test]
+ fn blit_frame_first_frame_produces_output() {
+ let frame = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ // Full redraw should start with clear screen.
+ assert!(
+ output_str.contains("\x1b[2J"),
+ "full redraw should clear screen"
+ );
+ assert!(
+ output_str.contains('H') || output_str.contains('i'),
+ "should contain cell content"
+ );
+ }
+
+ #[test]
+ fn blit_frame_diff_only_writes_changed_cells() {
+ let prev = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("H", 0, 0, 0),
+ make_cell("i", 0, 0, 0),
+ make_cell("!", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ ],
+ );
+
+ // Only the first cell changed.
+ let curr = make_frame(
+ 2,
+ 2,
+ vec![
+ make_cell("X", 0, 0, 0), // Changed
+ make_cell("i", 0, 0, 0), // Same
+ make_cell("!", 0, 0, 0), // Same
+ make_cell(" ", 0, 0, 0), // Same
+ ],
+ );
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ // Diff should NOT clear the screen.
+ assert!(
+ !output_str.contains("\x1b[2J"),
+ "diff should not clear screen"
+ );
+ // Should contain the changed cell content.
+ assert!(output_str.contains('X'), "should contain changed cell 'X'");
+ }
+
+ #[test]
+ fn blit_frame_size_change_triggers_full_redraw() {
+ let prev = make_frame(2, 2, vec![make_cell("A", 0, 0, 0); 4]);
+
+ let curr = make_frame(3, 2, vec![make_cell("B", 0, 0, 0); 6]);
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[2J"),
+ "size change should trigger full redraw"
+ );
+ }
+
+ #[test]
+ fn blit_frame_positions_cursor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[1;1H"),
+ "should position cursor at (1,1)"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_cursor_when_invisible() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25l"),
+ "should hide cursor when invisible"
+ );
+ }
+
+ #[test]
+ fn blit_frame_no_cursor_hides_cursor() {
+ let frame = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25l"),
+ "should hide cursor when no cursor state"
+ );
+ }
+
+ #[test]
+ fn blit_frame_restores_cursor_visibility() {
+ // First frame: cursor hidden.
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: false,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &prev, None);
+ assert!(
+ String::from_utf8(output).unwrap().contains("\x1b[?25l"),
+ "first frame should hide cursor"
+ );
+
+ // Second frame: cursor visible — should restore visibility.
+ let curr = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+ assert!(
+ output_str.contains("\x1b[?25h"),
+ "second frame should restore cursor visibility with ?25h"
+ );
+ assert!(
+ output_str.contains("\x1b[1;1H"),
+ "should position cursor after showing it"
+ );
+ }
+
+ #[test]
+ fn blit_frame_hides_previous_visible_cursor_when_next_frame_has_none() {
+ let prev = FrameData {
+ cells: vec![make_cell("A", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+ let curr = FrameData {
+ cells: vec![make_cell("B", 0, 0, 0)],
+ width: 1,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+
+ assert!(
+ String::from_utf8(output).unwrap().contains("\x1b[?25l"),
+ "diff redraw should hide a previously visible cursor when the next frame has none"
+ );
+ }
+
+ #[test]
+ fn full_redraw_skips_trailing_cells_covered_by_wide_graphemes() {
+ let frame = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &frame, None);
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(!output_str.contains("\x1b[1;2H"));
+ assert!(output_str.contains("\x1b[1;3H"));
+ }
+
+ #[test]
+ fn diff_redraw_reveals_cells_hidden_by_previous_wide_graphemes() {
+ let prev = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+ let curr = FrameData {
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(
+ output_str.contains("\x1b[1;2H"),
+ "cells hidden by a previous wide grapheme must be redrawn when they become visible"
+ );
+ }
+
+ #[test]
+ fn diff_redraw_skips_new_trailing_cells_covered_by_wide_graphemes() {
+ let prev = FrameData {
+ cells: vec![
+ make_cell("A", 0, 0, 0),
+ make_cell("B", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+ let curr = FrameData {
+ cells: vec![
+ make_cell(WIDE_GRAPHEME, 0, 0, 0),
+ make_cell(" ", 0, 0, 0),
+ make_cell("Z", 0, 0, 0),
+ ],
+ width: 3,
+ height: 1,
+ cursor: None,
+ };
+
+ let mut output = Vec::new();
+ blit_frame_to(&mut output, &curr, Some(&prev));
+ let output_str = String::from_utf8(output).unwrap();
+
+ assert!(output_str.contains("\x1b[1;1H"));
+ assert!(!output_str.contains("\x1b[1;2H"));
+ }
+}
diff --git a/src/client/input.rs b/src/client/input.rs
new file mode 100644
index 00000000..31e1c1e4
--- /dev/null
+++ b/src/client/input.rs
@@ -0,0 +1,135 @@
+//! Stdin input reading for the thin client.
+//!
+//! Reads raw bytes from stdin and forwards them to the main event loop.
+//! Unlike the monolithic herdr, the thin client does NOT parse the input
+//! into key/mouse/paste events — it just sends the raw bytes to the server
+//! as `ClientMessage::Input`. The server handles parsing.
+//!
+//! This is simpler and more reliable because:
+//! - The server has the same input parsing code
+//! - We avoid duplicating parsing logic in the client
+//! - Raw forwarding preserves all escape sequences faithfully
+
+use std::io::{self, Read};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+#[cfg(unix)]
+use std::os::fd::AsRawFd;
+use tokio::sync::mpsc;
+
+use super::ClientLoopEvent;
+
+// ---------------------------------------------------------------------------
+// Stdin reader thread
+// ---------------------------------------------------------------------------
+
+/// Reads raw bytes from stdin and sends them to the main event loop.
+///
+/// This runs on a dedicated thread because stdin reading is blocking.
+/// The main loop receives the raw bytes and forwards them as
+/// `ClientMessage::Input` to the server.
+pub fn stdin_reader_loop(event_tx: mpsc::Sender<ClientLoopEvent>, should_quit: &Arc<AtomicBool>) {
+ let stdin = io::stdin();
+ let mut reader = stdin.lock();
+ let mut scratch = [0u8; 4096];
+ let mut buffer = Vec::new();
+
+ while !should_quit.load(Ordering::Acquire) {
+ match reader.read(&mut scratch) {
+ Ok(0) => break,
+ Ok(n) => {
+ buffer.extend_from_slice(&scratch[..n]);
+
+ for data in crate::raw_input::drain_complete_input_bytes(&mut buffer) {
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinInput(data))
+ .is_err()
+ {
+ return;
+ }
+ }
+
+ if !buffer.is_empty() && stdin_read_ready(&reader, 10) == Some(false) {
+ if let Some(data) = crate::raw_input::flush_incomplete_input_bytes(&mut buffer)
+ {
+ if event_tx
+ .blocking_send(ClientLoopEvent::StdinInput(data))
+ .is_err()
+ {
+ return;
+ }
+ }
+ }
+ }
+ Err(err) => {
+ if err.kind() == io::ErrorKind::Interrupted {
+ continue;
+ }
+ break;
+ }
+ }
+ }
+}
+
+#[cfg(unix)]
+fn stdin_read_ready<R: AsRawFd>(reader: &R, timeout_ms: i32) -> Option<bool> {
+ poll_read_ready(reader.as_raw_fd(), timeout_ms)
+}
+
+#[cfg(not(unix))]
+fn stdin_read_ready<R>(_reader: &R, _timeout_ms: i32) -> Option<bool> {
+ None
+}
+
+#[cfg(unix)]
+fn poll_read_ready(fd: i32, timeout_ms: i32) -> Option<bool> {
+ #[repr(C)]
+ struct PollFd {
+ fd: i32,
+ events: i16,
+ revents: i16,
+ }
+
+ unsafe extern "C" {
+ fn poll(fds: *mut PollFd, nfds: usize, timeout: i32) -> i32;
+ }
+
+ const POLLIN: i16 = 0x0001;
+
+ let mut pfd = PollFd {
+ fd,
+ events: POLLIN,
+ revents: 0,
+ };
+
+ let result = unsafe { poll(&mut pfd as *mut PollFd, 1, timeout_ms) };
+ if result < 0 {
+ None
+ } else {
+ Some(result > 0)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ // The stdin reader thread is hard to unit test since it reads from actual stdin.
+ // Integration tests will verify the full client→server input flow.
+ // Here we test the event type construction.
+
+ use super::*;
+
+ #[test]
+ fn stdin_input_event_carries_raw_bytes() {
+ let data = vec![0x1b, b'[', b'A']; // Up arrow escape sequence
+ let event = ClientLoopEvent::StdinInput(data.clone());
+ match event {
+ ClientLoopEvent::StdinInput(d) => assert_eq!(d, data),
+ _ => panic!("expected StdinInput event"),
+ }
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
new file mode 100644
index 00000000..2476a069
--- /dev/null
+++ b/src/client/mod.rs
@@ -0,0 +1,741 @@
+//! Thin client mode — connects to the server's client socket.
+//!
+//! The client:
+//! - Connects to `herdr-client.sock`, sends Hello with terminal size and protocol version
+//! - Sets up the real terminal (raw mode, mouse capture, keyboard enhancements)
+//! - Receives Frame messages and blits them to the terminal (diff against last frame)
+//! - Reads stdin events (keystrokes, mouse, paste) and sends them as ClientMessage::Input
+//! - Detects terminal resize and sends ClientMessage::Resize
+//! - Restores terminal on exit (normal or error)
+//! - Handles ServerShutdown gracefully (clean exit, informative message to stderr)
+//! - Handles server unreachable (clear error screen, not blank/hang)
+//! - Forwards OSC 52 clipboard writes from server to its own stdout
+//! - Displays sound/toast notifications forwarded from server
+
+mod blit;
+mod input;
+
+use std::io::{self, Write as _};
+use std::os::unix::net::UnixStream;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::Duration;
+
+use crossterm::event::{
+ DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
+ KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+};
+use crossterm::execute;
+use tracing::{debug, info, warn};
+
+use crate::server::headless::client_socket_path;
+use crate::server::protocol::{
+ self, ClientMessage, FrameData, NotifyKind, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+};
+
+// ---------------------------------------------------------------------------
+// Client state
+// ---------------------------------------------------------------------------
+
+/// State tracking for the thin client.
+struct ClientState {
+ /// The last frame we rendered, used for diff-based blitting.
+ last_frame: Option<FrameData>,
+ /// The terminal size we reported to the server in our last Hello/Resize.
+ reported_size: (u16, u16),
+}
+
+// ---------------------------------------------------------------------------
+// Error types
+// ---------------------------------------------------------------------------
+
+/// Errors that can occur during client operation.
+#[derive(Debug)]
+pub enum ClientError {
+ /// Could not connect to the server's client socket.
+ ConnectionFailed(io::Error),
+ /// Server rejected our handshake.
+ HandshakeRejected { version: u32, error: String },
+ /// Server shut down.
+ ServerShutdown { reason: Option<String> },
+ /// Lost connection to the server.
+ ConnectionLost(io::Error),
+ /// Protocol error (framing, deserialization).
+ Protocol(protocol::FramingError),
+}
+
+impl std::fmt::Display for ClientError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ClientError::ConnectionFailed(err) => {
+ write!(f, "failed to connect to server: {err}")?;
+ let path = client_socket_path();
+ write!(
+ f,
+ "\nIs herdr server running? Start it with `herdr server`."
+ )?;
+ write!(f, "\nSocket path: {}", path.display())
+ }
+ ClientError::HandshakeRejected { version, error } => {
+ write!(f, "server rejected handshake (version {version}): {error}")
+ }
+ ClientError::ServerShutdown { reason } => {
+ match reason.as_deref() {
+ Some("detached") => {
+ write!(f, "detached from server")?;
+ write!(f, "\nRun `herdr` to reattach")?;
+ }
+ _ => {
+ write!(f, "server shut down")?;
+ if let Some(reason) = reason {
+ write!(f, ": {reason}")?;
+ }
+ }
+ }
+ Ok(())
+ }
+ ClientError::ConnectionLost(err) => {
+ write!(f, "lost connection to server: {err}")
+ }
+ ClientError::Protocol(err) => {
+ write!(f, "protocol error: {err}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for ClientError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ ClientError::ConnectionFailed(err) => Some(err),
+ ClientError::ConnectionLost(err) => Some(err),
+ ClientError::Protocol(err) => Some(err),
+ _ => None,
+ }
+ }
+}
+
+impl From<protocol::FramingError> for ClientError {
+ fn from(err: protocol::FramingError) -> Self {
+ ClientError::Protocol(err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Terminal setup / restore
+// ---------------------------------------------------------------------------
+
+/// Sets up the terminal for client mode (raw mode, mouse, keyboard enhancements).
+///
+/// Returns a guard that restores the terminal when dropped.
+fn setup_terminal() -> io::Result<TerminalGuard> {
+ ratatui::init();
+ execute!(
+ io::stdout(),
+ EnableMouseCapture,
+ EnableBracketedPaste,
+ PushKeyboardEnhancementFlags(
+ KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
+ | KeyboardEnhancementFlags::REPORT_EVENT_TYPES
+ | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
+ )
+ )?;
+
+ // tmux doesn't understand kitty keyboard protocol push.
+ // Enable modifyOtherKeys mode 2 for tmux.
+ let in_tmux = std::env::var("TMUX").is_ok();
+ if in_tmux {
+ io::stdout().write_all(b"\x1b[>4;2m")?;
+ io::stdout().flush()?;
+ }
+
+ Ok(TerminalGuard { in_tmux })
+}
+
+/// Guard that restores the terminal when dropped.
+struct TerminalGuard {
+ in_tmux: bool,
+}
+
+impl Drop for TerminalGuard {
+ fn drop(&mut self) {
+ // Reset modifyOtherKeys if we enabled it.
+ if self.in_tmux {
+ let _ = io::stdout().write_all(b"\x1b[>4;0m");
+ let _ = io::stdout().flush();
+ }
+
+ let _ = execute!(
+ io::stdout(),
+ PopKeyboardEnhancementFlags,
+ DisableBracketedPaste,
+ DisableMouseCapture
+ );
+ ratatui::restore();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Handshake
+// ---------------------------------------------------------------------------
+
+/// Performs the client→server handshake.
+///
+/// Sends Hello with the terminal size and protocol version, reads the Welcome
+/// response. Returns Ok(()) on success, or an error if the server rejects us.
+fn do_handshake(stream: &mut UnixStream, cols: u16, rows: u16) -> Result<(), ClientError> {
+ // Send Hello.
+ let hello = ClientMessage::Hello {
+ version: PROTOCOL_VERSION,
+ cols,
+ rows,
+ };
+ protocol::write_message(stream, &hello).map_err(|e| {
+ ClientError::ConnectionFailed(io::Error::new(io::ErrorKind::Other, e.to_string()))
+ })?;
+
+ // Read Welcome.
+ stream
+ .set_read_timeout(Some(Duration::from_secs(5)))
+ .map_err(ClientError::ConnectionFailed)?;
+ let welcome: ServerMessage = protocol::read_message(stream, MAX_FRAME_SIZE)?;
+ stream
+ .set_read_timeout(None)
+ .map_err(ClientError::ConnectionFailed)?;
+
+ match welcome {
+ ServerMessage::Welcome { version, error } => {
+ if let Some(error) = error {
+ return Err(ClientError::HandshakeRejected { version, error });
+ }
+ info!(version, "handshake succeeded");
+ Ok(())
+ }
+ _ => Err(ClientError::Protocol(protocol::FramingError::Io(
+ io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
+ ))),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Client event loop
+// ---------------------------------------------------------------------------
+
+/// Internal events for the client event loop.
+enum ClientLoopEvent {
+ /// Raw input bytes from stdin.
+ StdinInput(Vec<u8>),
+ /// Terminal resize detected.
+ Resize(u16, u16),
+ /// Server message received.
+ ServerMessage(ServerMessage),
+ /// Server reader thread exited (connection lost).
+ ServerDisconnected,
+ /// Timer tick.
+ Timer,
+}
+
+/// Runs the thin client: connects to the server, performs the handshake,
+/// and enters the main event loop.
+///
+/// This is the entry point called from `main.rs` when running in client mode.
+pub fn run_client() -> io::Result<()> {
+ init_logging();
+
+ let loaded_config = crate::config::Config::load();
+ let sound_config = loaded_config.config.ui.sound;
+
+ let socket_path = client_socket_path();
+ info!(path = %socket_path.display(), "connecting to server");
+
+ // Try to connect to the server.
+ let mut stream = match UnixStream::connect(&socket_path) {
+ Ok(s) => s,
+ Err(err) => {
+ // Server unreachable — show clear error and exit.
+ let client_err = ClientError::ConnectionFailed(err);
+ eprintln!("herdr: {client_err}");
+ std::process::exit(1);
+ }
+ };
+
+ // Get the terminal size before handshake (before raw mode).
+ let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
+
+ // Perform handshake while the stream is still in blocking mode.
+ if let Err(err) = do_handshake(&mut stream, cols, rows) {
+ eprintln!("herdr: {err}");
+ std::process::exit(1);
+ }
+
+ // Now set up the terminal (raw mode, mouse, keyboard enhancements).
+ // This must happen AFTER the handshake succeeds, so we don't leave
+ // the terminal in raw mode if the server rejects us.
+ let _guard = setup_terminal().map_err(|err| {
+ eprintln!("herdr: failed to set up terminal: {err}");
+ err
+ })?;
+
+ // Install a panic hook to restore the terminal on panic (same as monolithic).
+ let in_tmux = std::env::var("TMUX").is_ok();
+ let original_hook = std::panic::take_hook();
+ std::panic::set_hook(Box::new(move |info| {
+ if in_tmux {
+ let _ = io::stdout().write_all(b"\x1b[>4;0m");
+ }
+ let _ = execute!(
+ io::stdout(),
+ PopKeyboardEnhancementFlags,
+ DisableBracketedPaste,
+ DisableMouseCapture
+ );
+ ratatui::restore();
+ original_hook(info);
+ }));
+
+ // Create the tokio runtime.
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+
+ let should_quit = Arc::new(AtomicBool::new(false));
+
+ // Install Ctrl+C handler.
+ let quit_flag = should_quit.clone();
+ let _ = ctrlc::set_handler(move || {
+ quit_flag.store(true, Ordering::Release);
+ });
+
+ let result =
+ rt.block_on(async { run_client_loop(stream, cols, rows, should_quit, sound_config).await });
+
+ // Restore the terminal before printing any final status message.
+ drop(_guard);
+
+ if let Err(err) = result {
+ eprintln!("herdr: {err}");
+ rt.shutdown_timeout(Duration::from_millis(100));
+ info!("herdr client exiting");
+
+ if matches!(
+ err,
+ ClientError::ServerShutdown {
+ reason: Some(reason)
+ } if reason == "detached"
+ ) {
+ return Ok(());
+ }
+
+ std::process::exit(1);
+ }
+
+ rt.shutdown_timeout(Duration::from_millis(100));
+ info!("herdr client exiting");
+ Ok(())
+}
+
+/// The main client event loop.
+///
+/// Uses a threaded architecture:
+/// - stdin reader thread → sends raw input bytes to main loop
+/// - resize poller thread → sends resize events to main loop
+/// - server reader thread → reads ServerMessages and sends to main loop
+/// - main loop: coordinates input, output, and server communication
+async fn run_client_loop(
+ stream: UnixStream,
+ cols: u16,
+ rows: u16,
+ should_quit: Arc<AtomicBool>,
+ sound_config: crate::config::SoundConfig,
+) -> Result<(), ClientError> {
+ let mut state = ClientState {
+ last_frame: None,
+ reported_size: (cols, rows),
+ };
+
+ // Channel for events from the stdin, resize, and server reader threads.
+ let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ClientLoopEvent>(256);
+
+ // Spawn the stdin reader thread.
+ let stdin_quit = should_quit.clone();
+ let stdin_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ input::stdin_reader_loop(stdin_tx, &stdin_quit);
+ });
+
+ query_host_terminal_theme();
+
+ // Spawn the resize poller thread.
+ let resize_quit = should_quit.clone();
+ let resize_tx = event_tx.clone();
+ std::thread::spawn(move || {
+ resize_poll_loop(resize_tx, cols, rows, &resize_quit);
+ });
+
+ // Spawn the server reader thread (blocking reads from the socket).
+ // Clone the stream's file descriptor so we can read from a blocking stream.
+ let server_read_quit = should_quit.clone();
+ let server_read_tx = event_tx.clone();
+ let read_stream = stream.try_clone().map_err(ClientError::ConnectionFailed)?;
+ std::thread::spawn(move || {
+ server_reader_thread(read_stream, server_read_tx, &server_read_quit);
+ });
+
+ // Use the original stream for writing (blocking is fine since we write
+ // from the async loop).
+ let mut write_stream = stream;
+ write_stream
+ .set_nonblocking(false)
+ .map_err(ClientError::ConnectionFailed)?;
+
+ // Main event loop.
+ while !should_quit.load(Ordering::Acquire) {
+ let event = tokio::select! {
+ ev = event_rx.recv() => ev.unwrap_or(ClientLoopEvent::Timer),
+ _ = tokio::time::sleep(Duration::from_millis(100)) => ClientLoopEvent::Timer,
+ };
+
+ match event {
+ ClientLoopEvent::StdinInput(data) => {
+ let msg = ClientMessage::Input { data };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ }
+ ClientLoopEvent::Resize(new_cols, new_rows) => {
+ state.reported_size = (new_cols, new_rows);
+ let msg = ClientMessage::Resize {
+ cols: new_cols,
+ rows: new_rows,
+ };
+ if let Err(e) = write_to_server(&mut write_stream, &msg) {
+ return Err(ClientError::ConnectionLost(e));
+ }
+ }
+ ClientLoopEvent::ServerMessage(msg) => match msg {
+ ServerMessage::Frame(frame_data) => {
+ blit::blit_frame(&frame_data, state.last_frame.as_ref());
+ state.last_frame = Some(frame_data);
+ let _ = io::stdout().flush();
+ }
+ ServerMessage::ServerShutdown { reason } => {
+ return Err(ClientError::ServerShutdown { reason });
+ }
+ ServerMessage::Notify { kind, message } => {
+ handle_notify(kind, &message, &sound_config);
+ }
+ ServerMessage::Clipboard { data } => {
+ forward_clipboard(&data);
+ let _ = io::stdout().flush();
+ }
+ ServerMessage::Welcome { .. } => {
+ debug!("received unexpected Welcome in main loop");
+ }
+ },
+ ClientLoopEvent::ServerDisconnected => {
+ return Err(ClientError::ConnectionLost(io::Error::new(
+ io::ErrorKind::UnexpectedEof,
+ "server closed connection",
+ )));
+ }
+ ClientLoopEvent::Timer => {
+ // Check if we should quit.
+ }
+ }
+ }
+
+ // Clean exit (Ctrl+C). Send Detach before closing.
+ let detach = ClientMessage::Detach;
+ let _ = write_to_server(&mut write_stream, &detach);
+ let _ = io::stdout().flush();
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Server reader thread
+// ---------------------------------------------------------------------------
+
+/// Blocking thread that reads ServerMessages from the server and sends them
+/// to the main event loop.
+fn server_reader_thread(
+ mut stream: UnixStream,
+ event_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ // Ensure the read stream is in blocking mode to avoid WouldBlock errors
+ // from read_exact inside read_message. The stream should already be
+ // blocking after handshake, but we enforce it here as a safety measure.
+ if stream.set_nonblocking(false).is_err() {
+ // If we can't set blocking mode, the stream is likely broken.
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
+ return;
+ }
+
+ loop {
+ if should_quit.load(Ordering::Acquire) {
+ break;
+ }
+
+ match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ Ok(msg) => {
+ if event_tx
+ .blocking_send(ClientLoopEvent::ServerMessage(msg))
+ .is_err()
+ {
+ break; // Main loop gone.
+ }
+ }
+ Err(protocol::FramingError::UnexpectedEof) => {
+ // Server closed connection.
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
+ break;
+ }
+ Err(protocol::FramingError::Io(err)) if err.kind() == io::ErrorKind::WouldBlock => {
+ // Should not happen with blocking mode, but handle gracefully
+ // in case the stream was set nonblocking by another clone.
+ std::thread::sleep(Duration::from_millis(1));
+ continue;
+ }
+ Err(err) => {
+ warn!(err = %err, "server read error");
+ let _ = event_tx.blocking_send(ClientLoopEvent::ServerDisconnected);
+ break;
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Write helper
+// ---------------------------------------------------------------------------
+
+/// Writes a message to the server stream (blocking).
+fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<()> {
+ protocol::write_message(stream, msg)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))
+}
+
+// ---------------------------------------------------------------------------
+// Notifications
+// ---------------------------------------------------------------------------
+
+fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) {
+ match kind {
+ NotifyKind::Sound => {
+ let Some(sound) = sound_from_notify_message(message) else {
+ warn!(
+ message = message,
+ "received unknown sound notification from server"
+ );
+ return;
+ };
+ if sound_config.enabled {
+ crate::sound::play(sound, sound_config);
+ }
+ }
+ NotifyKind::Toast => {
+ debug!(message = message, "received toast notification from server");
+ }
+ }
+}
+
+fn sound_from_notify_message(message: &str) -> Option<crate::sound::Sound> {
+ match message {
+ "agent done" => Some(crate::sound::Sound::Done),
+ "agent attention" => Some(crate::sound::Sound::Request),
+ _ => None,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Clipboard forwarding
+// ---------------------------------------------------------------------------
+
+/// Decode a clipboard payload forwarded by the server.
+fn decode_clipboard_payload(data: &str) -> Option<Vec<u8>> {
+ use base64::Engine;
+ base64::engine::general_purpose::STANDARD.decode(data).ok()
+}
+
+/// Forwards a clipboard write from the server to the local client clipboard.
+fn forward_clipboard(data: &str) {
+ let Some(bytes) = decode_clipboard_payload(data) else {
+ warn!("received invalid clipboard payload from server");
+ return;
+ };
+
+ crate::selection::write_osc52_bytes(&bytes);
+}
+
+// ---------------------------------------------------------------------------
+// Resize polling
+// ---------------------------------------------------------------------------
+
+/// Polls the terminal size and sends resize events when it changes.
+fn resize_poll_loop(
+ resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>,
+ initial_cols: u16,
+ initial_rows: u16,
+ should_quit: &Arc<AtomicBool>,
+) {
+ let mut last_size = (initial_cols, initial_rows);
+ while !should_quit.load(Ordering::Acquire) {
+ std::thread::sleep(Duration::from_millis(100));
+ let new_size = crossterm::terminal::size().unwrap_or(last_size);
+ if new_size != last_size {
+ last_size = new_size;
+ if resize_tx
+ .blocking_send(ClientLoopEvent::Resize(new_size.0, new_size.1))
+ .is_err()
+ {
+ break; // Main loop gone.
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Logging
+// ---------------------------------------------------------------------------
+
+/// Initialize logging for the client process.
+fn query_host_terminal_theme() {
+ let _ = write_host_terminal_theme_query(io::stdout());
+}
+
+fn write_host_terminal_theme_query(mut writer: impl io::Write) -> io::Result<()> {
+ writer.write_all(crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes())?;
+ writer.flush()
+}
+
+fn init_logging() {
+ crate::logging::init_file_logging("herdr-client.log");
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn write_host_terminal_theme_query_emits_osc_queries() {
+ let mut output = Vec::new();
+ write_host_terminal_theme_query(&mut output).unwrap();
+ assert_eq!(
+ output,
+ crate::terminal_theme::HOST_COLOR_QUERY_SEQUENCE.as_bytes()
+ );
+ }
+
+ #[test]
+ fn client_error_display_connection_failed() {
+ let err = ClientError::ConnectionFailed(io::Error::new(
+ io::ErrorKind::ConnectionRefused,
+ "connection refused",
+ ));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("failed to connect to server"),
+ "should mention connection failure: {msg}"
+ );
+ assert!(
+ msg.contains("herdr server"),
+ "should suggest starting server: {msg}"
+ );
+ }
+
+ #[test]
+ fn client_error_display_handshake_rejected() {
+ let err = ClientError::HandshakeRejected {
+ version: 1,
+ error: "incompatible".into(),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("rejected handshake"),
+ "should mention rejection: {msg}"
+ );
+ assert!(msg.contains("incompatible"), "should include error: {msg}");
+ }
+
+ #[test]
+ fn client_error_display_server_shutdown() {
+ let err = ClientError::ServerShutdown {
+ reason: Some("maintenance".into()),
+ };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("server shut down"),
+ "should mention shutdown: {msg}"
+ );
+ assert!(msg.contains("maintenance"), "should include reason: {msg}");
+ }
+
+ #[test]
+ fn client_error_display_server_shutdown_no_reason() {
+ let err = ClientError::ServerShutdown { reason: None };
+ let msg = err.to_string();
+ assert!(
+ msg.contains("server shut down"),
+ "should mention shutdown: {msg}"
+ );
+ }
+
+ #[test]
+ fn client_error_display_connection_lost() {
+ let err =
+ ClientError::ConnectionLost(io::Error::new(io::ErrorKind::BrokenPipe, "broken pipe"));
+ let msg = err.to_string();
+ assert!(
+ msg.contains("lost connection to server"),
+ "should mention lost connection: {msg}"
+ );
+ }
+
+ #[test]
+ fn sound_from_notify_message_maps_done() {
+ assert_eq!(
+ sound_from_notify_message("agent done"),
+ Some(crate::sound::Sound::Done)
+ );
+ }
+
+ #[test]
+ fn sound_from_notify_message_maps_attention() {
+ assert_eq!(
+ sound_from_notify_message("agent attention"),
+ Some(crate::sound::Sound::Request)
+ );
+ }
+
+ #[test]
+ fn sound_from_notify_message_rejects_unknown_payloads() {
+ assert_eq!(sound_from_notify_message("toast"), None);
+ }
+
+ #[test]
+ fn decode_clipboard_payload_decodes_base64() {
+ assert_eq!(decode_clipboard_payload("dGVzdA=="), Some(b"test".to_vec()));
+ }
+
+ #[test]
+ fn decode_clipboard_payload_rejects_invalid_base64() {
+ assert_eq!(decode_clipboard_payload("not-base64!!!"), None);
+ }
+
+ #[test]
+ fn forward_clipboard_uses_local_clipboard_path() {
+ unsafe {
+ std::env::set_var("SSH_CONNECTION", "1 2 3 4");
+ }
+ forward_clipboard("dGVzdA==");
+ unsafe {
+ std::env::remove_var("SSH_CONNECTION");
+ }
+ }
+}
diff --git a/src/config.rs b/src/config.rs
index 7a317204..4fa772d0 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,410 +1,31 @@
-use std::path::{Path, PathBuf};
-
use crossterm::event::{KeyCode, KeyModifiers};
-use serde::Deserialize;
+
+mod io;
+mod keybinds;
+mod model;
+mod sound;
+mod theme;
+
+pub use self::{
+ io::{
+ config_dir, config_path, load_live_keybinds, save_onboarding_choices, upsert_section_bool,
+ upsert_section_value,
+ },
+ keybinds::{
+ format_key_combo, CommandKeybindConfig, CustomCommandAction, CustomCommandKeybind,
+ Keybinds, LiveKeybindConfig,
+ },
+ model::{Config, ToastConfig},
+ sound::{AgentSoundSetting, SoundConfig},
+ theme::{parse_color, CustomThemeColors, ThemeConfig},
+};
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
-use tracing::warn;
-
-pub fn app_dir_name() -> &'static str {
- if cfg!(debug_assertions) {
- "herdr-dev"
- } else {
- "herdr"
- }
-}
-
-pub fn config_dir() -> PathBuf {
- if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
- PathBuf::from(dir).join(app_dir_name())
- } else if let Ok(home) = std::env::var("HOME") {
- PathBuf::from(home).join(format!(".config/{}", app_dir_name()))
- } else {
- PathBuf::from(format!("/tmp/{}", app_dir_name()))
- }
-}
-
-use crate::detect::Agent;
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
-pub struct ToastConfig {
- pub enabled: bool,
-}
-
-#[derive(Debug, Default, Deserialize)]
-#[serde(default)]
-pub struct Config {
- pub onboarding: Option<bool>,
- pub theme: ThemeConfig,
- pub keys: KeysConfig,
- pub ui: UiConfig,
- pub advanced: AdvancedConfig,
-}
-
-/// Theme configuration: pick a built-in or override individual tokens.
-///
-/// ```toml
-/// [theme]
-/// name = "tokyo-night" # built-in: catppuccin, tokyo-night, dracula, nord, etc.
-///
-/// [theme.custom] # override individual tokens on top of the base
-/// accent = "#f5c2e7"
-/// red = "#ff6188"
-/// ```
-#[derive(Debug, Default, Deserialize)]
-#[serde(default)]
-pub struct ThemeConfig {
- /// Built-in theme name. Default: "catppuccin".
- pub name: Option<String>,
- /// Custom overrides — applied on top of the selected base theme.
- pub custom: Option<CustomThemeColors>,
-}
-
-/// Per-token color overrides. All fields optional — only set what you want to change.
-#[derive(Debug, Default, Deserialize)]
-#[serde(default)]
-pub struct CustomThemeColors {
- pub accent: Option<String>,
- pub panel_bg: Option<String>,
- pub surface0: Option<String>,
- pub surface1: Option<String>,
- pub surface_dim: Option<String>,
- pub overlay0: Option<String>,
- pub overlay1: Option<String>,
- pub text: Option<String>,
- pub subtext0: Option<String>,
- pub mauve: Option<String>,
- pub green: Option<String>,
- pub yellow: Option<String>,
- pub red: Option<String>,
- pub blue: Option<String>,
- pub teal: Option<String>,
- pub peach: Option<String>,
-}
-
-#[derive(Debug)]
-pub struct LoadedConfig {
- pub config: Config,
- pub diagnostics: Vec<String>,
-}
-
-#[derive(Debug)]
-pub struct LiveKeybindConfig {
- pub prefix: (KeyCode, KeyModifiers),
- pub keybinds: Keybinds,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
-#[serde(rename_all = "snake_case")]
-pub enum CommandKeybindType {
- #[default]
- Shell,
- Pane,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
-pub struct CommandKeybindConfig {
- /// Navigate-mode key that runs a command after pressing the prefix key.
- pub key: String,
- /// Command executed either in the background shell or inside a pane.
- pub command: String,
- /// Command execution mode. Default: "shell".
- #[serde(rename = "type")]
- pub action_type: CommandKeybindType,
-}
-
-impl Default for CommandKeybindConfig {
- fn default() -> Self {
- Self {
- key: String::new(),
- command: String::new(),
- action_type: CommandKeybindType::Shell,
- }
- }
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(default)]
-pub struct KeysConfig {
- /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc").
- pub prefix: String,
- /// Create a new workspace. Default: "n"
- pub new_workspace: String,
- /// Rename the selected workspace. Default: "shift+n"
- pub rename_workspace: String,
- /// Close the selected workspace. Default: "d"
- pub close_workspace: String,
- /// Select the previous workspace. Unset by default.
- pub previous_workspace: String,
- /// Select the next workspace. Unset by default.
- pub next_workspace: String,
- /// Create a new tab in the active workspace. Default: "c"
- pub new_tab: String,
- /// Rename the active tab. Unset by default.
- pub rename_tab: String,
- /// Select the previous tab. Unset by default.
- pub previous_tab: String,
- /// Select the next tab. Unset by default.
- pub next_tab: String,
- /// Close the active tab. Unset by default.
- pub close_tab: String,
- /// Focus the pane to the left in terminal mode. Unset by default.
- pub focus_pane_left: String,
- /// Focus the pane below in terminal mode. Unset by default.
- pub focus_pane_down: String,
- /// Focus the pane above in terminal mode. Unset by default.
- pub focus_pane_up: String,
- /// Focus the pane to the right in terminal mode. Unset by default.
- pub focus_pane_right: String,
- /// Split pane vertically (side by side). Default: "v"
- pub split_vertical: String,
- /// Split pane horizontally (stacked). Default: "-"
- pub split_horizontal: String,
- /// Close the focused pane. Default: "x"
- pub close_pane: String,
- /// Toggle fullscreen for the focused pane. Default: "f"
- pub fullscreen: String,
- /// Enter resize mode. Default: "r"
- pub resize_mode: String,
- /// Toggle sidebar collapse. Default: "b"
- pub toggle_sidebar: String,
- /// Prefix-mode custom command bindings.
- pub command: Vec<CommandKeybindConfig>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(default)]
-pub struct UiConfig {
- pub sidebar_width: u16,
- /// Ask for confirmation before closing a workspace. Default: true.
- pub confirm_close: bool,
- /// Accent color for highlights, borders, and navigation UI.
- /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)).
- pub accent: String,
- /// Optional visual toast notifications for background workspace events.
- pub toast: ToastConfig,
- /// Play sounds when agents change state in background workspaces.
- pub sound: SoundConfig,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(default)]
-pub struct AdvancedConfig {
- /// Allow launching herdr inside an existing herdr pane. Default: false.
- pub allow_nested: bool,
- /// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000.
- #[serde(alias = "scrollback_lines")]
- pub scrollback_limit_bytes: usize,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
-pub struct SoundConfig {
- pub enabled: bool,
- /// Optional mp3 file path used for all notification sounds.
- /// Relative paths are resolved from the config file's directory.
- pub path: Option<PathBuf>,
- /// Optional mp3 file path for "done" notifications.
- /// Relative paths are resolved from the config file's directory.
- pub done_path: Option<PathBuf>,
- /// Optional mp3 file path for "request" notifications.
- /// Relative paths are resolved from the config file's directory.
- pub request_path: Option<PathBuf>,
- pub agents: AgentSoundOverrides,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-#[serde(default)]
-pub struct AgentSoundOverrides {
- pub pi: AgentSoundSetting,
- pub claude: AgentSoundSetting,
- pub codex: AgentSoundSetting,
- pub gemini: AgentSoundSetting,
- pub cursor: AgentSoundSetting,
- pub cline: AgentSoundSetting,
- pub open_code: AgentSoundSetting,
- pub github_copilot: AgentSoundSetting,
- pub kimi: AgentSoundSetting,
- pub droid: AgentSoundSetting,
- pub amp: AgentSoundSetting,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
-#[serde(rename_all = "snake_case")]
-pub enum AgentSoundSetting {
- #[default]
- Default,
- On,
- Off,
-}
-
-impl SoundConfig {
- pub fn allows(&self, agent: Option<Agent>) -> bool {
- if !self.enabled {
- return false;
- }
-
- !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off)
- }
-
- pub fn path_for(&self, sound: crate::sound::Sound) -> Option<PathBuf> {
- let path = match sound {
- crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()),
- crate::sound::Sound::Request => self.request_path.as_ref().or(self.path.as_ref()),
- }?;
-
- Some(resolve_config_relative_path(path))
- }
-
- pub fn diagnostics(&self) -> Vec<String> {
- let mut diagnostics = Vec::new();
- for (field, path) in [
- ("ui.sound.path", self.path.as_ref()),
- ("ui.sound.done_path", self.done_path.as_ref()),
- ("ui.sound.request_path", self.request_path.as_ref()),
- ] {
- let Some(path) = path else {
- continue;
- };
-
- let resolved = resolve_config_relative_path(path);
- if resolved
- .extension()
- .and_then(|ext| ext.to_str())
- .is_none_or(|ext| !ext.eq_ignore_ascii_case("mp3"))
- {
- diagnostics.push(format!(
- "unsupported sound file format: {field} = {} resolves to {}; expected an mp3 file; using default sound",
- path.display(),
- resolved.display()
- ));
- continue;
- }
-
- if !resolved.exists() {
- diagnostics.push(format!(
- "missing sound file: {field} = {} resolves to {}; using default sound",
- path.display(),
- resolved.display()
- ));
- } else if !resolved.is_file() {
- diagnostics.push(format!(
- "invalid sound file: {field} = {} resolves to {}; using default sound",
- path.display(),
- resolved.display()
- ));
- }
- }
- diagnostics
- }
-}
-
-impl AgentSoundOverrides {
- pub fn for_agent(&self, agent: Option<Agent>) -> AgentSoundSetting {
- match agent {
- Some(Agent::Pi) => self.pi,
- Some(Agent::Claude) => self.claude,
- Some(Agent::Codex) => self.codex,
- Some(Agent::Gemini) => self.gemini,
- Some(Agent::Cursor) => self.cursor,
- Some(Agent::Cline) => self.cline,
- Some(Agent::OpenCode) => self.open_code,
- Some(Agent::GithubCopilot) => self.github_copilot,
- Some(Agent::Kimi) => self.kimi,
- Some(Agent::Droid) => self.droid,
- Some(Agent::Amp) => self.amp,
- None => AgentSoundSetting::Default,
- }
- }
-}
-
-impl Default for KeysConfig {
- fn default() -> Self {
- Self {
- prefix: "ctrl+b".into(),
- new_workspace: "n".into(),
- rename_workspace: "shift+n".into(),
- close_workspace: "d".into(),
- previous_workspace: "".into(),
- next_workspace: "".into(),
- new_tab: "c".into(),
- rename_tab: "".into(),
- previous_tab: "".into(),
- next_tab: "".into(),
- close_tab: "".into(),
- focus_pane_left: "".into(),
- focus_pane_down: "".into(),
- focus_pane_up: "".into(),
- focus_pane_right: "".into(),
- split_vertical: "v".into(),
- split_horizontal: "-".into(),
- close_pane: "x".into(),
- fullscreen: "f".into(),
- resize_mode: "r".into(),
- toggle_sidebar: "b".into(),
- command: Vec::new(),
- }
- }
-}
-
-impl Default for UiConfig {
- fn default() -> Self {
- Self {
- sidebar_width: 26,
- confirm_close: true,
- accent: "cyan".into(),
- toast: ToastConfig::default(),
- sound: SoundConfig::default(),
- }
- }
-}
-
-impl Default for ToastConfig {
- fn default() -> Self {
- Self { enabled: false }
- }
-}
-
-impl Default for SoundConfig {
- fn default() -> Self {
- Self {
- enabled: true,
- path: None,
- done_path: None,
- request_path: None,
- agents: AgentSoundOverrides::default(),
- }
- }
-}
-
-impl Default for AdvancedConfig {
- fn default() -> Self {
- Self {
- allow_nested: false,
- scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES,
- }
- }
-}
-impl Default for AgentSoundOverrides {
- fn default() -> Self {
- Self {
- pi: AgentSoundSetting::Default,
- claude: AgentSoundSetting::Default,
- codex: AgentSoundSetting::Default,
- gemini: AgentSoundSetting::Default,
- cursor: AgentSoundSetting::Default,
- cline: AgentSoundSetting::Default,
- open_code: AgentSoundSetting::Default,
- github_copilot: AgentSoundSetting::Default,
- kimi: AgentSoundSetting::Default,
- droid: AgentSoundSetting::Off,
- amp: AgentSoundSetting::Default,
- }
- }
+#[cfg(test)]
+pub(crate) fn app_dir_name() -> &'static str {
+ io::app_dir_name()
}
impl Config {
@@ -412,41 +33,6 @@ impl Config {
self.onboarding.unwrap_or(true)
}
- pub fn load() -> LoadedConfig {
- let path = config_path();
- if path.exists() {
- match std::fs::read_to_string(&path) {
- Ok(content) => match toml::from_str::<Config>(&content) {
- Ok(config) => {
- let diagnostics = config.collect_diagnostics();
- return LoadedConfig {
- config,
- diagnostics,
- };
- }
- Err(e) => {
- warn!(err = %e, "config parse error, using defaults");
- return LoadedConfig {
- config: Self::default(),
- diagnostics: vec![format!("config parse error: {e}; using defaults")],
- };
- }
- },
- Err(e) => {
- warn!(err = %e, "config read error, using defaults");
- return LoadedConfig {
- config: Self::default(),
- diagnostics: vec![format!("config read error: {e}; using defaults")],
- };
- }
- }
- }
- LoadedConfig {
- config: Self::default(),
- diagnostics: Vec::new(),
- }
- }
-
pub fn prefix_key(&self) -> (KeyCode, KeyModifiers) {
self.validated_keybinds().1
}
@@ -475,1503 +61,3 @@ impl Config {
}
}
}
-
-fn resolve_config_relative_path(path: &Path) -> PathBuf {
- if path.is_absolute() {
- return path.to_path_buf();
- }
-
- config_path()
- .parent()
- .unwrap_or_else(|| Path::new("."))
- .join(path)
-}
-
-impl Config {
- fn validated_keybinds(
- &self,
- ) -> (
- Option<String>,
- (KeyCode, KeyModifiers),
- Vec<String>,
- Keybinds,
- ) {
- #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
- enum BindingScope {
- Navigate,
- TerminalDirect,
- }
-
- #[derive(Clone)]
- struct RequiredBinding<'a> {
- scope: BindingScope,
- field: &'a str,
- label: String,
- default_label: &'a str,
- value: (KeyCode, KeyModifiers),
- default: (KeyCode, KeyModifiers),
- }
-
- struct OptionalBinding {
- scope: BindingScope,
- field: &'static str,
- value: Option<(KeyCode, KeyModifiers)>,
- label: Option<String>,
- }
-
- #[derive(Default)]
- struct BindingRegistry {
- seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
- }
-
- impl BindingRegistry {
- fn register(
- &mut self,
- scope: BindingScope,
- binding: (KeyCode, KeyModifiers),
- field: &str,
- ) -> Option<String> {
- let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None);
- self.seen
- .insert((scope, binding.0, binding.1), field.to_string())
- }
-
- fn conflict(
- &self,
- scope: BindingScope,
- binding: (KeyCode, KeyModifiers),
- ) -> Option<&str> {
- let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None);
- self.seen
- .get(&(scope, binding.0, binding.1))
- .map(String::as_str)
- }
-
- fn reserve_if_unbound(
- &mut self,
- scope: BindingScope,
- binding: (KeyCode, KeyModifiers),
- field: &str,
- ) {
- let binding = crate::input::normalize_app_key_binding(binding.0, binding.1, None);
- self.seen
- .entry((scope, binding.0, binding.1))
- .or_insert_with(|| field.to_string());
- }
- }
-
- fn required_binding<'a>(
- scope: BindingScope,
- field: &'a str,
- configured_label: &'a str,
- default_label: &'a str,
- default: (KeyCode, KeyModifiers),
- diagnostics: &mut Vec<String>,
- ) -> RequiredBinding<'a> {
- let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default);
- let label = if let Some(diag) = diag {
- diagnostics.push(diag);
- default_label.to_string()
- } else {
- configured_label.to_string()
- };
- RequiredBinding {
- scope,
- field,
- label,
- default_label,
- value,
- default,
- }
- }
-
- fn optional_binding(
- scope: BindingScope,
- field: &'static str,
- configured_label: &str,
- diagnostics: &mut Vec<String>,
- ) -> OptionalBinding {
- if configured_label.trim().is_empty() {
- return OptionalBinding {
- scope,
- field,
- value: None,
- label: None,
- };
- }
- match parse_key_combo(configured_label) {
- Some(value) => OptionalBinding {
- scope,
- field,
- value: Some(value),
- label: Some(configured_label.to_string()),
- },
- None => {
- let diag = format!(
- "invalid keybinding: {field} = {:?}; disabling binding",
- configured_label
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- OptionalBinding {
- scope,
- field,
- value: None,
- label: None,
- }
- }
- }
- }
-
- let mut diagnostics = Vec::new();
- let (prefix, prefix_diag) = parse_key_combo_with_diagnostic(
- &self.keys.prefix,
- "keys.prefix",
- (KeyCode::Char('b'), KeyModifiers::CONTROL),
- );
- if let Some(diag) = &prefix_diag {
- warn!(message = %diag, "config diagnostic");
- }
-
- let mut bindings = vec![
- required_binding(
- BindingScope::Navigate,
- "keys.new_workspace",
- &self.keys.new_workspace,
- "n",
- (KeyCode::Char('n'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.rename_workspace",
- &self.keys.rename_workspace,
- "shift+n",
- (KeyCode::Char('n'), KeyModifiers::SHIFT),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.close_workspace",
- &self.keys.close_workspace,
- "d",
- (KeyCode::Char('d'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.new_tab",
- &self.keys.new_tab,
- "c",
- (KeyCode::Char('c'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.split_vertical",
- &self.keys.split_vertical,
- "v",
- (KeyCode::Char('v'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.split_horizontal",
- &self.keys.split_horizontal,
- "-",
- (KeyCode::Char('-'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.close_pane",
- &self.keys.close_pane,
- "x",
- (KeyCode::Char('x'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.fullscreen",
- &self.keys.fullscreen,
- "f",
- (KeyCode::Char('f'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.resize_mode",
- &self.keys.resize_mode,
- "r",
- (KeyCode::Char('r'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- required_binding(
- BindingScope::Navigate,
- "keys.toggle_sidebar",
- &self.keys.toggle_sidebar,
- "b",
- (KeyCode::Char('b'), KeyModifiers::empty()),
- &mut diagnostics,
- ),
- ];
-
- let mut optional_bindings = vec![
- optional_binding(
- BindingScope::Navigate,
- "keys.previous_workspace",
- &self.keys.previous_workspace,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::Navigate,
- "keys.next_workspace",
- &self.keys.next_workspace,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::Navigate,
- "keys.rename_tab",
- &self.keys.rename_tab,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::Navigate,
- "keys.previous_tab",
- &self.keys.previous_tab,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::Navigate,
- "keys.next_tab",
- &self.keys.next_tab,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::Navigate,
- "keys.close_tab",
- &self.keys.close_tab,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::TerminalDirect,
- "keys.focus_pane_left",
- &self.keys.focus_pane_left,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::TerminalDirect,
- "keys.focus_pane_down",
- &self.keys.focus_pane_down,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::TerminalDirect,
- "keys.focus_pane_up",
- &self.keys.focus_pane_up,
- &mut diagnostics,
- ),
- optional_binding(
- BindingScope::TerminalDirect,
- "keys.focus_pane_right",
- &self.keys.focus_pane_right,
- &mut diagnostics,
- ),
- ];
-
- let mut registry = BindingRegistry::default();
- for binding in &mut bindings {
- if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
- let diag = format!(
- "duplicate keybinding: {} conflicts with {}; using default {}",
- binding.field, first_field, binding.default_label
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- binding.value = binding.default;
- binding.label = binding.default_label.to_string();
- }
- registry.register(binding.scope, binding.value, binding.field);
- }
-
- for binding in &mut optional_bindings {
- let Some(value) = binding.value else {
- continue;
- };
- if let Some(first_field) = registry.conflict(binding.scope, value) {
- let diag = format!(
- "duplicate keybinding: {} conflicts with {}; disabling binding",
- binding.field, first_field
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- binding.value = None;
- binding.label = None;
- continue;
- }
- registry.register(binding.scope, value, binding.field);
- }
-
- registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
- for (field, binding) in [
- ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())),
- (
- "navigate.open_workspace",
- (KeyCode::Enter, KeyModifiers::empty()),
- ),
- (
- "navigate.settings",
- (KeyCode::Char('s'), KeyModifiers::empty()),
- ),
- (
- "navigate.keybind_help",
- (KeyCode::Char('?'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_up",
- (KeyCode::Up, KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_down",
- (KeyCode::Down, KeyModifiers::empty()),
- ),
- (
- "navigate.focus_left",
- (KeyCode::Char('h'), KeyModifiers::empty()),
- ),
- (
- "navigate.focus_down",
- (KeyCode::Char('j'), KeyModifiers::empty()),
- ),
- (
- "navigate.focus_up",
- (KeyCode::Char('k'), KeyModifiers::empty()),
- ),
- (
- "navigate.focus_right",
- (KeyCode::Char('l'), KeyModifiers::empty()),
- ),
- (
- "navigate.arrow_left",
- (KeyCode::Left, KeyModifiers::empty()),
- ),
- (
- "navigate.arrow_right",
- (KeyCode::Right, KeyModifiers::empty()),
- ),
- ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())),
- (
- "navigate.tab_prev",
- (KeyCode::BackTab, KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_1",
- (KeyCode::Char('1'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_2",
- (KeyCode::Char('2'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_3",
- (KeyCode::Char('3'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_4",
- (KeyCode::Char('4'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_5",
- (KeyCode::Char('5'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_6",
- (KeyCode::Char('6'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_7",
- (KeyCode::Char('7'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_8",
- (KeyCode::Char('8'), KeyModifiers::empty()),
- ),
- (
- "navigate.workspace_9",
- (KeyCode::Char('9'), KeyModifiers::empty()),
- ),
- ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())),
- ] {
- registry.reserve_if_unbound(BindingScope::Navigate, binding, field);
- }
-
- let mut custom_commands = Vec::new();
- for (index, command) in self.keys.command.iter().enumerate() {
- let key_field = format!("keys.command[{index}].key");
- let command_field = format!("keys.command[{index}].command");
-
- if command.command.trim().is_empty() {
- let diag =
- format!("empty custom command: {command_field}; disabling custom command");
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- continue;
- }
-
- let Some(binding) = parse_key_combo(&command.key) else {
- let diag = format!(
- "invalid keybinding: {} = {:?}; disabling custom command",
- key_field, command.key
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- continue;
- };
-
- if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) {
- let diag = format!(
- "duplicate custom keybinding: {} conflicts with {}; disabling custom command",
- key_field, first_field
- );
- warn!(message = %diag, "config diagnostic");
- diagnostics.push(diag);
- continue;
- }
-
- registry.register(BindingScope::Navigate, binding, &key_field);
- let action = match command.action_type {
- CommandKeybindType::Shell => CustomCommandAction::Shell,
- CommandKeybindType::Pane => CustomCommandAction::Pane,
- };
- custom_commands.push(CustomCommandKeybind {
- key: binding,
- label: format_key_combo(binding),
- command: command.command.clone(),
- action,
- });
- }
-
- let keybinds = Keybinds {
- new_workspace: bindings[0].value,
- new_workspace_label: bindings[0].label.clone(),
- rename_workspace: bindings[1].value,
- rename_workspace_label: bindings[1].label.clone(),
- close_workspace: bindings[2].value,
- close_workspace_label: bindings[2].label.clone(),
- previous_workspace: optional_bindings[0].value,
- previous_workspace_label: optional_bindings[0].label.clone(),
- next_workspace: optional_bindings[1].value,
- next_workspace_label: optional_bindings[1].label.clone(),
- new_tab: bindings[3].value,
- new_tab_label: bindings[3].label.clone(),
- rename_tab: optional_bindings[2].value,
- rename_tab_label: optional_bindings[2].label.clone(),
- previous_tab: optional_bindings[3].value,
- previous_tab_label: optional_bindings[3].label.clone(),
- next_tab: optional_bindings[4].value,
- next_tab_label: optional_bindings[4].label.clone(),
- close_tab: optional_bindings[5].value,
- close_tab_label: optional_bindings[5].label.clone(),
- focus_pane_left: optional_bindings[6].value,
- focus_pane_left_label: optional_bindings[6].label.clone(),
- focus_pane_down: optional_bindings[7].value,
- focus_pane_down_label: optional_bindings[7].label.clone(),
- focus_pane_up: optional_bindings[8].value,
- focus_pane_up_label: optional_bindings[8].label.clone(),
- focus_pane_right: optional_bindings[9].value,
- focus_pane_right_label: optional_bindings[9].label.clone(),
- split_vertical: bindings[4].value,
- split_vertical_label: bindings[4].label.clone(),
- split_horizontal: bindings[5].value,
- split_horizontal_label: bindings[5].label.clone(),
- close_pane: bindings[6].value,
- close_pane_label: bindings[6].label.clone(),
- fullscreen: bindings[7].value,
- fullscreen_label: bindings[7].label.clone(),
- resize_mode: bindings[8].value,
- resize_mode_label: bindings[8].label.clone(),
- toggle_sidebar: bindings[9].value,
- toggle_sidebar_label: bindings[9].label.clone(),
- custom_commands,
- };
-
- (prefix_diag, prefix, diagnostics, keybinds)
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum CustomCommandAction {
- Shell,
- Pane,
-}
-
-#[derive(Debug, Clone)]
-pub struct CustomCommandKeybind {
- pub key: (KeyCode, KeyModifiers),
- pub label: String,
- pub command: String,
- pub action: CustomCommandAction,
-}
-
-/// Parsed keybinds for navigate mode actions.
-#[derive(Debug, Clone)]
-pub struct Keybinds {
- pub new_workspace: (KeyCode, KeyModifiers),
- pub new_workspace_label: String,
- pub rename_workspace: (KeyCode, KeyModifiers),
- pub rename_workspace_label: String,
- pub close_workspace: (KeyCode, KeyModifiers),
- pub close_workspace_label: String,
- pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
- pub previous_workspace_label: Option<String>,
- pub next_workspace: Option<(KeyCode, KeyModifiers)>,
- pub next_workspace_label: Option<String>,
- pub new_tab: (KeyCode, KeyModifiers),
- pub new_tab_label: String,
- pub rename_tab: Option<(KeyCode, KeyModifiers)>,
- pub rename_tab_label: Option<String>,
- pub previous_tab: Option<(KeyCode, KeyModifiers)>,
- pub previous_tab_label: Option<String>,
- pub next_tab: Option<(KeyCode, KeyModifiers)>,
- pub next_tab_label: Option<String>,
- pub close_tab: Option<(KeyCode, KeyModifiers)>,
- pub close_tab_label: Option<String>,
- pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_left_label: Option<String>,
- pub focus_pane_down: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_down_label: Option<String>,
- pub focus_pane_up: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_up_label: Option<String>,
- pub focus_pane_right: Option<(KeyCode, KeyModifiers)>,
- pub focus_pane_right_label: Option<String>,
- pub split_vertical: (KeyCode, KeyModifiers),
- pub split_vertical_label: String,
- pub split_horizontal: (KeyCode, KeyModifiers),
- pub split_horizontal_label: String,
- pub close_pane: (KeyCode, KeyModifiers),
- pub close_pane_label: String,
- pub fullscreen: (KeyCode, KeyModifiers),
- pub fullscreen_label: String,
- pub resize_mode: (KeyCode, KeyModifiers),
- pub resize_mode_label: String,
- pub toggle_sidebar: (KeyCode, KeyModifiers),
- pub toggle_sidebar_label: String,
- pub custom_commands: Vec<CustomCommandKeybind>,
-}
-
-/// Parse a color string into a ratatui Color.
-/// Supports: hex (#rrggbb, #rgb), named colors, rgb(r,g,b), and reset aliases.
-pub fn parse_color(s: &str) -> ratatui::style::Color {
- use ratatui::style::Color;
- let s = s.trim().to_lowercase();
-
- match s.as_str() {
- "reset" | "default" | "none" | "transparent" => return Color::Reset,
- _ => {}
- }
-
- // Hex: #rrggbb or #rgb
- if let Some(hex) = s.strip_prefix('#') {
- if hex.len() == 6 {
- if let (Ok(r), Ok(g), Ok(b)) = (
- u8::from_str_radix(&hex[0..2], 16),
- u8::from_str_radix(&hex[2..4], 16),
- u8::from_str_radix(&hex[4..6], 16),
- ) {
- return Color::Rgb(r, g, b);
- }
- } else if hex.len() == 3 {
- let chars: Vec<u8> = hex
- .chars()
- .filter_map(|c| u8::from_str_radix(&c.to_string(), 16).ok())
- .collect();
- if chars.len() == 3 {
- return Color::Rgb(chars[0] * 17, chars[1] * 17, chars[2] * 17);
- }
- }
- }
-
- // rgb(r, g, b)
- if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
- let parts: Vec<&str> = inner.split(',').collect();
- if parts.len() == 3 {
- if let (Ok(r), Ok(g), Ok(b)) = (
- parts[0].trim().parse::<u8>(),
- parts[1].trim().parse::<u8>(),
- parts[2].trim().parse::<u8>(),
- ) {
- return Color::Rgb(r, g, b);
- }
- }
- }
-
- // Named colors
- match s.as_str() {
- "black" => Color::Black,
- "red" => Color::Red,
- "green" => Color::Green,
- "yellow" => Color::Yellow,
- "blue" => Color::Blue,
- "magenta" | "purple" => Color::Magenta,
- "cyan" => Color::Cyan,
- "white" => Color::White,
- "gray" | "grey" => Color::Gray,
- "darkgray" | "darkgrey" => Color::DarkGray,
- "lightred" => Color::LightRed,
- "lightgreen" => Color::LightGreen,
- "lightyellow" => Color::LightYellow,
- "lightblue" => Color::LightBlue,
- "lightmagenta" => Color::LightMagenta,
- "lightcyan" => Color::LightCyan,
- _ => {
- warn!(color = s, "unknown color, defaulting to cyan");
- Color::Cyan
- }
- }
-}
-
-pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std::io::Result<()> {
- let path = config_path();
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent)?;
- }
-
- let content = std::fs::read_to_string(&path).unwrap_or_default();
- let content = upsert_top_level_bool(&content, "onboarding", false);
- let content = upsert_section_bool(&content, "ui.sound", "enabled", sound_enabled);
- let content = upsert_section_bool(&content, "ui.toast", "enabled", toast_enabled);
- std::fs::write(path, content)
-}
-
-pub fn config_path() -> PathBuf {
- if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) {
- return PathBuf::from(path);
- }
- config_dir().join("config.toml")
-}
-
-pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> {
- let path = config_path();
- if !path.exists() {
- return Config::default().live_keybinds();
- }
-
- let content = std::fs::read_to_string(&path).map_err(|err| {
- vec![format!(
- "config read error: {err}; keeping current keybinds"
- )]
- })?;
- let config = toml::from_str::<Config>(&content).map_err(|err| {
- vec![format!(
- "config parse error: {err}; keeping current keybinds"
- )]
- })?;
- config.live_keybinds()
-}
-
-fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String {
- let replacement = format!("{key} = {value}");
- let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
- let mut in_section = false;
-
- for line in &mut lines {
- let trimmed = line.trim();
- if trimmed.starts_with('[') && trimmed.ends_with(']') {
- in_section = true;
- continue;
- }
- if in_section {
- continue;
- }
- if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
- *line = replacement.clone();
- return lines.join("\n") + "\n";
- }
- }
-
- if lines.is_empty() {
- format!("{replacement}\n")
- } else {
- format!("{replacement}\n{}\n", lines.join("\n").trim_end())
- }
-}
-
-/// Write a key = value pair in a TOML section (creates section if missing).
-pub fn upsert_section_value(content: &str, section: &str, key: &str, value: &str) -> String {
- upsert_section_raw(content, section, key, value)
-}
-
-pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String {
- upsert_section_raw(content, section, key, &value.to_string())
-}
-
-fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String {
- let header = format!("[{section}]");
- let assignment = format!("{key} = {value}");
- let lines: Vec<&str> = content.lines().collect();
- let mut result = Vec::new();
- let mut i = 0;
- let mut found_section = false;
- let mut inserted = false;
-
- while i < lines.len() {
- let line = lines[i];
- let trimmed = line.trim();
-
- if trimmed == header {
- found_section = true;
- result.push(line.to_string());
- i += 1;
-
- while i < lines.len() {
- let current = lines[i];
- let current_trimmed = current.trim();
- if current_trimmed.starts_with('[') && current_trimmed.ends_with(']') {
- if !inserted {
- result.push(assignment.clone());
- inserted = true;
- }
- break;
- }
-
- if current_trimmed.starts_with(&format!("{key} "))
- || current_trimmed.starts_with(&format!("{key}="))
- {
- result.push(assignment.clone());
- inserted = true;
- } else {
- result.push(current.to_string());
- }
- i += 1;
- }
-
- continue;
- }
-
- result.push(line.to_string());
- i += 1;
- }
-
- if !found_section {
- if !result.is_empty() && !result.last().is_some_and(|line| line.trim().is_empty()) {
- result.push(String::new());
- }
- result.push(header);
- result.push(assignment);
- } else if found_section && !inserted {
- result.push(assignment);
- }
-
- result.join("\n") + "\n"
-}
-
-fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
- let parts: Vec<&str> = s.split('+').collect();
- let mut modifiers = KeyModifiers::empty();
- let mut key_str: Option<&str> = None;
-
- for part in &parts {
- let trimmed = part.trim();
- match trimmed.to_lowercase().as_str() {
- "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
- "shift" => modifiers |= KeyModifiers::SHIFT,
- "alt" | "meta" => modifiers |= KeyModifiers::ALT,
- _ if trimmed.is_empty() => return None,
- _ => {
- if key_str.is_some() {
- return None;
- }
- key_str = Some(trimmed);
- }
- }
- }
-
- let key_str = key_str?;
-
- let lower = key_str.to_lowercase();
- let code = match lower.as_str() {
- "space" | " " => KeyCode::Char(' '),
- "enter" | "return" => KeyCode::Enter,
- "esc" | "escape" => KeyCode::Esc,
- "tab" => KeyCode::Tab,
- "backspace" | "bs" => KeyCode::Backspace,
- "left" => KeyCode::Left,
- "right" => KeyCode::Right,
- "up" => KeyCode::Up,
- "down" => KeyCode::Down,
- s if s.len() == 1 => {
- let ch = key_str.chars().next().unwrap();
- if ch.is_ascii_uppercase() {
- modifiers |= KeyModifiers::SHIFT;
- KeyCode::Char(ch.to_ascii_lowercase())
- } else {
- KeyCode::Char(ch)
- }
- }
- s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?,
- _ => return None,
- };
-
- Some((code, modifiers))
-}
-
-pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
- let (code, modifiers) = binding;
- let mut parts = Vec::new();
- if modifiers.contains(KeyModifiers::CONTROL) {
- parts.push("ctrl".to_string());
- }
- if modifiers.contains(KeyModifiers::ALT) {
- parts.push("alt".to_string());
- }
- if modifiers.contains(KeyModifiers::SHIFT) {
- parts.push("shift".to_string());
- }
-
- let key = match code {
- KeyCode::Char(' ') => "space".to_string(),
- KeyCode::Char(c) => c.to_string(),
- KeyCode::Enter => "enter".to_string(),
- KeyCode::Esc => "esc".to_string(),
- KeyCode::Tab => "tab".to_string(),
- KeyCode::BackTab => "shift+tab".to_string(),
- KeyCode::Backspace => "backspace".to_string(),
- KeyCode::Left => "left".to_string(),
- KeyCode::Right => "right".to_string(),
- KeyCode::Up => "up".to_string(),
- KeyCode::Down => "down".to_string(),
- KeyCode::F(n) => format!("f{n}"),
- _ => format!("{:?}", code).to_lowercase(),
- };
-
- if matches!(code, KeyCode::BackTab) {
- return if parts.is_empty() {
- key
- } else {
- format!("{}+tab", parts.join("+"))
- };
- }
-
- parts.push(key);
- parts.join("+")
-}
-
-fn parse_key_combo_with_diagnostic(
- s: &str,
- field: &str,
- fallback: (KeyCode, KeyModifiers),
-) -> ((KeyCode, KeyModifiers), Option<String>) {
- match parse_key_combo(s) {
- Some(binding) => (binding, None),
- None => {
- let diag = format!("invalid keybinding: {field} = {s:?}; using fallback");
- warn!(message = %diag, "config diagnostic");
- (fallback, Some(diag))
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crossterm::event::{KeyCode, KeyModifiers};
-
- #[test]
- fn parse_simple_char() {
- assert_eq!(
- parse_key_combo("v"),
- Some((KeyCode::Char('v'), KeyModifiers::empty()))
- );
- }
-
- #[test]
- fn parse_ctrl_combo() {
- assert_eq!(
- parse_key_combo("ctrl+b"),
- Some((KeyCode::Char('b'), KeyModifiers::CONTROL))
- );
- }
-
- #[test]
- fn parse_special_key() {
- assert_eq!(
- parse_key_combo("enter"),
- Some((KeyCode::Enter, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("tab"),
- Some((KeyCode::Tab, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("esc"),
- Some((KeyCode::Esc, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("left"),
- Some((KeyCode::Left, KeyModifiers::empty()))
- );
- assert_eq!(
- parse_key_combo("alt+right"),
- Some((KeyCode::Right, KeyModifiers::ALT))
- );
- }
-
- #[test]
- fn parse_ctrl_shift() {
- assert_eq!(
- parse_key_combo("ctrl+shift+a"),
- Some((
- KeyCode::Char('a'),
- KeyModifiers::CONTROL | KeyModifiers::SHIFT
- ))
- );
- }
-
- #[test]
- fn parse_f_key() {
- assert_eq!(
- parse_key_combo("f5"),
- Some((KeyCode::F(5), KeyModifiers::empty()))
- );
- }
-
- #[test]
- fn parse_punctuation_key() {
- assert_eq!(
- parse_key_combo("ctrl+`"),
- Some((KeyCode::Char('`'), KeyModifiers::CONTROL))
- );
- }
-
- #[test]
- fn uppercase_char_implies_shift() {
- assert_eq!(
- parse_key_combo("D"),
- Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
- );
- }
-
- #[test]
- fn explicit_shift_and_uppercase_do_not_double_apply_shift() {
- assert_eq!(
- parse_key_combo("shift+D"),
- Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
- );
- }
-
- #[test]
- fn invalid_keybinding_is_rejected() {
- assert_eq!(parse_key_combo("ctrl+foo+bar"), None);
- assert_eq!(parse_key_combo("ctrl+"), None);
- }
-
- #[test]
- fn default_keybinds_parse() {
- let config = Config::default();
- let kb = config.keybinds();
- assert_eq!(kb.new_workspace.0, KeyCode::Char('n'));
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
- );
- assert_eq!(kb.close_workspace.0, KeyCode::Char('d'));
- assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
- assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
- assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
- assert_eq!(kb.fullscreen.0, KeyCode::Char('f'));
- assert_eq!(kb.resize_mode.0, KeyCode::Char('r'));
- assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b'));
- assert!(kb.custom_commands.is_empty());
- }
-
- #[test]
- fn custom_keybinds_from_toml() {
- let toml = r#"
-[keys]
-prefix = "ctrl+a"
-new_workspace = "c"
-rename_workspace = "shift+r"
-close_workspace = "ctrl+d"
-split_vertical = "s"
-split_horizontal = "shift+s"
-close_pane = "ctrl+w"
-fullscreen = "z"
-resize_mode = "ctrl+r"
-toggle_sidebar = "tab"
-focus_pane_left = "alt+h"
-focus_pane_right = "alt+right"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let (code, mods) = config.prefix_key();
- assert_eq!(code, KeyCode::Char('a'));
- assert_eq!(mods, KeyModifiers::CONTROL);
-
- let kb = config.keybinds();
- assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('c'), KeyModifiers::empty())
- );
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('r'), KeyModifiers::SHIFT)
- );
- assert_eq!(
- kb.close_workspace,
- (KeyCode::Char('d'), KeyModifiers::CONTROL)
- );
- assert_eq!(kb.split_vertical.0, KeyCode::Char('s'));
- assert_eq!(
- kb.split_horizontal,
- (KeyCode::Char('s'), KeyModifiers::SHIFT)
- );
- assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL));
- assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
- assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
- assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
- assert_eq!(
- kb.focus_pane_left,
- Some((KeyCode::Char('h'), KeyModifiers::ALT))
- );
- assert_eq!(
- kb.focus_pane_right,
- Some((KeyCode::Right, KeyModifiers::ALT))
- );
- assert_eq!(kb.focus_pane_down, None);
- assert_eq!(kb.focus_pane_up, None);
- }
-
- #[test]
- fn uppercase_keybind_from_toml_flows_into_shift_combo() {
- let toml = r#"
-[keys]
-split_horizontal = "D"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
- assert_eq!(
- kb.split_horizontal,
- (KeyCode::Char('d'), KeyModifiers::SHIFT)
- );
- }
-
- #[test]
- fn invalid_keybinding_produces_diagnostic_and_falls_back() {
- let toml = r#"
-[keys]
-rename_workspace = "wat"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("keys.rename_workspace")));
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
- );
- assert_eq!(kb.rename_workspace_label, "shift+n");
- }
-
- #[test]
- fn toast_config_parses() {
- let toml = r#"
-[ui.toast]
-enabled = true
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- assert!(config.ui.toast.enabled);
- }
-
- #[test]
- fn missing_onboarding_shows_setup() {
- let config = Config::default();
- assert!(config.should_show_onboarding());
- }
-
- #[test]
- fn onboarding_false_skips_setup() {
- let config: Config = toml::from_str("onboarding = false").unwrap();
- assert!(!config.should_show_onboarding());
- }
-
- #[test]
- fn upsert_top_level_bool_replaces_existing_value() {
- let content = "onboarding = true\n[keys]\nprefix = \"ctrl+b\"\n";
- let updated = upsert_top_level_bool(content, "onboarding", false);
- assert!(updated.contains("onboarding = false"));
- assert!(!updated.contains("onboarding = true"));
- }
-
- #[test]
- fn upsert_section_bool_adds_missing_section() {
- let updated = upsert_section_bool("", "ui.toast", "enabled", true);
- assert!(updated.contains("[ui.toast]"));
- assert!(updated.contains("enabled = true"));
- }
-
- #[test]
- fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() {
- let toml = r#"
-[keys]
-new_workspace = "g"
-rename_workspace = "g"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("duplicate keybinding")));
- assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
- );
- assert_eq!(kb.rename_workspace_label, "shift+n");
- }
-
- #[test]
- fn duplicate_optional_keybinding_is_disabled_with_diagnostic() {
- let toml = r#"
-[keys]
-new_workspace = "g"
-rename_tab = "g"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding")));
- assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
- assert_eq!(kb.rename_tab, None);
- }
-
- #[test]
- fn duplicate_shifted_symbol_binding_is_rejected_after_normalization() {
- let toml = r#"
-[keys]
-new_workspace = "?"
-rename_workspace = "shift+/"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics.iter().any(|d| {
- d.contains("duplicate keybinding")
- && d.contains("keys.rename_workspace")
- && d.contains("keys.new_workspace")
- }));
- assert_eq!(
- kb.new_workspace,
- (KeyCode::Char('?'), KeyModifiers::empty())
- );
- assert_eq!(
- kb.rename_workspace,
- (KeyCode::Char('n'), KeyModifiers::SHIFT)
- );
- assert_eq!(kb.rename_workspace_label, "shift+n");
- }
-
- #[test]
- fn custom_command_conflicting_with_shifted_symbol_reserved_key_is_disabled() {
- let toml = r#"
-[[keys.command]]
-key = "shift+/"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics.iter().any(|d| {
- d.contains("duplicate custom keybinding")
- && d.contains("keys.command[0].key")
- && d.contains("navigate.keybind_help")
- }));
- assert!(kb.custom_commands.is_empty());
- }
-
- #[test]
- fn custom_command_keybinds_parse_from_toml() {
- let toml = r#"
-[[keys.command]]
-key = "g"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
-
- assert_eq!(kb.custom_commands.len(), 1);
- assert_eq!(
- kb.custom_commands[0].key,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
- assert_eq!(kb.custom_commands[0].label, "g");
- assert_eq!(kb.custom_commands[0].command, "echo hi");
- assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell);
- }
-
- #[test]
- fn pane_custom_command_keybinds_parse_from_toml() {
- let toml = r#"
-[[keys.command]]
-key = "g"
-type = "pane"
-command = "lazygit"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let kb = config.keybinds();
-
- assert_eq!(kb.custom_commands.len(), 1);
- assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane);
- }
-
- #[test]
- fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() {
- let toml = r#"
-[keys]
-new_workspace = "g"
-
-[[keys.command]]
-key = "g"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics.iter().any(|d| {
- d.contains("duplicate custom keybinding")
- && d.contains("keys.command[0].key")
- && d.contains("keys.new_workspace")
- }));
- assert!(kb.custom_commands.is_empty());
- }
-
- #[test]
- fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() {
- let toml = r#"
-[[keys.command]]
-key = "q"
-command = "echo hi"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- let diagnostics = config.collect_diagnostics();
- let kb = config.keybinds();
-
- assert!(diagnostics.iter().any(|d| {
- d.contains("duplicate custom keybinding")
- && d.contains("keys.command[0].key")
- && d.contains("navigate.quit")
- }));
- assert!(kb.custom_commands.is_empty());
- }
-
- #[test]
- fn live_keybinds_reject_invalid_keybinding() {
- let config: Config = toml::from_str(
- r#"
-[keys]
-rename_workspace = "wat"
-"#,
- )
- .unwrap();
-
- let diagnostics = config.live_keybinds().unwrap_err();
- assert!(diagnostics
- .iter()
- .any(|d| d.contains("keys.rename_workspace")));
- }
-
- #[test]
- fn live_keybinds_ignore_non_key_diagnostics() {
- let config: Config = toml::from_str(
- r#"
-[keys]
-new_workspace = "g"
-
-[ui.sound]
-done_path = "sounds/missing.mp3"
-"#,
- )
- .unwrap();
-
- let live = config.live_keybinds().unwrap();
- assert_eq!(
- live.keybinds.new_workspace,
- (KeyCode::Char('g'), KeyModifiers::empty())
- );
- }
-
- #[test]
- fn sound_table_config_parses() {
- let toml = r#"
-[ui.sound]
-enabled = true
-path = "sounds/all.mp3"
-done_path = "sounds/done.mp3"
-request_path = "/tmp/request.mp3"
-
-[ui.sound.agents]
-droid = "off"
-claude = "on"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- assert!(config.ui.sound.enabled);
- assert_eq!(config.ui.sound.path, Some(PathBuf::from("sounds/all.mp3")));
- assert_eq!(
- config.ui.sound.done_path,
- Some(PathBuf::from("sounds/done.mp3"))
- );
- assert_eq!(
- config.ui.sound.request_path,
- Some(PathBuf::from("/tmp/request.mp3"))
- );
- assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off);
- assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On);
- assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default);
- }
-
- #[test]
- fn sound_path_resolution_prefers_specific_over_global() {
- let config: Config = toml::from_str(
- r#"
-[ui.sound]
-path = "sounds/all.mp3"
-done_path = "sounds/done.mp3"
-"#,
- )
- .unwrap();
-
- let config_root = config_path().parent().unwrap().to_path_buf();
- assert_eq!(
- config.ui.sound.path_for(crate::sound::Sound::Done),
- Some(config_root.join("sounds/done.mp3"))
- );
- assert_eq!(
- config.ui.sound.path_for(crate::sound::Sound::Request),
- Some(config_root.join("sounds/all.mp3"))
- );
- }
-
- #[test]
- fn missing_sound_file_produces_diagnostic() {
- let config: Config = toml::from_str(
- r#"
-[ui.sound]
-done_path = "sounds/missing.mp3"
-"#,
- )
- .unwrap();
-
- let diagnostics = config.collect_diagnostics();
- assert!(diagnostics.iter().any(
- |diag| diag.contains("ui.sound.done_path") && diag.contains("using default sound")
- ));
- }
-
- #[test]
- fn non_mp3_sound_file_produces_diagnostic() {
- let config: Config = toml::from_str(
- r#"
-[ui.sound]
-path = "sounds/notification.wav"
-"#,
- )
- .unwrap();
-
- let diagnostics = config.collect_diagnostics();
- assert!(diagnostics.iter().any(|diag| {
- diag.contains("ui.sound.path") && diag.contains("expected an mp3 file")
- }));
- }
-
- #[test]
- fn advanced_defaults_include_scrollback_limit_bytes() {
- let config = Config::default();
- assert_eq!(
- config.advanced.scrollback_limit_bytes,
- DEFAULT_SCROLLBACK_LIMIT_BYTES
- );
- }
-
- #[test]
- fn advanced_config_parses() {
- let toml = r#"
-[advanced]
-allow_nested = true
-scrollback_limit_bytes = 12345
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- assert!(config.advanced.allow_nested);
- assert_eq!(config.advanced.scrollback_limit_bytes, 12345);
- }
-
- #[test]
- fn advanced_legacy_scrollback_lines_alias_parses() {
- let toml = r#"
-[advanced]
-scrollback_lines = 12345
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- assert_eq!(config.advanced.scrollback_limit_bytes, 12345);
- }
-
- #[test]
- fn theme_name_parses() {
- let toml = r#"
-[theme]
-name = "dracula"
-"#;
- let config: Config = toml::from_str(toml).unwrap();
- assert_eq!(config.theme.name.as_deref(), Some("dracula"));
- }
-
- #[test]
- fn parse_color_accepts_reset_aliases() {
- use ratatui::style::Color;
-
- for value in ["reset", "default", "none", "transparent"] {
- assert_eq!(parse_color(value), Color::Reset, "value: {value}");
- }
- }
-
- #[test]
- fn theme_custom_overrides_parse() {
- let toml = r##"
-[theme]
-name = "nord"
-
-[theme.custom]
-panel_bg = "#1e1e2e"
-accent = "#ff79c6"
-red = "rgb(255, 85, 85)"
-"##;
- let config: Config = toml::from_str(toml).unwrap();
- assert_eq!(config.theme.name.as_deref(), Some("nord"));
- let custom = config.theme.custom.as_ref().unwrap();
- assert_eq!(custom.panel_bg.as_deref(), Some("#1e1e2e"));
- assert_eq!(custom.accent.as_deref(), Some("#ff79c6"));
- assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)"));
- assert!(custom.green.is_none());
- }
-
- #[test]
- fn theme_defaults_when_missing() {
- let config: Config = toml::from_str("").unwrap();
- assert!(config.theme.name.is_none());
- assert!(config.theme.custom.is_none());
- }
-}
diff --git a/src/config/io.rs b/src/config/io.rs
new file mode 100644
index 00000000..3bc18c4b
--- /dev/null
+++ b/src/config/io.rs
@@ -0,0 +1,226 @@
+use std::path::{Path, PathBuf};
+
+use tracing::warn;
+
+use super::{model::LoadedConfig, Config, LiveKeybindConfig, CONFIG_PATH_ENV_VAR};
+
+pub fn app_dir_name() -> &'static str {
+ if cfg!(debug_assertions) {
+ "herdr-dev"
+ } else {
+ "herdr"
+ }
+}
+
+pub fn config_dir() -> PathBuf {
+ if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
+ PathBuf::from(dir).join(app_dir_name())
+ } else if let Ok(home) = std::env::var("HOME") {
+ PathBuf::from(home).join(format!(".config/{}", app_dir_name()))
+ } else {
+ PathBuf::from(format!("/tmp/{}", app_dir_name()))
+ }
+}
+
+impl Config {
+ pub fn load() -> LoadedConfig {
+ let path = config_path();
+ if path.exists() {
+ match std::fs::read_to_string(&path) {
+ Ok(content) => match toml::from_str::<Config>(&content) {
+ Ok(config) => {
+ let diagnostics = config.collect_diagnostics();
+ return LoadedConfig {
+ config,
+ diagnostics,
+ };
+ }
+ Err(err) => {
+ warn!(err = %err, "config parse error, using defaults");
+ return LoadedConfig {
+ config: Self::default(),
+ diagnostics: vec![format!("config parse error: {err}; using defaults")],
+ };
+ }
+ },
+ Err(err) => {
+ warn!(err = %err, "config read error, using defaults");
+ return LoadedConfig {
+ config: Self::default(),
+ diagnostics: vec![format!("config read error: {err}; using defaults")],
+ };
+ }
+ }
+ }
+ LoadedConfig {
+ config: Self::default(),
+ diagnostics: Vec::new(),
+ }
+ }
+}
+
+pub(super) fn resolve_config_relative_path(path: &Path) -> PathBuf {
+ if path.is_absolute() {
+ return path.to_path_buf();
+ }
+
+ config_path()
+ .parent()
+ .unwrap_or_else(|| Path::new("."))
+ .join(path)
+}
+
+pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std::io::Result<()> {
+ let path = config_path();
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ let content = std::fs::read_to_string(&path).unwrap_or_default();
+ let content = upsert_top_level_bool(&content, "onboarding", false);
+ let content = upsert_section_bool(&content, "ui.sound", "enabled", sound_enabled);
+ let content = upsert_section_bool(&content, "ui.toast", "enabled", toast_enabled);
+ std::fs::write(path, content)
+}
+
+pub fn config_path() -> PathBuf {
+ if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) {
+ return PathBuf::from(path);
+ }
+ config_dir().join("config.toml")
+}
+
+pub fn load_live_keybinds() -> Result<LiveKeybindConfig, Vec<String>> {
+ let path = config_path();
+ if !path.exists() {
+ return Config::default().live_keybinds();
+ }
+
+ let content = std::fs::read_to_string(&path).map_err(|err| {
+ vec![format!(
+ "config read error: {err}; keeping current keybinds"
+ )]
+ })?;
+ let config = toml::from_str::<Config>(&content).map_err(|err| {
+ vec![format!(
+ "config parse error: {err}; keeping current keybinds"
+ )]
+ })?;
+ config.live_keybinds()
+}
+
+pub(super) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String {
+ let replacement = format!("{key} = {value}");
+ let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
+ let mut in_section = false;
+
+ for line in &mut lines {
+ let trimmed = line.trim();
+ if trimmed.starts_with('[') && trimmed.ends_with(']') {
+ in_section = true;
+ continue;
+ }
+ if in_section {
+ continue;
+ }
+ if trimmed.starts_with(&format!("{key} ")) || trimmed.starts_with(&format!("{key}=")) {
+ *line = replacement.clone();
+ return lines.join("\n") + "\n";
+ }
+ }
+
+ if lines.is_empty() {
+ format!("{replacement}\n")
+ } else {
+ format!("{replacement}\n{}\n", lines.join("\n").trim_end())
+ }
+}
+
+/// Write a key = value pair in a TOML section (creates section if missing).
+pub fn upsert_section_value(content: &str, section: &str, key: &str, value: &str) -> String {
+ upsert_section_raw(content, section, key, value)
+}
+
+pub fn upsert_section_bool(content: &str, section: &str, key: &str, value: bool) -> String {
+ upsert_section_raw(content, section, key, &value.to_string())
+}
+
+fn upsert_section_raw(content: &str, section: &str, key: &str, value: &str) -> String {
+ let header = format!("[{section}]");
+ let assignment = format!("{key} = {value}");
+ let lines: Vec<&str> = content.lines().collect();
+ let mut result = Vec::new();
+ let mut i = 0;
+ let mut found_section = false;
+ let mut inserted = false;
+
+ while i < lines.len() {
+ let line = lines[i];
+ let trimmed = line.trim();
+
+ if trimmed == header {
+ found_section = true;
+ result.push(line.to_string());
+ i += 1;
+
+ while i < lines.len() {
+ let current = lines[i];
+ let current_trimmed = current.trim();
+ if current_trimmed.starts_with('[') && current_trimmed.ends_with(']') {
+ if !inserted {
+ result.push(assignment.clone());
+ inserted = true;
+ }
+ break;
+ }
+
+ if current_trimmed.starts_with(&format!("{key} "))
+ || current_trimmed.starts_with(&format!("{key}="))
+ {
+ result.push(assignment.clone());
+ inserted = true;
+ } else {
+ result.push(current.to_string());
+ }
+ i += 1;
+ }
+
+ continue;
+ }
+
+ result.push(line.to_string());
+ i += 1;
+ }
+
+ if !found_section {
+ if !result.is_empty() && !result.last().is_some_and(|line| line.trim().is_empty()) {
+ result.push(String::new());
+ }
+ result.push(header);
+ result.push(assignment);
+ } else if !inserted {
+ result.push(assignment);
+ }
+
+ result.join("\n") + "\n"
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn upsert_top_level_bool_replaces_existing_value() {
+ let content = "onboarding = true\n[keys]\nprefix = \"ctrl+b\"\n";
+ let updated = upsert_top_level_bool(content, "onboarding", false);
+ assert!(updated.contains("onboarding = false"));
+ assert!(!updated.contains("onboarding = true"));
+ }
+
+ #[test]
+ fn upsert_section_bool_adds_missing_section() {
+ let updated = upsert_section_bool("", "ui.toast", "enabled", true);
+ assert!(updated.contains("[ui.toast]"));
+ assert!(updated.contains("enabled = true"));
+ }
+}
diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs
new file mode 100644
index 00000000..07c682ba
--- /dev/null
+++ b/src/config/keybinds.rs
@@ -0,0 +1,1089 @@
+use crossterm::event::{KeyCode, KeyModifiers};
+use serde::Deserialize;
+use tracing::warn;
+
+use super::Config;
+
+#[derive(Debug)]
+pub struct LiveKeybindConfig {
+ pub prefix: (KeyCode, KeyModifiers),
+ pub keybinds: Keybinds,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum CommandKeybindType {
+ #[default]
+ Shell,
+ Pane,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct CommandKeybindConfig {
+ /// Navigate-mode key that runs a command after pressing the prefix key.
+ pub key: String,
+ /// Command executed either in the background shell or inside a pane.
+ pub command: String,
+ /// Command execution mode. Default: "shell".
+ #[serde(rename = "type")]
+ pub action_type: CommandKeybindType,
+}
+
+impl Default for CommandKeybindConfig {
+ fn default() -> Self {
+ Self {
+ key: String::new(),
+ command: String::new(),
+ action_type: CommandKeybindType::Shell,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CustomCommandAction {
+ Shell,
+ Pane,
+}
+
+#[derive(Debug, Clone)]
+pub struct CustomCommandKeybind {
+ pub key: (KeyCode, KeyModifiers),
+ pub label: String,
+ pub command: String,
+ pub action: CustomCommandAction,
+}
+
+/// Parsed keybinds for navigate mode actions.
+#[derive(Debug, Clone)]
+pub struct Keybinds {
+ pub new_workspace: (KeyCode, KeyModifiers),
+ pub new_workspace_label: String,
+ pub rename_workspace: (KeyCode, KeyModifiers),
+ pub rename_workspace_label: String,
+ pub close_workspace: (KeyCode, KeyModifiers),
+ pub close_workspace_label: String,
+ pub detach: Option<(KeyCode, KeyModifiers)>,
+ pub detach_label: Option<String>,
+ pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
+ pub previous_workspace_label: Option<String>,
+ pub next_workspace: Option<(KeyCode, KeyModifiers)>,
+ pub next_workspace_label: Option<String>,
+ pub new_tab: (KeyCode, KeyModifiers),
+ pub new_tab_label: String,
+ pub rename_tab: Option<(KeyCode, KeyModifiers)>,
+ pub rename_tab_label: Option<String>,
+ pub previous_tab: Option<(KeyCode, KeyModifiers)>,
+ pub previous_tab_label: Option<String>,
+ pub next_tab: Option<(KeyCode, KeyModifiers)>,
+ pub next_tab_label: Option<String>,
+ pub close_tab: Option<(KeyCode, KeyModifiers)>,
+ pub close_tab_label: Option<String>,
+ pub focus_pane_left: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_left_label: Option<String>,
+ pub focus_pane_down: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_down_label: Option<String>,
+ pub focus_pane_up: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_up_label: Option<String>,
+ pub focus_pane_right: Option<(KeyCode, KeyModifiers)>,
+ pub focus_pane_right_label: Option<String>,
+ pub split_vertical: (KeyCode, KeyModifiers),
+ pub split_vertical_label: String,
+ pub split_horizontal: (KeyCode, KeyModifiers),
+ pub split_horizontal_label: String,
+ pub close_pane: (KeyCode, KeyModifiers),
+ pub close_pane_label: String,
+ pub fullscreen: (KeyCode, KeyModifiers),
+ pub fullscreen_label: String,
+ pub resize_mode: (KeyCode, KeyModifiers),
+ pub resize_mode_label: String,
+ pub toggle_sidebar: (KeyCode, KeyModifiers),
+ pub toggle_sidebar_label: String,
+ pub custom_commands: Vec<CustomCommandKeybind>,
+}
+
+impl Config {
+ pub(super) fn validated_keybinds(
+ &self,
+ ) -> (
+ Option<String>,
+ (KeyCode, KeyModifiers),
+ Vec<String>,
+ Keybinds,
+ ) {
+ #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+ enum BindingScope {
+ Navigate,
+ TerminalDirect,
+ }
+
+ #[derive(Clone)]
+ struct RequiredBinding<'a> {
+ scope: BindingScope,
+ field: &'a str,
+ label: String,
+ default_label: &'a str,
+ value: (KeyCode, KeyModifiers),
+ default: (KeyCode, KeyModifiers),
+ }
+
+ struct OptionalBinding {
+ scope: BindingScope,
+ field: &'static str,
+ value: Option<(KeyCode, KeyModifiers)>,
+ label: Option<String>,
+ }
+
+ #[derive(Default)]
+ struct BindingRegistry {
+ seen: std::collections::HashMap<(BindingScope, KeyCode, KeyModifiers), String>,
+ }
+
+ impl BindingRegistry {
+ fn register(
+ &mut self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ field: &str,
+ ) -> Option<String> {
+ self.seen
+ .insert((scope, binding.0, binding.1), field.to_string())
+ }
+
+ fn conflict(
+ &self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ ) -> Option<&str> {
+ self.seen
+ .get(&(scope, binding.0, binding.1))
+ .map(String::as_str)
+ }
+
+ fn reserve_if_unbound(
+ &mut self,
+ scope: BindingScope,
+ binding: (KeyCode, KeyModifiers),
+ field: &str,
+ ) {
+ self.seen
+ .entry((scope, binding.0, binding.1))
+ .or_insert_with(|| field.to_string());
+ }
+ }
+
+ fn required_binding<'a>(
+ scope: BindingScope,
+ field: &'a str,
+ configured_label: &'a str,
+ default_label: &'a str,
+ default: (KeyCode, KeyModifiers),
+ diagnostics: &mut Vec<String>,
+ ) -> RequiredBinding<'a> {
+ let (value, diag) = parse_key_combo_with_diagnostic(configured_label, field, default);
+ let label = if let Some(diag) = diag {
+ diagnostics.push(diag);
+ default_label.to_string()
+ } else {
+ configured_label.to_string()
+ };
+ RequiredBinding {
+ scope,
+ field,
+ label,
+ default_label,
+ value,
+ default,
+ }
+ }
+
+ fn optional_binding(
+ scope: BindingScope,
+ field: &'static str,
+ configured_label: &str,
+ diagnostics: &mut Vec<String>,
+ ) -> OptionalBinding {
+ if configured_label.trim().is_empty() {
+ return OptionalBinding {
+ scope,
+ field,
+ value: None,
+ label: None,
+ };
+ }
+ match parse_key_combo(configured_label) {
+ Some(value) => OptionalBinding {
+ scope,
+ field,
+ value: Some(value),
+ label: Some(configured_label.to_string()),
+ },
+ None => {
+ let diag = format!(
+ "invalid keybinding: {field} = {:?}; disabling binding",
+ configured_label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ OptionalBinding {
+ scope,
+ field,
+ value: None,
+ label: None,
+ }
+ }
+ }
+ }
+
+ let mut diagnostics = Vec::new();
+ let (prefix, prefix_diag) = parse_key_combo_with_diagnostic(
+ &self.keys.prefix,
+ "keys.prefix",
+ (KeyCode::Char('b'), KeyModifiers::CONTROL),
+ );
+ if let Some(diag) = &prefix_diag {
+ warn!(message = %diag, "config diagnostic");
+ }
+
+ let mut bindings = vec![
+ required_binding(
+ BindingScope::Navigate,
+ "keys.new_workspace",
+ &self.keys.new_workspace,
+ "n",
+ (KeyCode::Char('n'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.rename_workspace",
+ &self.keys.rename_workspace,
+ "shift+n",
+ (KeyCode::Char('n'), KeyModifiers::SHIFT),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.close_workspace",
+ &self.keys.close_workspace,
+ "shift+d",
+ (KeyCode::Char('d'), KeyModifiers::SHIFT),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.new_tab",
+ &self.keys.new_tab,
+ "c",
+ (KeyCode::Char('c'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.split_vertical",
+ &self.keys.split_vertical,
+ "v",
+ (KeyCode::Char('v'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.split_horizontal",
+ &self.keys.split_horizontal,
+ "-",
+ (KeyCode::Char('-'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.close_pane",
+ &self.keys.close_pane,
+ "x",
+ (KeyCode::Char('x'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.fullscreen",
+ &self.keys.fullscreen,
+ "f",
+ (KeyCode::Char('f'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.resize_mode",
+ &self.keys.resize_mode,
+ "r",
+ (KeyCode::Char('r'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ required_binding(
+ BindingScope::Navigate,
+ "keys.toggle_sidebar",
+ &self.keys.toggle_sidebar,
+ "b",
+ (KeyCode::Char('b'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ ];
+
+ let mut optional_bindings = vec![
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.detach",
+ &self.keys.detach,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.previous_workspace",
+ &self.keys.previous_workspace,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.next_workspace",
+ &self.keys.next_workspace,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.rename_tab",
+ &self.keys.rename_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.previous_tab",
+ &self.keys.previous_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.next_tab",
+ &self.keys.next_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::Navigate,
+ "keys.close_tab",
+ &self.keys.close_tab,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_left",
+ &self.keys.focus_pane_left,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_down",
+ &self.keys.focus_pane_down,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_up",
+ &self.keys.focus_pane_up,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ BindingScope::TerminalDirect,
+ "keys.focus_pane_right",
+ &self.keys.focus_pane_right,
+ &mut diagnostics,
+ ),
+ ];
+
+ let mut registry = BindingRegistry::default();
+ for binding in &mut bindings {
+ if let Some(first_field) = registry.conflict(binding.scope, binding.value) {
+ let diag = format!(
+ "duplicate keybinding: {} conflicts with {}; using default {}",
+ binding.field, first_field, binding.default_label
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ binding.value = binding.default;
+ binding.label = binding.default_label.to_string();
+ }
+ registry.register(binding.scope, binding.value, binding.field);
+ }
+
+ for binding in &mut optional_bindings {
+ let Some(value) = binding.value else {
+ continue;
+ };
+ if let Some(first_field) = registry.conflict(binding.scope, value) {
+ let diag = format!(
+ "duplicate keybinding: {} conflicts with {}; disabling binding",
+ binding.field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ binding.value = None;
+ binding.label = None;
+ continue;
+ }
+ registry.register(binding.scope, value, binding.field);
+ }
+
+ registry.reserve_if_unbound(BindingScope::Navigate, prefix, "keys.prefix");
+ for (field, binding) in [
+ ("navigate.quit", (KeyCode::Char('q'), KeyModifiers::empty())),
+ (
+ "navigate.open_workspace",
+ (KeyCode::Enter, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.settings",
+ (KeyCode::Char('s'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.keybind_help",
+ (KeyCode::Char('?'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_up",
+ (KeyCode::Up, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_down",
+ (KeyCode::Down, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_left",
+ (KeyCode::Char('h'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_down",
+ (KeyCode::Char('j'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_up",
+ (KeyCode::Char('k'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.focus_right",
+ (KeyCode::Char('l'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.arrow_left",
+ (KeyCode::Left, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.arrow_right",
+ (KeyCode::Right, KeyModifiers::empty()),
+ ),
+ ("navigate.tab_next", (KeyCode::Tab, KeyModifiers::empty())),
+ (
+ "navigate.tab_prev",
+ (KeyCode::BackTab, KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_1",
+ (KeyCode::Char('1'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_2",
+ (KeyCode::Char('2'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_3",
+ (KeyCode::Char('3'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_4",
+ (KeyCode::Char('4'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_5",
+ (KeyCode::Char('5'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_6",
+ (KeyCode::Char('6'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_7",
+ (KeyCode::Char('7'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_8",
+ (KeyCode::Char('8'), KeyModifiers::empty()),
+ ),
+ (
+ "navigate.workspace_9",
+ (KeyCode::Char('9'), KeyModifiers::empty()),
+ ),
+ ("navigate.back", (KeyCode::Esc, KeyModifiers::empty())),
+ ] {
+ registry.reserve_if_unbound(BindingScope::Navigate, binding, field);
+ }
+
+ let mut custom_commands = Vec::new();
+ for (index, command) in self.keys.command.iter().enumerate() {
+ let key_field = format!("keys.command[{index}].key");
+ let command_field = format!("keys.command[{index}].command");
+
+ if command.command.trim().is_empty() {
+ let diag =
+ format!("empty custom command: {command_field}; disabling custom command");
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ }
+
+ let Some(binding) = parse_key_combo(&command.key) else {
+ let diag = format!(
+ "invalid keybinding: {} = {:?}; disabling custom command",
+ key_field, command.key
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ };
+
+ if let Some(first_field) = registry.conflict(BindingScope::Navigate, binding) {
+ let diag = format!(
+ "duplicate custom keybinding: {} conflicts with {}; disabling custom command",
+ key_field, first_field
+ );
+ warn!(message = %diag, "config diagnostic");
+ diagnostics.push(diag);
+ continue;
+ }
+
+ registry.register(BindingScope::Navigate, binding, &key_field);
+ let action = match command.action_type {
+ CommandKeybindType::Shell => CustomCommandAction::Shell,
+ CommandKeybindType::Pane => CustomCommandAction::Pane,
+ };
+ custom_commands.push(CustomCommandKeybind {
+ key: binding,
+ label: format_key_combo(binding),
+ command: command.command.clone(),
+ action,
+ });
+ }
+
+ let keybinds = Keybinds {
+ new_workspace: bindings[0].value,
+ new_workspace_label: bindings[0].label.clone(),
+ rename_workspace: bindings[1].value,
+ rename_workspace_label: bindings[1].label.clone(),
+ close_workspace: bindings[2].value,
+ close_workspace_label: bindings[2].label.clone(),
+ detach: optional_bindings[0].value,
+ detach_label: optional_bindings[0].label.clone(),
+ previous_workspace: optional_bindings[1].value,
+ previous_workspace_label: optional_bindings[1].label.clone(),
+ next_workspace: optional_bindings[2].value,
+ next_workspace_label: optional_bindings[2].label.clone(),
+ new_tab: bindings[3].value,
+ new_tab_label: bindings[3].label.clone(),
+ rename_tab: optional_bindings[3].value,
+ rename_tab_label: optional_bindings[3].label.clone(),
+ previous_tab: optional_bindings[4].value,
+ previous_tab_label: optional_bindings[4].label.clone(),
+ next_tab: optional_bindings[5].value,
+ next_tab_label: optional_bindings[5].label.clone(),
+ close_tab: optional_bindings[6].value,
+ close_tab_label: optional_bindings[6].label.clone(),
+ focus_pane_left: optional_bindings[7].value,
+ focus_pane_left_label: optional_bindings[7].label.clone(),
+ focus_pane_down: optional_bindings[8].value,
+ focus_pane_down_label: optional_bindings[8].label.clone(),
+ focus_pane_up: optional_bindings[9].value,
+ focus_pane_up_label: optional_bindings[9].label.clone(),
+ focus_pane_right: optional_bindings[10].value,
+ focus_pane_right_label: optional_bindings[10].label.clone(),
+ split_vertical: bindings[4].value,
+ split_vertical_label: bindings[4].label.clone(),
+ split_horizontal: bindings[5].value,
+ split_horizontal_label: bindings[5].label.clone(),
+ close_pane: bindings[6].value,
+ close_pane_label: bindings[6].label.clone(),
+ fullscreen: bindings[7].value,
+ fullscreen_label: bindings[7].label.clone(),
+ resize_mode: bindings[8].value,
+ resize_mode_label: bindings[8].label.clone(),
+ toggle_sidebar: bindings[9].value,
+ toggle_sidebar_label: bindings[9].label.clone(),
+ custom_commands,
+ };
+
+ (prefix_diag, prefix, diagnostics, keybinds)
+ }
+}
+
+pub fn format_key_combo(binding: (KeyCode, KeyModifiers)) -> String {
+ let (code, modifiers) = binding;
+ let mut parts = Vec::new();
+ if modifiers.contains(KeyModifiers::CONTROL) {
+ parts.push("ctrl".to_string());
+ }
+ if modifiers.contains(KeyModifiers::ALT) {
+ parts.push("alt".to_string());
+ }
+ if modifiers.contains(KeyModifiers::SHIFT) {
+ parts.push("shift".to_string());
+ }
+
+ let key = match code {
+ KeyCode::Char(' ') => "space".to_string(),
+ KeyCode::Char(c) => c.to_string(),
+ KeyCode::Enter => "enter".to_string(),
+ KeyCode::Esc => "esc".to_string(),
+ KeyCode::Tab => "tab".to_string(),
+ KeyCode::BackTab => "shift+tab".to_string(),
+ KeyCode::Backspace => "backspace".to_string(),
+ KeyCode::Left => "left".to_string(),
+ KeyCode::Right => "right".to_string(),
+ KeyCode::Up => "up".to_string(),
+ KeyCode::Down => "down".to_string(),
+ KeyCode::F(n) => format!("f{n}"),
+ _ => format!("{:?}", code).to_lowercase(),
+ };
+
+ if matches!(code, KeyCode::BackTab) {
+ return if parts.is_empty() {
+ key
+ } else {
+ format!("{}+tab", parts.join("+"))
+ };
+ }
+
+ parts.push(key);
+ parts.join("+")
+}
+
+pub(super) fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
+ let parts: Vec<&str> = s.split('+').collect();
+ let mut modifiers = KeyModifiers::empty();
+ let mut key_str: Option<&str> = None;
+
+ for part in &parts {
+ let trimmed = part.trim();
+ match trimmed.to_lowercase().as_str() {
+ "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
+ "shift" => modifiers |= KeyModifiers::SHIFT,
+ "alt" | "meta" => modifiers |= KeyModifiers::ALT,
+ _ if trimmed.is_empty() => return None,
+ _ => {
+ if key_str.is_some() {
+ return None;
+ }
+ key_str = Some(trimmed);
+ }
+ }
+ }
+
+ let key_str = key_str?;
+
+ let lower = key_str.to_lowercase();
+ let code = match lower.as_str() {
+ "space" | " " => KeyCode::Char(' '),
+ "enter" | "return" => KeyCode::Enter,
+ "esc" | "escape" => KeyCode::Esc,
+ "tab" => KeyCode::Tab,
+ "backspace" | "bs" => KeyCode::Backspace,
+ "left" => KeyCode::Left,
+ "right" => KeyCode::Right,
+ "up" => KeyCode::Up,
+ "down" => KeyCode::Down,
+ s if s.len() == 1 => {
+ let ch = key_str.chars().next().unwrap();
+ if ch.is_ascii_uppercase() {
+ modifiers |= KeyModifiers::SHIFT;
+ KeyCode::Char(ch.to_ascii_lowercase())
+ } else {
+ KeyCode::Char(ch)
+ }
+ }
+ s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?,
+ _ => return None,
+ };
+
+ Some((code, modifiers))
+}
+
+fn parse_key_combo_with_diagnostic(
+ s: &str,
+ field: &str,
+ fallback: (KeyCode, KeyModifiers),
+) -> ((KeyCode, KeyModifiers), Option<String>) {
+ match parse_key_combo(s) {
+ Some(binding) => (binding, None),
+ None => {
+ let diag = format!("invalid keybinding: {field} = {s:?}; using fallback");
+ warn!(message = %diag, "config diagnostic");
+ (fallback, Some(diag))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+
+ #[test]
+ fn parse_simple_char() {
+ assert_eq!(
+ parse_key_combo("v"),
+ Some((KeyCode::Char('v'), KeyModifiers::empty()))
+ );
+ }
+
+ #[test]
+ fn parse_ctrl_combo() {
+ assert_eq!(
+ parse_key_combo("ctrl+b"),
+ Some((KeyCode::Char('b'), KeyModifiers::CONTROL))
+ );
+ }
+
+ #[test]
+ fn parse_special_key() {
+ assert_eq!(
+ parse_key_combo("enter"),
+ Some((KeyCode::Enter, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("tab"),
+ Some((KeyCode::Tab, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("esc"),
+ Some((KeyCode::Esc, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("left"),
+ Some((KeyCode::Left, KeyModifiers::empty()))
+ );
+ assert_eq!(
+ parse_key_combo("alt+right"),
+ Some((KeyCode::Right, KeyModifiers::ALT))
+ );
+ }
+
+ #[test]
+ fn parse_ctrl_shift() {
+ assert_eq!(
+ parse_key_combo("ctrl+shift+a"),
+ Some((
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::SHIFT
+ ))
+ );
+ }
+
+ #[test]
+ fn parse_f_key() {
+ assert_eq!(
+ parse_key_combo("f5"),
+ Some((KeyCode::F(5), KeyModifiers::empty()))
+ );
+ }
+
+ #[test]
+ fn parse_punctuation_key() {
+ assert_eq!(
+ parse_key_combo("ctrl+`"),
+ Some((KeyCode::Char('`'), KeyModifiers::CONTROL))
+ );
+ }
+
+ #[test]
+ fn uppercase_char_implies_shift() {
+ assert_eq!(
+ parse_key_combo("D"),
+ Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ );
+ }
+
+ #[test]
+ fn explicit_shift_and_uppercase_do_not_double_apply_shift() {
+ assert_eq!(
+ parse_key_combo("shift+D"),
+ Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ );
+ }
+
+ #[test]
+ fn invalid_keybinding_is_rejected() {
+ assert_eq!(parse_key_combo("ctrl+foo+bar"), None);
+ assert_eq!(parse_key_combo("ctrl+"), None);
+ }
+
+ #[test]
+ fn default_keybinds_parse() {
+ let config = Config::default();
+ let kb = config.keybinds();
+ assert_eq!(kb.new_workspace.0, KeyCode::Char('n'));
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(
+ kb.close_workspace,
+ (KeyCode::Char('d'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.detach, None);
+ assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
+ assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
+ assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
+ assert_eq!(kb.fullscreen.0, KeyCode::Char('f'));
+ assert_eq!(kb.resize_mode.0, KeyCode::Char('r'));
+ assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b'));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn custom_keybinds_from_toml() {
+ let toml = r#"
+[keys]
+prefix = "ctrl+a"
+new_workspace = "c"
+rename_workspace = "shift+r"
+close_workspace = "ctrl+d"
+split_vertical = "s"
+split_horizontal = "shift+s"
+close_pane = "ctrl+w"
+fullscreen = "z"
+resize_mode = "ctrl+r"
+toggle_sidebar = "tab"
+focus_pane_left = "alt+h"
+focus_pane_right = "alt+right"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let (code, mods) = config.prefix_key();
+ assert_eq!(code, KeyCode::Char('a'));
+ assert_eq!(mods, KeyModifiers::CONTROL);
+
+ let kb = config.keybinds();
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('c'), KeyModifiers::empty())
+ );
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('r'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(
+ kb.close_workspace,
+ (KeyCode::Char('d'), KeyModifiers::CONTROL)
+ );
+ assert_eq!(kb.split_vertical.0, KeyCode::Char('s'));
+ assert_eq!(
+ kb.split_horizontal,
+ (KeyCode::Char('s'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL));
+ assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
+ assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
+ assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
+ assert_eq!(
+ kb.focus_pane_left,
+ Some((KeyCode::Char('h'), KeyModifiers::ALT))
+ );
+ assert_eq!(
+ kb.focus_pane_right,
+ Some((KeyCode::Right, KeyModifiers::ALT))
+ );
+ assert_eq!(kb.focus_pane_down, None);
+ assert_eq!(kb.focus_pane_up, None);
+ }
+
+ #[test]
+ fn uppercase_keybind_from_toml_flows_into_shift_combo() {
+ let toml = r#"
+[keys]
+close_pane = "X"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+ assert_eq!(kb.close_pane, (KeyCode::Char('x'), KeyModifiers::SHIFT));
+ }
+
+ #[test]
+ fn invalid_keybinding_produces_diagnostic_and_falls_back() {
+ let toml = r#"
+[keys]
+rename_workspace = "wat"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_workspace")));
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.rename_workspace_label, "shift+n");
+ }
+
+ #[test]
+ fn duplicate_keybinding_produces_diagnostic_and_falls_back_later_binding() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+rename_workspace = "g"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("duplicate keybinding")));
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(
+ kb.rename_workspace,
+ (KeyCode::Char('n'), KeyModifiers::SHIFT)
+ );
+ assert_eq!(kb.rename_workspace_label, "shift+n");
+ }
+
+ #[test]
+ fn duplicate_optional_keybinding_is_disabled_with_diagnostic() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+rename_tab = "g"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_tab") && d.contains("disabling binding")));
+ assert_eq!(
+ kb.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(kb.rename_tab, None);
+ }
+
+ #[test]
+ fn custom_command_keybinds_parse_from_toml() {
+ let toml = r#"
+[[keys.command]]
+key = "g"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.custom_commands.len(), 1);
+ assert_eq!(
+ kb.custom_commands[0].key,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(kb.custom_commands[0].label, "g");
+ assert_eq!(kb.custom_commands[0].command, "echo hi");
+ assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Shell);
+ }
+
+ #[test]
+ fn pane_custom_command_keybinds_parse_from_toml() {
+ let toml = r#"
+[[keys.command]]
+key = "g"
+type = "pane"
+command = "lazygit"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+
+ assert_eq!(kb.custom_commands.len(), 1);
+ assert_eq!(kb.custom_commands[0].action, CustomCommandAction::Pane);
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_builtin_is_disabled_with_diagnostic() {
+ let toml = r#"
+[keys]
+new_workspace = "g"
+
+[[keys.command]]
+key = "g"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("keys.new_workspace")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn custom_command_conflicting_with_reserved_navigate_key_is_disabled_with_diagnostic() {
+ let toml = r#"
+[[keys.command]]
+key = "q"
+command = "echo hi"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let diagnostics = config.collect_diagnostics();
+ let kb = config.keybinds();
+
+ assert!(diagnostics.iter().any(|d| {
+ d.contains("duplicate custom keybinding")
+ && d.contains("keys.command[0].key")
+ && d.contains("navigate.quit")
+ }));
+ assert!(kb.custom_commands.is_empty());
+ }
+
+ #[test]
+ fn live_keybinds_reject_invalid_keybinding() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+rename_workspace = "wat"
+"#,
+ )
+ .unwrap();
+
+ let diagnostics = config.live_keybinds().unwrap_err();
+ assert!(diagnostics
+ .iter()
+ .any(|d| d.contains("keys.rename_workspace")));
+ }
+
+ #[test]
+ fn live_keybinds_ignore_non_key_diagnostics() {
+ let config: Config = toml::from_str(
+ r#"
+[keys]
+new_workspace = "g"
+
+[ui.sound]
+done_path = "sounds/missing.mp3"
+"#,
+ )
+ .unwrap();
+
+ let live = config.live_keybinds().unwrap();
+ assert_eq!(
+ live.keybinds.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ }
+}
diff --git a/src/config/model.rs b/src/config/model.rs
new file mode 100644
index 00000000..70eede5a
--- /dev/null
+++ b/src/config/model.rs
@@ -0,0 +1,216 @@
+use serde::Deserialize;
+
+use super::{CommandKeybindConfig, SoundConfig, ThemeConfig, DEFAULT_SCROLLBACK_LIMIT_BYTES};
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct ToastConfig {
+ pub enabled: bool,
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+pub struct Config {
+ pub onboarding: Option<bool>,
+ pub theme: ThemeConfig,
+ pub keys: KeysConfig,
+ pub ui: UiConfig,
+ pub advanced: AdvancedConfig,
+}
+
+#[derive(Debug)]
+pub struct LoadedConfig {
+ pub config: Config,
+ pub diagnostics: Vec<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct KeysConfig {
+ /// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc").
+ pub prefix: String,
+ /// Create a new workspace. Default: "n"
+ pub new_workspace: String,
+ /// Rename the selected workspace. Default: "shift+n"
+ pub rename_workspace: String,
+ /// Close the selected workspace. Default: "shift+d"
+ pub close_workspace: String,
+ /// Optional explicit detach shortcut in server/client mode. Unset by default.
+ pub detach: String,
+ /// Select the previous workspace. Unset by default.
+ pub previous_workspace: String,
+ /// Select the next workspace. Unset by default.
+ pub next_workspace: String,
+ /// Create a new tab in the active workspace. Default: "c"
+ pub new_tab: String,
+ /// Rename the active tab. Unset by default.
+ pub rename_tab: String,
+ /// Select the previous tab. Unset by default.
+ pub previous_tab: String,
+ /// Select the next tab. Unset by default.
+ pub next_tab: String,
+ /// Close the active tab. Unset by default.
+ pub close_tab: String,
+ /// Focus the pane to the left in terminal mode. Unset by default.
+ pub focus_pane_left: String,
+ /// Focus the pane below in terminal mode. Unset by default.
+ pub focus_pane_down: String,
+ /// Focus the pane above in terminal mode. Unset by default.
+ pub focus_pane_up: String,
+ /// Focus the pane to the right in terminal mode. Unset by default.
+ pub focus_pane_right: String,
+ /// Split pane vertically (side by side). Default: "v"
+ pub split_vertical: String,
+ /// Split pane horizontally (stacked). Default: "-"
+ pub split_horizontal: String,
+ /// Close the focused pane. Default: "x"
+ pub close_pane: String,
+ /// Toggle fullscreen for the focused pane. Default: "f"
+ pub fullscreen: String,
+ /// Enter resize mode. Default: "r"
+ pub resize_mode: String,
+ /// Toggle sidebar collapse. Default: "b"
+ pub toggle_sidebar: String,
+ /// Prefix-mode custom command bindings.
+ pub command: Vec<CommandKeybindConfig>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct UiConfig {
+ pub sidebar_width: u16,
+ /// Ask for confirmation before closing a workspace. Default: true.
+ pub confirm_close: bool,
+ /// Accent color for highlights, borders, and navigation UI.
+ /// Accepts hex (#89b4fa), named colors (cyan, blue), or RGB (rgb(137,180,250)).
+ pub accent: String,
+ /// Optional visual toast notifications for background workspace events.
+ pub toast: ToastConfig,
+ /// Play sounds when agents change state in background workspaces.
+ pub sound: SoundConfig,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct AdvancedConfig {
+ /// Allow launching herdr inside an existing herdr pane. Default: false.
+ pub allow_nested: bool,
+ /// Maximum scrollback buffer size in bytes retained per pane terminal. Default: 10000000.
+ #[serde(alias = "scrollback_lines")]
+ pub scrollback_limit_bytes: usize,
+}
+
+impl Default for KeysConfig {
+ fn default() -> Self {
+ Self {
+ prefix: "ctrl+b".into(),
+ new_workspace: "n".into(),
+ rename_workspace: "shift+n".into(),
+ close_workspace: "shift+d".into(),
+ detach: "".into(),
+ previous_workspace: "".into(),
+ next_workspace: "".into(),
+ new_tab: "c".into(),
+ rename_tab: "".into(),
+ previous_tab: "".into(),
+ next_tab: "".into(),
+ close_tab: "".into(),
+ focus_pane_left: "".into(),
+ focus_pane_down: "".into(),
+ focus_pane_up: "".into(),
+ focus_pane_right: "".into(),
+ split_vertical: "v".into(),
+ split_horizontal: "-".into(),
+ close_pane: "x".into(),
+ fullscreen: "f".into(),
+ resize_mode: "r".into(),
+ toggle_sidebar: "b".into(),
+ command: Vec::new(),
+ }
+ }
+}
+
+impl Default for UiConfig {
+ fn default() -> Self {
+ Self {
+ sidebar_width: 26,
+ confirm_close: true,
+ accent: "cyan".into(),
+ toast: ToastConfig::default(),
+ sound: SoundConfig::default(),
+ }
+ }
+}
+
+impl Default for ToastConfig {
+ fn default() -> Self {
+ Self { enabled: false }
+ }
+}
+
+impl Default for AdvancedConfig {
+ fn default() -> Self {
+ Self {
+ allow_nested: false,
+ scrollback_limit_bytes: DEFAULT_SCROLLBACK_LIMIT_BYTES,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn toast_config_parses() {
+ let toml = r#"
+[ui.toast]
+enabled = true
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert!(config.ui.toast.enabled);
+ }
+
+ #[test]
+ fn missing_onboarding_shows_setup() {
+ let config = Config::default();
+ assert!(config.should_show_onboarding());
+ }
+
+ #[test]
+ fn onboarding_false_skips_setup() {
+ let config: Config = toml::from_str("onboarding = false").unwrap();
+ assert!(!config.should_show_onboarding());
+ }
+
+ #[test]
+ fn advanced_defaults_include_scrollback_limit_bytes() {
+ let config = Config::default();
+ assert_eq!(
+ config.advanced.scrollback_limit_bytes,
+ DEFAULT_SCROLLBACK_LIMIT_BYTES
+ );
+ }
+
+ #[test]
+ fn advanced_config_parses() {
+ let toml = r#"
+[advanced]
+allow_nested = true
+scrollback_limit_bytes = 12345
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert!(config.advanced.allow_nested);
+ assert_eq!(config.advanced.scrollback_limit_bytes, 12345);
+ }
+
+ #[test]
+ fn advanced_legacy_scrollback_lines_alias_parses() {
+ let toml = r#"
+[advanced]
+scrollback_lines = 12345
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.advanced.scrollback_limit_bytes, 12345);
+ }
+}
diff --git a/src/config/sound.rs b/src/config/sound.rs
new file mode 100644
index 00000000..063a521b
--- /dev/null
+++ b/src/config/sound.rs
@@ -0,0 +1,249 @@
+use std::path::PathBuf;
+
+use serde::Deserialize;
+
+use crate::detect::Agent;
+
+use super::io::resolve_config_relative_path;
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct SoundConfig {
+ pub enabled: bool,
+ /// Optional mp3 file path used for all notification sounds.
+ /// Relative paths are resolved from the config file's directory.
+ pub path: Option<PathBuf>,
+ /// Optional mp3 file path for "done" notifications.
+ /// Relative paths are resolved from the config file's directory.
+ pub done_path: Option<PathBuf>,
+ /// Optional mp3 file path for "request" notifications.
+ /// Relative paths are resolved from the config file's directory.
+ pub request_path: Option<PathBuf>,
+ pub agents: AgentSoundOverrides,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct AgentSoundOverrides {
+ pub pi: AgentSoundSetting,
+ pub claude: AgentSoundSetting,
+ pub codex: AgentSoundSetting,
+ pub gemini: AgentSoundSetting,
+ pub cursor: AgentSoundSetting,
+ pub cline: AgentSoundSetting,
+ pub open_code: AgentSoundSetting,
+ pub github_copilot: AgentSoundSetting,
+ pub kimi: AgentSoundSetting,
+ pub droid: AgentSoundSetting,
+ pub amp: AgentSoundSetting,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentSoundSetting {
+ #[default]
+ Default,
+ On,
+ Off,
+}
+
+impl SoundConfig {
+ pub fn allows(&self, agent: Option<Agent>) -> bool {
+ if !self.enabled {
+ return false;
+ }
+
+ !matches!(self.agents.for_agent(agent), AgentSoundSetting::Off)
+ }
+
+ pub fn path_for(&self, sound: crate::sound::Sound) -> Option<PathBuf> {
+ let path = match sound {
+ crate::sound::Sound::Done => self.done_path.as_ref().or(self.path.as_ref()),
+ crate::sound::Sound::Request => self.request_path.as_ref().or(self.path.as_ref()),
+ }?;
+
+ Some(resolve_config_relative_path(path))
+ }
+
+ pub fn diagnostics(&self) -> Vec<String> {
+ let mut diagnostics = Vec::new();
+ for (field, path) in [
+ ("ui.sound.path", self.path.as_ref()),
+ ("ui.sound.done_path", self.done_path.as_ref()),
+ ("ui.sound.request_path", self.request_path.as_ref()),
+ ] {
+ let Some(path) = path else {
+ continue;
+ };
+
+ let resolved = resolve_config_relative_path(path);
+ if resolved
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .is_none_or(|ext: &str| !ext.eq_ignore_ascii_case("mp3"))
+ {
+ diagnostics.push(format!(
+ "unsupported sound file format: {field} = {} resolves to {}; expected an mp3 file; using default sound",
+ path.display(),
+ resolved.display()
+ ));
+ continue;
+ }
+
+ if !resolved.exists() {
+ diagnostics.push(format!(
+ "missing sound file: {field} = {} resolves to {}; using default sound",
+ path.display(),
+ resolved.display()
+ ));
+ } else if !resolved.is_file() {
+ diagnostics.push(format!(
+ "invalid sound file: {field} = {} resolves to {}; using default sound",
+ path.display(),
+ resolved.display()
+ ));
+ }
+ }
+ diagnostics
+ }
+}
+
+impl AgentSoundOverrides {
+ pub fn for_agent(&self, agent: Option<Agent>) -> AgentSoundSetting {
+ match agent {
+ Some(Agent::Pi) => self.pi,
+ Some(Agent::Claude) => self.claude,
+ Some(Agent::Codex) => self.codex,
+ Some(Agent::Gemini) => self.gemini,
+ Some(Agent::Cursor) => self.cursor,
+ Some(Agent::Cline) => self.cline,
+ Some(Agent::OpenCode) => self.open_code,
+ Some(Agent::GithubCopilot) => self.github_copilot,
+ Some(Agent::Kimi) => self.kimi,
+ Some(Agent::Droid) => self.droid,
+ Some(Agent::Amp) => self.amp,
+ None => AgentSoundSetting::Default,
+ }
+ }
+}
+
+impl Default for SoundConfig {
+ fn default() -> Self {
+ Self {
+ enabled: true,
+ path: None,
+ done_path: None,
+ request_path: None,
+ agents: AgentSoundOverrides::default(),
+ }
+ }
+}
+
+impl Default for AgentSoundOverrides {
+ fn default() -> Self {
+ Self {
+ pi: AgentSoundSetting::Default,
+ claude: AgentSoundSetting::Default,
+ codex: AgentSoundSetting::Default,
+ gemini: AgentSoundSetting::Default,
+ cursor: AgentSoundSetting::Default,
+ cline: AgentSoundSetting::Default,
+ open_code: AgentSoundSetting::Default,
+ github_copilot: AgentSoundSetting::Default,
+ kimi: AgentSoundSetting::Default,
+ droid: AgentSoundSetting::Off,
+ amp: AgentSoundSetting::Default,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use std::path::PathBuf;
+
+ use super::*;
+ use crate::config::{config_path, Config};
+
+ #[test]
+ fn sound_table_config_parses() {
+ let toml = r#"
+[ui.sound]
+enabled = true
+path = "sounds/all.mp3"
+done_path = "sounds/done.mp3"
+request_path = "/tmp/request.mp3"
+
+[ui.sound.agents]
+droid = "off"
+claude = "on"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert!(config.ui.sound.enabled);
+ assert_eq!(config.ui.sound.path, Some(PathBuf::from("sounds/all.mp3")));
+ assert_eq!(
+ config.ui.sound.done_path,
+ Some(PathBuf::from("sounds/done.mp3"))
+ );
+ assert_eq!(
+ config.ui.sound.request_path,
+ Some(PathBuf::from("/tmp/request.mp3"))
+ );
+ assert_eq!(config.ui.sound.agents.droid, AgentSoundSetting::Off);
+ assert_eq!(config.ui.sound.agents.claude, AgentSoundSetting::On);
+ assert_eq!(config.ui.sound.agents.pi, AgentSoundSetting::Default);
+ }
+
+ #[test]
+ fn sound_path_resolution_prefers_specific_over_global() {
+ let config: Config = toml::from_str(
+ r#"
+[ui.sound]
+path = "sounds/all.mp3"
+done_path = "sounds/done.mp3"
+"#,
+ )
+ .unwrap();
+
+ let config_root = config_path().parent().unwrap().to_path_buf();
+ assert_eq!(
+ config.ui.sound.path_for(crate::sound::Sound::Done),
+ Some(config_root.join("sounds/done.mp3"))
+ );
+ assert_eq!(
+ config.ui.sound.path_for(crate::sound::Sound::Request),
+ Some(config_root.join("sounds/all.mp3"))
+ );
+ }
+
+ #[test]
+ fn missing_sound_file_produces_diagnostic() {
+ let config: Config = toml::from_str(
+ r#"
+[ui.sound]
+done_path = "sounds/missing.mp3"
+"#,
+ )
+ .unwrap();
+
+ let diagnostics = config.collect_diagnostics();
+ assert!(diagnostics.iter().any(
+ |diag| diag.contains("ui.sound.done_path") && diag.contains("using default sound")
+ ));
+ }
+
+ #[test]
+ fn non_mp3_sound_file_produces_diagnostic() {
+ let config: Config = toml::from_str(
+ r#"
+[ui.sound]
+path = "sounds/notification.wav"
+"#,
+ )
+ .unwrap();
+
+ let diagnostics = config.collect_diagnostics();
+ assert!(diagnostics.iter().any(|diag| {
+ diag.contains("ui.sound.path") && diag.contains("expected an mp3 file")
+ }));
+ }
+}
diff --git a/src/config/theme.rs b/src/config/theme.rs
new file mode 100644
index 00000000..9d641862
--- /dev/null
+++ b/src/config/theme.rs
@@ -0,0 +1,163 @@
+use serde::Deserialize;
+use tracing::warn;
+
+/// Theme configuration: pick a built-in or override individual tokens.
+///
+/// ```toml
+/// [theme]
+/// name = "tokyo-night" # built-in: catppuccin, tokyo-night, dracula, nord, etc.
+///
+/// [theme.custom] # override individual tokens on top of the base
+/// accent = "#f5c2e7"
+/// red = "#ff6188"
+/// ```
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+pub struct ThemeConfig {
+ /// Built-in theme name. Default: "catppuccin".
+ pub name: Option<String>,
+ /// Custom overrides — applied on top of the selected base theme.
+ pub custom: Option<CustomThemeColors>,
+}
+
+/// Per-token color overrides. All fields optional — only set what you want to change.
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+pub struct CustomThemeColors {
+ pub accent: Option<String>,
+ pub panel_bg: Option<String>,
+ pub surface0: Option<String>,
+ pub surface1: Option<String>,
+ pub surface_dim: Option<String>,
+ pub overlay0: Option<String>,
+ pub overlay1: Option<String>,
+ pub text: Option<String>,
+ pub subtext0: Option<String>,
+ pub mauve: Option<String>,
+ pub green: Option<String>,
+ pub yellow: Option<String>,
+ pub red: Option<String>,
+ pub blue: Option<String>,
+ pub teal: Option<String>,
+ pub peach: Option<String>,
+}
+
+/// Parse a color string into a ratatui Color.
+/// Supports: hex (#rrggbb, #rgb), named colors, rgb(r,g,b), and reset aliases.
+pub fn parse_color(s: &str) -> ratatui::style::Color {
+ use ratatui::style::Color;
+ let s = s.trim().to_lowercase();
+
+ match s.as_str() {
+ "reset" | "default" | "none" | "transparent" => return Color::Reset,
+ _ => {}
+ }
+
+ if let Some(hex) = s.strip_prefix('#') {
+ if hex.len() == 6 {
+ if let (Ok(r), Ok(g), Ok(b)) = (
+ u8::from_str_radix(&hex[0..2], 16),
+ u8::from_str_radix(&hex[2..4], 16),
+ u8::from_str_radix(&hex[4..6], 16),
+ ) {
+ return Color::Rgb(r, g, b);
+ }
+ } else if hex.len() == 3 {
+ let chars: Vec<u8> = hex
+ .chars()
+ .filter_map(|c| u8::from_str_radix(&c.to_string(), 16).ok())
+ .collect();
+ if chars.len() == 3 {
+ return Color::Rgb(chars[0] * 17, chars[1] * 17, chars[2] * 17);
+ }
+ }
+ }
+
+ if let Some(inner) = s.strip_prefix("rgb(").and_then(|s| s.strip_suffix(')')) {
+ let parts: Vec<&str> = inner.split(',').collect();
+ if parts.len() == 3 {
+ if let (Ok(r), Ok(g), Ok(b)) = (
+ parts[0].trim().parse::<u8>(),
+ parts[1].trim().parse::<u8>(),
+ parts[2].trim().parse::<u8>(),
+ ) {
+ return Color::Rgb(r, g, b);
+ }
+ }
+ }
+
+ match s.as_str() {
+ "black" => Color::Black,
+ "red" => Color::Red,
+ "green" => Color::Green,
+ "yellow" => Color::Yellow,
+ "blue" => Color::Blue,
+ "magenta" | "purple" => Color::Magenta,
+ "cyan" => Color::Cyan,
+ "white" => Color::White,
+ "gray" | "grey" => Color::Gray,
+ "darkgray" | "darkgrey" => Color::DarkGray,
+ "lightred" => Color::LightRed,
+ "lightgreen" => Color::LightGreen,
+ "lightyellow" => Color::LightYellow,
+ "lightblue" => Color::LightBlue,
+ "lightmagenta" => Color::LightMagenta,
+ "lightcyan" => Color::LightCyan,
+ _ => {
+ warn!(color = s, "unknown color, defaulting to cyan");
+ Color::Cyan
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::Config;
+
+ #[test]
+ fn theme_name_parses() {
+ let toml = r#"
+[theme]
+name = "dracula"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.theme.name.as_deref(), Some("dracula"));
+ }
+
+ #[test]
+ fn parse_color_accepts_reset_aliases() {
+ use ratatui::style::Color;
+
+ for value in ["reset", "default", "none", "transparent"] {
+ assert_eq!(parse_color(value), Color::Reset, "value: {value}");
+ }
+ }
+
+ #[test]
+ fn theme_custom_overrides_parse() {
+ let toml = r##"
+[theme]
+name = "nord"
+
+[theme.custom]
+panel_bg = "#1e1e2e"
+accent = "#ff79c6"
+red = "rgb(255, 85, 85)"
+"##;
+ let config: Config = toml::from_str(toml).unwrap();
+ assert_eq!(config.theme.name.as_deref(), Some("nord"));
+ let custom = config.theme.custom.as_ref().unwrap();
+ assert_eq!(custom.panel_bg.as_deref(), Some("#1e1e2e"));
+ assert_eq!(custom.accent.as_deref(), Some("#ff79c6"));
+ assert_eq!(custom.red.as_deref(), Some("rgb(255, 85, 85)"));
+ assert!(custom.green.is_none());
+ }
+
+ #[test]
+ fn theme_defaults_when_missing() {
+ let config: Config = toml::from_str("").unwrap();
+ assert!(config.theme.name.is_none());
+ assert!(config.theme.custom.is_none());
+ }
+}
diff --git a/src/detect.rs b/src/detect.rs
index df5bbae4..55a7b2a5 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -352,10 +352,6 @@ fn detect_kimi(content: &str) -> AgentState {
fn detect_droid(content: &str) -> AgentState {
let lower = content.to_lowercase();
- if is_droid_mission_control(content, &lower) {
- return AgentState::Idle;
- }
-
// Blocked: EXECUTE approval prompt with selection UI chrome
// Primary (AND): structural keyword + chrome text = certain
let has_execute = content.contains("EXECUTE");
@@ -385,20 +381,6 @@ fn detect_droid(content: &str) -> AgentState {
AgentState::Idle
}
-fn is_droid_mission_control(content: &str, lower_content: &str) -> bool {
- let has_header = lower_content.contains("mission control");
- let has_active_feature = lower_content.contains("active feature");
- let has_feature_summary = lower_content.contains("features ");
- let has_mission_metrics = lower_content.contains("time ")
- && lower_content.contains("input ")
- && lower_content.contains("output ");
-
- content.contains("⛬")
- && has_header
- && has_active_feature
- && (has_feature_summary || has_mission_metrics)
-}
-
/// Amp (Sourcegraph) detection.
///
/// Blocked approval prompts use a shared footer with options like
@@ -1133,24 +1115,6 @@ mod tests {
}
#[test]
- fn droid_mission_control_is_idle_even_with_stop_chrome() {
- let screen = concat!(
- "⛬ Mission Control ~/Projects/herdr-worktrees/persistence TIME 22h 40m · Input 12.6M · Cached 129.7M · Output 471.5K\n",
- "● RUNNING\n",
- "Active Feature fix-scrutiny-misc-followups\n",
- "Features 25/28\n",
- "(Press ESC to stop)\n",
- );
- assert_eq!(detect_droid(screen), AgentState::Idle);
- }
-
- #[test]
- fn droid_leaves_mission_control_and_returns_to_working() {
- let screen = "> how u doin\n\n⠴ Thinking... (Press ESC to stop)\n\nAuto (Off)";
- assert_eq!(detect_droid(screen), AgentState::Working);
- }
-
- #[test]
fn droid_braille_spinner_detected() {
assert!(has_braille_spinner("⠴ Thinking..."));
assert!(has_braille_spinner(" ⠧ Loading..."));
diff --git a/src/events.rs b/src/events.rs
index dd433d55..1d90ac32 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -37,7 +37,7 @@ pub enum AppEvent {
agent_label: String,
known_agent: Option<Agent>,
},
- /// A new version was downloaded and installed. Restart to use it.
+ /// A new version is available and ready to install explicitly.
UpdateReady { version: String },
/// A pane child emitted a valid OSC 52 clipboard write. The main loop
/// re-emits it through herdr's own clipboard writer.
diff --git a/src/input/encode.rs b/src/input/encode.rs
index 6599b3ba..81454ddc 100644
--- a/src/input/encode.rs
+++ b/src/input/encode.rs
@@ -442,3 +442,366 @@ fn encode_f_key(n: u8) -> Vec<u8> {
_ => vec![],
}
}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+
+ use super::*;
+ use crate::input::parse_terminal_key_sequence;
+
+ fn assert_terminal_key_eq(
+ actual: TerminalKey,
+ code: KeyCode,
+ modifiers: KeyModifiers,
+ kind: crossterm::event::KeyEventKind,
+ shifted_codepoint: Option<u32>,
+ ) {
+ assert_eq!(actual.code, code);
+ assert_eq!(actual.modifiers, modifiers);
+ assert_eq!(actual.kind, kind);
+ assert_eq!(actual.shifted_codepoint, shifted_codepoint);
+ }
+
+ #[test]
+ fn legacy_enter() {
+ let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']);
+ }
+
+ #[test]
+ fn legacy_ctrl_c() {
+ let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]);
+ }
+
+ #[test]
+ fn legacy_shift_enter_is_just_cr() {
+ let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']);
+ }
+
+ #[test]
+ fn legacy_alt_up() {
+ let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;3A");
+ }
+
+ #[test]
+ fn legacy_shift_right() {
+ let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;2C");
+ }
+
+ #[test]
+ fn legacy_ctrl_left() {
+ let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;5D");
+ }
+
+ #[test]
+ fn legacy_ctrl_shift_end() {
+ let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;6F");
+ }
+
+ #[test]
+ fn legacy_alt_delete() {
+ let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[3;3~");
+ }
+
+ #[test]
+ fn legacy_shift_f5() {
+ let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[15;2~");
+ }
+
+ #[test]
+ fn legacy_alt_char_still_esc_prefix() {
+ let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba");
+ }
+
+ #[test]
+ fn application_cursor_keys_use_ss3_sequences() {
+ assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA");
+ assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB");
+ }
+
+ #[test]
+ fn normal_cursor_keys_use_csi_sequences() {
+ assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A");
+ assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B");
+ }
+
+ #[test]
+ fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() {
+ let encoded = encode_mouse_scroll(
+ crossterm::event::MouseEventKind::ScrollDown,
+ 4,
+ 6,
+ KeyModifiers::SHIFT,
+ MouseProtocolEncoding::Sgr,
+ )
+ .expect("mouse scroll should encode");
+
+ assert_eq!(encoded, b"\x1b[<69;5;7M");
+ }
+
+ #[test]
+ fn sgr_mouse_release_keeps_button_code() {
+ let encoded = encode_mouse_button(
+ crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
+ 11,
+ 9,
+ KeyModifiers::empty(),
+ MouseProtocolEncoding::Sgr,
+ )
+ .expect("mouse release should encode");
+
+ assert_eq!(encoded, b"\x1b[<0;12;10m");
+ }
+
+ #[test]
+ fn kitty_shift_enter() {
+ let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[13;2u"
+ );
+ }
+
+ #[test]
+ fn kitty_ctrl_shift_a() {
+ let key = KeyEvent::new(
+ KeyCode::Char('a'),
+ KeyModifiers::CONTROL | KeyModifiers::SHIFT,
+ );
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[97;6u"
+ );
+ }
+
+ #[test]
+ fn kitty_shift_uppercase_letter_sends_text() {
+ let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L");
+ }
+
+ #[test]
+ fn kitty_shift_uppercase_letter_ignores_alternate_key_reporting_for_text() {
+ let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"L");
+ }
+
+ #[test]
+ fn kitty_shift_lowercase_letter_sends_uppercase_text() {
+ let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L");
+ }
+
+ #[test]
+ fn kitty_alt_shift_uppercase_letter_uses_base_codepoint() {
+ let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::ALT | KeyModifiers::SHIFT);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[108;4u"
+ );
+ }
+
+ #[test]
+ fn kitty_ctrl_shift_uppercase_letter_uses_base_codepoint() {
+ let key = KeyEvent::new(
+ KeyCode::Char('L'),
+ KeyModifiers::CONTROL | KeyModifiers::SHIFT,
+ );
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[108;6u"
+ );
+ }
+
+ #[test]
+ fn legacy_shift_uppercase_letter_stays_uppercase() {
+ let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
+ assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"L");
+ }
+
+ #[test]
+ fn kitty_alt_enter() {
+ let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[13;3u"
+ );
+ }
+
+ #[test]
+ fn kitty_plain_ctrl_c_uses_csi_u() {
+ let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[99;5u"
+ );
+ }
+
+ #[test]
+ fn kitty_plain_ctrl_c_includes_press_event_when_requested() {
+ let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 3 }),
+ b"\x1b[99;5:1u"
+ );
+ }
+
+ #[test]
+ fn kitty_unmodified_uses_legacy() {
+ let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty());
+ assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"a");
+ }
+
+ #[test]
+ fn kitty_shift_tab() {
+ let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[9;2u"
+ );
+ }
+
+ #[test]
+ fn kitty_ctrl_shift_enter() {
+ let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
+ b"\x1b[13;6u"
+ );
+ }
+
+ #[test]
+ fn kitty_repeat_event_type_is_encoded_when_requested() {
+ let key = KeyEvent::new_with_kind(
+ KeyCode::Enter,
+ KeyModifiers::SHIFT,
+ crossterm::event::KeyEventKind::Repeat,
+ );
+ assert_eq!(
+ encode_key(key, KeyboardProtocol::Kitty { flags: 3 }),
+ b"\x1b[13;2:2u"
+ );
+ }
+
+ #[test]
+ fn kitty_shift_letter_release_does_not_emit_text() {
+ let key = KeyEvent::new_with_kind(
+ KeyCode::Char('L'),
+ KeyModifiers::SHIFT,
+ crossterm::event::KeyEventKind::Release,
+ );
+ assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"");
+ }
+
+ #[test]
+ fn kitty_shifted_symbol_sends_text() {
+ let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('!' as u32);
+ assert_eq!(
+ encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }),
+ b"!"
+ );
+ }
+
+ #[test]
+ fn legacy_modified_special_roundtrip_matrix() {
+ let cases = [
+ KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
+ KeyEvent::new(KeyCode::Down, KeyModifiers::ALT),
+ KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT),
+ KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
+ KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL),
+ KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
+ KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT),
+ KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL),
+ KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT),
+ KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT),
+ ];
+
+ for key in cases {
+ let encoded = encode_key(key, KeyboardProtocol::Legacy);
+ let parsed =
+ parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap();
+ assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None);
+ }
+ }
+
+ #[test]
+ fn kitty_shifted_symbol_prefers_text_over_roundtrip_key_identity() {
+ let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
+ .with_shifted_codepoint('!' as u32);
+ let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
+ assert_eq!(encoded, b"!");
+ }
+
+ #[test]
+ fn legacy_basic_special_roundtrip_matrix() {
+ let cases = [
+ KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Up, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Left, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Right, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Home, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::End, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Insert, KeyModifiers::empty()),
+ KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()),
+ ];
+
+ for key in cases {
+ let encoded = encode_key(key, KeyboardProtocol::Legacy);
+ let parsed =
+ parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap();
+ assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None);
+ }
+ }
+
+ #[test]
+ fn kitty_shifted_symbol_pair_matrix_is_encoded_as_text() {
+ let cases = [('1', '!'), ('/', '?'), ('[', '{')];
+
+ for (base, shifted) in cases {
+ let key = TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT)
+ .with_shifted_codepoint(shifted as u32);
+ let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
+ assert_eq!(encoded, shifted.to_string().into_bytes(), "base={base}");
+ }
+ }
+
+ #[test]
+ fn chinese_char_encodes_as_utf8() {
+ let key = TerminalKey::new(KeyCode::Char('中'), KeyModifiers::empty());
+ let encoded = encode_terminal_key(key, KeyboardProtocol::Legacy);
+ assert_eq!(encoded, "中".as_bytes());
+ }
+
+ #[test]
+ fn chinese_char_with_kitty_protocol_encodes_as_utf8() {
+ let key = TerminalKey::new(KeyCode::Char('文'), KeyModifiers::empty());
+ let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
+ assert_eq!(encoded, "文".as_bytes());
+ }
+
+ #[test]
+ fn chinese_char_with_modifiers_falls_back_to_kitty_encoding() {
+ let key = TerminalKey::new(KeyCode::Char('测'), KeyModifiers::ALT);
+ let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
+ assert!(!encoded.is_empty());
+ assert_ne!(encoded, "测".as_bytes());
+ }
+}
diff --git a/src/input/mod.rs b/src/input/mod.rs
index 67e8a14d..2536dfe2 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -8,11 +8,5 @@ pub use encode::encode_key;
pub use encode::{
encode_cursor_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
};
-pub use model::{
- normalize_app_key_binding, KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode,
- TerminalKey,
-};
+pub use model::{KeyboardProtocol, MouseProtocolEncoding, MouseProtocolMode, TerminalKey};
pub use parse::parse_terminal_key_sequence;
-
-#[cfg(test)]
-mod tests;
diff --git a/src/input/model.rs b/src/input/model.rs
index 5721f82b..181ba7b3 100644
--- a/src/input/model.rs
+++ b/src/input/model.rs
@@ -1,57 +1,5 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-pub fn normalize_app_key_binding(
- code: KeyCode,
- modifiers: KeyModifiers,
- shifted_codepoint: Option<u32>,
-) -> (KeyCode, KeyModifiers) {
- let KeyCode::Char(raw_char) = code else {
- return (code, modifiers);
- };
-
- let shifted_char = shifted_codepoint
- .and_then(char::from_u32)
- .or_else(|| modifiers.contains(KeyModifiers::SHIFT).then_some(raw_char))
- .and_then(shifted_printable_char);
-
- let normalized_char = shifted_char.unwrap_or(raw_char);
- let mut normalized_modifiers = modifiers;
- if shifted_char.is_some() {
- normalized_modifiers.remove(KeyModifiers::SHIFT);
- }
-
- (KeyCode::Char(normalized_char), normalized_modifiers)
-}
-
-fn shifted_printable_char(ch: char) -> Option<char> {
- match ch {
- 'a'..='z' => Some(ch.to_ascii_uppercase()),
- 'A'..='Z' => Some(ch),
- '`' | '~' => Some('~'),
- '1' | '!' => Some('!'),
- '2' | '@' => Some('@'),
- '3' | '#' => Some('#'),
- '4' | '$' => Some('$'),
- '5' | '%' => Some('%'),
- '6' | '^' => Some('^'),
- '7' | '&' => Some('&'),
- '8' | '*' => Some('*'),
- '9' | '(' => Some('('),
- '0' | ')' => Some(')'),
- '-' | '_' => Some('_'),
- '=' | '+' => Some('+'),
- '[' | '{' => Some('{'),
- ']' | '}' => Some('}'),
- '\\' | '|' => Some('|'),
- ';' | ':' => Some(':'),
- '\'' | '"' => Some('"'),
- ',' | '<' => Some('<'),
- '.' | '>' => Some('>'),
- '/' | '?' => Some('?'),
- _ => None,
- }
-}
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TerminalKey {
pub code: KeyCode,
@@ -129,3 +77,24 @@ pub enum MouseProtocolEncoding {
Utf8,
Sgr,
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn protocol_from_zero_flags_is_legacy() {
+ assert_eq!(
+ KeyboardProtocol::from_kitty_flags(0),
+ KeyboardProtocol::Legacy
+ );
+ }
+
+ #[test]
+ fn protocol_from_nonzero_flags_is_kitty() {
+ assert_eq!(
+ KeyboardProtocol::from_kitty_flags(7),
+ KeyboardProtocol::Kitty { flags: 7 }
+ );
+ }
+}
diff --git a/src/input/parse.rs b/src/input/parse.rs
index b7b449ae..79f11e23 100644
--- a/src/input/parse.rs
+++ b/src/input/parse.rs
@@ -301,3 +301,394 @@ fn key_modifiers_from_u8(modifier: u8) -> KeyModifiers {
}
mods
}
+
+#[cfg(test)]
+mod tests {
+ use crossterm::event::{KeyCode, KeyModifiers, ModifierKeyCode};
+
+ use super::*;
+ use crate::input::{encode_terminal_key, KeyboardProtocol};
+
+ fn assert_terminal_key_eq(
+ actual: TerminalKey,
+ code: KeyCode,
+ modifiers: KeyModifiers,
+ kind: crossterm::event::KeyEventKind,
+ shifted_codepoint: Option<u32>,
+ ) {
+ assert_eq!(actual.code, code);
+ assert_eq!(actual.modifiers, modifiers);
+ assert_eq!(actual.kind, kind);
+ assert_eq!(actual.shifted_codepoint, shifted_codepoint);
+ }
+
+ fn decode_hex(hex: &str) -> Vec<u8> {
+ let hex = hex.trim();
+ assert_eq!(hex.len() % 2, 0, "hex string must have even length");
+ (0..hex.len())
+ .step_by(2)
+ .map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap())
+ .collect()
+ }
+
+ fn parse_fixture_key_code(value: &str) -> KeyCode {
+ match value {
+ "enter" => KeyCode::Enter,
+ "tab" => KeyCode::Tab,
+ "backspace" => KeyCode::Backspace,
+ "esc" => KeyCode::Esc,
+ "up" => KeyCode::Up,
+ "down" => KeyCode::Down,
+ "left" => KeyCode::Left,
+ "right" => KeyCode::Right,
+ "home" => KeyCode::Home,
+ "end" => KeyCode::End,
+ "pageup" => KeyCode::PageUp,
+ "pagedown" => KeyCode::PageDown,
+ "insert" => KeyCode::Insert,
+ "delete" => KeyCode::Delete,
+ value if value.starts_with("char:") => {
+ KeyCode::Char(value.trim_start_matches("char:").chars().next().unwrap())
+ }
+ other => panic!("unsupported fixture key code: {other}"),
+ }
+ }
+
+ fn parse_fixture_modifiers(value: &str) -> KeyModifiers {
+ if value == "-" || value.is_empty() {
+ return KeyModifiers::empty();
+ }
+
+ let mut modifiers = KeyModifiers::empty();
+ for part in value.split('+') {
+ match part {
+ "shift" => modifiers |= KeyModifiers::SHIFT,
+ "alt" => modifiers |= KeyModifiers::ALT,
+ "control" => modifiers |= KeyModifiers::CONTROL,
+ "super" => modifiers |= KeyModifiers::SUPER,
+ "hyper" => modifiers |= KeyModifiers::HYPER,
+ "meta" => modifiers |= KeyModifiers::META,
+ other => panic!("unsupported fixture modifier: {other}"),
+ }
+ }
+ modifiers
+ }
+
+ fn parse_fixture_kind(value: &str) -> crossterm::event::KeyEventKind {
+ match value {
+ "press" => crossterm::event::KeyEventKind::Press,
+ "repeat" => crossterm::event::KeyEventKind::Repeat,
+ "release" => crossterm::event::KeyEventKind::Release,
+ other => panic!("unsupported fixture kind: {other}"),
+ }
+ }
+
+ #[test]
+ fn parse_legacy_f_keys() {
+ assert_terminal_key_eq(
+ parse_terminal_key_sequence("\x1bOP").expect("f1 should parse"),
+ KeyCode::F(1),
+ KeyModifiers::empty(),
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ assert_terminal_key_eq(
+ parse_terminal_key_sequence("\x1b[15~").expect("f5 should parse"),
+ KeyCode::F(5),
+ KeyModifiers::empty(),
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ }
+
+ #[test]
+ fn parse_modified_f_keys() {
+ assert_terminal_key_eq(
+ parse_terminal_key_sequence("\x1b[1;2P").expect("shift+f1 should parse"),
+ KeyCode::F(1),
+ KeyModifiers::SHIFT,
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ assert_terminal_key_eq(
+ parse_terminal_key_sequence("\x1b[15;2~").expect("shift+f5 should parse"),
+ KeyCode::F(5),
+ KeyModifiers::SHIFT,
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ }
+
+ #[test]
+ fn parse_kitty_sequence_preserves_shifted_symbol_pair() {
+ let key = parse_terminal_key_sequence("\x1b[49:33;2:1u").unwrap();
+ assert_eq!(key.code, KeyCode::Char('1'));
+ assert_eq!(key.modifiers, KeyModifiers::SHIFT);
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
+ assert_eq!(key.shifted_codepoint, Some('!' as u32));
+ }
+
+ #[test]
+ fn parse_kitty_sequence_preserves_shifted_letter_pair_and_release() {
+ let key = parse_terminal_key_sequence("\x1b[108:76;2:3u").unwrap();
+ assert_eq!(key.code, KeyCode::Char('l'));
+ assert_eq!(key.modifiers, KeyModifiers::SHIFT);
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
+ assert_eq!(key.shifted_codepoint, Some('L' as u32));
+ }
+
+ #[test]
+ fn parse_modify_other_keys_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[27;6;108~").unwrap();
+ assert_eq!(key.code, KeyCode::Char('l'));
+ assert_eq!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
+ assert_eq!(key.shifted_codepoint, None);
+ }
+
+ #[test]
+ fn parse_legacy_uppercase_letter_as_shifted_char() {
+ let key = parse_terminal_key_sequence("L").unwrap();
+ assert_eq!(key.code, KeyCode::Char('L'));
+ assert_eq!(key.modifiers, KeyModifiers::SHIFT);
+ }
+
+ #[test]
+ fn parse_legacy_up_arrow_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[A").unwrap();
+ assert_eq!(key.code, KeyCode::Up);
+ assert_eq!(key.modifiers, KeyModifiers::empty());
+ }
+
+ #[test]
+ fn parse_kitty_modifier_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[57441;2:1u").unwrap();
+ assert_eq!(key.code, KeyCode::Modifier(ModifierKeyCode::LeftShift));
+ assert_eq!(key.modifiers, KeyModifiers::SHIFT);
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
+ }
+
+ #[test]
+ fn parse_ghostty_enhanced_up_arrow_press_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[1;1:1A").unwrap();
+ assert_eq!(key.code, KeyCode::Up);
+ assert_eq!(key.modifiers, KeyModifiers::empty());
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
+ }
+
+ #[test]
+ fn parse_ghostty_enhanced_up_arrow_release_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[1;1:3A").unwrap();
+ assert_eq!(key.code, KeyCode::Up);
+ assert_eq!(key.modifiers, KeyModifiers::empty());
+ assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
+ }
+
+ #[test]
+ fn parse_xterm_alt_up_arrow_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[1;3A").unwrap();
+ assert_eq!(key.code, KeyCode::Up);
+ assert_eq!(key.modifiers, KeyModifiers::ALT);
+ }
+
+ #[test]
+ fn parse_xterm_alt_down_arrow_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[1;3B").unwrap();
+ assert_eq!(key.code, KeyCode::Down);
+ assert_eq!(key.modifiers, KeyModifiers::ALT);
+ }
+
+ #[test]
+ fn parse_kitty_functional_up_arrow_sequence() {
+ let key = parse_terminal_key_sequence("\x1b[57419;1u").unwrap();
+ assert_eq!(key.code, KeyCode::Up);
+ assert_eq!(key.modifiers, KeyModifiers::empty());
+ }
+
+ #[test]
+ fn parse_legacy_ctrl_b_sequence() {
+ let key = parse_terminal_key_sequence("\x02").unwrap();
+ assert_eq!(key.code, KeyCode::Char('b'));
+ assert_eq!(key.modifiers, KeyModifiers::CONTROL);
+ }
+
+ #[test]
+ fn parse_legacy_ctrl_c_sequence() {
+ let key = parse_terminal_key_sequence("\x03").unwrap();
+ assert_eq!(key.code, KeyCode::Char('c'));
+ assert_eq!(key.modifiers, KeyModifiers::CONTROL);
+ }
+
+ #[test]
+ fn parse_legacy_lf_sequence_as_ctrl_j() {
+ let key = parse_terminal_key_sequence("\n").unwrap();
+ assert_eq!(key.code, KeyCode::Char('j'));
+ assert_eq!(key.modifiers, KeyModifiers::CONTROL);
+ }
+
+ #[test]
+ fn legacy_lf_roundtrips_as_lf() {
+ let key = parse_terminal_key_sequence("\n").unwrap();
+ assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\n");
+ }
+
+ #[test]
+ fn legacy_ctrl_byte_matrix_is_covered() {
+ for (byte, expected) in [
+ (b'\x01', 'a'),
+ (b'\x02', 'b'),
+ (b'\x03', 'c'),
+ (b'\x1a', 'z'),
+ ] {
+ let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap();
+ assert_terminal_key_eq(
+ key,
+ KeyCode::Char(expected),
+ KeyModifiers::CONTROL,
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ }
+
+ for (byte, expected) in [
+ (b'\x1c', '\\'),
+ (b'\x1d', ']'),
+ (b'\x1e', '^'),
+ (b'\x1f', '-'),
+ ] {
+ let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap();
+ assert_terminal_key_eq(
+ key,
+ KeyCode::Char(expected),
+ KeyModifiers::CONTROL,
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ }
+ }
+
+ #[test]
+ fn kitty_functional_key_matrix_is_covered() {
+ let cases = [
+ ("\x1b[57417;1u", KeyCode::Left),
+ ("\x1b[57418;1u", KeyCode::Right),
+ ("\x1b[57419;1u", KeyCode::Up),
+ ("\x1b[57420;1u", KeyCode::Down),
+ ("\x1b[57421;1u", KeyCode::PageUp),
+ ("\x1b[57422;1u", KeyCode::PageDown),
+ ("\x1b[57423;1u", KeyCode::Home),
+ ("\x1b[57424;1u", KeyCode::End),
+ ("\x1b[57425;1u", KeyCode::Insert),
+ ("\x1b[57426;1u", KeyCode::Delete),
+ ];
+
+ for (sequence, code) in cases {
+ let parsed = parse_terminal_key_sequence(sequence).unwrap();
+ assert_terminal_key_eq(
+ parsed,
+ code,
+ KeyModifiers::empty(),
+ crossterm::event::KeyEventKind::Press,
+ None,
+ );
+ }
+ }
+
+ fn assert_fixture_corpus_parses(corpus: &str) {
+ for line in corpus.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+
+ let mut columns: Vec<_> = line.split('\t').collect();
+ if columns.len() == 5 {
+ columns.push("");
+ }
+
+ let (family, bytes_hex, code, modifiers, kind, shifted) = match columns.len() {
+ 6 => {
+ if columns[1].chars().all(|ch| ch.is_ascii_hexdigit()) {
+ (
+ columns[0], columns[1], columns[2], columns[3], columns[4], columns[5],
+ )
+ } else {
+ (
+ columns[0], columns[2], columns[3], columns[4], columns[5], "",
+ )
+ }
+ }
+ 7 => (
+ columns[0], columns[2], columns[3], columns[4], columns[5], columns[6],
+ ),
+ _ => panic!("fixture row must have 6 or 7 columns: {line}"),
+ };
+
+ assert!(
+ bytes_hex.chars().all(|ch| ch.is_ascii_hexdigit()),
+ "non-hex fixture bytes for {family}: {bytes_hex}"
+ );
+ let bytes = decode_hex(bytes_hex);
+ let text = std::str::from_utf8(&bytes).unwrap();
+ let parsed = parse_terminal_key_sequence(text)
+ .unwrap_or_else(|| panic!("fixture failed to parse: {family}"));
+
+ assert_terminal_key_eq(
+ parsed,
+ parse_fixture_key_code(code),
+ parse_fixture_modifiers(modifiers),
+ parse_fixture_kind(kind),
+ if shifted.is_empty() {
+ None
+ } else {
+ Some(shifted.parse::<u32>().unwrap())
+ },
+ );
+ }
+ }
+
+ #[test]
+ fn keyboard_protocol_corpus_fixture_parses() {
+ let corpus = include_str!("../../tests/fixtures/keyboard_protocol_corpus.tsv");
+ assert_fixture_corpus_parses(corpus);
+ }
+
+ #[test]
+ fn macos_terminal_variants_fixture_parses() {
+ let corpus = include_str!("../../tests/fixtures/macos_terminal_variants.tsv");
+ for line in corpus.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+
+ let mut columns: Vec<_> = line.split('\t').collect();
+ if columns.len() == 6 {
+ columns.push("");
+ }
+ assert_eq!(
+ columns.len(),
+ 7,
+ "macOS fixture row must have 7 columns: {line}"
+ );
+
+ let source = format!("{}:{}", columns[0], columns[1]);
+ let transformed = [
+ source.as_str(),
+ columns[2],
+ columns[3],
+ columns[4],
+ columns[5],
+ columns[6],
+ ]
+ .join("\t");
+ assert_fixture_corpus_parses(&transformed);
+ }
+ }
+
+ #[test]
+ fn linux_terminal_variants_fixture_parses() {
+ let corpus = include_str!("../../tests/fixtures/linux_terminal_variants.tsv");
+ assert_fixture_corpus_parses(corpus);
+ }
+}
diff --git a/src/input/tests.rs b/src/input/tests.rs
deleted file mode 100644
index 6e196ad7..00000000
--- a/src/input/tests.rs
+++ /dev/null
@@ -1,752 +0,0 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode};
-
-use super::{
- encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key,
- parse_terminal_key_sequence, KeyboardProtocol, MouseProtocolEncoding, TerminalKey,
-};
-
-fn assert_terminal_key_eq(
- actual: TerminalKey,
- code: KeyCode,
- modifiers: KeyModifiers,
- kind: crossterm::event::KeyEventKind,
- shifted_codepoint: Option<u32>,
-) {
- assert_eq!(actual.code, code);
- assert_eq!(actual.modifiers, modifiers);
- assert_eq!(actual.kind, kind);
- assert_eq!(actual.shifted_codepoint, shifted_codepoint);
-}
-
-fn decode_hex(hex: &str) -> Vec<u8> {
- let hex = hex.trim();
- assert_eq!(hex.len() % 2, 0, "hex string must have even length");
- (0..hex.len())
- .step_by(2)
- .map(|idx| u8::from_str_radix(&hex[idx..idx + 2], 16).unwrap())
- .collect()
-}
-
-fn parse_fixture_key_code(value: &str) -> KeyCode {
- match value {
- "enter" => KeyCode::Enter,
- "tab" => KeyCode::Tab,
- "backspace" => KeyCode::Backspace,
- "esc" => KeyCode::Esc,
- "up" => KeyCode::Up,
- "down" => KeyCode::Down,
- "left" => KeyCode::Left,
- "right" => KeyCode::Right,
- "home" => KeyCode::Home,
- "end" => KeyCode::End,
- "pageup" => KeyCode::PageUp,
- "pagedown" => KeyCode::PageDown,
- "insert" => KeyCode::Insert,
- "delete" => KeyCode::Delete,
- value if value.starts_with("char:") => {
- KeyCode::Char(value.trim_start_matches("char:").chars().next().unwrap())
- }
- other => panic!("unsupported fixture key code: {other}"),
- }
-}
-
-fn parse_fixture_modifiers(value: &str) -> KeyModifiers {
- if value == "-" || value.is_empty() {
- return KeyModifiers::empty();
- }
-
- let mut modifiers = KeyModifiers::empty();
- for part in value.split('+') {
- match part {
- "shift" => modifiers |= KeyModifiers::SHIFT,
- "alt" => modifiers |= KeyModifiers::ALT,
- "control" => modifiers |= KeyModifiers::CONTROL,
- "super" => modifiers |= KeyModifiers::SUPER,
- "hyper" => modifiers |= KeyModifiers::HYPER,
- "meta" => modifiers |= KeyModifiers::META,
- other => panic!("unsupported fixture modifier: {other}"),
- }
- }
- modifiers
-}
-
-fn parse_fixture_kind(value: &str) -> crossterm::event::KeyEventKind {
- match value {
- "press" => crossterm::event::KeyEventKind::Press,
- "repeat" => crossterm::event::KeyEventKind::Repeat,
- "release" => crossterm::event::KeyEventKind::Release,
- other => panic!("unsupported fixture kind: {other}"),
- }
-}
-
-#[test]
-fn legacy_enter() {
- let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::empty());
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']);
-}
-
-#[test]
-fn legacy_ctrl_c() {
- let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![3]);
-}
-
-#[test]
-fn legacy_shift_enter_is_just_cr() {
- let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
- // Enter/Tab/Backspace/Esc aren't special keys with xterm modifier encoding,
- // so Shift+Enter falls through to legacy which just sends CR
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), vec![b'\r']);
-}
-
-#[test]
-fn legacy_alt_up() {
- let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT);
- // xterm modified key format: CSI 1;3A (3 = 1 + Alt)
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;3A");
-}
-
-#[test]
-fn legacy_shift_right() {
- let key = KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;2C");
-}
-
-#[test]
-fn legacy_ctrl_left() {
- let key = KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;5D");
-}
-
-#[test]
-fn legacy_ctrl_shift_end() {
- let key = KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[1;6F");
-}
-
-#[test]
-fn legacy_alt_delete() {
- let key = KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[3;3~");
-}
-
-#[test]
-fn legacy_shift_f5() {
- let key = KeyEvent::new(KeyCode::F(5), KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1b[15;2~");
-}
-
-#[test]
-fn parse_legacy_f_keys() {
- assert_terminal_key_eq(
- parse_terminal_key_sequence("\x1bOP").expect("f1 should parse"),
- KeyCode::F(1),
- KeyModifiers::empty(),
- crossterm::event::KeyEventKind::Press,
- None,
- );
- assert_terminal_key_eq(
- parse_terminal_key_sequence("\x1b[15~").expect("f5 should parse"),
- KeyCode::F(5),
- KeyModifiers::empty(),
- crossterm::event::KeyEventKind::Press,
- None,
- );
-}
-
-#[test]
-fn parse_modified_f_keys() {
- assert_terminal_key_eq(
- parse_terminal_key_sequence("\x1b[1;2P").expect("shift+f1 should parse"),
- KeyCode::F(1),
- KeyModifiers::SHIFT,
- crossterm::event::KeyEventKind::Press,
- None,
- );
- assert_terminal_key_eq(
- parse_terminal_key_sequence("\x1b[15;2~").expect("shift+f5 should parse"),
- KeyCode::F(5),
- KeyModifiers::SHIFT,
- crossterm::event::KeyEventKind::Press,
- None,
- );
-}
-
-#[test]
-fn legacy_alt_char_still_esc_prefix() {
- // Alt+a on character keys still uses ESC prefix (not xterm modified)
- let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba");
-}
-
-#[test]
-fn application_cursor_keys_use_ss3_sequences() {
- assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA");
- assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB");
-}
-
-#[test]
-fn normal_cursor_keys_use_csi_sequences() {
- assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A");
- assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B");
-}
-
-#[test]
-fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() {
- let encoded = encode_mouse_scroll(
- crossterm::event::MouseEventKind::ScrollDown,
- 4,
- 6,
- KeyModifiers::SHIFT,
- MouseProtocolEncoding::Sgr,
- )
- .expect("mouse scroll should encode");
-
- assert_eq!(encoded, b"\x1b[<69;5;7M");
-}
-
-#[test]
-fn sgr_mouse_release_keeps_button_code() {
- let encoded = encode_mouse_button(
- crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
- 11,
- 9,
- KeyModifiers::empty(),
- MouseProtocolEncoding::Sgr,
- )
- .expect("mouse release should encode");
-
- assert_eq!(encoded, b"\x1b[<0;12;10m");
-}
-
-#[test]
-fn kitty_shift_enter() {
- let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[13;2u"
- );
-}
-
-#[test]
-fn kitty_ctrl_shift_a() {
- let key = KeyEvent::new(
- KeyCode::Char('a'),
- KeyModifiers::CONTROL | KeyModifiers::SHIFT,
- );
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[97;6u"
- );
-}
-
-#[test]
-fn kitty_shift_uppercase_letter_sends_text() {
- let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L");
-}
-
-#[test]
-fn kitty_shift_uppercase_letter_ignores_alternate_key_reporting_for_text() {
- let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"L");
-}
-
-#[test]
-fn kitty_shift_lowercase_letter_sends_uppercase_text() {
- let key = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"L");
-}
-
-#[test]
-fn kitty_alt_shift_uppercase_letter_uses_base_codepoint() {
- let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::ALT | KeyModifiers::SHIFT);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[108;4u"
- );
-}
-
-#[test]
-fn kitty_ctrl_shift_uppercase_letter_uses_base_codepoint() {
- let key = KeyEvent::new(
- KeyCode::Char('L'),
- KeyModifiers::CONTROL | KeyModifiers::SHIFT,
- );
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[108;6u"
- );
-}
-
-#[test]
-fn legacy_shift_uppercase_letter_stays_uppercase() {
- let key = KeyEvent::new(KeyCode::Char('L'), KeyModifiers::SHIFT);
- assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"L");
-}
-
-#[test]
-fn kitty_alt_enter() {
- let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[13;3u"
- );
-}
-
-#[test]
-fn kitty_plain_ctrl_c_uses_csi_u() {
- let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[99;5u"
- );
-}
-
-#[test]
-fn kitty_plain_ctrl_c_includes_press_event_when_requested() {
- let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 3 }),
- b"\x1b[99;5:1u"
- );
-}
-
-#[test]
-fn kitty_unmodified_uses_legacy() {
- let key = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty());
- assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 1 }), b"a");
-}
-
-#[test]
-fn kitty_shift_tab() {
- let key = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[9;2u"
- );
-}
-
-#[test]
-fn kitty_ctrl_shift_enter() {
- let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 1 }),
- b"\x1b[13;6u"
- );
-}
-
-#[test]
-fn kitty_repeat_event_type_is_encoded_when_requested() {
- let key = KeyEvent::new_with_kind(
- KeyCode::Enter,
- KeyModifiers::SHIFT,
- crossterm::event::KeyEventKind::Repeat,
- );
- assert_eq!(
- encode_key(key, KeyboardProtocol::Kitty { flags: 3 }),
- b"\x1b[13;2:2u"
- );
-}
-
-#[test]
-fn kitty_shift_letter_release_does_not_emit_text() {
- let key = KeyEvent::new_with_kind(
- KeyCode::Char('L'),
- KeyModifiers::SHIFT,
- crossterm::event::KeyEventKind::Release,
- );
- assert_eq!(encode_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"");
-}
-
-#[test]
-fn kitty_shifted_symbol_sends_text() {
- let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('!' as u32);
- assert_eq!(
- encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }),
- b"!"
- );
-}
-
-#[test]
-fn parse_kitty_sequence_preserves_shifted_symbol_pair() {
- let key = parse_terminal_key_sequence("\x1b[49:33;2:1u").unwrap();
- assert_eq!(key.code, KeyCode::Char('1'));
- assert_eq!(key.modifiers, KeyModifiers::SHIFT);
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
- assert_eq!(key.shifted_codepoint, Some('!' as u32));
-}
-
-#[test]
-fn parse_kitty_sequence_preserves_shifted_letter_pair_and_release() {
- let key = parse_terminal_key_sequence("\x1b[108:76;2:3u").unwrap();
- assert_eq!(key.code, KeyCode::Char('l'));
- assert_eq!(key.modifiers, KeyModifiers::SHIFT);
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
- assert_eq!(key.shifted_codepoint, Some('L' as u32));
-}
-
-#[test]
-fn parse_modify_other_keys_sequence() {
- let key = parse_terminal_key_sequence("\x1b[27;6;108~").unwrap();
- assert_eq!(key.code, KeyCode::Char('l'));
- assert_eq!(key.modifiers, KeyModifiers::CONTROL | KeyModifiers::SHIFT);
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
- assert_eq!(key.shifted_codepoint, None);
-}
-
-#[test]
-fn parse_legacy_uppercase_letter_as_shifted_char() {
- let key = parse_terminal_key_sequence("L").unwrap();
- assert_eq!(key.code, KeyCode::Char('L'));
- assert_eq!(key.modifiers, KeyModifiers::SHIFT);
-}
-
-#[test]
-fn parse_legacy_up_arrow_sequence() {
- let key = parse_terminal_key_sequence("\x1b[A").unwrap();
- assert_eq!(key.code, KeyCode::Up);
- assert_eq!(key.modifiers, KeyModifiers::empty());
-}
-
-#[test]
-fn parse_kitty_modifier_sequence() {
- let key = parse_terminal_key_sequence("\x1b[57441;2:1u").unwrap();
- assert_eq!(key.code, KeyCode::Modifier(ModifierKeyCode::LeftShift));
- assert_eq!(key.modifiers, KeyModifiers::SHIFT);
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
-}
-
-#[test]
-fn parse_ghostty_enhanced_up_arrow_press_sequence() {
- let key = parse_terminal_key_sequence("\x1b[1;1:1A").unwrap();
- assert_eq!(key.code, KeyCode::Up);
- assert_eq!(key.modifiers, KeyModifiers::empty());
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
-}
-
-#[test]
-fn parse_ghostty_enhanced_up_arrow_release_sequence() {
- let key = parse_terminal_key_sequence("\x1b[1;1:3A").unwrap();
- assert_eq!(key.code, KeyCode::Up);
- assert_eq!(key.modifiers, KeyModifiers::empty());
- assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
-}
-
-#[test]
-fn parse_xterm_alt_up_arrow_sequence() {
- let key = parse_terminal_key_sequence("\x1b[1;3A").unwrap();
- assert_eq!(key.code, KeyCode::Up);
- assert_eq!(key.modifiers, KeyModifiers::ALT);
-}
-
-#[test]
-fn parse_xterm_alt_down_arrow_sequence() {
- let key = parse_terminal_key_sequence("\x1b[1;3B").unwrap();
- assert_eq!(key.code, KeyCode::Down);
- assert_eq!(key.modifiers, KeyModifiers::ALT);
-}
-
-#[test]
-fn parse_kitty_functional_up_arrow_sequence() {
- let key = parse_terminal_key_sequence("\x1b[57419;1u").unwrap();
- assert_eq!(key.code, KeyCode::Up);
- assert_eq!(key.modifiers, KeyModifiers::empty());
-}
-
-#[test]
-fn parse_legacy_ctrl_b_sequence() {
- let key = parse_terminal_key_sequence("\x02").unwrap();
- assert_eq!(key.code, KeyCode::Char('b'));
- assert_eq!(key.modifiers, KeyModifiers::CONTROL);
-}
-
-#[test]
-fn parse_legacy_ctrl_c_sequence() {
- let key = parse_terminal_key_sequence("\x03").unwrap();
- assert_eq!(key.code, KeyCode::Char('c'));
- assert_eq!(key.modifiers, KeyModifiers::CONTROL);
-}
-
-#[test]
-fn parse_legacy_lf_sequence_as_ctrl_j() {
- let key = parse_terminal_key_sequence("\n").unwrap();
- assert_eq!(key.code, KeyCode::Char('j'));
- assert_eq!(key.modifiers, KeyModifiers::CONTROL);
-}
-
-#[test]
-fn legacy_lf_roundtrips_as_lf() {
- let key = parse_terminal_key_sequence("\n").unwrap();
- assert_eq!(encode_terminal_key(key, KeyboardProtocol::Legacy), b"\n");
-}
-
-#[test]
-fn legacy_ctrl_byte_matrix_is_covered() {
- for (byte, expected) in [
- (b'\x01', 'a'),
- (b'\x02', 'b'),
- (b'\x03', 'c'),
- (b'\x1a', 'z'),
- ] {
- let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap();
- assert_terminal_key_eq(
- key,
- KeyCode::Char(expected),
- KeyModifiers::CONTROL,
- crossterm::event::KeyEventKind::Press,
- None,
- );
- }
-
- // Ctrl+[ is byte-identical to Escape in legacy terminals, so the parser
- // intentionally treats 0x1b as Escape and only disambiguates the other
- // legacy control-symbol bytes here.
- for (byte, expected) in [
- (b'\x1c', '\\'),
- (b'\x1d', ']'),
- (b'\x1e', '^'),
- (b'\x1f', '-'),
- ] {
- let key = parse_terminal_key_sequence(std::str::from_utf8(&[byte]).unwrap()).unwrap();
- assert_terminal_key_eq(
- key,
- KeyCode::Char(expected),
- KeyModifiers::CONTROL,
- crossterm::event::KeyEventKind::Press,
- None,
- );
- }
-}
-
-#[test]
-fn legacy_modified_special_roundtrip_matrix() {
- let cases = [
- KeyEvent::new(KeyCode::Up, KeyModifiers::ALT),
- KeyEvent::new(KeyCode::Down, KeyModifiers::ALT),
- KeyEvent::new(KeyCode::Right, KeyModifiers::SHIFT),
- KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
- KeyEvent::new(KeyCode::Home, KeyModifiers::CONTROL),
- KeyEvent::new(KeyCode::End, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
- KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT),
- KeyEvent::new(KeyCode::PageDown, KeyModifiers::CONTROL),
- KeyEvent::new(KeyCode::Insert, KeyModifiers::SHIFT),
- KeyEvent::new(KeyCode::Delete, KeyModifiers::ALT),
- ];
-
- for key in cases {
- let encoded = encode_key(key, KeyboardProtocol::Legacy);
- let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap();
- assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None);
- }
-}
-
-#[test]
-fn kitty_shifted_symbol_prefers_text_over_roundtrip_key_identity() {
- let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
- .with_shifted_codepoint('!' as u32);
- let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
- assert_eq!(encoded, b"!");
-}
-
-#[test]
-fn legacy_basic_special_roundtrip_matrix() {
- let cases = [
- KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Tab, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Up, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Down, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Left, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Right, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Home, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::End, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::PageDown, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Insert, KeyModifiers::empty()),
- KeyEvent::new(KeyCode::Delete, KeyModifiers::empty()),
- ];
-
- for key in cases {
- let encoded = encode_key(key, KeyboardProtocol::Legacy);
- let parsed = parse_terminal_key_sequence(std::str::from_utf8(&encoded).unwrap()).unwrap();
- assert_terminal_key_eq(parsed, key.code, key.modifiers, key.kind, None);
- }
-}
-
-#[test]
-fn kitty_functional_key_matrix_is_covered() {
- let cases = [
- ("\x1b[57417;1u", KeyCode::Left),
- ("\x1b[57418;1u", KeyCode::Right),
- ("\x1b[57419;1u", KeyCode::Up),
- ("\x1b[57420;1u", KeyCode::Down),
- ("\x1b[57421;1u", KeyCode::PageUp),
- ("\x1b[57422;1u", KeyCode::PageDown),
- ("\x1b[57423;1u", KeyCode::Home),
- ("\x1b[57424;1u", KeyCode::End),
- ("\x1b[57425;1u", KeyCode::Insert),
- ("\x1b[57426;1u", KeyCode::Delete),
- ];
-
- for (sequence, code) in cases {
- let parsed = parse_terminal_key_sequence(sequence).unwrap();
- assert_terminal_key_eq(
- parsed,
- code,
- KeyModifiers::empty(),
- crossterm::event::KeyEventKind::Press,
- None,
- );
- }
-}
-
-#[test]
-fn kitty_shifted_symbol_pair_matrix_is_encoded_as_text() {
- let cases = [('1', '!'), ('/', '?'), ('[', '{')];
-
- for (base, shifted) in cases {
- let key = TerminalKey::new(KeyCode::Char(base), KeyModifiers::SHIFT)
- .with_shifted_codepoint(shifted as u32);
- let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
- assert_eq!(encoded, shifted.to_string().into_bytes(), "base={base}");
- }
-}
-
-fn assert_fixture_corpus_parses(corpus: &str) {
- for line in corpus.lines() {
- let line = line.trim();
- if line.is_empty() || line.starts_with('#') {
- continue;
- }
-
- let mut columns: Vec<_> = line.split('\t').collect();
- if columns.len() == 5 {
- columns.push("");
- }
-
- let (family, bytes_hex, code, modifiers, kind, shifted) = match columns.len() {
- 6 => {
- if columns[1].chars().all(|ch| ch.is_ascii_hexdigit()) {
- (
- columns[0], columns[1], columns[2], columns[3], columns[4], columns[5],
- )
- } else {
- (
- columns[0], columns[2], columns[3], columns[4], columns[5], "",
- )
- }
- }
- 7 => (
- columns[0], columns[2], columns[3], columns[4], columns[5], columns[6],
- ),
- _ => panic!("fixture row must have 6 or 7 columns: {line}"),
- };
-
- assert!(
- bytes_hex.chars().all(|ch| ch.is_ascii_hexdigit()),
- "non-hex fixture bytes for {family}: {bytes_hex}"
- );
- let bytes = decode_hex(bytes_hex);
- let text = std::str::from_utf8(&bytes).unwrap();
- let parsed = parse_terminal_key_sequence(text)
- .unwrap_or_else(|| panic!("fixture failed to parse: {family}"));
-
- assert_terminal_key_eq(
- parsed,
- parse_fixture_key_code(code),
- parse_fixture_modifiers(modifiers),
- parse_fixture_kind(kind),
- if shifted.is_empty() {
- None
- } else {
- Some(shifted.parse::<u32>().unwrap())
- },
- );
- }
-}
-
-#[test]
-fn keyboard_protocol_corpus_fixture_parses() {
- let corpus = include_str!("../../tests/fixtures/keyboard_protocol_corpus.tsv");
- assert_fixture_corpus_parses(corpus);
-}
-
-#[test]
-fn macos_terminal_variants_fixture_parses() {
- let corpus = include_str!("../../tests/fixtures/macos_terminal_variants.tsv");
- for line in corpus.lines() {
- let line = line.trim();
- if line.is_empty() || line.starts_with('#') {
- continue;
- }
-
- let mut columns: Vec<_> = line.split('\t').collect();
- if columns.len() == 6 {
- columns.push("");
- }
- assert_eq!(
- columns.len(),
- 7,
- "macOS fixture row must have 7 columns: {line}"
- );
-
- let source = format!("{}:{}", columns[0], columns[1]);
- let transformed = [
- source.as_str(),
- columns[2],
- columns[3],
- columns[4],
- columns[5],
- columns[6],
- ]
- .join("\t");
- assert_fixture_corpus_parses(&transformed);
- }
-}
-
-#[test]
-fn linux_terminal_variants_fixture_parses() {
- let corpus = include_str!("../../tests/fixtures/linux_terminal_variants.tsv");
- assert_fixture_corpus_parses(corpus);
-}
-
-#[test]
-fn protocol_from_zero_flags_is_legacy() {
- assert_eq!(
- KeyboardProtocol::from_kitty_flags(0),
- KeyboardProtocol::Legacy
- );
-}
-
-#[test]
-fn protocol_from_nonzero_flags_is_kitty() {
- assert_eq!(
- KeyboardProtocol::from_kitty_flags(7),
- KeyboardProtocol::Kitty { flags: 7 }
- );
-}
-
-#[test]
-fn chinese_char_encodes_as_utf8() {
- let key = TerminalKey::new(KeyCode::Char('中'), KeyModifiers::empty());
- let encoded = encode_terminal_key(key, KeyboardProtocol::Legacy);
- assert_eq!(encoded, "中".as_bytes());
-}
-
-#[test]
-fn chinese_char_with_kitty_protocol_encodes_as_utf8() {
- let key = TerminalKey::new(KeyCode::Char('文'), KeyModifiers::empty());
- let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
- assert_eq!(encoded, "文".as_bytes());
-}
-
-#[test]
-fn chinese_char_with_modifiers_falls_back_to_kitty_encoding() {
- let key = TerminalKey::new(KeyCode::Char('测'), KeyModifiers::ALT);
- let encoded = encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 });
- assert!(!encoded.is_empty());
- assert_ne!(encoded, "测".as_bytes());
-}
diff --git a/src/ipc.rs b/src/ipc.rs
new file mode 100644
index 00000000..aa0148a1
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,46 @@
+use std::fs;
+use std::io;
+use std::os::unix::fs::PermissionsExt;
+use std::os::unix::net::UnixStream;
+use std::path::Path;
+
+pub(crate) fn prepare_socket_path(
+ path: &Path,
+ busy_message: impl FnOnce(&Path) -> String,
+) -> io::Result<()> {
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent)?;
+ }
+
+ if !path.exists() {
+ return Ok(());
+ }
+
+ match UnixStream::connect(path) {
+ Ok(_) => {
+ return Err(io::Error::new(io::ErrorKind::AddrInUse, busy_message(path)));
+ }
+ Err(err)
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused
+ | io::ErrorKind::NotFound
+ | io::ErrorKind::TimedOut
+ ) => {}
+ Err(err) => return Err(err),
+ }
+
+ if let Err(err) = fs::remove_file(path) {
+ if err.kind() != io::ErrorKind::NotFound {
+ return Err(err);
+ }
+ }
+
+ Ok(())
+}
+
+pub(crate) fn restrict_socket_permissions(path: &Path, mode: u32) -> io::Result<()> {
+ let mut permissions = fs::metadata(path)?.permissions();
+ permissions.set_mode(mode);
+ fs::set_permissions(path, permissions)
+}
diff --git a/src/logging.rs b/src/logging.rs
new file mode 100644
index 00000000..899a9588
--- /dev/null
+++ b/src/logging.rs
@@ -0,0 +1,29 @@
+pub(crate) fn init_file_logging(file_name: &str) {
+ use std::fs::{self, OpenOptions};
+ use tracing_subscriber::EnvFilter;
+
+ let log_dir = crate::config::config_dir();
+ let _ = fs::create_dir_all(&log_dir);
+ let log_path = log_dir.join(file_name);
+
+ if let Ok(meta) = fs::metadata(&log_path) {
+ if meta.len() > 5 * 1024 * 1024 {
+ let _ = fs::remove_file(&log_path);
+ }
+ }
+
+ let file = match OpenOptions::new().create(true).append(true).open(&log_path) {
+ Ok(file) => file,
+ Err(_) => return,
+ };
+
+ let filter =
+ EnvFilter::try_from_env("HERDR_LOG").unwrap_or_else(|_| EnvFilter::new("herdr=info"));
+
+ let _ = tracing_subscriber::fmt()
+ .with_env_filter(filter)
+ .with_writer(file)
+ .with_ansi(false)
+ .with_target(false)
+ .try_init();
+}
diff --git a/src/main.rs b/src/main.rs
index c990f793..2637668e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -21,19 +21,23 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
mod api;
mod app;
mod cli;
+mod client;
mod config;
mod detect;
mod events;
mod ghostty;
mod input;
mod integration;
+mod ipc;
mod layout;
+mod logging;
mod pane;
mod persist;
mod platform;
mod raw_input;
mod release_notes;
mod selection;
+mod server;
mod sound;
mod terminal_theme;
mod ui;
@@ -41,34 +45,7 @@ mod update;
mod workspace;
fn init_logging() {
- use std::fs::{self, OpenOptions};
- use tracing_subscriber::EnvFilter;
-
- let log_dir = crate::config::config_dir();
- let _ = fs::create_dir_all(&log_dir);
- let log_path = log_dir.join("herdr.log");
-
- // Rotate: truncate if over 5MB
- if let Ok(meta) = fs::metadata(&log_path) {
- if meta.len() > 5 * 1024 * 1024 {
- let _ = fs::remove_file(&log_path);
- }
- }
-
- let file = match OpenOptions::new().create(true).append(true).open(&log_path) {
- Ok(f) => f,
- Err(_) => return, // can't open log file, proceed without logging
- };
-
- let filter =
- EnvFilter::try_from_env("HERDR_LOG").unwrap_or_else(|_| EnvFilter::new("herdr=info"));
-
- tracing_subscriber::fmt()
- .with_env_filter(filter)
- .with_writer(file)
- .with_ansi(false)
- .with_target(false)
- .init();
+ crate::logging::init_file_logging("herdr.log");
}
const DEFAULT_CONFIG: &str = r##"# herdr configuration
@@ -102,9 +79,10 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Navigate-mode actions
# new_workspace = "n"
# rename_workspace = "shift+n"
-# close_workspace = "d"
+# close_workspace = "shift+d"
# previous_workspace = "" # optional, unset by default
# next_workspace = "" # optional, unset by default
+# detach = "" # optional explicit detach shortcut in server/client mode
# new_tab = "c"
# rename_tab = "" # optional, unset by default
# previous_tab = "" # optional, unset by default
@@ -184,6 +162,15 @@ fn main() -> io::Result<()> {
}
// Subcommands and flags (no TUI, no logging needed)
+ if args.get(1).map(|s| s.as_str()) == Some("server") {
+ return server::headless::run_server();
+ }
+
+ // Client mode: connect to an existing server's client socket.
+ if args.get(1).map(|s| s.as_str()) == Some("client") {
+ return client::run_client();
+ }
+
if args.get(1).map(|s| s.as_str()) == Some("update") {
match update::self_update() {
Ok(_) => return Ok(()),
@@ -199,6 +186,7 @@ fn main() -> io::Result<()> {
println!();
println!("Usage: herdr [options]");
println!(" herdr update");
+ println!(" herdr server stop");
println!(" herdr workspace <subcommand> ...");
println!(" herdr tab <subcommand> ...");
println!(" herdr pane <subcommand> ...");
@@ -206,7 +194,12 @@ fn main() -> io::Result<()> {
println!(" herdr integration <subcommand> ...");
println!();
println!("Commands:");
- println!(" update Download and install the latest version");
+ println!(" server Run as headless server (no terminal, persists after client disconnect)");
+ println!(" server stop Stop the running server via the API socket");
+ println!(" client Connect to a running server as a thin client");
+ println!(
+ " update Download and install the latest version (run outside herdr)"
+ );
println!(" workspace workspace helpers over the socket api");
println!(" tab tab helpers over the socket api");
println!(" pane pane control helpers over the socket api");
@@ -214,7 +207,7 @@ fn main() -> io::Result<()> {
println!(" integration manage built-in agent integrations");
println!();
println!("Options:");
- println!(" --no-session Don't restore or save sessions");
+ println!(" --no-session Run monolithically (no server/client, escape hatch)");
println!(" --default-config Print default configuration and exit");
println!(" --version, -V Print version and exit");
println!(" --show-changelog Preview the current version's release notes");
@@ -257,7 +250,16 @@ fn main() -> io::Result<()> {
std::process::exit(1);
}
if !arg.starts_with('-')
- && !["update", "workspace", "pane", "wait", "integration"].contains(&arg.as_str())
+ && ![
+ "server",
+ "client",
+ "update",
+ "workspace",
+ "pane",
+ "wait",
+ "integration",
+ ]
+ .contains(&arg.as_str())
{
eprintln!("unknown command: {arg}");
eprintln!("run 'herdr --help' for usage");
@@ -274,6 +276,18 @@ fn main() -> io::Result<()> {
std::process::exit(1);
}
+ let no_session = args.iter().any(|a| a == "--no-session");
+
+ // Auto-detect launch: when --no-session is NOT set, use server/client mode.
+ // Check if a server is running, spawn one if needed, then attach as client.
+ if !no_session {
+ init_logging();
+ return server::autodetect::auto_detect_launch();
+ }
+
+ // --- Monolithic mode (--no-session escape hatch) ---
+ // This is the pre-mission single-process behavior.
+
init_logging();
let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
@@ -288,8 +302,7 @@ fn main() -> io::Result<()> {
Err(err) => return Err(err),
};
- let no_session = std::env::args().any(|a| a == "--no-session");
- let show_changelog = std::env::args().any(|a| a == "--show-changelog");
+ let show_changelog = args.iter().any(|a| a == "--show-changelog");
let in_tmux = std::env::var("TMUX").is_ok();
let original_hook = std::panic::take_hook();
@@ -321,7 +334,7 @@ fn main() -> io::Result<()> {
loaded_config.diagnostics.len() - 1
))
};
- info!("herdr starting, pid={}", std::process::id());
+ info!("herdr starting (monolithic), pid={}", std::process::id());
// Background update check (non-blocking, best-effort)
// Only checks for newer versions and notifies the TUI.
@@ -362,7 +375,7 @@ fn main() -> io::Result<()> {
let mut app = app::App::new(
config,
- no_session,
+ true, // no_session — monolithic mode never saves/restores sessions
config_diagnostic,
startup_release_notes,
api_rx,
diff --git a/src/pane.rs b/src/pane.rs
index fb34b6ab..eb37d0c1 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1,4 +1,3 @@
-use std::borrow::Cow;
use std::cell::Cell;
use std::io::{BufWriter, Read, Write};
use std::sync::{
@@ -8,19 +7,29 @@ use std::sync::{
use bytes::Bytes;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
-use ratatui::style::{Color, Modifier, Style};
use ratatui::{layout::Rect, Frame};
use tokio::sync::{mpsc, Notify};
use tracing::{debug, error, info, warn};
-use unicode_width::UnicodeWidthStr;
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::PaneId;
-const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
+mod input;
+mod osc;
+mod state;
+mod terminal;
+
+use self::{
+ state::stabilize_agent_state,
+ terminal::{GhosttyPaneTerminal, PaneTerminal},
+};
+pub use self::{
+ state::{EffectiveStateChange, PaneState},
+ terminal::{InputState, ScrollMetrics},
+};
+
const RELEASE_REACQUIRE_SUPPRESSION: std::time::Duration = std::time::Duration::from_secs(1);
-const DEFAULT_DETECTION_ROWS: usize = 24;
#[derive(Debug, Clone, Copy)]
struct PendingAgentRelease {
@@ -68,196 +77,50 @@ async fn publish_state_changed_event(
}
}
-fn stabilize_agent_state(
- agent: Option<Agent>,
- previous: AgentState,
- raw: AgentState,
- now: std::time::Instant,
- last_claude_working_at: &mut Option<std::time::Instant>,
-) -> AgentState {
- if agent != Some(Agent::Claude) {
- return raw;
- }
-
- match raw {
- AgentState::Working => {
- *last_claude_working_at = Some(now);
- AgentState::Working
- }
- AgentState::Blocked => AgentState::Blocked,
- AgentState::Idle if previous == AgentState::Working => {
- if last_claude_working_at
- .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD)
- {
- AgentState::Working
- } else {
- AgentState::Idle
- }
- }
- _ => raw,
- }
-}
+const AGENT_MISS_CONFIRMATION_ATTEMPTS: u8 = 6;
-// ---------------------------------------------------------------------------
-// PaneState — pure data, constructable without PTYs, testable
-// ---------------------------------------------------------------------------
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct HookAuthority {
- pub source: String,
- pub agent_label: String,
- pub state: AgentState,
- pub message: Option<String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct EffectiveStateChange {
- pub previous_agent_label: Option<String>,
- pub previous_known_agent: Option<Agent>,
- pub previous_state: AgentState,
- pub agent_label: Option<String>,
- pub known_agent: Option<Agent>,
- pub state: AgentState,
-}
-
-/// Observable state for a single pane.
-/// This is the only part of a pane that workspace logic and tests need.
-pub struct PaneState {
- pub detected_agent: Option<Agent>,
- pub fallback_state: AgentState,
- pub hook_authority: Option<HookAuthority>,
- pub state: AgentState,
- /// Whether the user has seen this pane since its last state change to Idle.
- /// False = "Done" (agent finished while user was in another workspace).
- pub seen: bool,
+#[derive(Debug, Clone, Copy)]
+struct AgentDetectionPresence {
+ current_agent: Option<Agent>,
+ consecutive_misses: u8,
}
-impl PaneState {
- pub fn new() -> Self {
+impl AgentDetectionPresence {
+ fn from_agent(current_agent: Option<Agent>) -> Self {
Self {
- detected_agent: None,
- fallback_state: AgentState::Unknown,
- hook_authority: None,
- state: AgentState::Unknown,
- seen: true,
+ current_agent,
+ consecutive_misses: 0,
}
}
- pub fn set_detected_state(
- &mut self,
- agent: Option<Agent>,
- fallback_state: AgentState,
- ) -> Option<EffectiveStateChange> {
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- self.detected_agent = agent;
- self.fallback_state = fallback_state;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- pub fn set_hook_authority(
- &mut self,
- source: String,
- agent_label: String,
- state: AgentState,
- message: Option<String>,
- ) -> Option<EffectiveStateChange> {
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- self.hook_authority = Some(HookAuthority {
- source,
- agent_label,
- state,
- message,
- });
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ fn current_agent(&self) -> Option<Agent> {
+ self.current_agent
}
- pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> {
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- let should_clear = self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
- if !should_clear {
- return None;
- }
- self.hook_authority = None;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- pub fn release_agent(
- &mut self,
- source: &str,
- agent_label: &str,
- ) -> Option<EffectiveStateChange> {
- let Some(current_agent_label) = self.effective_agent_label() else {
- return None;
- };
- if current_agent_label != agent_label {
- return None;
- }
-
- if self.hook_authority.as_ref().is_some_and(|authority| {
- authority.agent_label != agent_label || authority.source != source
- }) {
- return None;
- }
-
- let previous_agent_label = self.effective_agent_label().map(str::to_string);
- let previous_known_agent = self.effective_known_agent();
- let previous_state = self.state;
- self.detected_agent = None;
- self.fallback_state = AgentState::Unknown;
- self.hook_authority = None;
- self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
- }
-
- pub fn effective_agent_label(&self) -> Option<&str> {
- self.hook_authority
- .as_ref()
- .map(|authority| authority.agent_label.as_str())
- .or_else(|| self.detected_agent.map(crate::detect::agent_label))
- }
-
- pub fn effective_known_agent(&self) -> Option<Agent> {
- if let Some(authority) = &self.hook_authority {
- return crate::detect::parse_agent_label(&authority.agent_label);
+ fn observe_process_probe(&mut self, identified_agent: Option<Agent>) -> bool {
+ match identified_agent {
+ Some(agent) => {
+ self.consecutive_misses = 0;
+ if Some(agent) == self.current_agent {
+ return false;
+ }
+ self.current_agent = Some(agent);
+ true
+ }
+ None => {
+ if self.current_agent.is_none() {
+ self.consecutive_misses = 0;
+ return false;
+ }
+ self.consecutive_misses = self.consecutive_misses.saturating_add(1);
+ if self.consecutive_misses < AGENT_MISS_CONFIRMATION_ATTEMPTS {
+ return false;
+ }
+ self.current_agent = None;
+ self.consecutive_misses = 0;
+ true
+ }
}
- self.detected_agent
- }
-
- fn recompute_effective_state(
- &mut self,
- previous_agent_label: Option<String>,
- previous_known_agent: Option<Agent>,
- previous_state: AgentState,
- ) -> Option<EffectiveStateChange> {
- let state = self
- .hook_authority
- .as_ref()
- .map(|authority| authority.state)
- .unwrap_or(self.fallback_state);
- let agent_label = self.effective_agent_label().map(str::to_string);
- let known_agent = self.effective_known_agent();
-
- if previous_agent_label == agent_label && previous_state == state {
- return None;
- }
-
- self.state = state;
- Some(EffectiveStateChange {
- previous_agent_label,
- previous_known_agent,
- previous_state,
- agent_label,
- known_agent,
- state,
- })
}
}
@@ -281,1461 +144,6 @@ pub struct PaneRuntime {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct ScrollMetrics {
- pub offset_from_bottom: usize,
- pub max_offset_from_bottom: usize,
- pub viewport_rows: usize,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct InputState {
- pub alternate_screen: bool,
- pub application_cursor: bool,
- pub bracketed_paste: bool,
- pub focus_reporting: bool,
- pub mouse_protocol_mode: crate::input::MouseProtocolMode,
- pub mouse_protocol_encoding: crate::input::MouseProtocolEncoding,
- pub mouse_alternate_scroll: bool,
-}
-
-impl InputState {
- pub fn mouse_reporting_enabled(self) -> bool {
- self.mouse_protocol_mode.reporting_enabled()
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-struct ProcessBytesResult {
- request_render: bool,
- clipboard_writes: Vec<Vec<u8>>,
-}
-
-struct GhosttyPaneTerminal {
- core: Mutex<GhosttyPaneCore>,
- key_encoder: Mutex<crate::ghostty::KeyEncoder>,
-}
-
-struct GhosttyPaneCore {
- terminal: crate::ghostty::Terminal,
- render_state: crate::ghostty::RenderState,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- transient_default_color_owner_pgid: Option<u32>,
- default_color_tracker: DefaultColorOscTracker,
- osc52_forwarder: Osc52Forwarder,
-}
-
-#[derive(Debug, Default)]
-struct DefaultColorOscTracker {
- state: DefaultColorOscTrackerState,
- body: Vec<u8>,
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
-enum DefaultColorOscTrackerState {
- #[default]
- Ground,
- Escape,
- OscBody,
- OscEscape,
-}
-
-impl DefaultColorOscTracker {
- fn observe(&mut self, bytes: &[u8]) -> bool {
- let mut saw_default_color_set = false;
-
- for &byte in bytes {
- match self.state {
- DefaultColorOscTrackerState::Ground => {
- if byte == 0x1b {
- self.state = DefaultColorOscTrackerState::Escape;
- }
- }
- DefaultColorOscTrackerState::Escape => {
- if byte == b']' {
- self.body.clear();
- self.state = DefaultColorOscTrackerState::OscBody;
- } else if byte == 0x1b {
- self.state = DefaultColorOscTrackerState::Escape;
- } else {
- self.state = DefaultColorOscTrackerState::Ground;
- }
- }
- DefaultColorOscTrackerState::OscBody => match byte {
- 0x07 => {
- saw_default_color_set |= is_default_color_set_osc(&self.body);
- self.body.clear();
- self.state = DefaultColorOscTrackerState::Ground;
- }
- 0x1b => self.state = DefaultColorOscTrackerState::OscEscape,
- _ => self.body.push(byte),
- },
- DefaultColorOscTrackerState::OscEscape => {
- if byte == b'\\' {
- saw_default_color_set |= is_default_color_set_osc(&self.body);
- self.body.clear();
- self.state = DefaultColorOscTrackerState::Ground;
- } else {
- self.body.push(0x1b);
- self.body.push(byte);
- self.state = DefaultColorOscTrackerState::OscBody;
- }
- }
- }
-
- if self.body.len() > 1024 {
- self.body.clear();
- self.state = DefaultColorOscTrackerState::Ground;
- }
- }
-
- saw_default_color_set
- }
-}
-
-fn is_default_color_set_osc(body: &[u8]) -> bool {
- let Some(separator) = body.iter().position(|byte| *byte == b';') else {
- return false;
- };
- let command = &body[..separator];
- let value = &body[separator + 1..];
- matches!(command, b"10" | b"11") && !value.is_empty() && value != b"?"
-}
-
-/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies
-/// while still bounding memory against stream garbage.
-const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024;
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
-enum Osc52ForwarderState {
- #[default]
- Ground,
- Escape,
- OscBody,
- OscEscape,
-}
-
-/// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the
-/// main loop can re-emit them. `libghostty-vt` drops `.clipboard_contents`,
-/// so child clipboard writes never reach the host terminal unless we forward
-/// them ourselves.
-#[derive(Debug, Default)]
-struct Osc52Forwarder {
- state: Osc52ForwarderState,
- body: Vec<u8>,
- pending: Vec<Vec<u8>>,
-}
-
-impl Osc52Forwarder {
- fn observe(&mut self, bytes: &[u8]) {
- for &byte in bytes {
- match self.state {
- Osc52ForwarderState::Ground => {
- if byte == 0x1b {
- self.state = Osc52ForwarderState::Escape;
- }
- }
- Osc52ForwarderState::Escape => {
- if byte == b']' {
- self.body.clear();
- self.state = Osc52ForwarderState::OscBody;
- } else if byte == 0x1b {
- self.state = Osc52ForwarderState::Escape;
- } else {
- self.state = Osc52ForwarderState::Ground;
- }
- }
- Osc52ForwarderState::OscBody => match byte {
- 0x07 => {
- self.finalize();
- self.state = Osc52ForwarderState::Ground;
- }
- 0x1b => self.state = Osc52ForwarderState::OscEscape,
- _ => self.body.push(byte),
- },
- Osc52ForwarderState::OscEscape => {
- if byte == b'\\' {
- self.finalize();
- self.state = Osc52ForwarderState::Ground;
- } else {
- self.body.push(0x1b);
- self.body.push(byte);
- self.state = Osc52ForwarderState::OscBody;
- }
- }
- }
-
- if self.body.len() > OSC52_MAX_PAYLOAD_BYTES {
- self.body.clear();
- self.state = Osc52ForwarderState::Ground;
- }
- }
- }
-
- fn finalize(&mut self) {
- if let Some(content) = parse_osc52_clipboard_write(&self.body) {
- self.pending.push(content);
- }
- self.body.clear();
- }
-
- fn drain_pending(&mut self) -> Vec<Vec<u8>> {
- std::mem::take(&mut self.pending)
- }
-}
-
-/// Accepts `52;c;<base64>` and `52;;<base64>`.
-/// Queries (`?`) are rejected because herdr has no reply path.
-/// The payload must decode as base64 before it is forwarded.
-fn parse_osc52_clipboard_write(body: &[u8]) -> Option<Vec<u8>> {
- use base64::Engine;
-
- let rest = body.strip_prefix(b"52;")?;
- let sep = rest.iter().position(|b| *b == b';')?;
- let selector = &rest[..sep];
- let data = &rest[sep + 1..];
- if !(selector.is_empty() || selector == b"c") || data == b"?" {
- return None;
- }
- base64::engine::general_purpose::STANDARD.decode(data).ok()
-}
-
-fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool {
- job.processes.iter().any(|process| process.pid == shell_pid)
-}
-
-fn current_transient_default_color_owner(shell_pid: u32) -> Option<u32> {
- let job = crate::detect::foreground_job(shell_pid)?;
- (!foreground_job_is_shell(&job, shell_pid)).then_some(job.process_group_id)
-}
-
-fn foreground_job_uses_droid_scrollback_compat(job: &crate::platform::ForegroundJob) -> bool {
- job.processes.iter().any(|process| {
- process.name.eq_ignore_ascii_case("droid")
- || process
- .argv0
- .as_deref()
- .is_some_and(|argv0| argv0.eq_ignore_ascii_case("droid"))
- || process.cmdline.as_deref().is_some_and(|cmdline| {
- cmdline.eq_ignore_ascii_case("droid")
- || cmdline.starts_with("droid ")
- || cmdline.to_ascii_lowercase().contains("/droid")
- })
- })
-}
-
-fn contains_scrollback_clear_sequence(bytes: &[u8]) -> bool {
- bytes.windows(4).any(|window| window == b"\x1b[3J")
- || bytes.windows(5).any(|window| window == b"\x1b[?3J")
-}
-
-fn strip_scrollback_clear_sequences<'a>(bytes: &'a [u8]) -> Cow<'a, [u8]> {
- if !contains_scrollback_clear_sequence(bytes) {
- return Cow::Borrowed(bytes);
- }
-
- let mut filtered = Vec::with_capacity(bytes.len());
- let mut index = 0;
- while index < bytes.len() {
- let remaining = &bytes[index..];
- if remaining.starts_with(b"\x1b[3J") {
- index += 4;
- continue;
- }
- if remaining.starts_with(b"\x1b[?3J") {
- index += 5;
- continue;
- }
- filtered.push(bytes[index]);
- index += 1;
- }
-
- Cow::Owned(filtered)
-}
-
-fn maybe_filter_primary_screen_scrollback_clear<'a>(
- bytes: &'a [u8],
- alternate_screen: bool,
- foreground_job: Option<&crate::platform::ForegroundJob>,
-) -> Cow<'a, [u8]> {
- // Droid redraws its primary-screen TUI with CSI 3 J, which erases pane
- // scrollback inside herdr. Keep the hack scoped to Droid on the primary
- // screen so normal terminal clear-history behavior still works elsewhere.
- if alternate_screen
- || !contains_scrollback_clear_sequence(bytes)
- || !foreground_job.is_some_and(foreground_job_uses_droid_scrollback_compat)
- {
- return Cow::Borrowed(bytes);
- }
-
- strip_scrollback_clear_sequences(bytes)
-}
-
-#[cfg(target_os = "macos")]
-fn should_restore_host_terminal_theme(
- owner_pgid: u32,
- shell_pid: u32,
- alternate_screen: bool,
- foreground_job: Option<&crate::platform::ForegroundJob>,
-) -> bool {
- if alternate_screen {
- return false;
- }
-
- let Some(foreground_job) = foreground_job else {
- return false;
- };
-
- let _ = owner_pgid;
- foreground_job_is_shell(foreground_job, shell_pid)
-}
-
-#[cfg(not(target_os = "macos"))]
-fn should_restore_host_terminal_theme(
- owner_pgid: u32,
- shell_pid: u32,
- alternate_screen: bool,
- foreground_job: Option<&crate::platform::ForegroundJob>,
-) -> bool {
- if alternate_screen {
- return false;
- }
-
- let Some(foreground_job) = foreground_job else {
- return false;
- };
-
- foreground_job.process_group_id != owner_pgid
- && foreground_job_is_shell(foreground_job, shell_pid)
-}
-
-fn write_host_terminal_theme(
- terminal: &mut crate::ghostty::Terminal,
- theme: crate::terminal_theme::TerminalTheme,
-) {
- if let Some(color) = theme.foreground {
- let sequence = crate::terminal_theme::osc_set_default_color_sequence(
- crate::terminal_theme::DefaultColorKind::Foreground,
- color,
- );
- terminal.write(sequence.as_bytes());
- }
- if let Some(color) = theme.background {
- let sequence = crate::terminal_theme::osc_set_default_color_sequence(
- crate::terminal_theme::DefaultColorKind::Background,
- color,
- );
- terminal.write(sequence.as_bytes());
- }
-}
-
-fn restore_host_terminal_theme_if_needed(
- core: &mut GhosttyPaneCore,
- pane_id: PaneId,
- shell_pid: u32,
- alternate_screen: bool,
- foreground_job: Option<&crate::platform::ForegroundJob>,
-) -> bool {
- let Some(owner_pgid) = core.transient_default_color_owner_pgid else {
- return false;
- };
- if core.host_terminal_theme.is_empty() {
- return false;
- }
- if !should_restore_host_terminal_theme(owner_pgid, shell_pid, alternate_screen, foreground_job)
- {
- return false;
- }
-
- core.transient_default_color_owner_pgid = None;
- write_host_terminal_theme(&mut core.terminal, core.host_terminal_theme);
- info!(
- pane = pane_id.raw(),
- owner_pgid, "restored host terminal default colors after transient override"
- );
- true
-}
-
-struct PaneTerminal {
- ghostty: GhosttyPaneTerminal,
-}
-
-impl PaneTerminal {
- fn process_pty_bytes(
- &self,
- pane_id: PaneId,
- shell_pid: u32,
- bytes: &[u8],
- response_writer: &mpsc::Sender<Bytes>,
- ) -> ProcessBytesResult {
- self.ghostty
- .process_pty_bytes(pane_id, shell_pid, bytes, response_writer)
- }
-
- fn resize(&self, rows: u16, cols: u16) {
- self.ghostty.resize(rows, cols);
- }
-
- fn scroll_up(&self, lines: usize) {
- self.ghostty.scroll_up(lines);
- }
-
- fn scroll_down(&self, lines: usize) {
- self.ghostty.scroll_down(lines);
- }
-
- fn scroll_reset(&self) {
- self.ghostty.scroll_reset();
- }
-
- fn set_scroll_offset_from_bottom(&self, lines: usize) {
- self.ghostty.set_scroll_offset_from_bottom(lines);
- }
-
- fn scroll_metrics(&self) -> Option<ScrollMetrics> {
- self.ghostty.scroll_metrics()
- }
-
- fn input_state(&self) -> Option<InputState> {
- self.ghostty.input_state()
- }
-
- fn visible_text(&self) -> String {
- self.ghostty.visible_text()
- }
-
- fn detection_text(&self) -> String {
- self.ghostty.detection_text()
- }
-
- fn recent_text(&self, lines: usize) -> String {
- self.ghostty.recent_text(lines)
- }
-
- fn recent_unwrapped_text(&self, lines: usize) -> String {
- self.ghostty.recent_unwrapped_text(lines)
- }
-
- fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
- self.ghostty.extract_selection(selection)
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
- self.ghostty.render(frame, area, show_cursor);
- }
-
- fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
- self.ghostty.apply_host_terminal_theme(theme);
- }
-
- fn has_transient_default_color_override(&self) -> bool {
- self.ghostty.has_transient_default_color_override()
- }
-
- fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool {
- self.ghostty
- .maybe_restore_host_terminal_theme(pane_id, shell_pid)
- }
-
- fn keyboard_protocol(
- &self,
- fallback: crate::input::KeyboardProtocol,
- ) -> crate::input::KeyboardProtocol {
- self.ghostty.keyboard_protocol().unwrap_or(fallback)
- }
-
- fn encode_terminal_key(
- &self,
- key: crate::input::TerminalKey,
- protocol: crate::input::KeyboardProtocol,
- ) -> Vec<u8> {
- self.ghostty.encode_terminal_key(key, protocol)
- }
-
- fn encode_mouse_button(
- &self,
- kind: crossterm::event::MouseEventKind,
- column: u16,
- row: u16,
- modifiers: crossterm::event::KeyModifiers,
- ) -> Option<Vec<u8>> {
- self.ghostty
- .encode_mouse_button(kind, column, row, modifiers)
- }
-
- fn encode_mouse_wheel(
- &self,
- kind: crossterm::event::MouseEventKind,
- column: u16,
- row: u16,
- modifiers: crossterm::event::KeyModifiers,
- ) -> Option<Vec<u8>> {
- self.ghostty
- .encode_mouse_wheel(kind, column, row, modifiers)
- }
-}
-
-impl GhosttyPaneTerminal {
- fn new(
- mut terminal: crate::ghostty::Terminal,
- response_writer: mpsc::Sender<Bytes>,
- ) -> std::io::Result<Self> {
- terminal
- .set_write_pty_callback(move |bytes| {
- let _ = response_writer.try_send(Bytes::copy_from_slice(bytes));
- })
- .map_err(|e| std::io::Error::other(e.to_string()))?;
-
- let render_state =
- crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?;
- let mut key_encoder =
- crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?;
- key_encoder.set_from_terminal(&terminal);
- Ok(Self {
- core: Mutex::new(GhosttyPaneCore {
- terminal,
- render_state,
- host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
- transient_default_color_owner_pgid: None,
- default_color_tracker: DefaultColorOscTracker::default(),
- osc52_forwarder: Osc52Forwarder::default(),
- }),
- key_encoder: Mutex::new(key_encoder),
- })
- }
-
- fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
- if let Ok(mut core) = self.core.lock() {
- core.host_terminal_theme = theme;
- core.transient_default_color_owner_pgid = None;
- write_host_terminal_theme(&mut core.terminal, theme);
- }
- }
-
- fn has_transient_default_color_override(&self) -> bool {
- self.core
- .lock()
- .map(|core| core.transient_default_color_owner_pgid.is_some())
- .unwrap_or(false)
- }
-
- fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool {
- let foreground_job = crate::detect::foreground_job(shell_pid);
- let Ok(mut core) = self.core.lock() else {
- return false;
- };
-
- let alternate_screen = core
- .terminal
- .active_screen()
- .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate)
- .unwrap_or(false);
- restore_host_terminal_theme_if_needed(
- &mut core,
- pane_id,
- shell_pid,
- alternate_screen,
- foreground_job.as_ref(),
- )
- }
-
- fn process_pty_bytes(
- &self,
- pane_id: PaneId,
- shell_pid: u32,
- bytes: &[u8],
- response_writer: &mpsc::Sender<Bytes>,
- ) -> ProcessBytesResult {
- let Ok(mut core) = self.core.lock() else {
- error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader");
- return ProcessBytesResult {
- request_render: false,
- clipboard_writes: Vec::new(),
- };
- };
-
- if shell_pid > 0 && core.default_color_tracker.observe(bytes) {
- if let Some(owner_pgid) = current_transient_default_color_owner(shell_pid) {
- core.transient_default_color_owner_pgid = Some(owner_pgid);
- debug!(
- pane = pane_id.raw(),
- owner_pgid, "tracked transient default color override"
- );
- }
- }
-
- core.osc52_forwarder.observe(bytes);
- let clipboard_writes = core.osc52_forwarder.drain_pending();
-
- let alternate_screen = core
- .terminal
- .active_screen()
- .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate)
- .unwrap_or(false);
- let filtered_bytes = if shell_pid > 0 {
- let foreground_job = (!alternate_screen && contains_scrollback_clear_sequence(bytes))
- .then(|| crate::detect::foreground_job(shell_pid))
- .flatten();
- maybe_filter_primary_screen_scrollback_clear(
- bytes,
- alternate_screen,
- foreground_job.as_ref(),
- )
- } else {
- Cow::Borrowed(bytes)
- };
- if filtered_bytes.len() != bytes.len() {
- debug!(
- pane = pane_id.raw(),
- shell_pid, "ignored scrollback clear sequence for droid compatibility"
- );
- }
-
- core.terminal.write(filtered_bytes.as_ref());
- if let Ok(mut key_encoder) = self.key_encoder.lock() {
- key_encoder.set_from_terminal(&core.terminal);
- }
- let synchronized_output = core
- .terminal
- .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT)
- .unwrap_or(false);
- let _ = response_writer;
- ProcessBytesResult {
- request_render: !synchronized_output,
- clipboard_writes,
- }
- }
-
- fn resize(&self, rows: u16, cols: u16) {
- if let Ok(mut core) = self.core.lock() {
- let _ = core.terminal.resize(cols, rows);
- }
- }
-
- fn scroll_up(&self, lines: usize) {
- if let Ok(mut core) = self.core.lock() {
- core.terminal.scroll_viewport_delta(-(lines as isize));
- }
- }
-
- fn scroll_down(&self, lines: usize) {
- if let Ok(mut core) = self.core.lock() {
- core.terminal.scroll_viewport_delta(lines as isize);
- }
- }
-
- fn scroll_reset(&self) {
- if let Ok(mut core) = self.core.lock() {
- core.terminal.scroll_viewport_bottom();
- }
- }
-
- fn set_scroll_offset_from_bottom(&self, lines: usize) {
- if let Ok(mut core) = self.core.lock() {
- core.terminal.scroll_viewport_bottom();
- if lines > 0 {
- core.terminal.scroll_viewport_delta(-(lines as isize));
- }
- }
- }
-
- fn scroll_metrics(&self) -> Option<ScrollMetrics> {
- let Ok(core) = self.core.lock() else {
- return None;
- };
- let scrollbar = core.terminal.scrollbar().ok()?;
- Some(ScrollMetrics {
- offset_from_bottom: scrollbar
- .total
- .saturating_sub(scrollbar.offset + scrollbar.len),
- max_offset_from_bottom: scrollbar.total.saturating_sub(scrollbar.len),
- viewport_rows: scrollbar.len,
- })
- }
-
- fn keyboard_protocol(&self) -> Option<crate::input::KeyboardProtocol> {
- let Ok(core) = self.core.lock() else {
- return None;
- };
- Some(crate::input::KeyboardProtocol::from_kitty_flags(
- core.terminal.kitty_keyboard_flags().ok()? as u16,
- ))
- }
-
- fn input_state(&self) -> Option<InputState> {
- let Ok(core) = self.core.lock() else {
- return None;
- };
- let alternate_screen =
- core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate;
- let application_cursor = core
- .terminal
- .mode_get(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS)
- .ok()?;
- let bracketed_paste = core
- .terminal
- .mode_get(crate::ghostty::MODE_BRACKETED_PASTE)
- .ok()?;
- let focus_reporting = core
- .terminal
- .mode_get(crate::ghostty::MODE_FOCUS_EVENT)
- .ok()?;
- let mouse_sgr = core
- .terminal
- .mode_get(crate::ghostty::MODE_MOUSE_SGR)
- .ok()?;
- let mouse_utf8 = core
- .terminal
- .mode_get(crate::ghostty::MODE_MOUSE_UTF8)
- .ok()?;
- let mouse_alternate_scroll = core
- .terminal
- .mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL)
- .ok()?;
- let mouse_protocol_mode = if core.terminal.mode_get(1003).ok()? {
- crate::input::MouseProtocolMode::AnyMotion
- } else if core.terminal.mode_get(1002).ok()? {
- crate::input::MouseProtocolMode::ButtonMotion
- } else if core.terminal.mode_get(1000).ok()? {
- crate::input::MouseProtocolMode::PressRelease
- } else if core.terminal.mode_get(9).ok()? {
- crate::input::MouseProtocolMode::Press
- } else {
- crate::input::MouseProtocolMode::None
- };
- let mouse_protocol_encoding = if mouse_sgr {
- crate::input::MouseProtocolEncoding::Sgr
- } else if mouse_utf8 {
- crate::input::MouseProtocolEncoding::Utf8
- } else {
- crate::input::MouseProtocolEncoding::Default
- };
- Some(InputState {
- alternate_screen,
- application_cursor,
- bracketed_paste,
- focus_reporting,
- mouse_protocol_mode,
- mouse_protocol_encoding,
- mouse_alternate_scroll,
- })
- }
-
- fn encode_terminal_key(
- &self,
- key: crate::input::TerminalKey,
- protocol: crate::input::KeyboardProtocol,
- ) -> Vec<u8> {
- if ghostty_prefers_herdr_text_encoding(key) {
- return crate::input::encode_terminal_key(key, protocol);
- }
-
- let Some(event) = ghostty_key_event_from_terminal_key(key) else {
- return crate::input::encode_terminal_key(key, protocol);
- };
-
- let Ok(mut encoder) = self.key_encoder.lock() else {
- return crate::input::encode_terminal_key(key, protocol);
- };
- match encoder.encode(&event) {
- Ok(bytes) if !bytes.is_empty() => bytes,
- Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol),
- }
- }
-
- fn encode_mouse_button(
- &self,
- kind: crossterm::event::MouseEventKind,
- column: u16,
- row: u16,
- modifiers: crossterm::event::KeyModifiers,
- ) -> Option<Vec<u8>> {
- let Ok(core) = self.core.lock() else {
- return None;
- };
- let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
- let event = ghostty_mouse_event_from_button_kind(kind, column, row, modifiers)?;
- encoder.encode(&event).ok()
- }
-
- fn encode_mouse_wheel(
- &self,
- kind: crossterm::event::MouseEventKind,
- column: u16,
- row: u16,
- modifiers: crossterm::event::KeyModifiers,
- ) -> Option<Vec<u8>> {
- let Ok(core) = self.core.lock() else {
- return None;
- };
- let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
- let event = ghostty_mouse_event_from_wheel_kind(kind, column, row, modifiers)?;
- encoder.encode(&event).ok()
- }
-
- fn visible_text(&self) -> String {
- self.core
- .lock()
- .ok()
- .and_then(|mut core| ghostty_visible_text(&mut core).ok())
- .unwrap_or_default()
- }
-
- fn detection_text(&self) -> String {
- self.core
- .lock()
- .ok()
- .and_then(|core| ghostty_detection_text(&core).ok())
- .unwrap_or_default()
- }
-
- fn recent_text(&self, lines: usize) -> String {
- self.core
- .lock()
- .ok()
- .and_then(|core| ghostty_recent_text(&core, lines).ok())
- .unwrap_or_default()
- }
-
- fn recent_unwrapped_text(&self, lines: usize) -> String {
- self.core
- .lock()
- .ok()
- .and_then(|core| ghostty_recent_text_unwrapped(&core, lines).ok())
- .unwrap_or_default()
- }
-
- fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
- self.core
- .lock()
- .ok()
- .and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok())
- }
-
- fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
- let Ok(mut core) = self.core.lock() else {
- return;
- };
- let host_theme = core.host_terminal_theme;
- let GhosttyPaneCore {
- terminal,
- render_state,
- ..
- } = &mut *core;
- if render_state.update(terminal).is_err() {
- return;
- }
- let colors = render_state.colors().ok();
- let default_bg = colors.and_then(|c| ghostty_default_bg(c.background, host_theme));
- let default_fg = colors.map(|c| ghostty_color(c.foreground));
-
- let mut row_iterator = match crate::ghostty::RowIterator::new() {
- Ok(iterator) => iterator,
- Err(_) => return,
- };
- let mut row_cells = match crate::ghostty::RowCells::new() {
- Ok(cells) => cells,
- Err(_) => return,
- };
- {
- let buf = frame.buffer_mut();
- let mut rows = match render_state.populate_row_iterator(&mut row_iterator) {
- Ok(rows) => rows,
- Err(_) => return,
- };
- let mut y = 0u16;
- while y < area.height && rows.next() {
- let mut cells = match rows.populate_cells(&mut row_cells) {
- Ok(cells) => cells,
- Err(_) => break,
- };
- let mut x = 0u16;
- while x < area.width && cells.next() {
- let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow);
- let style = ghostty_cell_style(&cells, default_fg, default_bg);
- let symbol = ghostty_buffer_symbol(&cells, wide)
- .unwrap_or_else(|_| ghostty_blank_symbol_for_width(wide).to_string());
- let cell = &mut buf[(area.x + x, area.y + y)];
- cell.reset();
- cell.set_symbol(&symbol);
- cell.set_style(style);
- x += 1;
- }
- while x < area.width {
- let cell = &mut buf[(area.x + x, area.y + y)];
- ghostty_reset_cell(cell, default_fg, default_bg);
- x += 1;
- }
- y += 1;
- }
- while y < area.height {
- for x in 0..area.width {
- let cell = &mut buf[(area.x + x, area.y + y)];
- ghostty_reset_cell(cell, default_fg, default_bg);
- }
- y += 1;
- }
- }
-
- if show_cursor && render_state.cursor_visible().ok() == Some(true) {
- if let Ok(Some(cursor)) = render_state.cursor_viewport() {
- if cursor.x < area.width && cursor.y < area.height {
- frame.set_cursor_position((area.x + cursor.x, area.y + cursor.y));
- }
- }
- }
- }
-}
-
-fn ghostty_key_event_from_terminal_key(
- key: crate::input::TerminalKey,
-) -> Option<crate::ghostty::KeyEvent> {
- let mut event = crate::ghostty::KeyEvent::new().ok()?;
- event.set_action(match key.kind {
- crossterm::event::KeyEventKind::Press => {
- crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS
- }
- crossterm::event::KeyEventKind::Release => {
- crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE
- }
- crossterm::event::KeyEventKind::Repeat => {
- crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_REPEAT
- }
- });
- event.set_mods(ghostty_mods_from_key_modifiers(key.modifiers));
- event.set_key(ghostty_key_from_crossterm_key_code(
- key.code,
- key.shifted_codepoint,
- )?);
-
- if let Some(text) = ghostty_key_text(key) {
- event.set_utf8(&text);
- } else {
- event.set_utf8("");
- }
-
- if let Some(codepoint) = ghostty_unshifted_codepoint(key) {
- event.set_unshifted_codepoint(codepoint);
- }
-
- Some(event)
-}
-
-fn ghostty_prefers_herdr_text_encoding(key: crate::input::TerminalKey) -> bool {
- matches!(key.code, crossterm::event::KeyCode::Char(_))
-}
-
-fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyModifiers) -> u16 {
- let mut ghostty_mods = 0u16;
- if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
- ghostty_mods |= crate::ghostty::MOD_SHIFT;
- }
- if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
- ghostty_mods |= crate::ghostty::MOD_CTRL;
- }
- if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
- ghostty_mods |= crate::ghostty::MOD_ALT;
- }
- if modifiers.contains(crossterm::event::KeyModifiers::SUPER) {
- ghostty_mods |= crate::ghostty::MOD_SUPER;
- }
- ghostty_mods
-}
-
-fn ghostty_mouse_encoder_for_terminal(
- terminal: &crate::ghostty::Terminal,
-) -> Option<crate::ghostty::MouseEncoder> {
- let mut encoder = crate::ghostty::MouseEncoder::new().ok()?;
- encoder.set_from_terminal(terminal);
- let cols = terminal.cols().ok()? as u32;
- let rows = terminal.rows().ok()? as u32;
- encoder.set_size(cols, rows, 1, 1);
- Some(encoder)
-}
-
-fn ghostty_mouse_event_from_button_kind(
- kind: crossterm::event::MouseEventKind,
- column: u16,
- row: u16,
- modifiers: crossterm::event::KeyModifiers,
-) -> Option<crate::ghostty::MouseEvent> {
- let mut event = crate::ghostty::MouseEvent::new().ok()?;
- let (action, button) = match kind {
- crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => (
- crate::ghostty::MOUSE_ACTION_PRESS,
- Some(crate::ghostty::MOUSE_BUTTON_LEFT),
- ),
- crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Middle) => (
- crate::ghostty::MOUSE_ACTION_PRESS,
- Some(crate::ghostty::MOUSE_BUTTON_MIDDLE),
- ),
- crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => (
- crate::ghostty::MOUSE_ACTION_PRESS,
- Some(crate::ghostty::MOUSE_BUTTON_RIGHT),
- ),
- crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => (
- crate::ghostty::MOUSE_ACTION_RELEASE,
- Some(crate::ghostty::MOUSE_BUTTON_LEFT),
- ),
- crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Middle) => (
- crate::ghostty::MOUSE_ACTION_RELEASE,
- Some(crate::ghostty::MOUSE_BUTTON_MIDDLE),
- ),
- crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Right) => (
- crate::ghostty::MOUSE_ACTION_RELEASE,
- Some(crate::ghostty::MOUSE_BUTTON_RIGHT),
- ),
- crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => (
- crate::ghostty::MOUSE_ACTION_MOTION,
- Some(crate::ghostty::MOUSE_BUTTON_LEFT),
- ),
- crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Middle) => (
- crate::ghostty::MOUSE_ACTION_MOTION,
- Some(crate::ghostty::MOUSE_BUTTON_MIDDLE),
- ),
- crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Right) => (
- crate::ghostty::MOUSE_ACTION_MOTION,
- Some(crate::ghostty::MOUSE_BUTTON_RIGHT),
- ),
- _ => return None,
- };
- event.set_action(action);
- if let Some(button) = button {
- event.set_button(button);
- } else {
- event.clear_button();
- }
- event.set_mods(ghostty_mods_from_key_modifiers(modifiers));
- event.set_position(column as f32, row as f32);
- Some(event)
-}
-
-fn ghostty_mouse_event_from_wheel_kind(
- kind: crossterm::event::MouseEventKind,
- column: u16,
- row: u16,
- modifiers: crossterm::event::KeyModifiers,
-) -> Option<crate::ghostty::MouseEvent> {
- let mut event = crate::ghostty::MouseEvent::new().ok()?;
- event.set_action(crate::ghostty::MOUSE_ACTION_PRESS);
- let button = match kind {
- crossterm::event::MouseEventKind::ScrollUp => crate::ghostty::MOUSE_BUTTON_WHEEL_UP,
- crossterm::event::MouseEventKind::ScrollDown => crate::ghostty::MOUSE_BUTTON_WHEEL_DOWN,
- crossterm::event::MouseEventKind::ScrollLeft => crate::ghostty::MOUSE_BUTTON_WHEEL_LEFT,
- crossterm::event::MouseEventKind::ScrollRight => crate::ghostty::MOUSE_BUTTON_WHEEL_RIGHT,
- _ => return None,
- };
- event.set_button(button);
- event.set_mods(ghostty_mods_from_key_modifiers(modifiers));
- event.set_position(column as f32, row as f32);
- Some(event)
-}
-
-fn ghostty_key_text(key: crate::input::TerminalKey) -> Option<String> {
- match key.code {
- crossterm::event::KeyCode::Char(c) => Some(
- key.shifted_codepoint
- .and_then(char::from_u32)
- .unwrap_or(c)
- .to_string(),
- ),
- _ => None,
- }
-}
-
-fn ghostty_unshifted_codepoint(key: crate::input::TerminalKey) -> Option<u32> {
- match key.code {
- crossterm::event::KeyCode::Char(c) => Some(c as u32),
- _ => None,
- }
-}
-
-fn ghostty_key_from_crossterm_key_code(
- code: crossterm::event::KeyCode,
- shifted_codepoint: Option<u32>,
-) -> Option<u32> {
- use crate::ghostty::ffi;
- use crossterm::event::KeyCode;
-
- match code {
- KeyCode::Backspace => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE),
- KeyCode::Enter => Some(ffi::GhosttyKey_GHOSTTY_KEY_ENTER),
- KeyCode::Left => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_LEFT),
- KeyCode::Right => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_RIGHT),
- KeyCode::Up => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP),
- KeyCode::Down => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_DOWN),
- KeyCode::Home => Some(ffi::GhosttyKey_GHOSTTY_KEY_HOME),
- KeyCode::End => Some(ffi::GhosttyKey_GHOSTTY_KEY_END),
- KeyCode::PageUp => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAGE_UP),
- KeyCode::PageDown => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAGE_DOWN),
- KeyCode::Tab | KeyCode::BackTab => Some(ffi::GhosttyKey_GHOSTTY_KEY_TAB),
- KeyCode::Delete => Some(ffi::GhosttyKey_GHOSTTY_KEY_DELETE),
- KeyCode::Insert => Some(ffi::GhosttyKey_GHOSTTY_KEY_INSERT),
- KeyCode::Esc => Some(ffi::GhosttyKey_GHOSTTY_KEY_ESCAPE),
- KeyCode::F(n) => Some(match n {
- 1 => ffi::GhosttyKey_GHOSTTY_KEY_F1,
- 2 => ffi::GhosttyKey_GHOSTTY_KEY_F2,
- 3 => ffi::GhosttyKey_GHOSTTY_KEY_F3,
- 4 => ffi::GhosttyKey_GHOSTTY_KEY_F4,
- 5 => ffi::GhosttyKey_GHOSTTY_KEY_F5,
- 6 => ffi::GhosttyKey_GHOSTTY_KEY_F6,
- 7 => ffi::GhosttyKey_GHOSTTY_KEY_F7,
- 8 => ffi::GhosttyKey_GHOSTTY_KEY_F8,
- 9 => ffi::GhosttyKey_GHOSTTY_KEY_F9,
- 10 => ffi::GhosttyKey_GHOSTTY_KEY_F10,
- 11 => ffi::GhosttyKey_GHOSTTY_KEY_F11,
- 12 => ffi::GhosttyKey_GHOSTTY_KEY_F12,
- _ => return None,
- }),
- KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint),
- _ => None,
- }
-}
-
-fn ghostty_key_from_char(c: char, shifted_codepoint: Option<u32>) -> Option<u32> {
- use crate::ghostty::ffi;
-
- let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) {
- ghostty_unshifted_ascii_pair(shifted).unwrap_or(c)
- } else {
- c
- };
-
- match base.to_ascii_lowercase() {
- 'a' => Some(ffi::GhosttyKey_GHOSTTY_KEY_A),
- 'b' => Some(ffi::GhosttyKey_GHOSTTY_KEY_B),
- 'c' => Some(ffi::GhosttyKey_GHOSTTY_KEY_C),
- 'd' => Some(ffi::GhosttyKey_GHOSTTY_KEY_D),
- 'e' => Some(ffi::GhosttyKey_GHOSTTY_KEY_E),
- 'f' => Some(ffi::GhosttyKey_GHOSTTY_KEY_F),
- 'g' => Some(ffi::GhosttyKey_GHOSTTY_KEY_G),
- 'h' => Some(ffi::GhosttyKey_GHOSTTY_KEY_H),
- 'i' => Some(ffi::GhosttyKey_GHOSTTY_KEY_I),
- 'j' => Some(ffi::GhosttyKey_GHOSTTY_KEY_J),
- 'k' => Some(ffi::GhosttyKey_GHOSTTY_KEY_K),
- 'l' => Some(ffi::GhosttyKey_GHOSTTY_KEY_L),
- 'm' => Some(ffi::GhosttyKey_GHOSTTY_KEY_M),
- 'n' => Some(ffi::GhosttyKey_GHOSTTY_KEY_N),
- 'o' => Some(ffi::GhosttyKey_GHOSTTY_KEY_O),
- 'p' => Some(ffi::GhosttyKey_GHOSTTY_KEY_P),
- 'q' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Q),
- 'r' => Some(ffi::GhosttyKey_GHOSTTY_KEY_R),
- 's' => Some(ffi::GhosttyKey_GHOSTTY_KEY_S),
- 't' => Some(ffi::GhosttyKey_GHOSTTY_KEY_T),
- 'u' => Some(ffi::GhosttyKey_GHOSTTY_KEY_U),
- 'v' => Some(ffi::GhosttyKey_GHOSTTY_KEY_V),
- 'w' => Some(ffi::GhosttyKey_GHOSTTY_KEY_W),
- 'x' => Some(ffi::GhosttyKey_GHOSTTY_KEY_X),
- 'y' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Y),
- 'z' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Z),
- '0' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_0),
- '1' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_1),
- '2' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_2),
- '3' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_3),
- '4' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_4),
- '5' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_5),
- '6' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_6),
- '7' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_7),
- '8' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_8),
- '9' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_9),
- '`' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKQUOTE),
- '\\' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSLASH),
- '[' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT),
- ']' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_RIGHT),
- ',' => Some(ffi::GhosttyKey_GHOSTTY_KEY_COMMA),
- '=' => Some(ffi::GhosttyKey_GHOSTTY_KEY_EQUAL),
- '-' => Some(ffi::GhosttyKey_GHOSTTY_KEY_MINUS),
- '.' => Some(ffi::GhosttyKey_GHOSTTY_KEY_PERIOD),
- '\'' => Some(ffi::GhosttyKey_GHOSTTY_KEY_QUOTE),
- ';' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SEMICOLON),
- '/' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SLASH),
- ' ' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SPACE),
- _ => None,
- }
-}
-
-fn ghostty_unshifted_ascii_pair(c: char) -> Option<char> {
- Some(match c {
- '!' => '1',
- '@' => '2',
- '#' => '3',
- '$' => '4',
- '%' => '5',
- '^' => '6',
- '&' => '7',
- '*' => '8',
- '(' => '9',
- ')' => '0',
- '_' => '-',
- '+' => '=',
- '{' => '[',
- '}' => ']',
- '|' => '\\',
- ':' => ';',
- '"' => '\'',
- '<' => ',',
- '>' => '.',
- '?' => '/',
- '~' => '`',
- _ => return None,
- })
-}
-
-fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
- let GhosttyPaneCore {
- terminal,
- render_state,
- ..
- } = core;
- render_state.update(terminal)?;
- let mut row_iterator = crate::ghostty::RowIterator::new()?;
- let mut row_cells = crate::ghostty::RowCells::new()?;
- let mut rows = render_state.populate_row_iterator(&mut row_iterator)?;
- let mut lines = Vec::new();
- while rows.next() {
- let mut cells = rows.populate_cells(&mut row_cells)?;
- lines.push(ghostty_line_from_cells(&mut cells)?);
- }
- trim_trailing_blank_rows(&mut lines);
- Ok(lines_to_text(lines))
-}
-
-fn ghostty_detection_text(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
- let lines = core
- .terminal
- .rows()
- .ok()
- .map(|rows| usize::from(rows).max(1))
- .unwrap_or(DEFAULT_DETECTION_ROWS);
- ghostty_recent_text(core, lines)
-}
-
-fn ghostty_recent_text(
- core: &GhosttyPaneCore,
- lines: usize,
-) -> Result<String, crate::ghostty::Error> {
- let total_rows = core.terminal.total_rows()?;
- let cols = core.terminal.cols()?;
- let start = total_rows.saturating_sub(lines);
- let mut rows = Vec::with_capacity(total_rows.saturating_sub(start));
- for y in start..total_rows {
- rows.push(ghostty_screen_row(core, cols, y as u32)?);
- }
- trim_trailing_blank_rows(&mut rows);
- Ok(recent_text_from_rows(&rows, lines))
-}
-
-fn ghostty_recent_text_unwrapped(
- core: &GhosttyPaneCore,
- lines: usize,
-) -> Result<String, crate::ghostty::Error> {
- let total_rows = core.terminal.total_rows()?;
- let cols = core.terminal.cols()?;
- if total_rows == 0 || cols == 0 {
- return Ok(String::new());
- }
- let start = total_rows.saturating_sub(lines) as u32;
- let end = (total_rows.saturating_sub(1)) as u32;
- core.terminal
- .read_text_screen((0, start), (cols.saturating_sub(1), end), false)
-}
-
-fn ghostty_extract_selection(
- core: &mut GhosttyPaneCore,
- selection: &crate::selection::Selection,
-) -> Result<String, crate::ghostty::Error> {
- let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells();
- core.terminal
- .read_text_screen((start_col, start_row), (end_col, end_row), false)
-}
-
-fn ghostty_screen_row(
- core: &GhosttyPaneCore,
- cols: u16,
- y: u32,
-) -> Result<String, crate::ghostty::Error> {
- let mut line = String::new();
- for x in 0..cols {
- let graphemes = core.terminal.screen_graphemes(x, y)?;
- if graphemes.is_empty() {
- line.push(' ');
- } else {
- for codepoint in graphemes {
- if let Some(ch) = char::from_u32(codepoint) {
- line.push(ch);
- }
- }
- }
- }
- Ok(line.trim_end().to_string())
-}
-
-fn ghostty_line_from_cells(
- cells: &mut crate::ghostty::RowCellIter<'_>,
-) -> Result<String, crate::ghostty::Error> {
- let mut line = String::new();
- while cells.next() {
- line.push_str(&ghostty_cell_symbol(cells)?);
- }
- Ok(line.trim_end().to_string())
-}
-
-fn ghostty_cell_symbol(
- cells: &crate::ghostty::RowCellIter<'_>,
-) -> Result<String, crate::ghostty::Error> {
- let graphemes = cells.graphemes()?;
- if graphemes.is_empty() {
- return Ok(" ".to_string());
- }
- let mut text = String::new();
- for codepoint in graphemes {
- if let Some(ch) = char::from_u32(codepoint) {
- text.push(ch);
- }
- }
- if text.is_empty() {
- text.push(' ');
- }
- Ok(text)
-}
-
-fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) -> &'static str {
- match wide {
- crate::ghostty::CellWide::Wide => " ",
- crate::ghostty::CellWide::SpacerTail => "",
- crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => " ",
- }
-}
-
-fn ghostty_normalize_buffer_symbol(symbol: &str, wide: crate::ghostty::CellWide) -> String {
- let expected_width = match wide {
- crate::ghostty::CellWide::Wide => 2,
- crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => 1,
- crate::ghostty::CellWide::SpacerTail => 0,
- };
- let actual_width = symbol.width();
- if actual_width == expected_width {
- return symbol.to_string();
- }
- ghostty_blank_symbol_for_width(wide).to_string()
-}
-
-fn ghostty_buffer_symbol(
- cells: &crate::ghostty::RowCellIter<'_>,
- wide: crate::ghostty::CellWide,
-) -> Result<String, crate::ghostty::Error> {
- let symbol = match wide {
- crate::ghostty::CellWide::SpacerTail => String::new(),
- crate::ghostty::CellWide::SpacerHead => " ".to_string(),
- crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => {
- ghostty_cell_symbol(cells)?
- }
- };
- Ok(ghostty_normalize_buffer_symbol(&symbol, wide))
-}
-
-fn ghostty_reset_cell(
- cell: &mut ratatui::buffer::Cell,
- default_fg: Option<Color>,
- default_bg: Option<Color>,
-) {
- cell.reset();
- cell.set_symbol(" ");
- if let Some(bg) = default_bg {
- cell.set_bg(bg);
- }
- if let Some(fg) = default_fg {
- cell.set_fg(fg);
- }
-}
-
-fn ghostty_cell_style(
- cells: &crate::ghostty::RowCellIter<'_>,
- default_fg: Option<Color>,
- default_bg: Option<Color>,
-) -> Style {
- let style_data = cells.style().unwrap_or_default();
- let mut fg = cells
- .fg_color()
- .ok()
- .flatten()
- .map(ghostty_color)
- .or(default_fg);
- let mut bg = cells
- .bg_color()
- .ok()
- .flatten()
- .map(ghostty_color)
- .or(default_bg);
- if style_data.invisible {
- fg = bg.or(default_bg);
- }
- if style_data.inverse {
- std::mem::swap(&mut fg, &mut bg);
- }
-
- let mut style = Style::default();
- if let Some(fg) = fg {
- style = style.fg(fg);
- }
- if let Some(bg) = bg {
- style = style.bg(bg);
- }
-
- let mut modifiers = Modifier::empty();
- if style_data.bold {
- modifiers |= Modifier::BOLD;
- }
- if style_data.italic {
- modifiers |= Modifier::ITALIC;
- }
- if style_data.faint {
- modifiers |= Modifier::DIM;
- }
- if style_data.blink {
- modifiers |= Modifier::SLOW_BLINK;
- }
- if style_data.underlined {
- modifiers |= Modifier::UNDERLINED;
- }
- if style_data.strikethrough {
- modifiers |= Modifier::CROSSED_OUT;
- }
- style.add_modifier(modifiers)
-}
-
-fn ghostty_default_bg(
- color: crate::ghostty::RgbColor,
- host_theme: crate::terminal_theme::TerminalTheme,
-) -> Option<Color> {
- if host_theme.background == Some(terminal_theme_color(color)) {
- None
- } else {
- Some(ghostty_color(color))
- }
-}
-
-fn terminal_theme_color(color: crate::ghostty::RgbColor) -> crate::terminal_theme::RgbColor {
- crate::terminal_theme::RgbColor {
- r: color.r,
- g: color.g,
- b: color.b,
- }
-}
-
-fn ghostty_color(color: crate::ghostty::RgbColor) -> Color {
- Color::Rgb(color.r, color.g, color.b)
-}
-
-fn lines_to_text(lines: Vec<String>) -> String {
- let text = lines.join("\n");
- if text.is_empty() {
- text
- } else {
- format!("{text}\n")
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WheelRouting {
HostScroll,
MouseReport,
@@ -1751,22 +159,6 @@ impl Drop for PaneRuntime {
}
}
-fn trim_trailing_blank_rows(rows: &mut Vec<String>) {
- while rows.last().is_some_and(|row| row.trim().is_empty()) {
- rows.pop();
- }
-}
-
-fn recent_text_from_rows(rows: &[String], lines: usize) -> String {
- let start = rows.len().saturating_sub(lines);
- let text = rows[start..].join("\n");
- if text.is_empty() {
- text
- } else {
- format!("{text}\n")
- }
-}
-
fn wait_for_processes_to_exit(pids: &[u32], timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
loop {
@@ -1939,9 +331,7 @@ impl PaneRuntime {
.map_err(|e| std::io::Error::other(e.to_string()))?;
let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
- let terminal = Arc::new(PaneTerminal {
- ghostty: pane_terminal,
- });
+ let terminal = Arc::new(PaneTerminal::new(pane_terminal));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
let reader = pair
@@ -2050,7 +440,7 @@ impl PaneRuntime {
let pending_release_for_task = pending_release.clone();
let handle = tokio::spawn(async move {
- let mut agent: Option<Agent> = None;
+ let mut agent_presence = AgentDetectionPresence::from_agent(None);
let mut state = AgentState::Unknown;
let mut last_process_check = Instant::now();
let mut last_claude_working_at = None;
@@ -2063,7 +453,7 @@ impl PaneRuntime {
|| terminal.has_transient_default_color_override()
{
TICK_PENDING_RELEASE
- } else if agent.is_none() {
+ } else if agent_presence.current_agent().is_none() {
TICK_UNIDENTIFIED
} else {
TICK_IDENTIFIED
@@ -2071,7 +461,7 @@ impl PaneRuntime {
tokio::select! {
_ = tokio::time::sleep(tick) => {}
_ = detect_reset.notified() => {
- agent = None;
+ agent_presence = AgentDetectionPresence::from_agent(None);
state = AgentState::Unknown;
last_claude_working_at = None;
}
@@ -2080,48 +470,60 @@ impl PaneRuntime {
let now = Instant::now();
let suppressed_agent = active_pending_release(&pending_release_for_task, now);
let should_check_process = suppressed_agent.is_some()
- || agent.is_none()
+ || agent_presence.current_agent().is_none()
|| now.duration_since(last_process_check) >= PROCESS_RECHECK;
let mut agent_changed = false;
+ let mut agent = agent_presence.current_agent();
if should_check_process {
last_process_check = now;
let pid = child_pid.load(Ordering::Acquire);
if pid > 0 {
+ let mut process_name = None;
+ let mut process_group_id = None;
+ let mut new_agent = None;
+
if let Some(job) = detect::foreground_job(pid) {
+ process_group_id = Some(job.process_group_id);
let identified = detect::identify_agent_in_job(&job);
- let mut new_agent = identified.as_ref().map(|(agent, _)| *agent);
-
- if let Some(suppressed_agent) = suppressed_agent {
- if new_agent == Some(suppressed_agent) {
- new_agent = None;
- } else if let Ok(mut pending_release) =
- pending_release_for_task.lock()
- {
- *pending_release = None;
- }
+ process_name = identified
+ .as_ref()
+ .map(|(_, process_name)| process_name.clone());
+ new_agent = identified.as_ref().map(|(agent, _)| *agent);
+ }
+
+ if let Some(suppressed_agent) = suppressed_agent {
+ if new_agent == Some(suppressed_agent) {
+ new_agent = None;
+ } else if let Ok(mut pending_release) =
+ pending_release_for_task.lock()
+ {
+ *pending_release = None;
}
+ }
- if new_agent != agent {
- if let Some((_, process_name)) = identified {
- info!(
- pane = pane_id.raw(),
- ?new_agent,
- process = %process_name,
- pgid = job.process_group_id,
- "agent changed"
- );
- } else {
- info!(
- pane = pane_id.raw(),
- ?new_agent,
- pgid = job.process_group_id,
- "agent changed"
- );
- }
- agent = new_agent;
- agent_changed = true;
+ let previous_agent = agent_presence.current_agent();
+ if agent_presence.observe_process_probe(new_agent) {
+ agent = agent_presence.current_agent();
+ if let Some(process_name) = process_name {
+ info!(
+ pane = pane_id.raw(),
+ previous_agent = ?previous_agent,
+ ?agent,
+ process = %process_name,
+ pgid = ?process_group_id,
+ "agent changed"
+ );
+ } else {
+ info!(
+ pane = pane_id.raw(),
+ previous_agent = ?previous_agent,
+ ?agent,
+ pgid = ?process_group_id,
+ "agent changed"
+ );
}
+ agent_changed = true;
}
}
}
@@ -2405,16 +807,12 @@ impl PaneRuntime {
#[cfg(test)]
impl PaneRuntime {
- pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self {
- Self::test_with_scrollback_bytes(cols, rows, 0, bytes)
+ pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) {
+ Self::test_with_channel_and_scrollback_bytes(cols, rows, 0, &[])
}
- pub(crate) fn test_with_screen_bytes_and_receiver(
- cols: u16,
- rows: u16,
- bytes: &[u8],
- ) -> (Self, mpsc::Receiver<Bytes>) {
- Self::test_with_scrollback_bytes_and_receiver(cols, rows, 0, bytes)
+ pub(crate) fn test_with_screen_bytes(cols: u16, rows: u16, bytes: &[u8]) -> Self {
+ Self::test_with_scrollback_bytes(cols, rows, 0, bytes)
}
pub(crate) fn test_with_scrollback_bytes(
@@ -2423,10 +821,10 @@ impl PaneRuntime {
scrollback_limit_bytes: usize,
bytes: &[u8],
) -> Self {
- Self::test_with_scrollback_bytes_and_receiver(cols, rows, scrollback_limit_bytes, bytes).0
+ Self::test_with_channel_and_scrollback_bytes(cols, rows, scrollback_limit_bytes, bytes).0
}
- fn test_with_scrollback_bytes_and_receiver(
+ fn test_with_channel_and_scrollback_bytes(
cols: u16,
rows: u16,
scrollback_limit_bytes: usize,
@@ -2440,9 +838,9 @@ impl PaneRuntime {
(
Self {
- terminal: Arc::new(PaneTerminal {
- ghostty: GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
- }),
+ terminal: Arc::new(PaneTerminal::new(
+ GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
+ )),
sender: tx,
resize_tx,
current_size: Cell::new((rows, cols)),
@@ -2461,761 +859,6 @@ impl PaneRuntime {
mod tests {
use super::*;
- fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) {
- for i in 0..count {
- terminal.write(format!("{i:06}\r\n").as_bytes());
- }
- }
-
- fn pane_default_theme(pane: &GhosttyPaneTerminal) -> crate::terminal_theme::TerminalTheme {
- let mut core = pane.core.lock().unwrap();
- let GhosttyPaneCore {
- terminal,
- render_state,
- ..
- } = &mut *core;
- render_state.update(terminal).unwrap();
- let colors = render_state.colors().unwrap();
- crate::terminal_theme::TerminalTheme {
- foreground: Some(crate::terminal_theme::RgbColor {
- r: colors.foreground.r,
- g: colors.foreground.g,
- b: colors.foreground.b,
- }),
- background: Some(crate::terminal_theme::RgbColor {
- r: colors.background.r,
- g: colors.background.g,
- b: colors.background.b,
- }),
- }
- }
-
- fn shell_job(shell_pid: u32) -> crate::platform::ForegroundJob {
- crate::platform::ForegroundJob {
- process_group_id: shell_pid,
- processes: vec![crate::platform::ForegroundProcess {
- pid: shell_pid,
- name: "zsh".to_string(),
- argv0: Some("zsh".to_string()),
- cmdline: Some("zsh".to_string()),
- }],
- }
- }
-
- #[test]
- fn ghostty_render_can_suppress_cursor_position() {
- let (tx, _rx) = mpsc::channel(4);
- let mut first_terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
- first_terminal.write(b"left");
- let first = GhosttyPaneTerminal::new(first_terminal, tx.clone()).unwrap();
-
- let mut second_terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
- second_terminal.write(b"r\r\nb");
- let second = GhosttyPaneTerminal::new(second_terminal, tx).unwrap();
-
- let backend = ratatui::backend::TestBackend::new(40, 5);
- let mut terminal = ratatui::Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| {
- first.render(frame, Rect::new(0, 0, 20, 5), true);
- second.render(frame, Rect::new(20, 0, 20, 5), false);
- })
- .unwrap();
-
- terminal.backend_mut().assert_cursor_position((4, 0));
- }
-
- #[test]
- fn ghostty_keyboard_protocol_tracks_live_terminal_flags() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- terminal.write(b"\x1b[>3u");
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- assert_eq!(
- pane.keyboard_protocol(),
- Some(crate::input::KeyboardProtocol::Kitty { flags: 3 })
- );
- }
-
- #[test]
- fn ghostty_plain_text_chars_still_encode_as_text() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let encoded = pane.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Char('a'),
- crossterm::event::KeyModifiers::empty(),
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
-
- assert_eq!(encoded, b"a");
- }
-
- #[test]
- fn ghostty_char_keys_still_use_herdr_encoding() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- terminal.write(b"\x1b[>1u");
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let encoded = pane.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Char('a'),
- crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
-
- assert_eq!(encoded, vec![1]);
- }
-
- #[test]
- fn ghostty_key_encoding_honors_application_cursor_mode() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- terminal
- .mode_set(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS, true)
- .unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let encoded = pane.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Up,
- crossterm::event::KeyModifiers::empty(),
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
-
- assert_eq!(encoded, b"\x1bOA");
- }
-
- #[test]
- fn ghostty_key_encoder_updates_after_terminal_mode_changes() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
- let pane_id = PaneId::from_raw(1);
-
- let before = pane.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Up,
- crossterm::event::KeyModifiers::empty(),
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
- assert_eq!(before, b"\x1b[A");
-
- pane.process_pty_bytes(pane_id, 0, b"\x1b[?1h", &tx);
-
- let after = pane.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Up,
- crossterm::event::KeyModifiers::empty(),
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
- assert_eq!(after, b"\x1bOA");
- }
-
- #[test]
- fn ghostty_key_encoder_updates_after_kitty_flag_changes() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
- let pane_id = PaneId::from_raw(1);
- let key = crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Enter,
- crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
- );
-
- let before = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
- pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
- let after = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
-
- assert_ne!(before, after);
- assert_eq!(after, b"\x1b[13;6u");
- }
-
- #[test]
- fn ghostty_key_encoders_are_isolated_per_pane() {
- let (tx, _rx) = mpsc::channel(4);
- let first = GhosttyPaneTerminal::new(
- crate::ghostty::Terminal::new(80, 24, 0).unwrap(),
- tx.clone(),
- )
- .unwrap();
- let second = GhosttyPaneTerminal::new(
- crate::ghostty::Terminal::new(80, 24, 0).unwrap(),
- tx.clone(),
- )
- .unwrap();
-
- first.process_pty_bytes(PaneId::from_raw(1), 0, b"\x1b[?1h", &tx);
-
- let first_encoded = first.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Up,
- crossterm::event::KeyModifiers::empty(),
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
- let second_encoded = second.encode_terminal_key(
- crate::input::TerminalKey::new(
- crossterm::event::KeyCode::Up,
- crossterm::event::KeyModifiers::empty(),
- ),
- crate::input::KeyboardProtocol::Legacy,
- );
-
- assert_eq!(first_encoded, b"\x1bOA");
- assert_eq!(second_encoded, b"\x1b[A");
- }
-
- #[test]
- fn ghostty_mouse_button_encoding_uses_live_terminal_state() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- terminal.write(b"\x1b[?1000h\x1b[?1006h");
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let encoded = pane.encode_mouse_button(
- crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
- 11,
- 9,
- crossterm::event::KeyModifiers::empty(),
- );
-
- assert_eq!(encoded.as_deref(), Some(&b"\x1b[<0;12;10m"[..]));
- }
-
- #[test]
- fn ghostty_mouse_drag_encoding_uses_motion_reporting_state() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- terminal.write(b"\x1b[?1002h\x1b[?1006h");
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let encoded = pane.encode_mouse_button(
- crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
- 4,
- 6,
- crossterm::event::KeyModifiers::SHIFT,
- );
-
- assert_eq!(encoded.as_deref(), Some(&b"\x1b[<36;5;7M"[..]));
- }
-
- #[test]
- fn ghostty_normalize_buffer_symbol_uses_ratatui_width_contract() {
- assert_eq!(
- ghostty_normalize_buffer_symbol("🙂", crate::ghostty::CellWide::Wide),
- "🙂"
- );
- assert_eq!(
- ghostty_normalize_buffer_symbol("a", crate::ghostty::CellWide::Wide),
- " "
- );
- assert_eq!(
- ghostty_normalize_buffer_symbol("⌨️", crate::ghostty::CellWide::Narrow),
- " "
- );
- assert_eq!(
- ghostty_normalize_buffer_symbol(" ", crate::ghostty::CellWide::SpacerTail),
- ""
- );
- assert_eq!(
- ghostty_normalize_buffer_symbol("xx", crate::ghostty::CellWide::SpacerHead),
- " "
- );
- }
-
- #[test]
- fn pane_scrollback_controls_reach_top_without_ui_interference() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap();
- write_numbered_lines(&mut terminal, 1000);
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let before = pane.scroll_metrics().expect("scroll metrics before scroll");
- assert!(before.max_offset_from_bottom > 0);
- assert_eq!(before.offset_from_bottom, 0);
-
- pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom);
-
- let after = pane.scroll_metrics().expect("scroll metrics after scroll");
- assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom);
- assert!(pane.visible_text().contains("000000"));
- }
-
- #[test]
- fn detection_text_stays_at_bottom_when_viewport_is_scrolled() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap();
- write_numbered_lines(&mut terminal, 10);
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- let bottom_snapshot = pane.detection_text();
- assert_eq!(bottom_snapshot, pane.recent_text(3));
- assert!(bottom_snapshot.contains("000009"));
-
- let before = pane.scroll_metrics().expect("scroll metrics before scroll");
- pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom);
-
- assert!(pane.visible_text().contains("000000"));
- assert_eq!(pane.detection_text(), bottom_snapshot);
- }
-
- #[test]
- fn extract_selection_reads_screen_rows_not_current_viewport() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(8, 3, 1024).unwrap();
- write_numbered_lines(&mut terminal, 8);
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- pane.set_scroll_offset_from_bottom(3);
- let metrics = pane
- .scroll_metrics()
- .expect("scroll metrics after initial scroll");
- let mut selection =
- crate::selection::Selection::anchor(PaneId::from_raw(1), 0, 0, Some(metrics));
- selection.drag(5, 2, Rect::new(0, 0, 8, 3), Some(metrics));
-
- pane.scroll_reset();
-
- let text = pane
- .extract_selection(&selection)
- .expect("selection should extract text");
- assert_eq!(text, "000003\n000004\n000005");
- }
-
- #[test]
- fn recent_unwrapped_text_ignores_soft_wraps() {
- let (tx, _rx) = mpsc::channel(4);
- let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap();
- terminal.write(b"ABCDEFGHIJ");
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
-
- assert_eq!(pane.recent_text(3), "ABCDE\nFGHIJ\n");
- assert_eq!(pane.recent_unwrapped_text(3), "ABCDEFGHIJ");
- }
-
- #[test]
- fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- let pane_terminal = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
- let pane_id = PaneId::from_raw(1);
-
- let begin = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026h", &tx);
- assert!(!begin.request_render);
-
- let body = pane_terminal.process_pty_bytes(pane_id, 0, b"hello", &tx);
- assert!(!body.request_render);
-
- let end = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx);
- assert!(end.request_render);
- }
-
- #[test]
- fn default_color_tracker_detects_split_osc_11_sequences() {
- let mut tracker = DefaultColorOscTracker::default();
-
- assert!(!tracker.observe(b"\x1b]11;rgb:11/22"));
- assert!(tracker.observe(b"/33\x1b\\"));
- }
-
- #[test]
- fn default_color_tracker_ignores_osc_queries() {
- let mut tracker = DefaultColorOscTracker::default();
-
- assert!(!tracker.observe(b"\x1b]10;?\x1b\\"));
- assert!(!tracker.observe(b"\x1b]11;?\x07"));
- }
-
- #[test]
- fn osc52_forwarder_detects_write_with_bel() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGVsbG8=\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hello".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_detects_write_with_st() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hello".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_detects_empty_selector_form() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;;aGVsbG8=\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hello".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_accepts_clear_clipboard() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![Vec::<u8>::new()]);
- }
-
- #[test]
- fn osc52_forwarder_ignores_query() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;?\x07");
- assert!(fw.drain_pending().is_empty());
- }
-
- #[test]
- fn osc52_forwarder_ignores_empty_selector_query() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;;?\x07");
- assert!(fw.drain_pending().is_empty());
- }
-
- #[test]
- fn osc52_forwarder_ignores_other_kinds() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;p;aGk=\x07");
- fw.observe(b"\x1b]52;s;aGk=\x07");
- fw.observe(b"\x1b]52;q;aGk=\x07");
- fw.observe(b"\x1b]52;0;aGk=\x07");
- fw.observe(b"\x1b]52;7;aGk=\x07");
- assert!(fw.drain_pending().is_empty());
- }
-
- #[test]
- fn osc52_forwarder_ignores_invalid_base64() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;%%%\x07");
- fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07");
- assert!(fw.drain_pending().is_empty());
- }
-
- #[test]
- fn osc52_forwarder_ignores_non_osc52() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]11;?\x07");
- fw.observe(b"\x1b]0;title\x07");
- fw.observe(b"\x1b]8;;https://example.com\x1b\\");
- assert!(fw.drain_pending().is_empty());
- }
-
- #[test]
- fn osc52_forwarder_handles_split_sequence_mid_payload() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGVs");
- assert!(fw.drain_pending().is_empty());
- fw.observe(b"bG8gd29y");
- assert!(fw.drain_pending().is_empty());
- fw.observe(b"bGQ=\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hello world".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_handles_split_before_bel() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGk=");
- assert!(fw.drain_pending().is_empty());
- fw.observe(b"\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hi".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_handles_split_between_esc_and_backslash() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGk=\x1b");
- assert!(fw.drain_pending().is_empty());
- fw.observe(b"\\");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hi".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_payload_size_limit() {
- let mut fw = Osc52Forwarder::default();
- let mut huge = Vec::with_capacity(OSC52_MAX_PAYLOAD_BYTES + 32);
- huge.extend_from_slice(b"\x1b]52;c;");
- huge.extend(std::iter::repeat_n(b'A', OSC52_MAX_PAYLOAD_BYTES + 16));
- huge.push(0x07);
- fw.observe(&huge);
- assert!(fw.drain_pending().is_empty());
-
- fw.observe(b"\x1b]52;c;aGk=\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hi".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_recovers_after_garbage() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hi".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_multiple_in_one_chunk() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07");
- let pending = fw.drain_pending();
- assert_eq!(pending, vec![b"hi".to_vec(), b"bye".to_vec()]);
- }
-
- #[test]
- fn osc52_forwarder_drain_clears_pending() {
- let mut fw = Osc52Forwarder::default();
- fw.observe(b"\x1b]52;c;aGk=\x07");
- assert_eq!(fw.drain_pending(), vec![b"hi".to_vec()]);
- assert!(fw.drain_pending().is_empty());
- }
-
- #[test]
- fn droid_scrollback_compat_matches_process_name_and_cmdline() {
- let name_only = crate::platform::ForegroundJob {
- process_group_id: 42,
- processes: vec![crate::platform::ForegroundProcess {
- pid: 42,
- name: "droid".to_string(),
- argv0: None,
- cmdline: Some("/opt/factory/droid --resume".to_string()),
- }],
- };
- assert!(foreground_job_uses_droid_scrollback_compat(&name_only));
-
- let cmdline_only = crate::platform::ForegroundJob {
- process_group_id: 42,
- processes: vec![crate::platform::ForegroundProcess {
- pid: 42,
- name: "bun".to_string(),
- argv0: Some("bun".to_string()),
- cmdline: Some("/home/can/.local/bin/droid --resume".to_string()),
- }],
- };
- assert!(foreground_job_uses_droid_scrollback_compat(&cmdline_only));
-
- let shell = shell_job(7);
- assert!(!foreground_job_uses_droid_scrollback_compat(&shell));
- }
-
- #[test]
- fn strip_scrollback_clear_sequences_removes_ed3_only() {
- let filtered = strip_scrollback_clear_sequences(b"a\x1b[3Jb\x1b[?3Jc\x1b[2Jd");
- assert_eq!(filtered.as_ref(), b"abc\x1b[2Jd");
- }
-
- #[test]
- fn primary_screen_droid_compat_ignores_scrollback_clear_only_for_droid() {
- let droid_job = crate::platform::ForegroundJob {
- process_group_id: 42,
- processes: vec![crate::platform::ForegroundProcess {
- pid: 42,
- name: "droid".to_string(),
- argv0: Some("droid".to_string()),
- cmdline: Some("droid".to_string()),
- }],
- };
-
- let filtered = maybe_filter_primary_screen_scrollback_clear(
- b"\x1b[3J\x1b[2J",
- false,
- Some(&droid_job),
- );
- assert_eq!(filtered.as_ref(), b"\x1b[2J");
-
- let shell = maybe_filter_primary_screen_scrollback_clear(
- b"\x1b[3J\x1b[2J",
- false,
- Some(&shell_job(7)),
- );
- assert_eq!(shell.as_ref(), b"\x1b[3J\x1b[2J");
-
- let alternate =
- maybe_filter_primary_screen_scrollback_clear(b"\x1b[3J\x1b[2J", true, Some(&droid_job));
- assert_eq!(alternate.as_ref(), b"\x1b[3J\x1b[2J");
- }
-
- #[test]
- fn host_theme_restore_waits_for_shell_and_non_alternate_screen() {
- assert!(!should_restore_host_terminal_theme(
- 42,
- 7,
- true,
- Some(&shell_job(7)),
- ));
- assert!(!should_restore_host_terminal_theme(42, 7, false, None));
- assert!(!should_restore_host_terminal_theme(
- 42,
- 7,
- false,
- Some(&crate::platform::ForegroundJob {
- process_group_id: 42,
- processes: vec![crate::platform::ForegroundProcess {
- pid: 42,
- name: "droid".to_string(),
- argv0: Some("droid".to_string()),
- cmdline: Some("droid".to_string()),
- }],
- }),
- ));
- assert!(should_restore_host_terminal_theme(
- 42,
- 7,
- false,
- Some(&shell_job(7)),
- ));
-
- #[cfg(target_os = "macos")]
- assert!(should_restore_host_terminal_theme(
- 7,
- 7,
- false,
- Some(&shell_job(7)),
- ));
-
- #[cfg(not(target_os = "macos"))]
- assert!(!should_restore_host_terminal_theme(
- 7,
- 7,
- false,
- Some(&shell_job(7)),
- ));
- }
-
- #[test]
- fn restore_host_terminal_theme_reapplies_cached_colors() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
- let pane_id = PaneId::from_raw(1);
- let shell_pid = 7;
- let host_theme = crate::terminal_theme::TerminalTheme {
- foreground: Some(crate::terminal_theme::RgbColor {
- r: 0xaa,
- g: 0xbb,
- b: 0xcc,
- }),
- background: Some(crate::terminal_theme::RgbColor {
- r: 0x11,
- g: 0x22,
- b: 0x33,
- }),
- };
-
- pane.apply_host_terminal_theme(host_theme);
- {
- let mut core = pane.core.lock().unwrap();
- core.transient_default_color_owner_pgid = Some(42);
- core.terminal.write(b"\x1b]11;rgb:dd/ee/ff\x1b\\");
- }
- assert_eq!(
- pane_default_theme(&pane).background,
- Some(crate::terminal_theme::RgbColor {
- r: 0xdd,
- g: 0xee,
- b: 0xff,
- })
- );
-
- {
- let mut core = pane.core.lock().unwrap();
- assert!(restore_host_terminal_theme_if_needed(
- &mut core,
- pane_id,
- shell_pid,
- false,
- Some(&shell_job(shell_pid)),
- ));
- }
-
- assert_eq!(pane_default_theme(&pane).background, host_theme.background);
- assert_eq!(pane_default_theme(&pane).foreground, host_theme.foreground);
- }
-
- #[test]
- fn render_leaves_host_default_background_transparent() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
- let host_theme = crate::terminal_theme::TerminalTheme {
- foreground: Some(crate::terminal_theme::RgbColor {
- r: 0xaa,
- g: 0xbb,
- b: 0xcc,
- }),
- background: Some(crate::terminal_theme::RgbColor {
- r: 0x11,
- g: 0x22,
- b: 0x33,
- }),
- };
- pane.apply_host_terminal_theme(host_theme);
- {
- let mut core = pane.core.lock().unwrap();
- core.terminal.write(b"hi");
- }
-
- let backend = ratatui::backend::TestBackend::new(20, 5);
- let mut terminal = ratatui::Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
- .unwrap();
-
- let buffer = terminal.backend().buffer();
- assert_eq!(buffer[(0, 0)].symbol(), "h");
- assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset));
- assert_eq!(buffer[(2, 0)].symbol(), " ");
- assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset));
- }
-
- #[test]
- fn render_keeps_explicit_default_background_when_it_differs_from_host() {
- let (tx, _rx) = mpsc::channel(4);
- let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
- let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
- let host_theme = crate::terminal_theme::TerminalTheme {
- foreground: Some(crate::terminal_theme::RgbColor {
- r: 0xaa,
- g: 0xbb,
- b: 0xcc,
- }),
- background: Some(crate::terminal_theme::RgbColor {
- r: 0x11,
- g: 0x22,
- b: 0x33,
- }),
- };
- pane.apply_host_terminal_theme(host_theme);
- {
- let mut core = pane.core.lock().unwrap();
- core.terminal.write(b"\x1b]11;rgb:44/55/66\x1b\\hi");
- }
-
- let backend = ratatui::backend::TestBackend::new(20, 5);
- let mut terminal = ratatui::Terminal::new(backend).unwrap();
- terminal
- .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
- .unwrap();
-
- let buffer = terminal.backend().buffer();
- let expected_bg = Some(Color::Rgb(0x44, 0x55, 0x66));
- assert_eq!(buffer[(0, 0)].symbol(), "h");
- assert_eq!(buffer[(0, 0)].style().bg, expected_bg);
- assert_eq!(buffer[(2, 0)].symbol(), " ");
- assert_eq!(buffer[(2, 0)].style().bg, expected_bg);
- }
-
#[tokio::test]
async fn focus_events_are_forwarded_when_enabled() {
let (tx, mut rx) = mpsc::channel(4);
@@ -3225,9 +868,9 @@ mod tests {
.mode_set(crate::ghostty::MODE_FOCUS_EVENT, true)
.unwrap();
let runtime = PaneRuntime {
- terminal: Arc::new(PaneTerminal {
- ghostty: GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
- }),
+ terminal: Arc::new(PaneTerminal::new(
+ GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
+ )),
sender: tx,
resize_tx,
current_size: Cell::new((80, 24)),
@@ -3248,9 +891,9 @@ mod tests {
let (resize_tx, _resize_rx) = mpsc::channel(1);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
let runtime = PaneRuntime {
- terminal: Arc::new(PaneTerminal {
- ghostty: GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
- }),
+ terminal: Arc::new(PaneTerminal::new(
+ GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
+ )),
sender: tx,
resize_tx,
current_size: Cell::new((80, 24)),
@@ -3270,126 +913,31 @@ mod tests {
}
#[test]
- fn trim_trailing_blank_rows_drops_empty_viewport_tail() {
- let mut rows = vec!["hello".to_string(), "".to_string(), " ".to_string()];
- trim_trailing_blank_rows(&mut rows);
- assert_eq!(rows, vec!["hello".to_string()]);
- }
-
- #[test]
- fn claude_working_is_sticky_for_short_gap() {
- let now = std::time::Instant::now();
- let mut last_working = None;
-
- let working = stabilize_agent_state(
- Some(Agent::Claude),
- AgentState::Idle,
- AgentState::Working,
- now,
- &mut last_working,
- );
- assert_eq!(working, AgentState::Working);
-
- let still_working = stabilize_agent_state(
- Some(Agent::Claude),
- AgentState::Working,
- AgentState::Idle,
- now + std::time::Duration::from_millis(400),
- &mut last_working,
- );
- assert_eq!(still_working, AgentState::Working);
- }
-
- #[test]
- fn claude_transitions_to_idle_after_hold_expires() {
- let now = std::time::Instant::now();
- let mut last_working = Some(now);
-
- let state = stabilize_agent_state(
- Some(Agent::Claude),
- AgentState::Working,
- AgentState::Idle,
- now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1),
- &mut last_working,
- );
- assert_eq!(state, AgentState::Idle);
- }
-
- #[test]
- fn non_claude_states_are_unchanged() {
- let now = std::time::Instant::now();
- let mut last_working = None;
-
- let state = stabilize_agent_state(
- Some(Agent::Codex),
- AgentState::Working,
- AgentState::Idle,
- now,
- &mut last_working,
- );
- assert_eq!(state, AgentState::Idle);
- }
-
- #[test]
- fn hook_authority_overrides_fallback_for_same_agent() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
-
- assert_eq!(pane.detected_agent, Some(Agent::Pi));
- assert_eq!(pane.fallback_state, AgentState::Idle);
- assert_eq!(pane.effective_agent_label(), Some("pi"));
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn hook_authority_can_override_with_unknown_agent_label() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:custom".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- );
-
- assert_eq!(pane.detected_agent, Some(Agent::Pi));
- assert_eq!(pane.effective_agent_label(), Some("hermes"));
- assert_eq!(pane.effective_known_agent(), None);
- assert_eq!(pane.state, AgentState::Working);
- }
-
- #[test]
- fn hook_authority_survives_detected_agent_changes() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority(
- "herdr:custom".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- );
+ fn transient_process_miss_keeps_current_agent_detected() {
+ let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi));
- pane.set_detected_state(None, AgentState::Unknown);
+ let changed = presence.observe_process_probe(None);
- assert!(pane.hook_authority.is_some());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.effective_agent_label(), Some("hermes"));
- assert_eq!(pane.state, AgentState::Working);
+ assert!(!changed, "one miss should not clear the detected agent");
+ assert_eq!(presence.current_agent(), Some(Agent::Pi));
}
#[test]
- fn release_agent_clears_identity_immediately() {
- let mut pane = PaneState::new();
- pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
+ fn agent_only_clears_after_confirmation_misses() {
+ let mut presence = AgentDetectionPresence::from_agent(Some(Agent::Pi));
- pane.release_agent("herdr:pi", "pi");
+ for attempt in 1..AGENT_MISS_CONFIRMATION_ATTEMPTS {
+ let changed = presence.observe_process_probe(None);
+ assert!(
+ !changed,
+ "miss {attempt} should stay in the confirmation window"
+ );
+ assert_eq!(presence.current_agent(), Some(Agent::Pi));
+ }
- assert!(pane.hook_authority.is_none());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.fallback_state, AgentState::Unknown);
- assert_eq!(pane.state, AgentState::Unknown);
+ let changed = presence.observe_process_probe(None);
+ assert!(changed, "last confirmation miss should clear the agent");
+ assert_eq!(presence.current_agent(), None);
}
#[tokio::test]
diff --git a/src/pane/input.rs b/src/pane/input.rs
new file mode 100644
index 00000000..262e036d
--- /dev/null
+++ b/src/pane/input.rs
@@ -0,0 +1,293 @@
+pub(super) fn ghostty_key_event_from_terminal_key(
+ key: crate::input::TerminalKey,
+) -> Option<crate::ghostty::KeyEvent> {
+ let mut event = crate::ghostty::KeyEvent::new().ok()?;
+ event.set_action(match key.kind {
+ crossterm::event::KeyEventKind::Press => {
+ crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS
+ }
+ crossterm::event::KeyEventKind::Release => {
+ crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE
+ }
+ crossterm::event::KeyEventKind::Repeat => {
+ crate::ghostty::ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_REPEAT
+ }
+ });
+ event.set_mods(ghostty_mods_from_key_modifiers(key.modifiers));
+ event.set_key(ghostty_key_from_crossterm_key_code(
+ key.code,
+ key.shifted_codepoint,
+ )?);
+
+ if let Some(text) = ghostty_key_text(key) {
+ event.set_utf8(&text);
+ } else {
+ event.set_utf8("");
+ }
+
+ if let Some(codepoint) = ghostty_unshifted_codepoint(key) {
+ event.set_unshifted_codepoint(codepoint);
+ }
+
+ Some(event)
+}
+
+pub(super) fn ghostty_prefers_herdr_text_encoding(key: crate::input::TerminalKey) -> bool {
+ matches!(key.code, crossterm::event::KeyCode::Char(_))
+}
+
+pub(super) fn ghostty_mods_from_key_modifiers(modifiers: crossterm::event::KeyModifiers) -> u16 {
+ let mut ghostty_mods = 0u16;
+ if modifiers.contains(crossterm::event::KeyModifiers::SHIFT) {
+ ghostty_mods |= crate::ghostty::MOD_SHIFT;
+ }
+ if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
+ ghostty_mods |= crate::ghostty::MOD_CTRL;
+ }
+ if modifiers.contains(crossterm::event::KeyModifiers::ALT) {
+ ghostty_mods |= crate::ghostty::MOD_ALT;
+ }
+ if modifiers.contains(crossterm::event::KeyModifiers::SUPER) {
+ ghostty_mods |= crate::ghostty::MOD_SUPER;
+ }
+ ghostty_mods
+}
+
+pub(super) fn ghostty_mouse_encoder_for_terminal(
+ terminal: &crate::ghostty::Terminal,
+) -> Option<crate::ghostty::MouseEncoder> {
+ let mut encoder = crate::ghostty::MouseEncoder::new().ok()?;
+ encoder.set_from_terminal(terminal);
+ let cols = terminal.cols().ok()? as u32;
+ let rows = terminal.rows().ok()? as u32;
+ encoder.set_size(cols, rows, 1, 1);
+ Some(encoder)
+}
+
+pub(super) fn ghostty_mouse_event_from_button_kind(
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+) -> Option<crate::ghostty::MouseEvent> {
+ let mut event = crate::ghostty::MouseEvent::new().ok()?;
+ let (action, button) = match kind {
+ crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) => (
+ crate::ghostty::MOUSE_ACTION_PRESS,
+ Some(crate::ghostty::MOUSE_BUTTON_LEFT),
+ ),
+ crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Middle) => (
+ crate::ghostty::MOUSE_ACTION_PRESS,
+ Some(crate::ghostty::MOUSE_BUTTON_MIDDLE),
+ ),
+ crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Right) => (
+ crate::ghostty::MOUSE_ACTION_PRESS,
+ Some(crate::ghostty::MOUSE_BUTTON_RIGHT),
+ ),
+ crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left) => (
+ crate::ghostty::MOUSE_ACTION_RELEASE,
+ Some(crate::ghostty::MOUSE_BUTTON_LEFT),
+ ),
+ crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Middle) => (
+ crate::ghostty::MOUSE_ACTION_RELEASE,
+ Some(crate::ghostty::MOUSE_BUTTON_MIDDLE),
+ ),
+ crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Right) => (
+ crate::ghostty::MOUSE_ACTION_RELEASE,
+ Some(crate::ghostty::MOUSE_BUTTON_RIGHT),
+ ),
+ crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => (
+ crate::ghostty::MOUSE_ACTION_MOTION,
+ Some(crate::ghostty::MOUSE_BUTTON_LEFT),
+ ),
+ crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Middle) => (
+ crate::ghostty::MOUSE_ACTION_MOTION,
+ Some(crate::ghostty::MOUSE_BUTTON_MIDDLE),
+ ),
+ crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Right) => (
+ crate::ghostty::MOUSE_ACTION_MOTION,
+ Some(crate::ghostty::MOUSE_BUTTON_RIGHT),
+ ),
+ _ => return None,
+ };
+ event.set_action(action);
+ if let Some(button) = button {
+ event.set_button(button);
+ } else {
+ event.clear_button();
+ }
+ event.set_mods(ghostty_mods_from_key_modifiers(modifiers));
+ event.set_position(column as f32, row as f32);
+ Some(event)
+}
+
+pub(super) fn ghostty_mouse_event_from_wheel_kind(
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+) -> Option<crate::ghostty::MouseEvent> {
+ let mut event = crate::ghostty::MouseEvent::new().ok()?;
+ event.set_action(crate::ghostty::MOUSE_ACTION_PRESS);
+ let button = match kind {
+ crossterm::event::MouseEventKind::ScrollUp => crate::ghostty::MOUSE_BUTTON_WHEEL_UP,
+ crossterm::event::MouseEventKind::ScrollDown => crate::ghostty::MOUSE_BUTTON_WHEEL_DOWN,
+ crossterm::event::MouseEventKind::ScrollLeft => crate::ghostty::MOUSE_BUTTON_WHEEL_LEFT,
+ crossterm::event::MouseEventKind::ScrollRight => crate::ghostty::MOUSE_BUTTON_WHEEL_RIGHT,
+ _ => return None,
+ };
+ event.set_button(button);
+ event.set_mods(ghostty_mods_from_key_modifiers(modifiers));
+ event.set_position(column as f32, row as f32);
+ Some(event)
+}
+
+fn ghostty_key_text(key: crate::input::TerminalKey) -> Option<String> {
+ match key.code {
+ crossterm::event::KeyCode::Char(c) => Some(
+ key.shifted_codepoint
+ .and_then(char::from_u32)
+ .unwrap_or(c)
+ .to_string(),
+ ),
+ _ => None,
+ }
+}
+
+fn ghostty_unshifted_codepoint(key: crate::input::TerminalKey) -> Option<u32> {
+ match key.code {
+ crossterm::event::KeyCode::Char(c) => Some(c as u32),
+ _ => None,
+ }
+}
+
+fn ghostty_key_from_crossterm_key_code(
+ code: crossterm::event::KeyCode,
+ shifted_codepoint: Option<u32>,
+) -> Option<u32> {
+ use crate::ghostty::ffi;
+ use crossterm::event::KeyCode;
+
+ match code {
+ KeyCode::Backspace => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE),
+ KeyCode::Enter => Some(ffi::GhosttyKey_GHOSTTY_KEY_ENTER),
+ KeyCode::Left => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_LEFT),
+ KeyCode::Right => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_RIGHT),
+ KeyCode::Up => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP),
+ KeyCode::Down => Some(ffi::GhosttyKey_GHOSTTY_KEY_ARROW_DOWN),
+ KeyCode::Home => Some(ffi::GhosttyKey_GHOSTTY_KEY_HOME),
+ KeyCode::End => Some(ffi::GhosttyKey_GHOSTTY_KEY_END),
+ KeyCode::PageUp => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAGE_UP),
+ KeyCode::PageDown => Some(ffi::GhosttyKey_GHOSTTY_KEY_PAGE_DOWN),
+ KeyCode::Tab | KeyCode::BackTab => Some(ffi::GhosttyKey_GHOSTTY_KEY_TAB),
+ KeyCode::Delete => Some(ffi::GhosttyKey_GHOSTTY_KEY_DELETE),
+ KeyCode::Insert => Some(ffi::GhosttyKey_GHOSTTY_KEY_INSERT),
+ KeyCode::Esc => Some(ffi::GhosttyKey_GHOSTTY_KEY_ESCAPE),
+ KeyCode::F(n) => Some(match n {
+ 1 => ffi::GhosttyKey_GHOSTTY_KEY_F1,
+ 2 => ffi::GhosttyKey_GHOSTTY_KEY_F2,
+ 3 => ffi::GhosttyKey_GHOSTTY_KEY_F3,
+ 4 => ffi::GhosttyKey_GHOSTTY_KEY_F4,
+ 5 => ffi::GhosttyKey_GHOSTTY_KEY_F5,
+ 6 => ffi::GhosttyKey_GHOSTTY_KEY_F6,
+ 7 => ffi::GhosttyKey_GHOSTTY_KEY_F7,
+ 8 => ffi::GhosttyKey_GHOSTTY_KEY_F8,
+ 9 => ffi::GhosttyKey_GHOSTTY_KEY_F9,
+ 10 => ffi::GhosttyKey_GHOSTTY_KEY_F10,
+ 11 => ffi::GhosttyKey_GHOSTTY_KEY_F11,
+ 12 => ffi::GhosttyKey_GHOSTTY_KEY_F12,
+ _ => return None,
+ }),
+ KeyCode::Char(c) => ghostty_key_from_char(c, shifted_codepoint),
+ _ => None,
+ }
+}
+
+fn ghostty_key_from_char(c: char, shifted_codepoint: Option<u32>) -> Option<u32> {
+ use crate::ghostty::ffi;
+
+ let base = if let Some(shifted) = shifted_codepoint.and_then(char::from_u32) {
+ ghostty_unshifted_ascii_pair(shifted).unwrap_or(c)
+ } else {
+ c
+ };
+
+ match base.to_ascii_lowercase() {
+ 'a' => Some(ffi::GhosttyKey_GHOSTTY_KEY_A),
+ 'b' => Some(ffi::GhosttyKey_GHOSTTY_KEY_B),
+ 'c' => Some(ffi::GhosttyKey_GHOSTTY_KEY_C),
+ 'd' => Some(ffi::GhosttyKey_GHOSTTY_KEY_D),
+ 'e' => Some(ffi::GhosttyKey_GHOSTTY_KEY_E),
+ 'f' => Some(ffi::GhosttyKey_GHOSTTY_KEY_F),
+ 'g' => Some(ffi::GhosttyKey_GHOSTTY_KEY_G),
+ 'h' => Some(ffi::GhosttyKey_GHOSTTY_KEY_H),
+ 'i' => Some(ffi::GhosttyKey_GHOSTTY_KEY_I),
+ 'j' => Some(ffi::GhosttyKey_GHOSTTY_KEY_J),
+ 'k' => Some(ffi::GhosttyKey_GHOSTTY_KEY_K),
+ 'l' => Some(ffi::GhosttyKey_GHOSTTY_KEY_L),
+ 'm' => Some(ffi::GhosttyKey_GHOSTTY_KEY_M),
+ 'n' => Some(ffi::GhosttyKey_GHOSTTY_KEY_N),
+ 'o' => Some(ffi::GhosttyKey_GHOSTTY_KEY_O),
+ 'p' => Some(ffi::GhosttyKey_GHOSTTY_KEY_P),
+ 'q' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Q),
+ 'r' => Some(ffi::GhosttyKey_GHOSTTY_KEY_R),
+ 's' => Some(ffi::GhosttyKey_GHOSTTY_KEY_S),
+ 't' => Some(ffi::GhosttyKey_GHOSTTY_KEY_T),
+ 'u' => Some(ffi::GhosttyKey_GHOSTTY_KEY_U),
+ 'v' => Some(ffi::GhosttyKey_GHOSTTY_KEY_V),
+ 'w' => Some(ffi::GhosttyKey_GHOSTTY_KEY_W),
+ 'x' => Some(ffi::GhosttyKey_GHOSTTY_KEY_X),
+ 'y' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Y),
+ 'z' => Some(ffi::GhosttyKey_GHOSTTY_KEY_Z),
+ '0' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_0),
+ '1' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_1),
+ '2' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_2),
+ '3' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_3),
+ '4' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_4),
+ '5' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_5),
+ '6' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_6),
+ '7' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_7),
+ '8' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_8),
+ '9' => Some(ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_9),
+ '`' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKQUOTE),
+ '\\' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BACKSLASH),
+ '[' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT),
+ ']' => Some(ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_RIGHT),
+ ',' => Some(ffi::GhosttyKey_GHOSTTY_KEY_COMMA),
+ '=' => Some(ffi::GhosttyKey_GHOSTTY_KEY_EQUAL),
+ '-' => Some(ffi::GhosttyKey_GHOSTTY_KEY_MINUS),
+ '.' => Some(ffi::GhosttyKey_GHOSTTY_KEY_PERIOD),
+ '\'' => Some(ffi::GhosttyKey_GHOSTTY_KEY_QUOTE),
+ ';' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SEMICOLON),
+ '/' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SLASH),
+ ' ' => Some(ffi::GhosttyKey_GHOSTTY_KEY_SPACE),
+ _ => None,
+ }
+}
+
+fn ghostty_unshifted_ascii_pair(c: char) -> Option<char> {
+ Some(match c {
+ '!' => '1',
+ '@' => '2',
+ '#' => '3',
+ '$' => '4',
+ '%' => '5',
+ '^' => '6',
+ '&' => '7',
+ '*' => '8',
+ '(' => '9',
+ ')' => '0',
+ '_' => '-',
+ '+' => '=',
+ '{' => '[',
+ '}' => ']',
+ '|' => '\\',
+ ':' => ';',
+ '"' => '\'',
+ '<' => ',',
+ '>' => '.',
+ '?' => '/',
+ '~' => '`',
+ _ => return None,
+ })
+}
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
new file mode 100644
index 00000000..b5278125
--- /dev/null
+++ b/src/pane/osc.rs
@@ -0,0 +1,706 @@
+use std::borrow::Cow;
+
+use tracing::info;
+
+use crate::layout::PaneId;
+
+use super::terminal::GhosttyPaneCore;
+
+#[derive(Debug, Default)]
+pub(super) struct DefaultColorOscTracker {
+ state: DefaultColorOscTrackerState,
+ body: Vec<u8>,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+enum DefaultColorOscTrackerState {
+ #[default]
+ Ground,
+ Escape,
+ OscBody,
+ OscEscape,
+}
+
+impl DefaultColorOscTracker {
+ pub(super) fn observe(&mut self, bytes: &[u8]) -> bool {
+ let mut saw_default_color_set = false;
+
+ for &byte in bytes {
+ match self.state {
+ DefaultColorOscTrackerState::Ground => {
+ if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::Escape;
+ }
+ }
+ DefaultColorOscTrackerState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::OscBody;
+ } else if byte == 0x1b {
+ self.state = DefaultColorOscTrackerState::Escape;
+ } else {
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ }
+ DefaultColorOscTrackerState::OscBody => match byte {
+ 0x07 => {
+ saw_default_color_set |= is_default_color_set_osc(&self.body);
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ 0x1b => self.state = DefaultColorOscTrackerState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ DefaultColorOscTrackerState::OscEscape => {
+ if byte == b'\\' {
+ saw_default_color_set |= is_default_color_set_osc(&self.body);
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = DefaultColorOscTrackerState::OscBody;
+ }
+ }
+ }
+
+ if self.body.len() > 1024 {
+ self.body.clear();
+ self.state = DefaultColorOscTrackerState::Ground;
+ }
+ }
+
+ saw_default_color_set
+ }
+}
+
+fn is_default_color_set_osc(body: &[u8]) -> bool {
+ let Some(separator) = body.iter().position(|byte| *byte == b';') else {
+ return false;
+ };
+ let command = &body[..separator];
+ let value = &body[separator + 1..];
+ matches!(command, b"10" | b"11") && !value.is_empty() && value != b"?"
+}
+
+/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies
+/// while still bounding memory against stream garbage.
+const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+enum Osc52ForwarderState {
+ #[default]
+ Ground,
+ Escape,
+ OscBody,
+ OscEscape,
+}
+
+/// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the
+/// main loop can re-emit them. `libghostty-vt` drops `.clipboard_contents`,
+/// so child clipboard writes never reach the host terminal unless we forward
+/// them ourselves.
+#[derive(Debug, Default)]
+pub(super) struct Osc52Forwarder {
+ state: Osc52ForwarderState,
+ body: Vec<u8>,
+ pending: Vec<Vec<u8>>,
+}
+
+impl Osc52Forwarder {
+ pub(super) fn observe(&mut self, bytes: &[u8]) {
+ for &byte in bytes {
+ match self.state {
+ Osc52ForwarderState::Ground => {
+ if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ }
+ }
+ Osc52ForwarderState::Escape => {
+ if byte == b']' {
+ self.body.clear();
+ self.state = Osc52ForwarderState::OscBody;
+ } else if byte == 0x1b {
+ self.state = Osc52ForwarderState::Escape;
+ } else {
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ Osc52ForwarderState::OscBody => match byte {
+ 0x07 => {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ 0x1b => self.state = Osc52ForwarderState::OscEscape,
+ _ => self.body.push(byte),
+ },
+ Osc52ForwarderState::OscEscape => {
+ if byte == b'\\' {
+ self.finalize();
+ self.state = Osc52ForwarderState::Ground;
+ } else {
+ self.body.push(0x1b);
+ self.body.push(byte);
+ self.state = Osc52ForwarderState::OscBody;
+ }
+ }
+ }
+
+ if self.body.len() > OSC52_MAX_PAYLOAD_BYTES {
+ self.body.clear();
+ self.state = Osc52ForwarderState::Ground;
+ }
+ }
+ }
+
+ fn finalize(&mut self) {
+ if let Some(content) = parse_osc52_clipboard_write(&self.body) {
+ self.pending.push(content);
+ }
+ self.body.clear();
+ }
+
+ pub(super) fn drain_pending(&mut self) -> Vec<Vec<u8>> {
+ std::mem::take(&mut self.pending)
+ }
+}
+
+/// Accepts `52;c;<base64>` and `52;;<base64>`.
+/// Queries (`?`) are rejected because herdr has no reply path.
+/// The payload must decode as base64 before it is forwarded.
+fn parse_osc52_clipboard_write(body: &[u8]) -> Option<Vec<u8>> {
+ use base64::Engine;
+
+ let rest = body.strip_prefix(b"52;")?;
+ let sep = rest.iter().position(|b| *b == b';')?;
+ let selector = &rest[..sep];
+ let data = &rest[sep + 1..];
+ if !(selector.is_empty() || selector == b"c") || data == b"?" {
+ return None;
+ }
+ base64::engine::general_purpose::STANDARD.decode(data).ok()
+}
+
+fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool {
+ job.processes.iter().any(|process| process.pid == shell_pid)
+}
+
+pub(super) fn current_transient_default_color_owner(shell_pid: u32) -> Option<u32> {
+ let job = crate::detect::foreground_job(shell_pid)?;
+ (!foreground_job_is_shell(&job, shell_pid)).then_some(job.process_group_id)
+}
+
+fn foreground_job_uses_droid_scrollback_compat(job: &crate::platform::ForegroundJob) -> bool {
+ job.processes.iter().any(|process| {
+ process.name.eq_ignore_ascii_case("droid")
+ || process
+ .argv0
+ .as_deref()
+ .is_some_and(|argv0| argv0.eq_ignore_ascii_case("droid"))
+ || process.cmdline.as_deref().is_some_and(|cmdline| {
+ cmdline.eq_ignore_ascii_case("droid")
+ || cmdline.starts_with("droid ")
+ || cmdline.to_ascii_lowercase().contains("/droid")
+ })
+ })
+}
+
+pub(super) fn contains_scrollback_clear_sequence(bytes: &[u8]) -> bool {
+ bytes.windows(4).any(|window| window == b"\x1b[3J")
+ || bytes.windows(5).any(|window| window == b"\x1b[?3J")
+}
+
+fn strip_scrollback_clear_sequences<'a>(bytes: &'a [u8]) -> Cow<'a, [u8]> {
+ if !contains_scrollback_clear_sequence(bytes) {
+ return Cow::Borrowed(bytes);
+ }
+
+ let mut filtered = Vec::with_capacity(bytes.len());
+ let mut index = 0;
+ while index < bytes.len() {
+ let remaining = &bytes[index..];
+ if remaining.starts_with(b"\x1b[3J") {
+ index += 4;
+ continue;
+ }
+ if remaining.starts_with(b"\x1b[?3J") {
+ index += 5;
+ continue;
+ }
+ filtered.push(bytes[index]);
+ index += 1;
+ }
+
+ Cow::Owned(filtered)
+}
+
+pub(super) fn maybe_filter_primary_screen_scrollback_clear<'a>(
+ bytes: &'a [u8],
+ alternate_screen: bool,
+ foreground_job: Option<&crate::platform::ForegroundJob>,
+) -> Cow<'a, [u8]> {
+ // Droid redraws its primary-screen TUI with CSI 3 J, which erases pane
+ // scrollback inside herdr. Keep the hack scoped to Droid on the primary
+ // screen so normal terminal clear-history behavior still works elsewhere.
+ if alternate_screen
+ || !contains_scrollback_clear_sequence(bytes)
+ || !foreground_job.is_some_and(foreground_job_uses_droid_scrollback_compat)
+ {
+ return Cow::Borrowed(bytes);
+ }
+
+ strip_scrollback_clear_sequences(bytes)
+}
+
+#[cfg(target_os = "macos")]
+pub(super) fn should_restore_host_terminal_theme(
+ owner_pgid: u32,
+ shell_pid: u32,
+ alternate_screen: bool,
+ foreground_job: Option<&crate::platform::ForegroundJob>,
+) -> bool {
+ if alternate_screen {
+ return false;
+ }
+
+ let Some(foreground_job) = foreground_job else {
+ return false;
+ };
+
+ let _ = owner_pgid;
+ foreground_job_is_shell(foreground_job, shell_pid)
+}
+
+#[cfg(not(target_os = "macos"))]
+pub(super) fn should_restore_host_terminal_theme(
+ owner_pgid: u32,
+ shell_pid: u32,
+ alternate_screen: bool,
+ foreground_job: Option<&crate::platform::ForegroundJob>,
+) -> bool {
+ if alternate_screen {
+ return false;
+ }
+
+ let Some(foreground_job) = foreground_job else {
+ return false;
+ };
+
+ foreground_job.process_group_id != owner_pgid
+ && foreground_job_is_shell(foreground_job, shell_pid)
+}
+
+pub(super) fn write_host_terminal_theme(
+ terminal: &mut crate::ghostty::Terminal,
+ theme: crate::terminal_theme::TerminalTheme,
+) {
+ if let Some(color) = theme.foreground {
+ let sequence = crate::terminal_theme::osc_set_default_color_sequence(
+ crate::terminal_theme::DefaultColorKind::Foreground,
+ color,
+ );
+ terminal.write(sequence.as_bytes());
+ }
+ if let Some(color) = theme.background {
+ let sequence = crate::terminal_theme::osc_set_default_color_sequence(
+ crate::terminal_theme::DefaultColorKind::Background,
+ color,
+ );
+ terminal.write(sequence.as_bytes());
+ }
+}
+
+pub(super) fn restore_host_terminal_theme_if_needed(
+ core: &mut GhosttyPaneCore,
+ pane_id: PaneId,
+ shell_pid: u32,
+ alternate_screen: bool,
+ foreground_job: Option<&crate::platform::ForegroundJob>,
+) -> bool {
+ let Some(owner_pgid) = core.transient_default_color_owner_pgid else {
+ return false;
+ };
+ if core.host_terminal_theme.is_empty() {
+ return false;
+ }
+ if !should_restore_host_terminal_theme(owner_pgid, shell_pid, alternate_screen, foreground_job)
+ {
+ return false;
+ }
+
+ core.transient_default_color_owner_pgid = None;
+ write_host_terminal_theme(&mut core.terminal, core.host_terminal_theme);
+ info!(
+ pane = pane_id.raw(),
+ owner_pgid, "restored host terminal default colors after transient override"
+ );
+ true
+}
+
+#[cfg(test)]
+mod tests {
+ use tokio::sync::mpsc;
+
+ use super::*;
+ use crate::layout::PaneId;
+
+ fn pane_default_theme(
+ pane: &super::super::GhosttyPaneTerminal,
+ ) -> crate::terminal_theme::TerminalTheme {
+ let mut core = pane.core.lock().unwrap();
+ let super::super::terminal::GhosttyPaneCore {
+ terminal,
+ render_state,
+ ..
+ } = &mut *core;
+ render_state.update(terminal).unwrap();
+ let colors = render_state.colors().unwrap();
+ crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: colors.foreground.r,
+ g: colors.foreground.g,
+ b: colors.foreground.b,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: colors.background.r,
+ g: colors.background.g,
+ b: colors.background.b,
+ }),
+ }
+ }
+
+ fn shell_job(shell_pid: u32) -> crate::platform::ForegroundJob {
+ crate::platform::ForegroundJob {
+ process_group_id: shell_pid,
+ processes: vec![crate::platform::ForegroundProcess {
+ pid: shell_pid,
+ name: "zsh".to_string(),
+ argv0: Some("zsh".to_string()),
+ cmdline: Some("zsh".to_string()),
+ }],
+ }
+ }
+
+ #[test]
+ fn default_color_tracker_detects_split_osc_11_sequences() {
+ let mut tracker = DefaultColorOscTracker::default();
+
+ assert!(!tracker.observe(b"\x1b]11;rgb:11/22"));
+ assert!(tracker.observe(b"/33\x1b\\"));
+ }
+
+ #[test]
+ fn default_color_tracker_ignores_osc_queries() {
+ let mut tracker = DefaultColorOscTracker::default();
+
+ assert!(!tracker.observe(b"\x1b]10;?\x1b\\"));
+ assert!(!tracker.observe(b"\x1b]11;?\x07"));
+ }
+
+ #[test]
+ fn osc52_forwarder_detects_write_with_bel() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVsbG8=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hello".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_detects_write_with_st() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hello".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_detects_empty_selector_form() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;;aGVsbG8=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hello".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_accepts_clear_clipboard() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![Vec::<u8>::new()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_query() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;?\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_empty_selector_query() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;;?\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_other_kinds() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;p;aGk=\x07");
+ fw.observe(b"\x1b]52;s;aGk=\x07");
+ fw.observe(b"\x1b]52;q;aGk=\x07");
+ fw.observe(b"\x1b]52;0;aGk=\x07");
+ fw.observe(b"\x1b]52;7;aGk=\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_invalid_base64() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;%%%\x07");
+ fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_ignores_non_osc52() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]11;?\x07");
+ fw.observe(b"\x1b]0;title\x07");
+ fw.observe(b"\x1b]8;;https://example.com\x1b\\");
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn osc52_forwarder_handles_split_sequence_mid_payload() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGVs");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"bG8gd29y");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"bGQ=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hello world".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_handles_split_before_bel() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hi".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_handles_split_between_esc_and_backslash() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=\x1b");
+ assert!(fw.drain_pending().is_empty());
+ fw.observe(b"\\");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hi".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_payload_size_limit() {
+ let mut fw = Osc52Forwarder::default();
+ let mut huge = Vec::with_capacity(OSC52_MAX_PAYLOAD_BYTES + 32);
+ huge.extend_from_slice(b"\x1b]52;c;");
+ huge.extend(std::iter::repeat_n(b'A', OSC52_MAX_PAYLOAD_BYTES + 16));
+ huge.push(0x07);
+ fw.observe(&huge);
+ assert!(fw.drain_pending().is_empty());
+
+ fw.observe(b"\x1b]52;c;aGk=\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hi".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_recovers_after_garbage() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hi".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_multiple_in_one_chunk() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07");
+ let pending = fw.drain_pending();
+ assert_eq!(pending, vec![b"hi".to_vec(), b"bye".to_vec()]);
+ }
+
+ #[test]
+ fn osc52_forwarder_drain_clears_pending() {
+ let mut fw = Osc52Forwarder::default();
+ fw.observe(b"\x1b]52;c;aGk=\x07");
+ assert_eq!(fw.drain_pending(), vec![b"hi".to_vec()]);
+ assert!(fw.drain_pending().is_empty());
+ }
+
+ #[test]
+ fn droid_scrollback_compat_matches_process_name_and_cmdline() {
+ let name_only = crate::platform::ForegroundJob {
+ process_group_id: 42,
+ processes: vec![crate::platform::ForegroundProcess {
+ pid: 42,
+ name: "droid".to_string(),
+ argv0: None,
+ cmdline: Some("/opt/factory/droid --resume".to_string()),
+ }],
+ };
+ assert!(foreground_job_uses_droid_scrollback_compat(&name_only));
+
+ let cmdline_only = crate::platform::ForegroundJob {
+ process_group_id: 42,
+ processes: vec![crate::platform::ForegroundProcess {
+ pid: 42,
+ name: "bun".to_string(),
+ argv0: Some("bun".to_string()),
+ cmdline: Some("/home/can/.local/bin/droid --resume".to_string()),
+ }],
+ };
+ assert!(foreground_job_uses_droid_scrollback_compat(&cmdline_only));
+
+ let shell = shell_job(7);
+ assert!(!foreground_job_uses_droid_scrollback_compat(&shell));
+ }
+
+ #[test]
+ fn strip_scrollback_clear_sequences_removes_ed3_only() {
+ let filtered = strip_scrollback_clear_sequences(b"a\x1b[3Jb\x1b[?3Jc\x1b[2Jd");
+ assert_eq!(filtered.as_ref(), b"abc\x1b[2Jd");
+ }
+
+ #[test]
+ fn primary_screen_droid_compat_ignores_scrollback_clear_only_for_droid() {
+ let droid_job = crate::platform::ForegroundJob {
+ process_group_id: 42,
+ processes: vec![crate::platform::ForegroundProcess {
+ pid: 42,
+ name: "droid".to_string(),
+ argv0: Some("droid".to_string()),
+ cmdline: Some("droid".to_string()),
+ }],
+ };
+
+ let filtered = maybe_filter_primary_screen_scrollback_clear(
+ b"\x1b[3J\x1b[2J",
+ false,
+ Some(&droid_job),
+ );
+ assert_eq!(filtered.as_ref(), b"\x1b[2J");
+
+ let shell = maybe_filter_primary_screen_scrollback_clear(
+ b"\x1b[3J\x1b[2J",
+ false,
+ Some(&shell_job(7)),
+ );
+ assert_eq!(shell.as_ref(), b"\x1b[3J\x1b[2J");
+
+ let alternate =
+ maybe_filter_primary_screen_scrollback_clear(b"\x1b[3J\x1b[2J", true, Some(&droid_job));
+ assert_eq!(alternate.as_ref(), b"\x1b[3J\x1b[2J");
+ }
+
+ #[test]
+ fn host_theme_restore_waits_for_shell_and_non_alternate_screen() {
+ assert!(!should_restore_host_terminal_theme(
+ 42,
+ 7,
+ true,
+ Some(&shell_job(7)),
+ ));
+ assert!(!should_restore_host_terminal_theme(42, 7, false, None));
+ assert!(!should_restore_host_terminal_theme(
+ 42,
+ 7,
+ false,
+ Some(&crate::platform::ForegroundJob {
+ process_group_id: 42,
+ processes: vec![crate::platform::ForegroundProcess {
+ pid: 42,
+ name: "droid".to_string(),
+ argv0: Some("droid".to_string()),
+ cmdline: Some("droid".to_string()),
+ }],
+ }),
+ ));
+ assert!(should_restore_host_terminal_theme(
+ 42,
+ 7,
+ false,
+ Some(&shell_job(7)),
+ ));
+
+ #[cfg(target_os = "macos")]
+ assert!(should_restore_host_terminal_theme(
+ 7,
+ 7,
+ false,
+ Some(&shell_job(7)),
+ ));
+
+ #[cfg(not(target_os = "macos"))]
+ assert!(!should_restore_host_terminal_theme(
+ 7,
+ 7,
+ false,
+ Some(&shell_job(7)),
+ ));
+ }
+
+ #[test]
+ fn restore_host_terminal_theme_reapplies_cached_colors() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane = super::super::GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ let pane_id = PaneId::from_raw(1);
+ let shell_pid = 7;
+ let host_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ }),
+ };
+
+ pane.apply_host_terminal_theme(host_theme);
+ {
+ let mut core = pane.core.lock().unwrap();
+ core.transient_default_color_owner_pgid = Some(42);
+ core.terminal.write(b"\x1b]11;rgb:dd/ee/ff\x1b\\");
+ }
+ assert_eq!(
+ pane_default_theme(&pane).background,
+ Some(crate::terminal_theme::RgbColor {
+ r: 0xdd,
+ g: 0xee,
+ b: 0xff,
+ })
+ );
+
+ {
+ let mut core = pane.core.lock().unwrap();
+ assert!(restore_host_terminal_theme_if_needed(
+ &mut core,
+ pane_id,
+ shell_pid,
+ false,
+ Some(&shell_job(shell_pid)),
+ ));
+ }
+
+ assert_eq!(pane_default_theme(&pane).background, host_theme.background);
+ assert_eq!(pane_default_theme(&pane).foreground, host_theme.foreground);
+ }
+}
diff --git a/src/pane/state.rs b/src/pane/state.rs
new file mode 100644
index 00000000..973f660a
--- /dev/null
+++ b/src/pane/state.rs
@@ -0,0 +1,313 @@
+use crate::detect::{Agent, AgentState};
+
+const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct HookAuthority {
+ pub source: String,
+ pub agent_label: String,
+ pub state: AgentState,
+ pub message: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EffectiveStateChange {
+ pub previous_agent_label: Option<String>,
+ pub previous_known_agent: Option<Agent>,
+ pub previous_state: AgentState,
+ pub agent_label: Option<String>,
+ pub known_agent: Option<Agent>,
+ pub state: AgentState,
+}
+
+/// Observable state for a single pane.
+/// This is the only part of a pane that workspace logic and tests need.
+pub struct PaneState {
+ pub detected_agent: Option<Agent>,
+ pub fallback_state: AgentState,
+ pub hook_authority: Option<HookAuthority>,
+ pub state: AgentState,
+ /// Whether the user has seen this pane since its last state change to Idle.
+ /// False = "Done" (agent finished while user was in another workspace).
+ pub seen: bool,
+}
+
+impl PaneState {
+ pub fn new() -> Self {
+ Self {
+ detected_agent: None,
+ fallback_state: AgentState::Unknown,
+ hook_authority: None,
+ state: AgentState::Unknown,
+ seen: true,
+ }
+ }
+
+ pub fn set_detected_state(
+ &mut self,
+ agent: Option<Agent>,
+ fallback_state: AgentState,
+ ) -> Option<EffectiveStateChange> {
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ self.detected_agent = agent;
+ self.fallback_state = fallback_state;
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn set_hook_authority(
+ &mut self,
+ source: String,
+ agent_label: String,
+ state: AgentState,
+ message: Option<String>,
+ ) -> Option<EffectiveStateChange> {
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ self.hook_authority = Some(HookAuthority {
+ source,
+ agent_label,
+ state,
+ message,
+ });
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> {
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ let should_clear = self
+ .hook_authority
+ .as_ref()
+ .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
+ if !should_clear {
+ return None;
+ }
+ self.hook_authority = None;
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn release_agent(
+ &mut self,
+ source: &str,
+ agent_label: &str,
+ ) -> Option<EffectiveStateChange> {
+ let Some(current_agent_label) = self.effective_agent_label() else {
+ return None;
+ };
+ if current_agent_label != agent_label {
+ return None;
+ }
+
+ if self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.agent_label != agent_label || authority.source != source
+ }) {
+ return None;
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ self.detected_agent = None;
+ self.fallback_state = AgentState::Unknown;
+ self.hook_authority = None;
+ self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
+ }
+
+ pub fn effective_agent_label(&self) -> Option<&str> {
+ self.hook_authority
+ .as_ref()
+ .map(|authority| authority.agent_label.as_str())
+ .or_else(|| self.detected_agent.map(crate::detect::agent_label))
+ }
+
+ pub fn effective_known_agent(&self) -> Option<Agent> {
+ if let Some(authority) = &self.hook_authority {
+ return crate::detect::parse_agent_label(&authority.agent_label);
+ }
+ self.detected_agent
+ }
+
+ fn recompute_effective_state(
+ &mut self,
+ previous_agent_label: Option<String>,
+ previous_known_agent: Option<Agent>,
+ previous_state: AgentState,
+ ) -> Option<EffectiveStateChange> {
+ let state = self
+ .hook_authority
+ .as_ref()
+ .map(|authority| authority.state)
+ .unwrap_or(self.fallback_state);
+ let agent_label = self.effective_agent_label().map(str::to_string);
+ let known_agent = self.effective_known_agent();
+
+ if previous_agent_label == agent_label && previous_state == state {
+ return None;
+ }
+
+ self.state = state;
+ Some(EffectiveStateChange {
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ agent_label,
+ known_agent,
+ state,
+ })
+ }
+}
+
+pub(crate) fn stabilize_agent_state(
+ agent: Option<Agent>,
+ previous: AgentState,
+ raw: AgentState,
+ now: std::time::Instant,
+ last_claude_working_at: &mut Option<std::time::Instant>,
+) -> AgentState {
+ if agent != Some(Agent::Claude) {
+ return raw;
+ }
+
+ match raw {
+ AgentState::Working => {
+ *last_claude_working_at = Some(now);
+ AgentState::Working
+ }
+ AgentState::Blocked => AgentState::Blocked,
+ AgentState::Idle if previous == AgentState::Working => {
+ if last_claude_working_at
+ .is_some_and(|last_working| now.duration_since(last_working) < CLAUDE_WORKING_HOLD)
+ {
+ AgentState::Working
+ } else {
+ AgentState::Idle
+ }
+ }
+ _ => raw,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn claude_working_is_sticky_for_short_gap() {
+ let now = std::time::Instant::now();
+ let mut last_working = None;
+
+ let working = stabilize_agent_state(
+ Some(Agent::Claude),
+ AgentState::Idle,
+ AgentState::Working,
+ now,
+ &mut last_working,
+ );
+ assert_eq!(working, AgentState::Working);
+
+ let still_working = stabilize_agent_state(
+ Some(Agent::Claude),
+ AgentState::Working,
+ AgentState::Idle,
+ now + std::time::Duration::from_millis(400),
+ &mut last_working,
+ );
+ assert_eq!(still_working, AgentState::Working);
+ }
+
+ #[test]
+ fn claude_transitions_to_idle_after_hold_expires() {
+ let now = std::time::Instant::now();
+ let mut last_working = Some(now);
+
+ let state = stabilize_agent_state(
+ Some(Agent::Claude),
+ AgentState::Working,
+ AgentState::Idle,
+ now + CLAUDE_WORKING_HOLD + std::time::Duration::from_millis(1),
+ &mut last_working,
+ );
+ assert_eq!(state, AgentState::Idle);
+ }
+
+ #[test]
+ fn non_claude_states_are_unchanged() {
+ let now = std::time::Instant::now();
+ let mut last_working = None;
+
+ let state = stabilize_agent_state(
+ Some(Agent::Codex),
+ AgentState::Working,
+ AgentState::Idle,
+ now,
+ &mut last_working,
+ );
+ assert_eq!(state, AgentState::Idle);
+ }
+
+ #[test]
+ fn hook_authority_overrides_fallback_for_same_agent() {
+ let mut pane = PaneState::new();
+ pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
+
+ assert_eq!(pane.detected_agent, Some(Agent::Pi));
+ assert_eq!(pane.fallback_state, AgentState::Idle);
+ assert_eq!(pane.effective_agent_label(), Some("pi"));
+ assert_eq!(pane.state, AgentState::Working);
+ }
+
+ #[test]
+ fn hook_authority_can_override_with_unknown_agent_label() {
+ let mut pane = PaneState::new();
+ pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ pane.set_hook_authority(
+ "herdr:custom".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ );
+
+ assert_eq!(pane.detected_agent, Some(Agent::Pi));
+ assert_eq!(pane.effective_agent_label(), Some("hermes"));
+ assert_eq!(pane.effective_known_agent(), None);
+ assert_eq!(pane.state, AgentState::Working);
+ }
+
+ #[test]
+ fn hook_authority_survives_detected_agent_changes() {
+ let mut pane = PaneState::new();
+ pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ pane.set_hook_authority(
+ "herdr:custom".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ );
+
+ pane.set_detected_state(None, AgentState::Unknown);
+
+ assert!(pane.hook_authority.is_some());
+ assert_eq!(pane.detected_agent, None);
+ assert_eq!(pane.effective_agent_label(), Some("hermes"));
+ assert_eq!(pane.state, AgentState::Working);
+ }
+
+ #[test]
+ fn release_agent_clears_identity_immediately() {
+ let mut pane = PaneState::new();
+ pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
+
+ pane.release_agent("herdr:pi", "pi");
+
+ assert!(pane.hook_authority.is_none());
+ assert_eq!(pane.detected_agent, None);
+ assert_eq!(pane.fallback_state, AgentState::Unknown);
+ assert_eq!(pane.state, AgentState::Unknown);
+ }
+}
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
new file mode 100644
index 00000000..796220b9
--- /dev/null
+++ b/src/pane/terminal.rs
@@ -0,0 +1,1307 @@
+use std::borrow::Cow;
+use std::sync::Mutex;
+
+use bytes::Bytes;
+use ratatui::style::{Color, Modifier, Style};
+use ratatui::{layout::Rect, Frame};
+use tokio::sync::mpsc;
+use tracing::{debug, error};
+use unicode_width::UnicodeWidthStr;
+
+use crate::layout::PaneId;
+
+use super::{
+ input::{
+ ghostty_key_event_from_terminal_key, ghostty_mouse_encoder_for_terminal,
+ ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_wheel_kind,
+ ghostty_prefers_herdr_text_encoding,
+ },
+ osc::{
+ contains_scrollback_clear_sequence, current_transient_default_color_owner,
+ maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed,
+ write_host_terminal_theme, DefaultColorOscTracker, Osc52Forwarder,
+ },
+};
+
+const DEFAULT_DETECTION_ROWS: usize = 24;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ScrollMetrics {
+ pub offset_from_bottom: usize,
+ pub max_offset_from_bottom: usize,
+ pub viewport_rows: usize,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct InputState {
+ pub alternate_screen: bool,
+ pub application_cursor: bool,
+ pub bracketed_paste: bool,
+ pub focus_reporting: bool,
+ pub mouse_protocol_mode: crate::input::MouseProtocolMode,
+ pub mouse_protocol_encoding: crate::input::MouseProtocolEncoding,
+ pub mouse_alternate_scroll: bool,
+}
+
+impl InputState {
+ pub fn mouse_reporting_enabled(self) -> bool {
+ self.mouse_protocol_mode.reporting_enabled()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct ProcessBytesResult {
+ pub request_render: bool,
+ pub clipboard_writes: Vec<Vec<u8>>,
+}
+
+pub(crate) struct GhosttyPaneTerminal {
+ pub core: Mutex<GhosttyPaneCore>,
+ key_encoder: Mutex<crate::ghostty::KeyEncoder>,
+}
+
+pub(crate) struct GhosttyPaneCore {
+ pub terminal: crate::ghostty::Terminal,
+ pub render_state: crate::ghostty::RenderState,
+ pub host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ pub transient_default_color_owner_pgid: Option<u32>,
+ pub default_color_tracker: DefaultColorOscTracker,
+ pub osc52_forwarder: Osc52Forwarder,
+}
+
+pub(crate) struct PaneTerminal {
+ pub(crate) ghostty: GhosttyPaneTerminal,
+}
+
+impl PaneTerminal {
+ pub(crate) fn new(ghostty: GhosttyPaneTerminal) -> Self {
+ Self { ghostty }
+ }
+
+ pub fn process_pty_bytes(
+ &self,
+ pane_id: PaneId,
+ shell_pid: u32,
+ bytes: &[u8],
+ response_writer: &mpsc::Sender<Bytes>,
+ ) -> ProcessBytesResult {
+ self.ghostty
+ .process_pty_bytes(pane_id, shell_pid, bytes, response_writer)
+ }
+
+ pub fn resize(&self, rows: u16, cols: u16) {
+ self.ghostty.resize(rows, cols);
+ }
+
+ pub fn scroll_up(&self, lines: usize) {
+ self.ghostty.scroll_up(lines);
+ }
+
+ pub fn scroll_down(&self, lines: usize) {
+ self.ghostty.scroll_down(lines);
+ }
+
+ pub fn scroll_reset(&self) {
+ self.ghostty.scroll_reset();
+ }
+
+ pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
+ self.ghostty.set_scroll_offset_from_bottom(lines);
+ }
+
+ pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
+ self.ghostty.scroll_metrics()
+ }
+
+ pub fn input_state(&self) -> Option<InputState> {
+ self.ghostty.input_state()
+ }
+
+ pub fn visible_text(&self) -> String {
+ self.ghostty.visible_text()
+ }
+
+ pub fn detection_text(&self) -> String {
+ self.ghostty.detection_text()
+ }
+
+ pub fn recent_text(&self, lines: usize) -> String {
+ self.ghostty.recent_text(lines)
+ }
+
+ pub fn recent_unwrapped_text(&self, lines: usize) -> String {
+ self.ghostty.recent_unwrapped_text(lines)
+ }
+
+ pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
+ self.ghostty.extract_selection(selection)
+ }
+
+ pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
+ self.ghostty.render(frame, area, show_cursor);
+ }
+
+ pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
+ self.ghostty.apply_host_terminal_theme(theme);
+ }
+
+ pub fn has_transient_default_color_override(&self) -> bool {
+ self.ghostty.has_transient_default_color_override()
+ }
+
+ pub fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool {
+ self.ghostty
+ .maybe_restore_host_terminal_theme(pane_id, shell_pid)
+ }
+
+ pub fn keyboard_protocol(
+ &self,
+ fallback: crate::input::KeyboardProtocol,
+ ) -> crate::input::KeyboardProtocol {
+ self.ghostty.keyboard_protocol().unwrap_or(fallback)
+ }
+
+ pub fn encode_terminal_key(
+ &self,
+ key: crate::input::TerminalKey,
+ protocol: crate::input::KeyboardProtocol,
+ ) -> Vec<u8> {
+ self.ghostty.encode_terminal_key(key, protocol)
+ }
+
+ pub fn encode_mouse_button(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ self.ghostty
+ .encode_mouse_button(kind, column, row, modifiers)
+ }
+
+ pub fn encode_mouse_wheel(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ self.ghostty
+ .encode_mouse_wheel(kind, column, row, modifiers)
+ }
+}
+
+impl GhosttyPaneTerminal {
+ pub fn new(
+ mut terminal: crate::ghostty::Terminal,
+ response_writer: mpsc::Sender<Bytes>,
+ ) -> std::io::Result<Self> {
+ terminal
+ .set_write_pty_callback(move |bytes| {
+ let _ = response_writer.try_send(Bytes::copy_from_slice(bytes));
+ })
+ .map_err(|e| std::io::Error::other(e.to_string()))?;
+
+ let render_state =
+ crate::ghostty::RenderState::new().map_err(|e| std::io::Error::other(e.to_string()))?;
+ let mut key_encoder =
+ crate::ghostty::KeyEncoder::new().map_err(|e| std::io::Error::other(e.to_string()))?;
+ key_encoder.set_from_terminal(&terminal);
+ Ok(Self {
+ core: Mutex::new(GhosttyPaneCore {
+ terminal,
+ render_state,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ transient_default_color_owner_pgid: None,
+ default_color_tracker: DefaultColorOscTracker::default(),
+ osc52_forwarder: Osc52Forwarder::default(),
+ }),
+ key_encoder: Mutex::new(key_encoder),
+ })
+ }
+
+ pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
+ if let Ok(mut core) = self.core.lock() {
+ core.host_terminal_theme = theme;
+ core.transient_default_color_owner_pgid = None;
+ write_host_terminal_theme(&mut core.terminal, theme);
+ }
+ }
+
+ pub fn has_transient_default_color_override(&self) -> bool {
+ self.core
+ .lock()
+ .map(|core| core.transient_default_color_owner_pgid.is_some())
+ .unwrap_or(false)
+ }
+
+ pub fn maybe_restore_host_terminal_theme(&self, pane_id: PaneId, shell_pid: u32) -> bool {
+ let foreground_job = crate::detect::foreground_job(shell_pid);
+ let Ok(mut core) = self.core.lock() else {
+ return false;
+ };
+
+ let alternate_screen = core
+ .terminal
+ .active_screen()
+ .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate)
+ .unwrap_or(false);
+ restore_host_terminal_theme_if_needed(
+ &mut core,
+ pane_id,
+ shell_pid,
+ alternate_screen,
+ foreground_job.as_ref(),
+ )
+ }
+
+ pub fn process_pty_bytes(
+ &self,
+ pane_id: PaneId,
+ shell_pid: u32,
+ bytes: &[u8],
+ response_writer: &mpsc::Sender<Bytes>,
+ ) -> ProcessBytesResult {
+ let Ok(mut core) = self.core.lock() else {
+ error!(pane = pane_id.raw(), "ghostty core lock poisoned in reader");
+ return ProcessBytesResult {
+ request_render: false,
+ clipboard_writes: Vec::new(),
+ };
+ };
+
+ if shell_pid > 0 && core.default_color_tracker.observe(bytes) {
+ if let Some(owner_pgid) = current_transient_default_color_owner(shell_pid) {
+ core.transient_default_color_owner_pgid = Some(owner_pgid);
+ debug!(
+ pane = pane_id.raw(),
+ owner_pgid, "tracked transient default color override"
+ );
+ }
+ }
+
+ core.osc52_forwarder.observe(bytes);
+ let clipboard_writes = core.osc52_forwarder.drain_pending();
+
+ let alternate_screen = core
+ .terminal
+ .active_screen()
+ .map(|screen| screen == crate::ghostty::ActiveScreen::Alternate)
+ .unwrap_or(false);
+ let filtered_bytes = if shell_pid > 0 {
+ let foreground_job = (!alternate_screen && contains_scrollback_clear_sequence(bytes))
+ .then(|| crate::detect::foreground_job(shell_pid))
+ .flatten();
+ maybe_filter_primary_screen_scrollback_clear(
+ bytes,
+ alternate_screen,
+ foreground_job.as_ref(),
+ )
+ } else {
+ Cow::Borrowed(bytes)
+ };
+ if filtered_bytes.len() != bytes.len() {
+ debug!(
+ pane = pane_id.raw(),
+ shell_pid, "ignored scrollback clear sequence for droid compatibility"
+ );
+ }
+
+ core.terminal.write(filtered_bytes.as_ref());
+ if let Ok(mut key_encoder) = self.key_encoder.lock() {
+ key_encoder.set_from_terminal(&core.terminal);
+ }
+ let synchronized_output = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_SYNCHRONIZED_OUTPUT)
+ .unwrap_or(false);
+ let _ = response_writer;
+ ProcessBytesResult {
+ request_render: !synchronized_output,
+ clipboard_writes,
+ }
+ }
+
+ pub fn resize(&self, rows: u16, cols: u16) {
+ if let Ok(mut core) = self.core.lock() {
+ let _ = core.terminal.resize(cols, rows);
+ }
+ }
+
+ pub fn scroll_up(&self, lines: usize) {
+ if let Ok(mut core) = self.core.lock() {
+ core.terminal.scroll_viewport_delta(-(lines as isize));
+ }
+ }
+
+ pub fn scroll_down(&self, lines: usize) {
+ if let Ok(mut core) = self.core.lock() {
+ core.terminal.scroll_viewport_delta(lines as isize);
+ }
+ }
+
+ pub fn scroll_reset(&self) {
+ if let Ok(mut core) = self.core.lock() {
+ core.terminal.scroll_viewport_bottom();
+ }
+ }
+
+ pub fn set_scroll_offset_from_bottom(&self, lines: usize) {
+ if let Ok(mut core) = self.core.lock() {
+ core.terminal.scroll_viewport_bottom();
+ if lines > 0 {
+ core.terminal.scroll_viewport_delta(-(lines as isize));
+ }
+ }
+ }
+
+ pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
+ let Ok(core) = self.core.lock() else {
+ return None;
+ };
+ let scrollbar = core.terminal.scrollbar().ok()?;
+ Some(ScrollMetrics {
+ offset_from_bottom: scrollbar
+ .total
+ .saturating_sub(scrollbar.offset + scrollbar.len),
+ max_offset_from_bottom: scrollbar.total.saturating_sub(scrollbar.len),
+ viewport_rows: scrollbar.len,
+ })
+ }
+
+ pub fn keyboard_protocol(&self) -> Option<crate::input::KeyboardProtocol> {
+ let Ok(core) = self.core.lock() else {
+ return None;
+ };
+ Some(crate::input::KeyboardProtocol::from_kitty_flags(
+ core.terminal.kitty_keyboard_flags().ok()? as u16,
+ ))
+ }
+
+ pub fn input_state(&self) -> Option<InputState> {
+ let Ok(core) = self.core.lock() else {
+ return None;
+ };
+ let alternate_screen =
+ core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate;
+ let application_cursor = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS)
+ .ok()?;
+ let bracketed_paste = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_BRACKETED_PASTE)
+ .ok()?;
+ let focus_reporting = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_FOCUS_EVENT)
+ .ok()?;
+ let mouse_sgr = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_MOUSE_SGR)
+ .ok()?;
+ let mouse_utf8 = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_MOUSE_UTF8)
+ .ok()?;
+ let mouse_alternate_scroll = core
+ .terminal
+ .mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL)
+ .ok()?;
+ let mouse_protocol_mode = if core.terminal.mode_get(1003).ok()? {
+ crate::input::MouseProtocolMode::AnyMotion
+ } else if core.terminal.mode_get(1002).ok()? {
+ crate::input::MouseProtocolMode::ButtonMotion
+ } else if core.terminal.mode_get(1000).ok()? {
+ crate::input::MouseProtocolMode::PressRelease
+ } else if core.terminal.mode_get(9).ok()? {
+ crate::input::MouseProtocolMode::Press
+ } else {
+ crate::input::MouseProtocolMode::None
+ };
+ let mouse_protocol_encoding = if mouse_sgr {
+ crate::input::MouseProtocolEncoding::Sgr
+ } else if mouse_utf8 {
+ crate::input::MouseProtocolEncoding::Utf8
+ } else {
+ crate::input::MouseProtocolEncoding::Default
+ };
+ Some(InputState {
+ alternate_screen,
+ application_cursor,
+ bracketed_paste,
+ focus_reporting,
+ mouse_protocol_mode,
+ mouse_protocol_encoding,
+ mouse_alternate_scroll,
+ })
+ }
+
+ pub fn encode_terminal_key(
+ &self,
+ key: crate::input::TerminalKey,
+ protocol: crate::input::KeyboardProtocol,
+ ) -> Vec<u8> {
+ if ghostty_prefers_herdr_text_encoding(key) {
+ return crate::input::encode_terminal_key(key, protocol);
+ }
+
+ let Some(event) = ghostty_key_event_from_terminal_key(key) else {
+ return crate::input::encode_terminal_key(key, protocol);
+ };
+
+ let Ok(mut encoder) = self.key_encoder.lock() else {
+ return crate::input::encode_terminal_key(key, protocol);
+ };
+ match encoder.encode(&event) {
+ Ok(bytes) if !bytes.is_empty() => bytes,
+ Ok(_) | Err(_) => crate::input::encode_terminal_key(key, protocol),
+ }
+ }
+
+ pub fn encode_mouse_button(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ let Ok(core) = self.core.lock() else {
+ return None;
+ };
+ let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
+ let event = ghostty_mouse_event_from_button_kind(kind, column, row, modifiers)?;
+ encoder.encode(&event).ok()
+ }
+
+ pub fn encode_mouse_wheel(
+ &self,
+ kind: crossterm::event::MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: crossterm::event::KeyModifiers,
+ ) -> Option<Vec<u8>> {
+ let Ok(core) = self.core.lock() else {
+ return None;
+ };
+ let mut encoder = ghostty_mouse_encoder_for_terminal(&core.terminal)?;
+ let event = ghostty_mouse_event_from_wheel_kind(kind, column, row, modifiers)?;
+ encoder.encode(&event).ok()
+ }
+
+ pub fn visible_text(&self) -> String {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|mut core| ghostty_visible_text(&mut core).ok())
+ .unwrap_or_default()
+ }
+
+ pub fn detection_text(&self) -> String {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|core| ghostty_detection_text(&core).ok())
+ .unwrap_or_default()
+ }
+
+ pub fn recent_text(&self, lines: usize) -> String {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|core| ghostty_recent_text(&core, lines).ok())
+ .unwrap_or_default()
+ }
+
+ pub fn recent_unwrapped_text(&self, lines: usize) -> String {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|core| ghostty_recent_text_unwrapped(&core, lines).ok())
+ .unwrap_or_default()
+ }
+
+ pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> {
+ self.core
+ .lock()
+ .ok()
+ .and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok())
+ }
+
+ pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
+ let Ok(mut core) = self.core.lock() else {
+ return;
+ };
+ let host_theme = core.host_terminal_theme;
+ let GhosttyPaneCore {
+ terminal,
+ render_state,
+ ..
+ } = &mut *core;
+ if render_state.update(terminal).is_err() {
+ return;
+ }
+ let colors = render_state.colors().ok();
+ let default_bg = colors.and_then(|c| ghostty_default_bg(c.background, host_theme));
+ let default_fg = colors.map(|c| ghostty_color(c.foreground));
+
+ let mut row_iterator = match crate::ghostty::RowIterator::new() {
+ Ok(iterator) => iterator,
+ Err(_) => return,
+ };
+ let mut row_cells = match crate::ghostty::RowCells::new() {
+ Ok(cells) => cells,
+ Err(_) => return,
+ };
+ {
+ let buf = frame.buffer_mut();
+ let mut rows = match render_state.populate_row_iterator(&mut row_iterator) {
+ Ok(rows) => rows,
+ Err(_) => return,
+ };
+ let mut y = 0u16;
+ while y < area.height && rows.next() {
+ let mut cells = match rows.populate_cells(&mut row_cells) {
+ Ok(cells) => cells,
+ Err(_) => break,
+ };
+ let mut x = 0u16;
+ while x < area.width && cells.next() {
+ let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow);
+ let style = ghostty_cell_style(&cells, default_fg, default_bg);
+ let symbol = ghostty_buffer_symbol(&cells, wide)
+ .unwrap_or_else(|_| ghostty_blank_symbol_for_width(wide).to_string());
+ let cell = &mut buf[(area.x + x, area.y + y)];
+ cell.reset();
+ cell.set_symbol(&symbol);
+ cell.set_style(style);
+ x += 1;
+ }
+ while x < area.width {
+ let cell = &mut buf[(area.x + x, area.y + y)];
+ ghostty_reset_cell(cell, default_fg, default_bg);
+ x += 1;
+ }
+ y += 1;
+ }
+ while y < area.height {
+ for x in 0..area.width {
+ let cell = &mut buf[(area.x + x, area.y + y)];
+ ghostty_reset_cell(cell, default_fg, default_bg);
+ }
+ y += 1;
+ }
+ }
+
+ if show_cursor && render_state.cursor_visible().ok() == Some(true) {
+ if let Ok(Some(cursor)) = render_state.cursor_viewport() {
+ if cursor.x < area.width && cursor.y < area.height {
+ frame.set_cursor_position((area.x + cursor.x, area.y + cursor.y));
+ }
+ }
+ }
+ }
+}
+
+fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
+ let GhosttyPaneCore {
+ terminal,
+ render_state,
+ ..
+ } = core;
+ render_state.update(terminal)?;
+ let mut row_iterator = crate::ghostty::RowIterator::new()?;
+ let mut row_cells = crate::ghostty::RowCells::new()?;
+ let mut rows = render_state.populate_row_iterator(&mut row_iterator)?;
+ let mut lines = Vec::new();
+ while rows.next() {
+ let mut cells = rows.populate_cells(&mut row_cells)?;
+ lines.push(ghostty_line_from_cells(&mut cells)?);
+ }
+ trim_trailing_blank_rows(&mut lines);
+ Ok(lines_to_text(lines))
+}
+
+fn ghostty_detection_text(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
+ let lines = core
+ .terminal
+ .rows()
+ .ok()
+ .map(|rows| usize::from(rows).max(1))
+ .unwrap_or(DEFAULT_DETECTION_ROWS);
+ ghostty_recent_text(core, lines)
+}
+
+fn ghostty_recent_text(
+ core: &GhosttyPaneCore,
+ lines: usize,
+) -> Result<String, crate::ghostty::Error> {
+ let total_rows = core.terminal.total_rows()?;
+ let cols = core.terminal.cols()?;
+ let start = total_rows.saturating_sub(lines);
+ let mut rows = Vec::with_capacity(total_rows.saturating_sub(start));
+ for y in start..total_rows {
+ rows.push(ghostty_screen_row(core, cols, y as u32)?);
+ }
+ trim_trailing_blank_rows(&mut rows);
+ Ok(recent_text_from_rows(&rows, lines))
+}
+
+fn ghostty_recent_text_unwrapped(
+ core: &GhosttyPaneCore,
+ lines: usize,
+) -> Result<String, crate::ghostty::Error> {
+ let total_rows = core.terminal.total_rows()?;
+ let cols = core.terminal.cols()?;
+ if total_rows == 0 || cols == 0 {
+ return Ok(String::new());
+ }
+ let start = total_rows.saturating_sub(lines) as u32;
+ let end = (total_rows.saturating_sub(1)) as u32;
+ core.terminal
+ .read_text_screen((0, start), (cols.saturating_sub(1), end), false)
+}
+
+fn ghostty_extract_selection(
+ core: &mut GhosttyPaneCore,
+ selection: &crate::selection::Selection,
+) -> Result<String, crate::ghostty::Error> {
+ let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells();
+ core.terminal
+ .read_text_screen((start_col, start_row), (end_col, end_row), false)
+}
+
+fn ghostty_screen_row(
+ core: &GhosttyPaneCore,
+ cols: u16,
+ y: u32,
+) -> Result<String, crate::ghostty::Error> {
+ let mut line = String::new();
+ for x in 0..cols {
+ let graphemes = core.terminal.screen_graphemes(x, y)?;
+ if graphemes.is_empty() {
+ line.push(' ');
+ } else {
+ for codepoint in graphemes {
+ if let Some(ch) = char::from_u32(codepoint) {
+ line.push(ch);
+ }
+ }
+ }
+ }
+ Ok(line.trim_end().to_string())
+}
+
+fn ghostty_line_from_cells(
+ cells: &mut crate::ghostty::RowCellIter<'_>,
+) -> Result<String, crate::ghostty::Error> {
+ let mut line = String::new();
+ while cells.next() {
+ line.push_str(&ghostty_cell_symbol(cells)?);
+ }
+ Ok(line.trim_end().to_string())
+}
+
+fn ghostty_cell_symbol(
+ cells: &crate::ghostty::RowCellIter<'_>,
+) -> Result<String, crate::ghostty::Error> {
+ let graphemes = cells.graphemes()?;
+ if graphemes.is_empty() {
+ return Ok(" ".to_string());
+ }
+ let mut text = String::new();
+ for codepoint in graphemes {
+ if let Some(ch) = char::from_u32(codepoint) {
+ text.push(ch);
+ }
+ }
+ if text.is_empty() {
+ text.push(' ');
+ }
+ Ok(text)
+}
+
+pub(super) fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) -> &'static str {
+ match wide {
+ crate::ghostty::CellWide::Wide => " ",
+ crate::ghostty::CellWide::SpacerTail => "",
+ crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => " ",
+ }
+}
+
+pub(super) fn ghostty_normalize_buffer_symbol(
+ symbol: &str,
+ wide: crate::ghostty::CellWide,
+) -> String {
+ let expected_width = match wide {
+ crate::ghostty::CellWide::Wide => 2,
+ crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => 1,
+ crate::ghostty::CellWide::SpacerTail => 0,
+ };
+ let actual_width = symbol.width();
+ if actual_width == expected_width {
+ return symbol.to_string();
+ }
+
+ if wide == crate::ghostty::CellWide::Narrow && actual_width == 2 {
+ return symbol.to_string();
+ }
+
+ ghostty_blank_symbol_for_width(wide).to_string()
+}
+
+fn ghostty_buffer_symbol(
+ cells: &crate::ghostty::RowCellIter<'_>,
+ wide: crate::ghostty::CellWide,
+) -> Result<String, crate::ghostty::Error> {
+ let symbol = match wide {
+ crate::ghostty::CellWide::SpacerTail => String::new(),
+ crate::ghostty::CellWide::SpacerHead => " ".to_string(),
+ crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => {
+ ghostty_cell_symbol(cells)?
+ }
+ };
+ Ok(ghostty_normalize_buffer_symbol(&symbol, wide))
+}
+
+fn ghostty_reset_cell(
+ cell: &mut ratatui::buffer::Cell,
+ default_fg: Option<Color>,
+ default_bg: Option<Color>,
+) {
+ cell.reset();
+ cell.set_symbol(" ");
+ if let Some(bg) = default_bg {
+ cell.set_bg(bg);
+ }
+ if let Some(fg) = default_fg {
+ cell.set_fg(fg);
+ }
+}
+
+fn ghostty_cell_style(
+ cells: &crate::ghostty::RowCellIter<'_>,
+ default_fg: Option<Color>,
+ default_bg: Option<Color>,
+) -> Style {
+ let style_data = cells.style().unwrap_or_default();
+ let mut fg = cells
+ .fg_color()
+ .ok()
+ .flatten()
+ .map(ghostty_color)
+ .or(default_fg);
+ let mut bg = cells
+ .bg_color()
+ .ok()
+ .flatten()
+ .map(ghostty_color)
+ .or(default_bg);
+ if style_data.invisible {
+ fg = bg.or(default_bg);
+ }
+ if style_data.inverse {
+ std::mem::swap(&mut fg, &mut bg);
+ }
+
+ let mut style = Style::default();
+ if let Some(fg) = fg {
+ style = style.fg(fg);
+ }
+ if let Some(bg) = bg {
+ style = style.bg(bg);
+ }
+
+ let mut modifiers = Modifier::empty();
+ if style_data.bold {
+ modifiers |= Modifier::BOLD;
+ }
+ if style_data.italic {
+ modifiers |= Modifier::ITALIC;
+ }
+ if style_data.faint {
+ modifiers |= Modifier::DIM;
+ }
+ if style_data.blink {
+ modifiers |= Modifier::SLOW_BLINK;
+ }
+ if style_data.underlined {
+ modifiers |= Modifier::UNDERLINED;
+ }
+ if style_data.strikethrough {
+ modifiers |= Modifier::CROSSED_OUT;
+ }
+ style.add_modifier(modifiers)
+}
+
+fn ghostty_default_bg(
+ color: crate::ghostty::RgbColor,
+ host_theme: crate::terminal_theme::TerminalTheme,
+) -> Option<Color> {
+ if host_theme.background == Some(terminal_theme_color(color)) {
+ None
+ } else {
+ Some(ghostty_color(color))
+ }
+}
+
+fn terminal_theme_color(color: crate::ghostty::RgbColor) -> crate::terminal_theme::RgbColor {
+ crate::terminal_theme::RgbColor {
+ r: color.r,
+ g: color.g,
+ b: color.b,
+ }
+}
+
+fn ghostty_color(color: crate::ghostty::RgbColor) -> Color {
+ Color::Rgb(color.r, color.g, color.b)
+}
+
+fn lines_to_text(lines: Vec<String>) -> String {
+ let text = lines.join("\n");
+ if text.is_empty() {
+ text
+ } else {
+ format!("{text}\n")
+ }
+}
+
+pub(super) fn trim_trailing_blank_rows(rows: &mut Vec<String>) {
+ while rows.last().is_some_and(|row| row.trim().is_empty()) {
+ rows.pop();
+ }
+}
+
+fn recent_text_from_rows(rows: &[String], lines: usize) -> String {
+ let start = rows.len().saturating_sub(lines);
+ let text = rows[start..].join("\n");
+ if text.is_empty() {
+ text
+ } else {
+ format!("{text}\n")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ratatui::{layout::Rect, style::Color};
+ use tokio::sync::mpsc;
+
+ fn write_numbered_lines(terminal: &mut crate::ghostty::Terminal, count: usize) {
+ for i in 0..count {
+ terminal.write(format!("{i:06}\r\n").as_bytes());
+ }
+ }
+
+ #[test]
+ fn ghostty_render_can_suppress_cursor_position() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut first_terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ first_terminal.write(b"left");
+ let first = GhosttyPaneTerminal::new(first_terminal, tx.clone()).unwrap();
+
+ let mut second_terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ second_terminal.write(b"r\r\nb");
+ let second = GhosttyPaneTerminal::new(second_terminal, tx).unwrap();
+
+ let backend = ratatui::backend::TestBackend::new(40, 5);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| {
+ first.render(frame, Rect::new(0, 0, 20, 5), true);
+ second.render(frame, Rect::new(20, 0, 20, 5), false);
+ })
+ .unwrap();
+
+ terminal.backend_mut().assert_cursor_position((4, 0));
+ }
+
+ #[test]
+ fn ghostty_keyboard_protocol_tracks_live_terminal_flags() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[>3u");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ assert_eq!(
+ pane.keyboard_protocol(),
+ Some(crate::input::KeyboardProtocol::Kitty { flags: 3 })
+ );
+ }
+
+ #[test]
+ fn ghostty_plain_text_chars_still_encode_as_text() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Char('a'),
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+
+ assert_eq!(encoded, b"a");
+ }
+
+ #[test]
+ fn ghostty_char_keys_still_use_herdr_encoding() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[>1u");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Char('a'),
+ crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+
+ assert_eq!(encoded, vec![1]);
+ }
+
+ #[test]
+ fn ghostty_key_encoding_honors_application_cursor_mode() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal
+ .mode_set(crate::ghostty::MODE_APPLICATION_CURSOR_KEYS, true)
+ .unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Up,
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+
+ assert_eq!(encoded, b"\x1bOA");
+ }
+
+ #[test]
+ fn ghostty_key_encoder_updates_after_terminal_mode_changes() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let before = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Up,
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+ assert_eq!(before, b"\x1b[A");
+
+ pane.process_pty_bytes(pane_id, 0, b"\x1b[?1h", &tx);
+
+ let after = pane.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Up,
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+ assert_eq!(after, b"\x1bOA");
+ }
+
+ #[test]
+ fn ghostty_key_encoder_updates_after_kitty_flag_changes() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+ let key = crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Enter,
+ crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
+ );
+
+ let before = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+ pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u", &tx);
+ let after = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy);
+
+ assert_ne!(before, after);
+ assert_eq!(after, b"\x1b[13;6u");
+ }
+
+ #[test]
+ fn ghostty_key_encoders_are_isolated_per_pane() {
+ let (tx, _rx) = mpsc::channel(4);
+ let first = GhosttyPaneTerminal::new(
+ crate::ghostty::Terminal::new(80, 24, 0).unwrap(),
+ tx.clone(),
+ )
+ .unwrap();
+ let second = GhosttyPaneTerminal::new(
+ crate::ghostty::Terminal::new(80, 24, 0).unwrap(),
+ tx.clone(),
+ )
+ .unwrap();
+
+ first.process_pty_bytes(PaneId::from_raw(1), 0, b"\x1b[?1h", &tx);
+
+ let first_encoded = first.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Up,
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+ let second_encoded = second.encode_terminal_key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Up,
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ crate::input::KeyboardProtocol::Legacy,
+ );
+
+ assert_eq!(first_encoded, b"\x1bOA");
+ assert_eq!(second_encoded, b"\x1b[A");
+ }
+
+ #[test]
+ fn ghostty_mouse_button_encoding_uses_live_terminal_state() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[?1000h\x1b[?1006h");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_mouse_button(
+ crossterm::event::MouseEventKind::Up(crossterm::event::MouseButton::Left),
+ 11,
+ 9,
+ crossterm::event::KeyModifiers::empty(),
+ );
+
+ assert_eq!(encoded.as_deref(), Some(&b"\x1b[<0;12;10m"[..]));
+ }
+
+ #[test]
+ fn ghostty_mouse_drag_encoding_uses_motion_reporting_state() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ terminal.write(b"\x1b[?1002h\x1b[?1006h");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let encoded = pane.encode_mouse_button(
+ crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left),
+ 4,
+ 6,
+ crossterm::event::KeyModifiers::SHIFT,
+ );
+
+ assert_eq!(encoded.as_deref(), Some(&b"\x1b[<36;5;7M"[..]));
+ }
+
+ #[test]
+ fn ghostty_normalize_buffer_symbol_prefers_grapheme_width_when_metadata_disagrees() {
+ const WIDE_GRAPHEME: &str = "🙂";
+ const VS16_GRAPHEME: &str = "⚠️";
+ const EMOJI_GRAPHEME: &str = "💳";
+
+ assert_eq!(
+ ghostty_normalize_buffer_symbol(WIDE_GRAPHEME, crate::ghostty::CellWide::Wide),
+ WIDE_GRAPHEME
+ );
+ assert_eq!(
+ ghostty_normalize_buffer_symbol("a", crate::ghostty::CellWide::Wide),
+ " "
+ );
+ assert_eq!(
+ ghostty_normalize_buffer_symbol("⌨️", crate::ghostty::CellWide::Narrow),
+ "⌨️"
+ );
+ assert_eq!(
+ ghostty_normalize_buffer_symbol(VS16_GRAPHEME, crate::ghostty::CellWide::Narrow),
+ VS16_GRAPHEME
+ );
+ assert_eq!(
+ ghostty_normalize_buffer_symbol(EMOJI_GRAPHEME, crate::ghostty::CellWide::Narrow),
+ EMOJI_GRAPHEME
+ );
+ assert_eq!(
+ ghostty_normalize_buffer_symbol(" ", crate::ghostty::CellWide::SpacerTail),
+ ""
+ );
+ assert_eq!(
+ ghostty_normalize_buffer_symbol("xx", crate::ghostty::CellWide::SpacerHead),
+ " "
+ );
+ }
+
+ #[test]
+ fn pane_scrollback_controls_reach_top_without_ui_interference() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap();
+ write_numbered_lines(&mut terminal, 1000);
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let before = pane.scroll_metrics().expect("scroll metrics before scroll");
+ assert!(before.max_offset_from_bottom > 0);
+ assert_eq!(before.offset_from_bottom, 0);
+
+ pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom);
+
+ let after = pane.scroll_metrics().expect("scroll metrics after scroll");
+ assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom);
+ assert!(pane.visible_text().contains("000000"));
+ }
+
+ #[test]
+ fn detection_text_stays_at_bottom_when_viewport_is_scrolled() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap();
+ write_numbered_lines(&mut terminal, 10);
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ let bottom_snapshot = pane.detection_text();
+ assert_eq!(bottom_snapshot, pane.recent_text(3));
+ assert!(bottom_snapshot.contains("000009"));
+
+ let before = pane.scroll_metrics().expect("scroll metrics before scroll");
+ pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom);
+
+ assert!(pane.visible_text().contains("000000"));
+ assert_eq!(pane.detection_text(), bottom_snapshot);
+ }
+
+ #[test]
+ fn extract_selection_reads_screen_rows_not_current_viewport() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(8, 3, 1024).unwrap();
+ write_numbered_lines(&mut terminal, 8);
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ pane.set_scroll_offset_from_bottom(3);
+ let metrics = pane
+ .scroll_metrics()
+ .expect("scroll metrics after initial scroll");
+ let mut selection =
+ crate::selection::Selection::anchor(PaneId::from_raw(1), 0, 0, Some(metrics));
+ selection.drag(5, 2, Rect::new(0, 0, 8, 3), Some(metrics));
+
+ pane.scroll_reset();
+
+ let text = pane
+ .extract_selection(&selection)
+ .expect("selection should extract text");
+ assert_eq!(text, "000003\n000004\n000005");
+ }
+
+ #[test]
+ fn recent_unwrapped_text_ignores_soft_wraps() {
+ let (tx, _rx) = mpsc::channel(4);
+ let mut terminal = crate::ghostty::Terminal::new(5, 3, 100).unwrap();
+ terminal.write(b"ABCDEFGHIJ");
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+
+ assert_eq!(pane.recent_text(3), "ABCDE\nFGHIJ\n");
+ assert_eq!(pane.recent_unwrapped_text(3), "ABCDEFGHIJ");
+ }
+
+ #[test]
+ fn synchronized_output_suppresses_intermediate_render_requests_until_batch_ends() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
+ let pane_terminal = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ let begin = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026h", &tx);
+ assert!(!begin.request_render);
+
+ let body = pane_terminal.process_pty_bytes(pane_id, 0, b"hello", &tx);
+ assert!(!body.request_render);
+
+ let end = pane_terminal.process_pty_bytes(pane_id, 0, b"\x1b[?2026l", &tx);
+ assert!(end.request_render);
+ }
+
+ #[test]
+ fn render_leaves_host_default_background_transparent() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ let host_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ }),
+ };
+ pane.apply_host_terminal_theme(host_theme);
+ {
+ let mut core = pane.core.lock().unwrap();
+ core.terminal.write(b"hi");
+ }
+
+ let backend = ratatui::backend::TestBackend::new(20, 5);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
+ .unwrap();
+
+ let buffer = terminal.backend().buffer();
+ assert_eq!(buffer[(0, 0)].symbol(), "h");
+ assert_eq!(buffer[(0, 0)].style().bg, Some(Color::Reset));
+ assert_eq!(buffer[(2, 0)].symbol(), " ");
+ assert_eq!(buffer[(2, 0)].style().bg, Some(Color::Reset));
+ }
+
+ #[test]
+ fn render_keeps_explicit_default_background_when_it_differs_from_host() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(20, 5, 0).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap();
+ let host_theme = crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ }),
+ };
+ pane.apply_host_terminal_theme(host_theme);
+ {
+ let mut core = pane.core.lock().unwrap();
+ core.terminal.write(b"\x1b]11;rgb:44/55/66\x1b\\hi");
+ }
+
+ let backend = ratatui::backend::TestBackend::new(20, 5);
+ let mut terminal = ratatui::Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| pane.render(frame, Rect::new(0, 0, 20, 5), false))
+ .unwrap();
+
+ let buffer = terminal.backend().buffer();
+ let expected_bg = Some(Color::Rgb(0x44, 0x55, 0x66));
+ assert_eq!(buffer[(0, 0)].symbol(), "h");
+ assert_eq!(buffer[(0, 0)].style().bg, expected_bg);
+ assert_eq!(buffer[(2, 0)].symbol(), " ");
+ assert_eq!(buffer[(2, 0)].style().bg, expected_bg);
+ }
+
+ #[test]
+ fn trim_trailing_blank_rows_drops_empty_viewport_tail() {
+ let mut rows = vec!["hello".to_string(), "".to_string(), " ".to_string()];
+ trim_trailing_blank_rows(&mut rows);
+ assert_eq!(rows, vec!["hello".to_string()]);
+ }
+}
diff --git a/src/persist.rs b/src/persist.rs
index 6704dd17..1753d679 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -2,1054 +2,12 @@
//!
//! Stored at `~/.config/herdr/session.json`.
-use std::collections::HashMap;
-use std::path::PathBuf;
-use std::sync::atomic::AtomicBool;
-use std::sync::Arc;
-
-use ratatui::layout::Direction;
-use serde::{Deserialize, Serialize};
-use tracing::{error, info, warn};
-
-use tokio::sync::{mpsc, Notify};
-
-use crate::events::AppEvent;
-use crate::layout::{Node, PaneId, TileLayout};
-use crate::pane::{PaneRuntime, PaneState};
-use crate::workspace::Workspace;
-
-/// Current snapshot format version.
-const SNAPSHOT_VERSION: u32 = 3;
-
-/// Serializable snapshot of the entire herdr session.
-#[derive(Serialize, Deserialize)]
-pub struct SessionSnapshot {
- /// Format version — used to detect incompatible changes.
- #[serde(default)]
- pub version: u32,
- pub workspaces: Vec<WorkspaceSnapshot>,
- pub active: Option<usize>,
- pub selected: usize,
- #[serde(default)]
- pub agent_panel_scope: crate::app::state::AgentPanelScope,
- #[serde(default)]
- pub sidebar_width: Option<u16>,
- #[serde(default)]
- pub sidebar_section_split: Option<f32>,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct WorkspaceSnapshot {
- #[serde(default)]
- pub id: Option<String>,
- #[serde(default)]
- pub custom_name: Option<String>,
- pub identity_cwd: PathBuf,
- pub tabs: Vec<TabSnapshot>,
- #[serde(default)]
- pub active_tab: usize,
-}
-
-#[derive(Deserialize)]
-struct LegacyWorkspaceSnapshot {
- #[serde(default)]
- custom_name: Option<String>,
- layout: LayoutSnapshot,
- panes: HashMap<u32, PaneSnapshot>,
- zoomed: bool,
- #[serde(default)]
- focused: Option<u32>,
- #[serde(default)]
- root_pane: Option<u32>,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct TabSnapshot {
- #[serde(default)]
- pub custom_name: Option<String>,
- pub layout: LayoutSnapshot,
- pub panes: HashMap<u32, PaneSnapshot>,
- pub zoomed: bool,
- #[serde(default)]
- pub focused: Option<u32>,
- #[serde(default)]
- pub root_pane: Option<u32>,
-}
-
-#[derive(Serialize, Deserialize)]
-pub struct PaneSnapshot {
- pub cwd: PathBuf,
-}
-
-/// Serializable BSP tree.
-#[derive(Serialize, Deserialize)]
-pub enum LayoutSnapshot {
- Pane(u32),
- Split {
- direction: DirectionSnapshot,
- ratio: f32,
- first: Box<LayoutSnapshot>,
- second: Box<LayoutSnapshot>,
- },
-}
-
-#[derive(Serialize, Deserialize)]
-pub enum DirectionSnapshot {
- Horizontal,
- Vertical,
-}
-
-impl From<LegacyWorkspaceSnapshot> for WorkspaceSnapshot {
- fn from(snap: LegacyWorkspaceSnapshot) -> Self {
- let identity_cwd = legacy_identity_cwd(&snap);
- let tab = TabSnapshot {
- custom_name: None,
- layout: snap.layout,
- panes: snap.panes,
- zoomed: snap.zoomed,
- focused: snap.focused,
- root_pane: snap.root_pane,
- };
-
- Self {
- id: None,
- custom_name: snap.custom_name,
- identity_cwd,
- tabs: vec![tab],
- active_tab: 0,
- }
- }
-}
-
-#[derive(Deserialize)]
-struct RawSessionSnapshot {
- #[serde(default)]
- version: u32,
- #[serde(default)]
- workspaces: Vec<serde_json::Value>,
- #[serde(default)]
- active: Option<usize>,
- #[serde(default)]
- selected: usize,
- #[serde(default)]
- agent_panel_scope: crate::app::state::AgentPanelScope,
- #[serde(default)]
- sidebar_width: Option<u16>,
- #[serde(default)]
- sidebar_section_split: Option<f32>,
-}
-
-fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> {
- Ok(SessionSnapshot {
- version: raw.version,
- workspaces: raw
- .workspaces
- .into_iter()
- .map(migrate_workspace)
- .collect::<Result<Vec<_>, _>>()?,
- active: raw.active,
- selected: raw.selected,
- agent_panel_scope: raw.agent_panel_scope,
- sidebar_width: raw.sidebar_width,
- sidebar_section_split: raw.sidebar_section_split,
- })
-}
-
-fn migrate_workspace(raw: serde_json::Value) -> Result<WorkspaceSnapshot, String> {
- if raw.get("identity_cwd").is_some() {
- return serde_json::from_value(raw).map_err(|e| e.to_string());
- }
-
- if raw.get("layout").is_some() {
- let legacy =
- serde_json::from_value::<LegacyWorkspaceSnapshot>(raw).map_err(|e| e.to_string())?;
- return Ok(legacy.into());
- }
-
- Err("workspace snapshot is neither current nor legacy format".to_string())
-}
-
-fn legacy_identity_cwd(snap: &LegacyWorkspaceSnapshot) -> PathBuf {
- let root_pane = snap
- .root_pane
- .or_else(|| first_pane_id_in_layout(&snap.layout));
-
- root_pane
- .and_then(|pane_id| snap.panes.get(&pane_id))
- .map(|pane| pane.cwd.clone())
- .or_else(|| {
- first_pane_id_in_layout(&snap.layout)
- .and_then(|pane_id| snap.panes.get(&pane_id))
- .map(|pane| pane.cwd.clone())
- })
- .or_else(|| {
- snap.panes
- .keys()
- .min()
- .and_then(|pane_id| snap.panes.get(pane_id))
- .map(|pane| pane.cwd.clone())
- })
- .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()))
-}
-
-fn first_pane_id_in_layout(layout: &LayoutSnapshot) -> Option<u32> {
- match layout {
- LayoutSnapshot::Pane(id) => Some(*id),
- LayoutSnapshot::Split { first, second, .. } => {
- first_pane_id_in_layout(first).or_else(|| first_pane_id_in_layout(second))
- }
- }
-}
-
-// --- Capture ---
-
-/// Capture the current app state into a serializable snapshot.
-pub fn capture(
- workspaces: &[Workspace],
- active: Option<usize>,
- selected: usize,
- agent_panel_scope: crate::app::state::AgentPanelScope,
- sidebar_width: u16,
- sidebar_section_split: f32,
-) -> SessionSnapshot {
- SessionSnapshot {
- version: SNAPSHOT_VERSION,
- workspaces: workspaces.iter().map(capture_workspace).collect(),
- active,
- selected,
- agent_panel_scope,
- sidebar_width: Some(sidebar_width),
- sidebar_section_split: Some(sidebar_section_split),
- }
-}
-
-fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
- WorkspaceSnapshot {
- id: Some(ws.id.clone()),
- custom_name: ws.custom_name.clone(),
- identity_cwd: ws
- .resolved_identity_cwd()
- .unwrap_or_else(|| ws.identity_cwd.clone()),
- tabs: ws.tabs.iter().map(capture_tab).collect(),
- active_tab: ws.active_tab,
- }
-}
-
-fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot {
- let mut panes = HashMap::new();
- for id in tab.panes.keys() {
- let cwd = tab
- .cwd_for_pane(*id)
- .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- panes.insert(id.raw(), PaneSnapshot { cwd });
- }
- TabSnapshot {
- custom_name: tab.custom_name.clone(),
- layout: capture_node(tab.layout.root()),
- panes,
- zoomed: tab.zoomed,
- focused: Some(tab.layout.focused().raw()),
- root_pane: Some(tab.root_pane.raw()),
- }
-}
-
-fn capture_node(node: &Node) -> LayoutSnapshot {
- match node {
- Node::Pane(id) => LayoutSnapshot::Pane(id.raw()),
- Node::Split {
- direction,
- ratio,
- first,
- second,
- } => LayoutSnapshot::Split {
- direction: match direction {
- Direction::Horizontal => DirectionSnapshot::Horizontal,
- Direction::Vertical => DirectionSnapshot::Vertical,
- },
- ratio: *ratio,
- first: Box::new(capture_node(first)),
- second: Box::new(capture_node(second)),
- },
- }
-}
-
-// --- Restore ---
-
-/// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd.
-pub fn restore(
- snapshot: &SessionSnapshot,
- rows: u16,
- cols: u16,
- scrollback_limit_bytes: usize,
- events: mpsc::Sender<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
-) -> Vec<Workspace> {
- snapshot
- .workspaces
- .iter()
- .filter_map(|ws_snap| {
- restore_workspace(
- ws_snap,
- rows,
- cols,
- scrollback_limit_bytes,
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- )
- })
- .collect()
-}
-
-fn restore_workspace(
- snap: &WorkspaceSnapshot,
- rows: u16,
- cols: u16,
- scrollback_limit_bytes: usize,
- events: mpsc::Sender<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
-) -> Option<Workspace> {
- let mut tabs = Vec::new();
- let mut public_pane_numbers = HashMap::new();
- let mut next_public_pane_number = 1;
-
- for (idx, tab_snap) in snap.tabs.iter().enumerate() {
- let tab = restore_tab(
- tab_snap,
- idx + 1,
- rows,
- cols,
- scrollback_limit_bytes,
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- )?;
- for pane_id in tab.layout.pane_ids() {
- public_pane_numbers.insert(pane_id, next_public_pane_number);
- next_public_pane_number += 1;
- }
- tabs.push(tab);
- }
-
- if tabs.is_empty() {
- return None;
- }
-
- Some(Workspace {
- id: snap
- .id
- .clone()
- .unwrap_or_else(crate::workspace::generate_workspace_id),
- custom_name: snap.custom_name.clone(),
- identity_cwd: snap.identity_cwd.clone(),
- cached_git_ahead_behind: None,
- public_pane_numbers,
- next_public_pane_number,
- active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
- tabs,
- })
-}
-
-fn restore_tab(
- snap: &TabSnapshot,
- number: usize,
- rows: u16,
- cols: u16,
- scrollback_limit_bytes: usize,
- events: mpsc::Sender<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
-) -> Option<crate::workspace::Tab> {
- let (node, id_map) = restore_node_remapped(&snap.layout);
- let pane_ids = collect_pane_ids(&node);
- let focus = snap
- .focused
- .and_then(|old_raw| id_map.get(&old_raw).copied())
- .or_else(|| pane_ids.first().copied())
- .unwrap_or(PaneId::from_raw(0));
- let layout = TileLayout::from_saved(node, focus);
-
- let mut panes = HashMap::new();
- let mut pane_cwds = HashMap::new();
- let mut runtimes = HashMap::new();
- for id in &pane_ids {
- let old_id = id_map
- .iter()
- .find(|(_, new)| **new == *id)
- .map(|(old, _)| old);
- let cwd = old_id
- .and_then(|old| snap.panes.get(old))
- .map(|p| p.cwd.clone())
- .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
-
- match PaneRuntime::spawn(
- *id,
- rows,
- cols,
- cwd.clone(),
- scrollback_limit_bytes,
- crate::terminal_theme::TerminalTheme::default(),
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- ) {
- Ok(runtime) => {
- panes.insert(*id, PaneState::new());
- pane_cwds.insert(*id, cwd.clone());
- runtimes.insert(*id, runtime);
- }
- Err(e) => {
- error!(tab = ?snap.custom_name, err = %e, "failed to restore pane");
- return None;
- }
- }
- }
-
- let root_pane = snap
- .root_pane
- .and_then(|old_raw| id_map.get(&old_raw).copied())
- .or_else(|| pane_ids.first().copied())
- .unwrap_or(PaneId::from_raw(0));
-
- Some(crate::workspace::Tab {
- custom_name: snap.custom_name.clone(),
- number,
- root_pane,
- layout,
- panes,
- pane_cwds,
- runtimes,
- zoomed: snap.zoomed,
- events,
- render_notify,
- render_dirty,
- })
-}
-
-/// Restore a layout tree, remapping every pane ID to a fresh globally unique one.
-/// Returns the new tree and a map of old_raw_id → new PaneId.
-fn restore_node_remapped(snap: &LayoutSnapshot) -> (Node, HashMap<u32, PaneId>) {
- let mut id_map = HashMap::new();
- let node = remap_inner(snap, &mut id_map);
- (node, id_map)
-}
-
-fn remap_inner(snap: &LayoutSnapshot, id_map: &mut HashMap<u32, PaneId>) -> Node {
- match snap {
- LayoutSnapshot::Pane(old_id) => {
- let new_id = PaneId::alloc();
- id_map.insert(*old_id, new_id);
- Node::Pane(new_id)
- }
- LayoutSnapshot::Split {
- direction,
- ratio,
- first,
- second,
- } => {
- let first_node = remap_inner(first, id_map);
- let second_node = remap_inner(second, id_map);
- let dir = match direction {
- DirectionSnapshot::Horizontal => Direction::Horizontal,
- DirectionSnapshot::Vertical => Direction::Vertical,
- };
- Node::Split {
- direction: dir,
- ratio: *ratio,
- first: Box::new(first_node),
- second: Box::new(second_node),
- }
- }
- }
-}
-
-fn collect_pane_ids(node: &Node) -> Vec<PaneId> {
- let mut ids = Vec::new();
- collect_ids_inner(node, &mut ids);
- ids
-}
-
-fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) {
- match node {
- Node::Pane(id) => ids.push(*id),
- Node::Split { first, second, .. } => {
- collect_ids_inner(first, ids);
- collect_ids_inner(second, ids);
- }
- }
-}
-
-// --- File I/O ---
-
-fn session_path() -> PathBuf {
- crate::config::config_dir().join("session.json")
-}
-
-fn save_to_path(path: &std::path::Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent)?;
- }
- let json = serde_json::to_string_pretty(snapshot)?;
- let tmp_path = path.with_extension("json.tmp");
- std::fs::write(&tmp_path, &json)?;
- if let Err(err) = std::fs::rename(&tmp_path, path) {
- let _ = std::fs::remove_file(&tmp_path);
- return Err(err);
- }
- Ok(())
-}
-
-fn clear_path(path: &std::path::Path) -> std::io::Result<()> {
- match std::fs::remove_file(path) {
- Ok(()) => Ok(()),
- Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
- Err(err) => Err(err),
- }
-}
-
-pub fn save(snapshot: &SessionSnapshot) {
- let path = session_path();
- if let Err(err) = save_to_path(&path, snapshot) {
- error!(err = %err, path = %path.display(), "failed to save session");
- return;
- }
- info!(workspaces = snapshot.workspaces.len(), "session saved");
-}
-
-pub fn clear() {
- let path = session_path();
- if let Err(err) = clear_path(&path) {
- error!(err = %err, path = %path.display(), "failed to clear session");
- return;
- }
- info!(path = %path.display(), "session cleared");
-}
-
-fn parse_snapshot(content: &str) -> Result<SessionSnapshot, String> {
- let raw = serde_json::from_str::<RawSessionSnapshot>(content).map_err(|e| e.to_string())?;
- if raw.version > SNAPSHOT_VERSION {
- return Err(format!(
- "snapshot version {} is newer than supported {}",
- raw.version, SNAPSHOT_VERSION
- ));
- }
- migrate_snapshot(raw)
-}
-
-pub fn load() -> Option<SessionSnapshot> {
- let path = session_path();
- if !path.exists() {
- return None;
- }
- let content = match std::fs::read_to_string(&path) {
- Ok(c) => c,
- Err(e) => {
- warn!(err = %e, "failed to read session file");
- return None;
- }
- };
- match parse_snapshot(&content) {
- Ok(snap) => Some(snap),
- Err(e) => {
- if let Ok(raw) = serde_json::from_str::<RawSessionSnapshot>(&content) {
- if raw.version > SNAPSHOT_VERSION {
- warn!(
- file_version = raw.version,
- supported = SNAPSHOT_VERSION,
- "session file is from a newer herdr version, ignoring"
- );
- return None;
- }
- }
- warn!(err = %e, "failed to parse session file, ignoring");
- None
- }
- }
-}
-
-// --- Tests ---
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use ratatui::layout::Rect;
-
- use crate::app::{state::AgentPanelScope, AppState, Mode};
- use crate::layout::NavDirection;
- use crate::workspace::Workspace;
-
- fn session_fixture(name: &str) -> &'static str {
- match name {
- "current-herdr" => include_str!("../tests/fixtures/session/current-herdr-session.json"),
- "current-herdr-dev" => {
- include_str!("../tests/fixtures/session/current-herdr-dev-session.json")
- }
- "legacy-pre-tabs-v2" => {
- include_str!("../tests/fixtures/session/legacy-pre-tabs-v2.json")
- }
- other => panic!("unknown session fixture: {other}"),
- }
- }
-
- fn temp_session_path(name: &str) -> PathBuf {
- let unique = format!(
- "herdr-session-tests-{}-{}-{}",
- name,
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- );
- std::env::temp_dir().join(unique).join("session.json")
- }
-
- #[test]
- fn round_trip_empty_session() {
- let snap = SessionSnapshot {
- version: SNAPSHOT_VERSION,
- workspaces: vec![],
- active: None,
- selected: 0,
- agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace,
- sidebar_width: Some(26),
- sidebar_section_split: Some(0.5),
- };
- let json = serde_json::to_string(&snap).unwrap();
- let restored = parse_snapshot(&json).unwrap();
- assert!(restored.workspaces.is_empty());
- assert_eq!(restored.active, None);
- assert_eq!(restored.sidebar_width, Some(26));
- assert_eq!(restored.sidebar_section_split, Some(0.5));
- }
-
- #[test]
- fn clear_path_removes_existing_session_file() {
- let path = temp_session_path("clear-existing");
- save_to_path(
- &path,
- &SessionSnapshot {
- version: SNAPSHOT_VERSION,
- workspaces: vec![],
- active: None,
- selected: 0,
- agent_panel_scope: AgentPanelScope::CurrentWorkspace,
- sidebar_width: Some(26),
- sidebar_section_split: Some(0.5),
- },
- )
- .unwrap();
-
- clear_path(&path).unwrap();
-
- assert!(!path.exists());
- }
-
- #[test]
- fn clear_path_ignores_missing_session_file() {
- let path = temp_session_path("clear-missing");
-
- clear_path(&path).unwrap();
-
- assert!(!path.exists());
- }
-
- #[test]
- fn round_trip_layout_snapshot() {
- let layout = LayoutSnapshot::Split {
- direction: DirectionSnapshot::Horizontal,
- ratio: 0.6,
- first: Box::new(LayoutSnapshot::Pane(0)),
- second: Box::new(LayoutSnapshot::Split {
- direction: DirectionSnapshot::Vertical,
- ratio: 0.5,
- first: Box::new(LayoutSnapshot::Pane(1)),
- second: Box::new(LayoutSnapshot::Pane(2)),
- }),
- };
- let json = serde_json::to_string(&layout).unwrap();
- let restored: LayoutSnapshot = serde_json::from_str(&json).unwrap();
-
- // Verify structure
- match restored {
- LayoutSnapshot::Split { ratio, .. } => assert!((ratio - 0.6).abs() < 0.01),
- _ => panic!("expected split"),
- }
- }
-
- #[test]
- fn round_trip_full_workspace_snapshot() {
- let mut panes = HashMap::new();
- panes.insert(
- 0,
- PaneSnapshot {
- cwd: PathBuf::from("/home/can/Projects/herdr"),
- },
- );
- panes.insert(
- 1,
- PaneSnapshot {
- cwd: PathBuf::from("/home/can/Projects/website"),
- },
- );
-
- let snap = SessionSnapshot {
- workspaces: vec![WorkspaceSnapshot {
- id: Some("wproj".to_string()),
- custom_name: Some("pi-mono".to_string()),
- identity_cwd: PathBuf::from("/home/can/Projects/herdr"),
- tabs: vec![TabSnapshot {
- custom_name: Some("api".to_string()),
- layout: LayoutSnapshot::Split {
- direction: DirectionSnapshot::Horizontal,
- ratio: 0.5,
- first: Box::new(LayoutSnapshot::Pane(0)),
- second: Box::new(LayoutSnapshot::Pane(1)),
- },
- panes,
- zoomed: false,
- focused: Some(0),
- root_pane: Some(0),
- }],
- active_tab: 0,
- }],
- active: Some(0),
- selected: 0,
- agent_panel_scope: crate::app::state::AgentPanelScope::CurrentWorkspace,
- sidebar_width: Some(26),
- sidebar_section_split: Some(0.5),
- version: SNAPSHOT_VERSION,
- };
-
- let json = serde_json::to_string_pretty(&snap).unwrap();
- let restored = parse_snapshot(&json).unwrap();
-
- assert_eq!(restored.workspaces.len(), 1);
- assert_eq!(restored.workspaces[0].id.as_deref(), Some("wproj"));
- assert_eq!(
- restored.workspaces[0].custom_name.as_deref(),
- Some("pi-mono")
- );
- assert_eq!(restored.workspaces[0].tabs.len(), 1);
- assert_eq!(restored.workspaces[0].tabs[0].panes.len(), 2);
- assert_eq!(
- restored.workspaces[0].tabs[0].panes[&0].cwd,
- PathBuf::from("/home/can/Projects/herdr")
- );
- assert_eq!(
- restored.agent_panel_scope,
- crate::app::state::AgentPanelScope::CurrentWorkspace
- );
- assert_eq!(restored.sidebar_width, Some(26));
- assert_eq!(restored.sidebar_section_split, Some(0.5));
- }
-
- #[test]
- fn current_session_fixture_parses() {
- let snap = parse_snapshot(session_fixture("current-herdr")).unwrap();
-
- assert_eq!(snap.version, 3);
- assert_eq!(snap.workspaces.len(), 2);
- assert_eq!(snap.active, Some(0));
- assert_eq!(snap.selected, 0);
- assert_eq!(
- snap.agent_panel_scope,
- crate::app::state::AgentPanelScope::CurrentWorkspace
- );
- assert_eq!(snap.sidebar_width, None);
- assert_eq!(snap.sidebar_section_split, None);
- assert_eq!(snap.workspaces[0].tabs.len(), 2);
- assert_eq!(
- snap.workspaces[1].identity_cwd,
- PathBuf::from("/home/test/projects/project-b")
- );
- }
-
- #[test]
- fn current_dev_session_fixture_parses_additive_fields() {
- let snap = parse_snapshot(session_fixture("current-herdr-dev")).unwrap();
-
- assert_eq!(snap.version, 3);
- assert_eq!(snap.workspaces.len(), 2);
- assert_eq!(
- snap.agent_panel_scope,
- crate::app::state::AgentPanelScope::CurrentWorkspace
- );
- assert_eq!(snap.sidebar_section_split, Some(0.4));
- assert_eq!(snap.workspaces[0].active_tab, 1);
- assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2);
- }
-
- #[test]
- fn old_snapshot_defaults_agent_panel_scope() {
- let json = serde_json::json!({
- "version": SNAPSHOT_VERSION,
- "workspaces": [],
- "active": null,
- "selected": 0
- })
- .to_string();
-
- let restored = parse_snapshot(&json).unwrap();
-
- assert_eq!(
- restored.agent_panel_scope,
- crate::app::state::AgentPanelScope::CurrentWorkspace
- );
- assert_eq!(restored.sidebar_width, None);
- assert_eq!(restored.sidebar_section_split, None);
- }
-
- #[test]
- fn legacy_workspace_snapshot_migrates_to_single_tab() {
- let snap = parse_snapshot(session_fixture("legacy-pre-tabs-v2")).unwrap();
- let ws = &snap.workspaces[0];
-
- assert_eq!(snap.version, 2);
- assert_eq!(snap.workspaces.len(), 1);
- assert_eq!(ws.custom_name.as_deref(), Some("legacy"));
- assert_eq!(ws.identity_cwd, PathBuf::from("/tmp/pion"));
- assert_eq!(ws.active_tab, 0);
- assert_eq!(ws.tabs.len(), 1);
- assert_eq!(ws.tabs[0].focused, Some(1));
- assert_eq!(ws.tabs[0].root_pane, Some(0));
- assert_eq!(ws.tabs[0].panes[&0].cwd, PathBuf::from("/tmp/pion"));
- assert_eq!(ws.tabs[0].panes[&1].cwd, PathBuf::from("/tmp/herdr"));
- }
-
- #[test]
- fn capture_and_restore_node_round_trip() {
- // Create a tree: Split(H, 0.5, Pane(0), Split(V, 0.3, Pane(1), Pane(2)))
- let node = Node::Split {
- direction: Direction::Horizontal,
- ratio: 0.5,
- first: Box::new(Node::Pane(PaneId::from_raw(0))),
- second: Box::new(Node::Split {
- direction: Direction::Vertical,
- ratio: 0.3,
- first: Box::new(Node::Pane(PaneId::from_raw(1))),
- second: Box::new(Node::Pane(PaneId::from_raw(2))),
- }),
- };
-
- let snap = capture_node(&node);
- let (restored, id_map) = restore_node_remapped(&snap);
-
- assert_eq!(id_map.len(), 3);
- let ids = collect_pane_ids(&restored);
- assert_eq!(ids.len(), 3);
- let unique: std::collections::HashSet<u32> = ids.iter().map(|id| id.raw()).collect();
- assert_eq!(unique.len(), 3);
- }
-
- 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::Terminal;
- }
- state
- }
-
- fn capture_from_state(state: &AppState) -> SessionSnapshot {
- capture(
- &state.workspaces,
- state.active,
- state.selected,
- state.agent_panel_scope,
- state.sidebar_width,
- state.sidebar_section_split,
- )
- }
-
- fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> {
- match &tab.layout {
- LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
- LayoutSnapshot::Pane(_) => None,
- }
- }
-
- #[test]
- fn capture_contract_tracks_workspace_order_active_and_selected() {
- let mut state = state_with_workspaces(&["a", "b", "c"]);
- state.active = Some(1);
- state.selected = 2;
-
- state.move_workspace(1, 0);
-
- let snapshot = capture_from_state(&state);
- let ids: Vec<_> = state.workspaces.iter().map(|ws| ws.id.clone()).collect();
- let captured_ids: Vec<_> = snapshot
- .workspaces
- .iter()
- .map(|ws| ws.id.clone().unwrap())
- .collect();
- assert_eq!(captured_ids, ids);
- assert_eq!(snapshot.active, state.active);
- assert_eq!(snapshot.selected, state.selected);
- }
-
- #[test]
- fn capture_contract_tracks_workspace_and_tab_names_and_active_tab() {
- let mut state = state_with_workspaces(&["one"]);
- state.workspaces[0].set_custom_name("renamed-workspace".into());
- let second_tab = state.workspaces[0].test_add_tab(Some("logs"));
- state.workspaces[0].switch_tab(second_tab);
- state.workspaces[0].tabs[0].set_custom_name("main".into());
-
- let snapshot = capture_from_state(&state);
- let workspace = &snapshot.workspaces[0];
- assert_eq!(workspace.custom_name.as_deref(), Some("renamed-workspace"));
- assert_eq!(workspace.active_tab, second_tab);
- assert_eq!(workspace.tabs[0].custom_name.as_deref(), Some("main"));
- assert_eq!(workspace.tabs[1].custom_name.as_deref(), Some("logs"));
- }
-
- #[test]
- fn capture_contract_tracks_workspace_closure() {
- let mut state = state_with_workspaces(&["one", "two"]);
- state.selected = 1;
- state.active = Some(1);
-
- state.close_selected_workspace();
-
- let snapshot = capture_from_state(&state);
- assert_eq!(snapshot.workspaces.len(), 1);
- assert_eq!(snapshot.workspaces[0].custom_name.as_deref(), Some("one"));
- assert_eq!(snapshot.active, Some(0));
- assert_eq!(snapshot.selected, 0);
- }
-
- #[test]
- fn capture_contract_tracks_sidebar_state() {
- let mut state = state_with_workspaces(&["one"]);
- state.sidebar_width = 31;
- state.sidebar_section_split = 0.4;
- state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
-
- let snapshot = capture_from_state(&state);
- assert_eq!(snapshot.sidebar_width, Some(31));
- assert_eq!(snapshot.sidebar_section_split, Some(0.4));
- assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
- }
-
- #[test]
- fn capture_contract_tracks_layout_focus_zoom_and_root_pane() {
- let mut state = state_with_workspaces(&["one"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- let second = state.workspaces[0].test_split(Direction::Horizontal);
- state.workspaces[0].tabs[0].layout.focus_pane(second);
- state.toggle_fullscreen();
-
- let snapshot = capture_from_state(&state);
- let tab = &snapshot.workspaces[0].tabs[0];
- assert!(matches!(tab.layout, LayoutSnapshot::Split { .. }));
- assert_eq!(tab.focused, Some(second.raw()));
- assert_eq!(tab.root_pane, Some(root.raw()));
- assert!(tab.zoomed);
- assert_eq!(tab.panes.len(), 2);
- }
-
- #[test]
- fn capture_contract_tracks_focus_navigation() {
- let mut state = state_with_workspaces(&["one"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- let second = state.workspaces[0].test_split(Direction::Horizontal);
- crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20));
-
- state.navigate_pane(NavDirection::Right);
-
- let snapshot = capture_from_state(&state);
- assert_eq!(snapshot.workspaces[0].tabs[0].focused, Some(second.raw()));
- assert_ne!(snapshot.workspaces[0].tabs[0].focused, Some(root.raw()));
- }
-
- #[test]
- fn capture_contract_tracks_resize_ratio_changes() {
- let mut state = state_with_workspaces(&["one"]);
- state.workspaces[0].test_split(Direction::Horizontal);
- crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20));
- let before = capture_from_state(&state);
-
- state.resize_pane(NavDirection::Right);
-
- let after = capture_from_state(&state);
- let before_ratio = root_split_ratio(&before.workspaces[0].tabs[0]).unwrap();
- let after_ratio = root_split_ratio(&after.workspaces[0].tabs[0]).unwrap();
- assert_ne!(before_ratio, after_ratio);
- }
-
- #[test]
- fn capture_contract_tracks_tab_closure() {
- let mut state = state_with_workspaces(&["one"]);
- let second_tab = state.workspaces[0].test_add_tab(Some("logs"));
- state.switch_tab(second_tab);
-
- state.close_tab();
-
- let snapshot = capture_from_state(&state);
- let workspace = &snapshot.workspaces[0];
- assert_eq!(workspace.tabs.len(), 1);
- assert_eq!(workspace.active_tab, 0);
- assert!(workspace.tabs[0].custom_name.is_none());
- }
-
- #[test]
- fn capture_contract_tracks_pane_closure() {
- let mut state = state_with_workspaces(&["one"]);
- state.workspaces[0].test_split(Direction::Horizontal);
-
- state.close_pane();
-
- let snapshot = capture_from_state(&state);
- let tab = &snapshot.workspaces[0].tabs[0];
- assert_eq!(tab.panes.len(), 1);
- assert!(matches!(tab.layout, LayoutSnapshot::Pane(_)));
- assert!(!tab.zoomed);
- }
-
- #[test]
- fn capture_contract_tracks_workspace_identity_and_pane_cwds() {
- let mut state = state_with_workspaces(&["one"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- state.workspaces[0].tabs[0]
- .pane_cwds
- .insert(root, PathBuf::from("/tmp/pion"));
- let second = state.workspaces[0].test_split(Direction::Horizontal);
- state.workspaces[0].tabs[0]
- .pane_cwds
- .insert(second, PathBuf::from("/tmp/herdr"));
-
- let snapshot = capture_from_state(&state);
- let workspace = &snapshot.workspaces[0];
- let tab = &workspace.tabs[0];
- assert_eq!(workspace.identity_cwd, PathBuf::from("/tmp/pion"));
- assert_eq!(tab.panes[&root.raw()].cwd, PathBuf::from("/tmp/pion"));
- assert_eq!(tab.panes[&second.raw()].cwd, PathBuf::from("/tmp/herdr"));
- }
-
- #[test]
- fn old_unversioned_snapshot_loads_as_version_0() {
- // Simulate a snapshot from before versioning was added
- let json = r#"{"workspaces":[],"active":null,"selected":0}"#;
- let snap = parse_snapshot(json).unwrap();
- assert_eq!(snap.version, 0);
- }
-
- #[test]
- fn future_version_is_rejected() {
- let json = r#"{"version":999,"workspaces":[],"active":null,"selected":0}"#;
- assert!(parse_snapshot(json).is_err());
- }
-
- #[test]
- fn active_tab_default_is_zero() {
- let json = r#"{"custom_name":"test","identity_cwd":"/tmp","tabs":[]}"#;
- let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
- assert_eq!(ws.active_tab, 0);
- }
-}
+mod io;
+mod restore;
+mod snapshot;
+
+pub use self::io::{clear, load, save};
+pub use self::restore::restore;
+pub use self::snapshot::{
+ capture, DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
+};
diff --git a/src/persist/io.rs b/src/persist/io.rs
new file mode 100644
index 00000000..26e6fd4e
--- /dev/null
+++ b/src/persist/io.rs
@@ -0,0 +1,130 @@
+use std::path::{Path, PathBuf};
+
+use tracing::{error, info, warn};
+
+use super::snapshot::{parse_snapshot, snapshot_file_version, SessionSnapshot, SNAPSHOT_VERSION};
+
+fn session_path() -> PathBuf {
+ crate::config::config_dir().join("session.json")
+}
+
+pub(super) fn save_to_path(path: &Path, snapshot: &SessionSnapshot) -> std::io::Result<()> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let json = serde_json::to_string_pretty(snapshot)?;
+ let tmp_path = path.with_extension("json.tmp");
+ std::fs::write(&tmp_path, &json)?;
+ if let Err(err) = std::fs::rename(&tmp_path, path) {
+ let _ = std::fs::remove_file(&tmp_path);
+ return Err(err);
+ }
+ Ok(())
+}
+
+pub(super) fn clear_path(path: &Path) -> std::io::Result<()> {
+ match std::fs::remove_file(path) {
+ Ok(()) => Ok(()),
+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
+ Err(err) => Err(err),
+ }
+}
+
+pub fn save(snapshot: &SessionSnapshot) {
+ let path = session_path();
+ if let Err(err) = save_to_path(&path, snapshot) {
+ error!(err = %err, path = %path.display(), "failed to save session");
+ return;
+ }
+ info!(workspaces = snapshot.workspaces.len(), "session saved");
+}
+
+pub fn clear() {
+ let path = session_path();
+ if let Err(err) = clear_path(&path) {
+ error!(err = %err, path = %path.display(), "failed to clear session");
+ return;
+ }
+ info!(path = %path.display(), "session cleared");
+}
+
+pub fn load() -> Option<SessionSnapshot> {
+ let path = session_path();
+ if !path.exists() {
+ return None;
+ }
+ let content = match std::fs::read_to_string(&path) {
+ Ok(content) => content,
+ Err(err) => {
+ warn!(err = %err, "failed to read session file");
+ return None;
+ }
+ };
+ match parse_snapshot(&content) {
+ Ok(snapshot) => Some(snapshot),
+ Err(err) => {
+ if let Some(version) = snapshot_file_version(&content) {
+ if version > SNAPSHOT_VERSION {
+ warn!(
+ file_version = version,
+ supported = SNAPSHOT_VERSION,
+ "session file is from a newer herdr version, ignoring"
+ );
+ return None;
+ }
+ }
+ warn!(err = %err, "failed to parse session file, ignoring");
+ None
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::app::state::AgentPanelScope;
+
+ fn temp_session_path(name: &str) -> PathBuf {
+ let unique = format!(
+ "herdr-session-tests-{}-{}-{}",
+ name,
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ std::env::temp_dir().join(unique).join("session.json")
+ }
+
+ fn empty_snapshot() -> SessionSnapshot {
+ SessionSnapshot {
+ version: SNAPSHOT_VERSION,
+ workspaces: vec![],
+ active: None,
+ selected: 0,
+ agent_panel_scope: AgentPanelScope::CurrentWorkspace,
+ sidebar_width: Some(26),
+ sidebar_section_split: Some(0.5),
+ }
+ }
+
+ #[test]
+ fn clear_path_removes_existing_session_file() {
+ let path = temp_session_path("clear-existing");
+ save_to_path(&path, &empty_snapshot()).unwrap();
+
+ clear_path(&path).unwrap();
+
+ assert!(!path.exists());
+ }
+
+ #[test]
+ fn clear_path_ignores_missing_session_file() {
+ let path = temp_session_path("clear-missing");
+
+ clear_path(&path).unwrap();
+
+ assert!(!path.exists());
+ }
+}
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
new file mode 100644
index 00000000..eeef0af1
--- /dev/null
+++ b/src/persist/restore.rs
@@ -0,0 +1,353 @@
+use std::collections::{HashMap, HashSet};
+use std::path::PathBuf;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+
+use ratatui::layout::Direction;
+use tokio::sync::{mpsc, Notify};
+use tracing::{error, warn};
+
+use crate::events::AppEvent;
+use crate::layout::{Node, PaneId, TileLayout};
+use crate::pane::{PaneRuntime, PaneState};
+use crate::workspace::Workspace;
+
+use super::{DirectionSnapshot, LayoutSnapshot, SessionSnapshot, TabSnapshot, WorkspaceSnapshot};
+
+/// Restore workspaces from a snapshot. Each pane gets a fresh shell in its saved cwd.
+pub fn restore(
+ snapshot: &SessionSnapshot,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Vec<Workspace> {
+ snapshot
+ .workspaces
+ .iter()
+ .filter_map(|ws_snap| {
+ restore_workspace(
+ ws_snap,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )
+ })
+ .collect()
+}
+
+fn restore_workspace(
+ snap: &WorkspaceSnapshot,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Option<Workspace> {
+ let mut tabs = Vec::new();
+ let mut public_pane_numbers = HashMap::new();
+ let mut next_public_pane_number = 1;
+
+ for (idx, tab_snap) in snap.tabs.iter().enumerate() {
+ let tab = restore_tab(
+ tab_snap,
+ idx + 1,
+ rows,
+ cols,
+ scrollback_limit_bytes,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?;
+ for pane_id in tab.layout.pane_ids() {
+ public_pane_numbers.insert(pane_id, next_public_pane_number);
+ next_public_pane_number += 1;
+ }
+ tabs.push(tab);
+ }
+
+ if tabs.is_empty() {
+ return None;
+ }
+
+ Some(Workspace {
+ id: snap
+ .id
+ .clone()
+ .unwrap_or_else(crate::workspace::generate_workspace_id),
+ custom_name: snap.custom_name.clone(),
+ identity_cwd: snap.identity_cwd.clone(),
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number,
+ active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
+ tabs,
+ })
+}
+
+fn restore_tab(
+ snap: &TabSnapshot,
+ number: usize,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Option<crate::workspace::Tab> {
+ let (node, id_map) = restore_node_remapped(&snap.layout);
+ let reverse_id_map: HashMap<PaneId, u32> = id_map
+ .iter()
+ .map(|(&old_id, &new_id)| (new_id, old_id))
+ .collect();
+ let pane_ids = collect_pane_ids(&node);
+
+ let mut panes = HashMap::new();
+ let mut pane_cwds = HashMap::new();
+ let mut runtimes = HashMap::new();
+ for id in &pane_ids {
+ let saved_cwd = reverse_id_map
+ .get(id)
+ .and_then(|old_id| snap.panes.get(old_id))
+ .map(|p| p.cwd.clone())
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+
+ let cwd = if saved_cwd.exists() {
+ saved_cwd
+ } else {
+ warn!(
+ cwd = %saved_cwd.display(),
+ "saved pane cwd does not exist, falling back to HOME"
+ );
+ let home = std::env::var("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("/"));
+ if home.exists() {
+ home
+ } else {
+ PathBuf::from("/")
+ }
+ };
+
+ match PaneRuntime::spawn(
+ *id,
+ rows,
+ cols,
+ cwd.clone(),
+ scrollback_limit_bytes,
+ crate::terminal_theme::TerminalTheme::default(),
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ ) {
+ Ok(runtime) => {
+ panes.insert(*id, PaneState::new());
+ pane_cwds.insert(*id, cwd.clone());
+ runtimes.insert(*id, runtime);
+ }
+ Err(e) => {
+ error!(
+ tab = ?snap.custom_name,
+ pane_id = id.raw(),
+ err = %e,
+ "failed to restore pane, skipping"
+ );
+ }
+ }
+ }
+
+ if panes.is_empty() {
+ warn!(
+ tab = ?snap.custom_name,
+ "no panes could be restored for tab, dropping it"
+ );
+ return None;
+ }
+
+ let surviving: HashSet<PaneId> = panes.keys().copied().collect();
+ let Some(node) = prune_restored_node(node, &surviving) else {
+ warn!(
+ tab = ?snap.custom_name,
+ "restored tab lost all panes after pruning missing layout nodes"
+ );
+ return None;
+ };
+ let pane_ids = collect_pane_ids(&node);
+ let focus = resolve_restored_pane(snap.focused, &id_map, &surviving, &pane_ids)?;
+ let root_pane = resolve_restored_pane(snap.root_pane, &id_map, &surviving, &pane_ids)?;
+ let layout = TileLayout::from_saved(node, focus);
+
+ Some(crate::workspace::Tab {
+ custom_name: snap.custom_name.clone(),
+ number,
+ root_pane,
+ layout,
+ panes,
+ pane_cwds,
+ runtimes,
+ zoomed: snap.zoomed,
+ events,
+ render_notify,
+ render_dirty,
+ })
+}
+
+pub(super) fn prune_restored_node(node: Node, surviving: &HashSet<PaneId>) -> Option<Node> {
+ match node {
+ Node::Pane(id) => surviving.contains(&id).then_some(Node::Pane(id)),
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ let first = prune_restored_node(*first, surviving);
+ let second = prune_restored_node(*second, surviving);
+ match (first, second) {
+ (Some(first), Some(second)) => Some(Node::Split {
+ direction,
+ ratio,
+ first: Box::new(first),
+ second: Box::new(second),
+ }),
+ (Some(remaining), None) | (None, Some(remaining)) => Some(remaining),
+ (None, None) => None,
+ }
+ }
+ }
+}
+
+pub(super) fn resolve_restored_pane(
+ saved_old_id: Option<u32>,
+ id_map: &HashMap<u32, PaneId>,
+ surviving: &HashSet<PaneId>,
+ pane_ids: &[PaneId],
+) -> Option<PaneId> {
+ saved_old_id
+ .and_then(|old_id| id_map.get(&old_id).copied())
+ .filter(|pane_id| surviving.contains(pane_id))
+ .or_else(|| pane_ids.first().copied())
+}
+
+/// Restore a layout tree, remapping every pane ID to a fresh globally unique one.
+/// Returns the new tree and a map of old_raw_id → new PaneId.
+pub(super) fn restore_node_remapped(snap: &LayoutSnapshot) -> (Node, HashMap<u32, PaneId>) {
+ let mut id_map = HashMap::new();
+ let node = remap_inner(snap, &mut id_map);
+ (node, id_map)
+}
+
+fn remap_inner(snap: &LayoutSnapshot, id_map: &mut HashMap<u32, PaneId>) -> Node {
+ match snap {
+ LayoutSnapshot::Pane(old_id) => {
+ let new_id = PaneId::alloc();
+ id_map.insert(*old_id, new_id);
+ Node::Pane(new_id)
+ }
+ LayoutSnapshot::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => {
+ let first_node = remap_inner(first, id_map);
+ let second_node = remap_inner(second, id_map);
+ let dir = match direction {
+ DirectionSnapshot::Horizontal => Direction::Horizontal,
+ DirectionSnapshot::Vertical => Direction::Vertical,
+ };
+ Node::Split {
+ direction: dir,
+ ratio: *ratio,
+ first: Box::new(first_node),
+ second: Box::new(second_node),
+ }
+ }
+ }
+}
+
+pub(super) fn collect_pane_ids(node: &Node) -> Vec<PaneId> {
+ let mut ids = Vec::new();
+ collect_ids_inner(node, &mut ids);
+ ids
+}
+
+fn collect_ids_inner(node: &Node, ids: &mut Vec<PaneId>) {
+ match node {
+ Node::Pane(id) => ids.push(*id),
+ Node::Split { first, second, .. } => {
+ collect_ids_inner(first, ids);
+ collect_ids_inner(second, ids);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn capture_and_restore_node_round_trip() {
+ let node = Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.5,
+ first: Box::new(Node::Pane(PaneId::from_raw(0))),
+ second: Box::new(Node::Split {
+ direction: Direction::Vertical,
+ ratio: 0.3,
+ first: Box::new(Node::Pane(PaneId::from_raw(1))),
+ second: Box::new(Node::Pane(PaneId::from_raw(2))),
+ }),
+ };
+
+ let snap = super::super::snapshot::capture_node(&node);
+ let (restored, id_map) = restore_node_remapped(&snap);
+
+ assert_eq!(id_map.len(), 3);
+ let ids = collect_pane_ids(&restored);
+ assert_eq!(ids.len(), 3);
+ let unique: std::collections::HashSet<u32> = ids.iter().map(|id| id.raw()).collect();
+ assert_eq!(unique.len(), 3);
+ }
+
+ #[test]
+ fn prune_restored_node_collapses_missing_branch() {
+ let keep = PaneId::from_raw(11);
+ let missing = PaneId::from_raw(12);
+ let node = Node::Split {
+ direction: Direction::Horizontal,
+ ratio: 0.5,
+ first: Box::new(Node::Pane(keep)),
+ second: Box::new(Node::Pane(missing)),
+ };
+ let surviving = std::collections::HashSet::from([keep]);
+
+ let pruned = prune_restored_node(node, &surviving).expect("remaining pane should survive");
+
+ assert!(matches!(pruned, Node::Pane(id) if id == keep));
+ }
+
+ #[test]
+ fn resolve_restored_pane_prefers_surviving_saved_id_and_falls_back_to_first_remaining() {
+ let first = PaneId::from_raw(21);
+ let second = PaneId::from_raw(22);
+ let id_map = HashMap::from([(0_u32, first), (1_u32, second)]);
+ let surviving = std::collections::HashSet::from([first]);
+ let pane_ids = vec![first];
+
+ assert_eq!(
+ resolve_restored_pane(Some(0), &id_map, &surviving, &pane_ids),
+ Some(first)
+ );
+ assert_eq!(
+ resolve_restored_pane(Some(1), &id_map, &surviving, &pane_ids),
+ Some(first)
+ );
+ }
+}
diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs
new file mode 100644
index 00000000..1438cd6d
--- /dev/null
+++ b/src/persist/snapshot.rs
@@ -0,0 +1,744 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use ratatui::layout::Direction;
+use serde::{Deserialize, Serialize};
+
+use crate::layout::Node;
+use crate::workspace::Workspace;
+
+/// Current snapshot format version.
+pub(super) const SNAPSHOT_VERSION: u32 = 3;
+
+/// Serializable snapshot of the entire herdr session.
+#[derive(Serialize, Deserialize)]
+pub struct SessionSnapshot {
+ /// Format version — used to detect incompatible changes.
+ #[serde(default)]
+ pub version: u32,
+ pub workspaces: Vec<WorkspaceSnapshot>,
+ pub active: Option<usize>,
+ pub selected: usize,
+ #[serde(default)]
+ pub agent_panel_scope: crate::app::state::AgentPanelScope,
+ #[serde(default)]
+ pub sidebar_width: Option<u16>,
+ #[serde(default)]
+ pub sidebar_section_split: Option<f32>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct WorkspaceSnapshot {
+ #[serde(default)]
+ pub id: Option<String>,
+ #[serde(default)]
+ pub custom_name: Option<String>,
+ pub identity_cwd: PathBuf,
+ pub tabs: Vec<TabSnapshot>,
+ #[serde(default)]
+ pub active_tab: usize,
+}
+
+#[derive(Deserialize)]
+struct LegacyWorkspaceSnapshot {
+ #[serde(default)]
+ custom_name: Option<String>,
+ layout: LayoutSnapshot,
+ panes: HashMap<u32, PaneSnapshot>,
+ zoomed: bool,
+ #[serde(default)]
+ focused: Option<u32>,
+ #[serde(default)]
+ root_pane: Option<u32>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct TabSnapshot {
+ #[serde(default)]
+ pub custom_name: Option<String>,
+ pub layout: LayoutSnapshot,
+ pub panes: HashMap<u32, PaneSnapshot>,
+ pub zoomed: bool,
+ #[serde(default)]
+ pub focused: Option<u32>,
+ #[serde(default)]
+ pub root_pane: Option<u32>,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct PaneSnapshot {
+ pub cwd: PathBuf,
+}
+
+/// Serializable BSP tree.
+#[derive(Serialize, Deserialize)]
+pub enum LayoutSnapshot {
+ Pane(u32),
+ Split {
+ direction: DirectionSnapshot,
+ ratio: f32,
+ first: Box<LayoutSnapshot>,
+ second: Box<LayoutSnapshot>,
+ },
+}
+
+#[derive(Serialize, Deserialize)]
+pub enum DirectionSnapshot {
+ Horizontal,
+ Vertical,
+}
+
+impl From<LegacyWorkspaceSnapshot> for WorkspaceSnapshot {
+ fn from(snap: LegacyWorkspaceSnapshot) -> Self {
+ let identity_cwd = legacy_identity_cwd(&snap);
+ let tab = TabSnapshot {
+ custom_name: None,
+ layout: snap.layout,
+ panes: snap.panes,
+ zoomed: snap.zoomed,
+ focused: snap.focused,
+ root_pane: snap.root_pane,
+ };
+
+ Self {
+ id: None,
+ custom_name: snap.custom_name,
+ identity_cwd,
+ tabs: vec![tab],
+ active_tab: 0,
+ }
+ }
+}
+
+#[derive(Deserialize)]
+struct RawSessionSnapshot {
+ #[serde(default)]
+ version: u32,
+ #[serde(default)]
+ workspaces: Vec<serde_json::Value>,
+ #[serde(default)]
+ active: Option<usize>,
+ #[serde(default)]
+ selected: usize,
+ #[serde(default)]
+ agent_panel_scope: crate::app::state::AgentPanelScope,
+ #[serde(default)]
+ sidebar_width: Option<u16>,
+ #[serde(default)]
+ sidebar_section_split: Option<f32>,
+}
+
+fn migrate_snapshot(raw: RawSessionSnapshot) -> Result<SessionSnapshot, String> {
+ Ok(SessionSnapshot {
+ version: raw.version,
+ workspaces: raw
+ .workspaces
+ .into_iter()
+ .map(migrate_workspace)
+ .collect::<Result<Vec<_>, _>>()?,
+ active: raw.active,
+ selected: raw.selected,
+ agent_panel_scope: raw.agent_panel_scope,
+ sidebar_width: raw.sidebar_width,
+ sidebar_section_split: raw.sidebar_section_split,
+ })
+}
+
+fn migrate_workspace(raw: serde_json::Value) -> Result<WorkspaceSnapshot, String> {
+ if raw.get("identity_cwd").is_some() {
+ return serde_json::from_value(raw).map_err(|e| e.to_string());
+ }
+
+ if raw.get("layout").is_some() {
+ let legacy =
+ serde_json::from_value::<LegacyWorkspaceSnapshot>(raw).map_err(|e| e.to_string())?;
+ return Ok(legacy.into());
+ }
+
+ Err("workspace snapshot is neither current nor legacy format".to_string())
+}
+
+fn legacy_identity_cwd(snap: &LegacyWorkspaceSnapshot) -> PathBuf {
+ let root_pane = snap
+ .root_pane
+ .or_else(|| first_pane_id_in_layout(&snap.layout));
+
+ root_pane
+ .and_then(|pane_id| snap.panes.get(&pane_id))
+ .map(|pane| pane.cwd.clone())
+ .or_else(|| {
+ first_pane_id_in_layout(&snap.layout)
+ .and_then(|pane_id| snap.panes.get(&pane_id))
+ .map(|pane| pane.cwd.clone())
+ })
+ .or_else(|| {
+ snap.panes
+ .keys()
+ .min()
+ .and_then(|pane_id| snap.panes.get(pane_id))
+ .map(|pane| pane.cwd.clone())
+ })
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()))
+}
+
+fn first_pane_id_in_layout(layout: &LayoutSnapshot) -> Option<u32> {
+ match layout {
+ LayoutSnapshot::Pane(id) => Some(*id),
+ LayoutSnapshot::Split { first, second, .. } => {
+ first_pane_id_in_layout(first).or_else(|| first_pane_id_in_layout(second))
+ }
+ }
+}
+
+/// Capture the current app state into a serializable snapshot.
+pub fn capture(
+ workspaces: &[Workspace],
+ active: Option<usize>,
+ selected: usize,
+ agent_panel_scope: crate::app::state::AgentPanelScope,
+ sidebar_width: u16,
+ sidebar_section_split: f32,
+) -> SessionSnapshot {
+ SessionSnapshot {
+ version: SNAPSHOT_VERSION,
+ workspaces: workspaces.iter().map(capture_workspace).collect(),
+ active,
+ selected,
+ agent_panel_scope,
+ sidebar_width: Some(sidebar_width),
+ sidebar_section_split: Some(sidebar_section_split),
+ }
+}
+
+fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
+ WorkspaceSnapshot {
+ id: Some(ws.id.clone()),
+ custom_name: ws.custom_name.clone(),
+ identity_cwd: ws
+ .resolved_identity_cwd()
+ .unwrap_or_else(|| ws.identity_cwd.clone()),
+ tabs: ws.tabs.iter().map(capture_tab).collect(),
+ active_tab: ws.active_tab,
+ }
+}
+
+fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot {
+ let mut panes = HashMap::new();
+ for id in tab.panes.keys() {
+ let cwd = tab
+ .cwd_for_pane(*id)
+ .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ panes.insert(id.raw(), PaneSnapshot { cwd });
+ }
+ TabSnapshot {
+ custom_name: tab.custom_name.clone(),
+ layout: capture_node(tab.layout.root()),
+ panes,
+ zoomed: tab.zoomed,
+ focused: Some(tab.layout.focused().raw()),
+ root_pane: Some(tab.root_pane.raw()),
+ }
+}
+
+pub(super) fn capture_node(node: &Node) -> LayoutSnapshot {
+ match node {
+ Node::Pane(id) => LayoutSnapshot::Pane(id.raw()),
+ Node::Split {
+ direction,
+ ratio,
+ first,
+ second,
+ } => LayoutSnapshot::Split {
+ direction: match direction {
+ Direction::Horizontal => DirectionSnapshot::Horizontal,
+ Direction::Vertical => DirectionSnapshot::Vertical,
+ },
+ ratio: *ratio,
+ first: Box::new(capture_node(first)),
+ second: Box::new(capture_node(second)),
+ },
+ }
+}
+
+pub(super) fn parse_snapshot(content: &str) -> Result<SessionSnapshot, String> {
+ let raw = serde_json::from_str::<RawSessionSnapshot>(content).map_err(|e| e.to_string())?;
+ if raw.version > SNAPSHOT_VERSION {
+ return Err(format!(
+ "snapshot version {} is newer than supported {}",
+ raw.version, SNAPSHOT_VERSION
+ ));
+ }
+ migrate_snapshot(raw)
+}
+
+pub(super) fn snapshot_file_version(content: &str) -> Option<u32> {
+ serde_json::from_str::<RawSessionSnapshot>(content)
+ .ok()
+ .map(|raw| raw.version)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::collections::HashMap;
+ use std::path::PathBuf;
+
+ use ratatui::layout::{Direction, Rect};
+
+ use super::*;
+ use crate::app::{state::AgentPanelScope, AppState, Mode};
+ use crate::layout::{NavDirection, Node, PaneId};
+ use crate::workspace::Workspace;
+
+ fn session_fixture(name: &str) -> &'static str {
+ match name {
+ "current-herdr" => {
+ include_str!("../../tests/fixtures/session/current-herdr-session.json")
+ }
+ "current-herdr-dev" => {
+ include_str!("../../tests/fixtures/session/current-herdr-dev-session.json")
+ }
+ "legacy-pre-tabs-v2" => {
+ include_str!("../../tests/fixtures/session/legacy-pre-tabs-v2.json")
+ }
+ other => panic!("unknown session fixture: {other}"),
+ }
+ }
+
+ 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::Terminal;
+ }
+ state
+ }
+
+ fn capture_from_state(state: &AppState) -> SessionSnapshot {
+ capture(
+ &state.workspaces,
+ state.active,
+ state.selected,
+ state.agent_panel_scope,
+ state.sidebar_width,
+ state.sidebar_section_split,
+ )
+ }
+
+ fn root_split_ratio(tab: &TabSnapshot) -> Option<f32> {
+ match &tab.layout {
+ LayoutSnapshot::Split { ratio, .. } => Some(*ratio),
+ LayoutSnapshot::Pane(_) => None,
+ }
+ }
+
+ #[test]
+ fn round_trip_empty_session() {
+ let snap = SessionSnapshot {
+ version: SNAPSHOT_VERSION,
+ workspaces: vec![],
+ active: None,
+ selected: 0,
+ agent_panel_scope: AgentPanelScope::CurrentWorkspace,
+ sidebar_width: Some(26),
+ sidebar_section_split: Some(0.5),
+ };
+ let json = serde_json::to_string(&snap).unwrap();
+ let restored = parse_snapshot(&json).unwrap();
+ assert!(restored.workspaces.is_empty());
+ assert_eq!(restored.active, None);
+ assert_eq!(restored.sidebar_width, Some(26));
+ assert_eq!(restored.sidebar_section_split, Some(0.5));
+ }
+
+ #[test]
+ fn round_trip_layout_snapshot() {
+ let layout = LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Horizontal,
+ ratio: 0.6,
+ first: Box::new(LayoutSnapshot::Pane(0)),
+ second: Box::new(LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Vertical,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(1)),
+ second: Box::new(LayoutSnapshot::Pane(2)),
+ }),
+ };
+ let json = serde_json::to_string(&layout).unwrap();
+ let restored: LayoutSnapshot = serde_json::from_str(&json).unwrap();
+
+ match restored {
+ LayoutSnapshot::Split { ratio, .. } => assert!((ratio - 0.6).abs() < 0.01),
+ _ => panic!("expected split"),
+ }
+ }
+
+ #[test]
+ fn round_trip_full_workspace_snapshot() {
+ let mut panes = HashMap::new();
+ panes.insert(
+ 0,
+ PaneSnapshot {
+ cwd: PathBuf::from("/home/can/Projects/herdr"),
+ },
+ );
+ panes.insert(
+ 1,
+ PaneSnapshot {
+ cwd: PathBuf::from("/home/can/Projects/website"),
+ },
+ );
+
+ let snap = SessionSnapshot {
+ workspaces: vec![WorkspaceSnapshot {
+ id: Some("wproj".to_string()),
+ custom_name: Some("pi-mono".to_string()),
+ identity_cwd: PathBuf::from("/home/can/Projects/herdr"),
+ tabs: vec![TabSnapshot {
+ custom_name: Some("api".to_string()),
+ layout: LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Horizontal,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(0)),
+ second: Box::new(LayoutSnapshot::Pane(1)),
+ },
+ panes,
+ zoomed: false,
+ focused: Some(0),
+ root_pane: Some(0),
+ }],
+ active_tab: 0,
+ }],
+ active: Some(0),
+ selected: 0,
+ agent_panel_scope: AgentPanelScope::CurrentWorkspace,
+ sidebar_width: Some(26),
+ sidebar_section_split: Some(0.5),
+ version: SNAPSHOT_VERSION,
+ };
+
+ let json = serde_json::to_string_pretty(&snap).unwrap();
+ let restored = parse_snapshot(&json).unwrap();
+
+ assert_eq!(restored.workspaces.len(), 1);
+ assert_eq!(restored.workspaces[0].id.as_deref(), Some("wproj"));
+ assert_eq!(
+ restored.workspaces[0].custom_name.as_deref(),
+ Some("pi-mono")
+ );
+ assert_eq!(restored.workspaces[0].tabs.len(), 1);
+ assert_eq!(restored.workspaces[0].tabs[0].panes.len(), 2);
+ assert_eq!(
+ restored.workspaces[0].tabs[0].panes[&0].cwd,
+ PathBuf::from("/home/can/Projects/herdr")
+ );
+ assert_eq!(
+ restored.agent_panel_scope,
+ AgentPanelScope::CurrentWorkspace
+ );
+ assert_eq!(restored.sidebar_width, Some(26));
+ assert_eq!(restored.sidebar_section_split, Some(0.5));
+ }
+
+ #[test]
+ fn current_session_fixture_parses() {
+ let snap = parse_snapshot(session_fixture("current-herdr")).unwrap();
+
+ assert_eq!(snap.version, 3);
+ assert_eq!(snap.workspaces.len(), 2);
+ assert_eq!(snap.active, Some(0));
+ assert_eq!(snap.selected, 0);
+ assert_eq!(snap.agent_panel_scope, AgentPanelScope::CurrentWorkspace);
+ assert_eq!(snap.sidebar_width, None);
+ assert_eq!(snap.sidebar_section_split, None);
+ assert_eq!(snap.workspaces[0].tabs.len(), 2);
+ assert_eq!(
+ snap.workspaces[1].identity_cwd,
+ PathBuf::from("/home/test/projects/project-b")
+ );
+ }
+
+ #[test]
+ fn current_dev_session_fixture_parses_additive_fields() {
+ let snap = parse_snapshot(session_fixture("current-herdr-dev")).unwrap();
+
+ assert_eq!(snap.version, 3);
+ assert_eq!(snap.workspaces.len(), 2);
+ assert_eq!(snap.agent_panel_scope, AgentPanelScope::CurrentWorkspace);
+ assert_eq!(snap.sidebar_section_split, Some(0.4));
+ assert_eq!(snap.workspaces[0].active_tab, 1);
+ assert_eq!(snap.workspaces[1].tabs[0].panes.len(), 2);
+ }
+
+ #[test]
+ fn old_snapshot_defaults_agent_panel_scope() {
+ let json = serde_json::json!({
+ "version": SNAPSHOT_VERSION,
+ "workspaces": [],
+ "active": null,
+ "selected": 0
+ })
+ .to_string();
+
+ let restored = parse_snapshot(&json).unwrap();
+
+ assert_eq!(
+ restored.agent_panel_scope,
+ AgentPanelScope::CurrentWorkspace
+ );
+ assert_eq!(restored.sidebar_width, None);
+ assert_eq!(restored.sidebar_section_split, None);
+ }
+
+ #[test]
+ fn legacy_workspace_snapshot_migrates_to_single_tab() {
+ let snap = parse_snapshot(session_fixture("legacy-pre-tabs-v2")).unwrap();
+ let ws = &snap.workspaces[0];
+
+ assert_eq!(snap.version, 2);
+ assert_eq!(snap.workspaces.len(), 1);
+ assert_eq!(ws.custom_name.as_deref(), Some("legacy"));
+ assert_eq!(ws.identity_cwd, PathBuf::from("/tmp/pion"));
+ assert_eq!(ws.active_tab, 0);
+ assert_eq!(ws.tabs.len(), 1);
+ assert_eq!(ws.tabs[0].focused, Some(1));
+ assert_eq!(ws.tabs[0].root_pane, Some(0));
+ assert_eq!(ws.tabs[0].panes[&0].cwd, PathBuf::from("/tmp/pion"));
+ assert_eq!(ws.tabs[0].panes[&1].cwd, PathBuf::from("/tmp/herdr"));
+ }
+
+ #[test]
+ fn capture_contract_tracks_workspace_order_active_and_selected() {
+ let mut state = state_with_workspaces(&["a", "b", "c"]);
+ state.active = Some(1);
+ state.selected = 2;
+
+ state.move_workspace(1, 0);
+
+ let snapshot = capture_from_state(&state);
+ let ids: Vec<_> = state.workspaces.iter().map(|ws| ws.id.clone()).collect();
+ let captured_ids: Vec<_> = snapshot
+ .workspaces
+ .iter()
+ .map(|ws| ws.id.clone().unwrap())
+ .collect();
+ assert_eq!(captured_ids, ids);
+ assert_eq!(snapshot.active, state.active);
+ assert_eq!(snapshot.selected, state.selected);
+ }
+
+ #[test]
+ fn capture_contract_tracks_workspace_and_tab_names_and_active_tab() {
+ let mut state = state_with_workspaces(&["one"]);
+ state.workspaces[0].set_custom_name("renamed-workspace".into());
+ let second_tab = state.workspaces[0].test_add_tab(Some("logs"));
+ state.workspaces[0].switch_tab(second_tab);
+ state.workspaces[0].tabs[0].set_custom_name("main".into());
+
+ let snapshot = capture_from_state(&state);
+ let workspace = &snapshot.workspaces[0];
+ assert_eq!(workspace.custom_name.as_deref(), Some("renamed-workspace"));
+ assert_eq!(workspace.active_tab, second_tab);
+ assert_eq!(workspace.tabs[0].custom_name.as_deref(), Some("main"));
+ assert_eq!(workspace.tabs[1].custom_name.as_deref(), Some("logs"));
+ }
+
+ #[test]
+ fn capture_contract_tracks_workspace_closure() {
+ let mut state = state_with_workspaces(&["one", "two"]);
+ state.selected = 1;
+ state.active = Some(1);
+
+ state.close_selected_workspace();
+
+ let snapshot = capture_from_state(&state);
+ assert_eq!(snapshot.workspaces.len(), 1);
+ assert_eq!(snapshot.workspaces[0].custom_name.as_deref(), Some("one"));
+ assert_eq!(snapshot.active, Some(0));
+ assert_eq!(snapshot.selected, 0);
+ }
+
+ #[test]
+ fn capture_contract_tracks_sidebar_state() {
+ let mut state = state_with_workspaces(&["one"]);
+ state.sidebar_width = 31;
+ state.sidebar_section_split = 0.4;
+ state.agent_panel_scope = AgentPanelScope::AllWorkspaces;
+
+ let snapshot = capture_from_state(&state);
+ assert_eq!(snapshot.sidebar_width, Some(31));
+ assert_eq!(snapshot.sidebar_section_split, Some(0.4));
+ assert_eq!(snapshot.agent_panel_scope, AgentPanelScope::AllWorkspaces);
+ }
+
+ #[test]
+ fn capture_contract_tracks_layout_focus_zoom_and_root_pane() {
+ let mut state = state_with_workspaces(&["one"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let second = state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].tabs[0].layout.focus_pane(second);
+ state.toggle_fullscreen();
+
+ let snapshot = capture_from_state(&state);
+ let tab = &snapshot.workspaces[0].tabs[0];
+ assert!(matches!(tab.layout, LayoutSnapshot::Split { .. }));
+ assert_eq!(tab.focused, Some(second.raw()));
+ assert_eq!(tab.root_pane, Some(root.raw()));
+ assert!(tab.zoomed);
+ assert_eq!(tab.panes.len(), 2);
+ }
+
+ #[test]
+ fn capture_contract_tracks_focus_navigation() {
+ let mut state = state_with_workspaces(&["one"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ let second = state.workspaces[0].test_split(Direction::Horizontal);
+ crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20));
+
+ state.navigate_pane(NavDirection::Right);
+
+ let snapshot = capture_from_state(&state);
+ assert_eq!(snapshot.workspaces[0].tabs[0].focused, Some(second.raw()));
+ assert_ne!(snapshot.workspaces[0].tabs[0].focused, Some(root.raw()));
+ }
+
+ #[test]
+ fn capture_contract_tracks_resize_ratio_changes() {
+ let mut state = state_with_workspaces(&["one"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+ crate::ui::compute_view(&mut state, Rect::new(0, 0, 106, 20));
+ let before = capture_from_state(&state);
+
+ state.resize_pane(NavDirection::Right);
+
+ let after = capture_from_state(&state);
+ let before_ratio = root_split_ratio(&before.workspaces[0].tabs[0]).unwrap();
+ let after_ratio = root_split_ratio(&after.workspaces[0].tabs[0]).unwrap();
+ assert_ne!(before_ratio, after_ratio);
+ }
+
+ #[test]
+ fn capture_contract_tracks_tab_closure() {
+ let mut state = state_with_workspaces(&["one"]);
+ let second_tab = state.workspaces[0].test_add_tab(Some("logs"));
+ state.switch_tab(second_tab);
+
+ state.close_tab();
+
+ let snapshot = capture_from_state(&state);
+ let workspace = &snapshot.workspaces[0];
+ assert_eq!(workspace.tabs.len(), 1);
+ assert_eq!(workspace.active_tab, 0);
+ assert!(workspace.tabs[0].custom_name.is_none());
+ }
+
+ #[test]
+ fn capture_contract_tracks_pane_closure() {
+ let mut state = state_with_workspaces(&["one"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+
+ state.close_pane();
+
+ let snapshot = capture_from_state(&state);
+ let tab = &snapshot.workspaces[0].tabs[0];
+ assert_eq!(tab.panes.len(), 1);
+ assert!(matches!(tab.layout, LayoutSnapshot::Pane(_)));
+ assert!(!tab.zoomed);
+ }
+
+ #[test]
+ fn capture_contract_tracks_workspace_identity_and_pane_cwds() {
+ let mut state = state_with_workspaces(&["one"]);
+ let root = state.workspaces[0].tabs[0].root_pane;
+ state.workspaces[0].tabs[0]
+ .pane_cwds
+ .insert(root, PathBuf::from("/tmp/pion"));
+ let second = state.workspaces[0].test_split(Direction::Horizontal);
+ state.workspaces[0].tabs[0]
+ .pane_cwds
+ .insert(second, PathBuf::from("/tmp/herdr"));
+
+ let snapshot = capture_from_state(&state);
+ let workspace = &snapshot.workspaces[0];
+ let tab = &workspace.tabs[0];
+ assert_eq!(workspace.identity_cwd, PathBuf::from("/tmp/pion"));
+ assert_eq!(tab.panes[&root.raw()].cwd, PathBuf::from("/tmp/pion"));
+ assert_eq!(tab.panes[&second.raw()].cwd, PathBuf::from("/tmp/herdr"));
+ }
+
+ #[test]
+ fn old_unversioned_snapshot_loads_as_version_0() {
+ let json = r#"{"workspaces":[],"active":null,"selected":0}"#;
+ let snap = parse_snapshot(json).unwrap();
+ assert_eq!(snap.version, 0);
+ }
+
+ #[test]
+ fn future_version_is_rejected() {
+ let json = r#"{"version":999,"workspaces":[],"active":null,"selected":0}"#;
+ assert!(parse_snapshot(json).is_err());
+ }
+
+ #[test]
+ fn active_tab_default_is_zero() {
+ let json = r#"{"custom_name":"test","identity_cwd":"/tmp","tabs":[]}"#;
+ let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
+ assert_eq!(ws.active_tab, 0);
+ }
+
+ #[test]
+ fn restore_falls_back_to_home_when_cwd_missing() {
+ let mut panes = HashMap::new();
+ panes.insert(
+ 0,
+ PaneSnapshot {
+ cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"),
+ },
+ );
+ panes.insert(
+ 1,
+ PaneSnapshot {
+ cwd: std::env::var("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| PathBuf::from("/tmp")),
+ },
+ );
+
+ let snap = SessionSnapshot {
+ version: SNAPSHOT_VERSION,
+ workspaces: vec![WorkspaceSnapshot {
+ id: Some("test-ws".to_string()),
+ custom_name: Some("fallback test".to_string()),
+ identity_cwd: PathBuf::from("/tmp"),
+ tabs: vec![TabSnapshot {
+ custom_name: None,
+ layout: LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Horizontal,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(0)),
+ second: Box::new(LayoutSnapshot::Pane(1)),
+ },
+ panes,
+ zoomed: false,
+ focused: Some(0),
+ root_pane: Some(0),
+ }],
+ active_tab: 0,
+ }],
+ active: Some(0),
+ selected: 0,
+ agent_panel_scope: AgentPanelScope::CurrentWorkspace,
+ sidebar_width: Some(26),
+ sidebar_section_split: Some(0.5),
+ };
+
+ let json = serde_json::to_string(&snap).unwrap();
+ let restored = parse_snapshot(&json).unwrap();
+ assert_eq!(restored.workspaces.len(), 1);
+ assert_eq!(
+ restored.workspaces[0].tabs[0].panes[&0].cwd,
+ PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test")
+ );
+ }
+}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index f95c7418..dc944cd9 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -217,9 +217,16 @@ fn process_session_id(pid: u32) -> Option<i32> {
#[cfg(test)]
mod tests {
use super::*;
+ use std::sync::{Mutex, OnceLock};
+
+ fn env_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
#[test]
fn clipboard_commands_prefer_wayland_when_available() {
+ let _guard = env_lock().lock().unwrap();
unsafe {
std::env::set_var("WAYLAND_DISPLAY", "wayland-0");
std::env::remove_var("DISPLAY");
@@ -231,6 +238,7 @@ mod tests {
#[test]
fn clipboard_commands_include_x11_fallbacks() {
+ let _guard = env_lock().lock().unwrap();
unsafe {
std::env::remove_var("WAYLAND_DISPLAY");
std::env::set_var("DISPLAY", ":0");
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 262721c8..a98cf25d 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -2,6 +2,113 @@ use std::io::Read;
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
+/// Parse raw terminal input bytes into a list of `RawInputEvent`s.
+///
+/// This is used by the headless server to route client input through the
+/// same parsing pipeline that the monolithic binary uses for stdin.
+/// Incomplete sequences at the end of the buffer are flushed as best-effort
+/// (same logic as the live input reader).
+#[allow(dead_code)]
+pub fn parse_raw_input_bytes(data: &[u8]) -> Vec<RawInputEvent> {
+ // Delegate to the sync version which actually works.
+ parse_raw_input_bytes_sync(data)
+}
+
+/// A raw input event paired with the byte range it consumed from the original buffer.
+#[cfg(test)]
+#[derive(Debug)]
+pub struct RawInputEventWithRange {
+ /// The parsed event.
+ pub event: RawInputEvent,
+ /// Byte offset where this event starts in the original buffer.
+ pub start: usize,
+ /// Number of bytes this event consumed from the original buffer.
+ /// For events generated from flushed incomplete bytes, `len` may be 0
+ /// (synthetic events that don't map to original bytes).
+ pub len: usize,
+}
+
+/// Parse raw terminal input bytes into a list of `RawInputEventWithRange`s (synchronous version).
+///
+/// Unlike `parse_raw_input_bytes_sync`, this preserves the byte offset for each
+/// event, allowing callers to write only the specific bytes for each event
+/// instead of the entire input buffer.
+#[cfg(test)]
+pub fn parse_raw_input_bytes_with_ranges(data: &[u8]) -> Vec<RawInputEventWithRange> {
+ let mut buffer = data.to_vec();
+ let mut events = Vec::new();
+ let mut offset = 0usize;
+
+ loop {
+ let Some((event, consumed)) = extract_one_event(&buffer) else {
+ break;
+ };
+ buffer.drain(..consumed);
+ events.push(RawInputEventWithRange {
+ event,
+ start: offset,
+ len: consumed,
+ });
+ offset += consumed;
+ }
+
+ // Flush remaining incomplete bytes.
+ if !buffer.is_empty() {
+ if buffer.as_slice() == [ESC] {
+ events.push(RawInputEventWithRange {
+ event: RawInputEvent::Key(TerminalKey::new(
+ crossterm::event::KeyCode::Esc,
+ KeyModifiers::empty(),
+ )),
+ start: offset,
+ len: 1,
+ });
+ } else if let Ok(text) = std::str::from_utf8(&buffer) {
+ if let Some(key) = parse_terminal_key_sequence(text) {
+ events.push(RawInputEventWithRange {
+ event: RawInputEvent::Key(key),
+ start: offset,
+ len: buffer.len(),
+ });
+ }
+ }
+ }
+
+ events
+}
+
+/// Parse raw terminal input bytes into a list of `RawInputEvent`s (synchronous version).
+///
+/// Unlike `parse_raw_input_bytes`, this directly extracts events without
+/// going through a channel, making it suitable for synchronous use.
+pub fn parse_raw_input_bytes_sync(data: &[u8]) -> Vec<RawInputEvent> {
+ let mut buffer = data.to_vec();
+ let mut events = Vec::new();
+
+ loop {
+ let Some((event, consumed)) = extract_one_event(&buffer) else {
+ break;
+ };
+ buffer.drain(..consumed);
+ events.push(event);
+ }
+
+ if !buffer.is_empty() {
+ if buffer.as_slice() == [ESC] {
+ events.push(RawInputEvent::Key(TerminalKey::new(
+ crossterm::event::KeyCode::Esc,
+ KeyModifiers::empty(),
+ )));
+ } else if let Ok(text) = std::str::from_utf8(&buffer) {
+ if let Some(key) = parse_terminal_key_sequence(text) {
+ events.push(RawInputEvent::Key(key));
+ }
+ }
+ }
+
+ events
+}
+
#[cfg(unix)]
use std::os::fd::AsRawFd;
use tokio::sync::mpsc;
@@ -54,30 +161,56 @@ pub fn spawn_input_reader() -> mpsc::Receiver<RawInputEvent> {
}
fn drain_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
+ for bytes in drain_complete_input_bytes(buffer) {
+ let Some((event, _consumed)) = extract_one_event(&bytes) else {
+ continue;
+ };
+ tracing::debug!(raw_bytes = ?bytes, event = ?event, "raw input event parsed");
+ let _ = tx.blocking_send(event);
+ }
+}
+
+pub(crate) fn drain_complete_input_bytes(buffer: &mut Vec<u8>) -> Vec<Vec<u8>> {
+ let mut chunks = Vec::new();
+
loop {
- let Some((event, consumed)) = extract_one_event(buffer) else {
+ let Some((_event, consumed)) = extract_one_event(buffer) else {
break;
};
- tracing::debug!(
- raw_bytes = ?&buffer[..consumed],
- event = ?event,
- "raw input event parsed"
- );
+ chunks.push(buffer[..consumed].to_vec());
buffer.drain(..consumed);
- let _ = tx.blocking_send(event);
}
+
+ chunks
}
fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
+ if let Some(bytes) = flush_incomplete_input_bytes(buffer) {
+ if bytes.as_slice() == [ESC] {
+ let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new(
+ crossterm::event::KeyCode::Esc,
+ KeyModifiers::empty(),
+ )));
+ return;
+ }
+
+ let Some((event, _consumed)) = extract_one_event(&bytes) else {
+ return;
+ };
+ let _ = tx.blocking_send(event);
+ }
+}
+
+pub(crate) fn flush_incomplete_input_bytes(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
if buffer.is_empty() {
- return;
+ return None;
}
if buffer.starts_with(BRACKETED_PASTE_START)
&& find_subsequence(buffer, BRACKETED_PASTE_END).is_none()
{
tracing::trace!(len = buffer.len(), "waiting for bracketed paste terminator");
- return;
+ return None;
}
if buffer.as_slice() == [ESC] {
@@ -85,24 +218,18 @@ fn flush_incomplete_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent
bytes = ?buffer,
"flushing lone escape after input timeout; if this follows an alt chord or focus switch it may reach the pane as plain esc"
);
- let _ = tx.blocking_send(RawInputEvent::Key(TerminalKey::new(
- crossterm::event::KeyCode::Esc,
- KeyModifiers::empty(),
- )));
- buffer.clear();
- return;
+ return Some(std::mem::take(buffer));
}
if let Ok(text) = std::str::from_utf8(buffer) {
- if let Some(key) = parse_terminal_key_sequence(text) {
- let _ = tx.blocking_send(RawInputEvent::Key(key));
- buffer.clear();
- return;
+ if parse_terminal_key_sequence(text).is_some() {
+ return Some(std::mem::take(buffer));
}
}
tracing::debug!(bytes = ?buffer, "dropping incomplete raw input buffer after timeout");
buffer.clear();
+ None
}
#[cfg(unix)]
@@ -790,4 +917,78 @@ mod tests {
KeyModifiers::empty(),
);
}
+
+ #[test]
+ fn parse_with_ranges_tracks_byte_offsets() {
+ use super::parse_raw_input_bytes_with_ranges;
+
+ // Input: Up arrow (3 bytes) + 'a' (1 byte) + Down arrow (3 bytes)
+ let input = b"\x1b[Aa\x1b[B".to_vec();
+ let ranges = parse_raw_input_bytes_with_ranges(&input);
+
+ assert_eq!(ranges.len(), 3, "should parse three events");
+
+ // Up arrow: \x1b[A at offset 0, length 3
+ assert_eq!(ranges[0].start, 0);
+ assert_eq!(ranges[0].len, 3);
+ assert!(matches!(
+ &ranges[0].event,
+ RawInputEvent::Key(k) if k.code == KeyCode::Up
+ ));
+
+ // 'a' at offset 3, length 1
+ assert_eq!(ranges[1].start, 3);
+ assert_eq!(ranges[1].len, 1);
+ assert!(matches!(
+ &ranges[1].event,
+ RawInputEvent::Key(k) if k.code == KeyCode::Char('a')
+ ));
+
+ // Down arrow: \x1b[B at offset 4, length 3
+ assert_eq!(ranges[2].start, 4);
+ assert_eq!(ranges[2].len, 3);
+ assert!(matches!(
+ &ranges[2].event,
+ RawInputEvent::Key(k) if k.code == KeyCode::Down
+ ));
+
+ // Verify the raw bytes for each event slice correctly.
+ assert_eq!(
+ &input[ranges[0].start..ranges[0].start + ranges[0].len],
+ b"\x1b[A"
+ );
+ assert_eq!(
+ &input[ranges[1].start..ranges[1].start + ranges[1].len],
+ b"a"
+ );
+ assert_eq!(
+ &input[ranges[2].start..ranges[2].start + ranges[2].len],
+ b"\x1b[B"
+ );
+ }
+
+ #[test]
+ fn parse_with_ranges_handles_single_event() {
+ use super::parse_raw_input_bytes_with_ranges;
+
+ let input = b"a".to_vec();
+ let ranges = parse_raw_input_bytes_with_ranges(&input);
+
+ assert_eq!(ranges.len(), 1);
+ assert_eq!(ranges[0].start, 0);
+ assert_eq!(ranges[0].len, 1);
+ }
+
+ #[test]
+ fn parse_with_ranges_handles_mouse_event() {
+ use super::parse_raw_input_bytes_with_ranges;
+
+ let input = b"\x1b[<0;20;10M".to_vec();
+ let ranges = parse_raw_input_bytes_with_ranges(&input);
+
+ assert_eq!(ranges.len(), 1);
+ assert_eq!(ranges[0].start, 0);
+ assert_eq!(ranges[0].len, input.len());
+ assert!(matches!(&ranges[0].event, RawInputEvent::Mouse(_)));
+ }
}
diff --git a/src/selection.rs b/src/selection.rs
index 9d29b08e..6b2fc093 100644
--- a/src/selection.rs
+++ b/src/selection.rs
@@ -11,7 +11,7 @@
//! coordinates. That keeps selection stable while the pane scrolls.
use ratatui::layout::Rect;
-use std::io::Write;
+use std::{ffi::OsStr, io::Write};
use crate::{layout::PaneId, pane::ScrollMetrics};
@@ -171,6 +171,17 @@ fn osc52_sequence(bytes: &[u8]) -> String {
format!("\x1b]52;c;{encoded}\x07")
}
+fn should_prefer_osc52_for_env(ssh_connection: Option<&OsStr>, ssh_tty: Option<&OsStr>) -> bool {
+ ssh_connection.is_some() || ssh_tty.is_some()
+}
+
+fn should_prefer_osc52() -> bool {
+ should_prefer_osc52_for_env(
+ std::env::var_os("SSH_CONNECTION").as_deref(),
+ std::env::var_os("SSH_TTY").as_deref(),
+ )
+}
+
/// Write clipboard bytes to the system clipboard via OSC 52.
///
/// OSC 52 format: `ESC ] 52 ; c ; <base64> BEL`
@@ -178,7 +189,7 @@ fn osc52_sequence(bytes: &[u8]) -> String {
/// Some terminals still only honor BEL-terminated OSC 52 writes, so herdr
/// emits BEL here even though ST works in newer emulators.
pub fn write_osc52_bytes(bytes: &[u8]) {
- if crate::platform::write_clipboard(bytes) {
+ if !should_prefer_osc52() && crate::platform::write_clipboard(bytes) {
return;
}
@@ -187,11 +198,6 @@ pub fn write_osc52_bytes(bytes: &[u8]) {
let _ = std::io::stdout().flush();
}
-/// Write text to the system clipboard via OSC 52.
-pub fn write_osc52(text: &str) {
- write_osc52_bytes(text.as_bytes());
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -210,6 +216,19 @@ mod tests {
}
#[test]
+ fn ssh_sessions_prefer_osc52() {
+ assert!(should_prefer_osc52_for_env(
+ Some(OsStr::new("1 2 3 4")),
+ None
+ ));
+ assert!(should_prefer_osc52_for_env(
+ None,
+ Some(OsStr::new("/dev/ttys001"))
+ ));
+ assert!(!should_prefer_osc52_for_env(None, None));
+ }
+
+ #[test]
fn ordering_forward() {
let sel = make_sel(2, 5, 4, 10);
assert_eq!(sel.ordered(), ((2, 5), (4, 10)));
diff --git a/src/server/autodetect.rs b/src/server/autodetect.rs
new file mode 100644
index 00000000..b0ca2a2e
--- /dev/null
+++ b/src/server/autodetect.rs
@@ -0,0 +1,296 @@
+//! Auto-detect launch behavior for the `herdr` command.
+//!
+//! When the user runs `herdr` with no subcommand:
+//! 1. Check if a server is already listening on the client socket
+//! 2. If no server → spawn one as a background daemon → wait for socket readiness (up to 5s)
+//! 3. Attach as a thin client to the server
+//!
+//! The `--no-session` flag bypasses server/client entirely and runs monolithically
+//! (escape hatch for users who want the traditional single-process behavior).
+
+use std::io;
+use std::os::unix::net::UnixStream;
+use std::os::unix::process::CommandExt;
+use std::path::Path;
+use std::process::Command;
+use std::time::Duration;
+
+use tracing::{info, warn};
+
+use super::headless::client_socket_path;
+
+/// Maximum time to wait for the server's client socket to become ready
+/// after spawning the server process.
+const SERVER_READY_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// Poll interval when waiting for the server socket to appear.
+const SOCKET_POLL_INTERVAL: Duration = Duration::from_millis(50);
+
+// ---------------------------------------------------------------------------
+// Server detection
+// ---------------------------------------------------------------------------
+
+/// Checks whether a herdr server is currently listening on the client socket.
+///
+/// This works by attempting to connect to the client socket. If the connection
+/// succeeds, a server is running. If the socket file doesn't exist or the
+/// connection is refused, no server is running. Stale sockets (from a crashed
+/// server) are detected because `UnixStream::connect` returns `ConnectionRefused`
+/// when nobody is listening.
+#[allow(dead_code)] // Public API for external use and testing
+pub fn is_server_listening() -> bool {
+ is_server_listening_at(&client_socket_path())
+}
+
+/// Checks whether a herdr server is listening at a specific socket path.
+fn is_server_listening_at(socket_path: &Path) -> bool {
+ if !socket_path.exists() {
+ return false;
+ }
+
+ match UnixStream::connect(socket_path) {
+ Ok(_) => {
+ // Server is listening. Close the test connection immediately.
+ // The server's handshake handler will time out on this connection
+ // since we don't send Hello, which is fine.
+ true
+ }
+ Err(err)
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused | io::ErrorKind::TimedOut
+ ) =>
+ {
+ // Socket file exists but nobody is listening — stale socket.
+ false
+ }
+ Err(err) if err.kind() == io::ErrorKind::NotFound => {
+ // Socket file disappeared between exists() and connect().
+ false
+ }
+ Err(err) => {
+ // Other errors (permission denied, etc.) — assume not listening.
+ warn!(err = %err, "unexpected error checking server socket");
+ false
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Server spawning
+// ---------------------------------------------------------------------------
+
+/// Spawns the herdr server as a background daemon process.
+///
+/// The server process is fully detached:
+/// - Runs in its own session (setsid) so it survives the client exiting
+/// - Stdin/stdout/stderr are redirected to /dev/null
+/// - Inherits all relevant environment variables
+/// (`HERDR_SOCKET_PATH`, `HERDR_CLIENT_SOCKET_PATH`, `XDG_CONFIG_HOME`, etc.)
+///
+/// Returns the PID of the spawned server process.
+pub fn spawn_server_daemon() -> io::Result<u32> {
+ let exe = std::env::current_exe().map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!("failed to determine herdr executable path: {err}"),
+ )
+ })?;
+
+ info!(exe = %exe.display(), "spawning server daemon");
+
+ let child = Command::new(&exe)
+ .arg("server")
+ // Create a new process group so the server survives the parent's exit
+ // and doesn't receive SIGHUP when the client's terminal closes.
+ .process_group(0)
+ // Redirect stdio to /dev/null
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null())
+ .spawn()
+ .map_err(|err: io::Error| {
+ io::Error::new(err.kind(), format!("failed to spawn herdr server: {err}"))
+ })?;
+
+ let pid = child.id();
+ info!(pid, "server daemon spawned");
+
+ Ok(pid)
+}
+
+// ---------------------------------------------------------------------------
+// Socket readiness
+// ---------------------------------------------------------------------------
+
+/// Waits for the server's client socket to become ready for connections.
+///
+/// Polls the socket path at regular intervals until a connection succeeds
+/// or the timeout elapses. Returns an error if the server doesn't become
+/// ready within the timeout.
+pub fn wait_for_server_socket(socket_path: &Path, timeout: Duration) -> io::Result<()> {
+ let deadline = std::time::Instant::now() + timeout;
+
+ while std::time::Instant::now() < deadline {
+ if is_server_listening_at(socket_path) {
+ info!(path = %socket_path.display(), "server socket ready");
+ return Ok(());
+ }
+ std::thread::sleep(SOCKET_POLL_INTERVAL);
+ }
+
+ Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ format!(
+ "server did not become ready within {}s (socket: {})",
+ timeout.as_secs(),
+ socket_path.display()
+ ),
+ ))
+}
+
+// ---------------------------------------------------------------------------
+// Auto-detect launch
+// ---------------------------------------------------------------------------
+
+/// Performs auto-detect launch: check for server, spawn if needed, then
+/// attach as a thin client.
+///
+/// This is the entry point called from `main.rs` when the user runs `herdr`
+/// without `--no-session` and without a subcommand.
+///
+/// Flow:
+/// 1. Check if a server is listening on the client socket
+/// 2. If no server → spawn server daemon → wait for socket readiness
+/// 3. Run the thin client (which connects to the server)
+pub fn auto_detect_launch() -> io::Result<()> {
+ let socket_path = client_socket_path();
+ info!(path = %socket_path.display(), "auto-detect launch starting");
+
+ if is_server_listening_at(&socket_path) {
+ info!("server already running, attaching as client");
+ } else {
+ info!("no server running, spawning server daemon");
+ spawn_server_daemon()?;
+ wait_for_server_socket(&socket_path, SERVER_READY_TIMEOUT)?;
+ info!("server ready, attaching as client");
+ }
+
+ // Now attach as a thin client.
+ crate::client::run_client()
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::os::unix::net::UnixListener;
+
+ fn temp_socket_path(name: &str) -> std::path::PathBuf {
+ let unique = format!(
+ "herdr-autodetect-test-{}-{}-{}",
+ name,
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ std::env::temp_dir().join(unique).join("test.sock")
+ }
+
+ #[test]
+ fn is_server_listening_returns_false_for_nonexistent_path() {
+ let path = temp_socket_path("nonexistent");
+ assert!(!is_server_listening_at(&path));
+ }
+
+ #[test]
+ fn is_server_listening_returns_true_for_live_socket() {
+ let dir = temp_socket_path("live");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ let _listener = UnixListener::bind(&path).unwrap();
+ assert!(is_server_listening_at(&path));
+ }
+
+ #[test]
+ fn is_server_listening_returns_false_for_stale_socket() {
+ let dir = temp_socket_path("stale");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // Create a socket and immediately drop the listener.
+ // This leaves a stale socket file with nobody listening.
+ {
+ let _listener = UnixListener::bind(&path).unwrap();
+ }
+
+ // The socket file exists but nobody is listening.
+ assert!(!is_server_listening_at(&path));
+ }
+
+ #[test]
+ fn is_server_listening_returns_false_when_listener_dropped() {
+ let dir = temp_socket_path("dropped");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // Bind and immediately drop the listener.
+ drop(UnixListener::bind(&path).unwrap());
+
+ // Socket is stale — should return false.
+ assert!(!is_server_listening_at(&path));
+
+ let _ = std::fs::remove_dir_all(dir);
+ }
+
+ #[test]
+ fn wait_for_server_socket_succeeds_immediately() {
+ let dir = temp_socket_path("wait-ok");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ let _listener = UnixListener::bind(&path).unwrap();
+
+ // Should succeed immediately (socket is already ready).
+ let result = wait_for_server_socket(&path, Duration::from_millis(100));
+ assert!(result.is_ok());
+ }
+
+ #[test]
+ fn wait_for_server_socket_times_out() {
+ let dir = temp_socket_path("wait-timeout");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // No listener — should time out.
+ let result = wait_for_server_socket(&path, Duration::from_millis(50));
+ assert!(result.is_err());
+ assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut);
+ }
+
+ #[test]
+ fn wait_for_server_socket_succeeds_after_delay() {
+ let dir = temp_socket_path("wait-delay");
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("test.sock");
+
+ // Spawn a thread that will create the listener after a short delay.
+ let path_clone = path.clone();
+ std::thread::spawn(move || {
+ std::thread::sleep(Duration::from_millis(50));
+ let _listener = UnixListener::bind(&path_clone).unwrap();
+ // Keep the listener alive for a bit.
+ std::thread::sleep(Duration::from_secs(1));
+ });
+
+ // Wait with a generous timeout — should succeed.
+ let result = wait_for_server_socket(&path, Duration::from_secs(2));
+ assert!(result.is_ok());
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
new file mode 100644
index 00000000..ba6098df
--- /dev/null
+++ b/src/server/headless.rs
@@ -0,0 +1,2272 @@
+//! Headless server mode — runs the herdr event loop without a real terminal.
+//!
+//! The server:
+//! - Does not enter raw mode or read stdin
+//! - Creates and listens on both `herdr.sock` (existing JSON API) and
+//! `herdr-client.sock` (new binary protocol)
+//! - Initializes AppState and all PTYs from session restore or fresh state
+//! - Runs the main event loop (drain events, drain API requests, scheduled tasks)
+//! - Renders to a virtual ratatui Buffer in memory
+//! - Accepts client connections on the client socket
+//! - Streams frames to connected clients after each render
+//! - Routes client input events through the existing input pipeline
+//! - Continues running after client disconnect
+//! - Handles stale socket cleanup, explicit server stop, minimum terminal size,
+//! and pane spawn failure during restore
+
+use std::collections::HashMap;
+use std::fs;
+use std::io::{self, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
+use ratatui::layout::{Position, Rect, Size};
+use tokio::sync::mpsc;
+use tracing::{debug, error, info, warn};
+
+use base64::Engine;
+
+use crate::api;
+use crate::app;
+use crate::app::state::AppState;
+use crate::config;
+use crate::events::AppEvent;
+use crate::server::protocol::{
+ self, ClientMessage, CursorState, FrameData, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
+};
+
+// ---------------------------------------------------------------------------
+// Loop event enum for the headless server event loop
+// ---------------------------------------------------------------------------
+
+/// Events that the headless server event loop can process.
+enum LoopEvent {
+ Timer,
+ Internal(AppEvent),
+ Api(api::ApiRequestMessage),
+ ServerEvent(ServerEvent),
+ RenderRequested,
+}
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+/// Default shared runtime size (columns, rows) when no clients are attached.
+const MIN_COLS: u16 = 80;
+const MIN_ROWS: u16 = 24;
+
+/// Minimum accepted attached client size.
+///
+/// Narrow observers must be allowed to drive narrow renders, otherwise the
+/// server wraps pane content against a wider width and the client sees the
+/// right edge clipped.
+const MIN_CLIENT_COLS: u16 = 1;
+const MIN_CLIENT_ROWS: u16 = 1;
+
+/// Legacy environment variable for overriding the client socket path.
+///
+/// Contractual override behavior for auto-detect uses `HERDR_SOCKET_PATH`.
+/// This variable is kept as a fallback for callers that explicitly need a
+/// client-only override when `HERDR_SOCKET_PATH` is not set.
+const CLIENT_SOCKET_PATH_ENV_VAR: &str = "HERDR_CLIENT_SOCKET_PATH";
+
+/// Socket permission mode (owner read/write only).
+const SOCKET_PERMISSION_MODE: u32 = 0o600;
+
+/// Timeout for in-flight API requests during shutdown.
+#[allow(dead_code)]
+const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
+
+/// How long to wait for a client handshake before closing the connection.
+/// Set to 4 seconds (rather than 5) to guarantee the connection is closed
+/// within the 5-second deadline, even with
+/// OS timer slack, thread scheduling, and cleanup overhead.
+const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
+
+/// Maximum input payload size (bytes) for a single `ClientMessage::Input`.
+const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
+
+// ---------------------------------------------------------------------------
+// Socket path helpers
+// ---------------------------------------------------------------------------
+
+/// Returns the path for the client protocol socket.
+///
+/// Contract-aligned override behavior:
+/// 1. If `HERDR_SOCKET_PATH` is set, derive the client socket path from it by
+/// inserting `-client` before `.sock` (e.g. `herdr.sock` -> `herdr-client.sock`).
+/// This keeps JSON API and client socket overrides consistent.
+/// 2. Otherwise, honor `HERDR_CLIENT_SOCKET_PATH` (legacy/testing fallback).
+/// 3. Otherwise, use the app config directory.
+pub fn client_socket_path() -> PathBuf {
+ client_socket_path_from_overrides(
+ std::env::var(api::SOCKET_PATH_ENV_VAR).ok().as_deref(),
+ std::env::var(CLIENT_SOCKET_PATH_ENV_VAR).ok().as_deref(),
+ )
+}
+
+fn client_socket_path_from_overrides(
+ api_socket_override: Option<&str>,
+ client_socket_override: Option<&str>,
+) -> PathBuf {
+ if let Some(api_socket_override) = api_socket_override {
+ return derive_client_socket_from_api_socket(Path::new(api_socket_override));
+ }
+
+ if let Some(client_socket_override) = client_socket_override {
+ return PathBuf::from(client_socket_override);
+ }
+
+ config::config_dir().join("herdr-client.sock")
+}
+
+fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf {
+ let stem = api_socket_path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or("herdr");
+ let parent = api_socket_path.parent().unwrap_or_else(|| Path::new(""));
+
+ if api_socket_path
+ .extension()
+ .and_then(|ext| ext.to_str())
+ .is_some_and(|ext| ext == "sock")
+ {
+ return parent.join(format!("{stem}-client.sock"));
+ }
+
+ parent.join(format!("{stem}-client.sock"))
+}
+
+/// Clamp client-reported terminal dimensions to a minimum viable size.
+pub fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) {
+ let clamped_cols = cols.max(MIN_CLIENT_COLS);
+ let clamped_rows = rows.max(MIN_CLIENT_ROWS);
+ (clamped_cols, clamped_rows)
+}
+
+// ---------------------------------------------------------------------------
+// Connected client state
+// ---------------------------------------------------------------------------
+
+/// A connected client tracked by the server.
+struct ClientConnection {
+ /// The client's terminal size (after clamping).
+ terminal_size: (u16, u16),
+ /// Last known host terminal default colors for this client.
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ /// Monotonic activity stamp used to choose the fallback foreground client.
+ last_activity: u64,
+ /// Channel for sending framed ServerMessage data to the client writer thread.
+ writer: Option<std::sync::mpsc::Sender<Vec<u8>>>,
+}
+
+// ---------------------------------------------------------------------------
+// Stale socket cleanup
+// ---------------------------------------------------------------------------
+
+/// Prepares a socket path for binding: creates parent directories,
+/// removes stale socket files (where no server is listening), and
+/// returns an error if a live server is already bound.
+fn prepare_socket_path(path: &Path) -> io::Result<()> {
+ crate::ipc::prepare_socket_path(path, |path| {
+ format!(
+ "herdr server is already running (socket busy at {})",
+ path.display()
+ )
+ })
+}
+
+/// Restricts socket file permissions to owner-only (0o600).
+fn restrict_socket_permissions(path: &Path) -> io::Result<()> {
+ crate::ipc::restrict_socket_permissions(path, SOCKET_PERMISSION_MODE)
+}
+
+// ---------------------------------------------------------------------------
+// Virtual rendering
+// ---------------------------------------------------------------------------
+
+struct CursorTrackingBackend {
+ inner: TestBackend,
+ rendered_cursor: Option<Position>,
+}
+
+impl CursorTrackingBackend {
+ fn new(width: u16, height: u16) -> Self {
+ Self {
+ inner: TestBackend::new(width, height),
+ rendered_cursor: None,
+ }
+ }
+
+ fn buffer(&self) -> &ratatui::buffer::Buffer {
+ self.inner.buffer()
+ }
+
+ fn rendered_cursor(&self) -> Option<CursorState> {
+ self.rendered_cursor.map(|pos| CursorState {
+ x: pos.x,
+ y: pos.y,
+ visible: true,
+ })
+ }
+}
+
+impl Backend for CursorTrackingBackend {
+ type Error = std::convert::Infallible;
+
+ fn draw<'a, I>(&mut self, content: I) -> Result<(), Self::Error>
+ where
+ I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
+ {
+ self.inner.draw(content)
+ }
+
+ fn append_lines(&mut self, n: u16) -> Result<(), Self::Error> {
+ self.inner.append_lines(n)
+ }
+
+ fn hide_cursor(&mut self) -> Result<(), Self::Error> {
+ self.inner.hide_cursor()?;
+ self.rendered_cursor = None;
+ Ok(())
+ }
+
+ fn show_cursor(&mut self) -> Result<(), Self::Error> {
+ self.inner.show_cursor()
+ }
+
+ fn get_cursor_position(&mut self) -> Result<Position, Self::Error> {
+ self.inner.get_cursor_position()
+ }
+
+ fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> Result<(), Self::Error> {
+ let position = position.into();
+ self.inner.set_cursor_position(position)?;
+ self.rendered_cursor = Some(position);
+ Ok(())
+ }
+
+ fn clear(&mut self) -> Result<(), Self::Error> {
+ self.inner.clear()
+ }
+
+ fn clear_region(&mut self, clear_type: ClearType) -> Result<(), Self::Error> {
+ self.inner.clear_region(clear_type)
+ }
+
+ fn size(&self) -> Result<Size, Self::Error> {
+ self.inner.size()
+ }
+
+ fn window_size(&mut self) -> Result<WindowSize, Self::Error> {
+ self.inner.window_size()
+ }
+
+ fn flush(&mut self) -> Result<(), Self::Error> {
+ self.inner.flush()
+ }
+}
+
+/// Renders the AppState to an in-memory ratatui Buffer.
+///
+/// This produces the same output as the monolithic binary's terminal draw,
+/// but writes to a `Buffer` instead of stdout. Cursor visibility is captured
+/// from explicit frame cursor intent rather than incidental backend state.
+fn render_virtual(
+ app_state: &mut AppState,
+ area: Rect,
+ resize_panes: bool,
+) -> (ratatui::buffer::Buffer, Option<CursorState>) {
+ if resize_panes {
+ crate::ui::compute_view(app_state, area);
+ } else {
+ crate::ui::compute_view_without_resizing_panes(app_state, area);
+ }
+
+ let backend = CursorTrackingBackend::new(area.width, area.height);
+ let mut terminal = ratatui::Terminal::new(backend).expect("TestBackend::new should never fail");
+
+ terminal
+ .draw(|frame| {
+ crate::ui::render(app_state, frame);
+ })
+ .expect("render to TestBackend should never fail");
+
+ let buffer = terminal.backend().buffer().clone();
+ let cursor = terminal.backend().rendered_cursor();
+
+ (buffer, cursor)
+}
+
+// ---------------------------------------------------------------------------
+// Headless server
+// ---------------------------------------------------------------------------
+
+/// The headless server — runs the herdr event loop without a real terminal.
+pub struct HeadlessServer {
+ app: app::App,
+ client_listener: UnixListener,
+ client_socket_path: PathBuf,
+ clients: HashMap<u64, ClientConnection>,
+ next_client_id: u64,
+ /// The client currently driving the shared pane runtime size and theme.
+ foreground_client_id: Option<u64>,
+ /// Monotonic activity counter used to pick the most recently active client.
+ next_activity_stamp: u64,
+ /// Shared pane runtime size derived from the foreground client,
+ /// or MIN_COLS × MIN_ROWS when no clients are connected.
+ effective_size: (u16, u16),
+ /// Flag set when shutdown is initiated.
+ shutting_down: bool,
+ /// Flag set by Ctrl+C or `server stop` signal.
+ should_quit: Arc<AtomicBool>,
+ /// Channel for receiving server events from client connection threads.
+ server_event_rx: mpsc::Receiver<ServerEvent>,
+ /// Sender for server events (cloned for each client thread).
+ server_event_tx: mpsc::Sender<ServerEvent>,
+}
+
+impl HeadlessServer {
+ /// Creates and starts the headless server.
+ ///
+ /// This:
+ /// 1. Prepares the client socket path (cleans up stale sockets)
+ /// 2. Binds the client socket listener
+ /// 3. Returns the server ready to run
+ pub fn new(app: app::App) -> io::Result<Self> {
+ let client_path = client_socket_path();
+ prepare_socket_path(&client_path)?;
+
+ let listener = UnixListener::bind(&client_path)?;
+ restrict_socket_permissions(&client_path)?;
+ info!(path = %client_path.display(), "client protocol socket listening");
+
+ // Set non-blocking on the listener so we can poll it from the event loop.
+ listener.set_nonblocking(true)?;
+
+ let should_quit = Arc::new(AtomicBool::new(false));
+
+ // Channel for server events from client threads.
+ let (server_event_tx, server_event_rx) = mpsc::channel(64);
+
+ Ok(Self {
+ app,
+ client_listener: listener,
+ client_socket_path: client_path,
+ clients: HashMap::new(),
+ next_client_id: 1,
+ foreground_client_id: None,
+ next_activity_stamp: 1,
+ effective_size: (MIN_COLS, MIN_ROWS),
+ shutting_down: false,
+ should_quit,
+ server_event_rx,
+ server_event_tx,
+ })
+ }
+
+ /// Runs the headless server event loop until shutdown.
+ ///
+ /// This is the server's main loop — analogous to `App::run()` but without
+ /// a real terminal. It:
+ /// - Drains internal events (pane death, state changes)
+ /// - Drains API requests (from the JSON socket)
+ /// - Accepts new client connections
+ /// - Reads client messages and routes input
+ /// - Handles scheduled tasks (resize poll, animation, session save, etc.)
+ /// - Renders virtually and streams frames to clients
+ pub async fn run(&mut self) -> io::Result<()> {
+ info!("headless server starting");
+
+ // Register SIGINT handler for graceful shutdown.
+ let should_quit = self.should_quit.clone();
+ let quit_notify = self.server_event_tx.clone();
+ ctrlc_handler(should_quit, quit_notify);
+
+ // No input_rx needed — server doesn't read stdin.
+ // We use None for input_rx so the event loop doesn't try to read from stdin.
+ self.app.input_rx = None;
+
+ let mut needs_render = true;
+
+ loop {
+ // If shutdown has been initiated, complete it and exit.
+ if self.shutting_down {
+ self.complete_shutdown()?;
+ break;
+ }
+
+ // Check if we should start shutting down.
+ if self.app.state.should_quit || self.should_quit.load(Ordering::Acquire) {
+ self.initiate_shutdown();
+ continue;
+ }
+
+ // 1. Check render_dirty flag from PTY reader tasks.
+ if self.app.render_dirty.load(Ordering::Acquire) {
+ needs_render = true;
+ }
+
+ // 2. Drain internal events.
+ if self.drain_internal_events_with_forwarding() {
+ needs_render = true;
+ }
+
+ // 3. Drain API requests.
+ if self.drain_api_requests_with_shutdown_check() {
+ needs_render = true;
+ }
+
+ self.app.sync_focus_events();
+ self.app.sync_session_save_schedule();
+
+ // 4. Accept new client connections.
+ self.accept_client_connections()?;
+
+ // 5. Drain server events from client threads.
+ if self.drain_server_events() {
+ needs_render = true;
+ }
+
+ // 6. Handle scheduled tasks.
+ let now = Instant::now();
+ if self.handle_scheduled_tasks_headless(now) {
+ needs_render = true;
+ }
+
+ // Handle deferred requests.
+ if self.app.state.request_complete_onboarding {
+ self.app.state.request_complete_onboarding = false;
+ self.app.complete_onboarding();
+ needs_render = true;
+ }
+
+ if self.app.state.request_new_workspace {
+ self.app.state.request_new_workspace = false;
+ self.app.create_workspace();
+ needs_render = true;
+ }
+
+ if self.app.state.request_new_tab {
+ self.app.state.request_new_tab = false;
+ self.app.create_tab();
+ needs_render = true;
+ }
+
+ if self.app.state.request_reload_keybinds {
+ self.app.state.request_reload_keybinds = false;
+ self.app.reload_keybinds();
+ needs_render = true;
+ }
+
+ self.app.sync_animation_timer(now);
+
+ // 7. Render virtually and stream frames.
+ if needs_render && self.app.can_render_now(now) {
+ self.app.render_dirty.swap(false, Ordering::AcqRel);
+ self.render_and_stream();
+ self.app.last_render_at = Some(now);
+ needs_render = false;
+ continue;
+ }
+
+ // 8. Wait for next event.
+ let next_deadline = self.app.next_loop_deadline(now, needs_render);
+ let event = {
+ tokio::select! {
+ maybe_api = self.app.api_rx.recv() => match maybe_api {
+ Some(msg) => LoopEvent::Api(msg),
+ None => LoopEvent::Timer,
+ },
+ maybe_ev = self.app.event_rx.recv() => match maybe_ev {
+ Some(ev) => LoopEvent::Internal(ev),
+ None => LoopEvent::Timer,
+ },
+ maybe_server_ev = self.server_event_rx.recv() => match maybe_server_ev {
+ Some(ev) => LoopEvent::ServerEvent(ev),
+ None => LoopEvent::Timer,
+ },
+ _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer,
+ _ = self.app.render_notify.notified() => LoopEvent::RenderRequested,
+ }
+ };
+
+ match event {
+ LoopEvent::Timer => {}
+ LoopEvent::Internal(ev) => {
+ if self.handle_internal_event_with_forwarding(ev) {
+ needs_render = true;
+ }
+ }
+ LoopEvent::Api(msg) => {
+ if self.handle_api_request_with_shutdown_check(msg) {
+ needs_render = true;
+ }
+ }
+ LoopEvent::ServerEvent(ev) => {
+ if self.handle_server_event(ev) {
+ needs_render = true;
+ }
+ }
+ LoopEvent::RenderRequested => {
+ if self.app.render_dirty.load(Ordering::Acquire) {
+ needs_render = true;
+ }
+ }
+ }
+ }
+
+ // Save session on exit.
+ if !self.app.no_session {
+ self.app.save_session_now();
+ }
+
+ info!("headless server exiting");
+ Ok(())
+ }
+
+ fn allocate_activity_stamp(&mut self) -> u64 {
+ let stamp = self.next_activity_stamp;
+ self.next_activity_stamp = self.next_activity_stamp.saturating_add(1);
+ stamp
+ }
+
+ fn resize_shared_runtime_to_effective_size(&mut self) {
+ if self.foreground_client_id.is_none() {
+ return;
+ }
+ let (cols, rows) = self.effective_size;
+ let area = Rect::new(0, 0, cols, rows);
+ crate::ui::compute_view(&mut self.app.state, area);
+ }
+
+ fn sync_foreground_client_state(&mut self) {
+ let Some(client_id) = self.foreground_client_id else {
+ self.effective_size = (MIN_COLS, MIN_ROWS);
+ return;
+ };
+ let Some(client) = self.clients.get(&client_id) else {
+ self.foreground_client_id = None;
+ self.effective_size = (MIN_COLS, MIN_ROWS);
+ return;
+ };
+
+ self.effective_size = client.terminal_size;
+ if !client.host_terminal_theme.is_empty() {
+ self.app.set_host_terminal_theme(client.host_terminal_theme);
+ }
+ }
+
+ fn promote_client_to_foreground(&mut self, client_id: u64) -> bool {
+ let stamp = self.allocate_activity_stamp();
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+ client.last_activity = stamp;
+
+ let changed = self.foreground_client_id != Some(client_id);
+ self.foreground_client_id = Some(client_id);
+ self.sync_foreground_client_state();
+ changed
+ }
+
+ fn promote_latest_remaining_client(&mut self) -> bool {
+ let next_foreground = self
+ .clients
+ .iter()
+ .max_by_key(|(_, client)| client.last_activity)
+ .map(|(&client_id, _)| client_id);
+ let changed = next_foreground != self.foreground_client_id;
+ self.foreground_client_id = next_foreground;
+ self.sync_foreground_client_state();
+ changed
+ }
+
+ fn remove_client(&mut self, client_id: u64) -> bool {
+ let was_foreground = self.foreground_client_id == Some(client_id);
+ self.clients.remove(&client_id);
+ if was_foreground {
+ self.promote_latest_remaining_client()
+ } else {
+ false
+ }
+ }
+
+ fn update_client_host_theme_from_events(
+ &mut self,
+ client_id: u64,
+ events: &[crate::raw_input::RawInputEvent],
+ ) -> bool {
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return false;
+ };
+
+ let mut next_theme = client.host_terminal_theme;
+ for event in events {
+ if let crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } = event {
+ next_theme = next_theme.with_color(*kind, *color);
+ }
+ }
+
+ if next_theme == client.host_terminal_theme {
+ return false;
+ }
+
+ client.host_terminal_theme = next_theme;
+ if self.foreground_client_id == Some(client_id) {
+ self.app.set_host_terminal_theme(next_theme)
+ } else {
+ false
+ }
+ }
+
+ fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool {
+ events.iter().any(|event| {
+ matches!(
+ event,
+ crate::raw_input::RawInputEvent::Key(_)
+ | crate::raw_input::RawInputEvent::Mouse(_)
+ | crate::raw_input::RawInputEvent::Paste(_)
+ )
+ })
+ }
+
+ /// Accepts pending client connections from the non-blocking listener.
+ fn accept_client_connections(&mut self) -> io::Result<()> {
+ loop {
+ match self.client_listener.accept() {
+ Ok((stream, _addr)) => {
+ let client_id = self.next_client_id;
+ self.next_client_id += 1;
+
+ if let Err(err) = stream.set_nonblocking(true) {
+ warn!(err = %err, "failed to set client stream nonblocking");
+ continue;
+ }
+
+ // Spawn a thread for the handshake and read loop.
+ let should_quit = self.should_quit.clone();
+ let server_event_tx = self.server_event_tx.clone();
+ std::thread::spawn(move || {
+ if let Err(err) = handle_client_handshake(
+ stream,
+ client_id,
+ &server_event_tx,
+ &should_quit,
+ ) {
+ debug!(client_id, err = %err, "client handshake failed");
+ }
+ });
+ }
+ Err(ref err) if err.kind() == io::ErrorKind::WouldBlock => {
+ // No more pending connections.
+ break;
+ }
+ Err(err) => {
+ error!(err = %err, "client listener accept failed");
+ break;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ /// Drains server events from the dedicated channel.
+ ///
+ /// Returns true if any input was processed (requiring a re-render).
+ fn drain_server_events(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(ev) = self.server_event_rx.try_recv() {
+ changed |= self.handle_server_event(ev);
+ }
+ changed
+ }
+
+ /// Handles a single internal event with forwarding logic for clipboard,
+ /// sound, and toast notifications to connected clients.
+ ///
+ /// ALL internal events MUST be routed through this method to ensure
+ /// clipboard/notify forwarding is never bypassed. Do not call
+ /// `self.app.handle_internal_event()` directly for any internal event
+ /// in the headless server — use this method instead.
+ ///
+ /// Returns true if the event changed visual state (requiring a re-render).
+ fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool {
+ match &ev {
+ AppEvent::ClipboardWrite { content } => {
+ // Clipboard writes are client-local side effects. Forward them only to
+ // the foreground client instead of broadcasting to every attached client.
+ if let Some(client_id) = self.foreground_client_id {
+ let data = base64::engine::general_purpose::STANDARD.encode(content.as_slice());
+ self.send_to_client(client_id, ServerMessage::Clipboard { data });
+ }
+ // ClipboardWrite doesn't change visual state — no render needed.
+ false
+ }
+ AppEvent::StateChanged {
+ pane_id,
+ agent,
+ state,
+ } => {
+ // Capture toast before handling.
+ let toast_before = self.app.state.toast.clone();
+ let pane_id_val = *pane_id;
+ let agent_val = *agent;
+ let state_val = *state;
+
+ // Find the previous state of this pane before the event
+ // is processed. We need this to determine if a sound
+ // notification would be triggered.
+ let prev_state = self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .find_map(|ws| {
+ ws.tabs
+ .iter()
+ .find_map(|tab| tab.panes.get(&pane_id_val).map(|p| p.state))
+ })
+ .unwrap_or(crate::detect::AgentState::Unknown);
+
+ // Handle the state change (updates pane state, sets toast on AppState).
+ // Note: apply_pane_state_change inside handle_internal_event will try
+ // to play sounds, but sound.enabled=false in the headless server, so
+ // sound::play is never called. Toast may still be set on AppState if
+ // toast_config.enabled is true.
+ self.app.handle_internal_event(ev);
+
+ // Forward sound notification to clients.
+ // We check the agent-specific sound setting but NOT sound.enabled,
+ // because the server sets enabled=false to prevent local playback —
+ // clients should still receive sound notifications and decide
+ // locally whether to play them based on their own config.
+ let is_active_tab = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .is_some_and(|ws| {
+ ws.find_tab_index_for_pane(pane_id_val)
+ .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
+ });
+
+ if !matches!(
+ self.app.state.sound.agents.for_agent(agent_val),
+ crate::config::AgentSoundSetting::Off
+ ) {
+ if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
+ is_active_tab,
+ prev_state,
+ state_val,
+ ) {
+ let msg = match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ };
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ message: msg.to_owned(),
+ });
+ }
+ }
+
+ // Forward any new toast as a notification.
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ if let Some(toast) = &self.app.state.toast {
+ let msg = format!("{}: {}", toast.title, toast.context);
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg,
+ });
+ }
+ }
+
+ true
+ }
+ AppEvent::HookStateReported {
+ pane_id,
+ agent_label,
+ state,
+ ..
+ } => {
+ // The hook authority may not change the effective pane state if
+ // the detected agent doesn't match. We forward based on the
+ // hook-reported state transition regardless.
+ let toast_before = self.app.state.toast.clone();
+ let pane_id_val = *pane_id;
+ let agent_val = crate::detect::parse_agent_label(agent_label);
+ let hook_state_val = *state;
+
+ // Capture the previous hook authority state for this pane.
+ // If no hook authority exists, use the effective state.
+ let prev_hook_state = self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .find_map(|ws| {
+ ws.tabs.iter().find_map(|tab| {
+ tab.panes.get(&pane_id_val).map(|p| {
+ p.hook_authority
+ .as_ref()
+ .map(|h| h.state)
+ .unwrap_or(p.state)
+ })
+ })
+ })
+ .unwrap_or(crate::detect::AgentState::Unknown);
+
+ self.app.handle_internal_event(ev);
+
+ // Forward sound notification based on hook state transition.
+ // This ensures API-reported state changes (pane.report_agent)
+ // produce notifications even before fallback detection confirms.
+ let is_active_tab = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .is_some_and(|ws| {
+ ws.find_tab_index_for_pane(pane_id_val)
+ .is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
+ });
+
+ if !matches!(
+ self.app.state.sound.agents.for_agent(agent_val),
+ crate::config::AgentSoundSetting::Off
+ ) {
+ if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
+ is_active_tab,
+ prev_hook_state,
+ hook_state_val,
+ ) {
+ let msg = match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ };
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ message: msg.to_owned(),
+ });
+ }
+ }
+
+ // Forward any new toast as a notification.
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ if let Some(toast) = &self.app.state.toast {
+ let msg = format!("{}: {}", toast.title, toast.context);
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg,
+ });
+ }
+ }
+
+ true
+ }
+ AppEvent::UpdateReady { version: _ } => {
+ let toast_before = self.app.state.toast.clone();
+
+ self.app.handle_internal_event(ev);
+
+ // Forward the update toast notification.
+ if self.app.state.toast.is_some() && self.app.state.toast != toast_before {
+ if let Some(toast) = &self.app.state.toast {
+ let msg = format!("{}: {}", toast.title, toast.context);
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg,
+ });
+ }
+ }
+
+ true
+ }
+ _ => {
+ self.app.handle_internal_event(ev);
+ true
+ }
+ }
+ }
+
+ /// Drains internal events, forwarding clipboard, sound, and toast
+ /// notifications to connected clients instead of processing them locally.
+ ///
+ /// In the monolithic mode:
+ /// - `ClipboardWrite` events are written to stdout via `write_osc52_bytes`.
+ /// - Sound notifications are played locally via `sound::play`.
+ /// - Toast notifications are set on AppState and rendered into the frame.
+ ///
+ /// In the headless server, there is no stdout terminal or audio subsystem,
+ /// so we:
+ /// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the
+ /// foreground client only.
+ /// - Detect when a sound would be played and forward as
+ /// `ServerMessage::Notify { kind: Sound }` to all clients.
+ /// - Detect when a toast is set on AppState and forward as
+ /// `ServerMessage::Notify { kind: Toast }` to all clients.
+ fn drain_internal_events_with_forwarding(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(ev) = self.app.event_rx.try_recv() {
+ changed |= self.handle_internal_event_with_forwarding(ev);
+ }
+ changed
+ }
+
+ /// Encodes a server message into a length-prefixed frame.
+ fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, protocol::FramingError> {
+ let mut framed = Vec::new();
+ protocol::write_message(&mut framed, msg)?;
+ Ok(framed)
+ }
+
+ /// Sends a message to all connected clients.
+ /// Broken connections are tracked and cleaned up.
+ fn send_to_all_clients(&mut self, msg: ServerMessage) {
+ let serialized = match Self::frame_server_message(&msg) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(err = %err, "failed to serialize message for clients");
+ return;
+ }
+ };
+
+ let mut broken_clients: Vec<u64> = Vec::new();
+ for (&client_id, client) in &mut self.clients {
+ if let Some(writer) = &client.writer {
+ if writer.send(serialized.clone()).is_err() {
+ debug!(client_id, "client writer channel closed during broadcast");
+ broken_clients.push(client_id);
+ }
+ }
+ }
+
+ // Remove broken clients.
+ for client_id in broken_clients {
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ }
+ }
+
+ /// Sends a message to a specific client. Returns false if the client
+ /// was not found or the send failed (client removed).
+ fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool {
+ let serialized = match Self::frame_server_message(&msg) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize message for client");
+ return false;
+ }
+ };
+
+ if let Some(client) = self.clients.get(&client_id) {
+ if let Some(writer) = &client.writer {
+ if writer.send(serialized).is_err() {
+ debug!(
+ client_id,
+ "client writer channel closed during targeted send"
+ );
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ return false;
+ }
+ }
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Handles a server event. Returns true if the event requires a re-render.
+ fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
+ match ev {
+ ServerEvent::ClientConnected {
+ client_id,
+ cols,
+ rows,
+ writer,
+ } => {
+ info!(client_id, cols, rows, "client connected");
+ let last_activity = self.allocate_activity_stamp();
+ self.clients.insert(
+ client_id,
+ ClientConnection {
+ terminal_size: (cols, rows),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity,
+ writer: Some(writer),
+ },
+ );
+ self.foreground_client_id = Some(client_id);
+ self.sync_foreground_client_state();
+ self.resize_shared_runtime_to_effective_size();
+ true
+ }
+ ServerEvent::ClientInput { client_id, data } => {
+ debug!(client_id, len = data.len(), "client input received");
+ let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
+ let interaction = Self::events_include_interaction(&events);
+ let foreground_changed = if interaction {
+ self.promote_client_to_foreground(client_id)
+ } else {
+ false
+ };
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
+ self.app
+ .route_client_events(events, self.foreground_client_id == Some(client_id));
+
+ // Check if the detach keybind was triggered during input processing.
+ if self.app.state.detach_requested {
+ self.app.state.detach_requested = false;
+ info!(client_id, "client detach requested via keybind");
+
+ // Send a ServerShutdown with "detached" reason to this client
+ // so it exits cleanly (not with a connection-lost error).
+ // The client will close its connection after receiving this,
+ // which triggers a ClientDisconnected event that removes it.
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some("detached".to_owned()),
+ },
+ );
+
+ // Don't remove the client here — let the client disconnect
+ // naturally after receiving the ServerShutdown. The client's
+ // read loop will see EOF and the server will get a
+ // ClientDisconnected event which handles cleanup.
+ //
+ // However, we do need to stop sending frames to this client
+ // since it's detaching. Drop the writer channel so no more
+ // frames are queued for this client.
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.writer = None;
+ }
+
+ // No re-render needed for remaining clients.
+ false
+ } else {
+ foreground_changed || theme_changed || interaction
+ }
+ }
+ ServerEvent::ClientResize {
+ client_id,
+ cols,
+ rows,
+ } => {
+ info!(client_id, cols, rows, "client resize");
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.terminal_size = (cols, rows);
+ }
+ self.promote_client_to_foreground(client_id);
+ self.resize_shared_runtime_to_effective_size();
+ true
+ }
+ ServerEvent::ClientDetach { client_id } => {
+ info!(client_id, "client detached");
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ true
+ }
+ ServerEvent::ClientDisconnected { client_id } => {
+ info!(client_id, "client disconnected");
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ true
+ }
+ ServerEvent::QuitSignal => {
+ // The quit check at the top of the loop handles this.
+ // No render needed — the next iteration will initiate shutdown.
+ false
+ }
+ }
+ }
+
+ /// Drains API requests with shutdown awareness.
+ ///
+ /// During shutdown, remaining requests get a `server_unavailable` error.
+ fn drain_api_requests_with_shutdown_check(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(msg) = self.app.api_rx.try_recv() {
+ changed |= self.handle_api_request_with_shutdown_check(msg);
+ }
+ changed
+ }
+
+ /// Handles a single API request with shutdown awareness.
+ ///
+ /// Also forwards any toast/sound notifications that result from the API
+ /// request to connected clients. API methods like `pane.report_agent`
+ /// trigger internal events that may set toast state or would normally
+ /// play sounds — in headless mode we forward these to clients instead.
+ fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool {
+ if self.shutting_down {
+ // During shutdown, respond with server_unavailable.
+ let response = serde_json::to_string(&api::schema::ErrorResponse {
+ id: msg.request.id,
+ error: api::schema::ErrorBody {
+ code: "server_unavailable".into(),
+ message: "server is shutting down".into(),
+ },
+ })
+ .unwrap_or_else(|_| {
+ r#"{"id":"","error":{"code":"server_unavailable","message":"server is shutting down"}}"#
+ .to_string()
+ });
+ let _ = msg.respond_to.send(response);
+ return false;
+ }
+
+ let changed = api::request_changes_ui(&msg.request);
+
+ // Capture toast and pane agent states before the API call so we can
+ // forward any resulting notifications to connected clients.
+ // API requests like pane.report_agent trigger handle_internal_event
+ // internally, which bypasses drain_internal_events_with_forwarding.
+ // Since sound.enabled=false in the headless server, sounds would be
+ // silently dropped; toasts may be set but not forwarded.
+ //
+ // Note: pane.report_agent sets hook_authority on the pane, but the
+ // effective state may not change until the fallback detector confirms
+ // the agent (detected_agent must match). So we capture both the
+ // effective state AND the hook authority state for comparison.
+ let toast_before = self.app.state.toast.clone();
+ let pane_states_before: Vec<(
+ usize,
+ crate::layout::PaneId,
+ crate::detect::AgentState,
+ Option<crate::detect::AgentState>,
+ )> = self
+ .app
+ .state
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ ws.tabs.iter().flat_map(move |tab| {
+ tab.panes.iter().map(move |(&pane_id, pane)| {
+ (
+ ws_idx,
+ pane_id,
+ pane.state,
+ pane.hook_authority.as_ref().map(|h| h.state),
+ )
+ })
+ })
+ })
+ .collect();
+
+ let response = self.app.handle_api_request(msg.request);
+ let _ = msg.respond_to.send(response);
+
+ // Forward any new toast as a notification to clients.
+ let toast_after = self.app.state.toast.clone();
+ if toast_after.is_some() && toast_after != toast_before {
+ if let Some(toast) = &toast_after {
+ let msg_text = format!("{}: {}", toast.title, toast.context);
+ debug!(msg = %msg_text, "forwarding toast notification from API request");
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: msg_text,
+ });
+ }
+ }
+
+ // Forward sound notifications for any pane state changes that occurred
+ // during the API request. Compare before/after pane states (including
+ // hook authority state) to find transitions that would trigger a sound.
+ //
+ // pane.report_agent sets hook_authority on the pane, but the effective
+ // state may not change until the fallback detector confirms the agent
+ // (detected_agent must match hook_authority.agent). We check BOTH the
+ // effective state AND the hook authority state so that API-reported
+ // state changes trigger notifications even before fallback confirmation.
+ for (ws_idx, pane_id, prev_effective_state, prev_hook_state) in &pane_states_before {
+ let pane_after = self
+ .app
+ .state
+ .workspaces
+ .get(*ws_idx)
+ .and_then(|ws| ws.tabs.iter().find_map(|tab| tab.panes.get(pane_id)));
+
+ let Some(pane_after) = pane_after else {
+ continue;
+ };
+
+ let new_effective_state = pane_after.state;
+ let new_hook_state = pane_after.hook_authority.as_ref().map(|h| h.state);
+
+ // Check effective state change first.
+ let effective_changed = new_effective_state != *prev_effective_state;
+ // Check hook authority state change — this catches API-reported
+ // state changes that haven't been confirmed by fallback detection.
+ let hook_changed = new_hook_state != *prev_hook_state;
+
+ if effective_changed || hook_changed {
+ // Use the hook state if available (it reflects the API-reported
+ // state), otherwise use the effective state.
+ let prev_state = prev_hook_state.unwrap_or(*prev_effective_state);
+ let new_state = new_hook_state.unwrap_or(new_effective_state);
+
+ // Skip if the derived transition is a no-op.
+ if prev_state == new_state {
+ continue;
+ }
+
+ let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id);
+
+ // Get the known agent for sound settings. Unknown custom labels
+ // fall back to None so clients use the generic sound behavior.
+ let agent = pane_after.effective_known_agent();
+
+ debug!(
+ ws_idx,
+ pane_id = pane_id.raw(),
+ prev_state = ?prev_state,
+ new_state = ?new_state,
+ agent = ?agent,
+ effective_changed,
+ hook_changed,
+ "pane state changed during API request, checking sound notification"
+ );
+
+ // Check agent-specific sound setting but NOT sound.enabled,
+ // because the server sets enabled=false to prevent local playback.
+ // Clients decide locally whether to play sounds.
+ if !matches!(
+ self.app.state.sound.agents.for_agent(agent),
+ crate::config::AgentSoundSetting::Off
+ ) {
+ if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
+ is_active_tab,
+ prev_state,
+ new_state,
+ ) {
+ let msg_text = match sound {
+ crate::sound::Sound::Done => "agent done",
+ crate::sound::Sound::Request => "agent attention",
+ };
+ debug!(sound = ?sound, "forwarding sound notification from API request");
+ self.send_to_all_clients(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Sound,
+ message: msg_text.to_owned(),
+ });
+ }
+ }
+ }
+ }
+
+ changed
+ }
+
+ /// Renders the current state to client-sized virtual buffers and streams
+ /// frames to all connected clients.
+ fn render_and_stream(&mut self) {
+ let foreground_client_id = self.foreground_client_id;
+ let mut render_targets: Vec<(u64, (u16, u16), std::sync::mpsc::Sender<Vec<u8>>, bool)> =
+ self.clients
+ .iter()
+ .filter_map(|(&client_id, client)| {
+ client.writer.as_ref().map(|writer| {
+ (
+ client_id,
+ client.terminal_size,
+ writer.clone(),
+ foreground_client_id == Some(client_id),
+ )
+ })
+ })
+ .collect();
+
+ render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id));
+
+ if render_targets.is_empty() {
+ let (cols, rows) = self.effective_size;
+ let area = Rect::new(0, 0, cols, rows);
+ let _ = render_virtual(&mut self.app.state, area, true);
+ debug!(
+ cols,
+ rows, "rendered virtual frame with no attached clients"
+ );
+ return;
+ }
+
+ let mut broken_clients: Vec<u64> = Vec::new();
+ for (client_id, (cols, rows), writer, is_foreground) in render_targets {
+ let area = Rect::new(0, 0, cols, rows);
+ let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground);
+ let message = ServerMessage::Frame(FrameData::from_ratatui_buffer(&buffer, cursor));
+ let serialized = match Self::frame_server_message(&message) {
+ Ok(framed) => framed,
+ Err(err) => {
+ warn!(client_id, err = %err, "failed to serialize frame for client");
+ broken_clients.push(client_id);
+ continue;
+ }
+ };
+
+ if writer.send(serialized).is_err() {
+ debug!(client_id, "client writer channel closed, marking as broken");
+ broken_clients.push(client_id);
+ }
+ }
+
+ if !broken_clients.is_empty() {
+ for client_id in broken_clients {
+ let foreground_changed = self.remove_client(client_id);
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size();
+ }
+ }
+ }
+
+ let (cols, rows) = self.effective_size;
+ debug!(cols, rows, foreground_client_id = ?self.foreground_client_id, "rendered virtual frame(s)");
+ }
+
+ /// Handle scheduled tasks for the headless server.
+ ///
+ /// Similar to `App::handle_scheduled_tasks` but without resize polling
+ /// (the server doesn't have a terminal to resize).
+ fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool {
+ let mut changed = false;
+
+ self.app.sync_animation_timer(now);
+
+ // No resize polling needed — server has no terminal.
+ // Client resize messages drive size changes instead.
+
+ if self
+ .app
+ .config_diagnostic_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.config_diagnostic_deadline = None;
+ self.app.state.config_diagnostic = None;
+ changed = true;
+ }
+
+ if self
+ .app
+ .toast_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.toast_deadline = None;
+ self.app.state.toast = None;
+ changed = true;
+ }
+
+ if self
+ .app
+ .next_animation_tick
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.state.spinner_tick = self.app.state.spinner_tick.wrapping_add(1);
+ self.app.next_animation_tick = Some(now + app::ANIMATION_INTERVAL);
+ changed = true;
+ }
+
+ if self
+ .app
+ .git_refresh_deadline()
+ .is_some_and(|deadline| now >= deadline)
+ {
+ for ws in &mut self.app.state.workspaces {
+ ws.refresh_git_ahead_behind();
+ }
+ self.app.last_git_remote_status_refresh = now;
+ changed = true;
+ }
+
+ if self
+ .app
+ .next_auto_update_check
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.run_auto_update_check();
+ }
+
+ if self
+ .app
+ .session_save_deadline
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.app.save_session_now();
+ }
+
+ self.app.sync_animation_timer(now);
+ changed
+ }
+
+ /// Initiates graceful shutdown.
+ fn initiate_shutdown(&mut self) {
+ if self.shutting_down {
+ return;
+ }
+ info!("server shutdown initiated");
+ self.shutting_down = true;
+
+ // Send ServerShutdown to all connected clients.
+ let shutdown_msg = ServerMessage::ServerShutdown {
+ reason: Some("server is shutting down".to_owned()),
+ };
+ self.send_to_all_clients(shutdown_msg);
+
+ // Give client writer threads a moment to flush the shutdown message.
+ // A short sleep ensures the message is written to the socket before
+ // we close the connections.
+ std::thread::sleep(Duration::from_millis(50));
+
+ // Signal the main loop to exit.
+ self.should_quit.store(true, Ordering::Release);
+ self.app.state.should_quit = true;
+ }
+
+ /// Completes the shutdown sequence: send ServerShutdown to clients,
+ /// close client connections, remove socket files, and clean up.
+ fn complete_shutdown(&mut self) -> io::Result<()> {
+ info!("completing server shutdown");
+
+ // Send ServerShutdown to all remaining clients.
+ if !self.clients.is_empty() {
+ let shutdown_msg = ServerMessage::ServerShutdown {
+ reason: Some("server is shutting down".to_owned()),
+ };
+ self.send_to_all_clients(shutdown_msg);
+
+ // Give writer threads a moment to flush before closing.
+ std::thread::sleep(Duration::from_millis(50));
+ }
+
+ // Drain remaining API requests with server_unavailable.
+ self.drain_api_requests_with_shutdown_check();
+
+ // Close all client connections.
+ self.clients.clear();
+
+ // Remove socket files.
+ self.cleanup_sockets()?;
+
+ Ok(())
+ }
+
+ /// Removes socket files created by the server.
+ fn cleanup_sockets(&self) -> io::Result<()> {
+ if let Err(err) = fs::remove_file(&self.client_socket_path) {
+ if err.kind() != io::ErrorKind::NotFound {
+ warn!(
+ path = %self.client_socket_path.display(),
+ err = %err,
+ "failed to remove client socket on shutdown"
+ );
+ }
+ }
+ Ok(())
+ }
+}
+
+impl Drop for HeadlessServer {
+ fn drop(&mut self) {
+ let _ = self.cleanup_sockets();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Client handshake
+// ---------------------------------------------------------------------------
+
+/// Internal event sent from the handshake thread to the main event loop.
+#[derive(Debug)]
+pub enum ServerEvent {
+ /// A new client completed the handshake.
+ ClientConnected {
+ client_id: u64,
+ cols: u16,
+ rows: u16,
+ writer: std::sync::mpsc::Sender<Vec<u8>>,
+ },
+ /// A client sent an input message.
+ ClientInput { client_id: u64, data: Vec<u8> },
+ /// A client sent a resize message.
+ ClientResize {
+ client_id: u64,
+ cols: u16,
+ rows: u16,
+ },
+ /// A client detached gracefully.
+ ClientDetach { client_id: u64 },
+ /// A client connection was lost.
+ ClientDisconnected { client_id: u64 },
+ /// Ctrl+C or external shutdown signal received.
+ QuitSignal,
+}
+
+/// Handles the client handshake on a blocking thread.
+///
+/// Reads the `Hello` message, validates the version, sends `Welcome`,
+/// and then enters a read loop forwarding messages to the server event channel.
+fn handle_client_handshake(
+ mut stream: UnixStream,
+ client_id: u64,
+ server_event_tx: &mpsc::Sender<ServerEvent>,
+ should_quit: &Arc<AtomicBool>,
+) -> io::Result<()> {
+ // Reset to blocking mode — the accept loop sets nonblocking but
+ // the handshake thread needs blocking I/O for read_message/write_message.
+ stream.set_nonblocking(false)?;
+
+ // Set a read timeout for the handshake.
+ stream.set_read_timeout(Some(HANDSHAKE_TIMEOUT))?;
+
+ // Read the Hello message.
+ let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ Ok(msg) => msg,
+ Err(protocol::FramingError::UnexpectedEof) => {
+ debug!(client_id, "client disconnected before handshake");
+ return Ok(());
+ }
+ Err(protocol::FramingError::Oversized { claimed, max }) => {
+ warn!(client_id, claimed, max, "oversized handshake from client");
+ return Ok(());
+ }
+ Err(err) => {
+ debug!(client_id, err = %err, "failed to read client hello");
+ return Ok(());
+ }
+ };
+
+ let (client_cols, client_rows) = match hello {
+ ClientMessage::Hello {
+ version,
+ cols,
+ rows,
+ } => {
+ // Version check.
+ match protocol::check_client_version(version) {
+ protocol::VersionCheck::Compatible => {}
+ protocol::VersionCheck::Incompatible(reason) => {
+ // Send rejection Welcome.
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: Some(reason),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ }
+
+ // Clamp size.
+ let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
+ (clamped_cols, clamped_rows)
+ }
+ _ => {
+ // First message must be Hello.
+ debug!(client_id, "first message was not Hello, closing");
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: Some("expected Hello as first message".to_owned()),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ };
+
+ // Send Welcome.
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: None,
+ };
+ protocol::write_message(&mut stream, &welcome)
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
+
+ // Clear read timeout for normal operation.
+ stream.set_read_timeout(None)?;
+
+ // Create a channel for writing frames back to the client.
+ let (frame_tx, frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
+
+ // Notify the main loop about the new client.
+ let _ = server_event_tx.blocking_send(ServerEvent::ClientConnected {
+ client_id,
+ cols: client_cols,
+ rows: client_rows,
+ writer: frame_tx,
+ });
+
+ // Spawn a writer thread that forwards frames from the channel to the stream.
+ let write_stream = stream.try_clone()?;
+ let write_quit = should_quit.clone();
+ std::thread::spawn(move || {
+ client_writer_loop(write_stream, frame_rx, &write_quit);
+ });
+
+ // Enter read loop — read client messages and forward to main loop.
+ client_read_loop(stream, client_id, server_event_tx, should_quit)
+}
+
+/// The client writer loop — sends frames from the channel to the client stream.
+fn client_writer_loop(
+ mut stream: UnixStream,
+ frame_rx: std::sync::mpsc::Receiver<Vec<u8>>,
+ should_quit: &Arc<AtomicBool>,
+) {
+ while !should_quit.load(Ordering::Acquire) {
+ match frame_rx.recv_timeout(Duration::from_millis(100)) {
+ Ok(data) => {
+ if let Err(err) = stream.write_all(&data) {
+ debug!(err = %err, "client write failed, closing writer");
+ break;
+ }
+ if let Err(err) = stream.flush() {
+ debug!(err = %err, "client flush failed, closing writer");
+ break;
+ }
+ }
+ Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
+ Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
+ }
+ }
+ debug!("client writer thread exiting");
+}
+
+/// The client read loop — reads messages from the client and forwards to the server event channel.
+fn client_read_loop(
+ mut stream: UnixStream,
+ client_id: u64,
+ server_event_tx: &mpsc::Sender<ServerEvent>,
+ should_quit: &Arc<AtomicBool>,
+) -> io::Result<()> {
+ while !should_quit.load(Ordering::Acquire) {
+ let msg: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
+ Ok(msg) => msg,
+ Err(protocol::FramingError::UnexpectedEof) => {
+ // Client disconnected.
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ Err(protocol::FramingError::Oversized { claimed, max }) => {
+ warn!(
+ client_id,
+ claimed, max, "oversized message from client, closing"
+ );
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ Err(err) => {
+ debug!(client_id, err = %err, "client read error, closing");
+ let _ =
+ server_event_tx.blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ };
+
+ let event = match msg {
+ ClientMessage::Input { data } => {
+ // Validate input size.
+ if data.len() > MAX_INPUT_PAYLOAD {
+ warn!(
+ client_id,
+ size = data.len(),
+ "oversized input from client, closing"
+ );
+ ServerEvent::ClientDisconnected { client_id }
+ } else {
+ ServerEvent::ClientInput { client_id, data }
+ }
+ }
+ ClientMessage::Resize { cols, rows } => {
+ let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
+ ServerEvent::ClientResize {
+ client_id,
+ cols: clamped_cols,
+ rows: clamped_rows,
+ }
+ }
+ ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
+ ClientMessage::Hello { .. } => {
+ // Duplicate Hello — ignore.
+ continue;
+ }
+ };
+
+ if server_event_tx.blocking_send(event).is_err() {
+ break; // Main loop gone.
+ }
+ }
+
+ debug!(client_id, "client read thread exiting");
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Installs a Ctrl+C handler that sets the should_quit flag and wakes up
+/// the event loop by sending a QuitSignal on the server event channel.
+fn ctrlc_handler(should_quit: Arc<AtomicBool>, server_event_tx: mpsc::Sender<ServerEvent>) {
+ let _ = ctrlc::set_handler(move || {
+ should_quit.store(true, Ordering::Release);
+ // Wake up the event loop so the quit flag is checked promptly.
+ let _ = server_event_tx.try_send(ServerEvent::QuitSignal);
+ });
+}
+
+/// Sleep until a deadline, or return pending if none.
+async fn sleep_until_or_pending(deadline: Option<Instant>) {
+ match deadline {
+ Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
+ None => std::future::pending().await,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Entry point
+// ---------------------------------------------------------------------------
+
+/// Run the headless server. This is the entry point called from main.rs.
+pub fn run_server() -> io::Result<()> {
+ init_logging();
+
+ let loaded_config = config::Config::load();
+ let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let event_hub = api::EventHub::default();
+
+ // Start the JSON API socket server.
+ let _api_server = match api::start_server(api_tx, event_hub.clone()) {
+ Ok(server) => server,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ eprintln!("error: herdr server is already running");
+ eprintln!("api socket: {}", api::socket_path().display());
+ std::process::exit(1);
+ }
+ Err(err) => return Err(err),
+ };
+
+ let no_session = false; // Server always does session persistence.
+ let _show_changelog = false;
+
+ let rt = tokio::runtime::Builder::new_multi_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
+
+ let result = rt.block_on(async {
+ // Create the App (with AppState, event channels, etc.).
+ let mut app = app::App::new(
+ &loaded_config.config,
+ no_session,
+ None, // config_diagnostic
+ None, // startup_release_notes
+ api_rx,
+ event_hub,
+ );
+
+ // The server runs headless — disable local sound playback.
+ // Sound notifications are forwarded to connected clients as
+ // ServerMessage::Notify instead of played locally.
+ app.state.sound.enabled = false;
+
+ // Create the headless server.
+ let mut server = match HeadlessServer::new(app) {
+ Ok(server) => server,
+ Err(err) if err.kind() == io::ErrorKind::AddrInUse => {
+ eprintln!("error: herdr server is already running");
+ eprintln!("client socket: {}", client_socket_path().display());
+ std::process::exit(1);
+ }
+ Err(err) => return Err(err),
+ };
+
+ info!(
+ api_socket = %api::socket_path().display(),
+ client_socket = %client_socket_path().display(),
+ "herdr server started"
+ );
+
+ server.run().await
+ });
+
+ rt.shutdown_timeout(Duration::from_millis(100));
+ result
+}
+
+/// Initialize logging for the server process.
+fn init_logging() {
+ crate::logging::init_file_logging("herdr-server.log");
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ use crate::server::protocol::CursorState;
+
+ fn test_headless_server() -> HeadlessServer {
+ let config = crate::config::Config::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let app = crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default());
+
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-headless-test-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("client.sock");
+ let listener = UnixListener::bind(&socket_path).expect("bind test listener");
+ listener
+ .set_nonblocking(true)
+ .expect("set listener nonblocking");
+ let (server_event_tx, server_event_rx) = mpsc::channel(64);
+
+ HeadlessServer {
+ app,
+ client_listener: listener,
+ client_socket_path: socket_path,
+ clients: HashMap::new(),
+ next_client_id: 1,
+ foreground_client_id: None,
+ next_activity_stamp: 1,
+ effective_size: (MIN_COLS, MIN_ROWS),
+ shutting_down: false,
+ should_quit: Arc::new(AtomicBool::new(false)),
+ server_event_rx,
+ server_event_tx,
+ }
+ }
+
+ fn read_server_message(bytes: Vec<u8>) -> ServerMessage {
+ let mut cursor = std::io::Cursor::new(bytes);
+ protocol::read_message(&mut cursor, MAX_FRAME_SIZE).expect("decode server message")
+ }
+
+ fn read_server_frame(bytes: Vec<u8>) -> FrameData {
+ match read_server_message(bytes) {
+ ServerMessage::Frame(frame) => frame,
+ other => panic!("expected frame, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn clamp_terminal_size_zero_zero() {
+ assert_eq!(
+ clamp_terminal_size(0, 0),
+ (MIN_CLIENT_COLS, MIN_CLIENT_ROWS)
+ );
+ }
+
+ #[test]
+ fn clamp_terminal_size_one_one() {
+ assert_eq!(clamp_terminal_size(1, 1), (1, 1));
+ }
+
+ #[test]
+ fn clamp_terminal_size_preserves_narrow_client_size() {
+ assert_eq!(clamp_terminal_size(40, 12), (40, 12));
+ }
+
+ #[test]
+ fn clamp_terminal_size_valid() {
+ assert_eq!(clamp_terminal_size(120, 40), (120, 40));
+ }
+
+ #[test]
+ fn clamp_terminal_size_exact_minimum() {
+ assert_eq!(
+ clamp_terminal_size(MIN_CLIENT_COLS, MIN_CLIENT_ROWS),
+ (MIN_CLIENT_COLS, MIN_CLIENT_ROWS)
+ );
+ }
+
+ #[test]
+ fn client_socket_path_derived_from_api_socket_override() {
+ let path = client_socket_path_from_overrides(Some("/tmp/test-herdr.sock"), None);
+ assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
+ }
+
+ #[test]
+ fn client_socket_path_api_override_takes_precedence_over_legacy_client_override() {
+ let path = client_socket_path_from_overrides(
+ Some("/tmp/test-herdr.sock"),
+ Some("/tmp/legacy-client.sock"),
+ );
+ assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
+ }
+
+ #[test]
+ fn client_socket_path_respects_legacy_client_override_without_api_override() {
+ let path = client_socket_path_from_overrides(None, Some("/tmp/test-herdr-client.sock"));
+ assert_eq!(path, PathBuf::from("/tmp/test-herdr-client.sock"));
+ }
+
+ #[test]
+ fn client_socket_path_defaults_to_config_dir() {
+ let path = client_socket_path_from_overrides(None, None);
+ assert_eq!(path, config::config_dir().join("herdr-client.sock"));
+ }
+
+ #[test]
+ fn derive_client_socket_from_api_socket_without_sock_extension() {
+ let derived = derive_client_socket_from_api_socket(Path::new("/tmp/custom-api"));
+ assert_eq!(derived, PathBuf::from("/tmp/custom-api-client.sock"));
+ }
+
+ #[test]
+ fn prepare_socket_path_removes_stale_socket() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-test-stale-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("stale.sock");
+
+ // Create a socket file that nobody is listening on.
+ let _ = UnixListener::bind(&socket_path);
+ // The listener goes out of scope, so the socket becomes stale.
+
+ // prepare_socket_path should remove it without error.
+ let result = prepare_socket_path(&socket_path);
+ assert!(result.is_ok(), "should remove stale socket: {result:?}");
+
+ // Socket file should be gone.
+ assert!(!socket_path.exists());
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn prepare_socket_path_rejects_live_socket() {
+ let dir = std::env::temp_dir().join(format!(
+ "herdr-test-live-{}-{}",
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ let _ = fs::create_dir_all(&dir);
+ let socket_path = dir.join("live.sock");
+
+ // Bind a live listener.
+ let _listener = UnixListener::bind(&socket_path).expect("bind");
+
+ let result = prepare_socket_path(&socket_path);
+ assert!(result.is_err());
+ assert_eq!(result.unwrap_err().kind(), io::ErrorKind::AddrInUse);
+
+ let _ = fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn virtual_render_produces_nonempty_buffer() {
+ let mut state = AppState::test_new();
+ let area = Rect::new(0, 0, 80, 24);
+ let (buffer, _cursor) = render_virtual(&mut state, area, true);
+ assert_eq!(buffer.area.width, 80);
+ assert_eq!(buffer.area.height, 24);
+ }
+
+ #[test]
+ fn virtual_render_without_frame_cursor_keeps_cursor_hidden() {
+ let mut state = AppState::test_new();
+ let area = Rect::new(0, 0, 80, 24);
+ let (_buffer, cursor) = render_virtual(&mut state, area, true);
+
+ assert_eq!(cursor, None);
+ }
+
+ #[tokio::test]
+ async fn virtual_render_preserves_explicit_frame_cursor_position() {
+ let mut state = AppState::test_new();
+ let mut ws = crate::workspace::Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_screen_bytes(20, 5, b"left"),
+ );
+
+ state.workspaces = vec![ws];
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = crate::app::Mode::Terminal;
+
+ let area = Rect::new(0, 0, 80, 24);
+ let (_buffer, cursor) = render_virtual(&mut state, area, true);
+ let pane = state
+ .view
+ .pane_infos
+ .iter()
+ .find(|info| info.id == pane_id)
+ .expect("focused pane info");
+
+ assert_eq!(
+ cursor,
+ Some(CursorState {
+ x: pane.inner_rect.x + 4,
+ y: pane.inner_rect.y,
+ visible: true,
+ })
+ );
+ }
+
+ #[test]
+ fn latest_active_client_drives_shared_size_theme_and_fallback() {
+ let mut server = test_headless_server();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (160, 45),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0xaa,
+ g: 0xbb,
+ b: 0xcc,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0x11,
+ g: 0x22,
+ b: 0x33,
+ }),
+ },
+ last_activity: 1,
+ writer: None,
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme {
+ foreground: Some(crate::terminal_theme::RgbColor {
+ r: 0x10,
+ g: 0x20,
+ b: 0x30,
+ }),
+ background: Some(crate::terminal_theme::RgbColor {
+ r: 0xdd,
+ g: 0xee,
+ b: 0xff,
+ }),
+ },
+ last_activity: 2,
+ writer: None,
+ },
+ );
+
+ assert!(server.promote_client_to_foreground(1));
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.effective_size, (160, 45));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ server.clients[&1].host_terminal_theme
+ );
+
+ assert!(server.promote_client_to_foreground(2));
+ assert_eq!(server.foreground_client_id, Some(2));
+ assert_eq!(server.effective_size, (80, 24));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ server.clients[&2].host_terminal_theme
+ );
+
+ assert!(server.remove_client(2));
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.effective_size, (160, 45));
+ assert_eq!(
+ server.app.state.host_terminal_theme,
+ server.clients[&1].host_terminal_theme
+ );
+ }
+
+ #[test]
+ fn render_and_stream_uses_each_client_terminal_size() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+
+ let (desktop_tx, desktop_rx) = std::sync::mpsc::channel();
+ let (phone_tx, phone_rx) = std::sync::mpsc::channel();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ writer: Some(desktop_tx),
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 2,
+ writer: Some(phone_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ server.render_and_stream();
+
+ let desktop_frame = read_server_frame(desktop_rx.recv().expect("desktop frame"));
+ let phone_frame = read_server_frame(phone_rx.recv().expect("phone frame"));
+
+ assert_eq!((desktop_frame.width, desktop_frame.height), (120, 40));
+ assert_eq!((phone_frame.width, phone_frame.height), (80, 24));
+ }
+
+ #[test]
+ fn clipboard_write_targets_foreground_client_only() {
+ let mut server = test_headless_server();
+ let (background_tx, background_rx) = std::sync::mpsc::channel();
+ let (foreground_tx, foreground_rx) = std::sync::mpsc::channel();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ writer: Some(background_tx),
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 2,
+ writer: Some(foreground_tx),
+ },
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ let changed = server.handle_internal_event_with_forwarding(AppEvent::ClipboardWrite {
+ content: b"test".to_vec(),
+ });
+
+ assert!(!changed);
+ match read_server_message(
+ foreground_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("foreground clipboard message"),
+ ) {
+ ServerMessage::Clipboard { data } => assert_eq!(data, "dGVzdA=="),
+ other => panic!("expected clipboard message, got {other:?}"),
+ }
+ assert!(
+ background_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "background client should not receive clipboard writes"
+ );
+ }
+
+ #[test]
+ fn handshake_timeout_is_within_five_second_deadline() {
+ // The handshake timeout must be short enough that
+ // the connection is guaranteed to close within 5 seconds even with
+ // OS overhead (thread scheduling, timer slack, cleanup).
+ assert!(
+ HANDSHAKE_TIMEOUT < Duration::from_secs(5),
+ "HANDSHAKE_TIMEOUT ({:?}) must be less than 5 seconds to guarantee \
+ connection close within the 5-second deadline",
+ HANDSHAKE_TIMEOUT
+ );
+ }
+
+ /// Verify that no direct calls to `self.app.handle_internal_event`
+ /// exist outside of `handle_internal_event_with_forwarding` in this
+ /// module. This ensures the forwarding bypass cannot be reintroduced.
+ ///
+ /// The search pattern looks for `handle_internal_event` calls that
+ /// are NOT inside the `handle_internal_event_with_forwarding` method.
+ #[test]
+ fn no_handle_internal_event_bypass_in_module() {
+ let source = include_str!("headless.rs");
+
+ // Find all lines containing handle_internal_event
+ let mut bypass_lines: Vec<String> = Vec::new();
+ let mut inside_forwarding_method = false;
+ let mut forwarding_method_brace_depth = 0u32;
+
+ for (i, line) in source.lines().enumerate() {
+ let line_num = i + 1;
+
+ // Track when we're inside handle_internal_event_with_forwarding
+ if line.contains("fn handle_internal_event_with_forwarding") {
+ inside_forwarding_method = true;
+ forwarding_method_brace_depth = 0;
+ }
+
+ if inside_forwarding_method {
+ // Count braces to track when we exit the method
+ for ch in line.chars() {
+ match ch {
+ '{' => forwarding_method_brace_depth += 1,
+ '}' => {
+ forwarding_method_brace_depth =
+ forwarding_method_brace_depth.saturating_sub(1);
+ if forwarding_method_brace_depth == 0 {
+ inside_forwarding_method = false;
+ }
+ }
+ _ => {}
+ }
+ }
+ } else if line.contains("self.app.handle_internal_event(")
+ && !line.trim().starts_with("///")
+ && !line.contains("contains(")
+ {
+ // Direct call to handle_internal_event outside the forwarding method
+ bypass_lines.push(format!("line {}: {}", line_num, line.trim()));
+ }
+ }
+
+ assert!(
+ bypass_lines.is_empty(),
+ "Found direct calls to self.app.handle_internal_event outside \
+ handle_internal_event_with_forwarding (bypass risk):\n {}",
+ bypass_lines.join("\n ")
+ );
+ }
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
index 00000000..5b5f1d4e
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,3 @@
+pub mod autodetect;
+pub mod headless;
+pub mod protocol;
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
new file mode 100644
index 00000000..f3784a5e
--- /dev/null
+++ b/src/server/protocol.rs
@@ -0,0 +1,1212 @@
+//! Wire protocol for herdr server/client communication.
+//!
+//! Defines the message types, framing, version negotiation, and safety
+//! constraints for the binary protocol over Unix domain sockets.
+
+use std::io::{self, Read, Write};
+
+use serde::{Deserialize, Serialize};
+
+// ---------------------------------------------------------------------------
+// Protocol constants
+// ---------------------------------------------------------------------------
+
+/// Current protocol version. Bumped when wire format changes incompatibly.
+pub const PROTOCOL_VERSION: u32 = 1;
+
+/// Maximum allowed frame payload size (2 MB). Frames larger than this are
+/// rejected to prevent denial-of-service via oversized length prefixes.
+pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024;
+
+/// Length of the u32 little-endian length prefix in bytes.
+const LENGTH_PREFIX_BYTES: usize = 4;
+
+// ---------------------------------------------------------------------------
+// Client → Server messages
+// ---------------------------------------------------------------------------
+
+/// Messages sent from the client to the server over the client protocol socket.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ClientMessage {
+ /// Handshake: client announces its protocol version and terminal dimensions.
+ Hello {
+ /// Protocol version the client speaks.
+ version: u32,
+ /// Terminal width in columns.
+ cols: u16,
+ /// Terminal height in rows.
+ rows: u16,
+ },
+
+ /// Raw input bytes read from the client's stdin.
+ Input {
+ /// Raw terminal input (possibly multi-byte escape sequences).
+ data: Vec<u8>,
+ },
+
+ /// Terminal resize notification from the client.
+ Resize {
+ /// New terminal width in columns.
+ cols: u16,
+ /// New terminal height in rows.
+ rows: u16,
+ },
+
+ /// Graceful disconnect request.
+ Detach,
+}
+
+// ---------------------------------------------------------------------------
+// Server → Client messages
+// ---------------------------------------------------------------------------
+
+/// A single cell in a rendered frame, serialized independently from ratatui's
+/// `Cell` type to keep the wire protocol stable.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CellData {
+ /// Grapheme cluster displayed in this cell (usually 1–2 chars).
+ pub symbol: String,
+ /// Foreground color as a packed u32 (0xAARRGGBB or ratatui Color index).
+ pub fg: u32,
+ /// Background color as a packed u32.
+ pub bg: u32,
+ /// Bitmask of style modifiers (bold, italic, etc.).
+ pub modifier: u16,
+ /// Whether this cell should be skipped during diff-based rendering.
+ pub skip: bool,
+}
+
+/// Cursor position within a rendered frame.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct CursorState {
+ /// Column offset (0-based) of the cursor.
+ pub x: u16,
+ /// Row offset (0-based) of the cursor.
+ pub y: u16,
+ /// Whether the cursor is visible.
+ pub visible: bool,
+}
+
+/// A rendered frame to be displayed by the client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FrameData {
+ /// Cells in row-major order. Length must equal `width * height`.
+ pub cells: Vec<CellData>,
+ /// Frame width in columns.
+ pub width: u16,
+ /// Frame height in rows.
+ pub height: u16,
+ /// Cursor state for this frame, if applicable.
+ pub cursor: Option<CursorState>,
+}
+
+impl FrameData {
+ /// Creates a `FrameData` from a ratatui `Buffer` and optional cursor.
+ ///
+ /// This converts ratatui's internal cell representation into the
+ /// wire-protocol cell format. The conversion is lossless for all
+ /// commonly used cell attributes.
+ pub fn from_ratatui_buffer(
+ buffer: &ratatui::buffer::Buffer,
+ cursor: Option<CursorState>,
+ ) -> Self {
+ let area = buffer.area;
+ let width = area.width;
+ let height = area.height;
+
+ let mut cells = Vec::with_capacity((width as usize) * (height as usize));
+ for row in 0..height {
+ for col in 0..width {
+ let cell = buffer.cell((col, row)).expect("cell within bounds");
+ cells.push(CellData {
+ symbol: cell.symbol().to_owned(),
+ fg: color_to_u32(cell.fg),
+ bg: color_to_u32(cell.bg),
+ modifier: modifier_to_u16(cell.modifier),
+ skip: cell.skip,
+ });
+ }
+ }
+
+ FrameData {
+ cells,
+ width,
+ height,
+ cursor,
+ }
+ }
+
+ /// Reconstructs a ratatui `Buffer` from this frame data.
+ ///
+ /// Returns `None` if the cells vector length doesn't match `width * height`.
+ #[cfg(test)]
+ pub fn to_ratatui_buffer(&self) -> Option<ratatui::buffer::Buffer> {
+ let expected = (self.width as usize) * (self.height as usize);
+ if self.cells.len() != expected {
+ return None;
+ }
+
+ let area = ratatui::layout::Rect::new(0, 0, self.width, self.height);
+ let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" "));
+
+ for row in 0..self.height {
+ for col in 0..self.width {
+ let idx = (row as usize) * (self.width as usize) + (col as usize);
+ let cell_data = &self.cells[idx];
+ let cell = buffer.cell_mut((col, row)).expect("cell within bounds");
+ cell.set_symbol(&cell_data.symbol);
+ cell.fg = u32_to_color(cell_data.fg);
+ cell.bg = u32_to_color(cell_data.bg);
+ cell.modifier = u16_to_modifier(cell_data.modifier);
+ cell.skip = cell_data.skip;
+ }
+ }
+
+ Some(buffer)
+ }
+}
+
+/// Notification kind forwarded from server to client.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum NotifyKind {
+ /// Play a sound (bell/agent-done, etc.).
+ Sound,
+ /// Display a toast message.
+ Toast,
+}
+
+/// Messages sent from the server to the client over the client protocol socket.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum ServerMessage {
+ /// Handshake response: server acknowledges (or rejects) the client.
+ Welcome {
+ /// Protocol version the server speaks.
+ version: u32,
+ /// If present, the handshake failed and this describes why.
+ /// The client should exit with a clear error message.
+ error: Option<String>,
+ },
+
+ /// A rendered frame to be displayed by the client.
+ Frame(FrameData),
+
+ /// Server is shutting down. Clients should exit gracefully.
+ ServerShutdown {
+ /// Optional reason for the shutdown.
+ reason: Option<String>,
+ },
+
+ /// A notification event (sound/toast) to be rendered locally by the client.
+ Notify {
+ /// What kind of notification.
+ kind: NotifyKind,
+ /// Human-readable message.
+ message: String,
+ },
+
+ /// OSC 52 clipboard data forwarded from a PTY through the server.
+ Clipboard {
+ /// Base64-encoded clipboard data.
+ data: String,
+ },
+}
+
+// ---------------------------------------------------------------------------
+// Color / Modifier conversion helpers
+// ---------------------------------------------------------------------------
+
+/// Converts a ratatui `Color` to a packed u32 for wire transport.
+///
+/// Encoding:
+/// - Named colors (Reset, Black, …, White) → `0x00_00_00_XX` where XX is 0..=16
+/// - Indexed palette → `0x01_00_00_XX` where XX is the palette index
+/// - RGB → `0x02_RR_GG_BB` with components in the lower 3 bytes
+fn color_to_u32(color: ratatui::style::Color) -> u32 {
+ match color {
+ ratatui::style::Color::Reset => 0x00_00_00_00,
+ ratatui::style::Color::Black => 0x00_00_00_01,
+ ratatui::style::Color::Red => 0x00_00_00_02,
+ ratatui::style::Color::Green => 0x00_00_00_03,
+ ratatui::style::Color::Yellow => 0x00_00_00_04,
+ ratatui::style::Color::Blue => 0x00_00_00_05,
+ ratatui::style::Color::Magenta => 0x00_00_00_06,
+ ratatui::style::Color::Cyan => 0x00_00_00_07,
+ ratatui::style::Color::Gray => 0x00_00_00_08,
+ ratatui::style::Color::DarkGray => 0x00_00_00_09,
+ ratatui::style::Color::LightRed => 0x00_00_00_0A,
+ ratatui::style::Color::LightGreen => 0x00_00_00_0B,
+ ratatui::style::Color::LightYellow => 0x00_00_00_0C,
+ ratatui::style::Color::LightBlue => 0x00_00_00_0D,
+ ratatui::style::Color::LightMagenta => 0x00_00_00_0E,
+ ratatui::style::Color::LightCyan => 0x00_00_00_0F,
+ ratatui::style::Color::White => 0x00_00_00_10,
+ ratatui::style::Color::Indexed(i) => 0x01_00_00_00 | (i as u32),
+ ratatui::style::Color::Rgb(r, g, b) => {
+ 0x02_00_00_00 | ((r as u32) << 16) | ((g as u32) << 8) | (b as u32)
+ }
+ }
+}
+
+/// Converts a packed u32 back to a ratatui `Color`.
+#[cfg(test)]
+fn u32_to_color(val: u32) -> ratatui::style::Color {
+ match val >> 24 {
+ 0x00 => match val & 0xFF {
+ 0x00 => ratatui::style::Color::Reset,
+ 0x01 => ratatui::style::Color::Black,
+ 0x02 => ratatui::style::Color::Red,
+ 0x03 => ratatui::style::Color::Green,
+ 0x04 => ratatui::style::Color::Yellow,
+ 0x05 => ratatui::style::Color::Blue,
+ 0x06 => ratatui::style::Color::Magenta,
+ 0x07 => ratatui::style::Color::Cyan,
+ 0x08 => ratatui::style::Color::Gray,
+ 0x09 => ratatui::style::Color::DarkGray,
+ 0x0A => ratatui::style::Color::LightRed,
+ 0x0B => ratatui::style::Color::LightGreen,
+ 0x0C => ratatui::style::Color::LightYellow,
+ 0x0D => ratatui::style::Color::LightBlue,
+ 0x0E => ratatui::style::Color::LightMagenta,
+ 0x0F => ratatui::style::Color::LightCyan,
+ 0x10 => ratatui::style::Color::White,
+ _ => ratatui::style::Color::Reset, // unknown named → Reset
+ },
+ 0x01 => ratatui::style::Color::Indexed((val & 0xFF) as u8),
+ 0x02 => {
+ let r = ((val >> 16) & 0xFF) as u8;
+ let g = ((val >> 8) & 0xFF) as u8;
+ let b = (val & 0xFF) as u8;
+ ratatui::style::Color::Rgb(r, g, b)
+ }
+ _ => ratatui::style::Color::Reset, // unknown tag → Reset
+ }
+}
+
+/// Converts a ratatui `Modifier` bitmask to a u16 for wire transport.
+fn modifier_to_u16(modifier: ratatui::style::Modifier) -> u16 {
+ modifier.bits()
+}
+
+/// Converts a u16 back to a ratatui `Modifier`.
+#[cfg(test)]
+fn u16_to_modifier(val: u16) -> ratatui::style::Modifier {
+ ratatui::style::Modifier::from_bits_truncate(val)
+}
+
+// ---------------------------------------------------------------------------
+// Framing: length-prefixed binary messages
+// ---------------------------------------------------------------------------
+
+/// Errors that can occur during framing operations.
+#[derive(Debug)]
+pub enum FramingError {
+ /// The decoded payload length exceeds the configured maximum frame size.
+ Oversized { claimed: usize, max: usize },
+ /// An I/O error occurred while reading or writing.
+ Io(io::Error),
+ /// Bincode serialization or deserialization failed.
+ Bincode(String),
+ /// The connection was closed before a complete frame could be read.
+ UnexpectedEof,
+}
+
+impl std::fmt::Display for FramingError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ FramingError::Oversized { claimed, max } => {
+ write!(f, "frame size {claimed} exceeds maximum {max}")
+ }
+ FramingError::Io(e) => write!(f, "I/O error: {e}"),
+ FramingError::Bincode(e) => write!(f, "bincode error: {e}"),
+ FramingError::UnexpectedEof => write!(f, "unexpected end of stream"),
+ }
+ }
+}
+
+impl std::error::Error for FramingError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ FramingError::Io(e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From<io::Error> for FramingError {
+ fn from(e: io::Error) -> Self {
+ FramingError::Io(e)
+ }
+}
+
+/// Serializes a message and writes it as a length-prefixed frame:
+/// `[u32LE length][bincode payload]`.
+///
+/// This is a blocking/synchronous write suitable for use with `std::os::unix::net::UnixStream`
+/// in blocking mode, or with any `Write` implementor.
+///
+/// # Errors
+///
+/// Returns `FramingError::Bincode` if the payload length exceeds `u32::MAX`
+/// (would be truncated by the length prefix cast).
+pub fn write_message<W: Write, M: Serialize>(writer: &mut W, msg: &M) -> Result<(), FramingError> {
+ let payload = bincode::serde::encode_to_vec(msg, bincode::config::standard())
+ .map_err(|e| FramingError::Bincode(e.to_string()))?;
+
+ let len = payload.len();
+ if len > u32::MAX as usize {
+ return Err(FramingError::Bincode(format!(
+ "payload length {len} exceeds u32::MAX ({}), would be truncated by length prefix",
+ u32::MAX
+ )));
+ }
+
+ writer.write_all(&(len as u32).to_le_bytes())?;
+ writer.write_all(&payload)?;
+ writer.flush()?;
+ Ok(())
+}
+
+/// Reads and deserializes a length-prefixed frame from a reader.
+///
+/// Reassembles partial reads correctly. Rejects frames whose declared
+/// length exceeds `max_frame_size` without panicking or allocating
+/// oversized buffers.
+pub fn read_message<R: Read, M: for<'de> Deserialize<'de>>(
+ reader: &mut R,
+ max_frame_size: usize,
+) -> Result<M, FramingError> {
+ // Read the 4-byte length prefix, reassembling partial reads.
+ let mut len_buf = [0u8; LENGTH_PREFIX_BYTES];
+ read_exact_or_eof(reader, &mut len_buf)?;
+ let claimed_len = u32::from_le_bytes(len_buf) as usize;
+
+ if claimed_len > max_frame_size {
+ return Err(FramingError::Oversized {
+ claimed: claimed_len,
+ max: max_frame_size,
+ });
+ }
+
+ // Read the payload, reassembling partial reads.
+ let mut payload = vec![0u8; claimed_len];
+ read_exact_or_eof(reader, &mut payload)?;
+
+ let (msg, consumed) = bincode::serde::decode_from_slice(&payload, bincode::config::standard())
+ .map_err(|e| FramingError::Bincode(e.to_string()))?;
+
+ // Enforce that the decoder consumed the full payload.
+ // Trailing bytes after the decoded message indicate a protocol violation
+ // (e.g., a corrupted length prefix or concatenated payloads).
+ if consumed != claimed_len {
+ return Err(FramingError::Bincode(format!(
+ "decoded {} bytes but payload length was {claimed_len}; trailing bytes are not allowed",
+ consumed
+ )));
+ }
+
+ Ok(msg)
+}
+
+/// Like `Read::read_exact`, but returns `FramingError::UnexpectedEof`
+/// when the reader hits end-of-stream before filling the buffer, instead
+/// of the generic `io::ErrorKind::UnexpectedEof`.
+fn read_exact_or_eof<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), FramingError> {
+ reader.read_exact(buf).map_err(|e| {
+ if e.kind() == io::ErrorKind::UnexpectedEof {
+ FramingError::UnexpectedEof
+ } else {
+ FramingError::Io(e)
+ }
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Version negotiation
+// ---------------------------------------------------------------------------
+
+/// Result of checking a client's protocol version against the server's.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum VersionCheck {
+ /// Versions are compatible. The server should reply `Welcome { version, error: None }`.
+ Compatible,
+ /// Versions are incompatible. The server should reply
+ /// `Welcome { version, error: Some(reason) }` and close the connection.
+ Incompatible(String),
+}
+
+/// Checks whether a client's protocol version is compatible with this server.
+///
+/// Current rules:
+/// - Version 0 (pre-persistence client) is always rejected.
+/// - Matching major versions are accepted.
+/// - A client with a newer version than the server is rejected.
+/// - A client with an older version than the server is rejected
+/// (backward compatibility is not yet supported).
+pub fn check_client_version(client_version: u32) -> VersionCheck {
+ if client_version == 0 {
+ return VersionCheck::Incompatible(
+ "pre-persistence client (version 0) is not supported".to_owned(),
+ );
+ }
+
+ if client_version == PROTOCOL_VERSION {
+ VersionCheck::Compatible
+ } else if client_version < PROTOCOL_VERSION {
+ VersionCheck::Incompatible(format!(
+ "client version {client_version} is older than server version {PROTOCOL_VERSION}; please upgrade your herdr client"
+ ))
+ } else {
+ VersionCheck::Incompatible(format!(
+ "client version {client_version} is newer than server version {PROTOCOL_VERSION}; please upgrade the herdr server"
+ ))
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use ratatui::style::{Color, Modifier};
+
+ // ---- Round-trip: ClientMessage ----
+
+ #[test]
+ fn client_hello_roundtrip() {
+ let msg = ClientMessage::Hello {
+ version: 1,
+ cols: 80,
+ rows: 24,
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_input_roundtrip() {
+ let msg = ClientMessage::Input {
+ data: vec![0x1b, 0x5b, 0x41], // ESC [ A (up arrow)
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_resize_roundtrip() {
+ let msg = ClientMessage::Resize { cols: 80, rows: 24 };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn client_detach_roundtrip() {
+ let msg = ClientMessage::Detach;
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ClientMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Round-trip: ServerMessage ----
+
+ #[test]
+ fn server_welcome_roundtrip() {
+ let msg = ServerMessage::Welcome {
+ version: 1,
+ error: None,
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn server_welcome_with_error_roundtrip() {
+ let msg = ServerMessage::Welcome {
+ version: 1,
+ error: Some("incompatible version".to_owned()),
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn server_frame_roundtrip_nontrivial() {
+ // Build a 3×2 frame with varied styles (≥2×2).
+ let frame = FrameData {
+ cells: vec![
+ CellData {
+ symbol: "H".into(),
+ fg: color_to_u32(Color::Red),
+ bg: color_to_u32(Color::Black),
+ modifier: Modifier::BOLD.bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: "i".into(),
+ fg: color_to_u32(Color::Green),
+ bg: color_to_u32(Color::Reset),
+ modifier: Modifier::ITALIC.bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: "!".into(),
+ fg: color_to_u32(Color::Rgb(255, 128, 0)),
+ bg: color_to_u32(Color::Indexed(220)),
+ modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: " ".into(),
+ fg: color_to_u32(Color::Reset),
+ bg: color_to_u32(Color::Reset),
+ modifier: Modifier::empty().bits(),
+ skip: true,
+ },
+ CellData {
+ symbol: "→".into(), // multi-byte grapheme
+ fg: color_to_u32(Color::Cyan),
+ bg: color_to_u32(Color::Blue),
+ modifier: Modifier::REVERSED.bits(),
+ skip: false,
+ },
+ CellData {
+ symbol: "🦀".into(), // emoji, wide grapheme cluster
+ fg: color_to_u32(Color::Yellow),
+ bg: color_to_u32(Color::Magenta),
+ modifier: Modifier::empty().bits(),
+ skip: false,
+ },
+ ],
+ width: 3,
+ height: 2,
+ cursor: Some(CursorState {
+ x: 0,
+ y: 0,
+ visible: true,
+ }),
+ };
+ let msg = ServerMessage::Frame(frame.clone());
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn server_shutdown_roundtrip() {
+ let msg = ServerMessage::ServerShutdown {
+ reason: Some("updating".to_owned()),
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn server_notify_roundtrip() {
+ let msg = ServerMessage::Notify {
+ kind: NotifyKind::Sound,
+ message: "agent done".to_owned(),
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn server_clipboard_roundtrip() {
+ let msg = ServerMessage::Clipboard {
+ data: "dGVzdA==".to_owned(), // base64 "test"
+ };
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Framing ----
+
+ #[test]
+ fn framing_small_message_roundtrip() {
+ let msg = ClientMessage::Hello {
+ version: 1,
+ cols: 80,
+ rows: 24,
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_large_payload_roundtrip() {
+ // Create a Frame message that is ≥128 KB.
+ // Use a large frame with verbose cell data to exceed 128 KB after bincode encoding.
+ // 200×50 = 10000 cells. With varied symbols and styles, this should easily exceed 128 KB.
+ let width: u16 = 200;
+ let height: u16 = 50;
+ let cells: Vec<CellData> = (0..(width as usize) * (height as usize))
+ .map(|i| CellData {
+ symbol: if i % 256 < 32 {
+ " ".to_owned()
+ } else {
+ format!("{:03}", i % 1000)
+ },
+ fg: color_to_u32(Color::Rgb((i % 256) as u8, ((i / 256) % 256) as u8, 128)),
+ bg: color_to_u32(Color::Indexed((i % 256) as u8)),
+ modifier: ((i % 16) as u16),
+ skip: i % 100 == 0,
+ })
+ .collect();
+
+ let frame = FrameData {
+ cells,
+ width,
+ height,
+ cursor: Some(CursorState {
+ x: 10,
+ y: 5,
+ visible: true,
+ }),
+ };
+ let msg = ServerMessage::Frame(frame);
+
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ // Verify the payload is at least 128 KB
+ assert!(
+ buf.len() >= 128 * 1024,
+ "framed payload should be >= 128 KB, got {} bytes",
+ buf.len()
+ );
+
+ let decoded: ServerMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_multiple_messages_sequential() {
+ // Write 100+ messages of varying types and read them back.
+ let mut buf = Vec::new();
+ let mut expected = Vec::new();
+
+ for i in 0..150u32 {
+ let msg = match i % 4 {
+ 0 => ClientMessage::Hello {
+ version: 1,
+ cols: (80 + (i % 40) as u16),
+ rows: (24 + (i % 20) as u16),
+ },
+ 1 => ClientMessage::Input {
+ data: vec![(i % 256) as u8; (i as usize % 50) + 1],
+ },
+ 2 => ClientMessage::Resize {
+ cols: (100 + (i % 30) as u16),
+ rows: (30 + (i % 10) as u16),
+ },
+ 3 => ClientMessage::Detach,
+ _ => unreachable!(),
+ };
+ write_message(&mut buf, &msg).unwrap();
+ expected.push(msg);
+ }
+
+ let mut cursor = buf.as_slice();
+ for expected_msg in &expected {
+ let decoded: ClientMessage = read_message(&mut cursor, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(*expected_msg, decoded);
+ }
+ }
+
+ #[test]
+ fn framing_oversized_rejected_without_panic() {
+ // Craft a frame with a huge length prefix (4 GB claim).
+ let mut buf: Vec<u8> = (u32::MAX).to_le_bytes().to_vec();
+ // Add a few garbage bytes after the length prefix.
+ buf.extend_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::Oversized { claimed, max }) => {
+ assert_eq!(claimed, u32::MAX as usize);
+ assert_eq!(max, MAX_FRAME_SIZE);
+ }
+ other => panic!("expected Oversized error, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_malformed_payload_rejected_without_panic() {
+ // Valid length prefix pointing to garbage data.
+ let payload = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02];
+ let mut buf = (payload.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&payload);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err(), "malformed payload should be rejected");
+ match result {
+ Err(FramingError::Bincode(_)) => {} // expected
+ other => panic!("expected Bincode error, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_truncated_stream_returns_unexpected_eof() {
+ // Write a length prefix claiming 100 bytes, but only provide 4.
+ let mut buf: Vec<u8> = 100u32.to_le_bytes().to_vec();
+ buf.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::UnexpectedEof) => {}
+ other => panic!("expected UnexpectedEof, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn framing_zero_length_message() {
+ // A 1-byte message (smallest possible valid bincode payload).
+ // Actually, let's test with the smallest real message: Detach.
+ let msg = ClientMessage::Detach;
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+
+ // Verify the length prefix is correct
+ let len = u32::from_le_bytes(buf[..4].try_into().unwrap()) as usize;
+ assert_eq!(
+ len,
+ buf.len() - 4,
+ "length prefix should match payload size"
+ );
+
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn framing_partial_read_reassembly() {
+ // Simulate partial reads by using a reader that yields small chunks.
+ let msg = ClientMessage::Input {
+ data: vec![42; 500], // 500-byte input payload
+ };
+ let mut full_buf = Vec::new();
+ write_message(&mut full_buf, &msg).unwrap();
+
+ // Wrap in a chunked reader that only yields 7 bytes at a time.
+ let mut chunked = ChunkedReader::new(full_buf, 7);
+ let decoded: ClientMessage = read_message(&mut chunked, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ // ---- Version negotiation ----
+
+ #[test]
+ fn version_compatible() {
+ assert_eq!(check_client_version(1), VersionCheck::Compatible);
+ }
+
+ #[test]
+ fn version_older_client_rejected() {
+ match check_client_version(1) {
+ VersionCheck::Compatible => {} // current version
+ _ => panic!("current version should be compatible"),
+ }
+ // If we imagine a future server version 2, a v1 client would be rejected.
+ // We test this by checking the logic directly.
+ let result = check_client_version(0);
+ assert!(
+ matches!(result, VersionCheck::Incompatible(_)),
+ "version 0 should be incompatible"
+ );
+ }
+
+ #[test]
+ fn version_newer_client_rejected() {
+ let result = check_client_version(PROTOCOL_VERSION + 1);
+ assert!(matches!(result, VersionCheck::Incompatible(_)));
+ if let VersionCheck::Incompatible(msg) = result {
+ assert!(msg.contains("newer"), "error should mention newer version");
+ }
+ }
+
+ // ---- Pre-persistence client rejection ----
+
+ #[test]
+ fn prepersistence_version_zero_rejected() {
+ let result = check_client_version(0);
+ match result {
+ VersionCheck::Incompatible(msg) => {
+ assert!(
+ msg.contains("pre-persistence"),
+ "error should mention pre-persistence: {msg}"
+ );
+ }
+ _ => panic!("version 0 should be rejected as incompatible"),
+ }
+ }
+
+ #[test]
+ fn prepersistence_version_zero_welcome_has_error() {
+ // Simulating what the server would send to a v0 client.
+ let check = check_client_version(0);
+ let response = match check {
+ VersionCheck::Compatible => ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: None,
+ },
+ VersionCheck::Incompatible(reason) => ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ error: Some(reason),
+ },
+ };
+
+ match response {
+ ServerMessage::Welcome { error: Some(_), .. } => {}
+ other => panic!("expected Welcome with error, got: {other:?}"),
+ }
+ }
+
+ // ---- Malformed/oversized input ----
+
+ #[test]
+ fn oversized_frame_does_not_panic() {
+ // Claim 4GB payload — should return Oversized error, not panic.
+ let mut buf: Vec<u8> = 0xFFC00000u32.to_le_bytes().to_vec(); // ~4 GB claim
+ buf.extend_from_slice(&[0; 8]);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err());
+ // Did not panic — test passing is proof.
+ }
+
+ #[test]
+ fn malformed_frame_does_not_panic() {
+ // Random garbage bytes after a valid-ish length prefix.
+ let garbage: Vec<u8> = (0..200).map(|i| (i ^ 0xAA) as u8).collect();
+ let mut buf = (garbage.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&garbage);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ assert!(result.is_err());
+ // Did not panic.
+ }
+
+ #[test]
+ fn oversized_input_rejected_custom_max() {
+ // Verify a custom (small) max_frame_size is enforced.
+ let msg = ClientMessage::Input {
+ data: vec![0x41; 1000],
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+
+ let result: Result<ClientMessage, FramingError> = read_message(&mut buf.as_slice(), 64);
+ // The actual bincode payload for 1000 bytes of input will be > 64 bytes.
+ assert!(
+ matches!(result, Err(FramingError::Oversized { .. })),
+ "expected Oversized with small max_frame_size"
+ );
+ }
+
+ // ---- FrameData ↔ ratatui Buffer conversion ----
+
+ #[test]
+ fn frame_data_roundtrip_through_ratatui_buffer() {
+ let area = ratatui::layout::Rect::new(0, 0, 5, 3);
+ let mut buffer = ratatui::buffer::Buffer::filled(area, ratatui::buffer::Cell::new(" "));
+
+ // Write some styled content.
+ buffer.cell_mut((0, 0)).unwrap().set_symbol("H");
+ buffer.cell_mut((0, 0)).unwrap().fg = Color::Red;
+ buffer.cell_mut((0, 0)).unwrap().modifier = Modifier::BOLD;
+
+ buffer.cell_mut((1, 0)).unwrap().set_symbol("i");
+ buffer.cell_mut((1, 0)).unwrap().fg = Color::Green;
+ buffer.cell_mut((1, 0)).unwrap().modifier = Modifier::ITALIC;
+
+ buffer.cell_mut((2, 0)).unwrap().set_symbol("!");
+ buffer.cell_mut((2, 0)).unwrap().fg = Color::Rgb(255, 128, 0);
+ buffer.cell_mut((2, 0)).unwrap().bg = Color::Indexed(220);
+
+ let cursor = CursorState {
+ x: 1,
+ y: 0,
+ visible: true,
+ };
+ let frame = FrameData::from_ratatui_buffer(&buffer, Some(cursor.clone()));
+
+ // Verify frame dimensions.
+ assert_eq!(frame.width, 5);
+ assert_eq!(frame.height, 3);
+ assert_eq!(frame.cells.len(), 15);
+ assert_eq!(frame.cursor, Some(cursor));
+
+ // Verify specific cells survived the conversion.
+ assert_eq!(frame.cells[0].symbol, "H");
+ assert_eq!(frame.cells[0].fg, color_to_u32(Color::Red));
+ assert_eq!(frame.cells[0].modifier, Modifier::BOLD.bits());
+
+ assert_eq!(frame.cells[1].symbol, "i");
+ assert_eq!(frame.cells[1].fg, color_to_u32(Color::Green));
+ assert_eq!(frame.cells[1].modifier, Modifier::ITALIC.bits());
+
+ assert_eq!(frame.cells[2].symbol, "!");
+ assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0)));
+ assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220)));
+
+ // Convert back to ratatui buffer and compare.
+ let restored = frame.to_ratatui_buffer().expect("should reconstruct");
+ assert_eq!(restored.area, area);
+ assert_eq!(restored.cell((0, 0)).unwrap().symbol(), "H");
+ assert_eq!(restored.cell((0, 0)).unwrap().fg, Color::Red);
+ assert_eq!(restored.cell((0, 0)).unwrap().modifier, Modifier::BOLD);
+ assert_eq!(restored.cell((1, 0)).unwrap().symbol(), "i");
+ assert_eq!(restored.cell((2, 0)).unwrap().symbol(), "!");
+ assert_eq!(restored.cell((2, 0)).unwrap().fg, Color::Rgb(255, 128, 0));
+ }
+
+ #[test]
+ fn frame_data_rejects_mismatched_cell_count() {
+ let frame = FrameData {
+ cells: vec![
+ CellData {
+ symbol: "X".into(),
+ fg: 0,
+ bg: 0,
+ modifier: 0,
+ skip: false,
+ };
+ 5
+ ], // 5 cells but 3×2 = 6 expected
+ width: 3,
+ height: 2,
+ cursor: None,
+ };
+ assert!(frame.to_ratatui_buffer().is_none());
+ }
+
+ // ---- Color conversion coverage ----
+
+ #[test]
+ fn color_roundtrip_all_named_colors() {
+ let named = [
+ Color::Reset,
+ Color::Black,
+ Color::Red,
+ Color::Green,
+ Color::Yellow,
+ Color::Blue,
+ Color::Magenta,
+ Color::Cyan,
+ Color::Gray,
+ Color::DarkGray,
+ Color::LightRed,
+ Color::LightGreen,
+ Color::LightYellow,
+ Color::LightBlue,
+ Color::LightMagenta,
+ Color::LightCyan,
+ Color::White,
+ ];
+ for c in named {
+ assert_eq!(
+ u32_to_color(color_to_u32(c)),
+ c,
+ "roundtrip failed for {c:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn color_roundtrip_indexed() {
+ for i in 0..=255u8 {
+ let c = Color::Indexed(i);
+ assert_eq!(
+ u32_to_color(color_to_u32(c)),
+ c,
+ "roundtrip failed for Indexed({i})"
+ );
+ }
+ }
+
+ #[test]
+ fn color_roundtrip_rgb() {
+ let c = Color::Rgb(0xAB, 0xCD, 0xEF);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+
+ let c = Color::Rgb(0, 0, 0);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+
+ let c = Color::Rgb(255, 255, 255);
+ assert_eq!(u32_to_color(color_to_u32(c)), c);
+ }
+
+ // ---- Modifier conversion ----
+
+ #[test]
+ fn modifier_roundtrip() {
+ let all_mods = [
+ Modifier::BOLD,
+ Modifier::ITALIC,
+ Modifier::REVERSED,
+ Modifier::UNDERLINED,
+ Modifier::DIM,
+ Modifier::SLOW_BLINK,
+ Modifier::CROSSED_OUT,
+ Modifier::BOLD | Modifier::ITALIC,
+ Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED,
+ Modifier::empty(),
+ ];
+ for m in all_mods {
+ assert_eq!(
+ u16_to_modifier(modifier_to_u16(m)),
+ m,
+ "roundtrip failed for {m:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn read_message_rejects_trailing_bytes() {
+ // Encode a valid message, then append an extra byte after it.
+ let msg = ClientMessage::Detach;
+ let mut payload = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let original_len = payload.len();
+ payload.push(0xDE); // trailing garbage
+
+ // Frame it with the inflated length (original + 1).
+ let mut buf = (payload.len() as u32).to_le_bytes().to_vec();
+ buf.extend_from_slice(&payload);
+
+ let result: Result<ClientMessage, FramingError> =
+ read_message(&mut buf.as_slice(), MAX_FRAME_SIZE);
+ match result {
+ Err(FramingError::Bincode(msg)) => {
+ assert!(
+ msg.contains("trailing bytes"),
+ "error should mention trailing bytes: {msg}"
+ );
+ assert!(
+ msg.contains(&format!("decoded {original_len}")),
+ "error should mention decoded byte count: {msg}"
+ );
+ }
+ other => panic!("expected Bincode error about trailing bytes, got: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn read_message_accepts_exact_payload() {
+ // A normally-framed message should decode without error.
+ let msg = ClientMessage::Hello {
+ version: 1,
+ cols: 80,
+ rows: 24,
+ };
+ let mut buf = Vec::new();
+ write_message(&mut buf, &msg).unwrap();
+ let decoded: ClientMessage = read_message(&mut buf.as_slice(), MAX_FRAME_SIZE).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
+ #[test]
+ fn write_message_rejects_oversized_payload() {
+ // We can't easily create a message that exceeds u32::MAX in a test,
+ // but we can verify the check exists by testing that normal messages
+ // have lengths well within the limit and the function doesn't fail.
+ let msg = ClientMessage::Detach;
+ let mut buf = Vec::new();
+ assert!(write_message(&mut buf, &msg).is_ok());
+ }
+
+ // ---- Unix socketpair integration test ----
+
+ #[test]
+ fn framing_over_unix_socketpair() {
+ use std::os::unix::net::UnixStream;
+
+ let (mut a, mut b) = UnixStream::pair().expect("socketpair");
+
+ let messages = vec![
+ ClientMessage::Hello {
+ version: 1,
+ cols: 200,
+ rows: 60,
+ },
+ ClientMessage::Input {
+ data: b"hello world".to_vec(),
+ },
+ ClientMessage::Resize {
+ cols: 100,
+ rows: 30,
+ },
+ ClientMessage::Detach,
+ ];
+
+ // Set non-blocking so we can write and read in the same test.
+ a.set_nonblocking(false).unwrap();
+ b.set_nonblocking(false).unwrap();
+
+ for msg in &messages {
+ write_message(&mut a, msg).unwrap();
+ }
+
+ for expected in &messages {
+ let decoded: ClientMessage = read_message(&mut b, MAX_FRAME_SIZE).unwrap();
+ assert_eq!(*expected, decoded);
+ }
+ }
+
+ // ---- Helper: chunked reader for simulating partial reads ----
+
+ /// A `Read` wrapper that yields at most `chunk_size` bytes per `read()` call,
+ /// simulating partial reads on a real socket.
+ struct ChunkedReader {
+ data: Vec<u8>,
+ pos: usize,
+ chunk_size: usize,
+ }
+
+ impl ChunkedReader {
+ fn new(data: Vec<u8>, chunk_size: usize) -> Self {
+ Self {
+ data,
+ pos: 0,
+ chunk_size,
+ }
+ }
+ }
+
+ impl Read for ChunkedReader {
+ fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
+ if self.pos >= self.data.len() {
+ return Ok(0);
+ }
+ let remaining = self.data.len() - self.pos;
+ let to_read = buf.len().min(remaining).min(self.chunk_size);
+ buf[..to_read].copy_from_slice(&self.data[self.pos..self.pos + to_read]);
+ self.pos += to_read;
+ Ok(to_read)
+ }
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index 29af2e17..06ed1f34 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1,15 +1,64 @@
use ratatui::{
- layout::{Alignment, Constraint, Layout, Rect},
- style::{Color, Modifier, Style},
- text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::Span,
Frame,
};
-use crate::app::state::{AgentPanelScope, ToastKind, ToastNotification};
+mod dialogs;
+mod keybind_help;
+mod menus;
+mod onboarding;
+mod panes;
+mod release_notes;
+mod scrollbar;
+mod settings;
+mod sidebar;
+mod status;
+mod tabs;
+mod widgets;
+
+use self::dialogs::{render_confirm_close_overlay, render_rename_overlay};
+use self::keybind_help::render_keybind_help_overlay;
+use self::menus::{
+ render_context_menu, render_global_launcher_menu, render_navigate_overlay,
+ render_resize_overlay,
+};
+use self::onboarding::render_onboarding_overlay;
+pub(crate) use self::onboarding::{
+ onboarding_notification_button_rects, onboarding_welcome_continue_rect,
+};
+use self::panes::{compute_pane_infos, render_panes};
+use self::release_notes::render_release_notes_overlay;
+pub(crate) use self::release_notes::{
+ release_notes_close_button_rect, release_notes_display_lines, release_notes_sections,
+ RELEASE_NOTES_MODAL_SIZE,
+};
+pub(crate) use self::scrollbar::{
+ pane_scrollbar_rect, release_notes_scrollbar_rect, scrollbar_offset_from_drag_row,
+ scrollbar_offset_from_row, scrollbar_thumb_grab_offset, should_show_scrollbar,
+};
+use self::settings::render_settings_overlay;
+use self::sidebar::{render_sidebar, render_sidebar_collapsed};
+use self::status::{render_config_diagnostic, render_toast_notification};
+use self::tabs::render_tab_bar;
+pub(crate) use self::{
+ dialogs::{confirm_close_button_rects, confirm_close_popup_rect, rename_button_rects},
+ settings::settings_button_rects,
+ sidebar::{
+ agent_panel_body_rect, agent_panel_entries, agent_panel_scroll_metrics,
+ agent_panel_scrollbar_rect, agent_panel_toggle_rect, collapsed_sidebar_sections,
+ collapsed_sidebar_toggle_rect, compute_workspace_card_areas, expanded_sidebar_sections,
+ sidebar_section_divider_rect, workspace_drop_indicator_row, workspace_list_rect,
+ workspace_list_scroll_metrics, workspace_list_scrollbar_rect,
+ },
+};
+pub(crate) use self::{
+ keybind_help::keybind_help_lines,
+ tabs::compute_tab_bar_view,
+ widgets::{centered_popup_rect, modal_stack_areas},
+};
use crate::app::{AppState, Mode};
-use crate::detect::AgentState;
-use crate::layout::PaneInfo;
const COLLAPSED_WIDTH: u16 = 4; // num + space + dot + separator
pub(crate) const MIN_SIDEBAR_WIDTH: u16 = 18;
@@ -20,211 +69,26 @@ const SPINNERS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "â 
/// Map spinner_tick (incremented every frame at ~60fps) to a spinner frame.
/// We want ~8 updates/sec so divide by 8.
-fn spinner_frame(tick: u32) -> &'static str {
+pub(super) fn spinner_frame(tick: u32) -> &'static str {
SPINNERS[(tick as usize / 8) % SPINNERS.len()]
}
-use crate::app::state::Palette;
-
-pub(crate) struct AgentPanelEntry {
- pub ws_idx: usize,
- pub tab_idx: usize,
- pub pane_id: crate::layout::PaneId,
- pub primary_label: String,
- pub primary_tab_label: Option<String>,
- pub agent_label: Option<String>,
- pub state: AgentState,
- pub seen: bool,
-}
-
-fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
- if total_h == 0 {
- return (0, 0);
- }
-
- if total_h < 6 {
- let ws_h = (total_h + 1) / 2;
- return (ws_h, total_h.saturating_sub(ws_h));
- }
-
- let ratio = split_ratio.clamp(0.1, 0.9);
- let ws_h = ((total_h as f32) * ratio).round() as u16;
- let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
- let detail_h = total_h.saturating_sub(ws_h);
- (ws_h, detail_h)
-}
-
-pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
- return (Rect::default(), Rect::default());
- }
-
- let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
- let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
- (ws_area, detail_area)
-}
-
-pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height < 6 {
- return Rect::default();
- }
-
- let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
- Rect::new(content.x, content.y + ws_h, content.width, 1)
-}
-
-fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
- if matches!(
- app.mode,
- Mode::Navigate
- | Mode::RenameWorkspace
- | Mode::Resize
- | Mode::ConfirmClose
- | Mode::ContextMenu
- | Mode::Settings
- | Mode::GlobalMenu
- | Mode::KeybindHelp
- ) {
- Some(app.selected)
- } else {
- app.active
- }
-}
-
-fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
- match scope {
- AgentPanelScope::CurrentWorkspace => "current",
- AgentPanelScope::AllWorkspaces => "all",
- }
-}
-
-pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
- if area.width == 0 || area.height < 2 {
- return Rect::default();
- }
-
- let label = agent_panel_toggle_label(scope);
- let width = label.chars().count() as u16;
- Rect::new(
- area.x + area.width.saturating_sub(width),
- area.y + 1,
- width,
- 1,
- )
-}
-
-pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
- match app.agent_panel_scope {
- AgentPanelScope::CurrentWorkspace => {
- let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
- return Vec::new();
- };
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return Vec::new();
- };
- ws.pane_details()
- .into_iter()
- .map(|detail| AgentPanelEntry {
- ws_idx,
- tab_idx: detail.tab_idx,
- pane_id: detail.pane_id,
- primary_label: detail.label,
- primary_tab_label: None,
- agent_label: None,
- state: detail.state,
- seen: detail.seen,
- })
- .collect()
- }
- AgentPanelScope::AllWorkspaces => app
- .workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, ws)| {
- let multi_tab = ws.tabs.len() > 1;
- let workspace_label = ws.display_name();
- ws.pane_details()
- .into_iter()
- .map(move |detail| AgentPanelEntry {
- ws_idx,
- tab_idx: detail.tab_idx,
- pane_id: detail.pane_id,
- primary_label: workspace_label.clone(),
- primary_tab_label: multi_tab.then_some(detail.tab_label),
- agent_label: Some(detail.agent_label),
- state: detail.state,
- seen: detail.seen,
- })
- })
- .collect(),
- }
-}
-
-fn truncate_text(text: &str, max_width: usize) -> String {
- let len = text.chars().count();
- if len <= max_width {
- return text.to_string();
- }
- if max_width == 0 {
- return String::new();
- }
- if max_width == 1 {
- return "…".to_string();
- }
- let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
- format!("{prefix}…")
+/// Compute view geometry and reconcile pane sizes.
+/// Called before render to separate mutation from drawing.
+pub fn compute_view(app: &mut AppState, area: Rect) {
+ compute_view_internal(app, area, true);
}
-fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
- let Some(tab_label) = entry.primary_tab_label.as_deref() else {
- return truncate_text(&entry.primary_label, max_width);
- };
-
- let separator = " · ";
- let separator_width = separator.chars().count();
- if max_width <= separator_width + 2 {
- return truncate_text(
- &format!("{}{}{}", entry.primary_label, separator, tab_label),
- max_width,
- );
- }
-
- let available = max_width.saturating_sub(separator_width);
- let min_tab = 4.min(available.saturating_sub(1)).max(1);
- let preferred_workspace = ((available * 2) / 3).max(1);
- let mut workspace_budget = preferred_workspace
- .min(available.saturating_sub(min_tab))
- .max(1);
- let mut tab_budget = available.saturating_sub(workspace_budget);
-
- let workspace_len = entry.primary_label.chars().count();
- let tab_len = tab_label.chars().count();
-
- if workspace_len < workspace_budget {
- let spare = workspace_budget - workspace_len;
- workspace_budget = workspace_len;
- tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
- }
- if tab_len < tab_budget {
- let spare = tab_budget - tab_len;
- tab_budget = tab_len;
- workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
- }
-
- format!(
- "{}{}{}",
- truncate_text(&entry.primary_label, workspace_budget),
- separator,
- truncate_text(tab_label, tab_budget)
- )
+/// Compute view geometry for a client-sized render without resizing pane runtimes.
+///
+/// This is used by the headless server when a non-foreground client needs its
+/// own frame size while the shared pane runtimes stay pinned to the foreground
+/// client.
+pub(crate) fn compute_view_without_resizing_panes(app: &mut AppState, area: Rect) {
+ compute_view_internal(app, area, false);
}
-/// Compute view geometry and reconcile pane sizes.
-/// Called before render to separate mutation from drawing.
-pub fn compute_view(app: &mut AppState, area: Rect) {
+fn compute_view_internal(app: &mut AppState, area: Rect, resize_panes: bool) {
let sidebar_w = if app.sidebar_collapsed {
COLLAPSED_WIDTH
} else {
@@ -281,7 +145,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
.map(|ws| ws.layout.splits(terminal_area))
.unwrap_or_default();
- let pane_infos = compute_pane_infos(app, terminal_area);
+ let pane_infos = compute_pane_infos(app, terminal_area, resize_panes);
app.view = crate::app::ViewState {
sidebar_rect: sidebar_area,
@@ -343,1454 +207,6 @@ pub fn render(app: &AppState, frame: &mut Frame) {
}
}
-const MIN_TAB_WIDTH: u16 = 8;
-const NEW_TAB_WIDTH: u16 = 3;
-const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
-const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
-const AGENT_PANEL_HEADER_ROWS: u16 = 3;
-
-#[derive(Default)]
-pub(crate) struct TabBarView {
- pub scroll: usize,
- pub tab_hit_areas: Vec<Rect>,
- pub scroll_left_hit_area: Rect,
- pub scroll_right_hit_area: Rect,
- pub new_tab_hit_area: Rect,
-}
-
-fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
- if ws.branch().is_some() {
- 2
- } else {
- 1
- }
-}
-
-pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
- let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
- ws_area
-}
-
-pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
- if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS {
- return Rect::default();
- }
-
- let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS);
- let footer_y = area.y + area.height.saturating_sub(1);
- let body_height = footer_y.saturating_sub(body_y);
- let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
- Rect::new(area.x, body_y, body_width, body_height)
-}
-
-fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize {
- let body = workspace_list_body_rect(area, false);
- if body.width == 0 || body.height == 0 {
- return 0;
- }
-
- let mut used_rows = 0u16;
- let mut visible = 0usize;
- for ws in app.workspaces.iter().skip(scroll) {
- let needed = workspace_row_height(ws).saturating_add(1);
- if used_rows.saturating_add(needed) > body.height {
- break;
- }
- used_rows = used_rows.saturating_add(needed);
- visible += 1;
- }
- visible
-}
-
-pub(crate) fn workspace_list_scroll_metrics(
- app: &AppState,
- area: Rect,
-) -> crate::pane::ScrollMetrics {
- let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
- let total_rows = app.workspaces.len();
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- let offset_from_bottom = total_rows
- .saturating_sub(app.workspace_scroll)
- .saturating_sub(viewport_rows);
-
- crate::pane::ScrollMetrics {
- offset_from_bottom,
- max_offset_from_bottom,
- viewport_rows,
- }
-}
-
-pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
- let metrics = workspace_list_scroll_metrics(app, area);
- let body = workspace_list_body_rect(area, true);
- (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
- area.x + area.width.saturating_sub(1),
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
- if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS {
- return Rect::default();
- }
-
- let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS);
- let body_height = (area.y + area.height).saturating_sub(body_y);
- let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
- Rect::new(area.x, body_y, body_width, body_height)
-}
-
-fn agent_panel_visible_count(area: Rect) -> usize {
- let body = agent_panel_body_rect(area, false);
- if body.width == 0 || body.height < 2 {
- return 0;
- }
-
- let mut used_rows = 0u16;
- let mut visible = 0usize;
- while used_rows.saturating_add(2) <= body.height {
- used_rows = used_rows.saturating_add(2);
- visible += 1;
- if used_rows < body.height {
- used_rows = used_rows.saturating_add(1);
- }
- }
- visible
-}
-
-pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
- let viewport_rows = agent_panel_visible_count(area);
- let total_rows = agent_panel_entries(app).len();
- let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
- let offset_from_bottom = total_rows
- .saturating_sub(app.agent_panel_scroll)
- .saturating_sub(viewport_rows);
-
- crate::pane::ScrollMetrics {
- offset_from_bottom,
- max_offset_from_bottom,
- viewport_rows,
- }
-}
-
-pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
- let metrics = agent_panel_scroll_metrics(app, area);
- let body = agent_panel_body_rect(area, true);
- (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
- area.x + area.width.saturating_sub(1),
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn compute_workspace_card_areas(
- app: &AppState,
- area: Rect,
-) -> Vec<crate::app::state::WorkspaceCardArea> {
- let ws_area = workspace_list_rect(area, app.sidebar_section_split);
- if ws_area == Rect::default() {
- return Vec::new();
- }
-
- let metrics = workspace_list_scroll_metrics(app, ws_area);
- let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
- if body.width == 0 || body.height == 0 {
- return Vec::new();
- }
-
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- let mut cards = Vec::new();
-
- for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
- let row_height = workspace_row_height(ws);
- if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
- break;
- }
- cards.push(crate::app::state::WorkspaceCardArea {
- ws_idx,
- rect: Rect::new(body.x, row_y, body.width, row_height),
- });
- row_y = row_y.saturating_add(row_height + 1);
- }
-
- cards
-}
-
-fn tab_width(tab: &crate::workspace::Tab) -> u16 {
- (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH)
-}
-
-fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
- let mut rects = vec![Rect::default(); ws.tabs.len()];
- if area.width == 0 || area.height == 0 {
- return rects;
- }
-
- let mut x = area.x;
- let right = area.x + area.width;
- for idx in scroll..ws.tabs.len() {
- if x >= right {
- break;
- }
- let desired = tab_width(&ws.tabs[idx]);
- let remaining = right.saturating_sub(x);
- let width = desired.min(remaining).max(1);
- rects[idx] = Rect::new(x, area.y, width, 1);
- x = x.saturating_add(width + 1);
- }
- rects
-}
-
-fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
- let mut best_scroll = ws.active_tab;
- let mut best_distance = u16::MAX;
- let viewport_center = area.x.saturating_mul(2).saturating_add(area.width);
-
- for scroll in 0..=ws.active_tab {
- let rects = layout_tab_hit_areas(ws, area, scroll);
- let Some(active_rect) = rects.get(ws.active_tab).copied() else {
- continue;
- };
- if active_rect.width == 0 {
- continue;
- }
-
- let active_center = active_rect
- .x
- .saturating_mul(2)
- .saturating_add(active_rect.width);
- let distance = active_center.abs_diff(viewport_center);
- if distance <= best_distance {
- best_distance = distance;
- best_scroll = scroll;
- }
- }
-
- best_scroll
-}
-
-fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 {
- tab_hit_areas
- .iter()
- .rev()
- .find(|rect| rect.width > 0)
- .map(|rect| rect.x + rect.width)
- .unwrap_or(fallback_x)
-}
-
-fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
- (0..ws.tabs.len())
- .find(|&scroll| {
- layout_tab_hit_areas(ws, area, scroll)
- .last()
- .is_some_and(|rect| rect.width > 0)
- })
- .unwrap_or(0)
-}
-
-pub(crate) fn compute_tab_bar_view(
- ws: &crate::workspace::Workspace,
- area: Rect,
- current_scroll: usize,
- follow_active: bool,
-) -> TabBarView {
- if area.width == 0 || area.height == 0 {
- return TabBarView::default();
- }
-
- let area_right = area.x + area.width;
- let all_tabs_area = Rect::new(
- area.x,
- area.y,
- area.width.saturating_sub(NEW_TAB_WIDTH),
- area.height,
- );
- let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0);
- let overflow = all_tabs.iter().any(|rect| rect.width == 0);
- if !overflow {
- let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x);
- let new_tab_hit_area = Rect::new(
- new_tab_x,
- area.y,
- area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
- 1,
- );
- return TabBarView {
- scroll: 0,
- tab_hit_areas: all_tabs,
- scroll_left_hit_area: Rect::default(),
- scroll_right_hit_area: Rect::default(),
- new_tab_hit_area,
- };
- }
-
- let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1);
- let tab_area_x = left_hit_area.x + left_hit_area.width;
- let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH);
- let tab_area_right = area_right.saturating_sub(reserved_trailing_width);
- let tab_area = Rect::new(
- tab_area_x,
- area.y,
- tab_area_right.saturating_sub(tab_area_x),
- area.height,
- );
-
- let max_scroll = max_tab_scroll(ws, tab_area);
- let scroll = if follow_active {
- centered_tab_scroll(ws, tab_area).min(max_scroll)
- } else {
- current_scroll.min(max_scroll)
- };
- let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll);
- let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right);
- let right_hit_area = Rect::new(
- trailing_x,
- area.y,
- area_right
- .saturating_sub(trailing_x)
- .min(TAB_SCROLL_BUTTON_WIDTH),
- 1,
- );
- let new_tab_x = right_hit_area.x + right_hit_area.width;
- let new_tab_hit_area = Rect::new(
- new_tab_x,
- area.y,
- area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
- 1,
- );
-
- TabBarView {
- scroll,
- tab_hit_areas,
- scroll_left_hit_area: left_hit_area,
- scroll_right_hit_area: right_hit_area,
- new_tab_hit_area,
- }
-}
-
-fn tab_drop_indicator_x(
- app: &AppState,
- ws: &crate::workspace::Workspace,
- insert_idx: usize,
-) -> Option<u16> {
- let visible_tabs = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .filter(|(_, rect)| rect.width > 0);
- let first_visible = visible_tabs.clone().next()?;
- let last_visible = visible_tabs.last().unwrap_or(first_visible);
-
- if insert_idx == 0 {
- return Some(if first_visible.0 == 0 {
- first_visible.1.x
- } else {
- app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
- });
- }
-
- if let Some((_, rect)) = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find(|(idx, rect)| *idx == insert_idx && rect.width > 0)
- {
- return Some(rect.x.saturating_sub(1));
- }
-
- if insert_idx >= ws.tabs.len() {
- return Some(if last_visible.0 + 1 >= ws.tabs.len() {
- last_visible.1.x + last_visible.1.width
- } else {
- app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
- });
- }
-
- None
-}
-
-fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
- if area.width == 0 || area.height == 0 {
- return;
- }
- let Some(active_ws_idx) = app.active else {
- return;
- };
- let Some(ws) = app.workspaces.get(active_ws_idx) else {
- return;
- };
-
- let p = &app.palette;
-
- frame.render_widget(
- Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
- area,
- );
-
- let first_visible_idx = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .find(|(_, rect)| rect.width > 0)
- .map(|(idx, _)| idx);
- let last_visible_idx = app
- .view
- .tab_hit_areas
- .iter()
- .enumerate()
- .rev()
- .find(|(_, rect)| rect.width > 0)
- .map(|(idx, _)| idx);
- let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0;
- let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
- && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
-
- if app.view.tab_scroll_left_hit_area.width > 0 {
- let style = if can_scroll_left {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- };
- frame.render_widget(
- Paragraph::new(" < ").style(style),
- app.view.tab_scroll_left_hit_area,
- );
- }
-
- if app.view.tab_scroll_right_hit_area.width > 0 {
- let style = if can_scroll_right {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- };
- frame.render_widget(
- Paragraph::new(" > ").style(style),
- app.view.tab_scroll_right_hit_area,
- );
- }
-
- for (idx, tab) in ws.tabs.iter().enumerate() {
- let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
- break;
- };
- if rect.width == 0 {
- continue;
- }
- let active = idx == ws.active_tab;
- let style = if active {
- let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent);
- if tab.is_auto_named() {
- base.add_modifier(Modifier::DIM)
- } else {
- base.add_modifier(Modifier::BOLD)
- }
- } else if tab.is_auto_named() {
- Style::default()
- .fg(p.overlay0)
- .bg(p.surface0)
- .add_modifier(Modifier::DIM)
- } else {
- Style::default().fg(p.overlay1).bg(p.surface0)
- };
- let width = rect.width as usize;
- let name = tab.display_name();
- let text = format!(" {:width$}", name, width = width.saturating_sub(1));
- frame.render_widget(Paragraph::new(text).style(style), rect);
- }
-
- if let Some(crate::app::state::DragState {
- target:
- crate::app::state::DragTarget::TabReorder {
- ws_idx,
- insert_idx: Some(insert_idx),
- ..
- },
- }) = &app.drag
- {
- if *ws_idx == active_ws_idx {
- if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) {
- frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)]
- .set_symbol("│")
- .set_style(Style::default().fg(p.accent));
- }
- }
- }
-
- if app.view.new_tab_hit_area.width > 0 {
- frame.render_widget(
- Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
- app.view.new_tab_hit_area,
- );
- }
-
- if first_visible_idx.is_some_and(|idx| idx > 0) {
- let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width;
- if x < area.x + area.width {
- frame.buffer_mut()[(x, area.y)]
- .set_symbol("…")
- .set_style(Style::default().fg(p.overlay0));
- }
- }
- if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
- let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1);
- if x >= area.x && x < area.x + area.width {
- frame.buffer_mut()[(x, area.y)]
- .set_symbol("…")
- .set_style(Style::default().fg(p.overlay0));
- }
- }
-}
-
-/// Compute pane layout info and resize pane runtimes to match.
-fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
- let Some(ws_idx) = app.active else {
- return Vec::new();
- };
- let Some(ws) = app.workspaces.get(ws_idx) else {
- return Vec::new();
- };
-
- let multi_pane = ws.layout.pane_count() > 1;
- let terminal_active = app.mode == Mode::Terminal;
-
- if ws.zoomed {
- let focused_id = ws.layout.focused();
- let mut inner_rect = area;
- let mut scrollbar_rect = None;
- if let Some(rt) = ws.runtimes.get(&focused_id) {
- if rt
- .scroll_metrics()
- .is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 1)
- {
- inner_rect.width = inner_rect.width.saturating_sub(1);
- scrollbar_rect = Some(Rect::new(
- area.x + area.width.saturating_sub(1),
- area.y,
- 1,
- area.height,
- ));
- }
- rt.resize(inner_rect.height, inner_rect.width);
- }
- return vec![PaneInfo {
- id: focused_id,
- rect: area,
- inner_rect,
- scrollbar_rect,
- is_focused: true,
- }];
- }
-
- let mut pane_infos = ws.layout.panes(area);
-
- for info in &mut pane_infos {
- let pane_inner = if multi_pane {
- let border_set = if info.is_focused && terminal_active {
- ratatui::symbols::border::THICK
- } else {
- ratatui::symbols::border::PLAIN
- };
- let block = Block::default()
- .borders(Borders::ALL)
- .border_set(border_set);
- block.inner(info.rect)
- } else {
- area
- };
-
- let mut inner_rect = pane_inner;
- let mut scrollbar_rect = None;
- if let Some(rt) = ws.runtimes.get(&info.id) {
- if rt
- .scroll_metrics()
- .is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 1)
- {
- inner_rect.width = inner_rect.width.saturating_sub(1);
- scrollbar_rect = Some(Rect::new(
- pane_inner.x + pane_inner.width.saturating_sub(1),
- pane_inner.y,
- 1,
- pane_inner.height,
- ));
- }
- rt.resize(inner_rect.height, inner_rect.width);
- }
-
- info.inner_rect = inner_rect;
- info.scrollbar_rect = scrollbar_rect;
- }
-
- pane_infos
-}
-
-/// Auto-scale sidebar width based on workspace identity + agent summary.
-pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
- let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
- if content.width == 0 || content.height == 0 {
- return (Rect::default(), None, Rect::default());
- }
-
- if content.height < 7 {
- return (content, None, Rect::default());
- }
-
- let total_h = content.height as usize;
- let ws_h = (total_h + 1) / 2;
- let detail_h = total_h.saturating_sub(ws_h + 1);
- if ws_h == 0 || detail_h == 0 {
- return (content, None, Rect::default());
- }
-
- let divider_y = content.y + ws_h as u16;
- let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
- let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16);
- (ws_area, Some(divider_y), detail_area)
-}
-
-/// Collapsed sidebar: workspace glance on top, compact agent list below.
-fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
- let is_navigating = matches!(app.mode, Mode::Navigate);
-
- let p = &app.palette;
- let sep_style = if is_navigating {
- Style::default().fg(p.accent)
- } else {
- Style::default().fg(p.surface_dim)
- };
- let sep_x = area.x + area.width.saturating_sub(1);
- let buf = frame.buffer_mut();
- for y in area.y..area.y + area.height {
- buf[(sep_x, y)].set_symbol("│");
- buf[(sep_x, y)].set_style(sep_style);
- }
-
- let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
- if ws_area == Rect::default() {
- render_sidebar_toggle(app, frame, area, true, p);
- return;
- }
-
- for (visible_idx, ws) in app.workspaces.iter().enumerate() {
- let y = ws_area.y + visible_idx as u16;
- if y >= ws_area.y + ws_area.height {
- break;
- }
- let (agg_state, agg_seen) = ws.aggregate_state();
- let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
- let is_selected = visible_idx == app.selected && is_navigating;
- let is_active = Some(visible_idx) == app.active;
- let row_style = if is_selected {
- Style::default().bg(p.surface0)
- } else if is_active {
- Style::default().bg(p.surface_dim)
- } else {
- Style::default()
- };
- let num_style = if is_selected {
- Style::default().fg(p.overlay1).bg(p.surface0)
- } else if is_active {
- Style::default().fg(p.text).bg(p.surface_dim)
- } else {
- Style::default().fg(p.overlay0)
- };
-
- if is_selected || is_active {
- let buf = frame.buffer_mut();
- for x in ws_area.x..ws_area.x + ws_area.width {
- buf[(x, y)].set_style(row_style);
- }
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(format!("{}", visible_idx + 1), num_style),
- Span::styled(" ", row_style),
- Span::styled(icon, icon_style),
- ])),
- Rect::new(ws_area.x, y, ws_area.width, 1),
- );
- }
-
- if let Some(divider_y) = divider_y {
- let buf = frame.buffer_mut();
- for x in ws_area.x..ws_area.x + ws_area.width {
- buf[(x, divider_y)].set_symbol("─");
- buf[(x, divider_y)].set_style(Style::default().fg(p.surface_dim));
- }
- }
-
- let detail_ws_idx = if is_navigating {
- Some(app.selected)
- } else {
- app.active
- };
- 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() {
- if let Some(ws_idx) = detail_ws_idx {
- if let Some(ws) = app.workspaces.get(ws_idx) {
- for (detail_idx, detail) in ws.pane_details().iter().enumerate() {
- let y = detail_content_area.y + detail_idx as u16;
- if y >= detail_content_area.y + detail_content_area.height {
- break;
- }
- let pane_num = ws
- .public_pane_number(detail.pane_id)
- .unwrap_or(detail_idx + 1);
- let pane_style = Style::default().fg(p.overlay0);
- let (icon, icon_style) =
- agent_icon(detail.state, detail.seen, app.spinner_tick, p);
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(format!("{pane_num}"), pane_style),
- Span::styled(" ", pane_style),
- Span::styled(icon, icon_style),
- ])),
- Rect::new(detail_content_area.x, y, detail_content_area.width, 1),
- );
- }
- }
- }
- }
-
- render_sidebar_toggle(app, frame, area, true, p);
-}
-
-pub(crate) fn workspace_drop_indicator_row(
- cards: &[crate::app::state::WorkspaceCardArea],
- area: Rect,
- insert_idx: usize,
-) -> Option<u16> {
- if area.height == 0 {
- return None;
- }
- let list_bottom = area.y + area.height.saturating_sub(1);
-
- let first = cards.first()?;
- if insert_idx == first.ws_idx {
- return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
- }
-
- if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
- return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
- }
-
- cards
- .last()
- .filter(|card| insert_idx == card.ws_idx.saturating_add(1))
- .map(|card| card.rect.y.saturating_add(card.rect.height))
- .filter(|y| *y < list_bottom)
-}
-
-fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
- let is_navigating = matches!(app.mode, Mode::Navigate);
- let sep_style = if is_navigating {
- Style::default().fg(p.accent)
- } else {
- Style::default().fg(p.surface_dim)
- };
-
- // Right border
- let sep_x = area.x + area.width.saturating_sub(1);
- let buf = frame.buffer_mut();
- for y in area.y..area.y + area.height {
- buf[(sep_x, y)].set_symbol("│");
- buf[(sep_x, y)].set_style(sep_style);
- }
-
- let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
-
- // --- Top section: Workspaces ---
- render_workspace_list(app, frame, ws_area, is_navigating);
-
- // --- Bottom section: Agent detail ---
- render_agent_detail(app, frame, detail_area);
-
- render_sidebar_toggle(app, frame, area, false, p);
-}
-
-/// Render the workspace list in the top section of the sidebar.
-fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) {
- let p = &app.palette;
- let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) {
- Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => {
- Some(*source_ws_idx)
- }
- _ => None,
- };
- let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) {
- Some(crate::app::state::DragTarget::WorkspaceReorder {
- insert_idx: Some(insert_idx),
- ..
- }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx),
- _ => None,
- };
-
- // Section header + reserve last row for "new" button
- let list_bottom = area.y + area.height.saturating_sub(1);
- if area.height > 0 {
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " spaces",
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- )])),
- Rect::new(area.x, area.y, area.width, 1),
- );
- }
-
- let metrics = workspace_list_scroll_metrics(app, area);
- let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
- let cards = &app.view.workspace_card_areas;
-
- for card in cards {
- let i = card.ws_idx;
- let ws = &app.workspaces[i];
- let row_y = card.rect.y;
- let row_height = card.rect.height;
- let selected = i == app.selected && is_navigating;
- let is_active = Some(i) == app.active;
- let is_dragged = dragged_ws_idx == Some(i);
- let highlighted = selected || is_active || is_dragged;
- let (agg_state, agg_seen) = ws.aggregate_state();
-
- if highlighted {
- let bg = if selected {
- p.surface0
- } else if is_dragged {
- p.surface1
- } else {
- p.surface_dim
- };
- let buf = frame.buffer_mut();
- for y in row_y..row_y + row_height {
- if y >= list_bottom {
- break;
- }
- for x in card.rect.x..card.rect.x + card.rect.width {
- buf[(x, y)].set_style(Style::default().bg(bg));
- }
- }
- }
-
- if is_active {
- let buf = frame.buffer_mut();
- for y in row_y..row_y + row_height {
- if y >= list_bottom {
- break;
- }
- buf[(card.rect.x, y)].set_symbol("▌");
- buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
- }
- }
-
- let name_style = if selected || is_active || is_dragged {
- Style::default().fg(p.text).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.subtext0)
- };
- let num_style = if selected || is_active {
- Style::default().fg(p.overlay1)
- } else {
- Style::default().fg(p.overlay0)
- };
-
- let mut line1 = vec![
- Span::styled(" ", Style::default()),
- Span::styled(format!("{} ", i + 1), num_style),
- Span::styled(ws.display_name(), name_style),
- Span::styled(" ", Style::default()),
- ];
- let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
- line1.push(Span::styled(icon, icon_style));
-
- frame.render_widget(
- Paragraph::new(Line::from(line1)),
- Rect::new(card.rect.x, row_y, card.rect.width, 1),
- );
-
- if row_height > 1 && row_y + 1 < list_bottom {
- if let Some(branch) = ws.branch() {
- let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
- let mut parts = Vec::new();
- if ahead > 0 {
- parts.push((format!("↑{}", ahead), p.green));
- }
- if behind > 0 {
- parts.push((format!("↓{}", behind), p.red));
- }
- (!parts.is_empty()).then_some(parts)
- });
- let reserved = upstream_label
- .as_ref()
- .map(|parts| {
- parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
- })
- .unwrap_or(0);
- let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved);
- let branch_display = if branch.len() > max_branch_len {
- format!("{}…", &branch[..max_branch_len.saturating_sub(1)])
- } else {
- branch
- };
- let branch_color = if selected || is_active {
- p.mauve
- } else {
- p.overlay0
- };
- let mut spans = vec![
- Span::styled(" ", Style::default()),
- Span::styled(branch_display, Style::default().fg(branch_color)),
- ];
- if let Some(parts) = upstream_label {
- spans.push(Span::styled(" ", Style::default()));
- for (idx, (label, color)) in parts.into_iter().enumerate() {
- if idx > 0 {
- spans.push(Span::styled(" ", Style::default()));
- }
- spans.push(Span::styled(label, Style::default().fg(color)));
- }
- }
- frame.render_widget(
- Paragraph::new(Line::from(spans)),
- Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
- );
- }
- }
- }
-
- if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
- let indicator_right = scrollbar_rect
- .map(|rect| rect.x)
- .unwrap_or(area.x + area.width);
- let buf = frame.buffer_mut();
- for x in area.x..indicator_right {
- buf[(x, y)].set_symbol("─");
- buf[(x, y)].set_style(Style::default().fg(p.accent));
- }
- }
-
- if let Some(track) = scrollbar_rect {
- render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
- }
-
- // Footer actions for workspace section
- if list_bottom > area.y {
- let new_rect = app.sidebar_new_button_rect();
- frame.render_widget(
- Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))),
- new_rect,
- );
-
- let menu_rect = app.global_launcher_rect();
- let menu_line = if app.update_available.is_some() {
- Line::from(vec![
- Span::styled("menu", Style::default().fg(p.overlay0)),
- Span::styled(
- " ●",
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- ])
- } else {
- Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))])
- };
- frame.render_widget(
- Paragraph::new(menu_line).alignment(Alignment::Right),
- menu_rect,
- );
- }
-}
-
-/// Render the agent detail panel in the bottom section of the sidebar.
-fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
-
- if area.height < 3 {
- return;
- }
-
- let sep_line = "─".repeat(area.width as usize);
- frame.render_widget(
- Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))),
- Rect::new(area.x, area.y, area.width, 1),
- );
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " agents",
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- )])),
- Rect::new(area.x, area.y + 1, area.width, 1),
- );
- let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
- if toggle_rect != Rect::default() {
- frame.render_widget(
- Paragraph::new(Span::styled(
- agent_panel_toggle_label(app.agent_panel_scope),
- Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
- ))
- .alignment(Alignment::Right),
- toggle_rect,
- );
- }
-
- let details = agent_panel_entries(app);
- let metrics = agent_panel_scroll_metrics(app, area);
- let scrollbar_rect = agent_panel_scrollbar_rect(app, area);
- let body = agent_panel_body_rect(area, should_show_scrollbar(metrics));
- if body == Rect::default() {
- return;
- }
-
- let mut row_y = body.y;
- let body_bottom = body.y + body.height;
- for detail in details.iter().skip(app.agent_panel_scroll) {
- if row_y.saturating_add(1) >= body_bottom {
- break;
- }
-
- let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
- let label_color = state_label_color(detail.state, detail.seen, p);
- let label = state_label(detail.state, detail.seen);
-
- let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD);
- let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM);
- let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
-
- let primary_label =
- format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
- let name_line = Line::from(vec![
- Span::styled(" ", Style::default()),
- Span::styled(icon, icon_style),
- Span::styled(" ", Style::default()),
- Span::styled(primary_label, name_style),
- ]);
- frame.render_widget(
- Paragraph::new(name_line),
- Rect::new(body.x, row_y, body.width, 1),
- );
- row_y += 1;
-
- let mut status_spans = vec![
- Span::styled(" ", Style::default()),
- Span::styled(label, status_style),
- ];
- if let Some(agent_label) = &detail.agent_label {
- status_spans.push(Span::styled(" · ", agent_style));
- status_spans.push(Span::styled(agent_label, agent_style));
- }
- frame.render_widget(
- Paragraph::new(Line::from(status_spans)),
- Rect::new(body.x, row_y, body.width, 1),
- );
- row_y += 1;
-
- if row_y < body_bottom {
- row_y += 1;
- }
- }
-
- if let Some(track) = scrollbar_rect {
- render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
- }
-}
-
-pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect {
- let bottom_y = area.y + area.height.saturating_sub(1);
- let content_w = area.width.saturating_sub(1);
- if content_w == 0 || area.height == 0 {
- return Rect::default();
- }
- let x = area.x + content_w / 2;
- Rect::new(x, bottom_y, 1, 1)
-}
-
-fn render_sidebar_toggle(
- app: &AppState,
- frame: &mut Frame,
- area: Rect,
- collapsed: bool,
- p: &Palette,
-) {
- // Toggle button not needed when sidebar has content — skip for now
- // to avoid conflicting with the agent detail panel at the bottom.
- if !collapsed {
- return;
- }
- let toggle_area = collapsed_sidebar_toggle_rect(area);
- if toggle_area == Rect::default() {
- return;
- }
- let icon_style = if app.update_available.is_some() {
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(p.overlay0)
- };
- frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), toggle_area);
-}
-
-fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(ws_idx) = app.active else {
- render_empty(app, frame, area);
- return;
- };
- let Some(ws) = app.workspaces.get(ws_idx) else {
- render_empty(app, frame, area);
- return;
- };
-
- let multi_pane = ws.layout.pane_count() > 1;
- let terminal_active = app.mode == Mode::Terminal;
-
- for info in &app.view.pane_infos {
- if let Some(rt) = ws.runtimes.get(&info.id) {
- // Draw borders for multi-pane layouts
- if multi_pane {
- let (border_style, border_set) = if info.is_focused && terminal_active {
- (
- Style::default().fg(app.palette.accent),
- ratatui::symbols::border::THICK,
- )
- } else if info.is_focused {
- (
- Style::default().fg(app.palette.accent),
- ratatui::symbols::border::PLAIN,
- )
- } else {
- (
- Style::default().fg(app.palette.overlay0),
- ratatui::symbols::border::PLAIN,
- )
- };
-
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(border_style)
- .border_set(border_set);
- frame.render_widget(block, info.rect);
- }
-
- // Draw terminal content. Only the focused pane should own the cursor.
- rt.render(frame, info.inner_rect, info.is_focused && terminal_active);
- render_pane_scrollbar(app, frame, info, rt);
-
- // Dim unfocused panes only in navigate mode
- let should_dim = !info.is_focused && multi_pane && !terminal_active;
- if should_dim {
- let inner = info.inner_rect;
- let buf = frame.buffer_mut();
- for y in inner.y..inner.y + inner.height {
- for x in inner.x..inner.x + inner.width {
- let cell = &mut buf[(x, y)];
- let style = cell.style();
- let fg = style.fg.unwrap_or(Color::White);
- let dimmed_fg = dim_color(fg);
- cell.set_style(style.fg(dimmed_fg));
- }
- }
- }
-
- // Selection highlight
- render_selection_highlight(
- &app.selection,
- frame,
- info.id,
- info.inner_rect,
- rt.scroll_metrics(),
- &app.palette,
- );
- }
- }
-}
-
-/// Render selection highlight for a pane by inverting fg/bg colors.
-/// Reduce a color's brightness by blending it toward black.
-fn dim_color(color: Color) -> Color {
- match color {
- Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
- Color::White => Color::DarkGray,
- Color::Gray => Color::DarkGray,
- Color::DarkGray => Color::Rgb(30, 30, 30),
- Color::Red => Color::Rgb(60, 0, 0),
- Color::Green => Color::Rgb(0, 60, 0),
- Color::Yellow => Color::Rgb(60, 60, 0),
- Color::Blue => Color::Rgb(0, 0, 60),
- Color::Magenta => Color::Rgb(60, 0, 60),
- Color::Cyan => Color::Rgb(0, 60, 60),
- Color::LightRed => Color::Rgb(80, 30, 30),
- Color::LightGreen => Color::Rgb(30, 80, 30),
- Color::LightYellow => Color::Rgb(80, 80, 30),
- Color::LightBlue => Color::Rgb(30, 30, 80),
- Color::LightMagenta => Color::Rgb(80, 30, 80),
- Color::LightCyan => Color::Rgb(30, 80, 80),
- // Indexed colors and others: just use DIM modifier as fallback
- _ => Color::DarkGray,
- }
-}
-
-pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
- info.scrollbar_rect
-}
-
-pub(crate) fn release_notes_scrollbar_rect(
- body: Rect,
- metrics: crate::pane::ScrollMetrics,
-) -> Option<Rect> {
- (should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new(
- body.x + body.width - 1,
- body.y,
- 1,
- body.height,
- ))
-}
-
-pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
- metrics.max_offset_from_bottom > 0
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) struct ScrollbarThumb {
- pub top: u16,
- pub len: u16,
-}
-
-pub(crate) fn scrollbar_thumb(
- metrics: crate::pane::ScrollMetrics,
- track: Rect,
-) -> Option<ScrollbarThumb> {
- if metrics.max_offset_from_bottom == 0 || track.height == 0 {
- return None;
- }
-
- let track_height = track.height as usize;
- let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows;
- if total_rows == 0 {
- return None;
- }
-
- let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32)
- .round()
- .max(1.0)
- .min(track_height as f32) as usize;
- let max_thumb_top = track_height.saturating_sub(thumb_len);
- let scrolled_from_top = metrics
- .max_offset_from_bottom
- .saturating_sub(metrics.offset_from_bottom);
- let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 {
- 0
- } else {
- ((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32)
- .round()
- .clamp(0.0, max_thumb_top as f32) as usize
- };
-
- Some(ScrollbarThumb {
- top: track.y + thumb_top as u16,
- len: thumb_len as u16,
- })
-}
-
-pub(crate) fn scrollbar_thumb_grab_offset(
- metrics: crate::pane::ScrollMetrics,
- track: Rect,
- row: u16,
-) -> Option<u16> {
- let thumb = scrollbar_thumb(metrics, track)?;
- (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top)
-}
-
-fn scrollbar_offset_from_thumb_top(
- metrics: crate::pane::ScrollMetrics,
- track: Rect,
- thumb_top: usize,
-) -> usize {
- if metrics.max_offset_from_bottom == 0 {
- return 0;
- }
-
- let thumb_len = scrollbar_thumb(metrics, track)
- .map(|thumb| thumb.len as usize)
- .unwrap_or(1);
- let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize);
- if max_thumb_top == 0 {
- return 0;
- }
-
- let desired_top = thumb_top.min(max_thumb_top);
- let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32
- / max_thumb_top as f32)
- .round() as usize;
- metrics
- .max_offset_from_bottom
- .saturating_sub(scrolled_from_top)
-}
-
-pub(crate) fn scrollbar_offset_from_row(
- metrics: crate::pane::ScrollMetrics,
- track: Rect,
- row: u16,
-) -> usize {
- let thumb = match scrollbar_thumb(metrics, track) {
- Some(thumb) => thumb,
- None => return 0,
- };
- let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
- let row_offset = clamped_row.saturating_sub(track.y) as usize;
- let thumb_center = (thumb.len as usize) / 2;
- let desired_top = row_offset.saturating_sub(thumb_center);
- scrollbar_offset_from_thumb_top(metrics, track, desired_top)
-}
-
-pub(crate) fn scrollbar_offset_from_drag_row(
- metrics: crate::pane::ScrollMetrics,
- track: Rect,
- row: u16,
- grab_row_offset: u16,
-) -> usize {
- let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
- let row_offset = clamped_row.saturating_sub(track.y) as usize;
- let desired_top = row_offset.saturating_sub(grab_row_offset as usize);
- scrollbar_offset_from_thumb_top(metrics, track, desired_top)
-}
-
-fn render_scrollbar(
- frame: &mut Frame,
- metrics: crate::pane::ScrollMetrics,
- track: Rect,
- track_color: Color,
- thumb_color: Color,
- thumb_symbol: &str,
-) {
- if metrics.max_offset_from_bottom == 0 {
- return;
- }
-
- let Some(thumb) = scrollbar_thumb(metrics, track) else {
- return;
- };
-
- let buf = frame.buffer_mut();
- for y in track.y..track.y + track.height {
- let cell = &mut buf[(track.x, y)];
- cell.set_symbol("▕");
- cell.set_style(Style::default().fg(track_color));
- }
- for y in thumb.top..thumb.top + thumb.len {
- let cell = &mut buf[(track.x, y)];
- cell.set_symbol(thumb_symbol);
- cell.set_style(Style::default().fg(thumb_color));
- }
-}
-
-fn render_pane_scrollbar(
- app: &AppState,
- frame: &mut Frame,
- info: &PaneInfo,
- rt: &crate::pane::PaneRuntime,
-) {
- let Some(metrics) = rt.scroll_metrics() else {
- return;
- };
- let Some(track) = pane_scrollbar_rect(info) else {
- return;
- };
-
- let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
- (app.palette.overlay0, app.palette.overlay1, "▐")
- } else {
- (app.palette.surface_dim, app.palette.overlay0, "▕")
- };
-
- render_scrollbar(
- frame,
- metrics,
- track,
- track_color,
- thumb_color,
- thumb_symbol,
- );
-}
-
-fn render_selection_highlight(
- selection: &Option<crate::selection::Selection>,
- frame: &mut Frame,
- pane_id: crate::layout::PaneId,
- inner: Rect,
- scroll_metrics: Option<crate::pane::ScrollMetrics>,
- p: &Palette,
-) {
- if let Some(sel) = selection {
- if sel.is_visible() && sel.pane_id == pane_id {
- let buf = frame.buffer_mut();
- for y in 0..inner.height {
- for x in 0..inner.width {
- if sel.contains(y, x, scroll_metrics) {
- let cell = &mut buf[(inner.x + x, inner.y + y)];
- cell.set_style(Style::default().fg(panel_contrast_fg(p)).bg(p.blue));
- }
- }
- }
- }
- }
-}
-
-fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
- let p = &app.palette;
- let lines = vec![
- Line::from(""),
- Line::from(""),
- Line::from(Span::styled(
- " No workspaces yet",
- Style::default().fg(p.overlay0),
- )),
- Line::from(""),
- Line::from(Span::styled(
- " A workspace is one project context.",
- Style::default().fg(p.overlay1),
- )),
- Line::from(Span::styled(
- " Its root pane (top-left) sets the default repo or folder name.",
- Style::default().fg(p.overlay1),
- )),
- Line::from(""),
- Line::from(vec![
- Span::styled(" Press ", Style::default().fg(p.overlay0)),
- Span::styled(
- format!("{}", app.keybinds.new_workspace_label),
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- Span::styled(" to create one", Style::default().fg(p.overlay0)),
- ]),
- ];
- frame.render_widget(
- Paragraph::new(lines).block(
- Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(p.surface_dim)),
- ),
- area,
- );
-}
-
-const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
-
fn dim_background(frame: &mut Frame, area: Rect) {
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
@@ -1801,1730 +217,7 @@ fn dim_background(frame: &mut Frame, area: Rect) {
}
}
-fn render_panel_shell(
- frame: &mut Frame,
- area: Rect,
- border_color: Color,
- bg: Color,
-) -> Option<Rect> {
- if area.width < 2 || area.height < 2 {
- return None;
- }
-
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(border_color))
- .border_set(ratatui::symbols::border::PLAIN)
- .style(Style::default().bg(bg));
- let inner = block.inner(area);
- frame.render_widget(Clear, area);
- frame.render_widget(block, area);
- Some(inner)
-}
-
-fn panel_contrast_fg(p: &Palette) -> Color {
- match p.panel_bg {
- Color::Reset => p.surface_dim,
- color => color,
- }
-}
-
-pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
-
-pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> {
- 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;
- Some(Rect::new(popup_x, popup_y, popup_w, popup_h))
-}
-
-fn render_modal_shell(
- frame: &mut Frame,
- area: Rect,
- popup_w: u16,
- popup_h: u16,
- p: &Palette,
-) -> Option<Rect> {
- let popup = centered_popup_rect(area, popup_w, popup_h)?;
- render_panel_shell(frame, popup, p.accent, p.panel_bg)
-}
-
-fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
- let line = Line::from(vec![
- Span::styled(
- title,
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- ),
- Span::raw(" "),
- Span::styled("⬆", Style::default().fg(p.accent)),
- ]);
- frame.render_widget(Paragraph::new(line), area);
-}
-
-#[derive(Debug, Clone, Copy)]
-pub(crate) struct ModalStackAreas {
- pub header: Rect,
- pub content: Rect,
- pub footer: Option<Rect>,
- pub actions: Option<Rect>,
-}
-
-pub(crate) fn modal_stack_areas(
- inner: Rect,
- header_height: u16,
- footer_height: u16,
- actions_height: u16,
- gap: u16,
-) -> ModalStackAreas {
- #[derive(Clone, Copy)]
- enum Slot {
- Header,
- Content,
- Footer,
- Actions,
- }
-
- let mut constraints = Vec::new();
- let mut slots = Vec::new();
- let mut push = |slot: Slot, constraint: Constraint| {
- if !slots.is_empty() {
- constraints.push(Constraint::Length(gap));
- }
- constraints.push(constraint);
- slots.push(slot);
- };
-
- push(Slot::Header, Constraint::Length(header_height));
- push(Slot::Content, Constraint::Min(0));
- if footer_height > 0 {
- push(Slot::Footer, Constraint::Length(footer_height));
- }
- if actions_height > 0 {
- push(Slot::Actions, Constraint::Length(actions_height));
- }
-
- let areas = Layout::vertical(constraints).split(inner);
- let mut header = Rect::default();
- let mut content = Rect::default();
- let mut footer = None;
- let mut actions = None;
-
- for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) {
- match slot {
- Slot::Header => header = area,
- Slot::Content => content = area,
- Slot::Footer => footer = Some(area),
- Slot::Actions => actions = Some(area),
- }
- }
-
- ModalStackAreas {
- header,
- content,
- footer,
- actions,
- }
-}
-
-fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- dim_background(frame, area);
-
- match app.onboarding_step {
- 0 => render_onboarding_welcome(app, frame, area),
- _ => render_onboarding_notifications(app, frame, area),
- }
-}
-
-fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(notes) = &app.release_notes else {
- return;
- };
-
- dim_background(frame, area);
-
- let Some(inner) = render_modal_shell(
- frame,
- area,
- RELEASE_NOTES_MODAL_SIZE.0,
- RELEASE_NOTES_MODAL_SIZE.1,
- &app.palette,
- ) else {
- return;
- };
- if inner.height < 8 || inner.width < 20 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- let header_title_area = Rect::new(
- header_rows[0].x + 1,
- header_rows[0].y,
- header_rows[0].width.saturating_sub(2),
- header_rows[0].height,
- );
- let header_subtitle_area = Rect::new(
- header_rows[1].x + 1,
- header_rows[1].y,
- header_rows[1].width.saturating_sub(2),
- header_rows[1].height,
- );
-
- render_modal_header(
- frame,
- header_title_area,
- &format!("v{}", notes.version),
- &app.palette,
- );
- let subtitle = if notes.preview {
- "preview before updating"
- } else {
- "what's new in this release"
- };
- frame.render_widget(
- Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)),
- header_subtitle_area,
- );
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- "close",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-
- let sections = release_notes_sections(stack.content, notes.preview);
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
- max_offset_from_bottom: app.release_notes_max_scroll() as usize,
- viewport_rows: sections.notes_body.height.max(1) as usize,
- };
- let track = release_notes_scrollbar_rect(sections.notes_body, metrics);
- let notes_text_area = track
- .map(|_| {
- Rect::new(
- sections.notes_body.x,
- sections.notes_body.y,
- sections.notes_body.width.saturating_sub(1),
- sections.notes_body.height,
- )
- })
- .unwrap_or(sections.notes_body);
-
- if let Some(instructions_area) = sections.instructions {
- render_release_notes_preview_panel(frame, instructions_area, &notes.version, &app.palette);
- }
-
- let body = Paragraph::new(
- release_notes_lines(notes.body.as_str(), &app.palette)
- .into_iter()
- .map(|(_, line)| line)
- .collect::<Vec<_>>(),
- )
- .wrap(Wrap { trim: false })
- .scroll((notes.scroll, 0));
- frame.render_widget(body, notes_text_area);
- if let Some(track) = track {
- render_scrollbar(
- frame,
- metrics,
- track,
- app.palette.overlay0,
- app.palette.overlay1,
- "▐",
- );
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
- Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("close", Style::default().fg(app.palette.overlay0)),
- Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
- ])),
- stack.footer.unwrap_or_default(),
- );
-}
-
-fn release_notes_inline_spans<'a>(
- text: &str,
- base_style: Style,
- code_style: Style,
-) -> (usize, Vec<Span<'a>>) {
- let mut spans = Vec::new();
- let mut width = 0;
- let mut remaining = text;
-
- while let Some(start) = remaining.find('`') {
- let (before, after_start) = remaining.split_at(start);
- if !before.is_empty() {
- width += before.chars().count();
- spans.push(Span::styled(before.to_string(), base_style));
- }
-
- let after_start = &after_start[1..];
- let Some(end) = after_start.find('`') else {
- let literal = format!("`{after_start}");
- width += literal.chars().count();
- spans.push(Span::styled(literal, base_style));
- remaining = "";
- break;
- };
-
- let (code, after_end) = after_start.split_at(end);
- width += code.chars().count();
- if !code.is_empty() {
- spans.push(Span::styled(code.to_string(), code_style));
- }
- remaining = &after_end[1..];
- }
-
- if !remaining.is_empty() {
- width += remaining.chars().count();
- spans.push(Span::styled(remaining.to_string(), base_style));
- }
-
- if spans.is_empty() {
- spans.push(Span::styled(String::new(), base_style));
- }
-
- (width, spans)
-}
-
-pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> {
- let mut lines = Vec::new();
- let mut in_fenced_code_block = false;
- let text_style = Style::default().fg(p.text);
- let inline_code_style = Style::default()
- .fg(p.accent)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD);
-
- for raw in body.lines() {
- let trimmed = raw.trim_end();
- if trimmed.trim_start().starts_with("```") {
- in_fenced_code_block = !in_fenced_code_block;
- continue;
- }
-
- if in_fenced_code_block {
- let code_bg = p.surface1;
- let gutter_style = Style::default().fg(p.accent).bg(code_bg);
- let code_style = Style::default().fg(p.text).bg(code_bg);
- let width = 2 + trimmed.chars().count();
- let mut spans = vec![
- Span::styled("▏", gutter_style),
- Span::styled(" ", code_style),
- ];
- if !trimmed.is_empty() {
- spans.push(Span::styled(trimmed.to_string(), code_style));
- }
- lines.push((width, Line::from(spans)));
- continue;
- }
-
- if trimmed.is_empty() {
- lines.push((0, Line::raw("")));
- continue;
- }
-
- if let Some(rest) = trimmed.strip_prefix("### ") {
- let text = rest.trim().to_string();
- if text.is_empty() {
- lines.push((0, Line::raw("")));
- continue;
- }
- let width = 1 + text.chars().count();
- lines.push((
- width,
- Line::from(vec![
- Span::raw(" "),
- Span::styled(
- text.to_uppercase(),
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- ]),
- ));
- continue;
- }
-
- if let Some(rest) = trimmed.strip_prefix("- ") {
- let (text_width, mut spans) =
- release_notes_inline_spans(rest, text_style, inline_code_style);
- let width = 3 + text_width;
- let mut line_spans = vec![Span::styled(" • ", Style::default().fg(p.accent))];
- line_spans.append(&mut spans);
- lines.push((width, Line::from(line_spans)));
- continue;
- }
-
- let (text_width, mut spans) =
- release_notes_inline_spans(trimmed, text_style, inline_code_style);
- let width = 1 + text_width;
- let mut line_spans = vec![Span::raw(" ")];
- line_spans.append(&mut spans);
- lines.push((width, Line::from(line_spans)));
- }
-
- lines
-}
-
-pub(crate) struct ReleaseNotesSections {
- pub instructions: Option<Rect>,
- pub notes_body: Rect,
-}
-
-pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections {
- if preview && area.height >= 6 {
- let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area);
- ReleaseNotesSections {
- instructions: Some(rows[0]),
- notes_body: rows[1],
- }
- } else {
- ReleaseNotesSections {
- instructions: None,
- notes_body: area,
- }
- }
-}
-
-fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> {
- let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD);
- let text_style = Style::default().fg(p.text);
- let code_style = Style::default()
- .fg(p.accent)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD);
-
- vec![
- Line::from(vec![
- Span::styled(
- "●",
- Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
- ),
- Span::styled(" update available", title_style),
- ]),
- Line::from(vec![
- Span::styled("quit herdr and run ", text_style),
- Span::styled("herdr update", code_style),
- Span::styled(" to install", text_style),
- ]),
- ]
-}
-
-fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) {
- let rows = Layout::vertical([
- Constraint::Length(2),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<4>(area);
-
- let text_area = Rect::new(
- rows[0].x + 1,
- rows[0].y,
- rows[0].width.saturating_sub(2),
- rows[0].height,
- );
- frame.render_widget(
- Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }),
- text_area,
- );
-
- let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1);
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- "─".repeat(divider_area.width as usize),
- Style::default().fg(p.surface1),
- )])),
- divider_area,
- );
-}
-
-pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect {
- let width = action_button_width(Some("esc"), "close");
- Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)
-}
-
-pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect {
- Rect::new(
- area.x,
- area.y,
- action_button_width(Some("↵"), "continue"),
- 1,
- )
-}
-
-pub(crate) struct ActionButtonSpec<'a> {
- pub hint: Option<&'a str>,
- pub label: &'a str,
-}
-
-pub(crate) fn action_button_row_rects(
- area: Rect,
- buttons: &[ActionButtonSpec<'_>],
- gap: u16,
- row_offset: u16,
-) -> Vec<Rect> {
- let widths: Vec<u16> = buttons
- .iter()
- .map(|button| action_button_width(button.hint, button.label))
- .collect();
- centered_button_row(area, &widths, gap, row_offset)
-}
-
-pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) {
- let rects = action_button_row_rects(
- area,
- &[
- ActionButtonSpec {
- hint: Some("esc"),
- label: "back",
- },
- ActionButtonSpec {
- hint: Some("↵"),
- label: "start",
- },
- ],
- 2,
- 0,
- );
- (rects[0], rects[1])
-}
-
-fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else {
- return;
- };
- if inner.height < 11 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 0, 1, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
- let content_rows = Layout::vertical([
- Constraint::Length(3),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<4>(stack.content);
-
- frame.render_widget(
- Paragraph::new(" herdr").style(
- Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD),
- ),
- header_rows[0],
- );
- frame.render_widget(
- Paragraph::new(" terminal workspace manager for coding agents")
- .style(Style::default().fg(app.palette.overlay0)),
- header_rows[1],
- );
-
- frame.render_widget(
- Paragraph::new(
- " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.",
- )
- .style(Style::default().fg(app.palette.overlay1)),
- content_rows[0],
- );
-
- let key_line = Line::from(vec![
- Span::styled(" ", Style::default()),
- Span::styled(
- ONBOARDING_PREFIX_LABEL,
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " enters navigate mode · ",
- Style::default().fg(app.palette.overlay1),
- ),
- Span::styled(
- "?",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- " shows keybinds and settings",
- Style::default().fg(app.palette.overlay1),
- ),
- ]);
- frame.render_widget(Paragraph::new(key_line), content_rows[2]);
-
- let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
- render_action_button(
- frame,
- continue_rect,
- Some("↵"),
- "continue",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) {
- let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else {
- return;
- };
-
- if inner.height < 11 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 3, 0, 1, 1);
- let header_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<3>(stack.header);
- let option_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<5>(stack.content);
-
- render_modal_header(frame, header_rows[0], "notification style", &app.palette);
- frame.render_widget(
- Paragraph::new(" herdr watches background panes and can alert you")
- .style(Style::default().fg(app.palette.overlay1)),
- header_rows[1],
- );
- frame.render_widget(
- Paragraph::new(" when agents finish or need attention.")
- .style(Style::default().fg(app.palette.overlay1)),
- header_rows[2],
- );
-
- let options = [
- "quiet no interruptions",
- "visual only top-right toasts",
- "sound only sound alerts",
- "both sound and toasts",
- ];
-
- for (idx, option) in options.iter().enumerate() {
- let selected = idx == app.onboarding_list.selected;
- let prefix = if selected { "›" } else { " " };
- let style = if selected {
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- } else {
- Style::default().fg(app.palette.text)
- };
- frame.render_widget(
- Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style),
- option_rows[idx],
- );
- }
-
- let (back_rect, save_rect) =
- onboarding_notification_button_rects(stack.actions.unwrap_or_default());
- render_action_button(
- frame,
- back_rect,
- Some("esc"),
- "back",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- save_rect,
- Some("↵"),
- "start",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
-}
-
/// Floating overlay for navigate mode — appears at bottom of terminal area.
-fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let key = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
-
- let mode_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
-
- let kb = &app.keybinds;
- let line = Line::from(vec![
- Span::styled(" NAVIGATE ", mode_style),
- Span::raw(" "),
- Span::styled("esc", key),
- Span::styled(" back ", dim),
- Span::styled("↑↓", key),
- Span::styled(" ws ", dim),
- Span::styled("⇥", key),
- Span::styled(" pane ", dim),
- Span::styled(kb.new_tab_label.as_str(), key),
- Span::styled(" new tab ", dim),
- Span::styled(kb.split_vertical_label.as_str(), key),
- Span::styled(" split│ ", dim),
- Span::styled(kb.split_horizontal_label.as_str(), key),
- Span::styled(" split─ ", dim),
- Span::styled(kb.close_pane_label.as_str(), key),
- Span::styled(" close ", dim),
- Span::styled(kb.fullscreen_label.as_str(), key),
- Span::styled(" full ", dim),
- Span::styled(kb.resize_mode_label.as_str(), key),
- Span::styled(" resize ", dim),
- Span::styled("?", key),
- Span::styled(" keybinds ", dim),
- Span::styled("s", key),
- Span::styled(" settings ", dim),
- Span::styled("q", key),
- Span::styled(" quit", dim),
- ]);
-
- let overlay_y = area.y + area.height.saturating_sub(1);
- let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
-
- frame.render_widget(Clear, overlay_area);
- let bg = Style::default().bg(app.palette.panel_bg);
- let buf = frame.buffer_mut();
- for x in overlay_area.x..overlay_area.x + overlay_area.width {
- buf[(x, overlay_y)].set_style(bg);
- }
- frame.render_widget(Paragraph::new(line), overlay_area);
-
- if app.update_available.is_some() {
- let status = Line::from(vec![Span::styled(
- " update available",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- )]);
- let width = 13u16.min(overlay_area.width);
- let status_area = Rect::new(
- overlay_area.x + overlay_area.width.saturating_sub(width),
- overlay_area.y,
- width,
- overlay_area.height,
- );
- frame.render_widget(Clear, status_area);
- frame.render_widget(
- Paragraph::new(status).alignment(Alignment::Right),
- status_area,
- );
- }
-}
-
-fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
- let rect = app.global_menu_rect();
- let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg)
- else {
- return;
- };
-
- let items = app.global_menu_labels();
- for (idx, item) in items.iter().enumerate() {
- let y = inner.y + idx as u16;
- if y >= inner.y + inner.height {
- break;
- }
- let selected = idx == app.global_menu.highlighted;
- let rect = Rect::new(inner.x, y, inner.width, 1);
-
- if *item == "update available" {
- let line = if selected {
- Line::from(vec![
- Span::styled(
- " update available ",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(
- "● ",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- ])
- } else {
- Line::from(vec![
- Span::styled(" update available ", Style::default().fg(app.palette.text)),
- Span::styled(
- "● ",
- Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- ),
- ])
- };
- frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect);
- continue;
- }
-
- let style = if selected {
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(app.palette.text)
- };
- frame.render_widget(
- Paragraph::new(format!(" {item} "))
- .style(style)
- .alignment(Alignment::Left),
- rect,
- );
- }
-}
-
-fn optional_keybind_label(label: &Option<String>) -> String {
- label.clone().unwrap_or_else(|| "unset".to_string())
-}
-
-fn keybind_help_groups(app: &AppState) -> Vec<(&'static str, Vec<(String, &'static str)>)> {
- let kb = &app.keybinds;
- let mut groups = Vec::new();
-
- groups.push((
- "global",
- vec![
- (
- crate::config::format_key_combo((app.prefix_code, app.prefix_mods)),
- "navigate mode",
- ),
- ("prefix + ?".to_string(), "keybinds"),
- ],
- ));
-
- groups.push((
- "navigation",
- vec![
- ("esc".to_string(), "back"),
- ("↑ / ↓".to_string(), "workspace list"),
- ("h j k l / arrows".to_string(), "move focus"),
- ("tab / shift+tab".to_string(), "cycle pane"),
- ("enter".to_string(), "open workspace"),
- ("s".to_string(), "settings"),
- ("q".to_string(), "quit"),
- ],
- ));
-
- let workspace_tab = vec![
- (kb.new_workspace_label.clone(), "new workspace"),
- (kb.rename_workspace_label.clone(), "rename workspace"),
- (kb.close_workspace_label.clone(), "close workspace"),
- (
- optional_keybind_label(&kb.previous_workspace_label),
- "previous workspace",
- ),
- (
- optional_keybind_label(&kb.next_workspace_label),
- "next workspace",
- ),
- (kb.new_tab_label.clone(), "new tab"),
- (optional_keybind_label(&kb.rename_tab_label), "rename tab"),
- (
- optional_keybind_label(&kb.previous_tab_label),
- "previous tab",
- ),
- (optional_keybind_label(&kb.next_tab_label), "next tab"),
- (optional_keybind_label(&kb.close_tab_label), "close tab"),
- ];
- groups.push(("workspaces / tabs", workspace_tab));
-
- let panes = vec![
- (kb.split_vertical_label.clone(), "split vertical"),
- (kb.split_horizontal_label.clone(), "split horizontal"),
- (kb.close_pane_label.clone(), "close pane"),
- (kb.fullscreen_label.clone(), "fullscreen"),
- (kb.resize_mode_label.clone(), "resize mode"),
- (kb.toggle_sidebar_label.clone(), "toggle sidebar"),
- (
- optional_keybind_label(&kb.focus_pane_left_label),
- "focus pane left",
- ),
- (
- optional_keybind_label(&kb.focus_pane_down_label),
- "focus pane down",
- ),
- (
- optional_keybind_label(&kb.focus_pane_up_label),
- "focus pane up",
- ),
- (
- optional_keybind_label(&kb.focus_pane_right_label),
- "focus pane right",
- ),
- ];
- groups.push(("panes", panes));
-
- if !kb.custom_commands.is_empty() {
- groups.push((
- "custom",
- kb.custom_commands
- .iter()
- .map(|binding| (binding.label.clone(), "custom command"))
- .collect(),
- ));
- }
-
- groups
-}
-
-pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> {
- let heading_style = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let key_style = Style::default()
- .fg(app.palette.mauve)
- .add_modifier(Modifier::BOLD);
- let label_style = Style::default().fg(app.palette.text);
-
- let groups = keybind_help_groups(app);
- let key_width = groups
- .iter()
- .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
- .max()
- .unwrap_or(8);
-
- let mut lines = Vec::new();
-
- for (group, entries) in groups {
- lines.push((
- group.len() + 1,
- Line::from(vec![Span::styled(format!(" {group}"), heading_style)]),
- ));
- for (key, label) in entries {
- let padded_key = format!(" {:<width$} ", key, width = key_width);
- let width = padded_key.chars().count() + label.chars().count();
- lines.push((
- width,
- Line::from(vec![
- Span::styled(padded_key, key_style),
- Span::styled(label.to_string(), label_style),
- ]),
- ));
- }
- lines.push((0, Line::raw("")));
- }
-
- lines
-}
-
-fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
- dim_background(frame, frame.area());
-
- let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
- return;
- };
- if inner.height < 6 || inner.width < 20 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 2, 1, 0, 1);
- let header_rows =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
-
- render_modal_header(frame, header_rows[0], "keybinds", &app.palette);
- render_action_button(
- frame,
- release_notes_close_button_rect(header_rows[0]),
- Some("esc"),
- "close",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
- frame.render_widget(
- Paragraph::new(" available commands and configured shortcuts")
- .style(Style::default().fg(app.palette.overlay1)),
- header_rows[1],
- );
-
- let body_area = stack.content;
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: app
- .keybind_help_max_scroll()
- .saturating_sub(app.keybind_help.scroll) as usize,
- max_offset_from_bottom: app.keybind_help_max_scroll() as usize,
- viewport_rows: body_area.height.max(1) as usize,
- };
- let track = release_notes_scrollbar_rect(body_area, metrics);
- let text_area = track
- .map(|_| {
- Rect::new(
- body_area.x,
- body_area.y,
- body_area.width.saturating_sub(1),
- body_area.height,
- )
- })
- .unwrap_or(body_area);
-
- let body = Paragraph::new(
- keybind_help_lines(app)
- .into_iter()
- .map(|(_, line)| line)
- .collect::<Vec<_>>(),
- )
- .wrap(Wrap { trim: false })
- .scroll((app.keybind_help.scroll, 0));
- frame.render_widget(body, text_area);
- if let Some(track) = track {
- render_scrollbar(
- frame,
- metrics,
- track,
- app.palette.overlay0,
- app.palette.overlay1,
- "▐",
- );
- }
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
- Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("jump", Style::default().fg(app.palette.overlay0)),
- Span::styled(" pgup / pgdn ", Style::default().fg(app.palette.text)),
- Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
- Span::styled("close", Style::default().fg(app.palette.overlay0)),
- Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
- ])),
- stack.footer.unwrap_or_default(),
- );
-}
-
-/// Floating overlay for resize mode.
-fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let key = Style::default()
- .fg(app.palette.accent)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
-
- let mode_style = Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.mauve)
- .add_modifier(Modifier::BOLD);
-
- let line = Line::from(vec![
- Span::styled(" RESIZE ", mode_style),
- Span::raw(" "),
- Span::styled("h/l", key),
- Span::styled(" width ", dim),
- Span::styled("j/k", key),
- Span::styled(" height ", dim),
- Span::styled("esc", key),
- Span::styled(" done", dim),
- ]);
-
- let overlay_y = area.y + area.height.saturating_sub(1);
- let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
-
- frame.render_widget(Clear, overlay_area);
- let bg = Style::default().bg(app.palette.panel_bg);
- let buf = frame.buffer_mut();
- for x in overlay_area.x..overlay_area.x + overlay_area.width {
- buf[(x, overlay_y)].set_style(bg);
- }
- frame.render_widget(Paragraph::new(line), overlay_area);
-}
-
-/// Centered popup confirmation dialog with dimmed background.
-pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "save",
- },
- ActionButtonSpec {
- hint: Some("^c"),
- label: "clear",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- 3,
- );
- (rects[0], rects[1], rects[2])
-}
-
-fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- dim_background(frame, area);
-
- let title = match app.mode {
- Mode::RenameWorkspace => "rename workspace",
- Mode::RenameTab if app.creating_new_tab => "new tab",
- Mode::RenameTab => "rename tab",
- _ => return,
- };
-
- let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else {
- return;
- };
- if inner.height < 4 {
- return;
- }
-
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(0),
- ])
- .areas::<5>(inner);
-
- render_modal_header(frame, rows[0], title, &app.palette);
-
- let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
- frame.render_widget(Clear, input_rect);
- frame.render_widget(
- Paragraph::new(format!(" {}█", app.name_input)).style(
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0),
- ),
- input_rect,
- );
-
- let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
-
- render_action_button(
- frame,
- save_rect,
- Some("↵"),
- "save",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.accent)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- clear_rect,
- Some("^c"),
- "clear",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
-}
-
-fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- let ws_name = app
- .workspaces
- .get(app.selected)
- .map(|ws| ws.display_name())
- .unwrap_or_else(|| "?".to_string());
- let pane_count = app
- .workspaces
- .get(app.selected)
- .map(|ws| ws.layout.pane_count())
- .unwrap_or(0);
-
- let pane_text = if pane_count == 1 {
- "1 pane".to_string()
- } else {
- format!("{pane_count} panes")
- };
-
- // Dim the entire background
- let buf = frame.buffer_mut();
- for y in area.y..area.y + area.height {
- for x in area.x..area.x + area.width {
- let cell = &mut buf[(x, y)];
- cell.set_style(cell.style().add_modifier(Modifier::DIM));
- }
- }
-
- // Centered popup
- let Some(popup) = confirm_close_popup_rect(area) else {
- return;
- };
-
- let warn = Style::default()
- .fg(app.palette.red)
- .add_modifier(Modifier::BOLD);
- let dim = Style::default().fg(app.palette.overlay0);
-
- let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]);
-
- let detail_line = Line::from(vec![
- Span::styled(
- format!(" {ws_name}"),
- Style::default()
- .fg(app.palette.text)
- .add_modifier(Modifier::BOLD),
- ),
- Span::styled(format!(" — {pane_text}"), dim),
- ]);
-
- let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
- else {
- return;
- };
-
- if inner.height >= 3 {
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<3>(inner);
-
- frame.render_widget(Paragraph::new(title_line), rows[0]);
- frame.render_widget(Paragraph::new(detail_line), rows[1]);
-
- let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner);
- render_action_button(
- frame,
- confirm_rect,
- Some("↵"),
- "confirm",
- Style::default()
- .fg(panel_contrast_fg(&app.palette))
- .bg(app.palette.red)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- cancel_rect,
- Some("esc"),
- "cancel",
- Style::default()
- .fg(app.palette.text)
- .bg(app.palette.surface0)
- .add_modifier(Modifier::BOLD),
- );
- }
-}
-
-pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String {
- match hint {
- Some(hint) => format!(" {hint} {label} "),
- None => format!(" {label} "),
- }
-}
-
-pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 {
- action_button_text(hint, label).chars().count() as u16
-}
-
-fn render_action_button(
- frame: &mut Frame,
- rect: Rect,
- hint: Option<&str>,
- label: &str,
- style: Style,
-) {
- frame.render_widget(
- Paragraph::new(action_button_text(hint, label))
- .style(style)
- .alignment(Alignment::Center),
- rect,
- );
-}
-
-fn centered_button_row(inner: Rect, widths: &[u16], gap: u16, row_offset: u16) -> Vec<Rect> {
- let total_w = widths
- .iter()
- .copied()
- .sum::<u16>()
- .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16));
- let mut x = inner.x + inner.width.saturating_sub(total_w) / 2;
- let y = inner.y + row_offset.min(inner.height.saturating_sub(1));
- widths
- .iter()
- .map(|w| {
- let rect = Rect::new(
- x,
- y,
- (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))),
- 1,
- );
- x = x.saturating_add(*w).saturating_add(gap);
- rect
- })
- .collect()
-}
-
-pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option<Rect> {
- centered_popup_rect(area, 44, 5)
-}
-
-pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "confirm",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "cancel",
- },
- ],
- 2,
- 2,
- );
- (rects[0], rects[1])
-}
-
-pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) {
- let rects = action_button_row_rects(
- inner,
- &[
- ActionButtonSpec {
- hint: Some("↵"),
- label: "apply",
- },
- ActionButtonSpec {
- hint: Some("esc"),
- label: "close",
- },
- ],
- 2,
- inner.height.saturating_sub(1),
- );
- (rects[0], rects[1])
-}
-
-/// Right-click context menu popup anchored near the click position.
-// ---------------------------------------------------------------------------
-// Settings overlay
-// ---------------------------------------------------------------------------
-
-fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
- use crate::app::state::SettingsSection;
-
- let p = &app.palette;
- let Some(popup) = centered_popup_rect(area, 56, 20) else {
- return;
- };
-
- // Dim everything behind the modal
- dim_background(frame, area);
-
- let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else {
- return;
- };
- if inner.height < 4 || inner.width < 10 {
- return;
- }
-
- let stack = modal_stack_areas(inner, 3, 2, 0, 1);
- let header_rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- ])
- .areas::<3>(stack.header);
-
- // Title
- frame.render_widget(
- Paragraph::new(Line::from(vec![Span::styled(
- " settings",
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- )])),
- header_rows[0],
- );
-
- // Tab bar
- let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label()))
- .select(
- SettingsSection::ALL
- .iter()
- .position(|section| *section == app.settings.section)
- .unwrap_or(0),
- )
- .style(Style::default().fg(p.overlay1))
- .highlight_style(
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.accent)
- .add_modifier(Modifier::BOLD),
- )
- .divider(" ")
- .padding(" ", " ");
- frame.render_widget(tabs, header_rows[1]);
-
- // Separator
- let sep = "─".repeat(inner.width as usize);
- frame.render_widget(
- Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
- header_rows[2],
- );
-
- // Section content
- let content_area = stack.content;
-
- match app.settings.section {
- SettingsSection::Theme => {
- render_settings_theme(app, frame, content_area);
- }
- SettingsSection::Sound => {
- render_settings_toggle(
- frame,
- content_area,
- p,
- "sound alerts",
- "play sounds when agents change state in background",
- app.sound_enabled(),
- app.settings.list.selected,
- );
- }
- SettingsSection::Toast => {
- render_settings_toggle(
- frame,
- content_area,
- p,
- "visual toasts",
- "show top-right notifications for background events",
- app.toast_config.enabled,
- app.settings.list.selected,
- );
- }
- }
-
- // Footer buttons + hints
- if let Some(footer_area) = stack.footer {
- let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
- .areas::<2>(footer_area);
- let (apply_rect, close_rect) = settings_button_rects(inner);
- render_action_button(
- frame,
- apply_rect,
- Some("↵"),
- "apply",
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.accent)
- .add_modifier(Modifier::BOLD),
- );
- render_action_button(
- frame,
- close_rect,
- Some("esc"),
- "close",
- Style::default()
- .fg(p.text)
- .bg(p.surface0)
- .add_modifier(Modifier::BOLD),
- );
-
- frame.render_widget(
- Paragraph::new(Line::from(vec![
- Span::styled(" ↑↓", Style::default().fg(p.overlay0)),
- Span::styled(" select ", Style::default().fg(p.overlay1)),
- Span::styled("tab", Style::default().fg(p.overlay0)),
- Span::styled(" section", Style::default().fg(p.overlay1)),
- ])),
- footer_rows[0],
- );
- }
-}
-
-/// Render the theme picker list inside the settings panel.
-fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
- use crate::app::state::THEME_NAMES;
-
- let p = &app.palette;
- let items: Vec<ListItem> = THEME_NAMES
- .iter()
- .map(|name| {
- let is_current = name.to_lowercase().replace([' ', '_'], "-")
- == app.theme_name.to_lowercase().replace([' ', '_'], "-");
- let marker = if is_current { " ✓" } else { "" };
- ListItem::new(Line::from(vec![
- Span::styled(*name, Style::default().fg(p.subtext0)),
- Span::styled(marker, Style::default().fg(p.green)),
- ]))
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(
- Style::default()
- .bg(p.surface0)
- .fg(p.text)
- .add_modifier(Modifier::BOLD),
- )
- .highlight_symbol(" ▸ ")
- .style(Style::default().fg(p.subtext0));
-
- let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
- frame.render_stateful_widget(list, area, &mut state);
-}
-
-/// Reusable toggle widget for boolean settings (sound, toast).
-fn render_settings_toggle(
- frame: &mut Frame,
- area: Rect,
- p: &crate::app::state::Palette,
- title: &str,
- description: &str,
- current_value: bool,
- selected_idx: usize,
-) {
- let [desc_area, _, list_area] = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(2),
- ])
- .areas::<3>(area);
-
- let max_desc_len = (desc_area.width as usize).saturating_sub(2);
- let desc_text = if description.len() > max_desc_len {
- format!(" {}…", &description[..max_desc_len.saturating_sub(2)])
- } else {
- format!(" {description}")
- };
- frame.render_widget(
- Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))),
- desc_area,
- );
-
- let items: Vec<ListItem> = ["on", "off"]
- .into_iter()
- .map(|label| {
- let is_active = (label == "on") == current_value;
- let marker = if is_active { " ✓" } else { "" };
- ListItem::new(Line::from(vec![
- Span::styled(format!("{title}: {label}"), Style::default().fg(p.subtext0)),
- Span::styled(marker, Style::default().fg(p.green)),
- ]))
- })
- .collect();
-
- let list = List::new(items)
- .highlight_style(
- Style::default()
- .bg(p.surface0)
- .fg(p.text)
- .add_modifier(Modifier::BOLD),
- )
- .highlight_symbol(" ▸ ");
- let mut state = ListState::default().with_selected(Some(selected_idx.min(1)));
- frame.render_stateful_widget(list, list_area, &mut state);
-}
-
-fn render_context_menu(app: &AppState, frame: &mut Frame) {
- let Some(menu) = &app.context_menu else {
- return;
- };
-
- let p = &app.palette;
- let Some(menu_rect) = app.context_menu_rect() else {
- return;
- };
- let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else {
- return;
- };
-
- let items: Vec<ListItem> = menu
- .items()
- .iter()
- .map(|item| ListItem::new(Line::from(*item)))
- .collect();
- let list = List::new(items)
- .style(Style::default().fg(p.text))
- .highlight_style(
- Style::default()
- .bg(p.accent)
- .fg(panel_contrast_fg(p))
- .add_modifier(Modifier::BOLD),
- )
- .highlight_symbol(" ");
- let mut state = ListState::default().with_selected(Some(menu.list.highlighted));
- frame.render_stateful_widget(list, inner, &mut state);
-}
-
-fn render_toast_notification(
- frame: &mut Frame,
- area: Rect,
- toast: &ToastNotification,
- offset_for_warning: bool,
- p: &Palette,
-) {
- let dot_color = match toast.kind {
- ToastKind::NeedsAttention => p.red,
- ToastKind::Finished => p.blue,
- ToastKind::UpdateInstalled => p.accent,
- };
- let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4;
- let width = content_width.saturating_add(2).min(area.width);
- let height = 4u16.min(area.height);
- let x = area.x + area.width.saturating_sub(width);
- let y = area.y
- + area
- .height
- .saturating_sub(height + if offset_for_warning { 1 } else { 0 });
- let toast_area = Rect::new(x, y, width, height);
-
- frame.render_widget(Clear, toast_area);
- let block = Block::default()
- .borders(Borders::ALL)
- .border_style(Style::default().fg(p.overlay0))
- .style(Style::default().bg(p.panel_bg));
- let inner = block.inner(toast_area);
- frame.render_widget(block, toast_area);
-
- if inner.height < 2 {
- return;
- }
-
- let [title_row, context_row] =
- Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
-
- let title = Line::from(vec![
- Span::styled("●", Style::default().fg(dot_color)),
- Span::raw(" "),
- Span::styled(
- &toast.title,
- Style::default().fg(p.text).add_modifier(Modifier::BOLD),
- ),
- ]);
- let context = Line::from(vec![
- Span::styled(" ", Style::default().fg(p.overlay0)),
- Span::styled(&toast.context, Style::default().fg(p.overlay0)),
- ]);
-
- frame.render_widget(Paragraph::new(title), title_row);
- frame.render_widget(Paragraph::new(context), context_row);
-}
-
-/// Visual badge for a pane's state + seen flag.
-///
-/// | State | Icon | Color |
-/// |--------------------|------|--------|
-/// | Blocked | ● | Red |
-/// | Working | ● | Yellow |
-/// | Done (idle+unseen) | ● | Blue |
-/// | Idle (seen) | ○ | Green |
-/// | Unknown | · | Gray |
-///
-/// Filled dot = needs attention (blocked/working, or finished unseen).
-/// Hollow dot = nothing to do here.
-fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
- let text = format!(" config warning: {message} ");
- let width = text.len() as u16 + 2;
- let notif_area = Rect::new(
- area.x + area.width.saturating_sub(width.min(area.width)),
- area.y,
- width.min(area.width),
- 1,
- );
-
- frame.render_widget(Clear, notif_area);
- frame.render_widget(
- Paragraph::new(Span::styled(
- text,
- Style::default()
- .fg(panel_contrast_fg(p))
- .bg(p.yellow)
- .add_modifier(Modifier::BOLD),
- )),
- notif_area,
- );
-}
-
-/// Compact dot icon for workspace-level aggregate state (top section).
-fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) {
- match (state, seen) {
- (AgentState::Blocked, _) => ("●", Style::default().fg(p.red)),
- (AgentState::Working, _) => ("●", Style::default().fg(p.yellow)),
- (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)),
- (AgentState::Idle, true) => ("○", Style::default().fg(p.green)),
- (AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)),
- }
-}
-
-/// Rich icon for per-pane agent detail (bottom section).
-/// Uses animated spinner for the working state.
-fn agent_icon(state: AgentState, seen: bool, tick: u32, p: &Palette) -> (&'static str, Style) {
- match (state, seen) {
- (AgentState::Blocked, _) => ("◉", Style::default().fg(p.red)),
- (AgentState::Working, _) => (spinner_frame(tick), Style::default().fg(p.yellow)),
- (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)),
- (AgentState::Idle, true) => ("✓", Style::default().fg(p.green)),
- (AgentState::Unknown, _) => ("○", Style::default().fg(p.overlay0)),
- }
-}
-
-/// State label for the agent detail panel.
-fn state_label(state: AgentState, seen: bool) -> &'static str {
- match (state, seen) {
- (AgentState::Blocked, _) => "blocked",
- (AgentState::Working, _) => "working",
- (AgentState::Idle, false) => "done",
- (AgentState::Idle, true) => "idle",
- (AgentState::Unknown, _) => "idle",
- }
-}
-
-/// Color for the state label text.
-fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color {
- match (state, seen) {
- (AgentState::Blocked, _) => p.red,
- (AgentState::Working, _) => p.yellow,
- (AgentState::Idle, false) => p.teal,
- (AgentState::Idle, true) => p.green,
- (AgentState::Unknown, _) => p.overlay0,
- }
-}
-
fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> Vec<Span<'static>> {
let mut spans = Vec::new();
spans.push(Span::raw(" "));
@@ -3540,9 +233,13 @@ fn _build_hints(items: &[(&str, &str)], key_style: Style, dim_style: Style) -> V
#[cfg(test)]
mod tests {
+ use super::keybind_help::keybind_help_groups;
+ use super::release_notes::{release_notes_lines, release_notes_preview_lines};
+ use super::scrollbar::scrollbar_thumb;
use super::*;
- use crate::{detect::Agent, workspace::Workspace};
+ use crate::{app::state::Palette, layout::PaneInfo, workspace::Workspace};
use ratatui::{backend::TestBackend, Terminal};
+ use ratatui::{style::Color, text::Line};
#[tokio::test]
async fn focused_pane_cursor_wins_during_terminal_render() {
@@ -3607,6 +304,37 @@ mod tests {
}
#[test]
+ fn expanded_sidebar_workspace_rows_show_state_before_name_without_numbers() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut ws = Workspace::test_new("one");
+ let repo = temp_git_repo("main");
+ ws.identity_cwd = repo.clone();
+ let root_pane = ws.tabs[0].root_pane;
+ ws.tabs[0].pane_cwds.insert(root_pane, repo.clone());
+
+ app.workspaces = vec![ws];
+ app.selected = 0;
+ app.mode = Mode::Navigate;
+
+ compute_view(&mut app, Rect::new(0, 0, 80, 20));
+
+ let backend = TestBackend::new(80, 20);
+ let mut terminal = Terminal::new(backend).unwrap();
+ terminal.draw(|frame| render(&app, frame)).unwrap();
+ let buffer = terminal.backend().buffer();
+
+ let card = app.view.workspace_card_areas[0].rect;
+ let line1 = buffer_row_text(buffer, card, card.y);
+ let line2 = buffer_row_text(buffer, card, card.y + 1);
+
+ assert!(line1.starts_with(" · one"));
+ assert!(!line1.contains("1 one"));
+ assert_eq!(line2, " main");
+
+ std::fs::remove_dir_all(repo).ok();
+ }
+
+ #[test]
fn tab_bar_dims_auto_named_tabs_and_emphasizes_custom_tabs() {
let mut app = crate::app::state::AppState::test_new();
let mut ws = Workspace::test_new("test");
@@ -3765,78 +493,11 @@ mod tests {
}
#[test]
- fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
- let mut app = crate::app::state::AppState::test_new();
- 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_tab = second.test_add_tab(Some("logs"));
- let second_pane = second.tabs[second_tab].root_pane;
- second.tabs[second_tab]
- .panes
- .get_mut(&second_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- app.workspaces = vec![first, second];
- app.active = Some(0);
- app.selected = 0;
- app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
-
- let entries = agent_panel_entries(&app);
- assert_eq!(entries[0].primary_label, "one");
- assert!(entries[0].primary_tab_label.is_none());
- assert_eq!(entries[0].agent_label.as_deref(), Some("pi"));
- assert_eq!(entries[1].primary_label, "two");
- assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs"));
- assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
- }
-
- #[test]
- fn all_workspaces_primary_label_truncates_workspace_and_tab() {
- let entry = AgentPanelEntry {
- ws_idx: 0,
- tab_idx: 0,
- pane_id: crate::layout::PaneId::from_raw(1),
- primary_label: "agent-browser".into(),
- primary_tab_label: Some("test-escalation".into()),
- agent_label: Some("claude".into()),
- state: AgentState::Idle,
- seen: true,
- };
-
- let label = format_agent_panel_primary_label(&entry, 18);
-
- assert_eq!(label, "agent-bro… · test…");
- }
-
- #[test]
- fn expanded_sidebar_sections_handle_tiny_heights() {
- let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
-
- assert_eq!(ws_area, Rect::new(0, 0, 19, 3));
- assert_eq!(detail_area, Rect::new(0, 3, 19, 2));
- }
-
- #[test]
- fn sidebar_section_divider_is_hidden_for_tiny_heights() {
- let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5);
-
- assert_eq!(divider, Rect::default());
- }
-
- #[test]
- fn pane_scrollbar_rect_uses_rightmost_inner_column() {
+ fn pane_scrollbar_rect_overlays_rightmost_inner_column() {
let info = PaneInfo {
id: crate::layout::PaneId::from_raw(1),
rect: Rect::new(0, 0, 12, 8),
- inner_rect: Rect::new(1, 1, 9, 6),
+ inner_rect: Rect::new(1, 1, 10, 6),
scrollbar_rect: Some(Rect::new(10, 1, 1, 6)),
is_focused: true,
};
@@ -3844,6 +505,40 @@ mod tests {
assert_eq!(pane_scrollbar_rect(&info), Some(Rect::new(10, 1, 1, 6)));
}
+ #[tokio::test]
+ async fn compute_view_keeps_terminal_width_when_pane_scrollbar_is_visible() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut ws = Workspace::test_new("test");
+ let pane_id = ws.tabs[0].root_pane;
+ ws.tabs[0].runtimes.insert(
+ pane_id,
+ crate::pane::PaneRuntime::test_with_scrollback_bytes(
+ 12,
+ 4,
+ 4096,
+ b"000000000000\r\n111111111111\r\n222222222222\r\n333333333333\r\n444444444444\r\n",
+ ),
+ );
+
+ app.workspaces = vec![ws];
+ app.active = Some(0);
+ app.selected = 0;
+
+ compute_view(&mut app, Rect::new(0, 0, 40, 12));
+
+ let info = app.view.pane_infos.first().expect("pane info");
+ assert_eq!(info.inner_rect.width, app.view.terminal_area.width);
+ assert_eq!(
+ info.scrollbar_rect,
+ Some(Rect::new(
+ info.inner_rect.x + info.inner_rect.width.saturating_sub(1),
+ info.inner_rect.y,
+ 1,
+ info.inner_rect.height,
+ ))
+ );
+ }
+
#[test]
fn scrollbar_stays_hidden_without_scrollback() {
let metrics = crate::pane::ScrollMetrics {
@@ -3915,6 +610,29 @@ mod tests {
.collect::<String>()
}
+ fn buffer_row_text(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String {
+ (area.x..area.x + area.width)
+ .map(|x| buffer[(x, row)].symbol())
+ .collect::<String>()
+ .trim_end()
+ .to_string()
+ }
+
+ fn temp_git_repo(branch: &str) -> std::path::PathBuf {
+ let unique = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .expect("unix time")
+ .as_nanos();
+ let root = std::env::temp_dir().join(format!("herdr-ui-test-{unique}"));
+ std::fs::create_dir_all(root.join(".git")).expect("create .git dir");
+ std::fs::write(
+ root.join(".git/HEAD"),
+ format!("ref: refs/heads/{branch}\n"),
+ )
+ .expect("write HEAD");
+ root
+ }
+
#[test]
fn release_notes_inline_code_spans_are_styled_without_backticks() {
let palette = Palette::catppuccin();
@@ -3933,10 +651,10 @@ mod tests {
let lines = release_notes_preview_lines("0.5.0", &palette);
assert_eq!(lines.len(), 2);
- assert_eq!(line_text(&lines[0]), "● update available");
+ assert_eq!(line_text(&lines[0]), "● update ready");
assert_eq!(
line_text(&lines[1]),
- "quit herdr and run herdr update to install"
+ "detach from this session, then run herdr update in your shell"
);
assert_eq!(lines[0].spans[0].style.fg, Some(palette.accent));
assert_eq!(lines[0].spans[1].style.fg, Some(palette.text));
diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs
new file mode 100644
index 00000000..78f1d1cb
--- /dev/null
+++ b/src/ui/dialogs.rs
@@ -0,0 +1,213 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, Paragraph},
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, centered_popup_rect, panel_contrast_fg, render_action_button,
+ render_modal_header, render_modal_shell, render_panel_shell, ActionButtonSpec,
+};
+use crate::app::{AppState, Mode};
+
+pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "save",
+ },
+ ActionButtonSpec {
+ hint: Some("^c"),
+ label: "clear",
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "cancel",
+ },
+ ],
+ 2,
+ 3,
+ );
+ (rects[0], rects[1], rects[2])
+}
+
+pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ super::dim_background(frame, area);
+
+ let title = match app.mode {
+ Mode::RenameWorkspace => "rename workspace",
+ Mode::RenameTab if app.creating_new_tab => "new tab",
+ Mode::RenameTab => "rename tab",
+ _ => return,
+ };
+
+ let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else {
+ return;
+ };
+ if inner.height < 4 {
+ return;
+ }
+
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ])
+ .areas::<5>(inner);
+
+ render_modal_header(frame, rows[0], title, &app.palette);
+
+ let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
+ frame.render_widget(Clear, input_rect);
+ frame.render_widget(
+ Paragraph::new(format!(" {}█", app.name_input)).style(
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0),
+ ),
+ input_rect,
+ );
+
+ let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
+
+ render_action_button(
+ frame,
+ save_rect,
+ Some("↵"),
+ "save",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ clear_rect,
+ Some("^c"),
+ "clear",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ cancel_rect,
+ Some("esc"),
+ "cancel",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+}
+
+pub(super) fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let ws_name = app
+ .workspaces
+ .get(app.selected)
+ .map(|ws| ws.display_name())
+ .unwrap_or_else(|| "?".to_string());
+ let pane_count = app
+ .workspaces
+ .get(app.selected)
+ .map(|ws| ws.layout.pane_count())
+ .unwrap_or(0);
+
+ let pane_text = if pane_count == 1 {
+ "1 pane".to_string()
+ } else {
+ format!("{pane_count} panes")
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(popup) = confirm_close_popup_rect(area) else {
+ return;
+ };
+
+ let warn = Style::default()
+ .fg(app.palette.red)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(app.palette.overlay0);
+
+ let title_line = Line::from(vec![Span::styled(" Close workspace?", warn)]);
+
+ let detail_line = Line::from(vec![
+ Span::styled(
+ format!(" {ws_name}"),
+ Style::default()
+ .fg(app.palette.text)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(format!(" — {pane_text}"), dim),
+ ]);
+
+ let Some(inner) = render_panel_shell(frame, popup, app.palette.red, app.palette.panel_bg)
+ else {
+ return;
+ };
+
+ if inner.height >= 3 {
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<3>(inner);
+
+ frame.render_widget(Paragraph::new(title_line), rows[0]);
+ frame.render_widget(Paragraph::new(detail_line), rows[1]);
+
+ let (confirm_rect, cancel_rect) = confirm_close_button_rects(inner);
+ render_action_button(
+ frame,
+ confirm_rect,
+ Some("↵"),
+ "confirm",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.red)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ cancel_rect,
+ Some("esc"),
+ "cancel",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+ }
+}
+
+pub(crate) fn confirm_close_popup_rect(area: Rect) -> Option<Rect> {
+ centered_popup_rect(area, 44, 5)
+}
+
+pub(crate) fn confirm_close_button_rects(inner: Rect) -> (Rect, Rect) {
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "confirm",
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "cancel",
+ },
+ ],
+ 2,
+ 2,
+ );
+ (rects[0], rects[1])
+}
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
new file mode 100644
index 00000000..ccff95ab
--- /dev/null
+++ b/src/ui/keybind_help.rs
@@ -0,0 +1,240 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Paragraph, Wrap},
+ Frame,
+};
+
+use super::release_notes::release_notes_close_button_rect;
+use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
+use super::widgets::{
+ modal_stack_areas, panel_contrast_fg, render_action_button, render_modal_header,
+ render_modal_shell,
+};
+use crate::app::AppState;
+
+fn optional_keybind_label(label: &Option<String>) -> String {
+ label.clone().unwrap_or_else(|| "unset".to_string())
+}
+
+pub(super) fn keybind_help_groups(
+ app: &AppState,
+) -> Vec<(&'static str, Vec<(String, &'static str)>)> {
+ let kb = &app.keybinds;
+ let mut groups = Vec::new();
+
+ groups.push((
+ "global",
+ vec![
+ (
+ crate::config::format_key_combo((app.prefix_code, app.prefix_mods)),
+ "navigate mode",
+ ),
+ ("prefix + ?".to_string(), "keybinds"),
+ ],
+ ));
+
+ groups.push((
+ "navigation",
+ vec![
+ ("esc".to_string(), "back"),
+ ("↑ / ↓".to_string(), "workspace list"),
+ ("h j k l / arrows".to_string(), "move focus"),
+ ("tab / shift+tab".to_string(), "cycle pane"),
+ ("enter".to_string(), "open workspace"),
+ ("s".to_string(), "settings"),
+ ("q".to_string(), "quit"),
+ ],
+ ));
+
+ let mut workspace_tab = vec![
+ (kb.new_workspace_label.clone(), "new workspace"),
+ (kb.rename_workspace_label.clone(), "rename workspace"),
+ (kb.close_workspace_label.clone(), "close workspace"),
+ (
+ optional_keybind_label(&kb.previous_workspace_label),
+ "previous workspace",
+ ),
+ (
+ optional_keybind_label(&kb.next_workspace_label),
+ "next workspace",
+ ),
+ (kb.new_tab_label.clone(), "new tab"),
+ (optional_keybind_label(&kb.rename_tab_label), "rename tab"),
+ (
+ optional_keybind_label(&kb.previous_tab_label),
+ "previous tab",
+ ),
+ (optional_keybind_label(&kb.next_tab_label), "next tab"),
+ (optional_keybind_label(&kb.close_tab_label), "close tab"),
+ ];
+ if let Some(label) = &kb.detach_label {
+ workspace_tab.insert(3, (label.clone(), "detach from server"));
+ }
+ groups.push(("workspaces / tabs", workspace_tab));
+
+ let panes = vec![
+ (kb.split_vertical_label.clone(), "split vertical"),
+ (kb.split_horizontal_label.clone(), "split horizontal"),
+ (kb.close_pane_label.clone(), "close pane"),
+ (kb.fullscreen_label.clone(), "fullscreen"),
+ (kb.resize_mode_label.clone(), "resize mode"),
+ (kb.toggle_sidebar_label.clone(), "toggle sidebar"),
+ (
+ optional_keybind_label(&kb.focus_pane_left_label),
+ "focus pane left",
+ ),
+ (
+ optional_keybind_label(&kb.focus_pane_down_label),
+ "focus pane down",
+ ),
+ (
+ optional_keybind_label(&kb.focus_pane_up_label),
+ "focus pane up",
+ ),
+ (
+ optional_keybind_label(&kb.focus_pane_right_label),
+ "focus pane right",
+ ),
+ ];
+ groups.push(("panes", panes));
+
+ if !kb.custom_commands.is_empty() {
+ groups.push((
+ "custom",
+ kb.custom_commands
+ .iter()
+ .map(|binding| (binding.label.clone(), "custom command"))
+ .collect(),
+ ));
+ }
+
+ groups
+}
+
+pub(crate) fn keybind_help_lines(app: &AppState) -> Vec<(usize, Line<'static>)> {
+ let heading_style = Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+ let key_style = Style::default()
+ .fg(app.palette.mauve)
+ .add_modifier(Modifier::BOLD);
+ let label_style = Style::default().fg(app.palette.text);
+
+ let groups = keybind_help_groups(app);
+ let key_width = groups
+ .iter()
+ .flat_map(|(_, entries)| entries.iter().map(|(key, _)| key.chars().count()))
+ .max()
+ .unwrap_or(8);
+
+ let mut lines = Vec::new();
+
+ for (group, entries) in groups {
+ lines.push((
+ group.len() + 1,
+ Line::from(vec![Span::styled(format!(" {group}"), heading_style)]),
+ ));
+ for (key, label) in entries {
+ let padded_key = format!(" {:<width$} ", key, width = key_width);
+ let width = padded_key.chars().count() + label.chars().count();
+ lines.push((
+ width,
+ Line::from(vec![
+ Span::styled(padded_key, key_style),
+ Span::styled(label.to_string(), label_style),
+ ]),
+ ));
+ }
+ lines.push((0, Line::raw("")));
+ }
+
+ lines
+}
+
+pub(super) fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
+ super::dim_background(frame, frame.area());
+
+ let Some(inner) = render_modal_shell(frame, frame.area(), 76, 22, &app.palette) else {
+ return;
+ };
+ if inner.height < 6 || inner.width < 20 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+
+ render_modal_header(frame, header_rows[0], "keybinds", &app.palette);
+ render_action_button(
+ frame,
+ release_notes_close_button_rect(header_rows[0]),
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ frame.render_widget(
+ Paragraph::new(" available commands and configured shortcuts")
+ .style(Style::default().fg(app.palette.overlay1)),
+ header_rows[1],
+ );
+
+ let body_area = stack.content;
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app
+ .keybind_help_max_scroll()
+ .saturating_sub(app.keybind_help.scroll) as usize,
+ max_offset_from_bottom: app.keybind_help_max_scroll() as usize,
+ viewport_rows: body_area.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(body_area, metrics);
+ let text_area = track
+ .map(|_| {
+ Rect::new(
+ body_area.x,
+ body_area.y,
+ body_area.width.saturating_sub(1),
+ body_area.height,
+ )
+ })
+ .unwrap_or(body_area);
+
+ let body = Paragraph::new(
+ keybind_help_lines(app)
+ .into_iter()
+ .map(|(_, line)| line)
+ .collect::<Vec<_>>(),
+ )
+ .wrap(Wrap { trim: false })
+ .scroll((app.keybind_help.scroll, 0));
+ frame.render_widget(body, text_area);
+ if let Some(track) = track {
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ app.palette.overlay0,
+ app.palette.overlay1,
+ "▐",
+ );
+ }
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("jump", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" pgup / pgdn ", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("close", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ stack.footer.unwrap_or_default(),
+ );
+}
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
new file mode 100644
index 00000000..537ac9a1
--- /dev/null
+++ b/src/ui/menus.rs
@@ -0,0 +1,210 @@
+use ratatui::{
+ layout::{Alignment, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, List, ListItem, ListState, Paragraph},
+ Frame,
+};
+
+use super::widgets::{panel_contrast_fg, render_panel_shell};
+use crate::app::AppState;
+
+fn render_bottom_bar(frame: &mut Frame, area: Rect, line: Line<'_>, bg: ratatui::style::Color) {
+ frame.render_widget(Clear, area);
+ let buf = frame.buffer_mut();
+ for x in area.x..area.x + area.width {
+ buf[(x, area.y)].set_style(Style::default().bg(bg));
+ }
+ frame.render_widget(Paragraph::new(line), area);
+}
+
+pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let key = Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(app.palette.overlay0);
+
+ let mode_style = Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+
+ let kb = &app.keybinds;
+ let line = Line::from(vec![
+ Span::styled(" NAVIGATE ", mode_style),
+ Span::raw(" "),
+ Span::styled("esc", key),
+ Span::styled(" back ", dim),
+ Span::styled("↑↓", key),
+ Span::styled(" ws ", dim),
+ Span::styled("⇥", key),
+ Span::styled(" pane ", dim),
+ Span::styled(kb.new_tab_label.as_str(), key),
+ Span::styled(" new tab ", dim),
+ Span::styled(kb.split_vertical_label.as_str(), key),
+ Span::styled(" split│ ", dim),
+ Span::styled(kb.split_horizontal_label.as_str(), key),
+ Span::styled(" split─ ", dim),
+ Span::styled(kb.close_pane_label.as_str(), key),
+ Span::styled(" close ", dim),
+ Span::styled(kb.fullscreen_label.as_str(), key),
+ Span::styled(" full ", dim),
+ Span::styled(kb.resize_mode_label.as_str(), key),
+ Span::styled(" resize ", dim),
+ Span::styled("?", key),
+ Span::styled(" keybinds ", dim),
+ Span::styled("s", key),
+ Span::styled(" settings ", dim),
+ Span::styled("q", key),
+ Span::styled(" quit", dim),
+ ]);
+
+ let overlay_y = area.y + area.height.saturating_sub(1);
+ let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
+ render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
+
+ if app.update_available.is_some() {
+ let status = Line::from(vec![Span::styled(
+ " update ready",
+ Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ )]);
+ let width = 13u16.min(overlay_area.width);
+ let status_area = Rect::new(
+ overlay_area.x + overlay_area.width.saturating_sub(width),
+ overlay_area.y,
+ width,
+ overlay_area.height,
+ );
+ frame.render_widget(Clear, status_area);
+ frame.render_widget(
+ Paragraph::new(status).alignment(Alignment::Right),
+ status_area,
+ );
+ }
+}
+
+pub(super) fn render_global_launcher_menu(app: &AppState, frame: &mut Frame) {
+ let rect = app.global_menu_rect();
+ let Some(inner) = render_panel_shell(frame, rect, app.palette.accent, app.palette.panel_bg)
+ else {
+ return;
+ };
+
+ let items = app.global_menu_labels();
+ for (idx, item) in items.iter().enumerate() {
+ let y = inner.y + idx as u16;
+ if y >= inner.y + inner.height {
+ break;
+ }
+ let selected = idx == app.global_menu.highlighted;
+ let rect = Rect::new(inner.x, y, inner.width, 1);
+
+ if *item == "update ready" {
+ let line = if selected {
+ Line::from(vec![
+ Span::styled(
+ " update ready ",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ "● ",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ])
+ } else {
+ Line::from(vec![
+ Span::styled(" update ready ", Style::default().fg(app.palette.text)),
+ Span::styled(
+ "● ",
+ Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ ])
+ };
+ frame.render_widget(Paragraph::new(line).alignment(Alignment::Left), rect);
+ continue;
+ }
+
+ let style = if selected {
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(app.palette.text)
+ };
+ frame.render_widget(
+ Paragraph::new(format!(" {item} "))
+ .style(style)
+ .alignment(Alignment::Left),
+ rect,
+ );
+ }
+}
+
+pub(super) fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let key = Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(app.palette.overlay0);
+
+ let mode_style = Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.mauve)
+ .add_modifier(Modifier::BOLD);
+
+ let line = Line::from(vec![
+ Span::styled(" RESIZE ", mode_style),
+ Span::raw(" "),
+ Span::styled("h/l", key),
+ Span::styled(" width ", dim),
+ Span::styled("j/k", key),
+ Span::styled(" height ", dim),
+ Span::styled("esc", key),
+ Span::styled(" done", dim),
+ ]);
+
+ let overlay_y = area.y + area.height.saturating_sub(1);
+ let overlay_area = Rect::new(area.x, overlay_y, area.width, 1);
+ render_bottom_bar(frame, overlay_area, line, app.palette.panel_bg);
+}
+
+pub(super) fn render_context_menu(app: &AppState, frame: &mut Frame) {
+ let Some(menu) = &app.context_menu else {
+ return;
+ };
+
+ let p = &app.palette;
+ let Some(menu_rect) = app.context_menu_rect() else {
+ return;
+ };
+ let Some(inner) = render_panel_shell(frame, menu_rect, p.accent, p.panel_bg) else {
+ return;
+ };
+
+ let items: Vec<ListItem> = menu
+ .items()
+ .iter()
+ .map(|item| ListItem::new(Line::from(*item)))
+ .collect();
+ let list = List::new(items)
+ .style(Style::default().fg(p.text))
+ .highlight_style(
+ Style::default()
+ .bg(p.accent)
+ .fg(panel_contrast_fg(p))
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ");
+ let mut state = ListState::default().with_selected(Some(menu.list.highlighted));
+ frame.render_stateful_widget(list, inner, &mut state);
+}
diff --git a/src/ui/onboarding.rs b/src/ui/onboarding.rs
new file mode 100644
index 00000000..6f35c1a6
--- /dev/null
+++ b/src/ui/onboarding.rs
@@ -0,0 +1,215 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, action_button_width, modal_stack_areas, panel_contrast_fg,
+ render_action_button, render_modal_header, render_modal_shell, ActionButtonSpec,
+};
+use crate::app::AppState;
+
+const ONBOARDING_PREFIX_LABEL: &str = "ctrl+b";
+
+pub(super) fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ super::dim_background(frame, area);
+
+ match app.onboarding_step {
+ 0 => render_onboarding_welcome(app, frame, area),
+ _ => render_onboarding_notifications(app, frame, area),
+ }
+}
+
+pub(crate) fn onboarding_welcome_continue_rect(area: Rect) -> Rect {
+ Rect::new(
+ area.x,
+ area.y,
+ action_button_width(Some("↵"), "continue"),
+ 1,
+ )
+}
+
+pub(crate) fn onboarding_notification_button_rects(area: Rect) -> (Rect, Rect) {
+ let rects = action_button_row_rects(
+ area,
+ &[
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "back",
+ },
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "start",
+ },
+ ],
+ 2,
+ 0,
+ );
+ (rects[0], rects[1])
+}
+
+fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(inner) = render_modal_shell(frame, area, 64, 16, &app.palette) else {
+ return;
+ };
+ if inner.height < 11 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 0, 1, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+ let content_rows = Layout::vertical([
+ Constraint::Length(3),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ])
+ .areas::<4>(stack.content);
+
+ frame.render_widget(
+ Paragraph::new(" herdr").style(
+ Style::default()
+ .fg(app.palette.text)
+ .add_modifier(Modifier::BOLD),
+ ),
+ header_rows[0],
+ );
+ frame.render_widget(
+ Paragraph::new(" terminal workspace manager for coding agents")
+ .style(Style::default().fg(app.palette.overlay0)),
+ header_rows[1],
+ );
+
+ frame.render_widget(
+ Paragraph::new(
+ " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.",
+ )
+ .style(Style::default().fg(app.palette.overlay1)),
+ content_rows[0],
+ );
+
+ let key_line = Line::from(vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(
+ ONBOARDING_PREFIX_LABEL,
+ Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ " enters navigate mode · ",
+ Style::default().fg(app.palette.overlay1),
+ ),
+ Span::styled(
+ "?",
+ Style::default()
+ .fg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(
+ " shows keybinds and settings",
+ Style::default().fg(app.palette.overlay1),
+ ),
+ ]);
+ frame.render_widget(Paragraph::new(key_line), content_rows[2]);
+
+ let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
+ render_action_button(
+ frame,
+ continue_rect,
+ Some("↵"),
+ "continue",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+}
+
+fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(inner) = render_modal_shell(frame, area, 56, 14, &app.palette) else {
+ return;
+ };
+
+ if inner.height < 11 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 3, 0, 1, 1);
+ let header_rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<3>(stack.header);
+ let option_rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ])
+ .areas::<5>(stack.content);
+
+ render_modal_header(frame, header_rows[0], "notification style", &app.palette);
+ frame.render_widget(
+ Paragraph::new(" herdr watches background panes and can alert you")
+ .style(Style::default().fg(app.palette.overlay1)),
+ header_rows[1],
+ );
+ frame.render_widget(
+ Paragraph::new(" when agents finish or need attention.")
+ .style(Style::default().fg(app.palette.overlay1)),
+ header_rows[2],
+ );
+
+ let options = [
+ "quiet no interruptions",
+ "visual only top-right toasts",
+ "sound only sound alerts",
+ "both sound and toasts",
+ ];
+
+ for (idx, option) in options.iter().enumerate() {
+ let selected = idx == app.onboarding_list.selected;
+ let prefix = if selected { "›" } else { " " };
+ let style = if selected {
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ } else {
+ Style::default().fg(app.palette.text)
+ };
+ frame.render_widget(
+ Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style),
+ option_rows[idx],
+ );
+ }
+
+ let (back_rect, save_rect) =
+ onboarding_notification_button_rects(stack.actions.unwrap_or_default());
+ render_action_button(
+ frame,
+ back_rect,
+ Some("esc"),
+ "back",
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ save_rect,
+ Some("↵"),
+ "start",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+}
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
new file mode 100644
index 00000000..7e1490db
--- /dev/null
+++ b/src/ui/panes.rs
@@ -0,0 +1,252 @@
+use ratatui::{
+ layout::Rect,
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph},
+ Frame,
+};
+
+use super::scrollbar::{render_pane_scrollbar, should_show_scrollbar};
+use super::widgets::panel_contrast_fg;
+use crate::app::state::Palette;
+use crate::app::{AppState, Mode};
+use crate::layout::PaneInfo;
+
+/// Compute pane layout info and optionally resize pane runtimes to match.
+pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) -> Vec<PaneInfo> {
+ let Some(ws_idx) = app.active else {
+ return Vec::new();
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+
+ let multi_pane = ws.layout.pane_count() > 1;
+ let terminal_active = app.mode == Mode::Terminal;
+
+ if ws.zoomed {
+ let focused_id = ws.layout.focused();
+ let inner_rect = area;
+ let mut scrollbar_rect = None;
+ if let Some(rt) = ws.runtimes.get(&focused_id) {
+ if rt
+ .scroll_metrics()
+ .is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 0)
+ {
+ scrollbar_rect = Some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ area.y,
+ 1,
+ area.height,
+ ));
+ }
+ if resize_panes {
+ rt.resize(inner_rect.height, inner_rect.width);
+ }
+ }
+ return vec![PaneInfo {
+ id: focused_id,
+ rect: area,
+ inner_rect,
+ scrollbar_rect,
+ is_focused: true,
+ }];
+ }
+
+ let mut pane_infos = ws.layout.panes(area);
+
+ for info in &mut pane_infos {
+ let pane_inner = if multi_pane {
+ let border_set = if info.is_focused && terminal_active {
+ ratatui::symbols::border::THICK
+ } else {
+ ratatui::symbols::border::PLAIN
+ };
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_set(border_set);
+ block.inner(info.rect)
+ } else {
+ area
+ };
+
+ let inner_rect = pane_inner;
+ let mut scrollbar_rect = None;
+ if let Some(rt) = ws.runtimes.get(&info.id) {
+ if rt
+ .scroll_metrics()
+ .is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 0)
+ {
+ scrollbar_rect = Some(Rect::new(
+ pane_inner.x + pane_inner.width.saturating_sub(1),
+ pane_inner.y,
+ 1,
+ pane_inner.height,
+ ));
+ }
+ if resize_panes {
+ rt.resize(inner_rect.height, inner_rect.width);
+ }
+ }
+
+ info.inner_rect = inner_rect;
+ info.scrollbar_rect = scrollbar_rect;
+ }
+
+ pane_infos
+}
+
+pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(ws_idx) = app.active else {
+ render_empty(app, frame, area);
+ return;
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ render_empty(app, frame, area);
+ return;
+ };
+
+ let multi_pane = ws.layout.pane_count() > 1;
+ let terminal_active = app.mode == Mode::Terminal;
+
+ for info in &app.view.pane_infos {
+ if let Some(rt) = ws.runtimes.get(&info.id) {
+ if multi_pane {
+ let (border_style, border_set) = if info.is_focused && terminal_active {
+ (
+ Style::default().fg(app.palette.accent),
+ ratatui::symbols::border::THICK,
+ )
+ } else if info.is_focused {
+ (
+ Style::default().fg(app.palette.accent),
+ ratatui::symbols::border::PLAIN,
+ )
+ } else {
+ (
+ Style::default().fg(app.palette.overlay0),
+ ratatui::symbols::border::PLAIN,
+ )
+ };
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(border_style)
+ .border_set(border_set);
+ frame.render_widget(block, info.rect);
+ }
+
+ rt.render(frame, info.inner_rect, info.is_focused && terminal_active);
+ render_pane_scrollbar(app, frame, info, rt);
+
+ let should_dim = !info.is_focused && multi_pane && !terminal_active;
+ if should_dim {
+ let inner = info.inner_rect;
+ let buf = frame.buffer_mut();
+ for y in inner.y..inner.y + inner.height {
+ for x in inner.x..inner.x + inner.width {
+ let cell = &mut buf[(x, y)];
+ let style = cell.style();
+ let fg = style.fg.unwrap_or(Color::White);
+ let dimmed_fg = dim_color(fg);
+ cell.set_style(style.fg(dimmed_fg));
+ }
+ }
+ }
+
+ render_selection_highlight(
+ &app.selection,
+ frame,
+ info.id,
+ info.inner_rect,
+ rt.scroll_metrics(),
+ &app.palette,
+ );
+ }
+ }
+}
+
+/// Render selection highlight for a pane by inverting fg/bg colors.
+/// Reduce a color's brightness by blending it toward black.
+fn dim_color(color: Color) -> Color {
+ match color {
+ Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
+ Color::White => Color::DarkGray,
+ Color::Gray => Color::DarkGray,
+ Color::DarkGray => Color::Rgb(30, 30, 30),
+ Color::Red => Color::Rgb(60, 0, 0),
+ Color::Green => Color::Rgb(0, 60, 0),
+ Color::Yellow => Color::Rgb(60, 60, 0),
+ Color::Blue => Color::Rgb(0, 0, 60),
+ Color::Magenta => Color::Rgb(60, 0, 60),
+ Color::Cyan => Color::Rgb(0, 60, 60),
+ Color::LightRed => Color::Rgb(80, 30, 30),
+ Color::LightGreen => Color::Rgb(30, 80, 30),
+ Color::LightYellow => Color::Rgb(80, 80, 30),
+ Color::LightBlue => Color::Rgb(30, 30, 80),
+ Color::LightMagenta => Color::Rgb(80, 30, 80),
+ Color::LightCyan => Color::Rgb(30, 80, 80),
+ _ => Color::DarkGray,
+ }
+}
+
+fn render_selection_highlight(
+ selection: &Option<crate::selection::Selection>,
+ frame: &mut Frame,
+ pane_id: crate::layout::PaneId,
+ inner: Rect,
+ scroll_metrics: Option<crate::pane::ScrollMetrics>,
+ p: &Palette,
+) {
+ if let Some(sel) = selection {
+ if sel.is_visible() && sel.pane_id == pane_id {
+ let buf = frame.buffer_mut();
+ for y in 0..inner.height {
+ for x in 0..inner.width {
+ if sel.contains(y, x, scroll_metrics) {
+ let cell = &mut buf[(inner.x + x, inner.y + y)];
+ cell.set_style(Style::default().fg(panel_contrast_fg(p)).bg(p.blue));
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+ let lines = vec![
+ Line::from(""),
+ Line::from(""),
+ Line::from(Span::styled(
+ " No workspaces yet",
+ Style::default().fg(p.overlay0),
+ )),
+ Line::from(""),
+ Line::from(Span::styled(
+ " A workspace is one project context.",
+ Style::default().fg(p.overlay1),
+ )),
+ Line::from(Span::styled(
+ " Its root pane (top-left) sets the default repo or folder name.",
+ Style::default().fg(p.overlay1),
+ )),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" Press ", Style::default().fg(p.overlay0)),
+ Span::styled(
+ format!("{}", app.keybinds.new_workspace_label),
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" to create one", Style::default().fg(p.overlay0)),
+ ]),
+ ];
+ frame.render_widget(
+ Paragraph::new(lines).block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(p.surface_dim)),
+ ),
+ area,
+ );
+}
diff --git a/src/ui/release_notes.rs b/src/ui/release_notes.rs
new file mode 100644
index 00000000..346b06d5
--- /dev/null
+++ b/src/ui/release_notes.rs
@@ -0,0 +1,345 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Paragraph, Wrap},
+ Frame,
+};
+
+use super::scrollbar::{release_notes_scrollbar_rect, render_scrollbar};
+use super::widgets::{
+ action_button_width, modal_stack_areas, panel_contrast_fg, render_action_button,
+ render_modal_header, render_modal_shell,
+};
+use crate::app::{
+ state::{Palette, ReleaseNotesState},
+ AppState,
+};
+
+pub(crate) const RELEASE_NOTES_MODAL_SIZE: (u16, u16) = (80, 24);
+
+pub(super) fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(notes) = &app.release_notes else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_modal_shell(
+ frame,
+ area,
+ RELEASE_NOTES_MODAL_SIZE.0,
+ RELEASE_NOTES_MODAL_SIZE.1,
+ &app.palette,
+ ) else {
+ return;
+ };
+ if inner.height < 8 || inner.width < 20 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+
+ let header_title_area = Rect::new(
+ header_rows[0].x + 1,
+ header_rows[0].y,
+ header_rows[0].width.saturating_sub(2),
+ header_rows[0].height,
+ );
+ let header_subtitle_area = Rect::new(
+ header_rows[1].x + 1,
+ header_rows[1].y,
+ header_rows[1].width.saturating_sub(2),
+ header_rows[1].height,
+ );
+
+ render_modal_header(
+ frame,
+ header_title_area,
+ &format!("v{}", notes.version),
+ &app.palette,
+ );
+ let subtitle = if notes.preview {
+ "update ready"
+ } else {
+ "what's new in this release"
+ };
+ frame.render_widget(
+ Paragraph::new(subtitle).style(Style::default().fg(app.palette.overlay1)),
+ header_subtitle_area,
+ );
+ render_action_button(
+ frame,
+ release_notes_close_button_rect(header_rows[0]),
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(panel_contrast_fg(&app.palette))
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ let sections = release_notes_sections(stack.content, notes.preview);
+ let metrics = crate::pane::ScrollMetrics {
+ offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
+ max_offset_from_bottom: app.release_notes_max_scroll() as usize,
+ viewport_rows: sections.notes_body.height.max(1) as usize,
+ };
+ let track = release_notes_scrollbar_rect(sections.notes_body, metrics);
+ let notes_text_area = track
+ .map(|_| {
+ Rect::new(
+ sections.notes_body.x,
+ sections.notes_body.y,
+ sections.notes_body.width.saturating_sub(1),
+ sections.notes_body.height,
+ )
+ })
+ .unwrap_or(sections.notes_body);
+
+ if let Some(instructions_area) = sections.instructions {
+ render_release_notes_preview_panel(frame, instructions_area, &notes.version, &app.palette);
+ }
+
+ let body = Paragraph::new(
+ release_notes_display_lines(notes, &app.palette)
+ .into_iter()
+ .map(|(_, line)| line)
+ .collect::<Vec<_>>(),
+ )
+ .wrap(Wrap { trim: false })
+ .scroll((notes.scroll, 0));
+ frame.render_widget(body, notes_text_area);
+ if let Some(track) = track {
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ app.palette.overlay0,
+ app.palette.overlay1,
+ "▐",
+ );
+ }
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" scroll ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("wheel ↑↓", Style::default().fg(app.palette.text)),
+ Span::styled(" · ", Style::default().fg(app.palette.overlay0)),
+ Span::styled("close", Style::default().fg(app.palette.overlay0)),
+ Span::styled(" esc / enter ", Style::default().fg(app.palette.text)),
+ ])),
+ stack.footer.unwrap_or_default(),
+ );
+}
+
+fn release_notes_inline_spans<'a>(
+ text: &str,
+ base_style: Style,
+ code_style: Style,
+) -> (usize, Vec<Span<'a>>) {
+ let mut spans = Vec::new();
+ let mut width = 0;
+ let mut remaining = text;
+
+ while let Some(start) = remaining.find('`') {
+ let (before, after_start) = remaining.split_at(start);
+ if !before.is_empty() {
+ width += before.chars().count();
+ spans.push(Span::styled(before.to_string(), base_style));
+ }
+
+ let after_start = &after_start[1..];
+ let Some(end) = after_start.find('`') else {
+ let literal = format!("`{after_start}");
+ width += literal.chars().count();
+ spans.push(Span::styled(literal, base_style));
+ remaining = "";
+ break;
+ };
+
+ let (code, after_end) = after_start.split_at(end);
+ width += code.chars().count();
+ if !code.is_empty() {
+ spans.push(Span::styled(code.to_string(), code_style));
+ }
+ remaining = &after_end[1..];
+ }
+
+ if !remaining.is_empty() {
+ width += remaining.chars().count();
+ spans.push(Span::styled(remaining.to_string(), base_style));
+ }
+
+ if spans.is_empty() {
+ spans.push(Span::styled(String::new(), base_style));
+ }
+
+ (width, spans)
+}
+
+pub(crate) fn release_notes_lines<'a>(body: &'a str, p: &Palette) -> Vec<(usize, Line<'a>)> {
+ let mut lines = Vec::new();
+ let mut in_fenced_code_block = false;
+ let text_style = Style::default().fg(p.text);
+ let inline_code_style = Style::default()
+ .fg(p.accent)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD);
+
+ for raw in body.lines() {
+ let trimmed = raw.trim_end();
+ if trimmed.trim_start().starts_with("```") {
+ in_fenced_code_block = !in_fenced_code_block;
+ continue;
+ }
+
+ if in_fenced_code_block {
+ let code_bg = p.surface1;
+ let gutter_style = Style::default().fg(p.accent).bg(code_bg);
+ let code_style = Style::default().fg(p.text).bg(code_bg);
+ let width = 2 + trimmed.chars().count();
+ let mut spans = vec![
+ Span::styled("▏", gutter_style),
+ Span::styled(" ", code_style),
+ ];
+ if !trimmed.is_empty() {
+ spans.push(Span::styled(trimmed.to_string(), code_style));
+ }
+ lines.push((width, Line::from(spans)));
+ continue;
+ }
+
+ if trimmed.is_empty() {
+ lines.push((0, Line::raw("")));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("### ") {
+ let text = rest.trim().to_string();
+ if text.is_empty() {
+ lines.push((0, Line::raw("")));
+ continue;
+ }
+ let width = 1 + text.chars().count();
+ lines.push((
+ width,
+ Line::from(vec![
+ Span::raw(" "),
+ Span::styled(
+ text.to_uppercase(),
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ ]),
+ ));
+ continue;
+ }
+
+ if let Some(rest) = trimmed.strip_prefix("- ") {
+ let (text_width, mut spans) =
+ release_notes_inline_spans(rest, text_style, inline_code_style);
+ let width = 3 + text_width;
+ let mut line_spans = vec![Span::styled(" • ", Style::default().fg(p.accent))];
+ line_spans.append(&mut spans);
+ lines.push((width, Line::from(line_spans)));
+ continue;
+ }
+
+ let (text_width, mut spans) =
+ release_notes_inline_spans(trimmed, text_style, inline_code_style);
+ let width = 1 + text_width;
+ let mut line_spans = vec![Span::raw(" ")];
+ line_spans.append(&mut spans);
+ lines.push((width, Line::from(line_spans)));
+ }
+
+ lines
+}
+
+pub(crate) struct ReleaseNotesSections {
+ pub instructions: Option<Rect>,
+ pub notes_body: Rect,
+}
+
+pub(crate) fn release_notes_sections(area: Rect, preview: bool) -> ReleaseNotesSections {
+ if preview && area.height >= 6 {
+ let rows = Layout::vertical([Constraint::Length(5), Constraint::Min(0)]).areas::<2>(area);
+ ReleaseNotesSections {
+ instructions: Some(rows[0]),
+ notes_body: rows[1],
+ }
+ } else {
+ ReleaseNotesSections {
+ instructions: None,
+ notes_body: area,
+ }
+ }
+}
+
+pub(super) fn release_notes_preview_lines<'a>(_version: &str, p: &Palette) -> Vec<Line<'a>> {
+ let title_style = Style::default().fg(p.text).add_modifier(Modifier::BOLD);
+ let text_style = Style::default().fg(p.text);
+ let code_style = Style::default()
+ .fg(p.accent)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD);
+
+ vec![
+ Line::from(vec![
+ Span::styled(
+ "●",
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" update ready", title_style),
+ ]),
+ Line::from(vec![
+ Span::styled("detach from this session, then run ", text_style),
+ Span::styled("herdr update", code_style),
+ Span::styled(" in your shell", text_style),
+ ]),
+ ]
+}
+
+fn render_release_notes_preview_panel(frame: &mut Frame, area: Rect, _version: &str, p: &Palette) {
+ let rows = Layout::vertical([
+ Constraint::Length(2),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<4>(area);
+
+ let text_area = Rect::new(
+ rows[0].x + 1,
+ rows[0].y,
+ rows[0].width.saturating_sub(2),
+ rows[0].height,
+ );
+ frame.render_widget(
+ Paragraph::new(release_notes_preview_lines(_version, p)).wrap(Wrap { trim: false }),
+ text_area,
+ );
+
+ let divider_area = Rect::new(rows[2].x + 1, rows[2].y, rows[2].width.saturating_sub(2), 1);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ "─".repeat(divider_area.width as usize),
+ Style::default().fg(p.surface1),
+ )])),
+ divider_area,
+ );
+}
+
+pub(crate) fn release_notes_display_lines<'a>(
+ notes: &'a ReleaseNotesState,
+ p: &Palette,
+) -> Vec<(usize, Line<'a>)> {
+ release_notes_lines(notes.body.as_str(), p)
+}
+
+pub(crate) fn release_notes_close_button_rect(area: Rect) -> Rect {
+ let width = action_button_width(Some("esc"), "close");
+ Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1)
+}
diff --git a/src/ui/scrollbar.rs b/src/ui/scrollbar.rs
new file mode 100644
index 00000000..34e739d0
--- /dev/null
+++ b/src/ui/scrollbar.rs
@@ -0,0 +1,191 @@
+use ratatui::{
+ layout::Rect,
+ style::{Color, Style},
+ Frame,
+};
+
+use crate::app::AppState;
+use crate::layout::PaneInfo;
+
+pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
+ info.scrollbar_rect
+}
+
+pub(crate) fn release_notes_scrollbar_rect(
+ body: Rect,
+ metrics: crate::pane::ScrollMetrics,
+) -> Option<Rect> {
+ (should_show_scrollbar(metrics) && body.width > 1).then_some(Rect::new(
+ body.x + body.width - 1,
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
+ metrics.max_offset_from_bottom > 0
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct ScrollbarThumb {
+ pub top: u16,
+ pub len: u16,
+}
+
+pub(crate) fn scrollbar_thumb(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+) -> Option<ScrollbarThumb> {
+ if metrics.max_offset_from_bottom == 0 || track.height == 0 {
+ return None;
+ }
+
+ let track_height = track.height as usize;
+ let total_rows = metrics.max_offset_from_bottom + metrics.viewport_rows;
+ if total_rows == 0 {
+ return None;
+ }
+
+ let thumb_len = ((metrics.viewport_rows * track_height) as f32 / total_rows as f32)
+ .round()
+ .max(1.0)
+ .min(track_height as f32) as usize;
+ let max_thumb_top = track_height.saturating_sub(thumb_len);
+ let scrolled_from_top = metrics
+ .max_offset_from_bottom
+ .saturating_sub(metrics.offset_from_bottom);
+ let thumb_top = if max_thumb_top == 0 || metrics.max_offset_from_bottom == 0 {
+ 0
+ } else {
+ ((scrolled_from_top * max_thumb_top) as f32 / metrics.max_offset_from_bottom as f32)
+ .round()
+ .clamp(0.0, max_thumb_top as f32) as usize
+ };
+
+ Some(ScrollbarThumb {
+ top: track.y + thumb_top as u16,
+ len: thumb_len as u16,
+ })
+}
+
+pub(crate) fn scrollbar_thumb_grab_offset(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ row: u16,
+) -> Option<u16> {
+ let thumb = scrollbar_thumb(metrics, track)?;
+ (row >= thumb.top && row < thumb.top + thumb.len).then_some(row - thumb.top)
+}
+
+fn scrollbar_offset_from_thumb_top(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ thumb_top: usize,
+) -> usize {
+ if metrics.max_offset_from_bottom == 0 {
+ return 0;
+ }
+
+ let thumb_len = scrollbar_thumb(metrics, track)
+ .map(|thumb| thumb.len as usize)
+ .unwrap_or(1);
+ let max_thumb_top = track.height as usize - thumb_len.min(track.height as usize);
+ if max_thumb_top == 0 {
+ return 0;
+ }
+
+ let desired_top = thumb_top.min(max_thumb_top);
+ let scrolled_from_top = ((desired_top * metrics.max_offset_from_bottom) as f32
+ / max_thumb_top as f32)
+ .round() as usize;
+ metrics
+ .max_offset_from_bottom
+ .saturating_sub(scrolled_from_top)
+}
+
+pub(crate) fn scrollbar_offset_from_row(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ row: u16,
+) -> usize {
+ let thumb = match scrollbar_thumb(metrics, track) {
+ Some(thumb) => thumb,
+ None => return 0,
+ };
+ let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
+ let row_offset = clamped_row.saturating_sub(track.y) as usize;
+ let thumb_center = (thumb.len as usize) / 2;
+ let desired_top = row_offset.saturating_sub(thumb_center);
+ scrollbar_offset_from_thumb_top(metrics, track, desired_top)
+}
+
+pub(crate) fn scrollbar_offset_from_drag_row(
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ row: u16,
+ grab_row_offset: u16,
+) -> usize {
+ let clamped_row = row.clamp(track.y, track.y + track.height.saturating_sub(1));
+ let row_offset = clamped_row.saturating_sub(track.y) as usize;
+ let desired_top = row_offset.saturating_sub(grab_row_offset as usize);
+ scrollbar_offset_from_thumb_top(metrics, track, desired_top)
+}
+
+pub(super) fn render_scrollbar(
+ frame: &mut Frame,
+ metrics: crate::pane::ScrollMetrics,
+ track: Rect,
+ track_color: Color,
+ thumb_color: Color,
+ thumb_symbol: &str,
+) {
+ if metrics.max_offset_from_bottom == 0 {
+ return;
+ }
+
+ let Some(thumb) = scrollbar_thumb(metrics, track) else {
+ return;
+ };
+
+ let buf = frame.buffer_mut();
+ for y in track.y..track.y + track.height {
+ let cell = &mut buf[(track.x, y)];
+ cell.set_symbol("▕");
+ cell.set_style(Style::default().fg(track_color));
+ }
+ for y in thumb.top..thumb.top + thumb.len {
+ let cell = &mut buf[(track.x, y)];
+ cell.set_symbol(thumb_symbol);
+ cell.set_style(Style::default().fg(thumb_color));
+ }
+}
+
+pub(super) fn render_pane_scrollbar(
+ app: &AppState,
+ frame: &mut Frame,
+ info: &PaneInfo,
+ rt: &crate::pane::PaneRuntime,
+) {
+ let Some(metrics) = rt.scroll_metrics() else {
+ return;
+ };
+ let Some(track) = pane_scrollbar_rect(info) else {
+ return;
+ };
+
+ let (track_color, thumb_color, thumb_symbol) = if info.is_focused {
+ (app.palette.overlay0, app.palette.overlay1, "▐")
+ } else {
+ (app.palette.surface_dim, app.palette.overlay0, "▕")
+ };
+
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ track_color,
+ thumb_color,
+ thumb_symbol,
+ );
+}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
new file mode 100644
index 00000000..1123f408
--- /dev/null
+++ b/src/ui/settings.rs
@@ -0,0 +1,238 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{List, ListItem, ListState, Paragraph, Tabs},
+ Frame,
+};
+
+use super::widgets::{
+ action_button_row_rects, centered_popup_rect, modal_stack_areas, panel_contrast_fg,
+ render_action_button, render_panel_shell, ActionButtonSpec,
+};
+use crate::app::{state::Palette, AppState};
+
+pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ use crate::app::state::SettingsSection;
+
+ let p = &app.palette;
+ let Some(popup) = centered_popup_rect(area, 56, 20) else {
+ return;
+ };
+
+ super::dim_background(frame, area);
+
+ let Some(inner) = render_panel_shell(frame, popup, p.accent, p.panel_bg) else {
+ return;
+ };
+ if inner.height < 4 || inner.width < 10 {
+ return;
+ }
+
+ let stack = modal_stack_areas(inner, 3, 2, 0, 1);
+ let header_rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<3>(stack.header);
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " settings",
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD),
+ )])),
+ header_rows[0],
+ );
+
+ let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label()))
+ .select(
+ SettingsSection::ALL
+ .iter()
+ .position(|section| *section == app.settings.section)
+ .unwrap_or(0),
+ )
+ .style(Style::default().fg(p.overlay1))
+ .highlight_style(
+ Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD),
+ )
+ .divider(" ")
+ .padding(" ", " ");
+ frame.render_widget(tabs, header_rows[1]);
+
+ let sep = "─".repeat(inner.width as usize);
+ frame.render_widget(
+ Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
+ header_rows[2],
+ );
+
+ let content_area = stack.content;
+
+ match app.settings.section {
+ SettingsSection::Theme => {
+ render_settings_theme(app, frame, content_area);
+ }
+ SettingsSection::Sound => {
+ render_settings_toggle(
+ frame,
+ content_area,
+ p,
+ "sound alerts",
+ "play sounds when agents change state in background",
+ app.sound_enabled(),
+ app.settings.list.selected,
+ );
+ }
+ SettingsSection::Toast => {
+ render_settings_toggle(
+ frame,
+ content_area,
+ p,
+ "visual toasts",
+ "show top-right notifications for background events",
+ app.toast_config.enabled,
+ app.settings.list.selected,
+ );
+ }
+ }
+
+ if let Some(footer_area) = stack.footer {
+ let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
+ .areas::<2>(footer_area);
+ let (apply_rect, close_rect) = settings_button_rects(inner);
+ render_action_button(
+ frame,
+ apply_rect,
+ Some("↵"),
+ "apply",
+ Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD),
+ );
+ render_action_button(
+ frame,
+ close_rect,
+ Some("esc"),
+ "close",
+ Style::default()
+ .fg(p.text)
+ .bg(p.surface0)
+ .add_modifier(Modifier::BOLD),
+ );
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(" ↑↓", Style::default().fg(p.overlay0)),
+ Span::styled(" select ", Style::default().fg(p.overlay1)),
+ Span::styled("tab", Style::default().fg(p.overlay0)),
+ Span::styled(" section", Style::default().fg(p.overlay1)),
+ ])),
+ footer_rows[0],
+ );
+ }
+}
+
+pub(crate) fn settings_button_rects(inner: Rect) -> (Rect, Rect) {
+ let rects = action_button_row_rects(
+ inner,
+ &[
+ ActionButtonSpec {
+ hint: Some("↵"),
+ label: "apply",
+ },
+ ActionButtonSpec {
+ hint: Some("esc"),
+ label: "close",
+ },
+ ],
+ 2,
+ inner.height.saturating_sub(1),
+ );
+ (rects[0], rects[1])
+}
+
+fn render_settings_theme(app: &AppState, frame: &mut Frame, area: Rect) {
+ use crate::app::state::THEME_NAMES;
+
+ let p = &app.palette;
+ let items: Vec<ListItem> = THEME_NAMES
+ .iter()
+ .map(|name| {
+ let is_current = name.to_lowercase().replace([' ', '_'], "-")
+ == app.theme_name.to_lowercase().replace([' ', '_'], "-");
+ let marker = if is_current { " ✓" } else { "" };
+ ListItem::new(Line::from(vec![
+ Span::styled(*name, Style::default().fg(p.subtext0)),
+ Span::styled(marker, Style::default().fg(p.green)),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(
+ Style::default()
+ .bg(p.surface0)
+ .fg(p.text)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ▸ ")
+ .style(Style::default().fg(p.subtext0));
+
+ let mut state = ListState::default().with_selected(Some(app.settings.list.selected));
+ frame.render_stateful_widget(list, area, &mut state);
+}
+
+fn render_settings_toggle(
+ frame: &mut Frame,
+ area: Rect,
+ p: &Palette,
+ title: &str,
+ description: &str,
+ current_value: bool,
+ selected_idx: usize,
+) {
+ let [desc_area, _, list_area] = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(2),
+ ])
+ .areas::<3>(area);
+
+ let max_desc_len = (desc_area.width as usize).saturating_sub(2);
+ let desc_text = if description.len() > max_desc_len {
+ format!(" {}…", &description[..max_desc_len.saturating_sub(2)])
+ } else {
+ format!(" {description}")
+ };
+ frame.render_widget(
+ Paragraph::new(Span::styled(desc_text, Style::default().fg(p.overlay1))),
+ desc_area,
+ );
+
+ let items: Vec<ListItem> = ["on", "off"]
+ .into_iter()
+ .map(|label| {
+ let is_active = (label == "on") == current_value;
+ let marker = if is_active { " ✓" } else { "" };
+ ListItem::new(Line::from(vec![
+ Span::styled(format!("{title}: {label}"), Style::default().fg(p.subtext0)),
+ Span::styled(marker, Style::default().fg(p.green)),
+ ]))
+ })
+ .collect();
+
+ let list = List::new(items)
+ .highlight_style(
+ Style::default()
+ .bg(p.surface0)
+ .fg(p.text)
+ .add_modifier(Modifier::BOLD),
+ )
+ .highlight_symbol(" ▸ ");
+ let mut state = ListState::default().with_selected(Some(selected_idx.min(1)));
+ frame.render_stateful_widget(list, list_area, &mut state);
+}
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
new file mode 100644
index 00000000..36d1385a
--- /dev/null
+++ b/src/ui/sidebar.rs
@@ -0,0 +1,940 @@
+use ratatui::{
+ layout::{Alignment, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::scrollbar::{render_scrollbar, should_show_scrollbar};
+use super::status::{agent_icon, state_dot, state_label, state_label_color};
+use crate::app::state::{AgentPanelScope, Palette};
+use crate::app::{AppState, Mode};
+use crate::detect::AgentState;
+
+const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
+const AGENT_PANEL_HEADER_ROWS: u16 = 3;
+
+pub(crate) struct AgentPanelEntry {
+ pub ws_idx: usize,
+ pub tab_idx: usize,
+ pub pane_id: crate::layout::PaneId,
+ pub primary_label: String,
+ pub primary_tab_label: Option<String>,
+ pub agent_label: Option<String>,
+ pub state: AgentState,
+ pub seen: bool,
+}
+
+fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
+ if total_h == 0 {
+ return (0, 0);
+ }
+
+ if total_h < 6 {
+ let ws_h = (total_h + 1) / 2;
+ return (ws_h, total_h.saturating_sub(ws_h));
+ }
+
+ let ratio = split_ratio.clamp(0.1, 0.9);
+ let ws_h = ((total_h as f32) * ratio).round() as u16;
+ let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
+ let detail_h = total_h.saturating_sub(ws_h);
+ (ws_h, detail_h)
+}
+
+pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height == 0 {
+ return (Rect::default(), Rect::default());
+ }
+
+ let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
+ let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
+ let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
+ (ws_area, detail_area)
+}
+
+pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height < 6 {
+ return Rect::default();
+ }
+
+ let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
+ Rect::new(content.x, content.y + ws_h, content.width, 1)
+}
+
+fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
+ if matches!(
+ app.mode,
+ Mode::Navigate
+ | Mode::RenameWorkspace
+ | Mode::Resize
+ | Mode::ConfirmClose
+ | Mode::ContextMenu
+ | Mode::Settings
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ ) {
+ Some(app.selected)
+ } else {
+ app.active
+ }
+}
+
+fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
+ match scope {
+ AgentPanelScope::CurrentWorkspace => "current",
+ AgentPanelScope::AllWorkspaces => "all",
+ }
+}
+
+pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
+ if area.width == 0 || area.height < 2 {
+ return Rect::default();
+ }
+
+ let label = agent_panel_toggle_label(scope);
+ let width = label.chars().count() as u16;
+ Rect::new(
+ area.x + area.width.saturating_sub(width),
+ area.y + 1,
+ width,
+ 1,
+ )
+}
+
+pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
+ match app.agent_panel_scope {
+ AgentPanelScope::CurrentWorkspace => {
+ let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
+ return Vec::new();
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+ ws.pane_details()
+ .into_iter()
+ .map(|detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ primary_label: detail.label,
+ primary_tab_label: None,
+ agent_label: None,
+ state: detail.state,
+ seen: detail.seen,
+ })
+ .collect()
+ }
+ AgentPanelScope::AllWorkspaces => app
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ let multi_tab = ws.tabs.len() > 1;
+ let workspace_label = ws.display_name();
+ ws.pane_details()
+ .into_iter()
+ .map(move |detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ primary_label: workspace_label.clone(),
+ primary_tab_label: multi_tab.then_some(detail.tab_label),
+ agent_label: Some(detail.agent_label),
+ state: detail.state,
+ seen: detail.seen,
+ })
+ })
+ .collect(),
+ }
+}
+
+fn truncate_text(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width == 0 {
+ return String::new();
+ }
+ if max_width == 1 {
+ return "…".to_string();
+ }
+ let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
+ format!("{prefix}…")
+}
+
+fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
+ let Some(tab_label) = entry.primary_tab_label.as_deref() else {
+ return truncate_text(&entry.primary_label, max_width);
+ };
+
+ let separator = " · ";
+ let separator_width = separator.chars().count();
+ if max_width <= separator_width + 2 {
+ return truncate_text(
+ &format!("{}{}{}", entry.primary_label, separator, tab_label),
+ max_width,
+ );
+ }
+
+ let available = max_width.saturating_sub(separator_width);
+ let min_tab = 4.min(available.saturating_sub(1)).max(1);
+ let preferred_workspace = ((available * 2) / 3).max(1);
+ let mut workspace_budget = preferred_workspace
+ .min(available.saturating_sub(min_tab))
+ .max(1);
+ let mut tab_budget = available.saturating_sub(workspace_budget);
+
+ let workspace_len = entry.primary_label.chars().count();
+ let tab_len = tab_label.chars().count();
+
+ if workspace_len < workspace_budget {
+ let spare = workspace_budget - workspace_len;
+ workspace_budget = workspace_len;
+ tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
+ }
+ if tab_len < tab_budget {
+ let spare = tab_budget - tab_len;
+ tab_budget = tab_len;
+ workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
+ }
+
+ format!(
+ "{}{}{}",
+ truncate_text(&entry.primary_label, workspace_budget),
+ separator,
+ truncate_text(tab_label, tab_budget)
+ )
+}
+
+fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
+ if ws.branch().is_some() {
+ 2
+ } else {
+ 1
+ }
+}
+
+pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
+ let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
+ ws_area
+}
+
+pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
+ if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS {
+ return Rect::default();
+ }
+
+ let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS);
+ let footer_y = area.y + area.height.saturating_sub(1);
+ let body_height = footer_y.saturating_sub(body_y);
+ let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
+ Rect::new(area.x, body_y, body_width, body_height)
+}
+
+fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize {
+ let body = workspace_list_body_rect(area, false);
+ if body.width == 0 || body.height == 0 {
+ return 0;
+ }
+
+ let mut used_rows = 0u16;
+ let mut visible = 0usize;
+ for ws in app.workspaces.iter().skip(scroll) {
+ let needed = workspace_row_height(ws).saturating_add(1);
+ if used_rows.saturating_add(needed) > body.height {
+ break;
+ }
+ used_rows = used_rows.saturating_add(needed);
+ visible += 1;
+ }
+ visible
+}
+
+pub(crate) fn workspace_list_scroll_metrics(
+ app: &AppState,
+ area: Rect,
+) -> crate::pane::ScrollMetrics {
+ let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
+ let total_rows = app.workspaces.len();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ let offset_from_bottom = total_rows
+ .saturating_sub(app.workspace_scroll)
+ .saturating_sub(viewport_rows);
+
+ crate::pane::ScrollMetrics {
+ offset_from_bottom,
+ max_offset_from_bottom,
+ viewport_rows,
+ }
+}
+
+pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
+ let metrics = workspace_list_scroll_metrics(app, area);
+ let body = workspace_list_body_rect(area, true);
+ (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
+ if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS {
+ return Rect::default();
+ }
+
+ let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS);
+ let body_height = (area.y + area.height).saturating_sub(body_y);
+ let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
+ Rect::new(area.x, body_y, body_width, body_height)
+}
+
+fn agent_panel_visible_count(area: Rect) -> usize {
+ let body = agent_panel_body_rect(area, false);
+ if body.width == 0 || body.height < 2 {
+ return 0;
+ }
+
+ let mut used_rows = 0u16;
+ let mut visible = 0usize;
+ while used_rows.saturating_add(2) <= body.height {
+ used_rows = used_rows.saturating_add(2);
+ visible += 1;
+ if used_rows < body.height {
+ used_rows = used_rows.saturating_add(1);
+ }
+ }
+ visible
+}
+
+pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
+ let viewport_rows = agent_panel_visible_count(area);
+ let total_rows = agent_panel_entries(app).len();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ let offset_from_bottom = total_rows
+ .saturating_sub(app.agent_panel_scroll)
+ .saturating_sub(viewport_rows);
+
+ crate::pane::ScrollMetrics {
+ offset_from_bottom,
+ max_offset_from_bottom,
+ viewport_rows,
+ }
+}
+
+pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
+ let metrics = agent_panel_scroll_metrics(app, area);
+ let body = agent_panel_body_rect(area, true);
+ (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn compute_workspace_card_areas(
+ app: &AppState,
+ area: Rect,
+) -> Vec<crate::app::state::WorkspaceCardArea> {
+ let ws_area = workspace_list_rect(area, app.sidebar_section_split);
+ if ws_area == Rect::default() {
+ return Vec::new();
+ }
+
+ let metrics = workspace_list_scroll_metrics(app, ws_area);
+ let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
+ if body.width == 0 || body.height == 0 {
+ return Vec::new();
+ }
+
+ let mut row_y = body.y;
+ let body_bottom = body.y + body.height;
+ let mut cards = Vec::new();
+
+ for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
+ let row_height = workspace_row_height(ws);
+ if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
+ break;
+ }
+ cards.push(crate::app::state::WorkspaceCardArea {
+ ws_idx,
+ rect: Rect::new(body.x, row_y, body.width, row_height),
+ });
+ row_y = row_y.saturating_add(row_height + 1);
+ }
+
+ cards
+}
+
+/// Auto-scale sidebar width based on workspace identity + agent summary.
+pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height == 0 {
+ return (Rect::default(), None, Rect::default());
+ }
+
+ if content.height < 7 {
+ return (content, None, Rect::default());
+ }
+
+ let total_h = content.height as usize;
+ let ws_h = (total_h + 1) / 2;
+ let detail_h = total_h.saturating_sub(ws_h + 1);
+ if ws_h == 0 || detail_h == 0 {
+ return (content, None, Rect::default());
+ }
+
+ let divider_y = content.y + ws_h as u16;
+ let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
+ let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16);
+ (ws_area, Some(divider_y), detail_area)
+}
+
+/// Collapsed sidebar: workspace glance on top, compact agent list below.
+pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
+ let is_navigating = matches!(app.mode, Mode::Navigate);
+
+ let p = &app.palette;
+ let sep_style = if is_navigating {
+ Style::default().fg(p.accent)
+ } else {
+ Style::default().fg(p.surface_dim)
+ };
+ let sep_x = area.x + area.width.saturating_sub(1);
+ let buf = frame.buffer_mut();
+ for y in area.y..area.y + area.height {
+ buf[(sep_x, y)].set_symbol("│");
+ buf[(sep_x, y)].set_style(sep_style);
+ }
+
+ let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
+ if ws_area == Rect::default() {
+ render_sidebar_toggle(app, frame, area, true, p);
+ return;
+ }
+
+ for (visible_idx, ws) in app.workspaces.iter().enumerate() {
+ let y = ws_area.y + visible_idx as u16;
+ if y >= ws_area.y + ws_area.height {
+ break;
+ }
+ let (agg_state, agg_seen) = ws.aggregate_state();
+ let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
+ let is_selected = visible_idx == app.selected && is_navigating;
+ let is_active = Some(visible_idx) == app.active;
+ let row_style = if is_selected {
+ Style::default().bg(p.surface0)
+ } else if is_active {
+ Style::default().bg(p.surface_dim)
+ } else {
+ Style::default()
+ };
+ let num_style = if is_selected {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ } else if is_active {
+ Style::default().fg(p.text).bg(p.surface_dim)
+ } else {
+ Style::default().fg(p.overlay0)
+ };
+
+ if is_selected || is_active {
+ let buf = frame.buffer_mut();
+ for x in ws_area.x..ws_area.x + ws_area.width {
+ buf[(x, y)].set_style(row_style);
+ }
+ }
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{}", visible_idx + 1), num_style),
+ Span::styled(" ", row_style),
+ Span::styled(icon, icon_style),
+ ])),
+ Rect::new(ws_area.x, y, ws_area.width, 1),
+ );
+ }
+
+ if let Some(divider_y) = divider_y {
+ let buf = frame.buffer_mut();
+ for x in ws_area.x..ws_area.x + ws_area.width {
+ buf[(x, divider_y)].set_symbol("─");
+ buf[(x, divider_y)].set_style(Style::default().fg(p.surface_dim));
+ }
+ }
+
+ let detail_ws_idx = if is_navigating {
+ Some(app.selected)
+ } else {
+ app.active
+ };
+ 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() {
+ if let Some(ws_idx) = detail_ws_idx {
+ if let Some(ws) = app.workspaces.get(ws_idx) {
+ for (detail_idx, detail) in ws.pane_details().iter().enumerate() {
+ let y = detail_content_area.y + detail_idx as u16;
+ if y >= detail_content_area.y + detail_content_area.height {
+ break;
+ }
+ let pane_num = ws
+ .public_pane_number(detail.pane_id)
+ .unwrap_or(detail_idx + 1);
+ let pane_style = Style::default().fg(p.overlay0);
+ let (icon, icon_style) =
+ agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{pane_num}"), pane_style),
+ Span::styled(" ", pane_style),
+ Span::styled(icon, icon_style),
+ ])),
+ Rect::new(detail_content_area.x, y, detail_content_area.width, 1),
+ );
+ }
+ }
+ }
+ }
+
+ render_sidebar_toggle(app, frame, area, true, p);
+}
+
+pub(crate) fn workspace_drop_indicator_row(
+ cards: &[crate::app::state::WorkspaceCardArea],
+ area: Rect,
+ insert_idx: usize,
+) -> Option<u16> {
+ if area.height == 0 {
+ return None;
+ }
+ let list_bottom = area.y + area.height.saturating_sub(1);
+
+ let first = cards.first()?;
+ if insert_idx == first.ws_idx {
+ return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ }
+
+ if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
+ return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ }
+
+ cards
+ .last()
+ .filter(|card| insert_idx == card.ws_idx.saturating_add(1))
+ .map(|card| card.rect.y.saturating_add(card.rect.height))
+ .filter(|y| *y < list_bottom)
+}
+
+pub(super) fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+ let is_navigating = matches!(app.mode, Mode::Navigate);
+ let sep_style = if is_navigating {
+ Style::default().fg(p.accent)
+ } else {
+ Style::default().fg(p.surface_dim)
+ };
+
+ let sep_x = area.x + area.width.saturating_sub(1);
+ let buf = frame.buffer_mut();
+ for y in area.y..area.y + area.height {
+ buf[(sep_x, y)].set_symbol("│");
+ buf[(sep_x, y)].set_style(sep_style);
+ }
+
+ let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+
+ render_workspace_list(app, frame, ws_area, is_navigating);
+ render_agent_detail(app, frame, detail_area);
+ render_sidebar_toggle(app, frame, area, false, p);
+}
+
+fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) {
+ let p = &app.palette;
+ let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) {
+ Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => {
+ Some(*source_ws_idx)
+ }
+ _ => None,
+ };
+ let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) {
+ Some(crate::app::state::DragTarget::WorkspaceReorder {
+ insert_idx: Some(insert_idx),
+ ..
+ }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx),
+ _ => None,
+ };
+
+ let list_bottom = area.y + area.height.saturating_sub(1);
+ if area.height > 0 {
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " spaces",
+ Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ )])),
+ Rect::new(area.x, area.y, area.width, 1),
+ );
+ }
+
+ let metrics = workspace_list_scroll_metrics(app, area);
+ let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
+ let cards = &app.view.workspace_card_areas;
+
+ for card in cards {
+ let i = card.ws_idx;
+ let ws = &app.workspaces[i];
+ let row_y = card.rect.y;
+ let row_height = card.rect.height;
+ let selected = i == app.selected && is_navigating;
+ let is_active = Some(i) == app.active;
+ let is_dragged = dragged_ws_idx == Some(i);
+ let highlighted = selected || is_active || is_dragged;
+ let (agg_state, agg_seen) = ws.aggregate_state();
+
+ if highlighted {
+ let bg = if selected {
+ p.surface0
+ } else if is_dragged {
+ p.surface1
+ } else {
+ p.surface_dim
+ };
+ let buf = frame.buffer_mut();
+ for y in row_y..row_y + row_height {
+ if y >= list_bottom {
+ break;
+ }
+ for x in card.rect.x..card.rect.x + card.rect.width {
+ buf[(x, y)].set_style(Style::default().bg(bg));
+ }
+ }
+ }
+
+ if is_active {
+ let buf = frame.buffer_mut();
+ for y in row_y..row_y + row_height {
+ if y >= list_bottom {
+ break;
+ }
+ buf[(card.rect.x, y)].set_symbol("▌");
+ buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
+ }
+ }
+
+ let name_style = if selected || is_active || is_dragged {
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.subtext0)
+ };
+
+ let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
+ let line1 = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(icon, icon_style),
+ Span::styled(" ", Style::default()),
+ Span::styled(ws.display_name(), name_style),
+ ];
+
+ frame.render_widget(
+ Paragraph::new(Line::from(line1)),
+ Rect::new(card.rect.x, row_y, card.rect.width, 1),
+ );
+
+ if row_height > 1 && row_y + 1 < list_bottom {
+ if let Some(branch) = ws.branch() {
+ let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
+ let mut parts = Vec::new();
+ if ahead > 0 {
+ parts.push((format!("↑{}", ahead), p.green));
+ }
+ if behind > 0 {
+ parts.push((format!("↓{}", behind), p.red));
+ }
+ (!parts.is_empty()).then_some(parts)
+ });
+ let reserved = upstream_label
+ .as_ref()
+ .map(|parts| {
+ parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
+ })
+ .unwrap_or(0);
+ let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved);
+ let branch_display = if branch.len() > max_branch_len {
+ format!("{}…", &branch[..max_branch_len.saturating_sub(1)])
+ } else {
+ branch
+ };
+ let branch_color = if selected || is_active {
+ p.mauve
+ } else {
+ p.overlay0
+ };
+ let mut spans = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(branch_display, Style::default().fg(branch_color)),
+ ];
+ if let Some(parts) = upstream_label {
+ spans.push(Span::styled(" ", Style::default()));
+ for (idx, (label, color)) in parts.into_iter().enumerate() {
+ if idx > 0 {
+ spans.push(Span::styled(" ", Style::default()));
+ }
+ spans.push(Span::styled(label, Style::default().fg(color)));
+ }
+ }
+ frame.render_widget(
+ Paragraph::new(Line::from(spans)),
+ Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
+ );
+ }
+ }
+ }
+
+ if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
+ let indicator_right = scrollbar_rect
+ .map(|rect| rect.x)
+ .unwrap_or(area.x + area.width);
+ let buf = frame.buffer_mut();
+ for x in area.x..indicator_right {
+ buf[(x, y)].set_symbol("─");
+ buf[(x, y)].set_style(Style::default().fg(p.accent));
+ }
+ }
+
+ if let Some(track) = scrollbar_rect {
+ render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
+ }
+
+ if list_bottom > area.y {
+ let new_rect = app.sidebar_new_button_rect();
+ frame.render_widget(
+ Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))),
+ new_rect,
+ );
+
+ let menu_rect = app.global_launcher_rect();
+ let menu_line = if app.update_available.is_some() {
+ Line::from(vec![
+ Span::styled("menu", Style::default().fg(p.overlay0)),
+ Span::styled(
+ " ●",
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ ])
+ } else {
+ Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))])
+ };
+ frame.render_widget(
+ Paragraph::new(menu_line).alignment(Alignment::Right),
+ menu_rect,
+ );
+ }
+}
+
+fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+
+ if area.height < 3 {
+ return;
+ }
+
+ let sep_line = "─".repeat(area.width as usize);
+ frame.render_widget(
+ Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))),
+ Rect::new(area.x, area.y, area.width, 1),
+ );
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " agents",
+ Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ )])),
+ Rect::new(area.x, area.y + 1, area.width, 1),
+ );
+ let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
+ if toggle_rect != Rect::default() {
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ agent_panel_toggle_label(app.agent_panel_scope),
+ Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ ))
+ .alignment(Alignment::Right),
+ toggle_rect,
+ );
+ }
+
+ let details = agent_panel_entries(app);
+ let metrics = agent_panel_scroll_metrics(app, area);
+ let scrollbar_rect = agent_panel_scrollbar_rect(app, area);
+ let body = agent_panel_body_rect(area, should_show_scrollbar(metrics));
+ if body == Rect::default() {
+ return;
+ }
+
+ let mut row_y = body.y;
+ let body_bottom = body.y + body.height;
+ for detail in details.iter().skip(app.agent_panel_scroll) {
+ if row_y.saturating_add(1) >= body_bottom {
+ break;
+ }
+
+ let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+ let label_color = state_label_color(detail.state, detail.seen, p);
+ let label = state_label(detail.state, detail.seen);
+
+ let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD);
+ let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM);
+ let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
+
+ let primary_label =
+ format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
+ let name_line = Line::from(vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(icon, icon_style),
+ Span::styled(" ", Style::default()),
+ Span::styled(primary_label, name_style),
+ ]);
+ frame.render_widget(
+ Paragraph::new(name_line),
+ Rect::new(body.x, row_y, body.width, 1),
+ );
+ row_y += 1;
+
+ let mut status_spans = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(label, status_style),
+ ];
+ if let Some(agent_label) = &detail.agent_label {
+ status_spans.push(Span::styled(" · ", agent_style));
+ status_spans.push(Span::styled(agent_label, agent_style));
+ }
+ frame.render_widget(
+ Paragraph::new(Line::from(status_spans)),
+ Rect::new(body.x, row_y, body.width, 1),
+ );
+ row_y += 1;
+
+ if row_y < body_bottom {
+ row_y += 1;
+ }
+ }
+
+ if let Some(track) = scrollbar_rect {
+ render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
+ }
+}
+
+pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect {
+ let bottom_y = area.y + area.height.saturating_sub(1);
+ let content_w = area.width.saturating_sub(1);
+ if content_w == 0 || area.height == 0 {
+ return Rect::default();
+ }
+ let x = area.x + content_w / 2;
+ Rect::new(x, bottom_y, 1, 1)
+}
+
+fn render_sidebar_toggle(
+ app: &AppState,
+ frame: &mut Frame,
+ area: Rect,
+ collapsed: bool,
+ p: &Palette,
+) {
+ if !collapsed {
+ return;
+ }
+ let toggle_area = collapsed_sidebar_toggle_rect(area);
+ if toggle_area == Rect::default() {
+ return;
+ }
+ let icon_style = if app.update_available.is_some() {
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.overlay0)
+ };
+ frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), toggle_area);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{detect::Agent, workspace::Workspace};
+
+ #[test]
+ fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
+ let mut app = crate::app::state::AppState::test_new();
+ 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_tab = second.test_add_tab(Some("logs"));
+ let second_pane = second.tabs[second_tab].root_pane;
+ second.tabs[second_tab]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+
+ app.workspaces = vec![first, second];
+ app.active = Some(0);
+ app.selected = 0;
+ app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
+
+ let entries = agent_panel_entries(&app);
+ assert_eq!(entries[0].primary_label, "one");
+ assert!(entries[0].primary_tab_label.is_none());
+ assert_eq!(entries[0].agent_label.as_deref(), Some("pi"));
+ assert_eq!(entries[1].primary_label, "two");
+ assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs"));
+ assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
+ }
+
+ #[test]
+ fn all_workspaces_primary_label_truncates_workspace_and_tab() {
+ let entry = AgentPanelEntry {
+ ws_idx: 0,
+ tab_idx: 0,
+ pane_id: crate::layout::PaneId::from_raw(1),
+ primary_label: "agent-browser".into(),
+ primary_tab_label: Some("test-escalation".into()),
+ agent_label: Some("claude".into()),
+ state: AgentState::Idle,
+ seen: true,
+ };
+
+ let label = format_agent_panel_primary_label(&entry, 18);
+
+ assert_eq!(label, "agent-bro… · test…");
+ }
+
+ #[test]
+ fn expanded_sidebar_sections_handle_tiny_heights() {
+ let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
+
+ assert_eq!(ws_area, Rect::new(0, 0, 19, 3));
+ assert_eq!(detail_area, Rect::new(0, 3, 19, 2));
+ }
+
+ #[test]
+ fn sidebar_section_divider_is_hidden_for_tiny_heights() {
+ let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5);
+
+ assert_eq!(divider, Rect::default());
+ }
+}
diff --git a/src/ui/status.rs b/src/ui/status.rs
new file mode 100644
index 00000000..2eece8a0
--- /dev/null
+++ b/src/ui/status.rs
@@ -0,0 +1,135 @@
+use ratatui::{
+ layout::{Constraint, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+use super::widgets::panel_contrast_fg;
+use crate::{
+ app::state::{Palette, ToastKind, ToastNotification},
+ detect::AgentState,
+};
+
+pub(super) fn render_toast_notification(
+ frame: &mut Frame,
+ area: Rect,
+ toast: &ToastNotification,
+ offset_for_warning: bool,
+ p: &Palette,
+) {
+ let dot_color = match toast.kind {
+ ToastKind::NeedsAttention => p.red,
+ ToastKind::Finished => p.blue,
+ ToastKind::UpdateInstalled => p.accent,
+ };
+ let content_width = (toast.title.len().max(toast.context.len()) as u16) + 4;
+ let width = content_width.saturating_add(2).min(area.width);
+ let height = 4u16.min(area.height);
+ let x = area.x + area.width.saturating_sub(width);
+ let y = area.y
+ + area
+ .height
+ .saturating_sub(height + if offset_for_warning { 1 } else { 0 });
+ let toast_area = Rect::new(x, y, width, height);
+
+ frame.render_widget(Clear, toast_area);
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(p.overlay0))
+ .style(Style::default().bg(p.panel_bg));
+ let inner = block.inner(toast_area);
+ frame.render_widget(block, toast_area);
+
+ if inner.height < 2 {
+ return;
+ }
+
+ let [title_row, context_row] =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
+
+ let title = Line::from(vec![
+ Span::styled("●", Style::default().fg(dot_color)),
+ Span::raw(" "),
+ Span::styled(
+ &toast.title,
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD),
+ ),
+ ]);
+ let context = Line::from(vec![
+ Span::styled(" ", Style::default().fg(p.overlay0)),
+ Span::styled(&toast.context, Style::default().fg(p.overlay0)),
+ ]);
+
+ frame.render_widget(Paragraph::new(title), title_row);
+ frame.render_widget(Paragraph::new(context), context_row);
+}
+
+pub(super) fn render_config_diagnostic(frame: &mut Frame, area: Rect, message: &str, p: &Palette) {
+ let text = format!(" config warning: {message} ");
+ let width = text.len() as u16 + 2;
+ let notif_area = Rect::new(
+ area.x + area.width.saturating_sub(width.min(area.width)),
+ area.y,
+ width.min(area.width),
+ 1,
+ );
+
+ frame.render_widget(Clear, notif_area);
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ text,
+ Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.yellow)
+ .add_modifier(Modifier::BOLD),
+ )),
+ notif_area,
+ );
+}
+
+pub(super) fn state_dot(state: AgentState, seen: bool, p: &Palette) -> (&'static str, Style) {
+ match (state, seen) {
+ (AgentState::Blocked, _) => ("●", Style::default().fg(p.red)),
+ (AgentState::Working, _) => ("●", Style::default().fg(p.yellow)),
+ (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)),
+ (AgentState::Idle, true) => ("○", Style::default().fg(p.green)),
+ (AgentState::Unknown, _) => ("·", Style::default().fg(p.overlay0)),
+ }
+}
+
+pub(super) fn agent_icon(
+ state: AgentState,
+ seen: bool,
+ tick: u32,
+ p: &Palette,
+) -> (&'static str, Style) {
+ match (state, seen) {
+ (AgentState::Blocked, _) => ("◉", Style::default().fg(p.red)),
+ (AgentState::Working, _) => (super::spinner_frame(tick), Style::default().fg(p.yellow)),
+ (AgentState::Idle, false) => ("●", Style::default().fg(p.teal)),
+ (AgentState::Idle, true) => ("✓", Style::default().fg(p.green)),
+ (AgentState::Unknown, _) => ("○", Style::default().fg(p.overlay0)),
+ }
+}
+
+pub(super) fn state_label(state: AgentState, seen: bool) -> &'static str {
+ match (state, seen) {
+ (AgentState::Blocked, _) => "blocked",
+ (AgentState::Working, _) => "working",
+ (AgentState::Idle, false) => "done",
+ (AgentState::Idle, true) => "idle",
+ (AgentState::Unknown, _) => "idle",
+ }
+}
+
+pub(super) fn state_label_color(state: AgentState, seen: bool, p: &Palette) -> Color {
+ match (state, seen) {
+ (AgentState::Blocked, _) => p.red,
+ (AgentState::Working, _) => p.yellow,
+ (AgentState::Idle, false) => p.teal,
+ (AgentState::Idle, true) => p.green,
+ (AgentState::Unknown, _) => p.overlay0,
+ }
+}
diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs
new file mode 100644
index 00000000..400ee65f
--- /dev/null
+++ b/src/ui/tabs.rs
@@ -0,0 +1,356 @@
+use ratatui::{
+ layout::Rect,
+ style::{Modifier, Style},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::widgets::panel_contrast_fg;
+use crate::app::AppState;
+
+const MIN_TAB_WIDTH: u16 = 8;
+const NEW_TAB_WIDTH: u16 = 3;
+const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
+
+#[derive(Debug, Clone, Default)]
+pub(crate) struct TabBarView {
+ pub scroll: usize,
+ pub tab_hit_areas: Vec<Rect>,
+ pub scroll_left_hit_area: Rect,
+ pub scroll_right_hit_area: Rect,
+ pub new_tab_hit_area: Rect,
+}
+
+fn tab_width(tab: &crate::workspace::Tab) -> u16 {
+ (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH)
+}
+
+fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
+ let mut rects = vec![Rect::default(); ws.tabs.len()];
+ if area.width == 0 || area.height == 0 {
+ return rects;
+ }
+
+ let mut x = area.x;
+ let right = area.x + area.width;
+ for idx in scroll..ws.tabs.len() {
+ if x >= right {
+ break;
+ }
+ let desired = tab_width(&ws.tabs[idx]);
+ let remaining = right.saturating_sub(x);
+ let width = desired.min(remaining).max(1);
+ rects[idx] = Rect::new(x, area.y, width, 1);
+ x = x.saturating_add(width + 1);
+ }
+ rects
+}
+
+fn centered_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
+ let mut best_scroll = ws.active_tab;
+ let mut best_distance = u16::MAX;
+ let viewport_center = area.x.saturating_mul(2).saturating_add(area.width);
+
+ for scroll in 0..=ws.active_tab {
+ let rects = layout_tab_hit_areas(ws, area, scroll);
+ let Some(active_rect) = rects.get(ws.active_tab).copied() else {
+ continue;
+ };
+ if active_rect.width == 0 {
+ continue;
+ }
+
+ let active_center = active_rect
+ .x
+ .saturating_mul(2)
+ .saturating_add(active_rect.width);
+ let distance = active_center.abs_diff(viewport_center);
+ if distance <= best_distance {
+ best_distance = distance;
+ best_scroll = scroll;
+ }
+ }
+
+ best_scroll
+}
+
+fn trailing_tab_controls_x(tab_hit_areas: &[Rect], fallback_x: u16) -> u16 {
+ tab_hit_areas
+ .iter()
+ .rev()
+ .find(|rect| rect.width > 0)
+ .map(|rect| rect.x + rect.width)
+ .unwrap_or(fallback_x)
+}
+
+fn max_tab_scroll(ws: &crate::workspace::Workspace, area: Rect) -> usize {
+ (0..ws.tabs.len())
+ .find(|&scroll| {
+ layout_tab_hit_areas(ws, area, scroll)
+ .last()
+ .is_some_and(|rect| rect.width > 0)
+ })
+ .unwrap_or(0)
+}
+
+pub(crate) fn compute_tab_bar_view(
+ ws: &crate::workspace::Workspace,
+ area: Rect,
+ current_scroll: usize,
+ follow_active: bool,
+) -> TabBarView {
+ if area.width == 0 || area.height == 0 {
+ return TabBarView::default();
+ }
+
+ let area_right = area.x + area.width;
+ let all_tabs_area = Rect::new(
+ area.x,
+ area.y,
+ area.width.saturating_sub(NEW_TAB_WIDTH),
+ area.height,
+ );
+ let all_tabs = layout_tab_hit_areas(ws, all_tabs_area, 0);
+ let overflow = all_tabs.iter().any(|rect| rect.width == 0);
+ if !overflow {
+ let new_tab_x = trailing_tab_controls_x(&all_tabs, area.x);
+ let new_tab_hit_area = Rect::new(
+ new_tab_x,
+ area.y,
+ area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
+ 1,
+ );
+ return TabBarView {
+ scroll: 0,
+ tab_hit_areas: all_tabs,
+ scroll_left_hit_area: Rect::default(),
+ scroll_right_hit_area: Rect::default(),
+ new_tab_hit_area,
+ };
+ }
+
+ let left_hit_area = Rect::new(area.x, area.y, TAB_SCROLL_BUTTON_WIDTH.min(area.width), 1);
+ let tab_area_x = left_hit_area.x + left_hit_area.width;
+ let reserved_trailing_width = NEW_TAB_WIDTH.saturating_add(TAB_SCROLL_BUTTON_WIDTH);
+ let tab_area_right = area_right.saturating_sub(reserved_trailing_width);
+ let tab_area = Rect::new(
+ tab_area_x,
+ area.y,
+ tab_area_right.saturating_sub(tab_area_x),
+ area.height,
+ );
+
+ let max_scroll = max_tab_scroll(ws, tab_area);
+ let scroll = if follow_active {
+ centered_tab_scroll(ws, tab_area).min(max_scroll)
+ } else {
+ current_scroll.min(max_scroll)
+ };
+ let tab_hit_areas = layout_tab_hit_areas(ws, tab_area, scroll);
+ let trailing_x = trailing_tab_controls_x(&tab_hit_areas, tab_area_x).min(tab_area_right);
+ let right_hit_area = Rect::new(
+ trailing_x,
+ area.y,
+ area_right
+ .saturating_sub(trailing_x)
+ .min(TAB_SCROLL_BUTTON_WIDTH),
+ 1,
+ );
+ let new_tab_x = right_hit_area.x + right_hit_area.width;
+ let new_tab_hit_area = Rect::new(
+ new_tab_x,
+ area.y,
+ area_right.saturating_sub(new_tab_x).min(NEW_TAB_WIDTH),
+ 1,
+ );
+
+ TabBarView {
+ scroll,
+ tab_hit_areas,
+ scroll_left_hit_area: left_hit_area,
+ scroll_right_hit_area: right_hit_area,
+ new_tab_hit_area,
+ }
+}
+
+fn tab_drop_indicator_x(
+ app: &AppState,
+ ws: &crate::workspace::Workspace,
+ insert_idx: usize,
+) -> Option<u16> {
+ let visible_tabs = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .filter(|(_, rect)| rect.width > 0);
+ let first_visible = visible_tabs.clone().next()?;
+ let last_visible = visible_tabs.last().unwrap_or(first_visible);
+
+ if insert_idx == 0 {
+ return Some(if first_visible.0 == 0 {
+ first_visible.1.x
+ } else {
+ app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width
+ });
+ }
+
+ if let Some((_, rect)) = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find(|(idx, rect)| *idx == insert_idx && rect.width > 0)
+ {
+ return Some(rect.x.saturating_sub(1));
+ }
+
+ if insert_idx >= ws.tabs.len() {
+ return Some(if last_visible.0 + 1 >= ws.tabs.len() {
+ last_visible.1.x + last_visible.1.width
+ } else {
+ app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
+ });
+ }
+
+ None
+}
+
+pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let Some(active_ws_idx) = app.active else {
+ return;
+ };
+ let Some(ws) = app.workspaces.get(active_ws_idx) else {
+ return;
+ };
+
+ let p = &app.palette;
+
+ frame.render_widget(
+ Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
+ area,
+ );
+
+ let first_visible_idx = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx);
+ let last_visible_idx = app
+ .view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .rev()
+ .find(|(_, rect)| rect.width > 0)
+ .map(|(idx, _)| idx);
+ let can_scroll_left = app.view.tab_scroll_left_hit_area.width > 0 && app.tab_scroll > 0;
+ let can_scroll_right = app.view.tab_scroll_right_hit_area.width > 0
+ && last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len());
+
+ if app.view.tab_scroll_left_hit_area.width > 0 {
+ let style = if can_scroll_left {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ } else {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(p.surface0)
+ .add_modifier(Modifier::DIM)
+ };
+ frame.render_widget(
+ Paragraph::new(" < ").style(style),
+ app.view.tab_scroll_left_hit_area,
+ );
+ }
+
+ if app.view.tab_scroll_right_hit_area.width > 0 {
+ let style = if can_scroll_right {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ } else {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(p.surface0)
+ .add_modifier(Modifier::DIM)
+ };
+ frame.render_widget(
+ Paragraph::new(" > ").style(style),
+ app.view.tab_scroll_right_hit_area,
+ );
+ }
+
+ for (idx, tab) in ws.tabs.iter().enumerate() {
+ let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
+ break;
+ };
+ if rect.width == 0 {
+ continue;
+ }
+ let active = idx == ws.active_tab;
+ let style = if active {
+ let base = Style::default().fg(panel_contrast_fg(p)).bg(p.accent);
+ if tab.is_auto_named() {
+ base.add_modifier(Modifier::DIM)
+ } else {
+ base.add_modifier(Modifier::BOLD)
+ }
+ } else if tab.is_auto_named() {
+ Style::default()
+ .fg(p.overlay0)
+ .bg(p.surface0)
+ .add_modifier(Modifier::DIM)
+ } else {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ };
+ let width = rect.width as usize;
+ let name = tab.display_name();
+ let text = format!(" {:width$}", name, width = width.saturating_sub(1));
+ frame.render_widget(Paragraph::new(text).style(style), rect);
+ }
+
+ if let Some(crate::app::state::DragState {
+ target:
+ crate::app::state::DragTarget::TabReorder {
+ ws_idx,
+ insert_idx: Some(insert_idx),
+ ..
+ },
+ }) = &app.drag
+ {
+ if *ws_idx == active_ws_idx {
+ if let Some(x) = tab_drop_indicator_x(app, ws, *insert_idx) {
+ frame.buffer_mut()[(x.min(area.x + area.width.saturating_sub(1)), area.y)]
+ .set_symbol("│")
+ .set_style(Style::default().fg(p.accent));
+ }
+ }
+ }
+
+ if app.view.new_tab_hit_area.width > 0 {
+ frame.render_widget(
+ Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
+ app.view.new_tab_hit_area,
+ );
+ }
+
+ if first_visible_idx.is_some_and(|idx| idx > 0) {
+ let x = app.view.tab_scroll_left_hit_area.x + app.view.tab_scroll_left_hit_area.width;
+ if x < area.x + area.width {
+ frame.buffer_mut()[(x, area.y)]
+ .set_symbol("…")
+ .set_style(Style::default().fg(p.overlay0));
+ }
+ }
+ if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
+ let x = app.view.tab_scroll_right_hit_area.x.saturating_sub(1);
+ if x >= area.x && x < area.x + area.width {
+ frame.buffer_mut()[(x, area.y)]
+ .set_symbol("…")
+ .set_style(Style::default().fg(p.overlay0));
+ }
+ }
+}
diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs
new file mode 100644
index 00000000..354b7884
--- /dev/null
+++ b/src/ui/widgets.rs
@@ -0,0 +1,209 @@
+use ratatui::{
+ layout::{Alignment, Constraint, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Clear, Paragraph},
+ Frame,
+};
+
+use crate::app::state::Palette;
+
+pub(super) fn render_panel_shell(
+ frame: &mut Frame,
+ area: Rect,
+ border_color: Color,
+ bg: Color,
+) -> Option<Rect> {
+ if area.width < 2 || area.height < 2 {
+ return None;
+ }
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(border_color))
+ .border_set(ratatui::symbols::border::PLAIN)
+ .style(Style::default().bg(bg));
+ let inner = block.inner(area);
+ frame.render_widget(Clear, area);
+ frame.render_widget(block, area);
+ Some(inner)
+}
+
+pub(super) fn panel_contrast_fg(p: &Palette) -> Color {
+ match p.panel_bg {
+ Color::Reset => p.surface_dim,
+ color => color,
+ }
+}
+
+pub(crate) fn centered_popup_rect(area: Rect, popup_w: u16, popup_h: u16) -> Option<Rect> {
+ 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;
+ Some(Rect::new(popup_x, popup_y, popup_w, popup_h))
+}
+
+pub(super) fn render_modal_shell(
+ frame: &mut Frame,
+ area: Rect,
+ popup_w: u16,
+ popup_h: u16,
+ p: &Palette,
+) -> Option<Rect> {
+ let popup = centered_popup_rect(area, popup_w, popup_h)?;
+ render_panel_shell(frame, popup, p.accent, p.panel_bg)
+}
+
+pub(super) fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette) {
+ let line = Line::from(vec![
+ Span::styled(
+ title,
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" "),
+ Span::styled("⬆", Style::default().fg(p.accent)),
+ ]);
+ frame.render_widget(Paragraph::new(line), area);
+}
+
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct ModalStackAreas {
+ pub header: Rect,
+ pub content: Rect,
+ pub footer: Option<Rect>,
+ pub actions: Option<Rect>,
+}
+
+pub(crate) fn modal_stack_areas(
+ inner: Rect,
+ header_height: u16,
+ footer_height: u16,
+ actions_height: u16,
+ gap: u16,
+) -> ModalStackAreas {
+ #[derive(Clone, Copy)]
+ enum Slot {
+ Header,
+ Content,
+ Footer,
+ Actions,
+ }
+
+ let mut constraints = Vec::new();
+ let mut slots = Vec::new();
+ let mut push = |slot: Slot, constraint: Constraint| {
+ if !slots.is_empty() {
+ constraints.push(Constraint::Length(gap));
+ }
+ constraints.push(constraint);
+ slots.push(slot);
+ };
+
+ push(Slot::Header, Constraint::Length(header_height));
+ push(Slot::Content, Constraint::Min(0));
+ if footer_height > 0 {
+ push(Slot::Footer, Constraint::Length(footer_height));
+ }
+ if actions_height > 0 {
+ push(Slot::Actions, Constraint::Length(actions_height));
+ }
+
+ let areas = Layout::vertical(constraints).split(inner);
+ let mut header = Rect::default();
+ let mut content = Rect::default();
+ let mut footer = None;
+ let mut actions = None;
+
+ for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) {
+ match slot {
+ Slot::Header => header = area,
+ Slot::Content => content = area,
+ Slot::Footer => footer = Some(area),
+ Slot::Actions => actions = Some(area),
+ }
+ }
+
+ ModalStackAreas {
+ header,
+ content,
+ footer,
+ actions,
+ }
+}
+
+pub(crate) fn action_button_text(hint: Option<&str>, label: &str) -> String {
+ match hint {
+ Some(hint) => format!(" {hint} {label} "),
+ None => format!(" {label} "),
+ }
+}
+
+pub(crate) fn action_button_width(hint: Option<&str>, label: &str) -> u16 {
+ action_button_text(hint, label).chars().count() as u16
+}
+
+pub(crate) struct ActionButtonSpec<'a> {
+ pub hint: Option<&'a str>,
+ pub label: &'a str,
+}
+
+pub(crate) fn action_button_row_rects(
+ area: Rect,
+ buttons: &[ActionButtonSpec<'_>],
+ gap: u16,
+ row_offset: u16,
+) -> Vec<Rect> {
+ let widths: Vec<u16> = buttons
+ .iter()
+ .map(|button| action_button_width(button.hint, button.label))
+ .collect();
+ centered_button_row(area, &widths, gap, row_offset)
+}
+
+pub(super) fn render_action_button(
+ frame: &mut Frame,
+ rect: Rect,
+ hint: Option<&str>,
+ label: &str,
+ style: Style,
+) {
+ frame.render_widget(
+ Paragraph::new(action_button_text(hint, label))
+ .style(style)
+ .alignment(Alignment::Center),
+ rect,
+ );
+}
+
+pub(super) fn centered_button_row(
+ inner: Rect,
+ widths: &[u16],
+ gap: u16,
+ row_offset: u16,
+) -> Vec<Rect> {
+ let total_w = widths
+ .iter()
+ .copied()
+ .sum::<u16>()
+ .saturating_add(gap.saturating_mul(widths.len().saturating_sub(1) as u16));
+ let mut x = inner.x + inner.width.saturating_sub(total_w) / 2;
+ let y = inner.y + row_offset.min(inner.height.saturating_sub(1));
+ widths
+ .iter()
+ .map(|w| {
+ let rect = Rect::new(
+ x,
+ y,
+ (*w).min(inner.width.saturating_sub(x.saturating_sub(inner.x))),
+ 1,
+ );
+ x = x.saturating_add(*w).saturating_add(gap);
+ rect
+ })
+ .collect()
+}
diff --git a/src/update.rs b/src/update.rs
index af446fed..ed592ac7 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -9,7 +9,11 @@
use std::collections::BTreeMap;
use std::env;
use std::fs;
+use std::io::{self, BufRead, BufReader, IsTerminal, Write};
+use std::os::unix::net::UnixStream;
+use std::path::Path;
use std::process::Command;
+use std::time::{Duration, Instant};
use serde::Deserialize;
@@ -18,6 +22,9 @@ const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
+const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
+const SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5);
+const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
fn fake_release_notes_body(version: &str) -> String {
let notes_version = env::var(FAKE_UPDATE_NOTES_VERSION_ENV)
@@ -213,11 +220,174 @@ fn download_and_install(release: &ReleaseInfo) -> Result<(), String> {
}
// ---------------------------------------------------------------------------
+// Upgrade flow helpers
+// ---------------------------------------------------------------------------
+
+fn running_inside_herdr_env(herdr_env: Option<&str>) -> bool {
+ herdr_env == Some(crate::HERDR_ENV_VALUE)
+}
+
+fn running_inside_herdr() -> bool {
+ running_inside_herdr_env(env::var(crate::HERDR_ENV_VAR).ok().as_deref())
+}
+
+fn api_server_is_running_at(socket_path: &Path) -> bool {
+ if !socket_path.exists() {
+ return false;
+ }
+
+ UnixStream::connect(socket_path).is_ok()
+}
+
+fn api_server_is_running() -> bool {
+ api_server_is_running_at(&crate::api::socket_path())
+}
+
+fn stop_server_via_api_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
+ use crate::api::schema::{EmptyParams, Method, Request};
+
+ let request = Request {
+ id: "update:server:stop".into(),
+ method: Method::ServerStop(EmptyParams::default()),
+ };
+
+ let mut stream = UnixStream::connect(socket_path)
+ .map_err(|e| format!("failed to connect to running server: {e}"))?;
+ stream
+ .set_write_timeout(Some(timeout))
+ .map_err(|e| format!("failed to set server stop write timeout: {e}"))?;
+ stream
+ .set_read_timeout(Some(timeout))
+ .map_err(|e| format!("failed to set server stop read timeout: {e}"))?;
+ stream
+ .write_all(
+ serde_json::to_string(&request)
+ .map_err(|e| e.to_string())?
+ .as_bytes(),
+ )
+ .map_err(|e| format!("failed to send server stop request: {e}"))?;
+ stream
+ .write_all(b"\n")
+ .map_err(|e| format!("failed to finish server stop request: {e}"))?;
+ stream
+ .flush()
+ .map_err(|e| format!("failed to flush server stop request: {e}"))?;
+
+ let mut reader = BufReader::new(stream);
+ let mut line = String::new();
+ let read = reader
+ .read_line(&mut line)
+ .map_err(|e| format!("failed to read server stop response: {e}"))?;
+ if read == 0 || line.trim().is_empty() {
+ return Err("empty server stop response".into());
+ }
+ let response: serde_json::Value =
+ serde_json::from_str(&line).map_err(|e| format!("invalid server response: {e}"))?;
+ if let Some(error) = response.get("error") {
+ return Err(format!("server stop failed: {error}"));
+ }
+
+ Ok(())
+}
+
+fn stop_server_via_api() -> Result<(), String> {
+ stop_server_via_api_at(&crate::api::socket_path(), SERVER_STOP_RESPONSE_TIMEOUT)
+}
+
+fn server_shutdown_confirmed_at(socket_path: &Path) -> Result<bool, String> {
+ if !socket_path.exists() {
+ return Ok(true);
+ }
+
+ match UnixStream::connect(socket_path) {
+ Ok(_) => Ok(false),
+ Err(err)
+ if matches!(
+ err.kind(),
+ io::ErrorKind::ConnectionRefused
+ | io::ErrorKind::NotFound
+ | io::ErrorKind::TimedOut
+ ) =>
+ {
+ Ok(true)
+ }
+ Err(err) => Err(format!(
+ "failed to confirm whether the old server stopped on {}: {err}",
+ socket_path.display()
+ )),
+ }
+}
+
+fn wait_for_server_shutdown_at(socket_path: &Path, timeout: Duration) -> Result<(), String> {
+ let deadline = Instant::now() + timeout;
+ loop {
+ if server_shutdown_confirmed_at(socket_path)? {
+ return Ok(());
+ }
+ if Instant::now() >= deadline {
+ return Err(format!(
+ "shutdown was requested, but the old server is still responding on {} after {} seconds",
+ socket_path.display(),
+ timeout.as_secs()
+ ));
+ }
+ std::thread::sleep(SERVER_SHUTDOWN_POLL_INTERVAL);
+ }
+}
+
+fn wait_for_server_shutdown(timeout: Duration) -> Result<(), String> {
+ wait_for_server_shutdown_at(&crate::api::socket_path(), timeout)
+}
+
+fn parse_stop_server_response(input: &str) -> Option<bool> {
+ let trimmed = input.trim().to_ascii_lowercase();
+ match trimmed.as_str() {
+ "" | "y" | "yes" => Some(true),
+ "n" | "no" => Some(false),
+ _ => None,
+ }
+}
+
+fn prompt_to_stop_running_server() -> Result<bool, String> {
+ if !io::stdin().is_terminal() {
+ eprintln!(
+ "a herdr server is still running on the old version. rerun `herdr update` interactively or stop it later with `herdr server stop`."
+ );
+ return Ok(false);
+ }
+
+ loop {
+ eprint!("a herdr server is still running on the old version. stop it now to apply the update? [Y/n] ");
+ io::stderr()
+ .flush()
+ .map_err(|e| format!("failed to flush prompt: {e}"))?;
+
+ let mut input = String::new();
+ let read = io::stdin()
+ .read_line(&mut input)
+ .map_err(|e| format!("failed to read prompt response: {e}"))?;
+ if read == 0 {
+ return Ok(false);
+ }
+
+ if let Some(answer) = parse_stop_server_response(&input) {
+ return Ok(answer);
+ }
+
+ eprintln!("please answer y or n");
+ }
+}
+
+// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Manual self-update command (`herdr update`).
pub fn self_update() -> Result<Version, String> {
+ if running_inside_herdr() {
+ return Err("run `herdr update` outside herdr after detaching from the session".into());
+ }
+
eprintln!("checking for updates...");
let current = Version::current();
@@ -239,6 +409,28 @@ pub fn self_update() -> Result<Version, String> {
download_and_install(&release)?;
eprintln!("updated to v{}", release.version);
+ if api_server_is_running() {
+ if prompt_to_stop_running_server()? {
+ match stop_server_via_api() {
+ Ok(()) => match wait_for_server_shutdown(SERVER_SHUTDOWN_CONFIRM_TIMEOUT) {
+ Ok(()) => eprintln!("stopped the running herdr server. run herdr again."),
+ Err(err) => eprintln!(
+ "update installed, but {err}\ntry `herdr server stop` from another shell, then run `herdr` again."
+ ),
+ },
+ Err(err) => eprintln!(
+ "update installed, but failed to stop the running server: {err}\ntry `herdr server stop` from another shell, then run `herdr` again."
+ ),
+ }
+ } else {
+ eprintln!(
+ "update installed, but the old server is still running. restart it later to use the new version."
+ );
+ }
+ } else {
+ eprintln!("update installed. run herdr again.");
+ }
+
Ok(release.version)
}
@@ -283,7 +475,7 @@ pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
}
tracing::info!(
- "auto-update check: v{} available, waiting for manual install",
+ "auto-update check: v{} available, waiting for explicit install",
release.version
);
@@ -324,6 +516,42 @@ fn platform_target() -> (&'static str, &'static str) {
#[cfg(test)]
mod tests {
use super::*;
+ use std::os::unix::net::UnixListener;
+ use std::sync::{
+ atomic::{AtomicBool, Ordering},
+ Arc,
+ };
+ use std::thread;
+
+ fn unique_test_socket_path(name: &str) -> std::path::PathBuf {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_nanos();
+ std::env::temp_dir().join(format!(
+ "herdr-update-{name}-{}-{nanos}.sock",
+ std::process::id()
+ ))
+ }
+
+ fn spawn_accept_loop(path: &Path) -> (Arc<AtomicBool>, thread::JoinHandle<()>) {
+ let listener = UnixListener::bind(path).unwrap();
+ listener.set_nonblocking(true).unwrap();
+ let running = Arc::new(AtomicBool::new(true));
+ let running_thread = Arc::clone(&running);
+ let handle = thread::spawn(move || {
+ while running_thread.load(Ordering::Relaxed) {
+ match listener.accept() {
+ Ok((_stream, _)) => {}
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(10));
+ }
+ Err(_) => break,
+ }
+ }
+ });
+ (running, handle)
+ }
#[test]
fn parse_version_basic() {
@@ -378,6 +606,105 @@ mod tests {
}
#[test]
+ fn running_inside_herdr_env_requires_marker() {
+ assert!(running_inside_herdr_env(Some(crate::HERDR_ENV_VALUE)));
+ assert!(!running_inside_herdr_env(None));
+ assert!(!running_inside_herdr_env(Some("0")));
+ }
+
+ #[test]
+ fn parse_stop_server_response_defaults_yes_for_blank() {
+ assert_eq!(parse_stop_server_response(""), Some(true));
+ assert_eq!(parse_stop_server_response("\n"), Some(true));
+ assert_eq!(parse_stop_server_response("y"), Some(true));
+ assert_eq!(parse_stop_server_response("yes"), Some(true));
+ assert_eq!(parse_stop_server_response("n"), Some(false));
+ assert_eq!(parse_stop_server_response("no"), Some(false));
+ assert_eq!(parse_stop_server_response("later"), None);
+ }
+
+ #[test]
+ fn stop_server_via_api_accepts_success_response() {
+ let socket_path = unique_test_socket_path("stop-ok");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let handle = thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut request = String::new();
+ BufReader::new(stream.try_clone().unwrap())
+ .read_line(&mut request)
+ .unwrap();
+ assert!(
+ request.contains("server.stop")
+ || request.contains("ServerStop")
+ || request.contains("server_stop")
+ || request.contains("ServerStop")
+ );
+ stream
+ .write_all(b"{\"id\":\"update:server:stop\",\"result\":{}}\n")
+ .unwrap();
+ stream.flush().unwrap();
+ });
+
+ let result = stop_server_via_api_at(&socket_path, Duration::from_millis(200));
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(
+ result.is_ok(),
+ "expected stop request to succeed: {result:?}"
+ );
+ }
+
+ #[test]
+ fn stop_server_via_api_times_out_when_server_never_replies() {
+ let socket_path = unique_test_socket_path("stop-timeout");
+ let listener = UnixListener::bind(&socket_path).unwrap();
+ let handle = thread::spawn(move || {
+ let (_stream, _) = listener.accept().unwrap();
+ thread::sleep(Duration::from_millis(200));
+ });
+
+ let err = stop_server_via_api_at(&socket_path, Duration::from_millis(50)).unwrap_err();
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(
+ err.contains("failed to read server stop response")
+ || err.contains("empty server stop response"),
+ "unexpected error: {err}"
+ );
+ }
+
+ #[test]
+ fn wait_for_server_shutdown_succeeds_once_socket_stops_accepting() {
+ let socket_path = unique_test_socket_path("shutdown-ok");
+ let (running, handle) = spawn_accept_loop(&socket_path);
+ let running_for_stop = Arc::clone(&running);
+ let stopper = thread::spawn(move || {
+ thread::sleep(Duration::from_millis(60));
+ running_for_stop.store(false, Ordering::Relaxed);
+ });
+
+ let result = wait_for_server_shutdown_at(&socket_path, Duration::from_millis(500));
+ running.store(false, Ordering::Relaxed);
+ let _ = stopper.join();
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(result.is_ok(), "expected shutdown confirmation: {result:?}");
+ }
+
+ #[test]
+ fn wait_for_server_shutdown_times_out_while_socket_keeps_responding() {
+ let socket_path = unique_test_socket_path("shutdown-timeout");
+ let (running, handle) = spawn_accept_loop(&socket_path);
+
+ let err =
+ wait_for_server_shutdown_at(&socket_path, Duration::from_millis(120)).unwrap_err();
+ running.store(false, Ordering::Relaxed);
+ let _ = handle.join();
+ let _ = fs::remove_file(&socket_path);
+ assert!(err.contains("still responding"), "unexpected error: {err}");
+ }
+
+ #[test]
fn version_ordering() {
let v010 = Version::parse("0.1.0").unwrap();
let v011 = Version::parse("0.1.1").unwrap();
diff --git a/src/workspace.rs b/src/workspace.rs
index 30e968fe..b80e5773 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::ops::{Deref, DerefMut};
-use std::path::{Path, PathBuf};
+use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -9,11 +9,22 @@ use ratatui::layout::Direction;
use tokio::sync::{mpsc, Notify};
use tracing::info;
-use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
-use crate::layout::{PaneId, TileLayout};
+use crate::layout::PaneId;
+#[cfg(test)]
+use crate::layout::TileLayout;
use crate::pane::{PaneRuntime, PaneState};
+mod aggregate;
+mod git;
+mod tab;
+
+use self::git::git_ahead_behind;
+pub use self::{
+ git::{derive_label_from_cwd, git_branch},
+ tab::Tab,
+};
+
static NEXT_WORKSPACE_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn generate_workspace_id() -> String {
@@ -25,287 +36,6 @@ pub(crate) fn generate_workspace_id() -> String {
format!("w{micros:x}{counter:x}")
}
-pub struct Tab {
- pub custom_name: Option<String>,
- pub number: usize,
- /// Identity source for this tab's pane tree.
- pub root_pane: PaneId,
- pub layout: TileLayout,
- /// Pane state — always present, testable without PTYs.
- pub panes: HashMap<PaneId, PaneState>,
- /// Best-effort cwd cache per pane. Live runtime cwd wins when available.
- pub pane_cwds: HashMap<PaneId, PathBuf>,
- /// Pane runtimes — only present in production (empty in tests).
- pub runtimes: HashMap<PaneId, PaneRuntime>,
- pub zoomed: bool,
- pub events: mpsc::Sender<AppEvent>,
- pub(crate) render_notify: Arc<Notify>,
- pub(crate) render_dirty: Arc<AtomicBool>,
-}
-
-impl Drop for Tab {
- fn drop(&mut self) {
- let runtimes = std::mem::take(&mut self.runtimes);
- for (pane_id, runtime) in runtimes {
- runtime.shutdown(pane_id);
- }
- }
-}
-
-impl Tab {
- pub fn new(
- number: usize,
- initial_cwd: PathBuf,
- rows: u16,
- cols: u16,
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- events: mpsc::Sender<AppEvent>,
- render_notify: Arc<Notify>,
- render_dirty: Arc<AtomicBool>,
- ) -> std::io::Result<Self> {
- let (layout, root_id) = TileLayout::new();
- let runtime = PaneRuntime::spawn(
- root_id,
- rows,
- cols,
- initial_cwd.clone(),
- scrollback_limit_bytes,
- host_terminal_theme,
- events.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- )?;
-
- let mut panes = HashMap::new();
- panes.insert(root_id, PaneState::new());
- let mut pane_cwds = HashMap::new();
- pane_cwds.insert(root_id, initial_cwd);
- let mut runtimes = HashMap::new();
- runtimes.insert(root_id, runtime);
-
- Ok(Self {
- custom_name: None,
- number,
- root_pane: root_id,
- layout,
- panes,
- pane_cwds,
- runtimes,
- zoomed: false,
- events,
- render_notify,
- render_dirty,
- })
- }
-
- pub fn display_name(&self) -> String {
- self.custom_name
- .clone()
- .unwrap_or_else(|| self.number.to_string())
- }
-
- pub fn is_auto_named(&self) -> bool {
- self.custom_name.is_none()
- }
-
- pub fn set_custom_name(&mut self, name: String) {
- self.custom_name = Some(name);
- }
-
- pub fn split_focused(
- &mut self,
- direction: Direction,
- rows: u16,
- cols: u16,
- cwd: Option<PathBuf>,
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
- self.split_focused_with_runtime(
- direction,
- rows,
- cols,
- cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- None,
- )
- }
-
- pub fn split_focused_command(
- &mut self,
- direction: Direction,
- rows: u16,
- cols: u16,
- cwd: Option<PathBuf>,
- command: &str,
- extra_env: &[(String, String)],
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- ) -> std::io::Result<PaneId> {
- self.split_focused_with_runtime(
- direction,
- rows,
- cols,
- cwd,
- scrollback_limit_bytes,
- host_terminal_theme,
- Some((command, extra_env)),
- )
- }
-
- fn split_focused_with_runtime(
- &mut self,
- direction: Direction,
- rows: u16,
- cols: u16,
- cwd: Option<PathBuf>,
- scrollback_limit_bytes: usize,
- host_terminal_theme: crate::terminal_theme::TerminalTheme,
- command: Option<(&str, &[(String, String)])>,
- ) -> std::io::Result<PaneId> {
- let new_id = self.layout.split_focused(direction);
- let actual_cwd =
- cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let runtime = if let Some((command, extra_env)) = command {
- PaneRuntime::spawn_shell_command(
- new_id,
- rows,
- cols,
- actual_cwd.clone(),
- command,
- extra_env,
- scrollback_limit_bytes,
- host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- )?
- } else {
- PaneRuntime::spawn(
- new_id,
- rows,
- cols,
- actual_cwd.clone(),
- scrollback_limit_bytes,
- host_terminal_theme,
- self.events.clone(),
- self.render_notify.clone(),
- self.render_dirty.clone(),
- )?
- };
- self.panes.insert(new_id, PaneState::new());
- self.pane_cwds.insert(new_id, actual_cwd);
- self.runtimes.insert(new_id, runtime);
- self.zoomed = false;
- Ok(new_id)
- }
-
- pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> {
- let pane_id = self.layout.focused();
- self.detach_pane(pane_id)
- }
-
- pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
- self.detach_pane(pane_id)
- }
-
- pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
- self.detach_pane(pane_id)
- }
-
- fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
- if self.layout.pane_count() <= 1 {
- return None;
- }
-
- let next_root = self.promoted_root_if_needed(pane_id);
-
- if self.layout.focused() == pane_id {
- self.layout.close_focused();
- } else {
- let prev_focus = self.layout.focused();
- self.layout.focus_pane(pane_id);
- self.layout.close_focused();
- self.layout.focus_pane(prev_focus);
- }
-
- self.panes.remove(&pane_id);
- self.pane_cwds.remove(&pane_id);
- let runtime = self.runtimes.remove(&pane_id);
- self.zoomed = false;
- if let Some(next_root) = next_root {
- self.root_pane = next_root;
- }
- Some((pane_id, runtime))
- }
-
- fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> {
- if self.root_pane != closing {
- return None;
- }
- self.layout.pane_ids().into_iter().find(|id| *id != closing)
- }
-
- pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
- self.runtimes.get(&self.layout.focused())
- }
-
- pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> {
- self.runtimes
- .get(&pane_id)
- .and_then(|rt| rt.cwd())
- .or_else(|| self.pane_cwds.get(&pane_id).cloned())
- }
-
- pub fn root_cwd(&self) -> Option<PathBuf> {
- self.cwd_for_pane(self.root_pane)
- }
-
- pub fn has_working_pane(&self) -> bool {
- self.panes
- .values()
- .any(|pane| pane.state == AgentState::Working)
- }
-
- #[cfg(test)]
- #[allow(dead_code)] // retained for focused layout-order assertions in tests
- pub fn pane_states(&self) -> Vec<(AgentState, bool)> {
- self.layout
- .pane_ids()
- .iter()
- .map(|id| {
- self.panes
- .get(id)
- .map(|p| (p.state, p.seen))
- .unwrap_or((AgentState::Unknown, true))
- })
- .collect()
- }
-
- pub fn pane_details(&self) -> Vec<PaneDetail> {
- self.layout
- .pane_ids()
- .iter()
- .filter_map(|id| {
- let pane = self.panes.get(id)?;
- let agent_label = pane.effective_agent_label()?.to_string();
- Some(PaneDetail {
- pane_id: *id,
- tab_idx: self.number.saturating_sub(1),
- tab_label: self.display_name(),
- label: agent_label.clone(),
- agent_label,
- agent: pane.effective_known_agent(),
- state: pane.state,
- seen: pane.seen,
- })
- })
- .collect()
- }
-}
-
/// A named workspace containing tabs.
pub struct Workspace {
/// Stable public workspace identity, independent of display order.
@@ -602,33 +332,6 @@ impl Workspace {
.and_then(|cwd| git_ahead_behind(&cwd));
}
- pub fn aggregate_state(&self) -> (AgentState, bool) {
- self.tabs
- .iter()
- .flat_map(|tab| tab.panes.values())
- .map(|pane| (pane.state, pane.seen))
- .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
- .unwrap_or((AgentState::Unknown, true))
- }
-
- pub fn has_working_pane(&self) -> bool {
- self.tabs.iter().any(Tab::has_working_pane)
- }
-
- pub fn pane_details(&self) -> Vec<PaneDetail> {
- let multi_tab = self.tabs.len() > 1;
- self.tabs
- .iter()
- .flat_map(Tab::pane_details)
- .map(|mut detail| {
- if multi_tab {
- detail.label = format!("{}·{}", detail.tab_label, detail.agent_label);
- }
- detail
- })
- .collect()
- }
-
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
self.active_tab().and_then(Tab::focused_runtime)
}
@@ -714,123 +417,9 @@ impl Workspace {
}
}
-/// Detail info for a single pane, used by the agent detail panel.
-pub struct PaneDetail {
- pub pane_id: PaneId,
- pub tab_idx: usize,
- pub tab_label: String,
- pub label: String,
- pub agent_label: String,
- #[allow(dead_code)]
- pub agent: Option<Agent>,
- pub state: AgentState,
- pub seen: bool,
-}
-
-fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
- match (state, seen) {
- (AgentState::Blocked, _) => 4,
- (AgentState::Idle, false) => 3,
- (AgentState::Working, _) => 2,
- (AgentState::Idle, true) => 1,
- (AgentState::Unknown, _) => 0,
- }
-}
-
-pub fn derive_label_from_cwd(cwd: &Path) -> String {
- if let Some(repo_root) = git_repo_root(cwd) {
- if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) {
- return name.to_string();
- }
- }
-
- if let Ok(home) = std::env::var("HOME") {
- let home = Path::new(&home);
- if cwd == home {
- return "~".to_string();
- }
- }
-
- cwd.file_name()
- .and_then(|n| n.to_str())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string())
- .unwrap_or_else(|| cwd.display().to_string())
-}
-
-pub fn git_branch(cwd: &Path) -> Option<String> {
- let repo_root = git_repo_root(cwd)?;
- let git_dir = git_dir_for_repo_root(&repo_root)?;
- let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
- parse_git_head_branch(&head)
-}
-
-fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
- let git_path = repo_root.join(".git");
- if git_path.is_dir() {
- return Some(git_path);
- }
-
- let gitdir = std::fs::read_to_string(&git_path).ok()?;
- let relative = gitdir.trim().strip_prefix("gitdir:")?.trim();
- let resolved = Path::new(relative);
- Some(if resolved.is_absolute() {
- resolved.to_path_buf()
- } else {
- repo_root.join(resolved)
- })
-}
-
-fn parse_git_head_branch(head: &str) -> Option<String> {
- let branch = head.trim().strip_prefix("ref: refs/heads/")?;
- (!branch.is_empty()).then(|| branch.to_string())
-}
-
-fn git_repo_root(start: &Path) -> Option<PathBuf> {
- let mut current = if start.is_dir() {
- start.to_path_buf()
- } else {
- start.parent()?.to_path_buf()
- };
-
- loop {
- if current.join(".git").exists() {
- return Some(current);
- }
- if !current.pop() {
- return None;
- }
- }
-}
-
-fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> {
- git_repo_root(cwd)?;
-
- let output = std::process::Command::new("git")
- .arg("-C")
- .arg(cwd)
- .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
- .output()
- .ok()?;
-
- if !output.status.success() {
- return None;
- }
-
- let stdout = String::from_utf8(output.stdout).ok()?;
- parse_git_ahead_behind_output(&stdout)
-}
-
-fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> {
- let mut parts = stdout.split_whitespace();
- let ahead = parts.next()?.parse().ok()?;
- let behind = parts.next()?.parse().ok()?;
- Some((ahead, behind))
-}
-
#[cfg(test)]
impl Workspace {
- pub fn test_new(name: &str) -> Self {
+ pub(crate) fn test_new(name: &str) -> Self {
let (events, _) = mpsc::channel(64);
let render_notify = Arc::new(Notify::new());
let render_dirty = Arc::new(AtomicBool::new(false));
@@ -867,7 +456,7 @@ impl Workspace {
}
}
- pub fn test_split(&mut self, direction: Direction) -> PaneId {
+ pub(crate) fn test_split(&mut self, direction: Direction) -> PaneId {
let tab = self.active_tab_mut().expect("workspace must have tab");
let new_id = tab.layout.split_focused(direction);
tab.panes.insert(new_id, PaneState::new());
@@ -877,7 +466,7 @@ impl Workspace {
new_id
}
- pub fn test_add_tab(&mut self, name: Option<&str>) -> usize {
+ pub(crate) fn test_add_tab(&mut self, name: Option<&str>) -> usize {
let (events, _) = mpsc::channel(64);
let render_notify = Arc::new(Notify::new());
let render_dirty = Arc::new(AtomicBool::new(false));
@@ -909,68 +498,6 @@ impl Workspace {
#[cfg(test)]
mod tests {
use super::*;
- use crate::detect::{Agent, AgentState};
-
- fn temp_test_dir(name: &str) -> PathBuf {
- let unique = format!(
- "herdr-workspace-tests-{}-{}-{}",
- name,
- std::process::id(),
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- );
- let path = std::env::temp_dir().join(unique);
- std::fs::create_dir_all(&path).unwrap();
- path
- }
-
- #[test]
- fn aggregate_state_all_unknown() {
- let ws = Workspace::test_new("test");
- let (state, seen) = ws.aggregate_state();
- assert_eq!(state, AgentState::Unknown);
- assert!(seen);
- }
-
- #[test]
- fn aggregate_state_priority() {
- let mut ws = Workspace::test_new("test");
- let id2 = ws.test_split(Direction::Horizontal);
- let root_id = ws.tabs[0]
- .panes
- .keys()
- .find(|id| **id != id2)
- .copied()
- .unwrap();
- ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
- ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
-
- let (state, seen) = ws.aggregate_state();
- assert_eq!(state, AgentState::Working);
- assert!(seen);
- }
-
- #[test]
- fn aggregate_state_done_unseen_beats_working() {
- let mut ws = Workspace::test_new("test");
- let id2 = ws.test_split(Direction::Horizontal);
- let root_id = ws.tabs[0]
- .panes
- .keys()
- .find(|id| **id != id2)
- .copied()
- .unwrap();
- let root = ws.tabs[0].panes.get_mut(&root_id).unwrap();
- root.state = AgentState::Idle;
- root.seen = false;
- ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
-
- let (state, seen) = ws.aggregate_state();
- assert_eq!(state, AgentState::Idle);
- assert!(!seen);
- }
#[test]
fn workspace_identity_follows_first_tab_root_pane_cwd() {
@@ -986,53 +513,6 @@ mod tests {
}
#[test]
- fn git_branch_reads_head_from_standard_repo() {
- let root = temp_test_dir("standard-repo");
- std::fs::create_dir_all(root.join(".git")).unwrap();
- std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
-
- assert_eq!(git_branch(&root).as_deref(), Some("main"));
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn git_branch_reads_head_from_worktree_gitdir_file() {
- let root = temp_test_dir("worktree");
- let worktree_git_dir = root.join(".bare/worktrees/feature");
- std::fs::create_dir_all(&worktree_git_dir).unwrap();
- std::fs::write(root.join(".git"), "gitdir: .bare/worktrees/feature\n").unwrap();
- std::fs::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
-
- assert_eq!(git_branch(&root).as_deref(), Some("feature"));
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn git_branch_returns_none_for_detached_head() {
- let root = temp_test_dir("detached-head");
- std::fs::create_dir_all(root.join(".git")).unwrap();
- std::fs::write(root.join(".git/HEAD"), "3e1b9a8d\n").unwrap();
-
- assert_eq!(git_branch(&root), None);
-
- std::fs::remove_dir_all(root).unwrap();
- }
-
- #[test]
- fn pane_details_hide_plain_shells() {
- let mut ws = Workspace::test_new("test");
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
- ws.test_split(Direction::Horizontal);
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 1);
- assert_eq!(details[0].label, "pi");
- }
-
- #[test]
fn moving_tab_keeps_active_identity_and_renumbers_auto_tabs() {
let mut ws = Workspace::test_new("test");
let moved_root = ws.tabs[0].root_pane;
@@ -1051,46 +531,4 @@ mod tests {
assert_eq!(ws.tabs[2].root_pane, moved_root);
assert_eq!(ws.tabs[ws.active_tab].root_pane, active_root);
}
-
- #[test]
- fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() {
- let mut ws = Workspace::test_new("test");
- ws.tabs[0].set_custom_name("main".into());
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
-
- let tab_idx = ws.test_add_tab(Some("logs"));
- let second_root_pane = ws.tabs[tab_idx].root_pane;
- ws.tabs[tab_idx]
- .panes
- .get_mut(&second_root_pane)
- .unwrap()
- .detected_agent = Some(Agent::Claude);
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 2);
- assert!(details.iter().any(|detail| detail.label == "main·pi"));
- assert!(details.iter().any(|detail| detail.label == "logs·claude"));
- }
-
- #[test]
- fn pane_details_include_hook_reported_unknown_agents() {
- let mut ws = Workspace::test_new("test");
- let root_pane = ws.tabs[0].root_pane;
- ws.tabs[0]
- .panes
- .get_mut(&root_pane)
- .unwrap()
- .set_hook_authority(
- "custom:hermes".into(),
- "hermes".into(),
- AgentState::Working,
- None,
- );
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 1);
- assert_eq!(details[0].agent_label, "hermes");
- assert_eq!(details[0].agent, None);
- }
}
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
new file mode 100644
index 00000000..53e08920
--- /dev/null
+++ b/src/workspace/aggregate.rs
@@ -0,0 +1,193 @@
+use crate::detect::{Agent, AgentState};
+use crate::layout::PaneId;
+
+use super::{Tab, Workspace};
+
+/// Detail info for a single pane, used by the agent detail panel.
+pub struct PaneDetail {
+ pub pane_id: PaneId,
+ pub tab_idx: usize,
+ pub tab_label: String,
+ pub label: String,
+ pub agent_label: String,
+ #[allow(dead_code)]
+ pub agent: Option<Agent>,
+ pub state: AgentState,
+ pub seen: bool,
+}
+
+impl Tab {
+ pub fn has_working_pane(&self) -> bool {
+ self.panes
+ .values()
+ .any(|pane| pane.state == AgentState::Working)
+ }
+
+ pub fn pane_details(&self) -> Vec<PaneDetail> {
+ self.layout
+ .pane_ids()
+ .iter()
+ .filter_map(|id| {
+ let pane = self.panes.get(id)?;
+ let agent_label = pane.effective_agent_label()?.to_string();
+ Some(PaneDetail {
+ pane_id: *id,
+ tab_idx: self.number.saturating_sub(1),
+ tab_label: self.display_name(),
+ label: agent_label.clone(),
+ agent_label,
+ agent: pane.effective_known_agent(),
+ state: pane.state,
+ seen: pane.seen,
+ })
+ })
+ .collect()
+ }
+}
+
+fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
+ match (state, seen) {
+ (AgentState::Blocked, _) => 4,
+ (AgentState::Idle, false) => 3,
+ (AgentState::Working, _) => 2,
+ (AgentState::Idle, true) => 1,
+ (AgentState::Unknown, _) => 0,
+ }
+}
+
+impl Workspace {
+ pub fn aggregate_state(&self) -> (AgentState, bool) {
+ self.tabs
+ .iter()
+ .flat_map(|tab| tab.panes.values())
+ .map(|pane| (pane.state, pane.seen))
+ .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
+ .unwrap_or((AgentState::Unknown, true))
+ }
+
+ pub fn has_working_pane(&self) -> bool {
+ self.tabs.iter().any(Tab::has_working_pane)
+ }
+
+ pub fn pane_details(&self) -> Vec<PaneDetail> {
+ let multi_tab = self.tabs.len() > 1;
+ self.tabs
+ .iter()
+ .flat_map(Tab::pane_details)
+ .map(|mut detail| {
+ if multi_tab {
+ detail.label = format!("{}·{}", detail.tab_label, detail.agent_label);
+ }
+ detail
+ })
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use ratatui::layout::Direction;
+
+ use super::*;
+ use crate::detect::Agent;
+
+ #[test]
+ fn aggregate_state_all_unknown() {
+ let ws = Workspace::test_new("test");
+ let (state, seen) = ws.aggregate_state();
+ assert_eq!(state, AgentState::Unknown);
+ assert!(seen);
+ }
+
+ #[test]
+ fn aggregate_state_priority() {
+ let mut ws = Workspace::test_new("test");
+ let id2 = ws.test_split(Direction::Horizontal);
+ let root_id = ws.tabs[0]
+ .panes
+ .keys()
+ .find(|id| **id != id2)
+ .copied()
+ .unwrap();
+ ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
+ ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
+
+ let (state, seen) = ws.aggregate_state();
+ assert_eq!(state, AgentState::Working);
+ assert!(seen);
+ }
+
+ #[test]
+ fn aggregate_state_done_unseen_beats_working() {
+ let mut ws = Workspace::test_new("test");
+ let id2 = ws.test_split(Direction::Horizontal);
+ let root_id = ws.tabs[0]
+ .panes
+ .keys()
+ .find(|id| **id != id2)
+ .copied()
+ .unwrap();
+ let root = ws.tabs[0].panes.get_mut(&root_id).unwrap();
+ root.state = AgentState::Idle;
+ root.seen = false;
+ ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
+
+ let (state, seen) = ws.aggregate_state();
+ assert_eq!(state, AgentState::Idle);
+ assert!(!seen);
+ }
+
+ #[test]
+ fn pane_details_hide_plain_shells() {
+ let mut ws = Workspace::test_new("test");
+ let root_pane = ws.tabs[0].root_pane;
+ ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
+ ws.test_split(Direction::Horizontal);
+
+ let details = ws.pane_details();
+ assert_eq!(details.len(), 1);
+ assert_eq!(details[0].label, "pi");
+ }
+
+ #[test]
+ fn pane_details_include_tab_context_when_workspace_has_multiple_tabs() {
+ let mut ws = Workspace::test_new("test");
+ ws.tabs[0].set_custom_name("main".into());
+ let root_pane = ws.tabs[0].root_pane;
+ ws.tabs[0].panes.get_mut(&root_pane).unwrap().detected_agent = Some(Agent::Pi);
+
+ let tab_idx = ws.test_add_tab(Some("logs"));
+ let second_root_pane = ws.tabs[tab_idx].root_pane;
+ ws.tabs[tab_idx]
+ .panes
+ .get_mut(&second_root_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+
+ let details = ws.pane_details();
+ assert_eq!(details.len(), 2);
+ assert!(details.iter().any(|detail| detail.label == "main·pi"));
+ assert!(details.iter().any(|detail| detail.label == "logs·claude"));
+ }
+
+ #[test]
+ fn pane_details_include_hook_reported_unknown_agents() {
+ let mut ws = Workspace::test_new("test");
+ let root_pane = ws.tabs[0].root_pane;
+ ws.tabs[0]
+ .panes
+ .get_mut(&root_pane)
+ .unwrap()
+ .set_hook_authority(
+ "custom:hermes".into(),
+ "hermes".into(),
+ AgentState::Working,
+ None,
+ );
+
+ let details = ws.pane_details();
+ assert_eq!(details.len(), 1);
+ assert_eq!(details[0].agent_label, "hermes");
+ assert_eq!(details[0].agent, None);
+ }
+}
diff --git a/src/workspace/git.rs b/src/workspace/git.rs
new file mode 100644
index 00000000..ca453003
--- /dev/null
+++ b/src/workspace/git.rs
@@ -0,0 +1,149 @@
+use std::path::{Path, PathBuf};
+
+pub fn derive_label_from_cwd(cwd: &Path) -> String {
+ if let Some(repo_root) = git_repo_root(cwd) {
+ if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) {
+ return name.to_string();
+ }
+ }
+
+ if let Ok(home) = std::env::var("HOME") {
+ let home = Path::new(&home);
+ if cwd == home {
+ return "~".to_string();
+ }
+ }
+
+ cwd.file_name()
+ .and_then(|n| n.to_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| cwd.display().to_string())
+}
+
+pub fn git_branch(cwd: &Path) -> Option<String> {
+ let repo_root = git_repo_root(cwd)?;
+ let git_dir = git_dir_for_repo_root(&repo_root)?;
+ let head = std::fs::read_to_string(git_dir.join("HEAD")).ok()?;
+ parse_git_head_branch(&head)
+}
+
+fn git_dir_for_repo_root(repo_root: &Path) -> Option<PathBuf> {
+ let git_path = repo_root.join(".git");
+ if git_path.is_dir() {
+ return Some(git_path);
+ }
+
+ let gitdir = std::fs::read_to_string(&git_path).ok()?;
+ let relative = gitdir.trim().strip_prefix("gitdir:")?.trim();
+ let resolved = Path::new(relative);
+ Some(if resolved.is_absolute() {
+ resolved.to_path_buf()
+ } else {
+ repo_root.join(resolved)
+ })
+}
+
+fn parse_git_head_branch(head: &str) -> Option<String> {
+ let branch = head.trim().strip_prefix("ref: refs/heads/")?;
+ (!branch.is_empty()).then(|| branch.to_string())
+}
+
+fn git_repo_root(start: &Path) -> Option<PathBuf> {
+ let mut current = if start.is_dir() {
+ start.to_path_buf()
+ } else {
+ start.parent()?.to_path_buf()
+ };
+
+ loop {
+ if current.join(".git").exists() {
+ return Some(current);
+ }
+ if !current.pop() {
+ return None;
+ }
+ }
+}
+
+pub(super) fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> {
+ git_repo_root(cwd)?;
+
+ let output = std::process::Command::new("git")
+ .arg("-C")
+ .arg(cwd)
+ .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"])
+ .output()
+ .ok()?;
+
+ if !output.status.success() {
+ return None;
+ }
+
+ let stdout = String::from_utf8(output.stdout).ok()?;
+ parse_git_ahead_behind_output(&stdout)
+}
+
+fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> {
+ let mut parts = stdout.split_whitespace();
+ let ahead = parts.next()?.parse().ok()?;
+ let behind = parts.next()?.parse().ok()?;
+ Some((ahead, behind))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ use super::*;
+
+ fn temp_test_dir(name: &str) -> PathBuf {
+ let unique = format!(
+ "herdr-workspace-tests-{}-{}-{}",
+ name,
+ std::process::id(),
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap()
+ .as_nanos()
+ );
+ let path = std::env::temp_dir().join(unique);
+ std::fs::create_dir_all(&path).unwrap();
+ path
+ }
+
+ #[test]
+ fn git_branch_reads_head_from_standard_repo() {
+ let root = temp_test_dir("standard-repo");
+ std::fs::create_dir_all(root.join(".git")).unwrap();
+ std::fs::write(root.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
+
+ assert_eq!(git_branch(&root).as_deref(), Some("main"));
+
+ std::fs::remove_dir_all(root).unwrap();
+ }
+
+ #[test]
+ fn git_branch_reads_head_from_worktree_gitdir_file() {
+ let root = temp_test_dir("worktree");
+ let worktree_git_dir = root.join(".bare/worktrees/feature");
+ std::fs::create_dir_all(&worktree_git_dir).unwrap();
+ std::fs::write(root.join(".git"), "gitdir: .bare/worktrees/feature\n").unwrap();
+ std::fs::write(worktree_git_dir.join("HEAD"), "ref: refs/heads/feature\n").unwrap();
+
+ assert_eq!(git_branch(&root).as_deref(), Some("feature"));
+
+ std::fs::remove_dir_all(root).unwrap();
+ }
+
+ #[test]
+ fn git_branch_returns_none_for_detached_head() {
+ let root = temp_test_dir("detached-head");
+ std::fs::create_dir_all(root.join(".git")).unwrap();
+ std::fs::write(root.join(".git/HEAD"), "3e1b9a8d\n").unwrap();
+
+ assert_eq!(git_branch(&root), None);
+
+ std::fs::remove_dir_all(root).unwrap();
+ }
+}
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
new file mode 100644
index 00000000..f8b31eb5
--- /dev/null
+++ b/src/workspace/tab.rs
@@ -0,0 +1,265 @@
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
+
+use ratatui::layout::Direction;
+use tokio::sync::{mpsc, Notify};
+
+use crate::events::AppEvent;
+use crate::layout::{PaneId, TileLayout};
+use crate::pane::{PaneRuntime, PaneState};
+
+pub struct Tab {
+ pub custom_name: Option<String>,
+ pub number: usize,
+ /// Identity source for this tab's pane tree.
+ pub root_pane: PaneId,
+ pub layout: TileLayout,
+ /// Pane state — always present, testable without PTYs.
+ pub panes: HashMap<PaneId, PaneState>,
+ /// Best-effort cwd cache per pane. Live runtime cwd wins when available.
+ pub pane_cwds: HashMap<PaneId, PathBuf>,
+ /// Pane runtimes — only present in production (empty in tests).
+ pub runtimes: HashMap<PaneId, PaneRuntime>,
+ pub zoomed: bool,
+ pub events: mpsc::Sender<AppEvent>,
+ pub(crate) render_notify: Arc<Notify>,
+ pub(crate) render_dirty: Arc<AtomicBool>,
+}
+
+impl Drop for Tab {
+ fn drop(&mut self) {
+ let runtimes = std::mem::take(&mut self.runtimes);
+ for (pane_id, runtime) in runtimes {
+ runtime.shutdown(pane_id);
+ }
+ }
+}
+
+impl Tab {
+ pub fn new(
+ number: usize,
+ initial_cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> std::io::Result<Self> {
+ let (layout, root_id) = TileLayout::new();
+ let runtime = PaneRuntime::spawn(
+ root_id,
+ rows,
+ cols,
+ initial_cwd.clone(),
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?;
+
+ let mut panes = HashMap::new();
+ panes.insert(root_id, PaneState::new());
+ let mut pane_cwds = HashMap::new();
+ pane_cwds.insert(root_id, initial_cwd);
+ let mut runtimes = HashMap::new();
+ runtimes.insert(root_id, runtime);
+
+ Ok(Self {
+ custom_name: None,
+ number,
+ root_pane: root_id,
+ layout,
+ panes,
+ pane_cwds,
+ runtimes,
+ zoomed: false,
+ events,
+ render_notify,
+ render_dirty,
+ })
+ }
+
+ pub fn display_name(&self) -> String {
+ self.custom_name
+ .clone()
+ .unwrap_or_else(|| self.number.to_string())
+ }
+
+ pub fn is_auto_named(&self) -> bool {
+ self.custom_name.is_none()
+ }
+
+ pub fn set_custom_name(&mut self, name: String) {
+ self.custom_name = Some(name);
+ }
+
+ pub fn split_focused(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ ) -> std::io::Result<PaneId> {
+ self.split_focused_with_runtime(
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ None,
+ )
+ }
+
+ pub fn split_focused_command(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ command: &str,
+ extra_env: &[(String, String)],
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ ) -> std::io::Result<PaneId> {
+ self.split_focused_with_runtime(
+ direction,
+ rows,
+ cols,
+ cwd,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ Some((command, extra_env)),
+ )
+ }
+
+ fn split_focused_with_runtime(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ scrollback_limit_bytes: usize,
+ host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ command: Option<(&str, &[(String, String)])>,
+ ) -> std::io::Result<PaneId> {
+ let new_id = self.layout.split_focused(direction);
+ let actual_cwd =
+ cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ let runtime = if let Some((command, extra_env)) = command {
+ PaneRuntime::spawn_shell_command(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ command,
+ extra_env,
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?
+ } else {
+ PaneRuntime::spawn(
+ new_id,
+ rows,
+ cols,
+ actual_cwd.clone(),
+ scrollback_limit_bytes,
+ host_terminal_theme,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?
+ };
+ self.panes.insert(new_id, PaneState::new());
+ self.pane_cwds.insert(new_id, actual_cwd);
+ self.runtimes.insert(new_id, runtime);
+ self.zoomed = false;
+ Ok(new_id)
+ }
+
+ pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> {
+ let pane_id = self.layout.focused();
+ self.detach_pane(pane_id)
+ }
+
+ pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ self.detach_pane(pane_id)
+ }
+
+ pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ self.detach_pane(pane_id)
+ }
+
+ fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ if self.layout.pane_count() <= 1 {
+ return None;
+ }
+
+ let next_root = self.promoted_root_if_needed(pane_id);
+
+ if self.layout.focused() == pane_id {
+ self.layout.close_focused();
+ } else {
+ let prev_focus = self.layout.focused();
+ self.layout.focus_pane(pane_id);
+ self.layout.close_focused();
+ self.layout.focus_pane(prev_focus);
+ }
+
+ self.panes.remove(&pane_id);
+ self.pane_cwds.remove(&pane_id);
+ let runtime = self.runtimes.remove(&pane_id);
+ self.zoomed = false;
+ if let Some(next_root) = next_root {
+ self.root_pane = next_root;
+ }
+ Some((pane_id, runtime))
+ }
+
+ fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> {
+ if self.root_pane != closing {
+ return None;
+ }
+ self.layout.pane_ids().into_iter().find(|id| *id != closing)
+ }
+
+ pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
+ self.runtimes.get(&self.layout.focused())
+ }
+
+ pub fn cwd_for_pane(&self, pane_id: PaneId) -> Option<PathBuf> {
+ self.runtimes
+ .get(&pane_id)
+ .and_then(|rt| rt.cwd())
+ .or_else(|| self.pane_cwds.get(&pane_id).cloned())
+ }
+
+ pub fn root_cwd(&self) -> Option<PathBuf> {
+ self.cwd_for_pane(self.root_pane)
+ }
+
+ #[cfg(test)]
+ #[allow(dead_code)] // retained for focused layout-order assertions in tests
+ pub fn pane_states(&self) -> Vec<(crate::detect::AgentState, bool)> {
+ self.layout
+ .pane_ids()
+ .iter()
+ .map(|id| {
+ self.panes
+ .get(id)
+ .map(|p| (p.state, p.seen))
+ .unwrap_or((crate::detect::AgentState::Unknown, true))
+ })
+ .collect()
+ }
+}