From ac6c59e593d7ee5b51e2d1da33cd0da6e6424000 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:08:30 -0700 Subject: [PATCH] feat(tui): add log copy and visual selection mode Add clipboard support for the TUI log viewer using OSC 52 escape sequences. Users can copy individual log lines (y), the visible viewport (Y), or enter visual selection mode (v) to select a range of lines with j/k and yank with y. - Add clipboard module with OSC 52 base64 encoding - Add format_log_line_plain() for plain-text log rendering - Add visual selection mode with highlight styling and status bar - Context-switch nav bar hints between normal and visual modes - Add 7 unit tests for plain-text log formatting Closes #274 --- Cargo.lock | 1 + Cargo.toml | 3 + crates/navigator-tui/Cargo.toml | 1 + crates/navigator-tui/src/app.rs | 72 ++++++++- crates/navigator-tui/src/clipboard.rs | 23 +++ crates/navigator-tui/src/lib.rs | 1 + crates/navigator-tui/src/theme.rs | 9 ++ crates/navigator-tui/src/ui/mod.rs | 103 ++++++++----- crates/navigator-tui/src/ui/sandbox_logs.rs | 153 +++++++++++++++++++- 9 files changed, 323 insertions(+), 43 deletions(-) create mode 100644 crates/navigator-tui/src/clipboard.rs 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); + } +}