par_term/app/tmux_handler/gateway.rs
1//! tmux gateway session management and I/O routing.
2//!
3//! Covers:
4//! - Session lifecycle: initiate, attach, disconnect, status queries
5//! - Input routing: send_input_via_tmux, paste_via_tmux, prefix key handling
6//! - Pane operations: split_pane_via_tmux, close_pane_via_tmux
7//! - Clipboard + resize synchronization
8//!
9//! Profile auto-application on session connect lives in `gateway_profile`.
10
11use crate::app::window_state::WindowState;
12use crate::tmux::{SessionState, TmuxSession};
13
14impl WindowState {
15 // =========================================================================
16 // Gateway Mode Session Management
17 // =========================================================================
18
19 /// Initiate a new tmux session via gateway mode.
20 ///
21 /// This writes `tmux -CC new-session` to the active tab's PTY and enables
22 /// tmux control mode parsing. The session will be fully connected once we
23 /// receive the `%session-changed` notification.
24 ///
25 /// # Arguments
26 /// * `session_name` - Optional session name. If None, tmux will auto-generate one.
27 pub fn initiate_tmux_gateway(&mut self, session_name: Option<&str>) -> anyhow::Result<()> {
28 if !self.config.load().tmux.tmux_enabled {
29 anyhow::bail!("tmux integration is disabled");
30 }
31
32 if self.tmux_state.tmux_session.is_some() && self.is_tmux_connected() {
33 anyhow::bail!("Already connected to a tmux session");
34 }
35
36 crate::debug_info!(
37 "TMUX",
38 "Initiating gateway mode session: {:?}",
39 session_name.unwrap_or("(auto)")
40 );
41
42 // Generate the command
43 let cmd = match session_name {
44 Some(name) => TmuxSession::create_or_attach_command(name),
45 None => TmuxSession::create_new_command(None),
46 };
47
48 // Get the active tab ID and write the command to its PTY
49 let gateway_tab_id = self
50 .tab_manager
51 .active_tab_id()
52 .ok_or_else(|| anyhow::anyhow!("No active tab available for tmux gateway"))?;
53
54 let tab = self
55 .tab_manager
56 .active_tab_mut()
57 .ok_or_else(|| anyhow::anyhow!("No active tab available for tmux gateway"))?;
58
59 // Write the command to the PTY
60 // try_lock: intentional — initiate_tmux_gateway is user-initiated but called from
61 // the sync event loop context. If the terminal is locked by the async PTY reader
62 // the command cannot be sent. On miss: bails with an error so the caller can retry.
63 if let Ok(term) = tab.terminal.try_read() {
64 crate::debug_info!(
65 "TMUX",
66 "Writing gateway command to tab {}: {}",
67 gateway_tab_id,
68 cmd.trim()
69 );
70 term.write(cmd.as_bytes())?;
71 // Enable tmux control mode parsing AFTER writing the command
72 term.set_tmux_control_mode(true);
73 crate::debug_info!(
74 "TMUX",
75 "Enabled tmux control mode parsing on tab {}",
76 gateway_tab_id
77 );
78 } else {
79 anyhow::bail!("Could not acquire terminal lock");
80 }
81
82 // Mark this tab as the gateway
83 tab.tmux.tmux_gateway_active = true;
84
85 // Store the gateway tab ID so we know where to send commands
86 self.tmux_state.tmux_gateway_tab_id = Some(gateway_tab_id);
87 crate::debug_info!(
88 "TMUX",
89 "Gateway tab set to {}, state: Initiating",
90 gateway_tab_id
91 );
92
93 // Create session and set gateway state
94 let mut session = TmuxSession::new();
95 session.set_gateway_initiating();
96 self.tmux_state.tmux_session = Some(session);
97
98 // Show toast
99 self.show_toast("tmux: Connecting...");
100
101 Ok(())
102 }
103
104 /// Attach to an existing tmux session via gateway mode.
105 ///
106 /// This writes `tmux -CC attach -t session` to the active tab's PTY.
107 pub fn attach_tmux_gateway(&mut self, session_name: &str) -> anyhow::Result<()> {
108 if !self.config.load().tmux.tmux_enabled {
109 anyhow::bail!("tmux integration is disabled");
110 }
111
112 if self.tmux_state.tmux_session.is_some() && self.is_tmux_connected() {
113 anyhow::bail!("Already connected to a tmux session");
114 }
115
116 crate::debug_info!("TMUX", "Attaching to session via gateway: {}", session_name);
117
118 // Generate the attach command
119 let cmd = TmuxSession::create_attach_command(session_name);
120
121 // Get the active tab ID and write the command to its PTY
122 let gateway_tab_id = self
123 .tab_manager
124 .active_tab_id()
125 .ok_or_else(|| anyhow::anyhow!("No active tab available for tmux gateway"))?;
126
127 let tab = self
128 .tab_manager
129 .active_tab_mut()
130 .ok_or_else(|| anyhow::anyhow!("No active tab available for tmux gateway"))?;
131
132 // Write the command to the PTY
133 // try_lock: intentional — same rationale as initiate_tmux_gateway. On miss: bails
134 // so the user can retry the attach operation explicitly.
135 if let Ok(term) = tab.terminal.try_read() {
136 crate::debug_info!(
137 "TMUX",
138 "Writing attach command to tab {}: {}",
139 gateway_tab_id,
140 cmd.trim()
141 );
142 term.write(cmd.as_bytes())?;
143 term.set_tmux_control_mode(true);
144 crate::debug_info!(
145 "TMUX",
146 "Enabled tmux control mode parsing on tab {}",
147 gateway_tab_id
148 );
149 } else {
150 anyhow::bail!("Could not acquire terminal lock");
151 }
152
153 // Mark this tab as the gateway
154 tab.tmux.tmux_gateway_active = true;
155
156 // Store the gateway tab ID so we know where to send commands
157 self.tmux_state.tmux_gateway_tab_id = Some(gateway_tab_id);
158 crate::debug_info!(
159 "TMUX",
160 "Gateway tab set to {}, state: Initiating",
161 gateway_tab_id
162 );
163
164 // Create session and set gateway state
165 let mut session = TmuxSession::new();
166 session.set_gateway_initiating();
167 self.tmux_state.tmux_session = Some(session);
168
169 // Show toast
170 self.show_toast(format!("tmux: Attaching to '{}'...", session_name));
171
172 Ok(())
173 }
174
175 /// Disconnect from the current tmux session
176 pub fn disconnect_tmux_session(&mut self) {
177 // Restore gateway tab visibility before clearing state
178 self.show_gateway_tab();
179
180 // Clear the gateway tab ID
181 self.tmux_state.tmux_gateway_tab_id = None;
182
183 // First, disable tmux control mode on any gateway tabs
184 for tab in self.tab_manager.tabs_mut() {
185 if tab.tmux.tmux_gateway_active {
186 tab.tmux.tmux_gateway_active = false;
187 // try_lock: intentional — disconnect is called from the sync event loop.
188 // On miss: control mode stays on the terminal until the next frame; benign
189 // since the session is already being torn down and no further output arrives.
190 if let Ok(term) = tab.terminal.try_read() {
191 term.set_tmux_control_mode(false);
192 }
193 }
194 }
195
196 if let Some(mut session) = self.tmux_state.tmux_session.take() {
197 crate::debug_info!("TMUX", "Disconnecting from tmux session");
198 session.disconnect();
199 }
200
201 // Clear session name
202 self.tmux_state.tmux_session_name = None;
203
204 // Reset sync state
205 self.tmux_state.tmux_sync = crate::tmux::TmuxSync::new();
206
207 // Reset window title (now without tmux info)
208 self.update_window_title_with_tmux();
209 }
210
211 /// Check if tmux session is active
212 pub fn is_tmux_connected(&self) -> bool {
213 self.tmux_state
214 .tmux_session
215 .as_ref()
216 .is_some_and(|s| s.state() == SessionState::Connected)
217 }
218
219 /// Return true when a `tmux*` process is running under the active tab's
220 /// shell. Used by the input path to decide whether to encode Shift+Enter
221 /// as raw LF (iTerm2 convention) or as a CSI-u extended-keys sequence
222 /// that tmux's `extended-keys on` parser can relay to the inner app in
223 /// whatever keyboard protocol that app has negotiated (kitty/modifyOtherKeys).
224 pub fn shell_has_tmux_child(&self) -> bool {
225 use std::sync::atomic::Ordering;
226 let tab = match self.tab_manager.active_tab() {
227 Some(t) => t,
228 None => return false,
229 };
230 // Cache fallback: on `try_read` contention (frequent in release/LTO
231 // builds because the renderer holds the write lock briefly on every
232 // frame), fall back to the last-known result rather than reporting
233 // "no tmux". Returning `false` here was causing the Shift+Enter
234 // handler to send raw LF in shell context, which tmux mangles into
235 // Ctrl+J. See cached_has_tmux_child docs in src/tab/mod.rs.
236 match tab.terminal.try_read() {
237 Ok(term) => {
238 let has = term
239 .get_running_child_processes(&[])
240 .iter()
241 .any(|name| name.eq_ignore_ascii_case("tmux") || name.starts_with("tmux"));
242 tab.cached_has_tmux_child.store(has, Ordering::Relaxed);
243 has
244 }
245 Err(_) => tab.cached_has_tmux_child.load(Ordering::Relaxed),
246 }
247 }
248
249 /// Check if gateway mode is active (connected or connecting)
250 pub fn is_gateway_active(&self) -> bool {
251 self.tmux_state
252 .tmux_session
253 .as_ref()
254 .is_some_and(|s| s.is_gateway_active())
255 }
256
257 /// Update the tmux focused pane when a native pane is focused
258 ///
259 /// This should be called when the user clicks on a pane to ensure
260 /// input is routed to the correct tmux pane. Callers hit-test the
261 /// ACTIVE tab's panes, so the pane resolves within that tab — native
262 /// pane ids restart at 1 in every tab, and the pane id alone would be
263 /// ambiguous across daemon windows.
264 pub fn set_tmux_focused_pane_from_native(&mut self, native_pane_id: crate::pane::PaneId) {
265 let Some(active_tab) = self.tab_manager.active_tab() else {
266 return;
267 };
268 let Some(tmux_pane_id) = self
269 .tmux_state
270 .tmux_pane_in_tab(active_tab.id, native_pane_id)
271 else {
272 return;
273 };
274 crate::debug_info!(
275 "TMUX",
276 "Setting focused pane: native {} -> tmux %{}",
277 native_pane_id,
278 tmux_pane_id
279 );
280 if let Some(session) = &mut self.tmux_state.tmux_session {
281 session.set_focused_pane(Some(tmux_pane_id));
282 } else if self.tmux_state.transport.is_some() {
283 // par-mux transport: remember the focused pane for input routing.
284 self.tmux_state.mux_focused_pane = Some(tmux_pane_id);
285 // Focusing a done agent's pane is seeing it — clear the
286 // done-unseen mark (roster surfaces render it as done ✓).
287 #[cfg(feature = "mux")]
288 self.tmux_state.agent_roster.mark_seen(tmux_pane_id);
289 }
290 }
291
292 /// Focus the native pane backing a tmux/par-mux pane id — the target of
293 /// the agent-roster palette picker's `agent-roster-focus:<pane>` rows
294 /// (A2b task 3). Returns `false` when no native pane currently maps to
295 /// the id (session ended, layout mid-rebuild) or the owning tab is gone;
296 /// the caller logs and no-ops.
297 pub(crate) fn focus_agent_roster_pane(&mut self, tmux_pane_id: u64) -> bool {
298 let Some((owner_tab_id, native_pane_id)) = self.tmux_state.tmux_pane_owner(tmux_pane_id)
299 else {
300 return false;
301 };
302 let owning_tab_index = self
303 .tab_manager
304 .tabs()
305 .iter()
306 .position(|tab| tab.id == owner_tab_id);
307 let Some(tab_index) = owning_tab_index else {
308 return false;
309 };
310 self.switch_to_tab_index(tab_index);
311 if let Some(tab) = self.tab_manager.active_tab_mut()
312 && let Some(pm) = tab.pane_manager_mut()
313 {
314 pm.focus_pane(native_pane_id);
315 }
316 // Sync the tmux/mux side so input routes to the newly focused pane.
317 self.set_tmux_focused_pane_from_native(native_pane_id);
318 self.set_scroll_target(0);
319 self.focus_state.needs_redraw = true;
320 self.request_redraw();
321 true
322 }
323
324 // =========================================================================
325 // Gateway Mode Input Routing
326 // =========================================================================
327
328 /// Write a command to the gateway tab's terminal.
329 ///
330 /// The gateway tab is where the tmux control mode connection lives.
331 /// All tmux commands must be written to this tab, not the active tab.
332 pub(crate) fn write_to_gateway(&self, cmd: &str) -> bool {
333 let gateway_tab_id = match self.tmux_state.tmux_gateway_tab_id {
334 Some(id) => id,
335 None => {
336 crate::debug_trace!("TMUX", "No gateway tab ID set");
337 return false;
338 }
339 };
340
341 // try_lock: intentional — write_to_gateway is called from the sync event loop and
342 // from input handlers. Blocking would stall the GUI or create deadlock risk.
343 // On miss: the tmux command is silently dropped. For input this means a keypress
344 // is lost; for control commands (resize, split) the caller should retry as needed.
345 if let Some(tab) = self.tab_manager.get_tab(gateway_tab_id)
346 && tab.tmux.tmux_gateway_active
347 && let Ok(term) = tab.terminal.try_read()
348 {
349 match term.write(cmd.as_bytes()) {
350 Ok(()) => return true,
351 Err(e) => {
352 crate::debug_error!("TMUX", "PTY write failed (gateway command): {e}");
353 return false;
354 }
355 }
356 }
357
358 crate::debug_trace!("TMUX", "Failed to write to gateway tab");
359 false
360 }
361
362 /// Split the current pane via tmux control mode.
363 ///
364 /// Writes split-window command to the gateway PTY.
365 ///
366 /// # Arguments
367 /// * `vertical` - true for vertical split (side by side), false for horizontal (stacked)
368 ///
369 /// Returns true if the command was sent successfully.
370 pub fn split_pane_via_tmux(&self, vertical: bool) -> bool {
371 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
372 return false;
373 }
374
375 let session = match &self.tmux_state.tmux_session {
376 Some(s) => s,
377 None => return false,
378 };
379
380 // Get the focused pane ID
381 let pane_id = session.focused_pane();
382
383 // Format the split command
384 let cmd = if vertical {
385 match pane_id {
386 Some(id) => format!("split-window -h -t %{}\n", id),
387 None => "split-window -h\n".to_string(),
388 }
389 } else {
390 match pane_id {
391 Some(id) => format!("split-window -v -t %{}\n", id),
392 None => "split-window -v\n".to_string(),
393 }
394 };
395
396 // Write to gateway tab
397 if self.write_to_gateway(&cmd) {
398 crate::debug_info!(
399 "TMUX",
400 "Sent {} split command via gateway",
401 if vertical { "vertical" } else { "horizontal" }
402 );
403 return true;
404 }
405
406 false
407 }
408
409 /// Close the focused pane via tmux control mode.
410 ///
411 /// Writes kill-pane command to the gateway PTY.
412 ///
413 /// Returns true if the command was sent successfully.
414 pub fn close_pane_via_tmux(&self) -> bool {
415 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
416 return false;
417 }
418
419 let session = match &self.tmux_state.tmux_session {
420 Some(s) => s,
421 None => return false,
422 };
423
424 // Get the focused pane ID
425 let pane_id = match session.focused_pane() {
426 Some(id) => id,
427 None => {
428 crate::debug_info!("TMUX", "No focused pane to close");
429 return false;
430 }
431 };
432
433 let cmd = format!("kill-pane -t %{}\n", pane_id);
434
435 // Write to gateway tab
436 if self.write_to_gateway(&cmd) {
437 crate::debug_info!("TMUX", "Sent kill-pane command for pane %{}", pane_id);
438 return true;
439 }
440
441 false
442 }
443
444 /// Sync clipboard content to tmux paste buffer.
445 ///
446 /// Writes set-buffer command to the gateway PTY, or to the par-mux
447 /// daemon when a mux transport is attached (the panes live daemon-side
448 /// and there is no gateway tab to write to).
449 ///
450 /// Returns true if the command was sent successfully.
451 pub fn sync_clipboard_to_tmux(&self, content: &str) -> bool {
452 // Check if clipboard sync is enabled
453 if !self.config.load().tmux.tmux_clipboard_sync {
454 return false;
455 }
456
457 // Don't sync empty content
458 if content.is_empty() {
459 return false;
460 }
461
462 let cmd = crate::tmux::set_buffer_command(content);
463
464 // par-mux transport: set-buffer is session-wide (the daemon's
465 // single default buffer), so the command needs no pane target.
466 #[cfg(feature = "mux")]
467 if let Some(transport) = self.tmux_state.transport.as_ref() {
468 return match transport.send_command(&cmd) {
469 Ok(_) => {
470 crate::debug_trace!(
471 "MUX",
472 "Synced {} chars to daemon paste buffer",
473 content.len()
474 );
475 true
476 }
477 Err(e) => {
478 crate::debug_error!("MUX", "set-buffer failed: {e}");
479 false
480 }
481 };
482 }
483
484 if !self.config.load().tmux.tmux_enabled || !self.is_tmux_connected() {
485 return false;
486 }
487
488 // Write to gateway tab
489 if self.write_to_gateway(&format!("{cmd}\n")) {
490 crate::debug_trace!(
491 "TMUX",
492 "Synced {} chars to tmux paste buffer",
493 content.len()
494 );
495 return true;
496 }
497
498 false
499 }
500
501 // =========================================================================
502 // Pane Resize Sync
503 // =========================================================================
504
505 /// Sync pane resize to tmux after a divider drag.
506 ///
507 /// When the user resizes panes by dragging a divider in par-term, this
508 /// sends the new pane sizes to tmux so external clients see the same layout.
509 ///
510 /// # Arguments
511 /// * `is_horizontal_divider` - true if dragging a horizontal divider (changes heights),
512 /// false if dragging a vertical divider (changes widths)
513 pub fn sync_pane_resize_to_tmux(&self, is_horizontal_divider: bool) {
514 // par-mux transport: the same absolute resize-pane form, sent
515 // through the daemon client. Without it a divider drag resized only
516 // the native panes — the daemon PTYs kept their old size and never
517 // sent SIGWINCH, so htop and friends never reflowed.
518 #[cfg(feature = "mux")]
519 let mux_transport = self.tmux_state.transport.as_ref();
520 #[cfg(not(feature = "mux"))]
521 let mux_transport: Option<&Box<dyn super::tmux_state::TmuxTransport>> = None;
522
523 // Only sync if tmux gateway is active (or a mux transport is)
524 if !self.is_gateway_active() && mux_transport.is_none() {
525 return;
526 }
527
528 // Get cell dimensions from renderer
529 let (cell_width, cell_height) = match &self.renderer {
530 Some(r) => (r.cell_width(), r.cell_height()),
531 None => return,
532 };
533
534 // Get pane sizes from active tab's pane manager
535 let pane_sizes: Vec<(crate::tmux::TmuxPaneId, usize, usize)> = if let Some(tab) =
536 self.tab_manager.active_tab()
537 && let Some(pm) = tab.pane_manager()
538 {
539 pm.all_panes()
540 .iter()
541 .filter_map(|pane| {
542 // Get the tmux pane ID for this native pane (tab-scoped:
543 // pane ids restart at 1 in every tab)
544 let tmux_pane_id = self.tmux_state.tmux_pane_in_tab(tab.id, pane.id)?;
545 // Calculate size in columns/rows
546 let cols = (pane.bounds.width / cell_width).floor() as usize;
547 let rows = (pane.bounds.height / cell_height).floor() as usize;
548 Some((tmux_pane_id, cols.max(1), rows.max(1)))
549 })
550 .collect()
551 } else {
552 return;
553 };
554
555 // Send resize commands for each pane, but only for the dimension that changed
556 // Horizontal divider: changes height (rows) - use -y
557 // Vertical divider: changes width (cols) - use -x
558 for (tmux_pane_id, cols, rows) in pane_sizes {
559 let cmd = if is_horizontal_divider {
560 format!("resize-pane -t %{} -y {}\n", tmux_pane_id, rows)
561 } else {
562 format!("resize-pane -t %{} -x {}\n", tmux_pane_id, cols)
563 };
564 let sent = match mux_transport {
565 Some(transport) => match transport.send_command(cmd.trim_end()) {
566 Ok(_) => true,
567 Err(e) => {
568 crate::debug_error!("MUX", "resize-pane failed: {e}");
569 false
570 }
571 },
572 None => self.write_to_gateway(&cmd),
573 };
574 if sent {
575 crate::debug_info!(
576 "TMUX",
577 "Synced pane %{} {} resize to {}",
578 tmux_pane_id,
579 if is_horizontal_divider {
580 "height"
581 } else {
582 "width"
583 },
584 if is_horizontal_divider { rows } else { cols }
585 );
586 }
587 }
588 }
589
590 // =========================================================================
591 // Gateway Tab Visibility
592 // =========================================================================
593
594 /// Hide the gateway tab from the tab bar once tmux windows are active.
595 ///
596 /// Called after the first tmux window tab is created so the control-mode
597 /// connection tab no longer clutters the tab bar. The tab still exists and
598 /// all PTY I/O continues to flow through it; it is simply excluded from the
599 /// visible tab list. The tab is restored when the session ends.
600 ///
601 /// No-op when `config.tmux_hide_gateway_tab` is false.
602 pub(crate) fn hide_gateway_tab(&mut self) {
603 if !self.config.load().tmux.tmux_hide_gateway_tab {
604 return;
605 }
606 if let Some(gateway_tab_id) = self.tmux_state.tmux_gateway_tab_id
607 && let Some(tab) = self.tab_manager.get_tab_mut(gateway_tab_id)
608 && !tab.is_hidden
609 {
610 tab.is_hidden = true;
611 crate::debug_info!(
612 "TMUX",
613 "Gateway tab {} hidden (tmux windows active)",
614 gateway_tab_id
615 );
616 }
617 }
618
619 /// Restore the gateway tab to the tab bar when no tmux windows are active.
620 pub(crate) fn show_gateway_tab(&mut self) {
621 if let Some(gateway_tab_id) = self.tmux_state.tmux_gateway_tab_id
622 && let Some(tab) = self.tab_manager.get_tab_mut(gateway_tab_id)
623 && tab.is_hidden
624 {
625 tab.is_hidden = false;
626 crate::debug_info!("TMUX", "Gateway tab {} restored to tab bar", gateway_tab_id);
627 }
628 }
629
630 // =========================================================================
631 // Prefix Key Handling
632 // =========================================================================
633}