Skip to main content

reflex/semantic/
chat_tui.rs

1//! Interactive TUI chat mode for `rfx ask`
2//!
3//! Provides a Claude Code-like interface with:
4//! - Fixed stats panel (top)
5//! - Scrollable message history (middle)
6//! - Fixed input box (bottom)
7
8use anyhow::{Context, Result};
9use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
10use ratatui::{
11    Frame, Terminal,
12    backend::CrosstermBackend,
13    layout::{Constraint, Direction, Layout, Rect},
14    style::{Color, Modifier, Style},
15    text::{Line, Span},
16    widgets::{Block, Borders, Paragraph},
17};
18use std::io;
19use std::sync::mpsc::{self, Receiver, Sender};
20use std::time::Duration;
21use textwrap;
22
23use crate::cache::CacheManager;
24
25use super::AgenticConfig;
26use super::chat_session::{ChatSession, MessageRole};
27
28/// Progress updates from async execution
29#[derive(Debug, Clone)]
30enum PhaseUpdate {
31    /// Phase 0: Triage - deciding whether to search or answer directly
32    Triaging,
33
34    /// Fast path: Answering from conversation context
35    AnsweringFromContext,
36
37    /// Phase 1: Thinking/Assessment (agentic path)
38    Thinking {
39        reasoning: String,
40        needs_context: bool,
41    },
42    /// Phase 2: Tool gathering (agentic path)
43    Tools {
44        content: String,
45        tool_calls: Vec<String>,
46    },
47    /// Phase 3: Query generation (agentic path)
48    Queries { queries: Vec<String> },
49    /// Phase 4: Execution status (agentic path)
50    Executing {
51        results_count: usize,
52        execution_time_ms: u64,
53    },
54    /// Reindexing cache (schema mismatch detected)
55    #[allow(dead_code)]
56    Reindexing {
57        current: usize,
58        total: usize,
59        message: String,
60    },
61    /// Phase 5: Final answer (both paths)
62    Answer { answer: String },
63    /// Error occurred
64    Error { error: String },
65    /// Non-fatal notice surfaced to the status bar (e.g. degraded LLM mode
66    /// when the LLM call failed but the path can still produce a useful
67    /// result via fallback).
68    Notice { message: String },
69    /// Processing complete
70    Done,
71}
72
73/// Triage decision for question handling
74#[derive(Debug, Clone)]
75enum TriageDecision {
76    /// Can answer directly from conversation context
77    DirectAnswer,
78    /// Needs to search codebase
79    NeedsSearch { reasoning: String },
80}
81
82/// Helper function to wrap text with consistent "│ " prefix on each line
83fn wrap_with_prefix(content: &str, area_width: u16, border_color: Color) -> Vec<Line<'_>> {
84    let mut lines = Vec::new();
85
86    // Calculate usable width: total width - borders (2) - prefix "│ " (2)
87    let usable_width = (area_width.saturating_sub(4)) as usize;
88
89    // Ensure we have at least some width to work with
90    if usable_width < 10 {
91        // Fallback for very narrow terminals - just add prefix without wrapping
92        for content_line in content.lines() {
93            lines.push(Line::from(vec![
94                Span::styled("│ ", Style::default().fg(border_color)),
95                Span::styled(content_line, Style::default().fg(Color::White)),
96            ]));
97        }
98        return lines;
99    }
100
101    // Wrap each line of the content
102    for content_line in content.lines() {
103        if content_line.is_empty() {
104            // Preserve empty lines
105            lines.push(Line::from(Span::styled(
106                "│ ",
107                Style::default().fg(border_color),
108            )));
109        } else {
110            // Wrap the line to fit the usable width
111            let wrapped = textwrap::wrap(content_line, usable_width);
112            for wrapped_line in wrapped {
113                lines.push(Line::from(vec![
114                    Span::styled("│ ", Style::default().fg(border_color)),
115                    Span::styled(wrapped_line.to_string(), Style::default().fg(Color::White)),
116                ]));
117            }
118        }
119    }
120
121    lines
122}
123
124/// Helper function to render markdown with consistent "│ " prefix on each line
125fn render_markdown_with_prefix(
126    content: &str,
127    area_width: u16,
128    border_color: Color,
129) -> Vec<Line<'static>> {
130    let mut lines = Vec::new();
131
132    // Calculate usable width: total width - borders (2) - prefix "│ " (2)
133    let usable_width = (area_width.saturating_sub(4)) as usize;
134
135    // Ensure we have at least some width to work with
136    if usable_width < 10 {
137        // Fallback for very narrow terminals
138        for content_line in content.lines() {
139            lines.push(Line::from(vec![
140                Span::styled("│ ", Style::default().fg(border_color)),
141                Span::styled(content_line.to_string(), Style::default().fg(Color::White)),
142            ]));
143        }
144        return lines;
145    }
146
147    let mut in_code_block = false;
148
149    for content_line in content.lines() {
150        // Check for code block markers
151        if content_line.trim().starts_with("```") {
152            in_code_block = !in_code_block;
153            continue; // Don't render the ``` markers
154        }
155
156        if in_code_block {
157            // Inside code block - preserve formatting, no markdown processing
158            // Separate border color from content color
159            lines.push(Line::from(vec![
160                Span::styled("│ ", Style::default().fg(border_color)),
161                Span::styled(
162                    content_line.to_string(),
163                    Style::default().fg(Color::Cyan).bg(Color::Black),
164                ),
165            ]));
166            continue;
167        }
168
169        if content_line.is_empty() {
170            lines.push(Line::from(Span::styled(
171                "│ ",
172                Style::default().fg(border_color),
173            )));
174            continue;
175        }
176
177        // Check for headers
178        let (header_level, text_after_header) = if let Some(s) = content_line.strip_prefix("### ") {
179            (3, s)
180        } else if let Some(s) = content_line.strip_prefix("## ") {
181            (2, s)
182        } else if let Some(s) = content_line.strip_prefix("# ") {
183            (1, s)
184        } else {
185            (0, content_line)
186        };
187
188        let wrapped = textwrap::wrap(text_after_header, usable_width);
189
190        for wrapped_line in wrapped {
191            let parsed_spans = parse_inline_markdown(&wrapped_line);
192
193            // Build the line with prefix (using border color)
194            let mut line_spans = vec![Span::styled("│ ", Style::default().fg(border_color))];
195
196            // Apply header styling if needed
197            if header_level > 0 {
198                for span in parsed_spans {
199                    let mut style = span.style;
200                    style = style.add_modifier(Modifier::BOLD);
201                    if header_level == 1 {
202                        style = style.fg(Color::Yellow);
203                    } else if header_level == 2 {
204                        style = style.fg(Color::Cyan);
205                    }
206                    line_spans.push(Span::styled(span.content.to_string(), style));
207                }
208            } else {
209                line_spans.extend(
210                    parsed_spans
211                        .into_iter()
212                        .map(|s| Span::styled(s.content.to_string(), s.style)),
213                );
214            }
215
216            lines.push(Line::from(line_spans));
217        }
218    }
219
220    lines
221}
222
223/// Parse inline markdown elements (bold, italic, code)
224fn parse_inline_markdown(text: &str) -> Vec<Span<'static>> {
225    let mut result = Vec::new();
226    let chars: Vec<char> = text.chars().collect();
227    let mut i = 0;
228
229    while i < chars.len() {
230        // Check for **bold**
231        if i + 1 < chars.len()
232            && chars[i] == '*'
233            && chars[i + 1] == '*'
234            && let Some(end) = find_closing_double_star(&chars, i + 2)
235        {
236            let content: String = chars[i + 2..end].iter().collect();
237            result.push(Span::styled(
238                content,
239                Style::default()
240                    .fg(Color::White)
241                    .add_modifier(Modifier::BOLD),
242            ));
243            i = end + 2;
244            continue;
245        }
246
247        // Check for *italic* or _italic_
248        if chars[i] == '*' || chars[i] == '_' {
249            let marker = chars[i];
250            if let Some(end) = find_closing_char(&chars, i + 1, marker) {
251                let content: String = chars[i + 1..end].iter().collect();
252                result.push(Span::styled(
253                    content,
254                    Style::default()
255                        .fg(Color::White)
256                        .add_modifier(Modifier::ITALIC),
257                ));
258                i = end + 1;
259                continue;
260            }
261        }
262
263        // Check for `code`
264        if chars[i] == '`'
265            && let Some(end) = find_closing_char(&chars, i + 1, '`')
266        {
267            let content: String = chars[i + 1..end].iter().collect();
268            result.push(Span::styled(
269                content,
270                Style::default().fg(Color::Cyan).bg(Color::Black),
271            ));
272            i = end + 1;
273            continue;
274        }
275
276        // Regular text - collect until next markdown character
277        let mut plain_text = String::new();
278        while i < chars.len() && chars[i] != '*' && chars[i] != '_' && chars[i] != '`' {
279            plain_text.push(chars[i]);
280            i += 1;
281        }
282
283        if !plain_text.is_empty() {
284            result.push(Span::styled(plain_text, Style::default().fg(Color::White)));
285        }
286
287        // SAFETY: If we're still at a markdown character with no match, treat it as plain text
288        // This prevents infinite loops on unmatched *, _, or `
289        if i < chars.len() && (chars[i] == '*' || chars[i] == '_' || chars[i] == '`') {
290            result.push(Span::styled(
291                chars[i].to_string(),
292                Style::default().fg(Color::White),
293            ));
294            i += 1; // CRITICAL: Always advance to prevent infinite loop
295        }
296    }
297
298    if result.is_empty() {
299        result.push(Span::raw(""));
300    }
301
302    result
303}
304
305/// Find closing ** for bold
306fn find_closing_double_star(chars: &[char], start: usize) -> Option<usize> {
307    (start..chars.len().saturating_sub(1)).find(|&i| chars[i] == '*' && chars[i + 1] == '*')
308}
309
310/// Find closing character for italic or code
311fn find_closing_char(chars: &[char], start: usize, marker: char) -> Option<usize> {
312    for i in start..chars.len() {
313        if chars[i] == marker {
314            // For *, make sure it's not **
315            if marker == '*' && i + 1 < chars.len() && chars[i + 1] == '*' {
316                continue; // Skip **, we're looking for single *
317            }
318            return Some(i);
319        }
320    }
321    None
322}
323
324/// Main chat application state
325pub struct ChatApp {
326    /// Chat session (message history and token tracking)
327    session: ChatSession,
328
329    /// Current input buffer
330    input: String,
331
332    /// Cursor position in input
333    cursor: usize,
334
335    /// Scroll offset for message history (0 = bottom, higher = scroll up)
336    scroll_offset: usize,
337
338    /// Whether to quit the application
339    should_quit: bool,
340
341    /// Cache manager for executing queries
342    cache: CacheManager,
343
344    /// Provider configuration
345    provider_name: String,
346
347    /// Optional model override
348    model_override: Option<String>,
349
350    /// Status message (ephemeral, e.g., "Compacted 10 messages")
351    status_message: Option<String>,
352
353    /// Whether we're currently waiting for LLM response
354    waiting: bool,
355
356    /// Progress updates from async execution
357    progress_rx: Option<Receiver<PhaseUpdate>>,
358
359    /// Spinner animation frame counter for loading indicator
360    spinner_frame: usize,
361}
362
363impl ChatApp {
364    /// Create a new chat application
365    pub fn new(
366        cache: CacheManager,
367        provider_name: String,
368        model_override: Option<String>,
369    ) -> Result<Self> {
370        // Get actual model name (priority: override > user config > provider default).
371        // For self-hosted providers with no built-in default, fall back to
372        // a "(not configured)" placeholder for the session label — the
373        // failure will surface at send time as a Notice.
374        let model =
375            super::config::resolve_model_for(&provider_name, None, model_override.as_deref())
376                .or_else(|| {
377                    let d = super::providers::default_model_for(&provider_name);
378                    if d.is_empty() {
379                        None
380                    } else {
381                        Some(d.to_string())
382                    }
383                })
384                .unwrap_or_else(|| "(not configured)".to_string());
385
386        let session = ChatSession::new(provider_name.clone(), model);
387
388        Ok(Self {
389            session,
390            input: String::new(),
391            cursor: 0,
392            scroll_offset: 0,
393            should_quit: false,
394            cache,
395            provider_name,
396            model_override,
397            status_message: None,
398            waiting: false,
399            progress_rx: None,
400            spinner_frame: 0,
401        })
402    }
403
404    /// Run the chat event loop
405    pub fn run(&mut self) -> Result<()> {
406        // Setup terminal
407        let mut terminal = setup_terminal()?;
408
409        // Show welcome message
410        self.session.add_system_message(
411            "Welcome to rfx ask interactive mode!\n\
412             \n\
413             Type your questions naturally and press Enter to send.\n\
414             \n\
415             Slash commands:\n\
416             • /clear - Clear conversation history\n\
417             • /compact - Summarize old messages to save tokens\n\
418             • /model [provider] [model] - Show or change provider/model\n\
419             • /help - Show this help message\n\
420             \n\
421             Press Ctrl+C to exit."
422                .to_string(),
423        );
424
425        // Main event loop
426        let result = self.event_loop(&mut terminal);
427
428        // Restore terminal
429        restore_terminal(terminal)?;
430
431        result
432    }
433
434    fn event_loop(&mut self, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
435        loop {
436            // Check for progress updates from async execution
437            // Use a separate scope to release the borrow before calling handle_progress_update
438            let updates: Vec<PhaseUpdate> = if let Some(ref rx) = self.progress_rx {
439                let mut updates = Vec::new();
440                // Try to receive all pending updates (non-blocking)
441                while let Ok(update) = rx.try_recv() {
442                    updates.push(update);
443                }
444                updates
445            } else {
446                Vec::new()
447            };
448
449            // Process updates
450            for update in updates {
451                self.handle_progress_update(update);
452            }
453
454            // Update spinner animation frame
455            if self.waiting {
456                self.spinner_frame = (self.spinner_frame + 1) % 10;
457            }
458
459            // Render UI
460            terminal.draw(|f| self.render(f))?;
461
462            // Handle events (with timeout for smooth rendering)
463            if event::poll(Duration::from_millis(100))? {
464                match event::read()? {
465                    Event::Key(key) => self.handle_key(key)?,
466                    Event::Mouse(mouse) => self.handle_mouse(mouse),
467                    _ => {}
468                }
469            }
470
471            if self.should_quit {
472                break;
473            }
474        }
475
476        Ok(())
477    }
478
479    fn render(&mut self, f: &mut Frame) {
480        let size = f.area();
481
482        // Create layout: [Stats (2 lines), Messages (fill), Input (4 lines)]
483        let chunks = Layout::default()
484            .direction(Direction::Vertical)
485            .constraints([
486                Constraint::Length(2), // Stats panel
487                Constraint::Min(10),   // Message history (scrollable)
488                Constraint::Length(4), // Input box
489            ])
490            .split(size);
491
492        // Render each section
493        self.render_stats(f, chunks[0]);
494        self.render_messages(f, chunks[1]);
495        self.render_input(f, chunks[2]);
496    }
497
498    fn render_stats(&self, f: &mut Frame, area: Rect) {
499        let usage = self.session.context_usage();
500        let percentage = (usage * 100.0) as u32;
501
502        // Color based on usage
503        let usage_color = if usage > 0.9 {
504            Color::Red
505        } else if usage > 0.8 {
506            Color::Yellow
507        } else {
508            Color::Green
509        };
510
511        let line1 = Line::from(vec![
512            Span::raw("Model: "),
513            Span::styled(
514                format!("{} ", self.session.model()),
515                Style::default()
516                    .fg(Color::Cyan)
517                    .add_modifier(Modifier::BOLD),
518            ),
519            Span::raw("│ Provider: "),
520            Span::styled(
521                format!("{} ", self.session.provider()),
522                Style::default().fg(Color::Blue),
523            ),
524            Span::raw("│ Tokens: "),
525            Span::styled(
526                format!(
527                    "{}/{} ",
528                    self.session.total_tokens(),
529                    self.session.context_limit()
530                ),
531                Style::default()
532                    .fg(usage_color)
533                    .add_modifier(Modifier::BOLD),
534            ),
535            Span::styled(
536                format!("({}%)", percentage),
537                Style::default().fg(usage_color),
538            ),
539        ]);
540
541        // Status message or hint
542        let line2_text = if let Some(ref status) = self.status_message {
543            status.clone()
544        } else if self.waiting {
545            "⏳ Waiting for response...".to_string()
546        } else if self.session.should_compact() {
547            "⚠ Context >90% full! Use /compact to summarize older messages.".to_string()
548        } else if self.session.is_near_limit() {
549            "⚠ Context >80% full. Consider using /compact soon.".to_string()
550        } else {
551            "Ready • Type your question or /help for commands".to_string()
552        };
553
554        let line2_color = if self.session.should_compact() {
555            Color::Red
556        } else if self.session.is_near_limit() {
557            Color::Yellow
558        } else if self.waiting {
559            Color::Cyan
560        } else {
561            Color::Gray
562        };
563
564        let line2 = Line::from(Span::styled(line2_text, Style::default().fg(line2_color)));
565
566        let paragraph = Paragraph::new(vec![line1, line2]).style(Style::default().bg(Color::Black));
567
568        f.render_widget(paragraph, area);
569    }
570
571    fn render_messages(&mut self, f: &mut Frame, area: Rect) {
572        let mut lines: Vec<Line> = Vec::new();
573
574        // Render all messages
575        for msg in self.session.messages() {
576            match msg.role {
577                MessageRole::User => {
578                    // User message header
579                    lines.push(Line::from(""));
580                    lines.push(Line::from(Span::styled(
581                        "╭─ You ─────────────────────────────────────",
582                        Style::default()
583                            .fg(Color::Green)
584                            .add_modifier(Modifier::BOLD),
585                    )));
586
587                    // Message content (with proper wrapping and consistent green border)
588                    lines.extend(wrap_with_prefix(&msg.content, area.width, Color::Green));
589
590                    lines.push(Line::from(Span::styled(
591                        "╰───────────────────────────────────────────",
592                        Style::default().fg(Color::Green),
593                    )));
594                }
595                MessageRole::AssistantThinking => {
596                    // Phase 1: Thinking/Assessment
597                    lines.push(Line::from(""));
598                    lines.push(Line::from(Span::styled(
599                        "╭─ Assistant (Thinking) ────────────────────",
600                        Style::default()
601                            .fg(Color::Magenta)
602                            .add_modifier(Modifier::BOLD),
603                    )));
604
605                    // Show needs_context indicator
606                    if let Some(ref meta) = msg.metadata
607                        && meta.needs_context
608                    {
609                        lines.push(Line::from(vec![
610                            Span::styled("│ ", Style::default().fg(Color::Magenta)),
611                            Span::styled(
612                                "🔍 Needs context gathering",
613                                Style::default().fg(Color::Yellow),
614                            ),
615                        ]));
616                    }
617
618                    // Message content (with proper wrapping and consistent magenta border)
619                    lines.extend(wrap_with_prefix(&msg.content, area.width, Color::Magenta));
620
621                    lines.push(Line::from(Span::styled(
622                        "╰───────────────────────────────────────────",
623                        Style::default().fg(Color::Magenta),
624                    )));
625                }
626                MessageRole::AssistantTools => {
627                    // Phase 2: Tool gathering
628                    lines.push(Line::from(""));
629                    lines.push(Line::from(Span::styled(
630                        "╭─ Assistant (Tools) ───────────────────────",
631                        Style::default()
632                            .fg(Color::Blue)
633                            .add_modifier(Modifier::BOLD),
634                    )));
635
636                    // Show tool calls
637                    if let Some(ref meta) = msg.metadata
638                        && !meta.tool_calls.is_empty()
639                    {
640                        lines.push(Line::from(vec![
641                            Span::styled("│ ", Style::default().fg(Color::Blue)),
642                            Span::styled(
643                                format!("🔧 {} tool calls made", meta.tool_calls.len()),
644                                Style::default().fg(Color::DarkGray),
645                            ),
646                        ]));
647                    }
648
649                    // Message content (with proper wrapping and consistent blue border)
650                    lines.extend(wrap_with_prefix(&msg.content, area.width, Color::Blue));
651
652                    lines.push(Line::from(Span::styled(
653                        "╰───────────────────────────────────────────",
654                        Style::default().fg(Color::Blue),
655                    )));
656                }
657                MessageRole::AssistantQueries => {
658                    // Phase 3: Generated queries
659                    lines.push(Line::from(""));
660                    lines.push(Line::from(Span::styled(
661                        "╭─ Assistant (Queries) ─────────────────────",
662                        Style::default()
663                            .fg(Color::Magenta)
664                            .add_modifier(Modifier::BOLD),
665                    )));
666
667                    // Show query count
668                    if let Some(ref meta) = msg.metadata
669                        && !meta.queries.is_empty()
670                    {
671                        lines.push(Line::from(vec![
672                            Span::styled("│ ", Style::default().fg(Color::Magenta)),
673                            Span::styled(
674                                format!("📝 Generated {} queries", meta.queries.len()),
675                                Style::default().fg(Color::DarkGray),
676                            ),
677                        ]));
678                        // Optionally show the queries
679                        for (i, query) in meta.queries.iter().enumerate() {
680                            lines.push(Line::from(vec![
681                                Span::styled("│ ", Style::default().fg(Color::Magenta)),
682                                Span::styled(
683                                    format!("  {}. {}", i + 1, query),
684                                    Style::default().fg(Color::DarkGray),
685                                ),
686                            ]));
687                        }
688                    }
689
690                    // Message content (with proper wrapping and consistent magenta border)
691                    lines.extend(wrap_with_prefix(&msg.content, area.width, Color::Magenta));
692
693                    lines.push(Line::from(Span::styled(
694                        "╰───────────────────────────────────────────",
695                        Style::default().fg(Color::Magenta),
696                    )));
697                }
698                MessageRole::AssistantExecuting => {
699                    // Phase 4: Execution status
700                    lines.push(Line::from(""));
701                    lines.push(Line::from(Span::styled(
702                        "╭─ Assistant (Executing) ───────────────────",
703                        Style::default()
704                            .fg(Color::Yellow)
705                            .add_modifier(Modifier::BOLD),
706                    )));
707
708                    // Show execution stats
709                    if let Some(ref meta) = msg.metadata {
710                        let time_str = if let Some(ms) = meta.execution_time_ms {
711                            format!(" in {}ms", ms)
712                        } else {
713                            String::new()
714                        };
715                        lines.push(Line::from(vec![
716                            Span::styled("│ ", Style::default().fg(Color::Yellow)),
717                            Span::styled(
718                                format!(
719                                    "⚡ Found {} result{}{}",
720                                    meta.results_count,
721                                    if meta.results_count == 1 { "" } else { "s" },
722                                    time_str
723                                ),
724                                Style::default().fg(Color::DarkGray),
725                            ),
726                        ]));
727                    }
728
729                    // Message content (with proper wrapping and consistent yellow border)
730                    lines.extend(wrap_with_prefix(&msg.content, area.width, Color::Yellow));
731
732                    lines.push(Line::from(Span::styled(
733                        "╰───────────────────────────────────────────",
734                        Style::default().fg(Color::Yellow),
735                    )));
736                }
737                MessageRole::AssistantAnswer => {
738                    // Phase 5: Final answer
739                    lines.push(Line::from(""));
740                    lines.push(Line::from(Span::styled(
741                        "╭─ Assistant (Answer) ──────────────────────",
742                        Style::default()
743                            .fg(Color::Blue)
744                            .add_modifier(Modifier::BOLD),
745                    )));
746
747                    // Message content (with markdown rendering and consistent blue border)
748                    lines.extend(render_markdown_with_prefix(
749                        &msg.content,
750                        area.width,
751                        Color::Blue,
752                    ));
753
754                    lines.push(Line::from(Span::styled(
755                        "╰───────────────────────────────────────────",
756                        Style::default().fg(Color::Blue),
757                    )));
758                }
759                MessageRole::System => {
760                    // System message (e.g., welcome, compaction summary)
761                    lines.push(Line::from(""));
762                    lines.push(Line::from(Span::styled(
763                        "╭─ System ──────────────────────────────────",
764                        Style::default()
765                            .fg(Color::Yellow)
766                            .add_modifier(Modifier::BOLD),
767                    )));
768
769                    // Message content (with proper wrapping)
770                    lines.extend(wrap_with_prefix(&msg.content, area.width, Color::Yellow));
771
772                    lines.push(Line::from(Span::styled(
773                        "╰───────────────────────────────────────────",
774                        Style::default().fg(Color::Yellow),
775                    )));
776                }
777            }
778        }
779
780        // Show loading indicator if waiting for response
781        if self.waiting {
782            // Spinner animation characters (braille patterns)
783            const SPINNER_CHARS: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
784            let spinner_char = SPINNER_CHARS[self.spinner_frame % SPINNER_CHARS.len()];
785
786            // Get current status message or default
787            let status_text = self
788                .status_message
789                .clone()
790                .unwrap_or_else(|| "Working...".to_string());
791
792            lines.push(Line::from(""));
793            lines.push(Line::from(Span::styled(
794                "╭─ Processing ──────────────────────────────",
795                Style::default()
796                    .fg(Color::Cyan)
797                    .add_modifier(Modifier::BOLD),
798            )));
799            lines.push(Line::from(vec![
800                Span::styled("│ ", Style::default().fg(Color::Cyan)),
801                Span::styled(
802                    spinner_char,
803                    Style::default()
804                        .fg(Color::Cyan)
805                        .add_modifier(Modifier::BOLD),
806                ),
807                Span::styled(
808                    format!(" {}", status_text),
809                    Style::default().fg(Color::White),
810                ),
811            ]));
812            lines.push(Line::from(Span::styled(
813                "╰───────────────────────────────────────────",
814                Style::default().fg(Color::Cyan),
815            )));
816        }
817
818        // Always add bottom padding to account for text wrapping
819        // (long lines that span multiple terminal rows)
820        for _ in 0..8 {
821            lines.push(Line::from(""));
822        }
823
824        // Calculate scroll position
825        // scroll_offset = 0 means show bottom (latest messages)
826        // scroll_offset > 0 means scroll up
827        let total_lines = lines.len();
828        let visible_height = area.height.saturating_sub(2) as usize; // Account for borders
829
830        let scroll = if total_lines <= visible_height {
831            0 // No scrolling needed
832        } else {
833            // Calculate scroll from bottom
834            let max_scroll = total_lines.saturating_sub(visible_height);
835
836            // Clamp scroll_offset to valid range to prevent scrolling past top
837            self.scroll_offset = self.scroll_offset.min(max_scroll);
838
839            max_scroll.saturating_sub(self.scroll_offset) as u16
840        };
841
842        let paragraph = Paragraph::new(lines)
843            .block(
844                Block::default()
845                    .borders(Borders::ALL)
846                    .title(" Messages ")
847                    .border_style(Style::default().fg(Color::DarkGray)),
848            )
849            .scroll((scroll, 0));
850
851        f.render_widget(paragraph, area);
852    }
853
854    fn render_input(&self, f: &mut Frame, area: Rect) {
855        let input_display = if self.input.is_empty() {
856            "Type your question here...".to_string()
857        } else {
858            self.input.clone()
859        };
860
861        let input_style = if self.input.is_empty() {
862            Style::default().fg(Color::DarkGray)
863        } else {
864            Style::default().fg(Color::White)
865        };
866
867        // Show shortcuts in the border
868        let shortcuts = " Enter: Send | Ctrl+C: Quit | Ctrl+L: /clear | Ctrl+K: /compact | Ctrl+U: Clear input ";
869
870        let paragraph = Paragraph::new(input_display)
871            .block(
872                Block::default()
873                    .borders(Borders::ALL)
874                    .title(vec![
875                        Span::raw(" "),
876                        Span::styled(
877                            ">",
878                            Style::default()
879                                .fg(Color::Green)
880                                .add_modifier(Modifier::BOLD),
881                        ),
882                        Span::raw(" Input "),
883                    ])
884                    .title_bottom(Line::from(Span::styled(
885                        shortcuts,
886                        Style::default().fg(Color::DarkGray),
887                    )))
888                    .border_style(Style::default().fg(if self.waiting {
889                        Color::DarkGray
890                    } else {
891                        Color::Green
892                    })),
893            )
894            .style(input_style);
895
896        f.render_widget(paragraph, area);
897
898        // Set cursor position if not waiting
899        if !self.waiting && !self.input.is_empty() {
900            f.set_cursor_position((area.x + 1 + (self.cursor as u16), area.y + 1));
901        }
902    }
903
904    fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
905        // Global shortcuts
906        if key.modifiers.contains(KeyModifiers::CONTROL) {
907            match key.code {
908                KeyCode::Char('c') | KeyCode::Char('d') => {
909                    // Quit
910                    self.should_quit = true;
911                    return Ok(());
912                }
913                KeyCode::Char('l') => {
914                    // Clear conversation
915                    self.handle_slash_command("/clear")?;
916                    return Ok(());
917                }
918                KeyCode::Char('k') => {
919                    // Compact conversation
920                    self.handle_slash_command("/compact")?;
921                    return Ok(());
922                }
923                KeyCode::Char('u') => {
924                    // Clear input
925                    self.input.clear();
926                    self.cursor = 0;
927                    return Ok(());
928                }
929                _ => {}
930            }
931        }
932
933        // Don't accept input while waiting for response
934        if self.waiting {
935            return Ok(());
936        }
937
938        // Handle input
939        match key.code {
940            KeyCode::Enter => {
941                self.handle_enter()?;
942            }
943            KeyCode::Char(c) => {
944                self.input.insert(self.cursor, c);
945                self.cursor += 1;
946            }
947            KeyCode::Backspace if self.cursor > 0 => {
948                self.input.remove(self.cursor - 1);
949                self.cursor -= 1;
950            }
951            KeyCode::Delete if self.cursor < self.input.len() => {
952                self.input.remove(self.cursor);
953            }
954            KeyCode::Left => {
955                self.cursor = self.cursor.saturating_sub(1);
956            }
957            KeyCode::Right if self.cursor < self.input.len() => {
958                self.cursor += 1;
959            }
960            KeyCode::Home => {
961                self.cursor = 0;
962            }
963            KeyCode::End => {
964                self.cursor = self.input.len();
965            }
966            KeyCode::Up => {
967                // Scroll messages up
968                self.scroll_offset = self.scroll_offset.saturating_add(1);
969            }
970            KeyCode::Down => {
971                // Scroll messages down
972                self.scroll_offset = self.scroll_offset.saturating_sub(1);
973            }
974            KeyCode::PageUp => {
975                // Fast scroll up
976                self.scroll_offset = self.scroll_offset.saturating_add(10);
977            }
978            KeyCode::PageDown => {
979                // Fast scroll down
980                self.scroll_offset = self.scroll_offset.saturating_sub(10);
981            }
982            _ => {}
983        }
984
985        Ok(())
986    }
987
988    fn handle_mouse(&mut self, mouse: MouseEvent) {
989        // Handle mouse scroll events
990        match mouse.kind {
991            MouseEventKind::ScrollUp => {
992                // Scroll up (show older messages) - increase scroll_offset by 3
993                self.scroll_offset = self.scroll_offset.saturating_add(3);
994            }
995            MouseEventKind::ScrollDown => {
996                // Scroll down (show newer messages) - decrease scroll_offset by 3
997                self.scroll_offset = self.scroll_offset.saturating_sub(3);
998            }
999            _ => {}
1000        }
1001    }
1002
1003    fn handle_enter(&mut self) -> Result<()> {
1004        let input = self.input.trim().to_string();
1005
1006        if input.is_empty() {
1007            return Ok(());
1008        }
1009
1010        // Check for slash commands
1011        if input.starts_with('/') {
1012            return self.handle_slash_command(&input);
1013        }
1014
1015        // Add user message to session
1016        self.session.add_user_message(input.clone());
1017
1018        // Clear input
1019        self.input.clear();
1020        self.cursor = 0;
1021
1022        // Auto-scroll to bottom to see the new message
1023        self.scroll_offset = 0;
1024
1025        // Execute query asynchronously
1026        // For now, we'll do it synchronously (blocking)
1027        // TODO: Make this async for better UX
1028        self.execute_query(&input)?;
1029
1030        Ok(())
1031    }
1032
1033    fn execute_query(&mut self, question: &str) -> Result<()> {
1034        self.waiting = true;
1035        self.status_message = Some("Analyzing question...".to_string());
1036
1037        // Create progress channel
1038        let (tx, rx) = mpsc::channel();
1039        self.progress_rx = Some(rx);
1040
1041        // Clone data needed for background thread
1042        let question = question.to_string();
1043        let cache_path = self.cache.path().to_path_buf();
1044        let provider_name = self.provider_name.clone();
1045        let model_override = self.model_override.clone();
1046
1047        // Build conversation history for triage
1048        let conversation_history = self.session.build_context();
1049
1050        // Spawn background thread for async work
1051        std::thread::spawn(move || {
1052            // Create tokio runtime in background thread
1053            let runtime = match tokio::runtime::Runtime::new() {
1054                Ok(rt) => rt,
1055                Err(e) => {
1056                    let _ = tx.send(PhaseUpdate::Error {
1057                        error: format!("Failed to create async runtime: {}", e),
1058                    });
1059                    return;
1060                }
1061            };
1062
1063            runtime.block_on(async {
1064                execute_query_async(
1065                    &question,
1066                    &conversation_history,
1067                    cache_path,
1068                    &provider_name,
1069                    model_override.as_deref(),
1070                    tx,
1071                )
1072                .await
1073            });
1074        });
1075
1076        Ok(())
1077    }
1078
1079    fn handle_progress_update(&mut self, update: PhaseUpdate) {
1080        match update {
1081            PhaseUpdate::Triaging => {
1082                self.status_message = Some("Analyzing question...".to_string());
1083            }
1084            PhaseUpdate::AnsweringFromContext => {
1085                self.status_message = Some("Answering from conversation...".to_string());
1086            }
1087            PhaseUpdate::Thinking {
1088                reasoning,
1089                needs_context,
1090            } => {
1091                self.status_message = Some("Thinking...".to_string());
1092                self.session.add_thinking_message(reasoning, needs_context);
1093                self.scroll_offset = 0; // Auto-scroll to bottom
1094            }
1095            PhaseUpdate::Tools {
1096                content,
1097                tool_calls,
1098            } => {
1099                self.status_message =
1100                    Some(format!("Gathering context ({} tools)...", tool_calls.len()));
1101                self.session.add_tools_message(content, tool_calls);
1102                self.scroll_offset = 0;
1103            }
1104            PhaseUpdate::Queries { queries } => {
1105                self.status_message = Some(format!("Generated {} queries...", queries.len()));
1106                self.session.add_queries_message(queries);
1107                self.scroll_offset = 0;
1108            }
1109            PhaseUpdate::Executing {
1110                results_count,
1111                execution_time_ms,
1112            } => {
1113                self.status_message = Some(format!(
1114                    "Found {} result{}...",
1115                    results_count,
1116                    if results_count == 1 { "" } else { "s" }
1117                ));
1118                self.session
1119                    .add_execution_message(results_count, execution_time_ms);
1120                self.scroll_offset = 0;
1121            }
1122            PhaseUpdate::Reindexing {
1123                current,
1124                total,
1125                message,
1126            } => {
1127                let percentage = if total > 0 {
1128                    (current as f32 / total as f32 * 100.0) as u8
1129                } else {
1130                    0
1131                };
1132                self.status_message = Some(format!(
1133                    "Reindexing cache: [{}/{}] {}% - {}",
1134                    current, total, percentage, message
1135                ));
1136                // Don't scroll during reindexing - user should stay where they are
1137            }
1138            PhaseUpdate::Answer { answer } => {
1139                self.status_message = Some("Generating answer...".to_string());
1140                self.session.add_answer_message(answer);
1141                self.scroll_offset = 0;
1142            }
1143            PhaseUpdate::Error { error } => {
1144                self.session.add_system_message(format!("Error: {}", error));
1145                self.waiting = false;
1146                self.status_message = Some(format!("❌ Error: {}", error));
1147                self.progress_rx = None;
1148                self.scroll_offset = 0;
1149            }
1150            PhaseUpdate::Notice { message } => {
1151                self.status_message = Some(message);
1152            }
1153            PhaseUpdate::Done => {
1154                self.waiting = false;
1155                self.status_message = None;
1156                self.progress_rx = None;
1157            }
1158        }
1159    }
1160
1161    fn handle_slash_command(&mut self, command: &str) -> Result<()> {
1162        let command = command.trim();
1163
1164        match command {
1165            "/clear" => {
1166                self.session.clear();
1167                self.status_message = Some("✓ Conversation cleared".to_string());
1168                self.input.clear();
1169                self.cursor = 0;
1170
1171                // Add welcome message again
1172                self.session
1173                    .add_system_message("Conversation cleared. Start fresh!".to_string());
1174            }
1175            "/compact" => {
1176                self.handle_compact()?;
1177                self.input.clear();
1178                self.cursor = 0;
1179            }
1180            "/help" => {
1181                self.session.add_system_message(
1182                    "Available slash commands:\n\
1183                     \n\
1184                     • /clear - Clear conversation history\n\
1185                     • /compact - Summarize old messages to save tokens\n\
1186                     • /model [provider] [model] - Show or change provider/model\n\
1187                     • /help - Show this help message\n\
1188                     \n\
1189                     Keyboard shortcuts:\n\
1190                     • Enter - Send message\n\
1191                     • Ctrl+C - Quit\n\
1192                     • Ctrl+L - Clear conversation\n\
1193                     • Ctrl+K - Compact conversation\n\
1194                     • Ctrl+U - Clear input\n\
1195                     • Up/Down - Scroll messages\n\
1196                     • PgUp/PgDn - Fast scroll"
1197                        .to_string(),
1198                );
1199                self.input.clear();
1200                self.cursor = 0;
1201            }
1202            _ if command.starts_with("/model") => {
1203                self.handle_model_command(command)?;
1204                self.input.clear();
1205                self.cursor = 0;
1206            }
1207            _ => {
1208                self.status_message = Some(format!("Unknown command: {}", command));
1209            }
1210        }
1211
1212        Ok(())
1213    }
1214
1215    fn handle_compact(&mut self) -> Result<()> {
1216        // Prepare compaction (keep last 4 messages)
1217        let (old_messages, removed_count, tokens_saved_potential) =
1218            self.session.prepare_compaction(4);
1219
1220        if old_messages.is_empty() {
1221            self.status_message = Some("Nothing to compact (less than 4 messages)".to_string());
1222            return Ok(());
1223        }
1224
1225        self.waiting = true;
1226        self.status_message = Some("Compacting conversation...".to_string());
1227
1228        // Create tokio runtime for async operations
1229        let runtime = tokio::runtime::Runtime::new().context("Failed to create async runtime")?;
1230
1231        // Initialize provider for summarization
1232        let provider_instance = {
1233            let mut config = super::config::load_config(self.cache.path())?;
1234            config.provider = self.provider_name.clone();
1235            let api_key = super::config::get_api_key(&config.provider)?;
1236            let model = super::config::resolve_model(&config, self.model_override.as_deref());
1237            super::providers::create_provider(
1238                &config.provider,
1239                api_key,
1240                model,
1241                super::config::get_provider_options(&config.provider),
1242                config.timeout_seconds,
1243            )?
1244        };
1245
1246        // Build summarization prompt
1247        let prompt = format!(
1248            "Summarize the following conversation history concisely while retaining \
1249             key technical details, code findings, and decisions made. \
1250             Provide a 2-3 paragraph summary.\n\n{}",
1251            old_messages
1252        );
1253
1254        // Get summary from LLM
1255        let summary =
1256            runtime.block_on(async { provider_instance.complete(&prompt, false).await })?;
1257
1258        // Apply compaction
1259        self.session
1260            .apply_compaction(removed_count, summary.clone());
1261
1262        self.waiting = false;
1263        self.status_message = Some(format!(
1264            "✓ Compacted {} messages (saved ~{} tokens)",
1265            removed_count, tokens_saved_potential
1266        ));
1267
1268        Ok(())
1269    }
1270
1271    fn handle_model_command(&mut self, command: &str) -> Result<()> {
1272        let parts: Vec<&str> = command.split_whitespace().collect();
1273
1274        // /model (no args) - show current
1275        if parts.len() == 1 {
1276            self.session.add_system_message(format!(
1277                "Current configuration:\n\
1278                 • Provider: {}\n\
1279                 • Model: {}\n\
1280                 \n\
1281                 Available providers: openai, anthropic, openrouter\n\
1282                 \n\
1283                 Usage:\n\
1284                 • /model <provider> - Switch provider (uses configured model or default)\n\
1285                 • /model <provider> <model> - Switch to specific provider and model",
1286                self.session.provider(),
1287                self.session.model()
1288            ));
1289            return Ok(());
1290        }
1291
1292        // Extract provider and optional model
1293        let new_provider = parts[1].to_lowercase();
1294        let new_model_arg = parts.get(2).map(|s| s.to_string());
1295
1296        // Validate provider
1297        let valid_providers = ["openai", "anthropic", "openrouter", "openai-compatible"];
1298        if !valid_providers.contains(&new_provider.as_str()) {
1299            self.status_message = Some(format!(
1300                "Invalid provider '{}'. Available: {}",
1301                new_provider,
1302                valid_providers.join(", ")
1303            ));
1304            return Ok(());
1305        }
1306
1307        // Determine model (priority: command arg > user config > provider default)
1308        let new_model =
1309            super::config::resolve_model_for(&new_provider, None, new_model_arg.as_deref())
1310                .unwrap_or_else(|| super::providers::default_model_for(&new_provider).to_string());
1311
1312        // openai-compatible has no built-in default; refuse the switch with a
1313        // friendly message rather than persisting a blank model name.
1314        if new_model.is_empty() {
1315            self.status_message = Some(format!(
1316                "/model {} requires a model name (e.g. /model {} <model>) — \
1317                 self-hosted endpoints have no default. Run 'rfx llm config' first.",
1318                new_provider, new_provider
1319            ));
1320            return Ok(());
1321        }
1322
1323        // Update session
1324        self.session
1325            .update_provider(new_provider.clone(), new_model.clone());
1326        self.provider_name = new_provider.clone();
1327        self.model_override = new_model_arg.clone();
1328
1329        // Persist to user config
1330        if let Err(e) = super::save_user_provider(&new_provider, Some(&new_model)) {
1331            log::warn!("Failed to save provider preference to config: {}", e);
1332            self.status_message = Some("⚠ Model changed but not saved to config".to_string());
1333        } else {
1334            self.status_message = Some(format!("✓ Switched to {} ({})", new_provider, new_model));
1335        }
1336
1337        // Add system message
1338        self.session.add_system_message(format!(
1339            "Switched to provider '{}' with model '{}'.\n\
1340             \n\
1341             This preference has been saved to ~/.reflex/config.toml.",
1342            new_provider, new_model
1343        ));
1344
1345        Ok(())
1346    }
1347}
1348
1349/// Retry an async operation with exponential backoff
1350async fn retry_with_backoff<F, Fut, T>(
1351    mut operation: F,
1352    max_retries: usize,
1353    operation_name: &str,
1354) -> Result<T>
1355where
1356    F: FnMut() -> Fut,
1357    Fut: std::future::Future<Output = Result<T>>,
1358{
1359    let mut last_error = None;
1360
1361    for attempt in 0..=max_retries {
1362        match operation().await {
1363            Ok(result) => return Ok(result),
1364            Err(e) => {
1365                let err_msg = e.to_string();
1366
1367                // Determine wait time based on error type
1368                let wait_ms = if err_msg.contains("Rate limit exceeded") || err_msg.contains("429")
1369                {
1370                    // Rate limit: longer wait
1371                    5000 * (attempt as u64 + 1)
1372                } else if err_msg.contains("timeout") || err_msg.contains("Timeout") {
1373                    // Timeout: moderate wait
1374                    2000 * (attempt as u64 + 1)
1375                } else {
1376                    // Other errors: standard exponential backoff
1377                    1000 * (attempt as u64 + 1)
1378                };
1379
1380                if attempt < max_retries {
1381                    log::warn!(
1382                        "{} failed (attempt {}/{}): {}. Retrying in {}ms...",
1383                        operation_name,
1384                        attempt + 1,
1385                        max_retries + 1,
1386                        err_msg,
1387                        wait_ms
1388                    );
1389                    tokio::time::sleep(tokio::time::Duration::from_millis(wait_ms)).await;
1390                }
1391
1392                last_error = Some(e);
1393            }
1394        }
1395    }
1396
1397    Err(last_error.unwrap())
1398}
1399
1400/// Triage a question to determine if it needs codebase search
1401async fn triage_question(
1402    question: &str,
1403    conversation_history: &str,
1404    provider_name: &str,
1405    model_override: Option<&str>,
1406    cache_path: &std::path::Path,
1407) -> Result<TriageDecision> {
1408    // Create provider for triage
1409    let provider_instance = {
1410        let mut config = super::config::load_config(cache_path)?;
1411        config.provider = provider_name.to_string();
1412        let api_key = super::config::get_api_key(&config.provider)?;
1413        let model = super::config::resolve_model(&config, model_override);
1414        super::providers::create_provider(
1415            &config.provider,
1416            api_key,
1417            model,
1418            super::config::get_provider_options(&config.provider),
1419            config.timeout_seconds,
1420        )?
1421    };
1422
1423    // Build triage prompt
1424    let triage_prompt = format!(
1425        "You are a helpful coding assistant with access to a codebase search engine.\n\
1426         \n\
1427         {}\n\
1428         \n\
1429         USER'S NEW QUESTION: {}\n\
1430         \n\
1431         TASK: Determine if you can answer this question using ONLY the conversation history above, \
1432         or if you need to search the codebase.\n\
1433         \n\
1434         Answer \"direct\" if:\n\
1435         - It's a follow-up question about something already discussed\n\
1436         - It's asking for clarification or explanation of prior context\n\
1437         - It's a general programming question not specific to this codebase\n\
1438         - Examples: \"What does that mean?\", \"Can you explain X?\", \"Why?\"\n\
1439         \n\
1440         Answer \"search\" if:\n\
1441         - It's asking about code not yet discussed\n\
1442         - It requires finding specific files, functions, or patterns\n\
1443         - It's a new topic requiring codebase investigation\n\
1444         - Examples: \"How is auth implemented?\", \"Find all uses of X\", \"Where is Y defined?\"\n\
1445         \n\
1446         Respond with ONLY a single word: either \"direct\" or \"search\"",
1447        conversation_history, question
1448    );
1449
1450    // Call LLM for triage
1451    let response = provider_instance.complete(&triage_prompt, false).await?;
1452    let decision = response.trim().to_lowercase();
1453
1454    if decision.contains("direct") {
1455        Ok(TriageDecision::DirectAnswer)
1456    } else {
1457        Ok(TriageDecision::NeedsSearch {
1458            reasoning: "Question requires codebase search".to_string(),
1459        })
1460    }
1461}
1462
1463/// Execute query asynchronously and send progress updates
1464async fn execute_query_async(
1465    question: &str,
1466    conversation_history: &str,
1467    cache_path: std::path::PathBuf,
1468    provider_name: &str,
1469    model_override: Option<&str>,
1470    tx: Sender<PhaseUpdate>,
1471) {
1472    // Recreate cache manager from root directory
1473    // cache_path is .reflex/, so get parent to pass to CacheManager::new
1474    let root_dir = cache_path.parent().unwrap_or(&cache_path);
1475    let cache = CacheManager::new(root_dir);
1476
1477    // Extract codebase context (always available metadata: languages, file counts, directories)
1478    let codebase_context_str = super::context::CodebaseContext::extract(&cache)
1479        .ok()
1480        .map(|ctx| ctx.to_prompt_string());
1481
1482    // TRIAGE PHASE: Decide if we need to search or can answer directly
1483    let _ = tx.send(PhaseUpdate::Triaging);
1484
1485    let decision = match triage_question(
1486        question,
1487        conversation_history,
1488        provider_name,
1489        model_override,
1490        &cache_path,
1491    )
1492    .await
1493    {
1494        Ok(decision) => decision,
1495        Err(e) => {
1496            let msg = format!("LLM unavailable, falling back to search: {}", e);
1497            log::warn!("{}", msg);
1498            let _ = tx.send(PhaseUpdate::Notice { message: msg });
1499            TriageDecision::NeedsSearch {
1500                reasoning: "Triage failed, using search as fallback".to_string(),
1501            }
1502        }
1503    };
1504
1505    match decision {
1506        TriageDecision::DirectAnswer => {
1507            // FAST PATH: Answer from conversation context
1508            let _ = tx.send(PhaseUpdate::AnsweringFromContext);
1509
1510            // Generate answer using conversation history
1511            let provider_instance = match (|| -> Result<_> {
1512                let mut config = super::config::load_config(&cache_path)?;
1513                config.provider = provider_name.to_string();
1514                let api_key = super::config::get_api_key(&config.provider)?;
1515                let model = super::config::resolve_model(&config, model_override);
1516                super::providers::create_provider(
1517                    &config.provider,
1518                    api_key,
1519                    model,
1520                    super::config::get_provider_options(&config.provider),
1521                    config.timeout_seconds,
1522                )
1523            })() {
1524                Ok(provider) => provider,
1525                Err(e) => {
1526                    let _ = tx.send(PhaseUpdate::Error {
1527                        error: format!("Failed to create provider: {}", e),
1528                    });
1529                    return;
1530                }
1531            };
1532
1533            let answer_prompt = format!(
1534                "{}\n\nUSER'S QUESTION: {}\n\n\
1535                 Answer the question based on the conversation history above. \
1536                 Be concise and helpful.",
1537                conversation_history, question
1538            );
1539
1540            // Retry answer generation with exponential backoff
1541            let answer_result = retry_with_backoff(
1542                || async { provider_instance.complete(&answer_prompt, false).await },
1543                2, // max 2 retries
1544                "Answer generation",
1545            )
1546            .await;
1547
1548            match answer_result {
1549                Ok(answer) => {
1550                    let _ = tx.send(PhaseUpdate::Answer { answer });
1551                    let _ = tx.send(PhaseUpdate::Done);
1552                }
1553                Err(e) => {
1554                    // Fallback: If direct answer fails after retries, try search instead
1555                    log::warn!(
1556                        "Direct answer failed after retries, falling back to search: {}",
1557                        e
1558                    );
1559                    let _ = tx.send(PhaseUpdate::Thinking {
1560                        reasoning: format!(
1561                            "Direct answer failed ({}), searching codebase as fallback",
1562                            e
1563                        ),
1564                        needs_context: true,
1565                    });
1566
1567                    // Run search path (copy of agentic path below)
1568                    let agentic_config = AgenticConfig {
1569                        max_iterations: 2,
1570                        max_tools_per_phase: 5,
1571                        enable_evaluation: true,
1572                        eval_config: Default::default(),
1573                        provider_override: Some(provider_name.to_string()),
1574                        model_override: model_override.map(|s| s.to_string()),
1575                        show_reasoning: false,
1576                        verbose: false,
1577                        debug: false,
1578                    };
1579
1580                    let reporter = Box::new(super::QuietReporter);
1581
1582                    match super::run_agentic_loop(question, &cache, agentic_config, &*reporter)
1583                        .await
1584                    {
1585                        Ok(agentic_response) => {
1586                            // Send tools phase update if tools were executed
1587                            if let Some(ref tools) = agentic_response.tools_executed
1588                                && !tools.is_empty()
1589                            {
1590                                let content =
1591                                    format!("Gathered context using {} tools", tools.len());
1592                                let _ = tx.send(PhaseUpdate::Tools {
1593                                    content,
1594                                    tool_calls: tools.clone(),
1595                                });
1596                            }
1597
1598                            // Get results count (needed for answer generation)
1599                            let results_count = agentic_response.total_count.unwrap_or(0);
1600
1601                            // Send queries phase update only if queries were generated
1602                            if !agentic_response.queries.is_empty() {
1603                                let query_strings: Vec<String> = agentic_response
1604                                    .queries
1605                                    .iter()
1606                                    .map(|q| q.command.clone())
1607                                    .collect();
1608
1609                                let _ = tx.send(PhaseUpdate::Queries {
1610                                    queries: query_strings,
1611                                });
1612
1613                                let _ = tx.send(PhaseUpdate::Executing {
1614                                    results_count,
1615                                    execution_time_ms: 0,
1616                                });
1617                            }
1618
1619                            let provider_instance = match (|| -> Result<_> {
1620                                let mut config = super::config::load_config(&cache_path)?;
1621                                config.provider = provider_name.to_string();
1622                                let api_key = super::config::get_api_key(&config.provider)?;
1623                                let model = super::config::resolve_model(&config, model_override);
1624                                super::providers::create_provider(
1625                                    &config.provider,
1626                                    api_key,
1627                                    model,
1628                                    super::config::get_provider_options(&config.provider),
1629                                    config.timeout_seconds,
1630                                )
1631                            })() {
1632                                Ok(provider) => provider,
1633                                Err(e) => {
1634                                    let _ = tx.send(PhaseUpdate::Error {
1635                                        error: format!(
1636                                            "Failed to create provider for fallback: {}",
1637                                            e
1638                                        ),
1639                                    });
1640                                    return;
1641                                }
1642                            };
1643
1644                            match super::generate_answer(
1645                                question,
1646                                &agentic_response.results,
1647                                results_count,
1648                                agentic_response.gathered_context.as_deref(),
1649                                codebase_context_str.as_deref(),
1650                                &*provider_instance,
1651                            )
1652                            .await
1653                            {
1654                                Ok(answer) => {
1655                                    let _ = tx.send(PhaseUpdate::Answer { answer });
1656                                    let _ = tx.send(PhaseUpdate::Done);
1657                                }
1658                                Err(e) => {
1659                                    let _ = tx.send(PhaseUpdate::Error {
1660                                        error: format!("Fallback search failed: {}", e),
1661                                    });
1662                                }
1663                            }
1664                        }
1665                        Err(e) => {
1666                            let _ = tx.send(PhaseUpdate::Error {
1667                                error: format!("Both direct answer and search failed: {}", e),
1668                            });
1669                        }
1670                    }
1671                }
1672            }
1673        }
1674
1675        TriageDecision::NeedsSearch { reasoning } => {
1676            // AGENTIC PATH: Full search pipeline
1677            let _ = tx.send(PhaseUpdate::Thinking {
1678                reasoning,
1679                needs_context: true,
1680            });
1681
1682            // Configure agentic mode
1683            let agentic_config = AgenticConfig {
1684                max_iterations: 2,
1685                max_tools_per_phase: 5,
1686                enable_evaluation: true,
1687                eval_config: Default::default(),
1688                provider_override: Some(provider_name.to_string()),
1689                model_override: model_override.map(|s| s.to_string()),
1690                show_reasoning: false,
1691                verbose: false,
1692                debug: false,
1693            };
1694
1695            // Use quiet reporter to suppress console output
1696            let reporter = Box::new(super::QuietReporter);
1697
1698            // Run agentic loop
1699            let agentic_response =
1700                match super::run_agentic_loop(question, &cache, agentic_config, &*reporter).await {
1701                    Ok(response) => response,
1702                    Err(e) => {
1703                        let _ = tx.send(PhaseUpdate::Error {
1704                            error: format!("Agentic loop failed: {}", e),
1705                        });
1706                        return;
1707                    }
1708                };
1709
1710            // Send tools phase update if tools were executed
1711            if let Some(ref tools) = agentic_response.tools_executed
1712                && !tools.is_empty()
1713            {
1714                let content = format!("Gathered context using {} tools", tools.len());
1715                let _ = tx.send(PhaseUpdate::Tools {
1716                    content,
1717                    tool_calls: tools.clone(),
1718                });
1719            }
1720
1721            // Get results count (needed for answer generation)
1722            let results_count = agentic_response.total_count.unwrap_or(0);
1723
1724            // Send queries phase update only if queries were generated
1725            if !agentic_response.queries.is_empty() {
1726                let query_strings: Vec<String> = agentic_response
1727                    .queries
1728                    .iter()
1729                    .map(|q| q.command.clone())
1730                    .collect();
1731
1732                let _ = tx.send(PhaseUpdate::Queries {
1733                    queries: query_strings,
1734                });
1735
1736                // Send execution phase update only if queries were executed
1737                let start_time = std::time::Instant::now();
1738                let execution_time_ms = start_time.elapsed().as_millis() as u64;
1739
1740                let _ = tx.send(PhaseUpdate::Executing {
1741                    results_count,
1742                    execution_time_ms,
1743                });
1744            }
1745
1746            // Generate answer
1747            let provider_instance = match (|| -> Result<_> {
1748                let mut config = super::config::load_config(&cache_path)?;
1749                config.provider = provider_name.to_string();
1750                let api_key = super::config::get_api_key(&config.provider)?;
1751                let model = super::config::resolve_model(&config, model_override);
1752                super::providers::create_provider(
1753                    &config.provider,
1754                    api_key,
1755                    model,
1756                    super::config::get_provider_options(&config.provider),
1757                    config.timeout_seconds,
1758                )
1759            })() {
1760                Ok(provider) => provider,
1761                Err(e) => {
1762                    let _ = tx.send(PhaseUpdate::Error {
1763                        error: format!("Failed to create provider: {}", e),
1764                    });
1765                    return;
1766                }
1767            };
1768
1769            let answer = match super::generate_answer(
1770                question,
1771                &agentic_response.results,
1772                results_count,
1773                agentic_response.gathered_context.as_deref(),
1774                codebase_context_str.as_deref(),
1775                &*provider_instance,
1776            )
1777            .await
1778            {
1779                Ok(answer) => answer,
1780                Err(e) => {
1781                    let _ = tx.send(PhaseUpdate::Error {
1782                        error: format!("Failed to generate answer: {}", e),
1783                    });
1784                    return;
1785                }
1786            };
1787
1788            // Send answer phase update
1789            let _ = tx.send(PhaseUpdate::Answer { answer });
1790
1791            // Send done signal
1792            let _ = tx.send(PhaseUpdate::Done);
1793        }
1794    }
1795}
1796
1797/// Setup terminal for TUI mode
1798fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
1799    crossterm::terminal::enable_raw_mode()?;
1800    let mut stdout = io::stdout();
1801    crossterm::execute!(
1802        stdout,
1803        crossterm::terminal::EnterAlternateScreen,
1804        crossterm::event::EnableMouseCapture,
1805        crossterm::cursor::Show
1806    )?;
1807    let backend = CrosstermBackend::new(stdout);
1808    let terminal = Terminal::new(backend)?;
1809    Ok(terminal)
1810}
1811
1812/// Restore terminal after TUI mode
1813fn restore_terminal(mut terminal: Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
1814    crossterm::terminal::disable_raw_mode()?;
1815    crossterm::execute!(
1816        terminal.backend_mut(),
1817        crossterm::terminal::LeaveAlternateScreen,
1818        crossterm::event::DisableMouseCapture
1819    )?;
1820    terminal.show_cursor()?;
1821    Ok(())
1822}
1823
1824/// Run interactive chat mode
1825pub fn run_chat_mode(
1826    cache: CacheManager,
1827    provider: Option<String>,
1828    model: Option<String>,
1829) -> Result<()> {
1830    // Determine provider
1831    let provider_name = if let Some(p) = provider {
1832        p
1833    } else {
1834        // Load from config
1835        let config = super::config::load_config(cache.path())?;
1836        config.provider
1837    };
1838
1839    let mut app = ChatApp::new(cache, provider_name, model)?;
1840    app.run()
1841}