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