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