diff --git a/Cargo.lock b/Cargo.lock index 502adf623..b96fe8dcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2890,6 +2890,7 @@ dependencies = [ name = "navigator-tui" version = "0.1.0" dependencies = [ + "base64 0.22.1", "crossterm 0.28.1", "miette", "navigator-bootstrap", diff --git a/Cargo.toml b/Cargo.toml index ad4dc5087..215862cf3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # WebSocket tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } +# Clipboard (OSC 52) +base64 = "0.22" + # Utilities futures = "0.3" bytes = "1" diff --git a/crates/navigator-tui/Cargo.toml b/crates/navigator-tui/Cargo.toml index 5013bd199..b27ebbf6f 100644 --- a/crates/navigator-tui/Cargo.toml +++ b/crates/navigator-tui/Cargo.toml @@ -16,6 +16,7 @@ navigator-bootstrap = { path = "../navigator-bootstrap" } navigator-policy = { path = "../navigator-policy" } navigator-providers = { path = "../navigator-providers" } +base64 = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } terminal-colorsaurus = { workspace = true } diff --git a/crates/navigator-tui/src/app.rs b/crates/navigator-tui/src/app.rs index f2df672c5..bcdd3d1fe 100644 --- a/crates/navigator-tui/src/app.rs +++ b/crates/navigator-tui/src/app.rs @@ -355,6 +355,9 @@ pub struct App { pub log_viewport_height: usize, /// When `Some(idx)`, a detail popup is shown for the filtered log line at this index. pub log_detail_index: Option, + /// Anchor index (absolute in filtered list) for visual selection mode. + /// When `Some`, the user is in visual-select mode (`v`). + pub log_selection_anchor: Option, /// Handle for the streaming log task. Dropped to cancel. pub log_stream_handle: Option>, @@ -448,6 +451,7 @@ impl App { log_autoscroll: true, log_viewport_height: 0, log_detail_index: None, + log_selection_anchor: None, log_stream_handle: None, draft_chunks: Vec::new(), draft_version: 0, @@ -900,12 +904,69 @@ impl App { match key.code { KeyCode::Esc => { - self.cancel_log_stream(); - self.focus = Focus::SandboxPolicy; + if self.log_selection_anchor.is_some() { + // Cancel visual selection, stay in log viewer. + self.log_selection_anchor = None; + } else { + self.cancel_log_stream(); + self.log_selection_anchor = None; + self.focus = Focus::SandboxPolicy; + } } KeyCode::Char('q') => self.running = false, + KeyCode::Char('y') => { + if filtered_len == 0 { + return; + } + let filtered = self.filtered_log_lines(); + if let Some(anchor) = self.log_selection_anchor { + // Visual mode: yank selected range. + let cursor_abs = self.sandbox_log_scroll + self.log_cursor; + let start = anchor.min(cursor_abs); + let end = anchor.max(cursor_abs); + let text: String = filtered[start..=end.min(filtered.len() - 1)] + .iter() + .map(|l| crate::ui::sandbox_logs::format_log_line_plain(l)) + .collect::>() + .join("\n"); + crate::clipboard::copy_to_clipboard(&text); + self.log_selection_anchor = None; + } else { + // Normal mode: yank current line. + let abs = self.sandbox_log_scroll + self.log_cursor; + if let Some(log) = filtered.get(abs) { + let text = crate::ui::sandbox_logs::format_log_line_plain(log); + crate::clipboard::copy_to_clipboard(&text); + } + } + } + KeyCode::Char('Y') => { + // Yank all visible lines in the viewport. + if filtered_len == 0 { + return; + } + let filtered = self.filtered_log_lines(); + let start = self.sandbox_log_scroll; + let end = (start + vh).min(filtered.len()); + let text: String = filtered[start..end] + .iter() + .map(|l| crate::ui::sandbox_logs::format_log_line_plain(l)) + .collect::>() + .join("\n"); + crate::clipboard::copy_to_clipboard(&text); + } + KeyCode::Char('v') => { + // Toggle visual selection mode. + if self.log_selection_anchor.is_some() { + self.log_selection_anchor = None; + } else { + let abs = self.sandbox_log_scroll + self.log_cursor; + self.log_selection_anchor = Some(abs); + self.log_autoscroll = false; + } + } KeyCode::Enter => { - if filtered_len > 0 { + if filtered_len > 0 && self.log_selection_anchor.is_none() { let abs = self.sandbox_log_scroll + self.log_cursor; if abs < filtered_len { self.log_detail_index = Some(abs); @@ -936,6 +997,7 @@ impl App { self.log_autoscroll = false; } KeyCode::Char('G' | 'f') => { + self.log_selection_anchor = None; self.sandbox_log_scroll = self.log_autoscroll_offset(); self.log_autoscroll = true; let visible = filtered_len.saturating_sub(self.sandbox_log_scroll); @@ -948,13 +1010,16 @@ impl App { } KeyCode::Char('s') => { self.log_source_filter = self.log_source_filter.next(); + self.log_selection_anchor = None; self.sandbox_log_scroll = 0; self.log_cursor = 0; } KeyCode::Char('r') => { + self.log_selection_anchor = None; self.focus = Focus::SandboxDraft; } KeyCode::Char('p') => { + self.log_selection_anchor = None; self.focus = Focus::SandboxPolicy; } _ => {} @@ -1533,6 +1598,7 @@ impl App { self.log_cursor = 0; self.log_autoscroll = true; self.log_detail_index = None; + self.log_selection_anchor = None; self.confirm_delete = false; self.sandbox_policy = None; self.sandbox_providers_list.clear(); diff --git a/crates/navigator-tui/src/clipboard.rs b/crates/navigator-tui/src/clipboard.rs new file mode 100644 index 000000000..536e95b02 --- /dev/null +++ b/crates/navigator-tui/src/clipboard.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! OSC 52 clipboard support. +//! +//! Writes the OSC 52 escape sequence to stdout, which instructs the terminal +//! emulator to copy text to the system clipboard. Works over SSH, tmux, and +//! mosh — the sequence is forwarded to the local terminal. + +use base64::Engine; +use std::io::Write; + +/// Copy `text` to the system clipboard via the OSC 52 escape sequence. +/// +/// This is fire-and-forget: if the terminal does not support OSC 52 the +/// sequence is silently ignored. +pub fn copy_to_clipboard(text: &str) { + let encoded = base64::engine::general_purpose::STANDARD.encode(text); + // OSC 52 ; c ; ST — "c" selects the system clipboard. + let seq = format!("\x1b]52;c;{encoded}\x07"); + let _ = std::io::stdout().write_all(seq.as_bytes()); + let _ = std::io::stdout().flush(); +} diff --git a/crates/navigator-tui/src/lib.rs b/crates/navigator-tui/src/lib.rs index 8d0d3676c..d6fa2fc13 100644 --- a/crates/navigator-tui/src/lib.rs +++ b/crates/navigator-tui/src/lib.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 mod app; +mod clipboard; mod event; pub mod theme; mod ui; diff --git a/crates/navigator-tui/src/theme.rs b/crates/navigator-tui/src/theme.rs index 16364ec14..1be202367 100644 --- a/crates/navigator-tui/src/theme.rs +++ b/crates/navigator-tui/src/theme.rs @@ -71,6 +71,7 @@ pub struct Theme { // Log viewer pub log_cursor: Style, + pub log_selection: Style, // Animation pub claw: Style, @@ -121,6 +122,7 @@ impl Theme { key_hint: Style::new().fg(brand::NVIDIA_GREEN), log_cursor: Style::new().bg(brand::EVERGLADE), + log_selection: Style::new().bg(Color::Rgb(30, 60, 45)), claw: Style::new().fg(brand::MAROON).add_modifier(Modifier::BOLD), @@ -143,6 +145,7 @@ impl Theme { let border_color = Color::Rgb(180, 200, 170); let title_bg = Color::Rgb(220, 235, 210); let cursor_bg = Color::Rgb(230, 245, 220); + let selection_bg = Color::Rgb(215, 235, 200); Self { text: Style::new().fg(fg), @@ -166,6 +169,7 @@ impl Theme { key_hint: Style::new().fg(brand::NVIDIA_GREEN_DARK), log_cursor: Style::new().bg(cursor_bg), + log_selection: Style::new().bg(selection_bg), claw: Style::new().fg(brand::MAROON).add_modifier(Modifier::BOLD), @@ -244,6 +248,10 @@ mod tests { dark.log_cursor, light.log_cursor, "log_cursor style should differ" ); + assert_ne!( + dark.log_selection, light.log_selection, + "log_selection style should differ" + ); } #[test] @@ -277,6 +285,7 @@ mod tests { assert_eq!(d.status_err, Style::new().fg(Color::Red)); assert_eq!(d.key_hint, Style::new().fg(brand::NVIDIA_GREEN)); assert_eq!(d.log_cursor, Style::new().bg(brand::EVERGLADE)); + assert_eq!(d.log_selection, Style::new().bg(Color::Rgb(30, 60, 45))); assert_eq!( d.claw, Style::new().fg(brand::MAROON).add_modifier(Modifier::BOLD) diff --git a/crates/navigator-tui/src/ui/mod.rs b/crates/navigator-tui/src/ui/mod.rs index f705f8eb2..9c05466dd 100644 --- a/crates/navigator-tui/src/ui/mod.rs +++ b/crates/navigator-tui/src/ui/mod.rs @@ -224,43 +224,74 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) { }, Screen::Sandbox => match app.focus { Focus::SandboxLogs => { - let filter_label = app.log_source_filter.label(); - let autoscroll_label = if app.log_autoscroll { - " Autoscroll" + if app.log_selection_anchor.is_some() { + // Visual selection mode — reduced hint set. + vec![ + Span::styled(" ", t.text), + Span::styled("[j/k]", t.key_hint), + Span::styled(" Extend", t.text), + Span::styled(" ", t.text), + Span::styled("[y]", t.key_hint), + Span::styled(" Yank", t.text), + Span::styled(" ", t.text), + Span::styled("[g/G]", t.key_hint), + Span::styled(" Top/Bottom", t.text), + Span::styled(" | ", t.border), + Span::styled("[Esc]", t.muted), + Span::styled(" Cancel", t.muted), + Span::styled(" ", t.text), + Span::styled("[q]", t.muted), + Span::styled(" Quit", t.muted), + ] } else { - " Follow" - }; - let autoscroll_style = if app.log_autoscroll { - t.status_ok - } else { - t.text - }; - vec![ - Span::styled(" ", t.text), - Span::styled("[j/k]", t.key_hint), - Span::styled(" Navigate", t.text), - Span::styled(" ", t.text), - Span::styled("[Enter]", t.key_hint), - Span::styled(" Detail", t.text), - Span::styled(" ", t.text), - Span::styled("[g/G]", t.key_hint), - Span::styled(" Top/Bottom", t.text), - Span::styled(" ", t.text), - Span::styled("[f]", t.key_hint), - Span::styled(autoscroll_label, autoscroll_style), - Span::styled(" ", t.text), - Span::styled("[s]", t.key_hint), - Span::styled(format!(" Source: {filter_label}"), t.text), - Span::styled(" ", t.text), - Span::styled("[r]", t.key_hint), - Span::styled(" Rules", t.text), - Span::styled(" | ", t.border), - Span::styled("[Esc]", t.muted), - Span::styled(" Policy", t.muted), - Span::styled(" ", t.text), - Span::styled("[q]", t.muted), - Span::styled(" Quit", t.muted), - ] + // Normal log viewer mode. + let filter_label = app.log_source_filter.label(); + let autoscroll_label = if app.log_autoscroll { + " Autoscroll" + } else { + " Follow" + }; + let autoscroll_style = if app.log_autoscroll { + t.status_ok + } else { + t.text + }; + vec![ + Span::styled(" ", t.text), + Span::styled("[j/k]", t.key_hint), + Span::styled(" Navigate", t.text), + Span::styled(" ", t.text), + Span::styled("[Enter]", t.key_hint), + Span::styled(" Detail", t.text), + Span::styled(" ", t.text), + Span::styled("[g/G]", t.key_hint), + Span::styled(" Top/Bottom", t.text), + Span::styled(" ", t.text), + Span::styled("[f]", t.key_hint), + Span::styled(autoscroll_label, autoscroll_style), + Span::styled(" ", t.text), + Span::styled("[s]", t.key_hint), + Span::styled(format!(" Source: {filter_label}"), t.text), + Span::styled(" ", t.text), + Span::styled("[y]", t.key_hint), + Span::styled(" Copy", t.text), + Span::styled(" ", t.text), + Span::styled("[Y]", t.key_hint), + Span::styled(" Copy All", t.text), + Span::styled(" ", t.text), + Span::styled("[v]", t.key_hint), + Span::styled(" Select", t.text), + Span::styled(" ", t.text), + Span::styled("[r]", t.key_hint), + Span::styled(" Rules", t.text), + Span::styled(" | ", t.border), + Span::styled("[Esc]", t.muted), + Span::styled(" Policy", t.muted), + Span::styled(" ", t.text), + Span::styled("[q]", t.muted), + Span::styled(" Quit", t.muted), + ] + } } Focus::SandboxDraft => { // Build state-aware action hints based on selected chunk. diff --git a/crates/navigator-tui/src/ui/sandbox_logs.rs b/crates/navigator-tui/src/ui/sandbox_logs.rs index 287d7df94..3171be115 100644 --- a/crates/navigator-tui/src/ui/sandbox_logs.rs +++ b/crates/navigator-tui/src/ui/sandbox_logs.rs @@ -57,17 +57,29 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { return; } + // Compute visual selection range (if active). + let selection_range = app.log_selection_anchor.map(|anchor| { + let cursor_abs = app.sandbox_log_scroll + cursor_pos; + (anchor.min(cursor_abs), anchor.max(cursor_abs)) + }); + let lines: Vec> = filtered .iter() .skip(app.sandbox_log_scroll) .take(inner_height) .enumerate() .map(|(i, log)| { + let abs_idx = app.sandbox_log_scroll + i; + let in_selection = + selection_range.is_some_and(|(start, end)| abs_idx >= start && abs_idx <= end); let mut line = render_log_line(log, inner_width.saturating_sub(2), t); if i == cursor_pos { // Prepend green cursor marker and apply highlight background. line.spans.insert(0, Span::styled("▌ ", t.accent)); line = line.style(t.log_cursor); + } else if in_selection { + line.spans.insert(0, Span::styled("▌ ", t.accent)); + line = line.style(t.log_selection); } else { line.spans.insert(0, Span::raw(" ")); } @@ -84,14 +96,17 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) { String::new() }; - let autoscroll_span = if app.log_autoscroll { + let status_span = if let Some((start, end)) = selection_range { + let count = end - start + 1; + Span::styled(format!(" VISUAL ({count} lines) "), t.status_warn) + } else if app.log_autoscroll { Span::styled(" ● FOLLOWING ", t.status_ok) } else { Span::styled(" ○ PAUSED ", t.status_warn) }; let block = block.title_bottom(Line::from(vec![ - autoscroll_span, + status_span, Span::styled(scroll_info, t.muted), Span::styled(format!(" filter: {filter_label} "), t.muted), ])); @@ -333,7 +348,7 @@ const L7_FIELD_ORDER: &[&str] = &[ ]; /// Return fields in a smart order based on the log message type. -fn ordered_fields<'a>(log: &'a LogLine) -> Vec<(&'a str, &'a str)> { +pub(crate) fn ordered_fields<'a>(log: &'a LogLine) -> Vec<(&'a str, &'a str)> { let order: Option<&[&str]> = if log.message.starts_with("CONNECT") { Some(CONNECT_FIELD_ORDER) } else if log.message.starts_with("L7_REQUEST") { @@ -388,7 +403,7 @@ fn level_style(level: &str, t: &crate::theme::Theme) -> ratatui::style::Style { } } -fn format_short_time(epoch_ms: i64) -> String { +pub(crate) fn format_short_time(epoch_ms: i64) -> String { if epoch_ms <= 0 { return String::from("--:--:--"); } @@ -399,3 +414,133 @@ fn format_short_time(epoch_ms: i64) -> String { let seconds = time_of_day % 60; format!("{hours:02}:{minutes:02}:{seconds:02}") } + +/// Format a log line as plain text for clipboard copy. +/// +/// Produces the same layout as `render_log_line()` but without styles or +/// truncation: `HH:MM:SS {source:<7} {level:<5} {message} [key=value ...]` +pub(crate) fn format_log_line_plain(log: &LogLine) -> String { + let ts = format_short_time(log.timestamp_ms); + let mut s = format!("{ts} {:<7} {:<5} {}", log.source, log.level, log.message); + + if !log.fields.is_empty() { + let ordered = ordered_fields(log); + for (k, v) in &ordered { + if !v.is_empty() { + s.push(' '); + s.push_str(k); + s.push('='); + s.push_str(v); + } + } + } + + s +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn make_log(message: &str, fields: Vec<(&str, &str)>) -> LogLine { + LogLine { + timestamp_ms: 1_700_000_000_000, // 2023-11-14 22:13:20 UTC + level: "INFO".to_string(), + source: "sandbox".to_string(), + target: "test".to_string(), + message: message.to_string(), + fields: fields + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + } + } + + #[test] + fn plain_format_basic_line() { + let log = make_log("hello world", vec![]); + let result = format_log_line_plain(&log); + assert!(result.starts_with("22:13:20 sandbox INFO hello world")); + assert!(!result.contains('=')); + } + + #[test] + fn plain_format_with_fields() { + let log = make_log( + "CONNECT", + vec![("dst_host", "example.com"), ("dst_port", "443")], + ); + let result = format_log_line_plain(&log); + assert!(result.contains("dst_host=example.com")); + assert!(result.contains("dst_port=443")); + } + + #[test] + fn plain_format_skips_empty_fields() { + let log = make_log("test", vec![("key1", "val"), ("key2", "")]); + let result = format_log_line_plain(&log); + assert!(result.contains("key1=val")); + assert!(!result.contains("key2=")); + } + + #[test] + fn plain_format_zero_timestamp() { + let log = LogLine { + timestamp_ms: 0, + level: "ERROR".to_string(), + source: "gateway".to_string(), + target: String::new(), + message: "fail".to_string(), + fields: HashMap::new(), + }; + let result = format_log_line_plain(&log); + assert!(result.starts_with("--:--:-- gateway ERROR fail")); + } + + #[test] + fn plain_format_connect_field_order() { + let log = make_log( + "CONNECT", + vec![ + ("binary", "/usr/bin/curl"), + ("action", "allow"), + ("dst_host", "example.com"), + ], + ); + let result = format_log_line_plain(&log); + // "action" should appear before "dst_host" which should appear before "binary" + let action_pos = result.find("action=").unwrap(); + let dst_pos = result.find("dst_host=").unwrap(); + let binary_pos = result.find("binary=").unwrap(); + assert!(action_pos < dst_pos); + assert!(dst_pos < binary_pos); + } + + #[test] + fn plain_format_l7_field_order() { + let log = make_log( + "L7_REQUEST", + vec![ + ("policy", "default"), + ("l7_action", "allow"), + ("l7_target", "/api"), + ], + ); + let result = format_log_line_plain(&log); + let action_pos = result.find("l7_action=").unwrap(); + let target_pos = result.find("l7_target=").unwrap(); + let policy_pos = result.find("policy=").unwrap(); + assert!(action_pos < target_pos); + assert!(target_pos < policy_pos); + } + + #[test] + fn plain_format_alphabetical_fields_for_unknown_message() { + let log = make_log("SOMETHING", vec![("zebra", "z"), ("alpha", "a")]); + let result = format_log_line_plain(&log); + let alpha_pos = result.find("alpha=").unwrap(); + let zebra_pos = result.find("zebra=").unwrap(); + assert!(alpha_pos < zebra_pos); + } +}