Skip to main content

netget/tui/render/
stream.rs

1//! The stream: activity and conversation in one pane, newest at the bottom.
2//!
3//! Machine events (instances, peers, requests, questions parked for you, log
4//! lines) and the conversation (what you typed, what the model reasoned and
5//! answered, command output) are one timeline. Conversation entries wrap;
6//! event lines are one row each and truncate, since Enter opens what they
7//! point at.
8
9use ratatui::layout::Rect;
10use ratatui::style::Style;
11use ratatui::text::{Line, Span};
12use ratatui::widgets::Paragraph;
13use ratatui::Frame;
14
15use crate::tui::activity::{ActivityEntry, ActivityFeed, ActivityKind};
16use crate::tui::app::{DashboardApp, Focus, UiKey};
17use crate::tui::chat::{EntryKind, ScrollPos};
18use crate::tui::hit::HitTarget;
19use crate::tui::rail::fit;
20
21use super::pane_block;
22
23/// The entries the pane shows, under the current filters.
24pub fn visible_entries(app: &DashboardApp) -> Vec<&ActivityEntry> {
25    let level = app.core.log_level;
26    let only = if app.activity.only_selected {
27        app.cursor_key()
28    } else {
29        None
30    };
31    app.activity
32        .entries
33        .iter()
34        .filter(|e| ActivityFeed::passes(e, level, only))
35        .collect()
36}
37
38pub fn draw(frame: &mut Frame, app: &mut DashboardApp, area: Rect) {
39    if area.height < 3 {
40        return;
41    }
42    let focused = app.focus == Focus::Stream;
43    let mut title = vec![Span::styled(" ACTIVITY & CHAT ", app.styles.title)];
44    if app.activity.only_selected {
45        if let Some(key) = app.cursor_key() {
46            if let Some(instance) = app.instance(key) {
47                title.push(Span::styled(
48                    format!("· only {} ", instance.tag()),
49                    app.styles.warning,
50                ));
51            }
52        }
53    }
54    title.push(Span::styled(
55        format!("· log:{} ", app.core.log_level.as_str().to_lowercase()),
56        app.styles.dimmed,
57    ));
58    let hint = if focused {
59        " ↑↓ lines · Enter open · f filter · Esc "
60    } else {
61        " PageUp scrolls "
62    };
63    let block = pane_block(app, focused)
64        .title(Line::from(title))
65        .title_bottom(Span::styled(hint, app.styles.dimmed));
66    let mut inner = block.inner(area);
67    frame.render_widget(block, area);
68    app.hits.push(inner, HitTarget::Stream);
69    // Unscoped work (such as a global scheduled task) has no connection card.
70    // Keep its current status above the input even while the timeline is scrolled.
71    let background: Vec<_> = app
72        .snapshot
73        .llm_activity
74        .iter()
75        .filter(|work| {
76            crate::tui::llm_activity::owner(work).is_none()
77                && !matches!(
78                    work.source,
79                    crate::state::app_state::ConversationSource::User
80                )
81        })
82        .collect();
83    if let Some(work) = background.first().filter(|_| inner.height > 0) {
84        let extra = if background.len() > 1 {
85            format!(" (+{} more)", background.len() - 1)
86        } else {
87            String::new()
88        };
89        let text = format!(
90            "{} {} {}{extra}",
91            app.spinner(),
92            work.source.display_label(),
93            crate::tui::llm_activity::label(work)
94        );
95        frame.render_widget(
96            Paragraph::new(fit(&text, inner.width as usize)).style(app.styles.reasoning),
97            Rect {
98                y: inner.y + inner.height - 1,
99                height: 1,
100                ..inner
101            },
102        );
103        inner.height -= 1;
104    }
105    if inner.height == 0 {
106        return;
107    }
108
109    let entries = visible_entries(app);
110    if entries.is_empty() {
111        let hint = if app.activity.entries.is_empty() {
112            "Nothing yet. Instances starting, peers connecting, every request and its \
113             answer, anything waiting on you, and the conversation all land here."
114        } else {
115            "(nothing matches the current filter)"
116        };
117        frame.render_widget(
118            Paragraph::new(hint)
119                .style(app.styles.dimmed)
120                .wrap(ratatui::widgets::Wrap { trim: true }),
121            Rect {
122                x: inner.x + 1,
123                y: inner.y + 1.min(inner.height.saturating_sub(1)),
124                width: inner.width.saturating_sub(2),
125                height: inner.height.saturating_sub(1).max(1),
126            },
127        );
128        return;
129    }
130
131    // Every entry becomes one or more rows; the scroll is in rows.
132    let width = inner.width as usize;
133    let cursor = if focused {
134        app.activity
135            .cursor
136            .and_then(|seq| entries.iter().position(|entry| entry.seq >= seq))
137    } else {
138        None
139    };
140    let mut rows: Vec<(usize, Line)> = Vec::new();
141    for (index, entry) in entries.iter().enumerate() {
142        let is_cursor = cursor == Some(index);
143        for line in entry_lines(app, entry, width, is_cursor) {
144            rows.push((index, line));
145        }
146    }
147    let total = rows.len();
148    let viewport = inner.height as usize;
149    let max_offset = total.saturating_sub(viewport);
150    let mut offset = match app.activity.scroll {
151        ScrollPos::Follow => max_offset,
152        ScrollPos::Up(up) => max_offset.saturating_sub(up),
153    };
154    if app.activity.scroll != ScrollPos::Follow {
155        if let Some((seq, line)) = app.activity.scroll_anchor {
156            if let Some(first) = rows
157                .iter()
158                .position(|(index, _)| entries[*index].seq >= seq)
159            {
160                let same = entries[rows[first].0].seq == seq;
161                let count = rows[first..]
162                    .iter()
163                    .take_while(|(index, _)| *index == rows[first].0)
164                    .count();
165                offset = (first
166                    + if same {
167                        line.min(count.saturating_sub(1))
168                    } else {
169                        0
170                    })
171                .min(max_offset);
172            }
173        }
174    }
175    if let Some(cursor) = cursor {
176        let first = rows.iter().position(|(i, _)| *i == cursor).unwrap_or(0);
177        let last = rows
178            .iter()
179            .rposition(|(i, _)| *i == cursor)
180            .unwrap_or(first);
181        if first < offset {
182            offset = first;
183        } else if last >= offset + viewport {
184            offset = last + 1 - viewport;
185        }
186    }
187
188    let anchor = rows.get(offset).map(|(index, _)| {
189        let first = rows
190            .iter()
191            .position(|(other, _)| other == index)
192            .unwrap_or(offset);
193        (entries[*index].seq, offset - first)
194    });
195    let selected_seq = cursor.map(|index| entries[index].seq);
196    let shown = total.saturating_sub(offset).min(viewport);
197    let pad = viewport - shown;
198    let mut lines: Vec<Line> = Vec::with_capacity(viewport);
199    for _ in 0..pad {
200        lines.push(Line::from(""));
201    }
202    let mut hits: Vec<(Rect, HitTarget)> = Vec::new();
203    for (screen_index, (entry_index, line)) in
204        rows.into_iter().skip(offset).take(viewport).enumerate()
205    {
206        lines.push(line);
207        hits.push((
208            Rect {
209                x: inner.x,
210                y: inner.y + (pad + screen_index) as u16,
211                width: inner.width,
212                height: 1,
213            },
214            HitTarget::StreamRow(entries[entry_index].seq),
215        ));
216    }
217    drop(entries);
218    if focused {
219        app.activity.cursor = selected_seq;
220    }
221    if app.activity.scroll != ScrollPos::Follow {
222        app.activity.scroll_anchor = anchor;
223        app.activity.scroll = ScrollPos::Up(max_offset.saturating_sub(offset));
224    }
225    for (rect, target) in hits {
226        app.hits.push(rect, target);
227    }
228    frame.render_widget(Paragraph::new(lines), inner);
229
230    if app.activity.unseen > 0 {
231        let label = format!(" {} new ↓ ", app.activity.unseen);
232        let label_width = (label.chars().count() as u16).min(inner.width);
233        frame.render_widget(
234            Paragraph::new(label).style(app.styles.selected),
235            Rect {
236                x: inner.x + inner.width.saturating_sub(label_width),
237                y: inner.y + inner.height.saturating_sub(1),
238                width: label_width,
239                height: 1,
240            },
241        );
242    }
243}
244
245fn kind_glyph_and_style(app: &DashboardApp, kind: ActivityKind) -> (&'static str, Style) {
246    use crate::ui::app::LogLevel;
247    match kind {
248        ActivityKind::Log(LogLevel::Error) => ("✗ ", app.styles.error),
249        ActivityKind::Log(LogLevel::Warn) => ("⚠ ", app.styles.warning),
250        ActivityKind::Log(LogLevel::Info) => ("· ", app.styles.info),
251        ActivityKind::Log(LogLevel::Debug) => ("○ ", app.styles.debug),
252        ActivityKind::Log(LogLevel::Trace) => ("· ", app.styles.trace),
253        ActivityKind::Lifecycle => ("◆ ", app.styles.success),
254        ActivityKind::Peer => ("⇄ ", app.styles.connection),
255        ActivityKind::Request => ("→ ", app.styles.normal),
256        ActivityKind::Waiting => ("⚠ ", app.styles.error),
257        ActivityKind::Failure => ("✗ ", app.styles.error),
258        ActivityKind::Chat(EntryKind::User) => ("▶ ", app.styles.user),
259        ActivityKind::Chat(EntryKind::Reasoning) => ("∴ ", app.styles.reasoning),
260        ActivityKind::Chat(_) => ("  ", app.styles.normal),
261    }
262}
263
264/// One entry's rows: an event is one truncated row; a conversation entry
265/// is hard-wrapped with a hanging indent, so nothing it says is lost.
266fn entry_lines<'a>(
267    app: &DashboardApp,
268    entry: &ActivityEntry,
269    width: usize,
270    cursor: bool,
271) -> Vec<Line<'a>> {
272    let (glyph, style) = kind_glyph_and_style(app, entry.event.kind);
273    let sel = |s: Style| if cursor { app.styles.selected } else { s };
274
275    if let ActivityKind::Chat(_) = entry.event.kind {
276        let body_width = width.saturating_sub(2).max(1);
277        let mut rows = Vec::new();
278        for (i, text_line) in entry.event.text.split('\n').enumerate() {
279            let span = Span::raw(text_line);
280            let mut chunks = Vec::new();
281            let mut chunk = String::new();
282            let mut cells = 0;
283            for grapheme in span.styled_graphemes(Style::default()) {
284                let glyph_width = Span::raw(grapheme.symbol).width();
285                if cells + glyph_width > body_width && !chunk.is_empty() {
286                    chunks.push(std::mem::take(&mut chunk));
287                    cells = 0;
288                }
289                chunk.push_str(grapheme.symbol);
290                cells += glyph_width;
291            }
292            chunks.push(chunk);
293            for (j, chunk) in chunks.into_iter().enumerate() {
294                let prefix = if i == 0 && j == 0 { glyph } else { "  " };
295                let mut spans = vec![
296                    Span::styled(prefix, sel(style)),
297                    Span::styled(chunk.clone(), sel(style)),
298                ];
299                let filled = 2 + Span::raw(chunk.as_str()).width();
300                if cursor && filled < width {
301                    spans.push(Span::styled(
302                        " ".repeat(width - filled),
303                        app.styles.selected,
304                    ));
305                }
306                rows.push(Line::from(spans));
307            }
308        }
309        return rows;
310    }
311
312    let tag_style = match entry.event.owner {
313        Some(UiKey::Server(_)) => app.styles.server,
314        Some(UiKey::Client(_)) => app.styles.client,
315        None => app.styles.dimmed,
316    };
317    let time = format!("{} ", entry.time);
318    let tag = if entry.event.tag.is_empty() {
319        String::new()
320    } else {
321        format!("{:<9}", fit(&entry.event.tag, 9))
322    };
323    let used = time.chars().count() + tag.chars().count() + glyph.chars().count();
324    let text = fit(&entry.event.text, width.saturating_sub(used));
325    let mut spans = vec![
326        Span::styled(time, sel(app.styles.dimmed)),
327        Span::styled(tag, sel(tag_style)),
328        Span::styled(glyph, sel(style)),
329        Span::styled(text.clone(), sel(style)),
330    ];
331    let filled = used + text.chars().count();
332    if cursor && filled < width {
333        spans.push(Span::styled(
334            " ".repeat(width - filled),
335            app.styles.selected,
336        ));
337    }
338    vec![Line::from(spans)]
339}