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