Skip to main content

rab/agent/ui/components/
login_dialog.rs

1//! LoginDialog component — matching pi's LoginDialogComponent.
2//!
3//! Replaces the editor area during login flows. Shows a border, title,
4//! dynamic content area, and an input field for prompting the user.
5//!
6//! Methods (matching pi):
7//! - showPrompt(message, placeholder?) → Promise<string>
8//! - showManualInput(prompt) → Promise<string>
9//! - showInfo(lines)
10//! - showWaiting(message)
11//! - showProgress(message)
12//! - showAuth(url, instructions?)
13//! - showDeviceCode(info)
14
15use 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/// Internal state for what the dialog is currently doing.
25#[allow(dead_code)]
26enum DialogState {
27    /// Showing informational text (no input).
28    Info { lines: Vec<String> },
29    /// Showing a prompt with an input field.
30    Prompt {
31        message: String,
32        placeholder: Option<String>,
33    },
34    /// Prompt has been submitted, showing submitted value.
35    Submitted { value: String },
36    /// Showing an auth URL with optional instructions.
37    Auth {
38        url: String,
39        instructions: Option<String>,
40    },
41    /// Showing device code flow info.
42    DeviceCode {
43        verification_uri: String,
44        user_code: String,
45    },
46    /// Showing a waiting message (for polling flows).
47    Waiting { message: String },
48    /// Showing progress messages (appended one by one).
49    Progress { messages: Vec<String> },
50    /// Manual input prompt (for paste redirect URL).
51    ManualInput { prompt: String },
52    /// Dialog is done.
53    Done,
54}
55
56/// Login dialog — replaces editor area during login (matching pi's LoginDialogComponent).
57pub struct LoginDialog {
58    provider_id: String,
59    provider_name: String,
60    state: DialogState,
61    input_buffer: String,
62    /// Callback when user submits the prompt.
63    on_submit: Option<Box<dyn FnOnce(String)>>,
64    /// Callback when user cancels.
65    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    /// Set the callback for when user submits input.
83    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    /// Set the callback for when user cancels.
91    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    /// Show a prompt and wait for input (matching pi's showPrompt).
99    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    /// Show informational text (matching pi's showInfo).
108    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    /// Show an auth URL with optional instructions (matching pi's showAuth).
115    /// Opens the URL in the browser if supported.
116    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    /// Show device code flow info (matching pi's showDeviceCode).
124    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    /// Show a waiting message (matching pi's showWaiting).
132    pub fn show_waiting(&mut self, message: &str) {
133        self.state = DialogState::Waiting {
134            message: message.to_string(),
135        };
136    }
137
138    /// Show a progress message (matching pi's showProgress).
139    /// Appends to any existing progress messages.
140    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    /// Show a manual input prompt (matching pi's showManualInput).
154    /// Unlike showPrompt, this does NOT clear existing content — it appends
155    /// the input field below whatever is currently shown.
156    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    /// Reset the dialog for reuse.
164    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    /// The provider ID.
171    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        // Top border (matching pi's DynamicBorder)
182        lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
183        lines.push(String::new());
184
185        // Title
186        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                // Prompt message
210                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                // Input line with masked API key display
220                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}"; // full block
226                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                // Key hints (matching pi's keyHint)
240                lines.push(format!("  {}", theme.dim("Enter: submit · Esc: cancel")));
241            }
242            DialogState::Submitted { value } => {
243                // Show submitted value (matching pi's replaceInputWithSubmittedText)
244                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                // Show URL as clickable link hint
255                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                // Don't clear existing lines — show prompt below current content.
294                // The prompt is followed by the input field.
295                lines.push(format!("  {}", theme.fg_key(ThemeKey::Dim, prompt)));
296                lines.push(String::new());
297
298                // Input line (not masked — shows actual URL/code)
299                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        // Bottom border
320        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        // Escape cancels (matching pi's onEscape / select.cancel).
333        // Returns false so the main loop pops the overlay (same as OAuthSelector).
334        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        // Only handle text input in Prompt or ManualInput states
346        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        // Enter submits
356        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        // Backspace
379        if kb.matches(key, ACTION_EDITOR_DELETE_CHAR_BACKWARD) {
380            self.input_buffer.pop();
381            return true;
382        }
383
384        // Printable characters
385        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        // Ctrl+C cancels
397        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        // Insert pasted text into buffer when in an input state
414        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}