1use 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
32struct 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#[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#[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
170fn 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
208const 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#[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}