Skip to main content

teamctl_ui/
statusline.rs

1//! Bottom statusline — `·`-separated key hints contextual to the
2//! focused pane, with the always-visible `· t tutorial` hint pinned
3//! to the right per SPEC §4. Styles inactive hints muted so the
4//! contextual ones read as the actionable surface.
5
6use ratatui::buffer::Buffer;
7use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Paragraph, Widget};
11
12use crate::app::App;
13use crate::triptych::Pane;
14
15pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
16    Statusline { app }.render(area, f.buffer_mut());
17}
18
19pub struct Statusline<'a> {
20    pub app: &'a App,
21}
22
23impl Widget for Statusline<'_> {
24    fn render(self, area: Rect, buf: &mut Buffer) {
25        let muted = Style::default().fg(self.app.capabilities.muted());
26        // T-108 stream-keys: when stream-mode is active the
27        // statusline becomes the load-bearing visual indicator —
28        // bright reversed banner across the whole row, "STREAM-KEYS
29        // → <agent>  ·  Ctrl+E to exit". The bright reversed style
30        // matches the approvals stripe affordance so the operator
31        // reads it as a sticky modal warning even in monochrome
32        // terminals where colour alone wouldn't carry.
33        if matches!(self.app.stage, crate::app::Stage::StreamKeys) {
34            let target_id = self
35                .app
36                .selected_agent_id()
37                .unwrap_or_else(|| "<no agent>".into());
38            let target = crate::data::agent_label(&self.app.team, &target_id);
39            let banner = format!(
40                "● STREAM-KEYS → {target}   keys → tmux pane   ·   Ctrl+Shift+↑/↓ switch agent   ·   Ctrl+E to exit"
41            );
42            let style = Style::default()
43                .fg(self.app.capabilities.accent())
44                .add_modifier(Modifier::REVERSED | Modifier::BOLD);
45            Paragraph::new(banner)
46                .style(style)
47                .alignment(Alignment::Left)
48                .render(area, buf);
49            return;
50        }
51        // T-074 bug 7: the Tab pane-cycle chord is the load-bearing
52        // navigation primitive — operators who don't discover it get
53        // stranded in whichever pane Tab dropped them into. Pin it
54        // as the first segment of the statusline in *every* pane,
55        // styled bold + accented so it stands out from the muted
56        // contextual hints.
57        let tab_hint = Span::styled(
58            "Tab cycle panes",
59            Style::default()
60                .fg(self.app.capabilities.accent())
61                .add_modifier(Modifier::BOLD),
62        );
63        let sep = Span::styled("  ·  ", muted);
64
65        let contextual = match self.app.focused_pane {
66            Pane::Roster => "/ search · ⏎ open · @ send · q quit",
67            // T-108: surface the Ctrl+E entry chord when detail is
68            // focused so stream-keys is discoverable without
69            // opening the help overlay.
70            Pane::Detail => "Ctrl+E stream keys · / filter · w wall · @ send · q quit",
71            // T-131 PR-4: per-row mailbox UX is the load-bearing
72            // discoverability surface on a fresh install — operators
73            // hovering on the mailbox should see the new keystrokes
74            // here. Trimmed to ~50 cols of context: row nav, filter,
75            // search, detail modal, quit; ← → tab cycle and !
76            // broadcast both stay discoverable via the visible tabs
77            // row and the help overlay respectively.
78            Pane::Mailbox => "j/k row · f filter · / search · ⏎ detail · q quit",
79        };
80
81        let left = Line::from(vec![tab_hint, sep, Span::styled(contextual, muted)]);
82
83        // Always-visible right-anchor hint per SPEC §4.
84        let right = "? help · t tutorial";
85
86        let cols = Layout::default()
87            .direction(Direction::Horizontal)
88            .constraints([
89                Constraint::Min(0),
90                Constraint::Length(right.len() as u16 + 1),
91            ])
92            .split(area);
93
94        Paragraph::new(left)
95            .alignment(Alignment::Left)
96            .render(cols[0], buf);
97        Paragraph::new(right)
98            .style(muted)
99            .alignment(Alignment::Right)
100            .render(cols[1], buf);
101    }
102}