1use crate::{MemMessage, Role};
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TranscriptRole {
41 User,
43 Assistant,
45 Tool,
47 System,
50}
51
52impl TranscriptRole {
53 fn from_role(role: &Role) -> Self {
54 match role {
55 Role::User => Self::User,
56 Role::Assistant => Self::Assistant,
57 Role::Tool => Self::Tool,
58 Role::System => Self::System,
59 }
60 }
61
62 pub fn label(self) -> &'static str {
64 match self {
65 Self::User => "you",
66 Self::Assistant => "newt",
67 Self::Tool => "tool",
68 Self::System => "system",
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TranscriptLine {
77 pub role: TranscriptRole,
79 pub is_first: bool,
82 pub text: String,
84}
85
86#[derive(Debug, Clone)]
90pub struct TranscriptStyle {
91 pub blank_between_turns: bool,
93 pub show_system: bool,
95 pub show_tools: bool,
98}
99
100impl Default for TranscriptStyle {
101 fn default() -> Self {
102 Self {
103 blank_between_turns: true,
104 show_system: false,
105 show_tools: false,
106 }
107 }
108}
109
110pub fn transcript_lines(messages: &[MemMessage], width: usize) -> Vec<TranscriptLine> {
118 transcript_lines_styled(messages, width, &TranscriptStyle::default())
119}
120
121pub fn transcript_lines_styled(
123 messages: &[MemMessage],
124 width: usize,
125 style: &TranscriptStyle,
126) -> Vec<TranscriptLine> {
127 let width = width.max(1);
128 let mut out: Vec<TranscriptLine> = Vec::new();
129 let mut first_turn = true;
130 for msg in messages {
131 let role = TranscriptRole::from_role(&msg.role);
132 let visible = match role {
133 TranscriptRole::System => style.show_system,
134 TranscriptRole::Tool => style.show_tools,
135 TranscriptRole::User | TranscriptRole::Assistant => true,
136 };
137 if !visible {
138 continue;
139 }
140 if style.blank_between_turns && !first_turn {
141 out.push(TranscriptLine {
142 role,
143 is_first: false,
144 text: String::new(),
145 });
146 }
147 first_turn = false;
148
149 let mut first_line_of_msg = true;
150 for segment in msg.content.split('\n') {
152 for wrapped in wrap_to_width(segment, width) {
153 out.push(TranscriptLine {
154 role,
155 is_first: first_line_of_msg,
156 text: wrapped,
157 });
158 first_line_of_msg = false;
159 }
160 }
161 if first_line_of_msg {
163 out.push(TranscriptLine {
164 role,
165 is_first: true,
166 text: String::new(),
167 });
168 }
169 }
170 out
171}
172
173fn wrap_to_width(line: &str, width: usize) -> Vec<String> {
179 let chars: Vec<char> = line.chars().collect();
180 if chars.len() <= width {
181 return vec![line.to_string()];
182 }
183 let mut chunks = Vec::new();
184 let mut start = 0;
185 while start < chars.len() {
186 let end = (start + width).min(chars.len());
187 let mut break_at = end;
189 if end < chars.len() {
190 if let Some(space) = chars[start..end].iter().rposition(|&c| c == ' ') {
191 let candidate = start + space;
192 if candidate > start {
195 break_at = candidate;
196 }
197 }
198 }
199 let chunk: String = chars[start..break_at].iter().collect();
200 chunks.push(chunk);
201 start = if break_at < end && chars.get(break_at) == Some(&' ') {
203 break_at + 1
204 } else {
205 break_at
206 };
207 }
208 chunks
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn texts(lines: &[TranscriptLine]) -> Vec<&str> {
216 lines.iter().map(|l| l.text.as_str()).collect()
217 }
218
219 #[test]
220 fn user_and_assistant_turns_render_with_roles() {
221 let msgs = [
222 MemMessage::user("hello there"),
223 MemMessage::assistant("hi, how can I help?"),
224 ];
225 let lines = transcript_lines(&msgs, 80);
226 let first = &lines[0];
228 assert_eq!(first.role, TranscriptRole::User);
229 assert!(first.is_first);
230 assert_eq!(first.text, "hello there");
231 assert!(lines
233 .iter()
234 .any(|l| l.role == TranscriptRole::Assistant && l.text == "hi, how can I help?"));
235 assert!(
236 lines.iter().any(|l| l.text.is_empty()),
237 "a blank spacer separates turns by default"
238 );
239 }
240
241 #[test]
242 fn system_and_tool_turns_are_hidden_by_default() {
243 let msgs = [
244 MemMessage::system("you are newt"),
245 MemMessage::user("hi"),
246 MemMessage {
247 role: Role::Tool,
248 content: "tool output".into(),
249 },
250 MemMessage::assistant("done"),
251 ];
252 let lines = transcript_lines(&msgs, 80);
253 assert!(!lines.iter().any(|l| l.role == TranscriptRole::System));
254 assert!(!lines.iter().any(|l| l.role == TranscriptRole::Tool));
255 assert!(texts(&lines).contains(&"hi"));
256 assert!(texts(&lines).contains(&"done"));
257 }
258
259 #[test]
260 fn system_and_tool_turns_appear_when_enabled() {
261 let msgs = [
262 MemMessage::system("preamble"),
263 MemMessage {
264 role: Role::Tool,
265 content: "ran".into(),
266 },
267 ];
268 let style = TranscriptStyle {
269 show_system: true,
270 show_tools: true,
271 blank_between_turns: false,
272 };
273 let lines = transcript_lines_styled(&msgs, 80, &style);
274 assert!(lines
275 .iter()
276 .any(|l| l.role == TranscriptRole::System && l.text == "preamble"));
277 assert!(lines
278 .iter()
279 .any(|l| l.role == TranscriptRole::Tool && l.text == "ran"));
280 assert!(!lines.iter().any(|l| l.text.is_empty()));
282 }
283
284 #[test]
285 fn long_lines_wrap_to_the_pane_width() {
286 let msgs = [MemMessage::user("the quick brown fox jumps over it")];
288 let width = 10;
289 let lines = transcript_lines(&msgs, width);
290 for l in &lines {
292 assert!(
293 l.text.chars().count() <= width,
294 "line over width ({width}): {:?}",
295 l.text
296 );
297 }
298 let content: Vec<&TranscriptLine> = lines.iter().filter(|l| !l.text.is_empty()).collect();
300 assert!(content[0].is_first);
301 assert!(content[1..].iter().all(|l| !l.is_first));
302 let joined = content
304 .iter()
305 .map(|l| l.text.as_str())
306 .collect::<Vec<_>>()
307 .join(" ");
308 assert!(joined.contains("quick"));
309 assert!(joined.contains("jumps"));
310 }
311
312 #[test]
313 fn hard_newlines_start_new_lines() {
314 let msgs = [MemMessage::assistant("line one\nline two\nline three")];
315 let lines = transcript_lines(&msgs, 80);
316 let content: Vec<&str> = lines
317 .iter()
318 .filter(|l| !l.text.is_empty())
319 .map(|l| l.text.as_str())
320 .collect();
321 assert_eq!(content, vec!["line one", "line two", "line three"]);
322 let firsts: Vec<bool> = lines
324 .iter()
325 .filter(|l| !l.text.is_empty())
326 .map(|l| l.is_first)
327 .collect();
328 assert_eq!(firsts, vec![true, false, false]);
329 }
330
331 #[test]
332 fn zero_width_does_not_loop_forever() {
333 let msgs = [MemMessage::user("abc")];
334 let lines = transcript_lines(&msgs, 0);
336 let content: Vec<&str> = lines
337 .iter()
338 .filter(|l| !l.text.is_empty())
339 .map(|l| l.text.as_str())
340 .collect();
341 assert_eq!(content, vec!["a", "b", "c"]);
342 }
343
344 #[test]
345 fn empty_message_keeps_its_label_row() {
346 let msgs = [MemMessage::assistant("")];
347 let lines = transcript_lines(&msgs, 80);
348 assert_eq!(lines.len(), 1);
349 assert_eq!(lines[0].role, TranscriptRole::Assistant);
350 assert!(lines[0].is_first);
351 assert_eq!(lines[0].text, "");
352 }
353
354 #[test]
355 fn role_labels_are_stable() {
356 assert_eq!(TranscriptRole::User.label(), "you");
357 assert_eq!(TranscriptRole::Assistant.label(), "newt");
358 assert_eq!(TranscriptRole::Tool.label(), "tool");
359 assert_eq!(TranscriptRole::System.label(), "system");
360 }
361}