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
This commit is contained in:
John Myers
2026-03-12 23:08:30 -07:00
parent 7430c75438
commit ac6c59e593
9 changed files with 323 additions and 43 deletions
Generated
+1
View File
@@ -2890,6 +2890,7 @@ dependencies = [
name = "navigator-tui"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"crossterm 0.28.1",
"miette",
"navigator-bootstrap",
+3
View File
@@ -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"
+1
View File
@@ -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 }
+69 -3
View File
@@ -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<usize>,
/// 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<usize>,
/// Handle for the streaming log task. Dropped to cancel.
pub log_stream_handle: Option<tokio::task::JoinHandle<()>>,
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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();
+23
View File
@@ -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 ; <base64> 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();
}
+1
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
mod app;
mod clipboard;
mod event;
pub mod theme;
mod ui;
+9
View File
@@ -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)
+67 -36
View File
@@ -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.
+149 -4
View File
@@ -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<Line<'_>> = 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);
}
}