teamctl-ui 0.10.0

Interactive TUI for teamctl — Triptych view, approvals modal, send-mail compose.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Detail-pane → inner-tmux-session size sync.
//!
//! Background: teamctl-ui's `Detail` pane reads the focused agent's
//! tmux scrollback via `tmux capture-pane` (see `pane::TmuxPaneSource`)
//! and renders the captured content inside a ratatui rect. The inner
//! tmux session is spawned with `-x 200 -y 50` (see
//! `team-core::supervisor`) and stays that size until something tells
//! it to resize. T-098 / #99 wired SIGWINCH propagation through
//! `rl-watch` so the inner session reflows when the *outer* host
//! terminal resizes — but that signal only fires on real OS terminal
//! changes, not on teamctl-ui's own layout shifts.
//!
//! Net effect (T-199): when the operator resizes the host terminal so
//! that teamctl-ui's `Detail` rect becomes smaller than 200×50, claude
//! keeps rendering at the inner pane's original 200×50, the captured
//! output is wider+taller than the rect, and the operator sees an
//! overflowing pane.
//!
//! Fix: after every `terminal.draw`, compute the `Detail` rect the
//! Triptych layout would produce for the current terminal size and
//! call `tmux resize-window -t <session> -x W -y H` to keep the inner
//! session sized to match. It must be `resize-window`, **not**
//! `resize-pane`: the agent session is detached, single-pane, and
//! clientless, and `resize-pane` cannot shrink the sole pane of a
//! clientless window — that wrong verb is the #312 recurrence (see
//! [`TmuxPaneResizer`]). Cache the last value we pushed per session
//! so the common case (no resize, no focus switch) is a HashMap
//! lookup, not a subprocess spawn.

use std::collections::HashMap;
use std::process::Command;

use ratatui::layout::{Constraint, Direction, Layout, Rect};

/// The vertical constraints for the right-stack split — Detail above,
/// Mailbox below. The single source of truth for the split, shared by
/// the render path (`triptych::Triptych::render`) and the tmux-sync
/// path ([`triptych_detail_area`]), so the same proportions reach both
/// — the divergence #459 called out as the critical failure mode (if
/// only the render branch shrank the Mailbox, tmux would keep sizing
/// the agent pane to the old split and the shrink would be cosmetic).
///
/// Note this single-sources the *split*, not the absolute heights: the
/// sync path is handed the full terminal area while the render path
/// gets the body rect after the 2-row footer (statusline + status bar)
/// is carved off, so the synced pane runs ~1–2 rows taller than the
/// rendered Detail inner area. That footer offset predates #459 and is
/// orthogonal to the split.
///
/// In stream-keys mode the operator wants maximum room for the live
/// terminal, so Detail takes all remaining height and the Mailbox
/// shrinks to a fixed `Length(5)` strip — borders + tabs consume 3
/// rows, leaving 2 visible mailbox rows (1 when the filter/search
/// indicator row is showing) (#459). Otherwise the normal 60/40 split,
/// which survives terminal-resize because `Ratio` re-applies on every
/// render.
pub fn right_stack_constraints(is_stream_keys: bool) -> [Constraint; 2] {
    if is_stream_keys {
        [Constraint::Min(0), Constraint::Length(5)]
    } else {
        [Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)]
    }
}

/// Compute the `Rect` the Triptych layout would allocate to the
/// `Detail` pane given a total terminal area, whether the approvals
/// stripe is visible, and whether stream-keys mode is active. Mirrors
/// `triptych::Triptych::render`: the right-stack split comes from the
/// shared [`right_stack_constraints`], so the split proportions here
/// match what gets rendered (the absolute height carries the
/// pre-existing 2-row footer offset noted on `right_stack_constraints`).
///
/// Returns `None` when the area is too small for the layout to produce
/// a non-empty Detail rect (e.g. an 80×24 terminal in the middle of a
/// resize-down before crossterm catches up). The caller skips the
/// sync in that case rather than push a degenerate size to tmux.
pub fn triptych_detail_area(
    total: Rect,
    has_pending_approvals: bool,
    is_stream_keys: bool,
) -> Option<Rect> {
    if total.width == 0 || total.height == 0 {
        return None;
    }
    let body = if has_pending_approvals {
        // One-line approvals stripe at the top.
        let v = Layout::default()
            .direction(Direction::Vertical)
            .constraints([Constraint::Length(1), Constraint::Min(0)])
            .split(total);
        v[1]
    } else {
        total
    };
    let outer = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Length(28), // agents sidebar
            Constraint::Min(0),     // right-stack
        ])
        .split(body);
    let right_stack = Layout::default()
        .direction(Direction::Vertical)
        .constraints(right_stack_constraints(is_stream_keys))
        .split(outer[1]);
    let detail = right_stack[0];
    if detail.width == 0 || detail.height == 0 {
        return None;
    }
    Some(detail)
}

