1use 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
23pub 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 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 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
264fn 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}