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 /// Cheap activity probe (#277): the Unix timestamp (seconds) of the
23 /// session window's last activity, or `None` when it can't be read.
24 /// Callers use it to skip the heavy `capture` when the pane hasn't
25 /// changed. The default returns `None`, which keeps callers on the
26 /// unconditional-capture path, so existing impls need no change.
27 fn last_activity_secs(&self, _session: &str) -> Option<u64> {
28 None
29 }
30}
31
32/// Production implementation — shells out to `tmux capture-pane`.
33/// `-e` preserves ANSI escape sequences (T-074 bug 3 fix; without
34/// `-e` the captured output is colour-stripped and the detail pane
35/// renders as monochrome regardless of terminal colour mode). `-J`
36/// joins wrapped lines, `-p` writes to stdout, `-S -3000` pulls the
37/// last 3000 lines of scrollback. Order of flags is incidental;
38/// keep `-e` adjacent to the other capture-shape flags for grep.
39#[derive(Debug, Default, Clone, Copy)]
40pub struct TmuxPaneSource;
41
42impl PaneSource for TmuxPaneSource {
43 fn capture(&self, session: &str) -> Result<Vec<String>> {
44 let output = Command::new("tmux")
45 .args([
46 "capture-pane",
47 "-e",
48 "-p",
49 "-J",
50 "-S",
51 "-3000",
52 "-t",
53 session,
54 ])
55 .output()
56 .with_context(|| format!("invoke tmux capture-pane -t {session}"))?;
57 if !output.status.success() {
58 return Ok(Vec::new());
59 }
60 Ok(String::from_utf8_lossy(&output.stdout)
61 .lines()
62 .map(|s| s.to_string())
63 .collect())
64 }
65
66 /// `tmux display-message -p -t <session> '#{window_activity}'` is a
67 /// single cheap query (no scrollback transfer) returning the Unix
68 /// timestamp of the window's last activity. Gating `capture` on it
69 /// stops an idle pane being re-captured ~10x/second (#277). The
70 /// resolution is whole seconds, so an unchanged ts *within the current
71 /// second* does not mean the pane is idle (it may be mid-burst) —
72 /// `recapture_focused_pane` only treats the cache as fresh once this ts
73 /// is in an earlier second (its settled-second gate). This is the
74 /// *window's* activity, which is the right signal because a teamctl
75 /// agent session is single-window/single-pane today; if sessions ever
76 /// gain extra windows or splits, revisit this (the slow 1 Hz
77 /// unconditional refresh bounds any staleness to ~1s regardless). An
78 /// empty or non-numeric result (unknown session, or a tmux without
79 /// `window_activity`) yields `None`, which falls back to an
80 /// unconditional capture, the same behaviour as before this change.
81 fn last_activity_secs(&self, session: &str) -> Option<u64> {
82 let output = Command::new("tmux")
83 .args(["display-message", "-p", "-t", session, "#{window_activity}"])
84 .output()
85 .ok()?;
86 if !output.status.success() {
87 return None;
88 }
89 String::from_utf8_lossy(&output.stdout)
90 .trim()
91 .parse::<u64>()
92 .ok()
93 }
94}
95
96/// Take the last `n` lines so the detail pane never overruns its
97/// rect. Free function so tests can pin the slice without
98/// constructing a widget.
99pub fn tail_lines(lines: &[String], n: usize) -> Vec<String> {
100 let len = lines.len();
101 let start = len.saturating_sub(n);
102 lines[start..].to_vec()
103}
104
105/// Test fixtures. Made `pub` (rather than `#[cfg(test)]`) so sibling
106/// modules' tests (e.g. `app`) can reach them — same pattern as
107/// `keysender::test_support` and `compose::test_support`.
108pub mod test_support {
109 use super::*;
110 use std::sync::Mutex;
111
112 /// Test stub — returns the canned lines on every call, records
113 /// every session it was queried with so tests can assert that
114 /// the right session got captured.
115 #[derive(Default)]
116 pub struct MockPaneSource {
117 pub lines: Vec<String>,
118 pub asked: Mutex<Vec<String>>,
119 /// Canned activity timestamp returned by `last_activity_secs`.
120 /// `None` (the default) keeps callers on the always-capture path.
121 pub activity_ts: Option<u64>,
122 }
123
124 impl PaneSource for MockPaneSource {
125 fn capture(&self, session: &str) -> Result<Vec<String>> {
126 self.asked.lock().unwrap().push(session.to_string());
127 Ok(self.lines.clone())
128 }
129
130 fn last_activity_secs(&self, _session: &str) -> Option<u64> {
131 self.activity_ts
132 }
133 }
134}
135
136#[cfg(test)]
137mod tests {
138 use super::test_support::MockPaneSource;
139 use super::*;
140 use std::sync::Mutex;
141
142 #[test]
143 fn tail_lines_takes_last_n() {
144 let v: Vec<String> = (0..10).map(|i| format!("line {i}")).collect();
145 let tail = tail_lines(&v, 3);
146 assert_eq!(tail, vec!["line 7", "line 8", "line 9"]);
147 }
148
149 #[test]
150 fn tail_lines_under_n_returns_all() {
151 let v = vec!["a".to_string(), "b".to_string()];
152 assert_eq!(tail_lines(&v, 5), v);
153 }
154
155 #[test]
156 fn tail_lines_empty_returns_empty() {
157 let v: Vec<String> = Vec::new();
158 assert!(tail_lines(&v, 5).is_empty());
159 }
160
161 #[test]
162 fn mock_pane_source_records_session() {
163 let mock = MockPaneSource {
164 lines: vec!["hi".into(), "bye".into()],
165 asked: Mutex::new(Vec::new()),
166 ..Default::default()
167 };
168 let lines = mock.capture("t-p-a").unwrap();
169 assert_eq!(lines, vec!["hi", "bye"]);
170 assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
171 }
172}