Skip to main content

perspt_tui/
chat_app.rs

1//! Chat Application for Perspt TUI
2//!
3//! An elegant chat interface with markdown rendering, syntax highlighting,
4//! and reliable key handling. Now with async event-driven architecture.
5
6use 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/// Role of a chat message
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum MessageRole {
30    User,
31    Assistant,
32    System,
33}
34
35/// A single chat message
36#[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
65/// Elegant Chat application state
66pub struct ChatApp {
67    /// Chat message history
68    messages: Vec<ChatMessage>,
69    /// Simple input widget
70    input: SimpleInput,
71    /// Scroll offset for message display
72    scroll_offset: usize,
73    /// Buffer for streaming response
74    streaming_buffer: String,
75    /// Whether currently streaming a response
76    is_streaming: bool,
77    /// LLM provider
78    provider: Arc<GenAIProvider>,
79    /// Model to use
80    model: String,
81    /// Throbber state for loading animation
82    throbber_state: ThrobberState,
83    /// Theme for styling
84    #[allow(dead_code)]
85    theme: Theme,
86    /// Should quit the application
87    should_quit: bool,
88    /// Receiver for streaming chunks
89    stream_rx: Option<mpsc::UnboundedReceiver<String>>,
90    /// Total visual lines in messages (for scrolling) - after wrapping
91    total_visual_lines: usize,
92    /// Auto-scroll to bottom flag (set during streaming)
93    auto_scroll: bool,
94    /// Visible height of message area (updated during render)
95    visible_height: usize,
96    /// Flag to indicate a message send is pending (for async handling)
97    pending_send: bool,
98    /// Last viewport width used for wrapping (to detect resize)
99    last_viewport_width: usize,
100}
101
102impl ChatApp {
103    /// Create a new chat application
104    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, // Start with auto-scroll enabled
121            visible_height: 20,
122            pending_send: false,
123            last_viewport_width: 80,
124        }
125    }
126
127    /// Run the chat application main loop
128    pub async fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
129        loop {
130            // Render
131            terminal.draw(|frame| self.render(frame))?;
132
133            // Handle streaming updates - drain ALL pending chunks before rendering
134            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            // Immediate re-render after finalization to show final content without delay
158            if just_finalized {
159                terminal.draw(|frame| self.render(frame))?;
160            }
161
162            // Event handling
163            let timeout = if self.is_streaming {
164                std::time::Duration::from_millis(16) // ~60fps for smooth streaming
165            } 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                            // Quit
178                            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                            // Send message on Enter
185                            KeyCode::Enter if !self.is_streaming => {
186                                if !self.input.is_empty() {
187                                    self.send_message().await?;
188                                }
189                            }
190                            // Newline with Ctrl+J (reliable across terminals)
191                            KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
192                                if !self.is_streaming {
193                                    self.input.insert_newline();
194                                }
195                            }
196                            // Also support Ctrl+Enter for newline
197                            KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
198                                if !self.is_streaming {
199                                    self.input.insert_newline();
200                                }
201                            }
202                            // Scroll
203                            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                            // Input navigation
212                            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                            // Text editing
219                            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            // Update throbber
239            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    /// Handle an AppEvent from the async event loop
252    ///
253    /// Returns `true` to continue running, `false` to quit.
254    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 but continue
274                log::error!("App error: {}", e);
275                true
276            }
277            AppEvent::AgentUpdate(_) => true, // Not used in chat mode
278            AppEvent::CoreEvent(_) => true,   // Not used in chat mode
279        }
280    }
281
282    /// Handle a terminal event (key press, mouse, resize)
283    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                    // Quit
292                    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                    // Send message on Enter (needs special handling - sets pending_send flag)
299                    KeyCode::Enter if !self.is_streaming => {
300                        if !self.input.is_empty() {
301                            self.pending_send = true;
302                        }
303                    }
304                    // Newline with Ctrl+J
305                    KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => {
306                        if !self.is_streaming {
307                            self.input.insert_newline();
308                        }
309                    }
310                    // Ctrl+Enter for newline
311                    KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
312                        if !self.is_streaming {
313                            self.input.insert_newline();
314                        }
315                    }
316                    // Scroll
317                    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                    // Input navigation
326                    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                    // Text editing
333                    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                // Terminal resize - render will handle it
350            }
351            _ => {}
352        }
353        true
354    }
355
356    /// Check if a message send is pending (set by Enter key in handle_terminal_event)
357    pub fn is_send_pending(&self) -> bool {
358        self.pending_send
359    }
360
361    /// Clear the pending send flag
362    pub fn clear_pending_send(&mut self) {
363        self.pending_send = false;
364    }
365
366    /// Check and process pending stream chunks
367    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    /// Check if a render is needed
390    pub fn needs_render(&self) -> bool {
391        self.is_streaming || self.pending_send
392    }
393
394    /// Send the current message to the LLM
395    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        // Add user message
402        self.messages.push(ChatMessage::user(user_message.clone()));
403        self.input.clear();
404
405        // Build context
406        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        // Start streaming
424        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    /// Finalize streaming and add assistant message
444    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    /// Scroll up (disables auto-scroll)
456    fn scroll_up(&mut self, n: usize) {
457        self.auto_scroll = false; // User is manually scrolling
458        self.scroll_offset = self.scroll_offset.saturating_sub(n);
459    }
460
461    /// Scroll down
462    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; // Re-enable auto-scroll when at bottom
468        }
469    }
470
471    /// Enable auto-scroll to bottom (actual scroll happens in render)
472    fn scroll_to_bottom(&mut self) {
473        self.auto_scroll = true;
474    }
475
476    /// Wrap a single line of text to fit within the given width.
477    /// Returns a vector of wrapped lines (as owned Strings).
478    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                // Push current line and start new one
492                result.push(std::mem::take(&mut current_line));
493                current_width = 0;
494            }
495
496            // Handle very long words that exceed width
497            if word_width > width {
498                // Split the word character by character
499                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    /// Render the chat application
526    fn render(&mut self, frame: &mut Frame) {
527        let size = frame.area();
528
529        // Calculate input height dynamically
530        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),            // Header
536                Constraint::Min(10),              // Messages
537                Constraint::Length(input_height), // Input
538            ])
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    /// Render elegant header
547    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        // Render block
568        frame.render_widget(header, area);
569
570        // Render model name on right side
571        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    /// Render messages with markdown support and virtual scrolling
581    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        // Update cached width for resize detection
597        self.last_viewport_width = viewport_width;
598        self.visible_height = viewport_height;
599
600        // Step 1: Collect all logical lines as (text, style) tuples
601        let mut logical_lines: Vec<(String, Style)> = Vec::new();
602
603        for msg in &self.messages {
604            // Message header with role
605            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            // Add separator line (headers don't wrap - they're short)
630            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            // Render message content
644            if msg.role == MessageRole::Assistant {
645                // For assistant messages, extract text from tui-markdown rendered output
646                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                // Plain text for user/system
653                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())); // Spacing
659        }
660
661        // Add streaming content
662        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            // Streaming cursor
680            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        // Step 2: Wrap each logical line to visual lines
689        let mut visual_lines: Vec<(String, Style)> = Vec::new();
690
691        for (text, style) in logical_lines {
692            if text.is_empty() {
693                // Empty line stays as is
694                visual_lines.push((text, style));
695            } else if text.width() <= viewport_width {
696                // Line fits, no wrapping needed
697                visual_lines.push((text, style));
698            } else {
699                // Need to wrap - create new lines from wrapped text
700                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        // Handle throbber when loading with empty buffer
708        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        // Step 3: Calculate scroll position using visual line count
720        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        // Update scroll_offset to actual position
732        self.scroll_offset = scroll_pos;
733
734        // Step 4: Slice visible range and convert to Lines (virtual scrolling - key fix!)
735        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        // Step 5: Render only the visible slice (NO Paragraph::scroll needed!)
743        let paragraph = Paragraph::new(Text::from(visible_lines));
744        frame.render_widget(paragraph, inner);
745
746        // Scrollbar with accurate visual line count
747        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    /// Render input area
757    fn render_input(&self, frame: &mut Frame, area: Rect) {
758        if self.is_streaming {
759            // Show streaming indicator
760            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            // Render input with hint
775            self.input
776                .render(frame, area, "Enter=send │ Ctrl+J=newline");
777        }
778    }
779}
780
781/// Run the chat TUI
782pub 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    // Enable mouse capture
788    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    // Restore terminal
796    ratatui::restore();
797    execute!(stdout(), DisableMouseCapture)?;
798
799    result
800}