aicx 0.6.6

Operator CLI + MCP server: canonical corpus first, optional semantic index second (Claude Code, Codex, Gemini)
Documentation
use ratatui::prelude::*;
use ratatui::widgets::*;

use crate::wizard::app::{App, Confirmation, Screen};
use crate::wizard::screens::corpus::CorpusColumn;
use crate::wizard::screens::doctor::SeverityLabel;

pub fn render(frame: &mut Frame, app: &App) {
    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1),
            Constraint::Min(8),
            Constraint::Length(1),
        ])
        .split(frame.area());

    render_topbar(frame, layout[0], app);
    render_main(frame, layout[1], app);
    render_bottombar(frame, layout[2], app);

    if app.show_help {
        render_help(frame, centered_rect(64, 46, frame.area()));
    }
    if app.search_mode {
        render_search(frame, centered_rect(60, 18, frame.area()), app);
    }
    if let Some(action) = &app.confirmation {
        render_confirmation(frame, centered_rect(64, 24, frame.area()), action);
    }
}

fn render_topbar(frame: &mut Frame, area: Rect, app: &App) {
    let text = format!(
        " aicx wizard | {} | {} ",
        app.active.title(),
        app.corpus_stats()
    );
    frame.render_widget(
        Paragraph::new(text).style(Style::default().fg(Color::Black).bg(Color::Cyan)),
        area,
    );
}

fn render_bottombar(frame: &mut Frame, area: Rect, app: &App) {
    let text = match app.active {
        Screen::Corpus => " q quit | 1-4 screens | hjkl nav | / filter | Enter preview | ? help ",
        Screen::Doctor => " q quit | r refresh | f fix steer | b fix buckets | ? help ",
        Screen::Intents => {
            " q quit | p project | a agent | t time | / filter | Enter chunk | ? help "
        }
        Screen::Store => " q quit | s start | t range | Ctrl+C cancel | jk scroll | ? help ",
    };
    let line = format!("{} | {}", text, app.status);
    frame.render_widget(
        Paragraph::new(line).style(Style::default().fg(Color::Black).bg(Color::Gray)),
        area,
    );
}

fn render_main(frame: &mut Frame, area: Rect, app: &App) {
    match app.active {
        Screen::Corpus => render_corpus(frame, area, app),
        Screen::Doctor => render_doctor(frame, area, app),
        Screen::Intents => render_intents(frame, area, app),
        Screen::Store => render_store(frame, area, app),
    }
}

fn render_corpus(frame: &mut Frame, area: Rect, app: &App) {
    let chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage(18),
            Constraint::Percentage(24),
            Constraint::Percentage(30),
            Constraint::Percentage(28),
        ])
        .split(area);

    render_simple_list(
        frame,
        chunks[0],
        "Orgs",
        app.corpus.orgs(),
        app.corpus.column == CorpusColumn::Orgs,
        0,
    );
    render_simple_list(
        frame,
        chunks[1],
        "Repos",
        app.corpus.repos(),
        app.corpus.column == CorpusColumn::Repos,
        0,
    );

    let chunk_items = app
        .corpus
        .entries
        .iter()
        .map(|entry| entry.label.clone())
        .collect::<Vec<_>>();
    render_simple_list(
        frame,
        chunks[2],
        "Chunks",
        chunk_items,
        app.corpus.column == CorpusColumn::Chunks,
        app.corpus.selected,
    );

    let preview = app.corpus.selected_preview();
    frame.render_widget(
        Paragraph::new(preview)
            .block(block("Preview"))
            .wrap(Wrap { trim: false }),
        chunks[3],
    );
}

fn render_doctor(frame: &mut Frame, area: Rect, app: &App) {
    if !app.doctor.loaded {
        frame.render_widget(
            Paragraph::new("Press r or Enter to run aicx doctor.").block(block("Doctor")),
            area,
        );
        return;
    }

    let chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(58), Constraint::Percentage(42)])
        .split(area);

    let cards = app
        .doctor
        .cards
        .iter()
        .enumerate()
        .map(|(idx, card)| {
            let style = severity_style(card.severity.clone());
            let marker = if idx == app.doctor.selected {
                "> "
            } else {
                "  "
            };
            ListItem::new(Line::from(vec![
                Span::styled(marker, style),
                Span::styled(format!("{:?}", card.severity), style),
                Span::raw(format!(" {}", card.name)),
            ]))
        })
        .collect::<Vec<_>>();
    frame.render_widget(List::new(cards).block(block("Checks")), chunks[0]);

    let detail = app
        .doctor
        .cards
        .get(app.doctor.selected)
        .map(|card| {
            format!(
                "{}\n\n{}\n\n{}",
                card.name,
                card.detail,
                card.recommendation
                    .clone()
                    .unwrap_or_else(|| "No recommendation.".to_string())
            )
        })
        .unwrap_or_else(|| app.doctor.status.clone());
    frame.render_widget(
        Paragraph::new(detail)
            .block(block("Selected"))
            .wrap(Wrap { trim: false }),
        chunks[1],
    );
}

