Files
llmx/codex-rs/cloud-tasks/src/ui.rs
Ed Bayes d3e1beb26c add pulsing dot loading state (#4736)
## Description 
Changes default CLI spinner to pulsing dot


https://github.com/user-attachments/assets/b81225d6-6655-4ead-8cb1-d6568a603d5b

## Tests
Passes CI

---------

Co-authored-by: Fouad Matin <fouad@openai.com>
2025-10-05 21:26:27 -07:00

1068 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use ratatui::layout::Constraint;
use ratatui::layout::Direction;
use ratatui::layout::Layout;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
use ratatui::widgets::Clear;
use ratatui::widgets::List;
use ratatui::widgets::ListItem;
use ratatui::widgets::ListState;
use ratatui::widgets::Padding;
use ratatui::widgets::Paragraph;
use std::sync::OnceLock;
use std::time::Instant;
use crate::app::App;
use crate::app::AttemptView;
use chrono::Local;
use chrono::Utc;
use codex_cloud_tasks_client::AttemptStatus;
use codex_cloud_tasks_client::TaskStatus;
use codex_tui::render_markdown_text;
pub fn draw(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1), // list
Constraint::Length(2), // two-line footer (help + status)
])
.split(area);
if app.new_task.is_some() {
draw_new_task_page(frame, chunks[0], app);
draw_footer(frame, chunks[1], app);
} else {
draw_list(frame, chunks[0], app);
draw_footer(frame, chunks[1], app);
}
if app.diff_overlay.is_some() {
draw_diff_overlay(frame, area, app);
}
if app.env_modal.is_some() {
draw_env_modal(frame, area, app);
}
if app.best_of_modal.is_some() {
draw_best_of_modal(frame, area, app);
}
if app.apply_modal.is_some() {
draw_apply_modal(frame, area, app);
}
}
// ===== Overlay helpers (geometry + styling) =====
static ROUNDED: OnceLock<bool> = OnceLock::new();
fn rounded_enabled() -> bool {
*ROUNDED.get_or_init(|| {
std::env::var("CODEX_TUI_ROUNDED")
.ok()
.map(|v| v == "1")
.unwrap_or(true)
})
}
fn overlay_outer(area: Rect) -> Rect {
let outer_v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(10),
Constraint::Percentage(80),
Constraint::Percentage(10),
])
.split(area)[1];
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(10),
Constraint::Percentage(80),
Constraint::Percentage(10),
])
.split(outer_v)[1]
}
fn overlay_block() -> Block<'static> {
let base = Block::default().borders(Borders::ALL);
let base = if rounded_enabled() {
base.border_type(BorderType::Rounded)
} else {
base
};
base.padding(Padding::new(2, 2, 1, 1))
}
fn overlay_content(area: Rect) -> Rect {
overlay_block().inner(area)
}
pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) {
let title_spans = {
let mut spans: Vec<ratatui::text::Span> = vec!["New Task".magenta().bold()];
if let Some(id) = app
.new_task
.as_ref()
.and_then(|p| p.env_id.as_ref())
.cloned()
{
spans.push("".into());
// Try to map id to label
let label = app
.environments
.iter()
.find(|r| r.id == id)
.and_then(|r| r.label.clone())
.unwrap_or(id);
spans.push(label.dim());
} else {
spans.push("".into());
spans.push("Env: none (press ctrl-o to choose)".red());
}
if let Some(page) = app.new_task.as_ref() {
spans.push("".into());
let attempts = page.best_of_n;
let label = format!(
"{} attempt{}",
attempts,
if attempts == 1 { "" } else { "s" }
);
spans.push(label.cyan());
}
spans
};
let block = Block::default()
.borders(Borders::ALL)
.title(Line::from(title_spans));
frame.render_widget(Clear, area);
frame.render_widget(block.clone(), area);
let content = block.inner(area);
// Expand composer height up to (terminal height - 6), with a 3-line minimum.
let max_allowed = frame.area().height.saturating_sub(6).max(3);
let desired = app
.new_task
.as_ref()
.map(|p| p.composer.desired_height(content.width))
.unwrap_or(3)
.clamp(3, max_allowed);
// Anchor the composer to the bottom-left by allocating a flexible spacer
// above it and a fixed `desired`-height area for the composer.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(desired)])
.split(content);
let composer_area = rows[1];
if let Some(page) = app.new_task.as_ref() {
page.composer.render_ref(composer_area, frame.buffer_mut());
// Composer renders its own footer hints; no extra row here.
}
// Place cursor where composer wants it
if let Some(page) = app.new_task.as_ref()
&& let Some((x, y)) = page.composer.cursor_pos(composer_area)
{
frame.set_cursor_position((x, y));
}
}
fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) {
let items: Vec<ListItem> = app.tasks.iter().map(|t| render_task_item(app, t)).collect();
// Selection reflects the actual task index (no artificial spacer item).
let mut state = ListState::default().with_selected(Some(app.selected));
// Dim task list when a modal/overlay is active to emphasize focus.
let dim_bg = app.env_modal.is_some()
|| app.apply_modal.is_some()
|| app.best_of_modal.is_some()
|| app.diff_overlay.is_some();
// Dynamic title includes current environment filter
let suffix_span = if let Some(ref id) = app.env_filter {
let label = app
.environments
.iter()
.find(|r| &r.id == id)
.and_then(|r| r.label.clone())
.unwrap_or_else(|| "Selected".to_string());
format!("{label}").dim()
} else {
" • All".dim()
};
// Percent scrolled based on selection position in the list (0% at top, 100% at bottom).
let percent_span = if app.tasks.len() <= 1 {
" • 0%".dim()
} else {
let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32;
format!("{}%", p.clamp(0, 100)).dim()
};
let title_line = {
let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]);
if dim_bg {
base.style(Style::default().add_modifier(Modifier::DIM))
} else {
base
}
};
let block = Block::default().borders(Borders::ALL).title(title_line);
// Render the outer block first
frame.render_widget(block.clone(), area);
// Draw list inside with a persistent top spacer row
let inner = block.inner(area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
let mut list = List::new(items)
.highlight_symbol(" ")
.highlight_style(Style::default().bold());
if dim_bg {
list = list.style(Style::default().add_modifier(Modifier::DIM));
}
frame.render_stateful_widget(list, rows[1], &mut state);
// In-box spinner during initial/refresh loads
if app.refresh_inflight {
draw_centered_spinner(frame, inner, &mut app.spinner_start, "Loading tasks…");
}
}
fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
let mut help = vec![
"↑/↓".dim(),
": Move ".dim(),
"r".dim(),
": Refresh ".dim(),
"Enter".dim(),
": Open ".dim(),
];
// Apply hint; show disabled note when overlay is open without a diff.
if let Some(ov) = app.diff_overlay.as_ref() {
if !ov.current_can_apply() {
help.push("a".dim());
help.push(": Apply (disabled) ".dim());
} else {
help.push("a".dim());
help.push(": Apply ".dim());
}
if ov.attempt_count() > 1 {
help.push("Tab".dim());
help.push(": Next attempt ".dim());
help.push("[ ]".dim());
help.push(": Cycle attempts ".dim());
}
} else {
help.push("a".dim());
help.push(": Apply ".dim());
}
help.push("o : Set Env ".dim());
if app.new_task.is_some() {
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
help.push("(editing new task) ".dim());
} else {
help.push("n : New Task ".dim());
}
help.extend(vec!["q".dim(), ": Quit ".dim()]);
// Split footer area into two rows: help+spinner (top) and status (bottom)
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
// Top row: help text + spinner at right
let top = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Fill(1), Constraint::Length(18)])
.split(rows[0]);
let para = Paragraph::new(Line::from(help));
// Draw help text; avoid clearing the whole footer area every frame.
frame.render_widget(para, top[0]);
// Right side: spinner or clear the spinner area if idle to prevent stale glyphs.
if app.refresh_inflight
|| app.details_inflight
|| app.env_loading
|| app.apply_preflight_inflight
|| app.apply_inflight
{
draw_inline_spinner(frame, top[1], &mut app.spinner_start, "Loading…");
} else {
frame.render_widget(Clear, top[1]);
}
// Bottom row: status/log text across full width (single-line; sanitize newlines)
let mut status_line = app.status.replace('\n', " ");
if status_line.len() > 2000 {
// hard cap to avoid TUI noise
status_line.truncate(2000);
status_line.push('…');
}
// Clear the status row to avoid trailing characters when the message shrinks.
frame.render_widget(Clear, rows[1]);
let status = Paragraph::new(status_line);
frame.render_widget(status, rows[1]);
}
fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
let inner = overlay_outer(area);
if app.diff_overlay.is_none() {
return;
}
let ov_can_apply = app
.diff_overlay
.as_ref()
.map(super::app::DiffOverlay::current_can_apply)
.unwrap_or(false);
let is_error = app
.diff_overlay
.as_ref()
.and_then(|o| o.sd.wrapped_lines().first().cloned())
.map(|s| s.trim_start().starts_with("Task failed:"))
.unwrap_or(false)
&& !ov_can_apply;
let title = app
.diff_overlay
.as_ref()
.map(|o| o.title.clone())
.unwrap_or_default();
// Title block
let title_ref = title.as_str();
let mut title_spans: Vec<ratatui::text::Span> = if is_error {
vec![
"Details ".magenta(),
"[FAILED]".red().bold(),
" ".into(),
title_ref.magenta(),
]
} else if ov_can_apply {
vec!["Diff: ".magenta(), title_ref.magenta()]
} else {
vec!["Details: ".magenta(), title_ref.magenta()]
};
if let Some(p) = app
.diff_overlay
.as_ref()
.and_then(|o| o.sd.percent_scrolled())
{
title_spans.push("".dim());
title_spans.push(format!("{p}%").dim());
}
frame.render_widget(Clear, inner);
frame.render_widget(
overlay_block().title(Line::from(title_spans)).clone(),
inner,
);
// Content area and optional status bar
let content_full = overlay_content(inner);
let mut content_area = content_full;
if let Some(ov) = app.diff_overlay.as_mut() {
let has_text = ov.current_attempt().is_some_and(AttemptView::has_text);
let has_diff = ov.current_attempt().is_some_and(AttemptView::has_diff) || ov.base_can_apply;
if has_diff || has_text {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(content_full);
// Status bar label
let mut spans: Vec<ratatui::text::Span> = Vec::new();
if has_diff && has_text {
let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) {
"[Prompt]".magenta().bold()
} else {
"Prompt".dim()
};
let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) {
"[Diff]".magenta().bold()
} else {
"Diff".dim()
};
spans.extend(vec![
prompt_lbl,
" ".into(),
diff_lbl,
" ".into(),
"(← → to switch view)".dim(),
]);
} else if has_text {
spans.push("Conversation".magenta().bold());
} else {
spans.push("Diff".magenta().bold());
}
if let Some(total) = ov.expected_attempts().or({
if ov.attempts.is_empty() {
None
} else {
Some(ov.attempts.len())
}
}) && total > 1
{
spans.extend(vec![
" ".into(),
format!("Attempt {}/{}", ov.selected_attempt + 1, total)
.bold()
.dim(),
" ".into(),
"(Tab/Shift-Tab or [ ] to cycle attempts)".dim(),
]);
}
frame.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
ov.sd.set_width(rows[1].width);
ov.sd.set_viewport(rows[1].height);
content_area = rows[1];
} else {
ov.sd.set_width(content_full.width);
ov.sd.set_viewport(content_full.height);
content_area = content_full;
}
}
// Styled content render
// Choose styling by the active view, not just presence of a diff
let is_diff_view = app
.diff_overlay
.as_ref()
.map(|o| matches!(o.current_view, crate::app::DetailView::Diff))
.unwrap_or(false);
let styled_lines: Vec<Line<'static>> = if is_diff_view {
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
raw.unwrap_or(&[])
.iter()
.map(|l| style_diff_line(l))
.collect()
} else {
app.diff_overlay
.as_ref()
.map(|o| style_conversation_lines(&o.sd, o.current_attempt()))
.unwrap_or_default()
};
let raw_empty = app
.diff_overlay
.as_ref()
.map(|o| o.sd.wrapped_lines().is_empty())
.unwrap_or(true);
if app.details_inflight && raw_empty {
draw_centered_spinner(
frame,
content_area,
&mut app.spinner_start,
"Loading details…",
);
} else {
let scroll = app
.diff_overlay
.as_ref()
.map(|o| o.sd.state.scroll)
.unwrap_or(0);
let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0));
frame.render_widget(content, content_area);
}
}
pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let inner = overlay_outer(area);
let title = Line::from("Apply Changes?".magenta().bold());
let block = overlay_block().title(title);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
if let Some(m) = &app.apply_modal {
// Header
let header = Paragraph::new(Line::from(
format!("Apply '{}' ?", m.title).magenta().bold(),
))
.wrap(Wrap { trim: true });
// Footer instructions
let footer =
Paragraph::new(Line::from("Press Y to apply, P to preflight, N to cancel.").dim())
.wrap(Wrap { trim: true });
// Split into header/body/footer
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(content);
frame.render_widget(header, rows[0]);
// Body: spinner while preflight/apply runs; otherwise show result message and path lists
if app.apply_preflight_inflight {
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Checking…");
} else if app.apply_inflight {
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Applying…");
} else if m.result_message.is_none() {
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Loading…");
} else if let Some(msg) = &m.result_message {
let mut body_lines: Vec<Line> = Vec::new();
let first = match m.result_level {
Some(crate::app::ApplyResultLevel::Success) => msg.clone().green(),
Some(crate::app::ApplyResultLevel::Partial) => msg.clone().magenta(),
Some(crate::app::ApplyResultLevel::Error) => msg.clone().red(),
None => msg.clone().into(),
};
body_lines.push(Line::from(first));
// On partial or error, show conflicts/skips if present
if !matches!(m.result_level, Some(crate::app::ApplyResultLevel::Success)) {
use ratatui::text::Span;
if !m.conflict_paths.is_empty() {
body_lines.push(Line::from(""));
body_lines.push(
Line::from(format!("Conflicts ({}):", m.conflict_paths.len()))
.red()
.bold(),
);
for p in &m.conflict_paths {
body_lines
.push(Line::from(vec!["".into(), Span::raw(p.clone()).dim()]));
}
}
if !m.skipped_paths.is_empty() {
body_lines.push(Line::from(""));
body_lines.push(
Line::from(format!("Skipped ({}):", m.skipped_paths.len()))
.magenta()
.bold(),
);
for p in &m.skipped_paths {
body_lines
.push(Line::from(vec!["".into(), Span::raw(p.clone()).dim()]));
}
}
}
let body = Paragraph::new(body_lines).wrap(Wrap { trim: true });
frame.render_widget(body, rows[1]);
}
frame.render_widget(footer, rows[2]);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ConversationSpeaker {
User,
Assistant,
}
fn style_conversation_lines(
sd: &crate::scrollable_diff::ScrollableDiff,
attempt: Option<&AttemptView>,
) -> Vec<Line<'static>> {
use ratatui::text::Span;
let wrapped = sd.wrapped_lines();
if wrapped.is_empty() {
return Vec::new();
}
let indices = sd.wrapped_src_indices();
let mut styled: Vec<Line<'static>> = Vec::new();
let mut speaker: Option<ConversationSpeaker> = None;
let mut in_code = false;
let mut last_src: Option<usize> = None;
let mut bullet_indent: Option<usize> = None;
for (display, &src_idx) in wrapped.iter().zip(indices.iter()) {
let raw = sd.raw_line_at(src_idx);
let trimmed = raw.trim();
let is_new_raw = last_src.map(|prev| prev != src_idx).unwrap_or(true);
if trimmed.eq_ignore_ascii_case("user:") {
speaker = Some(ConversationSpeaker::User);
in_code = false;
bullet_indent = None;
styled.push(conversation_header_line(ConversationSpeaker::User, None));
last_src = Some(src_idx);
continue;
}
if trimmed.eq_ignore_ascii_case("assistant:") {
speaker = Some(ConversationSpeaker::Assistant);
in_code = false;
bullet_indent = None;
styled.push(conversation_header_line(
ConversationSpeaker::Assistant,
attempt,
));
last_src = Some(src_idx);
continue;
}
if raw.is_empty() {
let mut spans: Vec<Span> = Vec::new();
if let Some(role) = speaker {
spans.push(conversation_gutter_span(role));
} else {
spans.push(Span::raw(String::new()));
}
styled.push(Line::from(spans));
last_src = Some(src_idx);
bullet_indent = None;
continue;
}
if is_new_raw {
let trimmed_start = raw.trim_start();
if trimmed_start.starts_with("```") {
in_code = !in_code;
bullet_indent = None;
} else if !in_code
&& (trimmed_start.starts_with("- ") || trimmed_start.starts_with("* "))
{
let indent = raw.chars().take_while(|c| c.is_whitespace()).count();
bullet_indent = Some(indent);
} else if !in_code {
bullet_indent = None;
}
}
let mut spans: Vec<Span> = Vec::new();
if let Some(role) = speaker {
spans.push(conversation_gutter_span(role));
}
spans.extend(conversation_text_spans(
display,
in_code,
is_new_raw,
bullet_indent,
));
styled.push(Line::from(spans));
last_src = Some(src_idx);
}
if styled.is_empty() {
wrapped.iter().map(|l| Line::from(l.to_string())).collect()
} else {
styled
}
}
fn conversation_header_line(
speaker: ConversationSpeaker,
attempt: Option<&AttemptView>,
) -> Line<'static> {
use ratatui::text::Span;
let mut spans: Vec<Span> = vec!["".dim()];
match speaker {
ConversationSpeaker::User => {
spans.push("User".cyan().bold());
spans.push(" prompt".dim());
}
ConversationSpeaker::Assistant => {
spans.push("Assistant".magenta().bold());
spans.push(" response".dim());
if let Some(attempt) = attempt
&& let Some(status_span) = attempt_status_span(attempt.status)
{
spans.push("".dim());
spans.push(status_span);
}
}
}
Line::from(spans)
}
fn conversation_gutter_span(speaker: ConversationSpeaker) -> ratatui::text::Span<'static> {
match speaker {
ConversationSpeaker::User => "".cyan().dim(),
ConversationSpeaker::Assistant => "".magenta().dim(),
}
}
fn conversation_text_spans(
display: &str,
in_code: bool,
is_new_raw: bool,
bullet_indent: Option<usize>,
) -> Vec<ratatui::text::Span<'static>> {
use ratatui::text::Span;
if in_code {
return vec![Span::styled(
display.to_string(),
Style::default().fg(Color::Cyan),
)];
}
let trimmed = display.trim_start();
if let Some(indent) = bullet_indent {
if is_new_raw {
let rest = trimmed.get(2..).unwrap_or("").trim_start();
let mut spans: Vec<Span> = Vec::new();
if indent > 0 {
spans.push(Span::raw(" ".repeat(indent)));
}
spans.push("".into());
spans.push(Span::raw(rest.to_string()));
return spans;
}
let mut continuation = String::new();
continuation.push_str(&" ".repeat(indent + 2));
continuation.push_str(trimmed);
return vec![Span::raw(continuation)];
}
if is_new_raw
&& (trimmed.starts_with("### ") || trimmed.starts_with("## ") || trimmed.starts_with("# "))
{
return vec![Span::styled(
display.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)];
}
let mut rendered = render_markdown_text(display);
if rendered.lines.is_empty() {
return vec![Span::raw(display.to_string())];
}
// `render_markdown_text` can yield multiple lines when the input contains
// explicit breaks. We only expect a single line here; join the spans of the
// first rendered line for styling.
rendered.lines.remove(0).spans.into_iter().collect()
}
fn attempt_status_span(status: AttemptStatus) -> Option<ratatui::text::Span<'static>> {
match status {
AttemptStatus::Completed => Some("Completed".green()),
AttemptStatus::Failed => Some("Failed".red().bold()),
AttemptStatus::InProgress => Some("In progress".magenta()),
AttemptStatus::Pending => Some("Pending".cyan()),
AttemptStatus::Cancelled => Some("Cancelled".dim()),
AttemptStatus::Unknown => None,
}
}
fn style_diff_line(raw: &str) -> Line<'static> {
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
if raw.starts_with("@@") {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)]);
}
if raw.starts_with("+++") || raw.starts_with("---") {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default().add_modifier(Modifier::DIM),
)]);
}
if raw.starts_with('+') {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default().fg(Color::Green),
)]);
}
if raw.starts_with('-') {
return Line::from(vec![Span::styled(
raw.to_string(),
Style::default().fg(Color::Red),
)]);
}
Line::from(vec![Span::raw(raw.to_string())])
}
fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> ListItem<'static> {
let status = match t.status {
TaskStatus::Ready => "READY".green(),
TaskStatus::Pending => "PENDING".magenta(),
TaskStatus::Applied => "APPLIED".blue(),
TaskStatus::Error => "ERROR".red(),
};
// Title line: [STATUS] Title
let title = Line::from(vec![
"[".into(),
status,
"] ".into(),
t.title.clone().into(),
]);
// Meta line: environment label and relative time (dim)
let mut meta: Vec<ratatui::text::Span> = Vec::new();
if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) {
meta.push(lbl.clone().dim());
}
let when = format_relative_time(t.updated_at).dim();
if !meta.is_empty() {
meta.push(" ".into());
meta.push("".dim());
meta.push(" ".into());
}
meta.push(when);
let meta_line = Line::from(meta);
// Subline: summary when present; otherwise show "no diff"
let sub = if t.summary.files_changed > 0
|| t.summary.lines_added > 0
|| t.summary.lines_removed > 0
{
let adds = t.summary.lines_added;
let dels = t.summary.lines_removed;
let files = t.summary.files_changed;
Line::from(vec![
format!("+{adds}").green(),
"/".into(),
format!("{dels}").red(),
" ".into(),
"".dim(),
" ".into(),
format!("{files}").into(),
" ".into(),
"files".dim(),
])
} else {
Line::from("no diff".to_string().dim())
};
// Insert a blank spacer line after the summary to separate tasks
let spacer = Line::from("");
ListItem::new(vec![title, meta_line, sub, spacer])
}
fn format_relative_time(ts: chrono::DateTime<Utc>) -> String {
let now = Utc::now();
let mut secs = (now - ts).num_seconds();
if secs < 0 {
secs = 0;
}
if secs < 60 {
return format!("{secs}s ago");
}
let mins = secs / 60;
if mins < 60 {
return format!("{mins}m ago");
}
let hours = mins / 60;
if hours < 24 {
return format!("{hours}h ago");
}
let local = ts.with_timezone(&Local);
local.format("%b %e %H:%M").to_string()
}
fn draw_inline_spinner(
frame: &mut Frame,
area: Rect,
spinner_start: &mut Option<Instant>,
label: &str,
) {
use ratatui::widgets::Paragraph;
let start = spinner_start.get_or_insert_with(Instant::now);
let blink_on = (start.elapsed().as_millis() / 600).is_multiple_of(2);
let dot = if blink_on {
"".into()
} else {
"".dim()
};
let label = label.cyan();
let line = Line::from(vec![dot, label]);
frame.render_widget(Paragraph::new(line), area);
}
fn draw_centered_spinner(
frame: &mut Frame,
area: Rect,
spinner_start: &mut Option<Instant>,
label: &str,
) {
// Center a 1xN spinner within the given rect
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(50),
Constraint::Length(1),
Constraint::Percentage(49),
])
.split(area);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(50),
Constraint::Length(18),
Constraint::Percentage(50),
])
.split(rows[1]);
draw_inline_spinner(frame, cols[1], spinner_start, label);
}
// Styling helpers for diff rendering live inline where used.
pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
// Use shared overlay geometry and padding.
let inner = overlay_outer(area);
// Title: primary only; move long hints to a subheader inside content.
let title = Line::from(vec!["Select Environment".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
if app.env_loading {
draw_centered_spinner(
frame,
content,
&mut app.spinner_start,
"Loading environments…",
);
return;
}
// Layout: subheader + search + results list
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // subheader
Constraint::Length(1), // search
Constraint::Min(1), // list
])
.split(content);
// Subheader with usage hints (dim cyan)
let subheader = Paragraph::new(Line::from(
"Type to search, Enter select, Esc cancel; r refresh"
.cyan()
.dim(),
))
.wrap(Wrap { trim: true });
frame.render_widget(subheader, rows[0]);
let query = app
.env_modal
.as_ref()
.map(|m| m.query.clone())
.unwrap_or_default();
let ql = query.to_lowercase();
let search = Paragraph::new(format!("Search: {query}")).wrap(Wrap { trim: true });
frame.render_widget(search, rows[1]);
// Filter environments by query (case-insensitive substring over label/id/hints)
let envs: Vec<&crate::app::EnvironmentRow> = app
.environments
.iter()
.filter(|e| {
if ql.is_empty() {
return true;
}
let mut hay = String::new();
if let Some(l) = &e.label {
hay.push_str(&l.to_lowercase());
hay.push(' ');
}
hay.push_str(&e.id.to_lowercase());
if let Some(h) = &e.repo_hints {
hay.push(' ');
hay.push_str(&h.to_lowercase());
}
hay.contains(&ql)
})
.collect();
let mut items: Vec<ListItem> = Vec::new();
items.push(ListItem::new(Line::from("All Environments (Global)")));
for env in envs.iter() {
let primary = env.label.clone().unwrap_or_else(|| "<unnamed>".to_string());
let mut spans: Vec<ratatui::text::Span> = vec![primary.into()];
if env.is_pinned {
spans.push(" ".into());
spans.push("PINNED".magenta().bold());
}
spans.push(" ".into());
spans.push(env.id.clone().dim());
if let Some(hint) = &env.repo_hints {
spans.push(" ".into());
spans.push(hint.clone().dim());
}
items.push(ListItem::new(Line::from(spans)));
}
let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let sel = sel_desired.min(envs.len());
let mut list_state = ListState::default().with_selected(Some(sel));
let list = List::new(items)
.highlight_symbol(" ")
.highlight_style(Style::default().bold())
.block(Block::default().borders(Borders::NONE));
frame.render_stateful_widget(list, rows[2], &mut list_state);
}
pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let inner = overlay_outer(area);
const MAX_WIDTH: u16 = 40;
const MIN_WIDTH: u16 = 20;
const MAX_HEIGHT: u16 = 12;
const MIN_HEIGHT: u16 = 6;
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
let modal_height = inner
.height
.min(MAX_HEIGHT)
.max(inner.height.min(MIN_HEIGHT));
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, modal_area);
frame.render_widget(block.clone(), modal_area);
let content = overlay_content(modal_area);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
.wrap(Wrap { trim: true });
frame.render_widget(hint, rows[0]);
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let options = [1usize, 2, 3, 4];
let mut items: Vec<ListItem> = Vec::new();
for &attempts in &options {
let noun = if attempts == 1 { "attempt" } else { "attempts" };
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
spans.push(" ".into());
spans.push(format!("{attempts}x parallel").dim());
if attempts == app.best_of_n {
spans.push(" ".into());
spans.push("Current".magenta().bold());
}
items.push(ListItem::new(Line::from(spans)));
}
let sel = selected.min(options.len().saturating_sub(1));
let mut list_state = ListState::default().with_selected(Some(sel));
let list = List::new(items)
.highlight_symbol(" ")
.highlight_style(Style::default().bold())
.block(Block::default().borders(Borders::NONE));
frame.render_stateful_widget(list, rows[1], &mut list_state);
}