use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Row, Table, Wrap,
};
use ratatui::Frame;
use crate::domain::providers::{ProviderAuthStatus, ProviderCatalogEntry, ProviderUsageLimit};
use super::app::{slash_visible_indices, App, Overlay, Screen, TaskDetailView, SLASH_COMMANDS};
use super::components::{
banner, gauge, input as input_widget, modal, status_bar, trace as trace_widget,
};
use super::execution::TaskRunStatus;
use super::orchestration::RecordedStageState;
use super::palette::COMMANDS;
use super::parallel::AgentSlotStatus;
use super::runner::{StageContextSample, TokenUsageSample};
use super::stage::Stage;
use super::theme;
pub fn render(frame: &mut Frame, app: &App) {
let area = frame.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Min(3), Constraint::Length(2), ])
.split(area);
render_header(frame, chunks[0], app);
match app.screen {
Screen::Welcome => render_welcome(frame, chunks[1], app),
Screen::ProviderSetup => render_provider_setup(frame, chunks[1], app),
Screen::OrchestrationList => render_list(frame, chunks[1], app),
Screen::NewOrchestration => render_new(frame, chunks[1], app),
Screen::StageBoard => render_board(frame, chunks[1], app),
Screen::Running => render_running(frame, chunks[1], app),
Screen::TaskExecution => render_task_execution(frame, chunks[1], app, false),
Screen::TaskDecision => render_task_execution(frame, chunks[1], app, true),
Screen::ParallelRunning => render_parallel_running(frame, chunks[1], app),
Screen::ParallelDecision => render_parallel_decision(frame, chunks[1], app),
Screen::Viewer => render_viewer(frame, chunks[1], app),
Screen::Checkpoint => render_checkpoint(frame, chunks[1], app),
Screen::RefinementPrompt => render_refinement_prompt(frame, chunks[1], app),
Screen::ErrorPanel => render_error(frame, chunks[1], app),
Screen::Help => render_help(frame, chunks[1], app),
Screen::Summary => render_summary(frame, chunks[1], app),
}
render_footer(frame, chunks[2], app);
match app.overlay {
Overlay::CommandPalette => render_command_palette(frame, area, app),
Overlay::SlashMenu => render_slash_menu(frame, area, app),
Overlay::ProviderDialog => render_provider_dialog(frame, area, app),
Overlay::Telemetry => render_telemetry(frame, area, app),
Overlay::Confirm => render_confirm(frame, area, app),
Overlay::None => {}
}
}
fn render_welcome(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(6), Constraint::Length(5), Constraint::Length(3), ])
.split(area);
let version = concat!("v", env!("CARGO_PKG_VERSION"));
banner::render(frame, layout[0], version, "", &th);
let cmds: [(&str, &str); 3] = [
("Enter", "criar orquestração com o texto digitado"),
("Esc", "ver orquestrações existentes"),
("/", "menu TUI · etapas, provider, modelo, effort"),
];
let mut cmd_lines = vec![Line::from("")];
for (key, desc) in cmds {
cmd_lines.push(Line::from(vec![
Span::styled(
format!(" {key:<8}"),
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
),
Span::styled(desc, Style::default().fg(th.text_muted)),
]));
}
frame.render_widget(Paragraph::new(Text::from(cmd_lines)), layout[1]);
let props = input_widget::InputProps {
value: &app.input,
placeholder: "Ask anything… descreva a feature em linguagem natural",
mode_line: "",
focused: true,
};
input_widget::render(frame, layout[2], &props, &th);
}
fn render_confirm(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let buttons = [
modal::ModalButton {
label: "Cancelar",
focused: app.confirm_focus == 0,
},
modal::ModalButton {
label: "Sair",
focused: app.confirm_focus == 1,
},
];
modal::render(
frame,
area,
"Sair do SDD",
&[
"Deseja realmente encerrar a sessão?",
"Tab/setas alternam · Enter confirma · Esc cancela.",
],
&buttons,
&th,
);
}
fn current_theme() -> theme::Theme {
let colorterm = std::env::var("COLORTERM").ok();
let term = std::env::var("TERM").ok();
theme::theme(theme::detect(colorterm.as_deref(), term.as_deref()))
}
fn render_command_palette(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
th.overlay_dim(frame, area);
let modal = th.centered_rect(64, 70, area);
frame.render_widget(Clear, modal);
let inner_width = modal.width.saturating_sub(4) as usize;
let visible = app.palette.visible();
let selected = app.palette.selected.min(visible.len().saturating_sub(1));
let mut lines = vec![
Line::from(vec![
Span::styled(
" Comandos ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("esc fecha", Style::default().fg(th.text_muted)),
]),
Line::from(vec![
Span::styled("> ", Style::default().fg(th.primary)),
Span::styled(
app.palette.query.clone(),
Style::default().fg(th.text_strong),
),
Span::styled("▏", Style::default().fg(th.primary)),
]),
Line::from(""),
];
if visible.is_empty() {
lines.push(Line::from(vec![Span::styled(
" nenhum comando corresponde",
Style::default().fg(th.text_muted),
)]));
}
for (row, idx) in visible.iter().enumerate() {
let command = &COMMANDS[*idx];
let is_selected = row == selected;
let label = command.label;
let shortcut = command.shortcut;
let pad = inner_width
.saturating_sub(label.chars().count() + shortcut.chars().count() + 2)
.max(1);
let text = format!(" {label}{}{shortcut} ", " ".repeat(pad));
let style = if is_selected {
th.selection_style()
} else {
Style::default().fg(th.text_strong)
};
lines.push(Line::from(Span::styled(text, style)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(th.primary))
.title(" SDD ");
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(block),
modal,
);
}
fn render_slash_menu(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
if area.width == 0 || area.height == 0 {
return;
}
let visible = slash_visible_indices(&app.slash_query);
let selected = app.slash_index.min(visible.len().saturating_sub(1));
let max_rows = 9usize;
let rows_to_show = visible.len().min(max_rows).max(1);
let height = (rows_to_show + 5) as u16;
let modal = slash_menu_rect(area, height);
frame.render_widget(Clear, modal);
let inner_width = modal.width.saturating_sub(4) as usize;
let start = if selected >= max_rows {
selected + 1 - max_rows
} else {
0
};
let end = (start + max_rows).min(visible.len());
let mut lines = Vec::new();
if visible.is_empty() {
lines.push(Line::from(vec![Span::styled(
" nenhum comando corresponde",
Style::default().fg(th.text_muted),
)]));
} else {
for (row, idx) in visible[start..end].iter().enumerate() {
let command = &SLASH_COMMANDS[*idx];
let is_selected = start + row == selected;
let name_width = 18usize;
let desc_width = inner_width.saturating_sub(name_width + 3).max(8);
let name = format!(" {:<name_width$}", command.name);
let desc = truncate(command.description, desc_width);
let style = if is_selected {
th.selection_style()
} else {
Style::default().fg(th.text_strong)
};
lines.push(Line::from(vec![
Span::styled(name, style),
Span::styled(
desc,
if is_selected {
style
} else {
Style::default().fg(th.text_muted)
},
),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(" /", Style::default().fg(th.text_strong)),
Span::styled(
app.slash_query.clone(),
Style::default()
.fg(th.text_strong)
.add_modifier(Modifier::BOLD),
),
Span::styled("▏", Style::default().fg(th.primary)),
]));
lines.push(Line::from(vec![
Span::styled(
slash_scope_label(app),
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
),
Span::styled(" · ", Style::default().fg(th.text_muted)),
Span::styled(
slash_provider_route(app),
Style::default()
.fg(th.text_strong)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · Tab provider", Style::default().fg(th.text_muted)),
Span::styled(" · ←/→ model", Style::default().fg(th.text_muted)),
Span::styled(" · [/] effort", Style::default().fg(th.text_muted)),
]));
let block = Block::default()
.borders(Borders::LEFT | Borders::RIGHT)
.border_style(Style::default().fg(th.primary))
.style(Style::default().bg(th.overlay_dim));
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(block),
modal,
);
}
fn slash_menu_rect(area: Rect, requested_height: u16) -> Rect {
let height = requested_height.min(area.height);
let y = area
.y
.saturating_add(area.height.saturating_sub(height.saturating_add(4)));
Rect::new(area.x, y, area.width, height)
}
fn render_telemetry(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
th.overlay_dim(frame, area);
let modal = th.centered_rect(72, 70, area);
frame.render_widget(Clear, modal);
let mut lines = vec![
Line::from(vec![
Span::styled(
" Telemetria ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled("t/esc fecha", Style::default().fg(th.text_muted)),
]),
Line::from(""),
th.label_value("Uso da sessão", &session_usage_label(app)),
th.label_value("Última geração", &last_usage_label(app)),
];
let usage = &app.session_usage;
let max = (usage.total_tokens_estimate.max(1)) as u64;
let rows = vec![
("Entrada".to_string(), usage.input_tokens_estimate as u64),
("Saída".to_string(), usage.output_tokens_estimate as u64),
("Total".to_string(), usage.total_tokens_estimate as u64),
];
let bar_w = modal.width.saturating_sub(28).clamp(8, 40);
lines.push(Line::from(""));
lines.extend(gauge::dashboard("Uso de tokens", &rows, max, bar_w, &th));
if let Some(provider) = app.provider_selection.as_ref() {
let budget = provider
.token_budget
.map(|value| format!("{value} tokens"))
.unwrap_or_else(|| "padrão".to_string());
lines.push(th.label_value("Budget", &budget));
if let Some(token_budget) = provider.token_budget {
lines.push(th.label_value(
"Saldo local",
&token_budget_remaining_label(
token_budget,
app.session_usage.total_tokens_estimate,
),
));
}
lines.push(th.label_value(
"Limites",
&provider_usage_limits_label(&provider.id, &provider.usage_limits),
));
}
lines.push(Line::from(""));
lines.push(th.section_header("Roteamento por etapa"));
if let Some(provider) = app.provider_selection.as_ref() {
for stage in Stage::ALL {
let model = provider
.stage_models
.get(stage.key())
.cloned()
.unwrap_or_else(|| provider.model.clone());
lines.push(th.label_value(stage.label(), &truncate(&model, 40)));
}
} else {
lines.push(Line::from(Span::styled(
" provider não selecionado",
Style::default().fg(th.text_muted),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(th.primary))
.title(" SDD ");
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(block),
modal,
);
}
fn render_provider_dialog(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
th.overlay_dim(frame, area);
let width = area.width.saturating_sub(8).clamp(62, 118);
let height = area.height.saturating_sub(4).clamp(18, 34);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let modal = Rect::new(x, y, width.min(area.width), height.min(area.height));
frame.render_widget(Clear, modal);
frame.render_widget(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(th.primary))
.title(" Provider "),
modal,
);
let inner = modal.inner(Margin {
vertical: 1,
horizontal: 2,
});
if app.providers.is_empty() {
frame.render_widget(
Paragraph::new(Text::from(vec![
Line::from("Nenhum provider carregado."),
Line::from("Rode `sdd providers doctor` ou edite `sdd.config.yaml`."),
]))
.wrap(Wrap { trim: false }),
inner,
);
return;
}
let layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(36)])
.split(inner);
render_provider_list(frame, layout[0], app);
let provider = &app.providers[app.provider_index.min(app.providers.len() - 1)];
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(6)])
.split(layout[1]);
render_provider_picker(frame, right[0], app, provider);
let hints = Text::from(vec![
Line::from(vec![
Span::styled(
" Enter aplicar ",
Style::default().fg(th.on_primary).bg(th.primary),
),
Span::raw(" "),
Span::styled("Esc fechar", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled("↑/↓ provider", Style::default().fg(th.text_muted)),
Span::raw(" "),
Span::styled("←/→ modelo", Style::default().fg(th.text_muted)),
]),
Line::from(vec![
Span::styled("[/] effort", Style::default().fg(th.text_muted)),
Span::raw(" "),
Span::styled("r recarregar", Style::default().fg(th.text_muted)),
Span::raw(" "),
Span::styled("e editar config", Style::default().fg(th.text_muted)),
]),
]);
frame.render_widget(Paragraph::new(hints).wrap(Wrap { trim: false }), right[1]);
}
fn render_header(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let title = screen_title(app.screen);
let location = app
.active
.as_ref()
.map(|active| format!("{} · {}", active.name, active.slug))
.unwrap_or_else(|| app.root.display().to_string());
let lines = Text::from(vec![
Line::from(vec![
Span::styled(
" SDD ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" cockpit ",
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
),
Span::styled(format!("· {title}"), Style::default().fg(th.text_strong)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(truncate(&location, 96), Style::default().fg(th.text_muted)),
]),
]);
frame.render_widget(Paragraph::new(lines), area);
}
const TIPS: &[&str] = &[
"/ abre o menu TUI contextual; Ctrl+K mantém o command palette.",
"Tab troca provider; ←/→ troca modelo; [/] troca effort.",
"t alterna a telemetria; o dashboard mostra uso de tokens.",
"Cada etapa tem um checkpoint humano antes de avançar.",
"Refinement é opcional — pule em features triviais.",
"Esc volta; q encerra a sessão com segurança.",
];
fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
if let Some(msg) = &app.footer {
let hint = Line::from(Span::styled(
format!(" {msg}"),
Style::default()
.fg(th.on_primary)
.bg(th.warning)
.add_modifier(Modifier::BOLD),
));
let route = Line::from(vec![
Span::styled(" rota ", Style::default().fg(th.on_primary).bg(th.primary)),
Span::raw(" "),
Span::styled(
active_provider_summary(app),
Style::default().fg(th.text_strong),
),
]);
frame.render_widget(Paragraph::new(Text::from(vec![hint, route])), area);
return;
}
let route = active_provider_summary(app);
let shortcuts = shortcuts_hint(app.screen);
let tokens_abs = format_usize(app.session_usage.total_tokens_estimate);
let tokens_pct = app
.provider_selection
.as_ref()
.and_then(|p| p.token_budget)
.filter(|budget| *budget > 0)
.map(|budget| {
let used = app.session_usage.total_tokens_estimate.min(budget as usize);
((used as f64 / budget as f64) * 100.0).round() as u8
});
let tip = TIPS[app.session_usage.generation_count % TIPS.len()];
let props = status_bar::StatusBarProps {
route: &route,
tokens_pct,
tokens_abs: Some(&tokens_abs),
cost: None,
shortcuts: &shortcuts,
tip: Some(tip),
};
status_bar::render(frame, area, &props, &th);
}
fn shortcuts_hint(screen: Screen) -> String {
let base = match screen {
Screen::ProviderSetup => {
"↑/↓ provider · ←/→ modelo · [/] effort · Enter escolher · e config · q sair"
}
Screen::Welcome => {
"digite a ideia · / menu · Tab provider dialog · Enter criar · Esc orquestrações"
}
Screen::OrchestrationList => {
"↑/↓ navegar · Enter abrir · n nova · / menu · Tab provider dialog · q sair"
}
Screen::NewOrchestration => {
"digite a ideia · / menu · Tab provider dialog · Enter confirmar · Esc cancelar"
}
Screen::StageBoard => {
"↑/↓ etapa · Enter abrir/executar · / menu · Tab provider dialog · q sair"
}
Screen::Running => "executando… · q sair",
Screen::TaskExecution => {
"↑/↓ task · Tab log/diff/report · r retentar falha · Esc voltar se livre · q sair"
}
Screen::TaskDecision => {
"↑/↓ task · Tab log/diff/report · r retentar · a aceitar parcial · Esc cancelar"
}
Screen::ParallelRunning => "Agent Teams ativos · q sair · ? ajuda",
Screen::ParallelDecision => "r retentar falhos · a aceitar parcial · Esc cancelar",
Screen::Viewer => {
"↑/↓ rolar · a checkpoint · r regenerar · Tab provider dialog · Esc voltar"
}
Screen::Checkpoint => {
"a aprovar · r regenerar · Tab provider dialog · e editar · Esc voltar"
}
Screen::RefinementPrompt => "r executar · s pular · Tab provider dialog · Esc voltar",
Screen::ErrorPanel => "r tentar de novo · e editar config · Esc voltar",
Screen::Help => "Esc fechar ajuda",
Screen::Summary => "q encerrar",
};
format!(" {base} · / menu · Ctrl+K comandos · t telemetria · ? ajuda")
}
fn render_provider_setup(frame: &mut Frame, area: Rect, app: &App) {
let area = if area.height >= 18 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Min(3)])
.split(area);
render_sdd_signature(frame, chunks[0], app);
chunks[1]
} else {
area
};
if app.providers.is_empty() {
let th = current_theme();
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(
" Nenhum provider carregado.",
Style::default().fg(th.text_strong),
)),
Line::from(""),
];
if let Some(error) = &app.provider_error {
lines.push(Line::from(vec![
Span::styled(" Erro: ", Style::default().fg(th.error)),
Span::styled(error.clone(), Style::default().fg(th.text_strong)),
]));
lines.push(Line::from(""));
}
lines.push(Line::from(vec![
Span::styled(
" e editar config ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" r recarregar ", Style::default().fg(th.text_strong)),
]));
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(Block::default().title(" Provider ")),
area,
);
return;
}
let chunks = if area.width < 96 {
Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(8), Constraint::Min(8)])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(36), Constraint::Min(44)])
.split(area)
};
render_provider_list(frame, chunks[0], app);
let provider = &app.providers[app.provider_index.min(app.providers.len() - 1)];
let detail_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(13), Constraint::Min(8)])
.split(chunks[1]);
render_provider_picker(frame, detail_chunks[0], app, provider);
render_provider_routes(frame, detail_chunks[1], app, provider);
}
fn render_sdd_signature(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let route = active_provider_summary(app);
let rule = "─".repeat(area.width.saturating_sub(2) as usize);
let lines = Text::from(vec![
Line::from(vec![
Span::raw(" "),
Span::styled(
" SDD ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" Spec-Driven Development ",
Style::default()
.fg(th.text_strong)
.add_modifier(Modifier::BOLD),
),
Span::styled("TUI", Style::default().fg(th.text_muted)),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("provider", Style::default().fg(th.primary)),
Span::styled(" · ", Style::default().fg(th.text_muted)),
Span::styled("model", Style::default().fg(th.primary)),
Span::styled(" · ", Style::default().fg(th.text_muted)),
Span::styled("effort", Style::default().fg(th.primary)),
Span::styled(
" · checkpoints · artifacts · memory",
Style::default().fg(th.text_muted),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(truncate(&route, 88), Style::default().fg(th.text_strong)),
]),
Line::from(Span::styled(
format!(" {rule}"),
Style::default().fg(th.text_muted),
)),
]);
frame.render_widget(Paragraph::new(lines), area);
}
fn render_provider_list(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let items: Vec<ListItem> = app
.providers
.iter()
.map(|provider| {
let auth_mark = match provider.auth_present {
Some(true) => "●",
Some(false) => "!",
None => "·",
};
let enabled = if provider.enabled {
"enabled"
} else {
"disabled"
};
let strong = if provider.enabled {
th.text_strong
} else {
th.text_muted
};
let model = provider.model.as_deref().unwrap_or("default");
let lines = Text::from(vec![
Line::from(vec![
Span::styled(
format!(" {auth_mark} "),
Style::default().fg(auth_color(provider.auth_present, &th)),
),
Span::styled(
format!("{:<12}", truncate(&provider.id, 12)),
Style::default().fg(strong).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {enabled}"),
Style::default().fg(if provider.enabled {
th.success
} else {
th.text_muted
}),
),
]),
Line::from(vec![
Span::raw(" "),
Span::styled(
truncate(&provider.kind, 18),
Style::default().fg(th.primary),
),
Span::styled(" · ", Style::default().fg(th.text_muted)),
Span::styled(truncate(model, 24), Style::default().fg(th.text_muted)),
]),
]);
ListItem::new(lines)
})
.collect();
let list = List::new(items)
.block(Block::default().title(" Providers "))
.highlight_style(th.selection_style())
.highlight_symbol(" ");
let mut state = ListState::default();
state.select(Some(app.provider_index.min(app.providers.len() - 1)));
frame.render_stateful_widget(list, area, &mut state);
}
fn render_provider_picker(
frame: &mut Frame,
area: Rect,
app: &App,
provider: &ProviderCatalogEntry,
) {
let th = current_theme();
let model = selected_model_label(provider, app.provider_model_index);
let effort = selected_effort_label(provider, app.provider_effort_index);
let selected = app
.provider_selection
.as_ref()
.map(|selection| selection.id.as_str())
.unwrap_or("auto");
let auth_env = provider.auth_env.as_deref().unwrap_or("não requerido");
let auth_methods = auth_methods_label(&provider.auth_methods);
let auth_login = auth_login_hint(&provider.auth_methods);
let capabilities = if provider.capabilities.is_empty() {
"nenhuma declarada".to_string()
} else {
provider.capabilities.join(", ")
};
let token_budget = provider
.token_budget
.map(|value| format!("{value} tokens"))
.unwrap_or_else(|| "padrão".to_string());
let usage_limits = provider_usage_limits_label(&provider.id, &provider.usage_limits);
let model_count = provider.models.len().max(1);
let mut lines = vec![
Line::from(vec![
Span::styled(
" Provider ",
Style::default().fg(th.on_primary).bg(th.primary),
),
Span::raw(" "),
Span::styled(
provider.id.clone(),
Style::default()
.fg(th.text_strong)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" · {}", provider.kind),
Style::default().fg(th.text_muted),
),
]),
Line::from(vec![
Span::styled(" Modelo ", Style::default().fg(th.primary)),
Span::styled("◀ ", Style::default().fg(th.text_muted)),
Span::styled(
truncate(&model, 54),
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ▶ {}/{}", app.provider_model_index + 1, model_count),
Style::default().fg(th.text_muted),
),
]),
effort_segments(provider, app.provider_effort_index, &effort),
Line::from(vec![
Span::styled(" Auth ", Style::default().fg(th.primary)),
Span::styled(
auth_label(provider.auth_present),
Style::default()
.fg(th.on_primary)
.bg(auth_color(provider.auth_present, &th)),
),
Span::styled(format!(" · {auth_env}"), Style::default().fg(th.text_muted)),
]),
Line::from(vec![
Span::styled(" Métodos ", Style::default().fg(th.primary)),
Span::styled(
truncate(&auth_methods, 76),
Style::default().fg(th.text_strong),
),
]),
Line::from(vec![
Span::styled(" Login ", Style::default().fg(th.primary)),
Span::styled(
truncate(&auth_login, 76),
Style::default().fg(th.text_strong),
),
]),
Line::from(vec![
Span::styled(" Budget tokens ", Style::default().fg(th.primary)),
Span::styled(token_budget, Style::default().fg(th.text_strong)),
Span::styled(" · seleção ativa ", Style::default().fg(th.text_muted)),
Span::styled(selected.to_string(), Style::default().fg(th.text_strong)),
]),
Line::from(vec![
Span::styled(" Limites ", Style::default().fg(th.primary)),
Span::styled(
truncate(&usage_limits, 84),
Style::default().fg(th.text_strong),
),
]),
Line::from(vec![
Span::styled(" Capacidades ", Style::default().fg(th.primary)),
Span::styled(
truncate(&capabilities, 76),
Style::default().fg(th.text_strong),
),
]),
Line::from(vec![
Span::styled(
" Enter aplicar ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" e config ", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled(" r recarregar ", Style::default().fg(th.text_muted)),
]),
];
if let Some(error) = &app.provider_error {
lines.push(Line::from(vec![
Span::styled(" Erro ", Style::default().fg(th.error)),
Span::styled(truncate(error, 84), Style::default().fg(th.text_strong)),
]));
}
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(th.primary))
.title(" Seletor "),
),
area,
);
}
fn render_provider_routes(
frame: &mut Frame,
area: Rect,
app: &App,
provider: &ProviderCatalogEntry,
) {
let th = current_theme();
let fallback_model = selected_model_id(provider, app.provider_model_index);
let fallback_effort = selected_effort_label(provider, app.provider_effort_index);
let header = Row::new(vec!["Etapa", "Modelo efetivo", "Effort"])
.style(Style::default().fg(th.primary).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = Stage::ALL
.iter()
.map(|stage| {
let model = provider
.stage_models
.get(stage.key())
.cloned()
.unwrap_or_else(|| fallback_model.clone());
let effort = provider
.stage_efforts
.get(stage.key())
.cloned()
.unwrap_or_else(|| fallback_effort.clone());
let is_override = provider.stage_models.contains_key(stage.key())
|| provider.stage_efforts.contains_key(stage.key());
Row::new(vec![
stage.label().to_string(),
truncate(&model, 34),
effort,
])
.style(Style::default().fg(if is_override {
th.text_strong
} else {
th.text_muted
}))
})
.collect();
let table = Table::new(
rows,
[
Constraint::Length(13),
Constraint::Min(18),
Constraint::Length(9),
],
)
.header(header)
.block(Block::default().title(" Roteamento por etapa "));
frame.render_widget(table, area);
}
fn render_list(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
if app.orchestrations.is_empty() {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(7), Constraint::Length(3)])
.split(area);
let version = concat!("v", env!("CARGO_PKG_VERSION"));
banner::render(frame, chunks[0], version, "", &th);
let hints = Text::from(vec![
Line::from(Span::styled(
" Nenhuma orquestração encontrada em docs/.",
Style::default().fg(th.text_muted),
)),
Line::from(vec![
Span::styled(" n", Style::default().fg(th.primary)),
Span::styled(" cria uma nova ", Style::default().fg(th.text_muted)),
Span::styled("Ctrl+K", Style::default().fg(th.primary)),
Span::styled(" comandos", Style::default().fg(th.text_muted)),
]),
]);
frame.render_widget(Paragraph::new(hints).wrap(Wrap { trim: false }), chunks[1]);
return;
}
let items: Vec<ListItem> = app
.orchestrations
.iter()
.map(|o| {
let stage = o.current_stage().map(|s| s.label()).unwrap_or("concluída");
let line = Line::from(vec![
Span::styled(
format!("{:<28}", truncate(&o.name, 28)),
Style::default().fg(th.text_strong),
),
Span::styled(format!(" [{}]", o.state), Style::default().fg(th.primary)),
Span::styled(format!(" → {stage}"), Style::default().fg(th.text_muted)),
]);
ListItem::new(line)
})
.collect();
let list = List::new(items)
.highlight_style(th.selection_style())
.highlight_symbol(" ");
let mut state = ListState::default();
state.select(Some(
app.list_index
.min(app.orchestrations.len().saturating_sub(1)),
));
frame.render_stateful_widget(list, area, &mut state);
}
fn render_new(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(4)])
.split(area);
let target_label = app
.new_target_stage
.map(|stage| format!("Gere {} a partir do contexto abaixo:", stage.label()))
.unwrap_or_else(|| "Descreva a feature em linguagem natural:".to_string());
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {target_label}"),
Style::default().fg(th.text_muted),
))),
chunks[0],
);
let props = input_widget::InputProps {
value: &app.input,
placeholder: app
.new_target_stage
.map(|stage| match stage {
Stage::Idea => "Contexto para a Idea…",
Stage::Prd => "Contexto para gerar o PRD…",
Stage::Techspec => "Contexto para gerar a Tech Spec…",
Stage::Tasks => "Contexto para quebrar em tasks…",
Stage::Refinement => "Contexto para refinement técnico…",
Stage::Execution => "Contexto para execution…",
Stage::Adr => "Contexto para ADR pós-execução…",
Stage::Review => "Contexto para review…",
Stage::Memory => "Contexto para memory…",
})
.unwrap_or("Ask anything… \"reformular o layout do TUI\""),
mode_line: "",
focused: true,
};
input_widget::render(frame, chunks[1], &props, &th);
}
fn render_board(frame: &mut Frame, area: Rect, app: &App) {
let Some(active) = &app.active else {
return;
};
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(38), Constraint::Min(40)])
.split(area);
let th = current_theme();
let mut items: Vec<ListItem> = Vec::new();
items.push(ListItem::new(Line::from(vec![Span::styled(
" Pré-etapas recorded ",
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
)])));
for stage in &active.pre_stages {
items.push(render_recorded_lane_item(stage, &th));
}
items.push(ListItem::new(Line::from("")));
items.push(ListItem::new(Line::from(vec![Span::styled(
" Guia de execução ",
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
)])));
let guided_selection_offset = items.len();
items.extend(active.stages.iter().map(|st| {
let color = match st.status {
super::stage::StageStatus::Approved => th.success,
super::stage::StageStatus::Skipped => th.text_muted,
super::stage::StageStatus::Recorded => th.primary,
super::stage::StageStatus::InProgress => th.warning,
super::stage::StageStatus::Optional => th.primary,
_ => th.text_strong,
};
let approved = st
.approved_at
.as_deref()
.map(|t| format!(" ({t})"))
.unwrap_or_default();
ListItem::new(Line::from(vec![
Span::styled(
format!(" {} ", st.status.icon()),
Style::default().fg(color),
),
Span::styled(
format!("{:<12}", st.stage.label()),
Style::default().fg(color),
),
Span::styled(approved, Style::default().fg(th.text_muted)),
]))
}));
items.push(ListItem::new(Line::from("")));
items.push(ListItem::new(Line::from(vec![Span::styled(
" Pós-execução recorded ",
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
)])));
for stage in &active.post_stages {
items.push(render_recorded_lane_item(stage, &th));
}
let list = List::new(items)
.block(Block::default().title(format!(" {} ", truncate(&active.name, 40))))
.highlight_style(th.selection_style())
.highlight_symbol(" ");
let mut state = ListState::default();
state.select(Some(
guided_selection_offset + app.board_index.min(active.stages.len().saturating_sub(1)),
));
frame.render_stateful_widget(list, chunks[0], &mut state);
let selected = active
.stages
.get(app.board_index.min(active.stages.len().saturating_sub(1)));
let stage = selected.map(|st| st.stage).unwrap_or(Stage::Idea);
let file = active.path.join(stage.filename());
let status = selected
.map(|st| format!("{:?}", st.status))
.unwrap_or_else(|| "Unknown".to_string());
let artifact_exists = file.exists();
let provider = app
.provider_selection
.as_ref()
.map(|p| {
let model = p.stage_models.get(stage.key()).unwrap_or(&p.model);
let effort = p.stage_efforts.get(stage.key()).unwrap_or(&p.effort);
format!("{} / {} / {}", p.id, model, effort)
})
.unwrap_or_else(|| "auto / default".to_string());
let action = if artifact_exists {
"Enter abre o artefato; r no viewer regenera via provider e salva com --force."
} else {
"Enter gera o artefato inicial via provider quando houver adapter disponível."
};
let checkpoint = if stage == Stage::Refinement {
"Executar, aprovar ou pular com rastreabilidade explícita."
} else {
"Depois do artefato, aprovar, editar ou regenerar antes de avançar."
};
let lines = Text::from(vec![
Line::from(""),
th.label_value("Etapa", stage.label()),
th.label_value("Estado", &status),
th.label_value("Artefato", stage.filename()),
th.label_value("Existe", if artifact_exists { "sim" } else { "não" }),
Line::from(""),
th.label_value("Provider", &provider),
th.label_value(
"Pré-lanes",
&recorded_lane_summary(&active.pre_stages, "Project Discovery e Risk"),
),
th.label_value(
"Pós-lane",
&recorded_lane_summary(&active.post_stages, "ADR pós-execução"),
),
Line::from(""),
Line::from(Span::styled(
format!(" {action}"),
Style::default().fg(th.text_strong),
)),
Line::from(Span::styled(
format!(" {checkpoint}"),
Style::default().fg(th.text_muted),
)),
Line::from(Span::styled(
" Discovery, Risk e ADR aparecem no board como lanes recorded para consulta visual.",
Style::default().fg(th.text_muted),
)),
Line::from(""),
Line::from(vec![
Span::styled(" t", Style::default().fg(th.primary)),
Span::styled(" telemetria · ", Style::default().fg(th.text_muted)),
Span::styled("Ctrl+K", Style::default().fg(th.primary)),
Span::styled(" comandos", Style::default().fg(th.text_muted)),
]),
Line::from(""),
th.label_value("Caminho", &file.display().to_string()),
]);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().title(" Detalhes ")),
chunks[1],
);
}
fn render_recorded_lane_item(stage: &RecordedStageState, th: &theme::Theme) -> ListItem<'static> {
let color = match stage.status {
super::stage::StageStatus::Approved => th.success,
super::stage::StageStatus::Recorded => th.primary,
super::stage::StageStatus::Skipped => th.text_muted,
super::stage::StageStatus::InProgress => th.warning,
super::stage::StageStatus::Optional => th.primary,
_ => th.text_muted,
};
let approved = stage
.approved_at
.as_deref()
.map(|t| format!(" ({t})"))
.unwrap_or_default();
ListItem::new(Line::from(vec![
Span::styled(
format!(" {} ", stage.status.icon()),
Style::default().fg(color),
),
Span::styled(format!("{:<18}", stage.label), Style::default().fg(color)),
Span::styled(
format!(" {}", stage.file),
Style::default().fg(th.text_muted),
),
Span::styled(approved, Style::default().fg(th.text_muted)),
]))
}
fn recorded_lane_summary(stages: &[RecordedStageState], fallback: &str) -> String {
if stages.is_empty() {
return fallback.to_string();
}
stages
.iter()
.map(|stage| format!("{} {} [{}]", stage.status.icon(), stage.label, stage.key))
.collect::<Vec<_>>()
.join(" · ")
}
fn render_running(frame: &mut Frame, area: Rect, app: &App) {
if area.width >= 112 {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(52), Constraint::Min(48)])
.split(area);
render_running_overview(frame, chunks[0], app);
render_running_timeline(frame, chunks[1], app);
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(16), Constraint::Min(8)])
.split(area);
render_running_overview(frame, chunks[0], app);
render_running_timeline(frame, chunks[1], app);
}
}
fn render_task_execution(frame: &mut Frame, area: Rect, app: &App, decision: bool) {
if area.width >= 118 {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(42),
Constraint::Length(44),
Constraint::Min(42),
])
.split(area);
render_task_progress(frame, chunks[0], app, decision);
render_task_list(frame, chunks[1], app);
render_task_detail(frame, chunks[2], app);
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(if decision { 13 } else { 11 }),
Constraint::Length(9),
Constraint::Min(8),
])
.split(area);
render_task_progress(frame, chunks[0], app, decision);
render_task_list(frame, chunks[1], app);
render_task_detail(frame, chunks[2], app);
}
}
fn render_task_progress(frame: &mut Frame, area: Rect, app: &App, decision: bool) {
let total = app.execution_total_count();
let done = app.execution_done_count();
let errors = app.execution_error_count();
let pending = app.execution_pending_count();
let running = app
.execution
.as_ref()
.and_then(|session| session.current_index)
.and_then(|index| {
app.execution
.as_ref()
.and_then(|session| session.tasks.get(index))
});
let current = running
.map(|task| format!("{} — {}", task.task.id, task.task.title))
.or_else(|| {
app.selected_execution_task()
.map(|task| format!("{} — {}", task.task.id, task.task.title))
})
.unwrap_or_else(|| "nenhuma task selecionada".to_string());
let progress = progress_bar(done, total, area.width.saturating_sub(8) as usize);
let status = if decision {
"decisão pendente"
} else if running.is_some() {
"task em execução"
} else {
"preparando próxima task"
};
let th = current_theme();
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
format!(" {} ", app.loader.current_frame()),
Style::default().fg(th.primary),
),
Span::styled(
if decision {
"execução pausada por falha"
} else {
"executando tasks reais"
},
Style::default()
.fg(if decision { th.warning } else { th.text_strong })
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
metric_line("Progresso", &format!("{done}/{total} {progress}")),
metric_line("Status", status),
metric_line(
"Atual",
&truncate(¤t, area.width.saturating_sub(14) as usize),
),
metric_line("Tempo", &app.loader.elapsed_label()),
metric_line("Pendentes", &pending.to_string()),
metric_line("Erros", &errors.to_string()),
metric_line("Uso", &execution_usage_label(app)),
];
if decision {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
" r Retentar com erro ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" a Aceitar parcial ", Style::default().fg(th.text_strong)),
]));
}
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(Block::default().title(" Execution ")),
area,
);
}
fn render_task_list(frame: &mut Frame, area: Rect, app: &App) {
let Some(session) = app.execution.as_ref() else {
frame.render_widget(
Paragraph::new(" sem sessão de execução ")
.block(Block::default().borders(Borders::ALL).title("Tasks")),
area,
);
return;
};
let items = session
.tasks
.iter()
.map(|run| {
let style = task_status_style(run.status);
let line = Line::from(vec![
Span::styled(
format!(" {} ", run.status.marker()),
style.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{:<8}", truncate(&run.task.id, 8)), style),
Span::raw(" "),
Span::styled(truncate(&run.task.title, 28), style),
]);
ListItem::new(line)
})
.collect::<Vec<_>>();
let th = current_theme();
let list = List::new(items)
.block(Block::default().title(" Tasks "))
.highlight_style(th.selection_style())
.highlight_symbol(" ");
let mut state = ListState::default();
state.select(Some(
session
.selected_index
.min(session.tasks.len().saturating_sub(1)),
));
frame.render_stateful_widget(list, area, &mut state);
}
fn render_task_detail(frame: &mut Frame, area: Rect, app: &App) {
let Some(session) = app.execution.as_ref() else {
return;
};
let selected = app.selected_execution_task();
let title = selected
.map(|task| format!(" {} · {} ", task.task.id, session.detail_view.label()))
.unwrap_or_else(|| " Detalhe ".to_string());
let lines = match (selected, session.detail_view) {
(Some(task), TaskDetailView::Log) => task_log_lines(task, area.height),
(Some(task), TaskDetailView::Diff) => task_diff_lines(task, area.height),
(Some(task), TaskDetailView::Report) => task_report_lines(task, area.height),
(None, _) => vec![Line::from(" nenhuma task selecionada")],
};
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title(title)),
area,
);
}
fn task_log_lines(task: &super::app::TaskRun, height: u16) -> Vec<Line<'static>> {
let th = current_theme();
let mut lines = vec![
th.label_value("Status", task.status.label()),
th.label_value("Task", &truncate(&task.task.title, 96)),
Line::from(""),
];
let limit = height.saturating_sub(5).max(1) as usize;
if task.trace.is_empty() {
lines.push(Line::from(" aguardando eventos da task…"));
} else {
let start = task.trace.len().saturating_sub(limit);
for item in task.trace.iter().skip(start) {
lines.push(timeline_line(item, 120));
}
}
lines
}
fn task_diff_lines(task: &super::app::TaskRun, height: u16) -> Vec<Line<'static>> {
let Some(diff) = task.diff.as_ref() else {
return vec![Line::from(" diff ainda não disponível para esta task")];
};
if diff.is_empty() {
return vec![Line::from(" sem alterações detectadas para esta task")];
}
let th = current_theme();
let mut lines = vec![
th.label_value("Arquivos", &diff.changed_files.join(", ")),
Line::from(""),
];
let text = if diff.patch.trim().is_empty() {
diff.summary.as_str()
} else {
diff.patch.as_str()
};
let limit = height.saturating_sub(4).max(1) as usize;
for line in text.lines().take(limit) {
let style = if line.starts_with('+') && !line.starts_with("+++") {
Style::default().fg(th.success)
} else if line.starts_with('-') && !line.starts_with("---") {
Style::default().fg(th.error)
} else if line.starts_with("@@") {
Style::default().fg(th.primary)
} else {
Style::default().fg(th.text_strong)
};
lines.push(Line::from(Span::styled(format!(" {line}"), style)));
}
lines
}
fn task_report_lines(task: &super::app::TaskRun, height: u16) -> Vec<Line<'static>> {
if task.report.trim().is_empty() {
return vec![Line::from(
" relatório ainda não disponível para esta task",
)];
}
task.report
.lines()
.take(height.saturating_sub(2).max(1) as usize)
.map(|line| Line::from(format!(" {line}")))
.collect()
}
fn task_status_style(status: TaskRunStatus) -> Style {
let th = current_theme();
match status {
TaskRunStatus::Pending => Style::default().fg(th.text_muted),
TaskRunStatus::Running => Style::default().fg(th.primary),
TaskRunStatus::Succeeded => Style::default().fg(th.success),
TaskRunStatus::Failed => Style::default().fg(th.error),
TaskRunStatus::Skipped => Style::default().fg(th.warning),
}
}
fn progress_bar(done: usize, total: usize, width: usize) -> String {
let width = width.clamp(8, 28);
if total == 0 {
return format!("[{}]", " ".repeat(width));
}
let filled = width.saturating_mul(done).checked_div(total).unwrap_or(0);
format!(
"[{}{}]",
"█".repeat(filled),
" ".repeat(width.saturating_sub(filled))
)
}
fn render_parallel_running(frame: &mut Frame, area: Rect, app: &App) {
if area.width >= 112 {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(50), Constraint::Min(52)])
.split(area);
render_parallel_overview(frame, chunks[0], app, false);
render_parallel_slots(frame, chunks[1], app);
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(13), Constraint::Min(8)])
.split(area);
render_parallel_overview(frame, chunks[0], app, false);
render_parallel_slots(frame, chunks[1], app);
}
}
fn render_parallel_decision(frame: &mut Frame, area: Rect, app: &App) {
if area.width >= 112 {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(50), Constraint::Min(52)])
.split(area);
render_parallel_overview(frame, chunks[0], app, true);
render_parallel_slots(frame, chunks[1], app);
} else {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(17), Constraint::Min(8)])
.split(area);
render_parallel_overview(frame, chunks[0], app, true);
render_parallel_slots(frame, chunks[1], app);
}
}
fn render_parallel_overview(frame: &mut Frame, area: Rect, app: &App, decision: bool) {
let stage = app
.parallel_stage
.map(|stage| stage.key().to_string())
.unwrap_or_else(|| "desconhecida".to_string());
let total = app.parallel.len();
let done = app.parallel_done_count();
let errors = app.parallel_error_count();
let running = app
.parallel
.iter()
.filter(|slot| slot.status == AgentSlotStatus::EmExecucao)
.count();
let pending = app
.parallel
.iter()
.filter(|slot| slot.status == AgentSlotStatus::Aguardando)
.count();
let th = current_theme();
let mut lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
format!(" {} ", app.loader.current_frame()),
Style::default().fg(th.primary),
),
Span::styled(
if decision {
"barreira concluída com falhas"
} else {
"Agent Teams em execução"
},
Style::default()
.fg(if decision { th.warning } else { th.text_strong })
.add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
metric_line("Etapa", &stage),
metric_line("Slots", &format!("{done} de {total} concluídos")),
metric_line("Rodando", &running.to_string()),
metric_line("Fila", &pending.to_string()),
metric_line("Erros", &errors.to_string()),
metric_line("Uso", ¶llel_usage_label(app)),
];
if decision {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(
" r Retentar falhos ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" a Aceitar parcial ", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled(" Esc Cancelar ", Style::default().fg(th.text_muted)),
]));
}
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(Block::default().title(if decision {
" Decisão "
} else {
" Execução paralela "
})),
area,
);
}
fn render_parallel_slots(frame: &mut Frame, area: Rect, app: &App) {
let visible = area.height.saturating_sub(3).max(1) as usize;
let rows = app.parallel.iter().take(visible).map(|slot| {
let last_trace = slot
.trace
.last()
.map(|value| truncate(value, 54))
.unwrap_or_else(|| "aguardando evento".to_string());
Row::new(vec![
slot.task_id.clone(),
truncate(&slot.task_label, 30),
slot_status_label(slot.status, app),
last_trace,
])
.style(slot_status_style(slot.status))
});
let th = current_theme();
let header = Row::new(vec!["Task", "AgentSlot", "Status", "Último evento"])
.style(Style::default().fg(th.primary).add_modifier(Modifier::BOLD));
let table = Table::new(
rows,
[
Constraint::Length(10),
Constraint::Length(30),
Constraint::Length(16),
Constraint::Min(20),
],
)
.header(header)
.block(Block::default().title(" AgentSlots "));
frame.render_widget(table, area);
}
fn slot_status_label(status: AgentSlotStatus, app: &App) -> String {
match status {
AgentSlotStatus::Aguardando => "○ Aguardando".to_string(),
AgentSlotStatus::EmExecucao => format!("{} Em execução", app.loader.current_frame()),
AgentSlotStatus::Concluido => "✓ Concluído".to_string(),
AgentSlotStatus::Erro => "✗ Erro".to_string(),
}
}
fn slot_status_style(status: AgentSlotStatus) -> Style {
let th = current_theme();
match status {
AgentSlotStatus::Aguardando => Style::default().fg(th.text_muted),
AgentSlotStatus::EmExecucao => Style::default().fg(th.primary),
AgentSlotStatus::Concluido => Style::default().fg(th.success),
AgentSlotStatus::Erro => Style::default().fg(th.error),
}
}
fn render_running_overview(frame: &mut Frame, area: Rect, app: &App) {
let mut lines = vec![Line::from("")];
let elapsed = app.loader.elapsed_label();
let stage = app
.viewer_stage
.map(|stage| stage.key().to_string())
.unwrap_or_else(|| "desconhecida".to_string());
let th = current_theme();
if app.loader.is_visible() {
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", app.loader.current_frame()),
Style::default().fg(th.primary),
),
Span::styled(
app.loader.label.clone(),
Style::default().fg(th.text_strong),
),
]));
} else {
lines.push(Line::from(" preparando…"));
}
lines.push(Line::from(""));
lines.push(metric_line("Etapa", &stage));
lines.push(metric_line("Tempo", &elapsed));
lines.push(metric_line(
"Status",
if app.pending.is_some() {
"provider em execução"
} else {
"sem subprocesso pendente"
},
));
lines.push(Line::from(""));
lines.push(section_label("Uso e custo"));
lines.push(metric_line("Atual", ¤t_or_last_usage_label(app)));
lines.push(metric_line("Custo", ¤t_or_last_cost_label(app)));
lines.push(metric_line("Quota", ¤t_provider_quota_label(app)));
lines.push(metric_line("Sessão", &session_usage_label(app)));
lines.push(Line::from(""));
lines.push(section_label("Contexto enviado"));
lines.extend(context_lines(
app.current_context.as_ref().or(app.last_context.as_ref()),
));
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(Block::default().borders(Borders::ALL).title("Execução")),
area,
);
}
fn render_running_timeline(frame: &mut Frame, area: Rect, app: &App) {
let mut lines = Vec::new();
if !app.provider_trace.is_empty() {
let trace_limit = area.height.saturating_sub(2).clamp(6, 40) as usize;
let start = app.provider_trace.len().saturating_sub(trace_limit);
for item in app.provider_trace.iter().skip(start) {
lines.push(timeline_line(item, area.width));
}
} else {
lines.push(Line::from(""));
lines.push(Line::from(" aguardando primeiro evento do provider…"));
}
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.title("Timeline do agente"),
),
area,
);
}
fn timeline_line(item: &str, width: u16) -> Line<'static> {
let th = current_theme();
let state = if item.starts_with("erro") {
trace_widget::TraceState::Error
} else if item.starts_with("aviso") {
trace_widget::TraceState::Running
} else if item.starts_with("tokens")
|| item.starts_with("prompt")
|| item.starts_with("markdown")
|| item.starts_with("artifact")
|| item.starts_with("skills Codex")
|| item.starts_with("skill compat")
{
trace_widget::TraceState::Done
} else {
trace_widget::TraceState::Pending
};
let text = truncate(item, width.saturating_sub(8) as usize);
trace_widget::trace_line(state, &text, false, &th)
}
fn render_viewer(frame: &mut Frame, area: Rect, app: &App) {
if (app.last_usage.is_some() || app.last_context.is_some()) && area.height >= 16 {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(5), Constraint::Min(8)])
.split(area);
render_run_receipt(frame, chunks[0], app);
render_viewer_body(frame, chunks[1], app);
} else {
render_viewer_body(frame, area, app);
}
}
fn render_run_receipt(frame: &mut Frame, area: Rect, app: &App) {
let usage = last_usage_label(app);
let cost = current_or_last_cost_label(app);
let context = app
.last_context
.as_ref()
.map(|context| {
format!(
"contexto ~{} tokens · {} artefato(s){}",
format_usize(context.prompt_tokens_estimate),
context.artifact_count,
if context.truncated {
" · truncado"
} else {
""
}
)
})
.unwrap_or_else(|| "contexto não registrado".to_string());
let th = current_theme();
let lines = Text::from(vec![
th.label_value(
"Uso",
&truncate(&usage, area.width.saturating_sub(8) as usize),
),
th.label_value(
"Custo",
&truncate(&cost, area.width.saturating_sub(10) as usize),
),
th.label_value(
"Contexto",
&truncate(&context, area.width.saturating_sub(13) as usize),
),
]);
frame.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().title(" Recibo da geração ")),
area,
);
}
fn render_viewer_body(frame: &mut Frame, area: Rect, app: &App) {
let text = app
.viewer_text
.clone()
.unwrap_or_else(|| Text::from("(vazio)"));
let title = app
.viewer_stage
.map(|s| format!(" {} ", s.label()))
.unwrap_or_else(|| " Artefato ".to_string());
let p = Paragraph::new(text)
.wrap(Wrap { trim: false })
.scroll((app.viewer_scroll, 0))
.block(Block::default().borders(Borders::ALL).title(title));
frame.render_widget(p, area);
}
fn render_checkpoint(frame: &mut Frame, area: Rect, app: &App) {
let stage = app.viewer_stage.unwrap_or(Stage::Idea);
let stage_label = stage.label();
let artifact = app
.active
.as_ref()
.map(|active| active.path.join(stage.filename()).display().to_string())
.unwrap_or_else(|| stage.filename().to_string());
let provider = app
.provider_selection
.as_ref()
.map(|p| {
let model = p.stage_models.get(stage.key()).unwrap_or(&p.model);
let effort = p.stage_efforts.get(stage.key()).unwrap_or(&p.effort);
format!("{} / {} / {}", p.id, model, effort)
})
.unwrap_or_else(|| "auto / default".to_string());
let th = current_theme();
let contexto = app
.last_context
.as_ref()
.map(|context| {
format!(
"~{} tokens · {} artefato(s){}",
format_usize(context.prompt_tokens_estimate),
context.artifact_count,
if context.truncated {
" · truncado"
} else {
""
}
)
})
.unwrap_or_else(|| "não registrado".to_string());
let body = Text::from(vec![
Line::from(""),
th.label_value(
"Decisão",
&format!("aprovar, ajustar ou regenerar {stage_label}"),
),
th.label_value("Artefato", &artifact),
th.label_value("Provider/modelo/effort", &provider),
th.label_value("Uso artefato", &last_usage_label(app)),
th.label_value("Custo estimado", ¤t_or_last_cost_label(app)),
th.label_value("Contexto", &contexto),
th.label_value("Sessão atual", &session_usage_label(app)),
th.label_value(
"Critério",
"seções obrigatórias, rastreabilidade e evidência suficientes",
),
Line::from(""),
Line::from(vec![
Span::styled(
" a Aprovar ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" r Regenerar ", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled(" e Editar ", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled(" Esc Voltar ", Style::default().fg(th.text_muted)),
]),
]);
let p = Paragraph::new(body)
.wrap(Wrap { trim: false })
.block(Block::default().title(" Checkpoint de aprovação "));
frame.render_widget(p, area);
}
fn render_refinement_prompt(frame: &mut Frame, area: Rect, _app: &App) {
let th = current_theme();
let body = Text::from(vec![
Line::from(""),
Line::from(Span::styled(
" O Refinement é opcional. Deseja executá-lo?",
Style::default().fg(th.text_strong),
)),
Line::from(""),
Line::from(vec![
Span::styled(
" r Executar ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" s Pular ", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled(" Esc Voltar ", Style::default().fg(th.text_muted)),
]),
]);
frame.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.block(Block::default().title(" Refinement ")),
area,
);
}
fn render_error(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let raw = app
.error
.clone()
.unwrap_or_else(|| "erro desconhecido".to_string());
let overwrite_conflict = raw.contains("refusing to overwrite existing artifact");
let mut lines = vec![Line::from(Span::styled(
" Falha ao executar a etapa:",
Style::default().fg(th.error).add_modifier(Modifier::BOLD),
))];
if overwrite_conflict {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Um artefato local já existe. Para proteger checkpoints, o SDD não sobrescreve sem decisão explícita.",
Style::default().fg(th.text_muted),
)));
}
if !app.provider_trace.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Trace do provider:",
Style::default()
.fg(th.section_header)
.add_modifier(Modifier::BOLD),
)));
for item in app.provider_trace.iter().take(12) {
lines.push(trace_widget::trace_line(
trace_widget::TraceState::Pending,
item,
false,
&th,
));
}
lines.push(Line::from(""));
}
for l in raw.lines().take(20) {
lines.push(Line::from(Span::styled(
format!(" {l}"),
Style::default().fg(th.text_strong),
)));
}
lines.push(Line::from(""));
if overwrite_conflict {
lines.push(Line::from(vec![
Span::styled(
" r regenerar sobrescrevendo ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" Esc Voltar ", Style::default().fg(th.text_muted)),
]));
} else {
lines.push(Line::from(vec![
Span::styled(
" r tentar novamente ",
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(" e editar config ", Style::default().fg(th.text_strong)),
Span::raw(" "),
Span::styled(" Esc Voltar ", Style::default().fg(th.text_muted)),
]));
}
let p = Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(th.error))
.title(" Erro "),
);
frame.render_widget(p, area);
}
fn render_help(frame: &mut Frame, area: Rect, app: &App) {
let th = current_theme();
let rows = help_rows(app.help_return);
let mut lines = vec![Line::from("")];
for (key, desc) in rows {
lines.push(Line::from(vec![
Span::styled(
format!(" {key:<10}"),
Style::default().fg(th.primary).add_modifier(Modifier::BOLD),
),
Span::styled(desc, Style::default().fg(th.text_strong)),
]));
}
frame.render_widget(
Paragraph::new(Text::from(lines)).block(Block::default().title(" Ajuda ")),
area,
);
}
fn help_rows(screen: Screen) -> Vec<(&'static str, &'static str)> {
match screen {
Screen::ProviderSetup => vec![
("↑/↓, Tab", "selecionar provider"),
("←/→", "selecionar modelo inicial"),
("Ctrl+N", "avançar modelo inicial"),
("[/]", "alternar effort inicial"),
("Enter", "usar provider e abrir orquestrações"),
("r", "recarregar sdd.config.yaml"),
("e", "editar sdd.config.yaml"),
("/", "abrir menu TUI"),
("Esc", "voltar sem trocar"),
("q / Ctrl+C", "sair"),
],
Screen::OrchestrationList => vec![
("↑/↓, j/k", "navegar na lista"),
("Enter", "abrir orquestração"),
("n", "criar nova orquestração"),
("p", "trocar provider/modelo"),
("Tab", "abrir provider dialog"),
("/", "abrir menu TUI"),
("q / Ctrl+C", "sair"),
],
Screen::StageBoard => vec![
("↑/↓, j/k", "selecionar etapa"),
("Enter", "executar ou reler etapa"),
("p", "trocar provider/modelo"),
("Tab", "abrir provider dialog da etapa"),
("←/→", "trocar modelo da etapa"),
("[/]", "trocar effort da etapa"),
("/", "abrir menu TUI"),
("Esc", "voltar à lista"),
("q", "sair"),
],
Screen::Viewer => vec![
("↑/↓, j/k", "rolar o artefato"),
("a / Enter", "ir ao checkpoint"),
("r", "regenerar artefato via provider"),
("Tab", "abrir provider dialog da etapa"),
("←/→", "trocar modelo da etapa"),
("[/]", "trocar effort da etapa"),
("/", "abrir menu TUI"),
("Esc", "voltar ao fluxo"),
],
Screen::TaskExecution => vec![
("↑/↓, j/k", "selecionar task"),
("Tab", "alternar detalhe entre Log, Diff e Report"),
("Shift+Tab", "voltar detalhe"),
("r", "retentar task selecionada se ela falhou"),
("Esc", "voltar ao fluxo quando nenhuma task estiver rodando"),
("q / Ctrl+C", "sair"),
],
Screen::TaskDecision => vec![
("↑/↓, j/k", "selecionar task"),
("Tab", "alternar detalhe entre Log, Diff e Report"),
("r", "retentar task com erro"),
("a / Enter", "aceitar resultado parcial e gerar artefato"),
("Esc", "cancelar execução e voltar ao fluxo"),
],
Screen::ParallelRunning => vec![
("q / Ctrl+C", "sair mantendo o terminal limpo"),
("?", "abrir ajuda"),
],
Screen::ParallelDecision => vec![
("r", "retentar apenas AgentSlots em erro"),
("a / Enter", "aceitar resultado parcial e compor artefato"),
("Esc", "cancelar etapa e voltar ao fluxo"),
],
Screen::Checkpoint => vec![
("a", "aprovar e avançar"),
("r", "regenerar a etapa via provider"),
("e", "editar no $EDITOR"),
("Tab", "abrir provider dialog da etapa"),
("←/→", "trocar modelo da etapa"),
("[/]", "trocar effort da etapa"),
("/", "abrir menu TUI"),
("Esc", "voltar ao artefato"),
],
_ => vec![("Esc", "voltar"), ("q", "sair"), ("?", "ajuda")],
}
}
fn render_summary(frame: &mut Frame, area: Rect, app: &App) {
let Some(active) = &app.active else {
return;
};
let th = current_theme();
let header = Row::new(vec!["Etapa", "Arquivo", "Estado", "Aprovado em"])
.style(Style::default().fg(th.primary).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = active
.stages
.iter()
.map(|st| {
Row::new(vec![
st.stage.label().to_string(),
if st.file.is_empty() {
st.stage.filename().to_string()
} else {
st.file.clone()
},
format!("{:?}", st.status),
st.approved_at.clone().unwrap_or_default(),
])
.style(Style::default().fg(th.text_strong))
})
.collect();
let widths = [
Constraint::Length(12),
Constraint::Length(22),
Constraint::Length(12),
Constraint::Min(10),
];
let table = Table::new(rows, widths)
.header(header)
.block(Block::default().title(format!(" Resumo · {} ", truncate(&active.name, 36))));
frame.render_widget(table, area);
}
fn screen_title(screen: Screen) -> &'static str {
match screen {
Screen::Welcome => "Bem-vindo",
Screen::ProviderSetup => "Provider",
Screen::OrchestrationList => "Orquestrações",
Screen::NewOrchestration => "Nova orquestração",
Screen::StageBoard => "Fluxo",
Screen::Running => "Executando",
Screen::TaskExecution => "Execution",
Screen::TaskDecision => "Decisão de task",
Screen::ParallelRunning => "Agent Teams",
Screen::ParallelDecision => "Decisão paralela",
Screen::Viewer => "Artefato",
Screen::Checkpoint => "Checkpoint",
Screen::RefinementPrompt => "Refinement",
Screen::ErrorPanel => "Erro",
Screen::Help => "Ajuda",
Screen::Summary => "Resumo",
}
}
fn slash_scope_label(app: &App) -> String {
match app.screen {
Screen::StageBoard => format!(
"Step {}",
Stage::GUIDED[app.board_index.min(Stage::GUIDED.len() - 1)].label()
),
Screen::Running | Screen::Viewer | Screen::Checkpoint => app
.viewer_stage
.map(|stage| format!("Step {}", stage.label()))
.unwrap_or_else(|| "Build".to_string()),
Screen::RefinementPrompt => "Step Refinement".to_string(),
Screen::TaskExecution | Screen::TaskDecision => "Step Execution".to_string(),
Screen::ParallelRunning | Screen::ParallelDecision => app
.parallel_stage
.map(|stage| format!("Step {}", stage.label()))
.unwrap_or_else(|| "Agent Team".to_string()),
Screen::ProviderSetup => "Provider".to_string(),
_ => "Build".to_string(),
}
}
fn slash_provider_route(app: &App) -> String {
app.providers
.get(app.provider_index)
.map(|provider| {
format!(
"{} · {} · {}",
provider.id,
selected_model_id(provider, app.provider_model_index),
selected_effort_label(provider, app.provider_effort_index)
)
})
.unwrap_or_else(|| active_provider_summary(app))
}
fn active_provider_summary(app: &App) -> String {
if app.screen == Screen::ProviderSetup {
if let Some(provider) = app.providers.get(app.provider_index) {
return format!(
"{} · {} · {}",
provider.id,
selected_model_id(provider, app.provider_model_index),
selected_effort_label(provider, app.provider_effort_index)
);
}
}
app.provider_selection
.as_ref()
.map(|provider| format!("{} · {} · {}", provider.id, provider.model, provider.effort))
.unwrap_or_else(|| "provider auto · modelo default · effort medium".to_string())
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn auth_label(auth_present: Option<bool>) -> &'static str {
match auth_present {
Some(true) => "presente",
Some(false) => "ausente",
None => "não requerido",
}
}
fn auth_color(auth_present: Option<bool>, theme: &theme::Theme) -> Color {
match auth_present {
Some(true) => theme.success,
Some(false) => theme.warning,
None => theme.text_muted,
}
}
fn auth_methods_label(methods: &[ProviderAuthStatus]) -> String {
if methods.is_empty() {
return "não declarado".to_string();
}
methods
.iter()
.map(|method| {
let state = if method
.detail
.as_deref()
.is_some_and(|detail| detail.contains("modo rápido"))
{
"instalado"
} else if method.present {
"presente"
} else {
"ausente"
};
format!("{}:{}", method.label, state)
})
.collect::<Vec<_>>()
.join(", ")
}
fn auth_login_hint(methods: &[ProviderAuthStatus]) -> String {
if methods.iter().any(|method| {
method
.detail
.as_deref()
.is_some_and(|detail| detail.contains("modo rápido"))
}) {
return "rode `sdd providers doctor` para validar login real".to_string();
}
let mut commands = methods
.iter()
.filter(|method| !method.present)
.filter_map(|method| method.login_command.as_deref())
.filter(|command| !command.trim().is_empty())
.collect::<Vec<_>>();
commands.sort_unstable();
commands.dedup();
if commands.is_empty() {
return "sessão pronta ou variável de ambiente configurada".to_string();
}
commands
.into_iter()
.map(|command| format!("rode `{command}`"))
.collect::<Vec<_>>()
.join(" · ")
}
fn selected_model_id(provider: &ProviderCatalogEntry, index: usize) -> String {
provider
.models
.get(index.min(provider.models.len().saturating_sub(1)))
.map(|model| model.id.clone())
.or_else(|| provider.model.clone())
.unwrap_or_else(|| "default/config".to_string())
}
fn selected_model_label(provider: &ProviderCatalogEntry, index: usize) -> String {
provider
.models
.get(index.min(provider.models.len().saturating_sub(1)))
.map(|model| {
if model.label == model.id {
model.id.clone()
} else {
format!("{} ({})", model.label, model.id)
}
})
.or_else(|| provider.model.clone())
.unwrap_or_else(|| "default/config".to_string())
}
fn selected_effort_label(provider: &ProviderCatalogEntry, index: usize) -> String {
provider
.efforts
.get(index.min(provider.efforts.len().saturating_sub(1)))
.cloned()
.or_else(|| provider.effort.clone())
.unwrap_or_else(|| "medium".to_string())
}
fn effort_segments(
provider: &ProviderCatalogEntry,
selected_index: usize,
fallback: &str,
) -> Line<'static> {
let efforts = if provider.efforts.is_empty() {
vec![fallback.to_string()]
} else {
provider.efforts.clone()
};
let th = current_theme();
let mut spans = vec![Span::styled(" Effort ", Style::default().fg(th.primary))];
for (idx, effort) in efforts.iter().enumerate() {
let selected = idx == selected_index.min(efforts.len().saturating_sub(1));
let style = if selected {
Style::default()
.fg(th.on_primary)
.bg(th.primary)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(th.text_muted)
};
spans.push(Span::styled(format!(" {effort} "), style));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(
"[/] toggle",
Style::default().fg(th.text_muted),
));
Line::from(spans)
}
fn current_provider_quota_label(app: &App) -> String {
app.provider_selection
.as_ref()
.map(|provider| provider_usage_limits_label(&provider.id, &provider.usage_limits))
.unwrap_or_else(|| "provider não resolvido".to_string())
}
fn parallel_usage_label(app: &App) -> String {
let mut total = TokenUsageSample::default();
let mut count = 0usize;
for usage in app.parallel.iter().filter_map(|slot| slot.usage.as_ref()) {
total.input_tokens_estimate += usage.input_tokens_estimate;
total.cached_input_tokens_estimate += usage.cached_input_tokens_estimate;
total.output_tokens_estimate += usage.output_tokens_estimate;
total.reasoning_output_tokens_estimate += usage.reasoning_output_tokens_estimate;
total.total_tokens_estimate += usage.total_tokens_estimate;
total.input_bytes += usage.input_bytes;
total.output_bytes += usage.output_bytes;
total.provider_reported |= usage.provider_reported;
count += 1;
}
if count == 0 {
return "aguardando amostras".to_string();
}
format!("{} slot(s) · {}", count, usage_sample_label(&total))
}
fn execution_usage_label(app: &App) -> String {
let mut total = TokenUsageSample::default();
let mut count = 0usize;
if let Some(session) = app.execution.as_ref() {
for usage in session.tasks.iter().filter_map(|task| task.usage.as_ref()) {
total.input_tokens_estimate += usage.input_tokens_estimate;
total.cached_input_tokens_estimate += usage.cached_input_tokens_estimate;
total.output_tokens_estimate += usage.output_tokens_estimate;
total.reasoning_output_tokens_estimate += usage.reasoning_output_tokens_estimate;
total.total_tokens_estimate += usage.total_tokens_estimate;
total.input_bytes += usage.input_bytes;
total.output_bytes += usage.output_bytes;
total.provider_reported |= usage.provider_reported;
count += 1;
}
}
if count == 0 {
return "aguardando amostras".to_string();
}
format!("{} task(s) · {}", count, usage_sample_label(&total))
}
fn provider_usage_limits_label(provider_id: &str, limits: &[ProviderUsageLimit]) -> String {
if limits.is_empty() && provider_id == "codex" {
return "Codex CLI não expõe saldo real; usando sessão local".to_string();
}
usage_limits_label(limits)
}
fn usage_limits_label(limits: &[ProviderUsageLimit]) -> String {
if limits.is_empty() {
return "não informado pelo provider".to_string();
}
limits
.iter()
.take(4)
.map(usage_limit_label)
.collect::<Vec<_>>()
.join(" · ")
}
fn usage_limit_label(limit: &ProviderUsageLimit) -> String {
let unit = usage_unit_label(&limit.unit);
let window = usage_window_label(&limit.window);
let amount = match (limit.remaining, limit.limit) {
(Some(remaining), Some(total)) => {
format!(
"{} de {} {}",
format_amount(remaining),
format_amount(total),
unit
)
}
(Some(remaining), None) => format!("{} {} restantes", format_amount(remaining), unit),
(None, Some(total)) => format!("limite {} {}", format_amount(total), unit),
(None, None) => "saldo não informado".to_string(),
};
let mut label = if window.is_empty() {
amount
} else {
format!("{amount}/{window}")
};
if let Some(resets_at) = limit.resets_at.as_deref().filter(|value| !value.is_empty()) {
label.push_str(&format!(" reset {resets_at}"));
}
if let Some(source) = limit.source.as_deref().filter(|value| !value.is_empty()) {
label.push_str(&format!(" ({source})"));
}
label
}
fn usage_unit_label(unit: &str) -> &str {
match unit.trim().to_ascii_lowercase().as_str() {
"hour" | "hours" | "hora" | "horas" => "h",
"request" | "requests" | "requisicao" | "requisicoes" => "req",
"credit" | "credits" | "credito" | "creditos" => "créditos",
"token" | "tokens" => "tokens",
_ if unit.trim().is_empty() => "unid.",
_ => unit.trim(),
}
}
fn usage_window_label(window: &str) -> &str {
match window.trim().to_ascii_lowercase().as_str() {
"hour" | "hourly" | "hora" => "hora",
"day" | "daily" | "dia" => "dia",
"week" | "weekly" | "semana" => "semana",
"month" | "monthly" | "mes" | "mês" => "mês",
_ => window.trim(),
}
}
fn format_amount(value: f64) -> String {
if (value.fract()).abs() < f64::EPSILON {
format!("{}", value as i64)
} else {
format!("{value:.1}")
}
}
fn token_budget_remaining_label(budget: u32, used: usize) -> String {
let budget = budget as usize;
let remaining = budget.saturating_sub(used);
format!("~{} tokens", format_usize(remaining))
}
fn metric_line(label: &'static str, value: &str) -> Line<'static> {
let th = current_theme();
Line::from(vec![
Span::styled(format!(" {label:<8}"), Style::default().fg(th.primary)),
Span::styled(truncate(value, 42), Style::default().fg(th.text_strong)),
])
}
fn section_label(label: &'static str) -> Line<'static> {
let th = current_theme();
Line::from(Span::styled(
format!(" {label}"),
Style::default()
.fg(th.section_header)
.add_modifier(Modifier::BOLD),
))
}
fn context_lines(context: Option<&StageContextSample>) -> Vec<Line<'static>> {
let Some(context) = context else {
return vec![metric_line("Prompt", "aguardando montagem")];
};
let mut lines = vec![metric_line(
"Prompt",
&format!(
"~{} tokens · {} bytes",
format_usize(context.prompt_tokens_estimate),
format_usize(context.prompt_bytes)
),
)];
lines.push(metric_line(
"Artef.",
&format!(
"{} arquivo(s) · {} chars{}",
context.artifact_count,
format_usize(context.artifact_chars),
if context.truncated {
" · truncado"
} else {
""
}
),
));
lines.push(metric_line(
"Janela",
&context
.context_window_tokens
.map(|window| {
let pct = (context.prompt_tokens_estimate as f64 / window.max(1) as f64) * 100.0;
format!(
"{} tokens · {:.1}% usado",
format_usize(window as usize),
pct
)
})
.unwrap_or_else(|| "não configurada no provider".to_string()),
));
lines
}
fn session_usage_label(app: &App) -> String {
let usage = &app.session_usage;
let current = app.current_usage.as_ref();
let generation_count = usage.generation_count + usize::from(current.is_some());
if generation_count == 0 {
return "sem consumo estimado nesta sessão".to_string();
}
let input = usage.input_tokens_estimate
+ current
.map(|sample| sample.input_tokens_estimate)
.unwrap_or_default();
let output = usage.output_tokens_estimate
+ current
.map(|sample| sample.output_tokens_estimate)
.unwrap_or_default();
let total = usage.total_tokens_estimate
+ current
.map(|sample| sample.total_tokens_estimate)
.unwrap_or_default();
let provider_reported_count = usage.provider_reported_count
+ usize::from(current.is_some_and(|sample| sample.provider_reported));
let source = if provider_reported_count == generation_count {
"reportado"
} else if provider_reported_count == 0 {
"estimado"
} else {
"misto"
};
format!(
"{source}: in ~{} · out ~{} · total ~{} tokens · {} geração(ões)",
format_usize(input),
format_usize(output),
format_usize(total),
generation_count
)
}
fn current_or_last_usage_label(app: &App) -> String {
app.current_usage
.as_ref()
.or(app.last_usage.as_ref())
.map(usage_sample_label)
.unwrap_or_else(|| "aguardando primeira amostra".to_string())
}
fn current_or_last_cost_label(app: &App) -> String {
app.current_usage
.as_ref()
.or(app.last_usage.as_ref())
.map(cost_label)
.unwrap_or_else(|| "aguardando usage".to_string())
}
fn last_usage_label(app: &App) -> String {
app.last_usage
.as_ref()
.map(usage_sample_label)
.unwrap_or_else(|| "sem geração nesta sessão".to_string())
}
fn cost_label(sample: &TokenUsageSample) -> String {
let Some(cost) = &sample.cost else {
return "preço não configurado no modelo".to_string();
};
format!(
"{} {:.6} · in {:.6} · cache {:.6} · out {:.6}",
cost.currency, cost.total_cost, cost.input_cost, cost.cached_input_cost, cost.output_cost
)
}
fn usage_sample_label(sample: &TokenUsageSample) -> String {
let source = if sample.provider_reported {
"reportado"
} else {
"estimado"
};
format!(
"{source}: in ~{}{} · out ~{}{} · total ~{} tokens",
format_usize(sample.input_tokens_estimate),
if sample.cached_input_tokens_estimate > 0 {
format!(
" (cache ~{})",
format_usize(sample.cached_input_tokens_estimate)
)
} else {
String::new()
},
format_usize(sample.output_tokens_estimate),
if sample.reasoning_output_tokens_estimate > 0 {
format!(
" (reasoning ~{})",
format_usize(sample.reasoning_output_tokens_estimate)
)
} else {
String::new()
},
format_usize(sample.total_tokens_estimate)
)
}
fn format_usize(value: usize) -> String {
let raw = value.to_string();
let mut out = String::new();
for (idx, ch) in raw.chars().rev().enumerate() {
if idx > 0 && idx % 3 == 0 {
out.push('.');
}
out.push(ch);
}
out.chars().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::app::{ExecutionSession, TaskRun};
use crate::tui::execution::{DiffReport, ExecutionTask};
use crate::tui::parallel::{AgentHandle, AgentSlotStatus};
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::path::PathBuf;
use std::sync::mpsc;
fn draw(app: &App, w: u16, h: u16) {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| render(f, app)).unwrap();
}
fn parallel_handle(id: &str, status: AgentSlotStatus) -> AgentHandle {
let (_tx, rx) = mpsc::channel();
AgentHandle {
task_id: id.to_string(),
task_label: format!("Task {id}"),
rx,
status,
trace: vec![format!("trace {id}")],
usage: None,
result: None,
fragment_path: PathBuf::from(format!("/tmp/{id}.md")),
}
}
fn task_run(id: &str, status: TaskRunStatus) -> TaskRun {
TaskRun {
task: ExecutionTask {
id: id.to_string(),
title: format!("Task {id}"),
body: String::new(),
dependencies: Vec::new(),
independent: true,
},
status,
trace: vec![format!("trace {id}")],
usage: None,
result: None,
report_path: PathBuf::from(format!("/tmp/{id}.md")),
report: format!("Relatório {id}"),
diff: Some(DiffReport {
changed_files: vec![format!("src/{id}.rs")],
summary: format!("M src/{id}.rs"),
patch: format!("--- a/src/{id}.rs\n+++ b/src/{id}.rs\n"),
}),
before: None,
}
}
fn execution_session() -> ExecutionSession {
ExecutionSession {
stage: Stage::Execution,
force: false,
tasks: vec![
task_run("TSK-01", TaskRunStatus::Running),
task_run("TSK-02", TaskRunStatus::Succeeded),
task_run("TSK-03", TaskRunStatus::Failed),
],
selected_index: 1,
detail_view: TaskDetailView::Diff,
current_index: Some(0),
rx: None,
}
}
#[test]
fn renders_empty_list_no_panic() {
let app = App::new(PathBuf::from("."), PathBuf::from("sdd"));
for (w, h) in [(40, 12), (80, 24), (120, 40)] {
draw(&app, w, h);
}
}
#[test]
fn renders_all_screens_no_panic() {
let mut app = App::new(PathBuf::from("."), PathBuf::from("sdd"));
for screen in [
Screen::ProviderSetup,
Screen::OrchestrationList,
Screen::NewOrchestration,
Screen::Running,
Screen::TaskExecution,
Screen::TaskDecision,
Screen::ParallelRunning,
Screen::ParallelDecision,
Screen::ErrorPanel,
Screen::Help,
] {
app.screen = screen;
if screen == Screen::ErrorPanel {
app.error = Some("linha1\nlinha2".to_string());
}
if matches!(screen, Screen::ParallelRunning | Screen::ParallelDecision) {
app.parallel_stage = Some(Stage::Execution);
app.parallel = vec![
parallel_handle("T-01", AgentSlotStatus::EmExecucao),
parallel_handle("T-02", AgentSlotStatus::Concluido),
parallel_handle("T-03", AgentSlotStatus::Erro),
];
}
if matches!(screen, Screen::TaskExecution | Screen::TaskDecision) {
app.execution = Some(execution_session());
}
draw(&app, 80, 24);
draw(&app, 120, 32);
}
}
#[test]
fn renders_overlays_no_panic() {
let mut app = App::new(PathBuf::from("."), PathBuf::from("sdd"));
app.screen = Screen::OrchestrationList;
for overlay in [
Overlay::CommandPalette,
Overlay::SlashMenu,
Overlay::ProviderDialog,
Overlay::Telemetry,
Overlay::Confirm,
] {
app.overlay = overlay;
if overlay == Overlay::CommandPalette {
app.palette.open();
app.palette.push_char('n');
}
if overlay == Overlay::SlashMenu {
app.slash_query = "model".to_string();
app.slash_index = 0;
}
for (w, h) in [(40, 12), (80, 24), (120, 40)] {
draw(&app, w, h);
}
}
}
#[test]
fn slash_menu_rect_alinha_com_largura_do_campo() {
let area = Rect::new(2, 1, 120, 40);
let rect = slash_menu_rect(area, 14);
assert_eq!(rect.x, area.x);
assert_eq!(rect.width, area.width);
assert_eq!(rect.height, 14);
assert_eq!(rect.y, 23);
}
#[test]
fn ui_nao_usa_cor_literal_ad_hoc() {
let src = include_str!("ui.rs");
let needle = concat!("Color", "::");
assert!(
!src.contains(needle),
"ui.rs deve usar tokens do tema (theme.rs) em vez de cores literais"
);
}
}