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