Skip to content

Commit 66b4e36

Browse files
authored
feat: Add in-TUI log panel to prevent log messages from breaking layout (#106)
* feat: Add in-TUI log panel to prevent log messages from breaking layout (#104) This commit adds a dedicated log panel within the TUI interface to capture and display tracing events (ERROR, WARN, INFO, DEBUG) without breaking the ratatui alternate screen layout. New files: - src/ui/tui/log_buffer.rs: LogBuffer and LogEntry structs for in-memory storage - src/ui/tui/log_layer.rs: TuiLogLayer implementing tracing_subscriber::Layer - src/ui/tui/views/log_panel.rs: Log panel view component with color-coded output Key features: - Log buffer with configurable max entries (default 1000, BSSH_TUI_LOG_MAX_ENTRIES) - Color-coded log levels (ERROR=red, WARN=yellow, INFO=white, DEBUG=gray) - Toggle log panel visibility with 'l' key - Scroll logs with j/k keys when panel is visible - Resize panel with +/- keys (3-10 lines) - Toggle timestamps with 't' key - Auto-detect new log entries and trigger redraw Modified: - src/ui/tui/app.rs: Added log panel state fields and methods - src/ui/tui/event.rs: Added key bindings for log panel controls - src/ui/tui/mod.rs: Integrated log layer and panel rendering - src/ui/tui/views/*.rs: Added render_in_area() for layout flexibility - src/utils/logging.rs: Extracted filter creation for reuse - tests/tui_snapshot_tests.rs: Updated assertion for new footer layout * fix: Optimize log panel performance and add documentation - Add upper bound validation for BSSH_TUI_LOG_MAX_ENTRIES (max: 10000) - Reduce lock time in log_panel render by cloning entries quickly - Add documentation comments for lock contention optimization - Update README.md with log panel keybindings and configuration - Update ARCHITECTURE.md with log panel architecture details - Update manpage with log panel features and environment variable * fix: Auto-detect TUI mode and use appropriate logging layer - Move TUI logging logic from tui/mod.rs to utils/logging.rs - Auto-detect TUI mode based on TTY and CI environment - Use TuiLogLayer for TUI mode (captures logs to buffer) - Use fmt layer for non-TUI mode (console output) - Remove redundant init_tui_logging function from TUI module - Use global log buffer shared between logging and TUI Fixes issue where ERROR logs were breaking TUI display * feat: Add log panel keybinding hints to Help modal and title bar - Always show Log Panel section in Help modal (not just when visible) - Add inline keybinding hints to log panel title bar (j/k:scroll +/-:resize t:time) - Update Help modal with descriptive text for log panel controls
1 parent b5bb5b9 commit 66b4e36

File tree

17 files changed

+1353
-27
lines changed

17 files changed

+1353
-27
lines changed

ARCHITECTURE.md

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,12 +2046,15 @@ src/ui/tui/
20462046
├── event.rs # Keyboard input handling
20472047
├── progress.rs # Output parsing for progress indicators
20482048
├── terminal_guard.rs # RAII cleanup on exit/panic
2049+
├── log_buffer.rs # In-memory log buffer for TUI mode
2050+
├── log_layer.rs # Custom tracing Layer for TUI log capture
20492051
└── views/ # View implementations
20502052
├── mod.rs # View module exports
20512053
├── summary.rs # Multi-node overview
20522054
├── detail.rs # Single node full output
20532055
├── split.rs # Multi-pane view (2-4 nodes)
2054-
└── diff.rs # Side-by-side comparison
2056+
├── diff.rs # Side-by-side comparison
2057+
└── log_panel.rs # Log panel view component
20552058
```
20562059

20572060
### Core Components
@@ -2108,6 +2111,12 @@ pub enum ViewMode {
21082111
- **Diff view keys**:
21092112
- `↑/↓`: Synchronized scrolling (TODO: implementation pending)
21102113

2114+
- **Log panel keys** (when visible):
2115+
- `l`: Toggle log panel visibility
2116+
- `j/k`: Scroll log entries up/down
2117+
- `+/-`: Increase/decrease panel height (3-10 lines)
2118+
- `t`: Toggle timestamp display
2119+
21112120
**Design Pattern:**
21122121
- Centralized event routing via `handle_key_event()`
21132122
- Mode-specific handlers for clean separation of concerns
@@ -2156,7 +2165,66 @@ TerminalGuard
21562165
- Direct stderr writes as last resort
21572166
- Force terminal reset on panic: `\x1b[0m\x1b[?25h`
21582167

2159-
#### 5. View Implementations (`views/`)
2168+
#### 5. In-TUI Log Panel (Issue #104)
2169+
2170+
**Problem Solved:**
2171+
When ERROR or WARN level logs occur during TUI mode execution, the log messages were previously printed directly to the screen, breaking the ratatui alternate screen layout. The log panel captures these messages in a buffer and displays them in a dedicated panel within the TUI.
2172+
2173+
**Architecture:**
2174+
2175+
```
2176+
┌─────────────────────────────────────────────────┐
2177+
│ tracing subscriber │
2178+
│ │ │
2179+
│ ▼ │
2180+
│ TuiLogLayer (implements Layer trait) │
2181+
│ │ │
2182+
│ ▼ │
2183+
│ Arc<Mutex<LogBuffer>> │
2184+
│ │ │
2185+
│ └────────────► LogPanel (view) │
2186+
│ │ │
2187+
│ ▼ │
2188+
│ TUI Rendering │
2189+
└─────────────────────────────────────────────────┘
2190+
```
2191+
2192+
**Components:**
2193+
2194+
1. **LogBuffer** (`log_buffer.rs`):
2195+
- Thread-safe ring buffer with VecDeque storage
2196+
- FIFO eviction when max capacity reached (default: 1000, max: 10000)
2197+
- Configurable via `BSSH_TUI_LOG_MAX_ENTRIES` environment variable
2198+
- `LogEntry` struct: level, target, message, timestamp
2199+
2200+
2. **TuiLogLayer** (`log_layer.rs`):
2201+
- Implements `tracing_subscriber::Layer` trait
2202+
- Captures tracing events and stores in shared LogBuffer
2203+
- Minimal lock time: message extraction and entry creation outside lock
2204+
- O(1) push operation inside lock to minimize contention
2205+
2206+
3. **LogPanel** (`views/log_panel.rs`):
2207+
- Color-coded log display: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray)
2208+
- Scrollable with configurable height (3-10 lines)
2209+
- Toggle visibility with `l` key
2210+
- Timestamp display toggle with `t` key
2211+
2212+
**Thread Safety:**
2213+
- `Arc<Mutex<LogBuffer>>` shared between tracing layer and TUI thread
2214+
- Lock acquisition optimized for minimal contention:
2215+
- LogLayer: only holds lock during O(1) push
2216+
- LogPanel: clones entries quickly, renders outside lock
2217+
2218+
**State in TuiApp:**
2219+
```rust
2220+
pub log_buffer: Arc<Mutex<LogBuffer>>,
2221+
pub log_panel_visible: bool,
2222+
pub log_panel_height: u16, // 3-10 lines
2223+
pub log_scroll_offset: usize,
2224+
pub log_show_timestamps: bool,
2225+
```
2226+
2227+
#### 6. View Implementations (`views/`)
21602228

21612229
##### Summary View (`summary.rs`)
21622230

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ bssh -C production "apt-get update"
269269
| `Ctrl+C` | Any view | Quit TUI |
270270
| `?` | Any view | Toggle help overlay |
271271
| `Esc` | Any view | Return to summary view (or close help) |
272+
| `l` | Any view | Toggle log panel visibility |
272273
| **Summary View** |||
273274
| `1-9` | Summary | Jump to detail view for node N |
274275
| `s` | Summary | Enter split view (first 2-4 nodes) |
@@ -288,9 +289,24 @@ bssh -C production "apt-get update"
288289
| `1-4` | Split | Focus on specific node (switch to detail view) |
289290
| **Diff View** |||
290291
| `↑/↓` | Diff | Scroll* |
292+
| **Log Panel** (when visible) |||
293+
| `j` | Log panel | Scroll log up |
294+
| `k` | Log panel | Scroll log down |
295+
| `+` | Log panel | Increase log panel height |
296+
| `-` | Log panel | Decrease log panel height |
297+
| `t` | Log panel | Toggle timestamps |
291298

292299
*\*Note: Diff view scroll is planned but not yet implemented.*
293300

301+
**Log Panel:**
302+
303+
The TUI includes an in-app log panel that captures error and warning messages without breaking the alternate screen. This prevents log messages from corrupting the TUI display during execution.
304+
305+
- Toggle visibility with `l` key
306+
- Color-coded by level: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray)
307+
- Configurable buffer size via `BSSH_TUI_LOG_MAX_ENTRIES` environment variable (default: 1000, max: 10000)
308+
- Panel height adjustable from 3-10 lines
309+
294310
**TUI Activation:**
295311
- **Automatic**: Multi-node execution in interactive terminal
296312
- **Disabled when**:

docs/man/bssh.1

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,11 +1313,21 @@ TUI features:
13131313
.br
13141314
- Diff view (press d): Compare outputs side-by-side
13151315
.br
1316+
- Log panel (press l): Toggle in-TUI log display
1317+
.br
13161318
- Keyboard navigation: arrows, PgUp/PgDn, Home/End
13171319
.br
13181320
- Auto-scroll (press f): Toggle automatic scrolling
13191321
.br
13201322
- Help (press ?): Show all keyboard shortcuts
1323+
.br
1324+
Log panel keys (when visible):
1325+
.br
1326+
- j/k: Scroll log entries up/down
1327+
.br
1328+
- +/-: Adjust panel height (3-10 lines)
1329+
.br
1330+
- t: Toggle timestamps
13211331
.RE
13221332

13231333
.TP
@@ -1632,6 +1642,16 @@ Prefer the interactive prompt for security-sensitive operations.
16321642
.br
16331643
Example: BSSH_SUDO_PASSWORD=mypassword bssh -S -C cluster "sudo apt update"
16341644

1645+
.TP
1646+
.B BSSH_TUI_LOG_MAX_ENTRIES
1647+
Maximum number of log entries to keep in the TUI log panel buffer.
1648+
.br
1649+
Default: 1000
1650+
.br
1651+
Maximum: 10000 (prevents memory exhaustion)
1652+
.br
1653+
Example: BSSH_TUI_LOG_MAX_ENTRIES=5000 bssh -C cluster "command"
1654+
16351655
.TP
16361656
.B USER
16371657
Used as default username when not specified

src/ui/tui/app.rs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
//! This module manages the state of the interactive terminal UI, including
1818
//! view modes, scroll positions, and user interaction state.
1919
20+
use super::log_buffer::LogBuffer;
21+
use super::views::log_panel::{
22+
DEFAULT_LOG_PANEL_HEIGHT, MAX_LOG_PANEL_HEIGHT, MIN_LOG_PANEL_HEIGHT,
23+
};
2024
use std::collections::HashMap;
25+
use std::sync::{Arc, Mutex};
2126

2227
/// View mode for the TUI
2328
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -53,11 +58,26 @@ pub struct TuiApp {
5358
pub last_data_sizes: HashMap<usize, (usize, usize)>, // node_id -> (stdout_size, stderr_size)
5459
/// Whether all tasks have been completed
5560
pub all_tasks_completed: bool,
61+
/// Shared log buffer for capturing tracing events
62+
pub log_buffer: Arc<Mutex<LogBuffer>>,
63+
/// Whether the log panel is visible
64+
pub log_panel_visible: bool,
65+
/// Height of the log panel in lines
66+
pub log_panel_height: u16,
67+
/// Scroll offset for the log panel (0 = show most recent)
68+
pub log_scroll_offset: usize,
69+
/// Whether to show timestamps in log entries
70+
pub log_show_timestamps: bool,
5671
}
5772

5873
impl TuiApp {
5974
/// Create a new TUI application in summary view
6075
pub fn new() -> Self {
76+
Self::with_log_buffer(Arc::new(Mutex::new(LogBuffer::default())))
77+
}
78+
79+
/// Create a new TUI application with a shared log buffer
80+
pub fn with_log_buffer(log_buffer: Arc<Mutex<LogBuffer>>) -> Self {
6181
Self {
6282
view_mode: ViewMode::Summary,
6383
scroll_positions: HashMap::new(),
@@ -67,6 +87,11 @@ impl TuiApp {
6787
needs_redraw: true, // Initial draw needed
6888
last_data_sizes: HashMap::new(),
6989
all_tasks_completed: false,
90+
log_buffer,
91+
log_panel_visible: false, // Hidden by default
92+
log_panel_height: DEFAULT_LOG_PANEL_HEIGHT,
93+
log_scroll_offset: 0,
94+
log_show_timestamps: false, // Compact view by default
7095
}
7196
}
7297

@@ -235,12 +260,68 @@ impl TuiApp {
235260
}
236261
}
237262

263+
/// Toggle log panel visibility
264+
pub fn toggle_log_panel(&mut self) {
265+
self.log_panel_visible = !self.log_panel_visible;
266+
self.log_scroll_offset = 0; // Reset scroll when toggling
267+
self.needs_redraw = true;
268+
}
269+
270+
/// Increase log panel height
271+
pub fn increase_log_panel_height(&mut self) {
272+
if self.log_panel_height < MAX_LOG_PANEL_HEIGHT {
273+
self.log_panel_height += 1;
274+
self.needs_redraw = true;
275+
}
276+
}
277+
278+
/// Decrease log panel height
279+
pub fn decrease_log_panel_height(&mut self) {
280+
if self.log_panel_height > MIN_LOG_PANEL_HEIGHT {
281+
self.log_panel_height -= 1;
282+
self.needs_redraw = true;
283+
}
284+
}
285+
286+
/// Scroll log panel up (show older entries)
287+
pub fn scroll_log_up(&mut self, lines: usize) {
288+
if let Ok(buffer) = self.log_buffer.lock() {
289+
let max_offset = buffer.len().saturating_sub(1);
290+
self.log_scroll_offset = (self.log_scroll_offset + lines).min(max_offset);
291+
}
292+
self.needs_redraw = true;
293+
}
294+
295+
/// Scroll log panel down (show newer entries)
296+
pub fn scroll_log_down(&mut self, lines: usize) {
297+
self.log_scroll_offset = self.log_scroll_offset.saturating_sub(lines);
298+
self.needs_redraw = true;
299+
}
300+
301+
/// Toggle timestamp display in log panel
302+
pub fn toggle_log_timestamps(&mut self) {
303+
self.log_show_timestamps = !self.log_show_timestamps;
304+
self.needs_redraw = true;
305+
}
306+
307+
/// Check if there are new log entries and trigger redraw if needed
308+
pub fn check_log_updates(&mut self) -> bool {
309+
if let Ok(mut buffer) = self.log_buffer.lock() {
310+
if buffer.take_has_new_entries() {
311+
self.needs_redraw = true;
312+
return true;
313+
}
314+
}
315+
false
316+
}
317+
238318
/// Get help text for current view mode
239319
pub fn get_help_text(&self) -> Vec<(&'static str, &'static str)> {
240320
let mut help = vec![
241321
("q", "Quit"),
242322
("Esc", "Back to summary"),
243323
("?", "Toggle help"),
324+
("l", "Toggle log panel"),
244325
];
245326

246327
match &self.view_mode {
@@ -269,6 +350,19 @@ impl TuiApp {
269350
}
270351
}
271352

353+
// Always show log panel section in help
354+
help.push(("", "")); // Empty line as separator
355+
help.push(("── Log Panel ──", ""));
356+
if self.log_panel_visible {
357+
help.extend_from_slice(&[
358+
("j/k", "Scroll log up/down"),
359+
("+/-", "Resize panel (3-10 lines)"),
360+
("t", "Toggle timestamps"),
361+
]);
362+
} else {
363+
help.push(("l", "Press to show log panel"));
364+
}
365+
272366
help
273367
}
274368
}
@@ -387,4 +481,85 @@ mod tests {
387481
app.toggle_follow();
388482
assert!(app.follow_mode);
389483
}
484+
485+
#[test]
486+
fn test_log_panel_toggle() {
487+
let mut app = TuiApp::new();
488+
assert!(!app.log_panel_visible);
489+
490+
app.toggle_log_panel();
491+
assert!(app.log_panel_visible);
492+
493+
app.toggle_log_panel();
494+
assert!(!app.log_panel_visible);
495+
}
496+
497+
#[test]
498+
fn test_log_panel_height() {
499+
let mut app = TuiApp::new();
500+
let initial_height = app.log_panel_height;
501+
502+
app.increase_log_panel_height();
503+
assert_eq!(app.log_panel_height, initial_height + 1);
504+
505+
app.decrease_log_panel_height();
506+
assert_eq!(app.log_panel_height, initial_height);
507+
508+
// Test min bound
509+
for _ in 0..20 {
510+
app.decrease_log_panel_height();
511+
}
512+
assert_eq!(app.log_panel_height, MIN_LOG_PANEL_HEIGHT);
513+
514+
// Test max bound
515+
for _ in 0..20 {
516+
app.increase_log_panel_height();
517+
}
518+
assert_eq!(app.log_panel_height, MAX_LOG_PANEL_HEIGHT);
519+
}
520+
521+
#[test]
522+
fn test_log_scroll() {
523+
use super::super::log_buffer::LogEntry;
524+
use tracing::Level;
525+
526+
let buffer = Arc::new(Mutex::new(LogBuffer::new(100)));
527+
528+
// Add some entries
529+
{
530+
let mut b = buffer.lock().unwrap();
531+
for i in 0..10 {
532+
b.push(LogEntry::new(
533+
Level::INFO,
534+
"test".to_string(),
535+
format!("msg {i}"),
536+
));
537+
}
538+
}
539+
540+
let mut app = TuiApp::with_log_buffer(buffer);
541+
542+
assert_eq!(app.log_scroll_offset, 0);
543+
544+
app.scroll_log_up(3);
545+
assert_eq!(app.log_scroll_offset, 3);
546+
547+
app.scroll_log_down(1);
548+
assert_eq!(app.log_scroll_offset, 2);
549+
550+
app.scroll_log_down(10);
551+
assert_eq!(app.log_scroll_offset, 0);
552+
}
553+
554+
#[test]
555+
fn test_log_timestamps_toggle() {
556+
let mut app = TuiApp::new();
557+
assert!(!app.log_show_timestamps);
558+
559+
app.toggle_log_timestamps();
560+
assert!(app.log_show_timestamps);
561+
562+
app.toggle_log_timestamps();
563+
assert!(!app.log_show_timestamps);
564+
}
390565
}

0 commit comments

Comments
 (0)