aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-23 03:47:34 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-23 03:47:44 +0300
commite7fc85bfdb51f89488430adbfe5bbced3be79c2f (patch)
treec1c0324f28040c50d0075d6407bdf6ab14d1b8c0 /src/app
parent2a20e90a026936d0d5b96823d74e2e4fe13a166f (diff)
fix: preserve kitty printable key releases
refs #1746
Diffstat (limited to 'src/app')
-rw-r--r--src/app/api/panes.rs74
-rw-r--r--src/app/input/mod.rs13
-rw-r--r--src/app/input/terminal.rs175
-rw-r--r--src/app/mod.rs501
-rw-r--r--src/app/runtime.rs46
5 files changed, 753 insertions, 56 deletions
diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs
index 9947a3b8..2da6d308 100644
--- a/src/app/api/panes.rs
+++ b/src/app/api/panes.rs
@@ -2571,6 +2571,80 @@ mod tests {
assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), source);
}
+ #[tokio::test]
+ async fn key_release_follows_pane_moved_across_workspaces() {
+ let mut app = app_with_linked_worktree();
+ let source = app.state.workspaces[0].tabs[0].root_pane;
+ let source_terminal_id = app.state.workspaces[0].tabs[0]
+ .terminal_id(source)
+ .unwrap()
+ .clone();
+ let (runtime, mut rx) =
+ crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
+ 80,
+ 24,
+ 0,
+ b"\x1b[>15u",
+ 2,
+ );
+ app.terminal_runtimes.insert(source_terminal_id, runtime);
+ app.state.workspaces.push(Workspace::test_new("other"));
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+ let source_public = app.public_pane_id(0, source).unwrap();
+ let target = app.state.workspaces[1].tabs[0].root_pane;
+ let target_tab_id = app.public_tab_id(1, 0).unwrap();
+ let target_pane_id = app.public_pane_id(1, target).unwrap();
+
+ app.route_client_events_from(
+ 42,
+ vec![crate::raw_input::RawInputEvent::Key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Char('j'),
+ crossterm::event::KeyModifiers::empty(),
+ ),
+ )],
+ false,
+ );
+ let response = app.handle_pane_move(
+ "req".into(),
+ PaneMoveParams {
+ pane_id: source_public,
+ destination: PaneMoveDestination::Tab {
+ tab_id: target_tab_id,
+ target_pane_id: Some(target_pane_id),
+ split: SplitDirection::Down,
+ ratio: None,
+ },
+ focus: false,
+ },
+ );
+ let success: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert!(matches!(success.result, ResponseResult::PaneMove { .. }));
+ app.route_client_events_from(
+ 42,
+ vec![crate::raw_input::RawInputEvent::Key(
+ crate::input::TerminalKey::new(
+ crossterm::event::KeyCode::Char('j'),
+ crossterm::event::KeyModifiers::empty(),
+ )
+ .with_kind(crossterm::event::KeyEventKind::Release),
+ )],
+ false,
+ );
+
+ assert_eq!(
+ rx.try_recv().expect("forwarded press"),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.try_recv().expect("forwarded release after pane move"),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(app.pressed_terminal_keys.is_empty());
+ }
+
#[test]
fn api_pane_move_to_existing_tab_across_workspace_reassigns_public_pane_id() {
let mut app = app_with_linked_worktree();
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 5eee4250..738b7963 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -71,21 +71,23 @@ use super::App;
// ---------------------------------------------------------------------------
impl App {
- pub(super) async fn handle_key(&mut self, key: TerminalKey) {
+ pub(super) async fn handle_key(
+ &mut self,
+ key: TerminalKey,
+ ) -> Option<super::TerminalInputTarget> {
if self.state.popup_pane.is_some() {
- self.handle_terminal_key(key).await;
- return;
+ return self.handle_terminal_key(key).await;
}
let key_event = key.as_key_event();
if modal_paste_target_active(&self.state) && is_modal_paste_shortcut(&key_event) {
if let Some(text) = crate::platform::read_clipboard_text() {
self.paste_into_active_text_input(&text);
}
- return;
+ return None;
}
match self.state.mode {
- Mode::Terminal => self.handle_terminal_key(key).await,
+ Mode::Terminal => return self.handle_terminal_key(key).await,
Mode::Prefix => self.handle_prefix_key(key),
Mode::Navigate => self.handle_navigate_key(key),
Mode::Copy => self.handle_copy_mode_key(key),
@@ -114,6 +116,7 @@ impl App {
Mode::Terminal => unreachable!(),
},
}
+ None
}
pub(super) async fn handle_paste(&mut self, text: String) {
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 51a3899c..61c6bbb3 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -3,20 +3,24 @@ use crossterm::event::KeyCode;
use tracing::{debug, warn};
use crate::{
- app::{App, Mode},
+ app::{App, Mode, TerminalInputTarget},
input::TerminalKey,
};
struct PreparedPaneInput {
ws_idx: usize,
pane_id: crate::layout::PaneId,
+ target: TerminalInputTarget,
bytes: Bytes,
}
enum PreparedPopupInput {
NotOpen,
Consumed,
- Bytes(Bytes),
+ Bytes {
+ target: TerminalInputTarget,
+ bytes: Bytes,
+ },
}
fn is_modifier_only_key(code: &KeyCode) -> bool {
@@ -24,26 +28,27 @@ fn is_modifier_only_key(code: &KeyCode) -> bool {
}
impl App {
- pub(crate) fn handle_terminal_key_headless(&mut self, key: TerminalKey) {
+ pub(crate) fn handle_terminal_key_headless(
+ &mut self,
+ key: TerminalKey,
+ ) -> Option<TerminalInputTarget> {
match self.prepare_popup_key_forward(key) {
PreparedPopupInput::NotOpen => {}
- PreparedPopupInput::Consumed => return,
- PreparedPopupInput::Bytes(bytes) => {
+ PreparedPopupInput::Consumed => return None,
+ PreparedPopupInput::Bytes { target, bytes } => {
let Some(runtime) = self.popup_runtime() else {
self.close_popup_pane();
- return;
+ return None;
};
- let _ = runtime.try_send_bytes(bytes);
- return;
+ return runtime.try_send_bytes(bytes).is_ok().then_some(target);
}
}
- let Some(input) = self.prepare_terminal_key_forward(key) else {
- return;
- };
- if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
- let _ = runtime.try_send_bytes(input.bytes);
- }
+ let input = self.prepare_terminal_key_forward(key)?;
+ let sent = self
+ .lookup_runtime_sender(input.ws_idx, input.pane_id)
+ .is_some_and(|runtime| runtime.try_send_bytes(input.bytes).is_ok());
+ sent.then_some(input.target)
}
fn prepare_terminal_key_forward(&mut self, key: TerminalKey) -> Option<PreparedPaneInput> {
@@ -116,6 +121,7 @@ impl App {
let ws_idx = self.state.active?;
let ws = self.state.workspaces.get(ws_idx)?;
let pane_id = ws.focused_pane_id()?;
+ let terminal_id = ws.terminal_id(pane_id)?.clone();
let rt =
self.state
.runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id)?;
@@ -207,6 +213,7 @@ impl App {
Some(PreparedPaneInput {
ws_idx,
pane_id,
+ target: TerminalInputTarget { terminal_id },
bytes: Bytes::from(bytes),
})
}
@@ -215,7 +222,15 @@ impl App {
if self.state.popup_pane.is_none() {
return PreparedPopupInput::NotOpen;
}
- let Some(rt) = self.popup_runtime() else {
+ let Some(terminal_id) = self
+ .state
+ .popup_pane
+ .as_ref()
+ .map(|popup| popup.terminal_id.clone())
+ else {
+ return PreparedPopupInput::NotOpen;
+ };
+ let Some(rt) = self.terminal_runtimes.get(&terminal_id) else {
self.close_popup_pane();
return PreparedPopupInput::Consumed;
};
@@ -225,30 +240,136 @@ impl App {
if bytes.is_empty() {
PreparedPopupInput::Consumed
} else {
- PreparedPopupInput::Bytes(Bytes::from(bytes))
+ PreparedPopupInput::Bytes {
+ target: TerminalInputTarget { terminal_id },
+ bytes: Bytes::from(bytes),
+ }
+ }
+ }
+
+ pub(crate) fn host_keyboard_report_all_requested(&self) -> bool {
+ let runtime = if self.state.popup_pane.is_some() {
+ self.popup_runtime()
+ } else if self.state.mode == Mode::Terminal {
+ self.state.active.and_then(|ws_idx| {
+ self.state
+ .focused_runtime_in_workspace(&self.terminal_runtimes, ws_idx)
+ })
+ } else {
+ None
+ };
+
+ runtime.is_some_and(|runtime| runtime.keyboard_protocol().reports_all_keys())
+ }
+
+ fn terminal_input_runtime(
+ &self,
+ target: &TerminalInputTarget,
+ ) -> Option<&crate::terminal::TerminalRuntime> {
+ if let Some(runtime) = self.terminal_runtimes.get(&target.terminal_id) {
+ return Some(runtime);
+ }
+ #[cfg(test)]
+ for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
+ for tab in &workspace.tabs {
+ for (&pane_id, pane) in &tab.panes {
+ if pane.attached_terminal_id == target.terminal_id {
+ return self.state.runtime_for_pane_in_workspace(
+ &self.terminal_runtimes,
+ ws_idx,
+ pane_id,
+ );
+ }
+ }
+ }
}
+ None
+ }
+
+ pub(crate) fn forward_terminal_key_to_target_headless(
+ &self,
+ target: &TerminalInputTarget,
+ key: TerminalKey,
+ ) -> bool {
+ let Some(runtime) = self.terminal_input_runtime(target) else {
+ return false;
+ };
+ let bytes = runtime.encode_terminal_key(key);
+ bytes.is_empty() || runtime.try_send_bytes(Bytes::from(bytes)).is_ok()
}
- pub(super) async fn handle_terminal_key(&mut self, key: TerminalKey) {
+ pub(crate) async fn forward_terminal_key_to_target(
+ &self,
+ target: &TerminalInputTarget,
+ key: TerminalKey,
+ ) -> bool {
+ let Some(runtime) = self.terminal_input_runtime(target) else {
+ return false;
+ };
+ let bytes = runtime.encode_terminal_key(key);
+ bytes.is_empty() || runtime.send_bytes(Bytes::from(bytes)).await.is_ok()
+ }
+
+ fn take_pressed_keys_for_source(
+ &mut self,
+ source_id: crate::app::InputSourceId,
+ ) -> Vec<crate::app::PressedTerminalKey> {
+ let pressed = self
+ .pressed_terminal_keys
+ .iter()
+ .filter(|((id, _), _)| *id == source_id)
+ .map(|(_, pressed)| pressed.clone())
+ .collect();
+ self.pressed_terminal_keys
+ .retain(|(id, _), _| *id != source_id);
+ self.suppressed_repeat_keys
+ .retain(|(id, _)| *id != source_id);
+ pressed
+ }
+
+ pub(crate) fn release_input_source_headless(&mut self, source_id: crate::app::InputSourceId) {
+ for pressed in self.take_pressed_keys_for_source(source_id) {
+ let release = pressed
+ .key
+ .with_kind(crossterm::event::KeyEventKind::Release);
+ let _ = self.forward_terminal_key_to_target_headless(&pressed.target, release);
+ }
+ }
+
+ pub(crate) async fn release_input_source(&mut self, source_id: crate::app::InputSourceId) {
+ for pressed in self.take_pressed_keys_for_source(source_id) {
+ let release = pressed
+ .key
+ .with_kind(crossterm::event::KeyEventKind::Release);
+ let _ = self
+ .forward_terminal_key_to_target(&pressed.target, release)
+ .await;
+ }
+ }
+
+ pub(super) async fn handle_terminal_key(
+ &mut self,
+ key: TerminalKey,
+ ) -> Option<TerminalInputTarget> {
match self.prepare_popup_key_forward(key) {
PreparedPopupInput::NotOpen => {}
- PreparedPopupInput::Consumed => return,
- PreparedPopupInput::Bytes(bytes) => {
+ PreparedPopupInput::Consumed => return None,
+ PreparedPopupInput::Bytes { target, bytes } => {
let Some(runtime) = self.popup_runtime() else {
self.close_popup_pane();
- return;
+ return None;
};
- let _ = runtime.send_bytes(bytes).await;
- return;
+ return runtime.send_bytes(bytes).await.is_ok().then_some(target);
}
}
- let Some(input) = self.prepare_terminal_key_forward(key) else {
- return;
+ let input = self.prepare_terminal_key_forward(key)?;
+ let sent = if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
+ runtime.send_bytes(input.bytes).await.is_ok()
+ } else {
+ false
};
- if let Some(runtime) = self.lookup_runtime_sender(input.ws_idx, input.pane_id) {
- let _ = runtime.send_bytes(input.bytes).await;
- }
+ sent.then_some(input.target)
}
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 8a9e7389..b56c128c 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -135,8 +135,9 @@ pub struct App {
pub(crate) detached_custom_command_children: Vec<std::process::Child>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
- pub(crate) suppressed_repeat_keys:
- HashSet<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)>,
+ pub(crate) pressed_terminal_keys:
+ HashMap<(InputSourceId, crossterm::event::KeyCode), PressedTerminalKey>,
+ pub(crate) suppressed_repeat_keys: HashSet<(InputSourceId, crossterm::event::KeyCode)>,
pub render_notify: Arc<Notify>,
pub render_dirty: Arc<AtomicBool>,
pub(crate) full_redraw_pending: bool,
@@ -197,10 +198,25 @@ async fn sleep_until_or_pending(deadline: Option<Instant>) {
}
}
-fn repeat_key_identity(
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct TerminalInputTarget {
+ terminal_id: crate::terminal::TerminalId,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct PressedTerminalKey {
+ target: TerminalInputTarget,
+ key: crate::input::TerminalKey,
+}
+
+pub(crate) type InputSourceId = u64;
+const LOCAL_INPUT_SOURCE: InputSourceId = 0;
+
+fn pressed_key_identity(
+ source_id: InputSourceId,
key: &crate::input::TerminalKey,
-) -> (crossterm::event::KeyCode, crossterm::event::KeyModifiers) {
- (key.code, key.modifiers)
+) -> (InputSourceId, crossterm::event::KeyCode) {
+ (source_id, key.code)
}
fn auto_updates_enabled(no_session: bool) -> bool {
@@ -746,6 +762,7 @@ impl App {
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
last_render_at: None,
+ pressed_terminal_keys: HashMap::new(),
suppressed_repeat_keys: HashSet::new(),
api_rx,
event_hub,
@@ -895,6 +912,7 @@ impl App {
let mut needs_render = true;
let mut host_mouse_capture_active = self.state.mouse_capture;
+ let mut host_keyboard_report_all_active = false;
while !self.state.should_quit {
self.reap_finished_custom_commands();
@@ -1021,6 +1039,7 @@ impl App {
let now = Instant::now();
self.sync_animation_timer(now);
self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
+ self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
if needs_render && self.can_render_now(now) {
self.render_dirty.swap(false, Ordering::AcqRel);
@@ -1137,6 +1156,16 @@ impl App {
Ok(())
}
+ fn sync_host_keyboard_report_all(&self, active: &mut bool) -> io::Result<()> {
+ let desired = self.host_keyboard_report_all_requested();
+ if desired == *active {
+ return Ok(());
+ }
+ crate::terminal_modes::set_host_kitty_keyboard_report_all(&mut io::stdout(), desired)?;
+ *active = desired;
+ Ok(())
+ }
+
fn sync_host_mouse_capture(&self, active: &mut bool) -> io::Result<()> {
let desired = self
.state
@@ -1571,34 +1600,65 @@ impl App {
events: Vec<crate::raw_input::RawInputEvent>,
apply_host_terminal_theme: bool,
) {
+ self.route_client_events_from(LOCAL_INPUT_SOURCE, events, apply_host_terminal_theme);
+ }
+
+ pub(crate) fn route_client_events_from(
+ &mut self,
+ source_id: InputSourceId,
+ events: Vec<crate::raw_input::RawInputEvent>,
+ apply_host_terminal_theme: bool,
+ ) {
for event in events {
let previous_mode = self.state.mode;
match event {
crate::raw_input::RawInputEvent::Key(key) => {
- let key_id = repeat_key_identity(&key);
+ let pressed_key_id = pressed_key_identity(source_id, &key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal
{
- self.suppressed_repeat_keys.remove(&key_id);
- self.handle_terminal_key_headless(key);
+ self.suppressed_repeat_keys.remove(&pressed_key_id);
+ if let Some(target) = self.handle_terminal_key_headless(key) {
+ if !key.is_text_commit {
+ self.pressed_terminal_keys.insert(
+ pressed_key_id,
+ PressedTerminalKey { target, key },
+ );
+ }
+ } else {
+ self.pressed_terminal_keys.remove(&pressed_key_id);
+ }
} else {
- self.suppressed_repeat_keys.insert(key_id);
+ self.pressed_terminal_keys.remove(&pressed_key_id);
+ self.suppressed_repeat_keys.insert(pressed_key_id);
self.handle_non_terminal_key_headless(key);
}
}
crossterm::event::KeyEventKind::Repeat => {
- if (self.state.popup_pane.is_some()
+ if let Some(pressed) =
+ self.pressed_terminal_keys.get(&pressed_key_id).cloned()
+ {
+ if !self
+ .forward_terminal_key_to_target_headless(&pressed.target, key)
+ {
+ self.pressed_terminal_keys.remove(&pressed_key_id);
+ }
+ } else if (self.state.popup_pane.is_some()
|| self.state.mode == Mode::Terminal)
- && !self.suppressed_repeat_keys.contains(&key_id)
+ && !self.suppressed_repeat_keys.contains(&pressed_key_id)
{
- self.handle_terminal_key_headless(key);
+ let _ = self.handle_terminal_key_headless(key);
}
- // Repeats in non-terminal modes are ignored
- // (same as monolithic behavior).
}
crossterm::event::KeyEventKind::Release => {
- self.suppressed_repeat_keys.remove(&key_id);
+ self.suppressed_repeat_keys.remove(&pressed_key_id);
+ if let Some(pressed) =
+ self.pressed_terminal_keys.remove(&pressed_key_id)
+ {
+ let _ = self
+ .forward_terminal_key_to_target_headless(&pressed.target, key);
+ }
}
}
}
@@ -1634,6 +1694,7 @@ impl App {
self.send_outer_focus_event(crate::ghostty::FocusEvent::Gained);
}
crate::raw_input::RawInputEvent::OuterFocusLost => {
+ self.release_input_source_headless(source_id);
self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
}
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
@@ -1652,6 +1713,10 @@ impl App {
}
}
+ pub(crate) fn clear_input_source(&mut self, source_id: InputSourceId) {
+ self.release_input_source_headless(source_id);
+ }
+
/// Handles a key event in non-terminal mode for the headless server.
///
/// Uses the standalone handler functions that work on `&mut AppState`
@@ -3299,6 +3364,43 @@ mod tests {
}
#[tokio::test]
+ async fn monolithic_input_forwards_report_all_printable_event_kinds() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ for kind in [
+ KeyEventKind::Press,
+ KeyEventKind::Repeat,
+ KeyEventKind::Release,
+ ] {
+ app.handle_raw_input_event(raw_key(KeyCode::Char('j'), KeyModifiers::empty(), kind))
+ .await;
+ }
+
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:2u")
+ );
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
async fn outer_focus_gained_marks_visible_done_panes_seen() {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
@@ -4909,6 +5011,375 @@ last_pane = "prefix+tab"
}
#[tokio::test]
+ async fn host_report_all_follows_the_focused_terminal_protocol() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, _rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 1);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ assert!(app.host_keyboard_report_all_requested());
+
+ app.state
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b[<u");
+ assert!(!app.host_keyboard_report_all_requested());
+
+ app.state
+ .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, focused)
+ .unwrap()
+ .test_process_pty_bytes(b"\x1b[>15u");
+ let other_pane = app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ let (other_runtime, _rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>7u", 1);
+ app.state.workspaces[0].tabs[0]
+ .runtimes
+ .insert(other_pane, other_runtime);
+ assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(other_pane));
+ assert!(!app.host_keyboard_report_all_requested());
+
+ assert!(app.state.focus_pane_in_workspace(0, focused));
+ app.state.mode = Mode::Prefix;
+ assert!(!app.host_keyboard_report_all_requested());
+ }
+
+ #[tokio::test]
+ async fn route_client_input_forwards_report_all_printable_event_kinds() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(b"\x1b[106u\x1b[106;1:2u\x1b[106;1:3u".to_vec());
+
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:2u")
+ );
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn committed_ime_text_bypasses_report_all_key_encoding() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input("你".as_bytes().to_vec());
+
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static("你".as_bytes())
+ );
+ assert!(app.pressed_terminal_keys.is_empty());
+ }
+
+ #[tokio::test]
+ async fn committed_ascii_uppercase_bypasses_report_all_key_encoding() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(b"A".to_vec());
+
+ assert_eq!(rx.recv().await.unwrap(), bytes::Bytes::from_static(b"A"));
+ assert!(app.pressed_terminal_keys.is_empty());
+ }
+
+ #[tokio::test]
+ async fn committed_text_does_not_erase_owned_physical_key() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(b"\x1b[106u".to_vec());
+ app.route_client_input(b"j".to_vec());
+ app.route_client_input(b"\x1b[106;1:3u".to_vec());
+
+ assert_eq!(
+ rx.try_recv().expect("physical press"),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.try_recv().expect("committed text"),
+ bytes::Bytes::from_static(b"j")
+ );
+ assert_eq!(
+ rx.try_recv().expect("physical release"),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(app.pressed_terminal_keys.is_empty());
+ }
+
+ #[tokio::test]
+ async fn outer_focus_loss_releases_owned_report_all_keys() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_events(
+ vec![
+ raw_key(
+ KeyCode::Char('j'),
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ ),
+ crate::raw_input::RawInputEvent::OuterFocusLost,
+ ],
+ false,
+ );
+
+ assert_eq!(
+ rx.try_recv().expect("forwarded press"),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.try_recv().expect("synthetic release on focus loss"),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(app.pressed_terminal_keys.is_empty());
+ }
+
+ #[tokio::test]
+ async fn disconnected_input_source_releases_owned_report_all_keys() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_events_from(
+ 42,
+ vec![raw_key(
+ KeyCode::Char('j'),
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ )],
+ false,
+ );
+ app.clear_input_source(42);
+
+ assert_eq!(
+ rx.try_recv().expect("forwarded press"),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.try_recv().expect("synthetic release on disconnect"),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(app.pressed_terminal_keys.is_empty());
+ }
+
+ #[tokio::test]
+ async fn report_all_repeat_and_release_return_to_the_pressed_pane() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let pressed_pane = workspace.focused_pane_id().unwrap();
+ let other_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ workspace.tabs[0].layout.focus_pane(pressed_pane);
+ let (pressed_runtime, mut pressed_rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
+ let (other_runtime, mut other_rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 4);
+ workspace.tabs[0]
+ .runtimes
+ .insert(pressed_pane, pressed_runtime);
+ workspace.tabs[0].runtimes.insert(other_pane, other_runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(b"\x1b[106u".to_vec());
+ assert!(app.state.focus_pane_in_workspace(0, other_pane));
+ app.route_client_input(b"\x1b[106;1:2u\x1b[106;1:3u".to_vec());
+
+ assert_eq!(
+ pressed_rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ pressed_rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:2u")
+ );
+ assert_eq!(
+ pressed_rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(pressed_rx.try_recv().is_err());
+ assert!(other_rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn report_all_key_ownership_is_isolated_by_client() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let first_pane = workspace.focused_pane_id().unwrap();
+ let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
+ workspace.tabs[0].layout.focus_pane(first_pane);
+ let (first_runtime, mut first_rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
+ let (second_runtime, mut second_rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
+ workspace.tabs[0].runtimes.insert(first_pane, first_runtime);
+ workspace.tabs[0]
+ .runtimes
+ .insert(second_pane, second_runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_events_from(
+ 1,
+ vec![raw_key(
+ KeyCode::Char('j'),
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ )],
+ false,
+ );
+ assert!(app.state.focus_pane_in_workspace(0, second_pane));
+ app.route_client_events_from(
+ 2,
+ vec![raw_key(
+ KeyCode::Char('j'),
+ KeyModifiers::empty(),
+ KeyEventKind::Press,
+ )],
+ false,
+ );
+ app.route_client_events_from(
+ 1,
+ vec![raw_key(
+ KeyCode::Char('j'),
+ KeyModifiers::empty(),
+ KeyEventKind::Release,
+ )],
+ false,
+ );
+ app.route_client_events_from(
+ 2,
+ vec![raw_key(
+ KeyCode::Char('j'),
+ KeyModifiers::empty(),
+ KeyEventKind::Release,
+ )],
+ false,
+ );
+
+ for rx in [&mut first_rx, &mut second_rx] {
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:1u")
+ );
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(rx.try_recv().is_err());
+ }
+ }
+
+ #[tokio::test]
+ async fn report_all_release_survives_modifier_release_order() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 3);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(b"\x1b[106:74;2u\x1b[106;1:3u".to_vec());
+
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106:74;2:1u")
+ );
+ assert_eq!(
+ rx.recv().await.unwrap(),
+ bytes::Bytes::from_static(b"\x1b[106;1:3u")
+ );
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
+ async fn route_client_input_does_not_forward_release_for_consumed_prefix() {
+ let mut app = test_app();
+ let mut workspace = Workspace::test_new("test");
+ let focused = workspace.focused_pane_id().unwrap();
+ let (runtime, mut rx) =
+ TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"\x1b[>15u", 2);
+ workspace.tabs[0].runtimes.insert(focused, runtime);
+ app.state.workspaces = vec![workspace];
+ app.state.active = Some(0);
+ app.state.selected = 0;
+ app.state.mode = Mode::Terminal;
+
+ app.route_client_input(b"\x1b[98;5u\x1b[98;5:3u".to_vec());
+
+ assert_eq!(app.state.mode, Mode::Prefix);
+ assert!(rx.try_recv().is_err());
+ }
+
+ #[tokio::test]
async fn route_client_input_preserves_shift_enter_for_modify_other_keys_pane() {
let mut app = test_app();
let mut workspace = Workspace::test_new("test");
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 97edc471..269de37d 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -3,7 +3,7 @@ use std::time::{Duration, Instant};
use crossterm::terminal;
use super::{
- background_update_check_enabled, repeat_key_identity, App, Mode, ANIMATION_INTERVAL,
+ background_update_check_enabled, pressed_key_identity, App, ANIMATION_INTERVAL,
AUTO_UPDATE_CHECK_INTERVAL, GIT_REMOTE_STATUS_REFRESH_INTERVAL, MIN_RENDER_INTERVAL,
RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL,
};
@@ -139,20 +139,42 @@ impl App {
let previous_mode = self.state.mode;
let changed = match event {
crate::raw_input::RawInputEvent::Key(key) => {
- let key_id = repeat_key_identity(&key);
+ let pressed_key_id = pressed_key_identity(super::LOCAL_INPUT_SOURCE, &key);
match key.kind {
crossterm::event::KeyEventKind::Press => {
- if self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal {
- self.suppressed_repeat_keys.remove(&key_id);
+ if self.state.popup_pane.is_some()
+ || self.state.mode == crate::app::Mode::Terminal
+ {
+ self.suppressed_repeat_keys.remove(&pressed_key_id);
+ } else {
+ self.suppressed_repeat_keys.insert(pressed_key_id);
+ }
+ if let Some(target) = self.handle_key(key).await {
+ if !key.is_text_commit {
+ self.pressed_terminal_keys.insert(
+ pressed_key_id,
+ super::PressedTerminalKey { target, key },
+ );
+ }
} else {
- self.suppressed_repeat_keys.insert(key_id);
+ self.pressed_terminal_keys.remove(&pressed_key_id);
}
- self.handle_key(key).await;
true
}
crossterm::event::KeyEventKind::Repeat => {
- if (self.state.popup_pane.is_some() || self.state.mode == Mode::Terminal)
- && !self.suppressed_repeat_keys.contains(&key_id)
+ if let Some(pressed) =
+ self.pressed_terminal_keys.get(&pressed_key_id).cloned()
+ {
+ if !self
+ .forward_terminal_key_to_target(&pressed.target, key)
+ .await
+ {
+ self.pressed_terminal_keys.remove(&pressed_key_id);
+ }
+ true
+ } else if (self.state.popup_pane.is_some()
+ || self.state.mode == crate::app::Mode::Terminal)
+ && !self.suppressed_repeat_keys.contains(&pressed_key_id)
{
self.handle_key(key).await;
true
@@ -161,7 +183,12 @@ impl App {
}
}
crossterm::event::KeyEventKind::Release => {
- self.suppressed_repeat_keys.remove(&key_id);
+ self.suppressed_repeat_keys.remove(&pressed_key_id);
+ if let Some(pressed) = self.pressed_terminal_keys.remove(&pressed_key_id) {
+ let _ = self
+ .forward_terminal_key_to_target(&pressed.target, key)
+ .await;
+ }
false
}
}
@@ -189,6 +216,7 @@ impl App {
true
}
crate::raw_input::RawInputEvent::OuterFocusLost => {
+ self.release_input_source(super::LOCAL_INPUT_SOURCE).await;
self.send_outer_focus_event(crate::ghostty::FocusEvent::Lost);
self.state.outer_terminal_focus = Some(false);
false