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;
28const AUTOCHAT_QUICK_DEMO_TASK: &str = "Introduce yourselves with your role/personality, then relay one concrete implementation plan with clear next handoffs.";
29const GO_SWAP_MODEL_GLM: &str = "zai/glm-5";
30const GO_SWAP_MODEL_MINIMAX: &str = "minimax/MiniMax-M2.5";
31const CHAT_SYNC_DEFAULT_INTERVAL_SECS: u64 = 15;
32const CHAT_SYNC_MAX_INTERVAL_SECS: u64 = 300;
33const CHAT_SYNC_MAX_BATCH_BYTES: usize = 512 * 1024;
34const CHAT_SYNC_DEFAULT_BUCKET: &str = "codetether-chat-archive";
35const CHAT_SYNC_DEFAULT_PREFIX: &str = "sessions";
36const AGENT_AVATARS: [&str; 12] = [
37    "[o_o]", "[^_^]", "[>_<]", "[._.]", "[+_+]", "[~_~]", "[x_x]", "[0_0]", "[*_*]", "[=_=]",
38    "[T_T]", "[u_u]",
39];
40
41use crate::bus::relay::{ProtocolRelayRuntime, RelayAgentProfile};
42use crate::config::Config;
43use crate::provider::{ContentPart, Role};
44use crate::ralph::{RalphConfig, RalphLoop};
45use crate::rlm::RlmExecutor;
46use crate::session::{Session, SessionEvent, SessionSummary, list_sessions_with_opencode};
47use crate::swarm::{DecompositionStrategy, SwarmConfig, SwarmExecutor};
48use crate::tui::bus_log::{BusLogState, render_bus_log};
49use crate::tui::message_formatter::MessageFormatter;
50use crate::tui::ralph_view::{RalphEvent, RalphViewState, render_ralph_view};
51use crate::tui::swarm_view::{SwarmEvent, SwarmViewState, render_swarm_view};
52use crate::tui::theme::Theme;
53use crate::tui::token_display::TokenDisplay;
54use anyhow::Result;
55use base64::Engine;
56use crossterm::{
57    event::{
58        DisableBracketedPaste, EnableBracketedPaste, Event, EventStream, KeyCode, KeyEventKind,
59        KeyModifiers,
60    },
61    execute,
62    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
63};
64use futures::StreamExt;
65use minio::s3::builders::ObjectContent;
66use minio::s3::creds::StaticProvider;
67use minio::s3::http::BaseUrl;
68use minio::s3::types::S3Api;
69use minio::s3::{Client as MinioClient, ClientBuilder as MinioClientBuilder};
70use ratatui::{
71    Frame, Terminal,
72    backend::CrosstermBackend,
73    layout::{Constraint, Direction, Layout, Rect},
74    style::{Color, Modifier, Style},
75    text::{Line, Span},
76    widgets::{
77        Block, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap,
78    },
79};
80use serde::Serialize;
81use std::collections::HashMap;
82use std::io::{self, Read, Seek, SeekFrom, Write};
83use std::path::{Path, PathBuf};
84use std::process::Command;
85use std::time::{Duration, Instant};
86use tokio::sync::mpsc;
87
88/// Run the TUI
89pub async fn run(project: Option<PathBuf>) -> Result<()> {
90    // Change to project directory if specified
91    if let Some(dir) = project {
92        std::env::set_current_dir(&dir)?;
93    }
94
95    // Setup terminal
96    enable_raw_mode()?;
97    let mut stdout = io::stdout();
98    execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
99    let backend = CrosstermBackend::new(stdout);
100    let mut terminal = Terminal::new(backend)?;
101
102    // Run the app
103    let result = run_app(&mut terminal).await;
104
105    // Restore terminal
106    disable_raw_mode()?;
107    execute!(
108        terminal.backend_mut(),
109        LeaveAlternateScreen,
110        DisableBracketedPaste
111    )?;
112    terminal.show_cursor()?;
113
114    result
115}
116
117/// Message type for chat display
118#[derive(Debug, Clone)]
119enum MessageType {
120    Text(String),
121    Image {
122        url: String,
123        mime_type: Option<String>,
124    },
125    ToolCall {
126        name: String,
127        arguments_preview: String,
128        arguments_len: usize,
129        truncated: bool,
130    },
131    ToolResult {
132        name: String,
133        output_preview: String,
134        output_len: usize,
135        truncated: bool,
136        success: bool,
137        duration_ms: Option<u64>,
138    },
139    File {
140        path: String,
141        mime_type: Option<String>,
142    },
143    Thinking(String),
144}
145
146/// View mode for the TUI
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148enum ViewMode {
149    Chat,
150    Swarm,
151    Ralph,
152    BusLog,
153    Protocol,
154    SessionPicker,
155    ModelPicker,
156    AgentPicker,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
160enum ChatLayoutMode {
161    Classic,
162    Webview,
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166enum WorkspaceEntryKind {
167    Directory,
168    File,
169}
170
171#[derive(Debug, Clone)]
172struct WorkspaceEntry {
173    name: String,
174    kind: WorkspaceEntryKind,
175}
176
177#[derive(Debug, Clone, Default)]
178struct WorkspaceSnapshot {
179    root_display: String,
180    git_branch: Option<String>,
181    git_dirty_files: usize,
182    entries: Vec<WorkspaceEntry>,
183    captured_at: String,
184}
185
186/// Application state
187struct App {
188    input: String,
189    cursor_position: usize,
190    messages: Vec<ChatMessage>,
191    current_agent: String,
192    scroll: usize,
193    show_help: bool,
194    command_history: Vec<String>,
195    history_index: Option<usize>,
196    session: Option<Session>,
197    is_processing: bool,
198    processing_message: Option<String>,
199    current_tool: Option<String>,
200    current_tool_started_at: Option<Instant>,
201    /// Tracks when processing started for elapsed timer display
202    processing_started_at: Option<Instant>,
203    /// Partial streaming text being assembled (shown with typing indicator)
204    streaming_text: Option<String>,
205    /// Partial streaming text per spawned agent (shown live in chat)
206    streaming_agent_texts: HashMap<String, String>,
207    /// Total tool calls in this session for inspector
208    tool_call_count: usize,
209    response_rx: Option<mpsc::Receiver<SessionEvent>>,
210    /// Cached provider registry to avoid reloading from Vault on every message
211    provider_registry: Option<std::sync::Arc<crate::provider::ProviderRegistry>>,
212    /// Working directory for workspace-scoped session filtering
213    workspace_dir: PathBuf,
214    // Swarm mode state
215    view_mode: ViewMode,
216    chat_layout: ChatLayoutMode,
217    show_inspector: bool,
218    workspace: WorkspaceSnapshot,
219    swarm_state: SwarmViewState,
220    swarm_rx: Option<mpsc::Receiver<SwarmEvent>>,
221    // Ralph mode state
222    ralph_state: RalphViewState,
223    ralph_rx: Option<mpsc::Receiver<RalphEvent>>,
224    // Bus protocol log state
225    bus_log_state: BusLogState,
226    bus_log_rx: Option<mpsc::Receiver<crate::bus::BusEnvelope>>,
227    bus: Option<std::sync::Arc<crate::bus::AgentBus>>,
228    // Session picker state
229    session_picker_list: Vec<SessionSummary>,
230    session_picker_selected: usize,
231    session_picker_filter: String,
232    session_picker_confirm_delete: bool,
233    // Model picker state
234    model_picker_list: Vec<(String, String, String)>, // (display label, provider/model value, human name)
235    model_picker_selected: usize,
236    model_picker_filter: String,
237    // Agent picker state
238    agent_picker_selected: usize,
239    agent_picker_filter: String,
240    // Protocol registry view state
241    protocol_selected: usize,
242    protocol_scroll: usize,
243    active_model: Option<String>,
244    // Spawned sub-agents state
245    active_spawned_agent: Option<String>,
246    spawned_agents: HashMap<String, SpawnedAgent>,
247    agent_response_rxs: Vec<(String, mpsc::Receiver<SessionEvent>)>,
248    agent_tool_started_at: HashMap<String, Instant>,
249    autochat_rx: Option<mpsc::Receiver<AutochatUiEvent>>,
250    autochat_running: bool,
251    autochat_started_at: Option<Instant>,
252    autochat_status: Option<String>,
253    chat_archive_path: Option<PathBuf>,
254    archived_message_count: usize,
255    chat_sync_rx: Option<mpsc::Receiver<ChatSyncUiEvent>>,
256    chat_sync_status: Option<String>,
257    chat_sync_last_success: Option<String>,
258    chat_sync_last_error: Option<String>,
259    chat_sync_uploaded_bytes: u64,
260    chat_sync_uploaded_batches: usize,
261    secure_environment: bool,
262    // Cached max scroll for key handlers
263    last_max_scroll: usize,
264}
265
266#[allow(dead_code)]
267struct ChatMessage {
268    role: String,
269    content: String,
270    timestamp: String,
271    message_type: MessageType,
272    /// Per-step usage metadata (set on assistant messages)
273    usage_meta: Option<UsageMeta>,
274    /// Name of the spawned agent that produced this message (None = main chat)
275    agent_name: Option<String>,
276}
277
278/// A spawned sub-agent with its own independent LLM session.
279#[allow(dead_code)]
280struct SpawnedAgent {
281    /// User-facing name (e.g. "planner", "coder")
282    name: String,
283    /// System instructions for this agent
284    instructions: String,
285    /// Independent conversation session
286    session: Session,
287    /// Whether this agent is currently processing a message
288    is_processing: bool,
289}
290
291#[derive(Debug, Clone, Copy)]
292struct AgentProfile {
293    codename: &'static str,
294    profile: &'static str,
295    personality: &'static str,
296    collaboration_style: &'static str,
297    signature_move: &'static str,
298}
299
300/// Token usage + cost + latency for one LLM round-trip
301#[derive(Debug, Clone)]
302struct UsageMeta {
303    prompt_tokens: usize,
304    completion_tokens: usize,
305    duration_ms: u64,
306    cost_usd: Option<f64>,
307}
308
309enum AutochatUiEvent {
310    Progress(String),
311    SystemMessage(String),
312    AgentEvent {
313        agent_name: String,
314        event: SessionEvent,
315    },
316    Completed {
317        summary: String,
318    },
319}
320
321#[derive(Debug, Clone)]
322struct ChatSyncConfig {
323    endpoint: String,
324    fallback_endpoint: Option<String>,
325    access_key: String,
326    secret_key: String,
327    bucket: String,
328    prefix: String,
329    interval_secs: u64,
330    ignore_cert_check: bool,
331}
332
333enum ChatSyncUiEvent {
334    Status(String),
335    BatchUploaded {
336        bytes: u64,
337        records: usize,
338        object_key: String,
339    },
340    Error(String),
341}
342
343/// Estimate USD cost from model name and token counts.
344/// Uses approximate per-million-token pricing for well-known models.
345fn estimate_cost(model: &str, prompt_tokens: usize, completion_tokens: usize) -> Option<f64> {
346    let normalized_model = model.to_ascii_lowercase();
347
348    // (input $/M, output $/M)
349    let (input_rate, output_rate) = match normalized_model.as_str() {
350        // Anthropic - Claude
351        m if m.contains("claude-opus") => (15.0, 75.0),
352        m if m.contains("claude-sonnet") => (3.0, 15.0),
353        m if m.contains("claude-haiku") => (0.25, 1.25),
354        // OpenAI
355        m if m.contains("gpt-4o-mini") => (0.15, 0.6),
356        m if m.contains("gpt-4o") => (2.5, 10.0),
357        m if m.contains("o3") => (10.0, 40.0),
358        m if m.contains("o4-mini") => (1.10, 4.40),
359        // Google
360        m if m.contains("gemini-2.5-pro") => (1.25, 10.0),
361        m if m.contains("gemini-2.5-flash") => (0.15, 0.6),
362        m if m.contains("gemini-2.0-flash") => (0.10, 0.40),
363        // Bedrock third-party
364        m if m.contains("kimi-k2") => (0.35, 1.40),
365        m if m.contains("deepseek") => (0.80, 2.0),
366        m if m.contains("llama") => (0.50, 1.50),
367        // MiniMax
368        // Based on MiniMax M2.5 announcement (2026-02-12):
369        // Lightning: $0.3/M input, $2.4/M output
370        // M2.5: half of Lightning pricing
371        m if m.contains("minimax") && m.contains("m2.5-lightning") => (0.30, 2.40),
372        m if m.contains("minimax") && m.contains("m2.5") => (0.15, 1.20),
373        // Amazon Nova
374        m if m.contains("nova-pro") => (0.80, 3.20),
375        m if m.contains("nova-lite") => (0.06, 0.24),
376        m if m.contains("nova-micro") => (0.035, 0.14),
377        // Z.AI GLM
378        m if m.contains("glm-5") => (2.0, 8.0),
379        m if m.contains("glm-4.7-flash") => (0.0, 0.0),
380        m if m.contains("glm-4.7") => (0.50, 2.0),
381        m if m.contains("glm-4") => (0.35, 1.40),
382        _ => return None,
383    };
384    let cost =
385        (prompt_tokens as f64 * input_rate + completion_tokens as f64 * output_rate) / 1_000_000.0;
386    Some(cost)
387}
388
389fn current_spinner_frame() -> &'static str {
390    const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
391    let idx = (std::time::SystemTime::now()
392        .duration_since(std::time::UNIX_EPOCH)
393        .unwrap_or_default()
394        .as_millis()
395        / 100) as usize
396        % SPINNER.len();
397    SPINNER[idx]
398}
399
400fn format_duration_ms(duration_ms: u64) -> String {
401    if duration_ms >= 60_000 {
402        format!(
403            "{}m{:02}s",
404            duration_ms / 60_000,
405            (duration_ms % 60_000) / 1000
406        )
407    } else if duration_ms >= 1000 {
408        format!("{:.1}s", duration_ms as f64 / 1000.0)
409    } else {
410        format!("{duration_ms}ms")
411    }
412}
413
414fn format_bytes(bytes: u64) -> String {
415    const KB: f64 = 1024.0;
416    const MB: f64 = KB * 1024.0;
417    const GB: f64 = MB * 1024.0;
418
419    if bytes < 1024 {
420        format!("{bytes}B")
421    } else if (bytes as f64) < MB {
422        format!("{:.1}KB", bytes as f64 / KB)
423    } else if (bytes as f64) < GB {
424        format!("{:.1}MB", bytes as f64 / MB)
425    } else {
426        format!("{:.2}GB", bytes as f64 / GB)
427    }
428}
429
430fn env_non_empty(name: &str) -> Option<String> {
431    std::env::var(name)
432        .ok()
433        .map(|value| value.trim().to_string())
434        .filter(|value| !value.is_empty())
435}
436
437fn env_bool(name: &str) -> Option<bool> {
438    let value = env_non_empty(name)?;
439    let value = value.to_ascii_lowercase();
440    match value.as_str() {
441        "1" | "true" | "yes" | "on" => Some(true),
442        "0" | "false" | "no" | "off" => Some(false),
443        _ => None,
444    }
445}
446
447fn parse_bool_str(value: &str) -> Option<bool> {
448    match value.trim().to_ascii_lowercase().as_str() {
449        "1" | "true" | "yes" | "on" => Some(true),
450        "0" | "false" | "no" | "off" => Some(false),
451        _ => None,
452    }
453}
454
455fn is_placeholder_secret(value: &str) -> bool {
456    matches!(
457        value.trim().to_ascii_lowercase().as_str(),
458        "replace-me" | "changeme" | "change-me" | "your-token" | "your-key"
459    )
460}
461
462fn env_non_placeholder(name: &str) -> Option<String> {
463    env_non_empty(name).filter(|value| !is_placeholder_secret(value))
464}
465
466fn vault_extra_string(secrets: &crate::secrets::ProviderSecrets, keys: &[&str]) -> Option<String> {
467    keys.iter().find_map(|key| {
468        secrets
469            .extra
470            .get(*key)
471            .and_then(|value| value.as_str())
472            .map(|value| value.trim().to_string())
473            .filter(|value| !value.is_empty())
474    })
475}
476
477fn vault_extra_bool(secrets: &crate::secrets::ProviderSecrets, keys: &[&str]) -> Option<bool> {
478    keys.iter().find_map(|key| {
479        secrets.extra.get(*key).and_then(|value| match value {
480            serde_json::Value::Bool(flag) => Some(*flag),
481            serde_json::Value::String(text) => parse_bool_str(text),
482            _ => None,
483        })
484    })
485}
486
487fn is_secure_environment_from_values(
488    secure_environment: Option<bool>,
489    secure_env: Option<bool>,
490    environment_name: Option<&str>,
491) -> bool {
492    if let Some(value) = secure_environment {
493        return value;
494    }
495
496    if let Some(value) = secure_env {
497        return value;
498    }
499
500    environment_name.is_some_and(|value| {
501        matches!(
502            value.trim().to_ascii_lowercase().as_str(),
503            "secure" | "production" | "prod"
504        )
505    })
506}
507
508fn is_secure_environment() -> bool {
509    is_secure_environment_from_values(
510        env_bool("CODETETHER_SECURE_ENVIRONMENT"),
511        env_bool("CODETETHER_SECURE_ENV"),
512        env_non_empty("CODETETHER_ENV").as_deref(),
513    )
514}
515
516fn normalize_minio_endpoint(endpoint: &str) -> String {
517    let mut normalized = endpoint.trim().trim_end_matches('/').to_string();
518    if let Some(stripped) = normalized.strip_suffix("/login") {
519        normalized = stripped.trim_end_matches('/').to_string();
520    }
521    if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
522        normalized = format!("http://{normalized}");
523    }
524    normalized
525}
526
527fn minio_fallback_endpoint(endpoint: &str) -> Option<String> {
528    if endpoint.contains(":9001") {
529        Some(endpoint.replacen(":9001", ":9000", 1))
530    } else {
531        None
532    }
533}
534
535fn sanitize_s3_key_segment(value: &str) -> String {
536    let mut out = String::with_capacity(value.len());
537    for ch in value.chars() {
538        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
539            out.push(ch);
540        } else {
541            out.push('-');
542        }
543    }
544    let cleaned = out.trim_matches('-').to_string();
545    if cleaned.is_empty() {
546        "unknown".to_string()
547    } else {
548        cleaned
549    }
550}
551
552async fn parse_chat_sync_config(
553    require_chat_sync: bool,
554) -> std::result::Result<Option<ChatSyncConfig>, String> {
555    let enabled = env_bool("CODETETHER_CHAT_SYNC_ENABLED").unwrap_or(false);
556    if !enabled {
557        if require_chat_sync {
558            return Err(
559                "CODETETHER_CHAT_SYNC_ENABLED must be true in secure environment".to_string(),
560            );
561        }
562        return Ok(None);
563    }
564
565    let vault_secrets = crate::secrets::get_provider_secrets("chat-sync-minio").await;
566
567    let endpoint_raw = env_non_empty("CODETETHER_CHAT_SYNC_MINIO_ENDPOINT")
568        .or_else(|| {
569            vault_secrets.as_ref().and_then(|secrets| {
570                secrets
571                    .base_url
572                    .clone()
573                    .filter(|value| !value.trim().is_empty())
574                    .or_else(|| vault_extra_string(secrets, &["endpoint", "minio_endpoint"]))
575            })
576        })
577        .ok_or_else(|| {
578            "CODETETHER_CHAT_SYNC_MINIO_ENDPOINT is required when chat sync is enabled (env or Vault: codetether/providers/chat-sync-minio.base_url)".to_string()
579        })?;
580    let endpoint = normalize_minio_endpoint(&endpoint_raw);
581    let fallback_endpoint = minio_fallback_endpoint(&endpoint);
582
583    let access_key = env_non_placeholder("CODETETHER_CHAT_SYNC_MINIO_ACCESS_KEY")
584        .or_else(|| {
585            vault_secrets.as_ref().and_then(|secrets| {
586                vault_extra_string(
587                    secrets,
588                    &[
589                        "access_key",
590                        "minio_access_key",
591                        "username",
592                        "key",
593                        "api_key",
594                    ],
595                )
596                .or_else(|| {
597                    secrets
598                        .api_key
599                        .as_ref()
600                        .map(|value| value.trim().to_string())
601                        .filter(|value| !value.is_empty())
602                })
603            })
604        })
605        .ok_or_else(|| {
606            "CODETETHER_CHAT_SYNC_MINIO_ACCESS_KEY is required when chat sync is enabled (env or Vault: codetether/providers/chat-sync-minio.access_key/api_key)".to_string()
607        })?;
608    let secret_key = env_non_placeholder("CODETETHER_CHAT_SYNC_MINIO_SECRET_KEY")
609        .or_else(|| {
610            vault_secrets.as_ref().and_then(|secrets| {
611                vault_extra_string(
612                    secrets,
613                    &[
614                        "secret_key",
615                        "minio_secret_key",
616                        "password",
617                        "secret",
618                        "api_secret",
619                    ],
620                )
621            })
622        })
623        .ok_or_else(|| {
624            "CODETETHER_CHAT_SYNC_MINIO_SECRET_KEY is required when chat sync is enabled (env or Vault: codetether/providers/chat-sync-minio.secret_key)".to_string()
625        })?;
626
627    let bucket = env_non_empty("CODETETHER_CHAT_SYNC_MINIO_BUCKET")
628        .or_else(|| {
629            vault_secrets
630                .as_ref()
631                .and_then(|secrets| vault_extra_string(secrets, &["bucket", "minio_bucket"]))
632        })
633        .unwrap_or_else(|| CHAT_SYNC_DEFAULT_BUCKET.to_string());
634    let prefix = env_non_empty("CODETETHER_CHAT_SYNC_MINIO_PREFIX")
635        .or_else(|| {
636            vault_secrets
637                .as_ref()
638                .and_then(|secrets| vault_extra_string(secrets, &["prefix", "minio_prefix"]))
639        })
640        .unwrap_or_else(|| CHAT_SYNC_DEFAULT_PREFIX.to_string())
641        .trim_matches('/')
642        .to_string();
643
644    let interval_secs = env_non_empty("CODETETHER_CHAT_SYNC_INTERVAL_SECS")
645        .and_then(|value| value.parse::<u64>().ok())
646        .unwrap_or(CHAT_SYNC_DEFAULT_INTERVAL_SECS)
647        .clamp(1, CHAT_SYNC_MAX_INTERVAL_SECS);
648
649    let ignore_cert_check = env_bool("CODETETHER_CHAT_SYNC_MINIO_INSECURE_SKIP_TLS_VERIFY")
650        .or_else(|| {
651            vault_secrets.as_ref().and_then(|secrets| {
652                vault_extra_bool(
653                    secrets,
654                    &[
655                        "insecure_skip_tls_verify",
656                        "ignore_cert_check",
657                        "skip_tls_verify",
658                    ],
659                )
660            })
661        })
662        .unwrap_or(false);
663
664    Ok(Some(ChatSyncConfig {
665        endpoint,
666        fallback_endpoint,
667        access_key,
668        secret_key,
669        bucket,
670        prefix,
671        interval_secs,
672        ignore_cert_check,
673    }))
674}
675
676fn chat_sync_checkpoint_path(archive_path: &Path, config: &ChatSyncConfig) -> PathBuf {
677    let endpoint_tag = sanitize_s3_key_segment(&config.endpoint.replace("://", "-"));
678    let bucket_tag = sanitize_s3_key_segment(&config.bucket);
679    archive_path.with_file_name(format!(
680        "chat_events.minio-sync.{endpoint_tag}.{bucket_tag}.offset"
681    ))
682}
683
684fn load_chat_sync_offset(checkpoint_path: &Path) -> u64 {
685    std::fs::read_to_string(checkpoint_path)
686        .ok()
687        .and_then(|value| value.trim().parse::<u64>().ok())
688        .unwrap_or(0)
689}
690
691fn store_chat_sync_offset(checkpoint_path: &Path, offset: u64) {
692    if let Some(parent) = checkpoint_path.parent()
693        && let Err(err) = std::fs::create_dir_all(parent)
694    {
695        tracing::warn!(error = %err, path = %parent.display(), "Failed to create chat sync checkpoint directory");
696        return;
697    }
698
699    if let Err(err) = std::fs::write(checkpoint_path, offset.to_string()) {
700        tracing::warn!(error = %err, path = %checkpoint_path.display(), "Failed to persist chat sync checkpoint");
701    }
702}
703
704fn build_minio_client(endpoint: &str, config: &ChatSyncConfig) -> Result<MinioClient> {
705    let base_url: BaseUrl = endpoint.parse()?;
706    let provider = StaticProvider::new(&config.access_key, &config.secret_key, None);
707    let client = MinioClientBuilder::new(base_url)
708        .provider(Some(Box::new(provider)))
709        .ignore_cert_check(Some(config.ignore_cert_check))
710        .build()?;
711    Ok(client)
712}
713
714async fn ensure_minio_bucket(client: &MinioClient, bucket: &str) -> Result<()> {
715    let exists = client.bucket_exists(bucket).send().await?;
716    if !exists.exists {
717        match client.create_bucket(bucket).send().await {
718            Ok(_) => {}
719            Err(err) => {
720                let error_text = err.to_string();
721                if !error_text.contains("BucketAlreadyOwnedByYou")
722                    && !error_text.contains("BucketAlreadyExists")
723                {
724                    return Err(anyhow::anyhow!(error_text));
725                }
726            }
727        }
728    }
729    Ok(())
730}
731
732fn read_chat_archive_batch(archive_path: &Path, offset: u64) -> Result<(Vec<u8>, u64, usize)> {
733    let metadata = std::fs::metadata(archive_path)?;
734    let file_len = metadata.len();
735    if offset >= file_len {
736        return Ok((Vec::new(), offset, 0));
737    }
738
739    let mut file = std::fs::File::open(archive_path)?;
740    file.seek(SeekFrom::Start(offset))?;
741
742    let target_bytes = (file_len - offset).min(CHAT_SYNC_MAX_BATCH_BYTES as u64) as usize;
743    let mut buffer = vec![0_u8; target_bytes];
744    let read = file.read(&mut buffer)?;
745    buffer.truncate(read);
746
747    if read == 0 {
748        return Ok((Vec::new(), offset, 0));
749    }
750
751    // Try to end batches on a newline when there is still more data pending.
752    if offset + (read as u64) < file_len {
753        if let Some(last_newline) = buffer.iter().rposition(|byte| *byte == b'\n') {
754            buffer.truncate(last_newline + 1);
755        }
756
757        if buffer.is_empty() {
758            let mut rolling = Vec::new();
759            let mut temp = [0_u8; 4096];
760            loop {
761                let n = file.read(&mut temp)?;
762                if n == 0 {
763                    break;
764                }
765                rolling.extend_from_slice(&temp[..n]);
766                if let Some(pos) = rolling.iter().position(|byte| *byte == b'\n') {
767                    rolling.truncate(pos + 1);
768                    break;
769                }
770                if rolling.len() >= CHAT_SYNC_MAX_BATCH_BYTES {
771                    break;
772                }
773            }
774            buffer = rolling;
775        }
776    }
777
778    let next_offset = offset + buffer.len() as u64;
779    let records = buffer.iter().filter(|byte| **byte == b'\n').count();
780    Ok((buffer, next_offset, records))
781}
782
783#[derive(Debug)]
784struct ChatSyncBatch {
785    bytes: u64,
786    records: usize,
787    object_key: String,
788    next_offset: u64,
789}
790
791async fn sync_chat_archive_batch(
792    client: &MinioClient,
793    archive_path: &Path,
794    config: &ChatSyncConfig,
795    host_tag: &str,
796    offset: u64,
797) -> Result<Option<ChatSyncBatch>> {
798    if !archive_path.exists() {
799        return Ok(None);
800    }
801
802    ensure_minio_bucket(client, &config.bucket).await?;
803
804    let (payload, next_offset, records) = read_chat_archive_batch(archive_path, offset)?;
805    if payload.is_empty() {
806        return Ok(None);
807    }
808
809    let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
810    let key_prefix = config.prefix.trim_matches('/');
811    let object_key = if key_prefix.is_empty() {
812        format!("{host_tag}/{timestamp}-chat-events-{offset:020}-{next_offset:020}.jsonl")
813    } else {
814        format!(
815            "{key_prefix}/{host_tag}/{timestamp}-chat-events-{offset:020}-{next_offset:020}.jsonl"
816        )
817    };
818
819    let bytes = payload.len() as u64;
820    let content = ObjectContent::from(payload);
821    client
822        .put_object_content(&config.bucket, &object_key, content)
823        .send()
824        .await?;
825
826    Ok(Some(ChatSyncBatch {
827        bytes,
828        records,
829        object_key,
830        next_offset,
831    }))
832}
833
834async fn run_chat_sync_worker(
835    tx: mpsc::Sender<ChatSyncUiEvent>,
836    archive_path: PathBuf,
837    config: ChatSyncConfig,
838) {
839    let checkpoint_path = chat_sync_checkpoint_path(&archive_path, &config);
840    let mut offset = load_chat_sync_offset(&checkpoint_path);
841    let host_tag = sanitize_s3_key_segment(
842        &std::env::var("HOSTNAME").unwrap_or_else(|_| "localhost".to_string()),
843    );
844
845    let fallback_label = config
846        .fallback_endpoint
847        .as_ref()
848        .map(|fallback| format!(" (fallback: {fallback})"))
849        .unwrap_or_default();
850
851    let _ = tx
852        .send(ChatSyncUiEvent::Status(format!(
853            "Archive sync enabled → {} / {} every {}s{}",
854            config.endpoint, config.bucket, config.interval_secs, fallback_label
855        )))
856        .await;
857
858    let mut interval = tokio::time::interval(Duration::from_secs(config.interval_secs));
859    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
860
861    loop {
862        interval.tick().await;
863
864        let primary_client = match build_minio_client(&config.endpoint, &config) {
865            Ok(client) => client,
866            Err(err) => {
867                let _ = tx
868                    .send(ChatSyncUiEvent::Error(format!(
869                        "Chat sync client init failed for {}: {err}",
870                        config.endpoint
871                    )))
872                    .await;
873                continue;
874            }
875        };
876
877        let outcome = match sync_chat_archive_batch(
878            &primary_client,
879            &archive_path,
880            &config,
881            &host_tag,
882            offset,
883        )
884        .await
885        {
886            Ok(result) => Ok(result),
887            Err(primary_err) => {
888                if let Some(fallback_endpoint) = &config.fallback_endpoint {
889                    let fallback_client = build_minio_client(fallback_endpoint, &config);
890                    match fallback_client {
891                        Ok(client) => {
892                            let _ = tx
893                                .send(ChatSyncUiEvent::Status(format!(
894                                    "Primary endpoint failed; retrying with fallback {}",
895                                    fallback_endpoint
896                                )))
897                                .await;
898                            sync_chat_archive_batch(
899                                &client,
900                                &archive_path,
901                                &config,
902                                &host_tag,
903                                offset,
904                            )
905                            .await
906                        }
907                        Err(err) => Err(anyhow::anyhow!(
908                            "Primary sync error: {primary_err}; fallback init failed: {err}"
909                        )),
910                    }
911                } else {
912                    Err(primary_err)
913                }
914            }
915        };
916
917        match outcome {
918            Ok(Some(batch)) => {
919                offset = batch.next_offset;
920                store_chat_sync_offset(&checkpoint_path, offset);
921                let _ = tx
922                    .send(ChatSyncUiEvent::BatchUploaded {
923                        bytes: batch.bytes,
924                        records: batch.records,
925                        object_key: batch.object_key,
926                    })
927                    .await;
928            }
929            Ok(None) => {}
930            Err(err) => {
931                let _ = tx
932                    .send(ChatSyncUiEvent::Error(format!("Chat sync failed: {err}")))
933                    .await;
934            }
935        }
936    }
937}
938
939#[derive(Debug, Serialize)]
940struct ChatArchiveRecord {
941    recorded_at: String,
942    workspace: String,
943    session_id: Option<String>,
944    role: String,
945    agent_name: Option<String>,
946    message_type: String,
947    content: String,
948    tool_name: Option<String>,
949    tool_success: Option<bool>,
950    tool_duration_ms: Option<u64>,
951}
952
953impl ChatMessage {
954    fn new(role: impl Into<String>, content: impl Into<String>) -> Self {
955        let content = content.into();
956        Self {
957            role: role.into(),
958            timestamp: chrono::Local::now().format("%H:%M").to_string(),
959            message_type: MessageType::Text(content.clone()),
960            content,
961            usage_meta: None,
962            agent_name: None,
963        }
964    }
965
966    fn with_message_type(mut self, message_type: MessageType) -> Self {
967        self.message_type = message_type;
968        self
969    }
970
971    fn with_usage_meta(mut self, meta: UsageMeta) -> Self {
972        self.usage_meta = Some(meta);
973        self
974    }
975
976    fn with_agent_name(mut self, name: impl Into<String>) -> Self {
977        self.agent_name = Some(name.into());
978        self
979    }
980}
981
982impl WorkspaceSnapshot {
983    fn capture(root: &Path, max_entries: usize) -> Self {
984        let mut entries: Vec<WorkspaceEntry> = Vec::new();
985
986        if let Ok(read_dir) = std::fs::read_dir(root) {
987            for entry in read_dir.flatten() {
988                let file_name = entry.file_name().to_string_lossy().to_string();
989                if should_skip_workspace_entry(&file_name) {
990                    continue;
991                }
992
993                let kind = match entry.file_type() {
994                    Ok(ft) if ft.is_dir() => WorkspaceEntryKind::Directory,
995                    _ => WorkspaceEntryKind::File,
996                };
997
998                entries.push(WorkspaceEntry {
999                    name: file_name,
1000                    kind,
1001                });
1002            }
1003        }
1004
1005        entries.sort_by(|a, b| match (a.kind, b.kind) {
1006            (WorkspaceEntryKind::Directory, WorkspaceEntryKind::File) => std::cmp::Ordering::Less,
1007            (WorkspaceEntryKind::File, WorkspaceEntryKind::Directory) => {
1008                std::cmp::Ordering::Greater
1009            }
1010            _ => a
1011                .name
1012                .to_ascii_lowercase()
1013                .cmp(&b.name.to_ascii_lowercase()),
1014        });
1015        entries.truncate(max_entries);
1016
1017        Self {
1018            root_display: root.to_string_lossy().to_string(),
1019            git_branch: detect_git_branch(root),
1020            git_dirty_files: detect_git_dirty_files(root),
1021            entries,
1022            captured_at: chrono::Local::now().format("%H:%M:%S").to_string(),
1023        }
1024    }
1025}
1026
1027fn should_skip_workspace_entry(name: &str) -> bool {
1028    matches!(
1029        name,
1030        ".git" | "node_modules" | "target" | ".next" | "__pycache__" | ".venv"
1031    )
1032}
1033
1034fn detect_git_branch(root: &Path) -> Option<String> {
1035    let output = Command::new("git")
1036        .arg("-C")
1037        .arg(root)
1038        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1039        .output()
1040        .ok()?;
1041
1042    if !output.status.success() {
1043        return None;
1044    }
1045
1046    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
1047    if branch.is_empty() {
1048        None
1049    } else {
1050        Some(branch)
1051    }
1052}
1053
1054fn detect_git_dirty_files(root: &Path) -> usize {
1055    let output = match Command::new("git")
1056        .arg("-C")
1057        .arg(root)
1058        .args(["status", "--porcelain"])
1059        .output()
1060    {
1061        Ok(out) => out,
1062        Err(_) => return 0,
1063    };
1064
1065    if !output.status.success() {
1066        return 0;
1067    }
1068
1069    String::from_utf8_lossy(&output.stdout)
1070        .lines()
1071        .filter(|line| !line.trim().is_empty())
1072        .count()
1073}
1074
1075fn resolve_provider_for_model_autochat(
1076    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
1077    model_ref: &str,
1078) -> Option<(std::sync::Arc<dyn crate::provider::Provider>, String)> {
1079    let (provider_name, model_name) = crate::provider::parse_model_string(model_ref);
1080    if let Some(provider_name) = provider_name
1081        && let Some(provider) = registry.get(provider_name)
1082    {
1083        return Some((provider, model_name.to_string()));
1084    }
1085
1086    let fallbacks = [
1087        "zai",
1088        "openai",
1089        "github-copilot",
1090        "anthropic",
1091        "openrouter",
1092        "novita",
1093        "moonshotai",
1094        "google",
1095    ];
1096
1097    for provider_name in fallbacks {
1098        if let Some(provider) = registry.get(provider_name) {
1099            return Some((provider, model_ref.to_string()));
1100        }
1101    }
1102
1103    registry
1104        .list()
1105        .first()
1106        .copied()
1107        .and_then(|name| registry.get(name))
1108        .map(|provider| (provider, model_ref.to_string()))
1109}
1110
1111async fn prepare_autochat_handoff_with_registry(
1112    task: &str,
1113    from_agent: &str,
1114    output: &str,
1115    model_ref: &str,
1116    registry: &std::sync::Arc<crate::provider::ProviderRegistry>,
1117) -> (String, bool) {
1118    let mut used_rlm = false;
1119    let mut relay_payload = output.to_string();
1120
1121    if output.len() > AUTOCHAT_RLM_THRESHOLD_CHARS {
1122        relay_payload = truncate_with_ellipsis(output, 3_500);
1123
1124        if let Some((provider, model_name)) =
1125            resolve_provider_for_model_autochat(registry, model_ref)
1126        {
1127            let mut executor =
1128                RlmExecutor::new(output.to_string(), provider, model_name).with_max_iterations(2);
1129
1130            let query = "Summarize this agent output for the next specialist in a relay. Keep:\n\
1131                         1) key conclusions, 2) unresolved risks, 3) exact next action.\n\
1132                         Keep it concise and actionable.";
1133            match executor.analyze(query).await {
1134                Ok(result) => {
1135                    relay_payload = result.answer;
1136                    used_rlm = true;
1137                }
1138                Err(err) => {
1139                    tracing::warn!(error = %err, "RLM handoff summarization failed; using truncation fallback");
1140                }
1141            }
1142        }
1143    }
1144
1145    (
1146        format!(
1147            "Relay task:\n{task}\n\nIncoming handoff from @{from_agent}:\n{relay_payload}\n\n\
1148             Continue the work from this handoff. Keep your response focused and provide one concrete next-step instruction for the next agent."
1149        ),
1150        used_rlm,
1151    )
1152}
1153
1154async fn run_autochat_worker(
1155    tx: mpsc::Sender<AutochatUiEvent>,
1156    bus: std::sync::Arc<crate::bus::AgentBus>,
1157    profiles: Vec<(String, String, Vec<String>)>,
1158    task: String,
1159    model_ref: String,
1160) {
1161    let _ = tx
1162        .send(AutochatUiEvent::Progress(
1163            "Loading providers from Vault…".to_string(),
1164        ))
1165        .await;
1166
1167    let registry = match crate::provider::ProviderRegistry::from_vault().await {
1168        Ok(registry) => std::sync::Arc::new(registry),
1169        Err(err) => {
1170            let _ = tx
1171                .send(AutochatUiEvent::SystemMessage(format!(
1172                    "Failed to load providers for /autochat: {err}"
1173                )))
1174                .await;
1175            let _ = tx
1176                .send(AutochatUiEvent::Completed {
1177                    summary: "Autochat aborted: provider registry unavailable.".to_string(),
1178                })
1179                .await;
1180            return;
1181        }
1182    };
1183
1184    let relay = ProtocolRelayRuntime::new(bus);
1185    let mut relay_profiles = Vec::with_capacity(profiles.len());
1186    let mut ordered_agents = Vec::with_capacity(profiles.len());
1187    let mut sessions: HashMap<String, Session> = HashMap::new();
1188    let mut setup_errors: Vec<String> = Vec::new();
1189
1190    let _ = tx
1191        .send(AutochatUiEvent::Progress(
1192            "Initializing relay agent sessions…".to_string(),
1193        ))
1194        .await;
1195
1196    for (name, instructions, capabilities) in profiles {
1197        match Session::new().await {
1198            Ok(mut session) => {
1199                session.metadata.model = Some(model_ref.clone());
1200                session.agent = name.clone();
1201                session.add_message(crate::provider::Message {
1202                    role: Role::System,
1203                    content: vec![ContentPart::Text {
1204                        text: instructions.clone(),
1205                    }],
1206                });
1207
1208                relay_profiles.push(RelayAgentProfile {
1209                    name: name.clone(),
1210                    capabilities,
1211                });
1212                ordered_agents.push(name.clone());
1213                sessions.insert(name, session);
1214            }
1215            Err(err) => {
1216                setup_errors.push(format!(
1217                    "Failed creating relay agent session @{name}: {err}"
1218                ));
1219            }
1220        }
1221    }
1222
1223    if !setup_errors.is_empty() {
1224        let _ = tx
1225            .send(AutochatUiEvent::SystemMessage(format!(
1226                "Relay setup warnings:\n{}",
1227                setup_errors.join("\n")
1228            )))
1229            .await;
1230    }
1231
1232    if ordered_agents.len() < 2 {
1233        let _ = tx
1234            .send(AutochatUiEvent::SystemMessage(
1235                "Autochat needs at least 2 agents to relay.".to_string(),
1236            ))
1237            .await;
1238        let _ = tx
1239            .send(AutochatUiEvent::Completed {
1240                summary: "Autochat aborted: insufficient relay participants.".to_string(),
1241            })
1242            .await;
1243        return;
1244    }
1245
1246    relay.register_agents(&relay_profiles);
1247
1248    let _ = tx
1249        .send(AutochatUiEvent::Progress(format!(
1250            "Relay {} registered {} agents. Starting handoffs…",
1251            relay.relay_id(),
1252            ordered_agents.len()
1253        )))
1254        .await;
1255
1256    let roster_profiles = ordered_agents
1257        .iter()
1258        .map(|name| {
1259            format!(
1260                "• {} — {}",
1261                format_agent_identity(name),
1262                format_agent_profile_summary(name)
1263            )
1264        })
1265        .collect::<Vec<_>>()
1266        .join("\n");
1267    let _ = tx
1268        .send(AutochatUiEvent::SystemMessage(format!(
1269            "Relay {id} started • model: {model_ref}\n\nTeam personalities:\n{roster_profiles}",
1270            id = relay.relay_id()
1271        )))
1272        .await;
1273
1274    let mut baton = format!(
1275        "Task:\n{task}\n\nStart by proposing an execution strategy and one immediate next step."
1276    );
1277    let mut previous_normalized: Option<String> = None;
1278    let mut convergence_hits = 0usize;
1279    let mut turns = 0usize;
1280    let mut rlm_handoff_count = 0usize;
1281    let mut status = "max_rounds_reached";
1282    let mut failure_note: Option<String> = None;
1283
1284    'relay_loop: for round in 1..=AUTOCHAT_MAX_ROUNDS {
1285        for idx in 0..ordered_agents.len() {
1286            let to = ordered_agents[idx].clone();
1287            let from = if idx == 0 {
1288                if round == 1 {
1289                    "user".to_string()
1290                } else {
1291                    ordered_agents[ordered_agents.len() - 1].clone()
1292                }
1293            } else {
1294                ordered_agents[idx - 1].clone()
1295            };
1296
1297            turns += 1;
1298            relay.send_handoff(&from, &to, &baton);
1299            let _ = tx
1300                .send(AutochatUiEvent::Progress(format!(
1301                    "Round {round}/{AUTOCHAT_MAX_ROUNDS} • @{from} → @{to}"
1302                )))
1303                .await;
1304
1305            let Some(mut session) = sessions.remove(&to) else {
1306                status = "agent_error";
1307                failure_note = Some(format!("Relay agent @{to} session was unavailable."));
1308                break 'relay_loop;
1309            };
1310
1311            let (event_tx, mut event_rx) = mpsc::channel(256);
1312            let registry_for_prompt = registry.clone();
1313            let baton_for_prompt = baton.clone();
1314
1315            let join = tokio::spawn(async move {
1316                let result = session
1317                    .prompt_with_events(&baton_for_prompt, event_tx, registry_for_prompt)
1318                    .await;
1319                (session, result)
1320            });
1321
1322            while !join.is_finished() {
1323                while let Ok(event) = event_rx.try_recv() {
1324                    if !matches!(event, SessionEvent::SessionSync(_)) {
1325                        let _ = tx
1326                            .send(AutochatUiEvent::AgentEvent {
1327                                agent_name: to.clone(),
1328                                event,
1329                            })
1330                            .await;
1331                    }
1332                }
1333                tokio::time::sleep(Duration::from_millis(20)).await;
1334            }
1335
1336            let (updated_session, result) = match join.await {
1337                Ok(value) => value,
1338                Err(err) => {
1339                    status = "agent_error";
1340                    failure_note = Some(format!("Relay agent @{to} task join error: {err}"));
1341                    break 'relay_loop;
1342                }
1343            };
1344
1345            while let Ok(event) = event_rx.try_recv() {
1346                if !matches!(event, SessionEvent::SessionSync(_)) {
1347                    let _ = tx
1348                        .send(AutochatUiEvent::AgentEvent {
1349                            agent_name: to.clone(),
1350                            event,
1351                        })
1352                        .await;
1353                }
1354            }
1355
1356            sessions.insert(to.clone(), updated_session);
1357
1358            let output = match result {
1359                Ok(response) => response.text,
1360                Err(err) => {
1361                    status = "agent_error";
1362                    failure_note = Some(format!("Relay agent @{to} failed: {err}"));
1363                    let _ = tx
1364                        .send(AutochatUiEvent::SystemMessage(format!(
1365                            "Relay agent @{to} failed: {err}"
1366                        )))
1367                        .await;
1368                    break 'relay_loop;
1369                }
1370            };
1371
1372            let normalized = normalize_for_convergence(&output);
1373            if previous_normalized.as_deref() == Some(normalized.as_str()) {
1374                convergence_hits += 1;
1375            } else {
1376                convergence_hits = 0;
1377            }
1378            previous_normalized = Some(normalized);
1379
1380            let (next_handoff, used_rlm) =
1381                prepare_autochat_handoff_with_registry(&task, &to, &output, &model_ref, &registry)
1382                    .await;
1383            if used_rlm {
1384                rlm_handoff_count += 1;
1385            }
1386
1387            baton = next_handoff;
1388
1389            if convergence_hits >= 2 {
1390                status = "converged";
1391                break 'relay_loop;
1392            }
1393        }
1394    }
1395
1396    relay.shutdown_agents(&ordered_agents);
1397
1398    let _ = tx
1399        .send(AutochatUiEvent::Progress(
1400            "Finalizing relay summary…".to_string(),
1401        ))
1402        .await;
1403
1404    let mut summary = format!(
1405        "Autochat complete ({status}) — relay {} with {} agents over {} turns.",
1406        relay.relay_id(),
1407        ordered_agents.len(),
1408        turns,
1409    );
1410    if let Some(note) = failure_note {
1411        summary.push_str(&format!("\n\nFailure detail: {note}"));
1412    }
1413    if rlm_handoff_count > 0 {
1414        summary.push_str(&format!("\n\nRLM compressed handoffs: {rlm_handoff_count}"));
1415    }
1416    summary.push_str(&format!(
1417        "\n\nFinal relay handoff:\n{}",
1418        truncate_with_ellipsis(&baton, 4_000)
1419    ));
1420    summary.push_str(&format!(
1421        "\n\nCleanup: deregistered relay agents and disposed {} autochat worker session(s).",
1422        sessions.len()
1423    ));
1424
1425    let _ = tx.send(AutochatUiEvent::Completed { summary }).await;
1426}
1427
1428impl App {
1429    fn new() -> Self {
1430        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1431        let chat_archive_path = directories::ProjectDirs::from("com", "codetether", "codetether")
1432            .map(|dirs| dirs.data_local_dir().join("chat_events.jsonl"));
1433
1434        Self {
1435            input: String::new(),
1436            cursor_position: 0,
1437            messages: vec![
1438                ChatMessage::new("system", "Welcome to CodeTether Agent! Press ? for help."),
1439                ChatMessage::new(
1440                    "assistant",
1441                    "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",
1442                ),
1443            ],
1444            current_agent: "build".to_string(),
1445            scroll: 0,
1446            show_help: false,
1447            command_history: Vec::new(),
1448            history_index: None,
1449            session: None,
1450            is_processing: false,
1451            processing_message: None,
1452            current_tool: None,
1453            current_tool_started_at: None,
1454            processing_started_at: None,
1455            streaming_text: None,
1456            streaming_agent_texts: HashMap::new(),
1457            tool_call_count: 0,
1458            response_rx: None,
1459            provider_registry: None,
1460            workspace_dir: workspace_root.clone(),
1461            view_mode: ViewMode::Chat,
1462            chat_layout: ChatLayoutMode::Webview,
1463            show_inspector: true,
1464            workspace: WorkspaceSnapshot::capture(&workspace_root, 18),
1465            swarm_state: SwarmViewState::new(),
1466            swarm_rx: None,
1467            ralph_state: RalphViewState::new(),
1468            ralph_rx: None,
1469            bus_log_state: BusLogState::new(),
1470            bus_log_rx: None,
1471            bus: None,
1472            session_picker_list: Vec::new(),
1473            session_picker_selected: 0,
1474            session_picker_filter: String::new(),
1475            session_picker_confirm_delete: false,
1476            model_picker_list: Vec::new(),
1477            model_picker_selected: 0,
1478            model_picker_filter: String::new(),
1479            agent_picker_selected: 0,
1480            agent_picker_filter: String::new(),
1481            protocol_selected: 0,
1482            protocol_scroll: 0,
1483            active_model: None,
1484            active_spawned_agent: None,
1485            spawned_agents: HashMap::new(),
1486            agent_response_rxs: Vec::new(),
1487            agent_tool_started_at: HashMap::new(),
1488            autochat_rx: None,
1489            autochat_running: false,
1490            autochat_started_at: None,
1491            autochat_status: None,
1492            chat_archive_path,
1493            archived_message_count: 0,
1494            chat_sync_rx: None,
1495            chat_sync_status: None,
1496            chat_sync_last_success: None,
1497            chat_sync_last_error: None,
1498            chat_sync_uploaded_bytes: 0,
1499            chat_sync_uploaded_batches: 0,
1500            secure_environment: false,
1501            last_max_scroll: 0,
1502        }
1503    }
1504
1505    fn refresh_workspace(&mut self) {
1506        let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1507        self.workspace = WorkspaceSnapshot::capture(&workspace_root, 18);
1508    }
1509
1510    fn update_cached_sessions(&mut self, sessions: Vec<SessionSummary>) {
1511        self.session_picker_list = sessions.into_iter().take(16).collect();
1512        if self.session_picker_selected >= self.session_picker_list.len() {
1513            self.session_picker_selected = self.session_picker_list.len().saturating_sub(1);
1514        }
1515    }
1516
1517    fn is_agent_protocol_registered(&self, agent_name: &str) -> bool {
1518        self.bus
1519            .as_ref()
1520            .is_some_and(|bus| bus.registry.get(agent_name).is_some())
1521    }
1522
1523    fn protocol_registered_count(&self) -> usize {
1524        self.bus.as_ref().map_or(0, |bus| bus.registry.len())
1525    }
1526
1527    fn protocol_cards(&self) -> Vec<crate::a2a::types::AgentCard> {
1528        let Some(bus) = &self.bus else {
1529            return Vec::new();
1530        };
1531
1532        let mut ids = bus.registry.agent_ids();
1533        ids.sort_by_key(|id| id.to_lowercase());
1534
1535        ids.into_iter()
1536            .filter_map(|id| bus.registry.get(&id))
1537            .collect()
1538    }
1539
1540    fn open_protocol_view(&mut self) {
1541        self.protocol_selected = 0;
1542        self.protocol_scroll = 0;
1543        self.view_mode = ViewMode::Protocol;
1544    }
1545
1546    fn unique_spawned_name(&self, base: &str) -> String {
1547        if !self.spawned_agents.contains_key(base) {
1548            return base.to_string();
1549        }
1550
1551        let mut suffix = 2usize;
1552        loop {
1553            let candidate = format!("{base}-{suffix}");
1554            if !self.spawned_agents.contains_key(&candidate) {
1555                return candidate;
1556            }
1557            suffix += 1;
1558        }
1559    }
1560
1561    fn build_autochat_profiles(&self, count: usize) -> Vec<(String, String, Vec<String>)> {
1562        let templates = [
1563            (
1564                "planner",
1565                "Decompose objectives into precise, sequenced steps.",
1566                "planning",
1567            ),
1568            (
1569                "researcher",
1570                "Validate assumptions, surface edge cases, and gather critical evidence.",
1571                "research",
1572            ),
1573            (
1574                "coder",
1575                "Propose concrete implementation details and practical code-level direction.",
1576                "implementation",
1577            ),
1578            (
1579                "reviewer",
1580                "Challenge weak spots, enforce quality, and reduce regressions.",
1581                "review",
1582            ),
1583            (
1584                "tester",
1585                "Design verification strategy, tests, and failure-oriented checks.",
1586                "testing",
1587            ),
1588            (
1589                "integrator",
1590                "Synthesize contributions into a coherent delivery plan.",
1591                "integration",
1592            ),
1593            (
1594                "skeptic",
1595                "Stress-test confidence and call out hidden risks early.",
1596                "risk-analysis",
1597            ),
1598            (
1599                "summarizer",
1600                "Produce concise, actionable final guidance.",
1601                "summarization",
1602            ),
1603        ];
1604
1605        let mut profiles = Vec::with_capacity(count);
1606        for idx in 0..count {
1607            let (slug, mission, specialty) = templates[idx % templates.len()];
1608            let base = if idx < templates.len() {
1609                format!("auto-{slug}")
1610            } else {
1611                format!("auto-{slug}-{}", idx + 1)
1612            };
1613            let name = self.unique_spawned_name(&base);
1614            let profile = agent_profile(&name);
1615            let instructions = format!(
1616                "You are @{name}, codename {codename}.\n\
1617                 Profile: {profile_line}.\n\
1618                 Personality: {personality}.\n\
1619                 Collaboration style: {collaboration_style}.\n\
1620                 Signature move: {signature_move}.\n\
1621                 Specialty: {specialty}. {mission}\n\n
1622                 This is a protocol-first relay conversation. Treat the incoming handoff as the authoritative context.\n\
1623                 Keep your response concise, concrete, and useful for the next specialist.\n\
1624                 Include one clear recommendation for what the next agent should do.",
1625                codename = profile.codename,
1626                profile_line = profile.profile,
1627                personality = profile.personality,
1628                collaboration_style = profile.collaboration_style,
1629                signature_move = profile.signature_move,
1630            );
1631            let capabilities = vec![
1632                specialty.to_string(),
1633                profile.codename.to_ascii_lowercase(),
1634                "relay".to_string(),
1635                "context-handoff".to_string(),
1636                "rlm-aware".to_string(),
1637                "autochat".to_string(),
1638            ];
1639
1640            profiles.push((name, instructions, capabilities));
1641        }
1642
1643        profiles
1644    }
1645    async fn start_autochat_execution(
1646        &mut self,
1647        agent_count: usize,
1648        task: String,
1649        config: &Config,
1650    ) {
1651        if !(2..=AUTOCHAT_MAX_AGENTS).contains(&agent_count) {
1652            self.messages.push(ChatMessage::new(
1653                "system",
1654                format!(
1655                    "Usage: /autochat <count> <task>\ncount must be between 2 and {AUTOCHAT_MAX_AGENTS}."
1656                ),
1657            ));
1658            return;
1659        }
1660
1661        if self.autochat_running {
1662            self.messages.push(ChatMessage::new(
1663                "system",
1664                "Autochat relay already running. Wait for it to finish before starting another.",
1665            ));
1666            return;
1667        }
1668
1669        self.messages.push(ChatMessage::new(
1670            "user",
1671            format!("/autochat {agent_count} {task}"),
1672        ));
1673        self.messages.push(ChatMessage::new(
1674            "system",
1675            format!(
1676                "Preparing relay with {agent_count} agents…\nTask: {}\n(Compact mode: live agent streaming here, detailed relay envelopes in /buslog)",
1677                truncate_with_ellipsis(&task, 180)
1678            ),
1679        ));
1680        self.scroll = SCROLL_BOTTOM;
1681
1682        let Some(bus) = self.bus.clone() else {
1683            self.messages.push(ChatMessage::new(
1684                "system",
1685                "Protocol bus unavailable; cannot start /autochat relay.",
1686            ));
1687            return;
1688        };
1689
1690        let model_ref = self
1691            .active_model
1692            .clone()
1693            .or_else(|| config.default_model.clone())
1694            .unwrap_or_else(|| "zai/glm-5".to_string());
1695
1696        let profiles = self.build_autochat_profiles(agent_count);
1697        if profiles.is_empty() {
1698            self.messages.push(ChatMessage::new(
1699                "system",
1700                "No relay profiles could be created.",
1701            ));
1702            return;
1703        }
1704
1705        let (tx, rx) = mpsc::channel(512);
1706        self.autochat_rx = Some(rx);
1707        self.autochat_running = true;
1708        self.autochat_started_at = Some(Instant::now());
1709        self.autochat_status = Some("Preparing relay…".to_string());
1710        self.active_spawned_agent = None;
1711
1712        tokio::spawn(async move {
1713            run_autochat_worker(tx, bus, profiles, task, model_ref).await;
1714        });
1715    }
1716
1717    async fn submit_message(&mut self, config: &Config) {
1718        if self.input.is_empty() {
1719            return;
1720        }
1721
1722        let mut message = std::mem::take(&mut self.input);
1723        let easy_go_requested = is_easy_go_command(&message);
1724        self.cursor_position = 0;
1725
1726        // Save to command history
1727        if !message.trim().is_empty() {
1728            self.command_history.push(message.clone());
1729            self.history_index = None;
1730        }
1731
1732        // Easy-mode slash aliases (/go, /add, /talk, /list, ...)
1733        message = normalize_easy_command(&message);
1734
1735        if message.trim() == "/help" {
1736            self.show_help = true;
1737            return;
1738        }
1739
1740        // Backward-compatible /agent command aliases
1741        if message.trim().starts_with("/agent") {
1742            let rest = message.trim().strip_prefix("/agent").unwrap_or("").trim();
1743
1744            if rest.is_empty() {
1745                self.open_agent_picker();
1746                return;
1747            }
1748
1749            if rest == "pick" || rest == "picker" || rest == "select" {
1750                self.open_agent_picker();
1751                return;
1752            }
1753
1754            if rest == "main" || rest == "off" {
1755                if let Some(target) = self.active_spawned_agent.take() {
1756                    self.messages.push(ChatMessage::new(
1757                        "system",
1758                        format!("Exited focused sub-agent chat (@{target})."),
1759                    ));
1760                } else {
1761                    self.messages
1762                        .push(ChatMessage::new("system", "Already in main chat mode."));
1763                }
1764                return;
1765            }
1766
1767            if rest == "build" || rest == "plan" {
1768                self.current_agent = rest.to_string();
1769                self.active_spawned_agent = None;
1770                self.messages.push(ChatMessage::new(
1771                    "system",
1772                    format!("Switched main agent to '{rest}'. (Tab also works.)"),
1773                ));
1774                return;
1775            }
1776
1777            if rest == "list" || rest == "ls" {
1778                message = "/agents".to_string();
1779            } else if let Some(args) = rest
1780                .strip_prefix("spawn ")
1781                .map(str::trim)
1782                .filter(|s| !s.is_empty())
1783            {
1784                message = format!("/spawn {args}");
1785            } else if let Some(name) = rest
1786                .strip_prefix("kill ")
1787                .map(str::trim)
1788                .filter(|s| !s.is_empty())
1789            {
1790                message = format!("/kill {name}");
1791            } else if !rest.contains(' ') {
1792                let target = rest.trim_start_matches('@');
1793                if self.spawned_agents.contains_key(target) {
1794                    self.active_spawned_agent = Some(target.to_string());
1795                    self.messages.push(ChatMessage::new(
1796                        "system",
1797                        format!(
1798                            "Focused chat on @{target}. Type messages directly; use /agent main to exit focus."
1799                        ),
1800                    ));
1801                } else {
1802                    self.messages.push(ChatMessage::new(
1803                        "system",
1804                        format!(
1805                            "No agent named @{target}. Use /agents to list, or /spawn <name> <instructions> to create one."
1806                        ),
1807                    ));
1808                }
1809                return;
1810            } else if let Some((name, content)) = rest.split_once(' ') {
1811                let target = name.trim().trim_start_matches('@');
1812                let content = content.trim();
1813                if target.is_empty() || content.is_empty() {
1814                    self.messages
1815                        .push(ChatMessage::new("system", "Usage: /agent <name> <message>"));
1816                    return;
1817                }
1818                message = format!("@{target} {content}");
1819            } else {
1820                self.messages.push(ChatMessage::new(
1821                    "system",
1822                    "Unknown /agent usage. Try /agent, /agent <name>, /agent <name> <message>, or /agent list.",
1823                ));
1824                return;
1825            }
1826        }
1827
1828        // Check for /autochat command
1829        if let Some(rest) = command_with_optional_args(&message, "/autochat") {
1830            let Some((count, task)) = parse_autochat_args(rest) else {
1831                self.messages.push(ChatMessage::new(
1832                    "system",
1833                    format!(
1834                        "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: {})",
1835                        AUTOCHAT_MAX_AGENTS,
1836                        AUTOCHAT_DEFAULT_AGENTS,
1837                    ),
1838                ));
1839                return;
1840            };
1841
1842            if easy_go_requested {
1843                let current_model = self
1844                    .active_model
1845                    .as_deref()
1846                    .or(config.default_model.as_deref());
1847                let next_model = next_go_model(current_model);
1848                self.active_model = Some(next_model.clone());
1849                if let Some(session) = self.session.as_mut() {
1850                    session.metadata.model = Some(next_model.clone());
1851                }
1852                self.messages.push(ChatMessage::new(
1853                    "system",
1854                    format!("/go model swap → {next_model}"),
1855                ));
1856            }
1857
1858            self.start_autochat_execution(count, task.to_string(), config)
1859                .await;
1860            return;
1861        }
1862
1863        // Check for /swarm command
1864        if let Some(task) = command_with_optional_args(&message, "/swarm") {
1865            if task.is_empty() {
1866                self.messages.push(ChatMessage::new(
1867                    "system",
1868                    "Usage: /swarm <task description>",
1869                ));
1870                return;
1871            }
1872            self.start_swarm_execution(task.to_string(), config).await;
1873            return;
1874        }
1875
1876        // Check for /ralph command
1877        if message.trim().starts_with("/ralph") {
1878            let prd_path = message
1879                .trim()
1880                .strip_prefix("/ralph")
1881                .map(|s| s.trim())
1882                .filter(|s| !s.is_empty())
1883                .unwrap_or("prd.json")
1884                .to_string();
1885            self.start_ralph_execution(prd_path, config).await;
1886            return;
1887        }
1888
1889        if message.trim() == "/webview" {
1890            self.chat_layout = ChatLayoutMode::Webview;
1891            self.messages.push(ChatMessage::new(
1892                "system",
1893                "Switched to webview layout. Use /classic to return to single-pane chat.",
1894            ));
1895            return;
1896        }
1897
1898        if message.trim() == "/classic" {
1899            self.chat_layout = ChatLayoutMode::Classic;
1900            self.messages.push(ChatMessage::new(
1901                "system",
1902                "Switched to classic layout. Use /webview for dashboard-style panes.",
1903            ));
1904            return;
1905        }
1906
1907        if message.trim() == "/inspector" {
1908            self.show_inspector = !self.show_inspector;
1909            let state = if self.show_inspector {
1910                "enabled"
1911            } else {
1912                "disabled"
1913            };
1914            self.messages.push(ChatMessage::new(
1915                "system",
1916                format!("Inspector pane {}. Press F3 to toggle quickly.", state),
1917            ));
1918            return;
1919        }
1920
1921        if message.trim() == "/refresh" {
1922            self.refresh_workspace();
1923            match list_sessions_with_opencode(&self.workspace_dir).await {
1924                Ok(sessions) => self.update_cached_sessions(sessions),
1925                Err(err) => self.messages.push(ChatMessage::new(
1926                    "system",
1927                    format!(
1928                        "Workspace refreshed, but failed to refresh sessions: {}",
1929                        err
1930                    ),
1931                )),
1932            }
1933            self.messages.push(ChatMessage::new(
1934                "system",
1935                "Workspace and session cache refreshed.",
1936            ));
1937            return;
1938        }
1939
1940        if message.trim() == "/archive" {
1941            let details = if let Some(path) = &self.chat_archive_path {
1942                format!(
1943                    "Chat archive: {}\nCaptured records in this run: {}\n{}",
1944                    path.display(),
1945                    self.archived_message_count,
1946                    self.chat_sync_summary(),
1947                )
1948            } else {
1949                format!(
1950                    "Chat archive path unavailable in this environment.\n{}",
1951                    self.chat_sync_summary()
1952                )
1953            };
1954            self.messages.push(ChatMessage::new("system", details));
1955            self.scroll = SCROLL_BOTTOM;
1956            return;
1957        }
1958
1959        // Check for /view command to toggle views
1960        if message.trim() == "/view" {
1961            self.view_mode = match self.view_mode {
1962                ViewMode::Chat
1963                | ViewMode::SessionPicker
1964                | ViewMode::ModelPicker
1965                | ViewMode::AgentPicker
1966                | ViewMode::BusLog
1967                | ViewMode::Protocol => ViewMode::Swarm,
1968                ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
1969            };
1970            return;
1971        }
1972
1973        // Check for /buslog command to open protocol bus log
1974        if message.trim() == "/buslog" || message.trim() == "/bus" {
1975            self.view_mode = ViewMode::BusLog;
1976            return;
1977        }
1978
1979        // Check for /protocol command to inspect registered AgentCards
1980        if message.trim() == "/protocol" || message.trim() == "/registry" {
1981            self.open_protocol_view();
1982            return;
1983        }
1984
1985        // Check for /spawn command - create a named sub-agent
1986        if let Some(rest) = command_with_optional_args(&message, "/spawn") {
1987            let default_instructions = |agent_name: &str| {
1988                let profile = agent_profile(agent_name);
1989                format!(
1990                    "You are @{agent_name}, codename {codename}.\n\
1991                     Profile: {profile_line}.\n\
1992                     Personality: {personality}.\n\
1993                     Collaboration style: {style}.\n\
1994                     Signature move: {signature}.\n\
1995                     Be a helpful teammate: explain in simple words, short steps, and a friendly tone.",
1996                    codename = profile.codename,
1997                    profile_line = profile.profile,
1998                    personality = profile.personality,
1999                    style = profile.collaboration_style,
2000                    signature = profile.signature_move,
2001                )
2002            };
2003
2004            let (name, instructions, used_default_instructions) = if rest.is_empty() {
2005                self.messages.push(ChatMessage::new(
2006                    "system",
2007                    "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
2008                ));
2009                return;
2010            } else {
2011                let mut parts = rest.splitn(2, char::is_whitespace);
2012                let name = parts.next().unwrap_or("").trim();
2013                if name.is_empty() {
2014                    self.messages.push(ChatMessage::new(
2015                        "system",
2016                        "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>",
2017                    ));
2018                    return;
2019                }
2020
2021                let instructions = parts.next().map(str::trim).filter(|s| !s.is_empty());
2022                match instructions {
2023                    Some(custom) => (name.to_string(), custom.to_string(), false),
2024                    None => (name.to_string(), default_instructions(name), true),
2025                }
2026            };
2027
2028            if self.spawned_agents.contains_key(&name) {
2029                self.messages.push(ChatMessage::new(
2030                    "system",
2031                    format!("Agent @{name} already exists. Use /kill {name} first."),
2032                ));
2033                return;
2034            }
2035
2036            match Session::new().await {
2037                Ok(mut session) => {
2038                    // Use the same model as the main chat
2039                    session.metadata.model = self
2040                        .active_model
2041                        .clone()
2042                        .or_else(|| config.default_model.clone());
2043                    session.agent = name.clone();
2044
2045                    // Add system message with the agent's instructions
2046                    session.add_message(crate::provider::Message {
2047                        role: Role::System,
2048                        content: vec![ContentPart::Text {
2049                            text: format!(
2050                                "You are @{name}, a specialized sub-agent. {instructions}\n\n\
2051                                 When you receive a message from another agent (prefixed with their name), \
2052                                 respond helpfully. Keep responses concise and focused on your specialty."
2053                            ),
2054                        }],
2055                    });
2056
2057                    // Announce on bus
2058                    let mut protocol_registered = false;
2059                    if let Some(ref bus) = self.bus {
2060                        let handle = bus.handle(&name);
2061                        handle.announce_ready(vec!["sub-agent".to_string(), name.clone()]);
2062                        protocol_registered = bus.registry.get(&name).is_some();
2063                    }
2064
2065                    let agent = SpawnedAgent {
2066                        name: name.clone(),
2067                        instructions: instructions.clone(),
2068                        session,
2069                        is_processing: false,
2070                    };
2071                    self.spawned_agents.insert(name.clone(), agent);
2072                    self.active_spawned_agent = Some(name.clone());
2073
2074                    let protocol_line = if protocol_registered {
2075                        format!("Protocol registration: ✅ bus://local/{name}")
2076                    } else {
2077                        "Protocol registration: ⚠ unavailable (bus not connected)".to_string()
2078                    };
2079                    let profile_summary = format_agent_profile_summary(&name);
2080
2081                    self.messages.push(ChatMessage::new(
2082                        "system",
2083                        format!(
2084                            "Spawned agent {}\nProfile: {}\nInstructions: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>.\n{protocol_line}{}",
2085                            format_agent_identity(&name),
2086                            profile_summary,
2087                            if used_default_instructions {
2088                                "\nTip: I used friendly default instructions. You can customize with /add <name> <instructions>."
2089                            } else {
2090                                ""
2091                            }
2092                        ),
2093                    ));
2094                }
2095                Err(e) => {
2096                    self.messages.push(ChatMessage::new(
2097                        "system",
2098                        format!("Failed to spawn agent: {e}"),
2099                    ));
2100                }
2101            }
2102            return;
2103        }
2104
2105        // Check for /agents command - list spawned agents
2106        if message.trim() == "/agents" {
2107            if self.spawned_agents.is_empty() {
2108                self.messages.push(ChatMessage::new(
2109                    "system",
2110                    "No agents spawned. Use /spawn <name> <instructions> to create one.",
2111                ));
2112            } else {
2113                let mut lines = vec![format!(
2114                    "Active agents: {} (protocol registered: {})",
2115                    self.spawned_agents.len(),
2116                    self.protocol_registered_count()
2117                )];
2118
2119                let mut agents = self.spawned_agents.iter().collect::<Vec<_>>();
2120                agents.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
2121
2122                for (name, agent) in agents {
2123                    let status = if agent.is_processing {
2124                        "⚡ working"
2125                    } else {
2126                        "● idle"
2127                    };
2128                    let protocol_status = if self.is_agent_protocol_registered(name) {
2129                        "🔗 protocol"
2130                    } else {
2131                        "⚠ protocol-pending"
2132                    };
2133                    let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
2134                        " [focused]"
2135                    } else {
2136                        ""
2137                    };
2138                    let profile_summary = format_agent_profile_summary(name);
2139                    lines.push(format!(
2140                        "  {} @{name} [{status}] {protocol_status}{focused} — {} | {}",
2141                        agent_avatar(name),
2142                        profile_summary,
2143                        agent.instructions
2144                    ));
2145                }
2146                self.messages
2147                    .push(ChatMessage::new("system", lines.join("\n")));
2148                self.messages.push(ChatMessage::new(
2149                    "system",
2150                    "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
2151                ));
2152            }
2153            return;
2154        }
2155
2156        // Check for /kill command - remove a spawned agent
2157        if let Some(name) = command_with_optional_args(&message, "/kill") {
2158            if name.is_empty() {
2159                self.messages
2160                    .push(ChatMessage::new("system", "Usage: /kill <name>"));
2161                return;
2162            }
2163
2164            let name = name.to_string();
2165            if self.spawned_agents.remove(&name).is_some() {
2166                // Remove its response channels
2167                self.agent_response_rxs.retain(|(n, _)| n != &name);
2168                self.streaming_agent_texts.remove(&name);
2169                if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
2170                    self.active_spawned_agent = None;
2171                }
2172                // Announce shutdown on bus
2173                if let Some(ref bus) = self.bus {
2174                    let handle = bus.handle(&name);
2175                    handle.announce_shutdown();
2176                }
2177                self.messages.push(ChatMessage::new(
2178                    "system",
2179                    format!("Agent @{name} removed."),
2180                ));
2181            } else {
2182                self.messages.push(ChatMessage::new(
2183                    "system",
2184                    format!("No agent named @{name}. Use /agents to list."),
2185                ));
2186            }
2187            return;
2188        }
2189
2190        // Check for @mention - route message to a specific spawned agent
2191        if message.trim().starts_with('@') {
2192            let trimmed = message.trim();
2193            let (target, content) = match trimmed.split_once(' ') {
2194                Some((mention, rest)) => (
2195                    mention.strip_prefix('@').unwrap_or(mention).to_string(),
2196                    rest.to_string(),
2197                ),
2198                None => {
2199                    self.messages.push(ChatMessage::new(
2200                        "system",
2201                        format!(
2202                            "Usage: @agent_name your message\nAvailable: {}",
2203                            if self.spawned_agents.is_empty() {
2204                                "none (use /spawn first)".to_string()
2205                            } else {
2206                                self.spawned_agents
2207                                    .keys()
2208                                    .map(|n| format!("@{n}"))
2209                                    .collect::<Vec<_>>()
2210                                    .join(", ")
2211                            }
2212                        ),
2213                    ));
2214                    return;
2215                }
2216            };
2217
2218            if !self.spawned_agents.contains_key(&target) {
2219                self.messages.push(ChatMessage::new(
2220                    "system",
2221                    format!(
2222                        "No agent named @{target}. Available: {}",
2223                        if self.spawned_agents.is_empty() {
2224                            "none (use /spawn first)".to_string()
2225                        } else {
2226                            self.spawned_agents
2227                                .keys()
2228                                .map(|n| format!("@{n}"))
2229                                .collect::<Vec<_>>()
2230                                .join(", ")
2231                        }
2232                    ),
2233                ));
2234                return;
2235            }
2236
2237            // Show the user's @mention message in chat
2238            self.messages
2239                .push(ChatMessage::new("user", format!("@{target} {content}")));
2240            self.scroll = SCROLL_BOTTOM;
2241
2242            // Send the message over the bus
2243            if let Some(ref bus) = self.bus {
2244                let handle = bus.handle("user");
2245                handle.send_to_agent(
2246                    &target,
2247                    vec![crate::a2a::types::Part::Text {
2248                        text: content.clone(),
2249                    }],
2250                );
2251            }
2252
2253            // Send the message to the target agent's session
2254            self.send_to_agent(&target, &content, config).await;
2255            return;
2256        }
2257
2258        // If a spawned agent is focused, route plain messages there automatically.
2259        if !message.trim().starts_with('/')
2260            && let Some(target) = self.active_spawned_agent.clone()
2261        {
2262            if !self.spawned_agents.contains_key(&target) {
2263                self.active_spawned_agent = None;
2264                self.messages.push(ChatMessage::new(
2265                    "system",
2266                    format!(
2267                        "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
2268                    ),
2269                ));
2270                return;
2271            }
2272
2273            let content = message.trim().to_string();
2274            if content.is_empty() {
2275                return;
2276            }
2277
2278            self.messages
2279                .push(ChatMessage::new("user", format!("@{target} {content}")));
2280            self.scroll = SCROLL_BOTTOM;
2281
2282            if let Some(ref bus) = self.bus {
2283                let handle = bus.handle("user");
2284                handle.send_to_agent(
2285                    &target,
2286                    vec![crate::a2a::types::Part::Text {
2287                        text: content.clone(),
2288                    }],
2289                );
2290            }
2291
2292            self.send_to_agent(&target, &content, config).await;
2293            return;
2294        }
2295
2296        // Check for /sessions command - open session picker
2297        if message.trim() == "/sessions" {
2298            match list_sessions_with_opencode(&self.workspace_dir).await {
2299                Ok(sessions) => {
2300                    if sessions.is_empty() {
2301                        self.messages
2302                            .push(ChatMessage::new("system", "No saved sessions found."));
2303                    } else {
2304                        self.update_cached_sessions(sessions);
2305                        self.session_picker_selected = 0;
2306                        self.view_mode = ViewMode::SessionPicker;
2307                    }
2308                }
2309                Err(e) => {
2310                    self.messages.push(ChatMessage::new(
2311                        "system",
2312                        format!("Failed to list sessions: {}", e),
2313                    ));
2314                }
2315            }
2316            return;
2317        }
2318
2319        // Check for /resume command to load a session
2320        if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
2321            let session_id = message
2322                .trim()
2323                .strip_prefix("/resume")
2324                .map(|s| s.trim())
2325                .filter(|s| !s.is_empty());
2326            let loaded = if let Some(id) = session_id {
2327                if let Some(oc_id) = id.strip_prefix("opencode_") {
2328                    if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
2329                        Session::from_opencode(oc_id, &storage).await
2330                    } else {
2331                        Err(anyhow::anyhow!("OpenCode storage not available"))
2332                    }
2333                } else {
2334                    Session::load(id).await
2335                }
2336            } else {
2337                match Session::last_for_directory(Some(&self.workspace_dir)).await {
2338                    Ok(s) => Ok(s),
2339                    Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
2340                }
2341            };
2342
2343            match loaded {
2344                Ok(session) => {
2345                    // Convert session messages to chat messages
2346                    self.messages.clear();
2347                    self.messages.push(ChatMessage::new(
2348                        "system",
2349                        format!(
2350                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
2351                            session.title.as_deref().unwrap_or("(untitled)"),
2352                            session.created_at.format("%Y-%m-%d %H:%M"),
2353                            session.messages.len()
2354                        ),
2355                    ));
2356
2357                    for msg in &session.messages {
2358                        let role_str = match msg.role {
2359                            Role::System => "system",
2360                            Role::User => "user",
2361                            Role::Assistant => "assistant",
2362                            Role::Tool => "tool",
2363                        };
2364
2365                        // Process each content part separately
2366                        for part in &msg.content {
2367                            match part {
2368                                ContentPart::Text { text } => {
2369                                    if !text.is_empty() {
2370                                        self.messages
2371                                            .push(ChatMessage::new(role_str, text.clone()));
2372                                    }
2373                                }
2374                                ContentPart::Image { url, mime_type } => {
2375                                    self.messages.push(
2376                                        ChatMessage::new(role_str, "").with_message_type(
2377                                            MessageType::Image {
2378                                                url: url.clone(),
2379                                                mime_type: mime_type.clone(),
2380                                            },
2381                                        ),
2382                                    );
2383                                }
2384                                ContentPart::ToolCall {
2385                                    name, arguments, ..
2386                                } => {
2387                                    let (preview, truncated) = build_tool_arguments_preview(
2388                                        name,
2389                                        arguments,
2390                                        TOOL_ARGS_PREVIEW_MAX_LINES,
2391                                        TOOL_ARGS_PREVIEW_MAX_BYTES,
2392                                    );
2393                                    self.messages.push(
2394                                        ChatMessage::new(role_str, format!("🔧 {name}"))
2395                                            .with_message_type(MessageType::ToolCall {
2396                                                name: name.clone(),
2397                                                arguments_preview: preview,
2398                                                arguments_len: arguments.len(),
2399                                                truncated,
2400                                            }),
2401                                    );
2402                                }
2403                                ContentPart::ToolResult { content, .. } => {
2404                                    let truncated = truncate_with_ellipsis(content, 500);
2405                                    let (preview, preview_truncated) = build_text_preview(
2406                                        content,
2407                                        TOOL_OUTPUT_PREVIEW_MAX_LINES,
2408                                        TOOL_OUTPUT_PREVIEW_MAX_BYTES,
2409                                    );
2410                                    self.messages.push(
2411                                        ChatMessage::new(
2412                                            role_str,
2413                                            format!("✅ Result\n{truncated}"),
2414                                        )
2415                                        .with_message_type(MessageType::ToolResult {
2416                                            name: "tool".to_string(),
2417                                            output_preview: preview,
2418                                            output_len: content.len(),
2419                                            truncated: preview_truncated,
2420                                            success: true,
2421                                            duration_ms: None,
2422                                        }),
2423                                    );
2424                                }
2425                                ContentPart::File { path, mime_type } => {
2426                                    self.messages.push(
2427                                        ChatMessage::new(role_str, format!("📎 {}", path))
2428                                            .with_message_type(MessageType::File {
2429                                                path: path.clone(),
2430                                                mime_type: mime_type.clone(),
2431                                            }),
2432                                    );
2433                                }
2434                                ContentPart::Thinking { text } => {
2435                                    if !text.is_empty() {
2436                                        self.messages.push(
2437                                            ChatMessage::new(role_str, text.clone())
2438                                                .with_message_type(MessageType::Thinking(
2439                                                    text.clone(),
2440                                                )),
2441                                        );
2442                                    }
2443                                }
2444                            }
2445                        }
2446                    }
2447
2448                    self.current_agent = session.agent.clone();
2449                    self.session = Some(session);
2450                    self.scroll = SCROLL_BOTTOM;
2451                }
2452                Err(e) => {
2453                    self.messages.push(ChatMessage::new(
2454                        "system",
2455                        format!("Failed to load session: {}", e),
2456                    ));
2457                }
2458            }
2459            return;
2460        }
2461
2462        // Check for /model command - open model picker
2463        if message.trim() == "/model" || message.trim().starts_with("/model ") {
2464            let direct_model = message
2465                .trim()
2466                .strip_prefix("/model")
2467                .map(|s| s.trim())
2468                .filter(|s| !s.is_empty());
2469
2470            if let Some(model_str) = direct_model {
2471                // Direct set: /model provider/model-name
2472                self.active_model = Some(model_str.to_string());
2473                if let Some(session) = self.session.as_mut() {
2474                    session.metadata.model = Some(model_str.to_string());
2475                }
2476                self.messages.push(ChatMessage::new(
2477                    "system",
2478                    format!("Model set to: {}", model_str),
2479                ));
2480            } else {
2481                // Open model picker
2482                self.open_model_picker(config).await;
2483            }
2484            return;
2485        }
2486
2487        // Check for /undo command - remove last user turn and response
2488        if message.trim() == "/undo" {
2489            // Remove from TUI messages: walk backwards and remove everything
2490            // until we've removed the last "user" message (inclusive)
2491            let mut found_user = false;
2492            while let Some(msg) = self.messages.last() {
2493                if msg.role == "user" {
2494                    if found_user {
2495                        break; // hit the previous user turn, stop
2496                    }
2497                    found_user = true;
2498                }
2499                // Skip system messages that aren't part of the turn
2500                if msg.role == "system" && !found_user {
2501                    break;
2502                }
2503                self.messages.pop();
2504            }
2505
2506            if !found_user {
2507                self.messages
2508                    .push(ChatMessage::new("system", "Nothing to undo."));
2509                return;
2510            }
2511
2512            // Remove from session: walk backwards removing the last user message
2513            // and all assistant/tool messages after it
2514            if let Some(session) = self.session.as_mut() {
2515                let mut found_session_user = false;
2516                while let Some(msg) = session.messages.last() {
2517                    if msg.role == crate::provider::Role::User {
2518                        if found_session_user {
2519                            break;
2520                        }
2521                        found_session_user = true;
2522                    }
2523                    if msg.role == crate::provider::Role::System && !found_session_user {
2524                        break;
2525                    }
2526                    session.messages.pop();
2527                }
2528                if let Err(e) = session.save().await {
2529                    tracing::warn!(error = %e, "Failed to save session after undo");
2530                }
2531            }
2532
2533            self.messages.push(ChatMessage::new(
2534                "system",
2535                "Undid last message and response.",
2536            ));
2537            self.scroll = SCROLL_BOTTOM;
2538            return;
2539        }
2540
2541        // Check for /new command to start a fresh session
2542        if message.trim() == "/new" {
2543            self.session = None;
2544            self.messages.clear();
2545            self.messages.push(ChatMessage::new(
2546                "system",
2547                "Started a new session. Previous session was saved.",
2548            ));
2549            return;
2550        }
2551
2552        // Add user message
2553        self.messages
2554            .push(ChatMessage::new("user", message.clone()));
2555
2556        // Auto-scroll to bottom when user sends a message
2557        self.scroll = SCROLL_BOTTOM;
2558
2559        let current_agent = self.current_agent.clone();
2560        let model = self
2561            .active_model
2562            .clone()
2563            .or_else(|| {
2564                config
2565                    .agents
2566                    .get(&current_agent)
2567                    .and_then(|agent| agent.model.clone())
2568            })
2569            .or_else(|| config.default_model.clone())
2570            .or_else(|| Some("zai/glm-5".to_string()));
2571
2572        // Initialize session if needed
2573        if self.session.is_none() {
2574            match Session::new().await {
2575                Ok(session) => {
2576                    self.session = Some(session);
2577                }
2578                Err(err) => {
2579                    tracing::error!(error = %err, "Failed to create session");
2580                    self.messages
2581                        .push(ChatMessage::new("assistant", format!("Error: {err}")));
2582                    return;
2583                }
2584            }
2585        }
2586
2587        let session = match self.session.as_mut() {
2588            Some(session) => session,
2589            None => {
2590                self.messages.push(ChatMessage::new(
2591                    "assistant",
2592                    "Error: session not initialized",
2593                ));
2594                return;
2595            }
2596        };
2597
2598        if let Some(model) = model {
2599            session.metadata.model = Some(model);
2600        }
2601
2602        session.agent = current_agent;
2603
2604        // Set processing state
2605        self.is_processing = true;
2606        self.processing_message = Some("Thinking...".to_string());
2607        self.current_tool = None;
2608        self.current_tool_started_at = None;
2609        self.processing_started_at = Some(Instant::now());
2610        self.streaming_text = None;
2611
2612        // Load provider registry once and cache it
2613        if self.provider_registry.is_none() {
2614            match crate::provider::ProviderRegistry::from_vault().await {
2615                Ok(registry) => {
2616                    self.provider_registry = Some(std::sync::Arc::new(registry));
2617                }
2618                Err(err) => {
2619                    tracing::error!(error = %err, "Failed to load provider registry");
2620                    self.messages.push(ChatMessage::new(
2621                        "assistant",
2622                        format!("Error loading providers: {err}"),
2623                    ));
2624                    self.is_processing = false;
2625                    return;
2626                }
2627            }
2628        }
2629        let registry = self.provider_registry.clone().unwrap();
2630
2631        // Create channel for async communication
2632        let (tx, rx) = mpsc::channel(100);
2633        self.response_rx = Some(rx);
2634
2635        // Clone session for async processing
2636        let session_clone = session.clone();
2637        let message_clone = message.clone();
2638
2639        // Spawn async task to process the message with event streaming
2640        tokio::spawn(async move {
2641            let mut session = session_clone;
2642            if let Err(err) = session
2643                .prompt_with_events(&message_clone, tx.clone(), registry)
2644                .await
2645            {
2646                tracing::error!(error = %err, "Agent processing failed");
2647                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
2648                let _ = tx.send(SessionEvent::Done).await;
2649            }
2650        });
2651    }
2652
2653    fn handle_response(&mut self, event: SessionEvent) {
2654        // Auto-scroll to bottom when new content arrives
2655        self.scroll = SCROLL_BOTTOM;
2656
2657        match event {
2658            SessionEvent::Thinking => {
2659                self.processing_message = Some("Thinking...".to_string());
2660                self.current_tool = None;
2661                self.current_tool_started_at = None;
2662                if self.processing_started_at.is_none() {
2663                    self.processing_started_at = Some(Instant::now());
2664                }
2665            }
2666            SessionEvent::ToolCallStart { name, arguments } => {
2667                // Flush any streaming text before showing tool call
2668                if let Some(text) = self.streaming_text.take() {
2669                    if !text.is_empty() {
2670                        self.messages.push(ChatMessage::new("assistant", text));
2671                    }
2672                }
2673                self.processing_message = Some(format!("Running {}...", name));
2674                self.current_tool = Some(name.clone());
2675                self.current_tool_started_at = Some(Instant::now());
2676                self.tool_call_count += 1;
2677
2678                let (preview, truncated) = build_tool_arguments_preview(
2679                    &name,
2680                    &arguments,
2681                    TOOL_ARGS_PREVIEW_MAX_LINES,
2682                    TOOL_ARGS_PREVIEW_MAX_BYTES,
2683                );
2684                self.messages.push(
2685                    ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
2686                        MessageType::ToolCall {
2687                            name,
2688                            arguments_preview: preview,
2689                            arguments_len: arguments.len(),
2690                            truncated,
2691                        },
2692                    ),
2693                );
2694            }
2695            SessionEvent::ToolCallComplete {
2696                name,
2697                output,
2698                success,
2699            } => {
2700                let icon = if success { "✓" } else { "✗" };
2701                let duration_ms = self
2702                    .current_tool_started_at
2703                    .take()
2704                    .map(|started| started.elapsed().as_millis() as u64);
2705
2706                let (preview, truncated) = build_text_preview(
2707                    &output,
2708                    TOOL_OUTPUT_PREVIEW_MAX_LINES,
2709                    TOOL_OUTPUT_PREVIEW_MAX_BYTES,
2710                );
2711                self.messages.push(
2712                    ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
2713                        MessageType::ToolResult {
2714                            name,
2715                            output_preview: preview,
2716                            output_len: output.len(),
2717                            truncated,
2718                            success,
2719                            duration_ms,
2720                        },
2721                    ),
2722                );
2723                self.current_tool = None;
2724                self.processing_message = Some("Thinking...".to_string());
2725            }
2726            SessionEvent::TextChunk(text) => {
2727                // Show streaming text as it arrives (before TextComplete finalizes)
2728                self.streaming_text = Some(text);
2729            }
2730            SessionEvent::ThinkingComplete(text) => {
2731                if !text.is_empty() {
2732                    self.messages.push(
2733                        ChatMessage::new("assistant", &text)
2734                            .with_message_type(MessageType::Thinking(text)),
2735                    );
2736                }
2737            }
2738            SessionEvent::TextComplete(text) => {
2739                // Clear streaming preview and add the final message
2740                self.streaming_text = None;
2741                if !text.is_empty() {
2742                    self.messages.push(ChatMessage::new("assistant", text));
2743                }
2744            }
2745            SessionEvent::UsageReport {
2746                prompt_tokens,
2747                completion_tokens,
2748                duration_ms,
2749                model,
2750            } => {
2751                let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
2752                let meta = UsageMeta {
2753                    prompt_tokens,
2754                    completion_tokens,
2755                    duration_ms,
2756                    cost_usd,
2757                };
2758                // Attach to the most recent assistant message
2759                if let Some(msg) = self
2760                    .messages
2761                    .iter_mut()
2762                    .rev()
2763                    .find(|m| m.role == "assistant")
2764                {
2765                    msg.usage_meta = Some(meta);
2766                }
2767            }
2768            SessionEvent::SessionSync(session) => {
2769                // Sync the updated session (with full conversation history) back
2770                // so subsequent messages include prior context.
2771                self.session = Some(session);
2772            }
2773            SessionEvent::Error(err) => {
2774                self.current_tool_started_at = None;
2775                self.messages
2776                    .push(ChatMessage::new("assistant", format!("Error: {}", err)));
2777            }
2778            SessionEvent::Done => {
2779                self.is_processing = false;
2780                self.processing_message = None;
2781                self.current_tool = None;
2782                self.current_tool_started_at = None;
2783                self.processing_started_at = None;
2784                self.streaming_text = None;
2785                self.response_rx = None;
2786            }
2787        }
2788    }
2789
2790    /// Send a message to a specific spawned agent
2791    async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
2792        // Load provider registry if needed
2793        if self.provider_registry.is_none() {
2794            match crate::provider::ProviderRegistry::from_vault().await {
2795                Ok(registry) => {
2796                    self.provider_registry = Some(std::sync::Arc::new(registry));
2797                }
2798                Err(err) => {
2799                    self.messages.push(ChatMessage::new(
2800                        "system",
2801                        format!("Error loading providers: {err}"),
2802                    ));
2803                    return;
2804                }
2805            }
2806        }
2807        let registry = self.provider_registry.clone().unwrap();
2808
2809        let agent = match self.spawned_agents.get_mut(agent_name) {
2810            Some(a) => a,
2811            None => return,
2812        };
2813
2814        agent.is_processing = true;
2815        self.streaming_agent_texts.remove(agent_name);
2816        let session_clone = agent.session.clone();
2817        let msg_clone = message.to_string();
2818        let agent_name_owned = agent_name.to_string();
2819        let bus_arc = self.bus.clone();
2820
2821        let (tx, rx) = mpsc::channel(100);
2822        self.agent_response_rxs.push((agent_name.to_string(), rx));
2823
2824        tokio::spawn(async move {
2825            let mut session = session_clone;
2826            if let Err(err) = session
2827                .prompt_with_events(&msg_clone, tx.clone(), registry)
2828                .await
2829            {
2830                tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
2831                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
2832                let _ = tx.send(SessionEvent::Done).await;
2833            }
2834
2835            // Send the agent's response over the bus
2836            if let Some(ref bus) = bus_arc {
2837                let handle = bus.handle(&agent_name_owned);
2838                handle.send(
2839                    format!("agent.{agent_name_owned}.events"),
2840                    crate::bus::BusMessage::AgentMessage {
2841                        from: agent_name_owned.clone(),
2842                        to: "user".to_string(),
2843                        parts: vec![crate::a2a::types::Part::Text {
2844                            text: "(response complete)".to_string(),
2845                        }],
2846                    },
2847                );
2848            }
2849        });
2850    }
2851
2852    /// Handle an event from a spawned agent
2853    fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
2854        self.scroll = SCROLL_BOTTOM;
2855
2856        match event {
2857            SessionEvent::Thinking => {
2858                // Show thinking indicator for this agent
2859                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
2860                    agent.is_processing = true;
2861                }
2862            }
2863            SessionEvent::ToolCallStart { name, arguments } => {
2864                self.streaming_agent_texts.remove(agent_name);
2865                self.agent_tool_started_at
2866                    .insert(agent_name.to_string(), Instant::now());
2867                let (preview, truncated) = build_tool_arguments_preview(
2868                    &name,
2869                    &arguments,
2870                    TOOL_ARGS_PREVIEW_MAX_LINES,
2871                    TOOL_ARGS_PREVIEW_MAX_BYTES,
2872                );
2873                self.messages.push(
2874                    ChatMessage::new(
2875                        "tool",
2876                        format!("🔧 {} → {name}", format_agent_identity(agent_name)),
2877                    )
2878                    .with_message_type(MessageType::ToolCall {
2879                        name,
2880                        arguments_preview: preview,
2881                        arguments_len: arguments.len(),
2882                        truncated,
2883                    })
2884                    .with_agent_name(agent_name),
2885                );
2886            }
2887            SessionEvent::ToolCallComplete {
2888                name,
2889                output,
2890                success,
2891            } => {
2892                self.streaming_agent_texts.remove(agent_name);
2893                let icon = if success { "✓" } else { "✗" };
2894                let duration_ms = self
2895                    .agent_tool_started_at
2896                    .remove(agent_name)
2897                    .map(|started| started.elapsed().as_millis() as u64);
2898                let (preview, truncated) = build_text_preview(
2899                    &output,
2900                    TOOL_OUTPUT_PREVIEW_MAX_LINES,
2901                    TOOL_OUTPUT_PREVIEW_MAX_BYTES,
2902                );
2903                self.messages.push(
2904                    ChatMessage::new(
2905                        "tool",
2906                        format!("{icon} {} → {name}", format_agent_identity(agent_name)),
2907                    )
2908                    .with_message_type(MessageType::ToolResult {
2909                        name,
2910                        output_preview: preview,
2911                        output_len: output.len(),
2912                        truncated,
2913                        success,
2914                        duration_ms,
2915                    })
2916                    .with_agent_name(agent_name),
2917                );
2918            }
2919            SessionEvent::TextChunk(text) => {
2920                if text.is_empty() {
2921                    self.streaming_agent_texts.remove(agent_name);
2922                } else {
2923                    self.streaming_agent_texts
2924                        .insert(agent_name.to_string(), text);
2925                }
2926            }
2927            SessionEvent::ThinkingComplete(text) => {
2928                self.streaming_agent_texts.remove(agent_name);
2929                if !text.is_empty() {
2930                    self.messages.push(
2931                        ChatMessage::new("assistant", &text)
2932                            .with_message_type(MessageType::Thinking(text))
2933                            .with_agent_name(agent_name),
2934                    );
2935                }
2936            }
2937            SessionEvent::TextComplete(text) => {
2938                self.streaming_agent_texts.remove(agent_name);
2939                if !text.is_empty() {
2940                    self.messages
2941                        .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
2942                }
2943            }
2944            SessionEvent::UsageReport {
2945                prompt_tokens,
2946                completion_tokens,
2947                duration_ms,
2948                model,
2949            } => {
2950                let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
2951                let meta = UsageMeta {
2952                    prompt_tokens,
2953                    completion_tokens,
2954                    duration_ms,
2955                    cost_usd,
2956                };
2957                if let Some(msg) =
2958                    self.messages.iter_mut().rev().find(|m| {
2959                        m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
2960                    })
2961                {
2962                    msg.usage_meta = Some(meta);
2963                }
2964            }
2965            SessionEvent::SessionSync(session) => {
2966                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
2967                    agent.session = session;
2968                }
2969            }
2970            SessionEvent::Error(err) => {
2971                self.streaming_agent_texts.remove(agent_name);
2972                self.agent_tool_started_at.remove(agent_name);
2973                self.messages.push(
2974                    ChatMessage::new("assistant", format!("Error: {err}"))
2975                        .with_agent_name(agent_name),
2976                );
2977            }
2978            SessionEvent::Done => {
2979                self.streaming_agent_texts.remove(agent_name);
2980                self.agent_tool_started_at.remove(agent_name);
2981                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
2982                    agent.is_processing = false;
2983                }
2984            }
2985        }
2986    }
2987
2988    fn handle_autochat_event(&mut self, event: AutochatUiEvent) -> bool {
2989        match event {
2990            AutochatUiEvent::Progress(status) => {
2991                self.autochat_status = Some(status);
2992                false
2993            }
2994            AutochatUiEvent::SystemMessage(text) => {
2995                self.autochat_status = Some(
2996                    text.lines()
2997                        .next()
2998                        .unwrap_or("Relay update")
2999                        .trim()
3000                        .to_string(),
3001                );
3002                self.messages.push(ChatMessage::new("system", text));
3003                self.scroll = SCROLL_BOTTOM;
3004                false
3005            }
3006            AutochatUiEvent::AgentEvent { agent_name, event } => {
3007                self.autochat_status = Some(format!("Streaming from @{agent_name}…"));
3008                self.handle_agent_response(&agent_name, event);
3009                false
3010            }
3011            AutochatUiEvent::Completed { summary } => {
3012                self.autochat_status = Some("Completed".to_string());
3013                self.messages.push(ChatMessage::new("assistant", summary));
3014                self.scroll = SCROLL_BOTTOM;
3015                true
3016            }
3017        }
3018    }
3019
3020    /// Handle a swarm event
3021    fn handle_swarm_event(&mut self, event: SwarmEvent) {
3022        self.swarm_state.handle_event(event.clone());
3023
3024        // When swarm completes, switch back to chat view with summary
3025        if let SwarmEvent::Complete { success, ref stats } = event {
3026            self.view_mode = ViewMode::Chat;
3027            let summary = if success {
3028                format!(
3029                    "Swarm completed successfully.\n\
3030                     Subtasks: {} completed, {} failed\n\
3031                     Total tool calls: {}\n\
3032                     Time: {:.1}s (speedup: {:.1}x)",
3033                    stats.subagents_completed,
3034                    stats.subagents_failed,
3035                    stats.total_tool_calls,
3036                    stats.execution_time_ms as f64 / 1000.0,
3037                    stats.speedup_factor
3038                )
3039            } else {
3040                format!(
3041                    "Swarm completed with failures.\n\
3042                     Subtasks: {} completed, {} failed\n\
3043                     Check the subtask results for details.",
3044                    stats.subagents_completed, stats.subagents_failed
3045                )
3046            };
3047            self.messages.push(ChatMessage::new("system", &summary));
3048            self.swarm_rx = None;
3049        }
3050
3051        if let SwarmEvent::Error(ref err) = event {
3052            self.messages
3053                .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
3054        }
3055    }
3056
3057    /// Handle a Ralph event
3058    fn handle_ralph_event(&mut self, event: RalphEvent) {
3059        self.ralph_state.handle_event(event.clone());
3060
3061        // When Ralph completes, switch back to chat view with summary
3062        if let RalphEvent::Complete {
3063            ref status,
3064            passed,
3065            total,
3066        } = event
3067        {
3068            self.view_mode = ViewMode::Chat;
3069            let summary = format!(
3070                "Ralph loop finished: {}\n\
3071                 Stories: {}/{} passed",
3072                status, passed, total
3073            );
3074            self.messages.push(ChatMessage::new("system", &summary));
3075            self.ralph_rx = None;
3076        }
3077
3078        if let RalphEvent::Error(ref err) = event {
3079            self.messages
3080                .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
3081        }
3082    }
3083
3084    /// Start Ralph execution for a PRD
3085    async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
3086        // Add user message
3087        self.messages
3088            .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
3089
3090        // Get model from config
3091        let model = self
3092            .active_model
3093            .clone()
3094            .or_else(|| config.default_model.clone())
3095            .or_else(|| Some("zai/glm-5".to_string()));
3096
3097        let model = match model {
3098            Some(m) => m,
3099            None => {
3100                self.messages.push(ChatMessage::new(
3101                    "system",
3102                    "No model configured. Use /model to select one first.",
3103                ));
3104                return;
3105            }
3106        };
3107
3108        // Check PRD exists
3109        let prd_file = std::path::PathBuf::from(&prd_path);
3110        if !prd_file.exists() {
3111            self.messages.push(ChatMessage::new(
3112                "system",
3113                format!("PRD file not found: {}", prd_path),
3114            ));
3115            return;
3116        }
3117
3118        // Create channel for ralph events
3119        let (tx, rx) = mpsc::channel(200);
3120        self.ralph_rx = Some(rx);
3121
3122        // Switch to Ralph view
3123        self.view_mode = ViewMode::Ralph;
3124        self.ralph_state = RalphViewState::new();
3125
3126        // Build Ralph config
3127        let ralph_config = RalphConfig {
3128            prd_path: prd_path.clone(),
3129            max_iterations: 10,
3130            progress_path: "progress.txt".to_string(),
3131            quality_checks_enabled: true,
3132            auto_commit: true,
3133            model: Some(model.clone()),
3134            use_rlm: false,
3135            parallel_enabled: true,
3136            max_concurrent_stories: 3,
3137            worktree_enabled: true,
3138            story_timeout_secs: 300,
3139            conflict_timeout_secs: 120,
3140        };
3141
3142        // Parse provider/model from the model string
3143        let (provider_name, model_name) = if let Some(pos) = model.find('/') {
3144            (model[..pos].to_string(), model[pos + 1..].to_string())
3145        } else {
3146            (model.clone(), model.clone())
3147        };
3148
3149        let prd_path_clone = prd_path.clone();
3150        let tx_clone = tx.clone();
3151
3152        // Spawn Ralph execution
3153        tokio::spawn(async move {
3154            // Get provider from registry
3155            let provider = match crate::provider::ProviderRegistry::from_vault().await {
3156                Ok(registry) => match registry.get(&provider_name) {
3157                    Some(p) => p,
3158                    None => {
3159                        let _ = tx_clone
3160                            .send(RalphEvent::Error(format!(
3161                                "Provider '{}' not found",
3162                                provider_name
3163                            )))
3164                            .await;
3165                        return;
3166                    }
3167                },
3168                Err(e) => {
3169                    let _ = tx_clone
3170                        .send(RalphEvent::Error(format!(
3171                            "Failed to load providers: {}",
3172                            e
3173                        )))
3174                        .await;
3175                    return;
3176                }
3177            };
3178
3179            let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
3180            match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
3181                Ok(ralph) => {
3182                    let mut ralph = ralph.with_event_tx(tx_clone.clone());
3183                    match ralph.run().await {
3184                        Ok(_state) => {
3185                            // Complete event already emitted by run()
3186                        }
3187                        Err(e) => {
3188                            let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
3189                        }
3190                    }
3191                }
3192                Err(e) => {
3193                    let _ = tx_clone
3194                        .send(RalphEvent::Error(format!(
3195                            "Failed to initialize Ralph: {}",
3196                            e
3197                        )))
3198                        .await;
3199                }
3200            }
3201        });
3202
3203        self.messages.push(ChatMessage::new(
3204            "system",
3205            format!("Starting Ralph loop with PRD: {}", prd_path),
3206        ));
3207    }
3208
3209    /// Start swarm execution for a task
3210    async fn start_swarm_execution(&mut self, task: String, config: &Config) {
3211        // Add user message
3212        self.messages
3213            .push(ChatMessage::new("user", format!("/swarm {}", task)));
3214
3215        // Get model from config
3216        let model = config
3217            .default_model
3218            .clone()
3219            .or_else(|| Some("zai/glm-5".to_string()));
3220
3221        // Configure swarm
3222        let swarm_config = SwarmConfig {
3223            model,
3224            max_subagents: 10,
3225            max_steps_per_subagent: 50,
3226            worktree_enabled: true,
3227            worktree_auto_merge: true,
3228            working_dir: Some(
3229                std::env::current_dir()
3230                    .map(|p| p.to_string_lossy().to_string())
3231                    .unwrap_or_else(|_| ".".to_string()),
3232            ),
3233            ..Default::default()
3234        };
3235
3236        // Create channel for swarm events
3237        let (tx, rx) = mpsc::channel(100);
3238        self.swarm_rx = Some(rx);
3239
3240        // Switch to swarm view
3241        self.view_mode = ViewMode::Swarm;
3242        self.swarm_state = SwarmViewState::new();
3243
3244        // Send initial event
3245        let _ = tx
3246            .send(SwarmEvent::Started {
3247                task: task.clone(),
3248                total_subtasks: 0,
3249            })
3250            .await;
3251
3252        // Spawn swarm execution — executor emits all events via event_tx
3253        let task_clone = task;
3254        let bus_arc = self.bus.clone();
3255        tokio::spawn(async move {
3256            // Create executor with event channel — it handles decomposition + execution
3257            let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
3258            if let Some(bus) = bus_arc {
3259                executor = executor.with_bus(bus);
3260            }
3261            let result = executor
3262                .execute(&task_clone, DecompositionStrategy::Automatic)
3263                .await;
3264
3265            match result {
3266                Ok(swarm_result) => {
3267                    let _ = tx
3268                        .send(SwarmEvent::Complete {
3269                            success: swarm_result.success,
3270                            stats: swarm_result.stats,
3271                        })
3272                        .await;
3273                }
3274                Err(e) => {
3275                    let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
3276                }
3277            }
3278        });
3279    }
3280
3281    /// Populate and open the model picker overlay
3282    async fn open_model_picker(&mut self, config: &Config) {
3283        let mut models: Vec<(String, String, String)> = Vec::new();
3284
3285        // Try to build provider registry and list models
3286        match crate::provider::ProviderRegistry::from_vault().await {
3287            Ok(registry) => {
3288                for provider_name in registry.list() {
3289                    if let Some(provider) = registry.get(provider_name) {
3290                        match provider.list_models().await {
3291                            Ok(model_list) => {
3292                                for m in model_list {
3293                                    let label = format!("{}/{}", provider_name, m.id);
3294                                    let value = format!("{}/{}", provider_name, m.id);
3295                                    let name = m.name.clone();
3296                                    models.push((label, value, name));
3297                                }
3298                            }
3299                            Err(e) => {
3300                                tracing::warn!(
3301                                    "Failed to list models for {}: {}",
3302                                    provider_name,
3303                                    e
3304                                );
3305                            }
3306                        }
3307                    }
3308                }
3309            }
3310            Err(e) => {
3311                tracing::warn!("Failed to load provider registry: {}", e);
3312            }
3313        }
3314
3315        // Fallback: also try from config
3316        if models.is_empty() {
3317            if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
3318                for provider_name in registry.list() {
3319                    if let Some(provider) = registry.get(provider_name) {
3320                        if let Ok(model_list) = provider.list_models().await {
3321                            for m in model_list {
3322                                let label = format!("{}/{}", provider_name, m.id);
3323                                let value = format!("{}/{}", provider_name, m.id);
3324                                let name = m.name.clone();
3325                                models.push((label, value, name));
3326                            }
3327                        }
3328                    }
3329                }
3330            }
3331        }
3332
3333        if models.is_empty() {
3334            self.messages.push(ChatMessage::new(
3335                "system",
3336                "No models found. Check provider configuration (Vault or config).",
3337            ));
3338        } else {
3339            // Sort models by provider then name
3340            models.sort_by(|a, b| a.0.cmp(&b.0));
3341            self.model_picker_list = models;
3342            self.model_picker_selected = 0;
3343            self.model_picker_filter.clear();
3344            self.view_mode = ViewMode::ModelPicker;
3345        }
3346    }
3347
3348    /// Get filtered session list for the session picker
3349    fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
3350        if self.session_picker_filter.is_empty() {
3351            self.session_picker_list.iter().enumerate().collect()
3352        } else {
3353            let filter = self.session_picker_filter.to_lowercase();
3354            self.session_picker_list
3355                .iter()
3356                .enumerate()
3357                .filter(|(_, s)| {
3358                    s.title
3359                        .as_deref()
3360                        .unwrap_or("")
3361                        .to_lowercase()
3362                        .contains(&filter)
3363                        || s.agent.to_lowercase().contains(&filter)
3364                        || s.id.to_lowercase().contains(&filter)
3365                })
3366                .collect()
3367        }
3368    }
3369
3370    /// Get filtered model list
3371    fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
3372        if self.model_picker_filter.is_empty() {
3373            self.model_picker_list.iter().enumerate().collect()
3374        } else {
3375            let filter = self.model_picker_filter.to_lowercase();
3376            self.model_picker_list
3377                .iter()
3378                .enumerate()
3379                .filter(|(_, (label, _, name))| {
3380                    label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
3381                })
3382                .collect()
3383        }
3384    }
3385
3386    /// Get filtered spawned agents list (sorted by name)
3387    fn filtered_spawned_agents(&self) -> Vec<(String, String, bool, bool)> {
3388        let mut agents: Vec<(String, String, bool, bool)> = self
3389            .spawned_agents
3390            .iter()
3391            .map(|(name, agent)| {
3392                let protocol_registered = self.is_agent_protocol_registered(name);
3393                (
3394                    name.clone(),
3395                    agent.instructions.clone(),
3396                    agent.is_processing,
3397                    protocol_registered,
3398                )
3399            })
3400            .collect();
3401
3402        agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
3403
3404        if self.agent_picker_filter.is_empty() {
3405            agents
3406        } else {
3407            let filter = self.agent_picker_filter.to_lowercase();
3408            agents
3409                .into_iter()
3410                .filter(|(name, instructions, _, _)| {
3411                    name.to_lowercase().contains(&filter)
3412                        || instructions.to_lowercase().contains(&filter)
3413                })
3414                .collect()
3415        }
3416    }
3417
3418    /// Open picker for choosing a spawned sub-agent to focus
3419    fn open_agent_picker(&mut self) {
3420        if self.spawned_agents.is_empty() {
3421            self.messages.push(ChatMessage::new(
3422                "system",
3423                "No agents spawned yet. Use /spawn <name> <instructions> first.",
3424            ));
3425            return;
3426        }
3427
3428        self.agent_picker_filter.clear();
3429        let filtered = self.filtered_spawned_agents();
3430        self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
3431            filtered
3432                .iter()
3433                .position(|(name, _, _, _)| name == active)
3434                .unwrap_or(0)
3435        } else {
3436            0
3437        };
3438        self.view_mode = ViewMode::AgentPicker;
3439    }
3440
3441    fn navigate_history(&mut self, direction: isize) {
3442        if self.command_history.is_empty() {
3443            return;
3444        }
3445
3446        let history_len = self.command_history.len();
3447        let new_index = match self.history_index {
3448            Some(current) => {
3449                let new = current as isize + direction;
3450                if new < 0 {
3451                    None
3452                } else if new >= history_len as isize {
3453                    Some(history_len - 1)
3454                } else {
3455                    Some(new as usize)
3456                }
3457            }
3458            None => {
3459                if direction > 0 {
3460                    Some(0)
3461                } else {
3462                    Some(history_len.saturating_sub(1))
3463                }
3464            }
3465        };
3466
3467        self.history_index = new_index;
3468        if let Some(index) = new_index {
3469            self.input = self.command_history[index].clone();
3470            self.cursor_position = self.input.len();
3471        } else {
3472            self.input.clear();
3473            self.cursor_position = 0;
3474        }
3475    }
3476
3477    fn search_history(&mut self) {
3478        // Enhanced search: find commands matching current input prefix
3479        if self.command_history.is_empty() {
3480            return;
3481        }
3482
3483        let search_term = self.input.trim().to_lowercase();
3484
3485        if search_term.is_empty() {
3486            // Empty search - show most recent
3487            if !self.command_history.is_empty() {
3488                self.input = self.command_history.last().unwrap().clone();
3489                self.cursor_position = self.input.len();
3490                self.history_index = Some(self.command_history.len() - 1);
3491            }
3492            return;
3493        }
3494
3495        // Find the most recent command that starts with the search term
3496        for (index, cmd) in self.command_history.iter().enumerate().rev() {
3497            if cmd.to_lowercase().starts_with(&search_term) {
3498                self.input = cmd.clone();
3499                self.cursor_position = self.input.len();
3500                self.history_index = Some(index);
3501                return;
3502            }
3503        }
3504
3505        // If no prefix match, search for contains
3506        for (index, cmd) in self.command_history.iter().enumerate().rev() {
3507            if cmd.to_lowercase().contains(&search_term) {
3508                self.input = cmd.clone();
3509                self.cursor_position = self.input.len();
3510                self.history_index = Some(index);
3511                return;
3512            }
3513        }
3514    }
3515
3516    fn autochat_status_label(&self) -> Option<String> {
3517        if !self.autochat_running {
3518            return None;
3519        }
3520
3521        let elapsed = self
3522            .autochat_started_at
3523            .map(|started| {
3524                let elapsed = started.elapsed();
3525                if elapsed.as_secs() >= 60 {
3526                    format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
3527                } else {
3528                    format!("{:.1}s", elapsed.as_secs_f64())
3529                }
3530            })
3531            .unwrap_or_else(|| "0.0s".to_string());
3532
3533        let phase = self
3534            .autochat_status
3535            .as_deref()
3536            .unwrap_or("Relay is running…")
3537            .to_string();
3538
3539        Some(format!(
3540            "{} Autochat {elapsed} • {phase}",
3541            current_spinner_frame()
3542        ))
3543    }
3544
3545    fn chat_sync_summary(&self) -> String {
3546        if self.chat_sync_rx.is_none() && self.chat_sync_status.is_none() {
3547            if self.secure_environment {
3548                return "Remote sync: REQUIRED in secure environment (not running)".to_string();
3549            }
3550            return "Remote sync: disabled (set CODETETHER_CHAT_SYNC_ENABLED=true)".to_string();
3551        }
3552
3553        let status = self
3554            .chat_sync_status
3555            .as_deref()
3556            .unwrap_or("Remote sync active")
3557            .to_string();
3558        let last_success = self
3559            .chat_sync_last_success
3560            .as_deref()
3561            .unwrap_or("never")
3562            .to_string();
3563        let last_error = self
3564            .chat_sync_last_error
3565            .as_deref()
3566            .unwrap_or("none")
3567            .to_string();
3568
3569        format!(
3570            "Remote sync: {status}\nUploaded batches: {} ({})\nLast success: {last_success}\nLast error: {last_error}",
3571            self.chat_sync_uploaded_batches,
3572            format_bytes(self.chat_sync_uploaded_bytes)
3573        )
3574    }
3575
3576    fn handle_chat_sync_event(&mut self, event: ChatSyncUiEvent) {
3577        match event {
3578            ChatSyncUiEvent::Status(status) => {
3579                self.chat_sync_status = Some(status);
3580            }
3581            ChatSyncUiEvent::BatchUploaded {
3582                bytes,
3583                records,
3584                object_key,
3585            } => {
3586                self.chat_sync_uploaded_bytes = self.chat_sync_uploaded_bytes.saturating_add(bytes);
3587                self.chat_sync_uploaded_batches = self.chat_sync_uploaded_batches.saturating_add(1);
3588                let when = chrono::Local::now().format("%H:%M:%S").to_string();
3589                self.chat_sync_last_success = Some(format!(
3590                    "{} • {} records • {} • {}",
3591                    when,
3592                    records,
3593                    format_bytes(bytes),
3594                    object_key
3595                ));
3596                self.chat_sync_last_error = None;
3597                self.chat_sync_status =
3598                    Some(format!("Synced {} ({})", records, format_bytes(bytes)));
3599            }
3600            ChatSyncUiEvent::Error(error) => {
3601                self.chat_sync_last_error = Some(error.clone());
3602                self.chat_sync_status = Some("Sync error (will retry)".to_string());
3603            }
3604        }
3605    }
3606
3607    fn to_archive_record(
3608        message: &ChatMessage,
3609        workspace: &str,
3610        session_id: Option<String>,
3611    ) -> ChatArchiveRecord {
3612        let (message_type, tool_name, tool_success, tool_duration_ms) = match &message.message_type
3613        {
3614            MessageType::Text(_) => ("text".to_string(), None, None, None),
3615            MessageType::Image { .. } => ("image".to_string(), None, None, None),
3616            MessageType::ToolCall { name, .. } => {
3617                ("tool_call".to_string(), Some(name.clone()), None, None)
3618            }
3619            MessageType::ToolResult {
3620                name,
3621                success,
3622                duration_ms,
3623                ..
3624            } => (
3625                "tool_result".to_string(),
3626                Some(name.clone()),
3627                Some(*success),
3628                *duration_ms,
3629            ),
3630            MessageType::File { .. } => ("file".to_string(), None, None, None),
3631            MessageType::Thinking(_) => ("thinking".to_string(), None, None, None),
3632        };
3633
3634        ChatArchiveRecord {
3635            recorded_at: chrono::Utc::now().to_rfc3339(),
3636            workspace: workspace.to_string(),
3637            session_id,
3638            role: message.role.clone(),
3639            agent_name: message.agent_name.clone(),
3640            message_type,
3641            content: message.content.clone(),
3642            tool_name,
3643            tool_success,
3644            tool_duration_ms,
3645        }
3646    }
3647
3648    fn flush_chat_archive(&mut self) {
3649        let Some(path) = self.chat_archive_path.clone() else {
3650            self.archived_message_count = self.messages.len();
3651            return;
3652        };
3653
3654        if self.archived_message_count >= self.messages.len() {
3655            return;
3656        }
3657
3658        let workspace = self.workspace_dir.to_string_lossy().to_string();
3659        let session_id = self.session.as_ref().map(|session| session.id.clone());
3660        let records: Vec<ChatArchiveRecord> = self.messages[self.archived_message_count..]
3661            .iter()
3662            .map(|message| Self::to_archive_record(message, &workspace, session_id.clone()))
3663            .collect();
3664
3665        if let Some(parent) = path.parent()
3666            && let Err(err) = std::fs::create_dir_all(parent)
3667        {
3668            tracing::warn!(error = %err, path = %parent.display(), "Failed to create chat archive directory");
3669            return;
3670        }
3671
3672        let mut file = match std::fs::OpenOptions::new()
3673            .create(true)
3674            .append(true)
3675            .open(&path)
3676        {
3677            Ok(file) => file,
3678            Err(err) => {
3679                tracing::warn!(error = %err, path = %path.display(), "Failed to open chat archive file");
3680                return;
3681            }
3682        };
3683
3684        for record in records {
3685            if let Err(err) = serde_json::to_writer(&mut file, &record) {
3686                tracing::warn!(error = %err, path = %path.display(), "Failed to serialize chat archive record");
3687                return;
3688            }
3689            if let Err(err) = writeln!(&mut file) {
3690                tracing::warn!(error = %err, path = %path.display(), "Failed to write chat archive newline");
3691                return;
3692            }
3693        }
3694
3695        self.archived_message_count = self.messages.len();
3696    }
3697}
3698
3699async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
3700    let mut app = App::new();
3701    if let Ok(sessions) = list_sessions_with_opencode(&app.workspace_dir).await {
3702        app.update_cached_sessions(sessions);
3703    }
3704
3705    // Create agent bus and subscribe the TUI as an observer
3706    let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
3707    let mut bus_handle = bus.handle("tui-observer");
3708    let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
3709    app.bus_log_rx = Some(bus_rx);
3710    app.bus = Some(bus.clone());
3711
3712    // Spawn a forwarder task: bus broadcast → mpsc channel for the TUI event loop
3713    tokio::spawn(async move {
3714        loop {
3715            match bus_handle.recv().await {
3716                Some(env) => {
3717                    if bus_tx.send(env).await.is_err() {
3718                        break; // TUI closed
3719                    }
3720                }
3721                None => break, // bus closed
3722            }
3723        }
3724    });
3725
3726    // Load configuration and theme
3727    let mut config = Config::load().await?;
3728    let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
3729
3730    let secure_environment = is_secure_environment();
3731    app.secure_environment = secure_environment;
3732
3733    match parse_chat_sync_config(secure_environment).await {
3734        Ok(Some(sync_config)) => {
3735            if let Some(archive_path) = app.chat_archive_path.clone() {
3736                let (chat_sync_tx, chat_sync_rx) = mpsc::channel::<ChatSyncUiEvent>(64);
3737                app.chat_sync_rx = Some(chat_sync_rx);
3738                app.chat_sync_status = Some("Starting remote archive sync worker…".to_string());
3739                tokio::spawn(async move {
3740                    run_chat_sync_worker(chat_sync_tx, archive_path, sync_config).await;
3741                });
3742            } else {
3743                let message = "Remote chat sync is enabled, but local archive path is unavailable.";
3744                if secure_environment {
3745                    return Err(anyhow::anyhow!(
3746                        "{message} Secure environment requires remote chat sync."
3747                    ));
3748                }
3749                app.messages.push(ChatMessage::new("system", message));
3750            }
3751        }
3752        Ok(None) => {}
3753        Err(err) => {
3754            if secure_environment {
3755                return Err(anyhow::anyhow!(
3756                    "Secure environment requires remote chat sync: {err}"
3757                ));
3758            }
3759            app.messages.push(ChatMessage::new(
3760                "system",
3761                format!("Remote chat sync disabled due to configuration error: {err}"),
3762            ));
3763        }
3764    }
3765
3766    // Track last config modification time for hot-reloading
3767    let _config_paths = vec![
3768        std::path::PathBuf::from("./codetether.toml"),
3769        std::path::PathBuf::from("./.codetether/config.toml"),
3770    ];
3771
3772    let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
3773        .map(|dirs| dirs.config_dir().join("config.toml"));
3774
3775    let mut last_check = Instant::now();
3776    let mut event_stream = EventStream::new();
3777
3778    // Background session refresh — fires every 5s, sends results via channel
3779    let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
3780    {
3781        let workspace_dir = app.workspace_dir.clone();
3782        tokio::spawn(async move {
3783            let mut interval = tokio::time::interval(Duration::from_secs(5));
3784            loop {
3785                interval.tick().await;
3786                if let Ok(sessions) = list_sessions_with_opencode(&workspace_dir).await {
3787                    if session_tx.send(sessions).await.is_err() {
3788                        break; // TUI closed
3789                    }
3790                }
3791            }
3792        });
3793    }
3794
3795    loop {
3796        // --- Periodic background work (non-blocking) ---
3797
3798        // Receive session list updates from background task
3799        if let Ok(sessions) = session_rx.try_recv() {
3800            app.update_cached_sessions(sessions);
3801        }
3802
3803        // Check for theme changes if hot-reload is enabled
3804        if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
3805            if let Ok(new_config) = Config::load().await {
3806                if new_config.ui.theme != config.ui.theme
3807                    || new_config.ui.custom_theme != config.ui.custom_theme
3808                {
3809                    theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
3810                    config = new_config;
3811                }
3812            }
3813            last_check = Instant::now();
3814        }
3815
3816        terminal.draw(|f| ui(f, &mut app, &theme))?;
3817
3818        // Update max_scroll estimate for scroll key handlers
3819        // This needs to roughly match what ui() calculates
3820        let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
3821        let estimated_lines = app.messages.len() * 4; // rough estimate
3822        app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
3823
3824        // Drain all pending async responses
3825        if let Some(mut rx) = app.response_rx.take() {
3826            while let Ok(response) = rx.try_recv() {
3827                app.handle_response(response);
3828            }
3829            app.response_rx = Some(rx);
3830        }
3831
3832        // Drain all pending swarm events
3833        if let Some(mut rx) = app.swarm_rx.take() {
3834            while let Ok(event) = rx.try_recv() {
3835                app.handle_swarm_event(event);
3836            }
3837            app.swarm_rx = Some(rx);
3838        }
3839
3840        // Drain all pending ralph events
3841        if let Some(mut rx) = app.ralph_rx.take() {
3842            while let Ok(event) = rx.try_recv() {
3843                app.handle_ralph_event(event);
3844            }
3845            app.ralph_rx = Some(rx);
3846        }
3847
3848        // Drain all pending bus log events
3849        if let Some(mut rx) = app.bus_log_rx.take() {
3850            while let Ok(env) = rx.try_recv() {
3851                app.bus_log_state.ingest(&env);
3852            }
3853            app.bus_log_rx = Some(rx);
3854        }
3855
3856        // Drain all pending spawned-agent responses
3857        {
3858            let mut i = 0;
3859            while i < app.agent_response_rxs.len() {
3860                let mut done = false;
3861                while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
3862                    if matches!(event, SessionEvent::Done) {
3863                        done = true;
3864                    }
3865                    let name = app.agent_response_rxs[i].0.clone();
3866                    app.handle_agent_response(&name, event);
3867                }
3868                if done {
3869                    app.agent_response_rxs.swap_remove(i);
3870                } else {
3871                    i += 1;
3872                }
3873            }
3874        }
3875
3876        // Drain all pending background autochat events
3877        if let Some(mut rx) = app.autochat_rx.take() {
3878            let mut completed = false;
3879            while let Ok(event) = rx.try_recv() {
3880                if app.handle_autochat_event(event) {
3881                    completed = true;
3882                }
3883            }
3884
3885            if completed || rx.is_closed() {
3886                if !completed && app.autochat_running {
3887                    app.messages.push(ChatMessage::new(
3888                        "system",
3889                        "Autochat relay worker stopped unexpectedly.",
3890                    ));
3891                    app.scroll = SCROLL_BOTTOM;
3892                }
3893                app.autochat_running = false;
3894                app.autochat_started_at = None;
3895                app.autochat_status = None;
3896                app.autochat_rx = None;
3897            } else {
3898                app.autochat_rx = Some(rx);
3899            }
3900        }
3901
3902        // Drain all pending background chat sync events
3903        if let Some(mut rx) = app.chat_sync_rx.take() {
3904            while let Ok(event) = rx.try_recv() {
3905                app.handle_chat_sync_event(event);
3906            }
3907
3908            if rx.is_closed() {
3909                app.chat_sync_status = Some("Remote archive sync worker stopped.".to_string());
3910                app.chat_sync_rx = None;
3911                if app.secure_environment {
3912                    return Err(anyhow::anyhow!(
3913                        "Remote archive sync worker stopped in secure environment"
3914                    ));
3915                }
3916            } else {
3917                app.chat_sync_rx = Some(rx);
3918            }
3919        }
3920
3921        // Persist any newly appended chat messages for durable post-hoc analysis.
3922        app.flush_chat_archive();
3923
3924        // Wait for terminal events asynchronously (no blocking!)
3925        let ev = tokio::select! {
3926            maybe_event = event_stream.next() => {
3927                match maybe_event {
3928                    Some(Ok(ev)) => ev,
3929                    Some(Err(_)) => continue,
3930                    None => return Ok(()), // stream ended
3931                }
3932            }
3933            // Tick at 50ms to keep rendering responsive during streaming
3934            _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
3935        };
3936
3937        // Handle bracketed paste: insert entire clipboard text at cursor without submitting
3938        if let Event::Paste(text) = &ev {
3939            // Ensure cursor is at a valid char boundary before inserting
3940            let mut pos = app.cursor_position;
3941            while pos > 0 && !app.input.is_char_boundary(pos) {
3942                pos -= 1;
3943            }
3944            app.cursor_position = pos;
3945
3946            for c in text.chars() {
3947                if c == '\n' || c == '\r' {
3948                    // Replace newlines with spaces to keep paste as single message
3949                    app.input.insert(app.cursor_position, ' ');
3950                } else {
3951                    app.input.insert(app.cursor_position, c);
3952                }
3953                app.cursor_position += c.len_utf8();
3954            }
3955            continue;
3956        }
3957
3958        if let Event::Key(key) = ev {
3959            // Only handle key press events (not release or repeat-release).
3960            // Crossterm 0.29+ emits Press, Repeat, and Release events;
3961            // processing all of them causes double character entry.
3962            if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
3963                continue;
3964            }
3965
3966            // Help overlay
3967            if app.show_help {
3968                if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
3969                    app.show_help = false;
3970                }
3971                continue;
3972            }
3973
3974            // Model picker overlay
3975            if app.view_mode == ViewMode::ModelPicker {
3976                match key.code {
3977                    KeyCode::Esc => {
3978                        app.view_mode = ViewMode::Chat;
3979                    }
3980                    KeyCode::Up | KeyCode::Char('k')
3981                        if !key.modifiers.contains(KeyModifiers::ALT) =>
3982                    {
3983                        if app.model_picker_selected > 0 {
3984                            app.model_picker_selected -= 1;
3985                        }
3986                    }
3987                    KeyCode::Down | KeyCode::Char('j')
3988                        if !key.modifiers.contains(KeyModifiers::ALT) =>
3989                    {
3990                        let filtered = app.filtered_models();
3991                        if app.model_picker_selected < filtered.len().saturating_sub(1) {
3992                            app.model_picker_selected += 1;
3993                        }
3994                    }
3995                    KeyCode::Enter => {
3996                        let filtered = app.filtered_models();
3997                        if let Some((_, (label, value, _name))) =
3998                            filtered.get(app.model_picker_selected)
3999                        {
4000                            let label = label.clone();
4001                            let value = value.clone();
4002                            app.active_model = Some(value.clone());
4003                            if let Some(session) = app.session.as_mut() {
4004                                session.metadata.model = Some(value.clone());
4005                            }
4006                            app.messages.push(ChatMessage::new(
4007                                "system",
4008                                format!("Model set to: {}", label),
4009                            ));
4010                            app.view_mode = ViewMode::Chat;
4011                        }
4012                    }
4013                    KeyCode::Backspace => {
4014                        app.model_picker_filter.pop();
4015                        app.model_picker_selected = 0;
4016                    }
4017                    KeyCode::Char(c)
4018                        if !key.modifiers.contains(KeyModifiers::CONTROL)
4019                            && !key.modifiers.contains(KeyModifiers::ALT) =>
4020                    {
4021                        app.model_picker_filter.push(c);
4022                        app.model_picker_selected = 0;
4023                    }
4024                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4025                        return Ok(());
4026                    }
4027                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4028                        return Ok(());
4029                    }
4030                    _ => {}
4031                }
4032                continue;
4033            }
4034
4035            // Session picker overlay - handle specially
4036            if app.view_mode == ViewMode::SessionPicker {
4037                match key.code {
4038                    KeyCode::Esc => {
4039                        if app.session_picker_confirm_delete {
4040                            app.session_picker_confirm_delete = false;
4041                        } else {
4042                            app.session_picker_filter.clear();
4043                            app.view_mode = ViewMode::Chat;
4044                        }
4045                    }
4046                    KeyCode::Up | KeyCode::Char('k') => {
4047                        if app.session_picker_selected > 0 {
4048                            app.session_picker_selected -= 1;
4049                        }
4050                        app.session_picker_confirm_delete = false;
4051                    }
4052                    KeyCode::Down | KeyCode::Char('j') => {
4053                        let filtered_count = app.filtered_sessions().len();
4054                        if app.session_picker_selected < filtered_count.saturating_sub(1) {
4055                            app.session_picker_selected += 1;
4056                        }
4057                        app.session_picker_confirm_delete = false;
4058                    }
4059                    KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
4060                        if app.session_picker_confirm_delete {
4061                            // Second press: actually delete
4062                            let filtered = app.filtered_sessions();
4063                            if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
4064                                let session_id = app.session_picker_list[*orig_idx].id.clone();
4065                                let is_active = app
4066                                    .session
4067                                    .as_ref()
4068                                    .map(|s| s.id == session_id)
4069                                    .unwrap_or(false);
4070                                if !is_active {
4071                                    if let Err(e) = Session::delete(&session_id).await {
4072                                        app.messages.push(ChatMessage::new(
4073                                            "system",
4074                                            format!("Failed to delete session: {}", e),
4075                                        ));
4076                                    } else {
4077                                        app.session_picker_list.retain(|s| s.id != session_id);
4078                                        if app.session_picker_selected
4079                                            >= app.session_picker_list.len()
4080                                        {
4081                                            app.session_picker_selected =
4082                                                app.session_picker_list.len().saturating_sub(1);
4083                                        }
4084                                    }
4085                                }
4086                            }
4087                            app.session_picker_confirm_delete = false;
4088                        } else {
4089                            // First press: ask for confirmation
4090                            let filtered = app.filtered_sessions();
4091                            if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
4092                                let is_active = app
4093                                    .session
4094                                    .as_ref()
4095                                    .map(|s| s.id == app.session_picker_list[*orig_idx].id)
4096                                    .unwrap_or(false);
4097                                if !is_active {
4098                                    app.session_picker_confirm_delete = true;
4099                                }
4100                            }
4101                        }
4102                    }
4103                    KeyCode::Backspace => {
4104                        app.session_picker_filter.pop();
4105                        app.session_picker_selected = 0;
4106                        app.session_picker_confirm_delete = false;
4107                    }
4108                    KeyCode::Char('/') => {
4109                        // Focus filter (no-op, just signals we're in filter mode)
4110                    }
4111                    KeyCode::Enter => {
4112                        app.session_picker_confirm_delete = false;
4113                        let filtered = app.filtered_sessions();
4114                        let session_id = filtered
4115                            .get(app.session_picker_selected)
4116                            .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
4117                        if let Some(session_id) = session_id {
4118                            let load_result =
4119                                if let Some(oc_id) = session_id.strip_prefix("opencode_") {
4120                                    if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
4121                                        Session::from_opencode(oc_id, &storage).await
4122                                    } else {
4123                                        Err(anyhow::anyhow!("OpenCode storage not available"))
4124                                    }
4125                                } else {
4126                                    Session::load(&session_id).await
4127                                };
4128                            match load_result {
4129                                Ok(session) => {
4130                                    app.messages.clear();
4131                                    app.messages.push(ChatMessage::new(
4132                                        "system",
4133                                        format!(
4134                                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
4135                                            session.title.as_deref().unwrap_or("(untitled)"),
4136                                            session.created_at.format("%Y-%m-%d %H:%M"),
4137                                            session.messages.len()
4138                                        ),
4139                                    ));
4140
4141                                    for msg in &session.messages {
4142                                        let role_str = match msg.role {
4143                                            Role::System => "system",
4144                                            Role::User => "user",
4145                                            Role::Assistant => "assistant",
4146                                            Role::Tool => "tool",
4147                                        };
4148
4149                                        // Process each content part separately
4150                                        // (consistent with /resume command)
4151                                        for part in &msg.content {
4152                                            match part {
4153                                                ContentPart::Text { text } => {
4154                                                    if !text.is_empty() {
4155                                                        app.messages.push(ChatMessage::new(
4156                                                            role_str,
4157                                                            text.clone(),
4158                                                        ));
4159                                                    }
4160                                                }
4161                                                ContentPart::Image { url, mime_type } => {
4162                                                    app.messages.push(
4163                                                        ChatMessage::new(role_str, "")
4164                                                            .with_message_type(
4165                                                                MessageType::Image {
4166                                                                    url: url.clone(),
4167                                                                    mime_type: mime_type.clone(),
4168                                                                },
4169                                                            ),
4170                                                    );
4171                                                }
4172                                                ContentPart::ToolCall {
4173                                                    name, arguments, ..
4174                                                } => {
4175                                                    let (preview, truncated) =
4176                                                        build_tool_arguments_preview(
4177                                                            name,
4178                                                            arguments,
4179                                                            TOOL_ARGS_PREVIEW_MAX_LINES,
4180                                                            TOOL_ARGS_PREVIEW_MAX_BYTES,
4181                                                        );
4182                                                    app.messages.push(
4183                                                        ChatMessage::new(
4184                                                            role_str,
4185                                                            format!("🔧 {name}"),
4186                                                        )
4187                                                        .with_message_type(MessageType::ToolCall {
4188                                                            name: name.clone(),
4189                                                            arguments_preview: preview,
4190                                                            arguments_len: arguments.len(),
4191                                                            truncated,
4192                                                        }),
4193                                                    );
4194                                                }
4195                                                ContentPart::ToolResult { content, .. } => {
4196                                                    let truncated =
4197                                                        truncate_with_ellipsis(content, 500);
4198                                                    let (preview, preview_truncated) =
4199                                                        build_text_preview(
4200                                                            content,
4201                                                            TOOL_OUTPUT_PREVIEW_MAX_LINES,
4202                                                            TOOL_OUTPUT_PREVIEW_MAX_BYTES,
4203                                                        );
4204                                                    app.messages.push(
4205                                                        ChatMessage::new(
4206                                                            role_str,
4207                                                            format!("✅ Result\n{truncated}"),
4208                                                        )
4209                                                        .with_message_type(
4210                                                            MessageType::ToolResult {
4211                                                                name: "tool".to_string(),
4212                                                                output_preview: preview,
4213                                                                output_len: content.len(),
4214                                                                truncated: preview_truncated,
4215                                                                success: true,
4216                                                                duration_ms: None,
4217                                                            },
4218                                                        ),
4219                                                    );
4220                                                }
4221                                                ContentPart::File { path, mime_type } => {
4222                                                    app.messages.push(
4223                                                        ChatMessage::new(
4224                                                            role_str,
4225                                                            format!("📎 {path}"),
4226                                                        )
4227                                                        .with_message_type(MessageType::File {
4228                                                            path: path.clone(),
4229                                                            mime_type: mime_type.clone(),
4230                                                        }),
4231                                                    );
4232                                                }
4233                                                ContentPart::Thinking { text } => {
4234                                                    if !text.is_empty() {
4235                                                        app.messages.push(
4236                                                            ChatMessage::new(
4237                                                                role_str,
4238                                                                text.clone(),
4239                                                            )
4240                                                            .with_message_type(
4241                                                                MessageType::Thinking(text.clone()),
4242                                                            ),
4243                                                        );
4244                                                    }
4245                                                }
4246                                            }
4247                                        }
4248                                    }
4249
4250                                    app.current_agent = session.agent.clone();
4251                                    app.session = Some(session);
4252                                    app.scroll = SCROLL_BOTTOM;
4253                                    app.view_mode = ViewMode::Chat;
4254                                }
4255                                Err(e) => {
4256                                    app.messages.push(ChatMessage::new(
4257                                        "system",
4258                                        format!("Failed to load session: {}", e),
4259                                    ));
4260                                    app.view_mode = ViewMode::Chat;
4261                                }
4262                            }
4263                        }
4264                    }
4265                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4266                        return Ok(());
4267                    }
4268                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4269                        return Ok(());
4270                    }
4271                    KeyCode::Char(c)
4272                        if !key.modifiers.contains(KeyModifiers::CONTROL)
4273                            && !key.modifiers.contains(KeyModifiers::ALT)
4274                            && c != 'j'
4275                            && c != 'k' =>
4276                    {
4277                        app.session_picker_filter.push(c);
4278                        app.session_picker_selected = 0;
4279                        app.session_picker_confirm_delete = false;
4280                    }
4281                    _ => {}
4282                }
4283                continue;
4284            }
4285
4286            // Agent picker overlay
4287            if app.view_mode == ViewMode::AgentPicker {
4288                match key.code {
4289                    KeyCode::Esc => {
4290                        app.agent_picker_filter.clear();
4291                        app.view_mode = ViewMode::Chat;
4292                    }
4293                    KeyCode::Up | KeyCode::Char('k')
4294                        if !key.modifiers.contains(KeyModifiers::ALT) =>
4295                    {
4296                        if app.agent_picker_selected > 0 {
4297                            app.agent_picker_selected -= 1;
4298                        }
4299                    }
4300                    KeyCode::Down | KeyCode::Char('j')
4301                        if !key.modifiers.contains(KeyModifiers::ALT) =>
4302                    {
4303                        let filtered = app.filtered_spawned_agents();
4304                        if app.agent_picker_selected < filtered.len().saturating_sub(1) {
4305                            app.agent_picker_selected += 1;
4306                        }
4307                    }
4308                    KeyCode::Enter => {
4309                        let filtered = app.filtered_spawned_agents();
4310                        if let Some((name, _, _, _)) = filtered.get(app.agent_picker_selected) {
4311                            app.active_spawned_agent = Some(name.clone());
4312                            app.messages.push(ChatMessage::new(
4313                                "system",
4314                                format!(
4315                                    "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
4316                                ),
4317                            ));
4318                            app.view_mode = ViewMode::Chat;
4319                        }
4320                    }
4321                    KeyCode::Backspace => {
4322                        app.agent_picker_filter.pop();
4323                        app.agent_picker_selected = 0;
4324                    }
4325                    KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
4326                        app.active_spawned_agent = None;
4327                        app.messages
4328                            .push(ChatMessage::new("system", "Returned to main chat mode."));
4329                        app.view_mode = ViewMode::Chat;
4330                    }
4331                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4332                        return Ok(());
4333                    }
4334                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4335                        return Ok(());
4336                    }
4337                    KeyCode::Char(c)
4338                        if !key.modifiers.contains(KeyModifiers::CONTROL)
4339                            && !key.modifiers.contains(KeyModifiers::ALT)
4340                            && c != 'j'
4341                            && c != 'k'
4342                            && c != 'm' =>
4343                    {
4344                        app.agent_picker_filter.push(c);
4345                        app.agent_picker_selected = 0;
4346                    }
4347                    _ => {}
4348                }
4349                continue;
4350            }
4351
4352            // Swarm view key handling
4353            if app.view_mode == ViewMode::Swarm {
4354                match key.code {
4355                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4356                        return Ok(());
4357                    }
4358                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4359                        return Ok(());
4360                    }
4361                    KeyCode::Esc => {
4362                        if app.swarm_state.detail_mode {
4363                            app.swarm_state.exit_detail();
4364                        } else {
4365                            app.view_mode = ViewMode::Chat;
4366                        }
4367                    }
4368                    KeyCode::Up | KeyCode::Char('k') => {
4369                        if app.swarm_state.detail_mode {
4370                            // In detail mode, Up/Down switch between agents
4371                            app.swarm_state.exit_detail();
4372                            app.swarm_state.select_prev();
4373                            app.swarm_state.enter_detail();
4374                        } else {
4375                            app.swarm_state.select_prev();
4376                        }
4377                    }
4378                    KeyCode::Down | KeyCode::Char('j') => {
4379                        if app.swarm_state.detail_mode {
4380                            app.swarm_state.exit_detail();
4381                            app.swarm_state.select_next();
4382                            app.swarm_state.enter_detail();
4383                        } else {
4384                            app.swarm_state.select_next();
4385                        }
4386                    }
4387                    KeyCode::Enter => {
4388                        if !app.swarm_state.detail_mode {
4389                            app.swarm_state.enter_detail();
4390                        }
4391                    }
4392                    KeyCode::PageDown => {
4393                        app.swarm_state.detail_scroll_down(10);
4394                    }
4395                    KeyCode::PageUp => {
4396                        app.swarm_state.detail_scroll_up(10);
4397                    }
4398                    KeyCode::Char('?') => {
4399                        app.show_help = true;
4400                    }
4401                    KeyCode::F(2) => {
4402                        app.view_mode = ViewMode::Chat;
4403                    }
4404                    KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4405                        app.view_mode = ViewMode::Chat;
4406                    }
4407                    _ => {}
4408                }
4409                continue;
4410            }
4411
4412            // Ralph view key handling
4413            if app.view_mode == ViewMode::Ralph {
4414                match key.code {
4415                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4416                        return Ok(());
4417                    }
4418                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4419                        return Ok(());
4420                    }
4421                    KeyCode::Esc => {
4422                        if app.ralph_state.detail_mode {
4423                            app.ralph_state.exit_detail();
4424                        } else {
4425                            app.view_mode = ViewMode::Chat;
4426                        }
4427                    }
4428                    KeyCode::Up | KeyCode::Char('k') => {
4429                        if app.ralph_state.detail_mode {
4430                            app.ralph_state.exit_detail();
4431                            app.ralph_state.select_prev();
4432                            app.ralph_state.enter_detail();
4433                        } else {
4434                            app.ralph_state.select_prev();
4435                        }
4436                    }
4437                    KeyCode::Down | KeyCode::Char('j') => {
4438                        if app.ralph_state.detail_mode {
4439                            app.ralph_state.exit_detail();
4440                            app.ralph_state.select_next();
4441                            app.ralph_state.enter_detail();
4442                        } else {
4443                            app.ralph_state.select_next();
4444                        }
4445                    }
4446                    KeyCode::Enter => {
4447                        if !app.ralph_state.detail_mode {
4448                            app.ralph_state.enter_detail();
4449                        }
4450                    }
4451                    KeyCode::PageDown => {
4452                        app.ralph_state.detail_scroll_down(10);
4453                    }
4454                    KeyCode::PageUp => {
4455                        app.ralph_state.detail_scroll_up(10);
4456                    }
4457                    KeyCode::Char('?') => {
4458                        app.show_help = true;
4459                    }
4460                    KeyCode::F(2) | KeyCode::Char('s')
4461                        if key.modifiers.contains(KeyModifiers::CONTROL) =>
4462                    {
4463                        app.view_mode = ViewMode::Chat;
4464                    }
4465                    _ => {}
4466                }
4467                continue;
4468            }
4469
4470            // Bus log view key handling
4471            if app.view_mode == ViewMode::BusLog {
4472                match key.code {
4473                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4474                        return Ok(());
4475                    }
4476                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4477                        return Ok(());
4478                    }
4479                    KeyCode::Esc => {
4480                        if app.bus_log_state.detail_mode {
4481                            app.bus_log_state.exit_detail();
4482                        } else {
4483                            app.view_mode = ViewMode::Chat;
4484                        }
4485                    }
4486                    KeyCode::Up | KeyCode::Char('k') => {
4487                        if app.bus_log_state.detail_mode {
4488                            app.bus_log_state.exit_detail();
4489                            app.bus_log_state.select_prev();
4490                            app.bus_log_state.enter_detail();
4491                        } else {
4492                            app.bus_log_state.select_prev();
4493                        }
4494                    }
4495                    KeyCode::Down | KeyCode::Char('j') => {
4496                        if app.bus_log_state.detail_mode {
4497                            app.bus_log_state.exit_detail();
4498                            app.bus_log_state.select_next();
4499                            app.bus_log_state.enter_detail();
4500                        } else {
4501                            app.bus_log_state.select_next();
4502                        }
4503                    }
4504                    KeyCode::Enter => {
4505                        if !app.bus_log_state.detail_mode {
4506                            app.bus_log_state.enter_detail();
4507                        }
4508                    }
4509                    KeyCode::PageDown => {
4510                        app.bus_log_state.detail_scroll_down(10);
4511                    }
4512                    KeyCode::PageUp => {
4513                        app.bus_log_state.detail_scroll_up(10);
4514                    }
4515                    // Clear all entries
4516                    KeyCode::Char('c') => {
4517                        app.bus_log_state.entries.clear();
4518                        app.bus_log_state.selected_index = 0;
4519                    }
4520                    // Jump to bottom (re-enable auto-scroll)
4521                    KeyCode::Char('g') => {
4522                        let len = app.bus_log_state.filtered_entries().len();
4523                        if len > 0 {
4524                            app.bus_log_state.selected_index = len - 1;
4525                            app.bus_log_state.list_state.select(Some(len - 1));
4526                        }
4527                        app.bus_log_state.auto_scroll = true;
4528                    }
4529                    KeyCode::Char('?') => {
4530                        app.show_help = true;
4531                    }
4532                    _ => {}
4533                }
4534                continue;
4535            }
4536
4537            // Protocol registry view key handling
4538            if app.view_mode == ViewMode::Protocol {
4539                match key.code {
4540                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4541                        return Ok(());
4542                    }
4543                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4544                        return Ok(());
4545                    }
4546                    KeyCode::Esc => {
4547                        app.view_mode = ViewMode::Chat;
4548                    }
4549                    KeyCode::Up | KeyCode::Char('k') => {
4550                        if app.protocol_selected > 0 {
4551                            app.protocol_selected -= 1;
4552                        }
4553                        app.protocol_scroll = 0;
4554                    }
4555                    KeyCode::Down | KeyCode::Char('j') => {
4556                        let len = app.protocol_cards().len();
4557                        if app.protocol_selected < len.saturating_sub(1) {
4558                            app.protocol_selected += 1;
4559                        }
4560                        app.protocol_scroll = 0;
4561                    }
4562                    KeyCode::PageDown => {
4563                        app.protocol_scroll = app.protocol_scroll.saturating_add(10);
4564                    }
4565                    KeyCode::PageUp => {
4566                        app.protocol_scroll = app.protocol_scroll.saturating_sub(10);
4567                    }
4568                    KeyCode::Char('g') => {
4569                        app.protocol_scroll = 0;
4570                    }
4571                    KeyCode::Char('?') => {
4572                        app.show_help = true;
4573                    }
4574                    _ => {}
4575                }
4576                continue;
4577            }
4578
4579            match key.code {
4580                // Quit
4581                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4582                    return Ok(());
4583                }
4584                KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4585                    return Ok(());
4586                }
4587
4588                // Help
4589                KeyCode::Char('?') => {
4590                    app.show_help = true;
4591                }
4592
4593                // Toggle view mode (F2 or Ctrl+S)
4594                KeyCode::F(2) => {
4595                    app.view_mode = match app.view_mode {
4596                        ViewMode::Chat
4597                        | ViewMode::SessionPicker
4598                        | ViewMode::ModelPicker
4599                        | ViewMode::AgentPicker
4600                        | ViewMode::Protocol
4601                        | ViewMode::BusLog => ViewMode::Swarm,
4602                        ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
4603                    };
4604                }
4605                KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4606                    app.view_mode = match app.view_mode {
4607                        ViewMode::Chat
4608                        | ViewMode::SessionPicker
4609                        | ViewMode::ModelPicker
4610                        | ViewMode::AgentPicker
4611                        | ViewMode::Protocol
4612                        | ViewMode::BusLog => ViewMode::Swarm,
4613                        ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
4614                    };
4615                }
4616
4617                // Toggle inspector pane in webview layout
4618                KeyCode::F(3) => {
4619                    app.show_inspector = !app.show_inspector;
4620                }
4621
4622                // Copy latest assistant message to clipboard (Ctrl+Y)
4623                KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4624                    let msg = app
4625                        .messages
4626                        .iter()
4627                        .rev()
4628                        .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
4629                        .or_else(|| {
4630                            app.messages
4631                                .iter()
4632                                .rev()
4633                                .find(|m| !m.content.trim().is_empty())
4634                        });
4635
4636                    let Some(msg) = msg else {
4637                        app.messages
4638                            .push(ChatMessage::new("system", "Nothing to copy yet."));
4639                        app.scroll = SCROLL_BOTTOM;
4640                        continue;
4641                    };
4642
4643                    let text = message_clipboard_text(msg);
4644                    match copy_text_to_clipboard_best_effort(&text) {
4645                        Ok(method) => {
4646                            app.messages.push(ChatMessage::new(
4647                                "system",
4648                                format!("Copied latest reply ({method})."),
4649                            ));
4650                            app.scroll = SCROLL_BOTTOM;
4651                        }
4652                        Err(err) => {
4653                            tracing::warn!(error = %err, "Copy to clipboard failed");
4654                            app.messages.push(ChatMessage::new(
4655                                "system",
4656                                "Could not copy to clipboard in this environment.",
4657                            ));
4658                            app.scroll = SCROLL_BOTTOM;
4659                        }
4660                    }
4661                }
4662
4663                // Toggle chat layout (Ctrl+B)
4664                KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4665                    app.chat_layout = match app.chat_layout {
4666                        ChatLayoutMode::Classic => ChatLayoutMode::Webview,
4667                        ChatLayoutMode::Webview => ChatLayoutMode::Classic,
4668                    };
4669                }
4670
4671                // Escape - return to chat from swarm/picker view
4672                KeyCode::Esc => {
4673                    if app.view_mode == ViewMode::Swarm
4674                        || app.view_mode == ViewMode::Ralph
4675                        || app.view_mode == ViewMode::BusLog
4676                        || app.view_mode == ViewMode::Protocol
4677                        || app.view_mode == ViewMode::SessionPicker
4678                        || app.view_mode == ViewMode::ModelPicker
4679                        || app.view_mode == ViewMode::AgentPicker
4680                    {
4681                        app.view_mode = ViewMode::Chat;
4682                    }
4683                }
4684
4685                // Model picker (Ctrl+M)
4686                KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4687                    app.open_model_picker(&config).await;
4688                }
4689
4690                // Agent picker (Ctrl+A)
4691                KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4692                    app.open_agent_picker();
4693                }
4694
4695                // Bus protocol log (Ctrl+L)
4696                KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4697                    app.view_mode = ViewMode::BusLog;
4698                }
4699
4700                // Protocol registry view (Ctrl+P)
4701                KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4702                    app.open_protocol_view();
4703                }
4704
4705                // Switch agent
4706                KeyCode::Tab => {
4707                    app.current_agent = if app.current_agent == "build" {
4708                        "plan".to_string()
4709                    } else {
4710                        "build".to_string()
4711                    };
4712                }
4713
4714                // Submit message
4715                KeyCode::Enter => {
4716                    app.submit_message(&config).await;
4717                }
4718
4719                // Vim-style scrolling (Alt + j/k)
4720                KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
4721                    if app.scroll < SCROLL_BOTTOM {
4722                        app.scroll = app.scroll.saturating_add(1);
4723                    }
4724                }
4725                KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
4726                    if app.scroll >= SCROLL_BOTTOM {
4727                        app.scroll = app.last_max_scroll; // Leave auto-scroll mode
4728                    }
4729                    app.scroll = app.scroll.saturating_sub(1);
4730                }
4731
4732                // Command history
4733                KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4734                    app.search_history();
4735                }
4736                KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
4737                    app.navigate_history(-1);
4738                }
4739                KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
4740                    app.navigate_history(1);
4741                }
4742
4743                // Additional Vim-style navigation (with modifiers to avoid conflicts)
4744                KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4745                    app.scroll = 0; // Go to top
4746                }
4747                KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
4748                    // Go to bottom (auto-scroll)
4749                    app.scroll = SCROLL_BOTTOM;
4750                }
4751
4752                // Enhanced scrolling (with Alt to avoid conflicts)
4753                KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
4754                    // Half page down
4755                    if app.scroll < SCROLL_BOTTOM {
4756                        app.scroll = app.scroll.saturating_add(5);
4757                    }
4758                }
4759                KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
4760                    // Half page up
4761                    if app.scroll >= SCROLL_BOTTOM {
4762                        app.scroll = app.last_max_scroll;
4763                    }
4764                    app.scroll = app.scroll.saturating_sub(5);
4765                }
4766
4767                // Text input
4768                KeyCode::Char(c) => {
4769                    // Ensure cursor is at a valid char boundary
4770                    while app.cursor_position > 0
4771                        && !app.input.is_char_boundary(app.cursor_position)
4772                    {
4773                        app.cursor_position -= 1;
4774                    }
4775                    app.input.insert(app.cursor_position, c);
4776                    app.cursor_position += c.len_utf8();
4777                }
4778                KeyCode::Backspace => {
4779                    // Move back to previous char boundary
4780                    while app.cursor_position > 0
4781                        && !app.input.is_char_boundary(app.cursor_position)
4782                    {
4783                        app.cursor_position -= 1;
4784                    }
4785                    if app.cursor_position > 0 {
4786                        // Find start of previous char
4787                        let prev = app.input[..app.cursor_position].char_indices().rev().next();
4788                        if let Some((idx, ch)) = prev {
4789                            app.input.replace_range(idx..idx + ch.len_utf8(), "");
4790                            app.cursor_position = idx;
4791                        }
4792                    }
4793                }
4794                KeyCode::Delete => {
4795                    // Ensure cursor is at a valid char boundary
4796                    while app.cursor_position > 0
4797                        && !app.input.is_char_boundary(app.cursor_position)
4798                    {
4799                        app.cursor_position -= 1;
4800                    }
4801                    if app.cursor_position < app.input.len() {
4802                        let ch = app.input[app.cursor_position..].chars().next();
4803                        if let Some(ch) = ch {
4804                            app.input.replace_range(
4805                                app.cursor_position..app.cursor_position + ch.len_utf8(),
4806                                "",
4807                            );
4808                        }
4809                    }
4810                }
4811                KeyCode::Left => {
4812                    // Move left by one character (not byte)
4813                    let prev = app.input[..app.cursor_position].char_indices().rev().next();
4814                    if let Some((idx, _)) = prev {
4815                        app.cursor_position = idx;
4816                    }
4817                }
4818                KeyCode::Right => {
4819                    if app.cursor_position < app.input.len() {
4820                        let ch = app.input[app.cursor_position..].chars().next();
4821                        if let Some(ch) = ch {
4822                            app.cursor_position += ch.len_utf8();
4823                        }
4824                    }
4825                }
4826                KeyCode::Home => {
4827                    app.cursor_position = 0;
4828                }
4829                KeyCode::End => {
4830                    app.cursor_position = app.input.len();
4831                }
4832
4833                // Scroll (normalize first to handle SCROLL_BOTTOM sentinel)
4834                KeyCode::Up => {
4835                    if app.scroll >= SCROLL_BOTTOM {
4836                        app.scroll = app.last_max_scroll; // Leave auto-scroll mode
4837                    }
4838                    app.scroll = app.scroll.saturating_sub(1);
4839                }
4840                KeyCode::Down => {
4841                    if app.scroll < SCROLL_BOTTOM {
4842                        app.scroll = app.scroll.saturating_add(1);
4843                    }
4844                }
4845                KeyCode::PageUp => {
4846                    if app.scroll >= SCROLL_BOTTOM {
4847                        app.scroll = app.last_max_scroll;
4848                    }
4849                    app.scroll = app.scroll.saturating_sub(10);
4850                }
4851                KeyCode::PageDown => {
4852                    if app.scroll < SCROLL_BOTTOM {
4853                        app.scroll = app.scroll.saturating_add(10);
4854                    }
4855                }
4856
4857                _ => {}
4858            }
4859        }
4860    }
4861}
4862
4863fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
4864    // Check view mode
4865    if app.view_mode == ViewMode::Swarm {
4866        // Render swarm view
4867        let chunks = Layout::default()
4868            .direction(Direction::Vertical)
4869            .constraints([
4870                Constraint::Min(1),    // Swarm view
4871                Constraint::Length(3), // Input
4872                Constraint::Length(1), // Status bar
4873            ])
4874            .split(f.area());
4875
4876        // Swarm view
4877        render_swarm_view(f, &mut app.swarm_state, chunks[0]);
4878
4879        // Input area (for returning to chat)
4880        let input_block = Block::default()
4881            .borders(Borders::ALL)
4882            .title(" Press Esc, Ctrl+S, or /view to return to chat ")
4883            .border_style(Style::default().fg(Color::Cyan));
4884
4885        let input = Paragraph::new(app.input.as_str())
4886            .block(input_block)
4887            .wrap(Wrap { trim: false });
4888        f.render_widget(input, chunks[1]);
4889
4890        // Status bar
4891        let status_line = if app.swarm_state.detail_mode {
4892            Line::from(vec![
4893                Span::styled(
4894                    " AGENT DETAIL ",
4895                    Style::default().fg(Color::Black).bg(Color::Cyan),
4896                ),
4897                Span::raw(" | "),
4898                Span::styled("Esc", Style::default().fg(Color::Yellow)),
4899                Span::raw(": Back to list | "),
4900                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
4901                Span::raw(": Prev/Next agent | "),
4902                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
4903                Span::raw(": Scroll"),
4904            ])
4905        } else {
4906            Line::from(vec![
4907                Span::styled(
4908                    " SWARM MODE ",
4909                    Style::default().fg(Color::Black).bg(Color::Cyan),
4910                ),
4911                Span::raw(" | "),
4912                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
4913                Span::raw(": Select | "),
4914                Span::styled("Enter", Style::default().fg(Color::Yellow)),
4915                Span::raw(": Detail | "),
4916                Span::styled("Esc", Style::default().fg(Color::Yellow)),
4917                Span::raw(": Back | "),
4918                Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
4919                Span::raw(": Toggle view"),
4920            ])
4921        };
4922        let status = Paragraph::new(status_line);
4923        f.render_widget(status, chunks[2]);
4924        return;
4925    }
4926
4927    // Ralph view
4928    if app.view_mode == ViewMode::Ralph {
4929        let chunks = Layout::default()
4930            .direction(Direction::Vertical)
4931            .constraints([
4932                Constraint::Min(1),    // Ralph view
4933                Constraint::Length(3), // Input
4934                Constraint::Length(1), // Status bar
4935            ])
4936            .split(f.area());
4937
4938        render_ralph_view(f, &mut app.ralph_state, chunks[0]);
4939
4940        let input_block = Block::default()
4941            .borders(Borders::ALL)
4942            .title(" Press Esc to return to chat ")
4943            .border_style(Style::default().fg(Color::Magenta));
4944
4945        let input = Paragraph::new(app.input.as_str())
4946            .block(input_block)
4947            .wrap(Wrap { trim: false });
4948        f.render_widget(input, chunks[1]);
4949
4950        let status_line = if app.ralph_state.detail_mode {
4951            Line::from(vec![
4952                Span::styled(
4953                    " STORY DETAIL ",
4954                    Style::default().fg(Color::Black).bg(Color::Magenta),
4955                ),
4956                Span::raw(" | "),
4957                Span::styled("Esc", Style::default().fg(Color::Yellow)),
4958                Span::raw(": Back to list | "),
4959                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
4960                Span::raw(": Prev/Next story | "),
4961                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
4962                Span::raw(": Scroll"),
4963            ])
4964        } else {
4965            Line::from(vec![
4966                Span::styled(
4967                    " RALPH MODE ",
4968                    Style::default().fg(Color::Black).bg(Color::Magenta),
4969                ),
4970                Span::raw(" | "),
4971                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
4972                Span::raw(": Select | "),
4973                Span::styled("Enter", Style::default().fg(Color::Yellow)),
4974                Span::raw(": Detail | "),
4975                Span::styled("Esc", Style::default().fg(Color::Yellow)),
4976                Span::raw(": Back"),
4977            ])
4978        };
4979        let status = Paragraph::new(status_line);
4980        f.render_widget(status, chunks[2]);
4981        return;
4982    }
4983
4984    // Bus protocol log view
4985    if app.view_mode == ViewMode::BusLog {
4986        let chunks = Layout::default()
4987            .direction(Direction::Vertical)
4988            .constraints([
4989                Constraint::Min(1),    // Bus log view
4990                Constraint::Length(3), // Input
4991                Constraint::Length(1), // Status bar
4992            ])
4993            .split(f.area());
4994
4995        render_bus_log(f, &mut app.bus_log_state, chunks[0]);
4996
4997        let input_block = Block::default()
4998            .borders(Borders::ALL)
4999            .title(" Press Esc to return to chat ")
5000            .border_style(Style::default().fg(Color::Green));
5001
5002        let input = Paragraph::new(app.input.as_str())
5003            .block(input_block)
5004            .wrap(Wrap { trim: false });
5005        f.render_widget(input, chunks[1]);
5006
5007        let count_info = format!(
5008            " {}/{} ",
5009            app.bus_log_state.visible_count(),
5010            app.bus_log_state.total_count()
5011        );
5012        let status_line = Line::from(vec![
5013            Span::styled(
5014                " BUS LOG ",
5015                Style::default().fg(Color::Black).bg(Color::Green),
5016            ),
5017            Span::raw(&count_info),
5018            Span::raw("| "),
5019            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5020            Span::raw(": Select | "),
5021            Span::styled("Enter", Style::default().fg(Color::Yellow)),
5022            Span::raw(": Detail | "),
5023            Span::styled("c", Style::default().fg(Color::Yellow)),
5024            Span::raw(": Clear | "),
5025            Span::styled("Esc", Style::default().fg(Color::Yellow)),
5026            Span::raw(": Back"),
5027        ]);
5028        let status = Paragraph::new(status_line);
5029        f.render_widget(status, chunks[2]);
5030        return;
5031    }
5032
5033    // Protocol registry view
5034    if app.view_mode == ViewMode::Protocol {
5035        let chunks = Layout::default()
5036            .direction(Direction::Vertical)
5037            .constraints([
5038                Constraint::Min(1),    // Protocol details
5039                Constraint::Length(3), // Input
5040                Constraint::Length(1), // Status bar
5041            ])
5042            .split(f.area());
5043
5044        render_protocol_registry(f, app, theme, chunks[0]);
5045
5046        let input_block = Block::default()
5047            .borders(Borders::ALL)
5048            .title(" Press Esc to return to chat ")
5049            .border_style(Style::default().fg(Color::Blue));
5050
5051        let input = Paragraph::new(app.input.as_str())
5052            .block(input_block)
5053            .wrap(Wrap { trim: false });
5054        f.render_widget(input, chunks[1]);
5055
5056        let cards = app.protocol_cards();
5057        let status_line = Line::from(vec![
5058            Span::styled(
5059                " PROTOCOL REGISTRY ",
5060                Style::default().fg(Color::Black).bg(Color::Blue),
5061            ),
5062            Span::raw(format!(" {} cards | ", cards.len())),
5063            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5064            Span::raw(": Select | "),
5065            Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
5066            Span::raw(": Scroll detail | "),
5067            Span::styled("Esc", Style::default().fg(Color::Yellow)),
5068            Span::raw(": Back"),
5069        ]);
5070        let status = Paragraph::new(status_line);
5071        f.render_widget(status, chunks[2]);
5072        return;
5073    }
5074
5075    // Model picker view
5076    if app.view_mode == ViewMode::ModelPicker {
5077        let area = centered_rect(70, 70, f.area());
5078        f.render_widget(Clear, area);
5079
5080        let filter_display = if app.model_picker_filter.is_empty() {
5081            "type to filter".to_string()
5082        } else {
5083            format!("filter: {}", app.model_picker_filter)
5084        };
5085
5086        let picker_block = Block::default()
5087            .borders(Borders::ALL)
5088            .title(format!(
5089                " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
5090                filter_display
5091            ))
5092            .border_style(Style::default().fg(Color::Magenta));
5093
5094        let filtered = app.filtered_models();
5095        let mut list_lines: Vec<Line> = Vec::new();
5096        list_lines.push(Line::from(""));
5097
5098        if let Some(ref active) = app.active_model {
5099            list_lines.push(Line::styled(
5100                format!("  Current: {}", active),
5101                Style::default()
5102                    .fg(Color::Green)
5103                    .add_modifier(Modifier::DIM),
5104            ));
5105            list_lines.push(Line::from(""));
5106        }
5107
5108        if filtered.is_empty() {
5109            list_lines.push(Line::styled(
5110                "  No models match filter",
5111                Style::default().fg(Color::DarkGray),
5112            ));
5113        } else {
5114            let mut current_provider = String::new();
5115            for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
5116                let provider = label.split('/').next().unwrap_or("");
5117                if provider != current_provider {
5118                    if !current_provider.is_empty() {
5119                        list_lines.push(Line::from(""));
5120                    }
5121                    list_lines.push(Line::styled(
5122                        format!("  ─── {} ───", provider),
5123                        Style::default()
5124                            .fg(Color::Cyan)
5125                            .add_modifier(Modifier::BOLD),
5126                    ));
5127                    current_provider = provider.to_string();
5128                }
5129
5130                let is_selected = display_idx == app.model_picker_selected;
5131                let is_active = app.active_model.as_deref() == Some(label.as_str());
5132                let marker = if is_selected { "▶" } else { " " };
5133                let active_marker = if is_active { " ✓" } else { "" };
5134                let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
5135                // Show human name if different from ID
5136                let display = if human_name != &model_id && !human_name.is_empty() {
5137                    format!("{} ({})", human_name, model_id)
5138                } else {
5139                    model_id
5140                };
5141
5142                let style = if is_selected {
5143                    Style::default()
5144                        .fg(Color::Magenta)
5145                        .add_modifier(Modifier::BOLD)
5146                } else if is_active {
5147                    Style::default().fg(Color::Green)
5148                } else {
5149                    Style::default()
5150                };
5151
5152                list_lines.push(Line::styled(
5153                    format!("  {} {}{}", marker, display, active_marker),
5154                    style,
5155                ));
5156            }
5157        }
5158
5159        let list = Paragraph::new(list_lines)
5160            .block(picker_block)
5161            .wrap(Wrap { trim: false });
5162        f.render_widget(list, area);
5163        return;
5164    }
5165
5166    // Session picker view
5167    if app.view_mode == ViewMode::SessionPicker {
5168        let chunks = Layout::default()
5169            .direction(Direction::Vertical)
5170            .constraints([
5171                Constraint::Min(1),    // Session list
5172                Constraint::Length(1), // Status bar
5173            ])
5174            .split(f.area());
5175
5176        // Build title with filter display
5177        let filter_display = if app.session_picker_filter.is_empty() {
5178            String::new()
5179        } else {
5180            format!(" [filter: {}]", app.session_picker_filter)
5181        };
5182
5183        let list_block = Block::default()
5184            .borders(Borders::ALL)
5185            .title(format!(
5186                " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
5187                filter_display
5188            ))
5189            .border_style(Style::default().fg(Color::Cyan));
5190
5191        let mut list_lines: Vec<Line> = Vec::new();
5192        list_lines.push(Line::from(""));
5193
5194        let filtered = app.filtered_sessions();
5195        if filtered.is_empty() {
5196            if app.session_picker_filter.is_empty() {
5197                list_lines.push(Line::styled(
5198                    "  No sessions found.",
5199                    Style::default().fg(Color::DarkGray),
5200                ));
5201            } else {
5202                list_lines.push(Line::styled(
5203                    format!("  No sessions matching '{}'", app.session_picker_filter),
5204                    Style::default().fg(Color::DarkGray),
5205                ));
5206            }
5207        }
5208
5209        for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
5210            let is_selected = display_idx == app.session_picker_selected;
5211            let is_active = app
5212                .session
5213                .as_ref()
5214                .map(|s| s.id == session.id)
5215                .unwrap_or(false);
5216            let title = session.title.as_deref().unwrap_or("(untitled)");
5217            let date = session.updated_at.format("%Y-%m-%d %H:%M");
5218            let active_marker = if is_active { " ●" } else { "" };
5219            let line_str = format!(
5220                " {} {}{} - {} ({} msgs)",
5221                if is_selected { "▶" } else { " " },
5222                title,
5223                active_marker,
5224                date,
5225                session.message_count
5226            );
5227
5228            let style = if is_selected && app.session_picker_confirm_delete {
5229                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
5230            } else if is_selected {
5231                Style::default()
5232                    .fg(Color::Cyan)
5233                    .add_modifier(Modifier::BOLD)
5234            } else if is_active {
5235                Style::default().fg(Color::Green)
5236            } else {
5237                Style::default()
5238            };
5239
5240            list_lines.push(Line::styled(line_str, style));
5241
5242            // Show details for selected item
5243            if is_selected {
5244                if app.session_picker_confirm_delete {
5245                    list_lines.push(Line::styled(
5246                        "   ⚠ Press d again to confirm delete, Esc to cancel",
5247                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5248                    ));
5249                } else {
5250                    list_lines.push(Line::styled(
5251                        format!("   Agent: {} | ID: {}", session.agent, session.id),
5252                        Style::default().fg(Color::DarkGray),
5253                    ));
5254                }
5255            }
5256        }
5257
5258        let list = Paragraph::new(list_lines)
5259            .block(list_block)
5260            .wrap(Wrap { trim: false });
5261        f.render_widget(list, chunks[0]);
5262
5263        // Status bar with more actions
5264        let mut status_spans = vec![
5265            Span::styled(
5266                " SESSION PICKER ",
5267                Style::default().fg(Color::Black).bg(Color::Cyan),
5268            ),
5269            Span::raw(" "),
5270            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
5271            Span::raw(": Nav "),
5272            Span::styled("Enter", Style::default().fg(Color::Yellow)),
5273            Span::raw(": Load "),
5274            Span::styled("d", Style::default().fg(Color::Yellow)),
5275            Span::raw(": Delete "),
5276            Span::styled("Esc", Style::default().fg(Color::Yellow)),
5277            Span::raw(": Cancel "),
5278        ];
5279        if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
5280            status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
5281            status_spans.push(Span::raw(": Filter "));
5282        }
5283        let total = app.session_picker_list.len();
5284        let showing = filtered.len();
5285        if showing < total {
5286            status_spans.push(Span::styled(
5287                format!("{}/{}", showing, total),
5288                Style::default().fg(Color::DarkGray),
5289            ));
5290        }
5291
5292        let status = Paragraph::new(Line::from(status_spans));
5293        f.render_widget(status, chunks[1]);
5294        return;
5295    }
5296
5297    // Agent picker view
5298    if app.view_mode == ViewMode::AgentPicker {
5299        let area = centered_rect(70, 70, f.area());
5300        f.render_widget(Clear, area);
5301
5302        let filter_display = if app.agent_picker_filter.is_empty() {
5303            "type to filter".to_string()
5304        } else {
5305            format!("filter: {}", app.agent_picker_filter)
5306        };
5307
5308        let picker_block = Block::default()
5309            .borders(Borders::ALL)
5310            .title(format!(
5311                " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
5312                filter_display
5313            ))
5314            .border_style(Style::default().fg(Color::Magenta));
5315
5316        let filtered = app.filtered_spawned_agents();
5317        let mut list_lines: Vec<Line> = Vec::new();
5318        list_lines.push(Line::from(""));
5319
5320        if let Some(ref active) = app.active_spawned_agent {
5321            list_lines.push(Line::styled(
5322                format!("  Current focus: @{}", active),
5323                Style::default()
5324                    .fg(Color::Green)
5325                    .add_modifier(Modifier::DIM),
5326            ));
5327            list_lines.push(Line::from(""));
5328        }
5329
5330        if filtered.is_empty() {
5331            list_lines.push(Line::styled(
5332                "  No spawned agents match filter",
5333                Style::default().fg(Color::DarkGray),
5334            ));
5335        } else {
5336            for (display_idx, (name, instructions, is_processing, is_registered)) in
5337                filtered.iter().enumerate()
5338            {
5339                let is_selected = display_idx == app.agent_picker_selected;
5340                let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
5341                let marker = if is_selected { "▶" } else { " " };
5342                let focused_marker = if is_focused { " ✓" } else { "" };
5343                let status = if *is_processing { "⚡" } else { "●" };
5344                let protocol = if *is_registered { "🔗" } else { "⚠" };
5345                let avatar = agent_avatar(name);
5346
5347                let style = if is_selected {
5348                    Style::default()
5349                        .fg(Color::Magenta)
5350                        .add_modifier(Modifier::BOLD)
5351                } else if is_focused {
5352                    Style::default().fg(Color::Green)
5353                } else {
5354                    Style::default()
5355                };
5356
5357                list_lines.push(Line::styled(
5358                    format!("  {marker} {status} {protocol} {avatar} @{name}{focused_marker}"),
5359                    style,
5360                ));
5361
5362                if is_selected {
5363                    let profile = agent_profile(name);
5364                    list_lines.push(Line::styled(
5365                        format!("     profile: {} — {}", profile.codename, profile.profile),
5366                        Style::default().fg(Color::Cyan),
5367                    ));
5368                    list_lines.push(Line::styled(
5369                        format!("     {}", instructions),
5370                        Style::default().fg(Color::DarkGray),
5371                    ));
5372                    list_lines.push(Line::styled(
5373                        format!(
5374                            "     protocol: {}",
5375                            if *is_registered {
5376                                "registered"
5377                            } else {
5378                                "not registered"
5379                            }
5380                        ),
5381                        if *is_registered {
5382                            Style::default().fg(Color::Green)
5383                        } else {
5384                            Style::default().fg(Color::Yellow)
5385                        },
5386                    ));
5387                }
5388            }
5389        }
5390
5391        let list = Paragraph::new(list_lines)
5392            .block(picker_block)
5393            .wrap(Wrap { trim: false });
5394        f.render_widget(list, area);
5395        return;
5396    }
5397
5398    if app.chat_layout == ChatLayoutMode::Webview {
5399        if render_webview_chat(f, app, theme) {
5400            render_help_overlay_if_needed(f, app, theme);
5401            return;
5402        }
5403    }
5404
5405    // Chat view (default)
5406    let chunks = Layout::default()
5407        .direction(Direction::Vertical)
5408        .constraints([
5409            Constraint::Min(1),    // Messages
5410            Constraint::Length(3), // Input
5411            Constraint::Length(1), // Status bar
5412        ])
5413        .split(f.area());
5414
5415    // Messages area with theme-based styling
5416    let messages_area = chunks[0];
5417    let model_label = app.active_model.as_deref().unwrap_or("auto");
5418    let target_label = app
5419        .active_spawned_agent
5420        .as_ref()
5421        .map(|name| format!(" @{}", name))
5422        .unwrap_or_default();
5423    let messages_block = Block::default()
5424        .borders(Borders::ALL)
5425        .title(format!(
5426            " CodeTether Agent [{}{}] model:{} ",
5427            app.current_agent, target_label, model_label
5428        ))
5429        .border_style(Style::default().fg(theme.border_color.to_color()));
5430
5431    let max_width = messages_area.width.saturating_sub(4) as usize;
5432    let message_lines = build_message_lines(app, theme, max_width);
5433
5434    // Calculate scroll position
5435    let total_lines = message_lines.len();
5436    let visible_lines = messages_area.height.saturating_sub(2) as usize;
5437    let max_scroll = total_lines.saturating_sub(visible_lines);
5438    // SCROLL_BOTTOM means "stick to bottom", otherwise clamp to max_scroll
5439    let scroll = if app.scroll >= SCROLL_BOTTOM {
5440        max_scroll
5441    } else {
5442        app.scroll.min(max_scroll)
5443    };
5444
5445    // Render messages with scrolling
5446    let messages_paragraph = Paragraph::new(
5447        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
5448    )
5449    .block(messages_block.clone())
5450    .wrap(Wrap { trim: false });
5451
5452    f.render_widget(messages_paragraph, messages_area);
5453
5454    // Render scrollbar if needed
5455    if total_lines > visible_lines {
5456        let scrollbar = Scrollbar::default()
5457            .orientation(ScrollbarOrientation::VerticalRight)
5458            .symbols(ratatui::symbols::scrollbar::VERTICAL)
5459            .begin_symbol(Some("↑"))
5460            .end_symbol(Some("↓"));
5461
5462        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
5463
5464        let scrollbar_area = Rect::new(
5465            messages_area.right() - 1,
5466            messages_area.top() + 1,
5467            1,
5468            messages_area.height - 2,
5469        );
5470
5471        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
5472    }
5473
5474    // Input area
5475    let input_title = if app.is_processing {
5476        if let Some(started) = app.processing_started_at {
5477            let elapsed = started.elapsed();
5478            format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
5479        } else {
5480            " Message (Processing...) ".to_string()
5481        }
5482    } else if app.autochat_running {
5483        format!(
5484            " {} ",
5485            app.autochat_status_label()
5486                .unwrap_or_else(|| "Autochat running…".to_string())
5487        )
5488    } else if app.input.starts_with('/') {
5489        let hint = match_slash_command_hint(&app.input);
5490        format!(" {} ", hint)
5491    } else if let Some(target) = &app.active_spawned_agent {
5492        format!(" Message to @{target} (use /agent main to exit) ")
5493    } else {
5494        " Message (Enter to send, / for commands) ".to_string()
5495    };
5496    let input_block = Block::default()
5497        .borders(Borders::ALL)
5498        .title(input_title)
5499        .border_style(Style::default().fg(if app.is_processing {
5500            Color::Yellow
5501        } else if app.autochat_running {
5502            Color::Cyan
5503        } else if app.input.starts_with('/') {
5504            Color::Magenta
5505        } else {
5506            theme.input_border_color.to_color()
5507        }));
5508
5509    let input = Paragraph::new(app.input.as_str())
5510        .block(input_block)
5511        .wrap(Wrap { trim: false });
5512    f.render_widget(input, chunks[1]);
5513
5514    // Cursor
5515    f.set_cursor_position((
5516        chunks[1].x + app.cursor_position as u16 + 1,
5517        chunks[1].y + 1,
5518    ));
5519
5520    // Enhanced status bar with token display and model info
5521    let token_display = TokenDisplay::new();
5522    let mut status_line = token_display.create_status_bar(theme);
5523    let model_status = if let Some(ref active) = app.active_model {
5524        let (provider, model) = crate::provider::parse_model_string(active);
5525        format!(" {}:{} ", provider.unwrap_or("auto"), model)
5526    } else {
5527        " auto ".to_string()
5528    };
5529    status_line.spans.insert(
5530        0,
5531        Span::styled(
5532            "│ ",
5533            Style::default()
5534                .fg(theme.timestamp_color.to_color())
5535                .add_modifier(Modifier::DIM),
5536        ),
5537    );
5538    status_line.spans.insert(
5539        0,
5540        Span::styled(model_status, Style::default().fg(Color::Cyan)),
5541    );
5542    if let Some(autochat_status) = app.autochat_status_label() {
5543        status_line.spans.insert(
5544            0,
5545            Span::styled(
5546                format!(" {autochat_status} "),
5547                Style::default()
5548                    .fg(Color::Yellow)
5549                    .add_modifier(Modifier::BOLD),
5550            ),
5551        );
5552    }
5553    let status = Paragraph::new(status_line);
5554    f.render_widget(status, chunks[2]);
5555
5556    render_help_overlay_if_needed(f, app, theme);
5557}
5558
5559fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
5560    let area = f.area();
5561    if area.width < 90 || area.height < 18 {
5562        return false;
5563    }
5564
5565    let main_chunks = Layout::default()
5566        .direction(Direction::Vertical)
5567        .constraints([
5568            Constraint::Length(3), // Header
5569            Constraint::Min(1),    // Body
5570            Constraint::Length(3), // Input
5571            Constraint::Length(1), // Status
5572        ])
5573        .split(area);
5574
5575    render_webview_header(f, app, theme, main_chunks[0]);
5576
5577    let body_constraints = if app.show_inspector {
5578        vec![
5579            Constraint::Length(26),
5580            Constraint::Min(40),
5581            Constraint::Length(30),
5582        ]
5583    } else {
5584        vec![Constraint::Length(26), Constraint::Min(40)]
5585    };
5586
5587    let body_chunks = Layout::default()
5588        .direction(Direction::Horizontal)
5589        .constraints(body_constraints)
5590        .split(main_chunks[1]);
5591
5592    render_webview_sidebar(f, app, theme, body_chunks[0]);
5593    render_webview_chat_center(f, app, theme, body_chunks[1]);
5594    if app.show_inspector && body_chunks.len() > 2 {
5595        render_webview_inspector(f, app, theme, body_chunks[2]);
5596    }
5597
5598    render_webview_input(f, app, theme, main_chunks[2]);
5599
5600    let token_display = TokenDisplay::new();
5601    let mut status_line = token_display.create_status_bar(theme);
5602    let model_status = if let Some(ref active) = app.active_model {
5603        let (provider, model) = crate::provider::parse_model_string(active);
5604        format!(" {}:{} ", provider.unwrap_or("auto"), model)
5605    } else {
5606        " auto ".to_string()
5607    };
5608    status_line.spans.insert(
5609        0,
5610        Span::styled(
5611            "│ ",
5612            Style::default()
5613                .fg(theme.timestamp_color.to_color())
5614                .add_modifier(Modifier::DIM),
5615        ),
5616    );
5617    status_line.spans.insert(
5618        0,
5619        Span::styled(model_status, Style::default().fg(Color::Cyan)),
5620    );
5621    if let Some(autochat_status) = app.autochat_status_label() {
5622        status_line.spans.insert(
5623            0,
5624            Span::styled(
5625                format!(" {autochat_status} "),
5626                Style::default()
5627                    .fg(Color::Yellow)
5628                    .add_modifier(Modifier::BOLD),
5629            ),
5630        );
5631    }
5632    let status = Paragraph::new(status_line);
5633    f.render_widget(status, main_chunks[3]);
5634
5635    true
5636}
5637
5638fn render_protocol_registry(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
5639    let cards = app.protocol_cards();
5640    let selected = app.protocol_selected.min(cards.len().saturating_sub(1));
5641
5642    let chunks = Layout::default()
5643        .direction(Direction::Horizontal)
5644        .constraints([Constraint::Length(34), Constraint::Min(30)])
5645        .split(area);
5646
5647    let list_block = Block::default()
5648        .borders(Borders::ALL)
5649        .title(" Registered Agents ")
5650        .border_style(Style::default().fg(theme.border_color.to_color()));
5651
5652    let mut list_lines: Vec<Line> = Vec::new();
5653    if cards.is_empty() {
5654        list_lines.push(Line::styled(
5655            "No protocol-registered agents.",
5656            Style::default().fg(Color::DarkGray),
5657        ));
5658        list_lines.push(Line::styled(
5659            "Spawn an agent with /spawn.",
5660            Style::default().fg(Color::DarkGray),
5661        ));
5662    } else {
5663        for (idx, card) in cards.iter().enumerate() {
5664            let marker = if idx == selected { "▶" } else { " " };
5665            let style = if idx == selected {
5666                Style::default()
5667                    .fg(Color::Blue)
5668                    .add_modifier(Modifier::BOLD)
5669            } else {
5670                Style::default()
5671            };
5672            let transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
5673            list_lines.push(Line::styled(format!(" {marker} {}", card.name), style));
5674            list_lines.push(Line::styled(
5675                format!(
5676                    "    {transport} • {}",
5677                    truncate_with_ellipsis(&card.url, 22)
5678                ),
5679                Style::default().fg(Color::DarkGray),
5680            ));
5681        }
5682    }
5683
5684    let list = Paragraph::new(list_lines)
5685        .block(list_block)
5686        .wrap(Wrap { trim: false });
5687    f.render_widget(list, chunks[0]);
5688
5689    let detail_block = Block::default()
5690        .borders(Borders::ALL)
5691        .title(" Agent Card Detail ")
5692        .border_style(Style::default().fg(theme.border_color.to_color()));
5693
5694    let mut detail_lines: Vec<Line> = Vec::new();
5695    if let Some(card) = cards.get(selected) {
5696        let label_style = Style::default().fg(Color::DarkGray);
5697        detail_lines.push(Line::from(vec![
5698            Span::styled("Name: ", label_style),
5699            Span::styled(
5700                card.name.clone(),
5701                Style::default().add_modifier(Modifier::BOLD),
5702            ),
5703        ]));
5704        detail_lines.push(Line::from(vec![
5705            Span::styled("Description: ", label_style),
5706            Span::raw(card.description.clone()),
5707        ]));
5708        detail_lines.push(Line::from(vec![
5709            Span::styled("URL: ", label_style),
5710            Span::styled(card.url.clone(), Style::default().fg(Color::Cyan)),
5711        ]));
5712        detail_lines.push(Line::from(vec![
5713            Span::styled("Version: ", label_style),
5714            Span::raw(format!(
5715                "{} (protocol {})",
5716                card.version, card.protocol_version
5717            )),
5718        ]));
5719
5720        let preferred_transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
5721        detail_lines.push(Line::from(vec![
5722            Span::styled("Transport: ", label_style),
5723            Span::raw(preferred_transport.to_string()),
5724        ]));
5725        if !card.additional_interfaces.is_empty() {
5726            detail_lines.push(Line::from(vec![
5727                Span::styled("Interfaces: ", label_style),
5728                Span::raw(format!("{} additional", card.additional_interfaces.len())),
5729            ]));
5730            for iface in &card.additional_interfaces {
5731                detail_lines.push(Line::styled(
5732                    format!("  • {} -> {}", iface.transport, iface.url),
5733                    Style::default().fg(Color::DarkGray),
5734                ));
5735            }
5736        }
5737
5738        detail_lines.push(Line::from(""));
5739        detail_lines.push(Line::styled(
5740            "Capabilities",
5741            Style::default().add_modifier(Modifier::BOLD),
5742        ));
5743        detail_lines.push(Line::styled(
5744            format!(
5745                "  streaming={} push_notifications={} state_history={}",
5746                card.capabilities.streaming,
5747                card.capabilities.push_notifications,
5748                card.capabilities.state_transition_history
5749            ),
5750            Style::default().fg(Color::DarkGray),
5751        ));
5752        if !card.capabilities.extensions.is_empty() {
5753            detail_lines.push(Line::styled(
5754                format!(
5755                    "  extensions: {}",
5756                    card.capabilities
5757                        .extensions
5758                        .iter()
5759                        .map(|e| e.uri.as_str())
5760                        .collect::<Vec<_>>()
5761                        .join(", ")
5762                ),
5763                Style::default().fg(Color::DarkGray),
5764            ));
5765        }
5766
5767        detail_lines.push(Line::from(""));
5768        detail_lines.push(Line::styled(
5769            format!("Skills ({})", card.skills.len()),
5770            Style::default().add_modifier(Modifier::BOLD),
5771        ));
5772        if card.skills.is_empty() {
5773            detail_lines.push(Line::styled("  none", Style::default().fg(Color::DarkGray)));
5774        } else {
5775            for skill in &card.skills {
5776                let tags = if skill.tags.is_empty() {
5777                    "".to_string()
5778                } else {
5779                    format!(" [{}]", skill.tags.join(","))
5780                };
5781                detail_lines.push(Line::styled(
5782                    format!("  • {}{}", skill.name, tags),
5783                    Style::default().fg(Color::Green),
5784                ));
5785                if !skill.description.is_empty() {
5786                    detail_lines.push(Line::styled(
5787                        format!("    {}", skill.description),
5788                        Style::default().fg(Color::DarkGray),
5789                    ));
5790                }
5791            }
5792        }
5793
5794        detail_lines.push(Line::from(""));
5795        detail_lines.push(Line::styled(
5796            "Security",
5797            Style::default().add_modifier(Modifier::BOLD),
5798        ));
5799        if card.security_schemes.is_empty() {
5800            detail_lines.push(Line::styled(
5801                "  schemes: none",
5802                Style::default().fg(Color::DarkGray),
5803            ));
5804        } else {
5805            let mut names = card.security_schemes.keys().cloned().collect::<Vec<_>>();
5806            names.sort();
5807            detail_lines.push(Line::styled(
5808                format!("  schemes: {}", names.join(", ")),
5809                Style::default().fg(Color::DarkGray),
5810            ));
5811        }
5812        detail_lines.push(Line::styled(
5813            format!("  requirements: {}", card.security.len()),
5814            Style::default().fg(Color::DarkGray),
5815        ));
5816        detail_lines.push(Line::styled(
5817            format!(
5818                "  authenticated_extended_card: {}",
5819                card.supports_authenticated_extended_card
5820            ),
5821            Style::default().fg(Color::DarkGray),
5822        ));
5823    } else {
5824        detail_lines.push(Line::styled(
5825            "No card selected.",
5826            Style::default().fg(Color::DarkGray),
5827        ));
5828    }
5829
5830    let detail = Paragraph::new(detail_lines)
5831        .block(detail_block)
5832        .wrap(Wrap { trim: false })
5833        .scroll((app.protocol_scroll as u16, 0));
5834    f.render_widget(detail, chunks[1]);
5835}
5836
5837fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
5838    let session_title = app
5839        .session
5840        .as_ref()
5841        .and_then(|s| s.title.clone())
5842        .unwrap_or_else(|| "Workspace Chat".to_string());
5843    let session_id = app
5844        .session
5845        .as_ref()
5846        .map(|s| s.id.chars().take(8).collect::<String>())
5847        .unwrap_or_else(|| "new".to_string());
5848    let model_label = app
5849        .session
5850        .as_ref()
5851        .and_then(|s| s.metadata.model.clone())
5852        .unwrap_or_else(|| "auto".to_string());
5853    let workspace_label = app.workspace.root_display.clone();
5854    let branch_label = app
5855        .workspace
5856        .git_branch
5857        .clone()
5858        .unwrap_or_else(|| "no-git".to_string());
5859    let dirty_label = if app.workspace.git_dirty_files > 0 {
5860        format!("{} dirty", app.workspace.git_dirty_files)
5861    } else {
5862        "clean".to_string()
5863    };
5864
5865    let header_block = Block::default()
5866        .borders(Borders::ALL)
5867        .title(" CodeTether Webview ")
5868        .border_style(Style::default().fg(theme.border_color.to_color()));
5869
5870    let header_lines = vec![
5871        Line::from(vec![
5872            Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
5873            Span::raw(" "),
5874            Span::styled(
5875                format!("#{}", session_id),
5876                Style::default()
5877                    .fg(theme.timestamp_color.to_color())
5878                    .add_modifier(Modifier::DIM),
5879            ),
5880        ]),
5881        Line::from(vec![
5882            Span::styled(
5883                "Workspace ",
5884                Style::default().fg(theme.timestamp_color.to_color()),
5885            ),
5886            Span::styled(workspace_label, Style::default()),
5887            Span::raw("  "),
5888            Span::styled(
5889                "Branch ",
5890                Style::default().fg(theme.timestamp_color.to_color()),
5891            ),
5892            Span::styled(
5893                branch_label,
5894                Style::default()
5895                    .fg(Color::Cyan)
5896                    .add_modifier(Modifier::BOLD),
5897            ),
5898            Span::raw("  "),
5899            Span::styled(
5900                dirty_label,
5901                Style::default()
5902                    .fg(Color::Yellow)
5903                    .add_modifier(Modifier::BOLD),
5904            ),
5905            Span::raw("  "),
5906            Span::styled(
5907                "Model ",
5908                Style::default().fg(theme.timestamp_color.to_color()),
5909            ),
5910            Span::styled(model_label, Style::default().fg(Color::Green)),
5911        ]),
5912    ];
5913
5914    let header = Paragraph::new(header_lines)
5915        .block(header_block)
5916        .wrap(Wrap { trim: true });
5917    f.render_widget(header, area);
5918}
5919
5920fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
5921    let sidebar_chunks = Layout::default()
5922        .direction(Direction::Vertical)
5923        .constraints([Constraint::Min(8), Constraint::Min(6)])
5924        .split(area);
5925
5926    let workspace_block = Block::default()
5927        .borders(Borders::ALL)
5928        .title(" Workspace ")
5929        .border_style(Style::default().fg(theme.border_color.to_color()));
5930
5931    let mut workspace_lines = Vec::new();
5932    workspace_lines.push(Line::from(vec![
5933        Span::styled(
5934            "Updated ",
5935            Style::default().fg(theme.timestamp_color.to_color()),
5936        ),
5937        Span::styled(
5938            app.workspace.captured_at.clone(),
5939            Style::default().fg(theme.timestamp_color.to_color()),
5940        ),
5941    ]));
5942    workspace_lines.push(Line::from(""));
5943
5944    if app.workspace.entries.is_empty() {
5945        workspace_lines.push(Line::styled(
5946            "No entries found",
5947            Style::default().fg(Color::DarkGray),
5948        ));
5949    } else {
5950        for entry in app.workspace.entries.iter().take(12) {
5951            let icon = match entry.kind {
5952                WorkspaceEntryKind::Directory => "📁",
5953                WorkspaceEntryKind::File => "📄",
5954            };
5955            workspace_lines.push(Line::from(vec![
5956                Span::styled(icon, Style::default().fg(Color::Cyan)),
5957                Span::raw(" "),
5958                Span::styled(entry.name.clone(), Style::default()),
5959            ]));
5960        }
5961    }
5962
5963    workspace_lines.push(Line::from(""));
5964    workspace_lines.push(Line::styled(
5965        "Use /refresh to rescan",
5966        Style::default()
5967            .fg(Color::DarkGray)
5968            .add_modifier(Modifier::DIM),
5969    ));
5970
5971    let workspace_panel = Paragraph::new(workspace_lines)
5972        .block(workspace_block)
5973        .wrap(Wrap { trim: true });
5974    f.render_widget(workspace_panel, sidebar_chunks[0]);
5975
5976    let sessions_block = Block::default()
5977        .borders(Borders::ALL)
5978        .title(" Recent Sessions ")
5979        .border_style(Style::default().fg(theme.border_color.to_color()));
5980
5981    let mut session_lines = Vec::new();
5982    if app.session_picker_list.is_empty() {
5983        session_lines.push(Line::styled(
5984            "No sessions yet",
5985            Style::default().fg(Color::DarkGray),
5986        ));
5987    } else {
5988        for session in app.session_picker_list.iter().take(6) {
5989            let is_active = app
5990                .session
5991                .as_ref()
5992                .map(|s| s.id == session.id)
5993                .unwrap_or(false);
5994            let title = session.title.as_deref().unwrap_or("(untitled)");
5995            let indicator = if is_active { "●" } else { "○" };
5996            let line_style = if is_active {
5997                Style::default()
5998                    .fg(Color::Cyan)
5999                    .add_modifier(Modifier::BOLD)
6000            } else {
6001                Style::default()
6002            };
6003            session_lines.push(Line::from(vec![
6004                Span::styled(indicator, line_style),
6005                Span::raw(" "),
6006                Span::styled(title, line_style),
6007            ]));
6008            session_lines.push(Line::styled(
6009                format!(
6010                    "  {} msgs • {}",
6011                    session.message_count,
6012                    session.updated_at.format("%m-%d %H:%M")
6013                ),
6014                Style::default().fg(Color::DarkGray),
6015            ));
6016        }
6017    }
6018
6019    let sessions_panel = Paragraph::new(session_lines)
6020        .block(sessions_block)
6021        .wrap(Wrap { trim: true });
6022    f.render_widget(sessions_panel, sidebar_chunks[1]);
6023}
6024
6025fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6026    let messages_area = area;
6027    let focused_suffix = app
6028        .active_spawned_agent
6029        .as_ref()
6030        .map(|name| format!(" → @{name}"))
6031        .unwrap_or_default();
6032    let messages_block = Block::default()
6033        .borders(Borders::ALL)
6034        .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
6035        .border_style(Style::default().fg(theme.border_color.to_color()));
6036
6037    let max_width = messages_area.width.saturating_sub(4) as usize;
6038    let message_lines = build_message_lines(app, theme, max_width);
6039
6040    let total_lines = message_lines.len();
6041    let visible_lines = messages_area.height.saturating_sub(2) as usize;
6042    let max_scroll = total_lines.saturating_sub(visible_lines);
6043    let scroll = if app.scroll >= SCROLL_BOTTOM {
6044        max_scroll
6045    } else {
6046        app.scroll.min(max_scroll)
6047    };
6048
6049    let messages_paragraph = Paragraph::new(
6050        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
6051    )
6052    .block(messages_block.clone())
6053    .wrap(Wrap { trim: false });
6054
6055    f.render_widget(messages_paragraph, messages_area);
6056
6057    if total_lines > visible_lines {
6058        let scrollbar = Scrollbar::default()
6059            .orientation(ScrollbarOrientation::VerticalRight)
6060            .symbols(ratatui::symbols::scrollbar::VERTICAL)
6061            .begin_symbol(Some("↑"))
6062            .end_symbol(Some("↓"));
6063
6064        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
6065
6066        let scrollbar_area = Rect::new(
6067            messages_area.right() - 1,
6068            messages_area.top() + 1,
6069            1,
6070            messages_area.height - 2,
6071        );
6072
6073        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
6074    }
6075}
6076
6077fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6078    let block = Block::default()
6079        .borders(Borders::ALL)
6080        .title(" Inspector ")
6081        .border_style(Style::default().fg(theme.border_color.to_color()));
6082
6083    let status_label = if app.is_processing {
6084        "Processing"
6085    } else if app.autochat_running {
6086        "Autochat"
6087    } else {
6088        "Idle"
6089    };
6090    let status_style = if app.is_processing {
6091        Style::default()
6092            .fg(Color::Yellow)
6093            .add_modifier(Modifier::BOLD)
6094    } else if app.autochat_running {
6095        Style::default()
6096            .fg(Color::Cyan)
6097            .add_modifier(Modifier::BOLD)
6098    } else {
6099        Style::default().fg(Color::Green)
6100    };
6101    let tool_label = app
6102        .current_tool
6103        .clone()
6104        .unwrap_or_else(|| "none".to_string());
6105    let message_count = app.messages.len();
6106    let session_id = app
6107        .session
6108        .as_ref()
6109        .map(|s| s.id.chars().take(8).collect::<String>())
6110        .unwrap_or_else(|| "new".to_string());
6111    let model_label = app
6112        .active_model
6113        .as_deref()
6114        .or_else(|| {
6115            app.session
6116                .as_ref()
6117                .and_then(|s| s.metadata.model.as_deref())
6118        })
6119        .unwrap_or("auto");
6120    let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
6121
6122    let label_style = Style::default().fg(theme.timestamp_color.to_color());
6123
6124    let mut lines = Vec::new();
6125    lines.push(Line::from(vec![
6126        Span::styled("Status: ", label_style),
6127        Span::styled(status_label, status_style),
6128    ]));
6129
6130    // Show elapsed time when processing
6131    if let Some(started) = app.processing_started_at {
6132        let elapsed = started.elapsed();
6133        let elapsed_str = if elapsed.as_secs() >= 60 {
6134            format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
6135        } else {
6136            format!("{:.1}s", elapsed.as_secs_f64())
6137        };
6138        lines.push(Line::from(vec![
6139            Span::styled("Elapsed: ", label_style),
6140            Span::styled(
6141                elapsed_str,
6142                Style::default()
6143                    .fg(Color::Yellow)
6144                    .add_modifier(Modifier::BOLD),
6145            ),
6146        ]));
6147    }
6148
6149    if app.autochat_running {
6150        if let Some(status) = app.autochat_status_label() {
6151            lines.push(Line::from(vec![
6152                Span::styled("Relay: ", label_style),
6153                Span::styled(status, Style::default().fg(Color::Cyan)),
6154            ]));
6155        }
6156    }
6157
6158    lines.push(Line::from(vec![
6159        Span::styled("Tool: ", label_style),
6160        Span::styled(
6161            tool_label,
6162            if app.current_tool.is_some() {
6163                Style::default()
6164                    .fg(Color::Cyan)
6165                    .add_modifier(Modifier::BOLD)
6166            } else {
6167                Style::default().fg(Color::DarkGray)
6168            },
6169        ),
6170    ]));
6171    lines.push(Line::from(""));
6172    lines.push(Line::styled(
6173        "Session",
6174        Style::default().add_modifier(Modifier::BOLD),
6175    ));
6176    lines.push(Line::from(vec![
6177        Span::styled("ID: ", label_style),
6178        Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
6179    ]));
6180    lines.push(Line::from(vec![
6181        Span::styled("Model: ", label_style),
6182        Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
6183    ]));
6184    let agent_display = if let Some(target) = &app.active_spawned_agent {
6185        format!("{} → @{} (focused)", app.current_agent, target)
6186    } else {
6187        app.current_agent.clone()
6188    };
6189    lines.push(Line::from(vec![
6190        Span::styled("Agent: ", label_style),
6191        Span::styled(agent_display, Style::default()),
6192    ]));
6193    lines.push(Line::from(vec![
6194        Span::styled("Messages: ", label_style),
6195        Span::styled(message_count.to_string(), Style::default()),
6196    ]));
6197    lines.push(Line::from(vec![
6198        Span::styled("Context: ", label_style),
6199        Span::styled(format!("{} turns", conversation_depth), Style::default()),
6200    ]));
6201    lines.push(Line::from(vec![
6202        Span::styled("Tools used: ", label_style),
6203        Span::styled(app.tool_call_count.to_string(), Style::default()),
6204    ]));
6205    lines.push(Line::from(vec![
6206        Span::styled("Protocol: ", label_style),
6207        Span::styled(
6208            format!("{} registered", app.protocol_registered_count()),
6209            Style::default().fg(Color::Cyan),
6210        ),
6211    ]));
6212    lines.push(Line::from(vec![
6213        Span::styled("Archive: ", label_style),
6214        Span::styled(
6215            format!("{} records", app.archived_message_count),
6216            Style::default(),
6217        ),
6218    ]));
6219    let sync_style = if app.chat_sync_last_error.is_some() {
6220        Style::default().fg(Color::Red)
6221    } else if app.chat_sync_rx.is_some() {
6222        Style::default().fg(Color::Green)
6223    } else {
6224        Style::default().fg(Color::DarkGray)
6225    };
6226    lines.push(Line::from(vec![
6227        Span::styled("Remote sync: ", label_style),
6228        Span::styled(
6229            app.chat_sync_status
6230                .as_deref()
6231                .unwrap_or("disabled")
6232                .to_string(),
6233            sync_style,
6234        ),
6235    ]));
6236    lines.push(Line::from(""));
6237    lines.push(Line::styled(
6238        "Sub-agents",
6239        Style::default().add_modifier(Modifier::BOLD),
6240    ));
6241    if app.spawned_agents.is_empty() {
6242        lines.push(Line::styled(
6243            "None (use /spawn <name> <instructions>)",
6244            Style::default().fg(Color::DarkGray),
6245        ));
6246    } else {
6247        for (name, agent) in app.spawned_agents.iter().take(4) {
6248            let status = if agent.is_processing { "⚡" } else { "●" };
6249            let is_registered = app.is_agent_protocol_registered(name);
6250            let protocol = if is_registered { "🔗" } else { "⚠" };
6251            let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
6252                " [focused]"
6253            } else {
6254                ""
6255            };
6256            lines.push(Line::styled(
6257                format!(
6258                    "{status} {protocol} {} @{name}{focused}",
6259                    agent_avatar(name)
6260                ),
6261                if focused.is_empty() {
6262                    Style::default().fg(Color::Magenta)
6263                } else {
6264                    Style::default()
6265                        .fg(Color::Magenta)
6266                        .add_modifier(Modifier::BOLD)
6267                },
6268            ));
6269            let profile = agent_profile(name);
6270            lines.push(Line::styled(
6271                format!("   {} — {}", profile.codename, profile.profile),
6272                Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
6273            ));
6274            lines.push(Line::styled(
6275                format!("   {}", agent.instructions),
6276                Style::default()
6277                    .fg(Color::DarkGray)
6278                    .add_modifier(Modifier::DIM),
6279            ));
6280            if is_registered {
6281                lines.push(Line::styled(
6282                    format!("   bus://local/{name}"),
6283                    Style::default()
6284                        .fg(Color::Green)
6285                        .add_modifier(Modifier::DIM),
6286                ));
6287            }
6288        }
6289        if app.spawned_agents.len() > 4 {
6290            lines.push(Line::styled(
6291                format!("… and {} more", app.spawned_agents.len() - 4),
6292                Style::default()
6293                    .fg(Color::DarkGray)
6294                    .add_modifier(Modifier::DIM),
6295            ));
6296        }
6297    }
6298    lines.push(Line::from(""));
6299    lines.push(Line::styled(
6300        "Shortcuts",
6301        Style::default().add_modifier(Modifier::BOLD),
6302    ));
6303    lines.push(Line::from(vec![
6304        Span::styled("F3      ", Style::default().fg(Color::Yellow)),
6305        Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
6306    ]));
6307    lines.push(Line::from(vec![
6308        Span::styled("Ctrl+B  ", Style::default().fg(Color::Yellow)),
6309        Span::styled("Layout", Style::default().fg(Color::DarkGray)),
6310    ]));
6311    lines.push(Line::from(vec![
6312        Span::styled("Ctrl+Y  ", Style::default().fg(Color::Yellow)),
6313        Span::styled("Copy", Style::default().fg(Color::DarkGray)),
6314    ]));
6315    lines.push(Line::from(vec![
6316        Span::styled("Ctrl+M  ", Style::default().fg(Color::Yellow)),
6317        Span::styled("Model", Style::default().fg(Color::DarkGray)),
6318    ]));
6319    lines.push(Line::from(vec![
6320        Span::styled("Ctrl+S  ", Style::default().fg(Color::Yellow)),
6321        Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
6322    ]));
6323    lines.push(Line::from(vec![
6324        Span::styled("?       ", Style::default().fg(Color::Yellow)),
6325        Span::styled("Help", Style::default().fg(Color::DarkGray)),
6326    ]));
6327
6328    let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
6329    f.render_widget(panel, area);
6330}
6331
6332fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
6333    let title = if app.is_processing {
6334        if let Some(started) = app.processing_started_at {
6335            let elapsed = started.elapsed();
6336            format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
6337        } else {
6338            " Message (Processing...) ".to_string()
6339        }
6340    } else if app.autochat_running {
6341        format!(
6342            " {} ",
6343            app.autochat_status_label()
6344                .unwrap_or_else(|| "Autochat running…".to_string())
6345        )
6346    } else if app.input.starts_with('/') {
6347        // Show matching slash commands as hints
6348        let hint = match_slash_command_hint(&app.input);
6349        format!(" {} ", hint)
6350    } else if let Some(target) = &app.active_spawned_agent {
6351        format!(" Message to @{target} (use /agent main to exit) ")
6352    } else {
6353        " Message (Enter to send, / for commands) ".to_string()
6354    };
6355
6356    let input_block = Block::default()
6357        .borders(Borders::ALL)
6358        .title(title)
6359        .border_style(Style::default().fg(if app.is_processing {
6360            Color::Yellow
6361        } else if app.autochat_running {
6362            Color::Cyan
6363        } else if app.input.starts_with('/') {
6364            Color::Magenta
6365        } else {
6366            theme.input_border_color.to_color()
6367        }));
6368
6369    let input = Paragraph::new(app.input.as_str())
6370        .block(input_block)
6371        .wrap(Wrap { trim: false });
6372    f.render_widget(input, area);
6373
6374    f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
6375}
6376
6377fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
6378    let mut message_lines = Vec::new();
6379    let separator_width = max_width.min(60);
6380
6381    for (idx, message) in app.messages.iter().enumerate() {
6382        let role_style = theme.get_role_style(&message.role);
6383
6384        // Add a thin separator between messages (not before the first)
6385        if idx > 0 {
6386            let sep_char = match message.role.as_str() {
6387                "tool" => "·",
6388                _ => "─",
6389            };
6390            message_lines.push(Line::from(Span::styled(
6391                sep_char.repeat(separator_width),
6392                Style::default()
6393                    .fg(theme.timestamp_color.to_color())
6394                    .add_modifier(Modifier::DIM),
6395            )));
6396        }
6397
6398        // Role icons for better visual hierarchy
6399        let role_icon = match message.role.as_str() {
6400            "user" => "▸ ",
6401            "assistant" => "◆ ",
6402            "system" => "⚙ ",
6403            "tool" => "⚡",
6404            _ => "  ",
6405        };
6406
6407        let header_line = {
6408            let mut spans = vec![
6409                Span::styled(
6410                    format!("[{}] ", message.timestamp),
6411                    Style::default()
6412                        .fg(theme.timestamp_color.to_color())
6413                        .add_modifier(Modifier::DIM),
6414                ),
6415                Span::styled(role_icon, role_style),
6416                Span::styled(message.role.clone(), role_style),
6417            ];
6418            if let Some(ref agent) = message.agent_name {
6419                let profile = agent_profile(agent);
6420                spans.push(Span::styled(
6421                    format!(" {} @{agent} ‹{}›", agent_avatar(agent), profile.codename),
6422                    Style::default()
6423                        .fg(Color::Magenta)
6424                        .add_modifier(Modifier::BOLD),
6425                ));
6426            }
6427            Line::from(spans)
6428        };
6429        message_lines.push(header_line);
6430
6431        match &message.message_type {
6432            MessageType::ToolCall {
6433                name,
6434                arguments_preview,
6435                arguments_len,
6436                truncated,
6437            } => {
6438                let tool_header = Line::from(vec![
6439                    Span::styled("  🔧 ", Style::default().fg(Color::Yellow)),
6440                    Span::styled(
6441                        format!("Tool: {}", name),
6442                        Style::default()
6443                            .fg(Color::Yellow)
6444                            .add_modifier(Modifier::BOLD),
6445                    ),
6446                ]);
6447                message_lines.push(tool_header);
6448
6449                if arguments_preview.trim().is_empty() {
6450                    message_lines.push(Line::from(vec![
6451                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6452                        Span::styled(
6453                            "(no arguments)",
6454                            Style::default()
6455                                .fg(Color::DarkGray)
6456                                .add_modifier(Modifier::DIM),
6457                        ),
6458                    ]));
6459                } else {
6460                    for line in arguments_preview.lines() {
6461                        let args_line = Line::from(vec![
6462                            Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6463                            Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
6464                        ]);
6465                        message_lines.push(args_line);
6466                    }
6467                }
6468
6469                if *truncated {
6470                    let args_line = Line::from(vec![
6471                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6472                        Span::styled(
6473                            format!("... (truncated; {} bytes)", arguments_len),
6474                            Style::default()
6475                                .fg(Color::DarkGray)
6476                                .add_modifier(Modifier::DIM),
6477                        ),
6478                    ]);
6479                    message_lines.push(args_line);
6480                }
6481            }
6482            MessageType::ToolResult {
6483                name,
6484                output_preview,
6485                output_len,
6486                truncated,
6487                success,
6488                duration_ms,
6489            } => {
6490                let icon = if *success { "✅" } else { "❌" };
6491                let result_header = Line::from(vec![
6492                    Span::styled(
6493                        format!("  {icon} "),
6494                        Style::default().fg(if *success { Color::Green } else { Color::Red }),
6495                    ),
6496                    Span::styled(
6497                        format!("Result from {}", name),
6498                        Style::default()
6499                            .fg(if *success { Color::Green } else { Color::Red })
6500                            .add_modifier(Modifier::BOLD),
6501                    ),
6502                ]);
6503                message_lines.push(result_header);
6504
6505                let status_line = format!(
6506                    "  │ status: {}{}",
6507                    if *success { "success" } else { "failure" },
6508                    duration_ms
6509                        .map(|ms| format!(" • {}", format_duration_ms(ms)))
6510                        .unwrap_or_default()
6511                );
6512                message_lines.push(Line::from(vec![
6513                    Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6514                    Span::styled(
6515                        status_line.trim_start_matches("  │ ").to_string(),
6516                        Style::default().fg(Color::DarkGray),
6517                    ),
6518                ]));
6519
6520                if output_preview.trim().is_empty() {
6521                    message_lines.push(Line::from(vec![
6522                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6523                        Span::styled(
6524                            "(empty output)",
6525                            Style::default()
6526                                .fg(Color::DarkGray)
6527                                .add_modifier(Modifier::DIM),
6528                        ),
6529                    ]));
6530                } else {
6531                    for line in output_preview.lines() {
6532                        let output_line = Line::from(vec![
6533                            Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6534                            Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
6535                        ]);
6536                        message_lines.push(output_line);
6537                    }
6538                }
6539
6540                if *truncated {
6541                    message_lines.push(Line::from(vec![
6542                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6543                        Span::styled(
6544                            format!("... (truncated; {} bytes)", output_len),
6545                            Style::default()
6546                                .fg(Color::DarkGray)
6547                                .add_modifier(Modifier::DIM),
6548                        ),
6549                    ]));
6550                }
6551            }
6552            MessageType::Text(text) => {
6553                let formatter = MessageFormatter::new(max_width);
6554                let formatted_content = formatter.format_content(text, &message.role);
6555                message_lines.extend(formatted_content);
6556            }
6557            MessageType::Thinking(text) => {
6558                let thinking_style = Style::default()
6559                    .fg(Color::DarkGray)
6560                    .add_modifier(Modifier::DIM | Modifier::ITALIC);
6561                message_lines.push(Line::from(Span::styled(
6562                    "  💭 Thinking...",
6563                    Style::default()
6564                        .fg(Color::Magenta)
6565                        .add_modifier(Modifier::DIM),
6566                )));
6567                // Show truncated thinking content
6568                let max_thinking_lines = 8;
6569                let mut iter = text.lines();
6570                let mut shown = 0usize;
6571                while shown < max_thinking_lines {
6572                    let Some(line) = iter.next() else { break };
6573                    message_lines.push(Line::from(vec![
6574                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
6575                        Span::styled(line.to_string(), thinking_style),
6576                    ]));
6577                    shown += 1;
6578                }
6579                if iter.next().is_some() {
6580                    message_lines.push(Line::from(Span::styled(
6581                        "  │ ... (truncated)",
6582                        thinking_style,
6583                    )));
6584                }
6585            }
6586            MessageType::Image { url, mime_type } => {
6587                let formatter = MessageFormatter::new(max_width);
6588                let image_line = formatter.format_image(url, mime_type.as_deref());
6589                message_lines.push(image_line);
6590            }
6591            MessageType::File { path, mime_type } => {
6592                let mime_label = mime_type.as_deref().unwrap_or("unknown type");
6593                let file_header = Line::from(vec![
6594                    Span::styled("  📎 ", Style::default().fg(Color::Cyan)),
6595                    Span::styled(
6596                        format!("File: {}", path),
6597                        Style::default()
6598                            .fg(Color::Cyan)
6599                            .add_modifier(Modifier::BOLD),
6600                    ),
6601                    Span::styled(
6602                        format!(" ({})", mime_label),
6603                        Style::default()
6604                            .fg(Color::DarkGray)
6605                            .add_modifier(Modifier::DIM),
6606                    ),
6607                ]);
6608                message_lines.push(file_header);
6609            }
6610        }
6611
6612        // Show usage indicator after assistant messages
6613        if message.role == "assistant" {
6614            if let Some(ref meta) = message.usage_meta {
6615                let duration_str = if meta.duration_ms >= 60_000 {
6616                    format!(
6617                        "{}m{:02}.{}s",
6618                        meta.duration_ms / 60_000,
6619                        (meta.duration_ms % 60_000) / 1000,
6620                        (meta.duration_ms % 1000) / 100
6621                    )
6622                } else {
6623                    format!(
6624                        "{}.{}s",
6625                        meta.duration_ms / 1000,
6626                        (meta.duration_ms % 1000) / 100
6627                    )
6628                };
6629                let tokens_str =
6630                    format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
6631                let cost_str = match meta.cost_usd {
6632                    Some(c) if c < 0.01 => format!("${:.4}", c),
6633                    Some(c) => format!("${:.2}", c),
6634                    None => String::new(),
6635                };
6636                let dim_style = Style::default()
6637                    .fg(theme.timestamp_color.to_color())
6638                    .add_modifier(Modifier::DIM);
6639                let mut spans = vec![Span::styled(
6640                    format!("  ⏱ {} │ 📊 {}", duration_str, tokens_str),
6641                    dim_style,
6642                )];
6643                if !cost_str.is_empty() {
6644                    spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
6645                }
6646                message_lines.push(Line::from(spans));
6647            }
6648        }
6649
6650        message_lines.push(Line::from(""));
6651    }
6652
6653    // Show streaming text preview (text arriving before TextComplete finalizes it)
6654    if let Some(ref streaming) = app.streaming_text {
6655        if !streaming.is_empty() {
6656            message_lines.push(Line::from(Span::styled(
6657                "─".repeat(separator_width),
6658                Style::default()
6659                    .fg(theme.timestamp_color.to_color())
6660                    .add_modifier(Modifier::DIM),
6661            )));
6662            message_lines.push(Line::from(vec![
6663                Span::styled(
6664                    format!("[{}] ", chrono::Local::now().format("%H:%M")),
6665                    Style::default()
6666                        .fg(theme.timestamp_color.to_color())
6667                        .add_modifier(Modifier::DIM),
6668                ),
6669                Span::styled("◆ ", theme.get_role_style("assistant")),
6670                Span::styled("assistant", theme.get_role_style("assistant")),
6671                Span::styled(
6672                    " (streaming...)",
6673                    Style::default()
6674                        .fg(theme.timestamp_color.to_color())
6675                        .add_modifier(Modifier::DIM),
6676                ),
6677            ]));
6678            let formatter = MessageFormatter::new(max_width);
6679            let formatted = formatter.format_content(streaming, "assistant");
6680            message_lines.extend(formatted);
6681            message_lines.push(Line::from(""));
6682        }
6683    }
6684
6685    let mut agent_streams = app.streaming_agent_texts.iter().collect::<Vec<_>>();
6686    agent_streams.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
6687    for (agent, streaming) in agent_streams {
6688        if streaming.is_empty() {
6689            continue;
6690        }
6691
6692        let profile = agent_profile(agent);
6693
6694        message_lines.push(Line::from(Span::styled(
6695            "─".repeat(separator_width),
6696            Style::default()
6697                .fg(theme.timestamp_color.to_color())
6698                .add_modifier(Modifier::DIM),
6699        )));
6700        message_lines.push(Line::from(vec![
6701            Span::styled(
6702                format!("[{}] ", chrono::Local::now().format("%H:%M")),
6703                Style::default()
6704                    .fg(theme.timestamp_color.to_color())
6705                    .add_modifier(Modifier::DIM),
6706            ),
6707            Span::styled("◆ ", theme.get_role_style("assistant")),
6708            Span::styled("assistant", theme.get_role_style("assistant")),
6709            Span::styled(
6710                format!(" {} @{} ‹{}›", agent_avatar(agent), agent, profile.codename),
6711                Style::default()
6712                    .fg(Color::Magenta)
6713                    .add_modifier(Modifier::BOLD),
6714            ),
6715            Span::styled(
6716                " (streaming...)",
6717                Style::default()
6718                    .fg(theme.timestamp_color.to_color())
6719                    .add_modifier(Modifier::DIM),
6720            ),
6721        ]));
6722
6723        let formatter = MessageFormatter::new(max_width);
6724        let formatted = formatter.format_content(streaming, "assistant");
6725        message_lines.extend(formatted);
6726        message_lines.push(Line::from(""));
6727    }
6728
6729    if app.is_processing {
6730        let spinner = current_spinner_frame();
6731
6732        // Elapsed time display
6733        let elapsed_str = if let Some(started) = app.processing_started_at {
6734            let elapsed = started.elapsed();
6735            if elapsed.as_secs() >= 60 {
6736                format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
6737            } else {
6738                format!(" {:.1}s", elapsed.as_secs_f64())
6739            }
6740        } else {
6741            String::new()
6742        };
6743
6744        let processing_line = Line::from(vec![
6745            Span::styled(
6746                format!("[{}] ", chrono::Local::now().format("%H:%M")),
6747                Style::default()
6748                    .fg(theme.timestamp_color.to_color())
6749                    .add_modifier(Modifier::DIM),
6750            ),
6751            Span::styled("◆ ", theme.get_role_style("assistant")),
6752            Span::styled("assistant", theme.get_role_style("assistant")),
6753            Span::styled(
6754                elapsed_str,
6755                Style::default()
6756                    .fg(theme.timestamp_color.to_color())
6757                    .add_modifier(Modifier::DIM),
6758            ),
6759        ]);
6760        message_lines.push(processing_line);
6761
6762        let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
6763            (format!("  {spinner} Running: {}", tool), Color::Cyan)
6764        } else {
6765            (
6766                format!(
6767                    "  {} {}",
6768                    spinner,
6769                    app.processing_message.as_deref().unwrap_or("Thinking...")
6770                ),
6771                Color::Yellow,
6772            )
6773        };
6774
6775        let indicator_line = Line::from(vec![Span::styled(
6776            status_text,
6777            Style::default()
6778                .fg(status_color)
6779                .add_modifier(Modifier::BOLD),
6780        )]);
6781        message_lines.push(indicator_line);
6782        message_lines.push(Line::from(""));
6783    }
6784
6785    if app.autochat_running {
6786        let status_text = app
6787            .autochat_status_label()
6788            .unwrap_or_else(|| "Autochat running…".to_string());
6789        message_lines.push(Line::from(Span::styled(
6790            "─".repeat(separator_width),
6791            Style::default()
6792                .fg(theme.timestamp_color.to_color())
6793                .add_modifier(Modifier::DIM),
6794        )));
6795        message_lines.push(Line::from(vec![
6796            Span::styled(
6797                format!("[{}] ", chrono::Local::now().format("%H:%M")),
6798                Style::default()
6799                    .fg(theme.timestamp_color.to_color())
6800                    .add_modifier(Modifier::DIM),
6801            ),
6802            Span::styled("⚙ ", theme.get_role_style("system")),
6803            Span::styled(
6804                status_text,
6805                Style::default()
6806                    .fg(Color::Cyan)
6807                    .add_modifier(Modifier::BOLD),
6808            ),
6809        ]));
6810        message_lines.push(Line::from(""));
6811    }
6812
6813    message_lines
6814}
6815
6816fn match_slash_command_hint(input: &str) -> String {
6817    let commands = [
6818        ("/go ", "Easy mode: run relay chat with default team size"),
6819        ("/add ", "Easy mode: create a teammate"),
6820        ("/talk ", "Easy mode: message or focus a teammate"),
6821        ("/list", "Easy mode: list teammates"),
6822        ("/remove ", "Easy mode: remove a teammate"),
6823        ("/home", "Easy mode: return to main chat"),
6824        ("/help", "Open help"),
6825        ("/spawn ", "Create a named sub-agent"),
6826        ("/autochat ", "Run protocol-first multi-agent relay chat"),
6827        ("/agents", "List spawned sub-agents"),
6828        ("/kill ", "Remove a spawned sub-agent"),
6829        ("/agent ", "Focus or message a spawned sub-agent"),
6830        ("/swarm ", "Run task in parallel swarm mode"),
6831        ("/ralph", "Start autonomous PRD loop"),
6832        ("/undo", "Undo last message and response"),
6833        ("/sessions", "Open session picker"),
6834        ("/resume", "Resume a session"),
6835        ("/new", "Start a new session"),
6836        ("/model", "Select or set model"),
6837        ("/webview", "Switch to webview layout"),
6838        ("/classic", "Switch to classic layout"),
6839        ("/inspector", "Toggle inspector pane"),
6840        ("/refresh", "Refresh workspace"),
6841        ("/archive", "Show persistent chat archive path"),
6842        ("/view", "Toggle swarm view"),
6843        ("/buslog", "Show protocol bus log"),
6844        ("/protocol", "Show protocol registry"),
6845    ];
6846
6847    let trimmed = input.trim_start();
6848    let input_lower = trimmed.to_lowercase();
6849
6850    // Exact command (or command + args) should always resolve to one hint.
6851    if let Some((cmd, desc)) = commands.iter().find(|(cmd, _)| {
6852        let key = cmd.trim_end().to_ascii_lowercase();
6853        input_lower == key || input_lower.starts_with(&(key + " "))
6854    }) {
6855        return format!("{} — {}", cmd.trim(), desc);
6856    }
6857
6858    // Fallback to prefix matching while the user is still typing.
6859    let matches: Vec<_> = commands
6860        .iter()
6861        .filter(|(cmd, _)| cmd.starts_with(&input_lower))
6862        .collect();
6863
6864    if matches.len() == 1 {
6865        format!("{} — {}", matches[0].0.trim(), matches[0].1)
6866    } else if matches.is_empty() {
6867        "Unknown command".to_string()
6868    } else {
6869        let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
6870        cmds.join(" | ")
6871    }
6872}
6873
6874fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
6875    let trimmed = input.trim();
6876    let rest = trimmed.strip_prefix(command)?;
6877
6878    if rest.is_empty() {
6879        return Some("");
6880    }
6881
6882    let first = rest.chars().next()?;
6883    if first.is_whitespace() {
6884        Some(rest.trim())
6885    } else {
6886        None
6887    }
6888}
6889
6890fn normalize_easy_command(input: &str) -> String {
6891    let trimmed = input.trim();
6892    if trimmed.is_empty() {
6893        return String::new();
6894    }
6895
6896    if !trimmed.starts_with('/') {
6897        return input.to_string();
6898    }
6899
6900    let mut parts = trimmed.splitn(2, char::is_whitespace);
6901    let command = parts.next().unwrap_or("");
6902    let args = parts.next().unwrap_or("").trim();
6903
6904    match command.to_ascii_lowercase().as_str() {
6905        "/go" | "/team" => {
6906            if args.is_empty() {
6907                "/autochat".to_string()
6908            } else {
6909                let mut parts = args.splitn(2, char::is_whitespace);
6910                let first = parts.next().unwrap_or("").trim();
6911                if let Ok(count) = first.parse::<usize>() {
6912                    let rest = parts.next().unwrap_or("").trim();
6913                    if rest.is_empty() {
6914                        format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
6915                    } else {
6916                        format!("/autochat {count} {rest}")
6917                    }
6918                } else {
6919                    format!("/autochat {AUTOCHAT_DEFAULT_AGENTS} {args}")
6920                }
6921            }
6922        }
6923        "/add" => {
6924            if args.is_empty() {
6925                "/spawn".to_string()
6926            } else {
6927                format!("/spawn {args}")
6928            }
6929        }
6930        "/list" | "/ls" => "/agents".to_string(),
6931        "/remove" | "/rm" => {
6932            if args.is_empty() {
6933                "/kill".to_string()
6934            } else {
6935                format!("/kill {args}")
6936            }
6937        }
6938        "/talk" | "/say" => {
6939            if args.is_empty() {
6940                "/agent".to_string()
6941            } else {
6942                format!("/agent {args}")
6943            }
6944        }
6945        "/focus" => {
6946            if args.is_empty() {
6947                "/agent".to_string()
6948            } else {
6949                format!("/agent {}", args.trim_start_matches('@'))
6950            }
6951        }
6952        "/home" | "/main" => "/agent main".to_string(),
6953        "/h" | "/?" => "/help".to_string(),
6954        _ => trimmed.to_string(),
6955    }
6956}
6957
6958fn is_easy_go_command(input: &str) -> bool {
6959    let command = input
6960        .trim_start()
6961        .split_whitespace()
6962        .next()
6963        .unwrap_or("")
6964        .to_ascii_lowercase();
6965
6966    matches!(command.as_str(), "/go" | "/team")
6967}
6968
6969fn is_glm5_model(model: &str) -> bool {
6970    let normalized = model.trim().to_ascii_lowercase();
6971    matches!(
6972        normalized.as_str(),
6973        "zai/glm-5" | "z-ai/glm-5" | "openrouter/z-ai/glm-5"
6974    )
6975}
6976
6977fn is_minimax_m25_model(model: &str) -> bool {
6978    let normalized = model.trim().to_ascii_lowercase();
6979    matches!(normalized.as_str(), "minimax/minimax-m2.5" | "minimax-m2.5")
6980}
6981
6982fn next_go_model(current_model: Option<&str>) -> String {
6983    match current_model {
6984        Some(model) if is_glm5_model(model) => GO_SWAP_MODEL_MINIMAX.to_string(),
6985        Some(model) if is_minimax_m25_model(model) => GO_SWAP_MODEL_GLM.to_string(),
6986        _ => GO_SWAP_MODEL_MINIMAX.to_string(),
6987    }
6988}
6989
6990fn parse_autochat_args(rest: &str) -> Option<(usize, &str)> {
6991    let rest = rest.trim();
6992    if rest.is_empty() {
6993        return None;
6994    }
6995
6996    let mut parts = rest.splitn(2, char::is_whitespace);
6997    let first = parts.next().unwrap_or("").trim();
6998    if first.is_empty() {
6999        return None;
7000    }
7001
7002    if let Ok(count) = first.parse::<usize>() {
7003        let task = parts.next().unwrap_or("").trim();
7004        if task.is_empty() {
7005            Some((count, AUTOCHAT_QUICK_DEMO_TASK))
7006        } else {
7007            Some((count, task))
7008        }
7009    } else {
7010        Some((AUTOCHAT_DEFAULT_AGENTS, rest))
7011    }
7012}
7013
7014fn normalize_for_convergence(text: &str) -> String {
7015    let mut normalized = String::with_capacity(text.len().min(512));
7016    let mut last_was_space = false;
7017
7018    for ch in text.chars() {
7019        if ch.is_ascii_alphanumeric() {
7020            normalized.push(ch.to_ascii_lowercase());
7021            last_was_space = false;
7022        } else if ch.is_whitespace() && !last_was_space {
7023            normalized.push(' ');
7024            last_was_space = true;
7025        }
7026
7027        if normalized.len() >= 280 {
7028            break;
7029        }
7030    }
7031
7032    normalized.trim().to_string()
7033}
7034
7035fn agent_profile(agent_name: &str) -> AgentProfile {
7036    let normalized = agent_name.to_ascii_lowercase();
7037
7038    if normalized.contains("planner") {
7039        return AgentProfile {
7040            codename: "Strategist",
7041            profile: "Goal decomposition specialist",
7042            personality: "calm, methodical, and dependency-aware",
7043            collaboration_style: "opens with numbered plans and explicit priorities",
7044            signature_move: "turns vague goals into concrete execution ladders",
7045        };
7046    }
7047
7048    if normalized.contains("research") {
7049        return AgentProfile {
7050            codename: "Archivist",
7051            profile: "Evidence and assumptions analyst",
7052            personality: "curious, skeptical, and detail-focused",
7053            collaboration_style: "validates claims and cites edge-case evidence",
7054            signature_move: "surfaces blind spots before implementation starts",
7055        };
7056    }
7057
7058    if normalized.contains("coder") || normalized.contains("implement") {
7059        return AgentProfile {
7060            codename: "Forge",
7061            profile: "Implementation architect",
7062            personality: "pragmatic, direct, and execution-heavy",
7063            collaboration_style: "proposes concrete code-level actions quickly",
7064            signature_move: "translates plans into shippable implementation steps",
7065        };
7066    }
7067
7068    if normalized.contains("review") {
7069        return AgentProfile {
7070            codename: "Sentinel",
7071            profile: "Quality and regression guardian",
7072            personality: "disciplined, assertive, and standards-driven",
7073            collaboration_style: "challenges weak reasoning and hardens quality",
7074            signature_move: "detects brittle assumptions and failure modes",
7075        };
7076    }
7077
7078    if normalized.contains("tester") || normalized.contains("test") {
7079        return AgentProfile {
7080            codename: "Probe",
7081            profile: "Verification strategist",
7082            personality: "adversarial in a good way, systematic, and precise",
7083            collaboration_style: "designs checks around failure-first thinking",
7084            signature_move: "builds test matrices that catch hidden breakage",
7085        };
7086    }
7087
7088    if normalized.contains("integrat") {
7089        return AgentProfile {
7090            codename: "Conductor",
7091            profile: "Cross-stream synthesis lead",
7092            personality: "balanced, diplomatic, and outcome-oriented",
7093            collaboration_style: "reconciles competing inputs into one plan",
7094            signature_move: "merges parallel work into coherent delivery",
7095        };
7096    }
7097
7098    if normalized.contains("skeptic") || normalized.contains("risk") {
7099        return AgentProfile {
7100            codename: "Radar",
7101            profile: "Risk and threat analyst",
7102            personality: "blunt, anticipatory, and protective",
7103            collaboration_style: "flags downside scenarios and mitigation paths",
7104            signature_move: "turns uncertainty into explicit risk registers",
7105        };
7106    }
7107
7108    if normalized.contains("summary") || normalized.contains("summarizer") {
7109        return AgentProfile {
7110            codename: "Beacon",
7111            profile: "Decision synthesis specialist",
7112            personality: "concise, clear, and action-first",
7113            collaboration_style: "compresses complexity into executable next steps",
7114            signature_move: "creates crisp briefings that unblock teams quickly",
7115        };
7116    }
7117
7118    let fallback_profiles = [
7119        AgentProfile {
7120            codename: "Navigator",
7121            profile: "Generalist coordinator",
7122            personality: "adaptable and context-aware",
7123            collaboration_style: "balances speed with clarity",
7124            signature_move: "keeps team momentum aligned",
7125        },
7126        AgentProfile {
7127            codename: "Vector",
7128            profile: "Execution operator",
7129            personality: "focused and deadline-driven",
7130            collaboration_style: "prefers direct action and feedback loops",
7131            signature_move: "drives ambiguous tasks toward decisions",
7132        },
7133        AgentProfile {
7134            codename: "Signal",
7135            profile: "Communication specialist",
7136            personality: "clear, friendly, and structured",
7137            collaboration_style: "frames updates for quick handoffs",
7138            signature_move: "turns noisy context into clean status",
7139        },
7140        AgentProfile {
7141            codename: "Kernel",
7142            profile: "Core-systems thinker",
7143            personality: "analytical and stable",
7144            collaboration_style: "organizes work around constraints and invariants",
7145            signature_move: "locks down the critical path early",
7146        },
7147    ];
7148
7149    let mut hash: u64 = 2_166_136_261;
7150    for byte in normalized.bytes() {
7151        hash = (hash ^ u64::from(byte)).wrapping_mul(16_777_619);
7152    }
7153    fallback_profiles[hash as usize % fallback_profiles.len()]
7154}
7155
7156fn format_agent_profile_summary(agent_name: &str) -> String {
7157    let profile = agent_profile(agent_name);
7158    format!(
7159        "{} — {} ({})",
7160        profile.codename, profile.profile, profile.personality
7161    )
7162}
7163
7164fn agent_avatar(agent_name: &str) -> &'static str {
7165    let mut hash: u64 = 2_166_136_261;
7166    for byte in agent_name.bytes() {
7167        hash = (hash ^ u64::from(byte.to_ascii_lowercase())).wrapping_mul(16_777_619);
7168    }
7169    AGENT_AVATARS[hash as usize % AGENT_AVATARS.len()]
7170}
7171
7172fn format_agent_identity(agent_name: &str) -> String {
7173    let profile = agent_profile(agent_name);
7174    format!(
7175        "{} @{} ‹{}›",
7176        agent_avatar(agent_name),
7177        agent_name,
7178        profile.codename
7179    )
7180}
7181
7182fn format_relay_participant(participant: &str) -> String {
7183    if participant.eq_ignore_ascii_case("user") {
7184        "[you]".to_string()
7185    } else {
7186        format_agent_identity(participant)
7187    }
7188}
7189
7190fn format_relay_handoff_line(relay_id: &str, round: usize, from: &str, to: &str) -> String {
7191    format!(
7192        "[relay {relay_id} • round {round}] {} → {}",
7193        format_relay_participant(from),
7194        format_relay_participant(to)
7195    )
7196}
7197
7198fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
7199    // Avoid expensive JSON parsing/pretty-printing for very large payloads.
7200    // Large tool arguments are common (e.g., patches) and reformatting them provides
7201    // little value in a terminal preview.
7202    if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
7203        return arguments.to_string();
7204    }
7205
7206    let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
7207        Ok(value) => value,
7208        Err(_) => return arguments.to_string(),
7209    };
7210
7211    if name == "question"
7212        && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
7213    {
7214        return question.to_string();
7215    }
7216
7217    serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
7218}
7219
7220fn build_tool_arguments_preview(
7221    tool_name: &str,
7222    arguments: &str,
7223    max_lines: usize,
7224    max_bytes: usize,
7225) -> (String, bool) {
7226    // Pretty-print when reasonably sized; otherwise keep raw to avoid a heavy parse.
7227    let formatted = format_tool_call_arguments(tool_name, arguments);
7228    build_text_preview(&formatted, max_lines, max_bytes)
7229}
7230
7231/// Build a stable, size-limited preview used by the renderer.
7232///
7233/// Returns (preview_text, truncated).
7234fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
7235    if max_lines == 0 || max_bytes == 0 || text.is_empty() {
7236        return (String::new(), !text.is_empty());
7237    }
7238
7239    let mut out = String::new();
7240    let mut truncated = false;
7241    let mut remaining = max_bytes;
7242
7243    let mut iter = text.lines();
7244    for i in 0..max_lines {
7245        let Some(line) = iter.next() else { break };
7246
7247        // Add newline separator if needed
7248        if i > 0 {
7249            if remaining == 0 {
7250                truncated = true;
7251                break;
7252            }
7253            out.push('\n');
7254            remaining = remaining.saturating_sub(1);
7255        }
7256
7257        if remaining == 0 {
7258            truncated = true;
7259            break;
7260        }
7261
7262        if line.len() <= remaining {
7263            out.push_str(line);
7264            remaining = remaining.saturating_sub(line.len());
7265        } else {
7266            // Truncate this line to remaining bytes, respecting UTF-8 boundaries.
7267            let mut end = remaining;
7268            while end > 0 && !line.is_char_boundary(end) {
7269                end -= 1;
7270            }
7271            out.push_str(&line[..end]);
7272            truncated = true;
7273            break;
7274        }
7275    }
7276
7277    // If there are still lines left, we truncated.
7278    if !truncated && iter.next().is_some() {
7279        truncated = true;
7280    }
7281
7282    (out, truncated)
7283}
7284
7285fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
7286    if max_chars == 0 {
7287        return String::new();
7288    }
7289
7290    let mut chars = value.chars();
7291    let mut output = String::new();
7292    for _ in 0..max_chars {
7293        if let Some(ch) = chars.next() {
7294            output.push(ch);
7295        } else {
7296            return value.to_string();
7297        }
7298    }
7299
7300    if chars.next().is_some() {
7301        format!("{output}...")
7302    } else {
7303        output
7304    }
7305}
7306
7307fn message_clipboard_text(message: &ChatMessage) -> String {
7308    let mut prefix = String::new();
7309    if let Some(agent) = &message.agent_name {
7310        prefix = format!("@{agent}\n");
7311    }
7312
7313    match &message.message_type {
7314        MessageType::Text(text) => format!("{prefix}{text}"),
7315        MessageType::Thinking(text) => format!("{prefix}{text}"),
7316        MessageType::Image { url, .. } => format!("{prefix}{url}"),
7317        MessageType::File { path, .. } => format!("{prefix}{path}"),
7318        MessageType::ToolCall {
7319            name,
7320            arguments_preview,
7321            ..
7322        } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
7323        MessageType::ToolResult {
7324            name,
7325            output_preview,
7326            ..
7327        } => format!("{prefix}Tool result: {name}\n{output_preview}"),
7328    }
7329}
7330
7331fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
7332    if text.trim().is_empty() {
7333        return Err("empty text".to_string());
7334    }
7335
7336    // 1) Try system clipboard first (works locally when a clipboard provider is available)
7337    match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text.to_string())) {
7338        Ok(()) => return Ok("system clipboard"),
7339        Err(e) => {
7340            tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
7341        }
7342    }
7343
7344    // 2) Fallback: OSC52 (works in many terminals, including remote SSH sessions)
7345    osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
7346    Ok("OSC52")
7347}
7348
7349fn osc52_copy(text: &str) -> std::io::Result<()> {
7350    // OSC52 format: ESC ] 52 ; c ; <base64> BEL
7351    // Some terminals may disable OSC52 for security; we treat this as best-effort.
7352    let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
7353    let seq = format!("\u{1b}]52;c;{payload}\u{07}");
7354
7355    let mut stdout = std::io::stdout();
7356    crossterm::execute!(stdout, crossterm::style::Print(seq))?;
7357    use std::io::Write;
7358    stdout.flush()?;
7359    Ok(())
7360}
7361
7362fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
7363    if !app.show_help {
7364        return;
7365    }
7366
7367    let area = centered_rect(60, 60, f.area());
7368    f.render_widget(Clear, area);
7369
7370    let token_display = TokenDisplay::new();
7371    let token_info = token_display.create_detailed_display();
7372
7373    // Model / provider info
7374    let model_section: Vec<String> = if let Some(ref active) = app.active_model {
7375        let (provider, model) = crate::provider::parse_model_string(active);
7376        let provider_label = provider.unwrap_or("auto");
7377        vec![
7378            "".to_string(),
7379            "  ACTIVE MODEL".to_string(),
7380            "  ==============".to_string(),
7381            format!("  Provider:  {}", provider_label),
7382            format!("  Model:     {}", model),
7383            format!("  Agent:     {}", app.current_agent),
7384        ]
7385    } else {
7386        vec![
7387            "".to_string(),
7388            "  ACTIVE MODEL".to_string(),
7389            "  ==============".to_string(),
7390            format!("  Provider:  auto"),
7391            format!("  Model:     (default)"),
7392            format!("  Agent:     {}", app.current_agent),
7393        ]
7394    };
7395
7396    let help_text: Vec<String> = vec![
7397        "".to_string(),
7398        "  KEYBOARD SHORTCUTS".to_string(),
7399        "  ==================".to_string(),
7400        "".to_string(),
7401        "  Enter        Send message".to_string(),
7402        "  Tab          Switch between build/plan agents".to_string(),
7403        "  Ctrl+A       Open spawned-agent picker".to_string(),
7404        "  Ctrl+M       Open model picker".to_string(),
7405        "  Ctrl+L       Protocol bus log".to_string(),
7406        "  Ctrl+P       Protocol registry".to_string(),
7407        "  Ctrl+S       Toggle swarm view".to_string(),
7408        "  Ctrl+B       Toggle webview layout".to_string(),
7409        "  Ctrl+Y       Copy latest assistant reply".to_string(),
7410        "  F3           Toggle inspector pane".to_string(),
7411        "  Ctrl+C       Quit".to_string(),
7412        "  ?            Toggle this help".to_string(),
7413        "".to_string(),
7414        "  SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
7415        "  EASY MODE".to_string(),
7416        "  /go <task>      Start relay chat with default team size".to_string(),
7417        "  /add <name>     Create a helper teammate".to_string(),
7418        "  /talk <name> <message>  Message teammate".to_string(),
7419        "  /list           List teammates".to_string(),
7420        "  /remove <name>  Remove teammate".to_string(),
7421        "  /home           Return to main chat".to_string(),
7422        "  /help           Open this help".to_string(),
7423        "".to_string(),
7424        "  ADVANCED MODE".to_string(),
7425        "  /spawn <name> <instructions>  Create a named sub-agent".to_string(),
7426        "  /autochat [count] <task>  Protocol-first auto multi-agent relay".to_string(),
7427        "  /agents        List spawned sub-agents".to_string(),
7428        "  /kill <name>   Remove a spawned sub-agent".to_string(),
7429        "  /agent <name>  Focus chat on a spawned sub-agent".to_string(),
7430        "  /agent <name> <message>  Send one message to a spawned sub-agent".to_string(),
7431        "  /agent            Open spawned-agent picker".to_string(),
7432        "  /agent main|off  Exit focused sub-agent chat".to_string(),
7433        "  /swarm <task>   Run task in parallel swarm mode".to_string(),
7434        "  /ralph [path]   Start Ralph PRD loop (default: prd.json)".to_string(),
7435        "  /undo           Undo last message and response".to_string(),
7436        "  /sessions       Open session picker (filter, delete, load)".to_string(),
7437        "  /resume         Resume most recent session".to_string(),
7438        "  /resume <id>    Resume specific session by ID".to_string(),
7439        "  /new            Start a fresh session".to_string(),
7440        "  /model          Open model picker (or /model <name>)".to_string(),
7441        "  /view           Toggle swarm view".to_string(),
7442        "  /buslog         Show protocol bus log".to_string(),
7443        "  /protocol       Show protocol registry and AgentCards".to_string(),
7444        "  /webview        Web dashboard layout".to_string(),
7445        "  /classic        Single-pane layout".to_string(),
7446        "  /inspector      Toggle inspector pane".to_string(),
7447        "  /refresh        Refresh workspace and sessions".to_string(),
7448        "  /archive        Show persistent chat archive path".to_string(),
7449        "".to_string(),
7450        "  SESSION PICKER".to_string(),
7451        "  ↑/↓/j/k      Navigate sessions".to_string(),
7452        "  Enter         Load selected session".to_string(),
7453        "  d             Delete session (press twice to confirm)".to_string(),
7454        "  Type          Filter sessions by name/agent/ID".to_string(),
7455        "  Backspace     Clear filter character".to_string(),
7456        "  Esc           Close picker".to_string(),
7457        "".to_string(),
7458        "  VIM-STYLE NAVIGATION".to_string(),
7459        "  Alt+j        Scroll down".to_string(),
7460        "  Alt+k        Scroll up".to_string(),
7461        "  Ctrl+g       Go to top".to_string(),
7462        "  Ctrl+G       Go to bottom".to_string(),
7463        "".to_string(),
7464        "  SCROLLING".to_string(),
7465        "  Up/Down      Scroll messages".to_string(),
7466        "  PageUp/Dn    Scroll one page".to_string(),
7467        "  Alt+u/d      Scroll half page".to_string(),
7468        "".to_string(),
7469        "  COMMAND HISTORY".to_string(),
7470        "  Ctrl+R       Search history".to_string(),
7471        "  Ctrl+Up/Dn   Navigate history".to_string(),
7472        "".to_string(),
7473        "  Press ? or Esc to close".to_string(),
7474        "".to_string(),
7475    ];
7476
7477    let mut combined_text = token_info;
7478    combined_text.extend(model_section);
7479    combined_text.extend(help_text);
7480
7481    let help = Paragraph::new(combined_text.join("\n"))
7482        .block(
7483            Block::default()
7484                .borders(Borders::ALL)
7485                .title(" Help ")
7486                .border_style(Style::default().fg(theme.help_border_color.to_color())),
7487        )
7488        .wrap(Wrap { trim: false });
7489
7490    f.render_widget(help, area);
7491}
7492
7493/// Helper to create a centered rect
7494fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
7495    let popup_layout = Layout::default()
7496        .direction(Direction::Vertical)
7497        .constraints([
7498            Constraint::Percentage((100 - percent_y) / 2),
7499            Constraint::Percentage(percent_y),
7500            Constraint::Percentage((100 - percent_y) / 2),
7501        ])
7502        .split(r);
7503
7504    Layout::default()
7505        .direction(Direction::Horizontal)
7506        .constraints([
7507            Constraint::Percentage((100 - percent_x) / 2),
7508            Constraint::Percentage(percent_x),
7509            Constraint::Percentage((100 - percent_x) / 2),
7510        ])
7511        .split(popup_layout[1])[1]
7512}
7513
7514#[cfg(test)]
7515mod tests {
7516    use super::{
7517        AUTOCHAT_QUICK_DEMO_TASK, agent_avatar, agent_profile, command_with_optional_args,
7518        estimate_cost, format_agent_identity, format_relay_handoff_line, is_easy_go_command,
7519        is_secure_environment_from_values, match_slash_command_hint, minio_fallback_endpoint,
7520        next_go_model, normalize_easy_command, normalize_for_convergence, normalize_minio_endpoint,
7521        parse_autochat_args,
7522    };
7523
7524    #[test]
7525    fn command_with_optional_args_handles_bare_command() {
7526        assert_eq!(command_with_optional_args("/spawn", "/spawn"), Some(""));
7527    }
7528
7529    #[test]
7530    fn command_with_optional_args_handles_arguments() {
7531        assert_eq!(
7532            command_with_optional_args("/spawn planner you plan", "/spawn"),
7533            Some("planner you plan")
7534        );
7535    }
7536
7537    #[test]
7538    fn command_with_optional_args_ignores_prefix_collisions() {
7539        assert_eq!(command_with_optional_args("/spawned", "/spawn"), None);
7540    }
7541
7542    #[test]
7543    fn command_with_optional_args_ignores_autochat_prefix_collisions() {
7544        assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
7545    }
7546
7547    #[test]
7548    fn command_with_optional_args_trims_leading_whitespace_in_args() {
7549        assert_eq!(
7550            command_with_optional_args("/kill    local-agent-1", "/kill"),
7551            Some("local-agent-1")
7552        );
7553    }
7554
7555    #[test]
7556    fn slash_hint_includes_protocol_command() {
7557        let hint = match_slash_command_hint("/protocol");
7558        assert!(hint.contains("/protocol"));
7559    }
7560
7561    #[test]
7562    fn slash_hint_includes_autochat_command() {
7563        let hint = match_slash_command_hint("/autochat");
7564        assert!(hint.contains("/autochat"));
7565    }
7566
7567    #[test]
7568    fn normalize_easy_command_maps_go_to_autochat() {
7569        assert_eq!(
7570            normalize_easy_command("/go build a calculator"),
7571            "/autochat 3 build a calculator"
7572        );
7573    }
7574
7575    #[test]
7576    fn normalize_easy_command_maps_go_count_and_task() {
7577        assert_eq!(
7578            normalize_easy_command("/go 4 build a calculator"),
7579            "/autochat 4 build a calculator"
7580        );
7581    }
7582
7583    #[test]
7584    fn normalize_easy_command_maps_go_count_only_to_demo_task() {
7585        assert_eq!(
7586            normalize_easy_command("/go 4"),
7587            format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
7588        );
7589    }
7590
7591    #[test]
7592    fn slash_hint_handles_command_with_args() {
7593        let hint = match_slash_command_hint("/go 4");
7594        assert!(hint.contains("/go"));
7595    }
7596
7597    #[test]
7598    fn parse_autochat_args_supports_default_count() {
7599        assert_eq!(
7600            parse_autochat_args("build a calculator"),
7601            Some((3, "build a calculator"))
7602        );
7603    }
7604
7605    #[test]
7606    fn parse_autochat_args_supports_explicit_count() {
7607        assert_eq!(
7608            parse_autochat_args("4 build a calculator"),
7609            Some((4, "build a calculator"))
7610        );
7611    }
7612
7613    #[test]
7614    fn parse_autochat_args_count_only_uses_quick_demo_task() {
7615        assert_eq!(
7616            parse_autochat_args("4"),
7617            Some((4, AUTOCHAT_QUICK_DEMO_TASK))
7618        );
7619    }
7620
7621    #[test]
7622    fn normalize_for_convergence_ignores_case_and_punctuation() {
7623        let a = normalize_for_convergence("Done! Next Step: Add tests.");
7624        let b = normalize_for_convergence("done next step add tests");
7625        assert_eq!(a, b);
7626    }
7627
7628    #[test]
7629    fn agent_avatar_is_stable_and_ascii() {
7630        let avatar = agent_avatar("planner");
7631        assert_eq!(avatar, agent_avatar("planner"));
7632        assert!(avatar.is_ascii());
7633        assert!(avatar.starts_with('[') && avatar.ends_with(']'));
7634    }
7635
7636    #[test]
7637    fn relay_handoff_line_shows_avatar_labels() {
7638        let line = format_relay_handoff_line("relay-1", 2, "planner", "coder");
7639        assert!(line.contains("relay relay-1"));
7640        assert!(line.contains("@planner"));
7641        assert!(line.contains("@coder"));
7642        assert!(line.contains('['));
7643    }
7644
7645    #[test]
7646    fn relay_handoff_line_formats_user_sender() {
7647        let line = format_relay_handoff_line("relay-2", 1, "user", "planner");
7648        assert!(line.contains("[you]"));
7649        assert!(line.contains("@planner"));
7650    }
7651
7652    #[test]
7653    fn planner_profile_has_expected_personality() {
7654        let profile = agent_profile("auto-planner");
7655        assert_eq!(profile.codename, "Strategist");
7656        assert!(profile.profile.contains("decomposition"));
7657    }
7658
7659    #[test]
7660    fn formatted_identity_includes_codename() {
7661        let identity = format_agent_identity("auto-coder");
7662        assert!(identity.contains("@auto-coder"));
7663        assert!(identity.contains("‹Forge›"));
7664    }
7665
7666    #[test]
7667    fn normalize_minio_endpoint_strips_login_path() {
7668        assert_eq!(
7669            normalize_minio_endpoint("http://192.168.50.223:9001/login"),
7670            "http://192.168.50.223:9001"
7671        );
7672    }
7673
7674    #[test]
7675    fn normalize_minio_endpoint_adds_default_scheme() {
7676        assert_eq!(
7677            normalize_minio_endpoint("192.168.50.223:9000"),
7678            "http://192.168.50.223:9000"
7679        );
7680    }
7681
7682    #[test]
7683    fn fallback_endpoint_maps_console_port_to_s3_port() {
7684        assert_eq!(
7685            minio_fallback_endpoint("http://192.168.50.223:9001"),
7686            Some("http://192.168.50.223:9000".to_string())
7687        );
7688        assert_eq!(minio_fallback_endpoint("http://192.168.50.223:9000"), None);
7689    }
7690
7691    #[test]
7692    fn secure_environment_detection_respects_explicit_flags() {
7693        assert!(is_secure_environment_from_values(Some(true), None, None));
7694        assert!(!is_secure_environment_from_values(
7695            Some(false),
7696            Some(true),
7697            Some("secure")
7698        ));
7699    }
7700
7701    #[test]
7702    fn secure_environment_detection_uses_environment_name_fallback() {
7703        assert!(is_secure_environment_from_values(None, None, Some("PROD")));
7704        assert!(is_secure_environment_from_values(
7705            None,
7706            None,
7707            Some("production")
7708        ));
7709        assert!(!is_secure_environment_from_values(None, None, Some("dev")));
7710    }
7711
7712    #[test]
7713    fn minimax_m25_pricing_estimate_matches_announcement_rates() {
7714        let cost = estimate_cost("minimax/MiniMax-M2.5", 1_000_000, 1_000_000)
7715            .expect("MiniMax M2.5 cost should be available");
7716        assert!((cost - 1.35).abs() < 1e-9);
7717    }
7718
7719    #[test]
7720    fn minimax_m25_lightning_pricing_is_case_insensitive() {
7721        let cost = estimate_cost("MiniMax-M2.5-Lightning", 1_000_000, 1_000_000)
7722            .expect("MiniMax M2.5 Lightning cost should be available");
7723        assert!((cost - 2.7).abs() < 1e-9);
7724    }
7725
7726    #[test]
7727    fn easy_go_command_detects_go_and_team_aliases() {
7728        assert!(is_easy_go_command("/go build indexing"));
7729        assert!(is_easy_go_command("/team 4 implement auth"));
7730        assert!(!is_easy_go_command("/autochat build indexing"));
7731    }
7732
7733    #[test]
7734    fn next_go_model_toggles_between_glm_and_minimax() {
7735        assert_eq!(next_go_model(Some("zai/glm-5")), "minimax/MiniMax-M2.5");
7736        assert_eq!(next_go_model(Some("z-ai/glm-5")), "minimax/MiniMax-M2.5");
7737        assert_eq!(next_go_model(Some("minimax/MiniMax-M2.5")), "zai/glm-5");
7738        assert_eq!(next_go_model(Some("unknown/model")), "minimax/MiniMax-M2.5");
7739    }
7740}