Skip to main content

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}