From 7a36421701130571e7f1a01266453d267bf155ed Mon Sep 17 00:00:00 2001 From: Droid Agent Date: Thu, 29 Jan 2026 09:58:14 +0000 Subject: [PATCH] fix(tui): use alternate screen buffer for login screen This fixes the issue where the login screen would spam 'Welcome to Cortex CLI' messages on some Ubuntu terminals instead of properly rendering the interactive TUI. The root cause was that the inline rendering approach using cursor escape sequences (MoveUp/MoveDown) was not reliable across all terminal emulators. Some terminals don't properly support these cursor control sequences, especially in SSH sessions or with certain terminal multiplexer configurations. Solution: Refactored the login screen to use ratatui with the alternate screen buffer, similar to how the trust screen already works. This approach: - Uses EnterAlternateScreen for reliable full-screen rendering - Leverages ratatui's proper terminal abstraction - Works consistently across all terminal emulators - Prevents content from leaking to the scrollback buffer Fixes rendering issues on Ubuntu terminals where cursor escape sequences were not properly interpreted. --- cortex-tui/src/runner/login_screen.rs | 435 +++++++++++--------------- 1 file changed, 189 insertions(+), 246 deletions(-) diff --git a/cortex-tui/src/runner/login_screen.rs b/cortex-tui/src/runner/login_screen.rs index 695cb176..abcdf5b4 100644 --- a/cortex-tui/src/runner/login_screen.rs +++ b/cortex-tui/src/runner/login_screen.rs @@ -1,19 +1,20 @@ -//! Login Screen - Inline CLI +//! Login Screen - Full-screen TUI //! -//! Minimalist inline login screen with animation, no full-screen takeover. +//! Full-screen login screen using ratatui and alternate screen buffer for reliable +//! rendering across all terminal emulators. -use std::io::{Write, stdout}; +use std::io::stdout; use std::path::PathBuf; use std::time::{Duration, Instant}; use anyhow::Result; -use crossterm::{ - cursor, - event::{self, Event, KeyCode, KeyEvent}, - execute, queue, - style::{Color, Print, ResetColor, SetForegroundColor}, - terminal::{Clear, ClearType}, -}; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use tokio::sync::mpsc; use cortex_login::{SecureAuthData, save_auth_with_fallback}; @@ -25,19 +26,10 @@ use cortex_tui_components::spinner::SpinnerStyle; const API_BASE_URL: &str = "https://api.cortex.foundation"; const AUTH_BASE_URL: &str = "https://auth.cortex.foundation"; -const RENDER_LINES: u16 = 12; // Number of lines we use for rendering - -// Colors -const PRIMARY: Color = Color::Rgb { - r: 0x00, - g: 0xFF, - b: 0xA3, -}; -const DIM: Color = Color::Rgb { - r: 0x6b, - g: 0x6b, - b: 0x7b, -}; + +// Colors matching the original design +const PRIMARY: Color = Color::Rgb(0x00, 0xFF, 0xA3); +const DIM: Color = Color::Rgb(0x6b, 0x6b, 0x7b); const CYAN: Color = Color::Cyan; // ============================================================================ @@ -144,35 +136,37 @@ impl LoginScreen { } pub async fn run(&mut self) -> Result { - let mut stdout = stdout(); - - // Add 1 empty line to separate from previous output - println!(); - - // Print empty lines to make space for our render area - for _ in 0..RENDER_LINES { - println!(); - } - - // Move cursor back up to start of render area - execute!(stdout, cursor::MoveUp(RENDER_LINES))?; - + // Enter alternate screen mode for reliable rendering crossterm::terminal::enable_raw_mode()?; - execute!(stdout, cursor::Hide)?; + let mut stdout = stdout(); + crossterm::execute!( + stdout, + crossterm::terminal::EnterAlternateScreen, + crossterm::event::EnableMouseCapture, + )?; - init_panic_hook(); + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; - let result = self.run_loop(&mut stdout).await; + let result = self.run_loop(&mut terminal).await; - // Cleanup - execute!(stdout, cursor::Show)?; + // Cleanup - leave alternate screen crossterm::terminal::disable_raw_mode()?; - println!(); + crossterm::execute!( + terminal.backend_mut(), + crossterm::terminal::LeaveAlternateScreen, + crossterm::event::DisableMouseCapture, + )?; + terminal.show_cursor()?; result } - async fn run_loop(&mut self, stdout: &mut std::io::Stdout) -> Result { + async fn run_loop( + &mut self, + terminal: &mut Terminal>, + ) -> Result { // Flush any pending input events to prevent stale keypresses while event::poll(Duration::from_millis(0))? { let _ = event::read()?; @@ -197,7 +191,7 @@ impl LoginScreen { } // Render - self.render(stdout)?; + terminal.draw(|f| self.render(f))?; // Check async messages self.process_async_messages(); @@ -208,8 +202,6 @@ impl LoginScreen { // Filter to only handle key press events (not release) if key.kind == crossterm::event::KeyEventKind::Press { if let Some(result) = self.handle_key(key) { - // Move cursor to end of render area before returning - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; return Ok(result); } } @@ -218,15 +210,12 @@ impl LoginScreen { match self.state { LoginState::Success => { - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; return Ok(LoginResult::LoggedIn); } LoginState::Exit => { - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; return Ok(LoginResult::Exit); } LoginState::Failed => { - execute!(stdout, cursor::MoveDown(RENDER_LINES))?; let msg = self.error_message.clone().unwrap_or_default(); return Ok(LoginResult::Failed(msg)); } @@ -235,127 +224,113 @@ impl LoginScreen { } } - fn render(&self, stdout: &mut std::io::Stdout) -> Result<()> { + fn render(&self, f: &mut ratatui::Frame) { + let area = f.area(); + f.render_widget(Clear, area); + match self.state { - LoginState::SelectMethod => self.render_select_method(stdout)?, - LoginState::WaitingForAuth => self.render_waiting(stdout)?, + LoginState::SelectMethod => self.render_select_method(f, area), + LoginState::WaitingForAuth => self.render_waiting(f, area), _ => {} } - - stdout.flush()?; - - // Move cursor back up to start for next frame - execute!(stdout, cursor::MoveUp(RENDER_LINES))?; - - Ok(()) } - fn render_select_method(&self, stdout: &mut std::io::Stdout) -> Result<()> { + fn render_select_method(&self, f: &mut ratatui::Frame, area: Rect) { let version = env!("CARGO_PKG_VERSION"); - // Line 1: Separator line - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("────────────────────────────────────────────────────────────"), - ResetColor, - Print("\n") - )?; + // Center the content + let content_width = 70.min(area.width.saturating_sub(4)); + let content_height = 14; + let content_x = (area.width.saturating_sub(content_width)) / 2; + let content_y = (area.height.saturating_sub(content_height)) / 2; + let content_area = Rect::new(content_x, content_y, content_width, content_height); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Separator + Constraint::Length(1), // Welcome message + Constraint::Length(1), // Description + Constraint::Length(1), // Empty + Constraint::Length(1), // Select header + Constraint::Length(1), // Empty + Constraint::Length(3), // Method options + Constraint::Length(1), // Empty + Constraint::Length(1), // Hints + Constraint::Length(1), // Error message (if any) + ]) + .split(content_area); + + // Line 1: Separator + let separator = + Paragraph::new("────────────────────────────────────────────────────────────") + .style(Style::default().fg(DIM)); + f.render_widget(separator, chunks[0]); // Line 2: Welcome message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print("Welcome to Cortex CLI"), - SetForegroundColor(DIM), - Print(format!(" v{}", version)), - ResetColor, - Print("\n") - )?; + let welcome = Paragraph::new(Line::from(vec![ + Span::styled( + "Welcome to Cortex CLI", + Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" v{}", version), Style::default().fg(DIM)), + ])); + f.render_widget(welcome, chunks[1]); // Line 3: Description - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("Cortex can be used with your Cortex Foundation account or API key."), - ResetColor, - Print("\n") - )?; - - // Line 4: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + let description = + Paragraph::new("Cortex can be used with your Cortex Foundation account or API key.") + .style(Style::default().fg(DIM)); + f.render_widget(description, chunks[2]); // Line 5: Select header - queue!( - stdout, - Clear(ClearType::CurrentLine), - Print(" Select login method:"), - Print("\n") - )?; - - // Line 6: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + let header = Paragraph::new(" Select login method:"); + f.render_widget(header, chunks[4]); // Lines 7-9: Method options + let mut lines: Vec = Vec::new(); for (i, method) in LoginMethod::all().iter().enumerate() { let is_selected = i == self.selected_method; let prefix = if is_selected { " › " } else { " " }; - let color = if is_selected { PRIMARY } else { DIM }; - let desc = method.description(); - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(color) - )?; - - if desc.is_empty() { - // Exit option - no description - queue!( - stdout, - Print(format!("{}{}. ", prefix, i + 1)), - SetForegroundColor(if is_selected { PRIMARY } else { Color::White }), - Print(method.label()), - ResetColor, - Print("\n") - )?; - } else { - queue!( - stdout, - Print(format!("{}{}. ", prefix, i + 1)), - SetForegroundColor(if is_selected { PRIMARY } else { Color::White }), - Print(method.label()), - SetForegroundColor(DIM), - Print(format!(" · {}", desc)), - ResetColor, - Print("\n") - )?; + let mut spans = vec![ + Span::styled( + format!("{}{}. ", prefix, i + 1), + Style::default().fg(if is_selected { PRIMARY } else { DIM }), + ), + Span::styled( + method.label(), + Style::default().fg(if is_selected { PRIMARY } else { Color::White }), + ), + ]; + + let desc = method.description(); + if !desc.is_empty() { + spans.push(Span::styled( + format!(" · {}", desc), + Style::default().fg(DIM), + )); } - } - // Line 10: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + lines.push(Line::from(spans)); + } + let options = Paragraph::new(lines); + f.render_widget(options, chunks[6]); // Line 11: Hints - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("↑↓ to select · Enter to confirm · Ctrl+C to exit"), - ResetColor, - Print("\n") - )?; - - // Line 12: padding - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; - - Ok(()) + let hints = Paragraph::new("↑↓ to select · Enter to confirm · Ctrl+C to exit") + .style(Style::default().fg(DIM)); + f.render_widget(hints, chunks[8]); + + // Line 12: Error message (if any) + if let Some(ref error) = self.error_message { + let error_msg = + Paragraph::new(format!("Error: {}", error)).style(Style::default().fg(Color::Red)); + f.render_widget(error_msg, chunks[9]); + } } - fn render_waiting(&self, stdout: &mut std::io::Stdout) -> Result<()> { + fn render_waiting(&self, f: &mut ratatui::Frame, area: Rect) { let version = env!("CARGO_PKG_VERSION"); let breathing = SpinnerStyle::Breathing.frames(); let spinner = breathing[(self.frame_count % breathing.len() as u64) as usize]; @@ -367,108 +342,85 @@ impl LoginScreen { format!("{}/device", AUTH_BASE_URL) }; - // Line 1: Welcome message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print("Welcome to Cortex CLI"), - SetForegroundColor(DIM), - Print(format!(" v{}", version)), - ResetColor, - Print("\n") - )?; + // Center the content + let content_width = 70.min(area.width.saturating_sub(4)); + let content_height = 14; + let content_x = (area.width.saturating_sub(content_width)) / 2; + let content_y = (area.height.saturating_sub(content_height)) / 2; + let content_area = Rect::new(content_x, content_y, content_width, content_height); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Welcome message + Constraint::Length(1), // Empty + Constraint::Length(1), // Mascot top + Constraint::Length(1), // Mascot + waiting message + Constraint::Length(1), // Mascot bottom + Constraint::Length(1), // Mascot legs + Constraint::Length(1), // Empty + Constraint::Length(1), // Browser message + Constraint::Length(1), // URL + Constraint::Length(1), // Empty + Constraint::Length(1), // Hints + ]) + .split(content_area); - // Line 2: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + // Line 1: Welcome message + let welcome = Paragraph::new(Line::from(vec![ + Span::styled( + "Welcome to Cortex CLI", + Style::default().fg(PRIMARY).add_modifier(Modifier::BOLD), + ), + Span::styled(format!(" v{}", version), Style::default().fg(DIM)), + ])); + f.render_widget(welcome, chunks[0]); // Line 3: Mascot top - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print(" ▄█▀▀▀▀█▄ "), - ResetColor, - Print("\n") - )?; - - // Line 4: Mascot + Waiting message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print("██ ▌ ▐ ██ "), - Print("Waiting for browser authentication "), - Print(spinner), - ResetColor, - Print("\n") - )?; + let mascot_top = Paragraph::new(" ▄█▀▀▀▀█▄ ").style(Style::default().fg(PRIMARY)); + f.render_widget(mascot_top, chunks[2]); + + // Line 4: Mascot + waiting message + let mascot_middle = Paragraph::new(Line::from(vec![ + Span::styled("██ ▌ ▐ ██ ", Style::default().fg(PRIMARY)), + Span::styled( + format!("Waiting for browser authentication {}", spinner), + Style::default().fg(PRIMARY), + ), + ])); + f.render_widget(mascot_middle, chunks[3]); // Line 5: Mascot bottom - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print(" █▄▄▄▄▄▄█ "), - ResetColor, - Print("\n") - )?; + let mascot_bottom = Paragraph::new(" █▄▄▄▄▄▄█ ").style(Style::default().fg(PRIMARY)); + f.render_widget(mascot_bottom, chunks[4]); // Line 6: Mascot legs - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(PRIMARY), - Print(" █ █"), - ResetColor, - Print("\n") - )?; + let mascot_legs = Paragraph::new(" █ █").style(Style::default().fg(PRIMARY)); + f.render_widget(mascot_legs, chunks[5]); - // Line 7: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; - - // Line 8: Browser didn't open message - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("Browser didn't open? Click the URL below "), - Print(if self.copied_notification.is_some() { - "(✓ Copied!)" - } else { - "(c to copy)" - }), - ResetColor, - Print("\n") - )?; - - // Line 9: Direct URL - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(CYAN), - Print(&direct_url), - ResetColor, - Print("\n") - )?; - - // Line 10: Empty - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; + // Line 8: Browser message + let copy_hint = if self.copied_notification.is_some() { + "(✓ Copied!)" + } else { + "(c to copy)" + }; + let browser_msg = Paragraph::new(Line::from(vec![ + Span::styled( + "Browser didn't open? Click the URL below ", + Style::default().fg(DIM), + ), + Span::styled(copy_hint, Style::default().fg(DIM)), + ])); + f.render_widget(browser_msg, chunks[7]); + + // Line 9: URL + let url_line = Paragraph::new(&*direct_url).style(Style::default().fg(CYAN)); + f.render_widget(url_line, chunks[8]); // Line 11: Hints - queue!( - stdout, - Clear(ClearType::CurrentLine), - SetForegroundColor(DIM), - Print("Esc to go back · Ctrl+C to exit"), - ResetColor, - Print("\n") - )?; - - // Line 12: padding - queue!(stdout, Clear(ClearType::CurrentLine), Print("\n"))?; - - Ok(()) + let hints = + Paragraph::new("Esc to go back · Ctrl+C to exit").style(Style::default().fg(DIM)); + f.render_widget(hints, chunks[10]); } fn get_direct_url(&self) -> String { @@ -825,12 +777,3 @@ async fn poll_for_token_async( )) .await; } - -fn init_panic_hook() { - let original_hook = std::panic::take_hook(); - std::panic::set_hook(Box::new(move |panic_info| { - let _ = crossterm::terminal::disable_raw_mode(); - let _ = execute!(std::io::stdout(), cursor::Show); - original_hook(panic_info); - })); -}