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(¤t)
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}