fix: preserve live handoff terminal state
This commit is contained in:
parent
789738cada
commit
bf9cc20124
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ mod config;
|
|||
mod detect;
|
||||
mod events;
|
||||
mod ghostty;
|
||||
mod handoff_runtime;
|
||||
mod input;
|
||||
mod integration;
|
||||
mod ipc;
|
||||
|
|
|
|||
76
src/pane.rs
76
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));
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue