1use crate::markdown;
6use crate::theme;
7use ratatui::style::{Modifier, Style};
8use ratatui::text::{Line, Span};
9
10#[derive(Debug, Clone)]
12pub enum ConversationEntry {
13 User { text: String, timestamp: String },
15 AssistantText { text: String, timestamp: String },
17 ToolCall {
19 tool: String,
20 input: String,
21 output: Option<String>,
22 is_error: bool,
23 collapsed: bool,
24 },
25 Question { text: String, timestamp: String },
27 Status { text: String },
29}
30
31pub 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 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 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
241fn 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
258fn 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}