1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Rich TUI for task execution with tree navigation and filtered output display
use super::state::{OutputMode, TaskInfo, TaskStatus, TuiState};
use super::widgets::{OutputPanelWidget, TaskTreeWidget};
use crossterm::{
event::{self, Event as CrosstermEvent, KeyCode, KeyModifiers},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use cuenv_events::{CuenvEvent, EventCategory, EventReceiver, TaskEvent};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::io::{self, Stdout};
use std::time::Duration;
use tokio::sync::oneshot;
/// RAII guard that restores terminal state on drop.
///
/// This guard ensures the terminal is properly restored even if the TUI
/// exits unexpectedly (e.g., due to a panic). Errors during cleanup are
/// logged but cannot be propagated since Drop cannot return errors.
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
// Attempt to restore terminal state. Log errors since Drop can't propagate them.
// Users may need to run `reset` if these fail.
if let Err(e) = disable_raw_mode() {
tracing::warn!(error = %e, "Failed to disable raw mode");
}
if let Err(e) = execute!(io::stdout(), LeaveAlternateScreen) {
tracing::warn!(error = %e, "Failed to leave alternate screen");
}
}
}
/// Rich TUI manager for task execution
pub struct RichTui {
terminal: Terminal<CrosstermBackend<Stdout>>,
state: TuiState,
_guard: TerminalGuard,
event_rx: EventReceiver,
quit_requested: bool,
can_quit: bool,
received_completion_event: bool,
/// Oneshot channel to signal when the TUI event loop is ready.
/// This prevents a race condition where task execution starts
/// before the TUI is ready to receive events.
ready_tx: Option<oneshot::Sender<()>>,
}
impl RichTui {
/// Create a new rich TUI.
///
/// # Arguments
/// * `event_rx` - Receiver for cuenv events
/// * `ready_tx` - Oneshot sender to signal when the TUI event loop is ready.
/// Task execution should wait for this signal before starting.
///
/// # Errors
///
/// Returns an error if terminal initialization fails.
pub fn new(event_rx: EventReceiver, ready_tx: oneshot::Sender<()>) -> io::Result<Self> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
state: TuiState::new(),
_guard: TerminalGuard,
event_rx,
quit_requested: false,
can_quit: false,
received_completion_event: false,
ready_tx: Some(ready_tx),
})
}
/// Initialize task graph from task information
pub fn init_tasks(&mut self, tasks: Vec<TaskInfo>) {
for task in tasks {
self.state.add_task(task);
}
// Initialize the tree view with root tasks expanded
self.state.init_tree();
}
/// Run the TUI event loop.
///
/// # Errors
///
/// Returns an error if terminal operations fail.
pub fn run(&mut self) -> io::Result<()> {
// Signal that the TUI event loop is ready to receive events.
// This must happen before the first poll to prevent a race condition
// where task execution starts before we're listening for events.
if let Some(ready_tx) = self.ready_tx.take() {
// Ignore send error - receiver may have been dropped if task setup failed
let _ = ready_tx.send(());
}
loop {
// Render the UI
self.render()?;
// Check for quit conditions
if self.quit_requested && self.can_quit {
break;
}
// Handle events (non-blocking)
if !self.handle_events()? {
break;
}
}
// Log diagnostic if we're exiting without having received a completion event.
// This can happen if: the user force-quit (Ctrl+C), events were dropped,
// or there's a bug in event delivery.
if !self.received_completion_event {
tracing::debug!(
"TUI exited without receiving completion event (user may have quit early)"
);
}
Ok(())
}
/// Handle events (keyboard and cuenv events)
fn handle_events(&mut self) -> io::Result<bool> {
// Non-blocking poll for keyboard events
if event::poll(Duration::from_millis(50))?
&& let CrosstermEvent::Key(key) = event::read()?
{
match key.code {
// Quit handling
KeyCode::Char('q') => {
if self.state.is_complete {
return Ok(false); // Exit immediately if complete
}
self.quit_requested = true;
self.can_quit = self.state.is_complete;
}
KeyCode::Esc => {
// If in selected mode, return to all mode first
if self.state.output_mode == OutputMode::Selected {
self.state.show_all_output();
} else if self.state.is_complete {
return Ok(false);
} else {
self.quit_requested = true;
self.can_quit = self.state.is_complete;
}
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
// Force quit on Ctrl+C
return Ok(false);
}
// Tree navigation
KeyCode::Up | KeyCode::Char('k') => {
self.state.cursor_up();
}
KeyCode::Down | KeyCode::Char('j') => {
self.state.cursor_down();
}
// Expand/collapse tree nodes
KeyCode::Left | KeyCode::Char('h') => {
// Collapse current node
if let Some(node) = self.state.highlighted_node() {
let node_key = node.node_key();
if node.has_children && self.state.expanded_nodes.contains(&node_key) {
self.state.toggle_expansion(&node_key);
}
}
}
KeyCode::Right | KeyCode::Char('l') => {
// Expand current node
if let Some(node) = self.state.highlighted_node() {
let node_key = node.node_key();
if node.has_children && !self.state.expanded_nodes.contains(&node_key) {
self.state.toggle_expansion(&node_key);
}
}
}
// Select node for filtered output
KeyCode::Enter => {
self.state.select_current_node();
}
// Return to "All" output mode
KeyCode::Char('a') => {
self.state.show_all_output();
}
// Output scrolling (when in selected mode)
KeyCode::PageUp => {
if self.state.output_mode == OutputMode::Selected {
self.state.output_scroll = self.state.output_scroll.saturating_sub(10);
}
}
KeyCode::PageDown => {
if self.state.output_mode == OutputMode::Selected {
self.state.output_scroll += 10;
}
}
_ => {}
}
}
// Non-blocking drain of ALL available cuenv events
// Important: Use `while let` to process ALL pending events, not just one.
// Events can accumulate between render cycles (50ms), especially when
// multiple tasks emit events in quick succession.
while let Some(event) = self.event_rx.try_recv() {
self.handle_cuenv_event(event);
}
// Note: We don't need to detect channel closure separately since
// we emit a completion event before the channel closes (in task.rs)
Ok(true)
}
/// Handle a cuenv event
fn handle_cuenv_event(&mut self, event: CuenvEvent) {
match event.category {
EventCategory::Task(task_event) => self.handle_task_event(task_event),
EventCategory::Command(cmd_event) => {
use cuenv_events::CommandEvent;
match cmd_event {
CommandEvent::Completed {
success, command, ..
} => {
self.received_completion_event = true;
let error_msg = if success {
None
} else {
// Provide context that task execution failed
// Detailed error info is shown in task output panes
Some(format!(
"Command '{command}' failed - see task output for details"
))
};
self.state.complete(success, error_msg);
self.can_quit = true;
}
// Other command events are not displayed in TUI
CommandEvent::Started { .. } | CommandEvent::Progress { .. } => {}
}
}
// These event categories are not relevant for the TUI display
EventCategory::Service(_)
| EventCategory::Ci(_)
| EventCategory::Interactive(_)
| EventCategory::System(_)
| EventCategory::Output(_) => {}
}
}
/// Handle a task event
fn handle_task_event(&mut self, event: TaskEvent) {
match event {
TaskEvent::Started { name, .. } => {
self.state.update_task_status(&name, TaskStatus::Running);
}
TaskEvent::CacheHit { name, .. } => {
self.state.update_task_status(&name, TaskStatus::Cached);
}
TaskEvent::Output {
name,
stream,
content,
} => {
let stream_str = match stream {
cuenv_events::Stream::Stdout => "stdout",
cuenv_events::Stream::Stderr => "stderr",
};
self.state.add_task_output(&name, stream_str, content);
}
TaskEvent::Completed {
name,
success,
exit_code,
..
} => {
let status = if success {
TaskStatus::Completed
} else {
TaskStatus::Failed
};
self.state.update_task_status(&name, status);
// Update exit code
if let Some(task) = self.state.tasks.get_mut(&name) {
task.exit_code = exit_code;
}
}
// These events don't require status updates in the TUI
TaskEvent::CacheMiss { .. }
| TaskEvent::GroupStarted { .. }
| TaskEvent::GroupCompleted { .. } => {}
}
}
/// Render the TUI
fn render(&mut self) -> io::Result<()> {
// Extract references to avoid borrow checker issues in the closure
let state = &self.state;
let quit_requested = self.quit_requested;
self.terminal.draw(|f| {
let size = f.area();
// Create 3-panel layout: Header, Main Content, Status Bar
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header (elapsed time)
Constraint::Min(10), // Main content area
Constraint::Length(3), // Status Bar
])
.split(size);
// Render header
Self::render_header_static(state, f, main_chunks[0]);
// Create 2-panel horizontal split for main content
let content_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30), // Task tree (left)
Constraint::Percentage(70), // Output panel (right)
])
.split(main_chunks[1]);
// Render task tree widget (left panel)
let tree_widget = TaskTreeWidget::new(state);
f.render_widget(tree_widget, content_chunks[0]);
// Render output panel widget (right panel)
let output_widget = OutputPanelWidget::new(state);
f.render_widget(output_widget, content_chunks[1]);
// Render status bar
Self::render_status_bar_static(state, quit_requested, f, main_chunks[2]);
})?;
Ok(())
}
/// Render header with elapsed time (static version for use in closures)
fn render_header_static(state: &TuiState, f: &mut ratatui::Frame, area: Rect) {
let elapsed_ms = state.elapsed_ms();
let elapsed_secs = elapsed_ms / 1000;
let mins = elapsed_secs / 60;
let secs = elapsed_secs % 60;
// Determine title prefix and color based on completion state
let (title_prefix, color) = match (state.is_complete, state.success) {
(true, true) => ("Task Execution Complete", Color::Green),
(true, false) => ("Task Execution Failed", Color::Red),
(false, _) => ("Task Execution", Color::Cyan),
};
let title = format!(" {title_prefix} ({mins}:{secs:02}) ");
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(color));
let inner = block.inner(area);
f.render_widget(block, area);
// Show task counts
let total = state.tasks.len();
let completed = state
.tasks
.values()
.filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Cached))
.count();
let failed = state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Failed)
.count();
let running = state.running_tasks.len();
let info = format!(
"Total: {total} | Running: {running} | Completed: {completed} | Failed: {failed}"
);
let paragraph = Paragraph::new(vec![Line::from(vec![Span::raw(info)])]);
f.render_widget(paragraph, inner);
}
/// Render status bar (static version for use in closures)
fn render_status_bar_static(
state: &TuiState,
quit_requested: bool,
f: &mut ratatui::Frame,
area: Rect,
) {
let help_text = if state.is_complete {
"Press 'q' to quit"
} else if quit_requested {
"Waiting for tasks... (Ctrl+C to force)"
} else if state.output_mode == OutputMode::Selected {
"Esc/a: All | ↑↓/jk: Navigate | PgUp/PgDn: Scroll | q: Quit"
} else {
"↑↓/jk: Navigate | ←→/hl: Collapse/Expand | Enter: Select | a: All | q: Quit"
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray));
let inner = block.inner(area);
f.render_widget(block, area);
let paragraph = Paragraph::new(vec![Line::from(vec![Span::styled(
help_text,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
)])]);
f.render_widget(paragraph, inner);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_guard_drop() {
// Just verify TerminalGuard can be created and dropped
let _guard = TerminalGuard;
}
}