Skip to main content

codetether_agent/tui/
mod.rs

1//! Terminal User Interface
2//!
3//! Interactive TUI using Ratatui
4
5pub mod bus_log;
6pub mod message_formatter;
7pub mod ralph_view;
8pub mod swarm_view;
9pub mod theme;
10pub mod theme_utils;
11pub mod token_display;
12
13/// Sentinel value meaning "scroll to bottom"
14const SCROLL_BOTTOM: usize = 1_000_000;
15
16// Tool-call / tool-result rendering can carry very large JSON payloads (e.g. patches, file blobs).
17// If we pretty-print + split/collect that payload on every frame, the TUI can appear to “stop
18// rendering” after a few tool calls due to render-time CPU churn.
19const TOOL_ARGS_PRETTY_JSON_MAX_BYTES: usize = 16_000;
20const TOOL_ARGS_PREVIEW_MAX_LINES: usize = 10;
21const TOOL_ARGS_PREVIEW_MAX_BYTES: usize = 6_000;
22const TOOL_OUTPUT_PREVIEW_MAX_LINES: usize = 5;
23const TOOL_OUTPUT_PREVIEW_MAX_BYTES: usize = 4_000;
24
25use crate::config::Config;
26use crate::provider::{ContentPart, Role};
27use crate::ralph::{RalphConfig, RalphLoop};
28use crate::session::{Session, SessionEvent, SessionSummary, list_sessions_with_opencode};
29use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
30use crate::tui::bus_log::{BusLogState, render_bus_log};
31use crate::tui::message_formatter::MessageFormatter;
32use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
33use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
34use crate::tui::theme::Theme;
35use crate::tui::token_display::TokenDisplay;
36use anyhow::Result;
37use base64::Engine;
38use crossterm::{
39    event::{
40        DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyModifiers,
41    },
42    execute,
43    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
44};
45use futures::StreamExt;
46use ratatui::{
47    Frame, Terminal,
48    backend::CrosstermBackend,
49    layout::{Constraint, Direction, Layout, Rect},
50    style::{Color, Modifier, Style},
51    text::{Line, Span},
52    widgets::{
53        Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
54    },
55};
56use std::collections::HashMap;
57use std::io;
58use std::path::{Path, PathBuf};
59use std::process::Command;
60use std::time::{Duration, Instant};
61use tokio::sync::mpsc;
62
63/// Run the TUI
64pub async fn run(project: Option<PathBuf>) -> Result<()> {
65    // Change to project directory if specified
66    if let Some(dir) = project {
67        std::env::set_current_dir(&dir)?;
68    }
69
70    // Setup terminal
71    enable_raw_mode()?;
72    let mut stdout = io::stdout();
73    execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
74    let backend = CrosstermBackend::new(stdout);
75    let mut terminal = Terminal::new(backend)?;
76
77    // Run the app
78    let result = run_app(&mut terminal).await;
79
80    // Restore terminal
81    disable_raw_mode()?;
82    execute!(
83        terminal.backend_mut(),
84        LeaveAlternateScreen,
85        DisableBracketedPaste
86    )?;
87    terminal.show_cursor()?;
88
89    result
90}
91
92/// Message type for chat display
93#[derive(Debug, Clone)]
94enum MessageType {
95    Text(String),
96    Image {
97        url: String,
98        mime_type: Option<String>,
99    },
100    ToolCall {
101        name: String,
102        arguments_preview: String,
103        arguments_len: usize,
104        truncated: bool,
105    },
106    ToolResult {
107        name: String,
108        output_preview: String,
109        output_len: usize,
110        truncated: bool,
111    },
112    File {
113        path: String,
114        mime_type: Option<String>,
115    },
116    Thinking(String),
117}
118
119/// View mode for the TUI
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121enum ViewMode {
122    Chat,
123    Swarm,
124    Ralph,
125    BusLog,
126    SessionPicker,
127    ModelPicker,
128    AgentPicker,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132enum ChatLayoutMode {
133    Classic,
134    Webview,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138enum WorkspaceEntryKind {
139    Directory,
140    File,
141}
142
143#[derive(Debug, Clone)]
144struct WorkspaceEntry {
145    name: String,
146    kind: WorkspaceEntryKind,
147}
148
149#[derive(Debug, Clone, Default)]
150struct WorkspaceSnapshot {
151    root_display: String,
152    git_branch: Option<String>,
153    git_dirty_files: usize,
154    entries: Vec<WorkspaceEntry>,
155    captured_at: String,
156}
157
158/// Application state
159struct App {
160    input: String,
161    cursor_position: usize,
162    messages: Vec<ChatMessage>,
163    current_agent: String,
164    scroll: usize,
165    show_help: bool,
166    command_history: Vec<String>,
167    history_index: Option<usize>,
168    session: Option<Session>,
169    is_processing: bool,
170    processing_message: Option<String>,
171    current_tool: Option<String>,
172    /// Tracks when processing started for elapsed timer display
173    processing_started_at: Option<Instant>,
174    /// Partial streaming text being assembled (shown with typing indicator)
175    streaming_text: Option<String>,
176    /// Total tool calls in this session for inspector
177    tool_call_count: usize,
178    response_rx: Option<mpsc::Receiver<SessionEvent>>,
179    /// Cached provider registry to avoid reloading from Vault on every message
180    provider_registry: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
181    /// Working directory for workspace-scoped session filtering
182    workspace_dir: PathBuf,
183    // Swarm mode state
184    view_mode: ViewMode,
185    chat_layout: ChatLayoutMode,
186    show_inspector: bool,
187    workspace: WorkspaceSnapshot,
188    swarm_state: SwarmViewState,
189    swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
190    // Ralph mode state
191    ralph_state: RalphViewState,
192    ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
193    // Bus protocol log state
194    bus_log_state: BusLogState,
195    bus_log_rx: Option<mpsc::Receiver<crate::bus::BusEnvelope>>,
196    bus: Option<std::sync::Arc<crate::bus::AgentBus>>,
197    // Session picker state
198    session_picker_list: Vec<SessionSummary>,
199    session_picker_selected: usize,
200    session_picker_filter: String,
201    session_picker_confirm_delete: bool,
202    // Model picker state
203    model_picker_list: Vec<(String, String, String)>, // (display label, provider/model value, human name)
204    model_picker_selected: usize,
205    model_picker_filter: String,
206    // Agent picker state
207    agent_picker_selected: usize,
208    agent_picker_filter: String,
209    active_model: Option<String>,
210    // Spawned sub-agents state
211    active_spawned_agent: Option<String>,
212    spawned_agents: HashMap<String, SpawnedAgent>,
213    agent_response_rxs: Vec<(String, mpsc::Receiver<SessionEvent>)>,
214    // Cached max scroll for key handlers
215    last_max_scroll: usize,
216}
217
218#[allow(dead_code)]
219struct ChatMessage {
220    role: String,
221    content: String,
222    timestamp: String,
223    message_type: MessageType,
224    /// Per-step usage metadata (set on assistant messages)
225    usage_meta: Option<UsageMeta>,
226    /// Name of the spawned agent that produced this message (None = main chat)
227    agent_name: Option<String>,
228}
229
230/// A spawned sub-agent with its own independent LLM session.
231#[allow(dead_code)]
232struct SpawnedAgent {
233    /// User-facing name (e.g. "planner", "coder")
234    name: String,
235    /// System instructions for this agent
236    instructions: String,
237    /// Independent conversation session
238    session: Session,
239    /// Whether this agent is currently processing a message
240    is_processing: bool,
241}
242
243/// Token usage + cost + latency for one LLM round-trip
244#[derive(Debug, Clone)]
245struct UsageMeta {
246    prompt_tokens: usize,
247    completion_tokens: usize,
248    duration_ms: u64,
249    cost_usd: Option<f64>,
250}
251
252/// Estimate USD cost from model name and token counts.
253/// Uses approximate per-million-token pricing for well-known models.
254fn estimate_cost(model: &str, prompt_tokens: usize, completion_tokens: usize) -> Option<f64> {
255    // (input $/M, output $/M)
256    let (input_rate, output_rate) = match model {
257        // Anthropic - Claude
258        m if m.contains("claude-opus") => (15.0, 75.0),
259        m if m.contains("claude-sonnet") => (3.0, 15.0),
260        m if m.contains("claude-haiku") => (0.25, 1.25),
261        // OpenAI
262        m if m.contains("gpt-4o-mini") => (0.15, 0.6),
263        m if m.contains("gpt-4o") => (2.5, 10.0),
264        m if m.contains("o3") => (10.0, 40.0),
265        m if m.contains("o4-mini") => (1.10, 4.40),
266        // Google
267        m if m.contains("gemini-2.5-pro") => (1.25, 10.0),
268        m if m.contains("gemini-2.5-flash") => (0.15, 0.6),
269        m if m.contains("gemini-2.0-flash") => (0.10, 0.40),
270        // Bedrock third-party
271        m if m.contains("kimi-k2") => (0.35, 1.40),
272        m if m.contains("deepseek") => (0.80, 2.0),
273        m if m.contains("llama") => (0.50, 1.50),
274        // Amazon Nova
275        m if m.contains("nova-pro") => (0.80, 3.20),
276        m if m.contains("nova-lite") => (0.06, 0.24),
277        m if m.contains("nova-micro") => (0.035, 0.14),
278        // Z.AI GLM
279        m if m.contains("glm-5") => (2.0, 8.0),
280        m if m.contains("glm-4.7-flash") => (0.0, 0.0),
281        m if m.contains("glm-4.7") => (0.50, 2.0),
282        m if m.contains("glm-4") => (0.35, 1.40),
283        _ => return None,
284    };
285    let cost =
286        (prompt_tokens as f64 * input_rate + completion_tokens as f64 * output_rate) / 1_000_000.0;
287    Some(cost)
288}
289
290impl ChatMessage {
291    fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
292        let content = content.into();
293        Self {
294            role: role.into(),
295            timestamp: chrono::Local::now().format("%H:%M").to_string(),
296            message_type: MessageType::Text(content.clone()),
297            content,
298            usage_meta: None,
299            agent_name: None,
300        }
301    }
302
303    fn with_message_type(mut self, message_type: MessageType) -> Self {
304        self.message_type = message_type;
305        self
306    }
307
308    fn with_usage_meta(mut self, meta: UsageMeta) -> Self {
309        self.usage_meta = Some(meta);
310        self
311    }
312
313    fn with_agent_name(mut self, name: impl Into<String>) -> Self {
314        self.agent_name = Some(name.into());
315        self
316    }
317}
318
319impl WorkspaceSnapshot {
320    fn capture(root: &Path, max_entries: usize) -> Self {
321        let mut entries: Vec<WorkspaceEntry> = Vec::new();
322
323        if let Ok(read_dir) = std::fs::read_dir(root) {
324            for entry in read_dir.flatten() {
325                let file_name = entry.file_name().to_string_lossy().to_string();
326                if should_skip_workspace_entry(&file_name) {
327                    continue;
328                }
329
330                let kind = match entry.file_type() {
331                    Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
332                    _ => WorkspaceEntryKind::File,
333                };
334
335                entries.push(WorkspaceEntry {
336                    name: file_name,
337                    kind,
338                });
339            }
340        }
341
342        entries.sort_by(|a, b| match (a.kind, b.kind) {
343            (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
344            (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
345                std::cmp::Ordering::Greater
346            }
347            _ => a
348                .name
349                .to_ascii_lowercase()
350                .cmp(&b.name.to_ascii_lowercase()),
351        });
352        entries.truncate(max_entries);
353
354        Self {
355            root_display: root.to_string_lossy().to_string(),
356            git_branch: detect_git_branch(root),
357            git_dirty_files: detect_git_dirty_files(root),
358            entries,
359            captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
360        }
361    }
362}
363
364fn should_skip_workspace_entry(name: &str) -> bool {
365    matches!(
366        name,
367        ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
368    )
369}
370
371fn detect_git_branch(root: &Path) -> Option<String> {
372    let output = Command::new("git")
373        .arg("-C")
374        .arg(root)
375        .args(["rev-parse", "--abbrev-ref", "HEAD"])
376        .output()
377        .ok()?;
378
379    if !output.status.success() {
380        return None;
381    }
382
383    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
384    if branch.is_empty() {
385        None
386    } else {
387        Some(branch)
388    }
389}
390
391fn detect_git_dirty_files(root: &Path) -> usize {
392    let output = match Command::new("git")
393        .arg("-C")
394        .arg(root)
395        .args(["status", "--porcelain"])
396        .output()
397    {
398        Ok(out) => out,
399        Err(_) => return 0,
400    };
401
402    if !output.status.success() {
403        return 0;
404    }
405
406    String::from_utf8_lossy(&output.stdout)
407        .lines()
408        .filter(|line| !line.trim().is_empty())
409        .count()
410}
411
412impl App {
413    fn new() -> Self {
414        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
415
416        Self {
417            input: String::new(),
418            cursor_position: 0,
419            messages: vec![
420                ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
421                ChatMessage::new(
422                    "assistant",
423                    "Quick start:\n• Type a message to chat with the AI\n• Ctrl+Y - copy latest assistant reply\n• /model - pick a model (or Ctrl+M)\n• /spawn <name> <instructions> - create a sub-agent\n• /agent <name> - focus chat on a spawned sub-agent\n• /agent <name> <message> - send one message to a spawned sub-agent\n• /swarm <task> - parallel execution\n• /ralph [prd.json] - autonomous PRD loop\n• /buslog - protocol bus log (or Ctrl+L)\n• /sessions - pick a session to resume\n• /resume - continue last session\n• Tab - switch agents | ? - help",
424                ),
425            ],
426            current_agent: "build".to_string(),
427            scroll: 0,
428            show_help: false,
429            command_history: Vec::new(),
430            history_index: None,
431            session: None,
432            is_processing: false,
433            processing_message: None,
434            current_tool: None,
435            processing_started_at: None,
436            streaming_text: None,
437            tool_call_count: 0,
438            response_rx: None,
439            provider_registry: None,
440            workspace_dir: workspace_root.clone(),
441            view_mode: ViewMode::Chat,
442            chat_layout: ChatLayoutMode::Webview,
443            show_inspector: true,
444            workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
445            swarm_state: SwarmViewState::new(),
446            swarm_rx: None,
447            ralph_state: RalphViewState::new(),
448            ralph_rx: None,
449            bus_log_state: BusLogState::new(),
450            bus_log_rx: None,
451            bus: None,
452            session_picker_list: Vec::new(),
453            session_picker_selected: 0,
454            session_picker_filter: String::new(),
455            session_picker_confirm_delete: false,
456            model_picker_list: Vec::new(),
457            model_picker_selected: 0,
458            model_picker_filter: String::new(),
459            agent_picker_selected: 0,
460            agent_picker_filter: String::new(),
461            active_model: None,
462            active_spawned_agent: None,
463            spawned_agents: HashMap::new(),
464            agent_response_rxs: Vec::new(),
465            last_max_scroll: 0,
466        }
467    }
468
469    fn refresh_workspace(&mut self) {
470        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
471        self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
472    }
473
474    fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
475        self.session_picker_list = sessions.into_iter().take(16).collect();
476        if self.session_picker_selected >= self.session_picker_list.len() {
477            self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
478        }
479    }
480
481    async fn submit_message(&mut self, config: &Config) {
482        if self.input.is_empty() {
483            return;
484        }
485
486        let mut message = std::mem::take(&mut self.input);
487        self.cursor_position = 0;
488
489        // Save to command history
490        if !message.trim().is_empty() {
491            self.command_history.push(message.clone());
492            self.history_index = None;
493        }
494
495        // Backward-compatible /agent command aliases
496        if message.trim().starts_with("/agent") {
497            let rest = message
498                .trim()
499                .strip_prefix("/agent")
500                .unwrap_or("")
501                .trim();
502
503            if rest.is_empty() {
504                self.open_agent_picker();
505                return;
506            }
507
508            if rest == "pick" || rest == "picker" || rest == "select" {
509                self.open_agent_picker();
510                return;
511            }
512
513            if rest == "main" || rest == "off" {
514                if let Some(target) = self.active_spawned_agent.take() {
515                    self.messages.push(ChatMessage::new(
516                        "system",
517                        format!("Exited focused sub-agent chat (@{target})."),
518                    ));
519                } else {
520                    self.messages.push(ChatMessage::new(
521                        "system",
522                        "Already in main chat mode.",
523                    ));
524                }
525                return;
526            }
527
528            if rest == "build" || rest == "plan" {
529                self.current_agent = rest.to_string();
530                self.active_spawned_agent = None;
531                self.messages.push(ChatMessage::new(
532                    "system",
533                    format!("Switched main agent to '{rest}'. (Tab also works.)"),
534                ));
535                return;
536            }
537
538            if rest == "list" || rest == "ls" {
539                message = "/agents".to_string();
540            } else if let Some(args) = rest
541                .strip_prefix("spawn ")
542                .map(str::trim)
543                .filter(|s| !s.is_empty())
544            {
545                message = format!("/spawn {args}");
546            } else if let Some(name) = rest
547                .strip_prefix("kill ")
548                .map(str::trim)
549                .filter(|s| !s.is_empty())
550            {
551                message = format!("/kill {name}");
552            } else if !rest.contains(' ') {
553                let target = rest.trim_start_matches('@');
554                if self.spawned_agents.contains_key(target) {
555                    self.active_spawned_agent = Some(target.to_string());
556                    self.messages.push(ChatMessage::new(
557                        "system",
558                        format!(
559                            "Focused chat on @{target}. Type messages directly; use /agent main to exit focus."
560                        ),
561                    ));
562                } else {
563                    self.messages.push(ChatMessage::new(
564                        "system",
565                        format!(
566                            "No agent named @{target}. Use /agents to list, or /spawn <name> <instructions> to create one."
567                        ),
568                    ));
569                }
570                return;
571            } else if let Some((name, content)) = rest.split_once(' ') {
572                let target = name.trim().trim_start_matches('@');
573                let content = content.trim();
574                if target.is_empty() || content.is_empty() {
575                    self.messages.push(ChatMessage::new(
576                        "system",
577                        "Usage: /agent <name> <message>",
578                    ));
579                    return;
580                }
581                message = format!("@{target} {content}");
582            } else {
583                self.messages.push(ChatMessage::new(
584                    "system",
585                    "Unknown /agent usage. Try /agent, /agent <name>, /agent <name> <message>, or /agent list.",
586                ));
587                return;
588            }
589        }
590
591        // Check for /swarm command
592        if message.trim().starts_with("/swarm ") {
593            let task = message
594                .trim()
595                .strip_prefix("/swarm ")
596                .unwrap_or("")
597                .to_string();
598            if task.is_empty() {
599                self.messages.push(ChatMessage::new(
600                    "system",
601                    "Usage: /swarm <task description>",
602                ));
603                return;
604            }
605            self.start_swarm_execution(task, config).await;
606            return;
607        }
608
609        // Check for /ralph command
610        if message.trim().starts_with("/ralph") {
611            let prd_path = message
612                .trim()
613                .strip_prefix("/ralph")
614                .map(|s| s.trim())
615                .filter(|s| !s.is_empty())
616                .unwrap_or("prd.json")
617                .to_string();
618            self.start_ralph_execution(prd_path, config).await;
619            return;
620        }
621
622        if message.trim() == "/webview" {
623            self.chat_layout = ChatLayoutMode::Webview;
624            self.messages.push(ChatMessage::new(
625                "system",
626                "Switched to webview layout. Use /classic to return to single-pane chat.",
627            ));
628            return;
629        }
630
631        if message.trim() == "/classic" {
632            self.chat_layout = ChatLayoutMode::Classic;
633            self.messages.push(ChatMessage::new(
634                "system",
635                "Switched to classic layout. Use /webview for dashboard-style panes.",
636            ));
637            return;
638        }
639
640        if message.trim() == "/inspector" {
641            self.show_inspector = !self.show_inspector;
642            let state = if self.show_inspector {
643                "enabled"
644            } else {
645                "disabled"
646            };
647            self.messages.push(ChatMessage::new(
648                "system",
649                format!("Inspector pane {}. Press F3 to toggle quickly.", state),
650            ));
651            return;
652        }
653
654        if message.trim() == "/refresh" {
655            self.refresh_workspace();
656            match list_sessions_with_opencode(&self.workspace_dir).await {
657                Ok(sessions) => self.update_cached_sessions(sessions),
658                Err(err) => self.messages.push(ChatMessage::new(
659                    "system",
660                    format!(
661                        "Workspace refreshed, but failed to refresh sessions: {}",
662                        err
663                    ),
664                )),
665            }
666            self.messages.push(ChatMessage::new(
667                "system",
668                "Workspace and session cache refreshed.",
669            ));
670            return;
671        }
672
673        // Check for /view command to toggle views
674        if message.trim() == "/view" || message.trim() == "/swarm" {
675            self.view_mode = match self.view_mode {
676                ViewMode::Chat
677                | ViewMode::SessionPicker
678                | ViewMode::ModelPicker
679                | ViewMode::AgentPicker
680                | ViewMode::BusLog => ViewMode::Swarm,
681                ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
682            };
683            return;
684        }
685
686        // Check for /buslog command to open protocol bus log
687        if message.trim() == "/buslog" || message.trim() == "/bus" {
688            self.view_mode = ViewMode::BusLog;
689            return;
690        }
691
692        // Check for /spawn command - create a named sub-agent
693        if message.trim().starts_with("/spawn ") {
694            let rest = message.trim().strip_prefix("/spawn ").unwrap_or("").trim();
695            let (name, instructions) = match rest.split_once(' ') {
696                Some((n, i)) => (n.to_string(), i.to_string()),
697                None => {
698                    self.messages.push(ChatMessage::new(
699                        "system",
700                        "Usage: /spawn <name> <instructions>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
701                    ));
702                    return;
703                }
704            };
705
706            if self.spawned_agents.contains_key(&name) {
707                self.messages.push(ChatMessage::new(
708                    "system",
709                    format!("Agent @{name} already exists. Use /kill {name} first."),
710                ));
711                return;
712            }
713
714            match Session::new().await {
715                Ok(mut session) => {
716                    // Use the same model as the main chat
717                    session.metadata.model = self
718                        .active_model
719                        .clone()
720                        .or_else(|| config.default_model.clone());
721                    session.agent = name.clone();
722
723                    // Add system message with the agent's instructions
724                    session.add_message(crate::provider::Message {
725                        role: Role::System,
726                        content: vec![ContentPart::Text {
727                            text: format!(
728                                "You are @{name}, a specialized sub-agent. {instructions}\n\n\
729                                 When you receive a message from another agent (prefixed with their name), \
730                                 respond helpfully. Keep responses concise and focused on your specialty."
731                            ),
732                        }],
733                    });
734
735                    // Announce on bus
736                    if let Some(ref bus) = self.bus {
737                        let handle = bus.handle(&name);
738                        handle.announce_ready(vec![name.clone()]);
739                    }
740
741                    let agent = SpawnedAgent {
742                        name: name.clone(),
743                        instructions: instructions.clone(),
744                        session,
745                        is_processing: false,
746                    };
747                    self.spawned_agents.insert(name.clone(), agent);
748                    self.active_spawned_agent = Some(name.clone());
749                    self.messages.push(ChatMessage::new(
750                        "system",
751                        format!("Spawned agent @{name}: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>."),
752                    ));
753                }
754                Err(e) => {
755                    self.messages.push(ChatMessage::new(
756                        "system",
757                        format!("Failed to spawn agent: {e}"),
758                    ));
759                }
760            }
761            return;
762        }
763
764        // Check for /agents command - list spawned agents
765        if message.trim() == "/agents" {
766            if self.spawned_agents.is_empty() {
767                self.messages.push(ChatMessage::new(
768                    "system",
769                    "No agents spawned. Use /spawn <name> <instructions> to create one.",
770                ));
771            } else {
772                let mut lines = vec!["Active agents:".to_string()];
773                for (name, agent) in &self.spawned_agents {
774                    let status = if agent.is_processing {
775                        "⚡ working"
776                    } else {
777                        "● idle"
778                    };
779                    let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
780                        " [focused]"
781                    } else {
782                        ""
783                    };
784                    lines.push(format!(
785                        "  @{name} [{status}]{focused} — {}",
786                        agent.instructions
787                    ));
788                }
789                self.messages
790                    .push(ChatMessage::new("system", lines.join("\n")));
791                self.messages.push(ChatMessage::new(
792                    "system",
793                    "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
794                ));
795            }
796            return;
797        }
798
799        // Check for /kill command - remove a spawned agent
800        if message.trim().starts_with("/kill ") {
801            let name = message
802                .trim()
803                .strip_prefix("/kill ")
804                .unwrap_or("")
805                .trim()
806                .to_string();
807            if self.spawned_agents.remove(&name).is_some() {
808                // Remove its response channels
809                self.agent_response_rxs.retain(|(n, _)| n != &name);
810                if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
811                    self.active_spawned_agent = None;
812                }
813                // Announce shutdown on bus
814                if let Some(ref bus) = self.bus {
815                    let handle = bus.handle(&name);
816                    handle.send(
817                        "broadcast",
818                        crate::bus::BusMessage::AgentShutdown {
819                            agent_id: name.clone(),
820                        },
821                    );
822                }
823                self.messages.push(ChatMessage::new(
824                    "system",
825                    format!("Agent @{name} removed."),
826                ));
827            } else {
828                self.messages.push(ChatMessage::new(
829                    "system",
830                    format!("No agent named @{name}. Use /agents to list."),
831                ));
832            }
833            return;
834        }
835
836        // Check for @mention - route message to a specific spawned agent
837        if message.trim().starts_with('@') {
838            let trimmed = message.trim();
839            let (target, content) = match trimmed.split_once(' ') {
840                Some((mention, rest)) => (
841                    mention.strip_prefix('@').unwrap_or(mention).to_string(),
842                    rest.to_string(),
843                ),
844                None => {
845                    self.messages.push(ChatMessage::new(
846                        "system",
847                        format!(
848                            "Usage: @agent_name your message\nAvailable: {}",
849                            if self.spawned_agents.is_empty() {
850                                "none (use /spawn first)".to_string()
851                            } else {
852                                self.spawned_agents
853                                    .keys()
854                                    .map(|n| format!("@{n}"))
855                                    .collect::<Vec<_>>()
856                                    .join(", ")
857                            }
858                        ),
859                    ));
860                    return;
861                }
862            };
863
864            if !self.spawned_agents.contains_key(&target) {
865                self.messages.push(ChatMessage::new(
866                    "system",
867                    format!(
868                        "No agent named @{target}. Available: {}",
869                        if self.spawned_agents.is_empty() {
870                            "none (use /spawn first)".to_string()
871                        } else {
872                            self.spawned_agents
873                                .keys()
874                                .map(|n| format!("@{n}"))
875                                .collect::<Vec<_>>()
876                                .join(", ")
877                        }
878                    ),
879                ));
880                return;
881            }
882
883            // Show the user's @mention message in chat
884            self.messages
885                .push(ChatMessage::new("user", format!("@{target} {content}")));
886            self.scroll = SCROLL_BOTTOM;
887
888            // Send the message over the bus
889            if let Some(ref bus) = self.bus {
890                let handle = bus.handle("user");
891                handle.send_to_agent(
892                    &target,
893                    vec![crate::a2a::types::Part::Text {
894                        text: content.clone(),
895                    }],
896                );
897            }
898
899            // Send the message to the target agent's session
900            self.send_to_agent(&target, &content, config).await;
901            return;
902        }
903
904        // If a spawned agent is focused, route plain messages there automatically.
905        if !message.trim().starts_with('/')
906            && let Some(target) = self.active_spawned_agent.clone()
907        {
908            if !self.spawned_agents.contains_key(&target) {
909                self.active_spawned_agent = None;
910                self.messages.push(ChatMessage::new(
911                    "system",
912                    format!(
913                        "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
914                    ),
915                ));
916                return;
917            }
918
919            let content = message.trim().to_string();
920            if content.is_empty() {
921                return;
922            }
923
924            self.messages
925                .push(ChatMessage::new("user", format!("@{target} {content}")));
926            self.scroll = SCROLL_BOTTOM;
927
928            if let Some(ref bus) = self.bus {
929                let handle = bus.handle("user");
930                handle.send_to_agent(
931                    &target,
932                    vec![crate::a2a::types::Part::Text {
933                        text: content.clone(),
934                    }],
935                );
936            }
937
938            self.send_to_agent(&target, &content, config).await;
939            return;
940        }
941
942        // Check for /sessions command - open session picker
943        if message.trim() == "/sessions" {
944            match list_sessions_with_opencode(&self.workspace_dir).await {
945                Ok(sessions) => {
946                    if sessions.is_empty() {
947                        self.messages
948                            .push(ChatMessage::new("system", "No saved sessions found."));
949                    } else {
950                        self.update_cached_sessions(sessions);
951                        self.session_picker_selected = 0;
952                        self.view_mode = ViewMode::SessionPicker;
953                    }
954                }
955                Err(e) => {
956                    self.messages.push(ChatMessage::new(
957                        "system",
958                        format!("Failed to list sessions: {}", e),
959                    ));
960                }
961            }
962            return;
963        }
964
965        // Check for /resume command to load a session
966        if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
967            let session_id = message
968                .trim()
969                .strip_prefix("/resume")
970                .map(|s| s.trim())
971                .filter(|s| !s.is_empty());
972            let loaded = if let Some(id) = session_id {
973                if let Some(oc_id) = id.strip_prefix("opencode_") {
974                    if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
975                        Session::from_opencode(oc_id, &storage).await
976                    } else {
977                        Err(anyhow::anyhow!("OpenCode storage not available"))
978                    }
979                } else {
980                    Session::load(id).await
981                }
982            } else {
983                match Session::last_for_directory(Some(&self.workspace_dir)).await {
984                    Ok(s) => Ok(s),
985                    Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
986                }
987            };
988
989            match loaded {
990                Ok(session) => {
991                    // Convert session messages to chat messages
992                    self.messages.clear();
993                    self.messages.push(ChatMessage::new(
994                        "system",
995                        format!(
996                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
997                            session.title.as_deref().unwrap_or("(untitled)"),
998                            session.created_at.format("%Y-%m-%d %H:%M"),
999                            session.messages.len()
1000                        ),
1001                    ));
1002
1003                    for msg in &session.messages {
1004                        let role_str = match msg.role {
1005                            Role::System => "system",
1006                            Role::User => "user",
1007                            Role::Assistant => "assistant",
1008                            Role::Tool => "tool",
1009                        };
1010
1011                        // Process each content part separately
1012                        for part in &msg.content {
1013                            match part {
1014                                ContentPart::Text { text } => {
1015                                    if !text.is_empty() {
1016                                        self.messages
1017                                            .push(ChatMessage::new(role_str, text.clone()));
1018                                    }
1019                                }
1020                                ContentPart::Image { url, mime_type } => {
1021                                    self.messages.push(
1022                                        ChatMessage::new(role_str, "").with_message_type(
1023                                            MessageType::Image {
1024                                                url: url.clone(),
1025                                                mime_type: mime_type.clone(),
1026                                            },
1027                                        ),
1028                                    );
1029                                }
1030                                ContentPart::ToolCall {
1031                                    name, arguments, ..
1032                                } => {
1033                                    let (preview, truncated) = build_tool_arguments_preview(
1034                                        name,
1035                                        arguments,
1036                                        TOOL_ARGS_PREVIEW_MAX_LINES,
1037                                        TOOL_ARGS_PREVIEW_MAX_BYTES,
1038                                    );
1039                                    self.messages.push(
1040                                        ChatMessage::new(role_str, format!("🔧 {name}"))
1041                                            .with_message_type(MessageType::ToolCall {
1042                                                name: name.clone(),
1043                                                arguments_preview: preview,
1044                                                arguments_len: arguments.len(),
1045                                                truncated,
1046                                            }),
1047                                    );
1048                                }
1049                                ContentPart::ToolResult { content, .. } => {
1050                                    let truncated = truncate_with_ellipsis(content, 500);
1051                                    let (preview, preview_truncated) = build_text_preview(
1052                                        content,
1053                                        TOOL_OUTPUT_PREVIEW_MAX_LINES,
1054                                        TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1055                                    );
1056                                    self.messages.push(
1057                                        ChatMessage::new(
1058                                            role_str,
1059                                            format!("✅ Result\n{truncated}"),
1060                                        )
1061                                        .with_message_type(MessageType::ToolResult {
1062                                            name: "tool".to_string(),
1063                                            output_preview: preview,
1064                                            output_len: content.len(),
1065                                            truncated: preview_truncated,
1066                                        }),
1067                                    );
1068                                }
1069                                ContentPart::File { path, mime_type } => {
1070                                    self.messages.push(
1071                                        ChatMessage::new(role_str, format!("📎 {}", path))
1072                                            .with_message_type(MessageType::File {
1073                                                path: path.clone(),
1074                                                mime_type: mime_type.clone(),
1075                                            }),
1076                                    );
1077                                }
1078                                ContentPart::Thinking { text } => {
1079                                    if !text.is_empty() {
1080                                        self.messages.push(
1081                                            ChatMessage::new(role_str, text.clone())
1082                                                .with_message_type(MessageType::Thinking(
1083                                                    text.clone(),
1084                                                )),
1085                                        );
1086                                    }
1087                                }
1088                            }
1089                        }
1090                    }
1091
1092                    self.current_agent = session.agent.clone();
1093                    self.session = Some(session);
1094                    self.scroll = SCROLL_BOTTOM;
1095                }
1096                Err(e) => {
1097                    self.messages.push(ChatMessage::new(
1098                        "system",
1099                        format!("Failed to load session: {}", e),
1100                    ));
1101                }
1102            }
1103            return;
1104        }
1105
1106        // Check for /model command - open model picker
1107        if message.trim() == "/model" || message.trim().starts_with("/model ") {
1108            let direct_model = message
1109                .trim()
1110                .strip_prefix("/model")
1111                .map(|s| s.trim())
1112                .filter(|s| !s.is_empty());
1113
1114            if let Some(model_str) = direct_model {
1115                // Direct set: /model provider/model-name
1116                self.active_model = Some(model_str.to_string());
1117                if let Some(session) = self.session.as_mut() {
1118                    session.metadata.model = Some(model_str.to_string());
1119                }
1120                self.messages.push(ChatMessage::new(
1121                    "system",
1122                    format!("Model set to: {}", model_str),
1123                ));
1124            } else {
1125                // Open model picker
1126                self.open_model_picker(config).await;
1127            }
1128            return;
1129        }
1130
1131        // Check for /undo command - remove last user turn and response
1132        if message.trim() == "/undo" {
1133            // Remove from TUI messages: walk backwards and remove everything
1134            // until we've removed the last "user" message (inclusive)
1135            let mut found_user = false;
1136            while let Some(msg) = self.messages.last() {
1137                if msg.role == "user" {
1138                    if found_user {
1139                        break; // hit the previous user turn, stop
1140                    }
1141                    found_user = true;
1142                }
1143                // Skip system messages that aren't part of the turn
1144                if msg.role == "system" && !found_user {
1145                    break;
1146                }
1147                self.messages.pop();
1148            }
1149
1150            if !found_user {
1151                self.messages
1152                    .push(ChatMessage::new("system", "Nothing to undo."));
1153                return;
1154            }
1155
1156            // Remove from session: walk backwards removing the last user message
1157            // and all assistant/tool messages after it
1158            if let Some(session) = self.session.as_mut() {
1159                let mut found_session_user = false;
1160                while let Some(msg) = session.messages.last() {
1161                    if msg.role == crate::provider::Role::User {
1162                        if found_session_user {
1163                            break;
1164                        }
1165                        found_session_user = true;
1166                    }
1167                    if msg.role == crate::provider::Role::System && !found_session_user {
1168                        break;
1169                    }
1170                    session.messages.pop();
1171                }
1172                if let Err(e) = session.save().await {
1173                    tracing::warn!(error = %e, "Failed to save session after undo");
1174                }
1175            }
1176
1177            self.messages.push(ChatMessage::new(
1178                "system",
1179                "Undid last message and response.",
1180            ));
1181            self.scroll = SCROLL_BOTTOM;
1182            return;
1183        }
1184
1185        // Check for /new command to start a fresh session
1186        if message.trim() == "/new" {
1187            self.session = None;
1188            self.messages.clear();
1189            self.messages.push(ChatMessage::new(
1190                "system",
1191                "Started a new session. Previous session was saved.",
1192            ));
1193            return;
1194        }
1195
1196        // Add user message
1197        self.messages
1198            .push(ChatMessage::new("user", message.clone()));
1199
1200        // Auto-scroll to bottom when user sends a message
1201        self.scroll = SCROLL_BOTTOM;
1202
1203        let current_agent = self.current_agent.clone();
1204        let model = self
1205            .active_model
1206            .clone()
1207            .or_else(|| {
1208                config
1209                    .agents
1210                    .get(&current_agent)
1211                    .and_then(|agent| agent.model.clone())
1212            })
1213            .or_else(|| config.default_model.clone())
1214            .or_else(|| Some("zai/glm-5".to_string()));
1215
1216        // Initialize session if needed
1217        if self.session.is_none() {
1218            match Session::new().await {
1219                Ok(session) => {
1220                    self.session = Some(session);
1221                }
1222                Err(err) => {
1223                    tracing::error!(error = %err, "Failed to create session");
1224                    self.messages
1225                        .push(ChatMessage::new("assistant", format!("Error: {err}")));
1226                    return;
1227                }
1228            }
1229        }
1230
1231        let session = match self.session.as_mut() {
1232            Some(session) => session,
1233            None => {
1234                self.messages.push(ChatMessage::new(
1235                    "assistant",
1236                    "Error: session not initialized",
1237                ));
1238                return;
1239            }
1240        };
1241
1242        if let Some(model) = model {
1243            session.metadata.model = Some(model);
1244        }
1245
1246        session.agent = current_agent;
1247
1248        // Set processing state
1249        self.is_processing = true;
1250        self.processing_message = Some("Thinking...".to_string());
1251        self.current_tool = None;
1252        self.processing_started_at = Some(Instant::now());
1253        self.streaming_text = None;
1254
1255        // Load provider registry once and cache it
1256        if self.provider_registry.is_none() {
1257            match crate::provider::ProviderRegistry::from_vault().await {
1258                Ok(registry) => {
1259                    self.provider_registry = Some(std::sync::Arc::new(registry));
1260                }
1261                Err(err) => {
1262                    tracing::error!(error = %err, "Failed to load provider registry");
1263                    self.messages.push(ChatMessage::new(
1264                        "assistant",
1265                        format!("Error loading providers: {err}"),
1266                    ));
1267                    self.is_processing = false;
1268                    return;
1269                }
1270            }
1271        }
1272        let registry = self.provider_registry.clone().unwrap();
1273
1274        // Create channel for async communication
1275        let (tx, rx) = mpsc::channel(100);
1276        self.response_rx = Some(rx);
1277
1278        // Clone session for async processing
1279        let session_clone = session.clone();
1280        let message_clone = message.clone();
1281
1282        // Spawn async task to process the message with event streaming
1283        tokio::spawn(async move {
1284            let mut session = session_clone;
1285            if let Err(err) = session
1286                .prompt_with_events(&message_clone, tx.clone(), registry)
1287                .await
1288            {
1289                tracing::error!(error = %err, "Agent processing failed");
1290                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
1291                let _ = tx.send(SessionEvent::Done).await;
1292            }
1293        });
1294    }
1295
1296    fn handle_response(&mut self, event: SessionEvent) {
1297        // Auto-scroll to bottom when new content arrives
1298        self.scroll = SCROLL_BOTTOM;
1299
1300        match event {
1301            SessionEvent::Thinking => {
1302                self.processing_message = Some("Thinking...".to_string());
1303                self.current_tool = None;
1304                if self.processing_started_at.is_none() {
1305                    self.processing_started_at = Some(Instant::now());
1306                }
1307            }
1308            SessionEvent::ToolCallStart { name, arguments } => {
1309                // Flush any streaming text before showing tool call
1310                if let Some(text) = self.streaming_text.take() {
1311                    if !text.is_empty() {
1312                        self.messages.push(ChatMessage::new("assistant", text));
1313                    }
1314                }
1315                self.processing_message = Some(format!("Running {}...", name));
1316                self.current_tool = Some(name.clone());
1317                self.tool_call_count += 1;
1318
1319                let (preview, truncated) = build_tool_arguments_preview(
1320                    &name,
1321                    &arguments,
1322                    TOOL_ARGS_PREVIEW_MAX_LINES,
1323                    TOOL_ARGS_PREVIEW_MAX_BYTES,
1324                );
1325                self.messages.push(
1326                    ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
1327                        MessageType::ToolCall {
1328                            name,
1329                            arguments_preview: preview,
1330                            arguments_len: arguments.len(),
1331                            truncated,
1332                        },
1333                    ),
1334                );
1335            }
1336            SessionEvent::ToolCallComplete {
1337                name,
1338                output,
1339                success,
1340            } => {
1341                let icon = if success { "✓" } else { "✗" };
1342
1343                let (preview, truncated) = build_text_preview(
1344                    &output,
1345                    TOOL_OUTPUT_PREVIEW_MAX_LINES,
1346                    TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1347                );
1348                self.messages.push(
1349                    ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
1350                        MessageType::ToolResult {
1351                            name,
1352                            output_preview: preview,
1353                            output_len: output.len(),
1354                            truncated,
1355                        },
1356                    ),
1357                );
1358                self.current_tool = None;
1359                self.processing_message = Some("Thinking...".to_string());
1360            }
1361            SessionEvent::TextChunk(text) => {
1362                // Show streaming text as it arrives (before TextComplete finalizes)
1363                self.streaming_text = Some(text);
1364            }
1365            SessionEvent::ThinkingComplete(text) => {
1366                if !text.is_empty() {
1367                    self.messages.push(
1368                        ChatMessage::new("assistant", &text)
1369                            .with_message_type(MessageType::Thinking(text)),
1370                    );
1371                }
1372            }
1373            SessionEvent::TextComplete(text) => {
1374                // Clear streaming preview and add the final message
1375                self.streaming_text = None;
1376                if !text.is_empty() {
1377                    self.messages.push(ChatMessage::new("assistant", text));
1378                }
1379            }
1380            SessionEvent::UsageReport {
1381                prompt_tokens,
1382                completion_tokens,
1383                duration_ms,
1384                model,
1385            } => {
1386                let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
1387                let meta = UsageMeta {
1388                    prompt_tokens,
1389                    completion_tokens,
1390                    duration_ms,
1391                    cost_usd,
1392                };
1393                // Attach to the most recent assistant message
1394                if let Some(msg) = self
1395                    .messages
1396                    .iter_mut()
1397                    .rev()
1398                    .find(|m| m.role == "assistant")
1399                {
1400                    msg.usage_meta = Some(meta);
1401                }
1402            }
1403            SessionEvent::SessionSync(session) => {
1404                // Sync the updated session (with full conversation history) back
1405                // so subsequent messages include prior context.
1406                self.session = Some(session);
1407            }
1408            SessionEvent::Error(err) => {
1409                self.messages
1410                    .push(ChatMessage::new("assistant", format!("Error: {}", err)));
1411            }
1412            SessionEvent::Done => {
1413                self.is_processing = false;
1414                self.processing_message = None;
1415                self.current_tool = None;
1416                self.processing_started_at = None;
1417                self.streaming_text = None;
1418                self.response_rx = None;
1419            }
1420        }
1421    }
1422
1423    /// Send a message to a specific spawned agent
1424    async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
1425        // Load provider registry if needed
1426        if self.provider_registry.is_none() {
1427            match crate::provider::ProviderRegistry::from_vault().await {
1428                Ok(registry) => {
1429                    self.provider_registry = Some(std::sync::Arc::new(registry));
1430                }
1431                Err(err) => {
1432                    self.messages.push(ChatMessage::new(
1433                        "system",
1434                        format!("Error loading providers: {err}"),
1435                    ));
1436                    return;
1437                }
1438            }
1439        }
1440        let registry = self.provider_registry.clone().unwrap();
1441
1442        let agent = match self.spawned_agents.get_mut(agent_name) {
1443            Some(a) => a,
1444            None => return,
1445        };
1446
1447        agent.is_processing = true;
1448        let session_clone = agent.session.clone();
1449        let msg_clone = message.to_string();
1450        let agent_name_owned = agent_name.to_string();
1451        let bus_arc = self.bus.clone();
1452
1453        let (tx, rx) = mpsc::channel(100);
1454        self.agent_response_rxs.push((agent_name.to_string(), rx));
1455
1456        tokio::spawn(async move {
1457            let mut session = session_clone;
1458            if let Err(err) = session
1459                .prompt_with_events(&msg_clone, tx.clone(), registry)
1460                .await
1461            {
1462                tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
1463                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
1464                let _ = tx.send(SessionEvent::Done).await;
1465            }
1466
1467            // Send the agent's response over the bus
1468            if let Some(ref bus) = bus_arc {
1469                let handle = bus.handle(&agent_name_owned);
1470                handle.send(
1471                    format!("agent.{agent_name_owned}.events"),
1472                    crate::bus::BusMessage::AgentMessage {
1473                        from: agent_name_owned.clone(),
1474                        to: "user".to_string(),
1475                        parts: vec![crate::a2a::types::Part::Text {
1476                            text: "(response complete)".to_string(),
1477                        }],
1478                    },
1479                );
1480            }
1481        });
1482    }
1483
1484    /// Handle an event from a spawned agent
1485    fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
1486        self.scroll = SCROLL_BOTTOM;
1487
1488        match event {
1489            SessionEvent::Thinking => {
1490                // Show thinking indicator for this agent
1491                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
1492                    agent.is_processing = true;
1493                }
1494            }
1495            SessionEvent::ToolCallStart { name, arguments } => {
1496                let (preview, truncated) = build_tool_arguments_preview(
1497                    &name,
1498                    &arguments,
1499                    TOOL_ARGS_PREVIEW_MAX_LINES,
1500                    TOOL_ARGS_PREVIEW_MAX_BYTES,
1501                );
1502                self.messages.push(
1503                    ChatMessage::new("tool", format!("🔧 @{agent_name} → {name}"))
1504                        .with_message_type(MessageType::ToolCall {
1505                            name,
1506                            arguments_preview: preview,
1507                            arguments_len: arguments.len(),
1508                            truncated,
1509                        })
1510                        .with_agent_name(agent_name),
1511                );
1512            }
1513            SessionEvent::ToolCallComplete {
1514                name,
1515                output,
1516                success,
1517            } => {
1518                let icon = if success { "✓" } else { "✗" };
1519                let (preview, truncated) = build_text_preview(
1520                    &output,
1521                    TOOL_OUTPUT_PREVIEW_MAX_LINES,
1522                    TOOL_OUTPUT_PREVIEW_MAX_BYTES,
1523                );
1524                self.messages.push(
1525                    ChatMessage::new("tool", format!("{icon} @{agent_name} → {name}"))
1526                        .with_message_type(MessageType::ToolResult {
1527                            name,
1528                            output_preview: preview,
1529                            output_len: output.len(),
1530                            truncated,
1531                        })
1532                        .with_agent_name(agent_name),
1533                );
1534            }
1535            SessionEvent::TextChunk(_text) => {
1536                // For spawned agents, we could show streaming but for now just wait for complete
1537            }
1538            SessionEvent::ThinkingComplete(text) => {
1539                if !text.is_empty() {
1540                    self.messages.push(
1541                        ChatMessage::new("assistant", &text)
1542                            .with_message_type(MessageType::Thinking(text))
1543                            .with_agent_name(agent_name),
1544                    );
1545                }
1546            }
1547            SessionEvent::TextComplete(text) => {
1548                if !text.is_empty() {
1549                    self.messages
1550                        .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
1551                }
1552            }
1553            SessionEvent::UsageReport {
1554                prompt_tokens,
1555                completion_tokens,
1556                duration_ms,
1557                model,
1558            } => {
1559                let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
1560                let meta = UsageMeta {
1561                    prompt_tokens,
1562                    completion_tokens,
1563                    duration_ms,
1564                    cost_usd,
1565                };
1566                if let Some(msg) =
1567                    self.messages.iter_mut().rev().find(|m| {
1568                        m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
1569                    })
1570                {
1571                    msg.usage_meta = Some(meta);
1572                }
1573            }
1574            SessionEvent::SessionSync(session) => {
1575                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
1576                    agent.session = session;
1577                }
1578            }
1579            SessionEvent::Error(err) => {
1580                self.messages.push(
1581                    ChatMessage::new("assistant", format!("Error: {err}"))
1582                        .with_agent_name(agent_name),
1583                );
1584            }
1585            SessionEvent::Done => {
1586                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
1587                    agent.is_processing = false;
1588                }
1589            }
1590        }
1591    }
1592
1593    /// Handle a swarm event
1594    fn handle_swarm_event(&mut self, event: SwarmEvent) {
1595        self.swarm_state.handle_event(event.clone());
1596
1597        // When swarm completes, switch back to chat view with summary
1598        if let SwarmEvent::Complete { success, ref stats } = event {
1599            self.view_mode = ViewMode::Chat;
1600            let summary = if success {
1601                format!(
1602                    "Swarm completed successfully.\n\
1603                     Subtasks: {} completed, {} failed\n\
1604                     Total tool calls: {}\n\
1605                     Time: {:.1}s (speedup: {:.1}x)",
1606                    stats.subagents_completed,
1607                    stats.subagents_failed,
1608                    stats.total_tool_calls,
1609                    stats.execution_time_ms as f64 / 1000.0,
1610                    stats.speedup_factor
1611                )
1612            } else {
1613                format!(
1614                    "Swarm completed with failures.\n\
1615                     Subtasks: {} completed, {} failed\n\
1616                     Check the subtask results for details.",
1617                    stats.subagents_completed, stats.subagents_failed
1618                )
1619            };
1620            self.messages.push(ChatMessage::new("system", &summary));
1621            self.swarm_rx = None;
1622        }
1623
1624        if let SwarmEvent::Error(ref err) = event {
1625            self.messages
1626                .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
1627        }
1628    }
1629
1630    /// Handle a Ralph event
1631    fn handle_ralph_event(&mut self, event: RalphEvent) {
1632        self.ralph_state.handle_event(event.clone());
1633
1634        // When Ralph completes, switch back to chat view with summary
1635        if let RalphEvent::Complete {
1636            ref status,
1637            passed,
1638            total,
1639        } = event
1640        {
1641            self.view_mode = ViewMode::Chat;
1642            let summary = format!(
1643                "Ralph loop finished: {}\n\
1644                 Stories: {}/{} passed",
1645                status, passed, total
1646            );
1647            self.messages.push(ChatMessage::new("system", &summary));
1648            self.ralph_rx = None;
1649        }
1650
1651        if let RalphEvent::Error(ref err) = event {
1652            self.messages
1653                .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
1654        }
1655    }
1656
1657    /// Start Ralph execution for a PRD
1658    async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
1659        // Add user message
1660        self.messages
1661            .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
1662
1663        // Get model from config
1664        let model = self
1665            .active_model
1666            .clone()
1667            .or_else(|| config.default_model.clone())
1668            .or_else(|| Some("zai/glm-5".to_string()));
1669
1670        let model = match model {
1671            Some(m) => m,
1672            None => {
1673                self.messages.push(ChatMessage::new(
1674                    "system",
1675                    "No model configured. Use /model to select one first.",
1676                ));
1677                return;
1678            }
1679        };
1680
1681        // Check PRD exists
1682        let prd_file = std::path::PathBuf::from(&prd_path);
1683        if !prd_file.exists() {
1684            self.messages.push(ChatMessage::new(
1685                "system",
1686                format!("PRD file not found: {}", prd_path),
1687            ));
1688            return;
1689        }
1690
1691        // Create channel for ralph events
1692        let (tx, rx) = mpsc::channel(200);
1693        self.ralph_rx = Some(rx);
1694
1695        // Switch to Ralph view
1696        self.view_mode = ViewMode::Ralph;
1697        self.ralph_state = RalphViewState::new();
1698
1699        // Build Ralph config
1700        let ralph_config = RalphConfig {
1701            prd_path: prd_path.clone(),
1702            max_iterations: 10,
1703            progress_path: "progress.txt".to_string(),
1704            quality_checks_enabled: true,
1705            auto_commit: true,
1706            model: Some(model.clone()),
1707            use_rlm: false,
1708            parallel_enabled: true,
1709            max_concurrent_stories: 3,
1710            worktree_enabled: true,
1711            story_timeout_secs: 300,
1712            conflict_timeout_secs: 120,
1713        };
1714
1715        // Parse provider/model from the model string
1716        let (provider_name, model_name) = if let Some(pos) = model.find('/') {
1717            (model[..pos].to_string(), model[pos + 1..].to_string())
1718        } else {
1719            (model.clone(), model.clone())
1720        };
1721
1722        let prd_path_clone = prd_path.clone();
1723        let tx_clone = tx.clone();
1724
1725        // Spawn Ralph execution
1726        tokio::spawn(async move {
1727            // Get provider from registry
1728            let provider = match crate::provider::ProviderRegistry::from_vault().await {
1729                Ok(registry) => match registry.get(&provider_name) {
1730                    Some(p) => p,
1731                    None => {
1732                        let _ = tx_clone
1733                            .send(RalphEvent::Error(format!(
1734                                "Provider '{}' not found",
1735                                provider_name
1736                            )))
1737                            .await;
1738                        return;
1739                    }
1740                },
1741                Err(e) => {
1742                    let _ = tx_clone
1743                        .send(RalphEvent::Error(format!(
1744                            "Failed to load providers: {}",
1745                            e
1746                        )))
1747                        .await;
1748                    return;
1749                }
1750            };
1751
1752            let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
1753            match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
1754                Ok(ralph) => {
1755                    let mut ralph = ralph.with_event_tx(tx_clone.clone());
1756                    match ralph.run().await {
1757                        Ok(_state) => {
1758                            // Complete event already emitted by run()
1759                        }
1760                        Err(e) => {
1761                            let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
1762                        }
1763                    }
1764                }
1765                Err(e) => {
1766                    let _ = tx_clone
1767                        .send(RalphEvent::Error(format!(
1768                            "Failed to initialize Ralph: {}",
1769                            e
1770                        )))
1771                        .await;
1772                }
1773            }
1774        });
1775
1776        self.messages.push(ChatMessage::new(
1777            "system",
1778            format!("Starting Ralph loop with PRD: {}", prd_path),
1779        ));
1780    }
1781
1782    /// Start swarm execution for a task
1783    async fn start_swarm_execution(&mut self, task: String, config: &Config) {
1784        // Add user message
1785        self.messages
1786            .push(ChatMessage::new("user", format!("/swarm {}", task)));
1787
1788        // Get model from config
1789        let model = config
1790            .default_model
1791            .clone()
1792            .or_else(|| Some("zai/glm-5".to_string()));
1793
1794        // Configure swarm
1795        let swarm_config = SwarmConfig {
1796            model,
1797            max_subagents: 10,
1798            max_steps_per_subagent: 50,
1799            worktree_enabled: true,
1800            worktree_auto_merge: true,
1801            working_dir: Some(
1802                std::env::current_dir()
1803                    .map(|p| p.to_string_lossy().to_string())
1804                    .unwrap_or_else(|_| ".".to_string()),
1805            ),
1806            ..Default::default()
1807        };
1808
1809        // Create channel for swarm events
1810        let (tx, rx) = mpsc::channel(100);
1811        self.swarm_rx = Some(rx);
1812
1813        // Switch to swarm view
1814        self.view_mode = ViewMode::Swarm;
1815        self.swarm_state = SwarmViewState::new();
1816
1817        // Send initial event
1818        let _ = tx
1819            .send(SwarmEvent::Started {
1820                task: task.clone(),
1821                total_subtasks: 0,
1822            })
1823            .await;
1824
1825        // Spawn swarm execution — executor emits all events via event_tx
1826        let task_clone = task;
1827        let bus_arc = self.bus.clone();
1828        tokio::spawn(async move {
1829            // Create executor with event channel — it handles decomposition + execution
1830            let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
1831            if let Some(bus) = bus_arc {
1832                executor = executor.with_bus(bus);
1833            }
1834            let result = executor
1835                .execute(&task_clone, DecompositionStrategy::Automatic)
1836                .await;
1837
1838            match result {
1839                Ok(swarm_result) => {
1840                    let _ = tx
1841                        .send(SwarmEvent::Complete {
1842                            success: swarm_result.success,
1843                            stats: swarm_result.stats,
1844                        })
1845                        .await;
1846                }
1847                Err(e) => {
1848                    let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
1849                }
1850            }
1851        });
1852    }
1853
1854    /// Populate and open the model picker overlay
1855    async fn open_model_picker(&mut self, config: &Config) {
1856        let mut models: Vec<(String, String, String)> = Vec::new();
1857
1858        // Try to build provider registry and list models
1859        match crate::provider::ProviderRegistry::from_vault().await {
1860            Ok(registry) => {
1861                for provider_name in registry.list() {
1862                    if let Some(provider) = registry.get(provider_name) {
1863                        match provider.list_models().await {
1864                            Ok(model_list) => {
1865                                for m in model_list {
1866                                    let label = format!("{}/{}", provider_name, m.id);
1867                                    let value = format!("{}/{}", provider_name, m.id);
1868                                    let name = m.name.clone();
1869                                    models.push((label, value, name));
1870                                }
1871                            }
1872                            Err(e) => {
1873                                tracing::warn!(
1874                                    "Failed to list models for {}: {}",
1875                                    provider_name,
1876                                    e
1877                                );
1878                            }
1879                        }
1880                    }
1881                }
1882            }
1883            Err(e) => {
1884                tracing::warn!("Failed to load provider registry: {}", e);
1885            }
1886        }
1887
1888        // Fallback: also try from config
1889        if models.is_empty() {
1890            if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
1891                for provider_name in registry.list() {
1892                    if let Some(provider) = registry.get(provider_name) {
1893                        if let Ok(model_list) = provider.list_models().await {
1894                            for m in model_list {
1895                                let label = format!("{}/{}", provider_name, m.id);
1896                                let value = format!("{}/{}", provider_name, m.id);
1897                                let name = m.name.clone();
1898                                models.push((label, value, name));
1899                            }
1900                        }
1901                    }
1902                }
1903            }
1904        }
1905
1906        if models.is_empty() {
1907            self.messages.push(ChatMessage::new(
1908                "system",
1909                "No models found. Check provider configuration (Vault or config).",
1910            ));
1911        } else {
1912            // Sort models by provider then name
1913            models.sort_by(|a, b| a.0.cmp(&b.0));
1914            self.model_picker_list = models;
1915            self.model_picker_selected = 0;
1916            self.model_picker_filter.clear();
1917            self.view_mode = ViewMode::ModelPicker;
1918        }
1919    }
1920
1921    /// Get filtered session list for the session picker
1922    fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
1923        if self.session_picker_filter.is_empty() {
1924            self.session_picker_list.iter().enumerate().collect()
1925        } else {
1926            let filter = self.session_picker_filter.to_lowercase();
1927            self.session_picker_list
1928                .iter()
1929                .enumerate()
1930                .filter(|(_, s)| {
1931                    s.title
1932                        .as_deref()
1933                        .unwrap_or("")
1934                        .to_lowercase()
1935                        .contains(&filter)
1936                        || s.agent.to_lowercase().contains(&filter)
1937                        || s.id.to_lowercase().contains(&filter)
1938                })
1939                .collect()
1940        }
1941    }
1942
1943    /// Get filtered model list
1944    fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
1945        if self.model_picker_filter.is_empty() {
1946            self.model_picker_list.iter().enumerate().collect()
1947        } else {
1948            let filter = self.model_picker_filter.to_lowercase();
1949            self.model_picker_list
1950                .iter()
1951                .enumerate()
1952                .filter(|(_, (label, _, name))| {
1953                    label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
1954                })
1955                .collect()
1956        }
1957    }
1958
1959    /// Get filtered spawned agents list (sorted by name)
1960    fn filtered_spawned_agents(&self) -> Vec<(String, String, bool)> {
1961        let mut agents: Vec<(String, String, bool)> = self
1962            .spawned_agents
1963            .iter()
1964            .map(|(name, agent)| {
1965                (
1966                    name.clone(),
1967                    agent.instructions.clone(),
1968                    agent.is_processing,
1969                )
1970            })
1971            .collect();
1972
1973        agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
1974
1975        if self.agent_picker_filter.is_empty() {
1976            agents
1977        } else {
1978            let filter = self.agent_picker_filter.to_lowercase();
1979            agents
1980                .into_iter()
1981                .filter(|(name, instructions, _)| {
1982                    name.to_lowercase().contains(&filter)
1983                        || instructions.to_lowercase().contains(&filter)
1984                })
1985                .collect()
1986        }
1987    }
1988
1989    /// Open picker for choosing a spawned sub-agent to focus
1990    fn open_agent_picker(&mut self) {
1991        if self.spawned_agents.is_empty() {
1992            self.messages.push(ChatMessage::new(
1993                "system",
1994                "No agents spawned yet. Use /spawn <name> <instructions> first.",
1995            ));
1996            return;
1997        }
1998
1999        self.agent_picker_filter.clear();
2000        let filtered = self.filtered_spawned_agents();
2001        self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
2002            filtered
2003                .iter()
2004                .position(|(name, _, _)| name == active)
2005                .unwrap_or(0)
2006        } else {
2007            0
2008        };
2009        self.view_mode = ViewMode::AgentPicker;
2010    }
2011
2012    fn navigate_history(&mut self, direction: isize) {
2013        if self.command_history.is_empty() {
2014            return;
2015        }
2016
2017        let history_len = self.command_history.len();
2018        let new_index = match self.history_index {
2019            Some(current) => {
2020                let new = current as isize + direction;
2021                if new < 0 {
2022                    None
2023                } else if new >= history_len as isize {
2024                    Some(history_len - 1)
2025                } else {
2026                    Some(new as usize)
2027                }
2028            }
2029            None => {
2030                if direction > 0 {
2031                    Some(0)
2032                } else {
2033                    Some(history_len.saturating_sub(1))
2034                }
2035            }
2036        };
2037
2038        self.history_index = new_index;
2039        if let Some(index) = new_index {
2040            self.input = self.command_history[index].clone();
2041            self.cursor_position = self.input.len();
2042        } else {
2043            self.input.clear();
2044            self.cursor_position = 0;
2045        }
2046    }
2047
2048    fn search_history(&mut self) {
2049        // Enhanced search: find commands matching current input prefix
2050        if self.command_history.is_empty() {
2051            return;
2052        }
2053
2054        let search_term = self.input.trim().to_lowercase();
2055
2056        if search_term.is_empty() {
2057            // Empty search - show most recent
2058            if !self.command_history.is_empty() {
2059                self.input = self.command_history.last().unwrap().clone();
2060                self.cursor_position = self.input.len();
2061                self.history_index = Some(self.command_history.len() - 1);
2062            }
2063            return;
2064        }
2065
2066        // Find the most recent command that starts with the search term
2067        for (index, cmd) in self.command_history.iter().enumerate().rev() {
2068            if cmd.to_lowercase().starts_with(&search_term) {
2069                self.input = cmd.clone();
2070                self.cursor_position = self.input.len();
2071                self.history_index = Some(index);
2072                return;
2073            }
2074        }
2075
2076        // If no prefix match, search for contains
2077        for (index, cmd) in self.command_history.iter().enumerate().rev() {
2078            if cmd.to_lowercase().contains(&search_term) {
2079                self.input = cmd.clone();
2080                self.cursor_position = self.input.len();
2081                self.history_index = Some(index);
2082                return;
2083            }
2084        }
2085    }
2086}
2087
2088async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
2089    let mut app = App::new();
2090    if let Ok(sessions) = list_sessions_with_opencode(&app.workspace_dir).await {
2091        app.update_cached_sessions(sessions);
2092    }
2093
2094    // Create agent bus and subscribe the TUI as an observer
2095    let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
2096    let mut bus_handle = bus.handle("tui-observer");
2097    let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
2098    app.bus_log_rx = Some(bus_rx);
2099    app.bus = Some(bus.clone());
2100
2101    // Spawn a forwarder task: bus broadcast → mpsc channel for the TUI event loop
2102    tokio::spawn(async move {
2103        loop {
2104            match bus_handle.recv().await {
2105                Some(env) => {
2106                    if bus_tx.send(env).await.is_err() {
2107                        break; // TUI closed
2108                    }
2109                }
2110                None => break, // bus closed
2111            }
2112        }
2113    });
2114
2115    // Load configuration and theme
2116    let mut config = Config::load().await?;
2117    let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
2118
2119    // Track last config modification time for hot-reloading
2120    let _config_paths = vec![
2121        std::path::PathBuf::from("./codetether.toml"),
2122        std::path::PathBuf::from("./.codetether/config.toml"),
2123    ];
2124
2125    let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
2126        .map(|dirs| dirs.config_dir().join("config.toml"));
2127
2128    let mut last_check = Instant::now();
2129    let mut event_stream = EventStream::new();
2130
2131    // Background session refresh — fires every 5s, sends results via channel
2132    let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
2133    {
2134        let workspace_dir = app.workspace_dir.clone();
2135        tokio::spawn(async move {
2136            let mut interval = tokio::time::interval(Duration::from_secs(5));
2137            loop {
2138                interval.tick().await;
2139                if let Ok(sessions) = list_sessions_with_opencode(&workspace_dir).await {
2140                    if session_tx.send(sessions).await.is_err() {
2141                        break; // TUI closed
2142                    }
2143                }
2144            }
2145        });
2146    }
2147
2148    loop {
2149        // --- Periodic background work (non-blocking) ---
2150
2151        // Receive session list updates from background task
2152        if let Ok(sessions) = session_rx.try_recv() {
2153            app.update_cached_sessions(sessions);
2154        }
2155
2156        // Check for theme changes if hot-reload is enabled
2157        if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
2158            if let Ok(new_config) = Config::load().await {
2159                if new_config.ui.theme != config.ui.theme
2160                    || new_config.ui.custom_theme != config.ui.custom_theme
2161                {
2162                    theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
2163                    config = new_config;
2164                }
2165            }
2166            last_check = Instant::now();
2167        }
2168
2169        terminal.draw(|f| ui(f, &mut app, &theme))?;
2170
2171        // Update max_scroll estimate for scroll key handlers
2172        // This needs to roughly match what ui() calculates
2173        let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
2174        let estimated_lines = app.messages.len() * 4; // rough estimate
2175        app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
2176
2177        // Drain all pending async responses
2178        if let Some(mut rx) = app.response_rx.take() {
2179            while let Ok(response) = rx.try_recv() {
2180                app.handle_response(response);
2181            }
2182            app.response_rx = Some(rx);
2183        }
2184
2185        // Drain all pending swarm events
2186        if let Some(mut rx) = app.swarm_rx.take() {
2187            while let Ok(event) = rx.try_recv() {
2188                app.handle_swarm_event(event);
2189            }
2190            app.swarm_rx = Some(rx);
2191        }
2192
2193        // Drain all pending ralph events
2194        if let Some(mut rx) = app.ralph_rx.take() {
2195            while let Ok(event) = rx.try_recv() {
2196                app.handle_ralph_event(event);
2197            }
2198            app.ralph_rx = Some(rx);
2199        }
2200
2201        // Drain all pending bus log events
2202        if let Some(mut rx) = app.bus_log_rx.take() {
2203            while let Ok(env) = rx.try_recv() {
2204                app.bus_log_state.ingest(&env);
2205            }
2206            app.bus_log_rx = Some(rx);
2207        }
2208
2209        // Drain all pending spawned-agent responses
2210        {
2211            let mut i = 0;
2212            while i < app.agent_response_rxs.len() {
2213                let mut done = false;
2214                while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
2215                    if matches!(event, SessionEvent::Done) {
2216                        done = true;
2217                    }
2218                    let name = app.agent_response_rxs[i].0.clone();
2219                    app.handle_agent_response(&name, event);
2220                }
2221                if done {
2222                    app.agent_response_rxs.swap_remove(i);
2223                } else {
2224                    i += 1;
2225                }
2226            }
2227        }
2228
2229        // Wait for terminal events asynchronously (no blocking!)
2230        let ev = tokio::select! {
2231            maybe_event = event_stream.next() => {
2232                match maybe_event {
2233                    Some(Ok(ev)) => ev,
2234                    Some(Err(_)) => continue,
2235                    None => return Ok(()), // stream ended
2236                }
2237            }
2238            // Tick at 50ms to keep rendering responsive during streaming
2239            _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
2240        };
2241
2242        // Handle bracketed paste: insert entire clipboard text at cursor without submitting
2243        if let Event::Paste(text) = &ev {
2244            // Ensure cursor is at a valid char boundary before inserting
2245            let mut pos = app.cursor_position;
2246            while pos > 0 && !app.input.is_char_boundary(pos) {
2247                pos -= 1;
2248            }
2249            app.cursor_position = pos;
2250
2251            for c in text.chars() {
2252                if c == '\n' || c == '\r' {
2253                    // Replace newlines with spaces to keep paste as single message
2254                    app.input.insert(app.cursor_position, ' ');
2255                } else {
2256                    app.input.insert(app.cursor_position, c);
2257                }
2258                app.cursor_position += c.len_utf8();
2259            }
2260            continue;
2261        }
2262
2263        if let Event::Key(key) = ev {
2264            // Help overlay
2265            if app.show_help {
2266                if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
2267                    app.show_help = false;
2268                }
2269                continue;
2270            }
2271
2272            // Model picker overlay
2273            if app.view_mode == ViewMode::ModelPicker {
2274                match key.code {
2275                    KeyCode::Esc => {
2276                        app.view_mode = ViewMode::Chat;
2277                    }
2278                    KeyCode::Up | KeyCode::Char('k')
2279                        if !key.modifiers.contains(KeyModifiers::ALT) =>
2280                    {
2281                        if app.model_picker_selected > 0 {
2282                            app.model_picker_selected -= 1;
2283                        }
2284                    }
2285                    KeyCode::Down | KeyCode::Char('j')
2286                        if !key.modifiers.contains(KeyModifiers::ALT) =>
2287                    {
2288                        let filtered = app.filtered_models();
2289                        if app.model_picker_selected < filtered.len().saturating_sub(1) {
2290                            app.model_picker_selected += 1;
2291                        }
2292                    }
2293                    KeyCode::Enter => {
2294                        let filtered = app.filtered_models();
2295                        if let Some((_, (label, value, _name))) =
2296                            filtered.get(app.model_picker_selected)
2297                        {
2298                            let label = label.clone();
2299                            let value = value.clone();
2300                            app.active_model = Some(value.clone());
2301                            if let Some(session) = app.session.as_mut() {
2302                                session.metadata.model = Some(value.clone());
2303                            }
2304                            app.messages.push(ChatMessage::new(
2305                                "system",
2306                                format!("Model set to: {}", label),
2307                            ));
2308                            app.view_mode = ViewMode::Chat;
2309                        }
2310                    }
2311                    KeyCode::Backspace => {
2312                        app.model_picker_filter.pop();
2313                        app.model_picker_selected = 0;
2314                    }
2315                    KeyCode::Char(c)
2316                        if !key.modifiers.contains(KeyModifiers::CONTROL)
2317                            && !key.modifiers.contains(KeyModifiers::ALT) =>
2318                    {
2319                        app.model_picker_filter.push(c);
2320                        app.model_picker_selected = 0;
2321                    }
2322                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2323                        return Ok(());
2324                    }
2325                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2326                        return Ok(());
2327                    }
2328                    _ => {}
2329                }
2330                continue;
2331            }
2332
2333            // Session picker overlay - handle specially
2334            if app.view_mode == ViewMode::SessionPicker {
2335                match key.code {
2336                    KeyCode::Esc => {
2337                        if app.session_picker_confirm_delete {
2338                            app.session_picker_confirm_delete = false;
2339                        } else {
2340                            app.session_picker_filter.clear();
2341                            app.view_mode = ViewMode::Chat;
2342                        }
2343                    }
2344                    KeyCode::Up | KeyCode::Char('k') => {
2345                        if app.session_picker_selected > 0 {
2346                            app.session_picker_selected -= 1;
2347                        }
2348                        app.session_picker_confirm_delete = false;
2349                    }
2350                    KeyCode::Down | KeyCode::Char('j') => {
2351                        let filtered_count = app.filtered_sessions().len();
2352                        if app.session_picker_selected < filtered_count.saturating_sub(1) {
2353                            app.session_picker_selected += 1;
2354                        }
2355                        app.session_picker_confirm_delete = false;
2356                    }
2357                    KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2358                        if app.session_picker_confirm_delete {
2359                            // Second press: actually delete
2360                            let filtered = app.filtered_sessions();
2361                            if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
2362                                let session_id = app.session_picker_list[*orig_idx].id.clone();
2363                                let is_active = app
2364                                    .session
2365                                    .as_ref()
2366                                    .map(|s| s.id == session_id)
2367                                    .unwrap_or(false);
2368                                if !is_active {
2369                                    if let Err(e) = Session::delete(&session_id).await {
2370                                        app.messages.push(ChatMessage::new(
2371                                            "system",
2372                                            format!("Failed to delete session: {}", e),
2373                                        ));
2374                                    } else {
2375                                        app.session_picker_list.retain(|s| s.id != session_id);
2376                                        if app.session_picker_selected
2377                                            >= app.session_picker_list.len()
2378                                        {
2379                                            app.session_picker_selected =
2380                                                app.session_picker_list.len().saturating_sub(1);
2381                                        }
2382                                    }
2383                                }
2384                            }
2385                            app.session_picker_confirm_delete = false;
2386                        } else {
2387                            // First press: ask for confirmation
2388                            let filtered = app.filtered_sessions();
2389                            if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
2390                                let is_active = app
2391                                    .session
2392                                    .as_ref()
2393                                    .map(|s| s.id == app.session_picker_list[*orig_idx].id)
2394                                    .unwrap_or(false);
2395                                if !is_active {
2396                                    app.session_picker_confirm_delete = true;
2397                                }
2398                            }
2399                        }
2400                    }
2401                    KeyCode::Backspace => {
2402                        app.session_picker_filter.pop();
2403                        app.session_picker_selected = 0;
2404                        app.session_picker_confirm_delete = false;
2405                    }
2406                    KeyCode::Char('/') => {
2407                        // Focus filter (no-op, just signals we're in filter mode)
2408                    }
2409                    KeyCode::Enter => {
2410                        app.session_picker_confirm_delete = false;
2411                        let filtered = app.filtered_sessions();
2412                        let session_id = filtered
2413                            .get(app.session_picker_selected)
2414                            .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
2415                        if let Some(session_id) = session_id {
2416                            let load_result =
2417                                if let Some(oc_id) = session_id.strip_prefix("opencode_") {
2418                                    if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
2419                                        Session::from_opencode(oc_id, &storage).await
2420                                    } else {
2421                                        Err(anyhow::anyhow!("OpenCode storage not available"))
2422                                    }
2423                                } else {
2424                                    Session::load(&session_id).await
2425                                };
2426                            match load_result {
2427                                Ok(session) => {
2428                                    app.messages.clear();
2429                                    app.messages.push(ChatMessage::new(
2430                                        "system",
2431                                        format!(
2432                                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
2433                                            session.title.as_deref().unwrap_or("(untitled)"),
2434                                            session.created_at.format("%Y-%m-%d %H:%M"),
2435                                            session.messages.len()
2436                                        ),
2437                                    ));
2438
2439                                    for msg in &session.messages {
2440                                        let role_str = match msg.role {
2441                                            Role::System => "system",
2442                                            Role::User => "user",
2443                                            Role::Assistant => "assistant",
2444                                            Role::Tool => "tool",
2445                                        };
2446
2447                                        // Process each content part separately
2448                                        // (consistent with /resume command)
2449                                        for part in &msg.content {
2450                                            match part {
2451                                                ContentPart::Text { text } => {
2452                                                    if !text.is_empty() {
2453                                                        app.messages.push(ChatMessage::new(
2454                                                            role_str,
2455                                                            text.clone(),
2456                                                        ));
2457                                                    }
2458                                                }
2459                                                ContentPart::Image { url, mime_type } => {
2460                                                    app.messages.push(
2461                                                        ChatMessage::new(role_str, "")
2462                                                            .with_message_type(
2463                                                                MessageType::Image {
2464                                                                    url: url.clone(),
2465                                                                    mime_type: mime_type.clone(),
2466                                                                },
2467                                                            ),
2468                                                    );
2469                                                }
2470                                                ContentPart::ToolCall {
2471                                                    name, arguments, ..
2472                                                } => {
2473                                                    let (preview, truncated) =
2474                                                        build_tool_arguments_preview(
2475                                                            name,
2476                                                            arguments,
2477                                                            TOOL_ARGS_PREVIEW_MAX_LINES,
2478                                                            TOOL_ARGS_PREVIEW_MAX_BYTES,
2479                                                        );
2480                                                    app.messages.push(
2481                                                        ChatMessage::new(
2482                                                            role_str,
2483                                                            format!("🔧 {name}"),
2484                                                        )
2485                                                        .with_message_type(MessageType::ToolCall {
2486                                                            name: name.clone(),
2487                                                            arguments_preview: preview,
2488                                                            arguments_len: arguments.len(),
2489                                                            truncated,
2490                                                        }),
2491                                                    );
2492                                                }
2493                                                ContentPart::ToolResult { content, .. } => {
2494                                                    let truncated =
2495                                                        truncate_with_ellipsis(content, 500);
2496                                                    let (preview, preview_truncated) =
2497                                                        build_text_preview(
2498                                                            content,
2499                                                            TOOL_OUTPUT_PREVIEW_MAX_LINES,
2500                                                            TOOL_OUTPUT_PREVIEW_MAX_BYTES,
2501                                                        );
2502                                                    app.messages.push(
2503                                                        ChatMessage::new(
2504                                                            role_str,
2505                                                            format!("✅ Result\n{truncated}"),
2506                                                        )
2507                                                        .with_message_type(
2508                                                            MessageType::ToolResult {
2509                                                                name: "tool".to_string(),
2510                                                                output_preview: preview,
2511                                                                output_len: content.len(),
2512                                                                truncated: preview_truncated,
2513                                                            },
2514                                                        ),
2515                                                    );
2516                                                }
2517                                                ContentPart::File { path, mime_type } => {
2518                                                    app.messages.push(
2519                                                        ChatMessage::new(
2520                                                            role_str,
2521                                                            format!("📎 {path}"),
2522                                                        )
2523                                                        .with_message_type(MessageType::File {
2524                                                            path: path.clone(),
2525                                                            mime_type: mime_type.clone(),
2526                                                        }),
2527                                                    );
2528                                                }
2529                                                ContentPart::Thinking { text } => {
2530                                                    if !text.is_empty() {
2531                                                        app.messages.push(
2532                                                            ChatMessage::new(
2533                                                                role_str,
2534                                                                text.clone(),
2535                                                            )
2536                                                            .with_message_type(
2537                                                                MessageType::Thinking(text.clone()),
2538                                                            ),
2539                                                        );
2540                                                    }
2541                                                }
2542                                            }
2543                                        }
2544                                    }
2545
2546                                    app.current_agent = session.agent.clone();
2547                                    app.session = Some(session);
2548                                    app.scroll = SCROLL_BOTTOM;
2549                                    app.view_mode = ViewMode::Chat;
2550                                }
2551                                Err(e) => {
2552                                    app.messages.push(ChatMessage::new(
2553                                        "system",
2554                                        format!("Failed to load session: {}", e),
2555                                    ));
2556                                    app.view_mode = ViewMode::Chat;
2557                                }
2558                            }
2559                        }
2560                    }
2561                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2562                        return Ok(());
2563                    }
2564                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2565                        return Ok(());
2566                    }
2567                    KeyCode::Char(c)
2568                        if !key.modifiers.contains(KeyModifiers::CONTROL)
2569                            && !key.modifiers.contains(KeyModifiers::ALT)
2570                            && c != 'j'
2571                            && c != 'k' =>
2572                    {
2573                        app.session_picker_filter.push(c);
2574                        app.session_picker_selected = 0;
2575                        app.session_picker_confirm_delete = false;
2576                    }
2577                    _ => {}
2578                }
2579                continue;
2580            }
2581
2582            // Agent picker overlay
2583            if app.view_mode == ViewMode::AgentPicker {
2584                match key.code {
2585                    KeyCode::Esc => {
2586                        app.agent_picker_filter.clear();
2587                        app.view_mode = ViewMode::Chat;
2588                    }
2589                    KeyCode::Up | KeyCode::Char('k')
2590                        if !key.modifiers.contains(KeyModifiers::ALT) =>
2591                    {
2592                        if app.agent_picker_selected > 0 {
2593                            app.agent_picker_selected -= 1;
2594                        }
2595                    }
2596                    KeyCode::Down | KeyCode::Char('j')
2597                        if !key.modifiers.contains(KeyModifiers::ALT) =>
2598                    {
2599                        let filtered = app.filtered_spawned_agents();
2600                        if app.agent_picker_selected < filtered.len().saturating_sub(1) {
2601                            app.agent_picker_selected += 1;
2602                        }
2603                    }
2604                    KeyCode::Enter => {
2605                        let filtered = app.filtered_spawned_agents();
2606                        if let Some((name, _, _)) = filtered.get(app.agent_picker_selected) {
2607                            app.active_spawned_agent = Some(name.clone());
2608                            app.messages.push(ChatMessage::new(
2609                                "system",
2610                                format!(
2611                                    "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
2612                                ),
2613                            ));
2614                            app.view_mode = ViewMode::Chat;
2615                        }
2616                    }
2617                    KeyCode::Backspace => {
2618                        app.agent_picker_filter.pop();
2619                        app.agent_picker_selected = 0;
2620                    }
2621                    KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2622                        app.active_spawned_agent = None;
2623                        app.messages
2624                            .push(ChatMessage::new("system", "Returned to main chat mode."));
2625                        app.view_mode = ViewMode::Chat;
2626                    }
2627                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2628                        return Ok(());
2629                    }
2630                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2631                        return Ok(());
2632                    }
2633                    KeyCode::Char(c)
2634                        if !key.modifiers.contains(KeyModifiers::CONTROL)
2635                            && !key.modifiers.contains(KeyModifiers::ALT)
2636                            && c != 'j'
2637                            && c != 'k'
2638                            && c != 'm' =>
2639                    {
2640                        app.agent_picker_filter.push(c);
2641                        app.agent_picker_selected = 0;
2642                    }
2643                    _ => {}
2644                }
2645                continue;
2646            }
2647
2648            // Swarm view key handling
2649            if app.view_mode == ViewMode::Swarm {
2650                match key.code {
2651                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2652                        return Ok(());
2653                    }
2654                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2655                        return Ok(());
2656                    }
2657                    KeyCode::Esc => {
2658                        if app.swarm_state.detail_mode {
2659                            app.swarm_state.exit_detail();
2660                        } else {
2661                            app.view_mode = ViewMode::Chat;
2662                        }
2663                    }
2664                    KeyCode::Up | KeyCode::Char('k') => {
2665                        if app.swarm_state.detail_mode {
2666                            // In detail mode, Up/Down switch between agents
2667                            app.swarm_state.exit_detail();
2668                            app.swarm_state.select_prev();
2669                            app.swarm_state.enter_detail();
2670                        } else {
2671                            app.swarm_state.select_prev();
2672                        }
2673                    }
2674                    KeyCode::Down | KeyCode::Char('j') => {
2675                        if app.swarm_state.detail_mode {
2676                            app.swarm_state.exit_detail();
2677                            app.swarm_state.select_next();
2678                            app.swarm_state.enter_detail();
2679                        } else {
2680                            app.swarm_state.select_next();
2681                        }
2682                    }
2683                    KeyCode::Enter => {
2684                        if !app.swarm_state.detail_mode {
2685                            app.swarm_state.enter_detail();
2686                        }
2687                    }
2688                    KeyCode::PageDown => {
2689                        app.swarm_state.detail_scroll_down(10);
2690                    }
2691                    KeyCode::PageUp => {
2692                        app.swarm_state.detail_scroll_up(10);
2693                    }
2694                    KeyCode::Char('?') => {
2695                        app.show_help = true;
2696                    }
2697                    KeyCode::F(2) => {
2698                        app.view_mode = ViewMode::Chat;
2699                    }
2700                    KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2701                        app.view_mode = ViewMode::Chat;
2702                    }
2703                    _ => {}
2704                }
2705                continue;
2706            }
2707
2708            // Ralph view key handling
2709            if app.view_mode == ViewMode::Ralph {
2710                match key.code {
2711                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2712                        return Ok(());
2713                    }
2714                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2715                        return Ok(());
2716                    }
2717                    KeyCode::Esc => {
2718                        if app.ralph_state.detail_mode {
2719                            app.ralph_state.exit_detail();
2720                        } else {
2721                            app.view_mode = ViewMode::Chat;
2722                        }
2723                    }
2724                    KeyCode::Up | KeyCode::Char('k') => {
2725                        if app.ralph_state.detail_mode {
2726                            app.ralph_state.exit_detail();
2727                            app.ralph_state.select_prev();
2728                            app.ralph_state.enter_detail();
2729                        } else {
2730                            app.ralph_state.select_prev();
2731                        }
2732                    }
2733                    KeyCode::Down | KeyCode::Char('j') => {
2734                        if app.ralph_state.detail_mode {
2735                            app.ralph_state.exit_detail();
2736                            app.ralph_state.select_next();
2737                            app.ralph_state.enter_detail();
2738                        } else {
2739                            app.ralph_state.select_next();
2740                        }
2741                    }
2742                    KeyCode::Enter => {
2743                        if !app.ralph_state.detail_mode {
2744                            app.ralph_state.enter_detail();
2745                        }
2746                    }
2747                    KeyCode::PageDown => {
2748                        app.ralph_state.detail_scroll_down(10);
2749                    }
2750                    KeyCode::PageUp => {
2751                        app.ralph_state.detail_scroll_up(10);
2752                    }
2753                    KeyCode::Char('?') => {
2754                        app.show_help = true;
2755                    }
2756                    KeyCode::F(2) | KeyCode::Char('s')
2757                        if key.modifiers.contains(KeyModifiers::CONTROL) =>
2758                    {
2759                        app.view_mode = ViewMode::Chat;
2760                    }
2761                    _ => {}
2762                }
2763                continue;
2764            }
2765
2766            // Bus log view key handling
2767            if app.view_mode == ViewMode::BusLog {
2768                match key.code {
2769                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2770                        return Ok(());
2771                    }
2772                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2773                        return Ok(());
2774                    }
2775                    KeyCode::Esc => {
2776                        if app.bus_log_state.detail_mode {
2777                            app.bus_log_state.exit_detail();
2778                        } else {
2779                            app.view_mode = ViewMode::Chat;
2780                        }
2781                    }
2782                    KeyCode::Up | KeyCode::Char('k') => {
2783                        if app.bus_log_state.detail_mode {
2784                            app.bus_log_state.exit_detail();
2785                            app.bus_log_state.select_prev();
2786                            app.bus_log_state.enter_detail();
2787                        } else {
2788                            app.bus_log_state.select_prev();
2789                        }
2790                    }
2791                    KeyCode::Down | KeyCode::Char('j') => {
2792                        if app.bus_log_state.detail_mode {
2793                            app.bus_log_state.exit_detail();
2794                            app.bus_log_state.select_next();
2795                            app.bus_log_state.enter_detail();
2796                        } else {
2797                            app.bus_log_state.select_next();
2798                        }
2799                    }
2800                    KeyCode::Enter => {
2801                        if !app.bus_log_state.detail_mode {
2802                            app.bus_log_state.enter_detail();
2803                        }
2804                    }
2805                    KeyCode::PageDown => {
2806                        app.bus_log_state.detail_scroll_down(10);
2807                    }
2808                    KeyCode::PageUp => {
2809                        app.bus_log_state.detail_scroll_up(10);
2810                    }
2811                    // Clear all entries
2812                    KeyCode::Char('c') => {
2813                        app.bus_log_state.entries.clear();
2814                        app.bus_log_state.selected_index = 0;
2815                    }
2816                    // Jump to bottom (re-enable auto-scroll)
2817                    KeyCode::Char('g') => {
2818                        let len = app.bus_log_state.filtered_entries().len();
2819                        if len > 0 {
2820                            app.bus_log_state.selected_index = len - 1;
2821                            app.bus_log_state.list_state.select(Some(len - 1));
2822                        }
2823                        app.bus_log_state.auto_scroll = true;
2824                    }
2825                    KeyCode::Char('?') => {
2826                        app.show_help = true;
2827                    }
2828                    _ => {}
2829                }
2830                continue;
2831            }
2832
2833            match key.code {
2834                // Quit
2835                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2836                    return Ok(());
2837                }
2838                KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2839                    return Ok(());
2840                }
2841
2842                // Help
2843                KeyCode::Char('?') => {
2844                    app.show_help = true;
2845                }
2846
2847                // Toggle view mode (F2 or Ctrl+S)
2848                KeyCode::F(2) => {
2849                    app.view_mode = match app.view_mode {
2850                        ViewMode::Chat
2851                        | ViewMode::SessionPicker
2852                        | ViewMode::ModelPicker
2853                        | ViewMode::AgentPicker
2854                        | ViewMode::BusLog => ViewMode::Swarm,
2855                        ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
2856                    };
2857                }
2858                KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2859                    app.view_mode = match app.view_mode {
2860                        ViewMode::Chat
2861                        | ViewMode::SessionPicker
2862                        | ViewMode::ModelPicker
2863                        | ViewMode::AgentPicker
2864                        | ViewMode::BusLog => ViewMode::Swarm,
2865                        ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
2866                    };
2867                }
2868
2869                // Toggle inspector pane in webview layout
2870                KeyCode::F(3) => {
2871                    app.show_inspector = !app.show_inspector;
2872                }
2873
2874                // Copy latest assistant message to clipboard (Ctrl+Y)
2875                KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2876                    let msg = app
2877                        .messages
2878                        .iter()
2879                        .rev()
2880                        .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
2881                        .or_else(|| app.messages.iter().rev().find(|m| !m.content.trim().is_empty()));
2882
2883                    let Some(msg) = msg else {
2884                        app.messages.push(ChatMessage::new("system", "Nothing to copy yet."));
2885                        app.scroll = SCROLL_BOTTOM;
2886                        continue;
2887                    };
2888
2889                    let text = message_clipboard_text(msg);
2890                    match copy_text_to_clipboard_best_effort(&text) {
2891                        Ok(method) => {
2892                            app.messages.push(ChatMessage::new(
2893                                "system",
2894                                format!("Copied latest reply ({method})."),
2895                            ));
2896                            app.scroll = SCROLL_BOTTOM;
2897                        }
2898                        Err(err) => {
2899                            tracing::warn!(error = %err, "Copy to clipboard failed");
2900                            app.messages.push(ChatMessage::new(
2901                                "system",
2902                                "Could not copy to clipboard in this environment.",
2903                            ));
2904                            app.scroll = SCROLL_BOTTOM;
2905                        }
2906                    }
2907                }
2908
2909                // Toggle chat layout (Ctrl+B)
2910                KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2911                    app.chat_layout = match app.chat_layout {
2912                        ChatLayoutMode::Classic => ChatLayoutMode::Webview,
2913                        ChatLayoutMode::Webview => ChatLayoutMode::Classic,
2914                    };
2915                }
2916
2917                // Escape - return to chat from swarm/picker view
2918                KeyCode::Esc => {
2919                    if app.view_mode == ViewMode::Swarm
2920                        || app.view_mode == ViewMode::Ralph
2921                        || app.view_mode == ViewMode::BusLog
2922                        || app.view_mode == ViewMode::SessionPicker
2923                        || app.view_mode == ViewMode::ModelPicker
2924                        || app.view_mode == ViewMode::AgentPicker
2925                    {
2926                        app.view_mode = ViewMode::Chat;
2927                    }
2928                }
2929
2930                // Model picker (Ctrl+M)
2931                KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2932                    app.open_model_picker(&config).await;
2933                }
2934
2935                // Agent picker (Ctrl+A)
2936                KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2937                    app.open_agent_picker();
2938                }
2939
2940                // Bus protocol log (Ctrl+L)
2941                KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2942                    app.view_mode = ViewMode::BusLog;
2943                }
2944
2945                // Switch agent
2946                KeyCode::Tab => {
2947                    app.current_agent = if app.current_agent == "build" {
2948                        "plan".to_string()
2949                    } else {
2950                        "build".to_string()
2951                    };
2952                }
2953
2954                // Submit message
2955                KeyCode::Enter => {
2956                    app.submit_message(&config).await;
2957                }
2958
2959                // Vim-style scrolling (Alt + j/k)
2960                KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
2961                    if app.scroll < SCROLL_BOTTOM {
2962                        app.scroll = app.scroll.saturating_add(1);
2963                    }
2964                }
2965                KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
2966                    if app.scroll >= SCROLL_BOTTOM {
2967                        app.scroll = app.last_max_scroll; // Leave auto-scroll mode
2968                    }
2969                    app.scroll = app.scroll.saturating_sub(1);
2970                }
2971
2972                // Command history
2973                KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2974                    app.search_history();
2975                }
2976                KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
2977                    app.navigate_history(-1);
2978                }
2979                KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
2980                    app.navigate_history(1);
2981                }
2982
2983                // Additional Vim-style navigation (with modifiers to avoid conflicts)
2984                KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2985                    app.scroll = 0; // Go to top
2986                }
2987                KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2988                    // Go to bottom (auto-scroll)
2989                    app.scroll = SCROLL_BOTTOM;
2990                }
2991
2992                // Enhanced scrolling (with Alt to avoid conflicts)
2993                KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
2994                    // Half page down
2995                    if app.scroll < SCROLL_BOTTOM {
2996                        app.scroll = app.scroll.saturating_add(5);
2997                    }
2998                }
2999                KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
3000                    // Half page up
3001                    if app.scroll >= SCROLL_BOTTOM {
3002                        app.scroll = app.last_max_scroll;
3003                    }
3004                    app.scroll = app.scroll.saturating_sub(5);
3005                }
3006
3007                // Text input
3008                KeyCode::Char(c) => {
3009                    // Ensure cursor is at a valid char boundary
3010                    while app.cursor_position > 0
3011                        && !app.input.is_char_boundary(app.cursor_position)
3012                    {
3013                        app.cursor_position -= 1;
3014                    }
3015                    app.input.insert(app.cursor_position, c);
3016                    app.cursor_position += c.len_utf8();
3017                }
3018                KeyCode::Backspace => {
3019                    // Move back to previous char boundary
3020                    while app.cursor_position > 0
3021                        && !app.input.is_char_boundary(app.cursor_position)
3022                    {
3023                        app.cursor_position -= 1;
3024                    }
3025                    if app.cursor_position > 0 {
3026                        // Find start of previous char
3027                        let prev = app.input[..app.cursor_position].char_indices().rev().next();
3028                        if let Some((idx, ch)) = prev {
3029                            app.input.replace_range(idx..idx + ch.len_utf8(), "");
3030                            app.cursor_position = idx;
3031                        }
3032                    }
3033                }
3034                KeyCode::Delete => {
3035                    // Ensure cursor is at a valid char boundary
3036                    while app.cursor_position > 0
3037                        && !app.input.is_char_boundary(app.cursor_position)
3038                    {
3039                        app.cursor_position -= 1;
3040                    }
3041                    if app.cursor_position < app.input.len() {
3042                        let ch = app.input[app.cursor_position..].chars().next();
3043                        if let Some(ch) = ch {
3044                            app.input.replace_range(
3045                                app.cursor_position..app.cursor_position + ch.len_utf8(),
3046                                "",
3047                            );
3048                        }
3049                    }
3050                }
3051                KeyCode::Left => {
3052                    // Move left by one character (not byte)
3053                    let prev = app.input[..app.cursor_position].char_indices().rev().next();
3054                    if let Some((idx, _)) = prev {
3055                        app.cursor_position = idx;
3056                    }
3057                }
3058                KeyCode::Right => {
3059                    if app.cursor_position < app.input.len() {
3060                        let ch = app.input[app.cursor_position..].chars().next();
3061                        if let Some(ch) = ch {
3062                            app.cursor_position += ch.len_utf8();
3063                        }
3064                    }
3065                }
3066                KeyCode::Home => {
3067                    app.cursor_position = 0;
3068                }
3069                KeyCode::End => {
3070                    app.cursor_position = app.input.len();
3071                }
3072
3073                // Scroll (normalize first to handle SCROLL_BOTTOM sentinel)
3074                KeyCode::Up => {
3075                    if app.scroll >= SCROLL_BOTTOM {
3076                        app.scroll = app.last_max_scroll; // Leave auto-scroll mode
3077                    }
3078                    app.scroll = app.scroll.saturating_sub(1);
3079                }
3080                KeyCode::Down => {
3081                    if app.scroll < SCROLL_BOTTOM {
3082                        app.scroll = app.scroll.saturating_add(1);
3083                    }
3084                }
3085                KeyCode::PageUp => {
3086                    if app.scroll >= SCROLL_BOTTOM {
3087                        app.scroll = app.last_max_scroll;
3088                    }
3089                    app.scroll = app.scroll.saturating_sub(10);
3090                }
3091                KeyCode::PageDown => {
3092                    if app.scroll < SCROLL_BOTTOM {
3093                        app.scroll = app.scroll.saturating_add(10);
3094                    }
3095                }
3096
3097                _ => {}
3098            }
3099        }
3100    }
3101}
3102
3103fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
3104    // Check view mode
3105    if app.view_mode == ViewMode::Swarm {
3106        // Render swarm view
3107        let chunks = Layout::default()
3108            .direction(Direction::Vertical)
3109            .constraints([
3110                Constraint::Min(1),    // Swarm view
3111                Constraint::Length(3), // Input
3112                Constraint::Length(1), // Status bar
3113            ])
3114            .split(f.area());
3115
3116        // Swarm view
3117        render_swarm_view(f, &mut app.swarm_state, chunks[0]);
3118
3119        // Input area (for returning to chat)
3120        let input_block = Block::default()
3121            .borders(Borders::ALL)
3122            .title(" Press Esc, Ctrl+S, or /view to return to chat ")
3123            .border_style(Style::default().fg(Color::Cyan));
3124
3125        let input = Paragraph::new(app.input.as_str())
3126            .block(input_block)
3127            .wrap(Wrap { trim: false });
3128        f.render_widget(input, chunks[1]);
3129
3130        // Status bar
3131        let status_line = if app.swarm_state.detail_mode {
3132            Line::from(vec![
3133                Span::styled(
3134                    " AGENT DETAIL ",
3135                    Style::default().fg(Color::Black).bg(Color::Cyan),
3136                ),
3137                Span::raw(" | "),
3138                Span::styled("Esc", Style::default().fg(Color::Yellow)),
3139                Span::raw(": Back to list | "),
3140                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3141                Span::raw(": Prev/Next agent | "),
3142                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3143                Span::raw(": Scroll"),
3144            ])
3145        } else {
3146            Line::from(vec![
3147                Span::styled(
3148                    " SWARM MODE ",
3149                    Style::default().fg(Color::Black).bg(Color::Cyan),
3150                ),
3151                Span::raw(" | "),
3152                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3153                Span::raw(": Select | "),
3154                Span::styled("Enter", Style::default().fg(Color::Yellow)),
3155                Span::raw(": Detail | "),
3156                Span::styled("Esc", Style::default().fg(Color::Yellow)),
3157                Span::raw(": Back | "),
3158                Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
3159                Span::raw(": Toggle view"),
3160            ])
3161        };
3162        let status = Paragraph::new(status_line);
3163        f.render_widget(status, chunks[2]);
3164        return;
3165    }
3166
3167    // Ralph view
3168    if app.view_mode == ViewMode::Ralph {
3169        let chunks = Layout::default()
3170            .direction(Direction::Vertical)
3171            .constraints([
3172                Constraint::Min(1),    // Ralph view
3173                Constraint::Length(3), // Input
3174                Constraint::Length(1), // Status bar
3175            ])
3176            .split(f.area());
3177
3178        render_ralph_view(f, &mut app.ralph_state, chunks[0]);
3179
3180        let input_block = Block::default()
3181            .borders(Borders::ALL)
3182            .title(" Press Esc to return to chat ")
3183            .border_style(Style::default().fg(Color::Magenta));
3184
3185        let input = Paragraph::new(app.input.as_str())
3186            .block(input_block)
3187            .wrap(Wrap { trim: false });
3188        f.render_widget(input, chunks[1]);
3189
3190        let status_line = if app.ralph_state.detail_mode {
3191            Line::from(vec![
3192                Span::styled(
3193                    " STORY DETAIL ",
3194                    Style::default().fg(Color::Black).bg(Color::Magenta),
3195                ),
3196                Span::raw(" | "),
3197                Span::styled("Esc", Style::default().fg(Color::Yellow)),
3198                Span::raw(": Back to list | "),
3199                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3200                Span::raw(": Prev/Next story | "),
3201                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
3202                Span::raw(": Scroll"),
3203            ])
3204        } else {
3205            Line::from(vec![
3206                Span::styled(
3207                    " RALPH MODE ",
3208                    Style::default().fg(Color::Black).bg(Color::Magenta),
3209                ),
3210                Span::raw(" | "),
3211                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3212                Span::raw(": Select | "),
3213                Span::styled("Enter", Style::default().fg(Color::Yellow)),
3214                Span::raw(": Detail | "),
3215                Span::styled("Esc", Style::default().fg(Color::Yellow)),
3216                Span::raw(": Back"),
3217            ])
3218        };
3219        let status = Paragraph::new(status_line);
3220        f.render_widget(status, chunks[2]);
3221        return;
3222    }
3223
3224    // Bus protocol log view
3225    if app.view_mode == ViewMode::BusLog {
3226        let chunks = Layout::default()
3227            .direction(Direction::Vertical)
3228            .constraints([
3229                Constraint::Min(1),    // Bus log view
3230                Constraint::Length(3), // Input
3231                Constraint::Length(1), // Status bar
3232            ])
3233            .split(f.area());
3234
3235        render_bus_log(f, &mut app.bus_log_state, chunks[0]);
3236
3237        let input_block = Block::default()
3238            .borders(Borders::ALL)
3239            .title(" Press Esc to return to chat ")
3240            .border_style(Style::default().fg(Color::Green));
3241
3242        let input = Paragraph::new(app.input.as_str())
3243            .block(input_block)
3244            .wrap(Wrap { trim: false });
3245        f.render_widget(input, chunks[1]);
3246
3247        let count_info = format!(
3248            " {}/{} ",
3249            app.bus_log_state.visible_count(),
3250            app.bus_log_state.total_count()
3251        );
3252        let status_line = Line::from(vec![
3253            Span::styled(
3254                " BUS LOG ",
3255                Style::default().fg(Color::Black).bg(Color::Green),
3256            ),
3257            Span::raw(&count_info),
3258            Span::raw("| "),
3259            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3260            Span::raw(": Select | "),
3261            Span::styled("Enter", Style::default().fg(Color::Yellow)),
3262            Span::raw(": Detail | "),
3263            Span::styled("c", Style::default().fg(Color::Yellow)),
3264            Span::raw(": Clear | "),
3265            Span::styled("Esc", Style::default().fg(Color::Yellow)),
3266            Span::raw(": Back"),
3267        ]);
3268        let status = Paragraph::new(status_line);
3269        f.render_widget(status, chunks[2]);
3270        return;
3271    }
3272
3273    // Model picker view
3274    if app.view_mode == ViewMode::ModelPicker {
3275        let area = centered_rect(70, 70, f.area());
3276        f.render_widget(Clear, area);
3277
3278        let filter_display = if app.model_picker_filter.is_empty() {
3279            "type to filter".to_string()
3280        } else {
3281            format!("filter: {}", app.model_picker_filter)
3282        };
3283
3284        let picker_block = Block::default()
3285            .borders(Borders::ALL)
3286            .title(format!(
3287                " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
3288                filter_display
3289            ))
3290            .border_style(Style::default().fg(Color::Magenta));
3291
3292        let filtered = app.filtered_models();
3293        let mut list_lines: Vec<Line> = Vec::new();
3294        list_lines.push(Line::from(""));
3295
3296        if let Some(ref active) = app.active_model {
3297            list_lines.push(Line::styled(
3298                format!("  Current: {}", active),
3299                Style::default()
3300                    .fg(Color::Green)
3301                    .add_modifier(Modifier::DIM),
3302            ));
3303            list_lines.push(Line::from(""));
3304        }
3305
3306        if filtered.is_empty() {
3307            list_lines.push(Line::styled(
3308                "  No models match filter",
3309                Style::default().fg(Color::DarkGray),
3310            ));
3311        } else {
3312            let mut current_provider = String::new();
3313            for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
3314                let provider = label.split('/').next().unwrap_or("");
3315                if provider != current_provider {
3316                    if !current_provider.is_empty() {
3317                        list_lines.push(Line::from(""));
3318                    }
3319                    list_lines.push(Line::styled(
3320                        format!("  ─── {} ───", provider),
3321                        Style::default()
3322                            .fg(Color::Cyan)
3323                            .add_modifier(Modifier::BOLD),
3324                    ));
3325                    current_provider = provider.to_string();
3326                }
3327
3328                let is_selected = display_idx == app.model_picker_selected;
3329                let is_active = app.active_model.as_deref() == Some(label.as_str());
3330                let marker = if is_selected { "▶" } else { " " };
3331                let active_marker = if is_active { " ✓" } else { "" };
3332                let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
3333                // Show human name if different from ID
3334                let display = if human_name != &model_id && !human_name.is_empty() {
3335                    format!("{} ({})", human_name, model_id)
3336                } else {
3337                    model_id
3338                };
3339
3340                let style = if is_selected {
3341                    Style::default()
3342                        .fg(Color::Magenta)
3343                        .add_modifier(Modifier::BOLD)
3344                } else if is_active {
3345                    Style::default().fg(Color::Green)
3346                } else {
3347                    Style::default()
3348                };
3349
3350                list_lines.push(Line::styled(
3351                    format!("  {} {}{}", marker, display, active_marker),
3352                    style,
3353                ));
3354            }
3355        }
3356
3357        let list = Paragraph::new(list_lines)
3358            .block(picker_block)
3359            .wrap(Wrap { trim: false });
3360        f.render_widget(list, area);
3361        return;
3362    }
3363
3364    // Session picker view
3365    if app.view_mode == ViewMode::SessionPicker {
3366        let chunks = Layout::default()
3367            .direction(Direction::Vertical)
3368            .constraints([
3369                Constraint::Min(1),    // Session list
3370                Constraint::Length(1), // Status bar
3371            ])
3372            .split(f.area());
3373
3374        // Build title with filter display
3375        let filter_display = if app.session_picker_filter.is_empty() {
3376            String::new()
3377        } else {
3378            format!(" [filter: {}]", app.session_picker_filter)
3379        };
3380
3381        let list_block = Block::default()
3382            .borders(Borders::ALL)
3383            .title(format!(
3384                " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
3385                filter_display
3386            ))
3387            .border_style(Style::default().fg(Color::Cyan));
3388
3389        let mut list_lines: Vec<Line> = Vec::new();
3390        list_lines.push(Line::from(""));
3391
3392        let filtered = app.filtered_sessions();
3393        if filtered.is_empty() {
3394            if app.session_picker_filter.is_empty() {
3395                list_lines.push(Line::styled(
3396                    "  No sessions found.",
3397                    Style::default().fg(Color::DarkGray),
3398                ));
3399            } else {
3400                list_lines.push(Line::styled(
3401                    format!("  No sessions matching '{}'", app.session_picker_filter),
3402                    Style::default().fg(Color::DarkGray),
3403                ));
3404            }
3405        }
3406
3407        for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
3408            let is_selected = display_idx == app.session_picker_selected;
3409            let is_active = app
3410                .session
3411                .as_ref()
3412                .map(|s| s.id == session.id)
3413                .unwrap_or(false);
3414            let title = session.title.as_deref().unwrap_or("(untitled)");
3415            let date = session.updated_at.format("%Y-%m-%d %H:%M");
3416            let active_marker = if is_active { " ●" } else { "" };
3417            let line_str = format!(
3418                " {} {}{} - {} ({} msgs)",
3419                if is_selected { "▶" } else { " " },
3420                title,
3421                active_marker,
3422                date,
3423                session.message_count
3424            );
3425
3426            let style = if is_selected && app.session_picker_confirm_delete {
3427                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
3428            } else if is_selected {
3429                Style::default()
3430                    .fg(Color::Cyan)
3431                    .add_modifier(Modifier::BOLD)
3432            } else if is_active {
3433                Style::default().fg(Color::Green)
3434            } else {
3435                Style::default()
3436            };
3437
3438            list_lines.push(Line::styled(line_str, style));
3439
3440            // Show details for selected item
3441            if is_selected {
3442                if app.session_picker_confirm_delete {
3443                    list_lines.push(Line::styled(
3444                        "   ⚠ Press d again to confirm delete, Esc to cancel",
3445                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
3446                    ));
3447                } else {
3448                    list_lines.push(Line::styled(
3449                        format!("   Agent: {} | ID: {}", session.agent, session.id),
3450                        Style::default().fg(Color::DarkGray),
3451                    ));
3452                }
3453            }
3454        }
3455
3456        let list = Paragraph::new(list_lines)
3457            .block(list_block)
3458            .wrap(Wrap { trim: false });
3459        f.render_widget(list, chunks[0]);
3460
3461        // Status bar with more actions
3462        let mut status_spans = vec![
3463            Span::styled(
3464                " SESSION PICKER ",
3465                Style::default().fg(Color::Black).bg(Color::Cyan),
3466            ),
3467            Span::raw(" "),
3468            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
3469            Span::raw(": Nav "),
3470            Span::styled("Enter", Style::default().fg(Color::Yellow)),
3471            Span::raw(": Load "),
3472            Span::styled("d", Style::default().fg(Color::Yellow)),
3473            Span::raw(": Delete "),
3474            Span::styled("Esc", Style::default().fg(Color::Yellow)),
3475            Span::raw(": Cancel "),
3476        ];
3477        if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
3478            status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
3479            status_spans.push(Span::raw(": Filter "));
3480        }
3481        let total = app.session_picker_list.len();
3482        let showing = filtered.len();
3483        if showing < total {
3484            status_spans.push(Span::styled(
3485                format!("{}/{}", showing, total),
3486                Style::default().fg(Color::DarkGray),
3487            ));
3488        }
3489
3490        let status = Paragraph::new(Line::from(status_spans));
3491        f.render_widget(status, chunks[1]);
3492        return;
3493    }
3494
3495    // Agent picker view
3496    if app.view_mode == ViewMode::AgentPicker {
3497        let area = centered_rect(70, 70, f.area());
3498        f.render_widget(Clear, area);
3499
3500        let filter_display = if app.agent_picker_filter.is_empty() {
3501            "type to filter".to_string()
3502        } else {
3503            format!("filter: {}", app.agent_picker_filter)
3504        };
3505
3506        let picker_block = Block::default()
3507            .borders(Borders::ALL)
3508            .title(format!(
3509                " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
3510                filter_display
3511            ))
3512            .border_style(Style::default().fg(Color::Magenta));
3513
3514        let filtered = app.filtered_spawned_agents();
3515        let mut list_lines: Vec<Line> = Vec::new();
3516        list_lines.push(Line::from(""));
3517
3518        if let Some(ref active) = app.active_spawned_agent {
3519            list_lines.push(Line::styled(
3520                format!("  Current focus: @{}", active),
3521                Style::default()
3522                    .fg(Color::Green)
3523                    .add_modifier(Modifier::DIM),
3524            ));
3525            list_lines.push(Line::from(""));
3526        }
3527
3528        if filtered.is_empty() {
3529            list_lines.push(Line::styled(
3530                "  No spawned agents match filter",
3531                Style::default().fg(Color::DarkGray),
3532            ));
3533        } else {
3534            for (display_idx, (name, instructions, is_processing)) in filtered.iter().enumerate() {
3535                let is_selected = display_idx == app.agent_picker_selected;
3536                let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
3537                let marker = if is_selected { "▶" } else { " " };
3538                let focused_marker = if is_focused { " ✓" } else { "" };
3539                let status = if *is_processing { "⚡" } else { "●" };
3540
3541                let style = if is_selected {
3542                    Style::default()
3543                        .fg(Color::Magenta)
3544                        .add_modifier(Modifier::BOLD)
3545                } else if is_focused {
3546                    Style::default().fg(Color::Green)
3547                } else {
3548                    Style::default()
3549                };
3550
3551                list_lines.push(Line::styled(
3552                    format!("  {marker} {status} @{name}{focused_marker}"),
3553                    style,
3554                ));
3555
3556                if is_selected {
3557                    list_lines.push(Line::styled(
3558                        format!("     {}", instructions),
3559                        Style::default().fg(Color::DarkGray),
3560                    ));
3561                }
3562            }
3563        }
3564
3565        let list = Paragraph::new(list_lines)
3566            .block(picker_block)
3567            .wrap(Wrap { trim: false });
3568        f.render_widget(list, area);
3569        return;
3570    }
3571
3572    if app.chat_layout == ChatLayoutMode::Webview {
3573        if render_webview_chat(f, app, theme) {
3574            render_help_overlay_if_needed(f, app, theme);
3575            return;
3576        }
3577    }
3578
3579    // Chat view (default)
3580    let chunks = Layout::default()
3581        .direction(Direction::Vertical)
3582        .constraints([
3583            Constraint::Min(1),    // Messages
3584            Constraint::Length(3), // Input
3585            Constraint::Length(1), // Status bar
3586        ])
3587        .split(f.area());
3588
3589    // Messages area with theme-based styling
3590    let messages_area = chunks[0];
3591    let model_label = app.active_model.as_deref().unwrap_or("auto");
3592    let target_label = app
3593        .active_spawned_agent
3594        .as_ref()
3595        .map(|name| format!(" @{}", name))
3596        .unwrap_or_default();
3597    let messages_block = Block::default()
3598        .borders(Borders::ALL)
3599        .title(format!(
3600            " CodeTether Agent [{}{}] model:{} ",
3601            app.current_agent, target_label, model_label
3602        ))
3603        .border_style(Style::default().fg(theme.border_color.to_color()));
3604
3605    let max_width = messages_area.width.saturating_sub(4) as usize;
3606    let message_lines = build_message_lines(app, theme, max_width);
3607
3608    // Calculate scroll position
3609    let total_lines = message_lines.len();
3610    let visible_lines = messages_area.height.saturating_sub(2) as usize;
3611    let max_scroll = total_lines.saturating_sub(visible_lines);
3612    // SCROLL_BOTTOM means "stick to bottom", otherwise clamp to max_scroll
3613    let scroll = if app.scroll >= SCROLL_BOTTOM {
3614        max_scroll
3615    } else {
3616        app.scroll.min(max_scroll)
3617    };
3618
3619    // Render messages with scrolling
3620    let messages_paragraph = Paragraph::new(
3621        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
3622    )
3623    .block(messages_block.clone())
3624    .wrap(Wrap { trim: false });
3625
3626    f.render_widget(messages_paragraph, messages_area);
3627
3628    // Render scrollbar if needed
3629    if total_lines > visible_lines {
3630        let scrollbar = Scrollbar::default()
3631            .orientation(ScrollbarOrientation::VerticalRight)
3632            .symbols(ratatui::symbols::scrollbar::VERTICAL)
3633            .begin_symbol(Some("↑"))
3634            .end_symbol(Some("↓"));
3635
3636        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
3637
3638        let scrollbar_area = Rect::new(
3639            messages_area.right() - 1,
3640            messages_area.top() + 1,
3641            1,
3642            messages_area.height - 2,
3643        );
3644
3645        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
3646    }
3647
3648    // Input area
3649    let input_title = if app.is_processing {
3650        if let Some(started) = app.processing_started_at {
3651            let elapsed = started.elapsed();
3652            format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
3653        } else {
3654            " Message (Processing...) ".to_string()
3655        }
3656    } else if app.input.starts_with('/') {
3657        let hint = match_slash_command_hint(&app.input);
3658        format!(" {} ", hint)
3659    } else if let Some(target) = &app.active_spawned_agent {
3660        format!(" Message to @{target} (use /agent main to exit) ")
3661    } else {
3662        " Message (Enter to send, / for commands) ".to_string()
3663    };
3664    let input_block = Block::default()
3665        .borders(Borders::ALL)
3666        .title(input_title)
3667        .border_style(Style::default().fg(if app.is_processing {
3668            Color::Yellow
3669        } else if app.input.starts_with('/') {
3670            Color::Magenta
3671        } else {
3672            theme.input_border_color.to_color()
3673        }));
3674
3675    let input = Paragraph::new(app.input.as_str())
3676        .block(input_block)
3677        .wrap(Wrap { trim: false });
3678    f.render_widget(input, chunks[1]);
3679
3680    // Cursor
3681    f.set_cursor_position((
3682        chunks[1].x + app.cursor_position as u16 + 1,
3683        chunks[1].y + 1,
3684    ));
3685
3686    // Enhanced status bar with token display and model info
3687    let token_display = TokenDisplay::new();
3688    let mut status_line = token_display.create_status_bar(theme);
3689    let model_status = if let Some(ref active) = app.active_model {
3690        let (provider, model) = crate::provider::parse_model_string(active);
3691        format!(" {}:{} ", provider.unwrap_or("auto"), model)
3692    } else {
3693        " auto ".to_string()
3694    };
3695    status_line.spans.insert(
3696        0,
3697        Span::styled(
3698            "│ ",
3699            Style::default()
3700                .fg(theme.timestamp_color.to_color())
3701                .add_modifier(Modifier::DIM),
3702        ),
3703    );
3704    status_line.spans.insert(
3705        0,
3706        Span::styled(model_status, Style::default().fg(Color::Cyan)),
3707    );
3708    let status = Paragraph::new(status_line);
3709    f.render_widget(status, chunks[2]);
3710
3711    render_help_overlay_if_needed(f, app, theme);
3712}
3713
3714fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
3715    let area = f.area();
3716    if area.width < 90 || area.height < 18 {
3717        return false;
3718    }
3719
3720    let main_chunks = Layout::default()
3721        .direction(Direction::Vertical)
3722        .constraints([
3723            Constraint::Length(3), // Header
3724            Constraint::Min(1),    // Body
3725            Constraint::Length(3), // Input
3726            Constraint::Length(1), // Status
3727        ])
3728        .split(area);
3729
3730    render_webview_header(f, app, theme, main_chunks[0]);
3731
3732    let body_constraints = if app.show_inspector {
3733        vec![
3734            Constraint::Length(26),
3735            Constraint::Min(40),
3736            Constraint::Length(30),
3737        ]
3738    } else {
3739        vec![Constraint::Length(26), Constraint::Min(40)]
3740    };
3741
3742    let body_chunks = Layout::default()
3743        .direction(Direction::Horizontal)
3744        .constraints(body_constraints)
3745        .split(main_chunks[1]);
3746
3747    render_webview_sidebar(f, app, theme, body_chunks[0]);
3748    render_webview_chat_center(f, app, theme, body_chunks[1]);
3749    if app.show_inspector && body_chunks.len() > 2 {
3750        render_webview_inspector(f, app, theme, body_chunks[2]);
3751    }
3752
3753    render_webview_input(f, app, theme, main_chunks[2]);
3754
3755    let token_display = TokenDisplay::new();
3756    let mut status_line = token_display.create_status_bar(theme);
3757    let model_status = if let Some(ref active) = app.active_model {
3758        let (provider, model) = crate::provider::parse_model_string(active);
3759        format!(" {}:{} ", provider.unwrap_or("auto"), model)
3760    } else {
3761        " auto ".to_string()
3762    };
3763    status_line.spans.insert(
3764        0,
3765        Span::styled(
3766            "│ ",
3767            Style::default()
3768                .fg(theme.timestamp_color.to_color())
3769                .add_modifier(Modifier::DIM),
3770        ),
3771    );
3772    status_line.spans.insert(
3773        0,
3774        Span::styled(model_status, Style::default().fg(Color::Cyan)),
3775    );
3776    let status = Paragraph::new(status_line);
3777    f.render_widget(status, main_chunks[3]);
3778
3779    true
3780}
3781
3782fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3783    let session_title = app
3784        .session
3785        .as_ref()
3786        .and_then(|s| s.title.clone())
3787        .unwrap_or_else(|| "Workspace Chat".to_string());
3788    let session_id = app
3789        .session
3790        .as_ref()
3791        .map(|s| s.id.chars().take(8).collect::<String>())
3792        .unwrap_or_else(|| "new".to_string());
3793    let model_label = app
3794        .session
3795        .as_ref()
3796        .and_then(|s| s.metadata.model.clone())
3797        .unwrap_or_else(|| "auto".to_string());
3798    let workspace_label = app.workspace.root_display.clone();
3799    let branch_label = app
3800        .workspace
3801        .git_branch
3802        .clone()
3803        .unwrap_or_else(|| "no-git".to_string());
3804    let dirty_label = if app.workspace.git_dirty_files > 0 {
3805        format!("{} dirty", app.workspace.git_dirty_files)
3806    } else {
3807        "clean".to_string()
3808    };
3809
3810    let header_block = Block::default()
3811        .borders(Borders::ALL)
3812        .title(" CodeTether Webview ")
3813        .border_style(Style::default().fg(theme.border_color.to_color()));
3814
3815    let header_lines = vec![
3816        Line::from(vec![
3817            Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
3818            Span::raw(" "),
3819            Span::styled(
3820                format!("#{}", session_id),
3821                Style::default()
3822                    .fg(theme.timestamp_color.to_color())
3823                    .add_modifier(Modifier::DIM),
3824            ),
3825        ]),
3826        Line::from(vec![
3827            Span::styled(
3828                "Workspace ",
3829                Style::default().fg(theme.timestamp_color.to_color()),
3830            ),
3831            Span::styled(workspace_label, Style::default()),
3832            Span::raw("  "),
3833            Span::styled(
3834                "Branch ",
3835                Style::default().fg(theme.timestamp_color.to_color()),
3836            ),
3837            Span::styled(
3838                branch_label,
3839                Style::default()
3840                    .fg(Color::Cyan)
3841                    .add_modifier(Modifier::BOLD),
3842            ),
3843            Span::raw("  "),
3844            Span::styled(
3845                dirty_label,
3846                Style::default()
3847                    .fg(Color::Yellow)
3848                    .add_modifier(Modifier::BOLD),
3849            ),
3850            Span::raw("  "),
3851            Span::styled(
3852                "Model ",
3853                Style::default().fg(theme.timestamp_color.to_color()),
3854            ),
3855            Span::styled(model_label, Style::default().fg(Color::Green)),
3856        ]),
3857    ];
3858
3859    let header = Paragraph::new(header_lines)
3860        .block(header_block)
3861        .wrap(Wrap { trim: true });
3862    f.render_widget(header, area);
3863}
3864
3865fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3866    let sidebar_chunks = Layout::default()
3867        .direction(Direction::Vertical)
3868        .constraints([Constraint::Min(8), Constraint::Min(6)])
3869        .split(area);
3870
3871    let workspace_block = Block::default()
3872        .borders(Borders::ALL)
3873        .title(" Workspace ")
3874        .border_style(Style::default().fg(theme.border_color.to_color()));
3875
3876    let mut workspace_lines = Vec::new();
3877    workspace_lines.push(Line::from(vec![
3878        Span::styled(
3879            "Updated ",
3880            Style::default().fg(theme.timestamp_color.to_color()),
3881        ),
3882        Span::styled(
3883            app.workspace.captured_at.clone(),
3884            Style::default().fg(theme.timestamp_color.to_color()),
3885        ),
3886    ]));
3887    workspace_lines.push(Line::from(""));
3888
3889    if app.workspace.entries.is_empty() {
3890        workspace_lines.push(Line::styled(
3891            "No entries found",
3892            Style::default().fg(Color::DarkGray),
3893        ));
3894    } else {
3895        for entry in app.workspace.entries.iter().take(12) {
3896            let icon = match entry.kind {
3897                WorkspaceEntryKind::Directory => "📁",
3898                WorkspaceEntryKind::File => "📄",
3899            };
3900            workspace_lines.push(Line::from(vec![
3901                Span::styled(icon, Style::default().fg(Color::Cyan)),
3902                Span::raw(" "),
3903                Span::styled(entry.name.clone(), Style::default()),
3904            ]));
3905        }
3906    }
3907
3908    workspace_lines.push(Line::from(""));
3909    workspace_lines.push(Line::styled(
3910        "Use /refresh to rescan",
3911        Style::default()
3912            .fg(Color::DarkGray)
3913            .add_modifier(Modifier::DIM),
3914    ));
3915
3916    let workspace_panel = Paragraph::new(workspace_lines)
3917        .block(workspace_block)
3918        .wrap(Wrap { trim: true });
3919    f.render_widget(workspace_panel, sidebar_chunks[0]);
3920
3921    let sessions_block = Block::default()
3922        .borders(Borders::ALL)
3923        .title(" Recent Sessions ")
3924        .border_style(Style::default().fg(theme.border_color.to_color()));
3925
3926    let mut session_lines = Vec::new();
3927    if app.session_picker_list.is_empty() {
3928        session_lines.push(Line::styled(
3929            "No sessions yet",
3930            Style::default().fg(Color::DarkGray),
3931        ));
3932    } else {
3933        for session in app.session_picker_list.iter().take(6) {
3934            let is_active = app
3935                .session
3936                .as_ref()
3937                .map(|s| s.id == session.id)
3938                .unwrap_or(false);
3939            let title = session.title.as_deref().unwrap_or("(untitled)");
3940            let indicator = if is_active { "●" } else { "○" };
3941            let line_style = if is_active {
3942                Style::default()
3943                    .fg(Color::Cyan)
3944                    .add_modifier(Modifier::BOLD)
3945            } else {
3946                Style::default()
3947            };
3948            session_lines.push(Line::from(vec![
3949                Span::styled(indicator, line_style),
3950                Span::raw(" "),
3951                Span::styled(title, line_style),
3952            ]));
3953            session_lines.push(Line::styled(
3954                format!(
3955                    "  {} msgs • {}",
3956                    session.message_count,
3957                    session.updated_at.format("%m-%d %H:%M")
3958                ),
3959                Style::default().fg(Color::DarkGray),
3960            ));
3961        }
3962    }
3963
3964    let sessions_panel = Paragraph::new(session_lines)
3965        .block(sessions_block)
3966        .wrap(Wrap { trim: true });
3967    f.render_widget(sessions_panel, sidebar_chunks[1]);
3968}
3969
3970fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
3971    let messages_area = area;
3972    let focused_suffix = app
3973        .active_spawned_agent
3974        .as_ref()
3975        .map(|name| format!(" → @{name}"))
3976        .unwrap_or_default();
3977    let messages_block = Block::default()
3978        .borders(Borders::ALL)
3979        .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
3980        .border_style(Style::default().fg(theme.border_color.to_color()));
3981
3982    let max_width = messages_area.width.saturating_sub(4) as usize;
3983    let message_lines = build_message_lines(app, theme, max_width);
3984
3985    let total_lines = message_lines.len();
3986    let visible_lines = messages_area.height.saturating_sub(2) as usize;
3987    let max_scroll = total_lines.saturating_sub(visible_lines);
3988    let scroll = if app.scroll >= SCROLL_BOTTOM {
3989        max_scroll
3990    } else {
3991        app.scroll.min(max_scroll)
3992    };
3993
3994    let messages_paragraph = Paragraph::new(
3995        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
3996    )
3997    .block(messages_block.clone())
3998    .wrap(Wrap { trim: false });
3999
4000    f.render_widget(messages_paragraph, messages_area);
4001
4002    if total_lines > visible_lines {
4003        let scrollbar = Scrollbar::default()
4004            .orientation(ScrollbarOrientation::VerticalRight)
4005            .symbols(ratatui::symbols::scrollbar::VERTICAL)
4006            .begin_symbol(Some("↑"))
4007            .end_symbol(Some("↓"));
4008
4009        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
4010
4011        let scrollbar_area = Rect::new(
4012            messages_area.right() - 1,
4013            messages_area.top() + 1,
4014            1,
4015            messages_area.height - 2,
4016        );
4017
4018        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
4019    }
4020}
4021
4022fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4023    let block = Block::default()
4024        .borders(Borders::ALL)
4025        .title(" Inspector ")
4026        .border_style(Style::default().fg(theme.border_color.to_color()));
4027
4028    let status_label = if app.is_processing {
4029        "Processing"
4030    } else {
4031        "Idle"
4032    };
4033    let status_style = if app.is_processing {
4034        Style::default()
4035            .fg(Color::Yellow)
4036            .add_modifier(Modifier::BOLD)
4037    } else {
4038        Style::default().fg(Color::Green)
4039    };
4040    let tool_label = app
4041        .current_tool
4042        .clone()
4043        .unwrap_or_else(|| "none".to_string());
4044    let message_count = app.messages.len();
4045    let session_id = app
4046        .session
4047        .as_ref()
4048        .map(|s| s.id.chars().take(8).collect::<String>())
4049        .unwrap_or_else(|| "new".to_string());
4050    let model_label = app
4051        .active_model
4052        .as_deref()
4053        .or_else(|| {
4054            app.session
4055                .as_ref()
4056                .and_then(|s| s.metadata.model.as_deref())
4057        })
4058        .unwrap_or("auto");
4059    let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
4060
4061    let label_style = Style::default().fg(theme.timestamp_color.to_color());
4062
4063    let mut lines = Vec::new();
4064    lines.push(Line::from(vec![
4065        Span::styled("Status: ", label_style),
4066        Span::styled(status_label, status_style),
4067    ]));
4068
4069    // Show elapsed time when processing
4070    if let Some(started) = app.processing_started_at {
4071        let elapsed = started.elapsed();
4072        let elapsed_str = if elapsed.as_secs() >= 60 {
4073            format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4074        } else {
4075            format!("{:.1}s", elapsed.as_secs_f64())
4076        };
4077        lines.push(Line::from(vec![
4078            Span::styled("Elapsed: ", label_style),
4079            Span::styled(
4080                elapsed_str,
4081                Style::default()
4082                    .fg(Color::Yellow)
4083                    .add_modifier(Modifier::BOLD),
4084            ),
4085        ]));
4086    }
4087
4088    lines.push(Line::from(vec![
4089        Span::styled("Tool: ", label_style),
4090        Span::styled(
4091            tool_label,
4092            if app.current_tool.is_some() {
4093                Style::default()
4094                    .fg(Color::Cyan)
4095                    .add_modifier(Modifier::BOLD)
4096            } else {
4097                Style::default().fg(Color::DarkGray)
4098            },
4099        ),
4100    ]));
4101    lines.push(Line::from(""));
4102    lines.push(Line::styled(
4103        "Session",
4104        Style::default().add_modifier(Modifier::BOLD),
4105    ));
4106    lines.push(Line::from(vec![
4107        Span::styled("ID: ", label_style),
4108        Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
4109    ]));
4110    lines.push(Line::from(vec![
4111        Span::styled("Model: ", label_style),
4112        Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
4113    ]));
4114    let agent_display = if let Some(target) = &app.active_spawned_agent {
4115        format!("{} → @{} (focused)", app.current_agent, target)
4116    } else {
4117        app.current_agent.clone()
4118    };
4119    lines.push(Line::from(vec![
4120        Span::styled("Agent: ", label_style),
4121        Span::styled(agent_display, Style::default()),
4122    ]));
4123    lines.push(Line::from(vec![
4124        Span::styled("Messages: ", label_style),
4125        Span::styled(message_count.to_string(), Style::default()),
4126    ]));
4127    lines.push(Line::from(vec![
4128        Span::styled("Context: ", label_style),
4129        Span::styled(format!("{} turns", conversation_depth), Style::default()),
4130    ]));
4131    lines.push(Line::from(vec![
4132        Span::styled("Tools used: ", label_style),
4133        Span::styled(app.tool_call_count.to_string(), Style::default()),
4134    ]));
4135    lines.push(Line::from(""));
4136    lines.push(Line::styled(
4137        "Sub-agents",
4138        Style::default().add_modifier(Modifier::BOLD),
4139    ));
4140    if app.spawned_agents.is_empty() {
4141        lines.push(Line::styled(
4142            "None (use /spawn <name> <instructions>)",
4143            Style::default().fg(Color::DarkGray),
4144        ));
4145    } else {
4146        for (name, agent) in app.spawned_agents.iter().take(4) {
4147            let status = if agent.is_processing { "⚡" } else { "●" };
4148            let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
4149                " [focused]"
4150            } else {
4151                ""
4152            };
4153            lines.push(Line::styled(
4154                format!("{status} @{name}{focused}"),
4155                if focused.is_empty() {
4156                    Style::default().fg(Color::Magenta)
4157                } else {
4158                    Style::default()
4159                        .fg(Color::Magenta)
4160                        .add_modifier(Modifier::BOLD)
4161                },
4162            ));
4163            lines.push(Line::styled(
4164                format!("   {}", agent.instructions),
4165                Style::default()
4166                    .fg(Color::DarkGray)
4167                    .add_modifier(Modifier::DIM),
4168            ));
4169        }
4170        if app.spawned_agents.len() > 4 {
4171            lines.push(Line::styled(
4172                format!("… and {} more", app.spawned_agents.len() - 4),
4173                Style::default()
4174                    .fg(Color::DarkGray)
4175                    .add_modifier(Modifier::DIM),
4176            ));
4177        }
4178    }
4179    lines.push(Line::from(""));
4180    lines.push(Line::styled(
4181        "Shortcuts",
4182        Style::default().add_modifier(Modifier::BOLD),
4183    ));
4184    lines.push(Line::from(vec![
4185        Span::styled("F3      ", Style::default().fg(Color::Yellow)),
4186        Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
4187    ]));
4188    lines.push(Line::from(vec![
4189        Span::styled("Ctrl+B  ", Style::default().fg(Color::Yellow)),
4190        Span::styled("Layout", Style::default().fg(Color::DarkGray)),
4191    ]));
4192    lines.push(Line::from(vec![
4193        Span::styled("Ctrl+Y  ", Style::default().fg(Color::Yellow)),
4194        Span::styled("Copy", Style::default().fg(Color::DarkGray)),
4195    ]));
4196    lines.push(Line::from(vec![
4197        Span::styled("Ctrl+M  ", Style::default().fg(Color::Yellow)),
4198        Span::styled("Model", Style::default().fg(Color::DarkGray)),
4199    ]));
4200    lines.push(Line::from(vec![
4201        Span::styled("Ctrl+S  ", Style::default().fg(Color::Yellow)),
4202        Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
4203    ]));
4204    lines.push(Line::from(vec![
4205        Span::styled("?       ", Style::default().fg(Color::Yellow)),
4206        Span::styled("Help", Style::default().fg(Color::DarkGray)),
4207    ]));
4208
4209    let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
4210    f.render_widget(panel, area);
4211}
4212
4213fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
4214    let title = if app.is_processing {
4215        if let Some(started) = app.processing_started_at {
4216            let elapsed = started.elapsed();
4217            format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
4218        } else {
4219            " Message (Processing...) ".to_string()
4220        }
4221    } else if app.input.starts_with('/') {
4222        // Show matching slash commands as hints
4223        let hint = match_slash_command_hint(&app.input);
4224        format!(" {} ", hint)
4225    } else if let Some(target) = &app.active_spawned_agent {
4226        format!(" Message to @{target} (use /agent main to exit) ")
4227    } else {
4228        " Message (Enter to send, / for commands) ".to_string()
4229    };
4230
4231    let input_block = Block::default()
4232        .borders(Borders::ALL)
4233        .title(title)
4234        .border_style(Style::default().fg(if app.is_processing {
4235            Color::Yellow
4236        } else if app.input.starts_with('/') {
4237            Color::Magenta
4238        } else {
4239            theme.input_border_color.to_color()
4240        }));
4241
4242    let input = Paragraph::new(app.input.as_str())
4243        .block(input_block)
4244        .wrap(Wrap { trim: false });
4245    f.render_widget(input, area);
4246
4247    f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
4248}
4249
4250fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
4251    let mut message_lines = Vec::new();
4252    let separator_width = max_width.min(60);
4253
4254    for (idx, message) in app.messages.iter().enumerate() {
4255        let role_style = theme.get_role_style(&message.role);
4256
4257        // Add a thin separator between messages (not before the first)
4258        if idx > 0 {
4259            let sep_char = match message.role.as_str() {
4260                "tool" => "·",
4261                _ => "─",
4262            };
4263            message_lines.push(Line::from(Span::styled(
4264                sep_char.repeat(separator_width),
4265                Style::default()
4266                    .fg(theme.timestamp_color.to_color())
4267                    .add_modifier(Modifier::DIM),
4268            )));
4269        }
4270
4271        // Role icons for better visual hierarchy
4272        let role_icon = match message.role.as_str() {
4273            "user" => "▸ ",
4274            "assistant" => "◆ ",
4275            "system" => "⚙ ",
4276            "tool" => "⚡",
4277            _ => "  ",
4278        };
4279
4280        let header_line = {
4281            let mut spans = vec![
4282                Span::styled(
4283                    format!("[{}] ", message.timestamp),
4284                    Style::default()
4285                        .fg(theme.timestamp_color.to_color())
4286                        .add_modifier(Modifier::DIM),
4287                ),
4288                Span::styled(role_icon, role_style),
4289                Span::styled(message.role.clone(), role_style),
4290            ];
4291            if let Some(ref agent) = message.agent_name {
4292                spans.push(Span::styled(
4293                    format!(" @{agent}"),
4294                    Style::default()
4295                        .fg(Color::Magenta)
4296                        .add_modifier(Modifier::BOLD),
4297                ));
4298            }
4299            Line::from(spans)
4300        };
4301        message_lines.push(header_line);
4302
4303        match &message.message_type {
4304            MessageType::ToolCall {
4305                name,
4306                arguments_preview,
4307                arguments_len,
4308                truncated,
4309            } => {
4310                let tool_header = Line::from(vec![
4311                    Span::styled("  🔧 ", Style::default().fg(Color::Yellow)),
4312                    Span::styled(
4313                        format!("Tool: {}", name),
4314                        Style::default()
4315                            .fg(Color::Yellow)
4316                            .add_modifier(Modifier::BOLD),
4317                    ),
4318                ]);
4319                message_lines.push(tool_header);
4320
4321                if arguments_preview.trim().is_empty() {
4322                    message_lines.push(Line::from(vec![
4323                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4324                        Span::styled(
4325                            "(no arguments)",
4326                            Style::default()
4327                                .fg(Color::DarkGray)
4328                                .add_modifier(Modifier::DIM),
4329                        ),
4330                    ]));
4331                } else {
4332                    for line in arguments_preview.lines() {
4333                        let args_line = Line::from(vec![
4334                            Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4335                            Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
4336                        ]);
4337                        message_lines.push(args_line);
4338                    }
4339                }
4340
4341                if *truncated {
4342                    let args_line = Line::from(vec![
4343                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4344                        Span::styled(
4345                            format!("... (truncated; {} bytes)", arguments_len),
4346                            Style::default()
4347                                .fg(Color::DarkGray)
4348                                .add_modifier(Modifier::DIM),
4349                        ),
4350                    ]);
4351                    message_lines.push(args_line);
4352                }
4353            }
4354            MessageType::ToolResult {
4355                name,
4356                output_preview,
4357                output_len,
4358                truncated,
4359            } => {
4360                let result_header = Line::from(vec![
4361                    Span::styled("  ✅ ", Style::default().fg(Color::Green)),
4362                    Span::styled(
4363                        format!("Result from {}", name),
4364                        Style::default()
4365                            .fg(Color::Green)
4366                            .add_modifier(Modifier::BOLD),
4367                    ),
4368                ]);
4369                message_lines.push(result_header);
4370
4371                if output_preview.trim().is_empty() {
4372                    message_lines.push(Line::from(vec![
4373                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4374                        Span::styled(
4375                            "(empty output)",
4376                            Style::default()
4377                                .fg(Color::DarkGray)
4378                                .add_modifier(Modifier::DIM),
4379                        ),
4380                    ]));
4381                } else {
4382                    for line in output_preview.lines() {
4383                        let output_line = Line::from(vec![
4384                            Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4385                            Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
4386                        ]);
4387                        message_lines.push(output_line);
4388                    }
4389                }
4390
4391                if *truncated {
4392                    message_lines.push(Line::from(vec![
4393                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4394                        Span::styled(
4395                            format!("... (truncated; {} bytes)", output_len),
4396                            Style::default()
4397                                .fg(Color::DarkGray)
4398                                .add_modifier(Modifier::DIM),
4399                        ),
4400                    ]));
4401                }
4402            }
4403            MessageType::Text(text) => {
4404                let formatter = MessageFormatter::new(max_width);
4405                let formatted_content = formatter.format_content(text, &message.role);
4406                message_lines.extend(formatted_content);
4407            }
4408            MessageType::Thinking(text) => {
4409                let thinking_style = Style::default()
4410                    .fg(Color::DarkGray)
4411                    .add_modifier(Modifier::DIM | Modifier::ITALIC);
4412                message_lines.push(Line::from(Span::styled(
4413                    "  💭 Thinking...",
4414                    Style::default()
4415                        .fg(Color::Magenta)
4416                        .add_modifier(Modifier::DIM),
4417                )));
4418                // Show truncated thinking content
4419                let max_thinking_lines = 8;
4420                let mut iter = text.lines();
4421                let mut shown = 0usize;
4422                while shown < max_thinking_lines {
4423                    let Some(line) = iter.next() else { break };
4424                    message_lines.push(Line::from(vec![
4425                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
4426                        Span::styled(line.to_string(), thinking_style),
4427                    ]));
4428                    shown += 1;
4429                }
4430                if iter.next().is_some() {
4431                    message_lines.push(Line::from(Span::styled(
4432                        "  │ ... (truncated)",
4433                        thinking_style,
4434                    )));
4435                }
4436            }
4437            MessageType::Image { url, mime_type } => {
4438                let formatter = MessageFormatter::new(max_width);
4439                let image_line = formatter.format_image(url, mime_type.as_deref());
4440                message_lines.push(image_line);
4441            }
4442            MessageType::File { path, mime_type } => {
4443                let mime_label = mime_type.as_deref().unwrap_or("unknown type");
4444                let file_header = Line::from(vec![
4445                    Span::styled("  📎 ", Style::default().fg(Color::Cyan)),
4446                    Span::styled(
4447                        format!("File: {}", path),
4448                        Style::default()
4449                            .fg(Color::Cyan)
4450                            .add_modifier(Modifier::BOLD),
4451                    ),
4452                    Span::styled(
4453                        format!(" ({})", mime_label),
4454                        Style::default()
4455                            .fg(Color::DarkGray)
4456                            .add_modifier(Modifier::DIM),
4457                    ),
4458                ]);
4459                message_lines.push(file_header);
4460            }
4461        }
4462
4463        // Show usage indicator after assistant messages
4464        if message.role == "assistant" {
4465            if let Some(ref meta) = message.usage_meta {
4466                let duration_str = if meta.duration_ms >= 60_000 {
4467                    format!(
4468                        "{}m{:02}.{}s",
4469                        meta.duration_ms / 60_000,
4470                        (meta.duration_ms % 60_000) / 1000,
4471                        (meta.duration_ms % 1000) / 100
4472                    )
4473                } else {
4474                    format!(
4475                        "{}.{}s",
4476                        meta.duration_ms / 1000,
4477                        (meta.duration_ms % 1000) / 100
4478                    )
4479                };
4480                let tokens_str =
4481                    format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
4482                let cost_str = match meta.cost_usd {
4483                    Some(c) if c < 0.01 => format!("${:.4}", c),
4484                    Some(c) => format!("${:.2}", c),
4485                    None => String::new(),
4486                };
4487                let dim_style = Style::default()
4488                    .fg(theme.timestamp_color.to_color())
4489                    .add_modifier(Modifier::DIM);
4490                let mut spans = vec![Span::styled(
4491                    format!("  ⏱ {} │ 📊 {}", duration_str, tokens_str),
4492                    dim_style,
4493                )];
4494                if !cost_str.is_empty() {
4495                    spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
4496                }
4497                message_lines.push(Line::from(spans));
4498            }
4499        }
4500
4501        message_lines.push(Line::from(""));
4502    }
4503
4504    // Show streaming text preview (text arriving before TextComplete finalizes it)
4505    if let Some(ref streaming) = app.streaming_text {
4506        if !streaming.is_empty() {
4507            message_lines.push(Line::from(Span::styled(
4508                "─".repeat(separator_width),
4509                Style::default()
4510                    .fg(theme.timestamp_color.to_color())
4511                    .add_modifier(Modifier::DIM),
4512            )));
4513            message_lines.push(Line::from(vec![
4514                Span::styled(
4515                    format!("[{}] ", chrono::Local::now().format("%H:%M")),
4516                    Style::default()
4517                        .fg(theme.timestamp_color.to_color())
4518                        .add_modifier(Modifier::DIM),
4519                ),
4520                Span::styled("◆ ", theme.get_role_style("assistant")),
4521                Span::styled("assistant", theme.get_role_style("assistant")),
4522                Span::styled(
4523                    " (streaming...)",
4524                    Style::default()
4525                        .fg(theme.timestamp_color.to_color())
4526                        .add_modifier(Modifier::DIM),
4527                ),
4528            ]));
4529            let formatter = MessageFormatter::new(max_width);
4530            let formatted = formatter.format_content(streaming, "assistant");
4531            message_lines.extend(formatted);
4532            message_lines.push(Line::from(""));
4533        }
4534    }
4535
4536    if app.is_processing {
4537        let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
4538        let spinner_idx = (std::time::SystemTime::now()
4539            .duration_since(std::time::UNIX_EPOCH)
4540            .unwrap_or_default()
4541            .as_millis()
4542            / 100) as usize
4543            % spinner.len();
4544
4545        // Elapsed time display
4546        let elapsed_str = if let Some(started) = app.processing_started_at {
4547            let elapsed = started.elapsed();
4548            if elapsed.as_secs() >= 60 {
4549                format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4550            } else {
4551                format!(" {:.1}s", elapsed.as_secs_f64())
4552            }
4553        } else {
4554            String::new()
4555        };
4556
4557        let processing_line = Line::from(vec![
4558            Span::styled(
4559                format!("[{}] ", chrono::Local::now().format("%H:%M")),
4560                Style::default()
4561                    .fg(theme.timestamp_color.to_color())
4562                    .add_modifier(Modifier::DIM),
4563            ),
4564            Span::styled("◆ ", theme.get_role_style("assistant")),
4565            Span::styled("assistant", theme.get_role_style("assistant")),
4566            Span::styled(
4567                elapsed_str,
4568                Style::default()
4569                    .fg(theme.timestamp_color.to_color())
4570                    .add_modifier(Modifier::DIM),
4571            ),
4572        ]);
4573        message_lines.push(processing_line);
4574
4575        let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
4576            (
4577                format!("  {} Running: {}", spinner[spinner_idx], tool),
4578                Color::Cyan,
4579            )
4580        } else {
4581            (
4582                format!(
4583                    "  {} {}",
4584                    spinner[spinner_idx],
4585                    app.processing_message.as_deref().unwrap_or("Thinking...")
4586                ),
4587                Color::Yellow,
4588            )
4589        };
4590
4591        let indicator_line = Line::from(vec![Span::styled(
4592            status_text,
4593            Style::default()
4594                .fg(status_color)
4595                .add_modifier(Modifier::BOLD),
4596        )]);
4597        message_lines.push(indicator_line);
4598        message_lines.push(Line::from(""));
4599    }
4600
4601    message_lines
4602}
4603
4604fn match_slash_command_hint(input: &str) -> String {
4605    let commands = [
4606        ("/spawn ", "Create a named sub-agent"),
4607        ("/agents", "List spawned sub-agents"),
4608        ("/kill ", "Remove a spawned sub-agent"),
4609        ("/agent ", "Focus or message a spawned sub-agent"),
4610        ("/swarm ", "Run task in parallel swarm mode"),
4611        ("/ralph", "Start autonomous PRD loop"),
4612        ("/undo", "Undo last message and response"),
4613        ("/sessions", "Open session picker"),
4614        ("/resume", "Resume a session"),
4615        ("/new", "Start a new session"),
4616        ("/model", "Select or set model"),
4617        ("/webview", "Switch to webview layout"),
4618        ("/classic", "Switch to classic layout"),
4619        ("/inspector", "Toggle inspector pane"),
4620        ("/refresh", "Refresh workspace"),
4621        ("/view", "Toggle swarm view"),
4622        ("/buslog", "Show protocol bus log"),
4623    ];
4624
4625    let input_lower = input.to_lowercase();
4626    let matches: Vec<_> = commands
4627        .iter()
4628        .filter(|(cmd, _)| cmd.starts_with(&input_lower))
4629        .collect();
4630
4631    if matches.len() == 1 {
4632        format!("{} — {}", matches[0].0.trim(), matches[0].1)
4633    } else if matches.is_empty() {
4634        "Unknown command".to_string()
4635    } else {
4636        let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
4637        cmds.join(" | ")
4638    }
4639}
4640
4641fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
4642    // Avoid expensive JSON parsing/pretty-printing for very large payloads.
4643    // Large tool arguments are common (e.g., patches) and reformatting them provides
4644    // little value in a terminal preview.
4645    if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
4646        return arguments.to_string();
4647    }
4648
4649    let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
4650        Ok(value) => value,
4651        Err(_) => return arguments.to_string(),
4652    };
4653
4654    if name == "question"
4655        && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
4656    {
4657        return question.to_string();
4658    }
4659
4660    serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
4661}
4662
4663fn build_tool_arguments_preview(
4664    tool_name: &str,
4665    arguments: &str,
4666    max_lines: usize,
4667    max_bytes: usize,
4668) -> (String, bool) {
4669    // Pretty-print when reasonably sized; otherwise keep raw to avoid a heavy parse.
4670    let formatted = format_tool_call_arguments(tool_name, arguments);
4671    build_text_preview(&formatted, max_lines, max_bytes)
4672}
4673
4674/// Build a stable, size-limited preview used by the renderer.
4675///
4676/// Returns (preview_text, truncated).
4677fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
4678    if max_lines == 0 || max_bytes == 0 || text.is_empty() {
4679        return (String::new(), !text.is_empty());
4680    }
4681
4682    let mut out = String::new();
4683    let mut truncated = false;
4684    let mut remaining = max_bytes;
4685
4686    let mut iter = text.lines();
4687    for i in 0..max_lines {
4688        let Some(line) = iter.next() else { break };
4689
4690        // Add newline separator if needed
4691        if i > 0 {
4692            if remaining == 0 {
4693                truncated = true;
4694                break;
4695            }
4696            out.push('\n');
4697            remaining = remaining.saturating_sub(1);
4698        }
4699
4700        if remaining == 0 {
4701            truncated = true;
4702            break;
4703        }
4704
4705        if line.len() <= remaining {
4706            out.push_str(line);
4707            remaining = remaining.saturating_sub(line.len());
4708        } else {
4709            // Truncate this line to remaining bytes, respecting UTF-8 boundaries.
4710            let mut end = remaining;
4711            while end > 0 && !line.is_char_boundary(end) {
4712                end -= 1;
4713            }
4714            out.push_str(&line[..end]);
4715            truncated = true;
4716            break;
4717        }
4718    }
4719
4720    // If there are still lines left, we truncated.
4721    if !truncated && iter.next().is_some() {
4722        truncated = true;
4723    }
4724
4725    (out, truncated)
4726}
4727
4728fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
4729    if max_chars == 0 {
4730        return String::new();
4731    }
4732
4733    let mut chars = value.chars();
4734    let mut output = String::new();
4735    for _ in 0..max_chars {
4736        if let Some(ch) = chars.next() {
4737            output.push(ch);
4738        } else {
4739            return value.to_string();
4740        }
4741    }
4742
4743    if chars.next().is_some() {
4744        format!("{output}...")
4745    } else {
4746        output
4747    }
4748}
4749
4750fn message_clipboard_text(message: &ChatMessage) -> String {
4751    let mut prefix = String::new();
4752    if let Some(agent) = &message.agent_name {
4753        prefix = format!("@{agent}\n");
4754    }
4755
4756    match &message.message_type {
4757        MessageType::Text(text) => format!("{prefix}{text}"),
4758        MessageType::Thinking(text) => format!("{prefix}{text}"),
4759        MessageType::Image { url, .. } => format!("{prefix}{url}"),
4760        MessageType::File { path, .. } => format!("{prefix}{path}"),
4761        MessageType::ToolCall {
4762            name,
4763            arguments_preview,
4764            ..
4765        } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
4766        MessageType::ToolResult {
4767            name,
4768            output_preview,
4769            ..
4770        } => format!("{prefix}Tool result: {name}\n{output_preview}"),
4771    }
4772}
4773
4774fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
4775    if text.trim().is_empty() {
4776        return Err("empty text".to_string());
4777    }
4778
4779    // 1) Try system clipboard first (works locally when a clipboard provider is available)
4780    match arboard::Clipboard::new()
4781        .and_then(|mut clipboard| clipboard.set_text(text.to_string()))
4782    {
4783        Ok(()) => return Ok("system clipboard"),
4784        Err(e) => {
4785            tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
4786        }
4787    }
4788
4789    // 2) Fallback: OSC52 (works in many terminals, including remote SSH sessions)
4790    osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
4791    Ok("OSC52")
4792}
4793
4794fn osc52_copy(text: &str) -> std::io::Result<()> {
4795    // OSC52 format: ESC ] 52 ; c ; <base64> BEL
4796    // Some terminals may disable OSC52 for security; we treat this as best-effort.
4797    let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
4798    let seq = format!("\u{1b}]52;c;{payload}\u{07}");
4799
4800    let mut stdout = std::io::stdout();
4801    crossterm::execute!(stdout, crossterm::style::Print(seq))?;
4802    use std::io::Write;
4803    stdout.flush()?;
4804    Ok(())
4805}
4806
4807fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
4808    if !app.show_help {
4809        return;
4810    }
4811
4812    let area = centered_rect(60, 60, f.area());
4813    f.render_widget(Clear, area);
4814
4815    let token_display = TokenDisplay::new();
4816    let token_info = token_display.create_detailed_display();
4817
4818    // Model / provider info
4819    let model_section: Vec<String> = if let Some(ref active) = app.active_model {
4820        let (provider, model) = crate::provider::parse_model_string(active);
4821        let provider_label = provider.unwrap_or("auto");
4822        vec![
4823            "".to_string(),
4824            "  ACTIVE MODEL".to_string(),
4825            "  ==============".to_string(),
4826            format!("  Provider:  {}", provider_label),
4827            format!("  Model:     {}", model),
4828            format!("  Agent:     {}", app.current_agent),
4829        ]
4830    } else {
4831        vec![
4832            "".to_string(),
4833            "  ACTIVE MODEL".to_string(),
4834            "  ==============".to_string(),
4835            format!("  Provider:  auto"),
4836            format!("  Model:     (default)"),
4837            format!("  Agent:     {}", app.current_agent),
4838        ]
4839    };
4840
4841    let help_text: Vec<String> = vec![
4842        "".to_string(),
4843        "  KEYBOARD SHORTCUTS".to_string(),
4844        "  ==================".to_string(),
4845        "".to_string(),
4846        "  Enter        Send message".to_string(),
4847        "  Tab          Switch between build/plan agents".to_string(),
4848        "  Ctrl+A       Open spawned-agent picker".to_string(),
4849        "  Ctrl+M       Open model picker".to_string(),
4850        "  Ctrl+L       Protocol bus log".to_string(),
4851        "  Ctrl+S       Toggle swarm view".to_string(),
4852        "  Ctrl+B       Toggle webview layout".to_string(),
4853        "  Ctrl+Y       Copy latest assistant reply".to_string(),
4854        "  F3           Toggle inspector pane".to_string(),
4855        "  Ctrl+C       Quit".to_string(),
4856        "  ?            Toggle this help".to_string(),
4857        "".to_string(),
4858        "  SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
4859        "  /spawn <name> <instructions>  Create a named sub-agent".to_string(),
4860        "  /agents        List spawned sub-agents".to_string(),
4861        "  /kill <name>   Remove a spawned sub-agent".to_string(),
4862        "  /agent <name>  Focus chat on a spawned sub-agent".to_string(),
4863        "  /agent <name> <message>  Send one message to a spawned sub-agent"
4864            .to_string(),
4865        "  /agent            Open spawned-agent picker"
4866            .to_string(),
4867        "  /agent main|off  Exit focused sub-agent chat"
4868            .to_string(),
4869        "  /swarm <task>   Run task in parallel swarm mode".to_string(),
4870        "  /ralph [path]   Start Ralph PRD loop (default: prd.json)".to_string(),
4871        "  /undo           Undo last message and response".to_string(),
4872        "  /sessions       Open session picker (filter, delete, load)".to_string(),
4873        "  /resume         Resume most recent session".to_string(),
4874        "  /resume <id>    Resume specific session by ID".to_string(),
4875        "  /new            Start a fresh session".to_string(),
4876        "  /model          Open model picker (or /model <name>)".to_string(),
4877        "  /view           Toggle swarm view".to_string(),
4878        "  /buslog         Show protocol bus log".to_string(),
4879        "  /webview        Web dashboard layout".to_string(),
4880        "  /classic        Single-pane layout".to_string(),
4881        "  /inspector      Toggle inspector pane".to_string(),
4882        "  /refresh        Refresh workspace and sessions".to_string(),
4883        "".to_string(),
4884        "  SESSION PICKER".to_string(),
4885        "  ↑/↓/j/k      Navigate sessions".to_string(),
4886        "  Enter         Load selected session".to_string(),
4887        "  d             Delete session (press twice to confirm)".to_string(),
4888        "  Type          Filter sessions by name/agent/ID".to_string(),
4889        "  Backspace     Clear filter character".to_string(),
4890        "  Esc           Close picker".to_string(),
4891        "".to_string(),
4892        "  VIM-STYLE NAVIGATION".to_string(),
4893        "  Alt+j        Scroll down".to_string(),
4894        "  Alt+k        Scroll up".to_string(),
4895        "  Ctrl+g       Go to top".to_string(),
4896        "  Ctrl+G       Go to bottom".to_string(),
4897        "".to_string(),
4898        "  SCROLLING".to_string(),
4899        "  Up/Down      Scroll messages".to_string(),
4900        "  PageUp/Dn    Scroll one page".to_string(),
4901        "  Alt+u/d      Scroll half page".to_string(),
4902        "".to_string(),
4903        "  COMMAND HISTORY".to_string(),
4904        "  Ctrl+R       Search history".to_string(),
4905        "  Ctrl+Up/Dn   Navigate history".to_string(),
4906        "".to_string(),
4907        "  Press ? or Esc to close".to_string(),
4908        "".to_string(),
4909    ];
4910
4911    let mut combined_text = token_info;
4912    combined_text.extend(model_section);
4913    combined_text.extend(help_text);
4914
4915    let help = Paragraph::new(combined_text.join("\n"))
4916        .block(
4917            Block::default()
4918                .borders(Borders::ALL)
4919                .title(" Help ")
4920                .border_style(Style::default().fg(theme.help_border_color.to_color())),
4921        )
4922        .wrap(Wrap { trim: false });
4923
4924    f.render_widget(help, area);
4925}
4926
4927/// Helper to create a centered rect
4928fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
4929    let popup_layout = Layout::default()
4930        .direction(Direction::Vertical)
4931        .constraints([
4932            Constraint::Percentage((100 - percent_y) / 2),
4933            Constraint::Percentage(percent_y),
4934            Constraint::Percentage((100 - percent_y) / 2),
4935        ])
4936        .split(r);
4937
4938    Layout::default()
4939        .direction(Direction::Horizontal)
4940        .constraints([
4941            Constraint::Percentage((100 - percent_x) / 2),
4942            Constraint::Percentage(percent_x),
4943            Constraint::Percentage((100 - percent_x) / 2),
4944        ])
4945        .split(popup_layout[1])[1]
4946}