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, Component, ControlFlow, Elements, Handle, Hooks, Markdown, TextBlock,
21};
22use ratatui_core::{
23    buffer::Buffer,
24    layout::Rect,
25    style::{Color, Modifier, Style},
26    text::{Line, Span},
27    widgets::Widget,
28};
29use ratatui_widgets::paragraph::Paragraph;
30
31// ---------------------------------------------------------------------------
32// Application state
33// ---------------------------------------------------------------------------
34
35struct AppState {
36    messages: Vec<ChatMessage>,
37    next_id: u64,
38    input: String,
39    cursor: usize,
40}
41
42impl AppState {
43    fn new() -> Self {
44        Self {
45            messages: vec![],
46            next_id: 0,
47            input: String::new(),
48            cursor: 0,
49        }
50    }
51
52    fn next_id(&mut self) -> u64 {
53        let id = self.next_id;
54        self.next_id += 1;
55        id
56    }
57}
58
59struct ChatMessage {
60    id: u64,
61    kind: MessageKind,
62}
63
64enum MessageKind {
65    User(String),
66    Assistant { content: String, done: bool },
67}
68
69// ---------------------------------------------------------------------------
70// InputBox component — bordered text input
71// ---------------------------------------------------------------------------
72
73#[derive(Default)]
74struct InputBox {
75    pub text: String,
76    pub cursor: usize,
77    pub prompt: String,
78}
79
80impl Component for InputBox {
81    type State = ();
82
83    fn lifecycle(&self, hooks: &mut Hooks<Self::State>, _state: &Self::State) {
84        hooks.use_autofocus();
85    }
86
87    fn render(&self, area: Rect, buf: &mut Buffer, _state: &()) {
88        if area.height < 3 || area.width < 4 {
89            return;
90        }
91
92        let w = area.width;
93        let h = area.height;
94
95        // Top border with prompt label
96        let label = format!(" {} ", self.prompt);
97        let label_width = label.len() as u16;
98        let top_right_dashes = w.saturating_sub(3 + label_width);
99        let top_line = format!("┌─{}{}┐", label, "─".repeat(top_right_dashes as usize),);
100        buf.set_string(
101            area.x,
102            area.y,
103            &top_line,
104            Style::default().fg(Color::DarkGray),
105        );
106        // Highlight the label
107        buf.set_style(
108            Rect::new(area.x + 2, area.y, label_width, 1),
109            Style::default()
110                .fg(Color::Cyan)
111                .add_modifier(Modifier::BOLD),
112        );
113
114        // Bottom border
115        let bottom_line = format!("└{}┘", "─".repeat((w - 2) as usize));
116        buf.set_string(
117            area.x,
118            area.y + h - 1,
119            &bottom_line,
120            Style::default().fg(Color::DarkGray),
121        );
122
123        // Side borders
124        for y in (area.y + 1)..(area.y + h - 1) {
125            buf.set_string(area.x, y, "│", Style::default().fg(Color::DarkGray));
126            buf.set_string(area.x + w - 1, y, "│", Style::default().fg(Color::DarkGray));
127        }
128
129        // Text content inside border
130        let inner = Rect::new(
131            area.x + 2,
132            area.y + 1,
133            w.saturating_sub(4),
134            h.saturating_sub(2),
135        );
136        if inner.width > 0 && inner.height > 0 {
137            let text_style = Style::default().fg(Color::White);
138            let display = if self.text.is_empty() {
139                Line::from(Span::styled(
140                    "Type a message...",
141                    Style::default()
142                        .fg(Color::DarkGray)
143                        .add_modifier(Modifier::ITALIC),
144                ))
145            } else {
146                Line::from(Span::styled(&self.text, text_style))
147            };
148            Paragraph::new(display).render(inner, buf);
149        }
150    }
151
152    fn desired_height(&self, _width: u16, _state: &()) -> u16 {
153        3 // border-top + content + border-bottom
154    }
155
156    fn is_focusable(&self, _state: &()) -> bool {
157        true
158    }
159
160    fn cursor_position(&self, area: Rect, _state: &()) -> Option<(u16, u16)> {
161        // Cursor inside the border, offset by left border + padding
162        let col = 2 + self.cursor as u16;
163        if col < area.width.saturating_sub(1) {
164            Some((col, 1))
165        } else {
166            Some((area.width.saturating_sub(2), 1))
167        }
168    }
169
170    fn handle_event(
171        &self,
172        _event: &crossterm::event::Event,
173        _state: &mut eye_declare::Tracked<()>,
174    ) -> eye_declare::EventResult {
175        // Events handled by the app handler, not here
176        eye_declare::EventResult::Ignored
177    }
178}
179
180// ---------------------------------------------------------------------------
181// StreamingDots — animated indicator while streaming
182// ---------------------------------------------------------------------------
183
184#[derive(Default)]
185struct StreamingDots;
186
187#[derive(Default)]
188struct StreamingDotsState {
189    frame: usize,
190}
191
192impl Component for StreamingDots {
193    type State = StreamingDotsState;
194
195    fn render(&self, area: Rect, buf: &mut Buffer, state: &Self::State) {
196        let dots = match state.frame % 4 {
197            0 => "   ",
198            1 => ".  ",
199            2 => ".. ",
200            _ => "...",
201        };
202        let line = Line::from(Span::styled(dots, Style::default().fg(Color::DarkGray)));
203        Paragraph::new(line).render(area, buf);
204    }
205
206    fn desired_height(&self, _width: u16, _state: &Self::State) -> u16 {
207        1
208    }
209
210    fn initial_state(&self) -> Option<StreamingDotsState> {
211        Some(StreamingDotsState { frame: 0 })
212    }
213
214    fn lifecycle(&self, hooks: &mut Hooks<StreamingDotsState>, _state: &StreamingDotsState) {
215        hooks.use_interval(Duration::from_millis(300), |s| {
216            s.frame = s.frame.wrapping_add(1);
217        });
218    }
219}
220
221// ---------------------------------------------------------------------------
222// View function
223// ---------------------------------------------------------------------------
224
225fn chat_view(state: &AppState) -> Elements {
226    let mut els = Elements::new();
227
228    for msg in &state.messages {
229        let key = format!("msg-{}", msg.id);
230        match &msg.kind {
231            MessageKind::User(text) => {
232                els.add(
233                    TextBlock::new().line(
234                        format!("> {}", text),
235                        Style::default()
236                            .fg(Color::Cyan)
237                            .add_modifier(Modifier::BOLD),
238                    ),
239                )
240                .key(key);
241            }
242            MessageKind::Assistant { content, done } => {
243                if *done {
244                    els.add(Markdown::new(content)).key(key);
245                } else if content.is_empty() {
246                    els.add(StreamingDots).key(key);
247                } else {
248                    // Show content with a blinking cursor
249                    els.add(Markdown::new(format!("{}▌", content))).key(key);
250                }
251            }
252        }
253    }
254
255    // Separator
256    els.add(TextBlock::new());
257
258    // Input box
259    els.add(InputBox {
260        text: state.input.clone(),
261        cursor: state.cursor,
262        prompt: "You".into(),
263    })
264    .key("input");
265
266    els
267}
268
269// ---------------------------------------------------------------------------
270// Streaming response simulation
271// ---------------------------------------------------------------------------
272
273const RESPONSES: &[&str] = &[
274    "Here's a quick overview of the key concepts:\n\n\
275     **Ownership** is Rust's most unique feature. Every value has a single \
276     owner, and the value is dropped when the owner goes out of scope. This \
277     eliminates the need for a garbage collector.\n\n\
278     **Borrowing** lets you reference a value without taking ownership. \
279     You can have either one mutable reference or any number of immutable \
280     references at a time.\n\n\
281     ```rust\nlet s = String::from(\"hello\");\nlet r = &s; // immutable borrow\nprintln!(\"{}\", r);\n```\n\n\
282     The borrow checker enforces these rules at compile time, preventing \
283     data races and use-after-free bugs entirely.",
284    "Here are a few approaches you could consider:\n\n\
285     - **Pattern matching** with `match` is exhaustive — the compiler \
286     ensures you handle every case\n\
287     - **Iterator chains** like `.filter().map().collect()` are zero-cost \
288     abstractions that compile to the same code as hand-written loops\n\
289     - **Error handling** with `Result<T, E>` and the `?` operator makes \
290     error propagation clean and explicit\n\n\
291     The Rust compiler is your ally here — lean into its suggestions.",
292    "Let me break that down step by step:\n\n\
293     ### Step 1: Define the trait\n\n\
294     ```rust\ntrait Drawable {\n    fn draw(&self, buf: &mut Buffer);\n}\n```\n\n\
295     ### Step 2: Implement for your types\n\n\
296     Each type provides its own `draw` implementation. The compiler \
297     generates static dispatch when possible.\n\n\
298     ### Step 3: Use trait objects for dynamic dispatch\n\n\
299     When you need heterogeneous collections, use `Box<dyn Drawable>`. \
300     This adds a vtable pointer but enables runtime polymorphism.",
301];
302
303async fn stream_response(handle: Handle<AppState>, msg_id: u64) {
304    // Pick a response based on message ID
305    let response = RESPONSES[(msg_id / 2) as usize % RESPONSES.len()];
306
307    // Small initial delay (thinking)
308    tokio::time::sleep(Duration::from_millis(500)).await;
309
310    // Stream word by word
311    let words: Vec<&str> = response
312        .split_inclusive(|c: char| c.is_whitespace() || c == '\n')
313        .collect();
314    for word in words {
315        let w = word.to_string();
316        handle.update(move |state| {
317            if let Some(msg) = state.messages.iter_mut().find(|m| m.id == msg_id)
318                && let MessageKind::Assistant { content, .. } = &mut msg.kind
319            {
320                content.push_str(&w);
321            }
322        });
323        // Vary speed for natural feel
324        let delay = if word.contains('\n') {
325            80
326        } else {
327            25 + (word.len() as u64 * 5)
328        };
329        tokio::time::sleep(Duration::from_millis(delay)).await;
330    }
331
332    // Mark as done
333    handle.update(move |state| {
334        if let Some(msg) = state.messages.iter_mut().find(|m| m.id == msg_id)
335            && let MessageKind::Assistant { done, .. } = &mut msg.kind
336        {
337            *done = true;
338        }
339    });
340}
341
342// ---------------------------------------------------------------------------
343// Main
344// ---------------------------------------------------------------------------
345
346#[tokio::main]
347async fn main() -> io::Result<()> {
348    let (mut app, handle) = Application::builder()
349        .state(AppState::new())
350        .view(chat_view)
351        .on_commit(|_, state: &mut AppState| {
352            state.messages.remove(0);
353        })
354        .build()?;
355
356    // Initial build + set focus on the input box
357    app.update(|_| {});
358    app.flush(&mut io::stdout())?;
359    // Find and focus the input
360    // let renderer = app.renderer();
361    // let container = renderer.children(renderer.root())[0];
362    // if let Some(input_id) = renderer.find_by_key(container, "input") {
363    //     renderer.set_focus(input_id);
364    // }
365
366    let h = handle;
367    app.run_interactive(move |event, state| {
368        if let Event::Key(KeyEvent {
369            code,
370            kind: KeyEventKind::Press,
371            modifiers,
372            ..
373        }) = event
374        {
375            // Ignore Ctrl+key combos (Ctrl+C handled by framework)
376            if modifiers.contains(KeyModifiers::CONTROL) {
377                return ControlFlow::Continue;
378            }
379
380            match code {
381                KeyCode::Char(c) => {
382                    state.input.insert(state.cursor, *c);
383                    state.cursor += c.len_utf8();
384                }
385                KeyCode::Backspace => {
386                    if state.cursor > 0 {
387                        state.cursor -= 1;
388                        state.input.remove(state.cursor);
389                    }
390                }
391                KeyCode::Left => {
392                    state.cursor = state.cursor.saturating_sub(1);
393                }
394                KeyCode::Right => {
395                    if state.cursor < state.input.len() {
396                        state.cursor += 1;
397                    }
398                }
399                KeyCode::Enter => {
400                    if !state.input.is_empty() {
401                        // Add user message
402                        let text = std::mem::take(&mut state.input);
403                        state.cursor = 0;
404                        let user_id = state.next_id();
405                        state.messages.push(ChatMessage {
406                            id: user_id,
407                            kind: MessageKind::User(text),
408                        });
409
410                        // Add assistant placeholder
411                        let assistant_id = state.next_id();
412                        state.messages.push(ChatMessage {
413                            id: assistant_id,
414                            kind: MessageKind::Assistant {
415                                content: String::new(),
416                                done: false,
417                            },
418                        });
419
420                        // Start streaming
421                        let h2 = h.clone();
422                        tokio::spawn(async move {
423                            stream_response(h2, assistant_id).await;
424                        });
425                    }
426                }
427                KeyCode::Esc => {
428                    return ControlFlow::Exit;
429                }
430                _ => {}
431            }
432        }
433        ControlFlow::Continue
434    })
435    .await
436}