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