Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 70 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1974,12 +1974,15 @@ src/ui/tui/
├── event.rs # Keyboard input handling
├── progress.rs # Output parsing for progress indicators
├── terminal_guard.rs # RAII cleanup on exit/panic
├── log_buffer.rs # In-memory log buffer for TUI mode
├── log_layer.rs # Custom tracing Layer for TUI log capture
└── views/ # View implementations
├── mod.rs # View module exports
├── summary.rs # Multi-node overview
├── detail.rs # Single node full output
├── split.rs # Multi-pane view (2-4 nodes)
└── diff.rs # Side-by-side comparison
├── diff.rs # Side-by-side comparison
└── log_panel.rs # Log panel view component
```

### Core Components
Expand Down Expand Up @@ -2036,6 +2039,12 @@ pub enum ViewMode {
- **Diff view keys**:
- `↑/↓`: Synchronized scrolling (TODO: implementation pending)

- **Log panel keys** (when visible):
- `l`: Toggle log panel visibility
- `j/k`: Scroll log entries up/down
- `+/-`: Increase/decrease panel height (3-10 lines)
- `t`: Toggle timestamp display

**Design Pattern:**
- Centralized event routing via `handle_key_event()`
- Mode-specific handlers for clean separation of concerns
Expand Down Expand Up @@ -2084,7 +2093,66 @@ TerminalGuard
- Direct stderr writes as last resort
- Force terminal reset on panic: `\x1b[0m\x1b[?25h`

#### 5. View Implementations (`views/`)
#### 5. In-TUI Log Panel (Issue #104)

**Problem Solved:**
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.

**Architecture:**

```
┌─────────────────────────────────────────────────┐
│ tracing subscriber │
│ │ │
│ ▼ │
│ TuiLogLayer (implements Layer trait) │
│ │ │
│ ▼ │
│ Arc<Mutex<LogBuffer>> │
│ │ │
│ └────────────► LogPanel (view) │
│ │ │
│ ▼ │
│ TUI Rendering │
└─────────────────────────────────────────────────┘
```

**Components:**

1. **LogBuffer** (`log_buffer.rs`):
- Thread-safe ring buffer with VecDeque storage
- FIFO eviction when max capacity reached (default: 1000, max: 10000)
- Configurable via `BSSH_TUI_LOG_MAX_ENTRIES` environment variable
- `LogEntry` struct: level, target, message, timestamp

2. **TuiLogLayer** (`log_layer.rs`):
- Implements `tracing_subscriber::Layer` trait
- Captures tracing events and stores in shared LogBuffer
- Minimal lock time: message extraction and entry creation outside lock
- O(1) push operation inside lock to minimize contention

3. **LogPanel** (`views/log_panel.rs`):
- Color-coded log display: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray)
- Scrollable with configurable height (3-10 lines)
- Toggle visibility with `l` key
- Timestamp display toggle with `t` key

**Thread Safety:**
- `Arc<Mutex<LogBuffer>>` shared between tracing layer and TUI thread
- Lock acquisition optimized for minimal contention:
- LogLayer: only holds lock during O(1) push
- LogPanel: clones entries quickly, renders outside lock

**State in TuiApp:**
```rust
pub log_buffer: Arc<Mutex<LogBuffer>>,
pub log_panel_visible: bool,
pub log_panel_height: u16, // 3-10 lines
pub log_scroll_offset: usize,
pub log_show_timestamps: bool,
```

#### 6. View Implementations (`views/`)

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

Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ bssh -C production "apt-get update"
| `Ctrl+C` | Any view | Quit TUI |
| `?` | Any view | Toggle help overlay |
| `Esc` | Any view | Return to summary view (or close help) |
| `l` | Any view | Toggle log panel visibility |
| **Summary View** |||
| `1-9` | Summary | Jump to detail view for node N |
| `s` | Summary | Enter split view (first 2-4 nodes) |
Expand All @@ -288,9 +289,24 @@ bssh -C production "apt-get update"
| `1-4` | Split | Focus on specific node (switch to detail view) |
| **Diff View** |||
| `↑/↓` | Diff | Scroll* |
| **Log Panel** (when visible) |||
| `j` | Log panel | Scroll log up |
| `k` | Log panel | Scroll log down |
| `+` | Log panel | Increase log panel height |
| `-` | Log panel | Decrease log panel height |
| `t` | Log panel | Toggle timestamps |

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

**Log Panel:**

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.

- Toggle visibility with `l` key
- Color-coded by level: ERROR (red), WARN (yellow), INFO (white), DEBUG (gray)
- Configurable buffer size via `BSSH_TUI_LOG_MAX_ENTRIES` environment variable (default: 1000, max: 10000)
- Panel height adjustable from 3-10 lines

**TUI Activation:**
- **Automatic**: Multi-node execution in interactive terminal
- **Disabled when**:
Expand Down
20 changes: 20 additions & 0 deletions docs/man/bssh.1
Original file line number Diff line number Diff line change
Expand Up @@ -1237,11 +1237,21 @@ TUI features:
.br
- Diff view (press d): Compare outputs side-by-side
.br
- Log panel (press l): Toggle in-TUI log display
.br
- Keyboard navigation: arrows, PgUp/PgDn, Home/End
.br
- Auto-scroll (press f): Toggle automatic scrolling
.br
- Help (press ?): Show all keyboard shortcuts
.br
Log panel keys (when visible):
.br
- j/k: Scroll log entries up/down
.br
- +/-: Adjust panel height (3-10 lines)
.br
- t: Toggle timestamps
.RE

.TP
Expand Down Expand Up @@ -1556,6 +1566,16 @@ Prefer the interactive prompt for security-sensitive operations.
.br
Example: BSSH_SUDO_PASSWORD=mypassword bssh -S -C cluster "sudo apt update"

.TP
.B BSSH_TUI_LOG_MAX_ENTRIES
Maximum number of log entries to keep in the TUI log panel buffer.
.br
Default: 1000
.br
Maximum: 10000 (prevents memory exhaustion)
.br
Example: BSSH_TUI_LOG_MAX_ENTRIES=5000 bssh -C cluster "command"

.TP
.B USER
Used as default username when not specified
Expand Down
175 changes: 175 additions & 0 deletions src/ui/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@
//! This module manages the state of the interactive terminal UI, including
//! view modes, scroll positions, and user interaction state.

use super::log_buffer::LogBuffer;
use super::views::log_panel::{
DEFAULT_LOG_PANEL_HEIGHT, MAX_LOG_PANEL_HEIGHT, MIN_LOG_PANEL_HEIGHT,
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

/// View mode for the TUI
#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -53,11 +58,26 @@ pub struct TuiApp {
pub last_data_sizes: HashMap<usize, (usize, usize)>, // node_id -> (stdout_size, stderr_size)
/// Whether all tasks have been completed
pub all_tasks_completed: bool,
/// Shared log buffer for capturing tracing events
pub log_buffer: Arc<Mutex<LogBuffer>>,
/// Whether the log panel is visible
pub log_panel_visible: bool,
/// Height of the log panel in lines
pub log_panel_height: u16,
/// Scroll offset for the log panel (0 = show most recent)
pub log_scroll_offset: usize,
/// Whether to show timestamps in log entries
pub log_show_timestamps: bool,
}

impl TuiApp {
/// Create a new TUI application in summary view
pub fn new() -> Self {
Self::with_log_buffer(Arc::new(Mutex::new(LogBuffer::default())))
}

/// Create a new TUI application with a shared log buffer
pub fn with_log_buffer(log_buffer: Arc<Mutex<LogBuffer>>) -> Self {
Self {
view_mode: ViewMode::Summary,
scroll_positions: HashMap::new(),
Expand All @@ -67,6 +87,11 @@ impl TuiApp {
needs_redraw: true, // Initial draw needed
last_data_sizes: HashMap::new(),
all_tasks_completed: false,
log_buffer,
log_panel_visible: false, // Hidden by default
log_panel_height: DEFAULT_LOG_PANEL_HEIGHT,
log_scroll_offset: 0,
log_show_timestamps: false, // Compact view by default
}
}

Expand Down Expand Up @@ -235,12 +260,68 @@ impl TuiApp {
}
}

/// Toggle log panel visibility
pub fn toggle_log_panel(&mut self) {
self.log_panel_visible = !self.log_panel_visible;
self.log_scroll_offset = 0; // Reset scroll when toggling
self.needs_redraw = true;
}

/// Increase log panel height
pub fn increase_log_panel_height(&mut self) {
if self.log_panel_height < MAX_LOG_PANEL_HEIGHT {
self.log_panel_height += 1;
self.needs_redraw = true;
}
}

/// Decrease log panel height
pub fn decrease_log_panel_height(&mut self) {
if self.log_panel_height > MIN_LOG_PANEL_HEIGHT {
self.log_panel_height -= 1;
self.needs_redraw = true;
}
}

/// Scroll log panel up (show older entries)
pub fn scroll_log_up(&mut self, lines: usize) {
if let Ok(buffer) = self.log_buffer.lock() {
let max_offset = buffer.len().saturating_sub(1);
self.log_scroll_offset = (self.log_scroll_offset + lines).min(max_offset);
}
self.needs_redraw = true;
}

/// Scroll log panel down (show newer entries)
pub fn scroll_log_down(&mut self, lines: usize) {
self.log_scroll_offset = self.log_scroll_offset.saturating_sub(lines);
self.needs_redraw = true;
}

/// Toggle timestamp display in log panel
pub fn toggle_log_timestamps(&mut self) {
self.log_show_timestamps = !self.log_show_timestamps;
self.needs_redraw = true;
}

/// Check if there are new log entries and trigger redraw if needed
pub fn check_log_updates(&mut self) -> bool {
if let Ok(mut buffer) = self.log_buffer.lock() {
if buffer.take_has_new_entries() {
self.needs_redraw = true;
return true;
}
}
false
}

/// Get help text for current view mode
pub fn get_help_text(&self) -> Vec<(&'static str, &'static str)> {
let mut help = vec![
("q", "Quit"),
("Esc", "Back to summary"),
("?", "Toggle help"),
("l", "Toggle log panel"),
];

match &self.view_mode {
Expand Down Expand Up @@ -269,6 +350,19 @@ impl TuiApp {
}
}

// Always show log panel section in help
help.push(("", "")); // Empty line as separator
help.push(("── Log Panel ──", ""));
if self.log_panel_visible {
help.extend_from_slice(&[
("j/k", "Scroll log up/down"),
("+/-", "Resize panel (3-10 lines)"),
("t", "Toggle timestamps"),
]);
} else {
help.push(("l", "Press to show log panel"));
}

help
}
}
Expand Down Expand Up @@ -387,4 +481,85 @@ mod tests {
app.toggle_follow();
assert!(app.follow_mode);
}

#[test]
fn test_log_panel_toggle() {
let mut app = TuiApp::new();
assert!(!app.log_panel_visible);

app.toggle_log_panel();
assert!(app.log_panel_visible);

app.toggle_log_panel();
assert!(!app.log_panel_visible);
}

#[test]
fn test_log_panel_height() {
let mut app = TuiApp::new();
let initial_height = app.log_panel_height;

app.increase_log_panel_height();
assert_eq!(app.log_panel_height, initial_height + 1);

app.decrease_log_panel_height();
assert_eq!(app.log_panel_height, initial_height);

// Test min bound
for _ in 0..20 {
app.decrease_log_panel_height();
}
assert_eq!(app.log_panel_height, MIN_LOG_PANEL_HEIGHT);

// Test max bound
for _ in 0..20 {
app.increase_log_panel_height();
}
assert_eq!(app.log_panel_height, MAX_LOG_PANEL_HEIGHT);
}

#[test]
fn test_log_scroll() {
use super::super::log_buffer::LogEntry;
use tracing::Level;

let buffer = Arc::new(Mutex::new(LogBuffer::new(100)));

// Add some entries
{
let mut b = buffer.lock().unwrap();
for i in 0..10 {
b.push(LogEntry::new(
Level::INFO,
"test".to_string(),
format!("msg {i}"),
));
}
}

let mut app = TuiApp::with_log_buffer(buffer);

assert_eq!(app.log_scroll_offset, 0);

app.scroll_log_up(3);
assert_eq!(app.log_scroll_offset, 3);

app.scroll_log_down(1);
assert_eq!(app.log_scroll_offset, 2);

app.scroll_log_down(10);
assert_eq!(app.log_scroll_offset, 0);
}

#[test]
fn test_log_timestamps_toggle() {
let mut app = TuiApp::new();
assert!(!app.log_show_timestamps);

app.toggle_log_timestamps();
assert!(app.log_show_timestamps);

app.toggle_log_timestamps();
assert!(!app.log_show_timestamps);
}
}
Loading
Loading