diff --git a/apps/tui/COMMANDS.md b/apps/tui/COMMANDS.md index 4c060c8..ac9d997 100644 --- a/apps/tui/COMMANDS.md +++ b/apps/tui/COMMANDS.md @@ -33,8 +33,10 @@ editor, or form. A paste containing control characters is rejected and never submits a command or creates extra entry lines. Losing terminal ownership (including terminal suspension/focus loss) immediately revokes the lease, cancels Git and clipboard work, removes plaintext presentation state, and -returns to the locked screen. `NO_COLOR` or `TERM=dumb` selects the monochrome -fallback while preserving focused selections through reverse-video attributes. +returns to the locked screen. A native authentication prompt may temporarily +own focus; its result stays hidden until terminal focus returns. `NO_COLOR` or +`TERM=dumb` selects the monochrome fallback while preserving focused selections +through reverse-video attributes. Selected rows override nested field colors so labels, masked values, metadata, and OTP codes keep one high-contrast foreground across the complete selection. diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 1f7eb98..02f019e 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -87,6 +87,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { let mut authentication = None; let mut git_control = None; let mut authentication_initialized = false; + let mut terminal_focused = true; let mut key_resolver = KeyResolver::default(); let color_capability = ui::ColorCapability::detect(); let startup = app.begin_latest_request(); @@ -120,7 +121,8 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { Err(error) => app.authentication_failed(error), } } - if let Some(coordinator) = authentication.as_mut() + if terminal_focused + && let Some(coordinator) = authentication.as_mut() && let Some(event) = coordinator.completion() { apply_authentication_event( @@ -252,12 +254,18 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { } pasted.zeroize(); } + Event::FocusGained => terminal_focused = true, Event::FocusLost => { - handle_terminal_ownership_lost( + terminal_focused = false; + let native_prompt_pending = authentication + .as_ref() + .is_some_and(AuthenticationCoordinator::native_prompt_pending); + handle_terminal_focus_lost( &mut app, &mut authentication, &mut git_control, &mut clipboard_cancellations, + native_prompt_pending, ); } _ => {} @@ -266,6 +274,19 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { Ok(()) } +fn handle_terminal_focus_lost( + app: &mut App, + authentication: &mut Option, + git_control: &mut Option, + clipboard_cancellations: &mut ClipboardCancellations, + native_prompt_pending: bool, +) { + if native_prompt_pending { + return; + } + handle_terminal_ownership_lost(app, authentication, git_control, clipboard_cancellations); +} + fn current_unix_seconds() -> Result { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1620,6 +1641,24 @@ mod tests { assert_eq!(presentation.0, 0); } + #[test] + fn native_authentication_focus_handoff_waits_without_relocking() { + let mut app = App::new(); + let mut authentication = None; + let mut git_control = None; + let mut clipboard = ClipboardCancellations::default(); + + handle_terminal_focus_lost( + &mut app, + &mut authentication, + &mut git_control, + &mut clipboard, + true, + ); + + assert_eq!(app.mode(), crate::app::Mode::Browser); + } + #[test] fn authentication_expiry_cancels_the_active_clipboard_presentation() { let mut app = App::new(); diff --git a/apps/tui/src/runtime.rs b/apps/tui/src/runtime.rs index 17acc68..4c56e9b 100644 --- a/apps/tui/src/runtime.rs +++ b/apps/tui/src/runtime.rs @@ -53,6 +53,7 @@ pub struct AuthenticationCoordinator { sender: Sender, receiver: Receiver, generation: u64, + native_prompt_pending: bool, } impl AuthenticationCoordinator { @@ -71,6 +72,7 @@ impl AuthenticationCoordinator { sender, receiver, generation: 0, + native_prompt_pending: false, }) } @@ -96,6 +98,7 @@ impl AuthenticationCoordinator { fn request(&mut self, target: AuthenticationTarget) { self.generation = self.generation.wrapping_add(1); + self.native_prompt_pending = self.handle.is_none(); let generation = self.generation; let session = self.session.clone(); let key = self.key.clone(); @@ -118,6 +121,7 @@ impl AuthenticationCoordinator { .try_iter() .filter(|completion| completion.generation == self.generation) .last()?; + self.native_prompt_pending = false; match completion.result { Ok(handle) => { self.handle = Some(handle); @@ -172,8 +176,13 @@ impl AuthenticationCoordinator { self.handle.clone() } + pub const fn native_prompt_pending(&self) -> bool { + self.native_prompt_pending + } + pub fn lock(&mut self) -> Result<(), String> { self.generation = self.generation.wrapping_add(1); + self.native_prompt_pending = false; self.handle = None; self.session .manual_lock() diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 1d060a5..624b588 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -419,14 +419,7 @@ fn render_large_otp( let inner_width = usize::from(area.width.saturating_sub(2)); let mut lines = glyphs .iter() - .map(|row| { - Line::styled( - row.as_str(), - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD), - ) - }) + .map(|row| large_otp_line(row)) .collect::>(); lines.push(Line::styled( countdown_bar(remaining, period, inner_width), @@ -444,6 +437,26 @@ fn render_large_otp( ); } +fn large_otp_line(row: &str) -> Line<'_> { + let digit_style = Style::default() + .fg(Color::Black) + .bg(Color::Green) + .add_modifier(Modifier::BOLD); + Line::from( + row.split(' ') + .enumerate() + .flat_map(|(index, digits)| { + [ + (index != 0).then(|| Span::raw(" ")), + (!digits.is_empty()).then(|| Span::styled(digits, digit_style)), + ] + .into_iter() + .flatten() + }) + .collect::>(), + ) +} + fn render_compact_otp( frame: &mut Frame, display: &crate::app::OtpDisplay, @@ -1149,6 +1162,21 @@ mod tests { assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide); } + #[test] + fn large_otp_inverts_only_the_digits_that_form_each_glyph() { + let line = large_otp_line("1 22"); + assert_eq!(line.to_string(), "1 22"); + for span in line.spans { + if span.content == " " { + assert_eq!(span.style, Style::default()); + } else { + assert_eq!(span.style.fg, Some(Color::Black)); + assert_eq!(span.style.bg, Some(Color::Green)); + assert!(span.style.add_modifier.contains(Modifier::BOLD)); + } + } + } + #[test] fn color_capability_has_a_complete_monochrome_fallback() { assert_eq!(