Skip to main content

chat/
chat.rs

1//! Chat assistant demo.
2//!
3//! A boxed input prompt where you type a message, press Enter,
4//! and a streaming "AI response" appears above. As messages
5//! accumulate, old ones scroll into terminal scrollback and
6//! are committed (evicted from state).
7//!
8//! Demonstrates: event handling, custom focusable components,
9//! content insets (bordered input), streaming via Handle,
10//! committed scrollback via on_commit.
11//!
12//! Run with: cargo run --example chat
13//! Press Esc or Ctrl+C to exit.
14
15use std::io;
16use std::time::Duration;
17
18use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
19use eye_declare::{
20    Application, BorderType, Canvas, Cells, ControlFlow, Elements, Handle, Hooks, Markdown, Span,
21    Text, View, component, element, props,
22};
23use ratatui_core::{
24    buffer::Buffer,
25    layout::Rect,
26    style::{Color, Modifier, Style},
27    text::Line,
28    widgets::Widget,
29};
30use ratatui_widgets::paragraph::Paragraph;
31
32// ---------------------------------------------------------------------------
33// Application state
34// ---------------------------------------------------------------------------
35
36struct AppState {
37    messages: Vec<ChatMessage>,
38    next_id: u64,
39    input: String,
40    cursor: usize,
41}
42
43impl AppState {
44    fn new() -> Self {
45        Self {
46            messages: vec![],
47            next_id: 0,
48            input: String::new(),
49            cursor: 0,
50        }
51    }
52
53    fn next_id(&mut self) -> u64 {
54        let id = self.next_id;
55        self.next_id += 1;
56        id
57    }
58}
59
60struct ChatMessage {
61    id: u64,
62    kind: MessageKind,
63}
64
65enum MessageKind {
66    User(String),
67    Assistant { content: String, done: bool },
68}
69
70// ---------------------------------------------------------------------------
71// InputBox component — bordered text input using #[component]
72// ---------------------------------------------------------------------------
73
74#[props]
75struct InputBox {
76    text: String,
77    #[default(0usize)]
78    cursor: usize,
79    #[default("".to_string())]
80    prompt: String,
81}
82
83#[component(props = InputBox)]
84fn input_box(props: &InputBox, hooks: &mut Hooks<InputBox, ()>) -> Elements {
85    hooks.use_autofocus();
86    hooks.use_focusable(true);
87
88    let cursor_pos = props.cursor;
89    hooks.use_cursor(move |area: Rect, _props: &InputBox, _state: &()| {
90        let col = 2 + cursor_pos as u16;
91        if col < area.width.saturating_sub(1) {
92            Some((col, 1))
93        } else {
94            Some((area.width.saturating_sub(2), 1))
95        }
96    });
97
98    let text = props.text.clone();
99
100    element! {
101        View(
102            border: BorderType::Plain,
103            border_style: Style::default().fg(Color::DarkGray),
104            title: format!(" {} ", props.prompt),
105            title_style: Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
106            padding_left: Some(Cells(1)),
107            padding_right: Some(Cells(1)),
108        ) {
109            Canvas(render_fn: move |area: Rect, buf: &mut Buffer| {
110                if area.width == 0 || area.height == 0 {
111                    return;
112                }
113                let display = if text.is_empty() {
114                    Line::from(ratatui_core::text::Span::styled(
115                        "Type a message...",
116                        Style::default()
117                            .fg(Color::DarkGray)
118                            .add_modifier(Modifier::ITALIC),
119                    ))
120                } else {
121                    Line::from(ratatui_core::text::Span::styled(
122                        &text,
123                        Style::default().fg(Color::White),
124                    ))
125                };
126                Paragraph::new(display).render(area, buf);
127            }, height: 1u16)
128        }
129    }
130}
131
132// ---------------------------------------------------------------------------
133// StreamingDots — animated indicator while streaming
134// ---------------------------------------------------------------------------
135
136#[derive(Default)]
137struct StreamingDotsState {
138    frame: usize,
139}
140
141#[props]
142struct StreamingDots {}
143
144#[component(props = StreamingDots, state = StreamingDotsState)]
145fn streaming_dots(
146    _props: &StreamingDots,
147    state: &StreamingDotsState,
148    hooks: &mut Hooks<StreamingDots, StreamingDotsState>,
149) -> Elements {
150    hooks.use_interval(Duration::from_millis(300), |_props, s| {
151        s.frame = s.frame.wrapping_add(1);
152    });
153
154    let dots = match state.frame % 4 {
155        0 => "   ",
156        1 => ".  ",
157        2 => ".. ",
158        _ => "...",
159    };
160    let dots = dots.to_string();
161
162    element! {
163        Canvas(render_fn: move |area: Rect, buf: &mut Buffer| {
164            let line = Line::from(ratatui_core::text::Span::styled(&dots, Style::default().fg(Color::DarkGray)));
165            Paragraph::new(line).render(area, buf);
166        })
167    }
168}
169
170// ---------------------------------------------------------------------------
171// View function
172// ---------------------------------------------------------------------------
173
174fn chat_view(state: &AppState) -> Elements {
175    element! {
176        #(for msg in &state.messages {
177            #(message_element(msg))
178        })
179
180        Text { "" }
181
182        InputBox(key: "input", text: state.input.clone(), cursor: state.cursor, prompt: "You")
183    }
184}
185
186fn message_element(msg: &ChatMessage) -> Elements {
187    let key = format!("msg-{}", msg.id);
188    match &msg.kind {
189        MessageKind::User(text) => {
190            element! {
191                Text(key: key) {
192                    Span(text: format!("> {}", text), style: Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
193                }
194            }
195        }
196        MessageKind::Assistant { content, done } => {
197            if *done {
198                element! { Markdown(key: key, source: content.clone()) }
199            } else if content.is_empty() {
200                element! { StreamingDots(key: key) }
201            } else {
202                element! { Markdown(key: key, source: format!("{}▌", content)) }
203            }
204        }
205    }
206}
207
208// ---------------------------------------------------------------------------
209// Streaming response simulation
210// ---------------------------------------------------------------------------
211
212const RESPONSES: &[&str] = &[
213    "Here's a quick overview of the key concepts:\n\n\
214     **Ownership** is Rust's most unique feature. Every value has a single \
215     owner, and the value is dropped when the owner goes out of scope. This \
216     eliminates the need for a garbage collector.\n\n\
217     **Borrowing** lets you reference a value without taking ownership. \
218     You can have either one mutable reference or any number of immutable \
219     references at a time.\n\n\
220     ```rust\nlet s = String::from(\"hello\");\nlet r = &s; // immutable borrow\nprintln!(\"{}\", r);\n```\n\n\
221     The borrow checker enforces these rules at compile time, preventing \
222     data races and use-after-free bugs entirely.",
223    "Here are a few approaches you could consider:\n\n\
224     - **Pattern matching** with `match` is exhaustive — the compiler \
225     ensures you handle every case\n\
226     - **Iterator chains** like `.filter().map().collect()` are zero-cost \
227     abstractions that compile to the same code as hand-written loops\n\
228     - **Error handling** with `Result<T, E>` and the `?` operator makes \
229     error propagation clean and explicit\n\n\
230     The Rust compiler is your ally here — lean into its suggestions.",
231    "Let me break that down step by step:\n\n\
232     ### Step 1: Define the trait\n\n\
233     ```rust\ntrait Drawable {\n    fn draw(&self, buf: &mut Buffer);\n}\n```\n\n\
234     ### Step 2: Implement for your types\n\n\
235     Each type provides its own `draw` implementation. The compiler \
236     generates static dispatch when possible.\n\n\
237     ### Step 3: Use trait objects for dynamic dispatch\n\n\
238     When you need heterogeneous collections, use `Box<dyn Drawable>`. \
239     This adds a vtable pointer but enables runtime polymorphism.",
240];
241
242async fn stream_response(handle: Handle<AppState>, msg_id: u64) {
243    let response = RESPONSES[(msg_id / 2) as usize % RESPONSES.len()];
244
245    tokio::time::sleep(Duration::from_millis(500)).await;
246
247    let words: Vec<&str> = response
248        .split_inclusive(|c: char| c.is_whitespace() || c == '\n')
249        .collect();
250    for word in words {
251        let w = word.to_string();
252        handle.update(move |state| {
253            if let Some(msg) = state.messages.iter_mut().find(|m| m.id == msg_id)
254                && let MessageKind::Assistant { content, .. } = &mut msg.kind
255            {
256                content.push_str(&w);
257            }
258        });
259        let delay = if word.contains('\n') {
260            80
261        } else {
262            25 + (word.len() as u64 * 5)
263        };
264        tokio::time::sleep(Duration::from_millis(delay)).await;
265    }
266
267    handle.update(move |state| {
268        if let Some(msg) = state.messages.iter_mut().find(|m| m.id == msg_id)
269            && let MessageKind::Assistant { done, .. } = &mut msg.kind
270        {
271            *done = true;
272        }
273    });
274}
275
276// ---------------------------------------------------------------------------
277// Main
278// ---------------------------------------------------------------------------
279
280#[tokio::main]
281async fn main() -> io::Result<()> {
282    let (mut app, handle) = Application::builder()
283        .state(AppState::new())
284        .view(chat_view)
285        .on_commit(|_, state: &mut AppState| {
286            state.messages.remove(0);
287        })
288        .build()?;
289
290    app.update(|_| {});
291    app.flush(&mut io::stdout())?;
292
293    let h = handle;
294    app.run_interactive(move |event, state| {
295        if let Event::Key(KeyEvent {
296            code,
297            kind: KeyEventKind::Press,
298            modifiers,
299            ..
300        }) = event
301        {
302            if modifiers.contains(KeyModifiers::CONTROL) {
303                return ControlFlow::Continue;
304            }
305
306            match code {
307                KeyCode::Char(c) => {
308                    state.input.insert(state.cursor, *c);
309                    state.cursor += c.len_utf8();
310                }
311                KeyCode::Backspace => {
312                    if state.cursor > 0 {
313                        state.cursor -= 1;
314                        state.input.remove(state.cursor);
315                    }
316                }
317                KeyCode::Left => {
318                    state.cursor = state.cursor.saturating_sub(1);
319                }
320                KeyCode::Right => {
321                    if state.cursor < state.input.len() {
322                        state.cursor += 1;
323                    }
324                }
325                KeyCode::Enter => {
326                    if !state.input.is_empty() {
327                        let text = std::mem::take(&mut state.input);
328                        state.cursor = 0;
329                        let user_id = state.next_id();
330                        state.messages.push(ChatMessage {
331                            id: user_id,
332                            kind: MessageKind::User(text),
333                        });
334
335                        let assistant_id = state.next_id();
336                        state.messages.push(ChatMessage {
337                            id: assistant_id,
338                            kind: MessageKind::Assistant {
339                                content: String::new(),
340                                done: false,
341                            },
342                        });
343
344                        let h2 = h.clone();
345                        tokio::spawn(async move {
346                            stream_response(h2, assistant_id).await;
347                        });
348                    }
349                }
350                KeyCode::Esc => {
351                    return ControlFlow::Exit;
352                }
353                _ => {}
354            }
355        }
356        ControlFlow::Continue
357    })
358    .await
359}