Skip to main content

codetether_agent/tui/
mod.rs

1//! Terminal User Interface
2//!
3//! Interactive TUI using Ratatui
4
5pub mod message_formatter;
6pub mod swarm_view;
7pub mod theme;
8pub mod theme_utils;
9pub mod token_display;
10
11use crate::config::Config;
12use crate::session::{Session, SessionEvent};
13use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
14use crate::tui::message_formatter::MessageFormatter;
15use crate::tui::swarm_view::{render_swarm_view, SubTaskInfo, SwarmEvent, SwarmViewState};
16use crate::tui::theme::Theme;
17use crate::tui::token_display::TokenDisplay;
18use anyhow::Result;
19use crossterm::{
20    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
21    execute,
22    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
23};
24use ratatui::{
25    Frame, Terminal,
26    backend::CrosstermBackend,
27    layout::{Constraint, Direction, Layout, Rect},
28    style::{Color, Modifier, Style},
29    text::{Line, Span},
30    widgets::{
31        Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
32    },
33};
34use std::io;
35use std::path::PathBuf;
36use tokio::sync::mpsc;
37
38/// Run the TUI
39pub async fn run(project: Option<PathBuf>) -> Result<()> {
40    // Change to project directory if specified
41    if let Some(dir) = project {
42        std::env::set_current_dir(&dir)?;
43    }
44
45    // Setup terminal
46    enable_raw_mode()?;
47    let mut stdout = io::stdout();
48    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
49    let backend = CrosstermBackend::new(stdout);
50    let mut terminal = Terminal::new(backend)?;
51
52    // Run the app
53    let result = run_app(&mut terminal).await;
54
55    // Restore terminal
56    disable_raw_mode()?;
57    execute!(
58        terminal.backend_mut(),
59        LeaveAlternateScreen,
60        DisableMouseCapture
61    )?;
62    terminal.show_cursor()?;
63
64    result
65}
66
67/// Message type for chat display
68#[derive(Debug, Clone)]
69enum MessageType {
70    Text(String),
71    ToolCall { name: String, arguments: String },
72    ToolResult { name: String, output: String },
73}
74
75/// View mode for the TUI
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum ViewMode {
78    Chat,
79    Swarm,
80}
81
82/// Application state
83struct App {
84    input: String,
85    cursor_position: usize,
86    messages: Vec<ChatMessage>,
87    current_agent: String,
88    scroll: usize,
89    show_help: bool,
90    command_history: Vec<String>,
91    history_index: Option<usize>,
92    session: Option<Session>,
93    is_processing: bool,
94    processing_message: Option<String>,
95    current_tool: Option<String>,
96    response_rx: Option<mpsc::Receiver<SessionEvent>>,
97    // Swarm mode state
98    view_mode: ViewMode,
99    swarm_state: SwarmViewState,
100    swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
101}
102
103struct ChatMessage {
104    role: String,
105    content: String,
106    timestamp: String,
107    message_type: MessageType,
108}
109
110impl ChatMessage {
111    fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
112        Self {
113            role: role.into(),
114            content: content.into(),
115            timestamp: chrono::Local::now().format("%H:%M").to_string(),
116            message_type: MessageType::Text(String::new()),
117        }
118    }
119
120    fn with_message_type(mut self, message_type: MessageType) -> Self {
121        self.message_type = message_type;
122        self
123    }
124}
125
126impl App {
127    fn new() -> Self {
128        Self {
129            input: String::new(),
130            cursor_position: 0,
131            messages: vec![
132                ChatMessage::new("system", "Welcome to CodeTether Agent! Type a message to get started, or press ? for help.\n\nTip: Prefix with /swarm to run in parallel swarm mode!"),
133                ChatMessage::new("assistant", "Features:\n• Real-time tool call streaming\n• Swarm mode for parallel execution (/swarm <task>)\n• Press Tab to switch agents, ? for help\n\nExample code block:\n```rust\nfn main() {\n    println!(\"Hello, World!\");\n}\n```"),
134            ],
135            current_agent: "build".to_string(),
136            scroll: 0,
137            show_help: false,
138            command_history: Vec::new(),
139            history_index: None,
140            session: None,
141            is_processing: false,
142            processing_message: None,
143            current_tool: None,
144            response_rx: None,
145            view_mode: ViewMode::Chat,
146            swarm_state: SwarmViewState::new(),
147            swarm_rx: None,
148        }
149    }
150
151    async fn submit_message(&mut self, config: &Config) {
152        if self.input.is_empty() {
153            return;
154        }
155
156        let message = std::mem::take(&mut self.input);
157        self.cursor_position = 0;
158
159        // Save to command history
160        if !message.trim().is_empty() {
161            self.command_history.push(message.clone());
162            self.history_index = None;
163        }
164
165        // Check for /swarm command
166        if message.trim().starts_with("/swarm ") {
167            let task = message.trim().strip_prefix("/swarm ").unwrap_or("").to_string();
168            if task.is_empty() {
169                self.messages.push(ChatMessage::new("system", "Usage: /swarm <task description>"));
170                return;
171            }
172            self.start_swarm_execution(task, config).await;
173            return;
174        }
175
176        // Check for /view command to toggle views
177        if message.trim() == "/view" || message.trim() == "/swarm" {
178            self.view_mode = match self.view_mode {
179                ViewMode::Chat => ViewMode::Swarm,
180                ViewMode::Swarm => ViewMode::Chat,
181            };
182            return;
183        }
184
185        // Add user message
186        self.messages.push(ChatMessage::new("user", message.clone()));
187
188        // Auto-scroll to bottom when user sends a message
189        self.scroll = usize::MAX;
190
191        let current_agent = self.current_agent.clone();
192        let model = config
193            .agents
194            .get(&current_agent)
195            .and_then(|agent| agent.model.clone())
196            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
197            .or_else(|| config.default_model.clone())
198            .or_else(|| Some("zhipuai/glm-4.7".to_string()));
199
200        // Initialize session if needed
201        if self.session.is_none() {
202            match Session::new().await {
203                Ok(session) => {
204                    self.session = Some(session);
205                }
206                Err(err) => {
207                    tracing::error!(error = %err, "Failed to create session");
208                    self.messages.push(ChatMessage::new("assistant", format!("Error: {err}")));
209                    return;
210                }
211            }
212        }
213
214        let session = match self.session.as_mut() {
215            Some(session) => session,
216            None => {
217                self.messages.push(ChatMessage::new("assistant", "Error: session not initialized"));
218                return;
219            }
220        };
221
222        if let Some(model) = model {
223            session.metadata.model = Some(model);
224        }
225
226        session.agent = current_agent;
227
228        // Set processing state
229        self.is_processing = true;
230        self.processing_message = Some("Thinking...".to_string());
231        self.current_tool = None;
232
233        // Create channel for async communication
234        let (tx, rx) = mpsc::channel(100);
235        self.response_rx = Some(rx);
236
237        // Clone session for async processing
238        let session_clone = session.clone();
239        let message_clone = message.clone();
240
241        // Spawn async task to process the message with event streaming
242        tokio::spawn(async move {
243            let mut session = session_clone;
244            if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
245                tracing::error!(error = %err, "Agent processing failed");
246                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
247                let _ = tx.send(SessionEvent::Done).await;
248            }
249        });
250    }
251
252    fn handle_response(&mut self, event: SessionEvent) {
253        // Auto-scroll to bottom when new content arrives
254        self.scroll = usize::MAX;
255
256        match event {
257            SessionEvent::Thinking => {
258                self.processing_message = Some("Thinking...".to_string());
259                self.current_tool = None;
260            }
261            SessionEvent::ToolCallStart { name, arguments } => {
262                self.processing_message = Some(format!("Running {}...", name));
263                self.current_tool = Some(name.clone());
264                self.messages.push(
265                    ChatMessage::new("tool", format!("🔧 {}", name))
266                        .with_message_type(MessageType::ToolCall { name, arguments }),
267                );
268            }
269            SessionEvent::ToolCallComplete { name, output, success } => {
270                let icon = if success { "✓" } else { "✗" };
271                self.messages.push(
272                    ChatMessage::new("tool", format!("{} {}", icon, name))
273                        .with_message_type(MessageType::ToolResult { name, output }),
274                );
275                self.current_tool = None;
276                self.processing_message = Some("Thinking...".to_string());
277            }
278            SessionEvent::TextChunk(_text) => {
279                // Could be used for streaming text display in the future
280            }
281            SessionEvent::TextComplete(text) => {
282                if !text.is_empty() {
283                    self.messages.push(ChatMessage::new("assistant", text));
284                }
285            }
286            SessionEvent::Error(err) => {
287                self.messages.push(ChatMessage::new("assistant", format!("Error: {}", err)));
288            }
289            SessionEvent::Done => {
290                self.is_processing = false;
291                self.processing_message = None;
292                self.current_tool = None;
293                self.response_rx = None;
294            }
295        }
296    }
297
298    /// Handle a swarm event
299    fn handle_swarm_event(&mut self, event: SwarmEvent) {
300        self.swarm_state.handle_event(event.clone());
301
302        // When swarm completes, switch back to chat view with summary
303        if let SwarmEvent::Complete { success, ref stats } = event {
304            self.view_mode = ViewMode::Chat;
305            let summary = if success {
306                format!(
307                    "Swarm completed successfully.\n\
308                     Subtasks: {} completed, {} failed\n\
309                     Total tool calls: {}\n\
310                     Time: {:.1}s (speedup: {:.1}x)",
311                    stats.subagents_completed,
312                    stats.subagents_failed,
313                    stats.total_tool_calls,
314                    stats.execution_time_ms as f64 / 1000.0,
315                    stats.speedup_factor
316                )
317            } else {
318                format!(
319                    "Swarm completed with failures.\n\
320                     Subtasks: {} completed, {} failed\n\
321                     Check the subtask results for details.",
322                    stats.subagents_completed, stats.subagents_failed
323                )
324            };
325            self.messages.push(ChatMessage::new("system", &summary));
326            self.swarm_rx = None;
327        }
328
329        if let SwarmEvent::Error(ref err) = event {
330            self.messages.push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
331        }
332    }
333
334    /// Start swarm execution for a task
335    async fn start_swarm_execution(&mut self, task: String, config: &Config) {
336        // Add user message
337        self.messages.push(ChatMessage::new("user", format!("/swarm {}", task)));
338
339        // Get model from config
340        let model = config
341            .default_model
342            .clone()
343            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
344
345        // Configure swarm
346        let swarm_config = SwarmConfig {
347            model,
348            max_subagents: 10,
349            max_steps_per_subagent: 50,
350            worktree_enabled: true,
351            worktree_auto_merge: true,
352            working_dir: Some(std::env::current_dir()
353                .map(|p| p.to_string_lossy().to_string())
354                .unwrap_or_else(|_| ".".to_string())),
355            ..Default::default()
356        };
357
358        let executor = SwarmExecutor::new(swarm_config);
359
360        // Create channel for swarm events
361        let (tx, rx) = mpsc::channel(100);
362        self.swarm_rx = Some(rx);
363
364        // Switch to swarm view
365        self.view_mode = ViewMode::Swarm;
366        self.swarm_state = SwarmViewState::new();
367
368        // Clone task for async
369        let task_clone = task.clone();
370
371        // Send initial event
372        let _ = tx.send(SwarmEvent::Started {
373            task: task.clone(),
374            total_subtasks: 0,
375        }).await;
376
377        // Spawn swarm execution
378        tokio::spawn(async move {
379            let result = executor.execute(&task_clone, DecompositionStrategy::Automatic).await;
380
381            match result {
382                Ok(swarm_result) => {
383                    // Send decomposition info
384                    let subtask_infos: Vec<SubTaskInfo> = swarm_result
385                        .subtask_results
386                        .iter()
387                        .enumerate()
388                        .map(|(i, r)| SubTaskInfo {
389                            id: r.subtask_id.clone(),
390                            name: format!("Subtask {}", i + 1),
391                            status: if r.success {
392                                crate::swarm::SubTaskStatus::Completed
393                            } else {
394                                crate::swarm::SubTaskStatus::Failed
395                            },
396                            stage: 0,
397                            dependencies: vec![],
398                            agent_name: Some(r.subagent_id.clone()),
399                            current_tool: None,
400                            steps: r.steps,
401                            max_steps: 50,
402                        })
403                        .collect();
404
405                    let _ = tx.send(SwarmEvent::Decomposed {
406                        subtasks: subtask_infos,
407                    }).await;
408
409                    // Send completion
410                    let _ = tx.send(SwarmEvent::Complete {
411                        success: swarm_result.success,
412                        stats: swarm_result.stats,
413                    }).await;
414                }
415                Err(e) => {
416                    let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
417                }
418            }
419        });
420    }
421
422    fn navigate_history(&mut self, direction: isize) {
423        if self.command_history.is_empty() {
424            return;
425        }
426
427        let history_len = self.command_history.len();
428        let new_index = match self.history_index {
429            Some(current) => {
430                let new = current as isize + direction;
431                if new < 0 {
432                    None
433                } else if new >= history_len as isize {
434                    Some(history_len - 1)
435                } else {
436                    Some(new as usize)
437                }
438            }
439            None => {
440                if direction > 0 {
441                    Some(0)
442                } else {
443                    Some(history_len.saturating_sub(1))
444                }
445            }
446        };
447
448        self.history_index = new_index;
449        if let Some(index) = new_index {
450            self.input = self.command_history[index].clone();
451            self.cursor_position = self.input.len();
452        } else {
453            self.input.clear();
454            self.cursor_position = 0;
455        }
456    }
457
458    fn search_history(&mut self) {
459        // Enhanced search: find commands matching current input prefix
460        if self.command_history.is_empty() {
461            return;
462        }
463
464        let search_term = self.input.trim().to_lowercase();
465
466        if search_term.is_empty() {
467            // Empty search - show most recent
468            if !self.command_history.is_empty() {
469                self.input = self.command_history.last().unwrap().clone();
470                self.cursor_position = self.input.len();
471                self.history_index = Some(self.command_history.len() - 1);
472            }
473            return;
474        }
475
476        // Find the most recent command that starts with the search term
477        for (index, cmd) in self.command_history.iter().enumerate().rev() {
478            if cmd.to_lowercase().starts_with(&search_term) {
479                self.input = cmd.clone();
480                self.cursor_position = self.input.len();
481                self.history_index = Some(index);
482                return;
483            }
484        }
485
486        // If no prefix match, search for contains
487        for (index, cmd) in self.command_history.iter().enumerate().rev() {
488            if cmd.to_lowercase().contains(&search_term) {
489                self.input = cmd.clone();
490                self.cursor_position = self.input.len();
491                self.history_index = Some(index);
492                return;
493            }
494        }
495    }
496}
497
498async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
499    let mut app = App::new();
500
501    // Load configuration and theme
502    let mut config = Config::load().await?;
503    let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
504
505    // Track last config modification time for hot-reloading
506    let _config_paths = vec![
507        std::path::PathBuf::from("./codetether.toml"),
508        std::path::PathBuf::from("./.codetether/config.toml"),
509    ];
510
511    let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
512        .map(|dirs| dirs.config_dir().join("config.toml"));
513
514    let mut last_check = std::time::Instant::now();
515
516    loop {
517        // Check for theme changes if hot-reload is enabled
518        if config.ui.hot_reload && last_check.elapsed() > std::time::Duration::from_secs(2) {
519            if let Ok(new_config) = Config::load().await {
520                if new_config.ui.theme != config.ui.theme
521                    || new_config.ui.custom_theme != config.ui.custom_theme
522                {
523                    theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
524                    config = new_config;
525                }
526            }
527            last_check = std::time::Instant::now();
528        }
529
530        terminal.draw(|f| ui(f, &app, &theme))?;
531
532        // Check for async responses
533        if let Some(ref mut rx) = app.response_rx {
534            if let Ok(response) = rx.try_recv() {
535                app.handle_response(response);
536            }
537        }
538
539        // Check for swarm events
540        if let Some(ref mut rx) = app.swarm_rx {
541            if let Ok(event) = rx.try_recv() {
542                app.handle_swarm_event(event);
543            }
544        }
545
546        if event::poll(std::time::Duration::from_millis(100))? {
547            if let Event::Key(key) = event::read()? {
548                // Help overlay
549                if app.show_help {
550                    if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
551                        app.show_help = false;
552                    }
553                    continue;
554                }
555
556                match key.code {
557                    // Quit
558                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
559                        return Ok(());
560                    }
561                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
562                        return Ok(());
563                    }
564
565                    // Help
566                    KeyCode::Char('?') => {
567                        app.show_help = true;
568                    }
569
570                    // Toggle view mode (F2)
571                    KeyCode::F(2) => {
572                        app.view_mode = match app.view_mode {
573                            ViewMode::Chat => ViewMode::Swarm,
574                            ViewMode::Swarm => ViewMode::Chat,
575                        };
576                    }
577
578                    // Escape - return to chat from swarm view
579                    KeyCode::Esc => {
580                        if app.view_mode == ViewMode::Swarm {
581                            app.view_mode = ViewMode::Chat;
582                        }
583                    }
584
585                    // Switch agent
586                    KeyCode::Tab => {
587                        app.current_agent = if app.current_agent == "build" {
588                            "plan".to_string()
589                        } else {
590                            "build".to_string()
591                        };
592                    }
593
594                    // Submit message
595                    KeyCode::Enter => {
596                        app.submit_message(&config).await;
597                    }
598
599                    // Vim-style scrolling (Alt + j/k)
600                    KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
601                        app.scroll = app.scroll.saturating_add(1);
602                    }
603                    KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
604                        app.scroll = app.scroll.saturating_sub(1);
605                    }
606
607                    // Command history
608                    KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
609                        app.search_history();
610                    }
611                    KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
612                        app.navigate_history(-1);
613                    }
614                    KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
615                        app.navigate_history(1);
616                    }
617
618                    // Additional Vim-style navigation (with modifiers to avoid conflicts)
619                    KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
620                        app.scroll = 0; // Go to top
621                    }
622                    KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
623                        // Go to bottom
624                        app.scroll = usize::MAX;
625                    }
626
627                    // Enhanced scrolling (with Alt to avoid conflicts)
628                    KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
629                        // Half page down
630                        app.scroll = app.scroll.saturating_add(5);
631                    }
632                    KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
633                        // Half page up
634                        app.scroll = app.scroll.saturating_sub(5);
635                    }
636
637                    // Text input
638                    KeyCode::Char(c) => {
639                        app.input.insert(app.cursor_position, c);
640                        app.cursor_position += 1;
641                    }
642                    KeyCode::Backspace => {
643                        if app.cursor_position > 0 {
644                            app.cursor_position -= 1;
645                            app.input.remove(app.cursor_position);
646                        }
647                    }
648                    KeyCode::Delete => {
649                        if app.cursor_position < app.input.len() {
650                            app.input.remove(app.cursor_position);
651                        }
652                    }
653                    KeyCode::Left => {
654                        app.cursor_position = app.cursor_position.saturating_sub(1);
655                    }
656                    KeyCode::Right => {
657                        if app.cursor_position < app.input.len() {
658                            app.cursor_position += 1;
659                        }
660                    }
661                    KeyCode::Home => {
662                        app.cursor_position = 0;
663                    }
664                    KeyCode::End => {
665                        app.cursor_position = app.input.len();
666                    }
667
668                    // Scroll
669                    KeyCode::Up => {
670                        app.scroll = app.scroll.saturating_sub(1);
671                    }
672                    KeyCode::Down => {
673                        app.scroll = app.scroll.saturating_add(1);
674                    }
675                    KeyCode::PageUp => {
676                        app.scroll = app.scroll.saturating_sub(10);
677                    }
678                    KeyCode::PageDown => {
679                        app.scroll = app.scroll.saturating_add(10);
680                    }
681
682                    _ => {}
683                }
684            }
685        }
686    }
687}
688
689fn ui(f: &mut Frame, app: &App, theme: &Theme) {
690    // Check view mode
691    if app.view_mode == ViewMode::Swarm {
692        // Render swarm view
693        let chunks = Layout::default()
694            .direction(Direction::Vertical)
695            .constraints([
696                Constraint::Min(1),    // Swarm view
697                Constraint::Length(3), // Input
698                Constraint::Length(1), // Status bar
699            ])
700            .split(f.area());
701
702        // Swarm view
703        render_swarm_view(f, &app.swarm_state, chunks[0]);
704
705        // Input area (for returning to chat)
706        let input_block = Block::default()
707            .borders(Borders::ALL)
708            .title(" Press Esc or /view to return to chat ")
709            .border_style(Style::default().fg(Color::Cyan));
710
711        let input = Paragraph::new(app.input.as_str())
712            .block(input_block)
713            .wrap(Wrap { trim: false });
714        f.render_widget(input, chunks[1]);
715
716        // Status bar
717        let status = Paragraph::new(Line::from(vec![
718            Span::styled(" SWARM MODE ", Style::default().fg(Color::Black).bg(Color::Cyan)),
719            Span::raw(" | "),
720            Span::styled("Esc", Style::default().fg(Color::Yellow)),
721            Span::raw(": Back to chat | "),
722            Span::styled("F2", Style::default().fg(Color::Yellow)),
723            Span::raw(": Toggle view"),
724        ]));
725        f.render_widget(status, chunks[2]);
726        return;
727    }
728
729    // Chat view (default)
730    let chunks = Layout::default()
731        .direction(Direction::Vertical)
732        .constraints([
733            Constraint::Min(1),    // Messages
734            Constraint::Length(3), // Input
735            Constraint::Length(1), // Status bar
736        ])
737        .split(f.area());
738
739    // Messages area with theme-based styling
740    let messages_area = chunks[0];
741    let messages_block = Block::default()
742        .borders(Borders::ALL)
743        .title(format!(" CodeTether Agent [{}] ", app.current_agent))
744        .border_style(Style::default().fg(theme.border_color.to_color()));
745
746    // Create scrollable message content
747    let mut message_lines = Vec::new();
748    let max_width = messages_area.width.saturating_sub(4) as usize;
749
750    for message in &app.messages {
751        // Message header with theme-based styling
752        let role_style = theme.get_role_style(&message.role);
753
754        let header_line = Line::from(vec![
755            Span::styled(
756                format!("[{}] ", message.timestamp),
757                Style::default()
758                    .fg(theme.timestamp_color.to_color())
759                    .add_modifier(Modifier::DIM),
760            ),
761            Span::styled(&message.role, role_style),
762        ]);
763        message_lines.push(header_line);
764
765        // Format message content based on message type
766        match &message.message_type {
767            MessageType::ToolCall { name, arguments } => {
768                // Tool call display with distinct styling
769                let tool_header = Line::from(vec![
770                    Span::styled("  🔧 ", Style::default().fg(Color::Yellow)),
771                    Span::styled(format!("Tool: {}", name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
772                ]);
773                message_lines.push(tool_header);
774                
775                // Arguments (truncated if too long)
776                let args_str = if arguments.len() > 200 {
777                    format!("{}...", &arguments[..197])
778                } else {
779                    arguments.clone()
780                };
781                let args_line = Line::from(vec![
782                    Span::styled("     ", Style::default()),
783                    Span::styled(args_str, Style::default().fg(Color::DarkGray)),
784                ]);
785                message_lines.push(args_line);
786            }
787            MessageType::ToolResult { name, output } => {
788                // Tool result display
789                let result_header = Line::from(vec![
790                    Span::styled("  ✅ ", Style::default().fg(Color::Green)),
791                    Span::styled(format!("Result from {}", name), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
792                ]);
793                message_lines.push(result_header);
794                
795                // Output (truncated if too long)
796                let output_str = if output.len() > 300 {
797                    format!("{}... (truncated)", &output[..297])
798                } else {
799                    output.clone()
800                };
801                let output_lines: Vec<&str> = output_str.lines().collect();
802                for line in output_lines.iter().take(5) {
803                    let output_line = Line::from(vec![
804                        Span::styled("     ", Style::default()),
805                        Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
806                    ]);
807                    message_lines.push(output_line);
808                }
809                if output_lines.len() > 5 {
810                    message_lines.push(Line::from(vec![
811                        Span::styled("     ", Style::default()),
812                        Span::styled(format!("... and {} more lines", output_lines.len() - 5), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)),
813                    ]));
814                }
815            }
816            _ => {
817                // Regular text message
818                let formatter = MessageFormatter::new(max_width);
819                let formatted_content = formatter.format_content(&message.content, &message.role);
820                message_lines.extend(formatted_content);
821            }
822        }
823
824        // Add spacing between messages
825        message_lines.push(Line::from(""));
826    }
827
828    // Show processing indicator if active
829    if app.is_processing {
830        let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
831        let spinner_idx = (std::time::SystemTime::now()
832            .duration_since(std::time::UNIX_EPOCH)
833            .unwrap_or_default()
834            .as_millis() / 100) as usize % spinner.len();
835        
836        let processing_line = Line::from(vec![
837            Span::styled(
838                format!("[{}] ", chrono::Local::now().format("%H:%M")),
839                Style::default()
840                    .fg(theme.timestamp_color.to_color())
841                    .add_modifier(Modifier::DIM),
842            ),
843            Span::styled("assistant", theme.get_role_style("assistant")),
844        ]);
845        message_lines.push(processing_line);
846        
847        // Show different colors based on state
848        let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
849            (format!("  {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
850        } else {
851            (format!("  {} {}", spinner[spinner_idx], app.processing_message.as_deref().unwrap_or("Thinking...")), Color::Yellow)
852        };
853        
854        let indicator_line = Line::from(vec![
855            Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
856        ]);
857        message_lines.push(indicator_line);
858        message_lines.push(Line::from(""));
859    }
860
861    // Calculate scroll position
862    let total_lines = message_lines.len();
863    let visible_lines = messages_area.height.saturating_sub(2) as usize;
864    let max_scroll = total_lines.saturating_sub(visible_lines);
865    let scroll = app.scroll.min(max_scroll);
866
867    // Render messages with scrolling
868    let messages_paragraph = Paragraph::new(
869        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
870    )
871    .block(messages_block.clone())
872    .wrap(Wrap { trim: false });
873
874    f.render_widget(messages_paragraph, messages_area);
875
876    // Render scrollbar if needed
877    if total_lines > visible_lines {
878        let scrollbar = Scrollbar::default()
879            .orientation(ScrollbarOrientation::VerticalRight)
880            .symbols(ratatui::symbols::scrollbar::VERTICAL)
881            .begin_symbol(Some("↑"))
882            .end_symbol(Some("↓"));
883
884        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
885
886        let scrollbar_area = Rect::new(
887            messages_area.right() - 1,
888            messages_area.top() + 1,
889            1,
890            messages_area.height - 2,
891        );
892
893        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
894    }
895
896    // Input area
897    let input_block = Block::default()
898        .borders(Borders::ALL)
899        .title(if app.is_processing {
900            " Message (Processing...) "
901        } else {
902            " Message (Enter to send) "
903        })
904        .border_style(Style::default().fg(if app.is_processing {
905            Color::Yellow
906        } else {
907            theme.input_border_color.to_color()
908        }));
909
910    let input = Paragraph::new(app.input.as_str())
911        .block(input_block)
912        .wrap(Wrap { trim: false });
913    f.render_widget(input, chunks[1]);
914
915    // Cursor
916    f.set_cursor_position((
917        chunks[1].x + app.cursor_position as u16 + 1,
918        chunks[1].y + 1,
919    ));
920
921    // Enhanced status bar with token display
922    let token_display = TokenDisplay::new();
923    let status = Paragraph::new(token_display.create_status_bar(theme));
924    f.render_widget(status, chunks[2]);
925
926    // Help overlay
927    if app.show_help {
928        let area = centered_rect(60, 60, f.area());
929        f.render_widget(Clear, area);
930
931        // Enhanced token usage details
932        let token_display = TokenDisplay::new();
933        let token_info = token_display.create_detailed_display();
934
935        let help_text: Vec<String> = vec![
936            "".to_string(),
937            "  KEYBOARD SHORTCUTS".to_string(),
938            "  ==================".to_string(),
939            "".to_string(),
940            "  Enter        Send message".to_string(),
941            "  Tab          Switch between build/plan agents".to_string(),
942            "  Ctrl+C       Quit".to_string(),
943            "  ?            Toggle this help".to_string(),
944            "".to_string(),
945            "  VIM-STYLE NAVIGATION".to_string(),
946            "  Alt+j        Scroll down".to_string(),
947            "  Alt+k        Scroll up".to_string(),
948            "  Ctrl+g       Go to top".to_string(),
949            "  Ctrl+G       Go to bottom".to_string(),
950            "".to_string(),
951            "  SCROLLING".to_string(),
952            "  Up/Down      Scroll messages".to_string(),
953            "  PageUp       Scroll up one page".to_string(),
954            "  PageDown     Scroll down one page".to_string(),
955            "  Alt+u        Scroll up half page".to_string(),
956            "  Alt+d        Scroll down half page".to_string(),
957            "".to_string(),
958            "  COMMAND HISTORY".to_string(),
959            "  Ctrl+R       Search history (matches current input)".to_string(),
960            "  Ctrl+Up      Previous command".to_string(),
961            "  Ctrl+Down    Next command".to_string(),
962            "".to_string(),
963            "  TEXT EDITING".to_string(),
964            "  Left/Right   Move cursor".to_string(),
965            "  Home/End     Jump to start/end".to_string(),
966            "  Backspace    Delete backwards".to_string(),
967            "  Delete       Delete forwards".to_string(),
968            "".to_string(),
969            "  AGENTS".to_string(),
970            "  ======".to_string(),
971            "".to_string(),
972            "  build        Full access for development work".to_string(),
973            "  plan         Read-only for analysis & exploration".to_string(),
974            "".to_string(),
975            "  Press ? or Esc to close".to_string(),
976            "".to_string(),
977        ];
978
979        let mut combined_text = token_info;
980        combined_text.extend(help_text);
981
982        let help = Paragraph::new(combined_text.join("\n"))
983            .block(
984                Block::default()
985                    .borders(Borders::ALL)
986                    .title(" Help ")
987                    .border_style(Style::default().fg(theme.help_border_color.to_color())),
988            )
989            .wrap(Wrap { trim: false });
990
991        f.render_widget(help, area);
992    }
993}
994
995/// Helper to create a centered rect
996fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
997    let popup_layout = Layout::default()
998        .direction(Direction::Vertical)
999        .constraints([
1000            Constraint::Percentage((100 - percent_y) / 2),
1001            Constraint::Percentage(percent_y),
1002            Constraint::Percentage((100 - percent_y) / 2),
1003        ])
1004        .split(r);
1005
1006    Layout::default()
1007        .direction(Direction::Horizontal)
1008        .constraints([
1009            Constraint::Percentage((100 - percent_x) / 2),
1010            Constraint::Percentage(percent_x),
1011            Constraint::Percentage((100 - percent_x) / 2),
1012        ])
1013        .split(popup_layout[1])[1]
1014}