Skip to main content

conversation/
conversation.rs

1use mural::{
2    Color, Hr, Line, ListItem, Padding, Render, Size, Span, Spinner, Style, Terminal, Text,
3    Textarea,
4};
5use std::{thread, time::Duration};
6
7const FPS: u64 = 15;
8const FRAME_DELAY: Duration = Duration::from_millis(1_000 / FPS);
9
10fn main() -> Result<(), Box<dyn std::error::Error>> {
11    // Run this example manually to watch Mural update a conversation in the
12    // terminal's normal buffer:
13    //
14    //     cargo run --example conversation
15    let mut terminal = Terminal::stdout()?;
16
17    // Live blocks are the transcript. Pinned blocks render after live blocks and
18    // are useful for transient status and input UI. The status block starts
19    // hidden, then appears directly above the input separator while the
20    // assistant is answering.
21    terminal.push_pinned(Text::from_plain("")?)?;
22    terminal.insert_pinned("status", pinned(AnswerStatus::hidden()))?;
23    terminal.push_pinned(pinned(
24        Hr::new().style(Style::new().fg(Color::BrightBlack).dim()),
25    ))?;
26    terminal.insert_pinned(
27        "input",
28        pinned(
29            Textarea::new()
30                .placeholder("type a message…")?
31                .placeholder_style(Style::new().fg(Color::BrightBlack).dim())
32                .max_height(3),
33        ),
34    )?;
35    terminal.push_pinned(pinned(
36        Hr::new().style(Style::new().fg(Color::BrightBlack).dim()),
37    ))?;
38    render_frames(&mut terminal, 8)?;
39
40    // The first user message also goes through the textarea: applications own
41    // raw mode and keyboard events; this example mutates the textarea directly
42    // to simulate typing and submitting.
43    type_into_input(&mut terminal, "explain Mural in one sentence")?;
44    render_frames(&mut terminal, 4)?;
45    submit_input(&mut terminal)?;
46
47    show_status(&mut terminal, "answering")?;
48    terminal.insert_live("thinking", live_padded(thinking_message("thinking…")?))?;
49    render_frames(&mut terminal, 10)?;
50    terminal.remove_live("thinking")?;
51    terminal.insert_live("assistant", live_padded(assistant_message("Mural keeps")?))?;
52    render_frames(&mut terminal, 3)?;
53
54    // Identified blocks can be mutated between renders. This simulates a
55    // streaming assistant response over several visible frames. The pinned
56    // status spinner advances on every render while it is visible.
57    for content in [
58        "Mural keeps",
59        "Mural keeps a live conversation",
60        "Mural keeps a live conversation region plus pinned input",
61        "Mural keeps a live conversation region plus pinned input/status UI in a normal terminal buffer.",
62    ] {
63        *terminal
64            .live_block_mut::<Padding<ListItem>>("assistant")?
65            .content_mut() = assistant_message(content)?;
66        render_frames(&mut terminal, 5)?;
67    }
68    hide_status(&mut terminal)?;
69    render_frames(&mut terminal, 6)?;
70
71    // Type a second message, move the cursor left, insert a missing word, then
72    // submit. The submitted value is appended to the live transcript.
73    type_into_input(&mut terminal, "what happens if terminal changes size?")?;
74    move_input_left(&mut terminal, "terminal changes size?".chars().count())?;
75    type_into_input(&mut terminal, "the ")?;
76    render_frames(&mut terminal, 6)?;
77
78    let submitted = submit_input(&mut terminal)?;
79    show_status(&mut terminal, "adapting to resize")?;
80    terminal.insert_live(
81        "thinking-resize",
82        live_padded(thinking_message("checking terminal size…")?),
83    )?;
84    render_frames(&mut terminal, 8)?;
85
86    terminal.resize(Size::new(48, 12))?;
87    *terminal
88        .live_block_mut::<Padding<ListItem>>("thinking-resize")?
89        .content_mut() = thinking_message("reflowing the conversation for 48 columns…")?;
90    render_frames(&mut terminal, 10)?;
91    terminal.remove_live("thinking-resize")?;
92    terminal.insert_live(
93        "assistant-resize",
94        live_padded(assistant_message(format!(
95            "For `{submitted}`, the caller notifies Mural about the new size, and the next render performs a full redraw at the new safe width."
96        ))?),
97    )?;
98    render_frames(&mut terminal, 12)?;
99    hide_status(&mut terminal)?;
100    render_frames(&mut terminal, 6)?;
101
102    // finish() removes pinned UI, leaves live transcript text behind, restores
103    // the cursor, and flushes the backend.
104    terminal.finish()?;
105    println!("\nfinished: pinned status/input were cleaned up; live transcript remains above.");
106
107    Ok(())
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
111struct AnswerStatus {
112    spinner: Spinner,
113    visible: bool,
114}
115
116impl AnswerStatus {
117    fn hidden() -> Self {
118        Self {
119            spinner: Spinner::new(Text::from_plain("answering").unwrap())
120                .spinner_style(Style::new().fg(Color::BrightBlack).dim()),
121            visible: false,
122        }
123    }
124
125    fn show(&mut self, content: &str) -> Result<&mut Self, mural::TextError> {
126        *self.spinner.content_mut() = Text::from_plain(content)?;
127        self.spinner.reset();
128        self.visible = true;
129        Ok(self)
130    }
131
132    fn hide(&mut self) -> &mut Self {
133        self.visible = false;
134        self
135    }
136}
137
138impl Render for AnswerStatus {
139    fn render(&self, width: u16) -> Text {
140        if self.visible {
141            self.spinner.render(width)
142        } else {
143            Text::empty()
144        }
145    }
146
147    fn render_every_frame(&self) -> bool {
148        self.visible && self.spinner.render_every_frame()
149    }
150}
151
152fn user_message(content: impl AsRef<str>) -> Result<ListItem, mural::TextError> {
153    Ok(ListItem::new(styled_text(content, Style::new()))
154        .bullet("›")?
155        .bullet_style(Style::new().fg(Color::BrightCyan).bold())
156        .gap(1))
157}
158
159fn assistant_message(content: impl AsRef<str>) -> Result<ListItem, mural::TextError> {
160    Ok(ListItem::new(styled_text(content, Style::new()))
161        .bullet("✦")?
162        .bullet_style(Style::new().fg(Color::BrightMagenta).bold())
163        .gap(1))
164}
165
166fn thinking_message(content: impl AsRef<str>) -> Result<ListItem, mural::TextError> {
167    Ok(ListItem::new(styled_text(
168        content,
169        Style::new().fg(Color::BrightBlack).dim(),
170    ))
171    .bullet("·")?
172    .bullet_style(Style::new().fg(Color::BrightBlack).dim())
173    .gap(1))
174}
175
176fn styled_text(content: impl AsRef<str>, style: Style) -> Text {
177    Text::from_lines(vec![Line::from_spans(vec![
178        Span::new(content.as_ref(), style).expect("example message text is valid plain content"),
179    ])])
180}
181
182fn live_padded<T>(block: T) -> Padding<T> {
183    Padding::new(block).top(1).left(1)
184}
185
186fn pinned<T>(block: T) -> Padding<T> {
187    Padding::new(block).left(1)
188}
189
190fn render_frame<B: mural::Backend>(terminal: &mut Terminal<B>) -> std::io::Result<()> {
191    terminal.render()?;
192    thread::sleep(FRAME_DELAY);
193    Ok(())
194}
195
196fn render_frames<B: mural::Backend>(
197    terminal: &mut Terminal<B>,
198    frames: usize,
199) -> std::io::Result<()> {
200    for _ in 0..frames {
201        render_frame(terminal)?;
202    }
203    Ok(())
204}
205
206fn type_into_input<B: mural::Backend>(
207    terminal: &mut Terminal<B>,
208    content: &str,
209) -> Result<(), Box<dyn std::error::Error>> {
210    for ch in content.chars() {
211        terminal
212            .pinned_block_mut::<Padding<Textarea>>("input")?
213            .content_mut()
214            .insert_char(ch);
215        render_frame(terminal)?;
216    }
217    Ok(())
218}
219
220fn move_input_left<B: mural::Backend>(
221    terminal: &mut Terminal<B>,
222    steps: usize,
223) -> Result<(), Box<dyn std::error::Error>> {
224    for _ in 0..steps {
225        terminal
226            .pinned_block_mut::<Padding<Textarea>>("input")?
227            .content_mut()
228            .move_left();
229        render_frame(terminal)?;
230    }
231    Ok(())
232}
233
234fn submit_input<B: mural::Backend>(
235    terminal: &mut Terminal<B>,
236) -> Result<String, Box<dyn std::error::Error>> {
237    let submitted = {
238        terminal
239            .pinned_block_mut::<Padding<Textarea>>("input")?
240            .content_mut()
241            .take()
242    };
243
244    terminal.push_live(live_padded(user_message(&submitted)?))?;
245    render_frame(terminal)?;
246    Ok(submitted)
247}
248
249fn show_status<B: mural::Backend>(
250    terminal: &mut Terminal<B>,
251    content: &str,
252) -> Result<(), Box<dyn std::error::Error>> {
253    terminal
254        .pinned_block_mut::<Padding<AnswerStatus>>("status")?
255        .content_mut()
256        .show(content)?;
257    Ok(())
258}
259
260fn hide_status<B: mural::Backend>(
261    terminal: &mut Terminal<B>,
262) -> Result<(), Box<dyn std::error::Error>> {
263    terminal
264        .pinned_block_mut::<Padding<AnswerStatus>>("status")?
265        .content_mut()
266        .hide();
267    Ok(())
268}