imp_tui/views/
secrets_picker.rs1use 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(®istry);
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}