fix: preserve live handoff terminal state

This commit is contained in:
Ogulcan Celik 2026-05-28 04:11:56 +03:00
parent 789738cada
commit bf9cc20124
27 changed files with 1135 additions and 102 deletions

View File

@ -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)

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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);

View File

@ -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));
}

View File

@ -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);

View File

@ -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);

View File

@ -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(())
}

View File

@ -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();

View File

@ -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,

View File

@ -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,

36
src/handoff_runtime.rs Normal file
View File

@ -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,
}

View File

@ -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,

View File

@ -27,6 +27,7 @@ mod config;
mod detect;
mod events;
mod ghostty;
mod handoff_runtime;
mod input;
mod integration;
mod ipc;

View File

@ -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));

124
src/pane/kitty_keyboard.rs Normal file
View File

@ -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]);
}
}

View File

@ -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()),
})
}
@ -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[<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);

View File

@ -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,

View File

@ -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>,
@ -125,6 +114,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,
@ -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 {

View File

@ -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,
},
);

View File

@ -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 {

View File

@ -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,
},
);
}

View File

@ -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()
}
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<Bytes>) {
let (runtime, rx) = crate::pane::PaneRuntime::test_with_channel(cols, rows);
(Self(runtime), rx)

View File

@ -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,

View File

@ -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();