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 let mut terminal = Terminal::stdout()?;
16
17 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 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 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_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 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}