/// Decide whether to push a `tmux resize-window` to `session` given
/// the current Detail dimensions and the last size we already pushed
/// for this session. The common case (same agent focused, no resize)
/// is a no-op; we only fire the subprocess when the size has actually
/// changed or we haven't synced this session before.
pub fn should_sync(
    cache: &HashMap<String, (u16, u16)>,
    session: &str,
    current: (u16, u16),
) -> bool {
    cache.get(session) != Some(&current)
}

/// Pushes a `tmux resize-window` for a session. Production resizers
/// shell out via [`TmuxPaneResizer`]; tests pass a stub that records
/// calls without touching tmux.
pub trait PaneResizer: Send + Sync {
    /// Best-effort resize. Implementations should not panic or
    /// propagate errors — a missing/killed session is a normal
    /// transient state, not a fatal condition. The cache should
    /// advance regardless so we don't retry-spam a dead session.
    fn resize(&self, session: &str, cols: u16, rows: u16);
}

/// `tmux` argv that resizes `session`'s window to `cols`×`rows`.
///
/// Pulled out as a pure function so the exact subcommand is
/// unit-pinned. It MUST be `resize-window`, never `resize-pane` — see
/// the anti-regression note on [`TmuxPaneResizer`].
fn resize_window_argv(session: &str, cols: u16, rows: u16) -> [String; 7] {
    [
        "resize-window".to_string(),
        "-t".to_string(),
        session.to_string(),
        "-x".to_string(),
        cols.to_string(),
        "-y".to_string(),
        rows.to_string(),
    ]
}

/// Production implementation — shells out to **`tmux resize-window`**.
///
/// It MUST be `resize-window`, **not** `resize-pane`. The agent
/// session is a detached, single-pane, **clientless** tmux session
/// created at `-x 200 -y 50` (`team-core::supervisor`). `resize-pane`
/// only redistributes space *within* a window's existing geometry —
/// for the sole pane of a clientless window it is a silent no-op, so
/// the captured content stays 200×50 and overflows the smaller Detail
/// rect. Only `resize-window` changes the window (and hence its sole
/// pane) for a session with no attached client. This exact "right
/// trigger, wrong verb" error is why the bug recurred across
/// #99 → T-199/#210 → #312. Do NOT "simplify" this back to
/// `resize-pane`. (`resize-window` is tmux ≥ 2.9, 2018 — well below
/// any tmux the supervisor can drive.)
///
/// `-t <session>` targets the agent's session; `-x W -y H` set the
/// window size. Stdout/stderr are dropped: a failure (session gone,
/// tmux not on PATH) is silently ignored and the cache still advances
/// so a fresh spawn next tick can re-sync.
#[derive(Debug, Default, Clone, Copy)]
pub struct TmuxPaneResizer;

impl PaneResizer for TmuxPaneResizer {
    fn resize(&self, session: &str, cols: u16, rows: u16) {
        let _ = Command::new("tmux")
            .args(resize_window_argv(session, cols, rows))
            .status();
    }
}

/// Shared test fakes — mirrors `compose::test_support` /
/// `mailbox::test_support` / `keysender::test_support`. The
/// in-memory `MockPaneResizer` records every call so unit tests in
/// other modules (`app::tests::sync_*`) can assert the sequence
/// without spawning a real tmux subprocess.
pub mod test_support {
    use std::sync::Mutex;

    use super::PaneResizer;

