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        if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
98            crate::debug_info!(
99                "SHIFTENTER",
100                "send_literal_bytes_via_tmux: refused - enabled={}, connected={}",
101                self.config.load().tmux.tmux_enabled,
102                self.is_tmux_connected(),
103            );
104            return false;
105        }
106
107        let session = match &self.tmux_state.tmux_session {
108            Some(s) => s,
109            None => {
110                // par-mux transport: the literal form routes through the
111                // daemon client too (send-keys -H).
112                #[cfg(feature = "mux")]
113                {
114                    if let Some(transport) = &self.tmux_state.transport {
115                        let focused = self.focused_mux_pane_from_native();
116                        return match focused {
117                            Some(focused) => super::notifications::mux::route_literal_bytes(
118                                &**transport,
119                                Some(focused),
120                                bytes,
121                            ),
122                            // Not on a mux pane: local PTY input.
123                            None => false,
124                        };
125                    }
126                }
127                crate::debug_info!("SHIFTENTER", "send_literal_bytes_via_tmux: no tmux_session");
128                return false;
129            }
130        };
131
132        let focused = session.focused_pane();
133        let cmd = match session.format_send_hex_keys(bytes) {
134            Some(c) => c,
135            None => {
136                crate::debug_info!(
137                    "SHIFTENTER",
138                    "format_send_hex_keys returned None (focused_pane={:?}, state={:?}, gateway={:?})",
139                    focused,
140                    session.state(),
141                    session.gateway_state(),
142                );
143                return false;
144            }
145        };
146
147        crate::debug_info!(
148            "SHIFTENTER",
149            "writing to gateway (focused_pane={:?}): {:?}",
150            focused,
151            cmd.trim_end(),
152        );
153
154        if self.write_to_gateway(&cmd) {
155            crate::debug_info!(
156                "SHIFTENTER",
157                "send_literal_bytes_via_tmux: success ({} bytes)",
158                bytes.len()
159            );
160            return true;
161        }
162
163        crate::debug_info!("SHIFTENTER", "write_to_gateway failed");
164        false
165    }
166
167    /// The mux pane that input, splits, and size pushes target: the
168    /// focused native pane's tmux id, or `None` when the user is on a LOCAL
169    /// pane (the caller then writes to the local PTY). The focused native
170    /// pane is authoritative. The tracked `mux_focused_pane` used to win,
171    /// and it outlived its tab: closing the mux tab left it set, so every
172    /// keystroke in the remaining local tab went to a daemon pane that no
173    /// longer existed.
174    #[cfg(feature = "mux")]
175    pub(crate) fn focused_mux_pane_from_native(&self) -> Option<u64> {
176        let tab = self.tab_manager.active_tab()?;
177        let pane = tab.pane_manager()?.focused_pane()?;
178        self.tmux_state
179            .native_pane_to_tmux_pane
180            .get(&pane.id)
181            .copied()
182    }
183
184    /// Route an encoded mouse report to the focused par-mux pane's daemon
185    /// PTY. Returns `true` when it was sent that way; `false` means the
186    /// focused pane is not a mux pane and the caller writes locally.
187    ///
188    /// A mux pane's local terminal has no PTY — it only mirrors the daemon
189    /// — so a local write silently dropped every click, drag, and wheel
190    /// event (mouse-aware TUIs in mux panes ignored the mouse entirely).
191    /// The raw bytes go as `send-keys -H` so key-name translation cannot
192    /// mangle the escape sequence.
193    #[cfg_attr(not(feature = "mux"), allow(unused_variables))]
194    pub(crate) fn route_mouse_report_to_mux(&self, encoded: &[u8]) -> bool {
195        #[cfg(feature = "mux")]
196        if let Some(transport) = &self.tmux_state.transport
197            && let Some(pane) = self.focused_mux_pane_from_native()
198        {
199            return super::notifications::mux::route_literal_bytes(
200                &**transport,
201                Some(pane),
202                encoded,
203            );
204        }
205        false
206    }
207
208    /// Format send-keys command for a specific window (if mapping exists)
209    fn format_send_keys_for_window(&self, data: &[u8]) -> Option<String> {
210        let active_tab_id = self.tab_manager.active_tab_id()?;
211
212        // Find the tmux window for this tab
213        let tmux_window_id = self.tmux_state.tmux_sync.get_window(active_tab_id)?;
214
215        // Format send-keys command with window target using proper escaping
216        let escaped = crate::tmux::escape_keys_for_tmux(data);
217        Some(format!("send-keys -t @{} {}\n", tmux_window_id, escaped))
218    }
219
220    /// Send input via tmux window target (fallback when no pane ID is set).
221    ///
222    /// This method is a planned fallback path for `TmuxSync` integration: when
223    /// the pane-level routing in `send_input_bytes` cannot resolve a pane ID,
224    /// routing via the tmux window target (`@N`) is the intended recovery.
225    /// It duplicates part of `format_send_keys_for_window` on purpose — the
226    /// caller needs to write directly rather than just format the command.
227    ///
228    /// Not yet wired up because `TmuxSync::get_window` is not yet called from
229    /// the hot-path input handler. Wire it up when implementing pane-less
230    /// gateway fallback (tracked as a GitHub issue under "tmux integration").
231    #[allow(dead_code)] // Infrastructure for TmuxSync pane-less fallback — not yet wired
232    fn send_input_via_tmux_window(&self, data: &[u8]) -> bool {
233        let active_tab_id = match self.tab_manager.active_tab_id() {
234            Some(id) => id,
235            None => return false,
236        };
237
238        // Find the tmux window for this tab
239        let tmux_window_id = match self.tmux_state.tmux_sync.get_window(active_tab_id) {
240            Some(id) => id,
241            None => {
242                crate::debug_trace!(
243                    "TMUX",
244                    "No tmux window mapping for tab {}, using untargeted send-keys",
245                    active_tab_id
246                );
247                return false;
248            }
249        };
250
251        // Format send-keys command with window target using proper escaping
252        let escaped = crate::tmux::escape_keys_for_tmux(data);
253        let cmd = format!("send-keys -t @{} {}\n", tmux_window_id, escaped);
254
255        // Write to gateway tab
256        if self.write_to_gateway(&cmd) {
257            crate::debug_trace!(
258                "TMUX",
259                "Sent {} bytes via gateway to window @{}",
260                data.len(),
261                tmux_window_id
262            );
263            return true;
264        }
265
266        false
267    }
268
269    /// Send paste text through tmux gateway mode.
270    ///
271    /// Uses send-keys -l for literal text to handle special characters properly.
272    pub fn paste_via_tmux(&self, text: &str) -> bool {
273        // par-mux transport: the panes live in the daemon and the local
274        // mirror has no PTY, so falling through to a local paste would be
275        // dropped. Route the bytes daemon-side in the same literal
276        // `send-keys -H` form the mouse-report fix uses — the caller
277        // (Cmd+V, option-click, middle-click) has already sanitized the
278        // text, and hex form cannot mangle multi-line or special content.
279        #[cfg(feature = "mux")]
280        if let Some(transport) = &self.tmux_state.transport
281            && let Some(pane) = self.focused_mux_pane_from_native()
282        {
283            return super::notifications::mux::route_literal_bytes(
284                &**transport,
285                Some(pane),
286                text.as_bytes(),
287            );
288        }
289
290        if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
291            return false;
292        }
293
294        let session = match &self.tmux_state.tmux_session {
295            Some(s) => s,
296            None => return false,
297        };
298
299        // Format the literal send command
300        let cmd = match session.format_send_literal(text) {
301            Some(c) => c,
302            None => return false,
303        };
304
305        // Write to gateway tab
306        if self.write_to_gateway(&cmd) {
307            crate::debug_info!("TMUX", "Pasted {} chars via gateway", text.len());
308            return true;
309        }
310
311        false
312    }
313
314    /// Handle tmux prefix key mode
315    ///
316    /// In control mode, we intercept the prefix key (e.g., Ctrl+B or Ctrl+Space)
317    /// and wait for the next key to translate into a tmux command.
318    ///
319    /// Returns true if the key was handled by the prefix system.
320    pub fn handle_tmux_prefix_key(&mut self, event: &winit::event::KeyEvent) -> bool {
321        // Only handle on key press
322        if event.state != winit::event::ElementState::Pressed {
323            return false;
324        }
325
326        // Only handle if tmux is connected
327        if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
328            return false;
329        }
330
331        let modifiers = self.input_handler.modifiers.state();
332
333        // Check if we're in prefix mode (waiting for command key)
334        if self.tmux_state.tmux_prefix_state.is_active() {
335            // Ignore modifier-only key presses (Shift, Ctrl, Alt, Super)
336            // These are needed to type shifted characters like " and %
337            use winit::keyboard::{Key, NamedKey};
338            let is_modifier_only = matches!(
339                event.logical_key,
340                Key::Named(
341                    NamedKey::Shift
342                        | NamedKey::Control
343                        | NamedKey::Alt
344                        | NamedKey::Super
345                        | NamedKey::Meta
346                )
347            );
348            if is_modifier_only {
349                crate::debug_trace!(
350                    "TMUX",
351                    "Ignoring modifier-only key in prefix mode: {:?}",
352                    event.logical_key
353                );
354                return false; // Don't consume - let the modifier key through
355            }
356
357            // Exit prefix mode
358            self.tmux_state.tmux_prefix_state.exit();
359
360            // Get focused pane ID for targeted commands
361            let focused_pane = self
362                .tmux_state
363                .tmux_session
364                .as_ref()
365                .and_then(|s| s.focused_pane());
366
367            // Translate the command key to a tmux command
368            if let Some(cmd) =
369                crate::tmux::translate_command_key(&event.logical_key, modifiers, focused_pane)
370            {
371                crate::debug_info!(
372                    "TMUX",
373                    "Prefix command: {:?} -> {}",
374                    event.logical_key,
375                    cmd.trim()
376                );
377
378                // Send the command to tmux
379                if self.write_to_gateway(&cmd) {
380                    // Show toast for certain commands (check command base, ignoring target)
381                    let cmd_base = cmd.split(" -t").next().unwrap_or(&cmd).trim();
382                    match cmd_base {
383                        "detach-client" => self.show_toast("tmux: Detaching..."),
384                        "new-window" => self.show_toast("tmux: New window"),
385                        _ => {}
386                    }
387                    return true;
388                }
389            } else {
390                // Unknown command key - show feedback
391                crate::debug_info!(
392                    "TMUX",
393                    "Unknown prefix command key: {:?}",
394                    event.logical_key
395                );
396                self.show_toast(format!(
397                    "tmux: Unknown command key: {:?}",
398                    event.logical_key
399                ));
400            }
401            return true; // Consumed the key even if unknown
402        }
403
404        // Check if this is the prefix key
405        if let Some(ref prefix_key) = self.tmux_state.tmux_prefix_key
406            && prefix_key.matches(&event.logical_key, modifiers)
407        {
408            crate::debug_info!("TMUX", "Prefix key pressed, entering prefix mode");
409            self.tmux_state.tmux_prefix_state.enter();
410            self.show_toast("tmux: prefix...");
411            return true;
412        }
413
414        false
415    }
416}