use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Margin, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use tracing::Level;
use crate::logging::LogLine;
use crate::tui::app::App;
use crate::tui::format::clock;
use crate::tui::pod_logs::LogSource;
pub(in crate::tui) fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let area = area.inner(Margin::new(2, 0));
let [title_row, _, lines] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Fill(1),
])
.areas(area);
app.log_view.height = usize::from(lines.height);
let theme = &app.theme;
let (logs, view) = app.shown_logs();
let offset = view.offset(logs);
let window = logs.window(view.min, view.height, offset);
let pod = view.source == LogSource::Pod;
let heading = match (&app.pod_logs.run, pod) {
(Some(run), true) => format!("Logs · pod of run {run}"),
_ => format!("Logs · {} and above", level_name(view.min)),
};
let mut title = vec![Span::styled(heading, theme.title)];
if pod && let Some(error) = &app.pod_logs.error {
title.push(Span::styled(format!(" {error}"), theme.warn));
}
if view.anchor.is_some() {
title.push(Span::styled(
format!(" ({offset} newer lines below: G follows)"),
theme.dim,
));
}
frame.render_widget(Paragraph::new(Line::from(title)), title_row);
let mut items: Vec<ListItem> = window
.lines
.iter()
.map(|line| {
ListItem::new(if pod {
render_pod_line(line, theme)
} else {
render_line(line, theme)
})
})
.collect();
if items.is_empty() && pod {
items.push(ListItem::new(Span::styled(
"nothing kept yet: the pod's log is kept while overbrainer follows the run",
theme.dim,
)));
} else if items.is_empty() && matches!(view.min, Level::DEBUG | Level::TRACE) {
items.push(ListItem::new(Span::styled(
"nothing captured at this level: OVERBRAINER_LOG sets what is captured",
theme.dim,
)));
}
frame.render_widget(List::new(items), lines);
}
fn render_line<'a>(line: &'a LogLine, theme: &crate::tui::theme::Theme) -> Line<'a> {
Line::from(vec![
Span::styled(clock(line.time), theme.dim),
Span::raw(" "),
Span::styled(
format!("{:<5}", level_name(line.level)),
theme.level(line.level),
),
Span::raw(" "),
Span::styled(format!("{}: ", short_target(&line.target)), theme.dim),
Span::raw(line.message.as_str()),
])
}
fn render_pod_line<'a>(line: &'a LogLine, theme: &crate::tui::theme::Theme) -> Line<'a> {
Line::from(vec![
Span::styled(clock(line.time), theme.dim),
Span::raw(" "),
Span::styled(format!("{:<5}", line.target), theme.dim),
Span::raw(" "),
Span::raw(line.message.as_str()),
])
}
fn short_target(target: &str) -> &str {
target.strip_prefix("overbrainer::").unwrap_or(target)
}
pub(in crate::tui) fn level_name(level: Level) -> &'static str {
match level {
Level::ERROR => "ERROR",
Level::WARN => "WARN",
Level::INFO => "INFO",
Level::DEBUG => "DEBUG",
_ => "TRACE",
}
}
pub(in crate::tui) fn export_line(line: &LogLine) -> String {
let line = format!(
"{} {} {}: {}",
crate::runs::rfc3339(line.time),
level_name(line.level),
short_target(&line.target),
line.message
);
let mut escaped = String::with_capacity(line.len());
for c in line.chars() {
match c {
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
c if c.is_control() => escaped.extend(c.escape_unicode()),
c => escaped.push(c),
}
}
escaped
}
#[cfg(test)]
mod tests {
use std::time::{Duration, UNIX_EPOCH};
use super::*;
#[test]
fn export_line_formats_time_level_target_and_message() {
let line = LogLine {
seq: 1,
level: Level::INFO,
target: "overbrainer::pipeline".into(),
time: UNIX_EPOCH + Duration::from_secs(1_790_000_000),
message: "did a thing".into(),
};
assert_eq!(
export_line(&line),
"2026-09-21T14:13:20Z INFO pipeline: did a thing"
);
}
#[test]
fn export_line_escapes_control_characters_to_stay_on_one_line() {
let line = LogLine {
seq: 1,
level: Level::WARN,
target: "overbrainer::llm".into(),
time: UNIX_EPOCH + Duration::from_secs(1_790_000_000),
message: "first\r\nsecond\tthird\u{1b}[0m".into(),
};
assert_eq!(
export_line(&line),
"2026-09-21T14:13:20Z WARN llm: first\\r\\nsecond\\u{9}third\\u{1b}[0m"
);
}
}