1use 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
31struct 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#[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 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 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 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 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 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 }
155
156 fn is_focusable(&self, _state: &()) -> bool {
157 true
158 }
159
160 fn cursor_position(&self, area: Rect, _state: &()) -> Option<(u16, u16)> {
161 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 eye_declare::EventResult::Ignored
177 }
178}
179
180#[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
221fn 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 els.add(Markdown::new(format!("{}▌", content))).key(key);
250 }
251 }
252 }
253 }
254
255 els.add(TextBlock::new());
257
258 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
269const 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 let response = RESPONSES[(msg_id / 2) as usize % RESPONSES.len()];
306
307 tokio::time::sleep(Duration::from_millis(500)).await;
309
310 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 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 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#[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 app.update(|_| {});
358 app.flush(&mut io::stdout())?;
359 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 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 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 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 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}