Skip to content

Commit eee4104

Browse files
authored
feat(tui): add log copy and visual selection mode (#276)
* 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 * fix(tui): write OSC 52 to /dev/tty instead of stdout ratatui owns stdout via the alternate screen buffer, so OSC 52 escape sequences written to stdout are swallowed by the backend. Write directly to /dev/tty to bypass the buffer and reach the terminal emulator.
1 parent 9e447ff commit eee4104

File tree

9 files changed

+328
-43
lines changed

9 files changed

+328
-43
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
7272
# WebSocket
7373
tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] }
7474

75+
# Clipboard (OSC 52)
76+
base64 = "0.22"
77+
7578
# Utilities
7679
futures = "0.3"
7780
bytes = "1"

crates/openshell-tui/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ openshell-bootstrap = { path = "../openshell-bootstrap" }
1616
openshell-policy = { path = "../openshell-policy" }
1717
openshell-providers = { path = "../openshell-providers" }
1818

19+
base64 = { workspace = true }
1920
ratatui = { workspace = true }
2021
crossterm = { workspace = true }
2122
terminal-colorsaurus = { workspace = true }

crates/openshell-tui/src/app.rs

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,9 @@ pub struct App {
355355
pub log_viewport_height: usize,
356356
/// When `Some(idx)`, a detail popup is shown for the filtered log line at this index.
357357
pub log_detail_index: Option<usize>,
358+
/// Anchor index (absolute in filtered list) for visual selection mode.
359+
/// When `Some`, the user is in visual-select mode (`v`).
360+
pub log_selection_anchor: Option<usize>,
358361
/// Handle for the streaming log task. Dropped to cancel.
359362
pub log_stream_handle: Option<tokio::task::JoinHandle<()>>,
360363

@@ -448,6 +451,7 @@ impl App {
448451
log_autoscroll: true,
449452
log_viewport_height: 0,
450453
log_detail_index: None,
454+
log_selection_anchor: None,
451455
log_stream_handle: None,
452456
draft_chunks: Vec::new(),
453457
draft_version: 0,
@@ -900,12 +904,69 @@ impl App {
900904

901905
match key.code {
902906
KeyCode::Esc => {
903-
self.cancel_log_stream();
904-
self.focus = Focus::SandboxPolicy;
907+
if self.log_selection_anchor.is_some() {
908+
// Cancel visual selection, stay in log viewer.
909+
self.log_selection_anchor = None;
910+
} else {
911+
self.cancel_log_stream();
912+
self.log_selection_anchor = None;
913+
self.focus = Focus::SandboxPolicy;
914+
}
905915
}
906916
KeyCode::Char('q') => self.running = false,
917+
KeyCode::Char('y') => {
918+
if filtered_len == 0 {
919+
return;
920+
}
921+
let filtered = self.filtered_log_lines();
922+
if let Some(anchor) = self.log_selection_anchor {
923+
// Visual mode: yank selected range.
924+
let cursor_abs = self.sandbox_log_scroll + self.log_cursor;
925+
let start = anchor.min(cursor_abs);
926+
let end = anchor.max(cursor_abs);
927+
let text: String = filtered[start..=end.min(filtered.len() - 1)]
928+
.iter()
929+
.map(|l| crate::ui::sandbox_logs::format_log_line_plain(l))
930+
.collect::<Vec<_>>()
931+
.join("\n");
932+
crate::clipboard::copy_to_clipboard(&text);
933+
self.log_selection_anchor = None;
934+
} else {
935+
// Normal mode: yank current line.
936+
let abs = self.sandbox_log_scroll + self.log_cursor;
937+
if let Some(log) = filtered.get(abs) {
938+
let text = crate::ui::sandbox_logs::format_log_line_plain(log);
939+
crate::clipboard::copy_to_clipboard(&text);
940+
}
941+
}
942+
}
943+
KeyCode::Char('Y') => {
944+
// Yank all visible lines in the viewport.
945+
if filtered_len == 0 {
946+
return;
947+
}
948+
let filtered = self.filtered_log_lines();
949+
let start = self.sandbox_log_scroll;
950+
let end = (start + vh).min(filtered.len());
951+
let text: String = filtered[start..end]
952+
.iter()
953+
.map(|l| crate::ui::sandbox_logs::format_log_line_plain(l))
954+
.collect::<Vec<_>>()
955+
.join("\n");
956+
crate::clipboard::copy_to_clipboard(&text);
957+
}
958+
KeyCode::Char('v') => {
959+
// Toggle visual selection mode.
960+
if self.log_selection_anchor.is_some() {
961+
self.log_selection_anchor = None;
962+
} else {
963+
let abs = self.sandbox_log_scroll + self.log_cursor;
964+
self.log_selection_anchor = Some(abs);
965+
self.log_autoscroll = false;
966+
}
967+
}
907968
KeyCode::Enter => {
908-
if filtered_len > 0 {
969+
if filtered_len > 0 && self.log_selection_anchor.is_none() {
909970
let abs = self.sandbox_log_scroll + self.log_cursor;
910971
if abs < filtered_len {
911972
self.log_detail_index = Some(abs);
@@ -936,6 +997,7 @@ impl App {
936997
self.log_autoscroll = false;
937998
}
938999
KeyCode::Char('G' | 'f') => {
1000+
self.log_selection_anchor = None;
9391001
self.sandbox_log_scroll = self.log_autoscroll_offset();
9401002
self.log_autoscroll = true;
9411003
let visible = filtered_len.saturating_sub(self.sandbox_log_scroll);
@@ -948,13 +1010,16 @@ impl App {
9481010
}
9491011
KeyCode::Char('s') => {
9501012
self.log_source_filter = self.log_source_filter.next();
1013+
self.log_selection_anchor = None;
9511014
self.sandbox_log_scroll = 0;
9521015
self.log_cursor = 0;
9531016
}
9541017
KeyCode::Char('r') => {
1018+
self.log_selection_anchor = None;
9551019
self.focus = Focus::SandboxDraft;
9561020
}
9571021
KeyCode::Char('p') => {
1022+
self.log_selection_anchor = None;
9581023
self.focus = Focus::SandboxPolicy;
9591024
}
9601025
_ => {}
@@ -1533,6 +1598,7 @@ impl App {
15331598
self.log_cursor = 0;
15341599
self.log_autoscroll = true;
15351600
self.log_detail_index = None;
1601+
self.log_selection_anchor = None;
15361602
self.confirm_delete = false;
15371603
self.sandbox_policy = None;
15381604
self.sandbox_providers_list.clear();
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! OSC 52 clipboard support.
5+
//!
6+
//! Writes the OSC 52 escape sequence to stdout, which instructs the terminal
7+
//! emulator to copy text to the system clipboard. Works over SSH, tmux, and
8+
//! mosh — the sequence is forwarded to the local terminal.
9+
10+
use base64::Engine;
11+
use std::io::Write;
12+
13+
/// Copy `text` to the system clipboard via the OSC 52 escape sequence.
14+
///
15+
/// Writes directly to `/dev/tty` so the escape sequence reaches the terminal
16+
/// emulator even while ratatui owns stdout via the alternate screen buffer.
17+
///
18+
/// This is fire-and-forget: if `/dev/tty` cannot be opened or the terminal
19+
/// does not support OSC 52, the operation is silently ignored.
20+
pub fn copy_to_clipboard(text: &str) {
21+
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
22+
// OSC 52 ; c ; <base64> ST — "c" selects the system clipboard.
23+
let seq = format!("\x1b]52;c;{encoded}\x07");
24+
if let Ok(mut tty) = std::fs::OpenOptions::new().write(true).open("/dev/tty") {
25+
let _ = tty.write_all(seq.as_bytes());
26+
let _ = tty.flush();
27+
}
28+
}

crates/openshell-tui/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
mod app;
5+
mod clipboard;
56
mod event;
67
pub mod theme;
78
mod ui;

crates/openshell-tui/src/theme.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ pub struct Theme {
7171

7272
// Log viewer
7373
pub log_cursor: Style,
74+
pub log_selection: Style,
7475

7576
// Animation
7677
pub claw: Style,
@@ -121,6 +122,7 @@ impl Theme {
121122
key_hint: Style::new().fg(brand::NVIDIA_GREEN),
122123

123124
log_cursor: Style::new().bg(brand::EVERGLADE),
125+
log_selection: Style::new().bg(Color::Rgb(30, 60, 45)),
124126

125127
claw: Style::new().fg(brand::MAROON).add_modifier(Modifier::BOLD),
126128

@@ -143,6 +145,7 @@ impl Theme {
143145
let border_color = Color::Rgb(180, 200, 170);
144146
let title_bg = Color::Rgb(220, 235, 210);
145147
let cursor_bg = Color::Rgb(230, 245, 220);
148+
let selection_bg = Color::Rgb(215, 235, 200);
146149

147150
Self {
148151
text: Style::new().fg(fg),
@@ -166,6 +169,7 @@ impl Theme {
166169
key_hint: Style::new().fg(brand::NVIDIA_GREEN_DARK),
167170

168171
log_cursor: Style::new().bg(cursor_bg),
172+
log_selection: Style::new().bg(selection_bg),
169173

170174
claw: Style::new().fg(brand::MAROON).add_modifier(Modifier::BOLD),
171175

@@ -244,6 +248,10 @@ mod tests {
244248
dark.log_cursor, light.log_cursor,
245249
"log_cursor style should differ"
246250
);
251+
assert_ne!(
252+
dark.log_selection, light.log_selection,
253+
"log_selection style should differ"
254+
);
247255
}
248256

249257
#[test]
@@ -277,6 +285,7 @@ mod tests {
277285
assert_eq!(d.status_err, Style::new().fg(Color::Red));
278286
assert_eq!(d.key_hint, Style::new().fg(brand::NVIDIA_GREEN));
279287
assert_eq!(d.log_cursor, Style::new().bg(brand::EVERGLADE));
288+
assert_eq!(d.log_selection, Style::new().bg(Color::Rgb(30, 60, 45)));
280289
assert_eq!(
281290
d.claw,
282291
Style::new().fg(brand::MAROON).add_modifier(Modifier::BOLD)

crates/openshell-tui/src/ui/mod.rs

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -224,43 +224,74 @@ fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
224224
},
225225
Screen::Sandbox => match app.focus {
226226
Focus::SandboxLogs => {
227-
let filter_label = app.log_source_filter.label();
228-
let autoscroll_label = if app.log_autoscroll {
229-
" Autoscroll"
227+
if app.log_selection_anchor.is_some() {
228+
// Visual selection mode — reduced hint set.
229+
vec![
230+
Span::styled(" ", t.text),
231+
Span::styled("[j/k]", t.key_hint),
232+
Span::styled(" Extend", t.text),
233+
Span::styled(" ", t.text),
234+
Span::styled("[y]", t.key_hint),
235+
Span::styled(" Yank", t.text),
236+
Span::styled(" ", t.text),
237+
Span::styled("[g/G]", t.key_hint),
238+
Span::styled(" Top/Bottom", t.text),
239+
Span::styled(" | ", t.border),
240+
Span::styled("[Esc]", t.muted),
241+
Span::styled(" Cancel", t.muted),
242+
Span::styled(" ", t.text),
243+
Span::styled("[q]", t.muted),
244+
Span::styled(" Quit", t.muted),
245+
]
230246
} else {
231-
" Follow"
232-
};
233-
let autoscroll_style = if app.log_autoscroll {
234-
t.status_ok
235-
} else {
236-
t.text
237-
};
238-
vec![
239-
Span::styled(" ", t.text),
240-
Span::styled("[j/k]", t.key_hint),
241-
Span::styled(" Navigate", t.text),
242-
Span::styled(" ", t.text),
243-
Span::styled("[Enter]", t.key_hint),
244-
Span::styled(" Detail", t.text),
245-
Span::styled(" ", t.text),
246-
Span::styled("[g/G]", t.key_hint),
247-
Span::styled(" Top/Bottom", t.text),
248-
Span::styled(" ", t.text),
249-
Span::styled("[f]", t.key_hint),
250-
Span::styled(autoscroll_label, autoscroll_style),
251-
Span::styled(" ", t.text),
252-
Span::styled("[s]", t.key_hint),
253-
Span::styled(format!(" Source: {filter_label}"), t.text),
254-
Span::styled(" ", t.text),
255-
Span::styled("[r]", t.key_hint),
256-
Span::styled(" Rules", t.text),
257-
Span::styled(" | ", t.border),
258-
Span::styled("[Esc]", t.muted),
259-
Span::styled(" Policy", t.muted),
260-
Span::styled(" ", t.text),
261-
Span::styled("[q]", t.muted),
262-
Span::styled(" Quit", t.muted),
263-
]
247+
// Normal log viewer mode.
248+
let filter_label = app.log_source_filter.label();
249+
let autoscroll_label = if app.log_autoscroll {
250+
" Autoscroll"
251+
} else {
252+
" Follow"
253+
};
254+
let autoscroll_style = if app.log_autoscroll {
255+
t.status_ok
256+
} else {
257+
t.text
258+
};
259+
vec![
260+
Span::styled(" ", t.text),
261+
Span::styled("[j/k]", t.key_hint),
262+
Span::styled(" Navigate", t.text),
263+
Span::styled(" ", t.text),
264+
Span::styled("[Enter]", t.key_hint),
265+
Span::styled(" Detail", t.text),
266+
Span::styled(" ", t.text),
267+
Span::styled("[g/G]", t.key_hint),
268+
Span::styled(" Top/Bottom", t.text),
269+
Span::styled(" ", t.text),
270+
Span::styled("[f]", t.key_hint),
271+
Span::styled(autoscroll_label, autoscroll_style),
272+
Span::styled(" ", t.text),
273+
Span::styled("[s]", t.key_hint),
274+
Span::styled(format!(" Source: {filter_label}"), t.text),
275+
Span::styled(" ", t.text),
276+
Span::styled("[y]", t.key_hint),
277+
Span::styled(" Copy", t.text),
278+
Span::styled(" ", t.text),
279+
Span::styled("[Y]", t.key_hint),
280+
Span::styled(" Copy All", t.text),
281+
Span::styled(" ", t.text),
282+
Span::styled("[v]", t.key_hint),
283+
Span::styled(" Select", t.text),
284+
Span::styled(" ", t.text),
285+
Span::styled("[r]", t.key_hint),
286+
Span::styled(" Rules", t.text),
287+
Span::styled(" | ", t.border),
288+
Span::styled("[Esc]", t.muted),
289+
Span::styled(" Policy", t.muted),
290+
Span::styled(" ", t.text),
291+
Span::styled("[q]", t.muted),
292+
Span::styled(" Quit", t.muted),
293+
]
294+
}
264295
}
265296
Focus::SandboxDraft => {
266297
// Build state-aware action hints based on selected chunk.

0 commit comments

Comments
 (0)