aether-wisp 0.7.2

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::{ArtifactReviewOutput, ReviewOutcome, UiEvent};
use crate::surfaces::modal::frame::ModalFrame;
use agent_client_protocol::schema::v2::ElicitationContentValue;
use crate::view::generation::Generation;
use crate::{renderer::DrawContext, settings::builtin_review_theme_choices};
use clankerdiff_ratatui::ReviewCapabilities;
use clankerdiff_ratatui::markdown::{MarkdownDocument, MarkdownReviewEvent};
use clankerdiff_ratatui::{InputOutcome, MarkdownReviewState, MarkdownReviewWidget};
use crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::{
    buffer::Buffer,
    layout::{Constraint, Position, Rect},
    style::Style,
    widgets::{Clear, Paragraph, StatefulWidget, Widget},
};
use std::sync::Arc;
use utils::artifact_review::{ArtifactReviewElicitationMeta, ArtifactReviewSubmission};

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

impl ArtifactReviewScreen {
    pub fn new(meta: ArtifactReviewElicitationMeta, responder: impl Into<ElicitationResponder>) -> Self {
        let document = MarkdownDocument::parse_with_metadata(
            meta.path.as_ref().map(|path| path.display().to_string()),
            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, confirming_approval: false, 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<ArtifactReviewOutput> {
        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);
        }
        if self.confirming_approval {
            let frame = ModalFrame::new(
                "Approve and continue",
                Some("Enter: approve ยท Esc: return to review".into()),
                Constraint::Length(60),
                Constraint::Length(9),
                cx.theme,
            );
            (&frame).render(area, buf);
            Paragraph::new("Approve this artifact without feedback?")
                .style(Style::new().fg(cx.theme.text_primary))
                .render(frame.inner(area), buf);
            return None;
        }
        self.state.cursor_position()
    }

    fn submit(&mut self, submission: ArtifactReviewSubmission) -> Vec<ArtifactReviewOutput> {
        self.responder.accept(Some(submission.fields().map(|(name, value)| {
            (name.to_string(), ElicitationContentValue::String(value.to_string()))
        }).collect()));
        vec![match submission {
            ArtifactReviewSubmission::Approved => ArtifactReviewOutput::Approved,
            ArtifactReviewSubmission::Feedback { feedback } => ArtifactReviewOutput::Outcome(ReviewOutcome::Submitted(feedback)),
        }]
    }

    fn handle_event(&mut self, event: Event) -> Vec<ArtifactReviewOutput> {
        if self.responder.is_answered() {
            return Vec::new();
        }
        if self.confirming_approval {
            if let Event::Key(key) = event
                && key.kind == KeyEventKind::Press
                && key.modifiers.is_empty()
            {
                match key.code {
                    KeyCode::Enter => return self.submit(ArtifactReviewSubmission::Approved),
                    KeyCode::Esc => self.confirming_approval = false,
                    _ => {}
                }
            }
            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![ArtifactReviewOutput::SetTheme(theme_selection(id))],
            InputOutcome::Emitted(MarkdownReviewEvent::CopyFormatted(_)) => {
                self.error = Some("Clipboard is disabled".into());
                Vec::new()
            }
            InputOutcome::Emitted(MarkdownReviewEvent::Cancel) => {
                self.cancel();
                vec![ArtifactReviewOutput::Outcome(ReviewOutcome::Cancelled)]
            }
            InputOutcome::Emitted(MarkdownReviewEvent::Submit(submission)) => {
                if submission.comments.is_empty() {
                    self.confirming_approval = true;
                    Vec::new()
                } else {
                    self.submit(ArtifactReviewSubmission::Feedback { feedback: submission.formatted })
                }
            }
        }
    }
}