opencrabs 0.3.67

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
//! Plan overlay: the TUI surface for the design track. While a plan is
//! Editing it shows the scrollable session `.md` body with an
//! Approve/Discard footer (deliberately NOT the tool-policy approve
//! flow); while Active it shows the checklist with progress. The badge
//! line distinguishes pre-init (no approvable document yet, no Approve
//! hint) from post-init Editing and Active.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};

use crate::tui::app::App;
use crate::utils::plan_files::{self, PlanModeState};

pub(super) fn render_plan_overlay(f: &mut Frame, app: &App, area: Rect) {
    f.render_widget(Clear, area);

    if app.current_session.is_none() {
        let p = Paragraph::new("No active session.")
            .block(Block::default().borders(Borders::ALL).title(" Plan "));
        f.render_widget(p, area);
        return;
    }

    // Render from state the async layer already resolved: the loaded plan
    // (reload_plan) and the resolved plan file path. Deriving the mode-state
    // and body here keeps the render loop sync, since the location resolver
    // is a DB lookup that must not run per frame.
    let md_path = app.plan_file_path.as_ref().map(|p| p.with_extension("md"));
    let md_exists = md_path.as_ref().is_some_and(|p| p.exists());
    let state = plan_files::plan_mode_state_of(app.plan_document.as_ref(), md_exists);
    let (badge, badge_style) = match state {
        PlanModeState::NoPlan => ("no plan", Style::default().fg(Color::DarkGray)),
        PlanModeState::PreInitEditing => (
            "Editing · pre-init",
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        PlanModeState::PostInitEditing => (
            "Editing",
            Style::default()
                .fg(Color::Yellow)
                .add_modifier(Modifier::BOLD),
        ),
        PlanModeState::Active => (
            "Active",
            Style::default()
                .fg(Color::Green)
                .add_modifier(Modifier::BOLD),
        ),
    };

    let body: String = match state {
        PlanModeState::NoPlan => "No live plan for this session.\n\nStart one with /plan \
                                  (design track) or ask for a checklist."
            .to_string(),
        PlanModeState::PreInitEditing => "Plan mode is on, but `plan init` has not created \
                                          the design document yet.\n\nDescribe what you want \
                                          planned; the agent explores, then drafts the \
                                          SESSION PLAN for your approval. Leave Plan mode \
                                          with /discard."
            .to_string(),
        PlanModeState::PostInitEditing => md_path
            .as_ref()
            .and_then(|p| std::fs::read_to_string(p).ok())
            .unwrap_or_else(|| "(the session plan .md is unreadable)".to_string()),
        PlanModeState::Active => app
            .plan_document
            .as_ref()
            .map(crate::utils::plan_mode::format_active_checklist)
            .unwrap_or_else(|| "Plan JSON is unreadable.".to_string()),
    };

    let title = Line::from(vec![
        Span::raw(" 📋 Plan · "),
        Span::styled(badge, badge_style),
        Span::raw(" "),
    ]);

    let footer = match state {
        PlanModeState::PostInitEditing => {
            " a approve · d discard · ↑/↓ scroll · Esc close  (validator runs on approve) "
        }
        PlanModeState::Active => " d discard · ↑/↓ scroll · Esc close ",
        PlanModeState::PreInitEditing => " d discard (leave Plan mode) · Esc close ",
        PlanModeState::NoPlan => " Esc close ",
    };

    let block = Block::default()
        .borders(Borders::ALL)
        .title(title)
        .title_bottom(Line::from(Span::styled(
            footer,
            Style::default().fg(Color::DarkGray),
        )));

    let paragraph = Paragraph::new(body)
        .block(block)
        .wrap(Wrap { trim: false })
        .scroll((app.plan_overlay_scroll as u16, 0));
    f.render_widget(paragraph, area);
}