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