use std::collections::BTreeMap;
use ansi_to_tui::IntoText;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::prelude::{Alignment, Color, Line, Modifier, Span, Style, Text};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
use time::OffsetDateTime;
use cargowatch_core::{
AppConfig, DetectedProcess, LogFilter, SessionEvent, SessionHistoryEntry, SessionInfo,
SessionMode, SessionSelection, SessionState, SessionStatus, SummaryCounts,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UiAction {
Quit,
StartManagedCommand(String),
CancelSession(String),
LoadSession(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FocusPane {
Sessions,
Main,
Diagnostics,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CenterView {
Logs,
Diagnostics,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InputOverlay {
Search { buffer: String },
Command { buffer: String },
}
pub struct Dashboard {
config: AppConfig,
active_sessions: BTreeMap<String, SessionState>,
history_sessions: BTreeMap<String, SessionState>,
history: Vec<SessionHistoryEntry>,
selection: usize,
focus: FocusPane,
center_view: CenterView,
overlay: Option<InputOverlay>,
filter: LogFilter,
follow: bool,
show_raw: bool,
log_scroll: u16,
diagnostic_scroll: u16,
status_message: String,
}
impl Dashboard {
pub fn new(config: AppConfig) -> Self {
let follow = config.auto_follow_running_session;
Self {
config,
active_sessions: BTreeMap::new(),
history_sessions: BTreeMap::new(),
history: Vec::new(),
selection: 0,
focus: FocusPane::Sessions,
center_view: CenterView::Logs,
overlay: None,
filter: LogFilter::default(),
follow,
show_raw: false,
log_scroll: 0,
diagnostic_scroll: 0,
status_message: "Press `n` for a managed run with logs, diagnostics, and artifacts. Detected external builds are summary-only.".to_string()
}
}
pub fn set_history(&mut self, history: Vec<SessionHistoryEntry>) {
self.history = history;
if self.selection >= self.left_items().len() {
self.selection = self.left_items().len().saturating_sub(1);
}
}
pub fn insert_history_session(&mut self, session: SessionState) {
self.history_sessions
.insert(session.info.session_id.clone(), session);
}
pub fn apply_event(&mut self, event: &SessionEvent, max_logs: usize) {
match event {
SessionEvent::SessionStarted(info) => {
self.active_sessions
.entry(info.session_id.clone())
.or_insert_with(|| SessionState::new(info.clone(), max_logs));
}
SessionEvent::ProcessDetected(process) => {
let info = process_to_session_info(process);
self.active_sessions
.entry(info.session_id.clone())
.or_insert_with(|| SessionState::new(info, max_logs));
self.status_message = format!(
"Detected {} on pid {}. Logs, diagnostics, and artifacts require managed mode.",
process.classification.label(),
process.pid
);
}
SessionEvent::ProcessUpdated(process) => {
self.active_sessions
.entry(process.session_id.clone())
.or_insert_with(|| {
SessionState::new(process_to_session_info(process), max_logs)
})
.apply(event);
}
SessionEvent::ProcessGone { session_id, .. } => {
if let Some(session) = self.active_sessions.get_mut(session_id) {
session.apply(event);
}
}
SessionEvent::OutputLine { session_id, .. }
| SessionEvent::Diagnostic { session_id, .. }
| SessionEvent::ArtifactBuilt { session_id, .. }
| SessionEvent::SessionFinished(cargowatch_core::SessionFinished {
session_id, ..
}) => {
if let Some(session) = self.active_sessions.get_mut(session_id) {
session.apply(event);
}
}
}
}
pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Option<UiAction> {
use crossterm::event::{KeyCode, KeyModifiers};
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return self
.selected_session()
.filter(|session| session.info.mode == SessionMode::Managed && session.is_running())
.map(|session| UiAction::CancelSession(session.info.session_id.clone()))
.or(Some(UiAction::Quit));
}
if let Some(overlay) = &mut self.overlay {
match key.code {
KeyCode::Esc => {
self.overlay = None;
self.status_message = "Overlay dismissed.".to_string();
}
KeyCode::Enter => {
let action = match overlay {
InputOverlay::Search { buffer } => {
self.filter.search =
(!buffer.trim().is_empty()).then(|| buffer.trim().to_string());
self.status_message = format!(
"Search {}",
self.filter
.search
.as_deref()
.map(|query| format!("set to `{query}`"))
.unwrap_or_else(|| "cleared".to_string())
);
None
}
InputOverlay::Command { buffer } => {
let command = buffer.trim().to_string();
if command.is_empty() {
self.status_message =
"Enter a command after `n` to start a managed session."
.to_string();
None
} else {
self.status_message = format!("Launching `{command}`...");
Some(UiAction::StartManagedCommand(command))
}
}
};
self.overlay = None;
return action;
}
KeyCode::Backspace => match overlay {
InputOverlay::Search { buffer } | InputOverlay::Command { buffer } => {
buffer.pop();
}
},
KeyCode::Char(ch) => match overlay {
InputOverlay::Search { buffer } | InputOverlay::Command { buffer } => {
buffer.push(ch);
}
},
_ => {}
}
return None;
}
match key.code {
KeyCode::Char('q') => return Some(UiAction::Quit),
KeyCode::Tab => self.focus = self.focus.next(),
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::PageUp => self.scroll_active_pane(-8),
KeyCode::PageDown => self.scroll_active_pane(8),
KeyCode::Char('f') => {
self.follow = !self.follow;
self.status_message = if self.follow {
"Follow mode enabled.".to_string()
} else {
"Follow mode disabled.".to_string()
};
}
KeyCode::Char('r') => {
self.show_raw = !self.show_raw;
self.status_message = if self.show_raw {
"Showing raw output.".to_string()
} else {
"Showing rendered output.".to_string()
};
}
KeyCode::Char('v') => {
self.center_view = match self.center_view {
CenterView::Logs => CenterView::Diagnostics,
CenterView::Diagnostics => CenterView::Logs,
};
}
KeyCode::Char('/') => {
self.overlay = Some(InputOverlay::Search {
buffer: self.filter.search.clone().unwrap_or_default(),
});
}
KeyCode::Char('n') => {
self.overlay = Some(InputOverlay::Command {
buffer: "cargo check".to_string(),
});
}
KeyCode::Char('0') => self.filter = LogFilter::default(),
KeyCode::Char('1') => {
self.filter = LogFilter::only(cargowatch_core::event::Severity::Error)
}
KeyCode::Char('2') => {
self.filter = LogFilter::only(cargowatch_core::event::Severity::Warning)
}
KeyCode::Char('3') => {
self.filter = LogFilter::only(cargowatch_core::event::Severity::Note)
}
KeyCode::Char('4') => {
self.filter = LogFilter::only(cargowatch_core::event::Severity::Help)
}
KeyCode::Char('5') => {
self.filter = LogFilter::only(cargowatch_core::event::Severity::Info)
}
KeyCode::Enter => {
if let Some(session_id) = self.selected_history_id_to_load() {
return Some(UiAction::LoadSession(session_id));
}
}
KeyCode::Char('c') => {
return self
.selected_session()
.filter(|session| {
session.info.mode == SessionMode::Managed && session.is_running()
})
.map(|session| UiAction::CancelSession(session.info.session_id.clone()));
}
_ => {}
}
None
}
pub fn render(&self, frame: &mut Frame) {
let root = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(12),
Constraint::Length(2),
])
.split(root);
let body = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(24),
Constraint::Percentage(51),
Constraint::Percentage(25),
])
.split(rows[1]);
frame.render_widget(self.render_status_bar(), rows[0]);
let mut session_state = ListState::default();
session_state.select(Some(self.selection));
frame.render_stateful_widget(self.render_session_list(), body[0], &mut session_state);
frame.render_widget(self.render_center_pane(), body[1]);
frame.render_widget(self.render_diagnostics_pane(), body[2]);
frame.render_widget(self.render_footer(), rows[2]);
if let Some(overlay) = &self.overlay {
let popup = centered_rect(60, 5, root);
frame.render_widget(Clear, popup);
frame.render_widget(self.render_overlay(overlay), popup);
}
}
fn selected_history_id_to_load(&self) -> Option<String> {
match self.left_items().get(self.selection) {
Some(SessionSelection::History(session_id))
if !self.history_sessions.contains_key(session_id) =>
{
Some(session_id.clone())
}
_ => None,
}
}
fn selected_session(&self) -> Option<&SessionState> {
match self.left_items().get(self.selection) {
Some(SessionSelection::Active(session_id)) => self.active_sessions.get(session_id),
Some(SessionSelection::History(session_id)) => self.history_sessions.get(session_id),
None => None,
}
}
fn left_items(&self) -> Vec<SessionSelection> {
let mut active = self
.active_sessions
.values()
.map(|session| session.history_entry())
.collect::<Vec<_>>();
active.sort_by(|left, right| right.info.started_at.cmp(&left.info.started_at));
let mut items = active
.into_iter()
.map(|entry| SessionSelection::Active(entry.info.session_id))
.collect::<Vec<_>>();
for entry in &self.history {
if !self.active_sessions.contains_key(&entry.info.session_id) {
items.push(SessionSelection::History(entry.info.session_id.clone()));
}
}
items
}
fn move_selection(&mut self, delta: isize) {
match self.focus {
FocusPane::Sessions => {
let len = self.left_items().len();
if len == 0 {
self.selection = 0;
return;
}
let current = self.selection as isize;
self.selection = (current + delta).clamp(0, (len - 1) as isize) as usize;
}
FocusPane::Main => {
self.log_scroll = self.log_scroll.saturating_add_signed(-delta as i16)
}
FocusPane::Diagnostics => {
self.diagnostic_scroll = self.diagnostic_scroll.saturating_add_signed(-delta as i16)
}
}
}
fn scroll_active_pane(&mut self, delta: i16) {
match self.focus {
FocusPane::Sessions => self.move_selection(delta.signum() as isize),
FocusPane::Main => self.log_scroll = self.log_scroll.saturating_add_signed(delta),
FocusPane::Diagnostics => {
self.diagnostic_scroll = self.diagnostic_scroll.saturating_add_signed(delta)
}
}
}
fn render_status_bar(&self) -> Paragraph<'static> {
let selected = self.selected_session();
let title = selected
.map(|session| session.command_line())
.unwrap_or_else(|| "No session selected".to_string());
let workspace = selected
.map(|session| session.workspace_label())
.unwrap_or_else(|| "Waiting for a managed run or detected process".to_string());
let status = selected
.map(|session| format_status(session.info.status, session.duration_ms))
.unwrap_or_else(|| "idle".to_string());
let mode = selected
.map(|session| match session.info.mode {
SessionMode::Managed => "managed",
SessionMode::Detected => "detected",
})
.unwrap_or("monitor");
let lines = Text::from(vec![
Line::from(vec![
Span::styled(
" CargoWatch ",
Style::default()
.bg(self.color(&self.config.theme.accent))
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
mode,
Style::default().fg(self.color(&self.config.theme.info)),
),
Span::raw(" "),
Span::raw(title),
]),
Line::from(vec![
Span::styled(
"workspace ",
Style::default().fg(self.color(&self.config.theme.muted)),
),
Span::raw(workspace),
Span::raw(" "),
Span::styled(
"status ",
Style::default().fg(self.color(&self.config.theme.muted)),
),
Span::raw(status),
]),
]);
Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
}
fn render_session_list(&self) -> List<'static> {
let items = self
.left_items()
.into_iter()
.map(|selection| {
let history = match selection {
SessionSelection::Active(id) => self
.active_sessions
.get(&id)
.map(SessionState::history_entry),
SessionSelection::History(id) => self
.history_sessions
.get(&id)
.map(SessionState::history_entry)
.or_else(|| {
self.history
.iter()
.find(|entry| entry.info.session_id == id)
.cloned()
}),
};
history.unwrap_or_else(empty_history_item)
})
.map(|entry| {
let status_style = style_for_status(&self.config, entry.info.status);
let title = entry.info.title.clone();
let command_line = entry.command_line();
ListItem::new(vec![
Line::from(vec![
Span::styled(
format_status(entry.info.status, entry.duration_ms),
status_style,
),
Span::raw(" "),
Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
]),
Line::from(vec![Span::styled(
command_line,
Style::default().fg(self.color(&self.config.theme.muted)),
)]),
Line::from(vec![Span::raw(format!(
"e:{} w:{} n:{} h:{}",
entry.summary.errors,
entry.summary.warnings,
entry.summary.notes,
entry.summary.help
))]),
])
})
.collect::<Vec<_>>();
let block = titled_block(
self.focus == FocusPane::Sessions,
"Sessions / History",
self.color(&self.config.theme.accent),
);
List::new(items)
.block(block)
.highlight_style(Style::default().bg(Color::DarkGray))
.highlight_symbol(">> ")
}
fn render_center_pane(&self) -> Paragraph<'static> {
let title = match self.center_view {
CenterView::Logs => {
if self.show_raw {
"Live Logs (raw)"
} else {
"Live Logs (rendered)"
}
}
CenterView::Diagnostics => "Structured Diagnostics",
};
let text = match self.center_view {
CenterView::Logs => self.render_logs_text(),
CenterView::Diagnostics => self.render_diagnostic_text(true),
};
Paragraph::new(text)
.block(titled_block(
self.focus == FocusPane::Main,
title,
self.color(&self.config.theme.accent),
))
.wrap(Wrap { trim: false })
.scroll((self.current_log_scroll(), 0))
}
fn render_diagnostics_pane(&self) -> Paragraph<'static> {
let mut lines = Vec::new();
if let Some(session) = self.selected_session() {
lines.extend(render_summary_lines(&self.config, session.summary));
lines.push(Line::default());
let diagnostics = session
.diagnostics
.iter()
.filter(|diagnostic| self.filter.matches_diagnostic(diagnostic))
.take(64)
.collect::<Vec<_>>();
if diagnostics.is_empty() {
lines.push(Line::raw("No diagnostics match the current filter."));
} else {
for diagnostic in diagnostics {
let location = diagnostic
.file
.as_ref()
.map(|file| {
format!("{}:{}", file.display(), diagnostic.line.unwrap_or_default())
})
.unwrap_or_else(|| "unknown location".to_string());
lines.push(Line::from(vec![
Span::styled(
format!("[{}] ", diagnostic.severity_label()),
style_for_severity(&self.config, diagnostic.severity),
),
Span::raw(diagnostic.message.clone()),
]));
lines.push(Line::from(vec![Span::styled(
location,
Style::default().fg(self.color(&self.config.theme.muted)),
)]));
lines.push(Line::default());
}
}
} else {
lines.push(Line::raw(
"Waiting for a selected session. Use `n` for a managed run or leave the dashboard open for detected external build summaries.",
));
}
Paragraph::new(Text::from(lines))
.block(titled_block(
self.focus == FocusPane::Diagnostics,
"Diagnostics",
self.color(&self.config.theme.accent),
))
.wrap(Wrap { trim: false })
.scroll((self.diagnostic_scroll, 0))
}
fn render_footer(&self) -> Paragraph<'static> {
let filter = if self.filter == LogFilter::default() {
"all".to_string()
} else {
format!(
"e:{} w:{} n:{} h:{} i:{}",
self.filter.errors as u8,
self.filter.warnings as u8,
self.filter.notes as u8,
self.filter.help as u8,
self.filter.info as u8
)
};
let footer = Line::from(vec![
Span::raw("Tab panes "),
Span::raw("j/k move "),
Span::raw("PgUp/PgDn scroll "),
Span::raw("n new run "),
Span::raw("c cancel "),
Span::raw("/ search "),
Span::raw("r raw/rendered "),
Span::raw("v log/diag "),
Span::raw("0-5 filter "),
Span::raw("q quit "),
Span::styled(
format!(
"filter:{filter} follow:{}",
if self.follow { "on" } else { "off" }
),
Style::default().fg(self.color(&self.config.theme.info)),
),
]);
Paragraph::new(Text::from(vec![
footer,
Line::from(Span::styled(
self.status_message.clone(),
Style::default().fg(self.color(&self.config.theme.muted)),
)),
]))
.alignment(Alignment::Left)
}
fn render_overlay(&self, overlay: &InputOverlay) -> Paragraph<'static> {
let (title, buffer, hint) = match overlay {
InputOverlay::Search { buffer } => (
"Search Logs",
buffer.as_str(),
"Press Enter to apply, Esc to cancel.",
),
InputOverlay::Command { buffer } => (
"Start Managed Run",
buffer.as_str(),
"Command is parsed shell-style. Example: cargo test -p my_crate",
),
};
Paragraph::new(Text::from(vec![
Line::raw(buffer.to_string()),
Line::default(),
Line::styled(
hint,
Style::default().fg(self.color(&self.config.theme.muted)),
),
]))
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(self.color(&self.config.theme.accent))),
)
.wrap(Wrap { trim: false })
}
fn render_logs_text(&self) -> Text<'static> {
let Some(session) = self.selected_session() else {
return Text::from(vec![
Line::raw("No session selected."),
Line::raw(""),
Line::raw(
"Managed mode captures logs, diagnostics, and artifacts because CargoWatch launches the process.",
),
Line::raw(
"Detected mode only shows best-effort summaries for already-running external builds.",
),
]);
};
let entries = session
.logs
.iter()
.filter(|entry| self.filter.matches_log(entry))
.collect::<Vec<_>>();
if entries.is_empty() {
return Text::from(vec![
Line::raw("No log lines match the current filter."),
Line::raw(""),
Line::raw("Tip: press `0` to reset filters or `/` to edit the search query."),
]);
}
let mut lines = Vec::new();
for entry in entries {
let content = if self.show_raw {
entry.raw.as_deref().unwrap_or(&entry.text)
} else {
&entry.text
};
match content.to_string().into_text() {
Ok(text) => lines.extend(text.lines),
Err(_) => lines.push(Line::styled(
content.to_string(),
style_for_severity(
&self.config,
entry
.severity
.unwrap_or(cargowatch_core::event::Severity::Info),
),
)),
}
lines.push(Line::default());
}
Text::from(lines)
}
fn render_diagnostic_text(&self, detailed: bool) -> Text<'static> {
let Some(session) = self.selected_session() else {
return Text::from("No session selected.");
};
let diagnostics = session
.diagnostics
.iter()
.filter(|diagnostic| self.filter.matches_diagnostic(diagnostic))
.collect::<Vec<_>>();
if diagnostics.is_empty() {
return Text::from("No diagnostics match the current filter.");
}
let mut lines = Vec::new();
for diagnostic in diagnostics {
let body = if detailed {
diagnostic
.rendered
.as_deref()
.unwrap_or(&diagnostic.message)
} else {
&diagnostic.message
};
match body.to_string().into_text() {
Ok(text) => lines.extend(text.lines),
Err(_) => {
lines.push(Line::styled(
body.to_string(),
style_for_severity(&self.config, diagnostic.severity),
));
}
}
lines.push(Line::default());
}
Text::from(lines)
}
fn current_log_scroll(&self) -> u16 {
if self.follow { 65_535 } else { self.log_scroll }
}
fn color(&self, value: &str) -> Color {
parse_color(value).unwrap_or(Color::White)
}
}
impl FocusPane {
fn next(self) -> Self {
match self {
Self::Sessions => Self::Main,
Self::Main => Self::Diagnostics,
Self::Diagnostics => Self::Sessions,
}
}
}
trait DiagnosticSeverityLabel {
fn severity_label(&self) -> &'static str;
}
impl DiagnosticSeverityLabel for cargowatch_core::DiagnosticRecord {
fn severity_label(&self) -> &'static str {
match self.severity {
cargowatch_core::event::Severity::Error => "error",
cargowatch_core::event::Severity::Warning => "warning",
cargowatch_core::event::Severity::Note => "note",
cargowatch_core::event::Severity::Help => "help",
cargowatch_core::event::Severity::Info => "info",
cargowatch_core::event::Severity::Success => "success",
}
}
}
fn process_to_session_info(process: &DetectedProcess) -> SessionInfo {
SessionInfo {
session_id: process.session_id.clone(),
mode: SessionMode::Detected,
title: format!("{} ({})", process.classification.label(), process.pid),
command: process.command.clone(),
cwd: process.cwd.clone().unwrap_or_else(|| ".".into()),
workspace_root: process.workspace_root.clone(),
started_at: process.started_at,
status: SessionStatus::Running,
external_pid: Some(process.pid),
classification: Some(process.classification),
}
}
fn render_summary_lines(config: &AppConfig, summary: SummaryCounts) -> Vec<Line<'static>> {
vec![
Line::from(vec![
Span::styled(
format!("Errors: {}", summary.errors),
Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red)),
),
Span::raw(" "),
Span::styled(
format!("Warnings: {}", summary.warnings),
Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow)),
),
]),
Line::from(vec![
Span::styled(
format!("Notes: {}", summary.notes),
Style::default().fg(parse_color(&config.theme.info).unwrap_or(Color::Blue)),
),
Span::raw(" "),
Span::styled(
format!("Help: {}", summary.help),
Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan)),
),
]),
]
}
fn style_for_status(config: &AppConfig, status: SessionStatus) -> Style {
match status {
SessionStatus::Running => {
Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan))
}
SessionStatus::Succeeded => {
Style::default().fg(parse_color(&config.theme.success).unwrap_or(Color::Green))
}
SessionStatus::Failed => {
Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red))
}
SessionStatus::Cancelled => {
Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow))
}
SessionStatus::Lost => {
Style::default().fg(parse_color(&config.theme.muted).unwrap_or(Color::Gray))
}
}
}
fn style_for_severity(config: &AppConfig, severity: cargowatch_core::event::Severity) -> Style {
match severity {
cargowatch_core::event::Severity::Error => {
Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red))
}
cargowatch_core::event::Severity::Warning => {
Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow))
}
cargowatch_core::event::Severity::Note => {
Style::default().fg(parse_color(&config.theme.info).unwrap_or(Color::Blue))
}
cargowatch_core::event::Severity::Help => {
Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan))
}
cargowatch_core::event::Severity::Info => Style::default().fg(Color::White),
cargowatch_core::event::Severity::Success => {
Style::default().fg(parse_color(&config.theme.success).unwrap_or(Color::Green))
}
}
}
fn parse_color(value: &str) -> Option<Color> {
let hex = value.strip_prefix('#').unwrap_or(value);
if hex.len() != 6 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(red, green, blue))
}
fn titled_block(active: bool, title: &str, accent: Color) -> Block<'static> {
Block::default()
.title(if active {
Span::styled(
title.to_string(),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
)
} else {
Span::raw(title.to_string())
})
.borders(Borders::ALL)
}
fn empty_history_item() -> SessionHistoryEntry {
SessionHistoryEntry {
info: SessionInfo {
session_id: "missing".to_string(),
mode: SessionMode::Managed,
title: "missing session".to_string(),
command: Vec::new(),
cwd: ".".into(),
workspace_root: None,
started_at: OffsetDateTime::UNIX_EPOCH,
status: SessionStatus::Lost,
external_pid: None,
classification: None,
},
finished_at: None,
exit_code: None,
duration_ms: None,
summary: SummaryCounts::default(),
}
}
fn format_status(status: SessionStatus, duration_ms: Option<i64>) -> String {
let base = match status {
SessionStatus::Running => "running",
SessionStatus::Succeeded => "ok",
SessionStatus::Failed => "failed",
SessionStatus::Cancelled => "cancelled",
SessionStatus::Lost => "gone",
};
match duration_ms {
Some(duration_ms) => format!("{base} {}s", duration_ms / 1_000),
None => base.to_string(),
}
}
fn centered_rect(width_percent: u16, height: u16, area: Rect) -> Rect {
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(height),
Constraint::Min(1),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - width_percent) / 2),
Constraint::Percentage(width_percent),
Constraint::Percentage((100 - width_percent) / 2),
])
.split(vertical[1])[1]
}