1use crate::agent::ui::theme::ThemeKey;
16use crate::agent::ui::theme::current_theme;
17use crate::tui::Component;
18use crate::tui::keybindings::{
19 ACTION_EDITOR_DELETE_CHAR_BACKWARD, ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM,
20 get_keybindings,
21};
22use crossterm::event::{KeyCode, KeyEvent};
23
24#[allow(dead_code)]
26enum DialogState {
27 Info { lines: Vec<String> },
29 Prompt {
31 message: String,
32 placeholder: Option<String>,
33 },
34 Submitted { value: String },
36 Auth {
38 url: String,
39 instructions: Option<String>,
40 },
41 DeviceCode {
43 verification_uri: String,
44 user_code: String,
45 },
46 Waiting { message: String },
48 Progress { messages: Vec<String> },
50 ManualInput { prompt: String },
52 Done,
54}
55
56pub struct LoginDialog {
58 provider_id: String,
59 provider_name: String,
60 state: DialogState,
61 input_buffer: String,
62 on_submit: Option<Box<dyn FnOnce(String)>>,
64 on_cancel: Option<Box<dyn FnOnce()>>,
66 submitted: bool,
67}
68
69impl LoginDialog {
70 pub fn new(provider_id: String, provider_name: String) -> Self {
71 Self {
72 provider_id,
73 provider_name,
74 state: DialogState::Info { lines: Vec::new() },
75 input_buffer: String::new(),
76 on_submit: None,
77 on_cancel: None,
78 submitted: false,
79 }
80 }
81
82 pub fn on_submit<F>(&mut self, f: F)
84 where
85 F: FnOnce(String) + 'static,
86 {
87 self.on_submit = Some(Box::new(f));
88 }
89
90 pub fn on_cancel<F>(&mut self, f: F)
92 where
93 F: FnOnce() + 'static,
94 {
95 self.on_cancel = Some(Box::new(f));
96 }
97
98 pub fn show_prompt(&mut self, message: &str, placeholder: Option<&str>) {
100 self.state = DialogState::Prompt {
101 message: message.to_string(),
102 placeholder: placeholder.map(|s| s.to_string()),
103 };
104 self.input_buffer.clear();
105 }
106
107 pub fn show_info(&mut self, lines: &[&str]) {
109 self.state = DialogState::Info {
110 lines: lines.iter().map(|s| s.to_string()).collect(),
111 };
112 }
113
114 pub fn show_auth(&mut self, url: &str, instructions: Option<&str>) {
117 self.state = DialogState::Auth {
118 url: url.to_string(),
119 instructions: instructions.map(|s| s.to_string()),
120 };
121 }
122
123 pub fn show_device_code(&mut self, verification_uri: &str, user_code: &str) {
125 self.state = DialogState::DeviceCode {
126 verification_uri: verification_uri.to_string(),
127 user_code: user_code.to_string(),
128 };
129 }
130
131 pub fn show_waiting(&mut self, message: &str) {
133 self.state = DialogState::Waiting {
134 message: message.to_string(),
135 };
136 }
137
138 pub fn show_progress(&mut self, message: &str) {
141 match &mut self.state {
142 DialogState::Progress { messages } => {
143 messages.push(message.to_string());
144 }
145 _ => {
146 self.state = DialogState::Progress {
147 messages: vec![message.to_string()],
148 };
149 }
150 }
151 }
152
153 pub fn show_manual_input(&mut self, prompt: &str) {
157 self.state = DialogState::ManualInput {
158 prompt: prompt.to_string(),
159 };
160 self.input_buffer.clear();
161 }
162
163 pub fn reset(&mut self) {
165 self.state = DialogState::Info { lines: Vec::new() };
166 self.input_buffer.clear();
167 self.submitted = false;
168 }
169
170 pub fn provider_id(&self) -> &str {
172 &self.provider_id
173 }
174}
175
176impl Component for LoginDialog {
177 fn render(&mut self, width: usize) -> Vec<String> {
178 let theme = current_theme();
179 let mut lines: Vec<String> = Vec::new();
180
181 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
183 lines.push(String::new());
184
185 lines.push(format!(
187 " {}",
188 theme.bold(&theme.fg_key(
189 ThemeKey::Accent,
190 &format!("Login to {}", self.provider_name)
191 ))
192 ));
193 lines.push(String::new());
194
195 match &self.state {
196 DialogState::Info { lines: info_lines } => {
197 if info_lines.is_empty() {
198 lines.push(format!(" {}", theme.dim("Ready.")));
199 } else {
200 for line in info_lines {
201 lines.push(format!(" {}", line));
202 }
203 }
204 }
205 DialogState::Prompt {
206 message,
207 placeholder,
208 } => {
209 lines.push(format!(" {}", theme.fg_key(ThemeKey::Text, message)));
211 if let Some(placeholder) = placeholder {
212 lines.push(format!(
213 " {}",
214 theme.dim(&format!("e.g., {}", placeholder))
215 ));
216 }
217 lines.push(String::new());
218
219 let masked: String = if self.input_buffer.is_empty() {
221 String::new()
222 } else {
223 "\u{2022}".repeat(self.input_buffer.len().min(50))
224 };
225 let cursor = "\u{2588}"; lines.push(format!(
227 " {}",
228 theme.fg_key(ThemeKey::Text, &format!("{} {}", masked, cursor))
229 ));
230
231 if !self.input_buffer.is_empty() {
232 lines.push(format!(
233 " {}",
234 theme.dim(&format!("({} characters)", self.input_buffer.len()))
235 ));
236 lines.push(String::new());
237 }
238
239 lines.push(format!(" {}", theme.dim("Enter: submit · Esc: cancel")));
241 }
242 DialogState::Submitted { value } => {
243 lines.push(format!(
245 " {}",
246 theme.fg_key(ThemeKey::Text, &format!("> {}", value))
247 ));
248 if self.submitted {
249 lines.push(String::new());
250 lines.push(format!(" {}", theme.success("API key saved.")));
251 }
252 }
253 DialogState::Auth { url, instructions } => {
254 let linked = format!("\x1b]8;;{url}\x07{url}\x1b]8;;\x07", url = url);
256 lines.push(format!(" {}", theme.fg_key(ThemeKey::Accent, &linked)));
257 lines.push(format!(" {}", theme.dim("Ctrl+click to open in browser")));
258 if let Some(instr) = instructions {
259 lines.push(String::new());
260 lines.push(format!(" {}", theme.fg_key(ThemeKey::Warning, instr)));
261 }
262 lines.push(String::new());
263 lines.push(format!(" {}", theme.dim("Esc: cancel")));
264 }
265 DialogState::DeviceCode {
266 verification_uri,
267 user_code,
268 } => {
269 let linked = format!("\x1b]8;;{uri}\x07{uri}\x1b]8;;\x07", uri = verification_uri);
270 lines.push(format!(" {}", theme.fg_key(ThemeKey::Accent, &linked)));
271 lines.push(format!(" {}", theme.dim("Ctrl+click to open in browser")));
272 lines.push(String::new());
273 lines.push(format!(
274 " {}",
275 theme.fg_key(ThemeKey::Warning, &format!("Enter code: {}", user_code))
276 ));
277 lines.push(String::new());
278 lines.push(format!(" {}", theme.dim("Esc: cancel")));
279 }
280 DialogState::Waiting { message } => {
281 lines.push(format!(" {}", theme.fg_key(ThemeKey::Dim, message)));
282 lines.push(String::new());
283 lines.push(format!(" {}", theme.dim("Esc: cancel")));
284 }
285 DialogState::Progress { messages } => {
286 for msg in messages {
287 lines.push(format!(" {}", theme.fg_key(ThemeKey::Dim, msg)));
288 }
289 lines.push(String::new());
290 lines.push(format!(" {}", theme.dim("Esc: cancel")));
291 }
292 DialogState::ManualInput { prompt } => {
293 lines.push(format!(" {}", theme.fg_key(ThemeKey::Dim, prompt)));
296 lines.push(String::new());
297
298 let display = if self.input_buffer.is_empty() {
300 String::new()
301 } else {
302 self.input_buffer.clone()
303 };
304 let cursor = "\u{2588}";
305 lines.push(format!(
306 " {}",
307 theme.fg_key(ThemeKey::Text, &format!("{} {}", display, cursor))
308 ));
309 lines.push(String::new());
310 lines.push(format!(" {}", theme.dim("Enter: submit · Esc: cancel")));
311 }
312 DialogState::Done => {
313 lines.push(format!(" {}", theme.dim("Login complete.")));
314 }
315 }
316
317 lines.push(String::new());
318
319 lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
321
322 lines
323 }
324
325 fn handle_input(&mut self, key: &KeyEvent) -> bool {
326 if self.submitted {
327 return false;
328 }
329
330 let kb = get_keybindings();
331
332 if kb.matches(key, ACTION_SELECT_CANCEL) {
335 if self.submitted {
336 return false;
337 }
338 self.submitted = true;
339 if let Some(cb) = self.on_cancel.take() {
340 cb();
341 }
342 return false;
343 }
344
345 let is_input_state = matches!(
347 self.state,
348 DialogState::Prompt { .. } | DialogState::ManualInput { .. }
349 );
350
351 if !is_input_state {
352 return false;
353 }
354
355 if kb.matches(key, ACTION_SELECT_CONFIRM) {
357 let value = std::mem::take(&mut self.input_buffer);
358 if !value.is_empty() {
359 let old_state = std::mem::replace(
360 &mut self.state,
361 DialogState::Submitted {
362 value: value.clone(),
363 },
364 );
365 self.submitted = true;
366 if let Some(cb) = self.on_submit.take()
367 && matches!(
368 old_state,
369 DialogState::Prompt { .. } | DialogState::ManualInput { .. }
370 )
371 {
372 cb(value);
373 }
374 }
375 return true;
376 }
377
378 if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
380 self.input_buffer.pop();
381 return true;
382 }
383
384 if let KeyCode::Char(c) = key.code
386 && !c.is_control()
387 && !key
388 .modifiers
389 .contains(crossterm::event::KeyModifiers::CONTROL)
390 && !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
391 {
392 self.input_buffer.push(c);
393 return true;
394 }
395
396 if key.code == KeyCode::Char('c')
398 && key
399 .modifiers
400 .contains(crossterm::event::KeyModifiers::CONTROL)
401 {
402 self.submitted = true;
403 if let Some(cb) = self.on_cancel.take() {
404 cb();
405 }
406 return true;
407 }
408
409 false
410 }
411
412 fn handle_paste(&mut self, text: &str) {
413 if self.submitted {
415 return;
416 }
417 let is_input_state = matches!(
418 self.state,
419 DialogState::Prompt { .. } | DialogState::ManualInput { .. }
420 );
421 if is_input_state {
422 self.input_buffer.push_str(text);
423 }
424 }
425}