pub mod app;
pub mod components;
pub mod editor;
pub mod event;
pub mod execution;
pub mod loader;
pub mod markdown;
pub mod orchestration;
pub mod palette;
pub mod parallel;
pub mod persist;
pub mod runner;
pub mod stage;
pub mod theme;
pub mod ui;
use anyhow::{bail, Result};
use clap::Args;
use crossterm::event::{self as cevent, Event, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::tty::IsTty;
use ratatui::backend::CrosstermBackend;
use ratatui::Terminal;
use serde_json::json;
use std::io::{self, Stdout};
use std::path::PathBuf;
use std::sync::mpsc::TryRecvError;
use std::time::Duration;
use app::{App, Screen};
use event::map_key;
#[cfg(test)]
pub(crate) fn noop_success_exe() -> PathBuf {
#[cfg(windows)]
{
use std::io::Write;
let path = std::env::temp_dir().join("sdd-test-noop.cmd");
if let Ok(mut file) = std::fs::File::create(&path) {
let _ = file.write_all(b"@exit /b 0\r\n");
}
path
}
#[cfg(not(windows))]
{
PathBuf::from("/usr/bin/true")
}
}
#[derive(Args)]
pub struct TuiArgs {
#[arg(long)]
pub root: Option<PathBuf>,
}
pub fn tty_ready(is_tty: bool, term: Option<&str>) -> bool {
if !is_tty {
return false;
}
!matches!(term, Some("dumb"))
}
struct TerminalGuard;
impl TerminalGuard {
fn enter() -> Result<TerminalGuard> {
enable_raw_mode()?;
execute!(io::stdout(), EnterAlternateScreen)?;
Ok(TerminalGuard)
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
}
}
fn install_panic_hook() {
let original = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
original(info);
}));
}
fn log_event(root: &std::path::Path, event: serde_json::Value) {
let _ = crate::log_jsonl(root, "tui-events.jsonl", event);
}
pub fn run(args: TuiArgs) -> Result<()> {
let term = std::env::var("TERM").ok();
if !tty_ready(io::stdout().is_tty(), term.as_deref()) {
bail!(
"sdd tui requer um terminal interativo (TTY). \
Use os subcomandos individuais: sdd idea, sdd prd, sdd techspec, ..."
);
}
let root = args.root.unwrap_or_else(|| PathBuf::from("."));
let exe = runner::self_exe();
install_panic_hook();
let _guard = TerminalGuard::enter()?;
let backend = CrosstermBackend::new(io::stdout());
let mut terminal: Terminal<CrosstermBackend<Stdout>> = Terminal::new(backend)?;
let mut app = App::new(root.clone(), exe);
log_event(
&root,
json!({"ts": crate::now_public(), "event": "tui_open"}),
);
let res = event_loop(&mut terminal, &mut app, &root);
log_event(
&root,
json!({"ts": crate::now_public(), "event": "tui_close"}),
);
res
}
fn event_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
root: &std::path::Path,
) -> Result<()> {
loop {
if app.needs_redraw {
terminal.clear()?;
app.needs_redraw = false;
}
terminal.draw(|f| ui::render(f, app))?;
let mut events = Vec::new();
let mut finished = Vec::new();
if let Some(rx) = &app.pending {
while let Ok(event) = rx.try_recv() {
let is_finished = matches!(event, runner::StageEvent::Finished(_));
events.push(event);
if is_finished {
break;
}
}
}
for event in events {
if let Some(result) = app.on_stage_event(event) {
finished.push(result);
}
}
for result in finished {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "stage_finished",
"stage": result.stage.key(),
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"agent": result.agent.clone(),
"model": result.model.clone(),
"usage": {
"input_tokens": result.usage.input_tokens_estimate,
"cached_input_tokens": result.usage.cached_input_tokens_estimate,
"output_tokens": result.usage.output_tokens_estimate,
"reasoning_output_tokens": result.usage.reasoning_output_tokens_estimate,
"total_tokens": result.usage.total_tokens_estimate,
"provider_reported": result.usage.provider_reported,
"cost": result.usage.cost.as_ref().map(|cost| json!({
"currency": cost.currency,
"input_cost": cost.input_cost,
"cached_input_cost": cost.cached_input_cost,
"output_cost": cost.output_cost,
"total_cost": cost.total_cost,
})),
},
"context": {
"prompt_tokens_estimate": result.context.prompt_tokens_estimate,
"prompt_bytes": result.context.prompt_bytes,
"artifact_count": result.context.artifact_count,
"artifact_chars": result.context.artifact_chars,
"truncated": result.context.truncated,
"context_window_tokens": result.context.context_window_tokens,
},
}),
);
app.on_stage_result(result);
}
for result in drain_execution_events(app) {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "execution_task_finished",
"stage": result.stage.key(),
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"agent": result.agent.clone(),
"model": result.model.clone(),
"usage": {
"input_tokens": result.usage.input_tokens_estimate,
"cached_input_tokens": result.usage.cached_input_tokens_estimate,
"output_tokens": result.usage.output_tokens_estimate,
"reasoning_output_tokens": result.usage.reasoning_output_tokens_estimate,
"total_tokens": result.usage.total_tokens_estimate,
"provider_reported": result.usage.provider_reported,
},
}),
);
}
for (task_id, result) in drain_parallel_events(app) {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "parallel_slot_finished",
"stage": result.stage.key(),
"task_id": task_id,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"agent": result.agent.clone(),
"model": result.model.clone(),
"usage": {
"input_tokens": result.usage.input_tokens_estimate,
"cached_input_tokens": result.usage.cached_input_tokens_estimate,
"output_tokens": result.usage.output_tokens_estimate,
"reasoning_output_tokens": result.usage.reasoning_output_tokens_estimate,
"total_tokens": result.usage.total_tokens_estimate,
"provider_reported": result.usage.provider_reported,
},
}),
);
}
if app.screen == Screen::ParallelRunning && app.parallel_barrier_reached() {
let mut fx = app::SideEffects::default();
app.resolve_parallel_barrier(&mut fx);
process_side_effects(root, &fx);
}
app.loader.tick();
if cevent::poll(Duration::from_millis(100))? {
match cevent::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
let action = map_key(key, app.text_mode());
let fx = app.update(action);
process_side_effects(root, &fx);
}
Event::Resize(_, _) => { }
_ => {}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
fn drain_execution_events(app: &mut App) -> Vec<runner::StageResult> {
let mut finished = Vec::new();
loop {
let event = {
let Some(session) = app.execution.as_ref() else {
break;
};
let Some(rx) = session.rx.as_ref() else {
break;
};
rx.try_recv()
};
match event {
Ok(event) => {
if let Some(result) = app.on_execution_event(event) {
finished.push(result);
break;
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
app.on_execution_disconnected();
break;
}
}
}
finished
}
fn drain_parallel_events(app: &mut App) -> Vec<(String, runner::StageResult)> {
let mut finished = Vec::new();
let mut disconnected = Vec::new();
let len = app.parallel.len();
for idx in 0..len {
let task_id = app.parallel[idx].task_id.clone();
loop {
let event = app.parallel[idx].rx.try_recv();
match event {
Ok(event) => {
let result = match &event {
runner::StageEvent::Finished(result) => Some(result.clone()),
_ => None,
};
let is_finished = result.is_some();
app.on_parallel_event(idx, event);
if let Some(result) = result {
finished.push((task_id.clone(), result));
}
if is_finished {
break;
}
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
disconnected.push(idx);
break;
}
}
}
}
for idx in disconnected {
app.on_parallel_disconnected(idx);
}
finished
}
fn process_side_effects(root: &std::path::Path, fx: &app::SideEffects) {
if let Some(stage) = fx.log_stage_started {
log_event(
root,
json!({"ts": crate::now_public(), "event": "stage_started", "stage": stage.key()}),
);
}
if let Some((stage, slot_count)) = fx.log_parallel_started {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "parallel_started",
"stage": stage.key(),
"slot_count": slot_count,
}),
);
}
if let Some((stage, done, errors)) = fx.log_parallel_finished {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "parallel_finished",
"stage": stage.key(),
"done": done,
"errors": errors,
}),
);
}
if let Some((stage, decision)) = fx.log_checkpoint {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "checkpoint_decision",
"stage": stage.key(),
"decision": decision.as_str(),
}),
);
}
if let Some(slug) = &fx.log_orchestration_selected {
log_event(
root,
json!({"ts": crate::now_public(), "event": "orchestration_selected", "slug": slug}),
);
}
if let Some(provider) = &fx.log_provider_selected {
log_event(
root,
json!({
"ts": crate::now_public(),
"event": "provider_selected",
"provider": provider.id.as_str(),
"kind": provider.kind.as_str(),
"model": provider.model.as_str(),
"effort": provider.effort.as_str(),
"offline": provider.offline,
"enabled": provider.enabled,
"auth_env": provider.auth_env.as_deref(),
"auth_present": provider.auth_present,
"auth_methods": &provider.auth_methods,
"models": &provider.models,
"efforts": &provider.efforts,
"stage_models": &provider.stage_models,
"stage_efforts": &provider.stage_efforts,
"token_budget": provider.token_budget,
"usage_limits": &provider.usage_limits,
"capabilities": &provider.capabilities,
}),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::parallel::{AgentHandle, AgentSlotStatus};
use crate::tui::runner::{StageContextSample, StageEvent, StageResult, TokenUsageSample};
use crate::tui::stage::Stage;
use std::sync::mpsc;
#[test]
fn degrades_without_tty() {
assert!(!tty_ready(false, Some("xterm-256color")));
}
#[test]
fn degrades_on_dumb_term() {
assert!(!tty_ready(true, Some("dumb")));
}
#[test]
fn ready_on_normal_tty() {
assert!(tty_ready(true, Some("xterm-256color")));
assert!(tty_ready(true, None));
}
fn handle(id: &str, rx: mpsc::Receiver<StageEvent>) -> AgentHandle {
AgentHandle {
task_id: id.to_string(),
task_label: format!("Task {id}"),
rx,
status: AgentSlotStatus::Aguardando,
trace: Vec::new(),
usage: None,
result: None,
fragment_path: PathBuf::from(format!("/tmp/{id}.md")),
}
}
fn result(exit_code: i32) -> StageResult {
StageResult {
stage: Stage::Execution,
exit_code,
stdout: String::new(),
stderr: String::new(),
duration_ms: 1,
trace: vec!["done".to_string()],
agent: runner::AgentProvenance::default(),
model: runner::ModelProvenance::default(),
usage: TokenUsageSample::from_text("in", "out"),
context: StageContextSample::default(),
}
}
#[test]
fn drains_parallel_events_for_all_slots() {
let mut app = App::new(PathBuf::from("."), PathBuf::from("sdd"));
let (tx1, rx1) = mpsc::channel();
let (tx2, rx2) = mpsc::channel();
tx1.send(StageEvent::Trace("slot 1".to_string())).unwrap();
tx1.send(StageEvent::Finished(result(0))).unwrap();
tx2.send(StageEvent::Finished(result(1))).unwrap();
app.parallel = vec![handle("T-01", rx1), handle("T-02", rx2)];
let finished = drain_parallel_events(&mut app);
assert_eq!(finished.len(), 2);
assert_eq!(app.parallel[0].status, AgentSlotStatus::Concluido);
assert_eq!(app.parallel[1].status, AgentSlotStatus::Erro);
}
#[test]
fn disconnected_parallel_channel_marks_slot_as_error() {
let mut app = App::new(PathBuf::from("."), PathBuf::from("sdd"));
let (tx, rx) = mpsc::channel();
drop(tx);
app.parallel = vec![handle("T-01", rx)];
let finished = drain_parallel_events(&mut app);
assert!(finished.is_empty());
assert_eq!(app.parallel[0].status, AgentSlotStatus::Erro);
}
}