Skip to main content

mermaid_cli/render/widgets/
plan_config.rs

1//! The `/plan config` settings picker: a bottom-pane modal listing every
2//! plan-mode setting as a cyclable row. Pure ASCII, muted-gray meta text.
3
4use ratatui::{
5    buffer::Buffer,
6    layout::Rect,
7    style::Style,
8    text::{Line, Span},
9    widgets::{Block, Borders, Paragraph, Widget},
10};
11
12use crate::render::theme::Theme;
13use mermaid_domain::PlanConfig;
14
15/// Row count (kept in sync with `plan_config_rows` and the reducer's key
16/// handler). Rows: preset, builds, web, memory, tasks, model, reasoning,
17/// `auto_approve`, `post_approve`.
18pub const PLAN_CONFIG_ROWS: usize = 9;
19
20/// Pane height: rows + border(2) + hint line.
21pub const PLAN_CONFIG_HEIGHT: u16 = PLAN_CONFIG_ROWS as u16 + 3;
22
23/// The `(label, value)` pairs the picker shows, derived from the live
24/// config. Shared with the reducer tests so row indices can't drift.
25#[must_use]
26pub fn plan_config_rows(plan: &PlanConfig, session_model: &str) -> Vec<(String, String)> {
27    let perms = &plan.permissions;
28    vec![
29        (
30            "permissions".to_string(),
31            perms.preset_name().unwrap_or("custom").to_string(),
32        ),
33        (
34            "  builds/tests".to_string(),
35            perms.builds.as_str().to_string(),
36        ),
37        ("  web".to_string(), perms.web.as_str().to_string()),
38        (
39            "  memory writes".to_string(),
40            perms.memory.as_str().to_string(),
41        ),
42        ("  task tools".to_string(), perms.tasks.as_str().to_string()),
43        (
44            "plan model".to_string(),
45            plan.model.clone().unwrap_or_else(|| {
46                format!("unset (plans with the session model, now {session_model})")
47            }),
48        ),
49        (
50            "plan reasoning".to_string(),
51            plan.reasoning
52                .map(|r| r.as_str().to_string())
53                .unwrap_or_else(|| "unset".to_string()),
54        ),
55        (
56            "auto-approve plans".to_string(),
57            if plan.auto_approve { "on" } else { "off" }.to_string(),
58        ),
59        (
60            "after approval".to_string(),
61            match plan.post_approve {
62                None => "ask each time".to_string(),
63                Some(mermaid_domain::PlanPostApprove::Start) => "always start".to_string(),
64                Some(mermaid_domain::PlanPostApprove::Wait) => "always wait".to_string(),
65            },
66        ),
67    ]
68}
69
70pub struct PlanConfigWidget<'a> {
71    pub theme: &'a Theme,
72    pub plan: &'a PlanConfig,
73    pub session_model: &'a str,
74    pub cursor: usize,
75}
76
77impl<'a> Widget for PlanConfigWidget<'a> {
78    fn render(self, area: Rect, buf: &mut Buffer) {
79        let rows = plan_config_rows(self.plan, self.session_model);
80        let selected_style = Style::new().fg(self.theme.colors.info.to_color()).bold();
81        let label_style = Style::new().fg(self.theme.colors.text_primary.to_color());
82        let value_style = Style::new().fg(self.theme.colors.text_secondary.to_color());
83        let hint_style = Style::new().fg(self.theme.colors.text_disabled.to_color());
84
85        let mut lines: Vec<Line> = rows
86            .iter()
87            .enumerate()
88            .map(|(i, (label, value))| {
89                let marker = if i == self.cursor { "> " } else { "  " };
90                let ls = if i == self.cursor {
91                    selected_style
92                } else {
93                    label_style
94                };
95                Line::from(vec![
96                    Span::styled(format!("{marker}{label:<18}"), ls),
97                    Span::styled(value.clone(), value_style),
98                ])
99            })
100            .collect();
101        lines.push(Line::from(Span::styled(
102            "Enter/Left/Right change - Up/Down navigate - Esc close",
103            hint_style,
104        )));
105
106        Paragraph::new(lines)
107            .block(
108                Block::default()
109                    .borders(Borders::ALL)
110                    .title(" Plan mode settings "),
111            )
112            .render(area, buf);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn row_count_matches_the_reducer_contract() {
122        // The reducer's key handler hardcodes PLAN_CONFIG_ROW_COUNT = 9;
123        // this pins the widget to the same shape so indices can't drift.
124        let rows = plan_config_rows(&PlanConfig::default(), "ollama/test");
125        assert_eq!(rows.len(), PLAN_CONFIG_ROWS);
126        assert_eq!(rows.len(), 9);
127        assert_eq!(rows[0].1, "default");
128        assert_eq!(rows[4].1, "deny");
129        assert!(rows[5].1.starts_with("unset"));
130    }
131}