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