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