use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::theme;
use crate::{
application::Workspace,
domain::process::{ActivityState, Process, ProcessKind},
};
const MARKER_COLOR: Color = Color::Blue;
const ATTENTION_MARKER: &str = "● ";
const ATTENTION_COLOR: Color = Color::Green;
const WORKING_MARKER: &str = "◆ ";
const WORKING_COLOR: Color = Color::Yellow;
const EXPANDED_GLYPH: &str = "▾";
const COLLAPSED_GLYPH: &str = "▸";
const SECTION_INDENT: &str = " ";
const DESCRIPTION_INDENT: &str = " ";
const MANUAL_MARKER: &str = " manual";
const SECTION_RULE: &str = "─";
const RULE_MARGIN: usize = 1;
pub enum SidebarSelection {
Process(usize),
Project(usize),
}
pub fn render(
frame: &mut Frame,
area: Rect,
workspace: &Workspace,
focused: bool,
active_project: &str,
other_projects: &[String],
selection: SidebarSelection,
) {
let block = Block::default()
.borders(Borders::RIGHT)
.border_style(theme::border_style(focused));
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(build_lines(
workspace,
active_project,
other_projects,
selection,
inner.width as usize,
)),
inner,
);
}
fn build_lines(
workspace: &Workspace,
active_project: &str,
other_projects: &[String],
selection: SidebarSelection,
width: usize,
) -> Vec<Line<'static>> {
let (selected_process, selected_project) = match selection {
SidebarSelection::Process(index) => (Some(index), None),
SidebarSelection::Project(index) => (None, Some(index)),
};
let mut lines = Vec::new();
lines.push(project_line(EXPANDED_GLYPH, active_project, true, false));
let processes = workspace.processes();
let mut current: Option<ProcessKind> = None;
for (index, process) in processes.iter().enumerate() {
let kind = *process.kind();
if current != Some(kind) {
if current.is_some() {
lines.push(Line::default());
}
current = Some(kind);
lines.push(header_line(kind, processes, SECTION_INDENT, width));
}
push_item_lines(
&mut lines,
process,
selected_process == Some(index),
SECTION_INDENT,
);
}
for (index, name) in other_projects.iter().enumerate() {
lines.push(Line::default());
lines.push(project_line(
COLLAPSED_GLYPH,
name,
false,
selected_project == Some(index),
));
}
lines
}
fn project_line(glyph: &str, name: &str, active: bool, selected: bool) -> Line<'static> {
let marker = if selected {
theme::SELECTION_MARKER
} else {
" "
};
let name_style = if active || selected {
Style::default()
.fg(theme::SELECTED_COLOR)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme::HEADER_COLOR)
};
Line::from(vec![
Span::styled(
format!("{marker}{glyph} "),
Style::default().fg(MARKER_COLOR),
),
Span::styled(name.to_string(), name_style),
])
}
fn header_line(
kind: ProcessKind,
processes: &[Process],
indent: &str,
width: usize,
) -> Line<'static> {
let total = processes.iter().filter(|p| *p.kind() == kind).count();
let active = processes
.iter()
.filter(|p| *p.kind() == kind && p.state().is_active())
.count();
let title = format!("{indent}{}", theme::section_title(kind));
let count = format!("{active}/{total}");
let used = title.chars().count() + count.chars().count() + RULE_MARGIN * 2;
let rule = SECTION_RULE.repeat(width.saturating_sub(used));
Line::from(vec![
Span::styled(
title,
Style::default()
.fg(theme::HEADER_COLOR)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(rule, Style::default().fg(theme::COUNT_COLOR)),
Span::raw(" "),
Span::styled(count, Style::default().fg(theme::COUNT_COLOR)),
])
}
fn push_item_lines(
lines: &mut Vec<Line<'static>>,
process: &Process,
selected: bool,
indent: &str,
) {
let (glyph, color) = theme::status_indicator(*process.state());
let marker = if selected {
theme::SELECTION_MARKER
} else {
" "
};
let name_style = if selected {
Style::default()
.fg(theme::SELECTED_COLOR)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(
format!("{indent}{marker} "),
Style::default().fg(MARKER_COLOR),
),
Span::styled(format!("{glyph} "), Style::default().fg(color)),
];
if let Some((marker, color)) = activity_indicator(process) {
spans.push(Span::styled(marker.to_string(), Style::default().fg(color)));
}
spans.push(Span::styled(
process.name().as_ref().to_string(),
name_style,
));
if !process.autostart() {
spans.push(Span::styled(
MANUAL_MARKER.to_string(),
Style::default().fg(theme::DESCRIPTION_COLOR),
));
}
lines.push(Line::from(spans));
if let Some(description) = process.description() {
lines.push(Line::from(Span::styled(
format!("{indent}{DESCRIPTION_INDENT}{description}"),
Style::default().fg(theme::DESCRIPTION_COLOR),
)));
}
}
fn activity_indicator(process: &Process) -> Option<(&'static str, Color)> {
if !process.state().is_active() {
return None;
}
match process.activity() {
ActivityState::Idle => None,
ActivityState::Working => Some((WORKING_MARKER, WORKING_COLOR)),
ActivityState::AwaitingInput => Some((ATTENTION_MARKER, ATTENTION_COLOR)),
}
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::domain::{
process::{ProcessState, RestartPolicy},
value::{CommandLine, Description, PaneId, ProcessName},
};
fn process(
id: u64,
name: &str,
kind: ProcessKind,
state: ProcessState,
description: Option<&str>,
) -> Process {
Process::builder()
.id(PaneId::new(id))
.name(ProcessName::try_new(name).unwrap())
.kind(kind)
.command(Some(CommandLine::try_new("true").unwrap()))
.description(description.map(|d| Description::try_new(d).unwrap()))
.restart(RestartPolicy::Never)
.state(state)
.build()
}
fn sample_workspace() -> Workspace {
Workspace::builder()
.processes(vec![
process(
0,
"Claude Code",
ProcessKind::Agent,
ProcessState::Running,
None,
),
process(
1,
"Codex",
ProcessKind::Agent,
ProcessState::Running,
Some("banner display only"),
),
process(
2,
"Blank terminal",
ProcessKind::Terminal,
ProcessState::Pending,
None,
),
process(
3,
"worker",
ProcessKind::Command,
ProcessState::Crashed,
None,
),
])
.selected_index(1)
.build()
}
#[test]
fn renders_the_active_project_expanded_with_others_collapsed() {
let workspace = sample_workspace();
let mut terminal = Terminal::new(TestBackend::new(34, 18)).unwrap();
terminal
.draw(|frame| {
render(
frame,
frame.area(),
&workspace,
true,
"web-api",
&["web-ui".to_string(), "one canary".to_string()],
SidebarSelection::Process(1),
)
})
.unwrap();
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn a_long_process_name_cannot_clip_the_attention_marker() {
let mut waiting = process(
0,
"a process name wider than the sidebar",
ProcessKind::Command,
ProcessState::Running,
None,
);
waiting.set_activity(ActivityState::AwaitingInput);
let workspace = Workspace::builder()
.processes(vec![waiting])
.selected_index(0)
.build();
let mut terminal = Terminal::new(TestBackend::new(16, 5)).unwrap();
terminal
.draw(|frame| {
render(
frame,
frame.area(),
&workspace,
true,
"project",
&[],
SidebarSelection::Process(0),
)
})
.unwrap();
let marker = terminal.backend().buffer().cell((6, 2)).unwrap();
assert_eq!(marker.symbol(), "●");
assert_eq!(marker.fg, ATTENTION_COLOR);
}
#[test]
fn working_activity_is_visibly_distinct_from_idle() {
let mut working = process(
0,
"worker",
ProcessKind::Command,
ProcessState::Running,
None,
);
working.set_activity(ActivityState::Working);
let workspace = Workspace::builder()
.processes(vec![working])
.selected_index(0)
.build();
let mut terminal = Terminal::new(TestBackend::new(16, 5)).unwrap();
terminal
.draw(|frame| {
render(
frame,
frame.area(),
&workspace,
true,
"project",
&[],
SidebarSelection::Process(0),
)
})
.unwrap();
let marker = terminal.backend().buffer().cell((6, 2)).unwrap();
assert_eq!(marker.symbol(), "◆");
assert_eq!(marker.fg, WORKING_COLOR);
}
}