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