fn render_intents(frame: &mut Frame, area: Rect, app: &App) {
    let chunks = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Percentage(48), Constraint::Percentage(52)])
        .split(area);

    let items = app
        .intents
        .visible
        .iter()
        .enumerate()
        .map(|(idx, record)| {
            let style = if idx == app.intents.selected {
                Style::default().fg(Color::Cyan).bold()
            } else {
                Style::default()
            };
            ListItem::new(Line::from(vec![
                Span::styled(
                    if idx == app.intents.selected {
                        "> "
                    } else {
                        "  "
                    },
                    style,
                ),
                Span::styled(record.kind.heading(), Style::default().fg(Color::Yellow)),
                Span::raw(format!(
                    " {} {} {}",
                    record.date,
                    record.agent,
                    truncate(&record.summary, 72)
                )),
            ]))
        })
        .collect::<Vec<_>>();

    frame.render_widget(
        List::new(items).block(block(&format!(
            "Timeline - {}h - agent {:?}",
            app.intents.hours, app.intents.agent
        ))),
        chunks[0],
    );

    frame.render_widget(
        Paragraph::new(app.intents.selected_preview())
            .block(block("Source"))
            .wrap(Wrap { trim: false }),
        chunks[1],
    );
}

fn render_store(frame: &mut Frame, area: Rect, app: &App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(8)])
        .split(area);

    let ratio = app
        .store
        .progress
        .as_ref()
        .map(|progress| progress.ratio())
        .unwrap_or(0.0);
    let label = match (app.store.running, app.store.progress.as_ref()) {
        (true, Some(progress)) => match progress.total {
            Some(total) if total > 0 => format!(
                "{} {} {}/{} - last {}h",
                progress.phase, progress.status, progress.current, total, app.store.hours
            ),
            _ => format!(
                "{} {} - last {}h",
                progress.phase, progress.status, app.store.hours
            ),
        },
        (true, None) => format!("store starting - last {}h", app.store.hours),
        (false, Some(progress)) => format!("last phase: {} {}", progress.phase, progress.status),
        (false, None) => format!("idle - next run last {}h", app.store.hours),
    };
    frame.render_widget(
        Gauge::default()
            .block(block("Store"))
            .gauge_style(Style::default().fg(Color::Cyan))
            .ratio(ratio)
            .label(label.as_str()),
        chunks[0],
    );

    let start = app.store.scroll.min(app.store.log.len());
    let lines = app.store.log[start..]
        .iter()
        .map(|line| Line::from(line.clone()))
        .collect::<Vec<_>>();
    frame.render_widget(
        Paragraph::new(lines)
            .block(block("Log Tail"))
            .wrap(Wrap { trim: false }),
        chunks[1],
    );
}

fn render_simple_list(
    frame: &mut Frame,
    area: Rect,
    title: &str,
    values: Vec<String>,
    focused: bool,
    selected: usize,
) {
    let items = values
        .into_iter()
        .take(200)
        .enumerate()
        .map(|(idx, value)| {
            let style = if focused && idx == selected {
                Style::default().fg(Color::Cyan).bold()
            } else {
                Style::default()
            };
            ListItem::new(Line::from(Span::styled(truncate(&value, 80), style)))
        })
        .collect::<Vec<_>>();
    frame.render_widget(List::new(items).block(block(title)), area);
}

fn render_help(frame: &mut Frame, area: Rect) {
    frame.render_widget(Clear, area);
    let text = vec![
        Line::from("aicx wizard keymap"),
        Line::from(""),
        Line::from("1 corpus | 2 doctor | 3 intents | 4 store"),
        Line::from("hjkl / arrows navigate visible lists"),
        Line::from("/ filters corpus or intents"),
        Line::from("doctor: r refresh, f runs aicx doctor --fix, b shows Plan B deferral"),
        Line::from("store: t changes range, s runs aicx store -H <range> --emit none"),
        Line::from("store: Ctrl+C sends kill to the running subprocess"),
        Line::from("q quits when no long operation is in flight"),
    ];
    frame.render_widget(Paragraph::new(text).block(block("Help")), area);
}

fn render_search(frame: &mut Frame, area: Rect, app: &App) {
    frame.render_widget(Clear, area);
    frame.render_widget(
        Paragraph::new(format!("filter: {}", app.search_input)).block(block("Search")),
        area,
    );
}

fn render_confirmation(frame: &mut Frame, area: Rect, action: &Confirmation) {
    frame.render_widget(Clear, area);
    frame.render_widget(
        Paragraph::new(format!(
            "Run this command?\n\n{}\n\nEnter/y confirms, Esc/n cancels.",
            action.command()
        ))
        .block(block("Confirm")),
        area,
    );
}

fn block(title: &str) -> Block<'_> {
    Block::default()
        .borders(Borders::ALL)
        .border_type(BorderType::Rounded)
        .title(title.to_string())
}

fn severity_style(severity: SeverityLabel) -> Style {
    match severity {
        SeverityLabel::Green => Style::default().fg(Color::Green),
        SeverityLabel::Warning => Style::default().fg(Color::Yellow),
        SeverityLabel::Critical => Style::default().fg(Color::Red).bold(),
        SeverityLabel::Unknown => Style::default().fg(Color::Gray),
    }
}

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(vertical[1])[1]
}

fn truncate(value: &str, max: usize) -> String {
    if value.chars().count() <= max {
        return value.to_string();
    }
    let mut truncated = value
        .chars()
        .take(max.saturating_sub(1))
        .collect::<String>();
    truncated.push_str("...");
    truncated
}