Skip to main content

newt_core/agentic/
transcript.rs

1//! A **reusable transcript render** for a downstream chat pane (issue #308 —
2//! the cowork foundation).
3//!
4//! ## Why this is render-*data*, not a ratatui widget
5//!
6//! newt's own chat surface is a plain scroller and `docs/decisions/
7//! plain_scroller_tui.md` forbids adding ratatui / widget surfaces to it —
8//! ratatui is a dependency *only* for the startup splash, and panes/splits
9//! belong downstream in **gilamonster-agent**, which inherits newt's
10//! **published** crates. So the reusable render lives in `newt-core` as
11//! **renderer-agnostic data**: [`transcript_lines`] turns a `&[MemMessage]`
12//! into width-wrapped, role-tagged [`TranscriptLine`]s sized to fit an
13//! arbitrary `Rect`'s width. The downstream cowork UI maps each line to a
14//! `ratatui::text::Line` with its own styling and places the pane wherever its
15//! layout wants — newt-core never depends on ratatui, and the plain-scroller
16//! chat path is untouched.
17//!
18//! ```
19//! use newt_core::agentic::{transcript_lines, TranscriptStyle, TranscriptRole};
20//! use newt_core::MemMessage;
21//!
22//! let msgs = [
23//!     MemMessage::user("hello"),
24//!     MemMessage::assistant("hi there"),
25//! ];
26//! // Render into a 20-column-wide pane.
27//! let lines = transcript_lines(&msgs, 20);
28//! assert!(lines.iter().any(|l| l.role == TranscriptRole::User));
29//! assert!(lines.iter().any(|l| l.role == TranscriptRole::Assistant));
30//! // Speaker labels appear; system + tool turns are hidden by default.
31//! let _ = TranscriptStyle::default();
32//! ```
33
34use crate::{MemMessage, Role};
35
36/// Which speaker a rendered line belongs to. A renderer keys its styling
37/// (color, alignment, gutter) on this. Mirrors the subset of [`Role`] a chat
38/// pane shows; system / tool turns are folded away by default.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum TranscriptRole {
41    /// A human turn.
42    User,
43    /// A model turn.
44    Assistant,
45    /// A tool result (only present when [`TranscriptStyle::show_tools`] is set).
46    Tool,
47    /// A system/preamble turn (only present when
48    /// [`TranscriptStyle::show_system`] is set).
49    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    /// The speaker label a default renderer prints in the gutter.
63    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/// One physical line of the rendered transcript — already wrapped to the pane
74/// width. A renderer draws exactly this, one terminal row per [`TranscriptLine`].
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct TranscriptLine {
77    /// The speaker this line belongs to (drives the renderer's styling).
78    pub role: TranscriptRole,
79    /// `true` for the first wrapped line of a message — the row a renderer
80    /// stamps the speaker label / gutter onto. Continuation lines are `false`.
81    pub is_first: bool,
82    /// The line text, already wrapped to the requested width (never longer).
83    pub text: String,
84}
85
86/// Renderer-agnostic styling knobs for [`transcript_lines_styled`]. Defaults
87/// match a typical chat pane: blank line between turns, system and tool turns
88/// hidden.
89#[derive(Debug, Clone)]
90pub struct TranscriptStyle {
91    /// Emit a blank spacer line between consecutive turns.
92    pub blank_between_turns: bool,
93    /// Include `system`-role turns (the preamble). Off by default.
94    pub show_system: bool,
95    /// Include `tool`-role turns. Off by default — a chat pane usually shows
96    /// only the human/model dialogue.
97    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
110/// Wrap a chat transcript into width-bounded [`TranscriptLine`]s ready to draw
111/// into a `width`-column pane, using the default [`TranscriptStyle`].
112///
113/// `width` is the inner width of the target `Rect` (columns available for
114/// text). A `width` of 0 is treated as 1 to avoid an infinite wrap. Hard
115/// newlines in message content start new lines; over-long lines are wrapped at
116/// the width boundary (word-aware when a space is available).
117pub fn transcript_lines(messages: &[MemMessage], width: usize) -> Vec<TranscriptLine> {
118    transcript_lines_styled(messages, width, &TranscriptStyle::default())
119}
120
121/// Like [`transcript_lines`] but with explicit [`TranscriptStyle`].
122pub 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        // Preserve hard newlines; wrap each segment to the width.
151        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        // An entirely empty message still occupies its label row.
162        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
173/// Wrap a single logical line (no embedded `\n`) into chunks no wider than
174/// `width` columns (by `char` count — a pragmatic proxy for display width that
175/// matches how the plain scroller already reasons about output). Breaks on the
176/// last space before the limit when one exists, else hard-breaks mid-word. An
177/// empty input yields a single empty chunk so the line still renders.
178fn 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        // Try to break on the last space within [start, end) for a clean wrap.
188        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                // Only honor the space-break when it leaves real content; a
193                // leading space would make a zero-width chunk.
194                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        // Skip the single break space when we broke on one.
202        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        // First content line is the user turn, marked is_first.
227        let first = &lines[0];
228        assert_eq!(first.role, TranscriptRole::User);
229        assert!(first.is_first);
230        assert_eq!(first.text, "hello there");
231        // The assistant turn appears too, with a blank spacer between them.
232        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        // No blank spacers when disabled.
281        assert!(!lines.iter().any(|l| l.text.is_empty()));
282    }
283
284    #[test]
285    fn long_lines_wrap_to_the_pane_width() {
286        // One 30-char word-broken line into a 10-col pane.
287        let msgs = [MemMessage::user("the quick brown fox jumps over it")];
288        let width = 10;
289        let lines = transcript_lines(&msgs, width);
290        // Every rendered line fits the pane.
291        for l in &lines {
292            assert!(
293                l.text.chars().count() <= width,
294                "line over width ({width}): {:?}",
295                l.text
296            );
297        }
298        // The first wrapped line carries the is_first flag; the rest don't.
299        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        // Reassembling the wrapped text recovers the words.
303        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        // Only the very first physical line of the message is is_first.
323        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        // Treated as width 1 — each char on its own line, terminates.
335        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}