orchflow_mux/
tmux_backend.rs

1//! Tmux backend implementation for terminal multiplexing
2
3use crate::backend::{MuxBackend, MuxError, Pane, PaneSize, Session, SplitType};
4use async_trait::async_trait;
5use chrono::Utc;
6use std::process::Command;
7use tracing::{debug, info};
8
9/// Tmux implementation of MuxBackend
10#[derive(Clone, Default)]
11pub struct TmuxBackend;
12
13impl TmuxBackend {
14    pub fn new() -> Self {
15        Self
16    }
17
18    /// Execute a tmux command
19    async fn tmux_command(&self, args: &[&str]) -> Result<String, MuxError> {
20        let output = tokio::task::spawn_blocking({
21            let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
22            move || Command::new("tmux").args(&args).output()
23        })
24        .await
25        .map_err(|e| MuxError::Other(format!("Failed to spawn tmux command: {e}")))?
26        .map_err(|e| MuxError::CommandFailed(format!("Failed to execute tmux: {e}")))?;
27
28        if !output.status.success() {
29            let stderr = String::from_utf8_lossy(&output.stderr);
30            return Err(MuxError::CommandFailed(format!(
31                "tmux command failed: {stderr}"
32            )));
33        }
34
35        Ok(String::from_utf8_lossy(&output.stdout).to_string())
36    }
37}
38
39#[async_trait]
40impl MuxBackend for TmuxBackend {
41    async fn create_session(&self, name: &str) -> Result<String, MuxError> {
42        info!("Creating tmux session: {}", name);
43
44        // Create detached session
45        self.tmux_command(&["new-session", "-d", "-s", name])
46            .await?;
47
48        Ok(name.to_string())
49    }
50
51    async fn create_pane(&self, session_id: &str, split: SplitType) -> Result<String, MuxError> {
52        debug!(
53            "Creating pane in session: {} with split: {:?}",
54            session_id, split
55        );
56
57        let split_arg = match split {
58            SplitType::Horizontal => "-v",
59            SplitType::Vertical => "-h",
60            SplitType::None => {
61                return Err(MuxError::NotSupported(
62                    "Split type None not supported".to_string(),
63                ))
64            }
65        };
66
67        let output = self
68            .tmux_command(&[
69                "split-window",
70                split_arg,
71                "-t",
72                session_id,
73                "-P",
74                "-F",
75                "#{pane_id}",
76            ])
77            .await?;
78
79        Ok(output.trim().to_string())
80    }
81
82    async fn send_keys(&self, pane_id: &str, keys: &str) -> Result<(), MuxError> {
83        debug!("Sending keys to pane {}: {}", pane_id, keys);
84
85        self.tmux_command(&["send-keys", "-t", pane_id, keys, "Enter"])
86            .await?;
87        Ok(())
88    }
89
90    async fn capture_pane(&self, pane_id: &str) -> Result<String, MuxError> {
91        debug!("Capturing pane: {}", pane_id);
92
93        self.tmux_command(&["capture-pane", "-t", pane_id, "-p"])
94            .await
95    }
96
97    async fn list_sessions(&self) -> Result<Vec<Session>, MuxError> {
98        debug!("Listing tmux sessions");
99
100        let output = self.tmux_command(&[
101            "list-sessions",
102            "-F", "#{session_id}:#{session_name}:#{session_created}:#{session_windows}:#{session_attached}"
103        ]).await?;
104
105        let sessions = output
106            .lines()
107            .filter(|line| !line.is_empty())
108            .map(|line| {
109                let parts: Vec<&str> = line.split(':').collect();
110                if parts.len() >= 5 {
111                    Session {
112                        id: parts[0].trim_start_matches('$').to_string(),
113                        name: parts[1].to_string(),
114                        created_at: Utc::now(), // tmux timestamp parsing is complex
115                        window_count: parts[3].parse().unwrap_or(0),
116                        attached: parts[4] == "1",
117                    }
118                } else {
119                    Session {
120                        id: line.to_string(),
121                        name: line.to_string(),
122                        created_at: Utc::now(),
123                        window_count: 0,
124                        attached: false,
125                    }
126                }
127            })
128            .collect();
129
130        Ok(sessions)
131    }
132
133    async fn kill_session(&self, session_id: &str) -> Result<(), MuxError> {
134        info!("Killing tmux session: {}", session_id);
135
136        self.tmux_command(&["kill-session", "-t", session_id])
137            .await?;
138        Ok(())
139    }
140
141    async fn kill_pane(&self, pane_id: &str) -> Result<(), MuxError> {
142        debug!("Killing pane: {}", pane_id);
143
144        self.tmux_command(&["kill-pane", "-t", pane_id]).await?;
145        Ok(())
146    }
147
148    async fn resize_pane(&self, pane_id: &str, size: PaneSize) -> Result<(), MuxError> {
149        debug!(
150            "Resizing pane {} to {}x{}",
151            pane_id, size.width, size.height
152        );
153
154        // tmux doesn't support absolute sizing easily, this is a simplified version
155        Err(MuxError::NotSupported(
156            "Absolute pane resizing not implemented for tmux".to_string(),
157        ))
158    }
159
160    async fn select_pane(&self, pane_id: &str) -> Result<(), MuxError> {
161        debug!("Selecting pane: {}", pane_id);
162
163        self.tmux_command(&["select-pane", "-t", pane_id]).await?;
164        Ok(())
165    }
166
167    async fn list_panes(&self, session_id: &str) -> Result<Vec<Pane>, MuxError> {
168        debug!("Listing panes for session: {}", session_id);
169
170        let output = self.tmux_command(&[
171            "list-panes",
172            "-t", session_id,
173            "-F", "#{pane_id}:#{pane_index}:#{pane_title}:#{pane_active}:#{pane_width}:#{pane_height}"
174        ]).await?;
175
176        let panes = output
177            .lines()
178            .filter(|line| !line.is_empty())
179            .map(|line| {
180                let parts: Vec<&str> = line.split(':').collect();
181                if parts.len() >= 6 {
182                    Pane {
183                        id: parts[0].to_string(),
184                        session_id: session_id.to_string(),
185                        index: parts[1].parse().unwrap_or(0),
186                        title: parts[2].to_string(),
187                        active: parts[3] == "1",
188                        size: PaneSize {
189                            width: parts[4].parse().unwrap_or(80),
190                            height: parts[5].parse().unwrap_or(24),
191                        },
192                    }
193                } else {
194                    Pane {
195                        id: line.to_string(),
196                        session_id: session_id.to_string(),
197                        index: 0,
198                        title: String::new(),
199                        active: false,
200                        size: PaneSize {
201                            width: 80,
202                            height: 24,
203                        },
204                    }
205                }
206            })
207            .collect();
208
209        Ok(panes)
210    }
211
212    async fn attach_session(&self, session_id: &str) -> Result<(), MuxError> {
213        info!("Attaching to tmux session: {}", session_id);
214
215        // This would normally attach the current terminal to the session
216        // For a library, we just verify the session exists
217        self.tmux_command(&["has-session", "-t", session_id])
218            .await?;
219        Ok(())
220    }
221
222    async fn detach_session(&self, session_id: &str) -> Result<(), MuxError> {
223        info!("Detaching from tmux session: {}", session_id);
224
225        self.tmux_command(&["detach-client", "-s", session_id])
226            .await?;
227        Ok(())
228    }
229}