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
//! Bottom statusline — `·`-separated key hints contextual to the
//! focused pane, with the always-visible `· t tutorial` hint pinned
//! to the right per SPEC §4. Styles inactive hints muted so the
//! contextual ones read as the actionable surface.
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget};
use crate::app::App;
use crate::triptych::Pane;
pub fn draw(f: &mut ratatui::Frame<'_>, area: Rect, app: &App) {
Statusline { app }.render(area, f.buffer_mut());
}
pub struct Statusline<'a> {
pub app: &'a App,
}
impl Widget for Statusline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let muted = Style::default().fg(self.app.capabilities.muted());
// T-108 stream-keys: when stream-mode is active the
// statusline becomes the load-bearing visual indicator —
// bright reversed banner across the whole row, "STREAM-KEYS
// → <agent> · Ctrl+E to exit". The bright reversed style
// matches the approvals stripe affordance so the operator
// reads it as a sticky modal warning even in monochrome
// terminals where colour alone wouldn't carry.
if matches!(self.app.stage, crate::app::Stage::StreamKeys) {
let target_id = self
.app
.selected_agent_id()
.unwrap_or_else(|| "<no agent>".into());
let target = crate::data::agent_label(&self.app.team, &target_id);
let banner = format!(
"● STREAM-KEYS → {target} keys → tmux pane · Ctrl+Shift+↑/↓ switch agent · Ctrl+E to exit"
);
let style = Style::default()
.fg(self.app.capabilities.accent())
.add_modifier(Modifier::REVERSED | Modifier::BOLD);
Paragraph::new(banner)
.style(style)
.alignment(Alignment::Left)
.render(area, buf);
return;
}
// T-074 bug 7: the Tab pane-cycle chord is the load-bearing
// navigation primitive — operators who don't discover it get
// stranded in whichever pane Tab dropped them into. Pin it
// as the first segment of the statusline in *every* pane,
// styled bold + accented so it stands out from the muted
// contextual hints.
let tab_hint = Span::styled(
"Tab cycle panes",
Style::default()
.fg(self.app.capabilities.accent())
.add_modifier(Modifier::BOLD),
);
let sep = Span::styled(" · ", muted);
let contextual = match self.app.focused_pane {
Pane::Roster => "/ search · ⏎ open · @ send · q quit",
// T-108: surface the Ctrl+E entry chord when detail is
// focused so stream-keys is discoverable without
// opening the help overlay.
Pane::Detail => "Ctrl+E stream keys · / filter · w wall · @ send · q quit",
// T-131 PR-4: per-row mailbox UX is the load-bearing
// discoverability surface on a fresh install — operators
// hovering on the mailbox should see the new keystrokes
// here. Trimmed to ~50 cols of context: row nav, filter,
// search, detail modal, quit; ← → tab cycle and !
// broadcast both stay discoverable via the visible tabs
// row and the help overlay respectively.
Pane::Mailbox => "j/k row · f filter · / search · ⏎ detail · q quit",
};
let left = Line::from(vec![tab_hint, sep, Span::styled(contextual, muted)]);
// Always-visible right-anchor hint per SPEC §4.
let right = "? help · t tutorial";
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Min(0),
Constraint::Length(right.len() as u16 + 1),
])
.split(area);
Paragraph::new(left)
.alignment(Alignment::Left)
.render(cols[0], buf);
Paragraph::new(right)
.style(muted)
.alignment(Alignment::Right)
.render(cols[1], buf);
}
}