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}