aether-agent-cli 0.7.16

CLI and ACP server for the Aether AI coding agent
Documentation
use super::InitError;
use super::build_settings::{Preset, supported_providers};
use super::harness::HarnessIntegration;
use clap::ValueEnum;
use llm::catalog::Provider;
use std::io;
use tui::{
    Component, CrosstermEvent, Event, Frame, KeyCode, Line, MouseCapture, MultiSelect, RadioSelect, SelectOption,
    Style, TerminalConfig, TerminalRuntime, Theme, ViewContext, terminal_size,
};

pub async fn run_wizard(
    provider: Option<Provider>,
    preset: Option<Preset>,
    harnesses: Vec<HarnessIntegration>,
) -> Result<Option<(Provider, Preset, Vec<HarnessIntegration>)>, InitError> {
    if let (Some(p), Some(t)) = (provider, preset) {
        return Ok(Some((p, t, harnesses)));
    }

    let mut terminal = TerminalRuntime::new(
        io::stdout(),
        Theme::default(),
        terminal_size().unwrap_or((80, 24)),
        TerminalConfig { bracketed_paste: false, mouse_capture: MouseCapture::Disabled },
    )
    .map_err(InitError::Terminal)?;

    let provider_options = supported_providers().map(|p| (format_provider_title(p), p)).collect();
    let provider = match provider {
        Some(p) => p,
        None => match run_select(&mut terminal, "Choose a provider:", provider_options).await? {
            Some(p) => p,
            None => return Ok(None),
        },
    };

    let resolved_preset = match preset {
        Some(t) => t,
        None => match run_select(&mut terminal, "Choose a preset:", preset_options()).await? {
            Some(t) => t,
            None => return Ok(None),
        },
    };

    let selected_harnesses = if resolved_preset == Preset::BatteriesIncluded && harnesses.is_empty() {
        match run_multi_select(&mut terminal).await? {
            Some(h) => h,
            None => return Ok(None),
        }
    } else {
        harnesses
    };

    Ok(Some((provider, resolved_preset, selected_harnesses)))
}

fn harness_options() -> Vec<(SelectOption, HarnessIntegration)> {
    HarnessIntegration::value_variants()
        .iter()
        .map(|h| {
            let value = h.to_possible_value().map(|v| v.get_name().to_string()).unwrap_or_default();
            (SelectOption { value, title: h.title(), description: Some(h.description()) }, *h)
        })
        .collect()
}

async fn run_multi_select(
    terminal: &mut TerminalRuntime<io::Stdout>,
) -> Result<Option<Vec<HarnessIntegration>>, InitError> {
    let options = harness_options();
    let select_options: Vec<SelectOption> = options.iter().map(|(opt, _)| opt.clone()).collect();
    let all_selected = vec![true; options.len()];
    let mut select = MultiSelect::new(select_options, all_selected);
    let header = "Load prompts, skills, and rules from other harness conventions?";
    let footer =
        "  \u{2191}/\u{2193} to move \u{00b7} Space to toggle \u{00b7} Enter to confirm \u{00b7} Esc to cancel";

    let render = |s: &MultiSelect, ctx: &ViewContext| render_select(s, header, footer, ctx);
    if !run_event_loop(terminal, &mut select, render).await? {
        return Ok(None);
    }

    let selected: Vec<HarnessIntegration> =
        select.selected.iter().enumerate().filter(|(_, s)| **s).map(|(i, _)| options[i].1).collect();
    Ok(Some(selected))
}

fn preset_options() -> Vec<(String, Preset)> {
    vec![
        ("Minimal — one agent with bash + skills only".to_string(), Preset::Minimal),
        (
            "Batteries-included — Plan + Build + Explore agents wired to coding/skills/subagents".to_string(),
            Preset::BatteriesIncluded,
        ),
    ]
}

fn format_provider_title(provider: Provider) -> String {
    let marker = match provider.required_env_var().filter(|v| std::env::var(v).is_err()) {
        None => "\u{2713} ready".to_string(),
        Some(var) => format!("\u{26a0} set ${var}"),
    };
    format!("{:<12} {marker}", provider.display_name())
}

async fn run_select<T: Clone>(
    terminal: &mut TerminalRuntime<io::Stdout>,
    prompt: &str,
    options: Vec<(String, T)>,
) -> Result<Option<T>, InitError> {
    let select_options = options
        .iter()
        .enumerate()
        .map(|(i, (title, _))| SelectOption { value: i.to_string(), title: title.clone(), description: None })
        .collect();

    let mut select = RadioSelect::new(select_options, 0);
    let footer = "  \u{2191}/\u{2193} to move \u{00b7} Enter to confirm \u{00b7} Esc to cancel";

    let render = |s: &RadioSelect, ctx: &ViewContext| render_select(s, prompt, footer, ctx);
    if !run_event_loop(terminal, &mut select, render).await? {
        return Ok(None);
    }

    Ok(options.get(select.selected).map(|(_, v)| v.clone()))
}

async fn run_event_loop<W: Component>(
    terminal: &mut TerminalRuntime<io::Stdout>,
    widget: &mut W,
    render_fn: impl Fn(&W, &ViewContext) -> Frame,
) -> Result<bool, InitError> {
    draw(terminal, widget, &render_fn)?;

    loop {
        let Some(event) = terminal.next_event().await else {
            return Ok(false);
        };

        if let CrosstermEvent::Resize(c, r) = &event {
            terminal.on_resize((*c, *r));
        }

        let Ok(tui_event) = Event::try_from(event) else { continue };
        if let Event::Key(key) = &tui_event {
            match key.code {
                KeyCode::Esc => {
                    let _ = terminal.clear_screen();
                    return Ok(false);
                }
                KeyCode::Enter => {
                    let _ = terminal.clear_screen();
                    return Ok(true);
                }
                _ => {}
            }
        }

        widget.on_event(&tui_event).await;
        draw(terminal, widget, &render_fn)?;
    }
}

fn draw<T>(
    terminal: &mut TerminalRuntime<io::Stdout>,
    widget: &T,
    render_fn: &impl Fn(&T, &ViewContext) -> Frame,
) -> Result<(), InitError> {
    terminal.render_frame(|ctx| render_fn(widget, ctx)).map_err(InitError::Terminal)
}

fn render_select<T>(widget: &T, header: &str, footer: &str, ctx: &ViewContext) -> Frame
where
    T: SelectField,
{
    let mut lines = vec![Line::with_style(header.to_string(), Style::fg(ctx.theme.primary())), Line::default()];
    lines.extend(widget.field_lines(ctx));
    lines.push(Line::default());
    lines.push(Line::styled(footer.to_string(), ctx.theme.muted()));
    Frame::new(lines)
}

trait SelectField {
    fn field_lines(&self, ctx: &ViewContext) -> Vec<Line>;
}

impl SelectField for RadioSelect {
    fn field_lines(&self, ctx: &ViewContext) -> Vec<Line> {
        RadioSelect::render_field(self, ctx, true)
    }
}

impl SelectField for MultiSelect {
    fn field_lines(&self, ctx: &ViewContext) -> Vec<Line> {
        MultiSelect::render_field(self, ctx, true)
    }
}