1use std::{
2 env,
3 path::{Path, PathBuf},
4 process::Command,
5 str::FromStr,
6};
7
8use thiserror::Error;
9
10pub trait TmuxClient {
11 fn capabilities(&self) -> Result<TmuxCapabilities, TmuxError>;
12 fn current_context(&self) -> Result<TmuxContext, TmuxError>;
13 fn list_sessions(&self) -> Result<Vec<TmuxSession>, TmuxError>;
14 fn list_windows(&self) -> Result<Vec<TmuxWindow>, TmuxError>;
15 fn list_panes(&self, target: Option<&str>) -> Result<Vec<TmuxPane>, TmuxError>;
16 fn capture_pane(&self, target: &str) -> Result<String, TmuxError>;
17 fn snapshot(&self, query_windows: bool) -> Result<TmuxSnapshot, TmuxError>;
18 fn ensure_session(&self, session_name: &str, directory: &Path) -> Result<(), TmuxError>;
19 fn switch_or_attach_session(&self, session_name: &str) -> Result<(), TmuxError>;
20 fn rename_session(&self, session_name: &str, new_name: &str) -> Result<(), TmuxError>;
21 fn kill_session(&self, session_name: &str) -> Result<(), TmuxError>;
22 fn create_or_switch_session(
23 &self,
24 session_name: &str,
25 directory: &Path,
26 ) -> Result<(), TmuxError>;
27 fn open_popup(&self, command: &PopupCommand, options: &PopupOptions) -> Result<(), TmuxError>;
28 fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError>;
29 fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError>;
30 fn select_pane(&self, target: &str) -> Result<(), TmuxError>;
31 fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError>;
32 fn status_line_count(&self) -> Result<usize, TmuxError>;
33 fn set_status_line_count(&self, count: usize) -> Result<(), TmuxError>;
34 fn clear_status_line(&self, line: usize) -> Result<(), TmuxError>;
35 fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError>;
36 fn set_hook(&self, hook: &str, command: &str) -> Result<(), TmuxError>;
37 fn clear_hook(&self, hook: &str) -> Result<(), TmuxError>;
38 fn refresh_client_status(&self) -> Result<(), TmuxError>;
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TmuxSnapshot {
43 pub context: TmuxContext,
44 pub capabilities: TmuxCapabilities,
45 pub sessions: Vec<TmuxSession>,
46 pub windows: Vec<TmuxWindow>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
50pub struct TmuxContext {
51 pub client_tty: Option<String>,
52 pub session_name: Option<String>,
53 pub window_index: Option<u32>,
54 pub window_name: Option<String>,
55 pub pane_id: Option<String>,
56 pub inside_tmux: bool,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct TmuxCapabilities {
61 pub version: TmuxVersion,
62 pub supports_popup: bool,
63 pub supports_multi_status_lines: bool,
64 pub supports_status_mouse_ranges: bool,
65 pub mouse_enabled: bool,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct TmuxSession {
70 pub id: String,
71 pub name: String,
72 pub attached: bool,
73 pub windows: usize,
74 pub current: bool,
75 pub last_activity: Option<u64>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct TmuxWindow {
80 pub session_name: String,
81 pub index: u32,
82 pub name: String,
83 pub active: bool,
84 pub activity: bool,
85 pub bell: bool,
86 pub silence: bool,
87 pub current_path: Option<PathBuf>,
88 pub current_command: Option<String>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct TmuxPane {
93 pub session_name: String,
94 pub window_index: u32,
95 pub pane_id: String,
96 pub title: String,
97 pub active: bool,
98 pub current_command: Option<String>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct PopupCommand {
103 pub program: PathBuf,
104 pub args: Vec<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct PopupSpec {
109 pub command: PopupCommand,
110 pub options: PopupOptions,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct PopupOptions {
115 pub width: PopupDimension,
116 pub height: PopupDimension,
117 pub title: Option<String>,
118}
119
120impl Default for PopupOptions {
121 fn default() -> Self {
122 Self {
123 width: PopupDimension::Percent(80),
124 height: PopupDimension::Percent(85),
125 title: None,
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub enum PopupDimension {
132 Percent(u8),
133 Cells(u16),
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum SidebarSide {
138 Left,
139 Right,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct SidebarPaneSpec {
144 pub target: Option<String>,
145 pub side: SidebarSide,
146 pub width: u16,
147 pub title: Option<String>,
148 pub command: PopupCommand,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub enum EventStrategy {
153 PollingFallback,
154 ControlMode,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub enum TmuxCommand {
159 EnsureSession {
160 session_name: String,
161 directory: PathBuf,
162 },
163 SwitchOrAttachSession {
164 session_name: String,
165 },
166 CreateOrSwitchSession {
167 session_name: String,
168 directory: PathBuf,
169 },
170 KillPane {
171 target: Option<String>,
172 },
173 UpdateStatusLine {
174 line: usize,
175 content: String,
176 },
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum TmuxEvent {
181 SnapshotLoaded(TmuxSnapshot),
182 SessionAdded(TmuxSession),
183 SessionRemoved(String),
184 SessionUpdated(TmuxSession),
185 FocusChanged {
186 client_id: String,
187 session_name: String,
188 window_id: String,
189 pane_id: Option<String>,
190 },
191}
192
193impl PopupDimension {
194 #[must_use]
195 pub fn format(&self) -> String {
196 match self {
197 Self::Percent(value) => format!("{value}%"),
198 Self::Cells(value) => value.to_string(),
199 }
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
204pub struct TmuxVersion {
205 pub major: u8,
206 pub minor: u8,
207 pub patch: Option<u8>,
208}
209
210impl TmuxVersion {
211 #[must_use]
212 pub fn supports_popup(self) -> bool {
213 self.major > 3 || (self.major == 3 && self.minor >= 2)
214 }
215
216 #[must_use]
217 pub fn supports_multi_status_lines(self) -> bool {
218 self.major > 2 || (self.major == 2 && self.minor >= 9)
219 }
220
221 #[must_use]
222 pub fn supports_status_mouse_ranges(self) -> bool {
223 self.major > 3 || (self.major == 3 && self.minor >= 4)
224 }
225}
226
227impl FromStr for TmuxVersion {
228 type Err = TmuxError;
229
230 fn from_str(value: &str) -> Result<Self, Self::Err> {
231 let version = value
232 .strip_prefix("tmux ")
233 .ok_or_else(|| TmuxError::Parse {
234 context: "tmux version",
235 message: format!("unexpected version string `{value}`"),
236 })?;
237
238 let digits = version
239 .chars()
240 .take_while(|character| character.is_ascii_digit() || *character == '.')
241 .collect::<String>();
242 let mut parts = digits.split('.');
243 let major = parts
244 .next()
245 .ok_or_else(|| TmuxError::Parse {
246 context: "tmux version",
247 message: "missing major version".to_string(),
248 })?
249 .parse::<u8>()
250 .map_err(|_| TmuxError::Parse {
251 context: "tmux version",
252 message: format!("invalid major version in `{value}`"),
253 })?;
254 let minor = parts
255 .next()
256 .unwrap_or("0")
257 .parse::<u8>()
258 .map_err(|_| TmuxError::Parse {
259 context: "tmux version",
260 message: format!("invalid minor version in `{value}`"),
261 })?;
262 let patch = match parts.next() {
263 Some(raw_patch) => Some(raw_patch.parse::<u8>().map_err(|_| TmuxError::Parse {
264 context: "tmux version",
265 message: format!("invalid patch version in `{value}`"),
266 })?),
267 None => None,
268 };
269
270 Ok(Self {
271 major,
272 minor,
273 patch,
274 })
275 }
276}
277
278#[derive(Debug, Error)]
279pub enum TmuxError {
280 #[error("tmux is unavailable: {message}")]
281 Unavailable { message: String },
282 #[error("tmux command failed: {command:?} (status {status:?}): {stderr}")]
283 CommandFailed {
284 command: Vec<String>,
285 status: Option<i32>,
286 stderr: String,
287 },
288 #[error("failed to parse {context}: {message}")]
289 Parse {
290 context: &'static str,
291 message: String,
292 },
293 #[error("popup mode is unavailable on tmux {version}")]
294 PopupUnavailable { version: String },
295}
296
297#[derive(Debug, Clone)]
298pub struct CommandTmuxClient {
299 binary: PathBuf,
300 socket_name: Option<String>,
301 config_file: Option<PathBuf>,
302 inside_tmux: bool,
303}
304
305impl Default for CommandTmuxClient {
306 fn default() -> Self {
307 Self::new()
308 }
309}
310
311impl CommandTmuxClient {
312 #[must_use]
313 pub fn new() -> Self {
314 Self {
315 binary: PathBuf::from("tmux"),
316 socket_name: None,
317 config_file: None,
318 inside_tmux: env::var_os("TMUX").is_some(),
319 }
320 }
321
322 #[must_use]
323 pub fn with_binary(mut self, binary: impl Into<PathBuf>) -> Self {
324 self.binary = binary.into();
325 self
326 }
327
328 #[must_use]
329 pub fn with_socket_name(mut self, socket_name: impl Into<String>) -> Self {
330 self.socket_name = Some(socket_name.into());
331 self
332 }
333
334 #[must_use]
335 pub fn with_config_file(mut self, config_file: impl Into<PathBuf>) -> Self {
336 self.config_file = Some(config_file.into());
337 self
338 }
339
340 #[must_use]
341 pub fn with_inside_tmux(mut self, inside_tmux: bool) -> Self {
342 self.inside_tmux = inside_tmux;
343 self
344 }
345
346 fn run_tmux(&self, args: Vec<String>) -> Result<String, TmuxError> {
347 let command_line = self.command_line(&args);
348 let mut command = Command::new(&self.binary);
349 if let Some(socket_name) = &self.socket_name {
350 command.arg("-L").arg(socket_name);
351 }
352 if let Some(config_file) = &self.config_file {
353 command.arg("-f").arg(config_file);
354 }
355 command.args(&args);
356
357 let output = command.output().map_err(|source| {
358 if source.kind() == std::io::ErrorKind::NotFound {
359 TmuxError::Unavailable {
360 message: source.to_string(),
361 }
362 } else {
363 TmuxError::CommandFailed {
364 command: command_line.clone(),
365 status: None,
366 stderr: source.to_string(),
367 }
368 }
369 })?;
370
371 if output.status.success() {
372 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
373 } else {
374 Err(TmuxError::CommandFailed {
375 command: command_line,
376 status: output.status.code(),
377 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
378 })
379 }
380 }
381
382 fn command_line(&self, args: &[String]) -> Vec<String> {
383 let mut command = vec![self.binary.display().to_string()];
384 if let Some(socket_name) = &self.socket_name {
385 command.push("-L".to_string());
386 command.push(socket_name.clone());
387 }
388 if let Some(config_file) = &self.config_file {
389 command.push("-f".to_string());
390 command.push(config_file.display().to_string());
391 }
392 command.extend(args.iter().cloned());
393 command
394 }
395}
396
397impl TmuxClient for CommandTmuxClient {
398 fn capabilities(&self) -> Result<TmuxCapabilities, TmuxError> {
399 let output = self.run_tmux(vec!["-V".to_string()])?;
400 let version = output.parse::<TmuxVersion>()?;
401 let mouse_enabled = match self.run_tmux(vec![
402 "show-option".to_string(),
403 "-gv".to_string(),
404 "mouse".to_string(),
405 ]) {
406 Ok(output) => parse_tmux_bool(&output, "mouse option")?,
407 Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => false,
408 Err(error) => return Err(error),
409 };
410
411 Ok(TmuxCapabilities {
412 version,
413 supports_popup: version.supports_popup(),
414 supports_multi_status_lines: version.supports_multi_status_lines(),
415 supports_status_mouse_ranges: version.supports_status_mouse_ranges(),
416 mouse_enabled,
417 })
418 }
419
420 fn current_context(&self) -> Result<TmuxContext, TmuxError> {
421 let output = match self.run_tmux(vec![
422 "display-message".to_string(),
423 "-p".to_string(),
424 "#{client_tty}\t#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}"
425 .to_string(),
426 ]) {
427 Ok(output) => output,
428 Err(TmuxError::CommandFailed { stderr, .. })
429 if is_no_current_client_error(&stderr) || is_no_server_error(&stderr) =>
430 {
431 return Ok(TmuxContext {
432 inside_tmux: self.inside_tmux,
433 ..TmuxContext::default()
434 });
435 }
436 Err(error) => return Err(error),
437 };
438
439 let mut fields = output.split('\t');
440 let client_tty = empty_to_none(fields.next());
441 let session_name = empty_to_none(fields.next());
442 let window_index = fields.next().and_then(|field| field.parse::<u32>().ok());
443 let window_name = empty_to_none(fields.next());
444 let pane_id = empty_to_none(fields.next());
445
446 Ok(TmuxContext {
447 client_tty,
448 session_name,
449 window_index,
450 window_name,
451 pane_id,
452 inside_tmux: self.inside_tmux,
453 })
454 }
455
456 fn list_sessions(&self) -> Result<Vec<TmuxSession>, TmuxError> {
457 let output = match self.run_tmux(vec![
458 "list-sessions".to_string(),
459 "-F".to_string(),
460 "#{session_id}\t#{session_name}\t#{session_attached}\t#{session_windows}\t#{session_activity}".to_string(),
461 ]) {
462 Ok(output) => output,
463 Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
464 return Ok(Vec::new());
465 }
466 Err(error) => return Err(error),
467 };
468 let context = self.current_context()?;
469
470 parse_sessions(&output, context.session_name.as_deref())
471 }
472
473 fn list_windows(&self) -> Result<Vec<TmuxWindow>, TmuxError> {
474 let output = match self.run_tmux(vec![
475 "list-windows".to_string(),
476 "-a".to_string(),
477 "-F".to_string(),
478 "#{session_name}\t#{window_index}\t#{window_name}\t#{window_active}\t#{window_activity_flag}\t#{window_bell_flag}\t#{window_silence_flag}\t#{pane_current_path}\t#{pane_current_command}".to_string(),
479 ]) {
480 Ok(output) => output,
481 Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
482 return Ok(Vec::new());
483 }
484 Err(error) => return Err(error),
485 };
486
487 parse_windows(&output)
488 }
489
490 fn list_panes(&self, target: Option<&str>) -> Result<Vec<TmuxPane>, TmuxError> {
491 let mut args = vec![
492 "list-panes".to_string(),
493 "-F".to_string(),
494 "#{session_name}\t#{window_index}\t#{pane_id}\t#{pane_title}\t#{pane_active}\t#{pane_current_command}".to_string(),
495 ];
496 if let Some(target) = target {
497 args.push("-t".to_string());
498 args.push(target.to_string());
499 } else {
500 args.push("-a".to_string());
501 }
502
503 let output = match self.run_tmux(args) {
504 Ok(output) => output,
505 Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
506 return Ok(Vec::new());
507 }
508 Err(error) => return Err(error),
509 };
510
511 parse_panes(&output)
512 }
513
514 fn capture_pane(&self, target: &str) -> Result<String, TmuxError> {
515 self.run_tmux(vec![
516 "capture-pane".to_string(),
517 "-p".to_string(),
518 "-e".to_string(),
519 "-t".to_string(),
520 target.to_string(),
521 ])
522 }
523
524 fn snapshot(&self, query_windows: bool) -> Result<TmuxSnapshot, TmuxError> {
525 let capabilities = self.capabilities()?;
526 let context = self.current_context()?;
527 let sessions = self.list_sessions()?;
528 let windows = if query_windows {
529 self.list_windows()?
530 } else {
531 Vec::new()
532 };
533
534 Ok(TmuxSnapshot {
535 context,
536 capabilities,
537 sessions,
538 windows,
539 })
540 }
541
542 fn ensure_session(&self, session_name: &str, directory: &Path) -> Result<(), TmuxError> {
543 match self.run_tmux(vec![
544 "new-session".to_string(),
545 "-Ad".to_string(),
546 "-s".to_string(),
547 session_name.to_string(),
548 "-c".to_string(),
549 directory.display().to_string(),
550 ]) {
551 Ok(_) => Ok(()),
552 Err(TmuxError::CommandFailed { stderr, .. })
553 if stderr.contains("duplicate session") =>
554 {
555 Ok(())
556 }
557 Err(error) => Err(error),
558 }
559 }
560
561 fn switch_or_attach_session(&self, session_name: &str) -> Result<(), TmuxError> {
562 self.run_tmux(focus_session_command(session_name, self.inside_tmux))
563 .map(|_| ())
564 }
565
566 fn rename_session(&self, session_name: &str, new_name: &str) -> Result<(), TmuxError> {
567 self.run_tmux(vec![
568 "rename-session".to_string(),
569 "-t".to_string(),
570 session_name.to_string(),
571 new_name.to_string(),
572 ])
573 .map(|_| ())
574 }
575
576 fn create_or_switch_session(
577 &self,
578 session_name: &str,
579 directory: &Path,
580 ) -> Result<(), TmuxError> {
581 self.ensure_session(session_name, directory)?;
582 self.switch_or_attach_session(session_name)
583 }
584
585 fn kill_session(&self, session_name: &str) -> Result<(), TmuxError> {
586 self.run_tmux(vec![
587 "kill-session".to_string(),
588 "-t".to_string(),
589 session_name.to_string(),
590 ])
591 .map(|_| ())
592 }
593
594 fn open_popup(&self, command: &PopupCommand, options: &PopupOptions) -> Result<(), TmuxError> {
595 let capabilities = self.capabilities()?;
596 if !capabilities.supports_popup {
597 return Err(TmuxError::PopupUnavailable {
598 version: format!(
599 "{}.{}",
600 capabilities.version.major, capabilities.version.minor
601 ),
602 });
603 }
604
605 let mut args = vec![
606 "display-popup".to_string(),
607 "-E".to_string(),
608 "-w".to_string(),
609 options.width.format(),
610 "-h".to_string(),
611 options.height.format(),
612 ];
613 if let Some(title) = &options.title {
614 args.push("-T".to_string());
615 args.push(title.clone());
616 }
617 args.push(format_popup_command(command));
618
619 self.run_tmux(args).map(|_| ())
620 }
621
622 fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError> {
623 let pane_id = self.run_tmux(sidebar_pane_command(spec))?;
624 if let Some(title) = &spec.title {
625 self.run_tmux(select_pane_title_command(&pane_id, title))
626 .map(|_| ())?;
627 }
628 Ok(pane_id)
629 }
630
631 fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError> {
632 let mut args = vec!["kill-pane".to_string()];
633 if let Some(target) = target {
634 args.push("-t".to_string());
635 args.push(target.to_string());
636 }
637 self.run_tmux(args).map(|_| ())
638 }
639
640 fn select_pane(&self, target: &str) -> Result<(), TmuxError> {
641 self.run_tmux(select_pane_command(target)).map(|_| ())
642 }
643
644 fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError> {
645 self.run_tmux(resize_pane_width_command(target, width))
646 .map(|_| ())
647 }
648
649 fn status_line_count(&self) -> Result<usize, TmuxError> {
650 let output = match self.run_tmux(vec![
651 "show-option".to_string(),
652 "-gv".to_string(),
653 "status".to_string(),
654 ]) {
655 Ok(output) => output,
656 Err(TmuxError::CommandFailed { stderr, .. }) if is_no_server_error(&stderr) => {
657 return Ok(1);
658 }
659 Err(error) => return Err(error),
660 };
661 parse_status_line_count(&output)
662 }
663
664 fn set_status_line_count(&self, count: usize) -> Result<(), TmuxError> {
665 self.run_tmux(status_line_count_command(count)).map(|_| ())
666 }
667
668 fn clear_status_line(&self, line: usize) -> Result<(), TmuxError> {
669 self.run_tmux(clear_status_line_command(line)).map(|_| ())
670 }
671
672 fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError> {
673 self.run_tmux(status_line_command(line, content))
674 .map(|_| ())
675 }
676
677 fn set_hook(&self, hook: &str, command: &str) -> Result<(), TmuxError> {
678 self.run_tmux(set_hook_command(hook, command)).map(|_| ())
679 }
680
681 fn clear_hook(&self, hook: &str) -> Result<(), TmuxError> {
682 self.run_tmux(clear_hook_command(hook)).map(|_| ())
683 }
684
685 fn refresh_client_status(&self) -> Result<(), TmuxError> {
686 self.run_tmux(refresh_client_status_command()).map(|_| ())
687 }
688}
689
690#[must_use]
691pub fn focus_session_command(session_name: &str, inside_tmux: bool) -> Vec<String> {
692 if inside_tmux {
693 vec![
694 "switch-client".to_string(),
695 "-t".to_string(),
696 session_name.to_string(),
697 ]
698 } else {
699 vec![
700 "attach-session".to_string(),
701 "-t".to_string(),
702 session_name.to_string(),
703 ]
704 }
705}
706
707#[must_use]
708pub fn format_popup_command(command: &PopupCommand) -> String {
709 let mut parts = Vec::with_capacity(1 + command.args.len());
710 parts.push(shell_escape(&command.program.display().to_string()));
711 parts.extend(command.args.iter().map(|arg| shell_escape(arg)));
712 parts.join(" ")
713}
714
715#[must_use]
716pub fn sidebar_pane_command(spec: &SidebarPaneSpec) -> Vec<String> {
717 let mut args = vec![
718 "split-window".to_string(),
719 "-d".to_string(),
720 "-h".to_string(),
721 "-P".to_string(),
722 "-F".to_string(),
723 "#{pane_id}".to_string(),
724 ];
725 if matches!(spec.side, SidebarSide::Left) {
726 args.push("-b".to_string());
727 }
728 if let Some(target) = &spec.target {
729 args.push("-t".to_string());
730 args.push(target.clone());
731 }
732 args.push("-l".to_string());
733 args.push(spec.width.to_string());
734 args.push(format_popup_command(&spec.command));
735 args
736}
737
738#[must_use]
739pub fn select_pane_command(target: &str) -> Vec<String> {
740 vec![
741 "select-pane".to_string(),
742 "-t".to_string(),
743 target.to_string(),
744 ]
745}
746
747#[must_use]
748pub fn select_pane_title_command(target: &str, title: &str) -> Vec<String> {
749 vec![
750 "select-pane".to_string(),
751 "-T".to_string(),
752 title.to_string(),
753 "-t".to_string(),
754 target.to_string(),
755 ]
756}
757
758#[must_use]
759pub fn resize_pane_width_command(target: &str, width: u16) -> Vec<String> {
760 vec![
761 "resize-pane".to_string(),
762 "-x".to_string(),
763 width.to_string(),
764 "-t".to_string(),
765 target.to_string(),
766 ]
767}
768
769#[must_use]
770pub fn status_line_command(line: usize, content: &str) -> Vec<String> {
771 let slot = line.saturating_sub(1);
772 vec![
773 "set-option".to_string(),
774 "-gq".to_string(),
775 format!("status-format[{slot}]"),
776 content.to_string(),
777 ]
778}
779
780#[must_use]
781pub fn clear_status_line_command(line: usize) -> Vec<String> {
782 let slot = line.saturating_sub(1);
783 vec![
784 "set-option".to_string(),
785 "-guq".to_string(),
786 format!("status-format[{slot}]"),
787 ]
788}
789
790#[must_use]
791pub fn status_line_count_command(count: usize) -> Vec<String> {
792 vec![
793 "set-option".to_string(),
794 "-gq".to_string(),
795 "status".to_string(),
796 count.to_string(),
797 ]
798}
799
800#[must_use]
801pub fn set_hook_command(hook: &str, command: &str) -> Vec<String> {
802 vec![
803 "set-hook".to_string(),
804 "-g".to_string(),
805 hook.to_string(),
806 command.to_string(),
807 ]
808}
809
810#[must_use]
811pub fn clear_hook_command(hook: &str) -> Vec<String> {
812 vec!["set-hook".to_string(), "-gu".to_string(), hook.to_string()]
813}
814
815#[must_use]
816pub fn refresh_client_status_command() -> Vec<String> {
817 vec!["refresh-client".to_string(), "-S".to_string()]
818}
819
820pub trait TmuxBackend {
821 fn event_strategy(&self) -> EventStrategy;
822 fn snapshot(&self) -> Result<TmuxSnapshot, TmuxError>;
823 fn poll_events(&mut self) -> Result<Vec<TmuxEvent>, TmuxError>;
824 fn send(&self, command: TmuxCommand) -> Result<(), TmuxError>;
825 fn open_popup(&self, spec: &PopupSpec) -> Result<(), TmuxError>;
826 fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError>;
827 fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError>;
828 fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError>;
829 fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError>;
830}
831
832#[derive(Debug, Clone)]
833pub struct PollingTmuxBackend {
834 client: CommandTmuxClient,
835 query_windows: bool,
836 previous_snapshot: Option<TmuxSnapshot>,
837}
838
839impl PollingTmuxBackend {
840 #[must_use]
841 pub fn new(client: CommandTmuxClient) -> Self {
842 Self {
843 client,
844 query_windows: true,
845 previous_snapshot: None,
846 }
847 }
848
849 #[must_use]
850 pub fn with_windows(mut self, query_windows: bool) -> Self {
851 self.query_windows = query_windows;
852 self
853 }
854}
855
856impl TmuxBackend for PollingTmuxBackend {
857 fn event_strategy(&self) -> EventStrategy {
858 EventStrategy::PollingFallback
859 }
860
861 fn snapshot(&self) -> Result<TmuxSnapshot, TmuxError> {
862 self.client.snapshot(self.query_windows)
863 }
864
865 fn poll_events(&mut self) -> Result<Vec<TmuxEvent>, TmuxError> {
866 let snapshot = self.snapshot()?;
867 let events = match &self.previous_snapshot {
868 Some(previous) => diff_snapshots(previous, &snapshot),
869 None => vec![TmuxEvent::SnapshotLoaded(snapshot.clone())],
870 };
871 self.previous_snapshot = Some(snapshot);
872 Ok(events)
873 }
874
875 fn send(&self, command: TmuxCommand) -> Result<(), TmuxError> {
876 match command {
877 TmuxCommand::EnsureSession {
878 session_name,
879 directory,
880 } => self.client.ensure_session(&session_name, &directory),
881 TmuxCommand::SwitchOrAttachSession { session_name } => {
882 self.client.switch_or_attach_session(&session_name)
883 }
884 TmuxCommand::CreateOrSwitchSession {
885 session_name,
886 directory,
887 } => self
888 .client
889 .create_or_switch_session(&session_name, &directory),
890 TmuxCommand::KillPane { target } => self.client.close_sidebar_pane(target.as_deref()),
891 TmuxCommand::UpdateStatusLine { line, content } => {
892 self.client.update_status_line(line, &content)
893 }
894 }
895 }
896
897 fn open_popup(&self, spec: &PopupSpec) -> Result<(), TmuxError> {
898 self.client.open_popup(&spec.command, &spec.options)
899 }
900
901 fn open_sidebar_pane(&self, spec: &SidebarPaneSpec) -> Result<String, TmuxError> {
902 self.client.open_sidebar_pane(spec)
903 }
904
905 fn close_sidebar_pane(&self, target: Option<&str>) -> Result<(), TmuxError> {
906 self.client.close_sidebar_pane(target)
907 }
908
909 fn resize_pane_width(&self, target: &str, width: u16) -> Result<(), TmuxError> {
910 self.client.resize_pane_width(target, width)
911 }
912
913 fn update_status_line(&self, line: usize, content: &str) -> Result<(), TmuxError> {
914 self.client.update_status_line(line, content)
915 }
916}
917
918#[must_use]
919pub fn diff_snapshots(previous: &TmuxSnapshot, next: &TmuxSnapshot) -> Vec<TmuxEvent> {
920 let mut events = Vec::new();
921
922 for next_session in &next.sessions {
923 match previous
924 .sessions
925 .iter()
926 .find(|session| session.name == next_session.name)
927 {
928 None => events.push(TmuxEvent::SessionAdded(next_session.clone())),
929 Some(previous_session) if previous_session != next_session => {
930 events.push(TmuxEvent::SessionUpdated(next_session.clone()));
931 }
932 Some(_) => {}
933 }
934 }
935
936 for previous_session in &previous.sessions {
937 if next
938 .sessions
939 .iter()
940 .all(|session| session.name != previous_session.name)
941 {
942 events.push(TmuxEvent::SessionRemoved(previous_session.name.clone()));
943 }
944 }
945
946 if (previous.context.session_name != next.context.session_name
947 || previous.context.window_index != next.context.window_index
948 || previous.context.pane_id != next.context.pane_id)
949 && let (Some(session_name), Some(window_index)) =
950 (next.context.session_name.clone(), next.context.window_index)
951 {
952 events.push(TmuxEvent::FocusChanged {
953 client_id: next
954 .context
955 .client_tty
956 .clone()
957 .unwrap_or_else(|| "default".to_string()),
958 window_id: format!("{session_name}:{window_index}"),
959 session_name,
960 pane_id: next.context.pane_id.clone(),
961 });
962 }
963
964 events
965}
966
967fn shell_escape(value: &str) -> String {
968 if value.is_empty() {
969 return "''".to_string();
970 }
971
972 let escaped = value.replace('\'', "'\"'\"'");
973 format!("'{escaped}'")
974}
975
976fn parse_sessions(
977 output: &str,
978 current_session: Option<&str>,
979) -> Result<Vec<TmuxSession>, TmuxError> {
980 output
981 .lines()
982 .filter(|line| !line.trim().is_empty())
983 .map(|line| {
984 let mut fields = line.split('\t');
985 let id = required_field(fields.next(), "session id", line)?;
986 let name = required_field(fields.next(), "session name", line)?;
987 let attached =
988 parse_numeric_bool(required_field(fields.next(), "session attached", line)?)?;
989 let windows = required_field(fields.next(), "session windows", line)?
990 .parse::<usize>()
991 .map_err(|_| TmuxError::Parse {
992 context: "tmux sessions",
993 message: format!("invalid window count in `{line}`"),
994 })?;
995 let last_activity = empty_to_none(fields.next())
996 .map(|raw| {
997 raw.parse::<u64>().map_err(|_| TmuxError::Parse {
998 context: "tmux sessions",
999 message: format!("invalid session activity in `{line}`"),
1000 })
1001 })
1002 .transpose()?;
1003
1004 Ok(TmuxSession {
1005 id: id.to_string(),
1006 current: current_session.is_some_and(|current| current == name),
1007 name: name.to_string(),
1008 attached,
1009 windows,
1010 last_activity,
1011 })
1012 })
1013 .collect()
1014}
1015
1016fn parse_windows(output: &str) -> Result<Vec<TmuxWindow>, TmuxError> {
1017 output
1018 .lines()
1019 .filter(|line| !line.trim().is_empty())
1020 .map(|line| {
1021 let mut fields = line.split('\t');
1022 let session_name = required_field(fields.next(), "window session", line)?;
1023 let index = required_field(fields.next(), "window index", line)?
1024 .parse::<u32>()
1025 .map_err(|_| TmuxError::Parse {
1026 context: "tmux windows",
1027 message: format!("invalid window index in `{line}`"),
1028 })?;
1029 let name = required_field(fields.next(), "window name", line)?;
1030 let active = parse_numeric_bool(required_field(fields.next(), "window active", line)?)?;
1031 let activity =
1032 parse_numeric_bool(required_field(fields.next(), "window activity", line)?)?;
1033 let bell = parse_numeric_bool(required_field(fields.next(), "window bell", line)?)?;
1034 let silence =
1035 parse_numeric_bool(required_field(fields.next(), "window silence", line)?)?;
1036 let current_path = empty_to_none(fields.next()).map(PathBuf::from);
1037 let current_command = empty_to_none(fields.next());
1038
1039 Ok(TmuxWindow {
1040 session_name: session_name.to_string(),
1041 index,
1042 name: name.to_string(),
1043 active,
1044 activity,
1045 bell,
1046 silence,
1047 current_path,
1048 current_command,
1049 })
1050 })
1051 .collect()
1052}
1053
1054fn parse_panes(output: &str) -> Result<Vec<TmuxPane>, TmuxError> {
1055 output
1056 .lines()
1057 .filter(|line| !line.trim().is_empty())
1058 .map(|line| {
1059 let mut fields = line.split('\t');
1060 let session_name = required_field(fields.next(), "pane session", line)?;
1061 let window_index = required_field(fields.next(), "pane window index", line)?
1062 .parse::<u32>()
1063 .map_err(|_| TmuxError::Parse {
1064 context: "tmux panes",
1065 message: format!("invalid window index in `{line}`"),
1066 })?;
1067 let pane_id = required_field(fields.next(), "pane id", line)?;
1068 let title = required_field(fields.next(), "pane title", line)?;
1069 let active = parse_numeric_bool(required_field(fields.next(), "pane active", line)?)?;
1070 let current_command = empty_to_none(fields.next());
1071
1072 Ok(TmuxPane {
1073 session_name: session_name.to_string(),
1074 window_index,
1075 pane_id: pane_id.to_string(),
1076 title: title.to_string(),
1077 active,
1078 current_command,
1079 })
1080 })
1081 .collect()
1082}
1083
1084fn parse_numeric_bool(value: &str) -> Result<bool, TmuxError> {
1085 value
1086 .parse::<u8>()
1087 .map(|parsed| parsed > 0)
1088 .map_err(|_| TmuxError::Parse {
1089 context: "tmux output",
1090 message: format!("expected numeric boolean, got `{value}`"),
1091 })
1092}
1093
1094fn parse_tmux_bool(value: &str, context: &'static str) -> Result<bool, TmuxError> {
1095 match value.trim() {
1096 "on" => Ok(true),
1097 "off" => Ok(false),
1098 other => Err(TmuxError::Parse {
1099 context,
1100 message: format!("expected `on` or `off`, got `{other}`"),
1101 }),
1102 }
1103}
1104
1105fn parse_status_line_count(value: &str) -> Result<usize, TmuxError> {
1106 match value.trim() {
1107 "off" => Ok(0),
1108 "on" => Ok(1),
1109 other => other.parse::<usize>().map_err(|_| TmuxError::Parse {
1110 context: "status option",
1111 message: format!("expected `on`, `off`, or a number, got `{other}`"),
1112 }),
1113 }
1114}
1115
1116fn required_field<'line>(
1117 value: Option<&'line str>,
1118 field: &'static str,
1119 line: &'line str,
1120) -> Result<&'line str, TmuxError> {
1121 value.ok_or_else(|| TmuxError::Parse {
1122 context: "tmux output",
1123 message: format!("missing {field} in `{line}`"),
1124 })
1125}
1126
1127fn empty_to_none(value: Option<&str>) -> Option<String> {
1128 value
1129 .map(str::trim)
1130 .filter(|field| !field.is_empty())
1131 .map(ToOwned::to_owned)
1132}
1133
1134fn is_no_current_client_error(stderr: &str) -> bool {
1135 stderr.contains("no current client") || stderr.contains("no current target")
1136}
1137
1138fn is_no_server_error(stderr: &str) -> bool {
1139 stderr.contains("no server running")
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144 use std::path::PathBuf;
1145
1146 use crate::{
1147 EventStrategy, PollingTmuxBackend, PopupCommand, PopupDimension, SidebarPaneSpec,
1148 SidebarSide, TmuxBackend, TmuxContext, TmuxEvent, TmuxPane, TmuxSession, TmuxSnapshot,
1149 TmuxVersion, TmuxWindow, clear_hook_command, clear_status_line_command, diff_snapshots,
1150 focus_session_command, format_popup_command, parse_panes, parse_sessions,
1151 parse_status_line_count, parse_windows, refresh_client_status_command,
1152 resize_pane_width_command, select_pane_command, select_pane_title_command,
1153 set_hook_command, sidebar_pane_command, status_line_command, status_line_count_command,
1154 };
1155
1156 #[test]
1157 fn parses_tmux_versions_with_suffixes() {
1158 let version = "tmux 3.6a"
1159 .parse::<TmuxVersion>()
1160 .expect("version should parse");
1161
1162 assert_eq!(version.major, 3);
1163 assert_eq!(version.minor, 6);
1164 assert!(version.supports_popup());
1165 assert!(version.supports_status_mouse_ranges());
1166 }
1167
1168 #[test]
1169 fn selects_attach_or_switch_command_by_context() {
1170 assert_eq!(
1171 focus_session_command("work", false),
1172 vec!["attach-session", "-t", "work"]
1173 );
1174 assert_eq!(
1175 focus_session_command("work", true),
1176 vec!["switch-client", "-t", "work"]
1177 );
1178 }
1179
1180 #[test]
1181 fn shell_quotes_popup_commands() {
1182 let command = PopupCommand {
1183 program: PathBuf::from("/tmp/wisp"),
1184 args: vec!["popup".to_string(), "quote's test".to_string()],
1185 };
1186
1187 assert_eq!(
1188 format_popup_command(&command),
1189 "'/tmp/wisp' 'popup' 'quote'\"'\"'s test'"
1190 );
1191 }
1192
1193 #[test]
1194 fn formats_popup_dimensions() {
1195 assert_eq!(PopupDimension::Percent(80).format(), "80%");
1196 assert_eq!(PopupDimension::Cells(40).format(), "40");
1197 }
1198
1199 #[test]
1200 fn builds_sidebar_pane_commands() {
1201 let command = PopupCommand {
1202 program: PathBuf::from("/tmp/wisp"),
1203 args: vec!["sidebar".to_string()],
1204 };
1205
1206 let args = sidebar_pane_command(&SidebarPaneSpec {
1207 target: Some("alpha:1".to_string()),
1208 side: SidebarSide::Left,
1209 width: 36,
1210 title: Some("Wisp Sidebar".to_string()),
1211 command,
1212 });
1213
1214 assert_eq!(
1215 args,
1216 vec![
1217 "split-window",
1218 "-d",
1219 "-h",
1220 "-P",
1221 "-F",
1222 "#{pane_id}",
1223 "-b",
1224 "-t",
1225 "alpha:1",
1226 "-l",
1227 "36",
1228 "'/tmp/wisp' 'sidebar'",
1229 ]
1230 );
1231 }
1232
1233 #[test]
1234 fn parses_tmux_panes() {
1235 let panes =
1236 parse_panes("alpha\t1\t%7\tWisp Sidebar\t1\twisp\n").expect("panes should parse");
1237
1238 assert_eq!(
1239 panes,
1240 vec![TmuxPane {
1241 session_name: "alpha".to_string(),
1242 window_index: 1,
1243 pane_id: "%7".to_string(),
1244 title: "Wisp Sidebar".to_string(),
1245 active: true,
1246 current_command: Some("wisp".to_string()),
1247 }]
1248 );
1249 }
1250
1251 #[test]
1252 fn parses_tmux_windows_with_alert_flags() {
1253 let windows = parse_windows("alpha\t1\tshell\t1\t1\t0\t1\t/tmp\tbash\n")
1254 .expect("windows should parse");
1255
1256 assert_eq!(
1257 windows,
1258 vec![TmuxWindow {
1259 session_name: "alpha".to_string(),
1260 index: 1,
1261 name: "shell".to_string(),
1262 active: true,
1263 activity: true,
1264 bell: false,
1265 silence: true,
1266 current_path: Some(PathBuf::from("/tmp")),
1267 current_command: Some("bash".to_string()),
1268 }]
1269 );
1270 }
1271
1272 #[test]
1273 fn parses_tmux_sessions_with_tmux_ids() {
1274 let sessions =
1275 parse_sessions("$1\talpha\t1\t2\t42\n", Some("alpha")).expect("sessions should parse");
1276
1277 assert_eq!(
1278 sessions,
1279 vec![TmuxSession {
1280 id: "$1".to_string(),
1281 name: "alpha".to_string(),
1282 attached: true,
1283 windows: 2,
1284 current: true,
1285 last_activity: Some(42),
1286 }]
1287 );
1288 }
1289
1290 #[test]
1291 fn builds_select_pane_commands() {
1292 assert_eq!(select_pane_command("%3"), vec!["select-pane", "-t", "%3"]);
1293 assert_eq!(
1294 select_pane_title_command("%3", "Wisp Sidebar"),
1295 vec!["select-pane", "-T", "Wisp Sidebar", "-t", "%3"]
1296 );
1297 assert_eq!(
1298 resize_pane_width_command("%3", 36),
1299 vec!["resize-pane", "-x", "36", "-t", "%3"]
1300 );
1301 }
1302
1303 #[test]
1304 fn builds_status_line_option_updates() {
1305 assert_eq!(
1306 status_line_command(2, "Wisp main"),
1307 vec!["set-option", "-gq", "status-format[1]", "Wisp main"]
1308 );
1309 assert_eq!(
1310 clear_status_line_command(2),
1311 vec!["set-option", "-guq", "status-format[1]"]
1312 );
1313 assert_eq!(
1314 status_line_count_command(2),
1315 vec!["set-option", "-gq", "status", "2"]
1316 );
1317 assert_eq!(
1318 set_hook_command("client-session-changed[99]", "refresh-client -S"),
1319 vec![
1320 "set-hook",
1321 "-g",
1322 "client-session-changed[99]",
1323 "refresh-client -S"
1324 ]
1325 );
1326 assert_eq!(
1327 clear_hook_command("client-session-changed[99]"),
1328 vec!["set-hook", "-gu", "client-session-changed[99]"]
1329 );
1330 assert_eq!(
1331 refresh_client_status_command(),
1332 vec!["refresh-client", "-S"]
1333 );
1334 }
1335
1336 #[test]
1337 fn parses_status_line_counts() {
1338 assert_eq!(parse_status_line_count("off").expect("off count"), 0);
1339 assert_eq!(parse_status_line_count("on").expect("on count"), 1);
1340 assert_eq!(parse_status_line_count("3").expect("numeric count"), 3);
1341 }
1342
1343 #[test]
1344 fn diffs_snapshots_into_events() {
1345 let previous = TmuxSnapshot {
1346 context: TmuxContext::default(),
1347 capabilities: crate::TmuxCapabilities {
1348 version: TmuxVersion {
1349 major: 3,
1350 minor: 6,
1351 patch: None,
1352 },
1353 supports_popup: true,
1354 supports_multi_status_lines: true,
1355 supports_status_mouse_ranges: true,
1356 mouse_enabled: true,
1357 },
1358 sessions: vec![TmuxSession {
1359 id: "$1".to_string(),
1360 name: "alpha".to_string(),
1361 attached: false,
1362 windows: 1,
1363 current: false,
1364 last_activity: Some(1),
1365 }],
1366 windows: Vec::new(),
1367 };
1368 let next = TmuxSnapshot {
1369 context: TmuxContext {
1370 client_tty: Some("tty1".to_string()),
1371 session_name: Some("beta".to_string()),
1372 window_index: Some(1),
1373 window_name: Some("shell".to_string()),
1374 pane_id: Some("%1".to_string()),
1375 inside_tmux: true,
1376 },
1377 capabilities: previous.capabilities.clone(),
1378 sessions: vec![
1379 TmuxSession {
1380 id: "$1".to_string(),
1381 name: "alpha".to_string(),
1382 attached: true,
1383 windows: 2,
1384 current: false,
1385 last_activity: Some(2),
1386 },
1387 TmuxSession {
1388 id: "$2".to_string(),
1389 name: "beta".to_string(),
1390 attached: false,
1391 windows: 1,
1392 current: true,
1393 last_activity: Some(3),
1394 },
1395 ],
1396 windows: vec![TmuxWindow {
1397 session_name: "beta".to_string(),
1398 index: 1,
1399 name: "shell".to_string(),
1400 active: true,
1401 activity: false,
1402 bell: false,
1403 silence: false,
1404 current_path: None,
1405 current_command: None,
1406 }],
1407 };
1408
1409 let events = diff_snapshots(&previous, &next);
1410
1411 assert!(events.iter().any(
1412 |event| matches!(event, TmuxEvent::SessionAdded(session) if session.name == "beta")
1413 ));
1414 assert!(events.iter().any(
1415 |event| matches!(event, TmuxEvent::SessionUpdated(session) if session.name == "alpha")
1416 ));
1417 assert!(events.iter().any(|event| matches!(event, TmuxEvent::FocusChanged { session_name, .. } if session_name == "beta")));
1418 }
1419
1420 #[test]
1421 fn polling_backend_reports_polling_strategy() {
1422 let backend = PollingTmuxBackend::new(crate::CommandTmuxClient::new());
1423
1424 assert_eq!(backend.event_strategy(), EventStrategy::PollingFallback);
1425 }
1426}