Skip to main content

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}