Skip to main content

teamctl_ui/
pane.rs

1//! Pane capture — abstracts how the UI reads the focused agent's
2//! tmux scrollback so tests can stub it out. Production hits
3//! `tmux capture-pane`; tests pass a `MockPaneSource` with canned
4//! lines.
5//!
6//! The detail pane in PR-UI-2 polls the `PaneSource` once per
7//! refresh tick (currently 1s, same cadence as the roster) and
8//! re-renders. For PR-UI-3 / PR-UI-4 a streaming `tmux pipe-pane`
9//! variant can implement the same trait without changing callers.
10
11use std::process::Command;
12
13use anyhow::{Context, Result};
14
15/// Lookup contract: given a tmux session name, return its scrollback
16/// as a list of lines. Implementations may bound the depth — the
17/// production tmux variant takes the last 3000 lines via
18/// `capture-pane -S -3000`, matching `teamctl logs`.
19pub trait PaneSource: Send + Sync {
20    fn capture(&self, session: &str) -> Result<Vec<String>>;
21}
22
23/// Production implementation — shells out to `tmux capture-pane`.
24/// `-e` preserves ANSI escape sequences (T-074 bug 3 fix; without
25/// `-e` the captured output is colour-stripped and the detail pane
26/// renders as monochrome regardless of terminal colour mode). `-J`
27/// joins wrapped lines, `-p` writes to stdout, `-S -3000` pulls the
28/// last 3000 lines of scrollback. Order of flags is incidental;
29/// keep `-e` adjacent to the other capture-shape flags for grep.
30#[derive(Debug, Default, Clone, Copy)]
31pub struct TmuxPaneSource;
32
33impl PaneSource for TmuxPaneSource {
34    fn capture(&self, session: &str) -> Result<Vec<String>> {
35        let output = Command::new("tmux")
36            .args([
37                "capture-pane",
38                "-e",
39                "-p",
40                "-J",
41                "-S",
42                "-3000",
43                "-t",
44                session,
45            ])
46            .output()
47            .with_context(|| format!("invoke tmux capture-pane -t {session}"))?;
48        if !output.status.success() {
49            return Ok(Vec::new());
50        }
51        Ok(String::from_utf8_lossy(&output.stdout)
52            .lines()
53            .map(|s| s.to_string())
54            .collect())
55    }
56}
57
58/// Take the last `n` lines so the detail pane never overruns its
59/// rect. Free function so tests can pin the slice without
60/// constructing a widget.
61pub fn tail_lines(lines: &[String], n: usize) -> Vec<String> {
62    let len = lines.len();
63    let start = len.saturating_sub(n);
64    lines[start..].to_vec()
65}
66
67/// Test fixtures. Made `pub` (rather than `#[cfg(test)]`) so sibling
68/// modules' tests (e.g. `app`) can reach them — same pattern as
69/// `keysender::test_support` and `compose::test_support`.
70pub mod test_support {
71    use super::*;
72    use std::sync::Mutex;
73
74    /// Test stub — returns the canned lines on every call, records
75    /// every session it was queried with so tests can assert that
76    /// the right session got captured.
77    #[derive(Default)]
78    pub struct MockPaneSource {
79        pub lines: Vec<String>,
80        pub asked: Mutex<Vec<String>>,
81    }
82
83    impl PaneSource for MockPaneSource {
84        fn capture(&self, session: &str) -> Result<Vec<String>> {
85            self.asked.lock().unwrap().push(session.to_string());
86            Ok(self.lines.clone())
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::test_support::MockPaneSource;
94    use super::*;
95    use std::sync::Mutex;
96
97    #[test]
98    fn tail_lines_takes_last_n() {
99        let v: Vec<String> = (0..10).map(|i| format!("line {i}")).collect();
100        let tail = tail_lines(&v, 3);
101        assert_eq!(tail, vec!["line 7", "line 8", "line 9"]);
102    }
103
104    #[test]
105    fn tail_lines_under_n_returns_all() {
106        let v = vec!["a".to_string(), "b".to_string()];
107        assert_eq!(tail_lines(&v, 5), v);
108    }
109
110    #[test]
111    fn tail_lines_empty_returns_empty() {
112        let v: Vec<String> = Vec::new();
113        assert!(tail_lines(&v, 5).is_empty());
114    }
115
116    #[test]
117    fn mock_pane_source_records_session() {
118        let mock = MockPaneSource {
119            lines: vec!["hi".into(), "bye".into()],
120            asked: Mutex::new(Vec::new()),
121        };
122        let lines = mock.capture("t-p-a").unwrap();
123        assert_eq!(lines, vec!["hi", "bye"]);
124        assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
125    }
126}