Skip to main content

rz_cli/
tmux.rs

1//! Thin wrapper over tmux CLI commands.
2//!
3//! Uses `send-keys` for short text delivery, `load-buffer`/`paste-buffer` for
4//! long text, `list-panes` for structured pane discovery, and `capture-pane`
5//! for reading pane output.
6
7use std::process::{Command, Stdio};
8use std::io::Write as _;
9
10use eyre::{Result, bail};
11
12// ---------------------------------------------------------------------------
13// Pane info
14// ---------------------------------------------------------------------------
15
16#[derive(Debug, Clone)]
17pub struct PaneInfo {
18    pub id: String,
19    pub title: String,
20    pub command: Option<String>,
21    pub active: bool,
22}
23
24// ---------------------------------------------------------------------------
25// Identity
26// ---------------------------------------------------------------------------
27
28/// Get own pane ID from environment (e.g. "%0").
29pub fn own_pane_id() -> Result<String> {
30    std::env::var("TMUX_PANE")
31        .map(|id| normalize_pane_id(&id))
32        .map_err(|_| eyre::eyre!("TMUX_PANE not set — not inside tmux?"))
33}
34
35/// Get the current tmux session name.
36pub fn session_name() -> Result<String> {
37    // Try parsing from TMUX env var (format: /tmp/tmux-1000/default,12345,0)
38    if let Ok(val) = std::env::var("TMUX") {
39        if let Some(path) = val.split(',').next() {
40            if let Some(name) = path.rsplit('/').next() {
41                if !name.is_empty() {
42                    return Ok(name.to_string());
43                }
44            }
45        }
46    }
47    // Fallback: ask tmux directly
48    tmux_output(&["display-message", "-p", "#{session_name}"])
49}
50
51// ---------------------------------------------------------------------------
52// Input
53// ---------------------------------------------------------------------------
54
55/// Send text to a pane and submit it.
56///
57/// For short text (<=200 chars), uses `send-keys`. For longer text, pipes
58/// through `load-buffer -` then `paste-buffer` to avoid argument length issues.
59pub fn send(pane_id: &str, text: &str) -> Result<()> {
60    if text.len() <= 200 {
61        tmux(&["send-keys", "-t", pane_id, text, "Enter"])?;
62    } else {
63        // Load text into tmux buffer via stdin
64        let mut child = Command::new("tmux")
65            .args(["load-buffer", "-"])
66            .stdin(Stdio::piped())
67            .stdout(Stdio::piped())
68            .stderr(Stdio::piped())
69            .spawn()?;
70        if let Some(mut stdin) = child.stdin.take() {
71            stdin.write_all(text.as_bytes())?;
72        }
73        let output = child.wait_with_output()?;
74        if !output.status.success() {
75            bail!(
76                "tmux load-buffer failed: {}",
77                String::from_utf8_lossy(&output.stderr).trim()
78            );
79        }
80        // Paste buffer into target pane
81        tmux(&["paste-buffer", "-t", pane_id])?;
82        // Press Enter to submit
83        tmux(&["send-keys", "-t", pane_id, "Enter"])?;
84    }
85    Ok(())
86}
87
88// ---------------------------------------------------------------------------
89// Pane lifecycle
90// ---------------------------------------------------------------------------
91
92/// Spawn a command in a new horizontal split pane. Returns the new pane ID (e.g. "%5").
93pub fn spawn(cmd: &str, args: &[&str], _name: Option<&str>) -> Result<String> {
94    let full_cmd = if args.is_empty() {
95        cmd.to_string()
96    } else {
97        format!("{} {}", cmd, args.join(" "))
98    };
99
100    let output = Command::new("tmux")
101        .args(["split-window", "-h", "-P", "-F", "#{pane_id}", &full_cmd])
102        .output()?;
103    if !output.status.success() {
104        bail!(
105            "tmux split-window failed: {}",
106            String::from_utf8_lossy(&output.stderr).trim()
107        );
108    }
109    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
110}
111
112/// Close a pane by ID.
113pub fn close(pane_id: &str) -> Result<()> {
114    tmux(&["kill-pane", "-t", pane_id])
115}
116
117// ---------------------------------------------------------------------------
118// Query
119// ---------------------------------------------------------------------------
120
121/// List all panes as structured data.
122pub fn list_panes() -> Result<Vec<PaneInfo>> {
123    let raw = tmux_output(&[
124        "list-panes", "-a", "-F",
125        "#{pane_id}|#{pane_title}|#{pane_current_command}|#{pane_active}",
126    ])?;
127    let mut panes = Vec::new();
128    for line in raw.lines() {
129        let parts: Vec<&str> = line.splitn(4, '|').collect();
130        if parts.len() < 4 {
131            continue;
132        }
133        panes.push(PaneInfo {
134            id: parts[0].to_string(),
135            title: parts[1].to_string(),
136            command: if parts[2].is_empty() {
137                None
138            } else {
139                Some(parts[2].to_string())
140            },
141            active: parts[3] == "1",
142        });
143    }
144    Ok(panes)
145}
146
147/// List pane IDs only.
148pub fn list_pane_ids() -> Result<Vec<String>> {
149    Ok(list_panes()?.into_iter().map(|p| p.id).collect())
150}
151
152/// Normalize a user-provided pane ID.
153///
154/// - `"0"` -> `"%0"`
155/// - `"%0"` -> `"%0"` (passthrough)
156pub fn normalize_pane_id(input: &str) -> String {
157    if input.starts_with('%') {
158        input.to_string()
159    } else {
160        format!("%{input}")
161    }
162}
163
164/// Dump a pane's full scrollback.
165pub fn dump(pane_id: &str) -> Result<String> {
166    tmux_output(&["capture-pane", "-t", pane_id, "-p", "-S", "-"])
167}
168
169// ---------------------------------------------------------------------------
170// Polling
171// ---------------------------------------------------------------------------
172
173/// Poll `dump()` until output appears, then sleep `settle_secs` for it to stabilize.
174pub fn wait_for_stable_output(pane_id: &str, max_secs: u64, settle_secs: u64) {
175    let start = std::time::Instant::now();
176    let timeout = std::time::Duration::from_secs(max_secs);
177    loop {
178        if start.elapsed() >= timeout {
179            break;
180        }
181        if let Ok(text) = dump(pane_id) {
182            if !text.trim().is_empty() {
183                std::thread::sleep(std::time::Duration::from_secs(settle_secs));
184                return;
185            }
186        }
187        std::thread::sleep(std::time::Duration::from_millis(250));
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Internal
193// ---------------------------------------------------------------------------
194
195fn tmux(args: &[&str]) -> Result<()> {
196    let output = Command::new("tmux").args(args).output()?;
197    if !output.status.success() {
198        bail!(
199            "tmux {} failed: {}",
200            args.first().unwrap_or(&""),
201            String::from_utf8_lossy(&output.stderr).trim()
202        );
203    }
204    Ok(())
205}
206
207fn tmux_output(args: &[&str]) -> Result<String> {
208    let output = Command::new("tmux").args(args).output()?;
209    if !output.status.success() {
210        bail!(
211            "tmux {} failed: {}",
212            args.first().unwrap_or(&""),
213            String::from_utf8_lossy(&output.stderr).trim()
214        );
215    }
216    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
217}