use std::path::PathBuf;
use std::sync::mpsc::Receiver;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::contract;
use crate::domain::providers::{
provider_catalog_fast, resolve_provider_fast, ProviderCatalogEntry, ProviderResolveOptions,
ProviderSelection,
};
use ratatui::text::Text;
use super::event::Action;
use super::execution::{
self, CompletedTask, DiffReport, ExecutionTask, TaskRunStatus, WorkspaceSnapshot,
};
use super::loader::Loader;
use super::orchestration::{load_orchestration, scan_orchestrations, Orchestration};
use super::palette::{CommandId, CommandPalette};
use super::parallel::{self, AgentHandle, AgentSlotStatus, SlotResult};
use super::runner::{self, StageContextSample, StageEvent, StageResult, TokenUsageSample};
use super::stage::{can_navigate, Stage};
use super::{editor, persist};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Screen {
Welcome,
ProviderSetup,
OrchestrationList,
NewOrchestration,
StageBoard,
Running,
TaskExecution,
TaskDecision,
ParallelRunning,
ParallelDecision,
Viewer,
Checkpoint,
RefinementPrompt,
ErrorPanel,
Help,
Summary,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Overlay {
None,
CommandPalette,
SlashMenu,
ProviderDialog,
Telemetry,
Confirm,
}
fn command_to_action(command: CommandId) -> Action {
match command {
CommandId::NewOrchestration => Action::New,
CommandId::OpenOrchestration | CommandId::OpenStage => Action::Confirm,
CommandId::SwitchProvider => Action::Provider,
CommandId::Approve => Action::Approve,
CommandId::Regenerate => Action::Regenerate,
CommandId::Edit => Action::Edit,
CommandId::ToggleTelemetry => Action::Telemetry,
CommandId::Help => Action::Help,
CommandId::Quit => Action::Quit,
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum SlashCommandId {
Stage(Stage),
NewOrchestration,
OpenOrchestrations,
Provider,
Model,
Effort,
ProviderSetup,
Telemetry,
Help,
Editor,
Exit,
}
pub struct SlashCommand {
pub id: SlashCommandId,
pub name: &'static str,
pub description: &'static str,
}
pub const SLASH_COMMANDS: &[SlashCommand] = &[
SlashCommand {
id: SlashCommandId::Stage(Stage::Idea),
name: "/idea",
description: "Etapa 1 - descoberta da ideia",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Prd),
name: "/prd",
description: "Etapa 2 - Product Requirements",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Techspec),
name: "/techspec",
description: "Etapa 3 - arquitetura e contratos",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Tasks),
name: "/tasks",
description: "Etapa 4 - backlog atômico",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Refinement),
name: "/refinement",
description: "Etapa 5 - grooming opcional",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Execution),
name: "/execution",
description: "Etapa 6 - execução por tasks",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Adr),
name: "/adr",
description: "Etapa 6a - decisão arquitetural",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Review),
name: "/review",
description: "Etapa 7 - review e QA",
},
SlashCommand {
id: SlashCommandId::Stage(Stage::Memory),
name: "/memory",
description: "Etapa 8 - memória do fluxo",
},
SlashCommand {
id: SlashCommandId::NewOrchestration,
name: "/new",
description: "Criar orquestração com o chat",
},
SlashCommand {
id: SlashCommandId::OpenOrchestrations,
name: "/open",
description: "Abrir lista de orquestrações",
},
SlashCommand {
id: SlashCommandId::Provider,
name: "/provider",
description: "Abrir dialog de provider",
},
SlashCommand {
id: SlashCommandId::Model,
name: "/model",
description: "Trocar modelo no contexto atual",
},
SlashCommand {
id: SlashCommandId::Effort,
name: "/effort",
description: "Trocar effort no contexto atual",
},
SlashCommand {
id: SlashCommandId::ProviderSetup,
name: "/connect",
description: "Abrir dialog de provider",
},
SlashCommand {
id: SlashCommandId::ProviderSetup,
name: "/agents",
description: "Trocar agente/provider",
},
SlashCommand {
id: SlashCommandId::Editor,
name: "/editor",
description: "Abrir editor/config contextual",
},
SlashCommand {
id: SlashCommandId::Telemetry,
name: "/telemetry",
description: "Abrir dashboard de tokens",
},
SlashCommand {
id: SlashCommandId::Help,
name: "/help",
description: "Ajuda da TUI",
},
SlashCommand {
id: SlashCommandId::Exit,
name: "/exit",
description: "Sair da aplicação",
},
];
pub fn slash_visible_indices(query: &str) -> Vec<usize> {
let query = query.trim().trim_start_matches('/').to_lowercase();
let mut matched = SLASH_COMMANDS
.iter()
.enumerate()
.filter_map(|(index, command)| {
let name = command.name.trim_start_matches('/').to_lowercase();
let label = format!("{name} {}", command.description.to_lowercase());
slash_match_quality(&label, &query).map(|quality| (index, quality))
})
.collect::<Vec<_>>();
matched.sort_by_key(|(index, quality)| (*quality, *index));
matched.into_iter().map(|(index, _)| index).collect()
}
fn slash_match_quality(label: &str, query: &str) -> Option<u8> {
if query.is_empty() {
return Some(0);
}
if label.starts_with(query) {
return Some(0);
}
if label.contains(query) {
return Some(1);
}
let mut chars = label.chars();
query
.chars()
.all(|needle| chars.any(|item| item == needle))
.then_some(2)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CheckpointDecision {
Approve,
Regenerate,
Edit,
Skip,
}
impl CheckpointDecision {
pub fn as_str(self) -> &'static str {
match self {
CheckpointDecision::Approve => "approve",
CheckpointDecision::Regenerate => "regenerate",
CheckpointDecision::Edit => "edit",
CheckpointDecision::Skip => "skip",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TaskDetailView {
Log,
Diff,
Report,
}
impl TaskDetailView {
pub fn next(self) -> Self {
match self {
TaskDetailView::Log => TaskDetailView::Diff,
TaskDetailView::Diff => TaskDetailView::Report,
TaskDetailView::Report => TaskDetailView::Log,
}
}
pub fn previous(self) -> Self {
match self {
TaskDetailView::Log => TaskDetailView::Report,
TaskDetailView::Diff => TaskDetailView::Log,
TaskDetailView::Report => TaskDetailView::Diff,
}
}
pub fn label(self) -> &'static str {
match self {
TaskDetailView::Log => "Log",
TaskDetailView::Diff => "Diff",
TaskDetailView::Report => "Report",
}
}
}
pub struct TaskRun {
pub task: ExecutionTask,
pub status: TaskRunStatus,
pub trace: Vec<String>,
pub usage: Option<TokenUsageSample>,
pub result: Option<StageResult>,
pub report_path: PathBuf,
pub report: String,
pub diff: Option<DiffReport>,
pub(crate) before: Option<WorkspaceSnapshot>,
}
pub struct ExecutionSession {
pub stage: Stage,
pub force: bool,
pub tasks: Vec<TaskRun>,
pub selected_index: usize,
pub detail_view: TaskDetailView,
pub current_index: Option<usize>,
pub rx: Option<Receiver<StageEvent>>,
}
#[derive(Clone, Debug, Default)]
pub struct SideEffects {
pub log_stage_started: Option<Stage>,
pub log_parallel_started: Option<(Stage, usize)>,
pub log_parallel_finished: Option<(Stage, usize, usize)>,
pub log_checkpoint: Option<(Stage, CheckpointDecision)>,
pub log_orchestration_selected: Option<String>,
pub log_provider_selected: Option<ProviderSelection>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SessionTokenUsage {
pub input_tokens_estimate: usize,
pub output_tokens_estimate: usize,
pub total_tokens_estimate: usize,
pub input_bytes: usize,
pub output_bytes: usize,
pub generation_count: usize,
pub provider_reported_count: usize,
}
impl SessionTokenUsage {
fn record(&mut self, sample: &TokenUsageSample) {
self.input_tokens_estimate += sample.input_tokens_estimate;
self.output_tokens_estimate += sample.output_tokens_estimate;
self.total_tokens_estimate += sample.total_tokens_estimate;
self.input_bytes += sample.input_bytes;
self.output_bytes += sample.output_bytes;
self.generation_count += 1;
if sample.provider_reported {
self.provider_reported_count += 1;
}
}
}
pub struct App {
pub root: PathBuf,
pub exe: PathBuf,
pub screen: Screen,
pub providers: Vec<ProviderCatalogEntry>,
pub provider_index: usize,
pub provider_model_index: usize,
pub provider_effort_index: usize,
pub provider_return: Screen,
pub provider_selection: Option<ProviderSelection>,
pub provider_error: Option<String>,
pub orchestrations: Vec<Orchestration>,
pub list_index: usize,
pub active: Option<Orchestration>,
pub board_index: usize,
pub input: String,
pub viewer_text: Option<Text<'static>>,
pub viewer_raw: String,
pub viewer_scroll: u16,
pub viewer_stage: Option<Stage>,
pub loader: Loader,
pub error: Option<String>,
pub error_stage: Option<Stage>,
pub provider_trace: Vec<String>,
pub session_usage: SessionTokenUsage,
pub current_usage: Option<TokenUsageSample>,
pub last_usage: Option<TokenUsageSample>,
pub current_context: Option<StageContextSample>,
pub last_context: Option<StageContextSample>,
pub footer: Option<String>,
pub help_return: Screen,
pub pending: Option<Receiver<StageEvent>>,
pub parallel: Vec<AgentHandle>,
pub parallel_stage: Option<Stage>,
pub execution: Option<ExecutionSession>,
pub should_quit: bool,
pub needs_redraw: bool,
pub overlay: Overlay,
pub palette: CommandPalette,
pub slash_query: String,
pub slash_index: usize,
pub new_target_stage: Option<Stage>,
pub confirm_focus: usize,
}
impl App {
pub fn new(root: PathBuf, exe: PathBuf) -> Self {
let orchestrations = scan_orchestrations(&root);
let (providers, provider_selection, provider_error) = load_provider_state(&root);
let provider_index = provider_selection
.as_ref()
.and_then(|selection| providers.iter().position(|p| p.id == selection.id))
.unwrap_or(0);
let (provider_model_index, provider_effort_index) = provider_choice_indices(
&providers,
provider_index,
provider_selection.as_ref(),
None,
);
App {
root,
exe,
screen: Screen::Welcome,
providers,
provider_index,
provider_model_index,
provider_effort_index,
provider_return: Screen::OrchestrationList,
provider_selection,
provider_error,
orchestrations,
list_index: 0,
active: None,
board_index: 0,
input: String::new(),
viewer_text: None,
viewer_raw: String::new(),
viewer_scroll: 0,
viewer_stage: None,
loader: Loader::new(),
error: None,
error_stage: None,
provider_trace: Vec::new(),
session_usage: SessionTokenUsage::default(),
current_usage: None,
last_usage: None,
current_context: None,
last_context: None,
footer: None,
help_return: Screen::OrchestrationList,
pending: None,
parallel: Vec::new(),
parallel_stage: None,
execution: None,
should_quit: false,
needs_redraw: false,
overlay: Overlay::None,
palette: CommandPalette::default(),
slash_query: String::new(),
slash_index: 0,
new_target_stage: None,
confirm_focus: 0,
}
}
pub fn text_mode(&self) -> bool {
matches!(self.screen, Screen::NewOrchestration | Screen::Welcome)
|| matches!(self.overlay, Overlay::CommandPalette | Overlay::SlashMenu)
}
fn refresh_orchestrations(&mut self) {
self.orchestrations = scan_orchestrations(&self.root);
}
fn reload_active(&mut self) {
if let Some(active) = &self.active {
if let Some(updated) = load_orchestration(&active.path.clone()) {
self.active = Some(updated);
}
}
}
fn board_stage(&self) -> Stage {
Stage::GUIDED[self.board_index.min(Stage::GUIDED.len() - 1)]
}
fn stage_has_checkpoint(stage: Stage) -> bool {
contract::stage_by_key(stage.key())
.map(|spec| spec.requires_human_checkpoint)
.unwrap_or(false)
}
pub fn update(&mut self, action: Action) -> SideEffects {
let mut fx = SideEffects::default();
self.footer = None;
if self.overlay != Overlay::None {
self.update_overlay(action, &mut fx);
return fx;
}
match action {
Action::OpenPalette => self.open_palette(),
Action::OpenSlashMenu => self.open_slash_menu(),
Action::Telemetry => self.overlay = Overlay::Telemetry,
Action::Provider => self.open_provider_dialog(),
Action::NextProvider | Action::PreviousProvider
if !matches!(
self.screen,
Screen::ProviderSetup | Screen::TaskExecution | Screen::TaskDecision
) =>
{
self.open_provider_dialog();
}
Action::Quit => {
self.confirm_focus = 1;
self.overlay = Overlay::Confirm;
}
other => self.dispatch_screen(other, &mut fx),
}
fx
}
fn dispatch_screen(&mut self, action: Action, fx: &mut SideEffects) {
match self.screen {
Screen::Welcome => self.update_welcome(action, fx),
Screen::ProviderSetup => self.update_provider_setup(action, fx),
Screen::OrchestrationList => self.update_list(action, fx),
Screen::NewOrchestration => self.update_new(action, fx),
Screen::StageBoard => self.update_board(action, fx),
Screen::Running => self.update_running(action),
Screen::TaskExecution => self.update_task_execution(action),
Screen::TaskDecision => self.update_task_decision(action, fx),
Screen::ParallelRunning => self.update_parallel_running(action),
Screen::ParallelDecision => self.update_parallel_decision(action, fx),
Screen::Viewer => self.update_viewer(action, fx),
Screen::Checkpoint => self.update_checkpoint(action, fx),
Screen::RefinementPrompt => self.update_refinement_prompt(action, fx),
Screen::ErrorPanel => self.update_error(action, fx),
Screen::Help => self.update_help(action),
Screen::Summary => self.update_summary(action),
}
}
fn open_palette(&mut self) {
self.palette.open();
self.overlay = Overlay::CommandPalette;
}
fn open_slash_menu(&mut self) {
self.slash_query.clear();
self.slash_index = 0;
self.overlay = Overlay::SlashMenu;
}
fn open_provider_dialog(&mut self) {
self.sync_provider_choice_indices_for_stage(self.current_provider_stage());
self.overlay = Overlay::ProviderDialog;
}
fn update_overlay(&mut self, action: Action, fx: &mut SideEffects) {
match self.overlay {
Overlay::CommandPalette => match action {
Action::Back => {
self.palette.close();
self.overlay = Overlay::None;
}
Action::Up => self.palette.move_up(),
Action::Down => self.palette.move_down(),
Action::Char(c) => self.palette.push_char(c),
Action::Backspace => self.palette.backspace(),
Action::Confirm => {
let command = self.palette.selected_command();
self.palette.close();
self.overlay = Overlay::None;
if let Some(command) = command {
match command_to_action(command) {
Action::OpenPalette => self.open_palette(),
Action::Telemetry => self.overlay = Overlay::Telemetry,
Action::Provider => self.open_provider_dialog(),
resolved => self.dispatch_screen(resolved, fx),
}
}
}
_ => {}
},
Overlay::SlashMenu => match action {
Action::Back => self.close_slash_menu(),
Action::Up => self.move_slash_selection(-1),
Action::Down => self.move_slash_selection(1),
Action::Char(c) => {
self.slash_query.push(c);
self.clamp_slash_selection();
}
Action::Backspace => {
if self.slash_query.is_empty() {
self.close_slash_menu();
} else {
self.slash_query.pop();
self.clamp_slash_selection();
}
}
Action::NextProvider | Action::PreviousProvider | Action::Provider => {
self.close_slash_menu();
self.open_provider_dialog();
}
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Confirm => {
let command = self.selected_slash_command();
self.close_slash_menu();
if let Some(command) = command {
self.execute_slash_command(command, fx);
}
}
_ => {}
},
Overlay::ProviderDialog => match action {
Action::Back | Action::Provider => self.overlay = Overlay::None,
Action::Up | Action::PreviousProvider => {
if self.provider_index > 0 {
self.provider_index -= 1;
} else if !self.providers.is_empty() {
self.provider_index = self.providers.len() - 1;
}
self.sync_provider_choice_indices_for_stage(self.current_provider_stage());
}
Action::Down | Action::NextProvider => {
if !self.providers.is_empty() {
self.provider_index =
cycle_index(self.provider_index, self.providers.len(), 1);
}
self.sync_provider_choice_indices_for_stage(self.current_provider_stage());
}
Action::PreviousModel => self.cycle_provider_model(-1),
Action::NextModel => self.cycle_provider_model(1),
Action::PreviousEffort => self.cycle_provider_effort(-1),
Action::NextEffort => self.cycle_provider_effort(1),
Action::Regenerate => {
let (providers, selection, error) = load_provider_state(&self.root);
self.providers = providers;
self.provider_selection = selection;
self.provider_error = error;
self.sync_provider_choice_indices_for_stage(self.current_provider_stage());
}
Action::Edit => {
self.overlay = Overlay::None;
self.open_context_editor();
}
Action::Confirm
if self.apply_provider_choice(self.current_provider_stage(), fx) =>
{
self.overlay = Overlay::None;
}
_ => {}
},
Overlay::Telemetry => {
if matches!(action, Action::Back | Action::Telemetry | Action::Confirm) {
self.overlay = Overlay::None;
}
}
Overlay::Confirm => match action {
Action::Back => self.overlay = Overlay::None,
Action::Confirm => {
if self.confirm_focus == 1 {
self.should_quit = true;
}
self.overlay = Overlay::None;
}
Action::PreviousModel
| Action::NextModel
| Action::NextProvider
| Action::PreviousProvider
| Action::NextEffort
| Action::PreviousEffort
| Action::Up
| Action::Down => {
self.confirm_focus = 1 - self.confirm_focus.min(1);
}
_ => {}
},
Overlay::None => {}
}
}
fn close_slash_menu(&mut self) {
self.overlay = Overlay::None;
self.slash_query.clear();
self.slash_index = 0;
}
fn move_slash_selection(&mut self, delta: isize) {
let visible = slash_visible_indices(&self.slash_query);
if visible.is_empty() {
self.slash_index = 0;
return;
}
let len = visible.len() as isize;
self.slash_index =
(self.slash_index as isize + delta).clamp(0, len.saturating_sub(1)) as usize;
}
fn clamp_slash_selection(&mut self) {
let len = slash_visible_indices(&self.slash_query).len();
if len == 0 {
self.slash_index = 0;
} else if self.slash_index >= len {
self.slash_index = len - 1;
}
}
fn selected_slash_command(&self) -> Option<SlashCommandId> {
let visible = slash_visible_indices(&self.slash_query);
visible
.get(self.slash_index)
.and_then(|index| SLASH_COMMANDS.get(*index))
.map(|command| command.id)
}
fn execute_slash_command(&mut self, command: SlashCommandId, fx: &mut SideEffects) {
match command {
SlashCommandId::Stage(stage) => self.jump_to_stage(stage, fx),
SlashCommandId::NewOrchestration => {
self.input.clear();
self.new_target_stage = None;
self.screen = Screen::NewOrchestration;
}
SlashCommandId::OpenOrchestrations => {
self.refresh_orchestrations();
self.screen = Screen::OrchestrationList;
}
SlashCommandId::Provider => self.open_provider_dialog(),
SlashCommandId::Model => self.quick_cycle_model(1, fx),
SlashCommandId::Effort => self.quick_cycle_effort(1, fx),
SlashCommandId::ProviderSetup => self.open_provider_dialog(),
SlashCommandId::Telemetry => self.overlay = Overlay::Telemetry,
SlashCommandId::Help => self.open_help(),
SlashCommandId::Editor => self.open_context_editor(),
SlashCommandId::Exit => {
self.confirm_focus = 1;
self.overlay = Overlay::Confirm;
}
}
}
fn jump_to_stage(&mut self, stage: Stage, fx: &mut SideEffects) {
let Some(active) = self.active.as_ref() else {
self.input.clear();
self.new_target_stage = Some(stage);
self.screen = Screen::NewOrchestration;
self.footer = Some(format!("Descreva o contexto para gerar {}", stage.label()));
return;
};
if let Some(index) = Stage::GUIDED.iter().position(|item| *item == stage) {
self.board_index = index;
}
let stages = active.stages.clone();
if let Err(msg) = can_navigate(&stages, stage) {
self.footer = Some(msg);
self.screen = Screen::StageBoard;
return;
}
if !Stage::GUIDED.contains(&stage) {
self.viewer_stage = Some(stage);
if (self.stage_file_exists(stage) || stage == Stage::Adr) && self.load_viewer(stage) {
self.screen = Screen::Viewer;
} else {
self.start_stage(stage, false, fx);
}
return;
}
self.screen = Screen::StageBoard;
self.dispatch_screen(Action::Confirm, fx);
}
fn open_context_editor(&mut self) {
if let Some(stage) = self.viewer_stage {
if matches!(self.screen, Screen::Viewer | Screen::Checkpoint) {
self.edit_stage(stage);
return;
}
}
let cfg = self.root.join("sdd.config.yaml");
if let Err(e) = editor::open_editor(&cfg) {
self.error = Some(e.to_string());
self.error_stage = None;
self.screen = Screen::ErrorPanel;
return;
}
let (providers, selection, error) = load_provider_state(&self.root);
self.providers = providers;
self.provider_selection = selection;
self.provider_error = error;
self.sync_provider_choice_indices();
self.needs_redraw = true;
}
fn quick_cycle_provider(&mut self, delta: isize, fx: &mut SideEffects) {
if self.providers.is_empty() {
self.footer = Some("Nenhum provider disponível; edite sdd.config.yaml".to_string());
return;
}
self.provider_index = cycle_index(self.provider_index, self.providers.len(), delta);
self.sync_provider_choice_indices_for_stage(self.current_provider_stage());
self.apply_provider_choice(self.current_provider_stage(), fx);
}
fn quick_cycle_model(&mut self, delta: isize, fx: &mut SideEffects) {
let stage = self.current_provider_stage();
self.sync_provider_choice_indices_for_stage(stage);
self.cycle_provider_model(delta);
self.apply_provider_choice(stage, fx);
}
fn quick_cycle_effort(&mut self, delta: isize, fx: &mut SideEffects) {
let stage = self.current_provider_stage();
self.sync_provider_choice_indices_for_stage(stage);
self.cycle_provider_effort(delta);
self.apply_provider_choice(stage, fx);
}
fn apply_provider_choice(
&mut self,
stage_override: Option<Stage>,
fx: &mut SideEffects,
) -> bool {
if self.providers.is_empty() {
self.footer = Some("Nenhum provider disponível; edite sdd.config.yaml".to_string());
return false;
}
let provider = self.providers[self.provider_index.min(self.providers.len() - 1)].clone();
if !provider.enabled {
self.footer = Some(format!(
"Provider {} está desabilitado em sdd.config.yaml",
provider.id
));
return false;
}
let model = selected_provider_model(&provider, self.provider_model_index);
let effort = selected_provider_effort(&provider, self.provider_effort_index);
let options = ProviderResolveOptions {
provider: Some(provider.id.clone()),
model: Some(model.clone()),
effort: Some(effort.clone()),
offline: false,
};
match resolve_provider_fast(&self.root, &options) {
Ok(mut selection) => {
if let Some(stage) = stage_override {
selection
.stage_models
.insert(stage.key().to_string(), model.clone());
selection
.stage_efforts
.insert(stage.key().to_string(), effort.clone());
}
fx.log_provider_selected = Some(selection.clone());
self.provider_selection = Some(selection);
let scope = stage_override
.map(|stage| stage.label().to_string())
.unwrap_or_else(|| "sessão".to_string());
self.footer = Some(format!(
"Rota {scope}: {} · {} · {}",
provider.id, model, effort
));
true
}
Err(e) => {
self.provider_error = Some(e.to_string());
self.footer = Some("Não foi possível resolver o provider".to_string());
false
}
}
}
fn current_provider_stage(&self) -> Option<Stage> {
match self.screen {
Screen::StageBoard => Some(self.board_stage()),
Screen::Running | Screen::Viewer | Screen::Checkpoint => self.viewer_stage,
Screen::RefinementPrompt => Some(Stage::Refinement),
Screen::TaskExecution | Screen::TaskDecision => Some(Stage::Execution),
Screen::ParallelRunning | Screen::ParallelDecision => self.parallel_stage,
_ => None,
}
}
fn provider_return_stage_context(&self) -> Option<Stage> {
match self.provider_return {
Screen::StageBoard => Some(self.board_stage()),
Screen::Running | Screen::Viewer | Screen::Checkpoint => self.viewer_stage,
Screen::RefinementPrompt => Some(Stage::Refinement),
Screen::TaskExecution | Screen::TaskDecision => Some(Stage::Execution),
Screen::ParallelRunning | Screen::ParallelDecision => self.parallel_stage,
_ => None,
}
}
fn open_help(&mut self) {
self.help_return = self.screen;
self.screen = Screen::Help;
}
fn update_provider_setup(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::PreviousProvider => {
if !self.providers.is_empty() {
self.provider_index =
cycle_index(self.provider_index, self.providers.len(), -1);
self.sync_provider_choice_indices();
}
}
Action::NextProvider => {
if !self.providers.is_empty() {
self.provider_index = cycle_index(self.provider_index, self.providers.len(), 1);
self.sync_provider_choice_indices();
}
}
Action::Up => {
if self.provider_index > 0 {
self.provider_index -= 1;
self.sync_provider_choice_indices();
}
}
Action::Down => {
if self.provider_index + 1 < self.providers.len() {
self.provider_index += 1;
self.sync_provider_choice_indices();
}
}
Action::PreviousModel => self.cycle_provider_model(-1),
Action::NextModel => self.cycle_provider_model(1),
Action::PreviousEffort => self.cycle_provider_effort(-1),
Action::NextEffort => self.cycle_provider_effort(1),
Action::Regenerate => {
let (providers, selection, error) = load_provider_state(&self.root);
self.providers = providers;
self.provider_selection = selection;
self.provider_error = error;
if let Some(selection) = &self.provider_selection {
self.provider_index = self
.providers
.iter()
.position(|p| p.id == selection.id)
.unwrap_or(0);
}
self.sync_provider_choice_indices();
}
Action::Edit => {
let cfg = self.root.join("sdd.config.yaml");
if let Err(e) = editor::open_editor(&cfg) {
self.error = Some(e.to_string());
self.error_stage = None;
self.screen = Screen::ErrorPanel;
return;
}
let (providers, selection, error) = load_provider_state(&self.root);
self.providers = providers;
self.provider_selection = selection;
self.provider_error = error;
self.sync_provider_choice_indices();
self.needs_redraw = true;
}
Action::Back => {
if self.provider_selection.is_some() {
self.screen = self.provider_return;
} else {
self.footer = Some("Escolha um provider antes de avançar".to_string());
}
}
Action::Confirm => {
let stage = self.provider_return_stage_context();
if self.apply_provider_choice(stage, fx) {
self.screen = self.provider_return;
}
}
_ => {}
}
}
fn sync_provider_choice_indices(&mut self) {
self.sync_provider_choice_indices_for_stage(None);
}
fn sync_provider_choice_indices_for_stage(&mut self, stage: Option<Stage>) {
let (model_index, effort_index) = provider_choice_indices(
&self.providers,
self.provider_index,
self.provider_selection.as_ref(),
stage,
);
self.provider_model_index = model_index;
self.provider_effort_index = effort_index;
}
fn cycle_provider_model(&mut self, delta: isize) {
let Some(provider) = self.providers.get(self.provider_index) else {
return;
};
let len = provider.models.len().max(1);
self.provider_model_index = cycle_index(self.provider_model_index, len, delta);
}
fn cycle_provider_effort(&mut self, delta: isize) {
let Some(provider) = self.providers.get(self.provider_index) else {
return;
};
let len = provider.efforts.len().max(1);
self.provider_effort_index = cycle_index(self.provider_effort_index, len, delta);
}
fn update_list(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::Up => {
if self.list_index > 0 {
self.list_index -= 1;
}
}
Action::Down => {
if self.list_index + 1 < self.orchestrations.len() {
self.list_index += 1;
}
}
Action::New => {
self.input.clear();
self.new_target_stage = None;
self.screen = Screen::NewOrchestration;
}
Action::Provider => {
self.provider_return = self.screen;
self.screen = Screen::ProviderSetup;
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Confirm => {
if let Some(o) = self.orchestrations.get(self.list_index).cloned() {
fx.log_orchestration_selected = Some(o.slug.clone());
self.board_index = o
.current_stage()
.map(|s| Stage::GUIDED.iter().position(|x| *x == s).unwrap_or(0))
.unwrap_or(0);
self.active = Some(o);
self.screen = Screen::StageBoard;
}
}
_ => {}
}
}
fn update_welcome(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Confirm if self.input.trim().is_empty() => {
self.screen = Screen::OrchestrationList;
}
Action::Back => {
self.new_target_stage = None;
self.screen = Screen::OrchestrationList;
}
Action::Provider => {
self.provider_return = self.screen;
self.screen = Screen::ProviderSetup;
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Help => self.open_help(),
other => self.update_new(other, fx),
}
}
fn update_new(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Back => {
self.screen = Screen::OrchestrationList;
}
Action::Char(c) => self.input.push(c),
Action::Backspace => {
self.input.pop();
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Confirm => {
let idea = self.input.trim().to_string();
if idea.is_empty() {
self.footer = Some("Digite uma ideia antes de confirmar".to_string());
return;
}
match runner::run_init(&self.exe, &self.root, &idea) {
Ok(out) if out.status.success() => {
self.refresh_orchestrations();
if let Some(idx) = self
.orchestrations
.iter()
.position(|o| o.name == idea || o.slug == slugify_like(&idea))
{
self.list_index = idx;
self.active = Some(self.orchestrations[idx].clone());
fx.log_orchestration_selected =
Some(self.orchestrations[idx].slug.clone());
let target = self.new_target_stage.take();
if let Some(stage) = target {
if let Some(index) =
Stage::GUIDED.iter().position(|item| *item == stage)
{
self.board_index = index;
}
self.start_target_stage_from_new(stage, fx);
} else {
self.board_index = 0;
self.screen = Screen::StageBoard;
}
} else {
self.new_target_stage = None;
self.screen = Screen::OrchestrationList;
}
}
Ok(out) => {
self.error = Some(String::from_utf8_lossy(&out.stderr).into_owned());
self.error_stage = None;
self.screen = Screen::ErrorPanel;
}
Err(e) => {
self.error = Some(format!("falha ao executar sdd init: {e}"));
self.error_stage = None;
self.screen = Screen::ErrorPanel;
}
}
}
_ => {}
}
}
fn start_target_stage_from_new(&mut self, stage: Stage, fx: &mut SideEffects) {
self.screen = Screen::StageBoard;
self.viewer_stage = Some(stage);
match stage {
Stage::Execution => self.start_execution_stage(false, fx),
_ => self.start_stage(stage, false, fx),
}
}
fn update_board(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Back => {
self.active = None;
self.screen = Screen::OrchestrationList;
}
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::Provider => {
self.provider_return = self.screen;
self.screen = Screen::ProviderSetup;
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Up => {
if self.board_index > 0 {
self.board_index -= 1;
}
}
Action::Down => {
if self.board_index + 1 < Stage::GUIDED.len() {
self.board_index += 1;
}
}
Action::Confirm => {
let target = self.board_stage();
let stages = match &self.active {
Some(a) => a.stages.clone(),
None => return,
};
if let Err(msg) = can_navigate(&stages, target) {
self.footer = Some(msg);
return;
}
let already = stages
.iter()
.find(|s| s.stage == target)
.map(|s| s.status.is_cleared())
.unwrap_or(false);
if (already || self.stage_file_exists(target)) && self.load_viewer(target) {
self.viewer_stage = Some(target);
self.screen = Screen::Viewer;
return;
}
if target == Stage::Refinement {
self.screen = Screen::RefinementPrompt;
return;
}
if target == Stage::Execution {
self.start_execution_stage(false, fx);
return;
}
if parallel::supports_parallel(target) {
self.start_parallel_stage(target, false, fx);
return;
}
self.start_stage(target, false, fx);
}
_ => {}
}
}
fn start_stage(&mut self, stage: Stage, force: bool, fx: &mut SideEffects) {
let name = match &self.active {
Some(a) => a.name.clone(),
None => return,
};
self.parallel.clear();
self.parallel_stage = None;
self.execution = None;
let provider_label = self
.provider_selection
.as_ref()
.map(|p| {
let model = self.model_for_stage(p, stage);
let effort = self.effort_for_stage(p, stage);
format!("{} · {} · {}", p.id, model, effort)
})
.unwrap_or_else(|| "provider padrão".to_string());
let verb = if force { "Regenerando" } else { "Executando" };
self.loader
.arm(format!("{verb} {} via {provider_label}…", stage.label()));
self.provider_trace = self.stage_trace_preview(stage, force);
self.current_usage = None;
self.current_context = None;
self.pending = Some(runner::spawn_stage(
self.exe.clone(),
stage,
name,
self.root.clone(),
None,
self.stage_run_options(force, stage),
));
self.viewer_stage = Some(stage);
self.screen = Screen::Running;
fx.log_stage_started = Some(stage);
}
fn start_execution_stage(&mut self, force: bool, fx: &mut SideEffects) {
let active = match &self.active {
Some(active) => active.clone(),
None => return,
};
let tasks_path = active.path.join(Stage::Tasks.filename());
let tasks_md = match std::fs::read_to_string(&tasks_path) {
Ok(content) => content,
Err(e) => {
self.error = Some(format!(
"Não foi possível ler {} para executar tasks reais: {e}",
tasks_path.display()
));
self.error_stage = Some(Stage::Execution);
self.screen = Screen::ErrorPanel;
return;
}
};
let tasks = execution::parse_execution_tasks(&tasks_md);
if tasks.is_empty() {
self.error = Some(format!(
"{} não contém tasks reconhecidas. Use tabela `T-01` em `## Backlog` e detalhes `### T-01 — ...` em `## Prompts Agent`, ou itens `### TSK-01 — ...`.",
Stage::Tasks.filename()
));
self.error_stage = Some(Stage::Execution);
self.screen = Screen::ErrorPanel;
return;
}
let provider_id = self
.provider_selection
.as_ref()
.map(|provider| provider.id.as_str())
.unwrap_or("auto");
if provider_id != "codex" {
self.error = Some(format!(
"Execução real de tasks com edição de workspace ainda exige provider `codex`; `{provider_id}` já pode gerar artefatos via adapter direto quando autenticado, mas não executa patches reais nesta versão."
));
self.error_stage = Some(Stage::Execution);
self.screen = Screen::ErrorPanel;
return;
}
self.pending = None;
self.parallel.clear();
self.parallel_stage = None;
self.current_usage = None;
self.current_context = None;
self.provider_trace = vec![
format!("execution_started: {} task(s)", tasks.len()),
"modo: task-by-task com workspace-write".to_string(),
"diff: snapshot por task; git no resumo final quando disponível".to_string(),
];
let runs = tasks
.into_iter()
.map(|task| {
let report_path = execution_report_path(&active.path, &task.id);
TaskRun {
task,
status: TaskRunStatus::Pending,
trace: Vec::new(),
usage: None,
result: None,
report_path,
report: String::new(),
diff: None,
before: None,
}
})
.collect::<Vec<_>>();
self.execution = Some(ExecutionSession {
stage: Stage::Execution,
force,
tasks: runs,
selected_index: 0,
detail_view: TaskDetailView::Log,
current_index: None,
rx: None,
});
self.viewer_stage = Some(Stage::Execution);
self.screen = Screen::TaskExecution;
fx.log_stage_started = Some(Stage::Execution);
self.start_next_execution_task();
}
fn start_next_execution_task(&mut self) {
let Some(index) = self.execution.as_ref().and_then(|session| {
session
.tasks
.iter()
.position(|task| task.status == TaskRunStatus::Pending)
}) else {
return;
};
self.start_execution_task_at(index);
}
fn start_execution_task_at(&mut self, index: usize) {
let (stage, force, task, report_path, name, active_path) = {
let Some(session) = self.execution.as_ref() else {
return;
};
let Some(active) = self.active.as_ref() else {
return;
};
let Some(run) = session.tasks.get(index) else {
return;
};
(
session.stage,
session.force,
run.task.clone(),
run.report_path.clone(),
active.name.clone(),
active.path.clone(),
)
};
let before = match execution::capture_workspace_snapshot(&self.root, Some(&active_path)) {
Ok(snapshot) => snapshot,
Err(e) => {
if let Some(session) = self.execution.as_mut() {
if let Some(run) = session.tasks.get_mut(index) {
run.status = TaskRunStatus::Failed;
run.trace
.push(format!("falha ao capturar snapshot inicial: {e}"));
}
session.selected_index = index;
}
self.loader.disarm();
self.footer = Some("Falha antes de iniciar a task; retente ou cancele".to_string());
self.screen = Screen::TaskDecision;
return;
}
};
let options = self.stage_run_options(force, stage);
let rx = runner::spawn_execution_task(
stage,
name,
task.clone(),
self.root.clone(),
report_path,
options,
);
if let Some(session) = self.execution.as_mut() {
if let Some(run) = session.tasks.get_mut(index) {
run.status = TaskRunStatus::Running;
run.trace = vec![format!("task {} iniciada", task.id)];
run.usage = None;
run.result = None;
run.report.clear();
run.diff = None;
run.before = Some(before);
}
session.current_index = Some(index);
session.selected_index = index;
session.rx = Some(rx);
}
self.loader
.arm(format!("Executando {} — {}…", task.id, task.title));
self.screen = Screen::TaskExecution;
}
fn start_parallel_stage(&mut self, stage: Stage, force: bool, fx: &mut SideEffects) {
let active = match &self.active {
Some(active) => active.clone(),
None => return,
};
let tasks_path = active.path.join(Stage::Tasks.filename());
let tasks_md = match std::fs::read_to_string(&tasks_path) {
Ok(content) => content,
Err(_) => {
self.footer = Some(format!(
"Sem {}, usando execução sequencial",
Stage::Tasks.filename()
));
self.start_stage(stage, force, fx);
return;
}
};
let tasks = parallel::plan_parallel_slots(&tasks_md)
.into_iter()
.filter(|task| task.independent)
.collect::<Vec<_>>();
if tasks.is_empty() {
self.footer = Some("Nenhuma task independente; usando execução sequencial".to_string());
self.start_stage(stage, force, fx);
return;
}
let provider_label = self
.provider_selection
.as_ref()
.map(|p| {
let model = self.model_for_stage(p, stage);
let effort = self.effort_for_stage(p, stage);
format!("{} · {} · {}", p.id, model, effort)
})
.unwrap_or_else(|| "provider padrão".to_string());
self.loader.arm(format!(
"Executando {} AgentSlots de {} via {provider_label}…",
tasks.len(),
stage.label()
));
self.provider_trace = vec![
format!("parallel_started: {} slot(s)", tasks.len()),
format!("stage: {}", stage.key()),
];
self.current_usage = None;
self.current_context = None;
self.pending = None;
self.execution = None;
self.parallel.clear();
let options = self.stage_run_options(force, stage);
for task in tasks {
let fragment_path = parallel_fragment_path(&active.path, stage, &task.id);
let rx = runner::spawn_parallel_agent(
self.exe.clone(),
stage,
active.name.clone(),
task.clone(),
self.root.clone(),
fragment_path.clone(),
options.clone(),
);
self.parallel.push(AgentHandle {
task_id: task.id.clone(),
task_label: task.title.clone(),
rx,
status: AgentSlotStatus::Aguardando,
trace: vec![format!("slot criado para {}", task.id)],
usage: None,
result: None,
fragment_path,
});
}
self.parallel_stage = Some(stage);
self.viewer_stage = Some(stage);
self.screen = Screen::ParallelRunning;
fx.log_stage_started = Some(stage);
fx.log_parallel_started = Some((stage, self.parallel.len()));
}
fn stage_run_options(&self, force: bool, stage: Stage) -> runner::StageRunOptions {
let mut options = runner::StageRunOptions {
force,
..runner::StageRunOptions::default()
};
if let Some(provider) = &self.provider_selection {
options.provider = Some(provider.id.clone());
let model = self.model_for_stage(provider, stage);
if let Some(model_definition) = provider.models.iter().find(|item| item.id == model) {
options.context_window_tokens = model_definition.context_window_tokens;
options.pricing = Some(model_definition.pricing.clone());
}
options.model = Some(model);
options.effort = Some(self.effort_for_stage(provider, stage));
options.offline = provider.offline;
}
options.artifact_dir = self.active.as_ref().map(|active| active.path.clone());
options
}
fn stage_trace_preview(&self, stage: Stage, force: bool) -> Vec<String> {
let mut trace = Vec::new();
trace.push(format!(
"acao: {}",
if force {
"regenerar artefato"
} else {
"gerar artefato"
}
));
if let Some(provider) = &self.provider_selection {
let context_model = self.model_for_stage(provider, stage);
let context_effort = self.effort_for_stage(provider, stage);
trace.push(format!(
"rota resolvida: {} · {context_model} · {context_effort}",
provider.id
));
if runner::has_direct_generation_adapter(&provider.id) {
trace.push(format!("adapter direto: {}", provider.id));
} else {
trace.push(format!(
"adapter direto para {} nao configurado; fallback deterministico",
provider.id
));
}
if !provider.auth_methods.is_empty() {
let methods = provider
.auth_methods
.iter()
.map(|method| {
format!(
"{}:{}",
method.label,
if method.present {
"presente"
} else {
"ausente"
}
)
})
.collect::<Vec<_>>()
.join(", ");
trace.push(format!("auth: {methods}"));
}
} else {
trace.push("provider: auto/config".to_string());
trace.push("adapter: harness deterministico".to_string());
}
trace
}
fn model_for_stage(&self, provider: &ProviderSelection, stage: Stage) -> String {
provider
.stage_models
.get(stage.key())
.cloned()
.unwrap_or_else(|| provider.model.clone())
}
fn effort_for_stage(&self, provider: &ProviderSelection, stage: Stage) -> String {
provider
.stage_efforts
.get(stage.key())
.cloned()
.unwrap_or_else(|| provider.effort.clone())
}
fn stage_file_exists(&self, stage: Stage) -> bool {
self.active
.as_ref()
.map(|active| active.path.join(stage.filename()).exists())
.unwrap_or(false)
}
fn update_running(&mut self, action: Action) {
if let Action::Quit = action {
self.should_quit = true;
}
}
fn update_task_execution(&mut self, action: Action) {
match action {
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::Up => self.move_execution_selection(-1),
Action::Down => self.move_execution_selection(1),
Action::NextProvider | Action::NextEffort => {
if let Some(session) = self.execution.as_mut() {
session.detail_view = session.detail_view.next();
}
}
Action::PreviousProvider | Action::PreviousEffort => {
if let Some(session) = self.execution.as_mut() {
session.detail_view = session.detail_view.previous();
}
}
Action::Regenerate => {
if self.execution_has_running_task() {
self.footer =
Some("Aguarde a task atual terminar antes de retentar".to_string());
return;
}
if !self.retry_selected_or_first_failed_task() {
self.footer = Some("Nenhuma task com erro para retentar".to_string());
}
}
Action::Back => {
if self.execution_has_running_task() {
self.footer =
Some("Task em execução; aguarde finalizar ou use q para sair".to_string());
} else {
self.clear_execution_state();
self.screen = Screen::StageBoard;
}
}
_ => {}
}
}
fn update_task_decision(&mut self, action: Action, _fx: &mut SideEffects) {
match action {
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::Up => self.move_execution_selection(-1),
Action::Down => self.move_execution_selection(1),
Action::NextProvider | Action::NextEffort => {
if let Some(session) = self.execution.as_mut() {
session.detail_view = session.detail_view.next();
}
}
Action::PreviousProvider | Action::PreviousEffort => {
if let Some(session) = self.execution.as_mut() {
session.detail_view = session.detail_view.previous();
}
}
Action::Regenerate => {
if !self.retry_selected_or_first_failed_task() {
self.footer = Some("Nenhuma task com erro para retentar".to_string());
}
}
Action::Approve | Action::Confirm => self.compose_execution_artifact(true),
Action::Back => {
self.clear_execution_state();
self.screen = Screen::StageBoard;
}
_ => {}
}
}
fn move_execution_selection(&mut self, delta: isize) {
let Some(session) = self.execution.as_mut() else {
return;
};
if session.tasks.is_empty() {
session.selected_index = 0;
return;
}
let len = session.tasks.len() as isize;
session.selected_index =
(session.selected_index as isize + delta).clamp(0, len.saturating_sub(1)) as usize;
}
fn execution_has_running_task(&self) -> bool {
self.execution
.as_ref()
.is_some_and(|session| session.current_index.is_some())
}
fn retry_selected_or_first_failed_task(&mut self) -> bool {
let Some(index) = self.execution.as_ref().and_then(|session| {
let selected = session
.tasks
.get(session.selected_index)
.filter(|task| task.status == TaskRunStatus::Failed)
.map(|_| session.selected_index);
selected.or_else(|| {
session
.tasks
.iter()
.position(|task| task.status == TaskRunStatus::Failed)
})
}) else {
return false;
};
if let Some(session) = self.execution.as_mut() {
if let Some(run) = session.tasks.get_mut(index) {
run.status = TaskRunStatus::Pending;
run.trace.clear();
run.usage = None;
run.result = None;
run.report.clear();
run.diff = None;
run.before = None;
}
session.selected_index = index;
}
self.start_execution_task_at(index);
true
}
fn update_parallel_running(&mut self, action: Action) {
match action {
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
_ => {}
}
}
fn update_parallel_decision(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Regenerate => self.retry_failed_parallel_slots(fx),
Action::Approve | Action::Confirm => {
if let Some(stage) = self.parallel_stage {
self.compose_parallel_artifact(stage);
}
}
Action::Back | Action::Quit => {
self.clear_parallel_state();
self.screen = Screen::StageBoard;
}
Action::Help => self.open_help(),
_ => {}
}
}
fn retry_failed_parallel_slots(&mut self, fx: &mut SideEffects) {
let Some(stage) = self.parallel_stage else {
return;
};
let active = match &self.active {
Some(active) => active.clone(),
None => return,
};
let options = self.stage_run_options(true, stage);
let mut retried = 0usize;
for slot in &mut self.parallel {
if slot.status != AgentSlotStatus::Erro {
continue;
}
let task = parallel::TaskRef {
id: slot.task_id.clone(),
title: slot.task_label.clone(),
independent: true,
};
let fragment_path = parallel_fragment_path(&active.path, stage, &slot.task_id);
slot.rx = runner::spawn_parallel_agent(
self.exe.clone(),
stage,
active.name.clone(),
task,
self.root.clone(),
fragment_path.clone(),
options.clone(),
);
slot.status = AgentSlotStatus::Aguardando;
slot.trace = vec![format!("slot {} retentado", slot.task_id)];
slot.usage = None;
slot.result = None;
slot.fragment_path = fragment_path;
retried += 1;
}
if retried > 0 {
self.loader.arm(format!(
"Retentando {retried} AgentSlot(s) de {}…",
stage.label()
));
self.screen = Screen::ParallelRunning;
self.footer = None;
fx.log_parallel_started = Some((stage, retried));
}
}
fn compose_parallel_artifact(&mut self, stage: Stage) {
let active = match &self.active {
Some(active) => active.clone(),
None => return,
};
let results = self.collect_parallel_results();
let artifact = parallel::compose_parallel_artifact(stage, &results);
if let Err(e) = persist::save_stage_content(
&self.exe,
&self.root,
&active.name,
stage.key(),
artifact.as_bytes(),
"draft",
) {
self.error = Some(e.to_string());
self.error_stage = Some(stage);
self.screen = Screen::ErrorPanel;
return;
}
self.reload_active();
self.clear_parallel_state();
if self.load_viewer(stage) {
self.viewer_stage = Some(stage);
self.screen = Screen::Viewer;
} else {
self.error = Some(format!(
"Artefato paralelo gravado, mas {} não pôde ser relido.",
stage.filename()
));
self.error_stage = Some(stage);
self.screen = Screen::ErrorPanel;
}
}
fn collect_parallel_results(&self) -> Vec<SlotResult> {
self.parallel
.iter()
.map(|slot| {
let fragment = if slot.status == AgentSlotStatus::Concluido {
std::fs::read_to_string(&slot.fragment_path)
.or_else(|_| {
Ok::<_, std::io::Error>(
slot.result
.as_ref()
.map(|result| result.stdout.clone())
.unwrap_or_default(),
)
})
.unwrap_or_default()
} else {
String::new()
};
SlotResult {
task_id: slot.task_id.clone(),
task_label: slot.task_label.clone(),
status: slot.status,
fragment,
}
})
.collect()
}
fn clear_parallel_state(&mut self) {
self.parallel.clear();
self.parallel_stage = None;
self.current_usage = None;
self.current_context = None;
self.loader.disarm();
}
pub fn on_stage_event(&mut self, event: StageEvent) -> Option<StageResult> {
match event {
StageEvent::Trace(item) => {
if self.provider_trace.last() == Some(&item) {
return None;
}
self.provider_trace.push(item);
const TRACE_LIMIT: usize = 80;
if self.provider_trace.len() > TRACE_LIMIT {
let drop_count = self.provider_trace.len() - TRACE_LIMIT;
self.provider_trace.drain(0..drop_count);
}
None
}
StageEvent::Usage(usage) => {
self.current_usage = Some(usage);
None
}
StageEvent::Context(context) => {
self.current_context = Some(context);
None
}
StageEvent::Finished(result) => Some(result),
}
}
pub fn on_parallel_event(&mut self, slot_index: usize, event: StageEvent) {
let Some(slot) = self.parallel.get_mut(slot_index) else {
return;
};
match event {
StageEvent::Trace(item) => {
if !slot.status.is_terminal() {
slot.status = AgentSlotStatus::EmExecucao;
}
if slot.trace.last() != Some(&item) {
slot.trace.push(item);
}
const TRACE_LIMIT: usize = 80;
if slot.trace.len() > TRACE_LIMIT {
let drop_count = slot.trace.len() - TRACE_LIMIT;
slot.trace.drain(0..drop_count);
}
}
StageEvent::Usage(usage) => {
slot.usage = Some(usage.clone());
self.current_usage = Some(usage);
}
StageEvent::Context(context) => {
self.current_context = Some(context);
}
StageEvent::Finished(result) => {
slot.status = if result.exit_code == 0 {
AgentSlotStatus::Concluido
} else {
AgentSlotStatus::Erro
};
if slot.usage.is_none() && !result.usage.is_empty() {
slot.usage = Some(result.usage.clone());
}
if !result.usage.is_empty() {
self.session_usage.record(&result.usage);
self.last_usage = Some(result.usage.clone());
}
if result.context.prompt_tokens_estimate > 0 {
self.last_context = Some(result.context.clone());
}
slot.trace = result.trace.clone();
slot.result = Some(result);
}
}
}
pub fn on_parallel_disconnected(&mut self, slot_index: usize) {
let Some(slot) = self.parallel.get_mut(slot_index) else {
return;
};
if slot.status.is_terminal() {
return;
}
slot.status = AgentSlotStatus::Erro;
slot.trace
.push("canal do AgentSlot desconectado antes de Finished".to_string());
}
pub fn parallel_barrier_reached(&self) -> bool {
!self.parallel.is_empty() && self.parallel.iter().all(|slot| slot.status.is_terminal())
}
pub fn parallel_done_count(&self) -> usize {
self.parallel
.iter()
.filter(|slot| slot.status == AgentSlotStatus::Concluido)
.count()
}
pub fn parallel_error_count(&self) -> usize {
self.parallel
.iter()
.filter(|slot| slot.status == AgentSlotStatus::Erro)
.count()
}
pub fn resolve_parallel_barrier(&mut self, fx: &mut SideEffects) {
if self.screen != Screen::ParallelRunning || !self.parallel_barrier_reached() {
return;
}
self.loader.disarm();
let Some(stage) = self.parallel_stage else {
self.screen = Screen::StageBoard;
return;
};
let done = self.parallel_done_count();
let errors = self.parallel_error_count();
fx.log_parallel_finished = Some((stage, done, errors));
if errors > 0 {
self.footer = Some(
"Barreira concluída com erros: retente, aceite parcial ou cancele".to_string(),
);
self.screen = Screen::ParallelDecision;
return;
}
self.compose_parallel_artifact(stage);
}
pub fn on_execution_event(&mut self, event: StageEvent) -> Option<StageResult> {
match event {
StageEvent::Trace(item) => {
let session = self.execution.as_mut()?;
let index = session.current_index?;
let run = session.tasks.get_mut(index)?;
run.status = TaskRunStatus::Running;
if run.trace.last() != Some(&item) {
run.trace.push(item);
}
const TRACE_LIMIT: usize = 120;
if run.trace.len() > TRACE_LIMIT {
let drop_count = run.trace.len() - TRACE_LIMIT;
run.trace.drain(0..drop_count);
}
None
}
StageEvent::Usage(usage) => {
if let Some(session) = self.execution.as_mut() {
if let Some(index) = session.current_index {
if let Some(run) = session.tasks.get_mut(index) {
run.usage = Some(usage.clone());
}
}
}
self.current_usage = Some(usage);
None
}
StageEvent::Context(context) => {
self.current_context = Some(context);
None
}
StageEvent::Finished(result) => {
let result_for_log = result.clone();
self.finish_execution_task(result);
Some(result_for_log)
}
}
}
fn finish_execution_task(&mut self, result: StageResult) {
let (index, before, report_path, active_path) = {
let Some(session) = self.execution.as_mut() else {
return;
};
let Some(index) = session.current_index else {
return;
};
let Some(active) = self.active.as_ref() else {
return;
};
session.rx = None;
session.current_index = None;
let Some(run) = session.tasks.get_mut(index) else {
return;
};
(
index,
run.before.take(),
run.report_path.clone(),
active.path.clone(),
)
};
let diff = match before {
Some(before) => execution::capture_workspace_snapshot(&self.root, Some(&active_path))
.map(|after| execution::diff_snapshots(&before, &after))
.unwrap_or_else(|e| DiffReport {
changed_files: Vec::new(),
summary: format!("falha ao capturar snapshot final: {e}"),
patch: String::new(),
}),
None => DiffReport {
changed_files: Vec::new(),
summary: "snapshot inicial indisponível".to_string(),
patch: String::new(),
},
};
let report = std::fs::read_to_string(&report_path).unwrap_or_default();
let succeeded = result.exit_code == 0;
if let Some(session) = self.execution.as_mut() {
if let Some(run) = session.tasks.get_mut(index) {
run.status = if succeeded {
TaskRunStatus::Succeeded
} else {
TaskRunStatus::Failed
};
if run.usage.is_none() && !result.usage.is_empty() {
run.usage = Some(result.usage.clone());
}
run.trace = result.trace.clone();
run.report = report;
run.diff = Some(diff);
run.result = Some(result.clone());
}
session.selected_index = index;
}
if !result.usage.is_empty() {
self.session_usage.record(&result.usage);
self.last_usage = Some(result.usage.clone());
}
if result.context.prompt_tokens_estimate > 0 {
self.last_context = Some(result.context.clone());
}
self.current_usage = None;
self.current_context = None;
if succeeded {
if self.execution_pending_count() > 0 {
self.start_next_execution_task();
} else {
self.compose_execution_artifact(false);
}
} else {
self.loader.disarm();
self.footer =
Some("Task falhou: retente, aceite parcial ou cancele a execução".to_string());
self.screen = Screen::TaskDecision;
}
}
pub fn on_execution_disconnected(&mut self) {
let Some(session) = self.execution.as_mut() else {
return;
};
let Some(index) = session.current_index.take() else {
return;
};
session.rx = None;
if let Some(run) = session.tasks.get_mut(index) {
run.status = TaskRunStatus::Failed;
run.trace
.push("canal da task desconectado antes de Finished".to_string());
}
session.selected_index = index;
self.loader.disarm();
self.footer = Some("Task desconectou antes de concluir".to_string());
self.screen = Screen::TaskDecision;
}
pub fn execution_total_count(&self) -> usize {
self.execution
.as_ref()
.map(|session| session.tasks.len())
.unwrap_or(0)
}
pub fn execution_done_count(&self) -> usize {
self.execution
.as_ref()
.map(|session| {
session
.tasks
.iter()
.filter(|task| task.status == TaskRunStatus::Succeeded)
.count()
})
.unwrap_or(0)
}
pub fn execution_error_count(&self) -> usize {
self.execution
.as_ref()
.map(|session| {
session
.tasks
.iter()
.filter(|task| task.status == TaskRunStatus::Failed)
.count()
})
.unwrap_or(0)
}
pub fn execution_pending_count(&self) -> usize {
self.execution
.as_ref()
.map(|session| {
session
.tasks
.iter()
.filter(|task| task.status == TaskRunStatus::Pending)
.count()
})
.unwrap_or(0)
}
pub fn selected_execution_task(&self) -> Option<&TaskRun> {
let session = self.execution.as_ref()?;
session.tasks.get(session.selected_index)
}
fn compose_execution_artifact(&mut self, partial: bool) {
let active = match &self.active {
Some(active) => active.clone(),
None => return,
};
if partial {
if let Some(session) = self.execution.as_mut() {
for run in &mut session.tasks {
if matches!(run.status, TaskRunStatus::Pending | TaskRunStatus::Running) {
run.status = TaskRunStatus::Skipped;
run.trace
.push("task pulada ao aceitar resultado parcial".to_string());
}
}
}
}
let tasks = match &self.execution {
Some(session) => session
.tasks
.iter()
.map(|run| CompletedTask {
task: run.task.clone(),
status: run.status,
report: run.report.clone(),
diff: run.diff.clone().unwrap_or_default(),
result: run.result.clone(),
trace: run.trace.clone(),
})
.collect::<Vec<_>>(),
None => return,
};
let git_summary = execution::git_summary(&self.root);
let artifact = execution::compose_execution_artifact(
&active.name,
&tasks,
git_summary.as_deref(),
partial,
);
if let Err(e) = persist::save_stage_content(
&self.exe,
&self.root,
&active.name,
Stage::Execution.key(),
artifact.as_bytes(),
"draft",
) {
self.error = Some(e.to_string());
self.error_stage = Some(Stage::Execution);
self.screen = Screen::ErrorPanel;
return;
}
self.reload_active();
self.clear_execution_state();
if self.load_viewer(Stage::Execution) {
self.viewer_stage = Some(Stage::Execution);
self.screen = Screen::Viewer;
} else {
self.error = Some(format!(
"Artefato de execução gravado, mas {} não pôde ser relido.",
Stage::Execution.filename()
));
self.error_stage = Some(Stage::Execution);
self.screen = Screen::ErrorPanel;
}
}
fn clear_execution_state(&mut self) {
self.execution = None;
self.current_usage = None;
self.current_context = None;
self.loader.disarm();
}
pub fn on_stage_result(&mut self, res: StageResult) {
self.loader.disarm();
self.pending = None;
self.provider_trace = res.trace.clone();
let final_usage = self
.current_usage
.clone()
.filter(|usage| !usage.is_empty())
.or_else(|| {
if res.usage.is_empty() {
None
} else {
Some(res.usage.clone())
}
});
if let Some(usage) = final_usage {
self.session_usage.record(&usage);
self.last_usage = Some(usage);
}
let final_context = self
.current_context
.clone()
.filter(|context| context.prompt_tokens_estimate > 0)
.or_else(|| {
if res.context.prompt_tokens_estimate > 0 {
Some(res.context.clone())
} else {
None
}
});
if let Some(context) = final_context {
self.last_context = Some(context);
}
self.current_usage = None;
self.current_context = None;
if res.exit_code == 0 {
if self.load_viewer(res.stage) {
self.viewer_stage = Some(res.stage);
self.screen = Screen::Viewer;
} else {
self.error = Some(format!(
"Etapa concluiu mas o artefato {} não foi encontrado.",
res.stage.filename()
));
self.error_stage = Some(res.stage);
self.screen = Screen::ErrorPanel;
}
} else {
let detail = if res.stderr.trim().is_empty() {
res.stdout
} else {
res.stderr
};
self.error = Some(detail);
self.error_stage = Some(res.stage);
self.screen = Screen::ErrorPanel;
}
}
fn load_viewer(&mut self, stage: Stage) -> bool {
let Some(active) = &self.active else {
return false;
};
let path = active.path.join(stage.filename());
match std::fs::read_to_string(&path) {
Ok(content) => {
self.viewer_text = Some(super::markdown::parse(&content));
self.viewer_raw = content;
self.viewer_scroll = 0;
true
}
Err(_) => false,
}
}
fn update_viewer(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Back => {
self.screen = Screen::StageBoard;
}
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::Provider => {
self.provider_return = self.screen;
self.screen = Screen::ProviderSetup;
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Up => {
self.viewer_scroll = self.viewer_scroll.saturating_sub(1);
}
Action::Down => {
self.viewer_scroll = self.viewer_scroll.saturating_add(1);
}
Action::Confirm | Action::Approve => {
if let Some(stage) = self.viewer_stage {
if Self::stage_has_checkpoint(stage) {
self.screen = Screen::Checkpoint;
} else {
self.approve_stage(stage, "approved");
}
}
}
Action::Regenerate => {
if let Some(stage) = self.viewer_stage {
fx.log_checkpoint = Some((stage, CheckpointDecision::Regenerate));
if stage == Stage::Execution {
self.start_execution_stage(true, fx);
} else if parallel::supports_parallel(stage) {
self.start_parallel_stage(stage, true, fx);
} else {
self.start_stage(stage, true, fx);
}
}
}
_ => {}
}
}
fn update_checkpoint(&mut self, action: Action, fx: &mut SideEffects) {
let stage = match self.viewer_stage {
Some(s) => s,
None => {
self.screen = Screen::StageBoard;
return;
}
};
match action {
Action::Back => self.screen = Screen::Viewer,
Action::Help => self.open_help(),
Action::Provider => {
self.provider_return = self.screen;
self.screen = Screen::ProviderSetup;
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Approve => {
fx.log_checkpoint = Some((stage, CheckpointDecision::Approve));
self.approve_stage(stage, "approved");
}
Action::Regenerate => {
fx.log_checkpoint = Some((stage, CheckpointDecision::Regenerate));
if stage == Stage::Execution {
self.start_execution_stage(true, fx);
} else if parallel::supports_parallel(stage) {
self.start_parallel_stage(stage, true, fx);
} else {
self.start_stage(stage, true, fx);
}
}
Action::Edit => {
fx.log_checkpoint = Some((stage, CheckpointDecision::Edit));
self.edit_stage(stage);
}
_ => {}
}
}
fn approve_stage(&mut self, stage: Stage, state: &str) {
let active = match &self.active {
Some(a) => a.clone(),
None => return,
};
let file = active.path.join(stage.filename());
match persist::save_stage_state(
&self.exe,
&self.root,
&active.name,
stage.key(),
&file,
state,
) {
Ok(()) => {
self.reload_active();
if stage == Stage::Memory {
self.screen = Screen::Summary;
} else {
self.board_index = (self.board_index + 1).min(Stage::GUIDED.len() - 1);
self.screen = Screen::StageBoard;
}
}
Err(e) => {
self.error = Some(e.to_string());
self.error_stage = Some(stage);
self.screen = Screen::ErrorPanel;
}
}
}
fn edit_stage(&mut self, stage: Stage) {
let Some(active) = &self.active else { return };
let file = active.path.join(stage.filename());
if let Err(e) = editor::open_editor(&file) {
self.error = Some(e.to_string());
self.error_stage = Some(stage);
self.screen = Screen::ErrorPanel;
return;
}
self.load_viewer(stage);
self.needs_redraw = true;
self.screen = Screen::Checkpoint;
}
fn update_refinement_prompt(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Back => self.screen = Screen::StageBoard,
Action::Help => self.open_help(),
Action::Provider => {
self.provider_return = self.screen;
self.screen = Screen::ProviderSetup;
}
Action::NextProvider => self.quick_cycle_provider(1, fx),
Action::PreviousProvider => self.quick_cycle_provider(-1, fx),
Action::NextModel => self.quick_cycle_model(1, fx),
Action::PreviousModel => self.quick_cycle_model(-1, fx),
Action::NextEffort => self.quick_cycle_effort(1, fx),
Action::PreviousEffort => self.quick_cycle_effort(-1, fx),
Action::Regenerate | Action::Confirm => {
self.start_stage(Stage::Refinement, false, fx);
}
Action::Skip => {
fx.log_checkpoint = Some((Stage::Refinement, CheckpointDecision::Skip));
self.approve_stage(Stage::Refinement, "skipped");
}
_ => {}
}
}
fn update_error(&mut self, action: Action, fx: &mut SideEffects) {
match action {
Action::Back => {
self.error = None;
self.screen = Screen::StageBoard;
}
Action::Quit => self.should_quit = true,
Action::Help => self.open_help(),
Action::Regenerate => {
if let Some(stage) = self.error_stage {
self.error = None;
if stage == Stage::Execution {
self.start_execution_stage(true, fx);
} else if parallel::supports_parallel(stage) {
self.start_parallel_stage(stage, true, fx);
} else {
self.start_stage(stage, true, fx);
}
} else {
self.screen = Screen::OrchestrationList;
}
}
Action::Edit => {
let cfg = self.root.join("sdd.config.yaml");
let _ = editor::open_editor(&cfg);
self.needs_redraw = true;
}
_ => {}
}
}
fn update_help(&mut self, action: Action) {
if matches!(action, Action::Back | Action::Quit | Action::Help) {
self.screen = self.help_return;
}
}
fn update_summary(&mut self, action: Action) {
if matches!(action, Action::Quit | Action::Back | Action::Confirm) {
self.should_quit = true;
}
}
}
fn slugify_like(input: &str) -> String {
let mut out = String::new();
let mut prev_dash = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
out.trim_matches('-').to_string()
}
fn load_provider_state(
root: &std::path::Path,
) -> (
Vec<ProviderCatalogEntry>,
Option<ProviderSelection>,
Option<String>,
) {
let providers = match provider_catalog_fast(root) {
Ok(providers) => providers,
Err(e) => return (Vec::new(), None, Some(e.to_string())),
};
let selection = resolve_provider_fast(root, &ProviderResolveOptions::default()).ok();
(providers, selection, None)
}
fn provider_choice_indices(
providers: &[ProviderCatalogEntry],
provider_index: usize,
selection: Option<&ProviderSelection>,
stage: Option<Stage>,
) -> (usize, usize) {
let Some(provider) = providers.get(provider_index) else {
return (0, 0);
};
let selected_model = selection
.filter(|selection| selection.id == provider.id)
.and_then(|selection| {
stage
.and_then(|stage| selection.stage_models.get(stage.key()).map(String::as_str))
.or(Some(selection.model.as_str()))
})
.or(provider.model.as_deref());
let model_index = selected_model
.and_then(|model| provider.models.iter().position(|item| item.id == model))
.unwrap_or(0);
let selected_effort = selection
.filter(|selection| selection.id == provider.id)
.and_then(|selection| {
stage
.and_then(|stage| selection.stage_efforts.get(stage.key()).map(String::as_str))
.or(Some(selection.effort.as_str()))
})
.or(provider.effort.as_deref());
let effort_index = selected_effort
.and_then(|effort| provider.efforts.iter().position(|item| item == effort))
.unwrap_or(0);
(model_index, effort_index)
}
fn selected_provider_model(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".to_string())
}
fn selected_provider_effort(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 cycle_index(current: usize, len: usize, delta: isize) -> usize {
if len == 0 {
return 0;
}
let len = len as isize;
(current as isize + delta).rem_euclid(len) as usize
}
fn parallel_fragment_path(active_path: &std::path::Path, stage: Stage, task_id: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
active_path.join(".sdd-parallel").join(format!(
"{}-{}-{}-{nanos}.md",
stage.key(),
sanitize_fragment_id(task_id),
std::process::id()
))
}
fn execution_report_path(active_path: &std::path::Path, task_id: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
active_path.join(".sdd-execution").join(format!(
"{}-{}-{nanos}.md",
std::process::id(),
sanitize_fragment_id(task_id)
))
}
fn sanitize_fragment_id(value: &str) -> String {
let mut out = String::new();
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let out = out.trim_matches('-').to_string();
if out.is_empty() {
"task".to_string()
} else {
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::providers::ProviderSelection;
use std::fs;
use std::sync::mpsc;
fn app() -> App {
App::new(PathBuf::from("."), crate::tui::noop_success_exe())
}
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::new(),
usage: None,
result: None,
fragment_path: PathBuf::from(format!("/tmp/{id}.md")),
}
}
fn stage_result(stage: Stage, exit_code: i32) -> StageResult {
StageResult {
stage,
exit_code,
stdout: String::new(),
stderr: String::new(),
duration_ms: 10,
trace: vec!["finished".to_string()],
agent: super::runner::AgentProvenance::default(),
model: super::runner::ModelProvenance::default(),
usage: TokenUsageSample::from_text("entrada", "saida"),
context: StageContextSample::default(),
}
}
fn codex_selection() -> ProviderSelection {
ProviderSelection {
id: "codex".to_string(),
kind: "cli".to_string(),
model: "gpt-test".to_string(),
effort: "high".to_string(),
offline: false,
enabled: true,
auth_env: None,
auth_present: None,
auth_methods: Vec::new(),
models: Vec::new(),
efforts: Vec::new(),
stage_models: std::collections::BTreeMap::new(),
stage_efforts: std::collections::BTreeMap::new(),
capabilities: Vec::new(),
token_budget: None,
usage_limits: Vec::new(),
explicit: true,
}
}
fn execution_task(id: &str) -> ExecutionTask {
ExecutionTask {
id: id.to_string(),
title: format!("Task {id}"),
body: "Editar arquivo de teste".to_string(),
dependencies: Vec::new(),
independent: true,
}
}
#[test]
fn quit_from_list() {
let mut a = app();
a.screen = Screen::OrchestrationList;
a.update(Action::Quit);
assert_eq!(a.overlay, Overlay::Confirm);
assert!(!a.should_quit);
a.update(Action::Confirm);
assert!(a.should_quit);
assert_eq!(a.overlay, Overlay::None);
}
#[test]
fn quit_confirm_cancel_keeps_running() {
let mut a = app();
a.screen = Screen::OrchestrationList;
a.update(Action::Quit);
a.update(Action::NextModel);
a.update(Action::Confirm);
assert!(!a.should_quit);
assert_eq!(a.overlay, Overlay::None);
}
#[test]
fn new_orchestration_enters_text_mode() {
let mut a = app();
a.screen = Screen::OrchestrationList;
a.update(Action::New);
assert_eq!(a.screen, Screen::NewOrchestration);
assert!(a.text_mode());
a.update(Action::Char('o'));
a.update(Action::Char('i'));
assert_eq!(a.input, "oi");
a.update(Action::Backspace);
assert_eq!(a.input, "o");
}
#[test]
fn help_returns_to_origin() {
let mut a = app();
a.screen = Screen::ProviderSetup;
a.update(Action::Help);
assert_eq!(a.screen, Screen::Help);
a.update(Action::Back);
assert_eq!(a.screen, Screen::ProviderSetup);
}
#[test]
fn welcome_is_initial_screen() {
let a = app();
assert_eq!(a.screen, Screen::Welcome);
assert!(a.text_mode());
}
#[test]
fn slash_menu_opens_from_text_mode_and_filters_commands() {
let mut a = app();
a.update(Action::OpenSlashMenu);
assert_eq!(a.overlay, Overlay::SlashMenu);
assert!(a.text_mode());
for ch in "model".chars() {
a.update(Action::Char(ch));
}
assert_eq!(a.slash_query, "model");
assert_eq!(a.selected_slash_command(), Some(SlashCommandId::Model));
}
#[test]
fn slash_stage_without_active_opens_new_input_for_target_stage() {
let mut a = app();
a.update(Action::OpenSlashMenu);
for ch in "prd".chars() {
a.update(Action::Char(ch));
}
assert_eq!(
a.selected_slash_command(),
Some(SlashCommandId::Stage(Stage::Prd))
);
a.update(Action::Confirm);
assert_eq!(a.overlay, Overlay::None);
assert_eq!(a.screen, Screen::NewOrchestration);
assert_eq!(a.new_target_stage, Some(Stage::Prd));
assert!(a.text_mode());
}
#[test]
fn tab_opens_provider_dialog_without_immediate_switch() {
let mut a = app();
a.screen = Screen::StageBoard;
let initial_provider = a.provider_index;
a.update(Action::NextProvider);
assert_eq!(a.overlay, Overlay::ProviderDialog);
assert_eq!(a.provider_index, initial_provider);
}
#[test]
fn provider_dialog_applies_selection_on_confirm() {
let mut a = app();
a.screen = Screen::StageBoard;
a.update(Action::NextProvider);
a.update(Action::Down);
let selected_provider = a.providers[a.provider_index].id.clone();
a.update(Action::Confirm);
assert_eq!(a.overlay, Overlay::None);
assert_eq!(
a.provider_selection
.as_ref()
.map(|provider| provider.id.as_str()),
Some(selected_provider.as_str())
);
}
#[test]
fn stage_board_model_shortcut_sets_stage_override() {
let mut a = app();
let codex_index = a
.providers
.iter()
.position(|provider| provider.id == "codex")
.unwrap();
a.provider_index = codex_index;
a.sync_provider_choice_indices_for_stage(Some(Stage::Techspec));
a.screen = Screen::StageBoard;
a.board_index = Stage::GUIDED
.iter()
.position(|stage| *stage == Stage::Techspec)
.unwrap();
a.update(Action::NextModel);
let selection = a.provider_selection.as_ref().unwrap();
assert_eq!(selection.id, "codex");
assert_eq!(
selection.stage_models.get(Stage::Techspec.key()),
Some(&"gpt-5.4".to_string())
);
assert!(a
.footer
.as_deref()
.is_some_and(|footer| footer.contains("Tech Spec")));
}
#[test]
fn empty_idea_does_not_submit() {
let mut a = app();
a.screen = Screen::OrchestrationList;
a.update(Action::New);
a.update(Action::Confirm);
assert_eq!(a.screen, Screen::NewOrchestration);
assert!(a.footer.is_some());
}
#[test]
fn provider_confirm_enters_orchestration_list() {
let mut a = app();
a.screen = Screen::ProviderSetup;
a.update(Action::Confirm);
assert_eq!(a.screen, Screen::OrchestrationList);
assert!(a.provider_selection.is_some());
}
#[test]
fn provider_confirm_returns_to_origin_screen() {
let mut a = app();
a.screen = Screen::StageBoard;
a.update(Action::Provider);
assert_eq!(a.screen, Screen::StageBoard);
assert_eq!(a.overlay, Overlay::ProviderDialog);
a.update(Action::Confirm);
assert_eq!(a.screen, Screen::StageBoard);
assert_eq!(a.overlay, Overlay::None);
assert!(a.provider_selection.is_some());
}
#[test]
fn stage_result_accumulates_session_usage() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("docs/minha-feature");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("traceability-map.yaml"),
r#"
orchestration:
name: "Minha Feature"
slug: "minha-feature"
state: draft
artifacts:
idea:
file: 01-idea.md
state: draft
approved_at: ""
"#,
)
.unwrap();
fs::write(store.join("01-idea.md"), "# Idea\n\n## Rastreabilidade\n").unwrap();
let mut a = App::new(dir.path().to_path_buf(), crate::tui::noop_success_exe());
a.refresh_orchestrations();
a.active = a.orchestrations.first().cloned();
a.on_stage_result(StageResult {
stage: Stage::Idea,
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
duration_ms: 10,
trace: vec!["provider selecionado: codex".to_string()],
agent: super::runner::AgentProvenance::default(),
model: super::runner::ModelProvenance::default(),
usage: TokenUsageSample::from_text("entrada", "saida maior"),
context: StageContextSample::default(),
});
assert_eq!(a.session_usage.generation_count, 1);
assert!(a.session_usage.total_tokens_estimate > 0);
assert!(a.last_usage.is_some());
}
#[test]
fn stage_usage_event_updates_current_usage_before_finish() {
let mut a = app();
let usage = TokenUsageSample::from_provider_usage(100, 0, 20, 0, 120);
assert!(a.on_stage_event(StageEvent::Usage(usage.clone())).is_none());
assert_eq!(a.current_usage, Some(usage));
assert_eq!(a.session_usage.generation_count, 0);
}
#[test]
fn stage_context_event_updates_current_context_before_finish() {
let mut a = app();
let context = StageContextSample {
prompt_tokens_estimate: 900,
artifact_count: 2,
..StageContextSample::default()
};
assert!(a
.on_stage_event(StageEvent::Context(context.clone()))
.is_none());
assert_eq!(a.current_context, Some(context));
assert!(a.last_context.is_none());
}
#[test]
fn pending_stage_with_existing_artifact_opens_viewer() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("docs/minha-feature");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("traceability-map.yaml"),
r#"
orchestration:
name: "Minha Feature"
slug: "minha-feature"
state: draft
artifacts:
idea:
file: 01-idea.md
state: pending
approved_at: ""
"#,
)
.unwrap();
fs::write(store.join("01-idea.md"), "# Idea\n\n## Rastreabilidade\n").unwrap();
let mut a = App::new(dir.path().to_path_buf(), crate::tui::noop_success_exe());
a.refresh_orchestrations();
a.active = a.orchestrations.first().cloned();
a.screen = Screen::StageBoard;
a.board_index = 0;
a.update(Action::Confirm);
assert_eq!(a.screen, Screen::Viewer);
assert!(a.viewer_raw.contains("# Idea"));
}
#[test]
fn execution_with_empty_tasks_shows_error_instead_of_artifact_fallback() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("docs/feat");
fs::create_dir_all(&store).unwrap();
fs::write(store.join("04-tasks.md"), "## Backlog\n\nsem tasks\n").unwrap();
let mut a = App::new(dir.path().to_path_buf(), crate::tui::noop_success_exe());
a.provider_selection = Some(codex_selection());
a.active = Some(Orchestration {
name: "Feat".to_string(),
slug: "feat".to_string(),
path: store,
state: "draft".to_string(),
stages: Vec::new(),
pre_stages: Vec::new(),
post_stages: Vec::new(),
});
let mut fx = SideEffects::default();
a.start_execution_stage(false, &mut fx);
assert_eq!(a.screen, Screen::ErrorPanel);
assert!(a
.error
.as_deref()
.is_some_and(|error| error.contains("não contém tasks reconhecidas")));
}
#[test]
fn finished_execution_task_composes_artifact_with_snapshot_diff() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("docs/feat");
let src = dir.path().join("src");
fs::create_dir_all(&store).unwrap();
fs::create_dir_all(&src).unwrap();
fs::write(src.join("a.txt"), "one\n").unwrap();
let before = execution::capture_workspace_snapshot(dir.path(), Some(&store)).unwrap();
fs::write(src.join("a.txt"), "one\ntwo\n").unwrap();
let report_path = store.join(".sdd-execution/report.md");
fs::create_dir_all(report_path.parent().unwrap()).unwrap();
fs::write(&report_path, "## Resumo\n\nImplementado.").unwrap();
let mut a = App::new(dir.path().to_path_buf(), crate::tui::noop_success_exe());
a.active = Some(Orchestration {
name: "Feat".to_string(),
slug: "feat".to_string(),
path: store.clone(),
state: "draft".to_string(),
stages: Vec::new(),
pre_stages: Vec::new(),
post_stages: Vec::new(),
});
a.screen = Screen::TaskExecution;
a.execution = Some(ExecutionSession {
stage: Stage::Execution,
force: false,
tasks: vec![TaskRun {
task: execution_task("TSK-01"),
status: TaskRunStatus::Running,
trace: Vec::new(),
usage: None,
result: None,
report_path,
report: String::new(),
diff: None,
before: Some(before),
}],
selected_index: 0,
detail_view: TaskDetailView::Log,
current_index: Some(0),
rx: None,
});
a.on_execution_event(StageEvent::Finished(stage_result(Stage::Execution, 0)));
assert_eq!(a.screen, Screen::Viewer);
let artifact = fs::read_to_string(store.join("06-execution.md")).unwrap();
assert!(artifact.contains("## Diff por task"));
assert!(artifact.contains("M src/a.txt"));
}
#[test]
fn start_parallel_stage_populates_slots_for_independent_tasks() {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("docs/feat");
fs::create_dir_all(&store).unwrap();
fs::write(
store.join("04-tasks.md"),
"\
## Backlog
| ID | Título | Estimativa | Dependências | Arquivos | Status |
|----|--------|-----------|-------------|----------|--------|
| T-01 | Base | P | — | src/a.rs | Todo |
| T-02 | UI | P | T-01 | src/b.rs | Todo |
### Tasks independentes entre si
- Grupo A: T-02 é independente entre si.
",
)
.unwrap();
let mut a = App::new(dir.path().to_path_buf(), crate::tui::noop_success_exe());
a.active = Some(Orchestration {
name: "Feat".to_string(),
slug: "feat".to_string(),
path: store,
state: "draft".to_string(),
stages: Vec::new(),
pre_stages: Vec::new(),
post_stages: Vec::new(),
});
let mut fx = SideEffects::default();
a.start_parallel_stage(Stage::Execution, false, &mut fx);
assert_eq!(a.screen, Screen::ParallelRunning);
assert_eq!(a.parallel.len(), 2);
assert_eq!(fx.log_parallel_started, Some((Stage::Execution, 2)));
}
#[test]
fn on_parallel_event_updates_only_target_slot() {
let mut a = app();
a.parallel = vec![
parallel_handle("T-01", AgentSlotStatus::Aguardando),
parallel_handle("T-02", AgentSlotStatus::Aguardando),
];
a.on_parallel_event(0, StageEvent::Trace("rodando".to_string()));
assert_eq!(a.parallel[0].status, AgentSlotStatus::EmExecucao);
assert_eq!(a.parallel[1].status, AgentSlotStatus::Aguardando);
assert_eq!(a.parallel[0].trace, vec!["rodando".to_string()]);
}
#[test]
fn parallel_barrier_reached_only_when_all_terminal() {
let mut a = app();
a.parallel = vec![
parallel_handle("T-01", AgentSlotStatus::Concluido),
parallel_handle("T-02", AgentSlotStatus::EmExecucao),
];
assert!(!a.parallel_barrier_reached());
a.parallel[1].status = AgentSlotStatus::Erro;
assert!(a.parallel_barrier_reached());
}
#[test]
fn resolve_parallel_barrier_with_errors_enters_decision() {
let mut a = app();
a.screen = Screen::ParallelRunning;
a.parallel_stage = Some(Stage::Review);
a.parallel = vec![
parallel_handle("T-01", AgentSlotStatus::Concluido),
parallel_handle("T-02", AgentSlotStatus::Erro),
];
let mut fx = SideEffects::default();
a.resolve_parallel_barrier(&mut fx);
assert_eq!(a.screen, Screen::ParallelDecision);
assert_eq!(fx.log_parallel_finished, Some((Stage::Review, 1, 1)));
}
#[test]
fn parallel_decision_cancel_clears_state() {
let mut a = app();
a.screen = Screen::ParallelDecision;
a.parallel_stage = Some(Stage::Execution);
a.parallel = vec![parallel_handle("T-01", AgentSlotStatus::Erro)];
a.update(Action::Back);
assert_eq!(a.screen, Screen::StageBoard);
assert!(a.parallel.is_empty());
assert_eq!(a.parallel_stage, None);
}
#[test]
fn parallel_finished_result_records_usage_and_status() {
let mut a = app();
a.parallel = vec![parallel_handle("T-01", AgentSlotStatus::EmExecucao)];
a.on_parallel_event(0, StageEvent::Finished(stage_result(Stage::Execution, 0)));
assert_eq!(a.parallel[0].status, AgentSlotStatus::Concluido);
assert!(a.parallel[0].result.is_some());
assert_eq!(a.session_usage.generation_count, 1);
}
}