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
11/// Sentinel value meaning "scroll to bottom"
12const SCROLL_BOTTOM: usize = 1_000_000;
13
14use crate::config::Config;
15use crate::provider::{ContentPart, Role};
16use crate::session::{list_sessions, Session, SessionEvent, SessionSummary};
17use crate::swarm::{DecompositionStrategy, Orchestrator, SwarmConfig, SwarmExecutor, SwarmStats};
18use crate::tui::message_formatter::MessageFormatter;
19use crate::tui::swarm_view::{render_swarm_view, SubTaskInfo, SwarmEvent, SwarmViewState};
20use crate::tui::theme::Theme;
21use crate::tui::token_display::TokenDisplay;
22use anyhow::Result;
23use crossterm::{
24    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
25    execute,
26    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
27};
28use ratatui::{
29    Frame, Terminal,
30    backend::CrosstermBackend,
31    layout::{Constraint, Direction, Layout, Rect},
32    style::{Color, Modifier, Style},
33    text::{Line, Span},
34    widgets::{
35        Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
36    },
37};
38use std::io;
39use std::path::PathBuf;
40use tokio::sync::mpsc;
41
42/// Run the TUI
43pub async fn run(project: Option<PathBuf>) -> Result<()> {
44    // Change to project directory if specified
45    if let Some(dir) = project {
46        std::env::set_current_dir(&dir)?;
47    }
48
49    // Setup terminal
50    enable_raw_mode()?;
51    let mut stdout = io::stdout();
52    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
53    let backend = CrosstermBackend::new(stdout);
54    let mut terminal = Terminal::new(backend)?;
55
56    // Run the app
57    let result = run_app(&mut terminal).await;
58
59    // Restore terminal
60    disable_raw_mode()?;
61    execute!(
62        terminal.backend_mut(),
63        LeaveAlternateScreen,
64        DisableMouseCapture
65    )?;
66    terminal.show_cursor()?;
67
68    result
69}
70
71/// Message type for chat display
72#[derive(Debug, Clone)]
73enum MessageType {
74    Text(String),
75    ToolCall { name: String, arguments: String },
76    ToolResult { name: String, output: String },
77}
78
79/// View mode for the TUI
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum ViewMode {
82    Chat,
83    Swarm,
84    SessionPicker,
85}
86
87/// Application state
88struct App {
89    input: String,
90    cursor_position: usize,
91    messages: Vec<ChatMessage>,
92    current_agent: String,
93    scroll: usize,
94    show_help: bool,
95    command_history: Vec<String>,
96    history_index: Option<usize>,
97    session: Option<Session>,
98    is_processing: bool,
99    processing_message: Option<String>,
100    current_tool: Option<String>,
101    response_rx: Option<mpsc::Receiver<SessionEvent>>,
102    // Swarm mode state
103    view_mode: ViewMode,
104    swarm_state: SwarmViewState,
105    swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
106    // Session picker state
107    session_picker_list: Vec<SessionSummary>,
108    session_picker_selected: usize,
109}
110
111struct ChatMessage {
112    role: String,
113    content: String,
114    timestamp: String,
115    message_type: MessageType,
116}
117
118impl ChatMessage {
119    fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
120        let content = content.into();
121        Self {
122            role: role.into(),
123            timestamp: chrono::Local::now().format("%H:%M").to_string(),
124            message_type: MessageType::Text(content.clone()),
125            content,
126        }
127    }
128
129    fn with_message_type(mut self, message_type: MessageType) -> Self {
130        self.message_type = message_type;
131        self
132    }
133}
134
135impl App {
136    fn new() -> Self {
137        Self {
138            input: String::new(),
139            cursor_position: 0,
140            messages: vec![
141                ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
142                ChatMessage::new("assistant", "Quick start:\n• Type a message to chat with the AI\n• /swarm <task> - parallel execution\n• /sessions - pick a session to resume\n• /resume - continue last session\n• Tab - switch agents | ? - help"),
143            ],
144            current_agent: "build".to_string(),
145            scroll: 0,
146            show_help: false,
147            command_history: Vec::new(),
148            history_index: None,
149            session: None,
150            is_processing: false,
151            processing_message: None,
152            current_tool: None,
153            response_rx: None,
154            view_mode: ViewMode::Chat,
155            swarm_state: SwarmViewState::new(),
156            swarm_rx: None,
157            session_picker_list: Vec::new(),
158            session_picker_selected: 0,
159        }
160    }
161
162    async fn submit_message(&mut self, config: &Config) {
163        if self.input.is_empty() {
164            return;
165        }
166
167        let message = std::mem::take(&mut self.input);
168        self.cursor_position = 0;
169
170        // Save to command history
171        if !message.trim().is_empty() {
172            self.command_history.push(message.clone());
173            self.history_index = None;
174        }
175
176        // Check for /swarm command
177        if message.trim().starts_with("/swarm ") {
178            let task = message.trim().strip_prefix("/swarm ").unwrap_or("").to_string();
179            if task.is_empty() {
180                self.messages.push(ChatMessage::new("system", "Usage: /swarm <task description>"));
181                return;
182            }
183            self.start_swarm_execution(task, config).await;
184            return;
185        }
186
187        // Check for /view command to toggle views
188        if message.trim() == "/view" || message.trim() == "/swarm" {
189            self.view_mode = match self.view_mode {
190                ViewMode::Chat | ViewMode::SessionPicker => ViewMode::Swarm,
191                ViewMode::Swarm => ViewMode::Chat,
192            };
193            return;
194        }
195
196        // Check for /sessions command - open session picker
197        if message.trim() == "/sessions" {
198            match list_sessions().await {
199                Ok(sessions) => {
200                    if sessions.is_empty() {
201                        self.messages.push(ChatMessage::new("system", "No saved sessions found."));
202                    } else {
203                        self.session_picker_list = sessions.into_iter().take(10).collect();
204                        self.session_picker_selected = 0;
205                        self.view_mode = ViewMode::SessionPicker;
206                    }
207                }
208                Err(e) => {
209                    self.messages.push(ChatMessage::new("system", format!("Failed to list sessions: {}", e)));
210                }
211            }
212            return;
213        }
214
215        // Check for /resume command to load a session
216        if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
217            let session_id = message.trim().strip_prefix("/resume").map(|s| s.trim()).filter(|s| !s.is_empty());
218            let loaded = if let Some(id) = session_id {
219                Session::load(id).await
220            } else {
221                Session::last().await
222            };
223
224            match loaded {
225                Ok(session) => {
226                    // Convert session messages to chat messages
227                    self.messages.clear();
228                    self.messages.push(ChatMessage::new("system", format!(
229                        "Resumed session: {}\nCreated: {}\n{} messages loaded",
230                        session.title.as_deref().unwrap_or("(untitled)"),
231                        session.created_at.format("%Y-%m-%d %H:%M"),
232                        session.messages.len()
233                    )));
234
235                    for msg in &session.messages {
236                        let role_str = match msg.role {
237                            Role::System => "system",
238                            Role::User => "user",
239                            Role::Assistant => "assistant",
240                            Role::Tool => "tool",
241                        };
242
243                        // Extract text content
244                        let content: String = msg.content.iter()
245                            .filter_map(|part| match part {
246                                ContentPart::Text { text } => Some(text.clone()),
247                                ContentPart::ToolCall { name, arguments, .. } => {
248                                    Some(format!("[Tool: {}]\n{}", name, arguments))
249                                }
250                                ContentPart::ToolResult { content, .. } => {
251                                    let truncated = if content.len() > 500 {
252                                        format!("{}...", &content[..497])
253                                    } else {
254                                        content.clone()
255                                    };
256                                    Some(format!("[Result]\n{}", truncated))
257                                }
258                                _ => None,
259                            })
260                            .collect::<Vec<_>>()
261                            .join("\n");
262
263                        if !content.is_empty() {
264                            self.messages.push(ChatMessage::new(role_str, content));
265                        }
266                    }
267
268                    self.current_agent = session.agent.clone();
269                    self.session = Some(session);
270                    self.scroll = SCROLL_BOTTOM;
271                }
272                Err(e) => {
273                    self.messages.push(ChatMessage::new("system", format!("Failed to load session: {}", e)));
274                }
275            }
276            return;
277        }
278
279        // Check for /new command to start a fresh session
280        if message.trim() == "/new" {
281            self.session = None;
282            self.messages.clear();
283            self.messages.push(ChatMessage::new("system", "Started a new session. Previous session was saved."));
284            return;
285        }
286
287        // Add user message
288        self.messages.push(ChatMessage::new("user", message.clone()));
289
290        // Auto-scroll to bottom when user sends a message
291        self.scroll = SCROLL_BOTTOM;
292
293        let current_agent = self.current_agent.clone();
294        let model = config
295            .agents
296            .get(&current_agent)
297            .and_then(|agent| agent.model.clone())
298            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
299            .or_else(|| config.default_model.clone())
300            .or_else(|| Some("zhipuai/glm-4.7".to_string()));
301
302        // Initialize session if needed
303        if self.session.is_none() {
304            match Session::new().await {
305                Ok(session) => {
306                    self.session = Some(session);
307                }
308                Err(err) => {
309                    tracing::error!(error = %err, "Failed to create session");
310                    self.messages.push(ChatMessage::new("assistant", format!("Error: {err}")));
311                    return;
312                }
313            }
314        }
315
316        let session = match self.session.as_mut() {
317            Some(session) => session,
318            None => {
319                self.messages.push(ChatMessage::new("assistant", "Error: session not initialized"));
320                return;
321            }
322        };
323
324        if let Some(model) = model {
325            session.metadata.model = Some(model);
326        }
327
328        session.agent = current_agent;
329
330        // Set processing state
331        self.is_processing = true;
332        self.processing_message = Some("Thinking...".to_string());
333        self.current_tool = None;
334
335        // Create channel for async communication
336        let (tx, rx) = mpsc::channel(100);
337        self.response_rx = Some(rx);
338
339        // Clone session for async processing
340        let session_clone = session.clone();
341        let message_clone = message.clone();
342
343        // Spawn async task to process the message with event streaming
344        tokio::spawn(async move {
345            let mut session = session_clone;
346            if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
347                tracing::error!(error = %err, "Agent processing failed");
348                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
349                let _ = tx.send(SessionEvent::Done).await;
350            }
351        });
352    }
353
354    fn handle_response(&mut self, event: SessionEvent) {
355        // Auto-scroll to bottom when new content arrives
356        self.scroll = SCROLL_BOTTOM;
357
358        match event {
359            SessionEvent::Thinking => {
360                self.processing_message = Some("Thinking...".to_string());
361                self.current_tool = None;
362            }
363            SessionEvent::ToolCallStart { name, arguments } => {
364                self.processing_message = Some(format!("Running {}...", name));
365                self.current_tool = Some(name.clone());
366                self.messages.push(
367                    ChatMessage::new("tool", format!("🔧 {}", name))
368                        .with_message_type(MessageType::ToolCall { name, arguments }),
369                );
370            }
371            SessionEvent::ToolCallComplete { name, output, success } => {
372                let icon = if success { "✓" } else { "✗" };
373                self.messages.push(
374                    ChatMessage::new("tool", format!("{} {}", icon, name))
375                        .with_message_type(MessageType::ToolResult { name, output }),
376                );
377                self.current_tool = None;
378                self.processing_message = Some("Thinking...".to_string());
379            }
380            SessionEvent::TextChunk(_text) => {
381                // Could be used for streaming text display in the future
382            }
383            SessionEvent::TextComplete(text) => {
384                if !text.is_empty() {
385                    self.messages.push(ChatMessage::new("assistant", text));
386                }
387            }
388            SessionEvent::Error(err) => {
389                self.messages.push(ChatMessage::new("assistant", format!("Error: {}", err)));
390            }
391            SessionEvent::Done => {
392                self.is_processing = false;
393                self.processing_message = None;
394                self.current_tool = None;
395                self.response_rx = None;
396            }
397        }
398    }
399
400    /// Handle a swarm event
401    fn handle_swarm_event(&mut self, event: SwarmEvent) {
402        self.swarm_state.handle_event(event.clone());
403
404        // When swarm completes, switch back to chat view with summary
405        if let SwarmEvent::Complete { success, ref stats } = event {
406            self.view_mode = ViewMode::Chat;
407            let summary = if success {
408                format!(
409                    "Swarm completed successfully.\n\
410                     Subtasks: {} completed, {} failed\n\
411                     Total tool calls: {}\n\
412                     Time: {:.1}s (speedup: {:.1}x)",
413                    stats.subagents_completed,
414                    stats.subagents_failed,
415                    stats.total_tool_calls,
416                    stats.execution_time_ms as f64 / 1000.0,
417                    stats.speedup_factor
418                )
419            } else {
420                format!(
421                    "Swarm completed with failures.\n\
422                     Subtasks: {} completed, {} failed\n\
423                     Check the subtask results for details.",
424                    stats.subagents_completed, stats.subagents_failed
425                )
426            };
427            self.messages.push(ChatMessage::new("system", &summary));
428            self.swarm_rx = None;
429        }
430
431        if let SwarmEvent::Error(ref err) = event {
432            self.messages.push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
433        }
434    }
435
436    /// Start swarm execution for a task
437    async fn start_swarm_execution(&mut self, task: String, config: &Config) {
438        // Add user message
439        self.messages.push(ChatMessage::new("user", format!("/swarm {}", task)));
440
441        // Get model from config
442        let model = config
443            .default_model
444            .clone()
445            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
446
447        // Configure swarm
448        let swarm_config = SwarmConfig {
449            model,
450            max_subagents: 10,
451            max_steps_per_subagent: 50,
452            worktree_enabled: true,
453            worktree_auto_merge: true,
454            working_dir: Some(std::env::current_dir()
455                .map(|p| p.to_string_lossy().to_string())
456                .unwrap_or_else(|_| ".".to_string())),
457            ..Default::default()
458        };
459
460        // Create channel for swarm events
461        let (tx, rx) = mpsc::channel(100);
462        self.swarm_rx = Some(rx);
463
464        // Switch to swarm view
465        self.view_mode = ViewMode::Swarm;
466        self.swarm_state = SwarmViewState::new();
467
468        // Clone task for async
469        let task_clone = task.clone();
470
471        // Send initial event
472        let _ = tx.send(SwarmEvent::Started {
473            task: task.clone(),
474            total_subtasks: 0,
475        }).await;
476
477        // Spawn swarm execution with real-time events
478        tokio::spawn(async move {
479            // Create orchestrator for decomposition
480            let orchestrator_result = Orchestrator::new(swarm_config.clone()).await;
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                // Session picker overlay - handle specially
672                if app.view_mode == ViewMode::SessionPicker {
673                    match key.code {
674                        KeyCode::Esc => {
675                            app.view_mode = ViewMode::Chat;
676                        }
677                        KeyCode::Up | KeyCode::Char('k') => {
678                            if app.session_picker_selected > 0 {
679                                app.session_picker_selected -= 1;
680                            }
681                        }
682                        KeyCode::Down | KeyCode::Char('j') => {
683                            if app.session_picker_selected < app.session_picker_list.len().saturating_sub(1) {
684                                app.session_picker_selected += 1;
685                            }
686                        }
687                        KeyCode::Enter => {
688                            if let Some(session_summary) = app.session_picker_list.get(app.session_picker_selected) {
689                                let session_id = session_summary.id.clone();
690                                match Session::load(&session_id).await {
691                                    Ok(session) => {
692                                        app.messages.clear();
693                                        app.messages.push(ChatMessage::new("system", format!(
694                                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
695                                            session.title.as_deref().unwrap_or("(untitled)"),
696                                            session.created_at.format("%Y-%m-%d %H:%M"),
697                                            session.messages.len()
698                                        )));
699
700                                        for msg in &session.messages {
701                                            let role_str = match msg.role {
702                                                Role::System => "system",
703                                                Role::User => "user",
704                                                Role::Assistant => "assistant",
705                                                Role::Tool => "tool",
706                                            };
707
708                                            let text = msg.content.iter()
709                                                .filter_map(|part| {
710                                                    if let ContentPart::Text { text } = part {
711                                                        Some(text.as_str())
712                                                    } else {
713                                                        None
714                                                    }
715                                                })
716                                                .collect::<Vec<_>>()
717                                                .join("\n");
718
719                                            if !text.is_empty() {
720                                                app.messages.push(ChatMessage::new(role_str, text));
721                                            }
722                                        }
723
724                                        app.current_agent = session.agent.clone();
725                                        app.session = Some(session);
726                                        app.scroll = SCROLL_BOTTOM;
727                                        app.view_mode = ViewMode::Chat;
728                                    }
729                                    Err(e) => {
730                                        app.messages.push(ChatMessage::new("system", format!("Failed to load session: {}", e)));
731                                        app.view_mode = ViewMode::Chat;
732                                    }
733                                }
734                            }
735                        }
736                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
737                            return Ok(());
738                        }
739                        KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
740                            return Ok(());
741                        }
742                        _ => {}
743                    }
744                    continue;
745                }
746
747                match key.code {
748                    // Quit
749                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
750                        return Ok(());
751                    }
752                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
753                        return Ok(());
754                    }
755
756                    // Help
757                    KeyCode::Char('?') => {
758                        app.show_help = true;
759                    }
760
761                    // Toggle view mode (F2 or Ctrl+S)
762                    KeyCode::F(2) => {
763                        app.view_mode = match app.view_mode {
764                            ViewMode::Chat | ViewMode::SessionPicker => ViewMode::Swarm,
765                            ViewMode::Swarm => ViewMode::Chat,
766                        };
767                    }
768                    KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
769                        app.view_mode = match app.view_mode {
770                            ViewMode::Chat | ViewMode::SessionPicker => ViewMode::Swarm,
771                            ViewMode::Swarm => ViewMode::Chat,
772                        };
773                    }
774
775                    // Escape - return to chat from swarm/picker view
776                    KeyCode::Esc => {
777                        if app.view_mode == ViewMode::Swarm || app.view_mode == ViewMode::SessionPicker {
778                            app.view_mode = ViewMode::Chat;
779                        }
780                    }
781
782                    // Switch agent
783                    KeyCode::Tab => {
784                        app.current_agent = if app.current_agent == "build" {
785                            "plan".to_string()
786                        } else {
787                            "build".to_string()
788                        };
789                    }
790
791                    // Submit message
792                    KeyCode::Enter => {
793                        app.submit_message(&config).await;
794                    }
795
796                    // Vim-style scrolling (Alt + j/k)
797                    KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
798                        if app.scroll < SCROLL_BOTTOM {
799                            app.scroll = app.scroll.saturating_add(1);
800                        }
801                    }
802                    KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
803                        if app.scroll >= SCROLL_BOTTOM {
804                            app.scroll = SCROLL_BOTTOM - 1; // Leave auto-scroll mode
805                        }
806                        app.scroll = app.scroll.saturating_sub(1);
807                    }
808
809                    // Command history
810                    KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
811                        app.search_history();
812                    }
813                    KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
814                        app.navigate_history(-1);
815                    }
816                    KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
817                        app.navigate_history(1);
818                    }
819
820                    // Additional Vim-style navigation (with modifiers to avoid conflicts)
821                    KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
822                        app.scroll = 0; // Go to top
823                    }
824                    KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
825                        // Go to bottom (auto-scroll)
826                        app.scroll = SCROLL_BOTTOM;
827                    }
828
829                    // Enhanced scrolling (with Alt to avoid conflicts)
830                    KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
831                        // Half page down
832                        if app.scroll < SCROLL_BOTTOM {
833                            app.scroll = app.scroll.saturating_add(5);
834                        }
835                    }
836                    KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
837                        // Half page up
838                        if app.scroll >= SCROLL_BOTTOM {
839                            app.scroll = SCROLL_BOTTOM - 1;
840                        }
841                        app.scroll = app.scroll.saturating_sub(5);
842                    }
843
844                    // Text input
845                    KeyCode::Char(c) => {
846                        app.input.insert(app.cursor_position, c);
847                        app.cursor_position += 1;
848                    }
849                    KeyCode::Backspace => {
850                        if app.cursor_position > 0 {
851                            app.cursor_position -= 1;
852                            app.input.remove(app.cursor_position);
853                        }
854                    }
855                    KeyCode::Delete => {
856                        if app.cursor_position < app.input.len() {
857                            app.input.remove(app.cursor_position);
858                        }
859                    }
860                    KeyCode::Left => {
861                        app.cursor_position = app.cursor_position.saturating_sub(1);
862                    }
863                    KeyCode::Right => {
864                        if app.cursor_position < app.input.len() {
865                            app.cursor_position += 1;
866                        }
867                    }
868                    KeyCode::Home => {
869                        app.cursor_position = 0;
870                    }
871                    KeyCode::End => {
872                        app.cursor_position = app.input.len();
873                    }
874
875                    // Scroll (normalize first to handle SCROLL_BOTTOM sentinel)
876                    KeyCode::Up => {
877                        if app.scroll >= SCROLL_BOTTOM {
878                            app.scroll = SCROLL_BOTTOM - 1; // Leave auto-scroll mode
879                        }
880                        app.scroll = app.scroll.saturating_sub(1);
881                    }
882                    KeyCode::Down => {
883                        if app.scroll < SCROLL_BOTTOM {
884                            app.scroll = app.scroll.saturating_add(1);
885                        }
886                    }
887                    KeyCode::PageUp => {
888                        if app.scroll >= SCROLL_BOTTOM {
889                            app.scroll = SCROLL_BOTTOM - 1;
890                        }
891                        app.scroll = app.scroll.saturating_sub(10);
892                    }
893                    KeyCode::PageDown => {
894                        if app.scroll < SCROLL_BOTTOM {
895                            app.scroll = app.scroll.saturating_add(10);
896                        }
897                    }
898
899                    _ => {}
900                }
901            }
902        }
903    }
904}
905
906fn ui(f: &mut Frame, app: &App, theme: &Theme) {
907    // Check view mode
908    if app.view_mode == ViewMode::Swarm {
909        // Render swarm view
910        let chunks = Layout::default()
911            .direction(Direction::Vertical)
912            .constraints([
913                Constraint::Min(1),    // Swarm view
914                Constraint::Length(3), // Input
915                Constraint::Length(1), // Status bar
916            ])
917            .split(f.area());
918
919        // Swarm view
920        render_swarm_view(f, &app.swarm_state, chunks[0]);
921
922        // Input area (for returning to chat)
923        let input_block = Block::default()
924            .borders(Borders::ALL)
925            .title(" Press Esc, Ctrl+S, or /view to return to chat ")
926            .border_style(Style::default().fg(Color::Cyan));
927
928        let input = Paragraph::new(app.input.as_str())
929            .block(input_block)
930            .wrap(Wrap { trim: false });
931        f.render_widget(input, chunks[1]);
932
933        // Status bar
934        let status = Paragraph::new(Line::from(vec![
935            Span::styled(" SWARM MODE ", Style::default().fg(Color::Black).bg(Color::Cyan)),
936            Span::raw(" | "),
937            Span::styled("Esc", Style::default().fg(Color::Yellow)),
938            Span::raw(": Back | "),
939            Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
940            Span::raw(": Toggle view"),
941        ]));
942        f.render_widget(status, chunks[2]);
943        return;
944    }
945
946    // Session picker view
947    if app.view_mode == ViewMode::SessionPicker {
948        let chunks = Layout::default()
949            .direction(Direction::Vertical)
950            .constraints([
951                Constraint::Min(1),    // Session list
952                Constraint::Length(1), // Status bar
953            ])
954            .split(f.area());
955
956        // Session list
957        let list_block = Block::default()
958            .borders(Borders::ALL)
959            .title(" Select Session (↑↓ to navigate, Enter to load, Esc to cancel) ")
960            .border_style(Style::default().fg(Color::Cyan));
961
962        let mut list_lines: Vec<Line> = Vec::new();
963        list_lines.push(Line::from(""));
964
965        for (i, session) in app.session_picker_list.iter().enumerate() {
966            let is_selected = i == app.session_picker_selected;
967            let title = session.title.as_deref().unwrap_or("(untitled)");
968            let date = session.updated_at.format("%Y-%m-%d %H:%M");
969            let line_str = format!(
970                " {} {} - {} ({} msgs)",
971                if is_selected { "▶" } else { " " },
972                title,
973                date,
974                session.message_count
975            );
976
977            let style = if is_selected {
978                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
979            } else {
980                Style::default()
981            };
982
983            list_lines.push(Line::styled(line_str, style));
984
985            // Add agent info on next line for selected item
986            if is_selected {
987                list_lines.push(Line::styled(
988                    format!("   Agent: {} | ID: {}", session.agent, session.id),
989                    Style::default().fg(Color::DarkGray),
990                ));
991            }
992        }
993
994        let list = Paragraph::new(list_lines)
995            .block(list_block)
996            .wrap(Wrap { trim: false });
997        f.render_widget(list, chunks[0]);
998
999        // Status bar
1000        let status = Paragraph::new(Line::from(vec![
1001            Span::styled(" SESSION PICKER ", Style::default().fg(Color::Black).bg(Color::Cyan)),
1002            Span::raw(" | "),
1003            Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)),
1004            Span::raw(": Navigate | "),
1005            Span::styled("Enter", Style::default().fg(Color::Yellow)),
1006            Span::raw(": Load | "),
1007            Span::styled("Esc", Style::default().fg(Color::Yellow)),
1008            Span::raw(": Cancel"),
1009        ]));
1010        f.render_widget(status, chunks[1]);
1011        return;
1012    }
1013
1014    // Chat view (default)
1015    let chunks = Layout::default()
1016        .direction(Direction::Vertical)
1017        .constraints([
1018            Constraint::Min(1),    // Messages
1019            Constraint::Length(3), // Input
1020            Constraint::Length(1), // Status bar
1021        ])
1022        .split(f.area());
1023
1024    // Messages area with theme-based styling
1025    let messages_area = chunks[0];
1026    let messages_block = Block::default()
1027        .borders(Borders::ALL)
1028        .title(format!(" CodeTether Agent [{}] ", app.current_agent))
1029        .border_style(Style::default().fg(theme.border_color.to_color()));
1030
1031    // Create scrollable message content
1032    let mut message_lines = Vec::new();
1033    let max_width = messages_area.width.saturating_sub(4) as usize;
1034
1035    for message in &app.messages {
1036        // Message header with theme-based styling
1037        let role_style = theme.get_role_style(&message.role);
1038
1039        let header_line = Line::from(vec![
1040            Span::styled(
1041                format!("[{}] ", message.timestamp),
1042                Style::default()
1043                    .fg(theme.timestamp_color.to_color())
1044                    .add_modifier(Modifier::DIM),
1045            ),
1046            Span::styled(&message.role, role_style),
1047        ]);
1048        message_lines.push(header_line);
1049
1050        // Format message content based on message type
1051        match &message.message_type {
1052            MessageType::ToolCall { name, arguments } => {
1053                // Tool call display with distinct styling
1054                let tool_header = Line::from(vec![
1055                    Span::styled("  🔧 ", Style::default().fg(Color::Yellow)),
1056                    Span::styled(format!("Tool: {}", name), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
1057                ]);
1058                message_lines.push(tool_header);
1059                
1060                // Arguments (truncated if too long)
1061                let args_str = if arguments.len() > 200 {
1062                    format!("{}...", &arguments[..197])
1063                } else {
1064                    arguments.clone()
1065                };
1066                let args_line = Line::from(vec![
1067                    Span::styled("     ", Style::default()),
1068                    Span::styled(args_str, Style::default().fg(Color::DarkGray)),
1069                ]);
1070                message_lines.push(args_line);
1071            }
1072            MessageType::ToolResult { name, output } => {
1073                // Tool result display
1074                let result_header = Line::from(vec![
1075                    Span::styled("  ✅ ", Style::default().fg(Color::Green)),
1076                    Span::styled(format!("Result from {}", name), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
1077                ]);
1078                message_lines.push(result_header);
1079                
1080                // Output (truncated if too long)
1081                let output_str = if output.len() > 300 {
1082                    format!("{}... (truncated)", &output[..297])
1083                } else {
1084                    output.clone()
1085                };
1086                let output_lines: Vec<&str> = output_str.lines().collect();
1087                for line in output_lines.iter().take(5) {
1088                    let output_line = Line::from(vec![
1089                        Span::styled("     ", Style::default()),
1090                        Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
1091                    ]);
1092                    message_lines.push(output_line);
1093                }
1094                if output_lines.len() > 5 {
1095                    message_lines.push(Line::from(vec![
1096                        Span::styled("     ", Style::default()),
1097                        Span::styled(format!("... and {} more lines", output_lines.len() - 5), Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM)),
1098                    ]));
1099                }
1100            }
1101            MessageType::Text(text) => {
1102                // Regular text message - use the stored text content
1103                let formatter = MessageFormatter::new(max_width);
1104                let formatted_content = formatter.format_content(text, &message.role);
1105                message_lines.extend(formatted_content);
1106            }
1107        }
1108
1109        // Add spacing between messages
1110        message_lines.push(Line::from(""));
1111    }
1112
1113    // Show processing indicator if active
1114    if app.is_processing {
1115        let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1116        let spinner_idx = (std::time::SystemTime::now()
1117            .duration_since(std::time::UNIX_EPOCH)
1118            .unwrap_or_default()
1119            .as_millis() / 100) as usize % spinner.len();
1120        
1121        let processing_line = Line::from(vec![
1122            Span::styled(
1123                format!("[{}] ", chrono::Local::now().format("%H:%M")),
1124                Style::default()
1125                    .fg(theme.timestamp_color.to_color())
1126                    .add_modifier(Modifier::DIM),
1127            ),
1128            Span::styled("assistant", theme.get_role_style("assistant")),
1129        ]);
1130        message_lines.push(processing_line);
1131        
1132        // Show different colors based on state
1133        let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
1134            (format!("  {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
1135        } else {
1136            (format!("  {} {}", spinner[spinner_idx], app.processing_message.as_deref().unwrap_or("Thinking...")), Color::Yellow)
1137        };
1138        
1139        let indicator_line = Line::from(vec![
1140            Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
1141        ]);
1142        message_lines.push(indicator_line);
1143        message_lines.push(Line::from(""));
1144    }
1145
1146    // Calculate scroll position
1147    let total_lines = message_lines.len();
1148    let visible_lines = messages_area.height.saturating_sub(2) as usize;
1149    let max_scroll = total_lines.saturating_sub(visible_lines);
1150    // SCROLL_BOTTOM means "stick to bottom", otherwise clamp to max_scroll
1151    let scroll = if app.scroll >= SCROLL_BOTTOM {
1152        max_scroll
1153    } else {
1154        app.scroll.min(max_scroll)
1155    };
1156
1157    // Render messages with scrolling
1158    let messages_paragraph = Paragraph::new(
1159        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
1160    )
1161    .block(messages_block.clone())
1162    .wrap(Wrap { trim: false });
1163
1164    f.render_widget(messages_paragraph, messages_area);
1165
1166    // Render scrollbar if needed
1167    if total_lines > visible_lines {
1168        let scrollbar = Scrollbar::default()
1169            .orientation(ScrollbarOrientation::VerticalRight)
1170            .symbols(ratatui::symbols::scrollbar::VERTICAL)
1171            .begin_symbol(Some("↑"))
1172            .end_symbol(Some("↓"));
1173
1174        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
1175
1176        let scrollbar_area = Rect::new(
1177            messages_area.right() - 1,
1178            messages_area.top() + 1,
1179            1,
1180            messages_area.height - 2,
1181        );
1182
1183        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1184    }
1185
1186    // Input area
1187    let input_block = Block::default()
1188        .borders(Borders::ALL)
1189        .title(if app.is_processing {
1190            " Message (Processing...) "
1191        } else {
1192            " Message (Enter to send) "
1193        })
1194        .border_style(Style::default().fg(if app.is_processing {
1195            Color::Yellow
1196        } else {
1197            theme.input_border_color.to_color()
1198        }));
1199
1200    let input = Paragraph::new(app.input.as_str())
1201        .block(input_block)
1202        .wrap(Wrap { trim: false });
1203    f.render_widget(input, chunks[1]);
1204
1205    // Cursor
1206    f.set_cursor_position((
1207        chunks[1].x + app.cursor_position as u16 + 1,
1208        chunks[1].y + 1,
1209    ));
1210
1211    // Enhanced status bar with token display
1212    let token_display = TokenDisplay::new();
1213    let status = Paragraph::new(token_display.create_status_bar(theme));
1214    f.render_widget(status, chunks[2]);
1215
1216    // Help overlay
1217    if app.show_help {
1218        let area = centered_rect(60, 60, f.area());
1219        f.render_widget(Clear, area);
1220
1221        // Enhanced token usage details
1222        let token_display = TokenDisplay::new();
1223        let token_info = token_display.create_detailed_display();
1224
1225        let help_text: Vec<String> = vec![
1226            "".to_string(),
1227            "  KEYBOARD SHORTCUTS".to_string(),
1228            "  ==================".to_string(),
1229            "".to_string(),
1230            "  Enter        Send message".to_string(),
1231            "  Tab          Switch between build/plan agents".to_string(),
1232            "  Ctrl+S       Toggle swarm view".to_string(),
1233            "  Ctrl+C       Quit".to_string(),
1234            "  ?            Toggle this help".to_string(),
1235            "".to_string(),
1236            "  SLASH COMMANDS".to_string(),
1237            "  /swarm <task>   Run task in parallel swarm mode".to_string(),
1238            "  /sessions       Open session picker to resume".to_string(),
1239            "  /resume         Resume most recent session".to_string(),
1240            "  /resume <id>    Resume specific session by ID".to_string(),
1241            "  /new            Start a fresh session".to_string(),
1242            "  /view           Toggle swarm view".to_string(),
1243            "".to_string(),
1244            "  VIM-STYLE NAVIGATION".to_string(),
1245            "  Alt+j        Scroll down".to_string(),
1246            "  Alt+k        Scroll up".to_string(),
1247            "  Ctrl+g       Go to top".to_string(),
1248            "  Ctrl+G       Go to bottom".to_string(),
1249            "".to_string(),
1250            "  SCROLLING".to_string(),
1251            "  Up/Down      Scroll messages".to_string(),
1252            "  PageUp/Dn    Scroll one page".to_string(),
1253            "  Alt+u/d      Scroll half page".to_string(),
1254            "".to_string(),
1255            "  COMMAND HISTORY".to_string(),
1256            "  Ctrl+R       Search history".to_string(),
1257            "  Ctrl+Up/Dn   Navigate history".to_string(),
1258            "".to_string(),
1259            "  Press ? or Esc to close".to_string(),
1260            "".to_string(),
1261        ];
1262
1263        let mut combined_text = token_info;
1264        combined_text.extend(help_text);
1265
1266        let help = Paragraph::new(combined_text.join("\n"))
1267            .block(
1268                Block::default()
1269                    .borders(Borders::ALL)
1270                    .title(" Help ")
1271                    .border_style(Style::default().fg(theme.help_border_color.to_color())),
1272            )
1273            .wrap(Wrap { trim: false });
1274
1275        f.render_widget(help, area);
1276    }
1277}
1278
1279/// Helper to create a centered rect
1280fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1281    let popup_layout = Layout::default()
1282        .direction(Direction::Vertical)
1283        .constraints([
1284            Constraint::Percentage((100 - percent_y) / 2),
1285            Constraint::Percentage(percent_y),
1286            Constraint::Percentage((100 - percent_y) / 2),
1287        ])
1288        .split(r);
1289
1290    Layout::default()
1291        .direction(Direction::Horizontal)
1292        .constraints([
1293            Constraint::Percentage((100 - percent_x) / 2),
1294            Constraint::Percentage(percent_x),
1295            Constraint::Percentage((100 - percent_x) / 2),
1296        ])
1297        .split(popup_layout[1])[1]
1298}