1use crate::app_event::AppEvent;
7use crate::simple_input::SimpleInput;
8use crate::theme::{icons, Theme};
9use anyhow::Result;
10use crossterm::event::{
11 Event as CrosstermEvent, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind,
12};
13use perspt_core::{GenAIProvider, EOT_SIGNAL};
14use ratatui::{
15 crossterm::event::{self, Event},
16 layout::{Constraint, Direction, Layout, Margin, Rect},
17 style::{Color, Modifier, Style},
18 text::{Line, Span, Text},
19 widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState},
20 DefaultTerminal, Frame,
21};
22use std::sync::Arc;
23use throbber_widgets_tui::{Throbber, ThrobberState};
24use tokio::sync::mpsc;
25use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum MessageRole {
30 User,
31 Assistant,
32 System,
33}
34
35#[derive(Debug, Clone)]
37pub struct ChatMessage {
38 pub role: MessageRole,
39 pub content: String,
40}
41
42impl ChatMessage {
43 pub fn user(content: impl Into<String>) -> Self {
44 Self {
45 role: MessageRole::User,
46 content: content.into(),
47 }
48 }
49
50 pub fn assistant(content: impl Into<String>) -> Self {
51 Self {
52 role: MessageRole::Assistant,
53 content: content.into(),
54 }
55 }
56
57 pub fn system(content: impl Into<String>) -> Self {
58 Self {
59 role: MessageRole::System,
60 content: content.into(),
61 }
62 }
63}
64
65pub struct ChatApp {
67 messages: Vec<ChatMessage>,
69 input: SimpleInput,
71 scroll_offset: usize,
73 streaming_buffer: String,
75 is_streaming: bool,
77 provider: Arc<GenAIProvider>,
79 model: String,
81 throbber_state: ThrobberState,
83 #[allow(dead_code)]
85 theme: Theme,
86 should_quit: bool,
88 stream_rx: Option<mpsc::UnboundedReceiver<String>>,
90 total_visual_lines: usize,
92 auto_scroll: bool,
94 visible_height: usize,
96 pending_send: bool,
98 last_viewport_width: usize,
100}
101
102impl ChatApp {
103 pub fn new(provider: GenAIProvider, model: String) -> Self {
105 Self {
106 messages: vec![ChatMessage::system(
107 "Welcome to Perspt! Type your message and press Enter to send.",
108 )],
109 input: SimpleInput::new(),
110 scroll_offset: 0,
111 streaming_buffer: String::new(),
112 is_streaming: false,
113 provider: Arc::new(provider),
114 model,
115 throbber_state: ThrobberState::default(),
116 theme: Theme::default(),
117 should_quit: false,
118 stream_rx: None,
119 total_visual_lines: 0,
120 auto_scroll: true, visible_height: 20,
122 pending_send: false,
123 last_viewport_width: 80,
124 }
125 }
126
127 pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
129 loop {
130 terminal.draw(|frame| self.render(frame))?;
132
133 let mut just_finalized = false;
135 if let Some(ref mut rx) = self.stream_rx {
136 loop {
137 match rx.try_recv() {
138 Ok(chunk) => {
139 if chunk == EOT_SIGNAL {
140 self.finalize_streaming();
141 just_finalized = true;
142 break;
143 } else {
144 self.streaming_buffer.push_str(&chunk);
145 }
146 }
147 Err(mpsc::error::TryRecvError::Empty) => break,
148 Err(mpsc::error::TryRecvError::Disconnected) => {
149 self.finalize_streaming();
150 just_finalized = true;
151 break;
152 }
153 }
154 }
155 }
156
157 if just_finalized {
159 terminal.draw(|frame| self.render(frame))?;
160 }
161
162 let timeout = if self.is_streaming {
164 std::time::Duration::from_millis(16) } else {
166 std::time::Duration::from_millis(100)
167 };
168
169 if event::poll(timeout)? {
170 match event::read()? {
171 Event::Key(key) => {
172 if key.kind != KeyEventKind::Press {
173 continue;
174 }
175
176 match key.code {
177 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
179 self.should_quit = true;
180 }
181 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
182 self.should_quit = true;
183 }
184 KeyCode::Enter if !self.is_streaming => {
186 if !self.input.is_empty() {
187 self.send_message().await?;
188 }
189 }
190 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
192 if !self.is_streaming {
193 self.input.insert_newline();
194 }
195 }
196 KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
198 if !self.is_streaming {
199 self.input.insert_newline();
200 }
201 }
202 KeyCode::PageUp => self.scroll_up(10),
204 KeyCode::PageDown => self.scroll_down(10),
205 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
206 self.scroll_up(1)
207 }
208 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
209 self.scroll_down(1)
210 }
211 KeyCode::Left => self.input.move_left(),
213 KeyCode::Right => self.input.move_right(),
214 KeyCode::Up => self.input.move_up(),
215 KeyCode::Down => self.input.move_down(),
216 KeyCode::Home => self.input.move_home(),
217 KeyCode::End => self.input.move_end(),
218 KeyCode::Backspace => self.input.backspace(),
220 KeyCode::Delete => self.input.delete(),
221 KeyCode::Char(c) => {
222 if !self.is_streaming {
223 self.input.insert_char(c);
224 }
225 }
226 _ => {}
227 }
228 }
229 Event::Mouse(mouse) => match mouse.kind {
230 MouseEventKind::ScrollUp => self.scroll_up(3),
231 MouseEventKind::ScrollDown => self.scroll_down(3),
232 _ => {}
233 },
234 _ => {}
235 }
236 }
237
238 if self.is_streaming {
240 self.throbber_state.calc_next();
241 }
242
243 if self.should_quit {
244 break;
245 }
246 }
247
248 Ok(())
249 }
250
251 pub fn handle_app_event(&mut self, event: AppEvent) -> bool {
255 match event {
256 AppEvent::Terminal(crossterm_event) => self.handle_terminal_event(crossterm_event),
257 AppEvent::StreamChunk(chunk) => {
258 self.streaming_buffer.push_str(&chunk);
259 true
260 }
261 AppEvent::StreamComplete => {
262 self.finalize_streaming();
263 true
264 }
265 AppEvent::Tick => {
266 if self.is_streaming {
267 self.throbber_state.calc_next();
268 }
269 true
270 }
271 AppEvent::Quit => false,
272 AppEvent::Error(e) => {
273 log::error!("App error: {}", e);
275 true
276 }
277 AppEvent::AgentUpdate(_) => true, AppEvent::CoreEvent(_) => true, }
280 }
281
282 fn handle_terminal_event(&mut self, event: CrosstermEvent) -> bool {
284 match event {
285 CrosstermEvent::Key(key) => {
286 if key.kind != KeyEventKind::Press {
287 return true;
288 }
289
290 match key.code {
291 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
293 return false;
294 }
295 KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
296 return false;
297 }
298 KeyCode::Enter if !self.is_streaming => {
300 if !self.input.is_empty() {
301 self.pending_send = true;
302 }
303 }
304 KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
306 if !self.is_streaming {
307 self.input.insert_newline();
308 }
309 }
310 KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
312 if !self.is_streaming {
313 self.input.insert_newline();
314 }
315 }
316 KeyCode::PageUp => self.scroll_up(10),
318 KeyCode::PageDown => self.scroll_down(10),
319 KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
320 self.scroll_up(1)
321 }
322 KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
323 self.scroll_down(1)
324 }
325 KeyCode::Left => self.input.move_left(),
327 KeyCode::Right => self.input.move_right(),
328 KeyCode::Up => self.input.move_up(),
329 KeyCode::Down => self.input.move_down(),
330 KeyCode::Home => self.input.move_home(),
331 KeyCode::End => self.input.move_end(),
332 KeyCode::Backspace => self.input.backspace(),
334 KeyCode::Delete => self.input.delete(),
335 KeyCode::Char(c) => {
336 if !self.is_streaming {
337 self.input.insert_char(c);
338 }
339 }
340 _ => {}
341 }
342 }
343 CrosstermEvent::Mouse(mouse) => match mouse.kind {
344 MouseEventKind::ScrollUp => self.scroll_up(3),
345 MouseEventKind::ScrollDown => self.scroll_down(3),
346 _ => {}
347 },
348 CrosstermEvent::Resize(_, _) => {
349 }
351 _ => {}
352 }
353 true
354 }
355
356 pub fn is_send_pending(&self) -> bool {
358 self.pending_send
359 }
360
361 pub fn clear_pending_send(&mut self) {
363 self.pending_send = false;
364 }
365
366 pub fn process_stream_chunks(&mut self) {
368 if let Some(ref mut rx) = self.stream_rx {
369 loop {
370 match rx.try_recv() {
371 Ok(chunk) => {
372 if chunk == EOT_SIGNAL {
373 self.finalize_streaming();
374 break;
375 } else {
376 self.streaming_buffer.push_str(&chunk);
377 }
378 }
379 Err(mpsc::error::TryRecvError::Empty) => break,
380 Err(mpsc::error::TryRecvError::Disconnected) => {
381 self.finalize_streaming();
382 break;
383 }
384 }
385 }
386 }
387 }
388
389 pub fn needs_render(&self) -> bool {
391 self.is_streaming || self.pending_send
392 }
393
394 async fn send_message(&mut self) -> Result<()> {
396 let user_message = self.input.text().trim().to_string();
397 if user_message.is_empty() {
398 return Ok(());
399 }
400
401 self.messages.push(ChatMessage::user(user_message.clone()));
403 self.input.clear();
404
405 let context: Vec<String> = self
407 .messages
408 .iter()
409 .filter(|m| m.role != MessageRole::System)
410 .map(|m| {
411 format!(
412 "{}: {}",
413 match m.role {
414 MessageRole::User => "User",
415 MessageRole::Assistant => "Assistant",
416 MessageRole::System => "System",
417 },
418 m.content
419 )
420 })
421 .collect();
422
423 self.is_streaming = true;
425 self.streaming_buffer.clear();
426 self.scroll_to_bottom();
427
428 let (tx, rx) = mpsc::unbounded_channel();
429 self.stream_rx = Some(rx);
430
431 let provider = Arc::clone(&self.provider);
432 let model = self.model.clone();
433
434 tokio::spawn(async move {
435 let _ = provider
436 .generate_response_stream_to_channel(&model, &context.join("\n"), tx)
437 .await;
438 });
439
440 Ok(())
441 }
442
443 fn finalize_streaming(&mut self) {
445 if !self.streaming_buffer.is_empty() {
446 self.messages
447 .push(ChatMessage::assistant(self.streaming_buffer.clone()));
448 }
449 self.streaming_buffer.clear();
450 self.is_streaming = false;
451 self.stream_rx = None;
452 self.scroll_to_bottom();
453 }
454
455 fn scroll_up(&mut self, n: usize) {
457 self.auto_scroll = false; self.scroll_offset = self.scroll_offset.saturating_sub(n);
459 }
460
461 fn scroll_down(&mut self, n: usize) {
463 self.scroll_offset = self.scroll_offset.saturating_add(n);
464 let max = self.total_visual_lines.saturating_sub(self.visible_height);
465 if self.scroll_offset >= max {
466 self.scroll_offset = max;
467 self.auto_scroll = true; }
469 }
470
471 fn scroll_to_bottom(&mut self) {
473 self.auto_scroll = true;
474 }
475
476 fn wrap_text_to_width(text: &str, width: usize) -> Vec<String> {
479 if width == 0 {
480 return vec![text.to_string()];
481 }
482
483 let mut result = Vec::new();
484 let mut current_line = String::new();
485 let mut current_width = 0;
486
487 for word in text.split_inclusive(|c: char| c.is_whitespace()) {
488 let word_width = word.width();
489
490 if current_width + word_width > width && !current_line.is_empty() {
491 result.push(std::mem::take(&mut current_line));
493 current_width = 0;
494 }
495
496 if word_width > width {
498 for ch in word.chars() {
500 let ch_width = ch.width().unwrap_or(1);
501 if current_width + ch_width > width && !current_line.is_empty() {
502 result.push(std::mem::take(&mut current_line));
503 current_width = 0;
504 }
505 current_line.push(ch);
506 current_width += ch_width;
507 }
508 } else {
509 current_line.push_str(word);
510 current_width += word_width;
511 }
512 }
513
514 if !current_line.is_empty() {
515 result.push(current_line);
516 }
517
518 if result.is_empty() {
519 result.push(String::new());
520 }
521
522 result
523 }
524
525 fn render(&mut self, frame: &mut Frame) {
527 let size = frame.area();
528
529 let input_height = (self.input.line_count() as u16 + 2).clamp(3, 10);
531
532 let chunks = Layout::default()
533 .direction(Direction::Vertical)
534 .constraints([
535 Constraint::Length(3), Constraint::Min(10), Constraint::Length(input_height), ])
539 .split(size);
540
541 self.render_header(frame, chunks[0]);
542 self.render_messages(frame, chunks[1]);
543 self.render_input(frame, chunks[2]);
544 }
545
546 fn render_header(&self, frame: &mut Frame, area: Rect) {
548 let header = Block::default()
549 .borders(Borders::ALL)
550 .border_style(Style::default().fg(Color::Rgb(96, 125, 139)))
551 .title(Span::styled(
552 format!(" {} Perspt Chat ", icons::ROCKET),
553 Style::default()
554 .fg(Color::Rgb(129, 199, 132))
555 .add_modifier(Modifier::BOLD),
556 ))
557 .title_alignment(ratatui::layout::HorizontalAlignment::Left);
558
559 let model_display = format!(" {} ", self.model);
560 let model_span = Span::styled(
561 model_display,
562 Style::default()
563 .fg(Color::Rgb(176, 190, 197))
564 .add_modifier(Modifier::ITALIC),
565 );
566
567 frame.render_widget(header, area);
569
570 let model_area = Rect {
572 x: area.x + area.width - self.model.len() as u16 - 4,
573 y: area.y,
574 width: self.model.len() as u16 + 3,
575 height: 1,
576 };
577 frame.render_widget(Paragraph::new(model_span), model_area);
578 }
579
580 fn render_messages(&mut self, frame: &mut Frame, area: Rect) {
582 let block = Block::default()
583 .borders(Borders::ALL)
584 .border_style(Style::default().fg(Color::Rgb(96, 125, 139)))
585 .title(Span::styled(
586 " Messages ",
587 Style::default().fg(Color::Rgb(176, 190, 197)),
588 ));
589
590 let inner = block.inner(area);
591 frame.render_widget(block, area);
592
593 let viewport_width = inner.width as usize;
594 let viewport_height = inner.height as usize;
595
596 self.last_viewport_width = viewport_width;
598 self.visible_height = viewport_height;
599
600 let mut logical_lines: Vec<(String, Style)> = Vec::new();
602
603 for msg in &self.messages {
604 let (icon, header_style, content_style) = match msg.role {
606 MessageRole::User => (
607 icons::USER,
608 Style::default()
609 .fg(Color::Rgb(129, 199, 132))
610 .add_modifier(Modifier::BOLD),
611 Style::default().fg(Color::Rgb(224, 247, 250)),
612 ),
613 MessageRole::Assistant => (
614 icons::ASSISTANT,
615 Style::default()
616 .fg(Color::Rgb(144, 202, 249))
617 .add_modifier(Modifier::BOLD),
618 Style::default().fg(Color::Rgb(189, 189, 189)),
619 ),
620 MessageRole::System => (
621 icons::SYSTEM,
622 Style::default()
623 .fg(Color::Rgb(176, 190, 197))
624 .add_modifier(Modifier::ITALIC),
625 Style::default().fg(Color::Rgb(158, 158, 158)),
626 ),
627 };
628
629 logical_lines.push((
631 format!(
632 "━━━ {} {} ━━━",
633 icon,
634 match msg.role {
635 MessageRole::User => "You",
636 MessageRole::Assistant => "Assistant",
637 MessageRole::System => "System",
638 }
639 ),
640 header_style,
641 ));
642
643 if msg.role == MessageRole::Assistant {
645 let rendered = tui_markdown::from_str(&msg.content);
647 for line in rendered.lines {
648 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
649 logical_lines.push((text, content_style));
650 }
651 } else {
652 for line in msg.content.lines() {
654 logical_lines.push((format!(" {}", line), content_style));
655 }
656 }
657
658 logical_lines.push((String::new(), Style::default())); }
660
661 if self.is_streaming && !self.streaming_buffer.is_empty() {
663 let header_style = Style::default()
664 .fg(Color::Rgb(144, 202, 249))
665 .add_modifier(Modifier::BOLD);
666 let content_style = Style::default().fg(Color::Rgb(189, 189, 189));
667
668 logical_lines.push((
669 format!("━━━ {} Assistant ━━━", icons::ASSISTANT),
670 header_style,
671 ));
672
673 let rendered = tui_markdown::from_str(&self.streaming_buffer);
674 for line in rendered.lines {
675 let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
676 logical_lines.push((text, content_style));
677 }
678
679 logical_lines.push((
681 "▌".to_string(),
682 Style::default()
683 .fg(Color::Rgb(129, 212, 250))
684 .add_modifier(Modifier::SLOW_BLINK),
685 ));
686 }
687
688 let mut visual_lines: Vec<(String, Style)> = Vec::new();
690
691 for (text, style) in logical_lines {
692 if text.is_empty() {
693 visual_lines.push((text, style));
695 } else if text.width() <= viewport_width {
696 visual_lines.push((text, style));
698 } else {
699 let wrapped = Self::wrap_text_to_width(&text, viewport_width);
701 for wrapped_line in wrapped {
702 visual_lines.push((wrapped_line, style));
703 }
704 }
705 }
706
707 if self.is_streaming && self.streaming_buffer.is_empty() {
709 let throbber = Throbber::default()
710 .label(" Thinking...")
711 .style(Style::default().fg(Color::Rgb(255, 183, 77)));
712 frame.render_stateful_widget(
713 throbber,
714 Rect::new(inner.x + 1, inner.y + 1, 20, 1),
715 &mut self.throbber_state.clone(),
716 );
717 }
718
719 let total_visual = visual_lines.len();
721 self.total_visual_lines = total_visual;
722
723 let max_scroll = total_visual.saturating_sub(viewport_height);
724
725 let scroll_pos = if self.auto_scroll {
726 max_scroll
727 } else {
728 self.scroll_offset.min(max_scroll)
729 };
730
731 self.scroll_offset = scroll_pos;
733
734 let visible_lines: Vec<Line> = visual_lines
736 .into_iter()
737 .skip(scroll_pos)
738 .take(viewport_height)
739 .map(|(text, style)| Line::from(Span::styled(text, style)))
740 .collect();
741
742 let paragraph = Paragraph::new(Text::from(visible_lines));
744 frame.render_widget(paragraph, inner);
745
746 if total_visual > viewport_height {
748 let scrollbar = Scrollbar::default()
749 .orientation(ScrollbarOrientation::VerticalRight)
750 .thumb_style(Style::default().fg(Color::Rgb(96, 125, 139)));
751 let mut state = ScrollbarState::new(total_visual).position(scroll_pos);
752 frame.render_stateful_widget(scrollbar, area.inner(Margin::new(0, 1)), &mut state);
753 }
754 }
755
756 fn render_input(&self, frame: &mut Frame, area: Rect) {
758 if self.is_streaming {
759 let block = Block::default()
761 .borders(Borders::ALL)
762 .border_style(Style::default().fg(Color::Rgb(96, 125, 139)))
763 .title(Span::styled(
764 " Receiving response... ",
765 Style::default().fg(Color::Rgb(255, 183, 77)),
766 ));
767 let inner = block.inner(area);
768 frame.render_widget(block, area);
769
770 let text = Paragraph::new("Press Ctrl+C to cancel")
771 .style(Style::default().fg(Color::Rgb(120, 144, 156)));
772 frame.render_widget(text, inner);
773 } else {
774 self.input
776 .render(frame, area, "Enter=send │ Ctrl+J=newline");
777 }
778 }
779}
780
781pub async fn run_chat_tui(provider: GenAIProvider, model: String) -> Result<()> {
783 use ratatui::crossterm::event::{DisableMouseCapture, EnableMouseCapture};
784 use ratatui::crossterm::execute;
785 use std::io::stdout;
786
787 execute!(stdout(), EnableMouseCapture)?;
789
790 let mut terminal = ratatui::init();
791 let mut app = ChatApp::new(provider, model);
792
793 let result = app.run(&mut terminal).await;
794
795 ratatui::restore();
797 execute!(stdout(), DisableMouseCapture)?;
798
799 result
800}