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