scud/commands/spawn/tui/components/
streaming_view.rs

1//! Streaming view component for TUI
2//!
3//! Provides a UI widget for displaying streaming agent output with ANSI support,
4//! scrolling, and live updates. Designed for showing terminal output from agents.
5
6use ratatui::{
7    buffer::Buffer,
8    layout::Rect,
9    style::{Style, Stylize},
10    text::{Line, Span},
11    widgets::{
12        Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
13        ScrollbarState, StatefulWidget, Widget, Wrap,
14    },
15};
16
17use super::super::theme::*;
18
19/// Output line with optional styling
20#[derive(Debug, Clone)]
21pub struct OutputLine {
22    /// The text content of the line
23    pub text: String,
24    /// Line type for different styling
25    pub line_type: OutputLineType,
26    /// Timestamp when line was added (for live updates)
27    pub timestamp: Option<u64>,
28}
29
30impl OutputLine {
31    /// Create a new output line
32    pub fn new(text: impl Into<String>) -> Self {
33        Self {
34            text: text.into(),
35            line_type: OutputLineType::Normal,
36            timestamp: None,
37        }
38    }
39
40    /// Set line type
41    pub fn with_type(mut self, line_type: OutputLineType) -> Self {
42        self.line_type = line_type;
43        self
44    }
45
46    /// Set timestamp
47    pub fn with_timestamp(mut self, ts: u64) -> Self {
48        self.timestamp = Some(ts);
49        self
50    }
51}
52
53impl From<String> for OutputLine {
54    fn from(s: String) -> Self {
55        Self::new(s)
56    }
57}
58
59impl From<&str> for OutputLine {
60    fn from(s: &str) -> Self {
61        Self::new(s)
62    }
63}
64
65/// Type of output line for styling
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum OutputLineType {
68    /// Regular output text
69    #[default]
70    Normal,
71    /// Error or warning text
72    Error,
73    /// Success/completion text
74    Success,
75    /// System/info message
76    System,
77    /// User input echo
78    Input,
79    /// Prompt line (e.g., "$ " or ">" prefix)
80    Prompt,
81}
82
83impl OutputLineType {
84    /// Get the color for this line type
85    pub fn color(&self) -> ratatui::style::Color {
86        match self {
87            Self::Normal => TEXT_TERMINAL,
88            Self::Error => ERROR,
89            Self::Success => SUCCESS,
90            Self::System => TEXT_MUTED,
91            Self::Input => ACCENT,
92            Self::Prompt => TEXT_MUTED,
93        }
94    }
95}
96
97/// State for the streaming view widget
98#[derive(Debug, Default)]
99pub struct StreamingViewState {
100    /// Scroll offset (0 = bottom/most recent, higher = scrolled up)
101    pub scroll_offset: usize,
102    /// Whether to auto-scroll on new content
103    pub auto_scroll: bool,
104    /// Total number of lines (for scrollbar calculation)
105    total_lines: usize,
106}
107
108impl StreamingViewState {
109    /// Create new state with auto-scroll enabled
110    pub fn new() -> Self {
111        Self {
112            scroll_offset: 0,
113            auto_scroll: true,
114            total_lines: 0,
115        }
116    }
117
118    /// Scroll up (show older content)
119    pub fn scroll_up(&mut self, lines: usize) {
120        let max_scroll = self.total_lines.saturating_sub(1);
121        self.scroll_offset = (self.scroll_offset + lines).min(max_scroll);
122        self.auto_scroll = false;
123    }
124
125    /// Scroll down (show newer content)
126    pub fn scroll_down(&mut self, lines: usize) {
127        self.scroll_offset = self.scroll_offset.saturating_sub(lines);
128        if self.scroll_offset == 0 {
129            self.auto_scroll = true;
130        }
131    }
132
133    /// Jump to bottom (most recent output)
134    pub fn scroll_to_bottom(&mut self) {
135        self.scroll_offset = 0;
136        self.auto_scroll = true;
137    }
138
139    /// Jump to top (oldest output)
140    pub fn scroll_to_top(&mut self) {
141        self.scroll_offset = self.total_lines.saturating_sub(1);
142        self.auto_scroll = false;
143    }
144
145    /// Update total lines count (call before rendering)
146    pub fn set_total_lines(&mut self, total: usize) {
147        self.total_lines = total;
148        // Clamp scroll offset if content shrunk
149        if self.scroll_offset > 0 && self.scroll_offset >= total {
150            self.scroll_offset = total.saturating_sub(1);
151        }
152    }
153
154    /// Check if scrolled to bottom
155    pub fn is_at_bottom(&self) -> bool {
156        self.scroll_offset == 0
157    }
158}
159
160/// Streaming view widget for TUI
161///
162/// Displays streaming output with scrolling support and ANSI-aware rendering.
163/// Designed for showing live terminal output from agents.
164pub struct StreamingView<'a> {
165    /// Output lines to display
166    lines: &'a [OutputLine],
167    /// Whether this widget is focused
168    focused: bool,
169    /// Title for the view
170    title: Option<String>,
171    /// Show scrollbar
172    show_scrollbar: bool,
173    /// Whether this is fullscreen mode
174    fullscreen: bool,
175}
176
177impl<'a> StreamingView<'a> {
178    /// Create a new streaming view with the given output lines
179    pub fn new(lines: &'a [OutputLine]) -> Self {
180        Self {
181            lines,
182            focused: false,
183            title: None,
184            show_scrollbar: true,
185            fullscreen: false,
186        }
187    }
188
189    /// Create from raw string lines
190    pub fn from_strings(lines: &'a [String]) -> StreamingViewStrings<'a> {
191        StreamingViewStrings {
192            lines,
193            focused: false,
194            title: None,
195            show_scrollbar: true,
196            fullscreen: false,
197        }
198    }
199
200    /// Set focus state
201    pub fn focused(mut self, focused: bool) -> Self {
202        self.focused = focused;
203        self
204    }
205
206    /// Set custom title
207    pub fn title(mut self, title: impl Into<String>) -> Self {
208        self.title = Some(title.into());
209        self
210    }
211
212    /// Set scrollbar visibility
213    pub fn show_scrollbar(mut self, show: bool) -> Self {
214        self.show_scrollbar = show;
215        self
216    }
217
218    /// Set fullscreen mode (changes styling)
219    pub fn fullscreen(mut self, fullscreen: bool) -> Self {
220        self.fullscreen = fullscreen;
221        self
222    }
223}
224
225impl StatefulWidget for StreamingView<'_> {
226    type State = StreamingViewState;
227
228    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
229        // Update state with total lines
230        state.set_total_lines(self.lines.len());
231
232        let border_color = if self.focused {
233            BORDER_ACTIVE
234        } else {
235            BORDER_DEFAULT
236        };
237        let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
238
239        let title = if self.fullscreen {
240            self.title
241                .unwrap_or_else(|| " Output (Esc to exit) ".to_string())
242        } else {
243            self.title.unwrap_or_else(|| " Live Output ".to_string())
244        };
245
246        let block = Block::default()
247            .borders(Borders::ALL)
248            .border_type(BorderType::Rounded)
249            .border_style(Style::default().fg(border_color))
250            .title(Line::from(title).fg(title_color))
251            .style(Style::default().bg(BG_TERMINAL))
252            .padding(Padding::new(1, 0, 0, 0));
253
254        let inner = block.inner(area);
255        Widget::render(block, area, buf);
256
257        let visible_height = inner.height as usize;
258        let total_lines = self.lines.len();
259
260        // Calculate visible window based on scroll offset
261        let end_idx = total_lines.saturating_sub(state.scroll_offset);
262        let start_idx = end_idx.saturating_sub(visible_height);
263
264        // Reserve space for scrollbar
265        let text_width = if self.show_scrollbar && total_lines > visible_height {
266            inner.width.saturating_sub(2)
267        } else {
268            inner.width
269        };
270        let text_area = Rect::new(inner.x, inner.y, text_width, inner.height);
271
272        // Render visible lines
273        let visible_lines: Vec<Line> = self
274            .lines
275            .iter()
276            .skip(start_idx)
277            .take(visible_height)
278            .map(|line| {
279                Line::from(Span::styled(
280                    line.text.as_str(),
281                    Style::default().fg(line.line_type.color()),
282                ))
283            })
284            .collect();
285
286        let paragraph = Paragraph::new(visible_lines);
287        Widget::render(paragraph, text_area, buf);
288
289        // Render scrollbar
290        if self.show_scrollbar && total_lines > visible_height {
291            let scrollbar_area = Rect::new(
292                inner.x + inner.width.saturating_sub(1),
293                inner.y,
294                1,
295                inner.height,
296            );
297
298            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
299                .begin_symbol(None)
300                .end_symbol(None)
301                .track_symbol(Some(" "))
302                .thumb_symbol("▐");
303
304            let mut scrollbar_state = ScrollbarState::new(total_lines).position(start_idx);
305            StatefulWidget::render(scrollbar, scrollbar_area, buf, &mut scrollbar_state);
306        }
307    }
308}
309
310/// String-based streaming view (simpler API for raw string output)
311pub struct StreamingViewStrings<'a> {
312    lines: &'a [String],
313    focused: bool,
314    title: Option<String>,
315    show_scrollbar: bool,
316    fullscreen: bool,
317}
318
319impl<'a> StreamingViewStrings<'a> {
320    /// Set focus state
321    pub fn focused(mut self, focused: bool) -> Self {
322        self.focused = focused;
323        self
324    }
325
326    /// Set custom title
327    pub fn title(mut self, title: impl Into<String>) -> Self {
328        self.title = Some(title.into());
329        self
330    }
331
332    /// Set scrollbar visibility
333    pub fn show_scrollbar(mut self, show: bool) -> Self {
334        self.show_scrollbar = show;
335        self
336    }
337
338    /// Set fullscreen mode
339    pub fn fullscreen(mut self, fullscreen: bool) -> Self {
340        self.fullscreen = fullscreen;
341        self
342    }
343}
344
345impl StatefulWidget for StreamingViewStrings<'_> {
346    type State = StreamingViewState;
347
348    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
349        // Update state with total lines
350        state.set_total_lines(self.lines.len());
351
352        let border_color = if self.focused {
353            BORDER_ACTIVE
354        } else {
355            BORDER_DEFAULT
356        };
357        let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
358
359        let title = if self.fullscreen {
360            self.title
361                .unwrap_or_else(|| " Output (Esc to exit) ".to_string())
362        } else {
363            self.title.unwrap_or_else(|| " Live Output ".to_string())
364        };
365
366        let block = Block::default()
367            .borders(Borders::ALL)
368            .border_type(BorderType::Rounded)
369            .border_style(Style::default().fg(border_color))
370            .title(Line::from(title).fg(title_color))
371            .style(Style::default().bg(BG_TERMINAL))
372            .padding(Padding::new(1, 0, 0, 0));
373
374        let inner = block.inner(area);
375        Widget::render(block, area, buf);
376
377        let visible_height = inner.height as usize;
378        let total_lines = self.lines.len();
379
380        // Calculate visible window
381        let end_idx = total_lines.saturating_sub(state.scroll_offset);
382        let start_idx = end_idx.saturating_sub(visible_height);
383
384        // Reserve space for scrollbar
385        let text_width = if self.show_scrollbar && total_lines > visible_height {
386            inner.width.saturating_sub(2)
387        } else {
388            inner.width
389        };
390        let text_area = Rect::new(inner.x, inner.y, text_width, inner.height);
391
392        // Render visible lines
393        let visible_lines: Vec<Line> = self
394            .lines
395            .iter()
396            .skip(start_idx)
397            .take(visible_height)
398            .map(|line| {
399                Line::from(Span::styled(
400                    line.as_str(),
401                    Style::default().fg(TEXT_TERMINAL),
402                ))
403            })
404            .collect();
405
406        let paragraph = Paragraph::new(visible_lines);
407        Widget::render(paragraph, text_area, buf);
408
409        // Render scrollbar
410        if self.show_scrollbar && total_lines > visible_height {
411            let scrollbar_area = Rect::new(
412                inner.x + inner.width.saturating_sub(1),
413                inner.y,
414                1,
415                inner.height,
416            );
417
418            let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
419                .begin_symbol(None)
420                .end_symbol(None)
421                .track_symbol(Some(" "))
422                .thumb_symbol("▐");
423
424            let mut scrollbar_state = ScrollbarState::new(total_lines).position(start_idx);
425            StatefulWidget::render(scrollbar, scrollbar_area, buf, &mut scrollbar_state);
426        }
427    }
428}
429
430/// Simple output display (no scrolling state needed)
431pub struct OutputDisplay<'a> {
432    text: &'a str,
433    focused: bool,
434    title: Option<String>,
435    wrap: bool,
436}
437
438impl<'a> OutputDisplay<'a> {
439    /// Create a new output display with the given text
440    pub fn new(text: &'a str) -> Self {
441        Self {
442            text,
443            focused: false,
444            title: None,
445            wrap: true,
446        }
447    }
448
449    /// Set focus state
450    pub fn focused(mut self, focused: bool) -> Self {
451        self.focused = focused;
452        self
453    }
454
455    /// Set custom title
456    pub fn title(mut self, title: impl Into<String>) -> Self {
457        self.title = Some(title.into());
458        self
459    }
460
461    /// Set word wrapping
462    pub fn wrap(mut self, wrap: bool) -> Self {
463        self.wrap = wrap;
464        self
465    }
466}
467
468impl Widget for OutputDisplay<'_> {
469    fn render(self, area: Rect, buf: &mut Buffer) {
470        let border_color = if self.focused {
471            BORDER_ACTIVE
472        } else {
473            BORDER_DEFAULT
474        };
475        let title_color = if self.focused { ACCENT } else { TEXT_MUTED };
476
477        let title = self.title.unwrap_or_else(|| " Output ".to_string());
478
479        let block = Block::default()
480            .borders(Borders::ALL)
481            .border_type(BorderType::Rounded)
482            .border_style(Style::default().fg(border_color))
483            .title(Line::from(title).fg(title_color))
484            .style(Style::default().bg(BG_TERMINAL))
485            .padding(Padding::horizontal(1));
486
487        let mut paragraph = Paragraph::new(self.text)
488            .style(Style::default().fg(TEXT_TERMINAL))
489            .block(block);
490
491        if self.wrap {
492            paragraph = paragraph.wrap(Wrap { trim: false });
493        }
494
495        Widget::render(paragraph, area, buf);
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_output_line_creation() {
505        let line = OutputLine::new("Hello world")
506            .with_type(OutputLineType::Success)
507            .with_timestamp(12345);
508
509        assert_eq!(line.text, "Hello world");
510        assert_eq!(line.line_type, OutputLineType::Success);
511        assert_eq!(line.timestamp, Some(12345));
512    }
513
514    #[test]
515    fn test_output_line_from_string() {
516        let line: OutputLine = "Test line".into();
517        assert_eq!(line.text, "Test line");
518        assert_eq!(line.line_type, OutputLineType::Normal);
519    }
520
521    #[test]
522    fn test_streaming_view_state_scrolling() {
523        let mut state = StreamingViewState::new();
524        state.set_total_lines(100);
525
526        // Initially at bottom with auto-scroll
527        assert!(state.is_at_bottom());
528        assert!(state.auto_scroll);
529
530        // Scroll up disables auto-scroll
531        state.scroll_up(10);
532        assert_eq!(state.scroll_offset, 10);
533        assert!(!state.auto_scroll);
534
535        // Scroll down towards bottom
536        state.scroll_down(5);
537        assert_eq!(state.scroll_offset, 5);
538        assert!(!state.auto_scroll);
539
540        // Scroll to bottom re-enables auto-scroll
541        state.scroll_to_bottom();
542        assert!(state.is_at_bottom());
543        assert!(state.auto_scroll);
544    }
545
546    #[test]
547    fn test_scroll_clamping() {
548        let mut state = StreamingViewState::new();
549        state.set_total_lines(50);
550
551        // Scroll past max should clamp
552        state.scroll_up(100);
553        assert_eq!(state.scroll_offset, 49); // max is total - 1
554
555        // Content shrinking should clamp
556        state.set_total_lines(30);
557        assert_eq!(state.scroll_offset, 29);
558    }
559
560    #[test]
561    fn test_line_type_colors() {
562        // Just verify colors don't panic
563        let _ = OutputLineType::Normal.color();
564        let _ = OutputLineType::Error.color();
565        let _ = OutputLineType::Success.color();
566        let _ = OutputLineType::System.color();
567        let _ = OutputLineType::Input.color();
568        let _ = OutputLineType::Prompt.color();
569    }
570}