    /// Records every `resize` invocation as `(session, cols, rows)`.
    /// Backed by `Mutex` (not `RefCell`) so the `Send + Sync` bound
    /// on `PaneResizer` is satisfiable for parity with `PaneSource`.
    #[derive(Debug, Default)]
    pub struct MockPaneResizer {
        pub calls: Mutex<Vec<(String, u16, u16)>>,
    }

    impl PaneResizer for MockPaneResizer {
        fn resize(&self, session: &str, cols: u16, rows: u16) {
            self.calls
                .lock()
                .unwrap()
                .push((session.to_string(), cols, rows));
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detail_area_for_typical_terminal_without_approvals() {
        // 120×40: Agents=28 wide, right-stack=92 wide, Detail=24 rows
        // (3/5 of 40), Mailbox=16 rows (2/5 of 40).
        let total = Rect::new(0, 0, 120, 40);
        let detail = triptych_detail_area(total, false, false).unwrap();
        assert_eq!(detail.x, 28);
        assert_eq!(detail.y, 0);
        assert_eq!(detail.width, 92);
        assert_eq!(detail.height, 24);
    }

    #[test]
    fn detail_area_with_approvals_stripe_loses_one_row() {
        // Same 120×40 with the approvals stripe: stripe takes y=0
        // height=1, body starts at y=1 with height=39; Detail is
        // 3/5 of 39 = 23 rows, starting at y=1.
        let total = Rect::new(0, 0, 120, 40);
        let detail = triptych_detail_area(total, true, false).unwrap();
        assert_eq!(detail.x, 28);
        assert_eq!(detail.y, 1);
        assert_eq!(detail.width, 92);
        assert_eq!(detail.height, 23);
    }

    #[test]
    fn detail_area_returns_none_on_zero_dimension() {
        assert!(triptych_detail_area(Rect::new(0, 0, 0, 40), false, false).is_none());
        assert!(triptych_detail_area(Rect::new(0, 0, 120, 0), false, false).is_none());
    }

    #[test]
    fn detail_area_returns_none_when_sidebar_consumes_everything() {
        // Width 28 (or less) is exactly consumed by the Agents
        // sidebar; the right-stack has zero width, so Detail does too.
        let total = Rect::new(0, 0, 28, 40);
        assert!(triptych_detail_area(total, false, false).is_none());
    }

    #[test]
    fn right_stack_constraints_shrink_mailbox_in_stream_keys() {
        // Normal mode: the classic 60/40 Detail/Mailbox split.
        assert_eq!(
            right_stack_constraints(false),
            [Constraint::Ratio(3, 5), Constraint::Ratio(2, 5)]
        );
        // Stream-keys: Detail takes the rest, Mailbox a fixed 5-row strip
        // (#459). This shared helper is the single source of truth both
        // the render path and `triptych_detail_area` read, so pinning it
        // guards the two-geometry lockstep.
        assert_eq!(
            right_stack_constraints(true),
            [Constraint::Min(0), Constraint::Length(5)]
        );
    }

    #[test]
    fn detail_area_in_stream_keys_expands_to_near_full_height() {
        // 120×40 stream-keys: Mailbox is a fixed Length(5) strip, so
        // Detail gets the remaining 35 rows (vs 24 under the 3/5 split).
        let total = Rect::new(0, 0, 120, 40);
        let detail = triptych_detail_area(total, false, true).unwrap();
        assert_eq!(detail.x, 28);
        assert_eq!(detail.y, 0);
        assert_eq!(detail.width, 92);
        assert_eq!(detail.height, 35);
    }

    #[test]
    fn detail_area_in_stream_keys_with_approvals_stripe() {
        // Same 120×40 with the approvals stripe: body starts at y=1 with
        // height 39; the Length(5) Mailbox leaves Detail 34 rows at y=1.
        let total = Rect::new(0, 0, 120, 40);
        let detail = triptych_detail_area(total, true, true).unwrap();
        assert_eq!(detail.x, 28);
        assert_eq!(detail.y, 1);
        assert_eq!(detail.width, 92);
        assert_eq!(detail.height, 34);
    }

    #[test]
    fn should_sync_returns_true_on_first_call() {
        let cache = HashMap::new();
        assert!(should_sync(&cache, "t-hello-mgr", (92, 24)));
    }

    #[test]
    fn should_sync_returns_false_when_size_unchanged() {
        let mut cache = HashMap::new();
        cache.insert("t-hello-mgr".to_string(), (92, 24));
        assert!(!should_sync(&cache, "t-hello-mgr", (92, 24)));
    }

    #[test]
    fn should_sync_returns_true_when_size_differs() {
        let mut cache = HashMap::new();
        cache.insert("t-hello-mgr".to_string(), (92, 24));
        // Width changed.
        assert!(should_sync(&cache, "t-hello-mgr", (100, 24)));
        // Height changed.
        assert!(should_sync(&cache, "t-hello-mgr", (92, 25)));
    }

    #[test]
    fn should_sync_treats_different_sessions_independently() {
        let mut cache = HashMap::new();
        cache.insert("t-hello-mgr".to_string(), (92, 24));
        // Same size, different session → first-sync, should fire.
        assert!(should_sync(&cache, "t-hello-dev", (92, 24)));
    }

    use super::test_support::MockPaneResizer;

    #[test]
    fn mock_resizer_records_calls() {
        let m = MockPaneResizer::default();
        m.resize("t-a", 100, 30);
        m.resize("t-b", 80, 20);
        let calls = m.calls.lock().unwrap();
        assert_eq!(calls.len(), 2);
        assert_eq!(calls[0], ("t-a".to_string(), 100, 30));
        assert_eq!(calls[1], ("t-b".to_string(), 80, 20));
    }

    /// The CI guard for #312: pins the production verb. T-199/#210's
    /// tests asserted the sync *decision* via `MockPaneResizer` but
    /// never the *verb*, so a `resize-window`→`resize-pane` slip is
    /// invisible to them — exactly how #312 recurred. This runs in the
    /// default suite and fails the moment the verb regresses.
    #[test]
    fn resize_argv_is_resize_window_never_resize_pane() {
        let argv = super::resize_window_argv("t-hello-mgr", 92, 24);
        assert_eq!(
            argv[0], "resize-window",
            "MUST be `resize-window`: `resize-pane` silently no-ops on \
             the sole pane of a clientless detached session and reopens \
             #312"
        );
        assert_ne!(argv[0], "resize-pane", "the #312 regression verb");
        assert_eq!(
            argv,
            ["resize-window", "-t", "t-hello-mgr", "-x", "92", "-y", "24"].map(str::to_string)
        );
    }

    /// Empirical #312 repro, codified. `#[ignore]` because — unlike the
    /// rest of this crate's hermetic mock tests — it spawns a real
    /// `tmux` server; run with `cargo test -- --ignored` on a tmux
    /// host. Proves the production resizer actually shrinks a clientless
    /// session created the way `team-core::supervisor` creates it (the
    /// real-effect check #210 lacked). Against the pre-fix `resize-pane`
    /// code this fails: the window stays 200×50.
    #[test]
    #[ignore = "spawns a real tmux server; run with --ignored on a tmux host"]
    fn resize_window_actually_shrinks_a_clientless_session() {
        let session = "t312-regression-probe";
        let kill = || {
            let _ = Command::new("tmux")
                .args(["kill-session", "-t", session])
                .status();
        };
        kill();
        // Mirror team-core::supervisor: detached, clientless, 200×50.
        let created = Command::new("tmux")
            .args([
                "new-session",
                "-d",
                "-x",
                "200",
                "-y",
                "50",
                "-s",
                session,
                "sh",
                "-c",
                "while :; do sleep 5; done",
            ])
            .status();
        if !matches!(created, Ok(s) if s.success()) {
            // No usable tmux in this environment — nothing to assert.
            return;
        }

        TmuxPaneResizer.resize(session, 80, 24);

        let out = Command::new("tmux")
            .args([
                "display-message",
                "-p",
                "-t",
                session,
                "#{window_width}x#{window_height}",
            ])
            .output()
            .expect("tmux display-message");
        let geom = String::from_utf8_lossy(&out.stdout).trim().to_string();
        kill();

        assert_eq!(
            geom, "80x24",
            "resizer did not shrink the clientless window (got `{geom}`) \
             — the resize-pane regression (#312)"
        );
    }
}