aether-wisp 0.7.1

A terminal UI for AI coding agents via the Agent Client Protocol (ACP)
Documentation
use crate::screens::reviewer::{crossterm_event, theme_selection};
use crate::surfaces::elicitation::ElicitationResponder;
use crate::surfaces::input::{PlanReviewOutput, ReviewOutcome, UiEvent};
use crate::view::generation::Generation;
use crate::{renderer::DrawContext, settings::builtin_review_theme_choices};
use clankerdiff_ratatui::ReviewCapabilities;
use clankerdiff_ratatui::markdown::{MarkdownDocument, MarkdownReviewDecision, MarkdownReviewEvent};
use clankerdiff_ratatui::{InputOutcome, MarkdownReviewState, MarkdownReviewWidget};
use crossterm::event::Event;
use ratatui::{
    buffer::Buffer,
    layout::{Position, Rect},
    style::Style,
    widgets::{Clear, Paragraph, StatefulWidget, Widget},
};
use std::sync::Arc;
use utils::plan_review::{PlanReviewDecision, PlanReviewElicitationMeta};

pub struct PlanReviewScreen {
    title: String,
    state: MarkdownReviewState,
    responder: ElicitationResponder,
    error: Option<String>,
    theme_generation: Option<Generation>,
}

impl PlanReviewScreen {
    pub fn new(meta: PlanReviewElicitationMeta, responder: impl Into<ElicitationResponder>) -> Self {
        let document =
            MarkdownDocument::parse_with_metadata(Some(meta.plan_path), Some(meta.title.clone()), meta.markdown);
        let mut state = MarkdownReviewState::new(Arc::new(document));
        state.set_theme_choices(builtin_review_theme_choices());
        state.set_capabilities(ReviewCapabilities {
            repository: false,
            refresh: false,
            scope: false,
            submit: true,
            clipboard: false,
        });
        Self { title: meta.title, state, responder: responder.into(), error: None, theme_generation: None }
    }

    pub fn set_theme_choices(&mut self, choices: Vec<clankerdiff_ratatui::ThemeChoice>) {
        self.state.set_theme_choices(choices);
    }

    pub fn on_ui_event(&mut self, event: UiEvent) -> Vec<PlanReviewOutput> {
        self.handle_event(crossterm_event(event))
    }

    pub fn cancel(&mut self) {
        self.responder.cancel();
    }

    pub fn render(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
        if self.theme_generation != Some(cx.theme_generation) {
            self.state.set_theme(cx.theme.review().clone());
            self.theme_generation = Some(cx.theme_generation);
        }
        Clear.render(area, buf);
        MarkdownReviewWidget::new().title(&self.title).render(area, buf, &mut self.state);
        if let Some(error) = &self.error {
            let footer = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, area.height.min(1));
            Paragraph::new(error.as_str())
                .style(Style::new().fg(cx.theme.error).bg(cx.theme.background))
                .render(footer, buf);
        }
        self.state.cursor_position()
    }

    fn handle_event(&mut self, event: Event) -> Vec<PlanReviewOutput> {
        if self.responder.is_answered() {
            return Vec::new();
        }
        let outcome = match clankerdiff_ratatui::handle_markdown_crossterm_event(&mut self.state, event) {
            Ok(outcome) => outcome,
            Err(error) => {
                self.error = Some(error.to_string());
                return Vec::new();
            }
        };
        match outcome {
            InputOutcome::Ignored | InputOutcome::Consumed => Vec::new(),
            InputOutcome::ThemeSelected(id) => vec![PlanReviewOutput::SetTheme(theme_selection(id))],
            InputOutcome::Emitted(MarkdownReviewEvent::CopyFormatted(_)) => {
                self.error = Some("Clipboard is disabled".into());
                Vec::new()
            }
            InputOutcome::Emitted(MarkdownReviewEvent::Cancel) => {
                self.cancel();
                vec![PlanReviewOutput::Outcome(ReviewOutcome::Cancelled)]
            }
            InputOutcome::Emitted(MarkdownReviewEvent::Submit(submission)) => {
                let decision = match submission.decision {
                    MarkdownReviewDecision::Approved => PlanReviewDecision::Approve,
                    MarkdownReviewDecision::ChangesRequested => PlanReviewDecision::Deny,
                };
                self.responder.accept_strings([("decision", decision.as_str()), ("feedback", &submission.formatted)]);
                vec![PlanReviewOutput::Outcome(ReviewOutcome::Submitted(submission.formatted))]
            }
        }
    }
}