Skip to main content

apiari_tui/
conversation.rs

1//! Shared conversation types and rendering for agent TUI views.
2//!
3//! Used by both `swarm` (live agent TUI) and `apiari` (worker detail chat view).
4
5use crate::markdown;
6use crate::theme;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9
10/// A rendered conversation entry in the TUI.
11#[derive(Debug, Clone)]
12pub enum ConversationEntry {
13    /// User message.
14    User { text: String, timestamp: String },
15    /// Assistant text block (may be streamed incrementally).
16    AssistantText { text: String, timestamp: String },
17    /// A tool call with its result.
18    ToolCall {
19        tool: String,
20        input: String,
21        output: Option<String>,
22        is_error: bool,
23        collapsed: bool,
24    },
25    /// Assistant message that requires user response.
26    Question { text: String, timestamp: String },
27    /// Status message (e.g. "Session started", "Rate limited").
28    Status { text: String },
29}
30
31/// Render conversation entries into ratatui lines.
32///
33/// This is the shared "turn entries -> Lines" logic used by both swarm's agent TUI
34/// and apiari's worker detail view. Each caller handles scroll + frame rendering.
35///
36/// `focused_tool` is the index of the currently focused ToolCall entry (for
37/// keyboard navigation in swarm's TUI). Pass `None` when focus isn't relevant.
38///
39/// Returns an entry-line map: `Vec<(start_line, line_count)>` per entry,
40/// useful for scroll-to-focus calculations.
41pub fn render_conversation<'a>(
42    lines: &mut Vec<Line<'a>>,
43    entries: &'a [ConversationEntry],
44    focused_tool: Option<usize>,
45    assistant_label: Option<&str>,
46) -> Vec<(u32, u32)> {
47    let label = assistant_label.unwrap_or("Claude");
48    let mut last_shown_ts = String::new();
49    let mut entry_line_map: Vec<(u32, u32)> = Vec::with_capacity(entries.len());
50
51    for (i, entry) in entries.iter().enumerate() {
52        let start = lines.len() as u32;
53        let is_focused = focused_tool == Some(i);
54        match entry {
55            ConversationEntry::User { text, timestamp } => {
56                // Divider before user messages (visual turn boundary)
57                if i > 0 {
58                    lines.push(Line::from(""));
59                    lines.push(Line::from(Span::styled(
60                        format!("  {}", "\u{2500}".repeat(40)),
61                        Style::default().fg(theme::STEEL),
62                    )));
63                }
64                lines.push(Line::from(""));
65                let ts_span = dedup_timestamp(timestamp, &mut last_shown_ts);
66                lines.push(Line::from(vec![
67                    Span::styled(
68                        "  You:",
69                        Style::default()
70                            .fg(theme::HONEY)
71                            .add_modifier(Modifier::BOLD),
72                    ),
73                    ts_span,
74                ]));
75                for line in text.lines() {
76                    lines.push(Line::from(Span::styled(
77                        format!("  {}", line),
78                        theme::text(),
79                    )));
80                }
81            }
82            ConversationEntry::AssistantText { text, timestamp } => {
83                let in_same_turn = is_continuation_of_assistant_turn(entries, i);
84                if !in_same_turn {
85                    lines.push(Line::from(""));
86                    let ts_span = dedup_timestamp(timestamp, &mut last_shown_ts);
87                    lines.push(Line::from(vec![
88                        Span::styled(
89                            format!("  {label}:"),
90                            Style::default()
91                                .fg(theme::MINT)
92                                .add_modifier(Modifier::BOLD),
93                        ),
94                        ts_span,
95                    ]));
96                }
97                lines.extend(markdown::render_markdown(text));
98            }
99            ConversationEntry::ToolCall {
100                tool,
101                input,
102                output,
103                is_error,
104                collapsed,
105            } => {
106                let focus_prefix = if is_focused { "\u{25b6} " } else { "  " };
107                let tool_style_expanded = if is_focused {
108                    Style::default()
109                        .fg(theme::HONEY)
110                        .add_modifier(Modifier::BOLD)
111                } else {
112                    theme::tool_name()
113                };
114                if *collapsed {
115                    let (icon, icon_style) = if output.is_none() {
116                        ("\u{22ef}", theme::muted())
117                    } else if *is_error {
118                        ("\u{2716}", theme::error())
119                    } else {
120                        ("\u{2714}", Style::default().fg(theme::STEEL))
121                    };
122                    let collapsed_tool_style = if is_focused {
123                        Style::default()
124                            .fg(theme::HONEY)
125                            .add_modifier(Modifier::BOLD)
126                    } else {
127                        Style::default().fg(theme::STEEL)
128                    };
129                    let preview = input
130                        .lines()
131                        .next()
132                        .unwrap_or("")
133                        .chars()
134                        .take(50)
135                        .collect::<String>();
136                    let ellipsis = if input.lines().next().is_some_and(|l| l.len() > 50) {
137                        "..."
138                    } else {
139                        ""
140                    };
141                    lines.push(Line::from(vec![
142                        Span::styled(format!("{}{} ", focus_prefix, icon), icon_style),
143                        Span::styled(tool.as_str(), collapsed_tool_style),
144                        Span::styled(
145                            format!("  {}{}", preview, ellipsis),
146                            Style::default().fg(theme::STEEL),
147                        ),
148                    ]));
149                } else {
150                    lines.push(Line::from(""));
151                    lines.push(Line::from(vec![
152                        Span::styled(focus_prefix, theme::muted()),
153                        Span::styled(format!(" {} ", tool), tool_style_expanded),
154                        Span::styled(
155                            " \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}",
156                            Style::default().fg(theme::STEEL),
157                        ),
158                    ]));
159                    for line in input.lines().take(5) {
160                        lines.push(Line::from(Span::styled(
161                            format!("  \u{2502} {}", line),
162                            Style::default().fg(theme::SLATE),
163                        )));
164                    }
165                    if input.lines().count() > 5 {
166                        lines.push(Line::from(Span::styled(
167                            format!("  \u{2502} ... ({} more lines)", input.lines().count() - 5),
168                            theme::muted(),
169                        )));
170                    }
171                    if let Some(out) = output {
172                        let out_style = if *is_error {
173                            theme::error()
174                        } else {
175                            theme::muted()
176                        };
177                        lines.push(Line::from(Span::styled(
178                            "  \u{251c}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}",
179                            Style::default().fg(theme::STEEL),
180                        )));
181                        for line in out.lines().take(10) {
182                            lines.push(Line::from(Span::styled(
183                                format!("  \u{2502} {}", line),
184                                out_style,
185                            )));
186                        }
187                        if out.lines().count() > 10 {
188                            lines.push(Line::from(Span::styled(
189                                format!("  \u{2502} ... ({} more lines)", out.lines().count() - 10),
190                                theme::muted(),
191                            )));
192                        }
193                    }
194                    lines.push(Line::from(Span::styled(
195                        "  \u{2514}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}",
196                        Style::default().fg(theme::STEEL),
197                    )));
198                }
199            }
200            ConversationEntry::Question { text, timestamp } => {
201                let in_same_turn = is_continuation_of_assistant_turn(entries, i);
202                if !in_same_turn {
203                    lines.push(Line::from(""));
204                    let ts_span = dedup_timestamp(timestamp, &mut last_shown_ts);
205                    lines.push(Line::from(vec![
206                        Span::styled(
207                            format!("  \u{2753} {label}:"),
208                            Style::default()
209                                .fg(theme::HONEY)
210                                .add_modifier(Modifier::BOLD),
211                        ),
212                        ts_span,
213                    ]));
214                } else {
215                    // Even in continuation mode, show a visual marker so
216                    // action-needed messages are always distinguishable.
217                    lines.push(Line::from(Span::styled(
218                        "  \u{2753}",
219                        Style::default()
220                            .fg(theme::HONEY)
221                            .add_modifier(Modifier::BOLD),
222                    )));
223                }
224                lines.extend(markdown::render_markdown(text));
225            }
226            ConversationEntry::Status { text } => {
227                lines.push(Line::from(""));
228                lines.push(Line::from(Span::styled(
229                    format!("  {}", text),
230                    theme::muted(),
231                )));
232            }
233        }
234        let count = lines.len() as u32 - start;
235        entry_line_map.push((start, count));
236    }
237
238    entry_line_map
239}
240
241/// Check if entry at `idx` is a continuation of an assistant turn (looking past tool calls).
242fn is_continuation_of_assistant_turn(entries: &[ConversationEntry], idx: usize) -> bool {
243    if idx == 0 {
244        return false;
245    }
246    for j in (0..idx).rev() {
247        match &entries[j] {
248            ConversationEntry::AssistantText { .. } | ConversationEntry::Question { .. } => {
249                return true;
250            }
251            ConversationEntry::ToolCall { .. } => continue,
252            _ => return false,
253        }
254    }
255    false
256}
257
258/// Show timestamp only if it differs from the last shown one.
259fn dedup_timestamp<'a>(timestamp: &'a str, last_shown: &mut String) -> Span<'a> {
260    if timestamp == last_shown.as_str() || timestamp.is_empty() {
261        Span::raw("")
262    } else {
263        *last_shown = timestamp.to_string();
264        Span::styled(
265            format!("  {}", timestamp),
266            Style::default().fg(theme::SMOKE),
267        )
268    }
269}