Skip to main content

teamctl_ui/
pane_resize.rs

1//! Detail-pane → inner-tmux-session size sync.
2//!
3//! Background: teamctl-ui's `Detail` pane reads the focused agent's
4//! tmux scrollback via `tmux capture-pane` (see `pane::TmuxPaneSource`)
5//! and renders the captured content inside a ratatui rect. The inner
6//! tmux session is spawned with `-x 200 -y 50` (see
7//! `team-core::supervisor`) and stays that size until something tells
8//! it to resize. T-098 / #99 wired SIGWINCH propagation through
9//! `rl-watch` so the inner session reflows when the *outer* host
10//! terminal resizes — but that signal only fires on real OS terminal
11//! changes, not on teamctl-ui's own layout shifts.
12//!
13//! Net effect (T-199): when the operator resizes the host terminal so
14//! that teamctl-ui's `Detail` rect becomes smaller than 200×50, claude
15//! keeps rendering at the inner pane's original 200×50, the captured
16//! output is wider+taller than the rect, and the operator sees an
17//! overflowing pane.
18//!
19//! Fix: after every `terminal.draw`, compute the `Detail` rect the
20//! Triptych layout would produce for the current terminal size and
21//! call `tmux resize-window -t <session> -x W -y H` to keep the inner
22//! session sized to match. It must be `resize-window`, **not**
23//! `resize-pane`: the agent session is detached, single-pane, and
24//! clientless, and `resize-pane` cannot shrink the sole pane of a
25//! clientless window — that wrong verb is the #312 recurrence (see
26//! [`TmuxPaneResizer`]). Cache the last value we pushed per session
27//! so the common case (no resize, no focus switch) is a HashMap
28//! lookup, not a subprocess spawn.
29
30use std::collections::HashMap;
31use std::process::Command;
32
33use ratatui::layout::{Constraint, Direction, Layout, Rect};
34
35/// The vertical constraints for the right-stack split — Detail above,
36/// Mailbox below. The single source of truth for the split, shared by
37/// the render path (`triptych::Triptych::render`) and the tmux-sync
38/// path ([`triptych_detail_area`]), so the same proportions reach both
39/// — the divergence #459 called out as the critical failure mode (if
40/// only the render branch shrank the Mailbox, tmux would keep sizing
41/// the agent pane to the old split and the shrink would be cosmetic).
42///
43/// Note this single-sources the *split*, not the absolute heights: the
44/// sync path is handed the full terminal area while the render path
45/// gets the body rect after the 2-row footer (statusline + status bar)
46/// is carved off, so the synced pane runs ~1–2 rows taller than the
47/// rendered Detail inner area. That footer offset predates #459 and is
48/// orthogonal to the split.
49///
50/// In stream-keys mode the operator wants maximum room for the live
51/// terminal, so Detail takes all remaining height and the Mailbox
52/// shrinks to a fixed `Length(5)` strip — borders + tabs consume 3
53/// rows, leaving 2 visible mailbox rows (1 when the filter/search
54/// indicator row is showing) (#459). Otherwise the normal 60/40 split,
55/// which survives terminal-resize because `Ratio` re-applies on every
56/// render.
57pub fn right_stack_constraints(is_stream_keys: bool) -> [Constraint; 2] {
58    if is_stream_keys {
59        [Constraint::Min(0), Constraint::Length(5)]
60    } else {
61        [Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)]
62    }
63}
64
65/// Compute the `Rect` the Triptych layout would allocate to the
66/// `Detail` pane given a total terminal area, whether the approvals
67/// stripe is visible, and whether stream-keys mode is active. Mirrors
68/// `triptych::Triptych::render`: the right-stack split comes from the
69/// shared [`right_stack_constraints`], so the split proportions here
70/// match what gets rendered (the absolute height carries the
71/// pre-existing 2-row footer offset noted on `right_stack_constraints`).
72///
73/// Returns `None` when the area is too small for the layout to produce
74/// a non-empty Detail rect (e.g. an 80×24 terminal in the middle of a
75/// resize-down before crossterm catches up). The caller skips the
76/// sync in that case rather than push a degenerate size to tmux.
77pub fn triptych_detail_area(
78    total: Rect,
79    has_pending_approvals: bool,
80    is_stream_keys: bool,
81) -> Option<Rect> {
82    if total.width == 0 || total.height == 0 {
83        return None;
84    }
85    let body = if has_pending_approvals {
86        // One-line approvals stripe at the top.
87        let v = Layout::default()
88            .direction(Direction::Vertical)
89            .constraints([Constraint::Length(1), Constraint::Min(0)])
90            .split(total);
91        v[1]
92    } else {
93        total
94    };
95    let outer = Layout::default()
96        .direction(Direction::Horizontal)
97        .constraints([
98            Constraint::Length(28), // agents sidebar
99            Constraint::Min(0),     // right-stack
100        ])
101        .split(body);
102    let right_stack = Layout::default()
103        .direction(Direction::Vertical)
104        .constraints(right_stack_constraints(is_stream_keys))
105        .split(outer[1]);
106    let detail = right_stack[0];
107    if detail.width == 0 || detail.height == 0 {
108        return None;
109    }
110    Some(detail)
111}
112
113/// Decide whether to push a `tmux resize-window` to `session` given
114/// the current Detail dimensions and the last size we already pushed
115/// for this session. The common case (same agent focused, no resize)
116/// is a no-op; we only fire the subprocess when the size has actually
117/// changed or we haven't synced this session before.
118pub fn should_sync(
119    cache: &HashMap<String, (u16, u16)>,
120    session: &str,
121    current: (u16, u16),
122) -> bool {
123    cache.get(session) != Some(&current)
124}
125
126/// Pushes a `tmux resize-window` for a session. Production resizers
127/// shell out via [`TmuxPaneResizer`]; tests pass a stub that records
128/// calls without touching tmux.
129pub trait PaneResizer: Send + Sync {
130    /// Best-effort resize. Implementations should not panic or
131    /// propagate errors — a missing/killed session is a normal
132    /// transient state, not a fatal condition. The cache should
133    /// advance regardless so we don't retry-spam a dead session.
134    fn resize(&self, session: &str, cols: u16, rows: u16);
135}
136
137/// `tmux` argv that resizes `session`'s window to `cols`×`rows`.
138///
139/// Pulled out as a pure function so the exact subcommand is
140/// unit-pinned. It MUST be `resize-window`, never `resize-pane` — see
141/// the anti-regression note on [`TmuxPaneResizer`].
142fn resize_window_argv(session: &str, cols: u16, rows: u16) -> [String; 7] {
143    [
144        "resize-window".to_string(),
145        "-t".to_string(),
146        session.to_string(),
147        "-x".to_string(),
148        cols.to_string(),
149        "-y".to_string(),
150        rows.to_string(),
151    ]
152}
153
154/// Production implementation — shells out to **`tmux resize-window`**.
155///
156/// It MUST be `resize-window`, **not** `resize-pane`. The agent
157/// session is a detached, single-pane, **clientless** tmux session
158/// created at `-x 200 -y 50` (`team-core::supervisor`). `resize-pane`
159/// only redistributes space *within* a window's existing geometry —
160/// for the sole pane of a clientless window it is a silent no-op, so
161/// the captured content stays 200×50 and overflows the smaller Detail
162/// rect. Only `resize-window` changes the window (and hence its sole
163/// pane) for a session with no attached client. This exact "right
164/// trigger, wrong verb" error is why the bug recurred across
165/// #99 → T-199/#210 → #312. Do NOT "simplify" this back to
166/// `resize-pane`. (`resize-window` is tmux ≥ 2.9, 2018 — well below
167/// any tmux the supervisor can drive.)
168///
169/// `-t <session>` targets the agent's session; `-x W -y H` set the
170/// window size. Stdout/stderr are dropped: a failure (session gone,
171/// tmux not on PATH) is silently ignored and the cache still advances
172/// so a fresh spawn next tick can re-sync.
173#[derive(Debug, Default, Clone, Copy)]
174pub struct TmuxPaneResizer;
175
176impl PaneResizer for TmuxPaneResizer {
177    fn resize(&self, session: &str, cols: u16, rows: u16) {
178        let _ = Command::new("tmux")
179            .args(resize_window_argv(session, cols, rows))
180            .status();
181    }
182}
183
184/// Shared test fakes — mirrors `compose::test_support` /
185/// `mailbox::test_support` / `keysender::test_support`. The
186/// in-memory `MockPaneResizer` records every call so unit tests in
187/// other modules (`app::tests::sync_*`) can assert the sequence
188/// without spawning a real tmux subprocess.
189pub mod test_support {
190    use std::sync::Mutex;
191
192    use super::PaneResizer;
193
194    /// Records every `resize` invocation as `(session, cols, rows)`.
195    /// Backed by `Mutex` (not `RefCell`) so the `Send + Sync` bound
196    /// on `PaneResizer` is satisfiable for parity with `PaneSource`.
197    #[derive(Debug, Default)]
198    pub struct MockPaneResizer {
199        pub calls: Mutex<Vec<(String, u16, u16)>>,
200    }
201
202    impl PaneResizer for MockPaneResizer {
203        fn resize(&self, session: &str, cols: u16, rows: u16) {
204            self.calls
205                .lock()
206                .unwrap()
207                .push((session.to_string(), cols, rows));
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn detail_area_for_typical_terminal_without_approvals() {
218        // 120×40: Agents=28 wide, right-stack=92 wide, Detail=24 rows
219        // (3/5 of 40), Mailbox=16 rows (2/5 of 40).
220        let total = Rect::new(0, 0, 120, 40);
221        let detail = triptych_detail_area(total, false, false).unwrap();
222        assert_eq!(detail.x, 28);
223        assert_eq!(detail.y, 0);
224        assert_eq!(detail.width, 92);
225        assert_eq!(detail.height, 24);
226    }
227
228    #[test]
229    fn detail_area_with_approvals_stripe_loses_one_row() {
230        // Same 120×40 with the approvals stripe: stripe takes y=0
231        // height=1, body starts at y=1 with height=39; Detail is
232        // 3/5 of 39 = 23 rows, starting at y=1.
233        let total = Rect::new(0, 0, 120, 40);
234        let detail = triptych_detail_area(total, true, false).unwrap();
235        assert_eq!(detail.x, 28);
236        assert_eq!(detail.y, 1);
237        assert_eq!(detail.width, 92);
238        assert_eq!(detail.height, 23);
239    }
240
241    #[test]
242    fn detail_area_returns_none_on_zero_dimension() {
243        assert!(triptych_detail_area(Rect::new(0, 0, 0, 40), false, false).is_none());
244        assert!(triptych_detail_area(Rect::new(0, 0, 120, 0), false, false).is_none());
245    }
246
247    #[test]
248    fn detail_area_returns_none_when_sidebar_consumes_everything() {
249        // Width 28 (or less) is exactly consumed by the Agents
250        // sidebar; the right-stack has zero width, so Detail does too.
251        let total = Rect::new(0, 0, 28, 40);
252        assert!(triptych_detail_area(total, false, false).is_none());
253    }
254
255    #[test]
256    fn right_stack_constraints_shrink_mailbox_in_stream_keys() {
257        // Normal mode: the classic 60/40 Detail/Mailbox split.
258        assert_eq!(
259            right_stack_constraints(false),
260            [Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)]
261        );
262        // Stream-keys: Detail takes the rest, Mailbox a fixed 5-row strip
263        // (#459). This shared helper is the single source of truth both
264        // the render path and `triptych_detail_area` read, so pinning it
265        // guards the two-geometry lockstep.
266        assert_eq!(
267            right_stack_constraints(true),
268            [Constraint::Min(0), Constraint::Length(5)]
269        );
270    }
271
272    #[test]
273    fn detail_area_in_stream_keys_expands_to_near_full_height() {
274        // 120×40 stream-keys: Mailbox is a fixed Length(5) strip, so
275        // Detail gets the remaining 35 rows (vs 24 under the 3/5 split).
276        let total = Rect::new(0, 0, 120, 40);
277        let detail = triptych_detail_area(total, false, true).unwrap();
278        assert_eq!(detail.x, 28);
279        assert_eq!(detail.y, 0);
280        assert_eq!(detail.width, 92);
281        assert_eq!(detail.height, 35);
282    }
283
284    #[test]
285    fn detail_area_in_stream_keys_with_approvals_stripe() {
286        // Same 120×40 with the approvals stripe: body starts at y=1 with
287        // height 39; the Length(5) Mailbox leaves Detail 34 rows at y=1.
288        let total = Rect::new(0, 0, 120, 40);
289        let detail = triptych_detail_area(total, true, true).unwrap();
290        assert_eq!(detail.x, 28);
291        assert_eq!(detail.y, 1);
292        assert_eq!(detail.width, 92);
293        assert_eq!(detail.height, 34);
294    }
295
296    #[test]
297    fn should_sync_returns_true_on_first_call() {
298        let cache = HashMap::new();
299        assert!(should_sync(&cache, "t-hello-mgr", (92, 24)));
300    }
301
302    #[test]
303    fn should_sync_returns_false_when_size_unchanged() {
304        let mut cache = HashMap::new();
305        cache.insert("t-hello-mgr".to_string(), (92, 24));
306        assert!(!should_sync(&cache, "t-hello-mgr", (92, 24)));
307    }
308
309    #[test]
310    fn should_sync_returns_true_when_size_differs() {
311        let mut cache = HashMap::new();
312        cache.insert("t-hello-mgr".to_string(), (92, 24));
313        // Width changed.
314        assert!(should_sync(&cache, "t-hello-mgr", (100, 24)));
315        // Height changed.
316        assert!(should_sync(&cache, "t-hello-mgr", (92, 25)));
317    }
318
319    #[test]
320    fn should_sync_treats_different_sessions_independently() {
321        let mut cache = HashMap::new();
322        cache.insert("t-hello-mgr".to_string(), (92, 24));
323        // Same size, different session → first-sync, should fire.
324        assert!(should_sync(&cache, "t-hello-dev", (92, 24)));
325    }
326
327    use super::test_support::MockPaneResizer;
328
329    #[test]
330    fn mock_resizer_records_calls() {
331        let m = MockPaneResizer::default();
332        m.resize("t-a", 100, 30);
333        m.resize("t-b", 80, 20);
334        let calls = m.calls.lock().unwrap();
335        assert_eq!(calls.len(), 2);
336        assert_eq!(calls[0], ("t-a".to_string(), 100, 30));
337        assert_eq!(calls[1], ("t-b".to_string(), 80, 20));
338    }
339
340    /// The CI guard for #312: pins the production verb. T-199/#210's
341    /// tests asserted the sync *decision* via `MockPaneResizer` but
342    /// never the *verb*, so a `resize-window`→`resize-pane` slip is
343    /// invisible to them — exactly how #312 recurred. This runs in the
344    /// default suite and fails the moment the verb regresses.
345    #[test]
346    fn resize_argv_is_resize_window_never_resize_pane() {
347        let argv = super::resize_window_argv("t-hello-mgr", 92, 24);
348        assert_eq!(
349            argv[0], "resize-window",
350            "MUST be `resize-window`: `resize-pane` silently no-ops on \
351             the sole pane of a clientless detached session and reopens \
352             #312"
353        );
354        assert_ne!(argv[0], "resize-pane", "the #312 regression verb");
355        assert_eq!(
356            argv,
357            ["resize-window", "-t", "t-hello-mgr", "-x", "92", "-y", "24"].map(str::to_string)
358        );
359    }
360
361    /// Empirical #312 repro, codified. `#[ignore]` because — unlike the
362    /// rest of this crate's hermetic mock tests — it spawns a real
363    /// `tmux` server; run with `cargo test -- --ignored` on a tmux
364    /// host. Proves the production resizer actually shrinks a clientless
365    /// session created the way `team-core::supervisor` creates it (the
366    /// real-effect check #210 lacked). Against the pre-fix `resize-pane`
367    /// code this fails: the window stays 200×50.
368    #[test]
369    #[ignore = "spawns a real tmux server; run with --ignored on a tmux host"]
370    fn resize_window_actually_shrinks_a_clientless_session() {
371        let session = "t312-regression-probe";
372        let kill = || {
373            let _ = Command::new("tmux")
374                .args(["kill-session", "-t", session])
375                .status();
376        };
377        kill();
378        // Mirror team-core::supervisor: detached, clientless, 200×50.
379        let created = Command::new("tmux")
380            .args([
381                "new-session",
382                "-d",
383                "-x",
384                "200",
385                "-y",
386                "50",
387                "-s",
388                session,
389                "sh",
390                "-c",
391                "while :; do sleep 5; done",
392            ])
393            .status();
394        if !matches!(created, Ok(s) if s.success()) {
395            // No usable tmux in this environment — nothing to assert.
396            return;
397        }
398
399        TmuxPaneResizer.resize(session, 80, 24);
400
401        let out = Command::new("tmux")
402            .args([
403                "display-message",
404                "-p",
405                "-t",
406                session,
407                "#{window_width}x#{window_height}",
408            ])
409            .output()
410            .expect("tmux display-message");
411        let geom = String::from_utf8_lossy(&out.stdout).trim().to_string();
412        kill();
413
414        assert_eq!(
415            geom, "80x24",
416            "resizer did not shrink the clientless window (got `{geom}`) \
417             — the resize-pane regression (#312)"
418        );
419    }
420}