use crate::{MemMessage, Role};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TranscriptRole {
User,
Assistant,
Tool,
System,
}
impl TranscriptRole {
fn from_role(role: &Role) -> Self {
match role {
Role::User => Self::User,
Role::Assistant => Self::Assistant,
Role::Tool => Self::Tool,
Role::System => Self::System,
}
}
pub fn label(self) -> &'static str {
match self {
Self::User => "you",
Self::Assistant => "newt",
Self::Tool => "tool",
Self::System => "system",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TranscriptLine {
pub role: TranscriptRole,
pub is_first: bool,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct TranscriptStyle {
pub blank_between_turns: bool,
pub show_system: bool,
pub show_tools: bool,
}
impl Default for TranscriptStyle {
fn default() -> Self {
Self {
blank_between_turns: true,
show_system: false,
show_tools: false,
}
}
}
pub fn transcript_lines(messages: &[MemMessage], width: usize) -> Vec<TranscriptLine> {
transcript_lines_styled(messages, width, &TranscriptStyle::default())
}
pub fn transcript_lines_styled(
messages: &[MemMessage],
width: usize,
style: &TranscriptStyle,
) -> Vec<TranscriptLine> {
let width = width.max(1);
let mut out: Vec<TranscriptLine> = Vec::new();
let mut first_turn = true;
for msg in messages {
let role = TranscriptRole::from_role(&msg.role);
let visible = match role {
TranscriptRole::System => style.show_system,
TranscriptRole::Tool => style.show_tools,
TranscriptRole::User | TranscriptRole::Assistant => true,
};
if !visible {
continue;
}
if style.blank_between_turns && !first_turn {
out.push(TranscriptLine {
role,
is_first: false,
text: String::new(),
});
}
first_turn = false;
let mut first_line_of_msg = true;
for segment in msg.content.split('\n') {
for wrapped in wrap_to_width(segment, width) {
out.push(TranscriptLine {
role,
is_first: first_line_of_msg,
text: wrapped,
});
first_line_of_msg = false;
}
}
if first_line_of_msg {
out.push(TranscriptLine {
role,
is_first: true,
text: String::new(),
});
}
}
out
}
fn wrap_to_width(line: &str, width: usize) -> Vec<String> {
let chars: Vec<char> = line.chars().collect();
if chars.len() <= width {
return vec![line.to_string()];
}
let mut chunks = Vec::new();
let mut start = 0;
while start < chars.len() {
let end = (start + width).min(chars.len());
let mut break_at = end;
if end < chars.len() {
if let Some(space) = chars[start..end].iter().rposition(|&c| c == ' ') {
let candidate = start + space;
if candidate > start {
break_at = candidate;
}
}
}
let chunk: String = chars[start..break_at].iter().collect();
chunks.push(chunk);
start = if break_at < end && chars.get(break_at) == Some(&' ') {
break_at + 1
} else {
break_at
};
}
chunks
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(lines: &[TranscriptLine]) -> Vec<&str> {
lines.iter().map(|l| l.text.as_str()).collect()
}
#[test]
fn user_and_assistant_turns_render_with_roles() {
let msgs = [
MemMessage::user("hello there"),
MemMessage::assistant("hi, how can I help?"),
];
let lines = transcript_lines(&msgs, 80);
let first = &lines[0];
assert_eq!(first.role, TranscriptRole::User);
assert!(first.is_first);
assert_eq!(first.text, "hello there");
assert!(lines
.iter()
.any(|l| l.role == TranscriptRole::Assistant && l.text == "hi, how can I help?"));
assert!(
lines.iter().any(|l| l.text.is_empty()),
"a blank spacer separates turns by default"
);
}
#[test]
fn system_and_tool_turns_are_hidden_by_default() {
let msgs = [
MemMessage::system("you are newt"),
MemMessage::user("hi"),
MemMessage {
role: Role::Tool,
content: "tool output".into(),
},
MemMessage::assistant("done"),
];
let lines = transcript_lines(&msgs, 80);
assert!(!lines.iter().any(|l| l.role == TranscriptRole::System));
assert!(!lines.iter().any(|l| l.role == TranscriptRole::Tool));
assert!(texts(&lines).contains(&"hi"));
assert!(texts(&lines).contains(&"done"));
}
#[test]
fn system_and_tool_turns_appear_when_enabled() {
let msgs = [
MemMessage::system("preamble"),
MemMessage {
role: Role::Tool,
content: "ran".into(),
},
];
let style = TranscriptStyle {
show_system: true,
show_tools: true,
blank_between_turns: false,
};
let lines = transcript_lines_styled(&msgs, 80, &style);
assert!(lines
.iter()
.any(|l| l.role == TranscriptRole::System && l.text == "preamble"));
assert!(lines
.iter()
.any(|l| l.role == TranscriptRole::Tool && l.text == "ran"));
assert!(!lines.iter().any(|l| l.text.is_empty()));
}
#[test]
fn long_lines_wrap_to_the_pane_width() {
let msgs = [MemMessage::user("the quick brown fox jumps over it")];
let width = 10;
let lines = transcript_lines(&msgs, width);
for l in &lines {
assert!(
l.text.chars().count() <= width,
"line over width ({width}): {:?}",
l.text
);
}
let content: Vec<&TranscriptLine> = lines.iter().filter(|l| !l.text.is_empty()).collect();
assert!(content[0].is_first);
assert!(content[1..].iter().all(|l| !l.is_first));
let joined = content
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(joined.contains("quick"));
assert!(joined.contains("jumps"));
}
#[test]
fn hard_newlines_start_new_lines() {
let msgs = [MemMessage::assistant("line one\nline two\nline three")];
let lines = transcript_lines(&msgs, 80);
let content: Vec<&str> = lines
.iter()
.filter(|l| !l.text.is_empty())
.map(|l| l.text.as_str())
.collect();
assert_eq!(content, vec!["line one", "line two", "line three"]);
let firsts: Vec<bool> = lines
.iter()
.filter(|l| !l.text.is_empty())
.map(|l| l.is_first)
.collect();
assert_eq!(firsts, vec![true, false, false]);
}
#[test]
fn zero_width_does_not_loop_forever() {
let msgs = [MemMessage::user("abc")];
let lines = transcript_lines(&msgs, 0);
let content: Vec<&str> = lines
.iter()
.filter(|l| !l.text.is_empty())
.map(|l| l.text.as_str())
.collect();
assert_eq!(content, vec!["a", "b", "c"]);
}
#[test]
fn empty_message_keeps_its_label_row() {
let msgs = [MemMessage::assistant("")];
let lines = transcript_lines(&msgs, 80);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].role, TranscriptRole::Assistant);
assert!(lines[0].is_first);
assert_eq!(lines[0].text, "");
}
#[test]
fn role_labels_are_stable() {
assert_eq!(TranscriptRole::User.label(), "you");
assert_eq!(TranscriptRole::Assistant.label(), "newt");
assert_eq!(TranscriptRole::Tool.label(), "tool");
assert_eq!(TranscriptRole::System.label(), "system");
}
}