teamctl-ui 0.10.0

Interactive TUI for teamctl — Triptych view, approvals modal, send-mail compose.
Documentation
//! Pane capture — abstracts how the UI reads the focused agent's
//! tmux scrollback so tests can stub it out. Production hits
//! `tmux capture-pane`; tests pass a `MockPaneSource` with canned
//! lines.
//!
//! The detail pane in PR-UI-2 polls the `PaneSource` once per
//! refresh tick (currently 1s, same cadence as the roster) and
//! re-renders. For PR-UI-3 / PR-UI-4 a streaming `tmux pipe-pane`
//! variant can implement the same trait without changing callers.

use std::process::Command;

use anyhow::{Context, Result};

/// Lookup contract: given a tmux session name, return its scrollback
/// as a list of lines. Implementations may bound the depth — the
/// production tmux variant takes the last 3000 lines via
/// `capture-pane -S -3000`, matching `teamctl logs`.
pub trait PaneSource: Send + Sync {
    fn capture(&self, session: &str) -> Result<Vec<String>>;

    /// Cheap activity probe (#277): the Unix timestamp (seconds) of the
    /// session window's last activity, or `None` when it can't be read.
    /// Callers use it to skip the heavy `capture` when the pane hasn't
    /// changed. The default returns `None`, which keeps callers on the
    /// unconditional-capture path, so existing impls need no change.
    fn last_activity_secs(&self, _session: &str) -> Option<u64> {
        None
    }
}

/// Production implementation — shells out to `tmux capture-pane`.
/// `-e` preserves ANSI escape sequences (T-074 bug 3 fix; without
/// `-e` the captured output is colour-stripped and the detail pane
/// renders as monochrome regardless of terminal colour mode). `-J`
/// joins wrapped lines, `-p` writes to stdout, `-S -3000` pulls the
/// last 3000 lines of scrollback. Order of flags is incidental;
/// keep `-e` adjacent to the other capture-shape flags for grep.
#[derive(Debug, Default, Clone, Copy)]
pub struct TmuxPaneSource;

impl PaneSource for TmuxPaneSource {
    fn capture(&self, session: &str) -> Result<Vec<String>> {
        let output = Command::new("tmux")
            .args([
                "capture-pane",
                "-e",
                "-p",
                "-J",
                "-S",
                "-3000",
                "-t",
                session,
            ])
            .output()
            .with_context(|| format!("invoke tmux capture-pane -t {session}"))?;
        if !output.status.success() {
            return Ok(Vec::new());
        }
        Ok(String::from_utf8_lossy(&output.stdout)
            .lines()
            .map(|s| s.to_string())
            .collect())
    }

    /// `tmux display-message -p -t <session> '#{window_activity}'` is a
    /// single cheap query (no scrollback transfer) returning the Unix
    /// timestamp of the window's last activity. Gating `capture` on it
    /// stops an idle pane being re-captured ~10x/second (#277). The
    /// resolution is whole seconds, so an unchanged ts *within the current
    /// second* does not mean the pane is idle (it may be mid-burst) —
    /// `recapture_focused_pane` only treats the cache as fresh once this ts
    /// is in an earlier second (its settled-second gate). This is the
    /// *window's* activity, which is the right signal because a teamctl
    /// agent session is single-window/single-pane today; if sessions ever
    /// gain extra windows or splits, revisit this (the slow 1 Hz
    /// unconditional refresh bounds any staleness to ~1s regardless). An
    /// empty or non-numeric result (unknown session, or a tmux without
    /// `window_activity`) yields `None`, which falls back to an
    /// unconditional capture, the same behaviour as before this change.
    fn last_activity_secs(&self, session: &str) -> Option<u64> {
        let output = Command::new("tmux")
            .args(["display-message", "-p", "-t", session, "#{window_activity}"])
            .output()
            .ok()?;
        if !output.status.success() {
            return None;
        }
        String::from_utf8_lossy(&output.stdout)
            .trim()
            .parse::<u64>()
            .ok()
    }
}

/// Take the last `n` lines so the detail pane never overruns its
/// rect. Free function so tests can pin the slice without
/// constructing a widget.
pub fn tail_lines(lines: &[String], n: usize) -> Vec<String> {
    let len = lines.len();
    let start = len.saturating_sub(n);
    lines[start..].to_vec()
}

/// Test fixtures. Made `pub` (rather than `#[cfg(test)]`) so sibling
/// modules' tests (e.g. `app`) can reach them — same pattern as
/// `keysender::test_support` and `compose::test_support`.
pub mod test_support {
    use super::*;
    use std::sync::Mutex;

    /// Test stub — returns the canned lines on every call, records
    /// every session it was queried with so tests can assert that
    /// the right session got captured.
    #[derive(Default)]
    pub struct MockPaneSource {
        pub lines: Vec<String>,
        pub asked: Mutex<Vec<String>>,
        /// Canned activity timestamp returned by `last_activity_secs`.
        /// `None` (the default) keeps callers on the always-capture path.
        pub activity_ts: Option<u64>,
    }

    impl PaneSource for MockPaneSource {
        fn capture(&self, session: &str) -> Result<Vec<String>> {
            self.asked.lock().unwrap().push(session.to_string());
            Ok(self.lines.clone())
        }

        fn last_activity_secs(&self, _session: &str) -> Option<u64> {
            self.activity_ts
        }
    }
}

#[cfg(test)]
mod tests {
    use super::test_support::MockPaneSource;
    use super::*;
    use std::sync::Mutex;

    #[test]
    fn tail_lines_takes_last_n() {
        let v: Vec<String> = (0..10).map(|i| format!("line {i}")).collect();
        let tail = tail_lines(&v, 3);
        assert_eq!(tail, vec!["line 7", "line 8", "line 9"]);
    }

    #[test]
    fn tail_lines_under_n_returns_all() {
        let v = vec!["a".to_string(), "b".to_string()];
        assert_eq!(tail_lines(&v, 5), v);
    }

    #[test]
    fn tail_lines_empty_returns_empty() {
        let v: Vec<String> = Vec::new();
        assert!(tail_lines(&v, 5).is_empty());
    }

    #[test]
    fn mock_pane_source_records_session() {
        let mock = MockPaneSource {
            lines: vec!["hi".into(), "bye".into()],
            asked: Mutex::new(Vec::new()),
            ..Default::default()
        };
        let lines = mock.capture("t-p-a").unwrap();
        assert_eq!(lines, vec!["hi", "bye"]);
        assert_eq!(mock.asked.lock().unwrap().clone(), vec!["t-p-a"]);
    }
}