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 ralph_view;
7pub mod swarm_view;
8pub mod theme;
9pub mod theme_utils;
10pub mod token_display;
11
12/// Sentinel value meaning "scroll to bottom"
13const SCROLL_BOTTOM: usize = 1_000_000;
14
15use crate::config::Config;
16use crate::provider::{ContentPart, Role};
17use crate::ralph::{RalphConfig, RalphLoop};
18use crate::session::{Session, SessionEvent, SessionSummary, list_sessions};
19use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
20use crate::tui::message_formatter::MessageFormatter;
21use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
22use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
23use crate::tui::theme::Theme;
24use crate::tui::token_display::TokenDisplay;
25use anyhow::Result;
26use crossterm::{
27    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
28    execute,
29    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
30};
31use ratatui::{
32    Frame, Terminal,
33    backend::CrosstermBackend,
34    layout::{Constraint, Direction, Layout, Rect},
35    style::{Color, Modifier, Style},
36    text::{Line, Span},
37    widgets::{
38        Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
39    },
40};
41use std::io;
42use std::path::{Path, PathBuf};
43use std::process::Command;
44use std::time::{Duration, Instant};
45use tokio::sync::mpsc;
46
47/// Run the TUI
48pub async fn run(project: Option<PathBuf>) -> Result<()> {
49    // Change to project directory if specified
50    if let Some(dir) = project {
51        std::env::set_current_dir(&dir)?;
52    }
53
54    // Setup terminal
55    enable_raw_mode()?;
56    let mut stdout = io::stdout();
57    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
58    let backend = CrosstermBackend::new(stdout);
59    let mut terminal = Terminal::new(backend)?;
60
61    // Run the app
62    let result = run_app(&mut terminal).await;
63
64    // Restore terminal
65    disable_raw_mode()?;
66    execute!(
67        terminal.backend_mut(),
68        LeaveAlternateScreen,
69        DisableMouseCapture
70    )?;
71    terminal.show_cursor()?;
72
73    result
74}
75
76/// Message type for chat display
77#[derive(Debug, Clone)]
78enum MessageType {
79    Text(String),
80    ToolCall { name: String, arguments: String },
81    ToolResult { name: String, output: String },
82}
83
84/// View mode for the TUI
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86enum ViewMode {
87    Chat,
88    Swarm,
89    Ralph,
90    SessionPicker,
91    ModelPicker,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum ChatLayoutMode {
96    Classic,
97    Webview,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum WorkspaceEntryKind {
102    Directory,
103    File,
104}
105
106#[derive(Debug, Clone)]
107struct WorkspaceEntry {
108    name: String,
109    kind: WorkspaceEntryKind,
110}
111
112#[derive(Debug, Clone, Default)]
113struct WorkspaceSnapshot {
114    root_display: String,
115    git_branch: Option<String>,
116    git_dirty_files: usize,
117    entries: Vec<WorkspaceEntry>,
118    captured_at: String,
119}
120
121/// Application state
122struct App {
123    input: String,
124    cursor_position: usize,
125    messages: Vec<ChatMessage>,
126    current_agent: String,
127    scroll: usize,
128    show_help: bool,
129    command_history: Vec<String>,
130    history_index: Option<usize>,
131    session: Option<Session>,
132    is_processing: bool,
133    processing_message: Option<String>,
134    current_tool: Option<String>,
135    response_rx: Option<mpsc::Receiver<SessionEvent>>,
136    // Swarm mode state
137    view_mode: ViewMode,
138    chat_layout: ChatLayoutMode,
139    show_inspector: bool,
140    workspace: WorkspaceSnapshot,
141    swarm_state: SwarmViewState,
142    swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
143    // Ralph mode state
144    ralph_state: RalphViewState,
145    ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
146    // Session picker state
147    session_picker_list: Vec<SessionSummary>,
148    session_picker_selected: usize,
149    // Model picker state
150    model_picker_list: Vec<(String, String, String)>, // (display label, provider/model value, human name)
151    model_picker_selected: usize,
152    model_picker_filter: String,
153    active_model: Option<String>,
154    // Cached max scroll for key handlers
155    last_max_scroll: usize,
156}
157
158#[allow(dead_code)]
159struct ChatMessage {
160    role: String,
161    content: String,
162    timestamp: String,
163    message_type: MessageType,
164}
165
166impl ChatMessage {
167    fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
168        let content = content.into();
169        Self {
170            role: role.into(),
171            timestamp: chrono::Local::now().format("%H:%M").to_string(),
172            message_type: MessageType::Text(content.clone()),
173            content,
174        }
175    }
176
177    fn with_message_type(mut self, message_type: MessageType) -> Self {
178        self.message_type = message_type;
179        self
180    }
181}
182
183impl WorkspaceSnapshot {
184    fn capture(root: &Path, max_entries: usize) -> Self {
185        let mut entries: Vec<WorkspaceEntry> = Vec::new();
186
187        if let Ok(read_dir) = std::fs::read_dir(root) {
188            for entry in read_dir.flatten() {
189                let file_name = entry.file_name().to_string_lossy().to_string();
190                if should_skip_workspace_entry(&file_name) {
191                    continue;
192                }
193
194                let kind = match entry.file_type() {
195                    Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
196                    _ => WorkspaceEntryKind::File,
197                };
198
199                entries.push(WorkspaceEntry {
200                    name: file_name,
201                    kind,
202                });
203            }
204        }
205
206        entries.sort_by(|a, b| match (a.kind, b.kind) {
207            (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
208            (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
209                std::cmp::Ordering::Greater
210            }
211            _ => a
212                .name
213                .to_ascii_lowercase()
214                .cmp(&b.name.to_ascii_lowercase()),
215        });
216        entries.truncate(max_entries);
217
218        Self {
219            root_display: root.to_string_lossy().to_string(),
220            git_branch: detect_git_branch(root),
221            git_dirty_files: detect_git_dirty_files(root),
222            entries,
223            captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
224        }
225    }
226}
227
228fn should_skip_workspace_entry(name: &str) -> bool {
229    matches!(
230        name,
231        ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
232    )
233}
234
235fn detect_git_branch(root: &Path) -> Option<String> {
236    let output = Command::new("git")
237        .arg("-C")
238        .arg(root)
239        .args(["rev-parse", "--abbrev-ref", "HEAD"])
240        .output()
241        .ok()?;
242
243    if !output.status.success() {
244        return None;
245    }
246
247    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
248    if branch.is_empty() {
249        None
250    } else {
251        Some(branch)
252    }
253}
254
255fn detect_git_dirty_files(root: &Path) -> usize {
256    let output = match Command::new("git")
257        .arg("-C")
258        .arg(root)
259        .args(["status", "--porcelain"])
260        .output()
261    {
262        Ok(out) => out,
263        Err(_) => return 0,
264    };
265
266    if !output.status.success() {
267        return 0;
268    }
269
270    String::from_utf8_lossy(&output.stdout)
271        .lines()
272        .filter(|line| !line.trim().is_empty())
273        .count()
274}
275
276impl App {
277    fn new() -> Self {
278        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
279
280        Self {
281            input: String::new(),
282            cursor_position: 0,
283            messages: vec![
284                ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
285                ChatMessage::new(
286                    "assistant",
287                    "Quick start:\n• Type a message to chat with the AI\n• /model - pick a model (or Ctrl+M)\n• /swarm <task> - parallel execution\n• /ralph [prd.json] - autonomous PRD loop\n• /sessions - pick a session to resume\n• /resume - continue last session\n• Tab - switch agents | ? - help",
288                ),
289            ],
290            current_agent: "build".to_string(),
291            scroll: 0,
292            show_help: false,
293            command_history: Vec::new(),
294            history_index: None,
295            session: None,
296            is_processing: false,
297            processing_message: None,
298            current_tool: None,
299            response_rx: None,
300            view_mode: ViewMode::Chat,
301            chat_layout: ChatLayoutMode::Webview,
302            show_inspector: true,
303            workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
304            swarm_state: SwarmViewState::new(),
305            swarm_rx: None,
306            ralph_state: RalphViewState::new(),
307            ralph_rx: None,
308            session_picker_list: Vec::new(),
309            session_picker_selected: 0,
310            model_picker_list: Vec::new(),
311            model_picker_selected: 0,
312            model_picker_filter: String::new(),
313            active_model: None,
314            last_max_scroll: 0,
315        }
316    }
317
318    fn refresh_workspace(&mut self) {
319        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
320        self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
321    }
322
323    fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
324        self.session_picker_list = sessions.into_iter().take(16).collect();
325        if self.session_picker_selected >= self.session_picker_list.len() {
326            self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
327        }
328    }
329
330    async fn submit_message(&mut self, config: &Config) {
331        if self.input.is_empty() {
332            return;
333        }
334
335        let message = std::mem::take(&mut self.input);
336        self.cursor_position = 0;
337
338        // Save to command history
339        if !message.trim().is_empty() {
340            self.command_history.push(message.clone());
341            self.history_index = None;
342        }
343
344        // Check for /swarm command
345        if message.trim().starts_with("/swarm ") {
346            let task = message
347                .trim()
348                .strip_prefix("/swarm ")
349                .unwrap_or("")
350                .to_string();
351            if task.is_empty() {
352                self.messages.push(ChatMessage::new(
353                    "system",
354                    "Usage: /swarm <task description>",
355                ));
356                return;
357            }
358            self.start_swarm_execution(task, config).await;
359            return;
360        }
361
362        // Check for /ralph command
363        if message.trim().starts_with("/ralph") {
364            let prd_path = message
365                .trim()
366                .strip_prefix("/ralph")
367                .map(|s| s.trim())
368                .filter(|s| !s.is_empty())
369                .unwrap_or("prd.json")
370                .to_string();
371            self.start_ralph_execution(prd_path, config).await;
372            return;
373        }
374
375        if message.trim() == "/webview" {
376            self.chat_layout = ChatLayoutMode::Webview;
377            self.messages.push(ChatMessage::new(
378                "system",
379                "Switched to webview layout. Use /classic to return to single-pane chat.",
380            ));
381            return;
382        }
383
384        if message.trim() == "/classic" {
385            self.chat_layout = ChatLayoutMode::Classic;
386            self.messages.push(ChatMessage::new(
387                "system",
388                "Switched to classic layout. Use /webview for dashboard-style panes.",
389            ));
390            return;
391        }
392
393        if message.trim() == "/inspector" {
394            self.show_inspector = !self.show_inspector;
395            let state = if self.show_inspector { "enabled" } else { "disabled" };
396            self.messages.push(ChatMessage::new(
397                "system",
398                format!("Inspector pane {}. Press F3 to toggle quickly.", state),
399            ));
400            return;
401        }
402
403        if message.trim() == "/refresh" {
404            self.refresh_workspace();
405            match list_sessions().await {
406                Ok(sessions) => self.update_cached_sessions(sessions),
407                Err(err) => self.messages.push(ChatMessage::new(
408                    "system",
409                    format!("Workspace refreshed, but failed to refresh sessions: {}", err),
410                )),
411            }
412            self.messages.push(ChatMessage::new(
413                "system",
414                "Workspace and session cache refreshed.",
415            ));
416            return;
417        }
418
419        // Check for /view command to toggle views
420        if message.trim() == "/view" || message.trim() == "/swarm" {
421            self.view_mode = match self.view_mode {
422                ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
423                ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
424            };
425            return;
426        }
427
428        // Check for /sessions command - open session picker
429        if message.trim() == "/sessions" {
430            match list_sessions().await {
431                Ok(sessions) => {
432                    if sessions.is_empty() {
433                        self.messages
434                            .push(ChatMessage::new("system", "No saved sessions found."));
435                    } else {
436                        self.update_cached_sessions(sessions);
437                        self.session_picker_selected = 0;
438                        self.view_mode = ViewMode::SessionPicker;
439                    }
440                }
441                Err(e) => {
442                    self.messages.push(ChatMessage::new(
443                        "system",
444                        format!("Failed to list sessions: {}", e),
445                    ));
446                }
447            }
448            return;
449        }
450
451        // Check for /resume command to load a session
452        if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
453            let session_id = message
454                .trim()
455                .strip_prefix("/resume")
456                .map(|s| s.trim())
457                .filter(|s| !s.is_empty());
458            let loaded = if let Some(id) = session_id {
459                Session::load(id).await
460            } else {
461                Session::last().await
462            };
463
464            match loaded {
465                Ok(session) => {
466                    // Convert session messages to chat messages
467                    self.messages.clear();
468                    self.messages.push(ChatMessage::new(
469                        "system",
470                        format!(
471                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
472                            session.title.as_deref().unwrap_or("(untitled)"),
473                            session.created_at.format("%Y-%m-%d %H:%M"),
474                            session.messages.len()
475                        ),
476                    ));
477
478                    for msg in &session.messages {
479                        let role_str = match msg.role {
480                            Role::System => "system",
481                            Role::User => "user",
482                            Role::Assistant => "assistant",
483                            Role::Tool => "tool",
484                        };
485
486                        // Extract text content
487                        let content: String = msg
488                            .content
489                            .iter()
490                            .filter_map(|part| match part {
491                                ContentPart::Text { text } => Some(text.clone()),
492                                ContentPart::ToolCall {
493                                    name, arguments, ..
494                                } => Some(format!("[Tool: {}]\n{}", name, arguments)),
495                                ContentPart::ToolResult { content, .. } => {
496                                    let truncated = if content.len() > 500 {
497                                        format!("{}...", &content[..497])
498                                    } else {
499                                        content.clone()
500                                    };
501                                    Some(format!("[Result]\n{}", truncated))
502                                }
503                                _ => None,
504                            })
505                            .collect::<Vec<_>>()
506                            .join("\n");
507
508                        if !content.is_empty() {
509                            self.messages.push(ChatMessage::new(role_str, content));
510                        }
511                    }
512
513                    self.current_agent = session.agent.clone();
514                    self.session = Some(session);
515                    self.scroll = SCROLL_BOTTOM;
516                }
517                Err(e) => {
518                    self.messages.push(ChatMessage::new(
519                        "system",
520                        format!("Failed to load session: {}", e),
521                    ));
522                }
523            }
524            return;
525        }
526
527        // Check for /model command - open model picker
528        if message.trim() == "/model" || message.trim().starts_with("/model ") {
529            let direct_model = message
530                .trim()
531                .strip_prefix("/model")
532                .map(|s| s.trim())
533                .filter(|s| !s.is_empty());
534
535            if let Some(model_str) = direct_model {
536                // Direct set: /model provider/model-name
537                self.active_model = Some(model_str.to_string());
538                if let Some(session) = self.session.as_mut() {
539                    session.metadata.model = Some(model_str.to_string());
540                }
541                self.messages.push(ChatMessage::new(
542                    "system",
543                    format!("Model set to: {}", model_str),
544                ));
545            } else {
546                // Open model picker
547                self.open_model_picker(config).await;
548            }
549            return;
550        }
551
552        // Check for /new command to start a fresh session
553        if message.trim() == "/new" {
554            self.session = None;
555            self.messages.clear();
556            self.messages.push(ChatMessage::new(
557                "system",
558                "Started a new session. Previous session was saved.",
559            ));
560            return;
561        }
562
563        // Add user message
564        self.messages
565            .push(ChatMessage::new("user", message.clone()));
566
567        // Auto-scroll to bottom when user sends a message
568        self.scroll = SCROLL_BOTTOM;
569
570        let current_agent = self.current_agent.clone();
571        let model = self
572            .active_model
573            .clone()
574            .or_else(|| {
575                config
576                    .agents
577                    .get(&current_agent)
578                    .and_then(|agent| agent.model.clone())
579            })
580            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok())
581            .or_else(|| config.default_model.clone());
582
583        // Initialize session if needed
584        if self.session.is_none() {
585            match Session::new().await {
586                Ok(session) => {
587                    self.session = Some(session);
588                }
589                Err(err) => {
590                    tracing::error!(error = %err, "Failed to create session");
591                    self.messages
592                        .push(ChatMessage::new("assistant", format!("Error: {err}")));
593                    return;
594                }
595            }
596        }
597
598        let session = match self.session.as_mut() {
599            Some(session) => session,
600            None => {
601                self.messages.push(ChatMessage::new(
602                    "assistant",
603                    "Error: session not initialized",
604                ));
605                return;
606            }
607        };
608
609        if let Some(model) = model {
610            session.metadata.model = Some(model);
611        }
612
613        session.agent = current_agent;
614
615        // Set processing state
616        self.is_processing = true;
617        self.processing_message = Some("Thinking...".to_string());
618        self.current_tool = None;
619
620        // Create channel for async communication
621        let (tx, rx) = mpsc::channel(100);
622        self.response_rx = Some(rx);
623
624        // Clone session for async processing
625        let session_clone = session.clone();
626        let message_clone = message.clone();
627
628        // Spawn async task to process the message with event streaming
629        tokio::spawn(async move {
630            let mut session = session_clone;
631            if let Err(err) = session.prompt_with_events(&message_clone, tx.clone()).await {
632                tracing::error!(error = %err, "Agent processing failed");
633                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
634                let _ = tx.send(SessionEvent::Done).await;
635            }
636        });
637    }
638
639    fn handle_response(&mut self, event: SessionEvent) {
640        // Auto-scroll to bottom when new content arrives
641        self.scroll = SCROLL_BOTTOM;
642
643        match event {
644            SessionEvent::Thinking => {
645                self.processing_message = Some("Thinking...".to_string());
646                self.current_tool = None;
647            }
648            SessionEvent::ToolCallStart { name, arguments } => {
649                self.processing_message = Some(format!("Running {}...", name));
650                self.current_tool = Some(name.clone());
651                self.messages.push(
652                    ChatMessage::new("tool", format!("🔧 {}", name))
653                        .with_message_type(MessageType::ToolCall { name, arguments }),
654                );
655            }
656            SessionEvent::ToolCallComplete {
657                name,
658                output,
659                success,
660            } => {
661                let icon = if success { "✓" } else { "✗" };
662                self.messages.push(
663                    ChatMessage::new("tool", format!("{} {}", icon, name))
664                        .with_message_type(MessageType::ToolResult { name, output }),
665                );
666                self.current_tool = None;
667                self.processing_message = Some("Thinking...".to_string());
668            }
669            SessionEvent::TextChunk(_text) => {
670                // Could be used for streaming text display in the future
671            }
672            SessionEvent::TextComplete(text) => {
673                if !text.is_empty() {
674                    self.messages.push(ChatMessage::new("assistant", text));
675                }
676            }
677            SessionEvent::Error(err) => {
678                self.messages
679                    .push(ChatMessage::new("assistant", format!("Error: {}", err)));
680            }
681            SessionEvent::Done => {
682                self.is_processing = false;
683                self.processing_message = None;
684                self.current_tool = None;
685                self.response_rx = None;
686            }
687        }
688    }
689
690    /// Handle a swarm event
691    fn handle_swarm_event(&mut self, event: SwarmEvent) {
692        self.swarm_state.handle_event(event.clone());
693
694        // When swarm completes, switch back to chat view with summary
695        if let SwarmEvent::Complete { success, ref stats } = event {
696            self.view_mode = ViewMode::Chat;
697            let summary = if success {
698                format!(
699                    "Swarm completed successfully.\n\
700                     Subtasks: {} completed, {} failed\n\
701                     Total tool calls: {}\n\
702                     Time: {:.1}s (speedup: {:.1}x)",
703                    stats.subagents_completed,
704                    stats.subagents_failed,
705                    stats.total_tool_calls,
706                    stats.execution_time_ms as f64 / 1000.0,
707                    stats.speedup_factor
708                )
709            } else {
710                format!(
711                    "Swarm completed with failures.\n\
712                     Subtasks: {} completed, {} failed\n\
713                     Check the subtask results for details.",
714                    stats.subagents_completed, stats.subagents_failed
715                )
716            };
717            self.messages.push(ChatMessage::new("system", &summary));
718            self.swarm_rx = None;
719        }
720
721        if let SwarmEvent::Error(ref err) = event {
722            self.messages
723                .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
724        }
725    }
726
727    /// Handle a Ralph event
728    fn handle_ralph_event(&mut self, event: RalphEvent) {
729        self.ralph_state.handle_event(event.clone());
730
731        // When Ralph completes, switch back to chat view with summary
732        if let RalphEvent::Complete {
733            ref status,
734            passed,
735            total,
736        } = event
737        {
738            self.view_mode = ViewMode::Chat;
739            let summary = format!(
740                "Ralph loop finished: {}\n\
741                 Stories: {}/{} passed",
742                status, passed, total
743            );
744            self.messages.push(ChatMessage::new("system", &summary));
745            self.ralph_rx = None;
746        }
747
748        if let RalphEvent::Error(ref err) = event {
749            self.messages
750                .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
751        }
752    }
753
754    /// Start Ralph execution for a PRD
755    async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
756        // Add user message
757        self.messages
758            .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
759
760        // Get model from config
761        let model = self
762            .active_model
763            .clone()
764            .or_else(|| config.default_model.clone())
765            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
766
767        let model = match model {
768            Some(m) => m,
769            None => {
770                self.messages.push(ChatMessage::new(
771                    "system",
772                    "No model configured. Use /model to select one first.",
773                ));
774                return;
775            }
776        };
777
778        // Check PRD exists
779        let prd_file = std::path::PathBuf::from(&prd_path);
780        if !prd_file.exists() {
781            self.messages.push(ChatMessage::new(
782                "system",
783                format!("PRD file not found: {}", prd_path),
784            ));
785            return;
786        }
787
788        // Create channel for ralph events
789        let (tx, rx) = mpsc::channel(200);
790        self.ralph_rx = Some(rx);
791
792        // Switch to Ralph view
793        self.view_mode = ViewMode::Ralph;
794        self.ralph_state = RalphViewState::new();
795
796        // Build Ralph config
797        let ralph_config = RalphConfig {
798            prd_path: prd_path.clone(),
799            max_iterations: 10,
800            progress_path: "progress.txt".to_string(),
801            quality_checks_enabled: true,
802            auto_commit: true,
803            model: Some(model.clone()),
804            use_rlm: false,
805            parallel_enabled: true,
806            max_concurrent_stories: 3,
807            worktree_enabled: true,
808            story_timeout_secs: 300,
809            conflict_timeout_secs: 120,
810        };
811
812        // Parse provider/model from the model string
813        let (provider_name, model_name) = if let Some(pos) = model.find('/') {
814            (model[..pos].to_string(), model[pos + 1..].to_string())
815        } else {
816            (model.clone(), model.clone())
817        };
818
819        let prd_path_clone = prd_path.clone();
820        let tx_clone = tx.clone();
821
822        // Spawn Ralph execution
823        tokio::spawn(async move {
824            // Get provider from registry
825            let provider = match crate::provider::ProviderRegistry::from_vault().await {
826                Ok(registry) => match registry.get(&provider_name) {
827                    Some(p) => p,
828                    None => {
829                        let _ = tx_clone.send(RalphEvent::Error(
830                            format!("Provider '{}' not found", provider_name),
831                        )).await;
832                        return;
833                    }
834                },
835                Err(e) => {
836                    let _ = tx_clone.send(RalphEvent::Error(
837                        format!("Failed to load providers: {}", e),
838                    )).await;
839                    return;
840                }
841            };
842
843            let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
844            match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
845                Ok(ralph) => {
846                    let mut ralph = ralph.with_event_tx(tx_clone.clone());
847                    match ralph.run().await {
848                        Ok(_state) => {
849                            // Complete event already emitted by run()
850                        }
851                        Err(e) => {
852                            let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
853                        }
854                    }
855                }
856                Err(e) => {
857                    let _ = tx_clone.send(RalphEvent::Error(
858                        format!("Failed to initialize Ralph: {}", e),
859                    )).await;
860                }
861            }
862        });
863
864        self.messages.push(ChatMessage::new(
865            "system",
866            format!("Starting Ralph loop with PRD: {}", prd_path),
867        ));
868    }
869
870    /// Start swarm execution for a task
871    async fn start_swarm_execution(&mut self, task: String, config: &Config) {
872        // Add user message
873        self.messages
874            .push(ChatMessage::new("user", format!("/swarm {}", task)));
875
876        // Get model from config
877        let model = config
878            .default_model
879            .clone()
880            .or_else(|| std::env::var("CODETETHER_DEFAULT_MODEL").ok());
881
882        // Configure swarm
883        let swarm_config = SwarmConfig {
884            model,
885            max_subagents: 10,
886            max_steps_per_subagent: 50,
887            worktree_enabled: true,
888            worktree_auto_merge: true,
889            working_dir: Some(
890                std::env::current_dir()
891                    .map(|p| p.to_string_lossy().to_string())
892                    .unwrap_or_else(|_| ".".to_string()),
893            ),
894            ..Default::default()
895        };
896
897        // Create channel for swarm events
898        let (tx, rx) = mpsc::channel(100);
899        self.swarm_rx = Some(rx);
900
901        // Switch to swarm view
902        self.view_mode = ViewMode::Swarm;
903        self.swarm_state = SwarmViewState::new();
904
905        // Send initial event
906        let _ = tx
907            .send(SwarmEvent::Started {
908                task: task.clone(),
909                total_subtasks: 0,
910            })
911            .await;
912
913        // Spawn swarm execution — executor emits all events via event_tx
914        let task_clone = task;
915        tokio::spawn(async move {
916            // Create executor with event channel — it handles decomposition + execution
917            let executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
918            let result = executor
919                .execute(&task_clone, DecompositionStrategy::Automatic)
920                .await;
921
922            match result {
923                Ok(swarm_result) => {
924                    let _ = tx
925                        .send(SwarmEvent::Complete {
926                            success: swarm_result.success,
927                            stats: swarm_result.stats,
928                        })
929                        .await;
930                }
931                Err(e) => {
932                    let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
933                }
934            }
935        });
936    }
937
938    /// Populate and open the model picker overlay
939    async fn open_model_picker(&mut self, config: &Config) {
940        let mut models: Vec<(String, String, String)> = Vec::new();
941
942        // Try to build provider registry and list models
943        match crate::provider::ProviderRegistry::from_vault().await {
944            Ok(registry) => {
945                for provider_name in registry.list() {
946                    if let Some(provider) = registry.get(provider_name) {
947                        match provider.list_models().await {
948                            Ok(model_list) => {
949                                for m in model_list {
950                                    let label = format!("{}/{}", provider_name, m.id);
951                                    let value = format!("{}/{}", provider_name, m.id);
952                                    let name = m.name.clone();
953                                    models.push((label, value, name));
954                                }
955                            }
956                            Err(e) => {
957                                tracing::warn!("Failed to list models for {}: {}", provider_name, e);
958                            }
959                        }
960                    }
961                }
962            }
963            Err(e) => {
964                tracing::warn!("Failed to load provider registry: {}", e);
965            }
966        }
967
968        // Fallback: also try from config
969        if models.is_empty() {
970            if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
971                for provider_name in registry.list() {
972                    if let Some(provider) = registry.get(provider_name) {
973                        if let Ok(model_list) = provider.list_models().await {
974                            for m in model_list {
975                                let label = format!("{}/{}", provider_name, m.id);
976                                let value = format!("{}/{}", provider_name, m.id);
977                                let name = m.name.clone();
978                                models.push((label, value, name));
979                            }
980                        }
981                    }
982                }
983            }
984        }
985
986        if models.is_empty() {
987            self.messages.push(ChatMessage::new(
988                "system",
989                "No models found. Check provider configuration (Vault or config).",
990            ));
991        } else {
992            // Sort models by provider then name
993            models.sort_by(|a, b| a.0.cmp(&b.0));
994            self.model_picker_list = models;
995            self.model_picker_selected = 0;
996            self.model_picker_filter.clear();
997            self.view_mode = ViewMode::ModelPicker;
998        }
999    }
1000
1001    /// Get filtered model list
1002    fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
1003        if self.model_picker_filter.is_empty() {
1004            self.model_picker_list.iter().enumerate().collect()
1005        } else {
1006            let filter = self.model_picker_filter.to_lowercase();
1007            self.model_picker_list
1008                .iter()
1009                .enumerate()
1010                .filter(|(_, (label, _, name))| {
1011                    label.to_lowercase().contains(&filter)
1012                        || name.to_lowercase().contains(&filter)
1013                })
1014                .collect()
1015        }
1016    }
1017
1018    fn navigate_history(&mut self, direction: isize) {
1019        if self.command_history.is_empty() {
1020            return;
1021        }
1022
1023        let history_len = self.command_history.len();
1024        let new_index = match self.history_index {
1025            Some(current) => {
1026                let new = current as isize + direction;
1027                if new < 0 {
1028                    None
1029                } else if new >= history_len as isize {
1030                    Some(history_len - 1)
1031                } else {
1032                    Some(new as usize)
1033                }
1034            }
1035            None => {
1036                if direction > 0 {
1037                    Some(0)
1038                } else {
1039                    Some(history_len.saturating_sub(1))
1040                }
1041            }
1042        };
1043
1044        self.history_index = new_index;
1045        if let Some(index) = new_index {
1046            self.input = self.command_history[index].clone();
1047            self.cursor_position = self.input.len();
1048        } else {
1049            self.input.clear();
1050            self.cursor_position = 0;
1051        }
1052    }
1053
1054    fn search_history(&mut self) {
1055        // Enhanced search: find commands matching current input prefix
1056        if self.command_history.is_empty() {
1057            return;
1058        }
1059
1060        let search_term = self.input.trim().to_lowercase();
1061
1062        if search_term.is_empty() {
1063            // Empty search - show most recent
1064            if !self.command_history.is_empty() {
1065                self.input = self.command_history.last().unwrap().clone();
1066                self.cursor_position = self.input.len();
1067                self.history_index = Some(self.command_history.len() - 1);
1068            }
1069            return;
1070        }
1071
1072        // Find the most recent command that starts with the search term
1073        for (index, cmd) in self.command_history.iter().enumerate().rev() {
1074            if cmd.to_lowercase().starts_with(&search_term) {
1075                self.input = cmd.clone();
1076                self.cursor_position = self.input.len();
1077                self.history_index = Some(index);
1078                return;
1079            }
1080        }
1081
1082        // If no prefix match, search for contains
1083        for (index, cmd) in self.command_history.iter().enumerate().rev() {
1084            if cmd.to_lowercase().contains(&search_term) {
1085                self.input = cmd.clone();
1086                self.cursor_position = self.input.len();
1087                self.history_index = Some(index);
1088                return;
1089            }
1090        }
1091    }
1092}
1093
1094async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
1095    let mut app = App::new();
1096    if let Ok(sessions) = list_sessions().await {
1097        app.update_cached_sessions(sessions);
1098    }
1099
1100    // Load configuration and theme
1101    let mut config = Config::load().await?;
1102    let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
1103
1104    // Track last config modification time for hot-reloading
1105    let _config_paths = vec![
1106        std::path::PathBuf::from("./codetether.toml"),
1107        std::path::PathBuf::from("./.codetether/config.toml"),
1108    ];
1109
1110    let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
1111        .map(|dirs| dirs.config_dir().join("config.toml"));
1112
1113    let mut last_check = Instant::now();
1114    let mut last_session_refresh = Instant::now();
1115
1116    loop {
1117        // Check for theme changes if hot-reload is enabled
1118        if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
1119            if let Ok(new_config) = Config::load().await {
1120                if new_config.ui.theme != config.ui.theme
1121                    || new_config.ui.custom_theme != config.ui.custom_theme
1122                {
1123                    theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
1124                    config = new_config;
1125                }
1126            }
1127            last_check = Instant::now();
1128        }
1129
1130        if last_session_refresh.elapsed() > Duration::from_secs(5) {
1131            if let Ok(sessions) = list_sessions().await {
1132                app.update_cached_sessions(sessions);
1133            }
1134            last_session_refresh = Instant::now();
1135        }
1136
1137        terminal.draw(|f| ui(f, &mut app, &theme))?;
1138
1139        // Update max_scroll estimate for scroll key handlers
1140        // This needs to roughly match what ui() calculates
1141        let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
1142        let estimated_lines = app.messages.len() * 4; // rough estimate
1143        app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
1144
1145        // Drain all pending async responses
1146        if let Some(mut rx) = app.response_rx.take() {
1147            while let Ok(response) = rx.try_recv() {
1148                app.handle_response(response);
1149            }
1150            app.response_rx = Some(rx);
1151        }
1152
1153        // Drain all pending swarm events
1154        if let Some(mut rx) = app.swarm_rx.take() {
1155            while let Ok(event) = rx.try_recv() {
1156                app.handle_swarm_event(event);
1157            }
1158            app.swarm_rx = Some(rx);
1159        }
1160
1161        // Drain all pending ralph events
1162        if let Some(mut rx) = app.ralph_rx.take() {
1163            while let Ok(event) = rx.try_recv() {
1164                app.handle_ralph_event(event);
1165            }
1166            app.ralph_rx = Some(rx);
1167        }
1168
1169        if event::poll(std::time::Duration::from_millis(100))? {
1170            if let Event::Key(key) = event::read()? {
1171                // Help overlay
1172                if app.show_help {
1173                    if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
1174                        app.show_help = false;
1175                    }
1176                    continue;
1177                }
1178
1179                // Model picker overlay
1180                if app.view_mode == ViewMode::ModelPicker {
1181                    match key.code {
1182                        KeyCode::Esc => {
1183                            app.view_mode = ViewMode::Chat;
1184                        }
1185                        KeyCode::Up | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::ALT) => {
1186                            if app.model_picker_selected > 0 {
1187                                app.model_picker_selected -= 1;
1188                            }
1189                        }
1190                        KeyCode::Down | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::ALT) => {
1191                            let filtered = app.filtered_models();
1192                            if app.model_picker_selected < filtered.len().saturating_sub(1) {
1193                                app.model_picker_selected += 1;
1194                            }
1195                        }
1196                        KeyCode::Enter => {
1197                            let filtered = app.filtered_models();
1198                            if let Some((_, (label, value, _name))) = filtered.get(app.model_picker_selected) {
1199                                let label = label.clone();
1200                                let value = value.clone();
1201                                app.active_model = Some(value.clone());
1202                                if let Some(session) = app.session.as_mut() {
1203                                    session.metadata.model = Some(value.clone());
1204                                }
1205                                app.messages.push(ChatMessage::new(
1206                                    "system",
1207                                    format!("Model set to: {}", label),
1208                                ));
1209                                app.view_mode = ViewMode::Chat;
1210                            }
1211                        }
1212                        KeyCode::Backspace => {
1213                            app.model_picker_filter.pop();
1214                            app.model_picker_selected = 0;
1215                        }
1216                        KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => {
1217                            app.model_picker_filter.push(c);
1218                            app.model_picker_selected = 0;
1219                        }
1220                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1221                            return Ok(());
1222                        }
1223                        KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1224                            return Ok(());
1225                        }
1226                        _ => {}
1227                    }
1228                    continue;
1229                }
1230
1231                // Session picker overlay - handle specially
1232                if app.view_mode == ViewMode::SessionPicker {
1233                    match key.code {
1234                        KeyCode::Esc => {
1235                            app.view_mode = ViewMode::Chat;
1236                        }
1237                        KeyCode::Up | KeyCode::Char('k') => {
1238                            if app.session_picker_selected > 0 {
1239                                app.session_picker_selected -= 1;
1240                            }
1241                        }
1242                        KeyCode::Down | KeyCode::Char('j') => {
1243                            if app.session_picker_selected
1244                                < app.session_picker_list.len().saturating_sub(1)
1245                            {
1246                                app.session_picker_selected += 1;
1247                            }
1248                        }
1249                        KeyCode::Enter => {
1250                            if let Some(session_summary) =
1251                                app.session_picker_list.get(app.session_picker_selected)
1252                            {
1253                                let session_id = session_summary.id.clone();
1254                                match Session::load(&session_id).await {
1255                                    Ok(session) => {
1256                                        app.messages.clear();
1257                                        app.messages.push(ChatMessage::new("system", format!(
1258                                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
1259                                            session.title.as_deref().unwrap_or("(untitled)"),
1260                                            session.created_at.format("%Y-%m-%d %H:%M"),
1261                                            session.messages.len()
1262                                        )));
1263
1264                                        for msg in &session.messages {
1265                                            let role_str = match msg.role {
1266                                                Role::System => "system",
1267                                                Role::User => "user",
1268                                                Role::Assistant => "assistant",
1269                                                Role::Tool => "tool",
1270                                            };
1271
1272                                            let text = msg
1273                                                .content
1274                                                .iter()
1275                                                .filter_map(|part| {
1276                                                    if let ContentPart::Text { text } = part {
1277                                                        Some(text.as_str())
1278                                                    } else {
1279                                                        None
1280                                                    }
1281                                                })
1282                                                .collect::<Vec<_>>()
1283                                                .join("\n");
1284
1285                                            if !text.is_empty() {
1286                                                app.messages.push(ChatMessage::new(role_str, text));
1287                                            }
1288                                        }
1289
1290                                        app.current_agent = session.agent.clone();
1291                                        app.session = Some(session);
1292                                        app.scroll = SCROLL_BOTTOM;
1293                                        app.view_mode = ViewMode::Chat;
1294                                    }
1295                                    Err(e) => {
1296                                        app.messages.push(ChatMessage::new(
1297                                            "system",
1298                                            format!("Failed to load session: {}", e),
1299                                        ));
1300                                        app.view_mode = ViewMode::Chat;
1301                                    }
1302                                }
1303                            }
1304                        }
1305                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1306                            return Ok(());
1307                        }
1308                        KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1309                            return Ok(());
1310                        }
1311                        _ => {}
1312                    }
1313                    continue;
1314                }
1315
1316                // Swarm view key handling
1317                if app.view_mode == ViewMode::Swarm {
1318                    match key.code {
1319                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1320                            return Ok(());
1321                        }
1322                        KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1323                            return Ok(());
1324                        }
1325                        KeyCode::Esc => {
1326                            if app.swarm_state.detail_mode {
1327                                app.swarm_state.exit_detail();
1328                            } else {
1329                                app.view_mode = ViewMode::Chat;
1330                            }
1331                        }
1332                        KeyCode::Up | KeyCode::Char('k') => {
1333                            if app.swarm_state.detail_mode {
1334                                // In detail mode, Up/Down switch between agents
1335                                app.swarm_state.exit_detail();
1336                                app.swarm_state.select_prev();
1337                                app.swarm_state.enter_detail();
1338                            } else {
1339                                app.swarm_state.select_prev();
1340                            }
1341                        }
1342                        KeyCode::Down | KeyCode::Char('j') => {
1343                            if app.swarm_state.detail_mode {
1344                                app.swarm_state.exit_detail();
1345                                app.swarm_state.select_next();
1346                                app.swarm_state.enter_detail();
1347                            } else {
1348                                app.swarm_state.select_next();
1349                            }
1350                        }
1351                        KeyCode::Enter => {
1352                            if !app.swarm_state.detail_mode {
1353                                app.swarm_state.enter_detail();
1354                            }
1355                        }
1356                        KeyCode::PageDown => {
1357                            app.swarm_state.detail_scroll_down(10);
1358                        }
1359                        KeyCode::PageUp => {
1360                            app.swarm_state.detail_scroll_up(10);
1361                        }
1362                        KeyCode::Char('?') => {
1363                            app.show_help = true;
1364                        }
1365                        KeyCode::F(2) => {
1366                            app.view_mode = ViewMode::Chat;
1367                        }
1368                        KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1369                            app.view_mode = ViewMode::Chat;
1370                        }
1371                        _ => {}
1372                    }
1373                    continue;
1374                }
1375
1376                // Ralph view key handling
1377                if app.view_mode == ViewMode::Ralph {
1378                    match key.code {
1379                        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1380                            return Ok(());
1381                        }
1382                        KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1383                            return Ok(());
1384                        }
1385                        KeyCode::Esc => {
1386                            if app.ralph_state.detail_mode {
1387                                app.ralph_state.exit_detail();
1388                            } else {
1389                                app.view_mode = ViewMode::Chat;
1390                            }
1391                        }
1392                        KeyCode::Up | KeyCode::Char('k') => {
1393                            if app.ralph_state.detail_mode {
1394                                app.ralph_state.exit_detail();
1395                                app.ralph_state.select_prev();
1396                                app.ralph_state.enter_detail();
1397                            } else {
1398                                app.ralph_state.select_prev();
1399                            }
1400                        }
1401                        KeyCode::Down | KeyCode::Char('j') => {
1402                            if app.ralph_state.detail_mode {
1403                                app.ralph_state.exit_detail();
1404                                app.ralph_state.select_next();
1405                                app.ralph_state.enter_detail();
1406                            } else {
1407                                app.ralph_state.select_next();
1408                            }
1409                        }
1410                        KeyCode::Enter => {
1411                            if !app.ralph_state.detail_mode {
1412                                app.ralph_state.enter_detail();
1413                            }
1414                        }
1415                        KeyCode::PageDown => {
1416                            app.ralph_state.detail_scroll_down(10);
1417                        }
1418                        KeyCode::PageUp => {
1419                            app.ralph_state.detail_scroll_up(10);
1420                        }
1421                        KeyCode::Char('?') => {
1422                            app.show_help = true;
1423                        }
1424                        KeyCode::F(2) | KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1425                            app.view_mode = ViewMode::Chat;
1426                        }
1427                        _ => {}
1428                    }
1429                    continue;
1430                }
1431
1432                match key.code {
1433                    // Quit
1434                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1435                        return Ok(());
1436                    }
1437                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1438                        return Ok(());
1439                    }
1440
1441                    // Help
1442                    KeyCode::Char('?') => {
1443                        app.show_help = true;
1444                    }
1445
1446                    // Toggle view mode (F2 or Ctrl+S)
1447                    KeyCode::F(2) => {
1448                        app.view_mode = match app.view_mode {
1449                            ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
1450                            ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1451                        };
1452                    }
1453                    KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1454                        app.view_mode = match app.view_mode {
1455                            ViewMode::Chat | ViewMode::SessionPicker | ViewMode::ModelPicker => ViewMode::Swarm,
1456                            ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1457                        };
1458                    }
1459
1460                    // Toggle inspector pane in webview layout
1461                    KeyCode::F(3) => {
1462                        app.show_inspector = !app.show_inspector;
1463                    }
1464
1465                    // Toggle chat layout (Ctrl+B)
1466                    KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1467                        app.chat_layout = match app.chat_layout {
1468                            ChatLayoutMode::Classic => ChatLayoutMode::Webview,
1469                            ChatLayoutMode::Webview => ChatLayoutMode::Classic,
1470                        };
1471                    }
1472
1473                    // Escape - return to chat from swarm/picker view
1474                    KeyCode::Esc => {
1475                        if app.view_mode == ViewMode::Swarm
1476                            || app.view_mode == ViewMode::Ralph
1477                            || app.view_mode == ViewMode::SessionPicker
1478                            || app.view_mode == ViewMode::ModelPicker
1479                        {
1480                            app.view_mode = ViewMode::Chat;
1481                        }
1482                    }
1483
1484                    // Model picker (Ctrl+M)
1485                    KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1486                        app.open_model_picker(&config).await;
1487                    }
1488
1489                    // Switch agent
1490                    KeyCode::Tab => {
1491                        app.current_agent = if app.current_agent == "build" {
1492                            "plan".to_string()
1493                        } else {
1494                            "build".to_string()
1495                        };
1496                    }
1497
1498                    // Submit message
1499                    KeyCode::Enter => {
1500                        app.submit_message(&config).await;
1501                    }
1502
1503                    // Vim-style scrolling (Alt + j/k)
1504                    KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
1505                        if app.scroll < SCROLL_BOTTOM {
1506                            app.scroll = app.scroll.saturating_add(1);
1507                        }
1508                    }
1509                    KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
1510                        if app.scroll >= SCROLL_BOTTOM {
1511                            app.scroll = app.last_max_scroll; // Leave auto-scroll mode
1512                        }
1513                        app.scroll = app.scroll.saturating_sub(1);
1514                    }
1515
1516                    // Command history
1517                    KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1518                        app.search_history();
1519                    }
1520                    KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
1521                        app.navigate_history(-1);
1522                    }
1523                    KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
1524                        app.navigate_history(1);
1525                    }
1526
1527                    // Additional Vim-style navigation (with modifiers to avoid conflicts)
1528                    KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1529                        app.scroll = 0; // Go to top
1530                    }
1531                    KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1532                        // Go to bottom (auto-scroll)
1533                        app.scroll = SCROLL_BOTTOM;
1534                    }
1535
1536                    // Enhanced scrolling (with Alt to avoid conflicts)
1537                    KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
1538                        // Half page down
1539                        if app.scroll < SCROLL_BOTTOM {
1540                            app.scroll = app.scroll.saturating_add(5);
1541                        }
1542                    }
1543                    KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
1544                        // Half page up
1545                        if app.scroll >= SCROLL_BOTTOM {
1546                            app.scroll = app.last_max_scroll;
1547                        }
1548                        app.scroll = app.scroll.saturating_sub(5);
1549                    }
1550
1551                    // Text input
1552                    KeyCode::Char(c) => {
1553                        app.input.insert(app.cursor_position, c);
1554                        app.cursor_position += 1;
1555                    }
1556                    KeyCode::Backspace => {
1557                        if app.cursor_position > 0 {
1558                            app.cursor_position -= 1;
1559                            app.input.remove(app.cursor_position);
1560                        }
1561                    }
1562                    KeyCode::Delete => {
1563                        if app.cursor_position < app.input.len() {
1564                            app.input.remove(app.cursor_position);
1565                        }
1566                    }
1567                    KeyCode::Left => {
1568                        app.cursor_position = app.cursor_position.saturating_sub(1);
1569                    }
1570                    KeyCode::Right => {
1571                        if app.cursor_position < app.input.len() {
1572                            app.cursor_position += 1;
1573                        }
1574                    }
1575                    KeyCode::Home => {
1576                        app.cursor_position = 0;
1577                    }
1578                    KeyCode::End => {
1579                        app.cursor_position = app.input.len();
1580                    }
1581
1582                    // Scroll (normalize first to handle SCROLL_BOTTOM sentinel)
1583                    KeyCode::Up => {
1584                        if app.scroll >= SCROLL_BOTTOM {
1585                            app.scroll = app.last_max_scroll; // Leave auto-scroll mode
1586                        }
1587                        app.scroll = app.scroll.saturating_sub(1);
1588                    }
1589                    KeyCode::Down => {
1590                        if app.scroll < SCROLL_BOTTOM {
1591                            app.scroll = app.scroll.saturating_add(1);
1592                        }
1593                    }
1594                    KeyCode::PageUp => {
1595                        if app.scroll >= SCROLL_BOTTOM {
1596                            app.scroll = app.last_max_scroll;
1597                        }
1598                        app.scroll = app.scroll.saturating_sub(10);
1599                    }
1600                    KeyCode::PageDown => {
1601                        if app.scroll < SCROLL_BOTTOM {
1602                            app.scroll = app.scroll.saturating_add(10);
1603                        }
1604                    }
1605
1606                    _ => {}
1607                }
1608            }
1609        }
1610    }
1611}
1612
1613fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
1614    // Check view mode
1615    if app.view_mode == ViewMode::Swarm {
1616        // Render swarm view
1617        let chunks = Layout::default()
1618            .direction(Direction::Vertical)
1619            .constraints([
1620                Constraint::Min(1),    // Swarm view
1621                Constraint::Length(3), // Input
1622                Constraint::Length(1), // Status bar
1623            ])
1624            .split(f.area());
1625
1626        // Swarm view
1627        render_swarm_view(f, &mut app.swarm_state, chunks[0]);
1628
1629        // Input area (for returning to chat)
1630        let input_block = Block::default()
1631            .borders(Borders::ALL)
1632            .title(" Press Esc, Ctrl+S, or /view to return to chat ")
1633            .border_style(Style::default().fg(Color::Cyan));
1634
1635        let input = Paragraph::new(app.input.as_str())
1636            .block(input_block)
1637            .wrap(Wrap { trim: false });
1638        f.render_widget(input, chunks[1]);
1639
1640        // Status bar
1641        let status_line = if app.swarm_state.detail_mode {
1642            Line::from(vec![
1643                Span::styled(
1644                    " AGENT DETAIL ",
1645                    Style::default().fg(Color::Black).bg(Color::Cyan),
1646                ),
1647                Span::raw(" | "),
1648                Span::styled("Esc", Style::default().fg(Color::Yellow)),
1649                Span::raw(": Back to list | "),
1650                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1651                Span::raw(": Prev/Next agent | "),
1652                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
1653                Span::raw(": Scroll"),
1654            ])
1655        } else {
1656            Line::from(vec![
1657                Span::styled(
1658                    " SWARM MODE ",
1659                    Style::default().fg(Color::Black).bg(Color::Cyan),
1660                ),
1661                Span::raw(" | "),
1662                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1663                Span::raw(": Select | "),
1664                Span::styled("Enter", Style::default().fg(Color::Yellow)),
1665                Span::raw(": Detail | "),
1666                Span::styled("Esc", Style::default().fg(Color::Yellow)),
1667                Span::raw(": Back | "),
1668                Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
1669                Span::raw(": Toggle view"),
1670            ])
1671        };
1672        let status = Paragraph::new(status_line);
1673        f.render_widget(status, chunks[2]);
1674        return;
1675    }
1676
1677    // Ralph view
1678    if app.view_mode == ViewMode::Ralph {
1679        let chunks = Layout::default()
1680            .direction(Direction::Vertical)
1681            .constraints([
1682                Constraint::Min(1),    // Ralph view
1683                Constraint::Length(3), // Input
1684                Constraint::Length(1), // Status bar
1685            ])
1686            .split(f.area());
1687
1688        render_ralph_view(f, &mut app.ralph_state, chunks[0]);
1689
1690        let input_block = Block::default()
1691            .borders(Borders::ALL)
1692            .title(" Press Esc to return to chat ")
1693            .border_style(Style::default().fg(Color::Magenta));
1694
1695        let input = Paragraph::new(app.input.as_str())
1696            .block(input_block)
1697            .wrap(Wrap { trim: false });
1698        f.render_widget(input, chunks[1]);
1699
1700        let status_line = if app.ralph_state.detail_mode {
1701            Line::from(vec![
1702                Span::styled(
1703                    " STORY DETAIL ",
1704                    Style::default().fg(Color::Black).bg(Color::Magenta),
1705                ),
1706                Span::raw(" | "),
1707                Span::styled("Esc", Style::default().fg(Color::Yellow)),
1708                Span::raw(": Back to list | "),
1709                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1710                Span::raw(": Prev/Next story | "),
1711                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
1712                Span::raw(": Scroll"),
1713            ])
1714        } else {
1715            Line::from(vec![
1716                Span::styled(
1717                    " RALPH MODE ",
1718                    Style::default().fg(Color::Black).bg(Color::Magenta),
1719                ),
1720                Span::raw(" | "),
1721                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
1722                Span::raw(": Select | "),
1723                Span::styled("Enter", Style::default().fg(Color::Yellow)),
1724                Span::raw(": Detail | "),
1725                Span::styled("Esc", Style::default().fg(Color::Yellow)),
1726                Span::raw(": Back"),
1727            ])
1728        };
1729        let status = Paragraph::new(status_line);
1730        f.render_widget(status, chunks[2]);
1731        return;
1732    }
1733
1734    // Model picker view
1735    if app.view_mode == ViewMode::ModelPicker {
1736        let area = centered_rect(70, 70, f.area());
1737        f.render_widget(Clear, area);
1738
1739        let filter_display = if app.model_picker_filter.is_empty() {
1740            "type to filter".to_string()
1741        } else {
1742            format!("filter: {}", app.model_picker_filter)
1743        };
1744
1745        let picker_block = Block::default()
1746            .borders(Borders::ALL)
1747            .title(format!(" Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ", filter_display))
1748            .border_style(Style::default().fg(Color::Magenta));
1749
1750        let filtered = app.filtered_models();
1751        let mut list_lines: Vec<Line> = Vec::new();
1752        list_lines.push(Line::from(""));
1753
1754        if let Some(ref active) = app.active_model {
1755            list_lines.push(Line::styled(
1756                format!("  Current: {}", active),
1757                Style::default().fg(Color::Green).add_modifier(Modifier::DIM),
1758            ));
1759            list_lines.push(Line::from(""));
1760        }
1761
1762        if filtered.is_empty() {
1763            list_lines.push(Line::styled(
1764                "  No models match filter",
1765                Style::default().fg(Color::DarkGray),
1766            ));
1767        } else {
1768            let mut current_provider = String::new();
1769            for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
1770                let provider = label.split('/').next().unwrap_or("");
1771                if provider != current_provider {
1772                    if !current_provider.is_empty() {
1773                        list_lines.push(Line::from(""));
1774                    }
1775                    list_lines.push(Line::styled(
1776                        format!("  ─── {} ───", provider),
1777                        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
1778                    ));
1779                    current_provider = provider.to_string();
1780                }
1781
1782                let is_selected = display_idx == app.model_picker_selected;
1783                let is_active = app.active_model.as_deref() == Some(label.as_str());
1784                let marker = if is_selected { "▶" } else { " " };
1785                let active_marker = if is_active { " ✓" } else { "" };
1786                let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
1787                // Show human name if different from ID
1788                let display = if human_name != &model_id && !human_name.is_empty() {
1789                    format!("{} ({})", human_name, model_id)
1790                } else {
1791                    model_id
1792                };
1793
1794                let style = if is_selected {
1795                    Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)
1796                } else if is_active {
1797                    Style::default().fg(Color::Green)
1798                } else {
1799                    Style::default()
1800                };
1801
1802                list_lines.push(Line::styled(
1803                    format!("  {} {}{}", marker, display, active_marker),
1804                    style,
1805                ));
1806            }
1807        }
1808
1809        let list = Paragraph::new(list_lines)
1810            .block(picker_block)
1811            .wrap(Wrap { trim: false });
1812        f.render_widget(list, area);
1813        return;
1814    }
1815
1816    // Session picker view
1817    if app.view_mode == ViewMode::SessionPicker {
1818        let chunks = Layout::default()
1819            .direction(Direction::Vertical)
1820            .constraints([
1821                Constraint::Min(1),    // Session list
1822                Constraint::Length(1), // Status bar
1823            ])
1824            .split(f.area());
1825
1826        // Session list
1827        let list_block = Block::default()
1828            .borders(Borders::ALL)
1829            .title(" Select Session (↑↓ to navigate, Enter to load, Esc to cancel) ")
1830            .border_style(Style::default().fg(Color::Cyan));
1831
1832        let mut list_lines: Vec<Line> = Vec::new();
1833        list_lines.push(Line::from(""));
1834
1835        for (i, session) in app.session_picker_list.iter().enumerate() {
1836            let is_selected = i == app.session_picker_selected;
1837            let title = session.title.as_deref().unwrap_or("(untitled)");
1838            let date = session.updated_at.format("%Y-%m-%d %H:%M");
1839            let line_str = format!(
1840                " {} {} - {} ({} msgs)",
1841                if is_selected { "▶" } else { " " },
1842                title,
1843                date,
1844                session.message_count
1845            );
1846
1847            let style = if is_selected {
1848                Style::default()
1849                    .fg(Color::Cyan)
1850                    .add_modifier(Modifier::BOLD)
1851            } else {
1852                Style::default()
1853            };
1854
1855            list_lines.push(Line::styled(line_str, style));
1856
1857            // Add agent info on next line for selected item
1858            if is_selected {
1859                list_lines.push(Line::styled(
1860                    format!("   Agent: {} | ID: {}", session.agent, session.id),
1861                    Style::default().fg(Color::DarkGray),
1862                ));
1863            }
1864        }
1865
1866        let list = Paragraph::new(list_lines)
1867            .block(list_block)
1868            .wrap(Wrap { trim: false });
1869        f.render_widget(list, chunks[0]);
1870
1871        // Status bar
1872        let status = Paragraph::new(Line::from(vec![
1873            Span::styled(
1874                " SESSION PICKER ",
1875                Style::default().fg(Color::Black).bg(Color::Cyan),
1876            ),
1877            Span::raw(" | "),
1878            Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)),
1879            Span::raw(": Navigate | "),
1880            Span::styled("Enter", Style::default().fg(Color::Yellow)),
1881            Span::raw(": Load | "),
1882            Span::styled("Esc", Style::default().fg(Color::Yellow)),
1883            Span::raw(": Cancel"),
1884        ]));
1885        f.render_widget(status, chunks[1]);
1886        return;
1887    }
1888
1889    if app.chat_layout == ChatLayoutMode::Webview {
1890        if render_webview_chat(f, app, theme) {
1891            render_help_overlay_if_needed(f, app, theme);
1892            return;
1893        }
1894    }
1895
1896    // Chat view (default)
1897    let chunks = Layout::default()
1898        .direction(Direction::Vertical)
1899        .constraints([
1900            Constraint::Min(1),    // Messages
1901            Constraint::Length(3), // Input
1902            Constraint::Length(1), // Status bar
1903        ])
1904        .split(f.area());
1905
1906    // Messages area with theme-based styling
1907    let messages_area = chunks[0];
1908    let model_label = app.active_model.as_deref().unwrap_or("auto");
1909    let messages_block = Block::default()
1910        .borders(Borders::ALL)
1911        .title(format!(" CodeTether Agent [{}] model:{} ", app.current_agent, model_label))
1912        .border_style(Style::default().fg(theme.border_color.to_color()));
1913
1914    let max_width = messages_area.width.saturating_sub(4) as usize;
1915    let message_lines = build_message_lines(app, theme, max_width);
1916
1917    // Calculate scroll position
1918    let total_lines = message_lines.len();
1919    let visible_lines = messages_area.height.saturating_sub(2) as usize;
1920    let max_scroll = total_lines.saturating_sub(visible_lines);
1921    // SCROLL_BOTTOM means "stick to bottom", otherwise clamp to max_scroll
1922    let scroll = if app.scroll >= SCROLL_BOTTOM {
1923        max_scroll
1924    } else {
1925        app.scroll.min(max_scroll)
1926    };
1927
1928    // Render messages with scrolling
1929    let messages_paragraph = Paragraph::new(
1930        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
1931    )
1932    .block(messages_block.clone())
1933    .wrap(Wrap { trim: false });
1934
1935    f.render_widget(messages_paragraph, messages_area);
1936
1937    // Render scrollbar if needed
1938    if total_lines > visible_lines {
1939        let scrollbar = Scrollbar::default()
1940            .orientation(ScrollbarOrientation::VerticalRight)
1941            .symbols(ratatui::symbols::scrollbar::VERTICAL)
1942            .begin_symbol(Some("↑"))
1943            .end_symbol(Some("↓"));
1944
1945        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
1946
1947        let scrollbar_area = Rect::new(
1948            messages_area.right() - 1,
1949            messages_area.top() + 1,
1950            1,
1951            messages_area.height - 2,
1952        );
1953
1954        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
1955    }
1956
1957    // Input area
1958    let input_block = Block::default()
1959        .borders(Borders::ALL)
1960        .title(if app.is_processing {
1961            " Message (Processing...) "
1962        } else {
1963            " Message (Enter to send) "
1964        })
1965        .border_style(Style::default().fg(if app.is_processing {
1966            Color::Yellow
1967        } else {
1968            theme.input_border_color.to_color()
1969        }));
1970
1971    let input = Paragraph::new(app.input.as_str())
1972        .block(input_block)
1973        .wrap(Wrap { trim: false });
1974    f.render_widget(input, chunks[1]);
1975
1976    // Cursor
1977    f.set_cursor_position((
1978        chunks[1].x + app.cursor_position as u16 + 1,
1979        chunks[1].y + 1,
1980    ));
1981
1982    // Enhanced status bar with token display
1983    let token_display = TokenDisplay::new();
1984    let status = Paragraph::new(token_display.create_status_bar(theme));
1985    f.render_widget(status, chunks[2]);
1986
1987    render_help_overlay_if_needed(f, app, theme);
1988}
1989
1990fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
1991    let area = f.area();
1992    if area.width < 90 || area.height < 18 {
1993        return false;
1994    }
1995
1996    let main_chunks = Layout::default()
1997        .direction(Direction::Vertical)
1998        .constraints([
1999            Constraint::Length(3), // Header
2000            Constraint::Min(1),    // Body
2001            Constraint::Length(3), // Input
2002            Constraint::Length(1), // Status
2003        ])
2004        .split(area);
2005
2006    render_webview_header(f, app, theme, main_chunks[0]);
2007
2008    let body_constraints = if app.show_inspector {
2009        vec![
2010            Constraint::Length(26),
2011            Constraint::Min(40),
2012            Constraint::Length(30),
2013        ]
2014    } else {
2015        vec![Constraint::Length(26), Constraint::Min(40)]
2016    };
2017
2018    let body_chunks = Layout::default()
2019        .direction(Direction::Horizontal)
2020        .constraints(body_constraints)
2021        .split(main_chunks[1]);
2022
2023    render_webview_sidebar(f, app, theme, body_chunks[0]);
2024    render_webview_chat_center(f, app, theme, body_chunks[1]);
2025    if app.show_inspector && body_chunks.len() > 2 {
2026        render_webview_inspector(f, app, theme, body_chunks[2]);
2027    }
2028
2029    render_webview_input(f, app, theme, main_chunks[2]);
2030
2031    let token_display = TokenDisplay::new();
2032    let status = Paragraph::new(token_display.create_status_bar(theme));
2033    f.render_widget(status, main_chunks[3]);
2034
2035    true
2036}
2037
2038fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2039    let session_title = app
2040        .session
2041        .as_ref()
2042        .and_then(|s| s.title.clone())
2043        .unwrap_or_else(|| "Workspace Chat".to_string());
2044    let session_id = app
2045        .session
2046        .as_ref()
2047        .map(|s| s.id.chars().take(8).collect::<String>())
2048        .unwrap_or_else(|| "new".to_string());
2049    let model_label = app
2050        .session
2051        .as_ref()
2052        .and_then(|s| s.metadata.model.clone())
2053        .unwrap_or_else(|| "auto".to_string());
2054    let workspace_label = app.workspace.root_display.clone();
2055    let branch_label = app
2056        .workspace
2057        .git_branch
2058        .clone()
2059        .unwrap_or_else(|| "no-git".to_string());
2060    let dirty_label = if app.workspace.git_dirty_files > 0 {
2061        format!("{} dirty", app.workspace.git_dirty_files)
2062    } else {
2063        "clean".to_string()
2064    };
2065
2066    let header_block = Block::default()
2067        .borders(Borders::ALL)
2068        .title(" CodeTether Webview ")
2069        .border_style(Style::default().fg(theme.border_color.to_color()));
2070
2071    let header_lines = vec![
2072        Line::from(vec![
2073            Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
2074            Span::raw(" "),
2075            Span::styled(
2076                format!("#{}", session_id),
2077                Style::default()
2078                    .fg(theme.timestamp_color.to_color())
2079                    .add_modifier(Modifier::DIM),
2080            ),
2081        ]),
2082        Line::from(vec![
2083            Span::styled("Workspace ", Style::default().fg(theme.timestamp_color.to_color())),
2084            Span::styled(workspace_label, Style::default()),
2085            Span::raw("  "),
2086            Span::styled("Branch ", Style::default().fg(theme.timestamp_color.to_color())),
2087            Span::styled(
2088                branch_label,
2089                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
2090            ),
2091            Span::raw("  "),
2092            Span::styled(
2093                dirty_label,
2094                Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
2095            ),
2096            Span::raw("  "),
2097            Span::styled("Model ", Style::default().fg(theme.timestamp_color.to_color())),
2098            Span::styled(model_label, Style::default().fg(Color::Green)),
2099        ]),
2100    ];
2101
2102    let header = Paragraph::new(header_lines)
2103        .block(header_block)
2104        .wrap(Wrap { trim: true });
2105    f.render_widget(header, area);
2106}
2107
2108fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2109    let sidebar_chunks = Layout::default()
2110        .direction(Direction::Vertical)
2111        .constraints([Constraint::Min(8), Constraint::Min(6)])
2112        .split(area);
2113
2114    let workspace_block = Block::default()
2115        .borders(Borders::ALL)
2116        .title(" Workspace ")
2117        .border_style(Style::default().fg(theme.border_color.to_color()));
2118
2119    let mut workspace_lines = Vec::new();
2120    workspace_lines.push(Line::from(vec![
2121        Span::styled("Updated ", Style::default().fg(theme.timestamp_color.to_color())),
2122        Span::styled(
2123            app.workspace.captured_at.clone(),
2124            Style::default().fg(theme.timestamp_color.to_color()),
2125        ),
2126    ]));
2127    workspace_lines.push(Line::from(""));
2128
2129    if app.workspace.entries.is_empty() {
2130        workspace_lines.push(Line::styled(
2131            "No entries found",
2132            Style::default().fg(Color::DarkGray),
2133        ));
2134    } else {
2135        for entry in app.workspace.entries.iter().take(12) {
2136            let icon = match entry.kind {
2137                WorkspaceEntryKind::Directory => "📁",
2138                WorkspaceEntryKind::File => "📄",
2139            };
2140            workspace_lines.push(Line::from(vec![
2141                Span::styled(icon, Style::default().fg(Color::Cyan)),
2142                Span::raw(" "),
2143                Span::styled(entry.name.clone(), Style::default()),
2144            ]));
2145        }
2146    }
2147
2148    workspace_lines.push(Line::from(""));
2149    workspace_lines.push(Line::styled(
2150        "Use /refresh to rescan",
2151        Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
2152    ));
2153
2154    let workspace_panel = Paragraph::new(workspace_lines)
2155        .block(workspace_block)
2156        .wrap(Wrap { trim: true });
2157    f.render_widget(workspace_panel, sidebar_chunks[0]);
2158
2159    let sessions_block = Block::default()
2160        .borders(Borders::ALL)
2161        .title(" Recent Sessions ")
2162        .border_style(Style::default().fg(theme.border_color.to_color()));
2163
2164    let mut session_lines = Vec::new();
2165    if app.session_picker_list.is_empty() {
2166        session_lines.push(Line::styled(
2167            "No sessions yet",
2168            Style::default().fg(Color::DarkGray),
2169        ));
2170    } else {
2171        for session in app.session_picker_list.iter().take(6) {
2172            let is_active = app
2173                .session
2174                .as_ref()
2175                .map(|s| s.id == session.id)
2176                .unwrap_or(false);
2177            let title = session.title.as_deref().unwrap_or("(untitled)");
2178            let indicator = if is_active { "●" } else { "○" };
2179            let line_style = if is_active {
2180                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
2181            } else {
2182                Style::default()
2183            };
2184            session_lines.push(Line::from(vec![
2185                Span::styled(indicator, line_style),
2186                Span::raw(" "),
2187                Span::styled(title, line_style),
2188            ]));
2189            session_lines.push(Line::styled(
2190                format!(
2191                    "  {} msgs • {}",
2192                    session.message_count,
2193                    session.updated_at.format("%m-%d %H:%M")
2194                ),
2195                Style::default().fg(Color::DarkGray),
2196            ));
2197        }
2198    }
2199
2200    let sessions_panel = Paragraph::new(session_lines)
2201        .block(sessions_block)
2202        .wrap(Wrap { trim: true });
2203    f.render_widget(sessions_panel, sidebar_chunks[1]);
2204}
2205
2206fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2207    let messages_area = area;
2208    let messages_block = Block::default()
2209        .borders(Borders::ALL)
2210        .title(format!(" Chat [{}] ", app.current_agent))
2211        .border_style(Style::default().fg(theme.border_color.to_color()));
2212
2213    let max_width = messages_area.width.saturating_sub(4) as usize;
2214    let message_lines = build_message_lines(app, theme, max_width);
2215
2216    let total_lines = message_lines.len();
2217    let visible_lines = messages_area.height.saturating_sub(2) as usize;
2218    let max_scroll = total_lines.saturating_sub(visible_lines);
2219    let scroll = if app.scroll >= SCROLL_BOTTOM {
2220        max_scroll
2221    } else {
2222        app.scroll.min(max_scroll)
2223    };
2224
2225    let messages_paragraph = Paragraph::new(
2226        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
2227    )
2228    .block(messages_block.clone())
2229    .wrap(Wrap { trim: false });
2230
2231    f.render_widget(messages_paragraph, messages_area);
2232
2233    if total_lines > visible_lines {
2234        let scrollbar = Scrollbar::default()
2235            .orientation(ScrollbarOrientation::VerticalRight)
2236            .symbols(ratatui::symbols::scrollbar::VERTICAL)
2237            .begin_symbol(Some("↑"))
2238            .end_symbol(Some("↓"));
2239
2240        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
2241
2242        let scrollbar_area = Rect::new(
2243            messages_area.right() - 1,
2244            messages_area.top() + 1,
2245            1,
2246            messages_area.height - 2,
2247        );
2248
2249        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
2250    }
2251}
2252
2253fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2254    let block = Block::default()
2255        .borders(Borders::ALL)
2256        .title(" Inspector ")
2257        .border_style(Style::default().fg(theme.border_color.to_color()));
2258
2259    let status_label = if app.is_processing {
2260        "Processing"
2261    } else {
2262        "Idle"
2263    };
2264    let status_style = if app.is_processing {
2265        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
2266    } else {
2267        Style::default().fg(Color::Green)
2268    };
2269    let tool_label = app
2270        .current_tool
2271        .clone()
2272        .unwrap_or_else(|| "none".to_string());
2273    let message_count = app.messages.len();
2274    let session_id = app
2275        .session
2276        .as_ref()
2277        .map(|s| s.id.chars().take(8).collect::<String>())
2278        .unwrap_or_else(|| "new".to_string());
2279
2280    let mut lines = Vec::new();
2281    lines.push(Line::from(vec![
2282        Span::styled("Status: ", Style::default().fg(theme.timestamp_color.to_color())),
2283        Span::styled(status_label, status_style),
2284    ]));
2285    lines.push(Line::from(vec![
2286        Span::styled("Tool: ", Style::default().fg(theme.timestamp_color.to_color())),
2287        Span::styled(tool_label, Style::default()),
2288    ]));
2289    lines.push(Line::from(vec![
2290        Span::styled("Session: ", Style::default().fg(theme.timestamp_color.to_color())),
2291        Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
2292    ]));
2293    lines.push(Line::from(vec![
2294        Span::styled("Messages: ", Style::default().fg(theme.timestamp_color.to_color())),
2295        Span::styled(message_count.to_string(), Style::default()),
2296    ]));
2297    lines.push(Line::from(vec![
2298        Span::styled("Agent: ", Style::default().fg(theme.timestamp_color.to_color())),
2299        Span::styled(app.current_agent.clone(), Style::default()),
2300    ]));
2301    lines.push(Line::from(""));
2302    lines.push(Line::styled(
2303        "Shortcuts:",
2304        Style::default().add_modifier(Modifier::BOLD),
2305    ));
2306    lines.push(Line::styled("F3  Toggle inspector", Style::default().fg(Color::DarkGray)));
2307    lines.push(Line::styled("Ctrl+B Toggle layout", Style::default().fg(Color::DarkGray)));
2308    lines.push(Line::styled("Ctrl+S Swarm view", Style::default().fg(Color::DarkGray)));
2309
2310    let panel = Paragraph::new(lines)
2311        .block(block)
2312        .wrap(Wrap { trim: true });
2313    f.render_widget(panel, area);
2314}
2315
2316fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
2317    let input_block = Block::default()
2318        .borders(Borders::ALL)
2319        .title(if app.is_processing {
2320            " Message (Processing...) "
2321        } else {
2322            " Message (Enter to send) "
2323        })
2324        .border_style(Style::default().fg(if app.is_processing {
2325            Color::Yellow
2326        } else {
2327            theme.input_border_color.to_color()
2328        }));
2329
2330    let input = Paragraph::new(app.input.as_str())
2331        .block(input_block)
2332        .wrap(Wrap { trim: false });
2333    f.render_widget(input, area);
2334
2335    f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
2336}
2337
2338fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
2339    let mut message_lines = Vec::new();
2340
2341    for message in &app.messages {
2342        let role_style = theme.get_role_style(&message.role);
2343
2344        let header_line = Line::from(vec![
2345            Span::styled(
2346                format!("[{}] ", message.timestamp),
2347                Style::default()
2348                    .fg(theme.timestamp_color.to_color())
2349                    .add_modifier(Modifier::DIM),
2350            ),
2351            Span::styled(message.role.clone(), role_style),
2352        ]);
2353        message_lines.push(header_line);
2354
2355        match &message.message_type {
2356            MessageType::ToolCall { name, arguments } => {
2357                let tool_header = Line::from(vec![
2358                    Span::styled("  🔧 ", Style::default().fg(Color::Yellow)),
2359                    Span::styled(
2360                        format!("Tool: {}", name),
2361                        Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
2362                    ),
2363                ]);
2364                message_lines.push(tool_header);
2365
2366                let args_str = if arguments.len() > 200 {
2367                    format!("{}...", &arguments[..197])
2368                } else {
2369                    arguments.clone()
2370                };
2371                let args_line = Line::from(vec![
2372                    Span::styled("     ", Style::default()),
2373                    Span::styled(args_str, Style::default().fg(Color::DarkGray)),
2374                ]);
2375                message_lines.push(args_line);
2376            }
2377            MessageType::ToolResult { name, output } => {
2378                let result_header = Line::from(vec![
2379                    Span::styled("  ✅ ", Style::default().fg(Color::Green)),
2380                    Span::styled(
2381                        format!("Result from {}", name),
2382                        Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
2383                    ),
2384                ]);
2385                message_lines.push(result_header);
2386
2387                let output_str = if output.len() > 300 {
2388                    format!("{}... (truncated)", &output[..297])
2389                } else {
2390                    output.clone()
2391                };
2392                let output_lines: Vec<&str> = output_str.lines().collect();
2393                for line in output_lines.iter().take(5) {
2394                    let output_line = Line::from(vec![
2395                        Span::styled("     ", Style::default()),
2396                        Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
2397                    ]);
2398                    message_lines.push(output_line);
2399                }
2400                if output_lines.len() > 5 {
2401                    message_lines.push(Line::from(vec![
2402                        Span::styled("     ", Style::default()),
2403                        Span::styled(
2404                            format!("... and {} more lines", output_lines.len() - 5),
2405                            Style::default().fg(Color::DarkGray).add_modifier(Modifier::DIM),
2406                        ),
2407                    ]));
2408                }
2409            }
2410            MessageType::Text(text) => {
2411                let formatter = MessageFormatter::new(max_width);
2412                let formatted_content = formatter.format_content(text, &message.role);
2413                message_lines.extend(formatted_content);
2414            }
2415        }
2416
2417        message_lines.push(Line::from(""));
2418    }
2419
2420    if app.is_processing {
2421        let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2422        let spinner_idx = (std::time::SystemTime::now()
2423            .duration_since(std::time::UNIX_EPOCH)
2424            .unwrap_or_default()
2425            .as_millis()
2426            / 100) as usize
2427            % spinner.len();
2428
2429        let processing_line = Line::from(vec![
2430            Span::styled(
2431                format!("[{}] ", chrono::Local::now().format("%H:%M")),
2432                Style::default()
2433                    .fg(theme.timestamp_color.to_color())
2434                    .add_modifier(Modifier::DIM),
2435            ),
2436            Span::styled("assistant", theme.get_role_style("assistant")),
2437        ]);
2438        message_lines.push(processing_line);
2439
2440        let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
2441            (format!("  {} Running: {}", spinner[spinner_idx], tool), Color::Cyan)
2442        } else {
2443            (
2444                format!(
2445                    "  {} {}",
2446                    spinner[spinner_idx],
2447                    app.processing_message
2448                        .as_deref()
2449                        .unwrap_or("Thinking...")
2450                ),
2451                Color::Yellow,
2452            )
2453        };
2454
2455        let indicator_line = Line::from(vec![
2456            Span::styled(status_text, Style::default().fg(status_color).add_modifier(Modifier::BOLD)),
2457        ]);
2458        message_lines.push(indicator_line);
2459        message_lines.push(Line::from(""));
2460    }
2461
2462    message_lines
2463}
2464
2465fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
2466    if !app.show_help {
2467        return;
2468    }
2469
2470    let area = centered_rect(60, 60, f.area());
2471    f.render_widget(Clear, area);
2472
2473    let token_display = TokenDisplay::new();
2474    let token_info = token_display.create_detailed_display();
2475
2476    let help_text: Vec<String> = vec![
2477        "".to_string(),
2478        "  KEYBOARD SHORTCUTS".to_string(),
2479        "  ==================".to_string(),
2480        "".to_string(),
2481        "  Enter        Send message".to_string(),
2482        "  Tab          Switch between build/plan agents".to_string(),
2483        "  Ctrl+S       Toggle swarm view".to_string(),
2484        "  Ctrl+B       Toggle webview layout".to_string(),
2485        "  F3           Toggle inspector pane".to_string(),
2486        "  Ctrl+C       Quit".to_string(),
2487        "  ?            Toggle this help".to_string(),
2488        "".to_string(),
2489        "  SLASH COMMANDS".to_string(),
2490        "  /swarm <task>   Run task in parallel swarm mode".to_string(),
2491        "  /ralph [path]   Start Ralph PRD loop (default: prd.json)".to_string(),
2492        "  /sessions       Open session picker to resume".to_string(),
2493        "  /resume         Resume most recent session".to_string(),
2494        "  /resume <id>    Resume specific session by ID".to_string(),
2495        "  /new            Start a fresh session".to_string(),
2496        "  /model          Open model picker (or /model <name>)".to_string(),
2497        "  /view           Toggle swarm view".to_string(),
2498        "  /webview        Web dashboard layout".to_string(),
2499        "  /classic        Single-pane layout".to_string(),
2500        "  /inspector      Toggle inspector pane".to_string(),
2501        "  /refresh        Refresh workspace and sessions".to_string(),
2502        "".to_string(),
2503        "  VIM-STYLE NAVIGATION".to_string(),
2504        "  Alt+j        Scroll down".to_string(),
2505        "  Alt+k        Scroll up".to_string(),
2506        "  Ctrl+g       Go to top".to_string(),
2507        "  Ctrl+G       Go to bottom".to_string(),
2508        "".to_string(),
2509        "  SCROLLING".to_string(),
2510        "  Up/Down      Scroll messages".to_string(),
2511        "  PageUp/Dn    Scroll one page".to_string(),
2512        "  Alt+u/d      Scroll half page".to_string(),
2513        "".to_string(),
2514        "  COMMAND HISTORY".to_string(),
2515        "  Ctrl+R       Search history".to_string(),
2516        "  Ctrl+Up/Dn   Navigate history".to_string(),
2517        "".to_string(),
2518        "  Press ? or Esc to close".to_string(),
2519        "".to_string(),
2520    ];
2521
2522    let mut combined_text = token_info;
2523    combined_text.extend(help_text);
2524
2525    let help = Paragraph::new(combined_text.join("\n"))
2526        .block(
2527            Block::default()
2528                .borders(Borders::ALL)
2529                .title(" Help ")
2530                .border_style(Style::default().fg(theme.help_border_color.to_color())),
2531        )
2532        .wrap(Wrap { trim: false });
2533
2534    f.render_widget(help, area);
2535}
2536
2537/// Helper to create a centered rect
2538fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
2539    let popup_layout = Layout::default()
2540        .direction(Direction::Vertical)
2541        .constraints([
2542            Constraint::Percentage((100 - percent_y) / 2),
2543            Constraint::Percentage(percent_y),
2544            Constraint::Percentage((100 - percent_y) / 2),
2545        ])
2546        .split(r);
2547
2548    Layout::default()
2549        .direction(Direction::Horizontal)
2550        .constraints([
2551            Constraint::Percentage((100 - percent_x) / 2),
2552            Constraint::Percentage(percent_x),
2553            Constraint::Percentage((100 - percent_x) / 2),
2554        ])
2555        .split(popup_layout[1])[1]
2556}