aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-28 04:11:56 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-28 04:12:53 +0300
commitbf9cc201244946496b4d950046bf7edcc4892994 (patch)
tree920c866e67783cf31d2feff9642457204999c8eb
parent789738cada5afaa20bbd77a0d479fb689c455bfe (diff)
fix: preserve live handoff terminal state
-rw-r--r--src/app/actions.rs1
-rw-r--r--src/app/agents.rs4
-rw-r--r--src/app/api.rs82
-rw-r--r--src/app/api/panes.rs2
-rw-r--r--src/app/api/tabs.rs3
-rw-r--r--src/app/creation.rs4
-rw-r--r--src/app/ids.rs18
-rw-r--r--src/app/input/mod.rs1
-rw-r--r--src/app/input/mouse.rs3
-rw-r--r--src/app/input/navigate.rs1
-rw-r--r--src/app/mod.rs8
-rw-r--r--src/app/state.rs6
-rw-r--r--src/ghostty/mod.rs58
-rw-r--r--src/handoff_runtime.rs36
-rw-r--r--src/input/model.rs7
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs76
-rw-r--r--src/pane/kitty_keyboard.rs124
-rw-r--r--src/pane/terminal.rs218
-rw-r--r--src/persist.rs2
-rw-r--r--src/persist/restore.rs79
-rw-r--r--src/persist/snapshot.rs12
-rw-r--r--src/server/handoff.rs17
-rw-r--r--src/server/headless.rs45
-rw-r--r--src/terminal/runtime.rs15
-rw-r--r--src/terminal/state.rs7
-rw-r--r--tests/live_handoff.rs405
27 files changed, 1134 insertions, 101 deletions
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;
@@ -380,6 +382,8 @@ impl App {
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);
if focus {
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
@@ -66,6 +66,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);
self.schedule_session_save();
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<crate::layout::PaneId> {
+ 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::<u32>().ok()?);
+ let pane_id = self.resolve_raw_pane_id(pane_raw.parse::<u32>().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::<u32>().ok()?);
+ let pane_id = self.resolve_raw_pane_id(rest.parse::<u32>().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<crate::api::ApiRequestMessage>,
event_hub: crate::api::EventHub,
snapshot: &crate::persist::SessionSnapshot,
- imports: &mut std::collections::HashMap<u32, crate::persist::ImportedPaneRuntime>,
+ imports: &mut std::collections::HashMap<
+ u32,
+ crate::handoff_runtime::ImportedHandoffRuntime,
+ >,
) -> io::Result<Self> {
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<crate::terminal::TerminalId, crate::terminal::TerminalState>,
/// Terminal ids whose size is currently owned by a direct attach client.
pub direct_attach_resize_locks: std::collections::HashSet<crate::terminal::TerminalId>,
+ pub(crate) pane_id_aliases: std::collections::HashMap<u32, PaneId>,
pub workspaces: Vec<Workspace>,
pub active: Option<usize>,
pub(crate) previous_pane_focus: Option<PaneFocusTarget>,
@@ -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<String, Error> {
+ self.format_keyboard_state_ansi(false)
+ }
+
+ pub fn kitty_keyboard_state_ansi(&self) -> Result<String, Error> {
+ self.format_keyboard_state_ansi(true)
+ }
+
+ fn format_keyboard_state_ansi(&self, kitty_keyboard: bool) -> Result<String, Error> {
+ let mut formatter: ffi::GhosttyFormatter_ptr = ptr::null_mut();
+ let options = ffi::GhosttyFormatterTerminalOptions {
+ size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(),
+ emit: FormatterFormat::Vt.as_raw(),
+ unwrap: false,
+ trim: false,
+ extra: ffi::GhosttyFormatterTerminalExtra {
+ size: mem::size_of::<ffi::GhosttyFormatterTerminalExtra>(),
+ keyboard: true,
+ screen: ffi::GhosttyFormatterScreenExtra {
+ size: mem::size_of::<ffi::GhosttyFormatterScreenExtra>(),
+ 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<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub input_state: Option<crate::pane::InputState>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub initial_history_ansi: Option<String>,
+}
+
+#[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<String>,
-}
-
#[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<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
- 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<u8>,
+ stack: Vec<u16>,
+ 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::<Vec<_>>();
+ 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<String> {
+ 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::<u16>().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<crate::ghostty::RgbColor>,
pub initial_default_background: Option<crate::ghostty::RgbColor>,
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<String> {
+ 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<String> {
+ let core = self.core.lock().ok()?;
+ core.kitty_keyboard.replay_ansi()
+ }
+
pub fn input_state(&self) -> Option<InputState> {
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()),
})
}
@@ -1520,6 +1639,51 @@ mod tests {
}
#[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);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
@@ -1585,6 +1749,50 @@ mod tests {
}
#[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[<u", &pop_tx);
+ assert_eq!(
+ restored.keyboard_protocol(),
+ Some(crate::input::KeyboardProtocol::Kitty { flags: 1 })
+ );
+ }
+
+ #[test]
fn ghostty_modify_other_keys_mode_one_preserves_shift_enter() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 0).unwrap();
diff --git a/src/persist.rs b/src/persist.rs
index 0409511c..aff76bfe 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -10,7 +10,7 @@ mod snapshot;
pub use self::io::{clear, clear_history, load, load_history, save};
pub use self::restore::restore;
#[cfg(unix)]
-pub use self::restore::{restore_handoff, ImportedPaneRuntime};
+pub use self::restore::{handoff_pane_aliases, restore_handoff};
pub use self::snapshot::{
capture, capture_history, DirectionSnapshot, LayoutSnapshot, SessionHistorySnapshot,
SessionSnapshot, TabSnapshot, WorkspaceSnapshot,
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index 9c65d40d..26949cdd 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -22,17 +22,6 @@ use super::{
WorkspaceSnapshot,
};
-#[cfg(unix)]
-pub struct ImportedPaneRuntime {
- 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<String>,
-}
-
struct AgentRestoreState<'a> {
enabled: bool,
resumed_sessions: &'a mut HashSet<String>,
@@ -105,7 +94,7 @@ pub fn restore_handoff(
snapshot: &SessionSnapshot,
scrollback_limit_bytes: usize,
default_shell: &str,
- imports: &mut HashMap<u32, ImportedPaneRuntime>,
+ imports: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -126,6 +115,44 @@ pub fn restore_handoff(
}
#[cfg(unix)]
+pub fn handoff_pane_aliases(
+ snapshot: &SessionSnapshot,
+ workspaces: &[Workspace],
+) -> HashMap<u32, PaneId> {
+ 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<u32> {
+ 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<u32>) {
+ 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,
history: Option<&SessionHistorySnapshot>,
@@ -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<u32, ImportedPaneRuntime>,
+ imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -174,7 +201,7 @@ fn restore_with_imports(
scrollback_limit_bytes: usize,
default_shell: &str,
resume_agents_on_restore: bool,
- imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
+ imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -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<u32, ImportedPaneRuntime>,
+ imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
events: mpsc::Sender<AppEvent>,
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
@@ -250,7 +277,7 @@ fn restore_workspace(
cols: u16,
runtime_context: &RestoreRuntimeContext<'_>,
resumed_agent_sessions: &mut HashSet<String>,
- imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
+ imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
) -> RestoreFailures<Option<RestoredWorkspace>> {
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<String>,
- imported_panes: &mut HashMap<u32, ImportedPaneRuntime>,
+ imported_panes: &mut HashMap<u32, crate::handoff_runtime::ImportedHandoffRuntime>,
) -> RestoreFailures<Option<RestoredTab>> {
let (node, id_map) = restore_node_remapped(&snap.layout);
let reverse_id_map: HashMap<PaneId, u32> = 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_session: Option<PaneAgentSessionSnapshot>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub launch_argv: Option<Vec<String>>,
}
#[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<String>,
pub expected_protocol: Option<u32>,
pub snapshot: crate::persist::SessionSnapshot,
- pub panes: Vec<HandoffPane>,
-}
-
-#[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<String>,
+ pub panes: Vec<crate::handoff_runtime::HandoffRuntimeState>,
}
#[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<HandoffPane>,
+ panes: Vec<crate::handoff_runtime::HandoffRuntimeState>,
expected_protocol: Option<u32>,
expected_version: Option<String>,
) -> 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<AppEvent>,
@@ -369,14 +372,14 @@ impl TerminalRuntime {
pub fn cwd(&self) -> Option<std::path::PathBuf> {
self.0.cwd()
}
-}
-#[cfg(test)]
-impl TerminalRuntime {
pub(crate) fn current_size(&self) -> (u16, u16) {
self.0.current_size()
}
+}
+#[cfg(test)]
+impl TerminalRuntime {
pub(crate) fn test_with_channel(cols: u16, rows: u16) -> (Self, mpsc::Receiver<Bytes>) {
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<Vec<String>>,
+ 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 {
@@ -537,6 +537,409 @@ fn live_handoff_preserves_pane_process_io() {
}
#[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();
let base = unique_test_dir();