1use crate::native::{NativeSession, NativeSessionManager};
7use anyhow::Result;
8use std::collections::HashMap;
9use std::sync::Arc;
10use std::time::Duration;
11use tokio::sync::RwLock;
12use tokio::time::timeout;
13
14#[derive(Debug, Clone)]
16pub struct TmuxConfig {
17 pub command_timeout: Duration,
19 pub retry_count: u32,
21 pub retry_delay: Duration,
23 pub history_limit: usize,
25 pub auto_start_server: bool,
27 pub session_prefix: String,
29}
30
31impl Default for TmuxConfig {
32 fn default() -> Self {
33 Self {
34 command_timeout: Duration::from_secs(30),
35 retry_count: 3,
36 retry_delay: Duration::from_millis(500),
37 history_limit: 10000,
38 auto_start_server: true,
39 session_prefix: String::new(),
40 }
41 }
42}
43
44pub struct TmuxClient {
47 session_manager: Arc<NativeSessionManager>,
49 session_cache: Arc<RwLock<HashMap<String, Arc<tokio::sync::Mutex<NativeSession>>>>>,
51 windows: Arc<RwLock<HashMap<String, Vec<TmuxWindow>>>>,
53 #[allow(dead_code)]
55 panes: Arc<RwLock<HashMap<String, Vec<TmuxPane>>>>,
56 config: TmuxConfig,
58 server_running: Arc<RwLock<bool>>,
60}
61
62impl TmuxClient {
63 pub async fn new() -> Result<Self> {
65 Self::with_config(TmuxConfig::default()).await
66 }
67
68 pub async fn with_config(config: TmuxConfig) -> Result<Self> {
70 let client = Self {
71 session_manager: Arc::new(NativeSessionManager::new()),
72 session_cache: Arc::new(RwLock::new(HashMap::new())),
73 windows: Arc::new(RwLock::new(HashMap::new())),
74 panes: Arc::new(RwLock::new(HashMap::new())),
75 config,
76 server_running: Arc::new(RwLock::new(true)), };
78
79 if client.config.auto_start_server {
80 client.ensure_server_running().await?;
81 }
82
83 Ok(client)
84 }
85
86 pub async fn is_server_running(&self) -> bool {
88 *self.server_running.read().await
89 }
90
91 pub async fn ensure_server_running(&self) -> Result<()> {
93 let mut running = self.server_running.write().await;
94 if !*running {
95 *running = true;
98 }
99 Ok(())
100 }
101
102 pub fn validate_session_name(name: &str) -> Result<()> {
104 if name.contains(':') || name.contains('.') {
105 return Err(anyhow::anyhow!(
106 "Session name cannot contain ':' or '.' characters"
107 ));
108 }
109 if name.is_empty() {
110 return Err(anyhow::anyhow!("Session name cannot be empty"));
111 }
112 Ok(())
113 }
114
115 pub async fn create_session(&self, session_name: &str, working_directory: &str) -> Result<()> {
117 Self::validate_session_name(session_name)?;
118
119 let full_name = format!("{}{}", self.config.session_prefix, session_name);
120
121 self.execute_with_retry(|| async {
123 let session = self.session_manager.create_session(&full_name).await?;
124
125 let session_lock = session.lock().await;
127 session_lock
128 .send_input(&format!("cd {}\n", working_directory))
129 .await?;
130 drop(session_lock);
131
132 let mut cache = self.session_cache.write().await;
134 cache.insert(full_name.clone(), session);
135
136 let mut windows = self.windows.write().await;
138 windows.insert(
139 full_name.clone(),
140 vec![TmuxWindow {
141 id: "@1".to_string(),
142 name: "main".to_string(),
143 active: true,
144 layout: "".to_string(),
145 panes: vec![TmuxPane {
146 id: "%1".to_string(),
147 active: true,
148 current_path: working_directory.to_string(),
149 current_command: "bash".to_string(),
150 }],
151 }],
152 );
153
154 Ok(())
155 })
156 .await
157 }
158
159 pub async fn has_session(&self, session_name: &str) -> Result<bool> {
161 let full_name = format!("{}{}", self.config.session_prefix, session_name);
162 Ok(self.session_manager.has_session(&full_name).await)
163 }
164
165 pub async fn kill_session(&self, session_name: &str) -> Result<()> {
167 let full_name = format!("{}{}", self.config.session_prefix, session_name);
168
169 self.execute_with_retry(|| async {
170 self.session_manager.delete_session(&full_name).await?;
171
172 let mut cache = self.session_cache.write().await;
174 cache.remove(&full_name);
175
176 let mut windows = self.windows.write().await;
178 windows.remove(&full_name);
179
180 Ok(())
181 })
182 .await
183 }
184
185 pub async fn send_keys(&self, session_name: &str, keys: &str) -> Result<()> {
187 let full_name = format!("{}{}", self.config.session_prefix, session_name);
188
189 self.execute_with_retry(|| async {
190 if let Some(session) = self.session_manager.get_session(&full_name).await {
191 let session_lock = session.lock().await;
192
193 let input = match keys {
195 "C-c" => "\x03",
196 "C-z" => "\x1a",
197 "C-d" => "\x04",
198 "C-a" => "\x01",
199 "C-e" => "\x05",
200 "C-k" => "\x0b",
201 "C-l" => "\x0c",
202 "C-u" => "\x15",
203 "C-w" => "\x17",
204 "Enter" => "\n",
205 "Tab" => "\t",
206 "Escape" => "\x1b",
207 "Space" => " ",
208 _ => keys,
209 };
210
211 session_lock.send_input(input).await?;
212 Ok(())
213 } else {
214 Err(TmuxError::SessionNotFound(session_name.to_string()).into())
215 }
216 })
217 .await
218 }
219
220 pub async fn send_command(&self, session_name: &str, command: &str) -> Result<()> {
222 let full_name = format!("{}{}", self.config.session_prefix, session_name);
223
224 self.execute_with_retry(|| async {
225 if let Some(session) = self.session_manager.get_session(&full_name).await {
226 let session_lock = session.lock().await;
227 session_lock.send_input(&format!("{}\n", command)).await?;
228 Ok(())
229 } else {
230 Err(TmuxError::SessionNotFound(session_name.to_string()).into())
231 }
232 })
233 .await
234 }
235
236 pub async fn capture_pane(&self, session_name: &str, pane_id: Option<&str>) -> Result<String> {
238 self.capture_pane_with_options(session_name, pane_id, None)
239 .await
240 }
241
242 pub async fn capture_pane_with_options(
244 &self,
245 session_name: &str,
246 _pane_id: Option<&str>,
247 line_limit: Option<usize>,
248 ) -> Result<String> {
249 let full_name = format!("{}{}", self.config.session_prefix, session_name);
250
251 self.execute_with_retry(|| async {
252 if let Some(session) = self.session_manager.get_session(&full_name).await {
253 let session_lock = session.lock().await;
254 let lines = line_limit.unwrap_or(self.config.history_limit);
255 let output = session_lock.get_output(lines).await?;
256 Ok(output.join("\n"))
257 } else {
258 Err(TmuxError::SessionNotFound(session_name.to_string()).into())
259 }
260 })
261 .await
262 }
263
264 pub async fn list_sessions(&self) -> Result<Vec<TmuxSession>> {
266 let session_names = self.session_manager.list_sessions().await;
267 let mut sessions = Vec::new();
268
269 for (idx, name) in session_names.iter().enumerate() {
270 let display_name = if name.starts_with(&self.config.session_prefix) {
272 &name[self.config.session_prefix.len()..]
273 } else {
274 name
275 };
276
277 let windows = self.list_windows(display_name).await?;
278
279 sessions.push(TmuxSession {
280 name: display_name.to_string(),
281 id: format!("${}", idx + 1),
282 windows,
283 attached: false,
284 created: chrono::Utc::now().to_rfc3339(),
285 last_attached: None,
286 });
287 }
288
289 Ok(sessions)
290 }
291
292 pub async fn session_exists(&self, session_name: &str) -> Result<bool> {
294 self.has_session(session_name).await
295 }
296
297 pub async fn set_environment(&self, session_name: &str, name: &str, value: &str) -> Result<()> {
299 let full_name = format!("{}{}", self.config.session_prefix, session_name);
300
301 self.execute_with_retry(|| async {
302 if let Some(session) = self.session_manager.get_session(&full_name).await {
303 let session_lock = session.lock().await;
304 session_lock
306 .send_input(&format!("export {}='{}'\n", name, value))
307 .await?;
308 Ok(())
309 } else {
310 Err(TmuxError::SessionNotFound(session_name.to_string()).into())
311 }
312 })
313 .await
314 }
315
316 pub async fn set_option(&self, _session_name: &str, option: &str, value: &str) -> Result<()> {
318 tracing::debug!("TMux option set (no-op): {} = {}", option, value);
320 Ok(())
321 }
322
323 pub async fn new_window(
325 &self,
326 session_name: &str,
327 window_name: &str,
328 working_directory: Option<&str>,
329 ) -> Result<String> {
330 let full_name = format!("{}{}", self.config.session_prefix, session_name);
331
332 let mut windows = self.windows.write().await;
333 let session_windows = windows.entry(full_name.clone()).or_insert_with(Vec::new);
334
335 let window_id = format!("@{}", session_windows.len() + 1);
336
337 for window in session_windows.iter_mut() {
339 window.active = false;
340 }
341
342 session_windows.push(TmuxWindow {
343 id: window_id.clone(),
344 name: window_name.to_string(),
345 active: true,
346 layout: "".to_string(),
347 panes: vec![TmuxPane {
348 id: format!("%{}", session_windows.len() + 1),
349 active: true,
350 current_path: working_directory.unwrap_or("/").to_string(),
351 current_command: "bash".to_string(),
352 }],
353 });
354
355 if let Some(dir) = working_directory {
357 self.send_command(session_name, &format!("cd {}", dir))
358 .await?;
359 }
360
361 Ok(window_id)
362 }
363
364 pub async fn list_windows(&self, session_name: &str) -> Result<Vec<TmuxWindow>> {
366 let full_name = format!("{}{}", self.config.session_prefix, session_name);
367 let windows = self.windows.read().await;
368
369 Ok(windows.get(&full_name).cloned().unwrap_or_else(|| {
370 vec![TmuxWindow {
371 id: "@1".to_string(),
372 name: "main".to_string(),
373 active: true,
374 layout: "".to_string(),
375 panes: vec![],
376 }]
377 }))
378 }
379
380 pub async fn kill_window(&self, session_name: &str, window_id: &str) -> Result<()> {
382 let full_name = format!("{}{}", self.config.session_prefix, session_name);
383 let mut windows = self.windows.write().await;
384
385 if let Some(session_windows) = windows.get_mut(&full_name) {
386 session_windows.retain(|w| w.id != window_id);
387
388 if !session_windows.iter().any(|w| w.active) && !session_windows.is_empty() {
390 session_windows[0].active = true;
391 }
392 }
393
394 Ok(())
395 }
396
397 pub async fn list_panes(
399 &self,
400 session_name: &str,
401 window_id: Option<&str>,
402 ) -> Result<Vec<TmuxPane>> {
403 let full_name = format!("{}{}", self.config.session_prefix, session_name);
404 let windows = self.windows.read().await;
405
406 if let Some(session_windows) = windows.get(&full_name) {
407 if let Some(window_id) = window_id {
408 if let Some(window) = session_windows.iter().find(|w| w.id == window_id) {
410 Ok(window.panes.clone())
411 } else {
412 Ok(vec![])
413 }
414 } else {
415 if let Some(window) = session_windows.iter().find(|w| w.active) {
417 Ok(window.panes.clone())
418 } else {
419 Ok(vec![])
420 }
421 }
422 } else {
423 Ok(vec![])
424 }
425 }
426
427 pub async fn split_window(
429 &self,
430 session_name: &str,
431 window_id: Option<&str>,
432 vertical: bool,
433 percentage: Option<u8>,
434 ) -> Result<String> {
435 let full_name = format!("{}{}", self.config.session_prefix, session_name);
436 let mut windows = self.windows.write().await;
437
438 if let Some(session_windows) = windows.get_mut(&full_name) {
439 let window = if let Some(window_id) = window_id {
440 session_windows.iter_mut().find(|w| w.id == window_id)
441 } else {
442 session_windows.iter_mut().find(|w| w.active)
443 };
444
445 if let Some(window) = window {
446 let pane_id = format!("%{}", window.panes.len() + 1);
447
448 for pane in window.panes.iter_mut() {
450 pane.active = false;
451 }
452
453 window.panes.push(TmuxPane {
454 id: pane_id.clone(),
455 active: true,
456 current_path: "/".to_string(),
457 current_command: "bash".to_string(),
458 });
459
460 tracing::debug!(
462 "Split window {} {} with {}% size",
463 if vertical {
464 "vertically"
465 } else {
466 "horizontally"
467 },
468 window.id,
469 percentage.unwrap_or(50)
470 );
471
472 Ok(pane_id)
473 } else {
474 Err(anyhow::anyhow!("Window not found"))
475 }
476 } else {
477 Err(anyhow::anyhow!("Session not found"))
478 }
479 }
480
481 pub async fn select_pane(&self, session_name: &str, pane_id: &str) -> Result<()> {
483 let full_name = format!("{}{}", self.config.session_prefix, session_name);
484 let mut windows = self.windows.write().await;
485
486 if let Some(session_windows) = windows.get_mut(&full_name) {
487 for window in session_windows.iter_mut() {
488 for pane in window.panes.iter_mut() {
489 pane.active = pane.id == pane_id;
490 }
491 }
492 Ok(())
493 } else {
494 Err(TmuxError::SessionNotFound(session_name.to_string()).into())
495 }
496 }
497
498 pub async fn attach_session(&self, session_name: &str) -> Result<()> {
500 if !self.has_session(session_name).await? {
501 return Err(TmuxError::SessionNotFound(session_name.to_string()).into());
502 }
503
504 tracing::debug!("Session '{}' marked as attached", session_name);
506 Ok(())
507 }
508
509 pub async fn detach_session(&self, session_name: &str) -> Result<()> {
511 if !self.has_session(session_name).await? {
512 return Err(TmuxError::SessionNotFound(session_name.to_string()).into());
513 }
514
515 tracing::debug!("Session '{}' marked as detached", session_name);
517 Ok(())
518 }
519
520 pub async fn get_session_info(&self, session_name: &str) -> Result<TmuxSession> {
522 if self.has_session(session_name).await? {
523 let windows = self.list_windows(session_name).await?;
524
525 Ok(TmuxSession {
526 name: session_name.to_string(),
527 id: "$1".to_string(),
528 windows,
529 attached: false,
530 created: chrono::Utc::now().to_rfc3339(),
531 last_attached: None,
532 })
533 } else {
534 Err(TmuxError::SessionNotFound(session_name.to_string()).into())
535 }
536 }
537
538 async fn execute_with_retry<F, Fut, T>(&self, operation: F) -> Result<T>
540 where
541 F: Fn() -> Fut,
542 Fut: std::future::Future<Output = Result<T>>,
543 {
544 let mut last_error = None;
545
546 for attempt in 0..=self.config.retry_count {
547 if attempt > 0 {
548 tokio::time::sleep(self.config.retry_delay).await;
549 tracing::debug!("Retrying operation (attempt {})", attempt);
550 }
551
552 match timeout(self.config.command_timeout, operation()).await {
553 Ok(Ok(result)) => return Ok(result),
554 Ok(Err(e)) => {
555 last_error = Some(e);
556 if attempt < self.config.retry_count {
557 tracing::warn!(
558 "Operation failed, will retry: {}",
559 last_error.as_ref().unwrap()
560 );
561 }
562 }
563 Err(_) => {
564 last_error = Some(anyhow::anyhow!("Operation timed out"));
565 if attempt < self.config.retry_count {
566 tracing::warn!("Operation timed out, will retry");
567 }
568 }
569 }
570 }
571
572 Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Operation failed after retries")))
573 }
574}
575
576#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
578pub struct TmuxSession {
579 pub name: String,
580 pub id: String,
581 pub windows: Vec<TmuxWindow>,
582 pub attached: bool,
583 pub created: String,
584 pub last_attached: Option<String>,
585}
586
587#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
588pub struct TmuxWindow {
589 pub id: String,
590 pub name: String,
591 pub active: bool,
592 pub layout: String,
593 pub panes: Vec<TmuxPane>,
594}
595
596#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
597pub struct TmuxPane {
598 pub id: String,
599 pub active: bool,
600 pub current_path: String,
601 pub current_command: String,
602}
603
604#[derive(Debug, thiserror::Error)]
606pub enum TmuxError {
607 #[error("Tmux server is not running")]
608 ServerNotRunning,
609
610 #[error("Session '{0}' not found")]
611 SessionNotFound(String),
612
613 #[error("Window '{0}' not found")]
614 WindowNotFound(String),
615
616 #[error("Pane '{0}' not found")]
617 PaneNotFound(String),
618
619 #[error("Invalid session name: {0}")]
620 InvalidSessionName(String),
621
622 #[error("Command failed: {0}")]
623 CommandFailed(String),
624
625 #[error("Command timed out after {0:?}")]
626 CommandTimeout(Duration),
627
628 #[error("Server error: {0}")]
629 ServerError(String),
630
631 #[error("IO error: {0}")]
632 Io(#[from] std::io::Error),
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638
639 #[ignore = "requires tmux and can hang in CI"]
641 #[tokio::test]
642 async fn test_session_lifecycle() -> Result<()> {
643 let client = TmuxClient::new().await?;
644
645 client.create_session("test", "/tmp").await?;
647
648 assert!(client.has_session("test").await?);
650
651 client
653 .send_command("test", "echo 'Hello TMux Bridge'")
654 .await?;
655
656 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
658
659 client.send_keys("test", "C-c").await?;
661
662 let output = client.capture_pane("test", None).await?;
664 assert!(output.is_empty() || !output.is_empty());
667
668 client.kill_session("test").await?;
670 assert!(!client.has_session("test").await?);
671
672 Ok(())
673 }
674
675 #[cfg_attr(not(feature = "native-pty-tests"), ignore)]
676 #[tokio::test]
677 async fn test_window_management() -> Result<()> {
678 let client = TmuxClient::new().await?;
679
680 client.create_session("window-test", "/tmp").await?;
682
683 let window_id = client
685 .new_window("window-test", "dev", Some("/home"))
686 .await?;
687 assert!(!window_id.is_empty());
688
689 let windows = client.list_windows("window-test").await?;
691 assert_eq!(windows.len(), 2);
692
693 client.kill_window("window-test", &window_id).await?;
695
696 let windows = client.list_windows("window-test").await?;
697 assert_eq!(windows.len(), 1);
698
699 client.kill_session("window-test").await?;
701
702 Ok(())
703 }
704
705 #[cfg_attr(not(feature = "native-pty-tests"), ignore)]
706 #[tokio::test]
707 async fn test_pane_management() -> Result<()> {
708 let client = TmuxClient::new().await?;
709
710 client.create_session("pane-test", "/tmp").await?;
712
713 let pane_id = client
715 .split_window("pane-test", None, true, Some(50))
716 .await?;
717 assert!(!pane_id.is_empty());
718
719 let panes = client.list_panes("pane-test", None).await?;
721 assert_eq!(panes.len(), 2);
722
723 client.select_pane("pane-test", &pane_id).await?;
725
726 client.kill_session("pane-test").await?;
728
729 Ok(())
730 }
731
732 #[cfg_attr(not(feature = "native-pty-tests"), ignore)]
733 #[tokio::test]
734 async fn test_invalid_session_name() {
735 let client = TmuxClient::new().await.unwrap();
736
737 assert!(client.create_session("test:invalid", "/tmp").await.is_err());
739 assert!(client.create_session("test.invalid", "/tmp").await.is_err());
740 assert!(client.create_session("", "/tmp").await.is_err());
741 }
742
743 #[cfg_attr(not(feature = "native-pty-tests"), ignore)]
744 #[tokio::test]
745 async fn test_session_prefix() -> Result<()> {
746 let config = TmuxConfig {
747 session_prefix: "ccswarm-".to_string(),
748 ..Default::default()
749 };
750
751 let client = TmuxClient::with_config(config).await?;
752
753 client.create_session("frontend", "/tmp").await?;
755
756 assert!(client.has_session("frontend").await?);
758
759 let sessions = client.list_sessions().await?;
761 assert!(sessions.iter().any(|s| s.name == "frontend"));
762
763 client.kill_session("frontend").await?;
765
766 Ok(())
767 }
768}