use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
type Hint = (&'static str, &'static str);
const LEADER_LABEL: &str = " C-a ";
const HELP_HINT: Hint = ("?", "help");
const MOVE_HINT: Hint = ("↑↓", "move");
const ATTACH_HINT: Hint = ("⏎", "attach");
const START_STOP_HINT: Hint = ("s", "start/stop");
const RESTART_HINT: Hint = ("r", "restart");
const KILL_HINT: Hint = ("x", "kill");
const AUTOSTART_HINT: Hint = ("t", "autostart");
const ADD_HINT: Hint = ("a", "add");
const CLOSE_HINT: Hint = ("d", "close");
const REOPEN_HINT: Hint = ("u", "reopen");
const DETACH_HINT: Hint = ("h", "detach");
const PROCESS_HINTS: &[Hint] = &[
MOVE_HINT,
ATTACH_HINT,
START_STOP_HINT,
RESTART_HINT,
KILL_HINT,
AUTOSTART_HINT,
REOPEN_HINT,
ADD_HINT,
];
const PROCESS_HINT_PRIORITY: &[Hint] = &[
START_STOP_HINT,
ADD_HINT,
RESTART_HINT,
KILL_HINT,
MOVE_HINT,
ATTACH_HINT,
AUTOSTART_HINT,
REOPEN_HINT,
];
const AGENT_SESSION_HINTS: &[Hint] = &[
MOVE_HINT,
ATTACH_HINT,
START_STOP_HINT,
RESTART_HINT,
KILL_HINT,
CLOSE_HINT,
REOPEN_HINT,
ADD_HINT,
];
const AGENT_SESSION_HINT_PRIORITY: &[Hint] = &[
CLOSE_HINT,
REOPEN_HINT,
START_STOP_HINT,
ADD_HINT,
RESTART_HINT,
KILL_HINT,
MOVE_HINT,
ATTACH_HINT,
];
const PROJECT_HINTS: &[Hint] = &[("↑↓", "move"), ("→", "open"), ("d", "remove")];
const EMPTY_HINTS: &[Hint] = &[ADD_HINT, REOPEN_HINT, ("n", "new"), ("o", "projects")];
const TERMINAL_HINTS: &[Hint] = &[
DETACH_HINT,
START_STOP_HINT,
RESTART_HINT,
KILL_HINT,
REOPEN_HINT,
];
const TERMINAL_HINT_PRIORITY: &[Hint] = &[
START_STOP_HINT,
REOPEN_HINT,
RESTART_HINT,
KILL_HINT,
DETACH_HINT,
];
const TERMINAL_AGENT_SESSION_HINTS: &[Hint] = &[
DETACH_HINT,
START_STOP_HINT,
RESTART_HINT,
KILL_HINT,
CLOSE_HINT,
REOPEN_HINT,
];
const TERMINAL_AGENT_SESSION_HINT_PRIORITY: &[Hint] = &[
CLOSE_HINT,
REOPEN_HINT,
START_STOP_HINT,
RESTART_HINT,
KILL_HINT,
DETACH_HINT,
];
pub enum StatusContext {
Process,
AgentSession,
Project,
Empty,
Terminal,
TerminalAgentSession,
}
impl StatusContext {
fn hints(&self, available_width: u16) -> Vec<Hint> {
match self {
Self::Process => process_hints(available_width),
Self::AgentSession => prioritized_hints(
AGENT_SESSION_HINTS,
AGENT_SESSION_HINT_PRIORITY,
available_width,
),
Self::Project => PROJECT_HINTS.to_vec(),
Self::Empty => EMPTY_HINTS.to_vec(),
Self::Terminal => {
prioritized_hints(TERMINAL_HINTS, TERMINAL_HINT_PRIORITY, available_width)
},
Self::TerminalAgentSession => prioritized_hints(
TERMINAL_AGENT_SESSION_HINTS,
TERMINAL_AGENT_SESSION_HINT_PRIORITY,
available_width,
),
}
}
}
const KEY_COLOR: Color = Color::Cyan;
const LABEL_COLOR: Color = Color::DarkGray;
const LEADER_PENDING_BACKGROUND: Color = Color::Cyan;
const LEADER_PENDING_FOREGROUND: Color = Color::Black;
const HINT_GAP: &str = " ";
const ALERT_COLOR: Color = Color::Red;
const ALERT_GLYPH: &str = "⚠";
const NOTICE_PREFIX: &str = " ! ";
#[derive(Clone, Copy)]
pub enum NoticeTone {
Error,
}
pub fn render(
frame: &mut Frame,
area: Rect,
context: StatusContext,
crashed: usize,
notice: Option<(&str, NoticeTone)>,
leader_pending: bool,
) {
let terminal_context = matches!(
context,
StatusContext::Terminal | StatusContext::TerminalAgentSession
);
let leader_pending = leader_pending && terminal_context;
if let Some((notice, tone)) = notice {
let style = if leader_pending {
Style::default()
.fg(LEADER_PENDING_FOREGROUND)
.bg(LEADER_PENDING_BACKGROUND)
.add_modifier(Modifier::BOLD)
} else {
let color = match tone {
NoticeTone::Error => ALERT_COLOR,
};
Style::default().fg(color).add_modifier(Modifier::BOLD)
};
let prefix = match tone {
NoticeTone::Error => NOTICE_PREFIX,
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(format!("{prefix}{notice}"), style)))
.style(style),
area,
);
return;
}
let key_color = if leader_pending {
LEADER_PENDING_FOREGROUND
} else {
KEY_COLOR
};
let label_color = if leader_pending {
LEADER_PENDING_FOREGROUND
} else {
LABEL_COLOR
};
let mut spans = Vec::new();
if terminal_context {
spans.push(Span::styled(LEADER_LABEL, Style::default().fg(key_color)));
}
let alert_width = if crashed > 0 {
alert_label(crashed).chars().count() as u16
} else {
0
};
let leader_width = if terminal_context {
LEADER_LABEL.chars().count() as u16
} else {
0
};
let reserved_width = alert_width
.saturating_add(leader_width)
.saturating_add(hint_width(HELP_HINT));
let available_width = area.width.saturating_sub(reserved_width);
spans.extend(hint_spans(
&context.hints(available_width),
key_color,
label_color,
));
spans.extend(hint_spans(&[HELP_HINT], key_color, label_color));
let style = if leader_pending {
Style::default().bg(LEADER_PENDING_BACKGROUND)
} else {
Style::default()
};
frame.render_widget(Paragraph::new(Line::from(spans)).style(style), area);
if crashed > 0 {
render_alert(frame, area, crashed, leader_pending);
}
}
fn render_alert(frame: &mut Frame, area: Rect, crashed: usize, leader_pending: bool) {
let label = alert_label(crashed);
let width = label.chars().count() as u16;
if area.width <= width {
return;
}
let alert = Rect {
x: area.x + area.width - width,
y: area.y,
width,
height: area.height,
};
let style = if leader_pending {
Style::default()
.fg(LEADER_PENDING_FOREGROUND)
.bg(LEADER_PENDING_BACKGROUND)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(ALERT_COLOR)
.add_modifier(Modifier::BOLD)
};
frame.render_widget(
Paragraph::new(Line::from(Span::styled(label, style))),
alert,
);
}
fn alert_label(crashed: usize) -> String {
format!("{ALERT_GLYPH} {crashed} crashed ")
}
fn process_hints(available_width: u16) -> Vec<Hint> {
prioritized_hints(PROCESS_HINTS, PROCESS_HINT_PRIORITY, available_width)
}
fn prioritized_hints(canonical: &[Hint], priority: &[Hint], available_width: u16) -> Vec<Hint> {
let mut remaining = available_width;
let mut selected = Vec::new();
for hint in priority {
let width = hint_width(*hint);
if width <= remaining {
selected.push(*hint);
remaining -= width;
}
}
canonical
.iter()
.filter(|hint| selected.contains(hint))
.copied()
.collect()
}
fn hint_width((key, label): Hint) -> u16 {
format!("{key} {label}{HINT_GAP}").chars().count() as u16
}
fn hint_spans(hints: &[Hint], key_color: Color, label_color: Color) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for (key, label) in hints {
spans.push(Span::styled(
format!("{key} "),
Style::default().fg(key_color),
));
spans.push(Span::styled(
format!("{label}{HINT_GAP}"),
Style::default().fg(label_color),
));
}
spans
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
#[test]
fn a_wide_process_row_keeps_every_hint() {
assert_eq!(process_hints(u16::MAX), PROCESS_HINTS);
}
#[test]
fn a_narrow_process_row_keeps_the_add_hint() {
let width = hint_width(START_STOP_HINT) + hint_width(ADD_HINT);
assert_eq!(process_hints(width), vec![START_STOP_HINT, ADD_HINT]);
}
#[test]
fn an_agent_session_prioritizes_close_without_autostart() {
let hints = StatusContext::AgentSession.hints(u16::MAX);
assert!(hints.contains(&CLOSE_HINT));
assert!(!hints.contains(&AUTOSTART_HINT));
}
#[test]
fn shows_a_crashed_alert_pinned_right() {
let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), StatusContext::Process, 2, None, false))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn no_alert_when_nothing_has_crashed() {
let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), StatusContext::Process, 0, None, false))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn a_project_row_shows_its_own_hints() {
let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), StatusContext::Project, 0, None, false))
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn a_notice_replaces_the_hints() {
let mut terminal = Terminal::new(TestBackend::new(60, 1)).unwrap();
terminal
.draw(|frame| {
render(
frame,
frame.area(),
StatusContext::Process,
0,
Some(("one: no such file", NoticeTone::Error)),
false,
)
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn a_pending_leader_chord_highlights_the_status_row() {
let width = 60;
let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
terminal
.draw(|frame| render(frame, frame.area(), StatusContext::Terminal, 1, None, true))
.unwrap();
let buffer = terminal.backend().buffer();
for x in 0..width {
let cell = buffer.cell((x, 0)).unwrap();
assert_eq!(cell.bg, LEADER_PENDING_BACKGROUND);
}
}
#[test]
fn a_notice_keeps_the_pending_leader_cue_visible() {
let width = 60;
let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
terminal
.draw(|frame| {
render(
frame,
frame.area(),
StatusContext::Terminal,
0,
Some(("agent: finished", NoticeTone::Error)),
true,
)
})
.unwrap();
let buffer = terminal.backend().buffer();
assert!(buffer.content.iter().any(|cell| cell.symbol() == "!"));
for x in 0..width {
let cell = buffer.cell((x, 0)).unwrap();
assert_eq!(cell.bg, LEADER_PENDING_BACKGROUND);
}
}
}