Skip to main content

imp_tui/views/
secrets_picker.rs

1use ratatui::buffer::Buffer;
2use ratatui::layout::Rect;
3use ratatui::style::Style;
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Borders, Clear, Widget};
6
7use imp_llm::model::ProviderRegistry;
8
9use crate::theme::Theme;
10
11#[derive(Debug, Clone)]
12pub struct SecretProviderOption {
13    pub id: String,
14    pub label: String,
15    pub description: String,
16    pub configured: bool,
17}
18
19pub fn secret_providers(registry: &ProviderRegistry) -> Vec<SecretProviderOption> {
20    let mut providers: Vec<SecretProviderOption> = vec![
21        SecretProviderOption {
22            id: "exa".to_string(),
23            label: "Exa".to_string(),
24            description: "Secure API/service secrets · dashboard.exa.ai/api-keys".to_string(),
25            configured: false,
26        },
27        SecretProviderOption {
28            id: "tavily".to_string(),
29            label: "Tavily".to_string(),
30            description: "Secure API/service secrets · app.tavily.com/home".to_string(),
31            configured: false,
32        },
33    ];
34
35    providers.extend(
36        registry
37            .list()
38            .iter()
39            .filter(|provider| !matches!(provider.id, "anthropic" | "openai" | "openai-codex"))
40            .map(|provider| SecretProviderOption {
41                id: provider.id.to_string(),
42                label: provider.name.to_string(),
43                description: if provider.docs_url.is_empty() {
44                    "Secure API/service secrets".into()
45                } else {
46                    format!("Secure API/service secrets · {}", provider.docs_url)
47                },
48                configured: false,
49            }),
50    );
51
52    providers.sort_by(|a, b| a.label.cmp(&b.label));
53    providers.dedup_by(|a, b| a.id == b.id);
54    providers
55}
56
57#[derive(Debug, Clone)]
58pub struct SecretsPickerState {
59    pub providers: Vec<SecretProviderOption>,
60    pub selected: usize,
61}
62
63impl SecretsPickerState {
64    pub fn new(providers: Vec<SecretProviderOption>) -> Self {
65        Self {
66            providers,
67            selected: 0,
68        }
69    }
70
71    pub fn move_up(&mut self) {
72        if self.selected > 0 {
73            self.selected -= 1;
74        }
75    }
76
77    pub fn move_down(&mut self) {
78        if self.selected + 1 < self.providers.len() {
79            self.selected += 1;
80        }
81    }
82
83    pub fn selected_provider(&self) -> Option<&SecretProviderOption> {
84        self.providers.get(self.selected)
85    }
86}
87
88pub struct SecretsPickerView<'a> {
89    state: &'a SecretsPickerState,
90    theme: &'a Theme,
91}
92
93impl<'a> SecretsPickerView<'a> {
94    pub fn new(state: &'a SecretsPickerState, theme: &'a Theme) -> Self {
95        Self { state, theme }
96    }
97}
98
99impl Widget for SecretsPickerView<'_> {
100    fn render(self, area: Rect, buf: &mut Buffer) {
101        if area.height < 6 || area.width < 20 {
102            return;
103        }
104
105        Clear.render(area, buf);
106        let block = Block::default()
107            .title(" Secure Secrets ")
108            .borders(Borders::ALL)
109            .border_style(self.theme.accent_style());
110        let inner = block.inner(area);
111        block.render(area, buf);
112
113        if self.state.providers.is_empty() {
114            let line = Line::from(Span::styled(
115                "  No providers available",
116                self.theme.muted_style(),
117            ));
118            buf.set_line(inner.x, inner.y, &line, inner.width);
119            return;
120        }
121
122        let footer = "Enter: configure provider · Esc: cancel";
123        let footer_y = inner.y + inner.height.saturating_sub(1);
124
125        for (i, provider) in self.state.providers.iter().enumerate() {
126            if inner.y + i as u16 >= footer_y {
127                break;
128            }
129
130            let is_selected = i == self.state.selected;
131            let row_style = if is_selected {
132                self.theme.selected_style()
133            } else {
134                Style::default()
135            };
136
137            let status = if provider.configured {
138                vec![
139                    Span::raw("  "),
140                    Span::styled("✓ configured", self.theme.success_style()),
141                ]
142            } else {
143                Vec::new()
144            };
145
146            let mut spans = vec![
147                Span::styled(
148                    if is_selected { " ▸ " } else { "   " },
149                    self.theme.accent_style(),
150                ),
151                Span::styled(&provider.label, row_style),
152                Span::raw("  "),
153                Span::styled(&provider.description, self.theme.muted_style()),
154            ];
155            spans.extend(status);
156            let line = Line::from(spans);
157            buf.set_line(inner.x, inner.y + i as u16, &line, inner.width);
158        }
159
160        let footer_line = Line::from(Span::styled(footer, self.theme.muted_style()));
161        buf.set_line(inner.x, footer_y, &footer_line, inner.width);
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn secrets_picker_excludes_oauth_providers() {
171        let registry = ProviderRegistry::with_builtins();
172        let providers = secret_providers(&registry);
173        let ids: Vec<&str> = providers
174            .iter()
175            .map(|provider| provider.id.as_str())
176            .collect();
177        assert!(ids.contains(&"exa"));
178        assert!(!ids.contains(&"anthropic"));
179        assert!(!ids.contains(&"openai"));
180    }
181}