From bf9cc201244946496b4d950046bf7edcc4892994 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Thu, 28 May 2026 04:11:56 +0300 Subject: [PATCH] fix: preserve live handoff terminal state --- src/app/actions.rs | 1 + src/app/agents.rs | 4 + src/app/api.rs | 82 ++++++++ src/app/api/panes.rs | 2 + src/app/api/tabs.rs | 3 + src/app/creation.rs | 4 + src/app/ids.rs | 18 +- src/app/input/mod.rs | 1 + src/app/input/mouse.rs | 3 + src/app/input/navigate.rs | 1 + src/app/mod.rs | 8 +- src/app/state.rs | 6 + src/ghostty/mod.rs | 58 ++++++ src/handoff_runtime.rs | 36 ++++ src/input/model.rs | 7 +- src/main.rs | 1 + src/pane.rs | 76 +++++-- src/pane/kitty_keyboard.rs | 124 ++++++++++++ src/pane/terminal.rs | 218 +++++++++++++++++++- src/persist.rs | 2 +- src/persist/restore.rs | 79 +++++--- src/persist/snapshot.rs | 12 ++ src/server/handoff.rs | 17 +- src/server/headless.rs | 45 +++-- src/terminal/runtime.rs | 17 +- src/terminal/state.rs | 7 + tests/live_handoff.rs | 405 ++++++++++++++++++++++++++++++++++++- 27 files changed, 1135 insertions(+), 102 deletions(-) create mode 100644 src/handoff_runtime.rs create mode 100644 src/pane/kitty_keyboard.rs diff --git a/src/app/actions.rs b/src/app/actions.rs index 3dd5562f..c2329531 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2177,6 +2177,7 @@ impl AppState { let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id); let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx); + self.pane_id_aliases.retain(|_, alias| *alias != pane_id); let should_close_workspace = { let ws = &mut self.workspaces[ws_idx]; ws.remove_pane(pane_id) diff --git a/src/app/agents.rs b/src/app/agents.rs index b9534ce2..8868fb9c 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -332,6 +332,8 @@ impl App { self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let ws_idx = self.state.workspaces.len() - 1; + self.state + .remove_alias_shadowed_by_new_pane(self.state.workspaces[ws_idx].tabs[0].root_pane); if focus || self.state.active.is_none() { self.state.switch_workspace(ws_idx); self.state.mode = Mode::Terminal; @@ -379,6 +381,8 @@ impl App { .map_err(|err| AgentStartError::SpawnFailed(err.to_string()))?; self.terminal_runtimes .insert(result.1.terminal.id.clone(), result.1.runtime); + self.state + .remove_alias_shadowed_by_new_pane(result.1.pane_id); self.state .terminals .insert(result.1.terminal.id.clone(), result.1.terminal); diff --git a/src/app/api.rs b/src/app/api.rs index a69dccec..562bc449 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -12,6 +12,12 @@ mod worktrees; use super::{api_helpers::pane_agent_status, App, Mode, OverlayPaneState, ToastKind}; use crate::events::AppEvent; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeExitAction { + RespawnShell, + ClosePane, +} + impl App { pub(crate) fn handle_internal_event(&mut self, ev: AppEvent) { if let AppEvent::ClipboardWrite { content } = ev { @@ -43,6 +49,17 @@ impl App { return; } + if let AppEvent::PaneDied { pane_id } = &ev { + if self.runtime_exit_action(*pane_id) == RuntimeExitAction::RespawnShell + && self.respawn_shell_for_launch_pane(*pane_id) + { + self.overlay_panes.remove(pane_id); + self.render_dirty.store(true, Ordering::Release); + self.render_notify.notify_one(); + return; + } + } + let overlay_state = if let AppEvent::PaneDied { pane_id } = &ev { self.overlay_panes.remove(pane_id) } else { @@ -210,6 +227,71 @@ impl App { } } + fn runtime_exit_action(&self, pane_id: crate::layout::PaneId) -> RuntimeExitAction { + let Some((_, pane_state)) = self.find_pane(pane_id) else { + return RuntimeExitAction::ClosePane; + }; + let Some(terminal) = self.state.terminals.get(&pane_state.attached_terminal_id) else { + return RuntimeExitAction::ClosePane; + }; + + if terminal.respawn_shell_on_exit { + RuntimeExitAction::RespawnShell + } else { + RuntimeExitAction::ClosePane + } + } + + fn respawn_shell_for_launch_pane(&mut self, pane_id: crate::layout::PaneId) -> bool { + let Some((ws_idx, pane_state)) = self.find_pane(pane_id) else { + return false; + }; + let terminal_id = pane_state.attached_terminal_id.clone(); + let Some(terminal) = self.state.terminals.get(&terminal_id) else { + return false; + }; + + let cwd = terminal.cwd.clone(); + let (rows, cols) = self + .terminal_runtimes + .get(&terminal_id) + .map(|runtime| runtime.current_size()) + .unwrap_or_else(|| self.state.estimate_pane_size()); + let runtime = match crate::terminal::TerminalRuntime::spawn( + pane_id, + rows, + cols, + cwd, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + &self.state.default_shell, + self.event_tx.clone(), + self.render_notify.clone(), + self.render_dirty.clone(), + ) { + Ok(runtime) => runtime, + Err(err) => { + tracing::warn!( + pane = pane_id.raw(), + terminal = %terminal_id, + err = %err, + "failed to respawn shell after launch command exited" + ); + return false; + } + }; + + self.terminal_runtimes.insert(terminal_id.clone(), runtime); + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.launch_argv = None; + terminal.respawn_shell_on_exit = false; + terminal.clear_agent_name(); + } + self.state.focus_pane_in_workspace(ws_idx, pane_id); + self.schedule_session_save(); + true + } + pub(crate) 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; diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index a81a6080..4620329f 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -65,6 +65,8 @@ impl App { } self.terminal_runtimes .insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); self.state .terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index 0344fcea..f605d143 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -93,6 +93,9 @@ impl App { Ok((tab_idx, terminal, runtime)) => { self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); + self.state.remove_alias_shadowed_by_new_pane( + self.state.workspaces[ws_idx].tabs[tab_idx].root_pane, + ); if let Some(label) = label { let workspace_id = self.state.workspaces[ws_idx].id.clone(); let tab_id = self diff --git a/src/app/creation.rs b/src/app/creation.rs index a034fdc4..aa727dd5 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -112,8 +112,10 @@ impl App { self.state.host_terminal_theme, &self.state.default_shell, )?; + let root_pane = ws.tabs[idx].root_pane; self.terminal_runtimes.insert(terminal.id.clone(), runtime); self.state.terminals.insert(terminal.id.clone(), terminal); + self.state.remove_alias_shadowed_by_new_pane(root_pane); if focus { self.state.switch_workspace_tab(ws_idx, idx); self.state.mode = Mode::Terminal; @@ -149,6 +151,8 @@ impl App { self.state.terminals.insert(terminal.id.clone(), terminal); self.state.workspaces.push(ws); let idx = self.state.workspaces.len() - 1; + self.state + .remove_alias_shadowed_by_new_pane(self.state.workspaces[idx].tabs[0].root_pane); let workspace_id = self.state.workspaces[idx].id.clone(); let root_pane = self.state.workspaces[idx].tabs[0].root_pane.raw(); crate::logging::workspace_created(&workspace_id, root_pane); diff --git a/src/app/ids.rs b/src/app/ids.rs index 490e6051..a18efd2d 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -1,7 +1,7 @@ use super::App; impl App { - pub(super) fn find_pane( + pub(crate) fn find_pane( &self, pane_id: crate::layout::PaneId, ) -> Option<(usize, &crate::pane::PaneState)> { @@ -57,15 +57,27 @@ impl App { Some((ws_idx, tab_idx)) } + fn resolve_raw_pane_id(&self, raw: u32) -> Option { + if let Some(alias) = self.state.pane_id_aliases.get(&raw).copied() { + return self.find_pane(alias).map(|_| alias); + } + let pane_id = crate::layout::PaneId::from_raw(raw); + if self.find_pane(pane_id).is_some() { + return Some(pane_id); + } + None + } + 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::().ok()?); + let pane_id = self.resolve_raw_pane_id(pane_raw.parse::().ok()?)?; + self.state.workspaces.get(ws_idx)?.pane_state(pane_id)?; return Some((ws_idx, pane_id)); } - let pane_id = crate::layout::PaneId::from_raw(rest.parse::().ok()?); + let pane_id = self.resolve_raw_pane_id(rest.parse::().ok()?)?; return self.find_pane(pane_id).map(|(ws_idx, _)| (ws_idx, pane_id)); } diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 8ba8e4bd..d9092d40 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -419,6 +419,7 @@ impl AppState { ) { let new_id = new_pane.pane_id; terminal_runtimes.insert(new_pane.terminal.id.clone(), new_pane.runtime); + self.remove_alias_shadowed_by_new_pane(new_id); self.terminals .insert(new_pane.terminal.id.clone(), new_pane.terminal); self.record_pane_focus_change(previous_focus, ws_idx, new_id); diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 6db1a4a9..a9badfa9 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -2302,6 +2302,7 @@ mod tests { mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, mouse_alternate_scroll: true, + modify_other_keys: false, }; assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport); @@ -2626,6 +2627,7 @@ mod tests { mouse_protocol_mode: crate::input::MouseProtocolMode::None, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, + modify_other_keys: false, }; assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll); @@ -2641,6 +2643,7 @@ mod tests { mouse_protocol_mode: crate::input::MouseProtocolMode::None, mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Default, mouse_alternate_scroll: true, + modify_other_keys: false, }; assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll); diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 3b8435df..032ba8ae 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -348,6 +348,7 @@ impl App { temp_files, }, ); + self.state.remove_alias_shadowed_by_new_pane(new_pane_id); self.state.mode = Mode::Terminal; Ok(()) } diff --git a/src/app/mod.rs b/src/app/mod.rs index 12fb704e..fbbacd61 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -393,6 +393,7 @@ impl App { let mut state = AppState { terminals: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), + pane_id_aliases: std::collections::HashMap::new(), workspaces, active, previous_pane_focus: None, @@ -594,7 +595,10 @@ impl App { api_rx: tokio::sync::mpsc::UnboundedReceiver, event_hub: crate::api::EventHub, snapshot: &crate::persist::SessionSnapshot, - imports: &mut std::collections::HashMap, + imports: &mut std::collections::HashMap< + u32, + crate::handoff_runtime::ImportedHandoffRuntime, + >, ) -> io::Result { let mut app = Self::new(config, true, config_diagnostic, api_rx, event_hub); let (workspaces, terminals, runtimes) = crate::persist::restore_handoff( @@ -606,9 +610,11 @@ impl App { app.render_notify.clone(), app.render_dirty.clone(), )?; + let pane_id_aliases = crate::persist::handoff_pane_aliases(snapshot, &workspaces); app.no_session = false; app.state.detach_exits = false; + app.state.pane_id_aliases = pane_id_aliases; app.state.workspaces = workspaces; app.state.terminals = terminals; app.terminal_runtimes = runtimes.into(); diff --git a/src/app/state.rs b/src/app/state.rs index 7c47973c..37edc9df 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1046,6 +1046,7 @@ pub struct AppState { std::collections::HashMap, /// Terminal ids whose size is currently owned by a direct attach client. pub direct_attach_resize_locks: std::collections::HashSet, + pub(crate) pane_id_aliases: std::collections::HashMap, pub workspaces: Vec, pub active: Option, pub(crate) previous_pane_focus: Option, @@ -1182,6 +1183,10 @@ impl AppState { self.session_dirty = true; } + pub(crate) fn remove_alias_shadowed_by_new_pane(&mut self, pane_id: PaneId) { + self.pane_id_aliases.remove(&pane_id.raw()); + } + pub fn sound_enabled(&self) -> bool { self.sound.enabled } @@ -1349,6 +1354,7 @@ impl AppState { Self { terminals: std::collections::HashMap::new(), direct_attach_resize_locks: std::collections::HashSet::new(), + pane_id_aliases: std::collections::HashMap::new(), workspaces: Vec::new(), active: None, previous_pane_focus: None, diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 62d55835..9eda90d7 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -806,6 +806,64 @@ impl Terminal { ) } + pub fn keyboard_state_ansi(&self) -> Result { + self.format_keyboard_state_ansi(false) + } + + pub fn kitty_keyboard_state_ansi(&self) -> Result { + self.format_keyboard_state_ansi(true) + } + + fn format_keyboard_state_ansi(&self, kitty_keyboard: bool) -> Result { + let mut formatter: ffi::GhosttyFormatter_ptr = ptr::null_mut(); + let options = ffi::GhosttyFormatterTerminalOptions { + size: mem::size_of::(), + emit: FormatterFormat::Vt.as_raw(), + unwrap: false, + trim: false, + extra: ffi::GhosttyFormatterTerminalExtra { + size: mem::size_of::(), + keyboard: true, + screen: ffi::GhosttyFormatterScreenExtra { + size: mem::size_of::(), + kitty_keyboard, + ..Default::default() + }, + ..Default::default() + }, + selection: ptr::null(), + }; + unsafe { + ffi::ghostty_formatter_terminal_new(ptr::null(), &mut formatter, self.raw, options) + .into_result()?; + } + + let mut out_ptr = ptr::null_mut(); + let mut out_len = 0usize; + let result = unsafe { + ffi::ghostty_formatter_format_alloc(formatter, ptr::null(), &mut out_ptr, &mut out_len) + }; + unsafe { + ffi::ghostty_formatter_free(formatter); + } + result.into_result()?; + + let text = if out_len == 0 { + String::new() + } else { + let bytes = unsafe { slice::from_raw_parts(out_ptr.cast_const(), out_len) }; + String::from_utf8_lossy(bytes).into_owned() + }; + + if !out_ptr.is_null() { + unsafe { + ffi::ghostty_free(ptr::null(), out_ptr, out_len); + } + } + + Ok(text) + } + fn read_formatted_selection( &self, start: ffi::GhosttyPoint, diff --git a/src/handoff_runtime.rs b/src/handoff_runtime.rs new file mode 100644 index 00000000..331cff07 --- /dev/null +++ b/src/handoff_runtime.rs @@ -0,0 +1,36 @@ +#[cfg(unix)] +use serde::{Deserialize, Serialize}; + +#[cfg(unix)] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct HandoffRuntimeState { + pub pane_id: u32, + pub child_pid: u32, + pub rows: u16, + pub cols: u16, + pub cell_width_px: u32, + pub cell_height_px: u32, + #[serde(default)] + pub keyboard_protocol_flags: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyboard_protocol_ansi: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_history_ansi: Option, +} + +#[cfg(unix)] +impl HandoffRuntimeState { + pub fn with_pane_id(mut self, pane_id: crate::layout::PaneId) -> Self { + self.pane_id = pane_id.raw(); + self + } +} + +#[cfg(unix)] +#[derive(Debug)] +pub(crate) struct ImportedHandoffRuntime { + pub master_fd: std::os::fd::RawFd, + pub state: HandoffRuntimeState, +} diff --git a/src/input/model.rs b/src/input/model.rs index 5dddebda..777c6a3b 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -1,4 +1,5 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, KeyboardEnhancementFlags}; +use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TerminalKey { @@ -93,7 +94,8 @@ impl KeyboardProtocol { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum MouseProtocolMode { None, Press, @@ -108,7 +110,8 @@ impl MouseProtocolMode { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum MouseProtocolEncoding { Default, Utf8, diff --git a/src/main.rs b/src/main.rs index bc10c0f7..163ce66e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod config; mod detect; mod events; mod ghostty; +mod handoff_runtime; mod input; mod integration; mod ipc; diff --git a/src/pane.rs b/src/pane.rs index 77249de8..11d6e03c 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -16,6 +16,7 @@ use crate::events::AppEvent; use crate::layout::PaneId; mod input; +mod kitty_keyboard; mod osc; mod state; mod terminal; @@ -493,19 +494,6 @@ pub struct PaneRuntime { detect_handle: tokio::task::AbortHandle, } -#[cfg(unix)] -#[derive(Debug)] -pub struct PaneRuntimeImport { - pub pane_id: PaneId, - pub master_fd: std::os::fd::RawFd, - pub child_pid: u32, - pub rows: u16, - pub cols: u16, - pub cell_width_px: u32, - pub cell_height_px: u32, - pub initial_history_ansi: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WheelRouting { HostScroll, @@ -835,16 +823,25 @@ impl PaneRuntime { } #[cfg(unix)] - pub fn handoff_pane(&self, pane_id: u32) -> crate::server::handoff::HandoffPane { + pub fn handoff_runtime_state( + &self, + pane_id: u32, + ) -> crate::handoff_runtime::HandoffRuntimeState { let child_pid = self.child_pid.load(Ordering::Acquire); let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get(); - crate::server::handoff::HandoffPane { + crate::handoff_runtime::HandoffRuntimeState { pane_id, child_pid, rows, cols, cell_width_px, cell_height_px, + keyboard_protocol_flags: match self.keyboard_protocol() { + crate::input::KeyboardProtocol::Legacy => 0, + crate::input::KeyboardProtocol::Kitty { flags } => flags, + }, + keyboard_protocol_ansi: self.terminal.kitty_keyboard_state_ansi(), + input_state: self.input_state(), initial_history_ansi: None, } } @@ -1057,23 +1054,27 @@ impl PaneRuntime { #[cfg(unix)] pub fn from_handoff_fd( - import: PaneRuntimeImport, + import: crate::handoff_runtime::ImportedHandoffRuntime, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, ) -> std::io::Result { - let PaneRuntimeImport { + let crate::handoff_runtime::ImportedHandoffRuntime { master_fd, state } = import; + let crate::handoff_runtime::HandoffRuntimeState { pane_id, - master_fd, child_pid, rows, cols, cell_width_px, cell_height_px, + keyboard_protocol_flags, + keyboard_protocol_ansi, + input_state, initial_history_ansi, - } = import; + } = state; + let pane_id = PaneId::from_raw(pane_id); use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd}; let master_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(master_fd) }; @@ -1099,12 +1100,20 @@ impl PaneRuntime { } let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); + if let Some(input_state) = input_state { + pane_terminal.seed_handoff_input_state(input_state); + } + if let Some(ansi) = keyboard_protocol_ansi.as_deref() { + pane_terminal.seed_keyboard_protocol_ansi(ansi); + } else { + pane_terminal.seed_keyboard_protocol_flags(keyboard_protocol_flags); + } if let Some(ansi) = initial_history_ansi.as_deref() { pane_terminal.seed_history_ansi(ansi); } let terminal = Arc::new(PaneTerminal::new(pane_terminal)); let child_pid = Arc::new(AtomicU32::new(child_pid)); - let kitty_keyboard_flags = Arc::new(AtomicU16::new(0)); + let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags)); let (reader_stopped_tx, reader_stopped_rx) = std::sync::mpsc::channel(); { @@ -1790,7 +1799,6 @@ impl PaneRuntime { self.detect_reset_notify.notify_one(); } - #[cfg(test)] pub(crate) fn current_size(&self) -> (u16, u16) { let (rows, cols, _, _) = self.current_size.get(); (rows, cols) @@ -2220,6 +2228,32 @@ mod tests { assert!(runtime.handoff_history_ansi().is_none()); } + #[tokio::test] + async fn handoff_runtime_state_captures_terminal_input_state() { + let runtime = PaneRuntime::test_with_screen_bytes( + 80, + 24, + b"\x1b[>5u\x1b[>4;2m\x1b[?1h\x1b[?2004h\x1b[?1004h\x1b[?1002h\x1b[?1006h", + ); + + let pane = runtime.handoff_runtime_state(12); + + assert_eq!(pane.keyboard_protocol_flags, 5); + assert_eq!( + pane.input_state, + Some(InputState { + alternate_screen: false, + application_cursor: true, + bracketed_paste: true, + focus_reporting: true, + mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, + mouse_alternate_scroll: true, + modify_other_keys: true, + }) + ); + } + #[test] fn truncate_handoff_history_keeps_recent_utf8_boundary() { let history = format!("old\n{}\nrecent\n", "é".repeat(8)); diff --git a/src/pane/kitty_keyboard.rs b/src/pane/kitty_keyboard.rs new file mode 100644 index 00000000..1b7d3274 --- /dev/null +++ b/src/pane/kitty_keyboard.rs @@ -0,0 +1,124 @@ +#[derive(Debug, Clone, Default)] +pub(crate) struct KittyKeyboardTracker { + pending: Vec, + stack: Vec, + flags: u16, +} + +impl KittyKeyboardTracker { + pub(crate) fn observe(&mut self, bytes: &[u8]) { + let combined; + let bytes = if self.pending.is_empty() { + bytes + } else { + combined = self + .pending + .iter() + .copied() + .chain(bytes.iter().copied()) + .collect::>(); + self.pending.clear(); + &combined + }; + let mut index = 0; + while index < bytes.len() { + if bytes[index] != 0x1b { + index += 1; + continue; + } + if index + 1 >= bytes.len() { + self.store_pending(&bytes[index..]); + break; + } + if bytes[index + 1] != b'[' { + index += 1; + continue; + } + + let mut end = index + 2; + while end < bytes.len() && !(0x40..=0x7e).contains(&bytes[end]) { + end += 1; + } + if end >= bytes.len() { + self.store_pending(&bytes[index..]); + break; + } + + if bytes[end] == b'u' { + self.observe_csi_u(&bytes[index + 2..end]); + } + index = end + 1; + } + } + + fn store_pending(&mut self, bytes: &[u8]) { + self.pending.clear(); + if bytes.len() <= 64 { + self.pending.extend_from_slice(bytes); + } + } + + fn observe_csi_u(&mut self, params: &[u8]) { + let Some((&kind, rest)) = params.split_first() else { + return; + }; + match kind { + b'>' => { + let flags = parse_kitty_keyboard_flags(rest); + self.stack.push(self.flags); + self.flags = flags; + } + b'=' => { + self.flags = parse_kitty_keyboard_flags(rest); + } + b'<' => { + let count = parse_kitty_keyboard_flags(rest).max(1); + for _ in 0..count { + self.flags = self.stack.pop().unwrap_or(0); + } + } + _ => {} + } + } + + pub(crate) fn replay_ansi(&self) -> Option { + if self.stack.is_empty() { + return (self.flags != 0).then(|| format!("\x1b[={}u", self.flags)); + } + + let mut ansi = String::new(); + let baseline = self.stack[0]; + if baseline != 0 { + ansi.push_str(&format!("\x1b[={baseline}u")); + } + for flags in self.stack.iter().skip(1).copied().chain([self.flags]) { + ansi.push_str(&format!("\x1b[>{flags}u")); + } + (!ansi.is_empty()).then_some(ansi) + } +} + +fn parse_kitty_keyboard_flags(bytes: &[u8]) -> u16 { + let first_param = bytes.split(|byte| *byte == b';').next().unwrap_or_default(); + std::str::from_utf8(first_param) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn buffers_split_csi_sequences() { + let mut tracker = KittyKeyboardTracker::default(); + + tracker.observe(b"\x1b[>1u\x1b[>5"); + tracker.observe(b"u\x1b[<"); + tracker.observe(b"u"); + + assert_eq!(tracker.flags, 1); + assert_eq!(tracker.stack, vec![0]); + } +} diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index 77c83447..c248bded 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -5,6 +5,7 @@ use std::time::Duration; use bytes::Bytes; use ratatui::style::{Color, Modifier, Style}; use ratatui::{layout::Rect, Frame}; +use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tracing::{debug, error}; use unicode_width::UnicodeWidthStr; @@ -17,6 +18,7 @@ use super::{ ghostty_mouse_event_from_button_kind, ghostty_mouse_event_from_wheel_kind, ghostty_prefers_herdr_text_encoding, }, + kitty_keyboard::KittyKeyboardTracker, osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, @@ -27,6 +29,10 @@ use super::{ const DEFAULT_DETECTION_ROWS: usize = 24; const KITTY_GRAPHICS_REDRAW_SETTLE: Duration = Duration::from_millis(20); +const MODE_MOUSE_X10: u16 = 9; +const MODE_MOUSE_PRESS_RELEASE: u16 = 1000; +const MODE_MOUSE_BUTTON_MOTION: u16 = 1002; +const MODE_MOUSE_ANY_MOTION: u16 = 1003; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ScrollMetrics { @@ -57,7 +63,7 @@ fn decscusr_cursor_shape(style: crate::ghostty::CursorVisualStyle, blinking: boo } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct InputState { pub alternate_screen: bool, pub application_cursor: bool, @@ -66,6 +72,8 @@ pub struct InputState { pub mouse_protocol_mode: crate::input::MouseProtocolMode, pub mouse_protocol_encoding: crate::input::MouseProtocolEncoding, pub mouse_alternate_scroll: bool, + #[serde(default)] + pub modify_other_keys: bool, } impl InputState { @@ -89,6 +97,7 @@ pub(crate) struct GhosttyPaneTerminal { pub(crate) struct GhosttyPaneCore { pub terminal: crate::ghostty::Terminal, pub render_state: crate::ghostty::RenderState, + pub kitty_keyboard: KittyKeyboardTracker, pub initial_default_foreground: Option, pub initial_default_background: Option, pub host_terminal_theme: crate::terminal_theme::TerminalTheme, @@ -224,6 +233,12 @@ impl PaneTerminal { self.ghostty.keyboard_protocol().unwrap_or(fallback) } + pub fn kitty_keyboard_state_ansi(&self) -> Option { + self.ghostty + .kitty_keyboard_state_ansi() + .filter(|ansi| !ansi.is_empty()) + } + pub fn encode_terminal_key( &self, key: crate::input::TerminalKey, @@ -281,6 +296,7 @@ impl GhosttyPaneTerminal { core: Mutex::new(GhosttyPaneCore { terminal, render_state, + kitty_keyboard: KittyKeyboardTracker::default(), initial_default_foreground, initial_default_background, host_terminal_theme: crate::terminal_theme::TerminalTheme::default(), @@ -395,6 +411,7 @@ impl GhosttyPaneTerminal { ); } + core.kitty_keyboard.observe(filtered_bytes.as_ref()); core.terminal.write(filtered_bytes.as_ref()); core.default_color_event_tracker .observe(filtered_bytes.as_ref()); @@ -433,6 +450,98 @@ impl GhosttyPaneTerminal { } } + pub fn seed_handoff_input_state(&self, input_state: InputState) { + let Ok(mut core) = self.core.lock() else { + return; + }; + + if input_state.alternate_screen { + core.terminal.write(b"\x1b[?1049h"); + } + let _ = core.terminal.mode_set( + crate::ghostty::MODE_APPLICATION_CURSOR_KEYS, + input_state.application_cursor, + ); + let _ = core.terminal.mode_set( + crate::ghostty::MODE_BRACKETED_PASTE, + input_state.bracketed_paste, + ); + let _ = core.terminal.mode_set( + crate::ghostty::MODE_FOCUS_EVENT, + input_state.focus_reporting, + ); + let _ = core.terminal.mode_set( + crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL, + input_state.mouse_alternate_scroll, + ); + + for mode in [ + MODE_MOUSE_X10, + MODE_MOUSE_PRESS_RELEASE, + MODE_MOUSE_BUTTON_MOTION, + MODE_MOUSE_ANY_MOTION, + ] { + let _ = core.terminal.mode_set(mode, false); + } + let mouse_mode = match input_state.mouse_protocol_mode { + crate::input::MouseProtocolMode::None => None, + crate::input::MouseProtocolMode::Press => Some(MODE_MOUSE_X10), + crate::input::MouseProtocolMode::PressRelease => Some(MODE_MOUSE_PRESS_RELEASE), + crate::input::MouseProtocolMode::ButtonMotion => Some(MODE_MOUSE_BUTTON_MOTION), + crate::input::MouseProtocolMode::AnyMotion => Some(MODE_MOUSE_ANY_MOTION), + }; + if let Some(mode) = mouse_mode { + let _ = core.terminal.mode_set(mode, true); + } + + let _ = core + .terminal + .mode_set(crate::ghostty::MODE_MOUSE_UTF8, false); + let _ = core + .terminal + .mode_set(crate::ghostty::MODE_MOUSE_SGR, false); + match input_state.mouse_protocol_encoding { + crate::input::MouseProtocolEncoding::Default => {} + crate::input::MouseProtocolEncoding::Utf8 => { + let _ = core + .terminal + .mode_set(crate::ghostty::MODE_MOUSE_UTF8, true); + } + crate::input::MouseProtocolEncoding::Sgr => { + let _ = core.terminal.mode_set(crate::ghostty::MODE_MOUSE_SGR, true); + } + } + + if input_state.modify_other_keys { + core.terminal.write(b"\x1b[>4;2m"); + } + + if let Ok(mut key_encoder) = self.key_encoder.lock() { + key_encoder.set_from_terminal(&core.terminal); + } + } + + pub fn seed_keyboard_protocol_flags(&self, flags: u16) { + if flags == 0 { + return; + } + self.seed_keyboard_protocol_ansi(&format!("\x1b[>{flags}u")); + } + + pub fn seed_keyboard_protocol_ansi(&self, ansi: &str) { + if ansi.is_empty() { + return; + } + let Ok(mut core) = self.core.lock() else { + return; + }; + core.kitty_keyboard.observe(ansi.as_bytes()); + core.terminal.write(ansi.as_bytes()); + if let Ok(mut key_encoder) = self.key_encoder.lock() { + key_encoder.set_from_terminal(&core.terminal); + } + } + pub fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) { if let Ok(mut core) = self.core.lock() { let _ = core @@ -491,6 +600,11 @@ impl GhosttyPaneTerminal { )) } + pub fn kitty_keyboard_state_ansi(&self) -> Option { + let core = self.core.lock().ok()?; + core.kitty_keyboard.replay_ansi() + } + pub fn input_state(&self) -> Option { let Ok(core) = self.core.lock() else { return None; @@ -521,13 +635,13 @@ impl GhosttyPaneTerminal { .terminal .mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL) .ok()?; - let mouse_protocol_mode = if core.terminal.mode_get(1003).ok()? { + let mouse_protocol_mode = if core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()? { crate::input::MouseProtocolMode::AnyMotion - } else if core.terminal.mode_get(1002).ok()? { + } else if core.terminal.mode_get(MODE_MOUSE_BUTTON_MOTION).ok()? { crate::input::MouseProtocolMode::ButtonMotion - } else if core.terminal.mode_get(1000).ok()? { + } else if core.terminal.mode_get(MODE_MOUSE_PRESS_RELEASE).ok()? { crate::input::MouseProtocolMode::PressRelease - } else if core.terminal.mode_get(9).ok()? { + } else if core.terminal.mode_get(MODE_MOUSE_X10).ok()? { crate::input::MouseProtocolMode::Press } else { crate::input::MouseProtocolMode::None @@ -547,6 +661,11 @@ impl GhosttyPaneTerminal { mouse_protocol_mode, mouse_protocol_encoding, mouse_alternate_scroll, + modify_other_keys: core + .terminal + .keyboard_state_ansi() + .ok() + .is_some_and(|ansi| !ansi.is_empty()), }) } @@ -1519,6 +1638,51 @@ mod tests { assert_eq!(encoded, b"\x1bOA"); } + #[test] + fn ghostty_seed_handoff_input_state_restores_input_modes() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + pane.seed_handoff_input_state(InputState { + alternate_screen: true, + application_cursor: true, + bracketed_paste: true, + focus_reporting: true, + mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, + mouse_alternate_scroll: true, + modify_other_keys: true, + }); + + assert_eq!( + pane.input_state(), + Some(InputState { + alternate_screen: true, + application_cursor: true, + bracketed_paste: true, + focus_reporting: true, + mouse_protocol_mode: crate::input::MouseProtocolMode::ButtonMotion, + mouse_protocol_encoding: crate::input::MouseProtocolEncoding::Sgr, + mouse_alternate_scroll: true, + modify_other_keys: true, + }) + ); + + 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"); + + let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); + let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + assert_eq!(encoded, b"\x1b[27;2;13~"); + } + #[test] fn ghostty_key_encoder_updates_after_terminal_mode_changes() { let (tx, _rx) = mpsc::channel(4); @@ -1584,6 +1748,50 @@ mod tests { assert_eq!(encoded, b"\x1b[13;2u"); } + #[test] + fn ghostty_seed_keyboard_protocol_flags_restores_shift_enter_encoding() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + pane.seed_keyboard_protocol_flags(5); + + let key = crate::input::parse_terminal_key_sequence("\x1b[13;2u").unwrap(); + let encoded = pane.encode_terminal_key(key, crate::input::KeyboardProtocol::Legacy); + + assert_eq!( + pane.keyboard_protocol(), + Some(crate::input::KeyboardProtocol::Kitty { flags: 5 }) + ); + assert_eq!(encoded, b"\x1b[13;2u"); + } + + #[test] + fn ghostty_keyboard_protocol_state_replays_nested_stack() { + 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); + pane.process_pty_bytes(pane_id, 0, b"\x1b[>1u\x1b[>5u", &tx); + + let ansi = pane.kitty_keyboard_state_ansi().unwrap(); + + let (restored_tx, _restored_rx) = mpsc::channel(4); + let restored_terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap(); + let restored = GhosttyPaneTerminal::new(restored_terminal, restored_tx).unwrap(); + restored.seed_keyboard_protocol_ansi(&ansi); + assert_eq!( + restored.keyboard_protocol(), + Some(crate::input::KeyboardProtocol::Kitty { flags: 5 }) + ); + + let (pop_tx, _pop_rx) = mpsc::channel(4); + restored.process_pty_bytes(pane_id, 0, b"\x1b[, -} - struct AgentRestoreState<'a> { enabled: bool, resumed_sessions: &'a mut HashSet, @@ -105,7 +94,7 @@ pub fn restore_handoff( snapshot: &SessionSnapshot, scrollback_limit_bytes: usize, default_shell: &str, - imports: &mut HashMap, + imports: &mut HashMap, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -125,6 +114,44 @@ pub fn restore_handoff( ) } +#[cfg(unix)] +pub fn handoff_pane_aliases( + snapshot: &SessionSnapshot, + workspaces: &[Workspace], +) -> HashMap { + let mut aliases = HashMap::new(); + for (ws_snap, workspace) in snapshot.workspaces.iter().zip(workspaces) { + for (tab_snap, tab) in ws_snap.tabs.iter().zip(&workspace.tabs) { + let old_ids = collect_snapshot_pane_ids(&tab_snap.layout); + let new_ids = tab.layout.pane_ids(); + for (old_id, new_id) in old_ids.into_iter().zip(new_ids) { + if old_id != new_id.raw() { + aliases.insert(old_id, new_id); + } + } + } + } + aliases +} + +#[cfg(unix)] +fn collect_snapshot_pane_ids(node: &LayoutSnapshot) -> Vec { + let mut ids = Vec::new(); + collect_snapshot_ids_inner(node, &mut ids); + ids +} + +#[cfg(unix)] +fn collect_snapshot_ids_inner(node: &LayoutSnapshot, ids: &mut Vec) { + match node { + LayoutSnapshot::Pane(id) => ids.push(*id), + LayoutSnapshot::Split { first, second, .. } => { + collect_snapshot_ids_inner(first, ids); + collect_snapshot_ids_inner(second, ids); + } + } +} + #[cfg(unix)] fn restore_with_imports_strict( snapshot: &SessionSnapshot, @@ -134,7 +161,7 @@ fn restore_with_imports_strict( scrollback_limit_bytes: usize, default_shell: &str, resume_agents_on_restore: bool, - imported_panes: &mut HashMap, + imported_panes: &mut HashMap, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -174,7 +201,7 @@ fn restore_with_imports( scrollback_limit_bytes: usize, default_shell: &str, resume_agents_on_restore: bool, - imported_panes: &mut HashMap, + imported_panes: &mut HashMap, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -203,7 +230,7 @@ fn restore_with_imports_and_failures( scrollback_limit_bytes: usize, default_shell: &str, resume_agents_on_restore: bool, - imported_panes: &mut HashMap, + imported_panes: &mut HashMap, events: mpsc::Sender, render_notify: Arc, render_dirty: Arc, @@ -250,7 +277,7 @@ fn restore_workspace( cols: u16, runtime_context: &RestoreRuntimeContext<'_>, resumed_agent_sessions: &mut HashSet, - imported_panes: &mut HashMap, + imported_panes: &mut HashMap, ) -> RestoreFailures> { let mut tabs = Vec::new(); let mut terminals = Vec::new(); @@ -331,7 +358,7 @@ fn restore_tab( cols: u16, runtime_context: &RestoreRuntimeContext<'_>, resumed_agent_sessions: &mut HashSet, - imported_panes: &mut HashMap, + imported_panes: &mut HashMap, ) -> RestoreFailures> { let (node, id_map) = restore_node_remapped(&snap.layout); let reverse_id_map: HashMap = id_map @@ -370,6 +397,7 @@ fn restore_tab( let saved_label = saved_pane.and_then(|p| p.label.clone()); let saved_agent_name = saved_pane.and_then(|p| p.agent_name.clone()); + let saved_launch_argv = saved_pane.and_then(|p| p.launch_argv.clone()); let saved_agent_session = saved_pane.and_then(|p| p.agent_session.as_ref()); let saved_history = old_id.and_then(|old_id| history.and_then(|history| history.panes.get(old_id))); @@ -390,15 +418,9 @@ fn restore_tab( let was_imported = imported_runtime.is_some(); let runtime_result = if let Some(imported) = imported_runtime { TerminalRuntime::from_handoff_fd( - crate::pane::PaneRuntimeImport { - pane_id: *id, + crate::handoff_runtime::ImportedHandoffRuntime { master_fd: imported.master_fd, - child_pid: imported.child_pid, - rows: imported.rows, - cols: imported.cols, - cell_width_px: imported.cell_width_px, - cell_height_px: imported.cell_height_px, - initial_history_ansi: imported.initial_history_ansi, + state: imported.state.with_pane_id(*id), }, runtime_context.scrollback_limit_bytes, crate::terminal_theme::TerminalTheme::default(), @@ -443,6 +465,11 @@ fn restore_tab( Ok(runtime) => { let terminal_id = TerminalId::alloc(); let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone()); + if was_imported { + if let Some(argv) = saved_launch_argv { + terminal = terminal.with_launch_argv(argv).with_respawn_shell_on_exit(); + } + } if let Some(label) = saved_label { terminal.set_manual_label(label); } @@ -974,6 +1001,7 @@ mod tests { kind: crate::agent_resume::AgentSessionRefKind::Id, value: "opencode-session".into(), }), + launch_argv: None, }, )]), zoomed: false, @@ -1100,6 +1128,7 @@ mod tests { label: None, agent_name: None, agent_session: None, + launch_argv: None, }, ); let history = SessionHistorySnapshot { diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 36df83b1..7cd01a64 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -97,6 +97,8 @@ pub struct PaneSnapshot { pub agent_name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent_session: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_argv: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -314,6 +316,11 @@ fn capture_tab( .get(id) .and_then(|pane| terminals.get(&pane.attached_terminal_id)) .and_then(|terminal| terminal.agent_name.clone()); + let launch_argv = tab + .panes + .get(id) + .and_then(|pane| terminals.get(&pane.attached_terminal_id)) + .and_then(|terminal| terminal.launch_argv.clone()); let agent_session = tab.panes .get(id) @@ -345,6 +352,7 @@ fn capture_tab( label, agent_name, agent_session, + launch_argv, }, ); } @@ -580,6 +588,7 @@ mod tests { label: None, agent_name: None, agent_session: None, + launch_argv: None, }, ); panes.insert( @@ -589,6 +598,7 @@ mod tests { label: Some("website".into()), agent_name: None, agent_session: None, + launch_argv: None, }, ); @@ -1109,6 +1119,7 @@ mod tests { label: None, agent_name: None, agent_session: None, + launch_argv: None, }, ); panes.insert( @@ -1120,6 +1131,7 @@ mod tests { label: None, agent_name: None, agent_session: None, + launch_argv: None, }, ); diff --git a/src/server/handoff.rs b/src/server/handoff.rs index 6dadfdf4..22d1f902 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -38,20 +38,7 @@ pub(crate) struct HandoffManifest { pub expected_version: Option, pub expected_protocol: Option, pub snapshot: crate::persist::SessionSnapshot, - pub panes: Vec, -} - -#[cfg(unix)] -#[derive(Debug, Serialize, Deserialize)] -pub(crate) struct HandoffPane { - pub pane_id: u32, - pub child_pid: u32, - pub rows: u16, - pub cols: u16, - pub cell_width_px: u32, - pub cell_height_px: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub initial_history_ansi: Option, + pub panes: Vec, } #[cfg(unix)] @@ -307,7 +294,7 @@ pub(crate) fn report_owned(stream: &mut UnixStream) -> io::Result<()> { #[cfg(unix)] pub(crate) fn manifest_for( snapshot: crate::persist::SessionSnapshot, - panes: Vec, + panes: Vec, expected_protocol: Option, expected_version: Option, ) -> HandoffManifest { diff --git a/src/server/headless.rs b/src/server/headless.rs index f06c66bc..18717782 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -631,12 +631,12 @@ impl HeadlessServer { self.app.state.collapsed_space_keys.clone(), ); - let mut panes = Vec::new(); + let mut handoff_entries = Vec::new(); for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { let Some(pane_id) = pane_by_terminal.get(terminal_id).copied() else { continue; }; - let mut handoff_pane = runtime.handoff_pane(pane_id); + let mut handoff_runtime = runtime.handoff_runtime_state(pane_id); let has_agent_session = self .app .state @@ -644,11 +644,15 @@ impl HeadlessServer { .get(terminal_id) .is_some_and(|terminal| terminal.persisted_agent_session.is_some()); if !has_agent_session { - handoff_pane.initial_history_ansi = runtime.handoff_history_ansi(); + handoff_runtime.initial_history_ansi = runtime.handoff_history_ansi(); } - panes.push(handoff_pane); + handoff_entries.push((terminal_id.clone(), handoff_runtime)); } + let panes = handoff_entries + .iter() + .map(|(_, runtime)| runtime.clone()) + .collect(); let manifest = crate::server::handoff::manifest_for( snapshot, panes, @@ -671,10 +675,10 @@ impl HeadlessServer { let mut fds = Vec::new(); let duplicate_result = (|| { - for (terminal_id, runtime) in self.app.terminal_runtimes.iter() { - if !pane_by_terminal.contains_key(terminal_id) { + for (terminal_id, _) in &handoff_entries { + let Some(runtime) = self.app.terminal_runtimes.get(terminal_id) else { continue; - } + }; fds.push(runtime.duplicate_handoff_fd()?); } Ok::<(), io::Error>(()) @@ -1356,6 +1360,7 @@ impl HeadlessServer { true } AppEvent::PaneDied { pane_id } => { + let pane_id_val = *pane_id; let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { ws.tabs.iter().find_map(|tab| { tab.panes @@ -1366,11 +1371,13 @@ impl HeadlessServer { self.app.handle_internal_event(ev); - if let Some(terminal_id) = terminal_id { - self.shutdown_terminal_attach_clients( - &terminal_id, - format!("terminal {terminal_id} exited"), - ); + if self.app.find_pane(pane_id_val).is_none() { + if let Some(terminal_id) = terminal_id { + self.shutdown_terminal_attach_clients( + &terminal_id, + format!("terminal {terminal_id} exited"), + ); + } } true @@ -2721,17 +2728,13 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> let event_hub = api::EventHub::default(); let mut imports = HashMap::new(); - for (pane, fd) in received.manifest.panes.iter().zip(received.fds) { + for (pane, fd) in received.manifest.panes.into_iter().zip(received.fds) { + let pane_id = pane.pane_id; imports.insert( - pane.pane_id, - crate::persist::ImportedPaneRuntime { + pane_id, + crate::handoff_runtime::ImportedHandoffRuntime { master_fd: fd, - child_pid: pane.child_pid, - rows: pane.rows, - cols: pane.cols, - cell_width_px: pane.cell_width_px, - cell_height_px: pane.cell_height_px, - initial_history_ansi: pane.initial_history_ansi.clone(), + state: pane, }, ); } diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index fa81aa4a..636972a1 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -45,8 +45,11 @@ impl TerminalRuntime { } #[cfg(unix)] - pub fn handoff_pane(&self, pane_id: u32) -> crate::server::handoff::HandoffPane { - self.0.handoff_pane(pane_id) + pub fn handoff_runtime_state( + &self, + pane_id: u32, + ) -> crate::handoff_runtime::HandoffRuntimeState { + self.0.handoff_runtime_state(pane_id) } #[cfg(unix)] @@ -56,7 +59,7 @@ impl TerminalRuntime { #[cfg(unix)] pub fn from_handoff_fd( - import: crate::pane::PaneRuntimeImport, + import: crate::handoff_runtime::ImportedHandoffRuntime, scrollback_limit_bytes: usize, host_terminal_theme: crate::terminal_theme::TerminalTheme, events: mpsc::Sender, @@ -369,14 +372,14 @@ impl TerminalRuntime { pub fn cwd(&self) -> Option { self.0.cwd() } + + pub(crate) fn current_size(&self) -> (u16, u16) { + self.0.current_size() + } } #[cfg(test)] impl TerminalRuntime { - pub(crate) fn current_size(&self) -> (u16, u16) { - self.0.current_size() - } - pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver) { let (runtime, rx) = crate::pane::PaneRuntime::test_with_channel(cols, rows); (Self(runtime), rx) diff --git a/src/terminal/state.rs b/src/terminal/state.rs index d713118c..f061e318 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -72,6 +72,7 @@ pub struct TerminalState { pub state: AgentState, pub revision: u64, pub launch_argv: Option>, + pub respawn_shell_on_exit: bool, } impl TerminalState { @@ -96,6 +97,7 @@ impl TerminalState { state: AgentState::Unknown, revision: 0, launch_argv: None, + respawn_shell_on_exit: false, } } @@ -104,6 +106,11 @@ impl TerminalState { self } + pub fn with_respawn_shell_on_exit(mut self) -> Self { + self.respawn_shell_on_exit = true; + self + } + #[cfg(test)] pub fn set_detected_state( &mut self, diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs index d9a8b34f..f55b5875 100644 --- a/tests/live_handoff.rs +++ b/tests/live_handoff.rs @@ -13,7 +13,7 @@ use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use support::{ cleanup_test_base, client_handshake, register_runtime_dir, register_spawned_herdr_pid, - unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, + send_input, unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, }; struct SpawnedHerdr { @@ -536,6 +536,409 @@ fn live_handoff_preserves_pane_process_io() { cleanup_test_base(&base); } +#[test] +fn live_handoff_preserves_keyboard_protocol_for_client_input() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let script = base.join("read-raw.py"); + let ready_marker = base.join("keyboard-ready"); + let received_marker = base.join("keyboard-received"); + + fs::create_dir_all(&base).unwrap(); + fs::write( + &script, + format!( + r#"import os +import pathlib +import select +import sys +import tty + +sys.stdout.buffer.write(b"\x1b[>5u") +sys.stdout.flush() +pathlib.Path({ready:?}).write_text("ready") +tty.setraw(sys.stdin.fileno()) +ready_fds, _, _ = select.select([sys.stdin.fileno()], [], [], 5) +data = os.read(sys.stdin.fileno(), 32) if ready_fds else b"" +pathlib.Path({received:?}).write_text(data.hex()) +"#, + ready = ready_marker.display().to_string(), + received = received_marker.display().to_string() + ), + ) + .unwrap(); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:run", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("python3 {}", script.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&ready_marker, Duration::from_secs(5)); + + let protocol = request( + &api_socket, + serde_json::json!({"id":"test:protocol","method":"ping","params":{}}), + )["result"]["protocol"] + .as_u64() + .unwrap() as u32; + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let mut client_stream = UnixStream::connect(&client_socket).unwrap(); + let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + assert_eq!(server_protocol, protocol); + assert!(error.is_none(), "client handshake failed: {error:?}"); + send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + + wait_for_file_contains(&received_marker, "1b5b31333b3275", Duration::from_secs(5)); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_preserves_modify_other_keys_for_client_input() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let client_socket = runtime_dir.join("herdr-client.sock"); + let script = base.join("read-raw.py"); + let ready_marker = base.join("modify-ready"); + let received_marker = base.join("modify-received"); + + fs::create_dir_all(&base).unwrap(); + fs::write( + &script, + format!( + r#"import os +import pathlib +import select +import sys +import tty + +sys.stdout.buffer.write(b"\x1b[>4;2m") +sys.stdout.flush() +pathlib.Path({ready:?}).write_text("ready") +tty.setraw(sys.stdin.fileno()) +ready_fds, _, _ = select.select([sys.stdin.fileno()], [], [], 5) +data = os.read(sys.stdin.fileno(), 32) if ready_fds else b"" +pathlib.Path({received:?}).write_text(data.hex()) +"#, + ready = ready_marker.display().to_string(), + received = received_marker.display().to_string() + ), + ) + .unwrap(); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:run", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("python3 {}", script.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&ready_marker, Duration::from_secs(5)); + + let protocol = request( + &api_socket, + serde_json::json!({"id":"test:protocol","method":"ping","params":{}}), + )["result"]["protocol"] + .as_u64() + .unwrap() as u32; + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + wait_for_socket(&client_socket, Duration::from_secs(5)); + + let mut client_stream = UnixStream::connect(&client_socket).unwrap(); + let (server_protocol, error) = client_handshake(&mut client_stream, protocol, 80, 24).unwrap(); + assert_eq!(server_protocol, protocol); + assert!(error.is_none(), "client handshake failed: {error:?}"); + send_input(&mut client_stream, b"\x1b[13;2u").unwrap(); + + wait_for_file_contains( + &received_marker, + "1b5b32373b323b31337e", + Duration::from_secs(5), + ); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_accepts_old_pane_id_from_child_env() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let pane_id_marker = base.join("old-pane-id"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:print-id", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("printf '%s' \"$HERDR_PANE_ID\" > {}", pane_id_marker.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&pane_id_marker, Duration::from_secs(5)); + let old_pane_id = fs::read_to_string(&pane_id_marker).unwrap(); + assert!( + old_pane_id.starts_with("p_"), + "unexpected pane id from env: {old_pane_id:?}" + ); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:old-pane-report", + "method": "pane.report_agent", + "params": { + "pane_id": old_pane_id, + "source": "handoff-test", + "agent": "pi", + "state": "working" + } + }), + )); + let agents = request( + &api_socket, + serde_json::json!({"id":"test:agent-list","method":"agent.list","params":{}}), + ); + let found = agents["result"]["agents"] + .as_array() + .unwrap() + .iter() + .any(|agent| { + agent["agent"].as_str() == Some("pi") + && agent["agent_status"].as_str() == Some("working") + }); + assert!( + found, + "old pane id report did not update restored pane: {agents}" + ); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_keeps_agent_started_pane_after_agent_exits() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let started_marker = base.join("agent-started"); + let exited_marker = base.join("agent-exited"); + let shell_marker = base.join("shell-after-agent"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let command = format!( + "echo started > {}; sleep 1; echo exited > {}", + started_marker.display(), + exited_marker.display() + ); + let started = request( + &api_socket, + serde_json::json!({ + "id": "test:agent-start", + "method": "agent.start", + "params": { + "name": "handoff-agent", + "cwd": "/tmp", + "focus": true, + "argv": ["/bin/sh", "-c", command] + } + }), + ); + assert_ok(started.clone()); + let pane_id = started["result"]["agent"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + support::wait_for_file(&started_marker, Duration::from_secs(5)); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + support::wait_for_file(&exited_marker, Duration::from_secs(5)); + thread::sleep(Duration::from_millis(300)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:shell-after-agent", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("echo alive > {}", shell_marker.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&shell_marker, Duration::from_secs(5)); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + +#[test] +fn live_handoff_keeps_shell_pane_after_foreground_process_exits() { + let _lock = test_lock(); + let base = unique_test_dir(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let api_socket = runtime_dir.join("herdr.sock"); + let started_marker = base.join("foreground-started"); + let exited_marker = base.join("foreground-exited"); + let shell_marker = base.join("shell-after-foreground"); + + let spawned = spawn_server(&config_home, &runtime_dir, &api_socket); + wait_for_socket(&api_socket, Duration::from_secs(10)); + register_runtime_dir(&runtime_dir); + + let created = request( + &api_socket, + serde_json::json!({ + "id": "test:workspace:create", + "method": "workspace.create", + "params": {"cwd": "/tmp", "focus": true} + }), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + let command = format!( + "sh -c 'echo started > {}; sleep 1; echo exited > {}'", + started_marker.display(), + exited_marker.display() + ); + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:run-foreground", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": command, "keys": ["Enter"]} + }), + )); + support::wait_for_file(&started_marker, Duration::from_secs(5)); + + assert_ok(request( + &api_socket, + serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}), + )); + drop(spawned); + wait_for_api(&api_socket, Duration::from_secs(10)); + support::wait_for_file(&exited_marker, Duration::from_secs(5)); + + assert_ok(request( + &api_socket, + serde_json::json!({ + "id": "test:pane:shell-after-foreground", + "method": "pane.send_input", + "params": {"pane_id": pane_id, "text": format!("echo alive > {}", shell_marker.display()), "keys": ["Enter"]} + }), + )); + support::wait_for_file(&shell_marker, Duration::from_secs(5)); + + let _ = request( + &api_socket, + serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}), + ); + cleanup_test_base(&base); +} + #[test] fn live_handoff_preserves_python_http_server() { let _lock = test_lock();