par_term/app/tmux_handler/gateway_input.rs
1//! tmux input routing: send/paste to tmux sessions and prefix key handling.
2
3use crate::app::window_state::WindowState;
4
5impl WindowState {
6 /// Send input through tmux gateway mode.
7 ///
8 /// When in gateway mode, keyboard input is sent via `send-keys` command
9 /// written to the gateway tab's PTY. This routes input to the appropriate tmux pane.
10 ///
11 /// Returns true if input was handled via tmux, false if it should go to PTY directly.
12 pub fn send_input_via_tmux(&self, data: &[u8]) -> bool {
13 // par-mux transport routes input through the daemon client — but
14 // only when the user is focused on a MUX pane. A local tab's input
15 // must fall through to its own PTY; consuming it here starves every
16 // non-mux tab the moment a transport is attached.
17 #[cfg(feature = "mux")]
18 {
19 if let Some(transport) = &self.tmux_state.transport {
20 // The daemon REQUIRES -t (untargeted send-keys is an error),
21 // and nothing sets mux_focused_pane until a pane click or a
22 // daemon focus push — so a fresh attach with keyboard focus
23 // only must fall back to the pane the user is actually
24 // focused on, via the native→tmux reverse map.
25 let focused = self.focused_mux_pane_from_native();
26 return match focused {
27 Some(focused) => {
28 super::notifications::mux::route_input(&**transport, Some(focused), data)
29 }
30 // Not on a mux pane: let the caller write to the local PTY.
31 None => false,
32 };
33 }
34 }
35
36 // Check if tmux is enabled and connected
37 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
38 crate::debug_trace!(
39 "TMUX",
40 "send_input_via_tmux: not sending - enabled={}, connected={}",
41 self.config.load().tmux.tmux_enabled,
42 self.is_tmux_connected()
43 );
44 return false;
45 }
46
47 let session = match &self.tmux_state.tmux_session {
48 Some(s) => s,
49 None => return false,
50 };
51
52 // Format the send-keys command - try pane-specific first
53 let cmd = match session.format_send_keys(data) {
54 Some(c) => {
55 crate::debug_trace!("TMUX", "Using pane-specific send-keys: {}", c.trim());
56 c
57 }
58 None => {
59 crate::debug_trace!("TMUX", "No focused pane for send-keys, trying window-based");
60 // No focused pane - try window-based routing
61 if let Some(cmd) = self.format_send_keys_for_window(data) {
62 crate::debug_trace!("TMUX", "Using window-based send-keys: {}", cmd.trim());
63 cmd
64 } else {
65 // No window mapping either - use untargeted send-keys
66 // This sends to tmux's currently active pane
67 let escaped = crate::tmux::escape_keys_for_tmux(data);
68 format!("send-keys {}\n", escaped)
69 }
70 }
71 };
72
73 // Write the command to the gateway tab's PTY
74 if self.write_to_gateway(&cmd) {
75 crate::debug_trace!("TMUX", "Sent {} bytes via gateway send-keys", data.len());
76 return true;
77 }
78
79 false
80 }
81
82 /// Send raw bytes to the focused tmux pane as literal input, bypassing tmux's
83 /// key-name interpretation and any per-pane modifyOtherKeys / extended-keys
84 /// re-encoding.
85 ///
86 /// Uses `send-keys -H`, which tags each byte as `KEYC_LITERAL`. tmux writes
87 /// these bytes straight to the pane's PTY via `bufferevent_write`, skipping
88 /// the `input_key()` encoder entirely — so whatever we put in arrives
89 /// unchanged at the inner application.
90 ///
91 /// Used for cases where the normal `send-keys` path would mangle the bytes,
92 /// e.g. Shift+Enter: the iTerm2 convention is to send raw LF (0x0a), but
93 /// `escape_keys_for_tmux` translates that to `C-j`, and tmux's
94 /// modifyOtherKeys-mode-2 encoder then delivers `\x1b[27;5;106~` instead of
95 /// the literal newline the application expects.
96 pub fn send_literal_bytes_via_tmux(&self, bytes: &[u8]) -> bool {
97 // par-mux first: a mux-attached window has no tmux_session at all,
98 // so the tmux_enabled/is_tmux_connected gate below would refuse and
99 // the caller would drop the bytes onto the hidden local shell.
100 #[cfg(feature = "mux")]
101 if let Some(transport) = &self.tmux_state.transport {
102 return match self.focused_mux_pane_from_native() {
103 Some(focused) => super::notifications::mux::route_literal_bytes(
104 &**transport,
105 Some(focused),
106 bytes,
107 ),
108 // Not on a mux pane: local PTY input.
109 None => false,
110 };
111 }
112
113 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
114 crate::debug_info!(
115 "SHIFTENTER",
116 "send_literal_bytes_via_tmux: refused - enabled={}, connected={}",
117 self.config.load().tmux.tmux_enabled,
118 self.is_tmux_connected(),
119 );
120 return false;
121 }
122
123 let session = match &self.tmux_state.tmux_session {
124 Some(s) => s,
125 None => {
126 crate::debug_info!("SHIFTENTER", "send_literal_bytes_via_tmux: no tmux_session");
127 return false;
128 }
129 };
130
131 let focused = session.focused_pane();
132 let cmd = match session.format_send_hex_keys(bytes) {
133 Some(c) => c,
134 None => {
135 crate::debug_info!(
136 "SHIFTENTER",
137 "format_send_hex_keys returned None (focused_pane={:?}, state={:?}, gateway={:?})",
138 focused,
139 session.state(),
140 session.gateway_state(),
141 );
142 return false;
143 }
144 };
145
146 crate::debug_info!(
147 "SHIFTENTER",
148 "writing to gateway (focused_pane={:?}): {:?}",
149 focused,
150 cmd.trim_end(),
151 );
152
153 if self.write_to_gateway(&cmd) {
154 crate::debug_info!(
155 "SHIFTENTER",
156 "send_literal_bytes_via_tmux: success ({} bytes)",
157 bytes.len()
158 );
159 return true;
160 }
161
162 crate::debug_info!("SHIFTENTER", "write_to_gateway failed");
163 false
164 }
165
166 /// The mux pane that input, splits, and size pushes target: the
167 /// focused native pane's tmux id, or `None` when the user is on a LOCAL
168 /// pane (the caller then writes to the local PTY). The focused native
169 /// pane is authoritative. The tracked `mux_focused_pane` used to win,
170 /// and it outlived its tab: closing the mux tab left it set, so every
171 /// keystroke in the remaining local tab went to a daemon pane that no
172 /// longer existed.
173 #[cfg(feature = "mux")]
174 pub(crate) fn focused_mux_pane_from_native(&self) -> Option<u64> {
175 let tab = self.tab_manager.active_tab()?;
176 let pane = tab.pane_manager()?.focused_pane()?;
177 self.tmux_state.tmux_pane_in_tab(tab.id, pane.id)
178 }
179
180 /// Route an encoded mouse report to the focused par-mux pane's daemon
181 /// PTY. Returns `true` when it was sent that way; `false` means the
182 /// focused pane is not a mux pane and the caller writes locally.
183 ///
184 /// A mux pane's local terminal has no PTY — it only mirrors the daemon
185 /// — so a local write silently dropped every click, drag, and wheel
186 /// event (mouse-aware TUIs in mux panes ignored the mouse entirely).
187 /// The raw bytes go as `send-keys -H` so key-name translation cannot
188 /// mangle the escape sequence.
189 #[cfg_attr(not(feature = "mux"), allow(unused_variables))]
190 pub(crate) fn route_mouse_report_to_mux(&self, encoded: &[u8]) -> bool {
191 #[cfg(feature = "mux")]
192 if let Some(transport) = &self.tmux_state.transport
193 && let Some(pane) = self.focused_mux_pane_from_native()
194 {
195 return super::notifications::mux::route_literal_bytes(
196 &**transport,
197 Some(pane),
198 encoded,
199 );
200 }
201 false
202 }
203
204 /// Route a focus report (ESC[I / ESC[O) for one mux mirror pane to its
205 /// daemon pane. Returns `true` when it was sent that way; `false`
206 /// means the pane is not a mux pane and the caller reports locally.
207 ///
208 /// Same class as [`Self::route_mouse_report_to_mux`]: a mirror has no
209 /// PTY, so the focus-in/out sequence an app that enabled focus
210 /// tracking (DECSET 1004) expects must arrive as daemon input, not as
211 /// a local write into the mirror.
212 #[cfg_attr(not(feature = "mux"), allow(unused_variables))]
213 pub(crate) fn route_focus_report_to_mux(
214 &self,
215 tab_id: crate::tab::TabId,
216 pane_id: crate::pane::PaneId,
217 focused: bool,
218 ) -> bool {
219 #[cfg(feature = "mux")]
220 if let Some(transport) = &self.tmux_state.transport
221 && let Some(pane) = self.tmux_state.tmux_pane_in_tab(tab_id, pane_id)
222 {
223 let bytes: &[u8] = if focused { b"\x1b[I" } else { b"\x1b[O" };
224 return super::notifications::mux::route_literal_bytes(&**transport, Some(pane), bytes);
225 }
226 false
227 }
228
229 /// Format send-keys command for a specific window (if mapping exists)
230 fn format_send_keys_for_window(&self, data: &[u8]) -> Option<String> {
231 let active_tab_id = self.tab_manager.active_tab_id()?;
232
233 // Find the tmux window for this tab
234 let tmux_window_id = self.tmux_state.tmux_sync.get_window(active_tab_id)?;
235
236 // Format send-keys command with window target using proper escaping
237 let escaped = crate::tmux::escape_keys_for_tmux(data);
238 Some(format!("send-keys -t @{} {}\n", tmux_window_id, escaped))
239 }
240
241 /// Send input via tmux window target (fallback when no pane ID is set).
242 ///
243 /// This method is a planned fallback path for `TmuxSync` integration: when
244 /// the pane-level routing in `send_input_bytes` cannot resolve a pane ID,
245 /// routing via the tmux window target (`@N`) is the intended recovery.
246 /// It duplicates part of `format_send_keys_for_window` on purpose — the
247 /// caller needs to write directly rather than just format the command.
248 ///
249 /// Not yet wired up because `TmuxSync::get_window` is not yet called from
250 /// the hot-path input handler. Wire it up when implementing pane-less
251 /// gateway fallback (tracked as a GitHub issue under "tmux integration").
252 #[allow(dead_code)] // Infrastructure for TmuxSync pane-less fallback — not yet wired
253 fn send_input_via_tmux_window(&self, data: &[u8]) -> bool {
254 let active_tab_id = match self.tab_manager.active_tab_id() {
255 Some(id) => id,
256 None => return false,
257 };
258
259 // Find the tmux window for this tab
260 let tmux_window_id = match self.tmux_state.tmux_sync.get_window(active_tab_id) {
261 Some(id) => id,
262 None => {
263 crate::debug_trace!(
264 "TMUX",
265 "No tmux window mapping for tab {}, using untargeted send-keys",
266 active_tab_id
267 );
268 return false;
269 }
270 };
271
272 // Format send-keys command with window target using proper escaping
273 let escaped = crate::tmux::escape_keys_for_tmux(data);
274 let cmd = format!("send-keys -t @{} {}\n", tmux_window_id, escaped);
275
276 // Write to gateway tab
277 if self.write_to_gateway(&cmd) {
278 crate::debug_trace!(
279 "TMUX",
280 "Sent {} bytes via gateway to window @{}",
281 data.len(),
282 tmux_window_id
283 );
284 return true;
285 }
286
287 false
288 }
289
290 /// Send paste text through tmux gateway mode.
291 ///
292 /// Uses send-keys -l for literal text to handle special characters properly.
293 pub fn paste_via_tmux(&self, text: &str) -> bool {
294 // par-mux transport: the panes live in the daemon and the local
295 // mirror has no PTY, so falling through to a local paste would be
296 // dropped. Route the bytes daemon-side in the same literal
297 // `send-keys -H` form the mouse-report fix uses — the caller
298 // (Cmd+V, option-click, middle-click) has already sanitized the
299 // text, and hex form cannot mangle multi-line or special content.
300 #[cfg(feature = "mux")]
301 if let Some(transport) = &self.tmux_state.transport
302 && let Some(pane) = self.focused_mux_pane_from_native()
303 {
304 return self.paste_via_mux_pane(&**transport, pane, text);
305 }
306
307 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
308 return false;
309 }
310
311 let session = match &self.tmux_state.tmux_session {
312 Some(s) => s,
313 None => return false,
314 };
315
316 // Format the literal send command
317 let cmd = match session.format_send_literal(text) {
318 Some(c) => c,
319 None => return false,
320 };
321
322 // Write to gateway tab
323 if self.write_to_gateway(&cmd) {
324 crate::debug_info!("TMUX", "Pasted {} chars via gateway", text.len());
325 return true;
326 }
327
328 false
329 }
330
331 /// Paste into a daemon pane with the same semantics the local PTY
332 /// paths apply: the focused mirror's mode-2004 state (tracked from
333 /// the daemon pane's own output) decides the bracketed wrapping,
334 /// newlines become carriage returns, and a multi-line paste under a
335 /// configured `paste_delay_ms` is fed line-by-line — one chunk per
336 /// poll tick — instead of one burst (card 01a0d9b551c57243b9888e1ac3be6b55).
337 #[cfg(feature = "mux")]
338 fn paste_via_mux_pane(
339 &self,
340 transport: &dyn super::tmux_state::TmuxTransport,
341 pane: crate::tmux::TmuxPaneId,
342 text: &str,
343 ) -> bool {
344 let text = text.replace('\n', "\r");
345 if text.is_empty() {
346 return true; // consumed; matches the local paste no-op
347 }
348 let (start, end) = self.focused_mirror_bracketed_sequences();
349 let delay_ms = self.config.load().selection.paste_delay_ms;
350
351 if delay_ms == 0 || !text.contains('\r') {
352 // One burst: start + content + end, the wire equivalent of the
353 // three writes `TerminalManager::paste` makes.
354 let mut bytes = start;
355 bytes.extend_from_slice(text.as_bytes());
356 bytes.extend_from_slice(&end);
357 if !bytes.is_empty() {
358 super::notifications::mux::route_literal_bytes(transport, Some(pane), &bytes);
359 }
360 return true;
361 }
362
363 // Line-by-line under the delay, mirroring `paste_with_delay`: the
364 // start sequence leads the first line, every non-final line carries
365 // its own `\r`, and the end sequence trails the last line. The
366 // lead chunk goes out now so the first line shows immediately.
367 let lines: Vec<&str> = text.split('\r').collect();
368 let mut lead = start;
369 lead.extend_from_slice(lines[0].as_bytes());
370 lead.push(b'\r');
371 if !lead.is_empty() {
372 super::notifications::mux::route_literal_bytes(transport, Some(pane), &lead);
373 }
374 let mut chunks: std::collections::VecDeque<Vec<u8>> = std::collections::VecDeque::new();
375 for (i, line) in lines.iter().enumerate().skip(1) {
376 let mut chunk = Vec::from(line.as_bytes());
377 if i < lines.len() - 1 {
378 chunk.push(b'\r');
379 } else {
380 chunk.extend_from_slice(&end);
381 }
382 if !chunk.is_empty() {
383 chunks.push_back(chunk);
384 }
385 }
386 if chunks.is_empty() {
387 return true;
388 }
389 let delay = std::time::Duration::from_millis(delay_ms);
390 let mut pending = self.tmux_state.pending_mux_paste.borrow_mut();
391 if pending.is_some() {
392 crate::debug_info!("MUX", "delayed paste replaced an unfinished one");
393 }
394 *pending = Some(super::tmux_state::PendingMuxPaste {
395 pane,
396 chunks,
397 delay,
398 next_due: std::time::Instant::now() + delay,
399 });
400 drop(pending);
401 // The event loop sleeps when idle (`ControlFlow::Wait`); wake it so
402 // the first delayed chunk is not parked until an unrelated event.
403 self.request_redraw();
404 true
405 }
406
407 /// The focused mux pane's mirror-terminal bracketed-paste sequences —
408 /// empty (paste unwrapped) when the pane cannot be resolved or its
409 /// terminal lock is contended, rather than blocking the event loop.
410 #[cfg(feature = "mux")]
411 fn focused_mirror_bracketed_sequences(&self) -> (Vec<u8>, Vec<u8>) {
412 let Some(tab) = self.tab_manager.active_tab() else {
413 return (Vec::new(), Vec::new());
414 };
415 let Some(pane) = tab.pane_manager().and_then(|pm| pm.focused_pane()) else {
416 return (Vec::new(), Vec::new());
417 };
418 match pane.terminal.try_read() {
419 Ok(term) => term.bracketed_paste_sequences(),
420 Err(_) => (Vec::new(), Vec::new()),
421 }
422 }
423
424 /// Send the due chunk of a delayed mux paste, if any. Returns whether
425 /// a chunk went out (a visual change — the daemon echoes it). A queue
426 /// whose daemon died is dropped with the transport.
427 #[cfg(feature = "mux")]
428 pub(crate) fn tick_pending_mux_paste(&mut self) -> bool {
429 let Some(transport) = self.tmux_state.transport.as_deref() else {
430 self.tmux_state.pending_mux_paste.borrow_mut().take();
431 return false;
432 };
433 let (pane, chunk, delay, drained) = {
434 let mut pending = self.tmux_state.pending_mux_paste.borrow_mut();
435 let Some(p) = pending.as_mut() else {
436 return false;
437 };
438 if std::time::Instant::now() < p.next_due {
439 return false;
440 }
441 match p.chunks.pop_front() {
442 Some(chunk) => (p.pane, chunk, p.delay, p.chunks.is_empty()),
443 None => {
444 pending.take();
445 return false;
446 }
447 }
448 };
449 super::notifications::mux::route_literal_bytes(transport, Some(pane), &chunk);
450 if drained {
451 self.tmux_state.pending_mux_paste.borrow_mut().take();
452 } else if let Some(p) = self.tmux_state.pending_mux_paste.borrow_mut().as_mut() {
453 p.next_due = std::time::Instant::now() + delay;
454 }
455 // More chunks remain: re-arm the sleeping event loop for the tick
456 // that sends the next one.
457 if self.tmux_state.pending_mux_paste.borrow().is_some() {
458 self.request_redraw();
459 }
460 true
461 }
462
463 /// Handle tmux prefix key mode
464 ///
465 /// In control mode, we intercept the prefix key (e.g., Ctrl+B or Ctrl+Space)
466 /// and wait for the next key to translate into a tmux command.
467 ///
468 /// Returns true if the key was handled by the prefix system.
469 pub fn handle_tmux_prefix_key(&mut self, event: &winit::event::KeyEvent) -> bool {
470 // Only handle on key press
471 if event.state != winit::event::ElementState::Pressed {
472 return false;
473 }
474
475 // Only handle if tmux is connected
476 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
477 return false;
478 }
479
480 let modifiers = self.input_handler.modifiers.state();
481
482 // Check if we're in prefix mode (waiting for command key)
483 if self.tmux_state.tmux_prefix_state.is_active() {
484 // Ignore modifier-only key presses (Shift, Ctrl, Alt, Super)
485 // These are needed to type shifted characters like " and %
486 use winit::keyboard::{Key, NamedKey};
487 let is_modifier_only = matches!(
488 event.logical_key,
489 Key::Named(
490 NamedKey::Shift
491 | NamedKey::Control
492 | NamedKey::Alt
493 | NamedKey::Super
494 | NamedKey::Meta
495 )
496 );
497 if is_modifier_only {
498 crate::debug_trace!(
499 "TMUX",
500 "Ignoring modifier-only key in prefix mode: {:?}",
501 event.logical_key
502 );
503 return false; // Don't consume - let the modifier key through
504 }
505
506 // Exit prefix mode
507 self.tmux_state.tmux_prefix_state.exit();
508
509 // Get focused pane ID for targeted commands
510 let focused_pane = self
511 .tmux_state
512 .tmux_session
513 .as_ref()
514 .and_then(|s| s.focused_pane());
515
516 // Translate the command key to a tmux command
517 if let Some(cmd) =
518 crate::tmux::translate_command_key(&event.logical_key, modifiers, focused_pane)
519 {
520 crate::debug_info!(
521 "TMUX",
522 "Prefix command: {:?} -> {}",
523 event.logical_key,
524 cmd.trim()
525 );
526
527 // Send the command to tmux
528 if self.write_to_gateway(&cmd) {
529 // Show toast for certain commands (check command base, ignoring target)
530 let cmd_base = cmd.split(" -t").next().unwrap_or(&cmd).trim();
531 match cmd_base {
532 "detach-client" => self.show_toast("tmux: Detaching..."),
533 "new-window" => self.show_toast("tmux: New window"),
534 _ => {}
535 }
536 return true;
537 }
538 } else {
539 // Unknown command key - show feedback
540 crate::debug_info!(
541 "TMUX",
542 "Unknown prefix command key: {:?}",
543 event.logical_key
544 );
545 self.show_toast(format!(
546 "tmux: Unknown command key: {:?}",
547 event.logical_key
548 ));
549 }
550 return true; // Consumed the key even if unknown
551 }
552
553 // Check if this is the prefix key
554 if let Some(ref prefix_key) = self.tmux_state.tmux_prefix_key
555 && prefix_key.matches(&event.logical_key, modifiers)
556 {
557 crate::debug_info!("TMUX", "Prefix key pressed, entering prefix mode");
558 self.tmux_state.tmux_prefix_state.enter();
559 self.show_toast("tmux: prefix...");
560 return true;
561 }
562
563 false
564 }
565}