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) = OkrRepository::from_config().await {
3251                        self.okr_repository = Some(std::sync::Arc::new(repo));
3252                    }
3253                }
3254
3255                // Create pending OKR approval gate
3256                let pending = PendingOkrApproval::new(task.to_string(), count, next_model.clone());
3257
3258                self.messages
3259                    .push(ChatMessage::new("system", pending.approval_prompt()));
3260                self.scroll = SCROLL_BOTTOM;
3261
3262                // Store pending approval and wait for user input
3263                self.pending_okr_approval = Some(pending);
3264                return;
3265            }
3266
3267            self.start_autochat_execution(count, task.to_string(), config, None, None)
3268                .await;
3269            return;
3270        }
3271
3272        // Check for /swarm command
3273        if let Some(task) = command_with_optional_args(&message, "/swarm") {
3274            if task.is_empty() {
3275                self.messages.push(ChatMessage::new(
3276                    "system",
3277                    "Usage: /swarm <task description>",
3278                ));
3279                return;
3280            }
3281            self.start_swarm_execution(task.to_string(), config).await;
3282            return;
3283        }
3284
3285        // Check for /ralph command
3286        if message.trim().starts_with("/ralph") {
3287            let prd_path = message
3288                .trim()
3289                .strip_prefix("/ralph")
3290                .map(|s| s.trim())
3291                .filter(|s| !s.is_empty())
3292                .unwrap_or("prd.json")
3293                .to_string();
3294            self.start_ralph_execution(prd_path, config).await;
3295            return;
3296        }
3297
3298        if message.trim() == "/webview" {
3299            self.chat_layout = ChatLayoutMode::Webview;
3300            self.messages.push(ChatMessage::new(
3301                "system",
3302                "Switched to webview layout. Use /classic to return to single-pane chat.",
3303            ));
3304            return;
3305        }
3306
3307        if message.trim() == "/classic" {
3308            self.chat_layout = ChatLayoutMode::Classic;
3309            self.messages.push(ChatMessage::new(
3310                "system",
3311                "Switched to classic layout. Use /webview for dashboard-style panes.",
3312            ));
3313            return;
3314        }
3315
3316        if message.trim() == "/inspector" {
3317            self.show_inspector = !self.show_inspector;
3318            let state = if self.show_inspector {
3319                "enabled"
3320            } else {
3321                "disabled"
3322            };
3323            self.messages.push(ChatMessage::new(
3324                "system",
3325                format!("Inspector pane {}. Press F3 to toggle quickly.", state),
3326            ));
3327            return;
3328        }
3329
3330        if message.trim() == "/refresh" {
3331            self.refresh_workspace();
3332            let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
3333                .ok()
3334                .and_then(|v| v.parse().ok())
3335                .unwrap_or(100);
3336            // Reset offset on refresh
3337            self.session_picker_offset = 0;
3338            match list_sessions_with_opencode_paged(&self.workspace_dir, limit, 0).await {
3339                Ok(sessions) => self.update_cached_sessions(sessions),
3340                Err(err) => self.messages.push(ChatMessage::new(
3341                    "system",
3342                    format!(
3343                        "Workspace refreshed, but failed to refresh sessions: {}",
3344                        err
3345                    ),
3346                )),
3347            }
3348            self.messages.push(ChatMessage::new(
3349                "system",
3350                "Workspace and session cache refreshed.",
3351            ));
3352            return;
3353        }
3354
3355        if message.trim() == "/archive" {
3356            let details = if let Some(path) = &self.chat_archive_path {
3357                format!(
3358                    "Chat archive: {}\nCaptured records in this run: {}\n{}",
3359                    path.display(),
3360                    self.archived_message_count,
3361                    self.chat_sync_summary(),
3362                )
3363            } else {
3364                format!(
3365                    "Chat archive path unavailable in this environment.\n{}",
3366                    self.chat_sync_summary()
3367                )
3368            };
3369            self.messages.push(ChatMessage::new("system", details));
3370            self.scroll = SCROLL_BOTTOM;
3371            return;
3372        }
3373
3374        // Check for /view command to toggle views
3375        if message.trim() == "/view" {
3376            self.view_mode = match self.view_mode {
3377                ViewMode::Chat
3378                | ViewMode::SessionPicker
3379                | ViewMode::ModelPicker
3380                | ViewMode::AgentPicker
3381                | ViewMode::BusLog
3382                | ViewMode::Protocol => ViewMode::Swarm,
3383                ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
3384            };
3385            return;
3386        }
3387
3388        // Check for /buslog command to open protocol bus log
3389        if message.trim() == "/buslog" || message.trim() == "/bus" {
3390            self.view_mode = ViewMode::BusLog;
3391            return;
3392        }
3393
3394        // Check for /protocol command to inspect registered AgentCards
3395        if message.trim() == "/protocol" || message.trim() == "/registry" {
3396            self.open_protocol_view();
3397            return;
3398        }
3399
3400        // Check for /spawn command - create a named sub-agent
3401        if let Some(rest) = command_with_optional_args(&message, "/spawn") {
3402            let default_instructions = |agent_name: &str| {
3403                let profile = agent_profile(agent_name);
3404                format!(
3405                    "You are @{agent_name}, codename {codename}.\n\
3406                     Profile: {profile_line}.\n\
3407                     Personality: {personality}.\n\
3408                     Collaboration style: {style}.\n\
3409                     Signature move: {signature}.\n\
3410                     Be a helpful teammate: explain in simple words, short steps, and a friendly tone.",
3411                    codename = profile.codename,
3412                    profile_line = profile.profile,
3413                    personality = profile.personality,
3414                    style = profile.collaboration_style,
3415                    signature = profile.signature_move,
3416                )
3417            };
3418
3419            let (name, instructions, used_default_instructions) = if rest.is_empty() {
3420                self.messages.push(ChatMessage::new(
3421                    "system",
3422                    "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>\nExample: /spawn planner You are a planning agent. Break tasks into steps.",
3423                ));
3424                return;
3425            } else {
3426                let mut parts = rest.splitn(2, char::is_whitespace);
3427                let name = parts.next().unwrap_or("").trim();
3428                if name.is_empty() {
3429                    self.messages.push(ChatMessage::new(
3430                        "system",
3431                        "Usage: /spawn <name> [instructions]\nEasy mode: /add <name>",
3432                    ));
3433                    return;
3434                }
3435
3436                let instructions = parts.next().map(str::trim).filter(|s| !s.is_empty());
3437                match instructions {
3438                    Some(custom) => (name.to_string(), custom.to_string(), false),
3439                    None => (name.to_string(), default_instructions(name), true),
3440                }
3441            };
3442
3443            if self.spawned_agents.contains_key(&name) {
3444                self.messages.push(ChatMessage::new(
3445                    "system",
3446                    format!("Agent @{name} already exists. Use /kill {name} first."),
3447                ));
3448                return;
3449            }
3450
3451            match Session::new().await {
3452                Ok(mut session) => {
3453                    // Use the same model as the main chat
3454                    session.metadata.model = self
3455                        .active_model
3456                        .clone()
3457                        .or_else(|| config.default_model.clone());
3458                    session.agent = name.clone();
3459
3460                    // Add system message with the agent's instructions
3461                    session.add_message(crate::provider::Message {
3462                        role: Role::System,
3463                        content: vec![ContentPart::Text {
3464                            text: format!(
3465                                "You are @{name}, a specialized sub-agent. {instructions}\n\n\
3466                                 When you receive a message from another agent (prefixed with their name), \
3467                                 respond helpfully. Keep responses concise and focused on your specialty."
3468                            ),
3469                        }],
3470                    });
3471
3472                    // Announce on bus
3473                    let mut protocol_registered = false;
3474                    if let Some(ref bus) = self.bus {
3475                        let handle = bus.handle(&name);
3476                        handle.announce_ready(vec!["sub-agent".to_string(), name.clone()]);
3477                        protocol_registered = bus.registry.get(&name).is_some();
3478                    }
3479
3480                    let agent = SpawnedAgent {
3481                        name: name.clone(),
3482                        instructions: instructions.clone(),
3483                        session,
3484                        is_processing: false,
3485                    };
3486                    self.spawned_agents.insert(name.clone(), agent);
3487                    self.active_spawned_agent = Some(name.clone());
3488
3489                    let protocol_line = if protocol_registered {
3490                        format!("Protocol registration: ✅ bus://local/{name}")
3491                    } else {
3492                        "Protocol registration: ⚠ unavailable (bus not connected)".to_string()
3493                    };
3494                    let profile_summary = format_agent_profile_summary(&name);
3495
3496                    self.messages.push(ChatMessage::new(
3497                        "system",
3498                        format!(
3499                            "Spawned agent {}\nProfile: {}\nInstructions: {instructions}\nFocused chat on @{name}. Type directly, or use @{name} <message>.\n{protocol_line}{}",
3500                            format_agent_identity(&name),
3501                            profile_summary,
3502                            if used_default_instructions {
3503                                "\nTip: I used friendly default instructions. You can customize with /add <name> <instructions>."
3504                            } else {
3505                                ""
3506                            }
3507                        ),
3508                    ));
3509                }
3510                Err(e) => {
3511                    self.messages.push(ChatMessage::new(
3512                        "system",
3513                        format!("Failed to spawn agent: {e}"),
3514                    ));
3515                }
3516            }
3517            return;
3518        }
3519
3520        // Check for /agents command - list spawned agents
3521        if message.trim() == "/agents" {
3522            if self.spawned_agents.is_empty() {
3523                self.messages.push(ChatMessage::new(
3524                    "system",
3525                    "No agents spawned. Use /spawn <name> <instructions> to create one.",
3526                ));
3527            } else {
3528                let mut lines = vec![format!(
3529                    "Active agents: {} (protocol registered: {})",
3530                    self.spawned_agents.len(),
3531                    self.protocol_registered_count()
3532                )];
3533
3534                let mut agents = self.spawned_agents.iter().collect::<Vec<_>>();
3535                agents.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
3536
3537                for (name, agent) in agents {
3538                    let status = if agent.is_processing {
3539                        "⚡ working"
3540                    } else {
3541                        "● idle"
3542                    };
3543                    let protocol_status = if self.is_agent_protocol_registered(name) {
3544                        "🔗 protocol"
3545                    } else {
3546                        "⚠ protocol-pending"
3547                    };
3548                    let focused = if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
3549                        " [focused]"
3550                    } else {
3551                        ""
3552                    };
3553                    let profile_summary = format_agent_profile_summary(name);
3554                    lines.push(format!(
3555                        "  {} @{name} [{status}] {protocol_status}{focused} — {} | {}",
3556                        agent_avatar(name),
3557                        profile_summary,
3558                        agent.instructions
3559                    ));
3560                }
3561                self.messages
3562                    .push(ChatMessage::new("system", lines.join("\n")));
3563                self.messages.push(ChatMessage::new(
3564                    "system",
3565                    "Tip: use /agent to open the picker, /agent <name> to focus, or Ctrl+A.",
3566                ));
3567            }
3568            return;
3569        }
3570
3571        // Check for /kill command - remove a spawned agent
3572        if let Some(name) = command_with_optional_args(&message, "/kill") {
3573            if name.is_empty() {
3574                self.messages
3575                    .push(ChatMessage::new("system", "Usage: /kill <name>"));
3576                return;
3577            }
3578
3579            let name = name.to_string();
3580            if self.spawned_agents.remove(&name).is_some() {
3581                // Remove its response channels
3582                self.agent_response_rxs.retain(|(n, _)| n != &name);
3583                self.streaming_agent_texts.remove(&name);
3584                if self.active_spawned_agent.as_deref() == Some(name.as_str()) {
3585                    self.active_spawned_agent = None;
3586                }
3587                // Announce shutdown on bus
3588                if let Some(ref bus) = self.bus {
3589                    let handle = bus.handle(&name);
3590                    handle.announce_shutdown();
3591                }
3592                self.messages.push(ChatMessage::new(
3593                    "system",
3594                    format!("Agent @{name} removed."),
3595                ));
3596            } else {
3597                self.messages.push(ChatMessage::new(
3598                    "system",
3599                    format!("No agent named @{name}. Use /agents to list."),
3600                ));
3601            }
3602            return;
3603        }
3604
3605        // Check for @mention - route message to a specific spawned agent
3606        if message.trim().starts_with('@') {
3607            let trimmed = message.trim();
3608            let (target, content) = match trimmed.split_once(' ') {
3609                Some((mention, rest)) => (
3610                    mention.strip_prefix('@').unwrap_or(mention).to_string(),
3611                    rest.to_string(),
3612                ),
3613                None => {
3614                    self.messages.push(ChatMessage::new(
3615                        "system",
3616                        format!(
3617                            "Usage: @agent_name your message\nAvailable: {}",
3618                            if self.spawned_agents.is_empty() {
3619                                "none (use /spawn first)".to_string()
3620                            } else {
3621                                self.spawned_agents
3622                                    .keys()
3623                                    .map(|n| format!("@{n}"))
3624                                    .collect::<Vec<_>>()
3625                                    .join(", ")
3626                            }
3627                        ),
3628                    ));
3629                    return;
3630                }
3631            };
3632
3633            if !self.spawned_agents.contains_key(&target) {
3634                self.messages.push(ChatMessage::new(
3635                    "system",
3636                    format!(
3637                        "No agent named @{target}. Available: {}",
3638                        if self.spawned_agents.is_empty() {
3639                            "none (use /spawn first)".to_string()
3640                        } else {
3641                            self.spawned_agents
3642                                .keys()
3643                                .map(|n| format!("@{n}"))
3644                                .collect::<Vec<_>>()
3645                                .join(", ")
3646                        }
3647                    ),
3648                ));
3649                return;
3650            }
3651
3652            // Show the user's @mention message in chat
3653            self.messages
3654                .push(ChatMessage::new("user", format!("@{target} {content}")));
3655            self.scroll = SCROLL_BOTTOM;
3656
3657            // Send the message over the bus
3658            if let Some(ref bus) = self.bus {
3659                let handle = bus.handle("user");
3660                handle.send_to_agent(
3661                    &target,
3662                    vec![crate::a2a::types::Part::Text {
3663                        text: content.clone(),
3664                    }],
3665                );
3666            }
3667
3668            // Send the message to the target agent's session
3669            self.send_to_agent(&target, &content, config).await;
3670            return;
3671        }
3672
3673        // If a spawned agent is focused, route plain messages there automatically.
3674        if !message.trim().starts_with('/')
3675            && let Some(target) = self.active_spawned_agent.clone()
3676        {
3677            if !self.spawned_agents.contains_key(&target) {
3678                self.active_spawned_agent = None;
3679                self.messages.push(ChatMessage::new(
3680                    "system",
3681                    format!(
3682                        "Focused agent @{target} is no longer available. Use /agents or /spawn to continue."
3683                    ),
3684                ));
3685                return;
3686            }
3687
3688            let content = message.trim().to_string();
3689            if content.is_empty() {
3690                return;
3691            }
3692
3693            self.messages
3694                .push(ChatMessage::new("user", format!("@{target} {content}")));
3695            self.scroll = SCROLL_BOTTOM;
3696
3697            if let Some(ref bus) = self.bus {
3698                let handle = bus.handle("user");
3699                handle.send_to_agent(
3700                    &target,
3701                    vec![crate::a2a::types::Part::Text {
3702                        text: content.clone(),
3703                    }],
3704                );
3705            }
3706
3707            self.send_to_agent(&target, &content, config).await;
3708            return;
3709        }
3710
3711        // Check for /sessions command - open session picker
3712        if message.trim() == "/sessions" {
3713            let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
3714                .ok()
3715                .and_then(|v| v.parse().ok())
3716                .unwrap_or(100);
3717            // Reset offset when opening session picker
3718            self.session_picker_offset = 0;
3719            match list_sessions_with_opencode_paged(&self.workspace_dir, limit, 0).await {
3720                Ok(sessions) => {
3721                    if sessions.is_empty() {
3722                        self.messages
3723                            .push(ChatMessage::new("system", "No saved sessions found."));
3724                    } else {
3725                        self.update_cached_sessions(sessions);
3726                        self.session_picker_selected = 0;
3727                        self.view_mode = ViewMode::SessionPicker;
3728                    }
3729                }
3730                Err(e) => {
3731                    self.messages.push(ChatMessage::new(
3732                        "system",
3733                        format!("Failed to list sessions: {}", e),
3734                    ));
3735                }
3736            }
3737            return;
3738        }
3739
3740        // Check for /resume command to load a session or resume an interrupted relay
3741        if message.trim() == "/resume" || message.trim().starts_with("/resume ") {
3742            let session_id = message
3743                .trim()
3744                .strip_prefix("/resume")
3745                .map(|s| s.trim())
3746                .filter(|s| !s.is_empty());
3747
3748            // If no specific session ID, check for an interrupted relay checkpoint first
3749            if session_id.is_none() {
3750                if let Some(checkpoint) = RelayCheckpoint::load().await {
3751                    self.messages.push(ChatMessage::new("user", "/resume"));
3752                    self.resume_autochat_relay(checkpoint).await;
3753                    return;
3754                }
3755            }
3756
3757            let loaded = if let Some(id) = session_id {
3758                if let Some(oc_id) = id.strip_prefix("opencode_") {
3759                    if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
3760                        Session::from_opencode(oc_id, &storage).await
3761                    } else {
3762                        Err(anyhow::anyhow!("OpenCode storage not available"))
3763                    }
3764                } else {
3765                    Session::load(id).await
3766                }
3767            } else {
3768                match Session::last_for_directory(Some(&self.workspace_dir)).await {
3769                    Ok(s) => Ok(s),
3770                    Err(_) => Session::last_opencode_for_directory(&self.workspace_dir).await,
3771                }
3772            };
3773
3774            match loaded {
3775                Ok(session) => {
3776                    // Convert session messages to chat messages
3777                    self.messages.clear();
3778                    self.messages.push(ChatMessage::new(
3779                        "system",
3780                        format!(
3781                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
3782                            session.title.as_deref().unwrap_or("(untitled)"),
3783                            session.created_at.format("%Y-%m-%d %H:%M"),
3784                            session.messages.len()
3785                        ),
3786                    ));
3787
3788                    for msg in &session.messages {
3789                        let role_str = match msg.role {
3790                            Role::System => "system",
3791                            Role::User => "user",
3792                            Role::Assistant => "assistant",
3793                            Role::Tool => "tool",
3794                        };
3795
3796                        // Process each content part separately
3797                        for part in &msg.content {
3798                            match part {
3799                                ContentPart::Text { text } => {
3800                                    if !text.is_empty() {
3801                                        self.messages
3802                                            .push(ChatMessage::new(role_str, text.clone()));
3803                                    }
3804                                }
3805                                ContentPart::Image { url, mime_type } => {
3806                                    self.messages.push(
3807                                        ChatMessage::new(role_str, "").with_message_type(
3808                                            MessageType::Image {
3809                                                url: url.clone(),
3810                                                mime_type: mime_type.clone(),
3811                                            },
3812                                        ),
3813                                    );
3814                                }
3815                                ContentPart::ToolCall {
3816                                    name, arguments, ..
3817                                } => {
3818                                    let (preview, truncated) = build_tool_arguments_preview(
3819                                        name,
3820                                        arguments,
3821                                        TOOL_ARGS_PREVIEW_MAX_LINES,
3822                                        TOOL_ARGS_PREVIEW_MAX_BYTES,
3823                                    );
3824                                    self.messages.push(
3825                                        ChatMessage::new(role_str, format!("🔧 {name}"))
3826                                            .with_message_type(MessageType::ToolCall {
3827                                                name: name.clone(),
3828                                                arguments_preview: preview,
3829                                                arguments_len: arguments.len(),
3830                                                truncated,
3831                                            }),
3832                                    );
3833                                }
3834                                ContentPart::ToolResult { content, .. } => {
3835                                    let truncated = truncate_with_ellipsis(content, 500);
3836                                    let (preview, preview_truncated) = build_text_preview(
3837                                        content,
3838                                        TOOL_OUTPUT_PREVIEW_MAX_LINES,
3839                                        TOOL_OUTPUT_PREVIEW_MAX_BYTES,
3840                                    );
3841                                    self.messages.push(
3842                                        ChatMessage::new(
3843                                            role_str,
3844                                            format!("✅ Result\n{truncated}"),
3845                                        )
3846                                        .with_message_type(MessageType::ToolResult {
3847                                            name: "tool".to_string(),
3848                                            output_preview: preview,
3849                                            output_len: content.len(),
3850                                            truncated: preview_truncated,
3851                                            success: true,
3852                                            duration_ms: None,
3853                                        }),
3854                                    );
3855                                }
3856                                ContentPart::File { path, mime_type } => {
3857                                    self.messages.push(
3858                                        ChatMessage::new(role_str, format!("📎 {}", path))
3859                                            .with_message_type(MessageType::File {
3860                                                path: path.clone(),
3861                                                mime_type: mime_type.clone(),
3862                                            }),
3863                                    );
3864                                }
3865                                ContentPart::Thinking { text } => {
3866                                    if !text.is_empty() {
3867                                        self.messages.push(
3868                                            ChatMessage::new(role_str, text.clone())
3869                                                .with_message_type(MessageType::Thinking(
3870                                                    text.clone(),
3871                                                )),
3872                                        );
3873                                    }
3874                                }
3875                            }
3876                        }
3877                    }
3878
3879                    self.current_agent = session.agent.clone();
3880                    self.session = Some(session);
3881                    self.scroll = SCROLL_BOTTOM;
3882                }
3883                Err(e) => {
3884                    self.messages.push(ChatMessage::new(
3885                        "system",
3886                        format!("Failed to load session: {}", e),
3887                    ));
3888                }
3889            }
3890            return;
3891        }
3892
3893        // Check for /model command - open model picker
3894        if message.trim() == "/model" || message.trim().starts_with("/model ") {
3895            let direct_model = message
3896                .trim()
3897                .strip_prefix("/model")
3898                .map(|s| s.trim())
3899                .filter(|s| !s.is_empty());
3900
3901            if let Some(model_str) = direct_model {
3902                // Direct set: /model provider/model-name
3903                self.active_model = Some(model_str.to_string());
3904                if let Some(session) = self.session.as_mut() {
3905                    session.metadata.model = Some(model_str.to_string());
3906                }
3907                self.messages.push(ChatMessage::new(
3908                    "system",
3909                    format!("Model set to: {}", model_str),
3910                ));
3911            } else {
3912                // Open model picker
3913                self.open_model_picker(config).await;
3914            }
3915            return;
3916        }
3917
3918        // Check for /undo command - remove last user turn and response
3919        if message.trim() == "/undo" {
3920            // Remove from TUI messages: walk backwards and remove everything
3921            // until we've removed the last "user" message (inclusive)
3922            let mut found_user = false;
3923            while let Some(msg) = self.messages.last() {
3924                if msg.role == "user" {
3925                    if found_user {
3926                        break; // hit the previous user turn, stop
3927                    }
3928                    found_user = true;
3929                }
3930                // Skip system messages that aren't part of the turn
3931                if msg.role == "system" && !found_user {
3932                    break;
3933                }
3934                self.messages.pop();
3935            }
3936
3937            if !found_user {
3938                self.messages
3939                    .push(ChatMessage::new("system", "Nothing to undo."));
3940                return;
3941            }
3942
3943            // Remove from session: walk backwards removing the last user message
3944            // and all assistant/tool messages after it
3945            if let Some(session) = self.session.as_mut() {
3946                let mut found_session_user = false;
3947                while let Some(msg) = session.messages.last() {
3948                    if msg.role == crate::provider::Role::User {
3949                        if found_session_user {
3950                            break;
3951                        }
3952                        found_session_user = true;
3953                    }
3954                    if msg.role == crate::provider::Role::System && !found_session_user {
3955                        break;
3956                    }
3957                    session.messages.pop();
3958                }
3959                if let Err(e) = session.save().await {
3960                    tracing::warn!(error = %e, "Failed to save session after undo");
3961                }
3962            }
3963
3964            self.messages.push(ChatMessage::new(
3965                "system",
3966                "Undid last message and response.",
3967            ));
3968            self.scroll = SCROLL_BOTTOM;
3969            return;
3970        }
3971
3972        // Check for /new command to start a fresh session
3973        if message.trim() == "/new" {
3974            self.session = None;
3975            self.messages.clear();
3976            self.messages.push(ChatMessage::new(
3977                "system",
3978                "Started a new session. Previous session was saved.",
3979            ));
3980            return;
3981        }
3982
3983        // Add user message
3984        self.messages
3985            .push(ChatMessage::new("user", message.clone()));
3986
3987        // Auto-scroll to bottom when user sends a message
3988        self.scroll = SCROLL_BOTTOM;
3989
3990        let current_agent = self.current_agent.clone();
3991        let model = self
3992            .active_model
3993            .clone()
3994            .or_else(|| {
3995                config
3996                    .agents
3997                    .get(&current_agent)
3998                    .and_then(|agent| agent.model.clone())
3999            })
4000            .or_else(|| config.default_model.clone())
4001            .or_else(|| Some("zai/glm-5".to_string()));
4002
4003        // Initialize session if needed
4004        if self.session.is_none() {
4005            match Session::new().await {
4006                Ok(session) => {
4007                    self.session = Some(session);
4008                }
4009                Err(err) => {
4010                    tracing::error!(error = %err, "Failed to create session");
4011                    self.messages
4012                        .push(ChatMessage::new("assistant", format!("Error: {err}")));
4013                    return;
4014                }
4015            }
4016        }
4017
4018        let session = match self.session.as_mut() {
4019            Some(session) => session,
4020            None => {
4021                self.messages.push(ChatMessage::new(
4022                    "assistant",
4023                    "Error: session not initialized",
4024                ));
4025                return;
4026            }
4027        };
4028
4029        if let Some(model) = model {
4030            session.metadata.model = Some(model);
4031        }
4032
4033        session.agent = current_agent;
4034
4035        // Set processing state
4036        self.is_processing = true;
4037        self.processing_message = Some("Thinking...".to_string());
4038        self.current_tool = None;
4039        self.current_tool_started_at = None;
4040        self.processing_started_at = Some(Instant::now());
4041        self.streaming_text = None;
4042
4043        // Load provider registry once and cache it
4044        if self.provider_registry.is_none() {
4045            match crate::provider::ProviderRegistry::from_vault().await {
4046                Ok(registry) => {
4047                    self.provider_registry = Some(std::sync::Arc::new(registry));
4048                }
4049                Err(err) => {
4050                    tracing::error!(error = %err, "Failed to load provider registry");
4051                    self.messages.push(ChatMessage::new(
4052                        "assistant",
4053                        format!("Error loading providers: {err}"),
4054                    ));
4055                    self.is_processing = false;
4056                    return;
4057                }
4058            }
4059        }
4060        let registry = self.provider_registry.clone().unwrap();
4061
4062        // Create channel for async communication
4063        let (tx, rx) = mpsc::channel(100);
4064        self.response_rx = Some(rx);
4065
4066        // Clone session for async processing
4067        let session_clone = session.clone();
4068        let message_clone = message.clone();
4069
4070        // Spawn async task to process the message with event streaming
4071        tokio::spawn(async move {
4072            let mut session = session_clone;
4073            if let Err(err) = session
4074                .prompt_with_events(&message_clone, tx.clone(), registry)
4075                .await
4076            {
4077                tracing::error!(error = %err, "Agent processing failed");
4078                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
4079                let _ = tx.send(SessionEvent::Done).await;
4080            }
4081        });
4082    }
4083
4084    fn handle_response(&mut self, event: SessionEvent) {
4085        // Auto-scroll to bottom when new content arrives
4086        self.scroll = SCROLL_BOTTOM;
4087
4088        match event {
4089            SessionEvent::Thinking => {
4090                self.processing_message = Some("Thinking...".to_string());
4091                self.current_tool = None;
4092                self.current_tool_started_at = None;
4093                if self.processing_started_at.is_none() {
4094                    self.processing_started_at = Some(Instant::now());
4095                }
4096            }
4097            SessionEvent::ToolCallStart { name, arguments } => {
4098                // Flush any streaming text before showing tool call
4099                if let Some(text) = self.streaming_text.take() {
4100                    if !text.is_empty() {
4101                        self.messages.push(ChatMessage::new("assistant", text));
4102                    }
4103                }
4104                self.processing_message = Some(format!("Running {}...", name));
4105                self.current_tool = Some(name.clone());
4106                self.current_tool_started_at = Some(Instant::now());
4107                self.tool_call_count += 1;
4108
4109                let (preview, truncated) = build_tool_arguments_preview(
4110                    &name,
4111                    &arguments,
4112                    TOOL_ARGS_PREVIEW_MAX_LINES,
4113                    TOOL_ARGS_PREVIEW_MAX_BYTES,
4114                );
4115                self.messages.push(
4116                    ChatMessage::new("tool", format!("🔧 {}", name)).with_message_type(
4117                        MessageType::ToolCall {
4118                            name,
4119                            arguments_preview: preview,
4120                            arguments_len: arguments.len(),
4121                            truncated,
4122                        },
4123                    ),
4124                );
4125            }
4126            SessionEvent::ToolCallComplete {
4127                name,
4128                output,
4129                success,
4130            } => {
4131                let icon = if success { "✓" } else { "✗" };
4132                let duration_ms = self
4133                    .current_tool_started_at
4134                    .take()
4135                    .map(|started| started.elapsed().as_millis() as u64);
4136
4137                let (preview, truncated) = build_text_preview(
4138                    &output,
4139                    TOOL_OUTPUT_PREVIEW_MAX_LINES,
4140                    TOOL_OUTPUT_PREVIEW_MAX_BYTES,
4141                );
4142                self.messages.push(
4143                    ChatMessage::new("tool", format!("{} {}", icon, name)).with_message_type(
4144                        MessageType::ToolResult {
4145                            name,
4146                            output_preview: preview,
4147                            output_len: output.len(),
4148                            truncated,
4149                            success,
4150                            duration_ms,
4151                        },
4152                    ),
4153                );
4154                self.current_tool = None;
4155                self.processing_message = Some("Thinking...".to_string());
4156            }
4157            SessionEvent::TextChunk(text) => {
4158                // Show streaming text as it arrives (before TextComplete finalizes)
4159                self.streaming_text = Some(text);
4160            }
4161            SessionEvent::ThinkingComplete(text) => {
4162                if !text.is_empty() {
4163                    self.messages.push(
4164                        ChatMessage::new("assistant", &text)
4165                            .with_message_type(MessageType::Thinking(text)),
4166                    );
4167                }
4168            }
4169            SessionEvent::TextComplete(text) => {
4170                // Clear streaming preview and add the final message
4171                self.streaming_text = None;
4172                if !text.is_empty() {
4173                    self.messages.push(ChatMessage::new("assistant", text));
4174                }
4175            }
4176            SessionEvent::UsageReport {
4177                prompt_tokens,
4178                completion_tokens,
4179                duration_ms,
4180                model,
4181            } => {
4182                let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
4183                let meta = UsageMeta {
4184                    prompt_tokens,
4185                    completion_tokens,
4186                    duration_ms,
4187                    cost_usd,
4188                };
4189                // Attach to the most recent assistant message
4190                if let Some(msg) = self
4191                    .messages
4192                    .iter_mut()
4193                    .rev()
4194                    .find(|m| m.role == "assistant")
4195                {
4196                    msg.usage_meta = Some(meta);
4197                }
4198            }
4199            SessionEvent::SessionSync(session) => {
4200                // Sync the updated session (with full conversation history) back
4201                // so subsequent messages include prior context.
4202                self.session = Some(session);
4203            }
4204            SessionEvent::Error(err) => {
4205                self.current_tool_started_at = None;
4206                self.messages
4207                    .push(ChatMessage::new("assistant", format!("Error: {}", err)));
4208            }
4209            SessionEvent::Done => {
4210                self.is_processing = false;
4211                self.processing_message = None;
4212                self.current_tool = None;
4213                self.current_tool_started_at = None;
4214                self.processing_started_at = None;
4215                self.streaming_text = None;
4216                self.response_rx = None;
4217            }
4218        }
4219    }
4220
4221    /// Send a message to a specific spawned agent
4222    async fn send_to_agent(&mut self, agent_name: &str, message: &str, _config: &Config) {
4223        // Load provider registry if needed
4224        if self.provider_registry.is_none() {
4225            match crate::provider::ProviderRegistry::from_vault().await {
4226                Ok(registry) => {
4227                    self.provider_registry = Some(std::sync::Arc::new(registry));
4228                }
4229                Err(err) => {
4230                    self.messages.push(ChatMessage::new(
4231                        "system",
4232                        format!("Error loading providers: {err}"),
4233                    ));
4234                    return;
4235                }
4236            }
4237        }
4238        let registry = self.provider_registry.clone().unwrap();
4239
4240        let agent = match self.spawned_agents.get_mut(agent_name) {
4241            Some(a) => a,
4242            None => return,
4243        };
4244
4245        agent.is_processing = true;
4246        self.streaming_agent_texts.remove(agent_name);
4247        let session_clone = agent.session.clone();
4248        let msg_clone = message.to_string();
4249        let agent_name_owned = agent_name.to_string();
4250        let bus_arc = self.bus.clone();
4251
4252        let (tx, rx) = mpsc::channel(100);
4253        self.agent_response_rxs.push((agent_name.to_string(), rx));
4254
4255        tokio::spawn(async move {
4256            let mut session = session_clone;
4257            if let Err(err) = session
4258                .prompt_with_events(&msg_clone, tx.clone(), registry)
4259                .await
4260            {
4261                tracing::error!(agent = %agent_name_owned, error = %err, "Spawned agent failed");
4262                let _ = tx.send(SessionEvent::Error(format!("Error: {err}"))).await;
4263                let _ = tx.send(SessionEvent::Done).await;
4264            }
4265
4266            // Send the agent's response over the bus
4267            if let Some(ref bus) = bus_arc {
4268                let handle = bus.handle(&agent_name_owned);
4269                handle.send(
4270                    format!("agent.{agent_name_owned}.events"),
4271                    crate::bus::BusMessage::AgentMessage {
4272                        from: agent_name_owned.clone(),
4273                        to: "user".to_string(),
4274                        parts: vec![crate::a2a::types::Part::Text {
4275                            text: "(response complete)".to_string(),
4276                        }],
4277                    },
4278                );
4279            }
4280        });
4281    }
4282
4283    /// Handle an event from a spawned agent
4284    fn handle_agent_response(&mut self, agent_name: &str, event: SessionEvent) {
4285        self.scroll = SCROLL_BOTTOM;
4286
4287        match event {
4288            SessionEvent::Thinking => {
4289                // Show thinking indicator for this agent
4290                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
4291                    agent.is_processing = true;
4292                }
4293            }
4294            SessionEvent::ToolCallStart { name, arguments } => {
4295                self.streaming_agent_texts.remove(agent_name);
4296                self.agent_tool_started_at
4297                    .insert(agent_name.to_string(), Instant::now());
4298                let (preview, truncated) = build_tool_arguments_preview(
4299                    &name,
4300                    &arguments,
4301                    TOOL_ARGS_PREVIEW_MAX_LINES,
4302                    TOOL_ARGS_PREVIEW_MAX_BYTES,
4303                );
4304                self.messages.push(
4305                    ChatMessage::new(
4306                        "tool",
4307                        format!("🔧 {} → {name}", format_agent_identity(agent_name)),
4308                    )
4309                    .with_message_type(MessageType::ToolCall {
4310                        name,
4311                        arguments_preview: preview,
4312                        arguments_len: arguments.len(),
4313                        truncated,
4314                    })
4315                    .with_agent_name(agent_name),
4316                );
4317            }
4318            SessionEvent::ToolCallComplete {
4319                name,
4320                output,
4321                success,
4322            } => {
4323                self.streaming_agent_texts.remove(agent_name);
4324                let icon = if success { "✓" } else { "✗" };
4325                let duration_ms = self
4326                    .agent_tool_started_at
4327                    .remove(agent_name)
4328                    .map(|started| started.elapsed().as_millis() as u64);
4329                let (preview, truncated) = build_text_preview(
4330                    &output,
4331                    TOOL_OUTPUT_PREVIEW_MAX_LINES,
4332                    TOOL_OUTPUT_PREVIEW_MAX_BYTES,
4333                );
4334                self.messages.push(
4335                    ChatMessage::new(
4336                        "tool",
4337                        format!("{icon} {} → {name}", format_agent_identity(agent_name)),
4338                    )
4339                    .with_message_type(MessageType::ToolResult {
4340                        name,
4341                        output_preview: preview,
4342                        output_len: output.len(),
4343                        truncated,
4344                        success,
4345                        duration_ms,
4346                    })
4347                    .with_agent_name(agent_name),
4348                );
4349            }
4350            SessionEvent::TextChunk(text) => {
4351                if text.is_empty() {
4352                    self.streaming_agent_texts.remove(agent_name);
4353                } else {
4354                    self.streaming_agent_texts
4355                        .insert(agent_name.to_string(), text);
4356                }
4357            }
4358            SessionEvent::ThinkingComplete(text) => {
4359                self.streaming_agent_texts.remove(agent_name);
4360                if !text.is_empty() {
4361                    self.messages.push(
4362                        ChatMessage::new("assistant", &text)
4363                            .with_message_type(MessageType::Thinking(text))
4364                            .with_agent_name(agent_name),
4365                    );
4366                }
4367            }
4368            SessionEvent::TextComplete(text) => {
4369                self.streaming_agent_texts.remove(agent_name);
4370                if !text.is_empty() {
4371                    self.messages
4372                        .push(ChatMessage::new("assistant", &text).with_agent_name(agent_name));
4373                }
4374            }
4375            SessionEvent::UsageReport {
4376                prompt_tokens,
4377                completion_tokens,
4378                duration_ms,
4379                model,
4380            } => {
4381                let cost_usd = estimate_cost(&model, prompt_tokens, completion_tokens);
4382                let meta = UsageMeta {
4383                    prompt_tokens,
4384                    completion_tokens,
4385                    duration_ms,
4386                    cost_usd,
4387                };
4388                if let Some(msg) =
4389                    self.messages.iter_mut().rev().find(|m| {
4390                        m.role == "assistant" && m.agent_name.as_deref() == Some(agent_name)
4391                    })
4392                {
4393                    msg.usage_meta = Some(meta);
4394                }
4395            }
4396            SessionEvent::SessionSync(session) => {
4397                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
4398                    agent.session = session;
4399                }
4400            }
4401            SessionEvent::Error(err) => {
4402                self.streaming_agent_texts.remove(agent_name);
4403                self.agent_tool_started_at.remove(agent_name);
4404                self.messages.push(
4405                    ChatMessage::new("assistant", format!("Error: {err}"))
4406                        .with_agent_name(agent_name),
4407                );
4408            }
4409            SessionEvent::Done => {
4410                self.streaming_agent_texts.remove(agent_name);
4411                self.agent_tool_started_at.remove(agent_name);
4412                if let Some(agent) = self.spawned_agents.get_mut(agent_name) {
4413                    agent.is_processing = false;
4414                }
4415            }
4416        }
4417    }
4418
4419    fn handle_autochat_event(&mut self, event: AutochatUiEvent) -> bool {
4420        match event {
4421            AutochatUiEvent::Progress(status) => {
4422                self.autochat_status = Some(status);
4423                false
4424            }
4425            AutochatUiEvent::SystemMessage(text) => {
4426                self.autochat_status = Some(
4427                    text.lines()
4428                        .next()
4429                        .unwrap_or("Relay update")
4430                        .trim()
4431                        .to_string(),
4432                );
4433                self.messages.push(ChatMessage::new("system", text));
4434                self.scroll = SCROLL_BOTTOM;
4435                false
4436            }
4437            AutochatUiEvent::AgentEvent { agent_name, event } => {
4438                self.autochat_status = Some(format!("Streaming from @{agent_name}…"));
4439                self.handle_agent_response(&agent_name, event);
4440                false
4441            }
4442            AutochatUiEvent::Completed {
4443                summary,
4444                okr_id,
4445                okr_run_id,
4446                relay_id,
4447            } => {
4448                self.autochat_status = Some("Completed".to_string());
4449
4450                // Add OKR correlation info to the completion message if present
4451                let mut full_summary = summary.clone();
4452                if let (Some(okr_id), Some(okr_run_id)) = (&okr_id, &okr_run_id) {
4453                    full_summary.push_str(&format!(
4454                        "\n\n📊 OKR Tracking: okr_id={} run_id={}",
4455                        &okr_id[..8.min(okr_id.len())],
4456                        &okr_run_id[..8.min(okr_run_id.len())]
4457                    ));
4458                }
4459                if let Some(rid) = &relay_id {
4460                    full_summary.push_str(&format!("\n🔗 Relay: {}", rid));
4461                }
4462
4463                self.messages
4464                    .push(ChatMessage::new("assistant", full_summary));
4465                self.scroll = SCROLL_BOTTOM;
4466                true
4467            }
4468        }
4469    }
4470
4471    /// Handle a swarm event
4472    fn handle_swarm_event(&mut self, event: SwarmEvent) {
4473        self.swarm_state.handle_event(event.clone());
4474
4475        // When swarm completes, switch back to chat view with summary
4476        if let SwarmEvent::Complete { success, ref stats } = event {
4477            self.view_mode = ViewMode::Chat;
4478            let summary = if success {
4479                format!(
4480                    "Swarm completed successfully.\n\
4481                     Subtasks: {} completed, {} failed\n\
4482                     Total tool calls: {}\n\
4483                     Time: {:.1}s (speedup: {:.1}x)",
4484                    stats.subagents_completed,
4485                    stats.subagents_failed,
4486                    stats.total_tool_calls,
4487                    stats.execution_time_ms as f64 / 1000.0,
4488                    stats.speedup_factor
4489                )
4490            } else {
4491                format!(
4492                    "Swarm completed with failures.\n\
4493                     Subtasks: {} completed, {} failed\n\
4494                     Check the subtask results for details.",
4495                    stats.subagents_completed, stats.subagents_failed
4496                )
4497            };
4498            self.messages.push(ChatMessage::new("system", &summary));
4499            self.swarm_rx = None;
4500        }
4501
4502        if let SwarmEvent::Error(ref err) = event {
4503            self.messages
4504                .push(ChatMessage::new("system", &format!("Swarm error: {}", err)));
4505        }
4506    }
4507
4508    /// Handle a Ralph event
4509    fn handle_ralph_event(&mut self, event: RalphEvent) {
4510        self.ralph_state.handle_event(event.clone());
4511
4512        // When Ralph completes, switch back to chat view with summary
4513        if let RalphEvent::Complete {
4514            ref status,
4515            passed,
4516            total,
4517        } = event
4518        {
4519            self.view_mode = ViewMode::Chat;
4520            let summary = format!(
4521                "Ralph loop finished: {}\n\
4522                 Stories: {}/{} passed",
4523                status, passed, total
4524            );
4525            self.messages.push(ChatMessage::new("system", &summary));
4526            self.ralph_rx = None;
4527        }
4528
4529        if let RalphEvent::Error(ref err) = event {
4530            self.messages
4531                .push(ChatMessage::new("system", &format!("Ralph error: {}", err)));
4532        }
4533    }
4534
4535    /// Start Ralph execution for a PRD
4536    async fn start_ralph_execution(&mut self, prd_path: String, config: &Config) {
4537        // Add user message
4538        self.messages
4539            .push(ChatMessage::new("user", format!("/ralph {}", prd_path)));
4540
4541        // Get model from config
4542        let model = self
4543            .active_model
4544            .clone()
4545            .or_else(|| config.default_model.clone())
4546            .or_else(|| Some("zai/glm-5".to_string()));
4547
4548        let model = match model {
4549            Some(m) => m,
4550            None => {
4551                self.messages.push(ChatMessage::new(
4552                    "system",
4553                    "No model configured. Use /model to select one first.",
4554                ));
4555                return;
4556            }
4557        };
4558
4559        // Check PRD exists
4560        let prd_file = std::path::PathBuf::from(&prd_path);
4561        if !prd_file.exists() {
4562            self.messages.push(ChatMessage::new(
4563                "system",
4564                format!("PRD file not found: {}", prd_path),
4565            ));
4566            return;
4567        }
4568
4569        // Create channel for ralph events
4570        let (tx, rx) = mpsc::channel(200);
4571        self.ralph_rx = Some(rx);
4572
4573        // Switch to Ralph view
4574        self.view_mode = ViewMode::Ralph;
4575        self.ralph_state = RalphViewState::new();
4576
4577        // Build Ralph config
4578        let ralph_config = RalphConfig {
4579            prd_path: prd_path.clone(),
4580            max_iterations: 10,
4581            progress_path: "progress.txt".to_string(),
4582            quality_checks_enabled: true,
4583            auto_commit: true,
4584            model: Some(model.clone()),
4585            use_rlm: false,
4586            parallel_enabled: true,
4587            max_concurrent_stories: 3,
4588            worktree_enabled: true,
4589            story_timeout_secs: 300,
4590            conflict_timeout_secs: 120,
4591        };
4592
4593        // Parse provider/model from the model string
4594        let (provider_name, model_name) = if let Some(pos) = model.find('/') {
4595            (model[..pos].to_string(), model[pos + 1..].to_string())
4596        } else {
4597            (model.clone(), model.clone())
4598        };
4599
4600        let prd_path_clone = prd_path.clone();
4601        let tx_clone = tx.clone();
4602
4603        // Spawn Ralph execution
4604        tokio::spawn(async move {
4605            // Get provider from registry
4606            let provider = match crate::provider::ProviderRegistry::from_vault().await {
4607                Ok(registry) => match registry.get(&provider_name) {
4608                    Some(p) => p,
4609                    None => {
4610                        let _ = tx_clone
4611                            .send(RalphEvent::Error(format!(
4612                                "Provider '{}' not found",
4613                                provider_name
4614                            )))
4615                            .await;
4616                        return;
4617                    }
4618                },
4619                Err(e) => {
4620                    let _ = tx_clone
4621                        .send(RalphEvent::Error(format!(
4622                            "Failed to load providers: {}",
4623                            e
4624                        )))
4625                        .await;
4626                    return;
4627                }
4628            };
4629
4630            let prd_path_buf = std::path::PathBuf::from(&prd_path_clone);
4631            match RalphLoop::new(prd_path_buf, provider, model_name, ralph_config).await {
4632                Ok(ralph) => {
4633                    let mut ralph = ralph.with_event_tx(tx_clone.clone());
4634                    match ralph.run().await {
4635                        Ok(_state) => {
4636                            // Complete event already emitted by run()
4637                        }
4638                        Err(e) => {
4639                            let _ = tx_clone.send(RalphEvent::Error(e.to_string())).await;
4640                        }
4641                    }
4642                }
4643                Err(e) => {
4644                    let _ = tx_clone
4645                        .send(RalphEvent::Error(format!(
4646                            "Failed to initialize Ralph: {}",
4647                            e
4648                        )))
4649                        .await;
4650                }
4651            }
4652        });
4653
4654        self.messages.push(ChatMessage::new(
4655            "system",
4656            format!("Starting Ralph loop with PRD: {}", prd_path),
4657        ));
4658    }
4659
4660    /// Start swarm execution for a task
4661    async fn start_swarm_execution(&mut self, task: String, config: &Config) {
4662        // Add user message
4663        self.messages
4664            .push(ChatMessage::new("user", format!("/swarm {}", task)));
4665
4666        // Get model from config
4667        let model = config
4668            .default_model
4669            .clone()
4670            .or_else(|| Some("zai/glm-5".to_string()));
4671
4672        // Configure swarm
4673        let swarm_config = SwarmConfig {
4674            model,
4675            max_subagents: 10,
4676            max_steps_per_subagent: 50,
4677            worktree_enabled: true,
4678            worktree_auto_merge: true,
4679            working_dir: Some(
4680                std::env::current_dir()
4681                    .map(|p| p.to_string_lossy().to_string())
4682                    .unwrap_or_else(|_| ".".to_string()),
4683            ),
4684            ..Default::default()
4685        };
4686
4687        // Create channel for swarm events
4688        let (tx, rx) = mpsc::channel(100);
4689        self.swarm_rx = Some(rx);
4690
4691        // Switch to swarm view
4692        self.view_mode = ViewMode::Swarm;
4693        self.swarm_state = SwarmViewState::new();
4694
4695        // Send initial event
4696        let _ = tx
4697            .send(SwarmEvent::Started {
4698                task: task.clone(),
4699                total_subtasks: 0,
4700            })
4701            .await;
4702
4703        // Spawn swarm execution — executor emits all events via event_tx
4704        let task_clone = task;
4705        let bus_arc = self.bus.clone();
4706        tokio::spawn(async move {
4707            // Create executor with event channel — it handles decomposition + execution
4708            let mut executor = SwarmExecutor::new(swarm_config).with_event_tx(tx.clone());
4709            if let Some(bus) = bus_arc {
4710                executor = executor.with_bus(bus);
4711            }
4712            let result = executor
4713                .execute(&task_clone, DecompositionStrategy::Automatic)
4714                .await;
4715
4716            match result {
4717                Ok(swarm_result) => {
4718                    let _ = tx
4719                        .send(SwarmEvent::Complete {
4720                            success: swarm_result.success,
4721                            stats: swarm_result.stats,
4722                        })
4723                        .await;
4724                }
4725                Err(e) => {
4726                    let _ = tx.send(SwarmEvent::Error(e.to_string())).await;
4727                }
4728            }
4729        });
4730    }
4731
4732    /// Populate and open the model picker overlay
4733    async fn open_model_picker(&mut self, config: &Config) {
4734        let mut models: Vec<(String, String, String)> = Vec::new();
4735
4736        // Try to build provider registry and list models
4737        match crate::provider::ProviderRegistry::from_vault().await {
4738            Ok(registry) => {
4739                for provider_name in registry.list() {
4740                    if let Some(provider) = registry.get(provider_name) {
4741                        match provider.list_models().await {
4742                            Ok(model_list) => {
4743                                for m in model_list {
4744                                    let label = format!("{}/{}", provider_name, m.id);
4745                                    let value = format!("{}/{}", provider_name, m.id);
4746                                    let name = m.name.clone();
4747                                    models.push((label, value, name));
4748                                }
4749                            }
4750                            Err(e) => {
4751                                tracing::warn!(
4752                                    "Failed to list models for {}: {}",
4753                                    provider_name,
4754                                    e
4755                                );
4756                            }
4757                        }
4758                    }
4759                }
4760            }
4761            Err(e) => {
4762                tracing::warn!("Failed to load provider registry: {}", e);
4763            }
4764        }
4765
4766        // Fallback: also try from config
4767        if models.is_empty() {
4768            if let Ok(registry) = crate::provider::ProviderRegistry::from_config(config).await {
4769                for provider_name in registry.list() {
4770                    if let Some(provider) = registry.get(provider_name) {
4771                        if let Ok(model_list) = provider.list_models().await {
4772                            for m in model_list {
4773                                let label = format!("{}/{}", provider_name, m.id);
4774                                let value = format!("{}/{}", provider_name, m.id);
4775                                let name = m.name.clone();
4776                                models.push((label, value, name));
4777                            }
4778                        }
4779                    }
4780                }
4781            }
4782        }
4783
4784        if models.is_empty() {
4785            self.messages.push(ChatMessage::new(
4786                "system",
4787                "No models found. Check provider configuration (Vault or config).",
4788            ));
4789        } else {
4790            // Sort models by provider then name
4791            models.sort_by(|a, b| a.0.cmp(&b.0));
4792            self.model_picker_list = models;
4793            self.model_picker_selected = 0;
4794            self.model_picker_filter.clear();
4795            self.view_mode = ViewMode::ModelPicker;
4796        }
4797    }
4798
4799    /// Get filtered session list for the session picker
4800    fn filtered_sessions(&self) -> Vec<(usize, &SessionSummary)> {
4801        if self.session_picker_filter.is_empty() {
4802            self.session_picker_list.iter().enumerate().collect()
4803        } else {
4804            let filter = self.session_picker_filter.to_lowercase();
4805            self.session_picker_list
4806                .iter()
4807                .enumerate()
4808                .filter(|(_, s)| {
4809                    s.title
4810                        .as_deref()
4811                        .unwrap_or("")
4812                        .to_lowercase()
4813                        .contains(&filter)
4814                        || s.agent.to_lowercase().contains(&filter)
4815                        || s.id.to_lowercase().contains(&filter)
4816                })
4817                .collect()
4818        }
4819    }
4820
4821    /// Get filtered model list
4822    fn filtered_models(&self) -> Vec<(usize, &(String, String, String))> {
4823        if self.model_picker_filter.is_empty() {
4824            self.model_picker_list.iter().enumerate().collect()
4825        } else {
4826            let filter = self.model_picker_filter.to_lowercase();
4827            self.model_picker_list
4828                .iter()
4829                .enumerate()
4830                .filter(|(_, (label, _, name))| {
4831                    label.to_lowercase().contains(&filter) || name.to_lowercase().contains(&filter)
4832                })
4833                .collect()
4834        }
4835    }
4836
4837    /// Get filtered spawned agents list (sorted by name)
4838    fn filtered_spawned_agents(&self) -> Vec<(String, String, bool, bool)> {
4839        let mut agents: Vec<(String, String, bool, bool)> = self
4840            .spawned_agents
4841            .iter()
4842            .map(|(name, agent)| {
4843                let protocol_registered = self.is_agent_protocol_registered(name);
4844                (
4845                    name.clone(),
4846                    agent.instructions.clone(),
4847                    agent.is_processing,
4848                    protocol_registered,
4849                )
4850            })
4851            .collect();
4852
4853        agents.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
4854
4855        if self.agent_picker_filter.is_empty() {
4856            agents
4857        } else {
4858            let filter = self.agent_picker_filter.to_lowercase();
4859            agents
4860                .into_iter()
4861                .filter(|(name, instructions, _, _)| {
4862                    name.to_lowercase().contains(&filter)
4863                        || instructions.to_lowercase().contains(&filter)
4864                })
4865                .collect()
4866        }
4867    }
4868
4869    /// Open picker for choosing a spawned sub-agent to focus
4870    fn open_agent_picker(&mut self) {
4871        if self.spawned_agents.is_empty() {
4872            self.messages.push(ChatMessage::new(
4873                "system",
4874                "No agents spawned yet. Use /spawn <name> <instructions> first.",
4875            ));
4876            return;
4877        }
4878
4879        self.agent_picker_filter.clear();
4880        let filtered = self.filtered_spawned_agents();
4881        self.agent_picker_selected = if let Some(active) = &self.active_spawned_agent {
4882            filtered
4883                .iter()
4884                .position(|(name, _, _, _)| name == active)
4885                .unwrap_or(0)
4886        } else {
4887            0
4888        };
4889        self.view_mode = ViewMode::AgentPicker;
4890    }
4891
4892    fn navigate_history(&mut self, direction: isize) {
4893        if self.command_history.is_empty() {
4894            return;
4895        }
4896
4897        let history_len = self.command_history.len();
4898        let new_index = match self.history_index {
4899            Some(current) => {
4900                let new = current as isize + direction;
4901                if new < 0 {
4902                    None
4903                } else if new >= history_len as isize {
4904                    Some(history_len - 1)
4905                } else {
4906                    Some(new as usize)
4907                }
4908            }
4909            None => {
4910                if direction > 0 {
4911                    Some(0)
4912                } else {
4913                    Some(history_len.saturating_sub(1))
4914                }
4915            }
4916        };
4917
4918        self.history_index = new_index;
4919        if let Some(index) = new_index {
4920            self.input = self.command_history[index].clone();
4921            self.cursor_position = self.input.len();
4922        } else {
4923            self.input.clear();
4924            self.cursor_position = 0;
4925        }
4926    }
4927
4928    fn search_history(&mut self) {
4929        // Enhanced search: find commands matching current input prefix
4930        if self.command_history.is_empty() {
4931            return;
4932        }
4933
4934        let search_term = self.input.trim().to_lowercase();
4935
4936        if search_term.is_empty() {
4937            // Empty search - show most recent
4938            if !self.command_history.is_empty() {
4939                self.input = self.command_history.last().unwrap().clone();
4940                self.cursor_position = self.input.len();
4941                self.history_index = Some(self.command_history.len() - 1);
4942            }
4943            return;
4944        }
4945
4946        // Find the most recent command that starts with the search term
4947        for (index, cmd) in self.command_history.iter().enumerate().rev() {
4948            if cmd.to_lowercase().starts_with(&search_term) {
4949                self.input = cmd.clone();
4950                self.cursor_position = self.input.len();
4951                self.history_index = Some(index);
4952                return;
4953            }
4954        }
4955
4956        // If no prefix match, search for contains
4957        for (index, cmd) in self.command_history.iter().enumerate().rev() {
4958            if cmd.to_lowercase().contains(&search_term) {
4959                self.input = cmd.clone();
4960                self.cursor_position = self.input.len();
4961                self.history_index = Some(index);
4962                return;
4963            }
4964        }
4965    }
4966
4967    fn autochat_status_label(&self) -> Option<String> {
4968        if !self.autochat_running {
4969            return None;
4970        }
4971
4972        let elapsed = self
4973            .autochat_started_at
4974            .map(|started| {
4975                let elapsed = started.elapsed();
4976                if elapsed.as_secs() >= 60 {
4977                    format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
4978                } else {
4979                    format!("{:.1}s", elapsed.as_secs_f64())
4980                }
4981            })
4982            .unwrap_or_else(|| "0.0s".to_string());
4983
4984        let phase = self
4985            .autochat_status
4986            .as_deref()
4987            .unwrap_or("Relay is running…")
4988            .to_string();
4989
4990        Some(format!(
4991            "{} Autochat {elapsed} • {phase}",
4992            current_spinner_frame()
4993        ))
4994    }
4995
4996    fn chat_sync_summary(&self) -> String {
4997        if self.chat_sync_rx.is_none() && self.chat_sync_status.is_none() {
4998            if self.secure_environment {
4999                return "Remote sync: REQUIRED in secure environment (not running)".to_string();
5000            }
5001            return "Remote sync: disabled (set CODETETHER_CHAT_SYNC_ENABLED=true)".to_string();
5002        }
5003
5004        let status = self
5005            .chat_sync_status
5006            .as_deref()
5007            .unwrap_or("Remote sync active")
5008            .to_string();
5009        let last_success = self
5010            .chat_sync_last_success
5011            .as_deref()
5012            .unwrap_or("never")
5013            .to_string();
5014        let last_error = self
5015            .chat_sync_last_error
5016            .as_deref()
5017            .unwrap_or("none")
5018            .to_string();
5019
5020        format!(
5021            "Remote sync: {status}\nUploaded batches: {} ({})\nLast success: {last_success}\nLast error: {last_error}",
5022            self.chat_sync_uploaded_batches,
5023            format_bytes(self.chat_sync_uploaded_bytes)
5024        )
5025    }
5026
5027    fn handle_chat_sync_event(&mut self, event: ChatSyncUiEvent) {
5028        match event {
5029            ChatSyncUiEvent::Status(status) => {
5030                self.chat_sync_status = Some(status);
5031            }
5032            ChatSyncUiEvent::BatchUploaded {
5033                bytes,
5034                records,
5035                object_key,
5036            } => {
5037                self.chat_sync_uploaded_bytes = self.chat_sync_uploaded_bytes.saturating_add(bytes);
5038                self.chat_sync_uploaded_batches = self.chat_sync_uploaded_batches.saturating_add(1);
5039                let when = chrono::Local::now().format("%H:%M:%S").to_string();
5040                self.chat_sync_last_success = Some(format!(
5041                    "{} • {} records • {} • {}",
5042                    when,
5043                    records,
5044                    format_bytes(bytes),
5045                    object_key
5046                ));
5047                self.chat_sync_last_error = None;
5048                self.chat_sync_status =
5049                    Some(format!("Synced {} ({})", records, format_bytes(bytes)));
5050            }
5051            ChatSyncUiEvent::Error(error) => {
5052                self.chat_sync_last_error = Some(error.clone());
5053                self.chat_sync_status = Some("Sync error (will retry)".to_string());
5054            }
5055        }
5056    }
5057
5058    fn to_archive_record(
5059        message: &ChatMessage,
5060        workspace: &str,
5061        session_id: Option<String>,
5062    ) -> ChatArchiveRecord {
5063        let (message_type, tool_name, tool_success, tool_duration_ms) = match &message.message_type
5064        {
5065            MessageType::Text(_) => ("text".to_string(), None, None, None),
5066            MessageType::Image { .. } => ("image".to_string(), None, None, None),
5067            MessageType::ToolCall { name, .. } => {
5068                ("tool_call".to_string(), Some(name.clone()), None, None)
5069            }
5070            MessageType::ToolResult {
5071                name,
5072                success,
5073                duration_ms,
5074                ..
5075            } => (
5076                "tool_result".to_string(),
5077                Some(name.clone()),
5078                Some(*success),
5079                *duration_ms,
5080            ),
5081            MessageType::File { .. } => ("file".to_string(), None, None, None),
5082            MessageType::Thinking(_) => ("thinking".to_string(), None, None, None),
5083        };
5084
5085        ChatArchiveRecord {
5086            recorded_at: chrono::Utc::now().to_rfc3339(),
5087            workspace: workspace.to_string(),
5088            session_id,
5089            role: message.role.clone(),
5090            agent_name: message.agent_name.clone(),
5091            message_type,
5092            content: message.content.clone(),
5093            tool_name,
5094            tool_success,
5095            tool_duration_ms,
5096        }
5097    }
5098
5099    fn flush_chat_archive(&mut self) {
5100        let Some(path) = self.chat_archive_path.clone() else {
5101            self.archived_message_count = self.messages.len();
5102            return;
5103        };
5104
5105        if self.archived_message_count >= self.messages.len() {
5106            return;
5107        }
5108
5109        let workspace = self.workspace_dir.to_string_lossy().to_string();
5110        let session_id = self.session.as_ref().map(|session| session.id.clone());
5111        let records: Vec<ChatArchiveRecord> = self.messages[self.archived_message_count..]
5112            .iter()
5113            .map(|message| Self::to_archive_record(message, &workspace, session_id.clone()))
5114            .collect();
5115
5116        if let Some(parent) = path.parent()
5117            && let Err(err) = std::fs::create_dir_all(parent)
5118        {
5119            tracing::warn!(error = %err, path = %parent.display(), "Failed to create chat archive directory");
5120            return;
5121        }
5122
5123        let mut file = match std::fs::OpenOptions::new()
5124            .create(true)
5125            .append(true)
5126            .open(&path)
5127        {
5128            Ok(file) => file,
5129            Err(err) => {
5130                tracing::warn!(error = %err, path = %path.display(), "Failed to open chat archive file");
5131                return;
5132            }
5133        };
5134
5135        for record in records {
5136            if let Err(err) = serde_json::to_writer(&mut file, &record) {
5137                tracing::warn!(error = %err, path = %path.display(), "Failed to serialize chat archive record");
5138                return;
5139            }
5140            if let Err(err) = writeln!(&mut file) {
5141                tracing::warn!(error = %err, path = %path.display(), "Failed to write chat archive newline");
5142                return;
5143            }
5144        }
5145
5146        self.archived_message_count = self.messages.len();
5147    }
5148}
5149
5150async fn run_app(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> Result<()> {
5151    let mut app = App::new();
5152    // Use paginated session loading - default 100, configurable via CODETETHER_SESSION_PICKER_LIMIT
5153    let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5154        .ok()
5155        .and_then(|v| v.parse().ok())
5156        .unwrap_or(100);
5157    if let Ok(sessions) = list_sessions_with_opencode_paged(&app.workspace_dir, limit, 0).await {
5158        app.update_cached_sessions(sessions);
5159    }
5160
5161    // Create agent bus and subscribe the TUI as an observer
5162    let bus = std::sync::Arc::new(crate::bus::AgentBus::new());
5163    let mut bus_handle = bus.handle("tui-observer");
5164    let (bus_tx, bus_rx) = mpsc::channel::<crate::bus::BusEnvelope>(512);
5165    app.bus_log_rx = Some(bus_rx);
5166    app.bus = Some(bus.clone());
5167
5168    // Spawn a forwarder task: bus broadcast → mpsc channel for the TUI event loop
5169    tokio::spawn(async move {
5170        loop {
5171            match bus_handle.recv().await {
5172                Some(env) => {
5173                    if bus_tx.send(env).await.is_err() {
5174                        break; // TUI closed
5175                    }
5176                }
5177                None => break, // bus closed
5178            }
5179        }
5180    });
5181
5182    // Load configuration and theme
5183    let mut config = Config::load().await?;
5184    let mut theme = crate::tui::theme_utils::validate_theme(&config.load_theme());
5185
5186    let secure_environment = is_secure_environment();
5187    app.secure_environment = secure_environment;
5188
5189    match parse_chat_sync_config(secure_environment).await {
5190        Ok(Some(sync_config)) => {
5191            if let Some(archive_path) = app.chat_archive_path.clone() {
5192                let (chat_sync_tx, chat_sync_rx) = mpsc::channel::<ChatSyncUiEvent>(64);
5193                app.chat_sync_rx = Some(chat_sync_rx);
5194                app.chat_sync_status = Some("Starting remote archive sync worker…".to_string());
5195                tokio::spawn(async move {
5196                    run_chat_sync_worker(chat_sync_tx, archive_path, sync_config).await;
5197                });
5198            } else {
5199                let message = "Remote chat sync is enabled, but local archive path is unavailable.";
5200                if secure_environment {
5201                    return Err(anyhow::anyhow!(
5202                        "{message} Secure environment requires remote chat sync."
5203                    ));
5204                }
5205                app.messages.push(ChatMessage::new("system", message));
5206            }
5207        }
5208        Ok(None) => {}
5209        Err(err) => {
5210            if secure_environment {
5211                return Err(anyhow::anyhow!(
5212                    "Secure environment requires remote chat sync: {err}"
5213                ));
5214            }
5215            app.messages.push(ChatMessage::new(
5216                "system",
5217                format!("Remote chat sync disabled due to configuration error: {err}"),
5218            ));
5219        }
5220    }
5221
5222    // Track last config modification time for hot-reloading
5223    let _config_paths = vec![
5224        std::path::PathBuf::from("./codetether.toml"),
5225        std::path::PathBuf::from("./.codetether/config.toml"),
5226    ];
5227
5228    let _global_config_path = directories::ProjectDirs::from("com", "codetether", "codetether")
5229        .map(|dirs| dirs.config_dir().join("config.toml"));
5230
5231    let mut last_check = Instant::now();
5232    let mut event_stream = EventStream::new();
5233
5234    // Background session refresh — fires every 5s, sends results via channel
5235    let (session_tx, mut session_rx) = mpsc::channel::<Vec<crate::session::SessionSummary>>(1);
5236    {
5237        let workspace_dir = app.workspace_dir.clone();
5238        let session_limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5239            .ok()
5240            .and_then(|v| v.parse().ok())
5241            .unwrap_or(100);
5242        tokio::spawn(async move {
5243            let mut interval = tokio::time::interval(Duration::from_secs(5));
5244            loop {
5245                interval.tick().await;
5246                if let Ok(sessions) = list_sessions_with_opencode_paged(&workspace_dir, session_limit, 0).await {
5247                    if session_tx.send(sessions).await.is_err() {
5248                        break; // TUI closed
5249                    }
5250                }
5251            }
5252        });
5253    }
5254
5255    // Check for an interrupted relay checkpoint and notify the user
5256    if let Some(checkpoint) = RelayCheckpoint::load().await {
5257        app.messages.push(ChatMessage::new(
5258            "system",
5259            format!(
5260                "Interrupted relay detected!\nTask: {}\nAgents: {}\nCompleted {} turns, was at round {}, index {}\n\nType /resume to continue the relay from where it left off.",
5261                truncate_with_ellipsis(&checkpoint.task, 120),
5262                checkpoint.ordered_agents.join(" → "),
5263                checkpoint.turns,
5264                checkpoint.round,
5265                checkpoint.idx,
5266            ),
5267        ));
5268    }
5269
5270    loop {
5271        // --- Periodic background work (non-blocking) ---
5272
5273        // Receive session list updates from background task
5274        if let Ok(sessions) = session_rx.try_recv() {
5275            app.update_cached_sessions(sessions);
5276        }
5277
5278        // Check for theme changes if hot-reload is enabled
5279        if config.ui.hot_reload && last_check.elapsed() > Duration::from_secs(2) {
5280            if let Ok(new_config) = Config::load().await {
5281                if new_config.ui.theme != config.ui.theme
5282                    || new_config.ui.custom_theme != config.ui.custom_theme
5283                {
5284                    theme = crate::tui::theme_utils::validate_theme(&new_config.load_theme());
5285                    config = new_config;
5286                }
5287            }
5288            last_check = Instant::now();
5289        }
5290
5291        terminal.draw(|f| ui(f, &mut app, &theme))?;
5292
5293        // Update max_scroll estimate for scroll key handlers
5294        // This needs to roughly match what ui() calculates
5295        let terminal_height = terminal.size()?.height.saturating_sub(6) as usize;
5296        let estimated_lines = app.messages.len() * 4; // rough estimate
5297        app.last_max_scroll = estimated_lines.saturating_sub(terminal_height);
5298
5299        // Drain all pending async responses
5300        if let Some(mut rx) = app.response_rx.take() {
5301            while let Ok(response) = rx.try_recv() {
5302                app.handle_response(response);
5303            }
5304            app.response_rx = Some(rx);
5305        }
5306
5307        // Drain all pending swarm events
5308        if let Some(mut rx) = app.swarm_rx.take() {
5309            while let Ok(event) = rx.try_recv() {
5310                app.handle_swarm_event(event);
5311            }
5312            app.swarm_rx = Some(rx);
5313        }
5314
5315        // Drain all pending ralph events
5316        if let Some(mut rx) = app.ralph_rx.take() {
5317            while let Ok(event) = rx.try_recv() {
5318                app.handle_ralph_event(event);
5319            }
5320            app.ralph_rx = Some(rx);
5321        }
5322
5323        // Drain all pending bus log events
5324        if let Some(mut rx) = app.bus_log_rx.take() {
5325            while let Ok(env) = rx.try_recv() {
5326                app.bus_log_state.ingest(&env);
5327            }
5328            app.bus_log_rx = Some(rx);
5329        }
5330
5331        // Drain all pending spawned-agent responses
5332        {
5333            let mut i = 0;
5334            while i < app.agent_response_rxs.len() {
5335                let mut done = false;
5336                while let Ok(event) = app.agent_response_rxs[i].1.try_recv() {
5337                    if matches!(event, SessionEvent::Done) {
5338                        done = true;
5339                    }
5340                    let name = app.agent_response_rxs[i].0.clone();
5341                    app.handle_agent_response(&name, event);
5342                }
5343                if done {
5344                    app.agent_response_rxs.swap_remove(i);
5345                } else {
5346                    i += 1;
5347                }
5348            }
5349        }
5350
5351        // Drain all pending background autochat events
5352        if let Some(mut rx) = app.autochat_rx.take() {
5353            let mut completed = false;
5354            while let Ok(event) = rx.try_recv() {
5355                if app.handle_autochat_event(event) {
5356                    completed = true;
5357                }
5358            }
5359
5360            if completed || rx.is_closed() {
5361                if !completed && app.autochat_running {
5362                    app.messages.push(ChatMessage::new(
5363                        "system",
5364                        "Autochat relay worker stopped unexpectedly.",
5365                    ));
5366                    app.scroll = SCROLL_BOTTOM;
5367                }
5368                app.autochat_running = false;
5369                app.autochat_started_at = None;
5370                app.autochat_status = None;
5371                app.autochat_rx = None;
5372            } else {
5373                app.autochat_rx = Some(rx);
5374            }
5375        }
5376
5377        // Drain all pending background chat sync events
5378        if let Some(mut rx) = app.chat_sync_rx.take() {
5379            while let Ok(event) = rx.try_recv() {
5380                app.handle_chat_sync_event(event);
5381            }
5382
5383            if rx.is_closed() {
5384                app.chat_sync_status = Some("Remote archive sync worker stopped.".to_string());
5385                app.chat_sync_rx = None;
5386                if app.secure_environment {
5387                    return Err(anyhow::anyhow!(
5388                        "Remote archive sync worker stopped in secure environment"
5389                    ));
5390                }
5391            } else {
5392                app.chat_sync_rx = Some(rx);
5393            }
5394        }
5395
5396        // Persist any newly appended chat messages for durable post-hoc analysis.
5397        app.flush_chat_archive();
5398
5399        // Wait for terminal events asynchronously (no blocking!)
5400        let ev = tokio::select! {
5401            maybe_event = event_stream.next() => {
5402                match maybe_event {
5403                    Some(Ok(ev)) => ev,
5404                    Some(Err(_)) => continue,
5405                    None => return Ok(()), // stream ended
5406                }
5407            }
5408            // Tick at 50ms to keep rendering responsive during streaming
5409            _ = tokio::time::sleep(Duration::from_millis(50)) => continue,
5410        };
5411
5412        // Handle bracketed paste: insert entire clipboard text at cursor without submitting
5413        if let Event::Paste(text) = &ev {
5414            // Ensure cursor is at a valid char boundary before inserting
5415            let mut pos = app.cursor_position;
5416            while pos > 0 && !app.input.is_char_boundary(pos) {
5417                pos -= 1;
5418            }
5419            app.cursor_position = pos;
5420
5421            for c in text.chars() {
5422                if c == '\n' || c == '\r' {
5423                    // Replace newlines with spaces to keep paste as single message
5424                    app.input.insert(app.cursor_position, ' ');
5425                } else {
5426                    app.input.insert(app.cursor_position, c);
5427                }
5428                app.cursor_position += c.len_utf8();
5429            }
5430            continue;
5431        }
5432
5433        if let Event::Key(key) = ev {
5434            // Only handle key press events (not release or repeat-release).
5435            // Crossterm 0.29+ emits Press, Repeat, and Release events;
5436            // processing all of them causes double character entry.
5437            if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
5438                continue;
5439            }
5440
5441            // Help overlay
5442            if app.show_help {
5443                if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) {
5444                    app.show_help = false;
5445                }
5446                continue;
5447            }
5448
5449            // Model picker overlay
5450            if app.view_mode == ViewMode::ModelPicker {
5451                match key.code {
5452                    KeyCode::Esc => {
5453                        app.view_mode = ViewMode::Chat;
5454                    }
5455                    KeyCode::Up | KeyCode::Char('k')
5456                        if !key.modifiers.contains(KeyModifiers::ALT) =>
5457                    {
5458                        if app.model_picker_selected > 0 {
5459                            app.model_picker_selected -= 1;
5460                        }
5461                    }
5462                    KeyCode::Down | KeyCode::Char('j')
5463                        if !key.modifiers.contains(KeyModifiers::ALT) =>
5464                    {
5465                        let filtered = app.filtered_models();
5466                        if app.model_picker_selected < filtered.len().saturating_sub(1) {
5467                            app.model_picker_selected += 1;
5468                        }
5469                    }
5470                    KeyCode::Enter => {
5471                        let filtered = app.filtered_models();
5472                        if let Some((_, (label, value, _name))) =
5473                            filtered.get(app.model_picker_selected)
5474                        {
5475                            let label = label.clone();
5476                            let value = value.clone();
5477                            app.active_model = Some(value.clone());
5478                            if let Some(session) = app.session.as_mut() {
5479                                session.metadata.model = Some(value.clone());
5480                            }
5481                            app.messages.push(ChatMessage::new(
5482                                "system",
5483                                format!("Model set to: {}", label),
5484                            ));
5485                            app.view_mode = ViewMode::Chat;
5486                        }
5487                    }
5488                    KeyCode::Backspace => {
5489                        app.model_picker_filter.pop();
5490                        app.model_picker_selected = 0;
5491                    }
5492                    KeyCode::Char(c)
5493                        if !key.modifiers.contains(KeyModifiers::CONTROL)
5494                            && !key.modifiers.contains(KeyModifiers::ALT) =>
5495                    {
5496                        app.model_picker_filter.push(c);
5497                        app.model_picker_selected = 0;
5498                    }
5499                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5500                        return Ok(());
5501                    }
5502                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5503                        return Ok(());
5504                    }
5505                    _ => {}
5506                }
5507                continue;
5508            }
5509
5510            // Session picker overlay - handle specially
5511            if app.view_mode == ViewMode::SessionPicker {
5512                match key.code {
5513                    KeyCode::Esc => {
5514                        if app.session_picker_confirm_delete {
5515                            app.session_picker_confirm_delete = false;
5516                        } else {
5517                            app.session_picker_filter.clear();
5518                            app.session_picker_offset = 0;
5519                            app.view_mode = ViewMode::Chat;
5520                        }
5521                    }
5522                    KeyCode::Up | KeyCode::Char('k') => {
5523                        if app.session_picker_selected > 0 {
5524                            app.session_picker_selected -= 1;
5525                        }
5526                        app.session_picker_confirm_delete = false;
5527                    }
5528                    KeyCode::Down | KeyCode::Char('j') => {
5529                        let filtered_count = app.filtered_sessions().len();
5530                        if app.session_picker_selected < filtered_count.saturating_sub(1) {
5531                            app.session_picker_selected += 1;
5532                        }
5533                        app.session_picker_confirm_delete = false;
5534                    }
5535                    KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
5536                        if app.session_picker_confirm_delete {
5537                            // Second press: actually delete
5538                            let filtered = app.filtered_sessions();
5539                            if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
5540                                let session_id = app.session_picker_list[*orig_idx].id.clone();
5541                                let is_active = app
5542                                    .session
5543                                    .as_ref()
5544                                    .map(|s| s.id == session_id)
5545                                    .unwrap_or(false);
5546                                if !is_active {
5547                                    if let Err(e) = Session::delete(&session_id).await {
5548                                        app.messages.push(ChatMessage::new(
5549                                            "system",
5550                                            format!("Failed to delete session: {}", e),
5551                                        ));
5552                                    } else {
5553                                        app.session_picker_list.retain(|s| s.id != session_id);
5554                                        if app.session_picker_selected
5555                                            >= app.session_picker_list.len()
5556                                        {
5557                                            app.session_picker_selected =
5558                                                app.session_picker_list.len().saturating_sub(1);
5559                                        }
5560                                    }
5561                                }
5562                            }
5563                            app.session_picker_confirm_delete = false;
5564                        } else {
5565                            // First press: ask for confirmation
5566                            let filtered = app.filtered_sessions();
5567                            if let Some((orig_idx, _)) = filtered.get(app.session_picker_selected) {
5568                                let is_active = app
5569                                    .session
5570                                    .as_ref()
5571                                    .map(|s| s.id == app.session_picker_list[*orig_idx].id)
5572                                    .unwrap_or(false);
5573                                if !is_active {
5574                                    app.session_picker_confirm_delete = true;
5575                                }
5576                            }
5577                        }
5578                    }
5579                    KeyCode::Backspace => {
5580                        app.session_picker_filter.pop();
5581                        app.session_picker_selected = 0;
5582                        app.session_picker_confirm_delete = false;
5583                    }
5584                    // Pagination: 'n' = next page, 'p' = previous page
5585                    KeyCode::Char('n') => {
5586                        let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5587                            .ok()
5588                            .and_then(|v| v.parse().ok())
5589                            .unwrap_or(100);
5590                        let new_offset = app.session_picker_offset + limit;
5591                        app.session_picker_offset = new_offset;
5592                        match list_sessions_with_opencode_paged(&app.workspace_dir, limit, new_offset).await {
5593                            Ok(sessions) => {
5594                                app.update_cached_sessions(sessions);
5595                                app.session_picker_selected = 0;
5596                            }
5597                            Err(e) => {
5598                                app.messages.push(ChatMessage::new(
5599                                    "system",
5600                                    format!("Failed to load more sessions: {}", e),
5601                                ));
5602                            }
5603                        }
5604                    }
5605                    KeyCode::Char('p') => {
5606                        if app.session_picker_offset > 0 {
5607                            let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
5608                                .ok()
5609                                .and_then(|v| v.parse().ok())
5610                                .unwrap_or(100);
5611                            let new_offset = app.session_picker_offset.saturating_sub(limit);
5612                            app.session_picker_offset = new_offset;
5613                            match list_sessions_with_opencode_paged(&app.workspace_dir, limit, new_offset).await {
5614                                Ok(sessions) => {
5615                                    app.update_cached_sessions(sessions);
5616                                    app.session_picker_selected = 0;
5617                                }
5618                                Err(e) => {
5619                                    app.messages.push(ChatMessage::new(
5620                                        "system",
5621                                        format!("Failed to load previous sessions: {}", e),
5622                                    ));
5623                                }
5624                            }
5625                        }
5626                    }
5627                    KeyCode::Char('/') => {
5628                        // Focus filter (no-op, just signals we're in filter mode)
5629                    }
5630                    KeyCode::Enter => {
5631                        app.session_picker_confirm_delete = false;
5632                        let filtered = app.filtered_sessions();
5633                        let session_id = filtered
5634                            .get(app.session_picker_selected)
5635                            .map(|(orig_idx, _)| app.session_picker_list[*orig_idx].id.clone());
5636                        if let Some(session_id) = session_id {
5637                            let load_result =
5638                                if let Some(oc_id) = session_id.strip_prefix("opencode_") {
5639                                    if let Some(storage) = crate::opencode::OpenCodeStorage::new() {
5640                                        Session::from_opencode(oc_id, &storage).await
5641                                    } else {
5642                                        Err(anyhow::anyhow!("OpenCode storage not available"))
5643                                    }
5644                                } else {
5645                                    Session::load(&session_id).await
5646                                };
5647                            match load_result {
5648                                Ok(session) => {
5649                                    app.messages.clear();
5650                                    app.messages.push(ChatMessage::new(
5651                                        "system",
5652                                        format!(
5653                                            "Resumed session: {}\nCreated: {}\n{} messages loaded",
5654                                            session.title.as_deref().unwrap_or("(untitled)"),
5655                                            session.created_at.format("%Y-%m-%d %H:%M"),
5656                                            session.messages.len()
5657                                        ),
5658                                    ));
5659
5660                                    for msg in &session.messages {
5661                                        let role_str = match msg.role {
5662                                            Role::System => "system",
5663                                            Role::User => "user",
5664                                            Role::Assistant => "assistant",
5665                                            Role::Tool => "tool",
5666                                        };
5667
5668                                        // Process each content part separately
5669                                        // (consistent with /resume command)
5670                                        for part in &msg.content {
5671                                            match part {
5672                                                ContentPart::Text { text } => {
5673                                                    if !text.is_empty() {
5674                                                        app.messages.push(ChatMessage::new(
5675                                                            role_str,
5676                                                            text.clone(),
5677                                                        ));
5678                                                    }
5679                                                }
5680                                                ContentPart::Image { url, mime_type } => {
5681                                                    app.messages.push(
5682                                                        ChatMessage::new(role_str, "")
5683                                                            .with_message_type(
5684                                                                MessageType::Image {
5685                                                                    url: url.clone(),
5686                                                                    mime_type: mime_type.clone(),
5687                                                                },
5688                                                            ),
5689                                                    );
5690                                                }
5691                                                ContentPart::ToolCall {
5692                                                    name, arguments, ..
5693                                                } => {
5694                                                    let (preview, truncated) =
5695                                                        build_tool_arguments_preview(
5696                                                            name,
5697                                                            arguments,
5698                                                            TOOL_ARGS_PREVIEW_MAX_LINES,
5699                                                            TOOL_ARGS_PREVIEW_MAX_BYTES,
5700                                                        );
5701                                                    app.messages.push(
5702                                                        ChatMessage::new(
5703                                                            role_str,
5704                                                            format!("🔧 {name}"),
5705                                                        )
5706                                                        .with_message_type(MessageType::ToolCall {
5707                                                            name: name.clone(),
5708                                                            arguments_preview: preview,
5709                                                            arguments_len: arguments.len(),
5710                                                            truncated,
5711                                                        }),
5712                                                    );
5713                                                }
5714                                                ContentPart::ToolResult { content, .. } => {
5715                                                    let truncated =
5716                                                        truncate_with_ellipsis(content, 500);
5717                                                    let (preview, preview_truncated) =
5718                                                        build_text_preview(
5719                                                            content,
5720                                                            TOOL_OUTPUT_PREVIEW_MAX_LINES,
5721                                                            TOOL_OUTPUT_PREVIEW_MAX_BYTES,
5722                                                        );
5723                                                    app.messages.push(
5724                                                        ChatMessage::new(
5725                                                            role_str,
5726                                                            format!("✅ Result\n{truncated}"),
5727                                                        )
5728                                                        .with_message_type(
5729                                                            MessageType::ToolResult {
5730                                                                name: "tool".to_string(),
5731                                                                output_preview: preview,
5732                                                                output_len: content.len(),
5733                                                                truncated: preview_truncated,
5734                                                                success: true,
5735                                                                duration_ms: None,
5736                                                            },
5737                                                        ),
5738                                                    );
5739                                                }
5740                                                ContentPart::File { path, mime_type } => {
5741                                                    app.messages.push(
5742                                                        ChatMessage::new(
5743                                                            role_str,
5744                                                            format!("📎 {path}"),
5745                                                        )
5746                                                        .with_message_type(MessageType::File {
5747                                                            path: path.clone(),
5748                                                            mime_type: mime_type.clone(),
5749                                                        }),
5750                                                    );
5751                                                }
5752                                                ContentPart::Thinking { text } => {
5753                                                    if !text.is_empty() {
5754                                                        app.messages.push(
5755                                                            ChatMessage::new(
5756                                                                role_str,
5757                                                                text.clone(),
5758                                                            )
5759                                                            .with_message_type(
5760                                                                MessageType::Thinking(text.clone()),
5761                                                            ),
5762                                                        );
5763                                                    }
5764                                                }
5765                                            }
5766                                        }
5767                                    }
5768
5769                                    app.current_agent = session.agent.clone();
5770                                    app.session = Some(session);
5771                                    app.scroll = SCROLL_BOTTOM;
5772                                    app.view_mode = ViewMode::Chat;
5773                                }
5774                                Err(e) => {
5775                                    app.messages.push(ChatMessage::new(
5776                                        "system",
5777                                        format!("Failed to load session: {}", e),
5778                                    ));
5779                                    app.view_mode = ViewMode::Chat;
5780                                }
5781                            }
5782                        }
5783                    }
5784                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5785                        return Ok(());
5786                    }
5787                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5788                        return Ok(());
5789                    }
5790                    KeyCode::Char(c)
5791                        if !key.modifiers.contains(KeyModifiers::CONTROL)
5792                            && !key.modifiers.contains(KeyModifiers::ALT)
5793                            && c != 'j'
5794                            && c != 'k' =>
5795                    {
5796                        app.session_picker_filter.push(c);
5797                        app.session_picker_selected = 0;
5798                        app.session_picker_confirm_delete = false;
5799                    }
5800                    _ => {}
5801                }
5802                continue;
5803            }
5804
5805            // Agent picker overlay
5806            if app.view_mode == ViewMode::AgentPicker {
5807                match key.code {
5808                    KeyCode::Esc => {
5809                        app.agent_picker_filter.clear();
5810                        app.view_mode = ViewMode::Chat;
5811                    }
5812                    KeyCode::Up | KeyCode::Char('k')
5813                        if !key.modifiers.contains(KeyModifiers::ALT) =>
5814                    {
5815                        if app.agent_picker_selected > 0 {
5816                            app.agent_picker_selected -= 1;
5817                        }
5818                    }
5819                    KeyCode::Down | KeyCode::Char('j')
5820                        if !key.modifiers.contains(KeyModifiers::ALT) =>
5821                    {
5822                        let filtered = app.filtered_spawned_agents();
5823                        if app.agent_picker_selected < filtered.len().saturating_sub(1) {
5824                            app.agent_picker_selected += 1;
5825                        }
5826                    }
5827                    KeyCode::Enter => {
5828                        let filtered = app.filtered_spawned_agents();
5829                        if let Some((name, _, _, _)) = filtered.get(app.agent_picker_selected) {
5830                            app.active_spawned_agent = Some(name.clone());
5831                            app.messages.push(ChatMessage::new(
5832                                "system",
5833                                format!(
5834                                    "Focused chat on @{name}. Type messages directly; use /agent main to exit focus."
5835                                ),
5836                            ));
5837                            app.view_mode = ViewMode::Chat;
5838                        }
5839                    }
5840                    KeyCode::Backspace => {
5841                        app.agent_picker_filter.pop();
5842                        app.agent_picker_selected = 0;
5843                    }
5844                    KeyCode::Char('m') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
5845                        app.active_spawned_agent = None;
5846                        app.messages
5847                            .push(ChatMessage::new("system", "Returned to main chat mode."));
5848                        app.view_mode = ViewMode::Chat;
5849                    }
5850                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5851                        return Ok(());
5852                    }
5853                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5854                        return Ok(());
5855                    }
5856                    KeyCode::Char(c)
5857                        if !key.modifiers.contains(KeyModifiers::CONTROL)
5858                            && !key.modifiers.contains(KeyModifiers::ALT)
5859                            && c != 'j'
5860                            && c != 'k'
5861                            && c != 'm' =>
5862                    {
5863                        app.agent_picker_filter.push(c);
5864                        app.agent_picker_selected = 0;
5865                    }
5866                    _ => {}
5867                }
5868                continue;
5869            }
5870
5871            // Swarm view key handling
5872            if app.view_mode == ViewMode::Swarm {
5873                match key.code {
5874                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5875                        return Ok(());
5876                    }
5877                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5878                        return Ok(());
5879                    }
5880                    KeyCode::Esc => {
5881                        if app.swarm_state.detail_mode {
5882                            app.swarm_state.exit_detail();
5883                        } else {
5884                            app.view_mode = ViewMode::Chat;
5885                        }
5886                    }
5887                    KeyCode::Up | KeyCode::Char('k') => {
5888                        if app.swarm_state.detail_mode {
5889                            // In detail mode, Up/Down switch between agents
5890                            app.swarm_state.exit_detail();
5891                            app.swarm_state.select_prev();
5892                            app.swarm_state.enter_detail();
5893                        } else {
5894                            app.swarm_state.select_prev();
5895                        }
5896                    }
5897                    KeyCode::Down | KeyCode::Char('j') => {
5898                        if app.swarm_state.detail_mode {
5899                            app.swarm_state.exit_detail();
5900                            app.swarm_state.select_next();
5901                            app.swarm_state.enter_detail();
5902                        } else {
5903                            app.swarm_state.select_next();
5904                        }
5905                    }
5906                    KeyCode::Enter => {
5907                        if !app.swarm_state.detail_mode {
5908                            app.swarm_state.enter_detail();
5909                        }
5910                    }
5911                    KeyCode::PageDown => {
5912                        app.swarm_state.detail_scroll_down(10);
5913                    }
5914                    KeyCode::PageUp => {
5915                        app.swarm_state.detail_scroll_up(10);
5916                    }
5917                    KeyCode::Char('?') => {
5918                        app.show_help = true;
5919                    }
5920                    KeyCode::F(2) => {
5921                        app.view_mode = ViewMode::Chat;
5922                    }
5923                    KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5924                        app.view_mode = ViewMode::Chat;
5925                    }
5926                    _ => {}
5927                }
5928                continue;
5929            }
5930
5931            // Ralph view key handling
5932            if app.view_mode == ViewMode::Ralph {
5933                match key.code {
5934                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5935                        return Ok(());
5936                    }
5937                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5938                        return Ok(());
5939                    }
5940                    KeyCode::Esc => {
5941                        if app.ralph_state.detail_mode {
5942                            app.ralph_state.exit_detail();
5943                        } else {
5944                            app.view_mode = ViewMode::Chat;
5945                        }
5946                    }
5947                    KeyCode::Up | KeyCode::Char('k') => {
5948                        if app.ralph_state.detail_mode {
5949                            app.ralph_state.exit_detail();
5950                            app.ralph_state.select_prev();
5951                            app.ralph_state.enter_detail();
5952                        } else {
5953                            app.ralph_state.select_prev();
5954                        }
5955                    }
5956                    KeyCode::Down | KeyCode::Char('j') => {
5957                        if app.ralph_state.detail_mode {
5958                            app.ralph_state.exit_detail();
5959                            app.ralph_state.select_next();
5960                            app.ralph_state.enter_detail();
5961                        } else {
5962                            app.ralph_state.select_next();
5963                        }
5964                    }
5965                    KeyCode::Enter => {
5966                        if !app.ralph_state.detail_mode {
5967                            app.ralph_state.enter_detail();
5968                        }
5969                    }
5970                    KeyCode::PageDown => {
5971                        app.ralph_state.detail_scroll_down(10);
5972                    }
5973                    KeyCode::PageUp => {
5974                        app.ralph_state.detail_scroll_up(10);
5975                    }
5976                    KeyCode::Char('?') => {
5977                        app.show_help = true;
5978                    }
5979                    KeyCode::F(2) | KeyCode::Char('s')
5980                        if key.modifiers.contains(KeyModifiers::CONTROL) =>
5981                    {
5982                        app.view_mode = ViewMode::Chat;
5983                    }
5984                    _ => {}
5985                }
5986                continue;
5987            }
5988
5989            // Bus log view key handling
5990            if app.view_mode == ViewMode::BusLog {
5991                match key.code {
5992                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5993                        return Ok(());
5994                    }
5995                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
5996                        return Ok(());
5997                    }
5998                    KeyCode::Esc => {
5999                        if app.bus_log_state.detail_mode {
6000                            app.bus_log_state.exit_detail();
6001                        } else {
6002                            app.view_mode = ViewMode::Chat;
6003                        }
6004                    }
6005                    KeyCode::Up | KeyCode::Char('k') => {
6006                        if app.bus_log_state.detail_mode {
6007                            app.bus_log_state.exit_detail();
6008                            app.bus_log_state.select_prev();
6009                            app.bus_log_state.enter_detail();
6010                        } else {
6011                            app.bus_log_state.select_prev();
6012                        }
6013                    }
6014                    KeyCode::Down | KeyCode::Char('j') => {
6015                        if app.bus_log_state.detail_mode {
6016                            app.bus_log_state.exit_detail();
6017                            app.bus_log_state.select_next();
6018                            app.bus_log_state.enter_detail();
6019                        } else {
6020                            app.bus_log_state.select_next();
6021                        }
6022                    }
6023                    KeyCode::Enter => {
6024                        if !app.bus_log_state.detail_mode {
6025                            app.bus_log_state.enter_detail();
6026                        }
6027                    }
6028                    KeyCode::PageDown => {
6029                        app.bus_log_state.detail_scroll_down(10);
6030                    }
6031                    KeyCode::PageUp => {
6032                        app.bus_log_state.detail_scroll_up(10);
6033                    }
6034                    // Clear all entries
6035                    KeyCode::Char('c') => {
6036                        app.bus_log_state.entries.clear();
6037                        app.bus_log_state.selected_index = 0;
6038                    }
6039                    // Jump to bottom (re-enable auto-scroll)
6040                    KeyCode::Char('g') => {
6041                        let len = app.bus_log_state.filtered_entries().len();
6042                        if len > 0 {
6043                            app.bus_log_state.selected_index = len - 1;
6044                            app.bus_log_state.list_state.select(Some(len - 1));
6045                        }
6046                        app.bus_log_state.auto_scroll = true;
6047                    }
6048                    KeyCode::Char('?') => {
6049                        app.show_help = true;
6050                    }
6051                    _ => {}
6052                }
6053                continue;
6054            }
6055
6056            // Protocol registry view key handling
6057            if app.view_mode == ViewMode::Protocol {
6058                match key.code {
6059                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6060                        return Ok(());
6061                    }
6062                    KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6063                        return Ok(());
6064                    }
6065                    KeyCode::Esc => {
6066                        app.view_mode = ViewMode::Chat;
6067                    }
6068                    KeyCode::Up | KeyCode::Char('k') => {
6069                        if app.protocol_selected > 0 {
6070                            app.protocol_selected -= 1;
6071                        }
6072                        app.protocol_scroll = 0;
6073                    }
6074                    KeyCode::Down | KeyCode::Char('j') => {
6075                        let len = app.protocol_cards().len();
6076                        if app.protocol_selected < len.saturating_sub(1) {
6077                            app.protocol_selected += 1;
6078                        }
6079                        app.protocol_scroll = 0;
6080                    }
6081                    KeyCode::PageDown => {
6082                        app.protocol_scroll = app.protocol_scroll.saturating_add(10);
6083                    }
6084                    KeyCode::PageUp => {
6085                        app.protocol_scroll = app.protocol_scroll.saturating_sub(10);
6086                    }
6087                    KeyCode::Char('g') => {
6088                        app.protocol_scroll = 0;
6089                    }
6090                    KeyCode::Char('?') => {
6091                        app.show_help = true;
6092                    }
6093                    _ => {}
6094                }
6095                continue;
6096            }
6097
6098            match key.code {
6099                // Quit
6100                KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6101                    return Ok(());
6102                }
6103                KeyCode::Char('q') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6104                    return Ok(());
6105                }
6106
6107                // Help
6108                KeyCode::Char('?') => {
6109                    app.show_help = true;
6110                }
6111
6112                // OKR approval gate: 'a' to approve, 'd' to deny
6113                KeyCode::Char('a') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
6114                    if let Some(pending) = app.pending_okr_approval.take() {
6115                        // Approve: save OKR and run, then start relay
6116                        app.messages.push(ChatMessage::new(
6117                            "system",
6118                            "✅ OKR approved! Starting relay execution...",
6119                        ));
6120                        app.scroll = SCROLL_BOTTOM;
6121
6122                        let task = pending.task.clone();
6123                        let agent_count = pending.agent_count;
6124                        let config = config.clone();
6125
6126                        // Save OKR and run to repository asynchronously
6127                        let okr_id = pending.okr.id;
6128                        let okr_run_id = pending.run.id;
6129
6130                        tokio::spawn(async move {
6131                            if let Ok(repo) = OkrRepository::from_config().await {
6132                                let _ = repo.create_okr(pending.okr).await;
6133                                let mut run = pending.run;
6134                                run.status = OkrRunStatus::Approved;
6135                                run.correlation_id = Some(format!("relay-{}", Uuid::new_v4()));
6136                                let _ = repo.create_run(run).await;
6137                                tracing::info!(okr_id = %okr_id, okr_run_id = %okr_run_id, "OKR run approved and saved");
6138                            }
6139                        });
6140
6141                        // Start the relay with OKR IDs
6142                        app.start_autochat_execution(
6143                            agent_count,
6144                            task,
6145                            &config,
6146                            Some(okr_id),
6147                            Some(okr_run_id),
6148                        )
6149                        .await;
6150                        continue;
6151                    }
6152                }
6153
6154                KeyCode::Char('d') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
6155                    if let Some(pending) = app.pending_okr_approval.take() {
6156                        // Deny: show denial message
6157                        app.messages.push(ChatMessage::new(
6158                            "system",
6159                            "❌ OKR denied. Relay not started.\n\nUse /autochat for tactical execution without OKR tracking.",
6160                        ));
6161                        app.scroll = SCROLL_BOTTOM;
6162                        continue;
6163                    }
6164                }
6165
6166                // Toggle view mode (F2 or Ctrl+S)
6167                KeyCode::F(2) => {
6168                    app.view_mode = match app.view_mode {
6169                        ViewMode::Chat
6170                        | ViewMode::SessionPicker
6171                        | ViewMode::ModelPicker
6172                        | ViewMode::AgentPicker
6173                        | ViewMode::Protocol
6174                        | ViewMode::BusLog => ViewMode::Swarm,
6175                        ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
6176                    };
6177                }
6178                KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6179                    app.view_mode = match app.view_mode {
6180                        ViewMode::Chat
6181                        | ViewMode::SessionPicker
6182                        | ViewMode::ModelPicker
6183                        | ViewMode::AgentPicker
6184                        | ViewMode::Protocol
6185                        | ViewMode::BusLog => ViewMode::Swarm,
6186                        ViewMode::Swarm | ViewMode::Ralph => ViewMode::Chat,
6187                    };
6188                }
6189
6190                // Toggle inspector pane in webview layout
6191                KeyCode::F(3) => {
6192                    app.show_inspector = !app.show_inspector;
6193                }
6194
6195                // Copy latest assistant message to clipboard (Ctrl+Y)
6196                KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6197                    let msg = app
6198                        .messages
6199                        .iter()
6200                        .rev()
6201                        .find(|m| m.role == "assistant" && !m.content.trim().is_empty())
6202                        .or_else(|| {
6203                            app.messages
6204                                .iter()
6205                                .rev()
6206                                .find(|m| !m.content.trim().is_empty())
6207                        });
6208
6209                    let Some(msg) = msg else {
6210                        app.messages
6211                            .push(ChatMessage::new("system", "Nothing to copy yet."));
6212                        app.scroll = SCROLL_BOTTOM;
6213                        continue;
6214                    };
6215
6216                    let text = message_clipboard_text(msg);
6217                    match copy_text_to_clipboard_best_effort(&text) {
6218                        Ok(method) => {
6219                            app.messages.push(ChatMessage::new(
6220                                "system",
6221                                format!("Copied latest reply ({method})."),
6222                            ));
6223                            app.scroll = SCROLL_BOTTOM;
6224                        }
6225                        Err(err) => {
6226                            tracing::warn!(error = %err, "Copy to clipboard failed");
6227                            app.messages.push(ChatMessage::new(
6228                                "system",
6229                                "Could not copy to clipboard in this environment.",
6230                            ));
6231                            app.scroll = SCROLL_BOTTOM;
6232                        }
6233                    }
6234                }
6235
6236                // Toggle chat layout (Ctrl+B)
6237                KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6238                    app.chat_layout = match app.chat_layout {
6239                        ChatLayoutMode::Classic => ChatLayoutMode::Webview,
6240                        ChatLayoutMode::Webview => ChatLayoutMode::Classic,
6241                    };
6242                }
6243
6244                // Escape - return to chat from swarm/picker view
6245                KeyCode::Esc => {
6246                    if app.view_mode == ViewMode::Swarm
6247                        || app.view_mode == ViewMode::Ralph
6248                        || app.view_mode == ViewMode::BusLog
6249                        || app.view_mode == ViewMode::Protocol
6250                        || app.view_mode == ViewMode::SessionPicker
6251                        || app.view_mode == ViewMode::ModelPicker
6252                        || app.view_mode == ViewMode::AgentPicker
6253                    {
6254                        app.view_mode = ViewMode::Chat;
6255                    }
6256                }
6257
6258                // Model picker (Ctrl+M)
6259                KeyCode::Char('m') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6260                    app.open_model_picker(&config).await;
6261                }
6262
6263                // Agent picker (Ctrl+A)
6264                KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6265                    app.open_agent_picker();
6266                }
6267
6268                // Bus protocol log (Ctrl+L)
6269                KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6270                    app.view_mode = ViewMode::BusLog;
6271                }
6272
6273                // Protocol registry view (Ctrl+P)
6274                KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6275                    app.open_protocol_view();
6276                }
6277
6278                // Switch agent
6279                KeyCode::Tab => {
6280                    app.current_agent = if app.current_agent == "build" {
6281                        "plan".to_string()
6282                    } else {
6283                        "build".to_string()
6284                    };
6285                }
6286
6287                // Submit message
6288                KeyCode::Enter => {
6289                    app.submit_message(&config).await;
6290                }
6291
6292                // Vim-style scrolling (Alt + j/k)
6293                KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::ALT) => {
6294                    if app.scroll < SCROLL_BOTTOM {
6295                        app.scroll = app.scroll.saturating_add(1);
6296                    }
6297                }
6298                KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::ALT) => {
6299                    if app.scroll >= SCROLL_BOTTOM {
6300                        app.scroll = app.last_max_scroll; // Leave auto-scroll mode
6301                    }
6302                    app.scroll = app.scroll.saturating_sub(1);
6303                }
6304
6305                // Command history
6306                KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6307                    app.search_history();
6308                }
6309                KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => {
6310                    app.navigate_history(-1);
6311                }
6312                KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => {
6313                    app.navigate_history(1);
6314                }
6315
6316                // Additional Vim-style navigation (with modifiers to avoid conflicts)
6317                KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6318                    app.scroll = 0; // Go to top
6319                }
6320                KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::CONTROL) => {
6321                    // Go to bottom (auto-scroll)
6322                    app.scroll = SCROLL_BOTTOM;
6323                }
6324
6325                // Enhanced scrolling (with Alt to avoid conflicts)
6326                KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::ALT) => {
6327                    // Half page down
6328                    if app.scroll < SCROLL_BOTTOM {
6329                        app.scroll = app.scroll.saturating_add(5);
6330                    }
6331                }
6332                KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::ALT) => {
6333                    // Half page up
6334                    if app.scroll >= SCROLL_BOTTOM {
6335                        app.scroll = app.last_max_scroll;
6336                    }
6337                    app.scroll = app.scroll.saturating_sub(5);
6338                }
6339
6340                // Text input
6341                KeyCode::Char(c) => {
6342                    // Ensure cursor is at a valid char boundary
6343                    while app.cursor_position > 0
6344                        && !app.input.is_char_boundary(app.cursor_position)
6345                    {
6346                        app.cursor_position -= 1;
6347                    }
6348                    app.input.insert(app.cursor_position, c);
6349                    app.cursor_position += c.len_utf8();
6350                }
6351                KeyCode::Backspace => {
6352                    // Move back to previous char boundary
6353                    while app.cursor_position > 0
6354                        && !app.input.is_char_boundary(app.cursor_position)
6355                    {
6356                        app.cursor_position -= 1;
6357                    }
6358                    if app.cursor_position > 0 {
6359                        // Find start of previous char
6360                        let prev = app.input[..app.cursor_position].char_indices().rev().next();
6361                        if let Some((idx, ch)) = prev {
6362                            app.input.replace_range(idx..idx + ch.len_utf8(), "");
6363                            app.cursor_position = idx;
6364                        }
6365                    }
6366                }
6367                KeyCode::Delete => {
6368                    // Ensure cursor is at a valid char boundary
6369                    while app.cursor_position > 0
6370                        && !app.input.is_char_boundary(app.cursor_position)
6371                    {
6372                        app.cursor_position -= 1;
6373                    }
6374                    if app.cursor_position < app.input.len() {
6375                        let ch = app.input[app.cursor_position..].chars().next();
6376                        if let Some(ch) = ch {
6377                            app.input.replace_range(
6378                                app.cursor_position..app.cursor_position + ch.len_utf8(),
6379                                "",
6380                            );
6381                        }
6382                    }
6383                }
6384                KeyCode::Left => {
6385                    // Move left by one character (not byte)
6386                    let prev = app.input[..app.cursor_position].char_indices().rev().next();
6387                    if let Some((idx, _)) = prev {
6388                        app.cursor_position = idx;
6389                    }
6390                }
6391                KeyCode::Right => {
6392                    if app.cursor_position < app.input.len() {
6393                        let ch = app.input[app.cursor_position..].chars().next();
6394                        if let Some(ch) = ch {
6395                            app.cursor_position += ch.len_utf8();
6396                        }
6397                    }
6398                }
6399                KeyCode::Home => {
6400                    app.cursor_position = 0;
6401                }
6402                KeyCode::End => {
6403                    app.cursor_position = app.input.len();
6404                }
6405
6406                // Scroll (normalize first to handle SCROLL_BOTTOM sentinel)
6407                KeyCode::Up => {
6408                    if app.scroll >= SCROLL_BOTTOM {
6409                        app.scroll = app.last_max_scroll; // Leave auto-scroll mode
6410                    }
6411                    app.scroll = app.scroll.saturating_sub(1);
6412                }
6413                KeyCode::Down => {
6414                    if app.scroll < SCROLL_BOTTOM {
6415                        app.scroll = app.scroll.saturating_add(1);
6416                    }
6417                }
6418                KeyCode::PageUp => {
6419                    if app.scroll >= SCROLL_BOTTOM {
6420                        app.scroll = app.last_max_scroll;
6421                    }
6422                    app.scroll = app.scroll.saturating_sub(10);
6423                }
6424                KeyCode::PageDown => {
6425                    if app.scroll < SCROLL_BOTTOM {
6426                        app.scroll = app.scroll.saturating_add(10);
6427                    }
6428                }
6429
6430                _ => {}
6431            }
6432        }
6433    }
6434}
6435
6436fn ui(f: &mut Frame, app: &mut App, theme: &Theme) {
6437    // Check view mode
6438    if app.view_mode == ViewMode::Swarm {
6439        // Render swarm view
6440        let chunks = Layout::default()
6441            .direction(Direction::Vertical)
6442            .constraints([
6443                Constraint::Min(1),    // Swarm view
6444                Constraint::Length(3), // Input
6445                Constraint::Length(1), // Status bar
6446            ])
6447            .split(f.area());
6448
6449        // Swarm view
6450        render_swarm_view(f, &mut app.swarm_state, chunks[0]);
6451
6452        // Input area (for returning to chat)
6453        let input_block = Block::default()
6454            .borders(Borders::ALL)
6455            .title(" Press Esc, Ctrl+S, or /view to return to chat ")
6456            .border_style(Style::default().fg(Color::Cyan));
6457
6458        let input = Paragraph::new(app.input.as_str())
6459            .block(input_block)
6460            .wrap(Wrap { trim: false });
6461        f.render_widget(input, chunks[1]);
6462
6463        // Status bar
6464        let status_line = if app.swarm_state.detail_mode {
6465            Line::from(vec![
6466                Span::styled(
6467                    " AGENT DETAIL ",
6468                    Style::default().fg(Color::Black).bg(Color::Cyan),
6469                ),
6470                Span::raw(" | "),
6471                Span::styled("Esc", Style::default().fg(Color::Yellow)),
6472                Span::raw(": Back to list | "),
6473                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6474                Span::raw(": Prev/Next agent | "),
6475                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
6476                Span::raw(": Scroll"),
6477            ])
6478        } else {
6479            Line::from(vec![
6480                Span::styled(
6481                    " SWARM MODE ",
6482                    Style::default().fg(Color::Black).bg(Color::Cyan),
6483                ),
6484                Span::raw(" | "),
6485                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6486                Span::raw(": Select | "),
6487                Span::styled("Enter", Style::default().fg(Color::Yellow)),
6488                Span::raw(": Detail | "),
6489                Span::styled("Esc", Style::default().fg(Color::Yellow)),
6490                Span::raw(": Back | "),
6491                Span::styled("Ctrl+S", Style::default().fg(Color::Yellow)),
6492                Span::raw(": Toggle view"),
6493            ])
6494        };
6495        let status = Paragraph::new(status_line);
6496        f.render_widget(status, chunks[2]);
6497        return;
6498    }
6499
6500    // Ralph view
6501    if app.view_mode == ViewMode::Ralph {
6502        let chunks = Layout::default()
6503            .direction(Direction::Vertical)
6504            .constraints([
6505                Constraint::Min(1),    // Ralph view
6506                Constraint::Length(3), // Input
6507                Constraint::Length(1), // Status bar
6508            ])
6509            .split(f.area());
6510
6511        render_ralph_view(f, &mut app.ralph_state, chunks[0]);
6512
6513        let input_block = Block::default()
6514            .borders(Borders::ALL)
6515            .title(" Press Esc to return to chat ")
6516            .border_style(Style::default().fg(Color::Magenta));
6517
6518        let input = Paragraph::new(app.input.as_str())
6519            .block(input_block)
6520            .wrap(Wrap { trim: false });
6521        f.render_widget(input, chunks[1]);
6522
6523        let status_line = if app.ralph_state.detail_mode {
6524            Line::from(vec![
6525                Span::styled(
6526                    " STORY DETAIL ",
6527                    Style::default().fg(Color::Black).bg(Color::Magenta),
6528                ),
6529                Span::raw(" | "),
6530                Span::styled("Esc", Style::default().fg(Color::Yellow)),
6531                Span::raw(": Back to list | "),
6532                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6533                Span::raw(": Prev/Next story | "),
6534                Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
6535                Span::raw(": Scroll"),
6536            ])
6537        } else {
6538            Line::from(vec![
6539                Span::styled(
6540                    " RALPH MODE ",
6541                    Style::default().fg(Color::Black).bg(Color::Magenta),
6542                ),
6543                Span::raw(" | "),
6544                Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6545                Span::raw(": Select | "),
6546                Span::styled("Enter", Style::default().fg(Color::Yellow)),
6547                Span::raw(": Detail | "),
6548                Span::styled("Esc", Style::default().fg(Color::Yellow)),
6549                Span::raw(": Back"),
6550            ])
6551        };
6552        let status = Paragraph::new(status_line);
6553        f.render_widget(status, chunks[2]);
6554        return;
6555    }
6556
6557    // Bus protocol log view
6558    if app.view_mode == ViewMode::BusLog {
6559        let chunks = Layout::default()
6560            .direction(Direction::Vertical)
6561            .constraints([
6562                Constraint::Min(1),    // Bus log view
6563                Constraint::Length(3), // Input
6564                Constraint::Length(1), // Status bar
6565            ])
6566            .split(f.area());
6567
6568        render_bus_log(f, &mut app.bus_log_state, chunks[0]);
6569
6570        let input_block = Block::default()
6571            .borders(Borders::ALL)
6572            .title(" Press Esc to return to chat ")
6573            .border_style(Style::default().fg(Color::Green));
6574
6575        let input = Paragraph::new(app.input.as_str())
6576            .block(input_block)
6577            .wrap(Wrap { trim: false });
6578        f.render_widget(input, chunks[1]);
6579
6580        let count_info = format!(
6581            " {}/{} ",
6582            app.bus_log_state.visible_count(),
6583            app.bus_log_state.total_count()
6584        );
6585        let status_line = Line::from(vec![
6586            Span::styled(
6587                " BUS LOG ",
6588                Style::default().fg(Color::Black).bg(Color::Green),
6589            ),
6590            Span::raw(&count_info),
6591            Span::raw("| "),
6592            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6593            Span::raw(": Select | "),
6594            Span::styled("Enter", Style::default().fg(Color::Yellow)),
6595            Span::raw(": Detail | "),
6596            Span::styled("c", Style::default().fg(Color::Yellow)),
6597            Span::raw(": Clear | "),
6598            Span::styled("Esc", Style::default().fg(Color::Yellow)),
6599            Span::raw(": Back"),
6600        ]);
6601        let status = Paragraph::new(status_line);
6602        f.render_widget(status, chunks[2]);
6603        return;
6604    }
6605
6606    // Protocol registry view
6607    if app.view_mode == ViewMode::Protocol {
6608        let chunks = Layout::default()
6609            .direction(Direction::Vertical)
6610            .constraints([
6611                Constraint::Min(1),    // Protocol details
6612                Constraint::Length(3), // Input
6613                Constraint::Length(1), // Status bar
6614            ])
6615            .split(f.area());
6616
6617        render_protocol_registry(f, app, theme, chunks[0]);
6618
6619        let input_block = Block::default()
6620            .borders(Borders::ALL)
6621            .title(" Press Esc to return to chat ")
6622            .border_style(Style::default().fg(Color::Blue));
6623
6624        let input = Paragraph::new(app.input.as_str())
6625            .block(input_block)
6626            .wrap(Wrap { trim: false });
6627        f.render_widget(input, chunks[1]);
6628
6629        let cards = app.protocol_cards();
6630        let status_line = Line::from(vec![
6631            Span::styled(
6632                " PROTOCOL REGISTRY ",
6633                Style::default().fg(Color::Black).bg(Color::Blue),
6634            ),
6635            Span::raw(format!(" {} cards | ", cards.len())),
6636            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6637            Span::raw(": Select | "),
6638            Span::styled("PgUp/PgDn", Style::default().fg(Color::Yellow)),
6639            Span::raw(": Scroll detail | "),
6640            Span::styled("Esc", Style::default().fg(Color::Yellow)),
6641            Span::raw(": Back"),
6642        ]);
6643        let status = Paragraph::new(status_line);
6644        f.render_widget(status, chunks[2]);
6645        return;
6646    }
6647
6648    // Model picker view
6649    if app.view_mode == ViewMode::ModelPicker {
6650        let area = centered_rect(70, 70, f.area());
6651        f.render_widget(Clear, area);
6652
6653        let filter_display = if app.model_picker_filter.is_empty() {
6654            "type to filter".to_string()
6655        } else {
6656            format!("filter: {}", app.model_picker_filter)
6657        };
6658
6659        let picker_block = Block::default()
6660            .borders(Borders::ALL)
6661            .title(format!(
6662                " Select Model (↑↓ navigate, Enter select, Esc cancel) [{}] ",
6663                filter_display
6664            ))
6665            .border_style(Style::default().fg(Color::Magenta));
6666
6667        let filtered = app.filtered_models();
6668        let mut list_lines: Vec<Line> = Vec::new();
6669        list_lines.push(Line::from(""));
6670
6671        if let Some(ref active) = app.active_model {
6672            list_lines.push(Line::styled(
6673                format!("  Current: {}", active),
6674                Style::default()
6675                    .fg(Color::Green)
6676                    .add_modifier(Modifier::DIM),
6677            ));
6678            list_lines.push(Line::from(""));
6679        }
6680
6681        if filtered.is_empty() {
6682            list_lines.push(Line::styled(
6683                "  No models match filter",
6684                Style::default().fg(Color::DarkGray),
6685            ));
6686        } else {
6687            let mut current_provider = String::new();
6688            for (display_idx, (_, (label, _, human_name))) in filtered.iter().enumerate() {
6689                let provider = label.split('/').next().unwrap_or("");
6690                if provider != current_provider {
6691                    if !current_provider.is_empty() {
6692                        list_lines.push(Line::from(""));
6693                    }
6694                    list_lines.push(Line::styled(
6695                        format!("  ─── {} ───", provider),
6696                        Style::default()
6697                            .fg(Color::Cyan)
6698                            .add_modifier(Modifier::BOLD),
6699                    ));
6700                    current_provider = provider.to_string();
6701                }
6702
6703                let is_selected = display_idx == app.model_picker_selected;
6704                let is_active = app.active_model.as_deref() == Some(label.as_str());
6705                let marker = if is_selected { "▶" } else { " " };
6706                let active_marker = if is_active { " ✓" } else { "" };
6707                let model_id = label.split('/').skip(1).collect::<Vec<_>>().join("/");
6708                // Show human name if different from ID
6709                let display = if human_name != &model_id && !human_name.is_empty() {
6710                    format!("{} ({})", human_name, model_id)
6711                } else {
6712                    model_id
6713                };
6714
6715                let style = if is_selected {
6716                    Style::default()
6717                        .fg(Color::Magenta)
6718                        .add_modifier(Modifier::BOLD)
6719                } else if is_active {
6720                    Style::default().fg(Color::Green)
6721                } else {
6722                    Style::default()
6723                };
6724
6725                list_lines.push(Line::styled(
6726                    format!("  {} {}{}", marker, display, active_marker),
6727                    style,
6728                ));
6729            }
6730        }
6731
6732        let list = Paragraph::new(list_lines)
6733            .block(picker_block)
6734            .wrap(Wrap { trim: false });
6735        f.render_widget(list, area);
6736        return;
6737    }
6738
6739    // Session picker view
6740    if app.view_mode == ViewMode::SessionPicker {
6741        let chunks = Layout::default()
6742            .direction(Direction::Vertical)
6743            .constraints([
6744                Constraint::Min(1),    // Session list
6745                Constraint::Length(1), // Status bar
6746            ])
6747            .split(f.area());
6748
6749        // Build title with filter display
6750        let filter_display = if app.session_picker_filter.is_empty() {
6751            String::new()
6752        } else {
6753            format!(" [filter: {}]", app.session_picker_filter)
6754        };
6755
6756        let list_block = Block::default()
6757            .borders(Borders::ALL)
6758            .title(format!(
6759                " Sessions (↑↓ navigate, Enter load, d delete, Esc cancel){} ",
6760                filter_display
6761            ))
6762            .border_style(Style::default().fg(Color::Cyan));
6763
6764        let mut list_lines: Vec<Line> = Vec::new();
6765        list_lines.push(Line::from(""));
6766
6767        let filtered = app.filtered_sessions();
6768        if filtered.is_empty() {
6769            if app.session_picker_filter.is_empty() {
6770                list_lines.push(Line::styled(
6771                    "  No sessions found.",
6772                    Style::default().fg(Color::DarkGray),
6773                ));
6774            } else {
6775                list_lines.push(Line::styled(
6776                    format!("  No sessions matching '{}'", app.session_picker_filter),
6777                    Style::default().fg(Color::DarkGray),
6778                ));
6779            }
6780        }
6781
6782        for (display_idx, (_orig_idx, session)) in filtered.iter().enumerate() {
6783            let is_selected = display_idx == app.session_picker_selected;
6784            let is_active = app
6785                .session
6786                .as_ref()
6787                .map(|s| s.id == session.id)
6788                .unwrap_or(false);
6789            let title = session.title.as_deref().unwrap_or("(untitled)");
6790            let date = session.updated_at.format("%Y-%m-%d %H:%M");
6791            let active_marker = if is_active { " ●" } else { "" };
6792            let line_str = format!(
6793                " {} {}{} - {} ({} msgs)",
6794                if is_selected { "▶" } else { " " },
6795                title,
6796                active_marker,
6797                date,
6798                session.message_count
6799            );
6800
6801            let style = if is_selected && app.session_picker_confirm_delete {
6802                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
6803            } else if is_selected {
6804                Style::default()
6805                    .fg(Color::Cyan)
6806                    .add_modifier(Modifier::BOLD)
6807            } else if is_active {
6808                Style::default().fg(Color::Green)
6809            } else {
6810                Style::default()
6811            };
6812
6813            list_lines.push(Line::styled(line_str, style));
6814
6815            // Show details for selected item
6816            if is_selected {
6817                if app.session_picker_confirm_delete {
6818                    list_lines.push(Line::styled(
6819                        "   ⚠ Press d again to confirm delete, Esc to cancel",
6820                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
6821                    ));
6822                } else {
6823                    list_lines.push(Line::styled(
6824                        format!("   Agent: {} | ID: {}", session.agent, session.id),
6825                        Style::default().fg(Color::DarkGray),
6826                    ));
6827                }
6828            }
6829        }
6830
6831        let list = Paragraph::new(list_lines)
6832            .block(list_block)
6833            .wrap(Wrap { trim: false });
6834        f.render_widget(list, chunks[0]);
6835
6836        // Status bar with more actions
6837        let mut status_spans = vec![
6838            Span::styled(
6839                " SESSION PICKER ",
6840                Style::default().fg(Color::Black).bg(Color::Cyan),
6841            ),
6842            Span::raw(" "),
6843            Span::styled("↑↓", Style::default().fg(Color::Yellow)),
6844            Span::raw(": Nav "),
6845            Span::styled("Enter", Style::default().fg(Color::Yellow)),
6846            Span::raw(": Load "),
6847            Span::styled("d", Style::default().fg(Color::Yellow)),
6848            Span::raw(": Delete "),
6849            Span::styled("Esc", Style::default().fg(Color::Yellow)),
6850            Span::raw(": Cancel "),
6851        ];
6852        if !app.session_picker_filter.is_empty() || !app.session_picker_list.is_empty() {
6853            status_spans.push(Span::styled("Type", Style::default().fg(Color::Yellow)));
6854            status_spans.push(Span::raw(": Filter "));
6855        }
6856        let limit = std::env::var("CODETETHER_SESSION_PICKER_LIMIT")
6857            .ok()
6858            .and_then(|v| v.parse().ok())
6859            .unwrap_or(100);
6860        // Pagination info
6861        if app.session_picker_offset > 0 || app.session_picker_list.len() >= limit {
6862            status_spans.push(Span::styled("n", Style::default().fg(Color::Yellow)));
6863            status_spans.push(Span::raw(": Next "));
6864            if app.session_picker_offset > 0 {
6865                status_spans.push(Span::styled("p", Style::default().fg(Color::Yellow)));
6866                status_spans.push(Span::raw(": Prev "));
6867            }
6868        }
6869        let total = app.session_picker_list.len();
6870        let showing = filtered.len();
6871        let offset_display = if app.session_picker_offset > 0 {
6872            format!("+{}", app.session_picker_offset)
6873        } else {
6874            String::new()
6875        };
6876        if showing < total {
6877            status_spans.push(Span::styled(
6878                format!("{}{}/{}", offset_display, showing, total),
6879                Style::default().fg(Color::DarkGray),
6880            ));
6881        }
6882
6883        let status = Paragraph::new(Line::from(status_spans));
6884        f.render_widget(status, chunks[1]);
6885        return;
6886    }
6887
6888    // Agent picker view
6889    if app.view_mode == ViewMode::AgentPicker {
6890        let area = centered_rect(70, 70, f.area());
6891        f.render_widget(Clear, area);
6892
6893        let filter_display = if app.agent_picker_filter.is_empty() {
6894            "type to filter".to_string()
6895        } else {
6896            format!("filter: {}", app.agent_picker_filter)
6897        };
6898
6899        let picker_block = Block::default()
6900            .borders(Borders::ALL)
6901            .title(format!(
6902                " Select Agent (↑↓ navigate, Enter focus, m main chat, Esc cancel) [{}] ",
6903                filter_display
6904            ))
6905            .border_style(Style::default().fg(Color::Magenta));
6906
6907        let filtered = app.filtered_spawned_agents();
6908        let mut list_lines: Vec<Line> = Vec::new();
6909        list_lines.push(Line::from(""));
6910
6911        if let Some(ref active) = app.active_spawned_agent {
6912            list_lines.push(Line::styled(
6913                format!("  Current focus: @{}", active),
6914                Style::default()
6915                    .fg(Color::Green)
6916                    .add_modifier(Modifier::DIM),
6917            ));
6918            list_lines.push(Line::from(""));
6919        }
6920
6921        if filtered.is_empty() {
6922            list_lines.push(Line::styled(
6923                "  No spawned agents match filter",
6924                Style::default().fg(Color::DarkGray),
6925            ));
6926        } else {
6927            for (display_idx, (name, instructions, is_processing, is_registered)) in
6928                filtered.iter().enumerate()
6929            {
6930                let is_selected = display_idx == app.agent_picker_selected;
6931                let is_focused = app.active_spawned_agent.as_deref() == Some(name.as_str());
6932                let marker = if is_selected { "▶" } else { " " };
6933                let focused_marker = if is_focused { " ✓" } else { "" };
6934                let status = if *is_processing { "⚡" } else { "●" };
6935                let protocol = if *is_registered { "🔗" } else { "⚠" };
6936                let avatar = agent_avatar(name);
6937
6938                let style = if is_selected {
6939                    Style::default()
6940                        .fg(Color::Magenta)
6941                        .add_modifier(Modifier::BOLD)
6942                } else if is_focused {
6943                    Style::default().fg(Color::Green)
6944                } else {
6945                    Style::default()
6946                };
6947
6948                list_lines.push(Line::styled(
6949                    format!("  {marker} {status} {protocol} {avatar} @{name}{focused_marker}"),
6950                    style,
6951                ));
6952
6953                if is_selected {
6954                    let profile = agent_profile(name);
6955                    list_lines.push(Line::styled(
6956                        format!("     profile: {} — {}", profile.codename, profile.profile),
6957                        Style::default().fg(Color::Cyan),
6958                    ));
6959                    list_lines.push(Line::styled(
6960                        format!("     {}", instructions),
6961                        Style::default().fg(Color::DarkGray),
6962                    ));
6963                    list_lines.push(Line::styled(
6964                        format!(
6965                            "     protocol: {}",
6966                            if *is_registered {
6967                                "registered"
6968                            } else {
6969                                "not registered"
6970                            }
6971                        ),
6972                        if *is_registered {
6973                            Style::default().fg(Color::Green)
6974                        } else {
6975                            Style::default().fg(Color::Yellow)
6976                        },
6977                    ));
6978                }
6979            }
6980        }
6981
6982        let list = Paragraph::new(list_lines)
6983            .block(picker_block)
6984            .wrap(Wrap { trim: false });
6985        f.render_widget(list, area);
6986        return;
6987    }
6988
6989    if app.chat_layout == ChatLayoutMode::Webview {
6990        if render_webview_chat(f, app, theme) {
6991            render_help_overlay_if_needed(f, app, theme);
6992            return;
6993        }
6994    }
6995
6996    // Chat view (default)
6997    let chunks = Layout::default()
6998        .direction(Direction::Vertical)
6999        .constraints([
7000            Constraint::Min(1),    // Messages
7001            Constraint::Length(3), // Input
7002            Constraint::Length(1), // Status bar
7003        ])
7004        .split(f.area());
7005
7006    // Messages area with theme-based styling
7007    let messages_area = chunks[0];
7008    let model_label = app.active_model.as_deref().unwrap_or("auto");
7009    let target_label = app
7010        .active_spawned_agent
7011        .as_ref()
7012        .map(|name| format!(" @{}", name))
7013        .unwrap_or_default();
7014    let messages_block = Block::default()
7015        .borders(Borders::ALL)
7016        .title(format!(
7017            " CodeTether Agent [{}{}] model:{} ",
7018            app.current_agent, target_label, model_label
7019        ))
7020        .border_style(Style::default().fg(theme.border_color.to_color()));
7021
7022    let max_width = messages_area.width.saturating_sub(4) as usize;
7023    let message_lines = build_message_lines(app, theme, max_width);
7024
7025    // Calculate scroll position
7026    let total_lines = message_lines.len();
7027    let visible_lines = messages_area.height.saturating_sub(2) as usize;
7028    let max_scroll = total_lines.saturating_sub(visible_lines);
7029    // SCROLL_BOTTOM means "stick to bottom", otherwise clamp to max_scroll
7030    let scroll = if app.scroll >= SCROLL_BOTTOM {
7031        max_scroll
7032    } else {
7033        app.scroll.min(max_scroll)
7034    };
7035
7036    // Render messages with scrolling
7037    let messages_paragraph = Paragraph::new(
7038        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
7039    )
7040    .block(messages_block.clone())
7041    .wrap(Wrap { trim: false });
7042
7043    f.render_widget(messages_paragraph, messages_area);
7044
7045    // Render scrollbar if needed
7046    if total_lines > visible_lines {
7047        let scrollbar = Scrollbar::default()
7048            .orientation(ScrollbarOrientation::VerticalRight)
7049            .symbols(ratatui::symbols::scrollbar::VERTICAL)
7050            .begin_symbol(Some("↑"))
7051            .end_symbol(Some("↓"));
7052
7053        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
7054
7055        let scrollbar_area = Rect::new(
7056            messages_area.right() - 1,
7057            messages_area.top() + 1,
7058            1,
7059            messages_area.height - 2,
7060        );
7061
7062        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
7063    }
7064
7065    // Input area
7066    let input_title = if app.is_processing {
7067        if let Some(started) = app.processing_started_at {
7068            let elapsed = started.elapsed();
7069            format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
7070        } else {
7071            " Message (Processing...) ".to_string()
7072        }
7073    } else if app.autochat_running {
7074        format!(
7075            " {} ",
7076            app.autochat_status_label()
7077                .unwrap_or_else(|| "Autochat running…".to_string())
7078        )
7079    } else if app.input.starts_with('/') {
7080        let hint = match_slash_command_hint(&app.input);
7081        format!(" {} ", hint)
7082    } else if let Some(target) = &app.active_spawned_agent {
7083        format!(" Message to @{target} (use /agent main to exit) ")
7084    } else {
7085        " Message (Enter to send, / for commands) ".to_string()
7086    };
7087    let input_block = Block::default()
7088        .borders(Borders::ALL)
7089        .title(input_title)
7090        .border_style(Style::default().fg(if app.is_processing {
7091            Color::Yellow
7092        } else if app.autochat_running {
7093            Color::Cyan
7094        } else if app.input.starts_with('/') {
7095            Color::Magenta
7096        } else {
7097            theme.input_border_color.to_color()
7098        }));
7099
7100    let input = Paragraph::new(app.input.as_str())
7101        .block(input_block)
7102        .wrap(Wrap { trim: false });
7103    f.render_widget(input, chunks[1]);
7104
7105    // Cursor
7106    f.set_cursor_position((
7107        chunks[1].x + app.cursor_position as u16 + 1,
7108        chunks[1].y + 1,
7109    ));
7110
7111    // Enhanced status bar with token display and model info
7112    let token_display = TokenDisplay::new();
7113    let mut status_line = token_display.create_status_bar(theme);
7114    let model_status = if let Some(ref active) = app.active_model {
7115        let (provider, model) = crate::provider::parse_model_string(active);
7116        format!(" {}:{} ", provider.unwrap_or("auto"), model)
7117    } else {
7118        " auto ".to_string()
7119    };
7120    status_line.spans.insert(
7121        0,
7122        Span::styled(
7123            "│ ",
7124            Style::default()
7125                .fg(theme.timestamp_color.to_color())
7126                .add_modifier(Modifier::DIM),
7127        ),
7128    );
7129    status_line.spans.insert(
7130        0,
7131        Span::styled(model_status, Style::default().fg(Color::Cyan)),
7132    );
7133    if let Some(autochat_status) = app.autochat_status_label() {
7134        status_line.spans.insert(
7135            0,
7136            Span::styled(
7137                format!(" {autochat_status} "),
7138                Style::default()
7139                    .fg(Color::Yellow)
7140                    .add_modifier(Modifier::BOLD),
7141            ),
7142        );
7143    }
7144    let status = Paragraph::new(status_line);
7145    f.render_widget(status, chunks[2]);
7146
7147    render_help_overlay_if_needed(f, app, theme);
7148}
7149
7150fn render_webview_chat(f: &mut Frame, app: &App, theme: &Theme) -> bool {
7151    let area = f.area();
7152    if area.width < 90 || area.height < 18 {
7153        return false;
7154    }
7155
7156    let main_chunks = Layout::default()
7157        .direction(Direction::Vertical)
7158        .constraints([
7159            Constraint::Length(3), // Header
7160            Constraint::Min(1),    // Body
7161            Constraint::Length(3), // Input
7162            Constraint::Length(1), // Status
7163        ])
7164        .split(area);
7165
7166    render_webview_header(f, app, theme, main_chunks[0]);
7167
7168    let body_constraints = if app.show_inspector {
7169        vec![
7170            Constraint::Length(26),
7171            Constraint::Min(40),
7172            Constraint::Length(30),
7173        ]
7174    } else {
7175        vec![Constraint::Length(26), Constraint::Min(40)]
7176    };
7177
7178    let body_chunks = Layout::default()
7179        .direction(Direction::Horizontal)
7180        .constraints(body_constraints)
7181        .split(main_chunks[1]);
7182
7183    render_webview_sidebar(f, app, theme, body_chunks[0]);
7184    render_webview_chat_center(f, app, theme, body_chunks[1]);
7185    if app.show_inspector && body_chunks.len() > 2 {
7186        render_webview_inspector(f, app, theme, body_chunks[2]);
7187    }
7188
7189    render_webview_input(f, app, theme, main_chunks[2]);
7190
7191    let token_display = TokenDisplay::new();
7192    let mut status_line = token_display.create_status_bar(theme);
7193    let model_status = if let Some(ref active) = app.active_model {
7194        let (provider, model) = crate::provider::parse_model_string(active);
7195        format!(" {}:{} ", provider.unwrap_or("auto"), model)
7196    } else {
7197        " auto ".to_string()
7198    };
7199    status_line.spans.insert(
7200        0,
7201        Span::styled(
7202            "│ ",
7203            Style::default()
7204                .fg(theme.timestamp_color.to_color())
7205                .add_modifier(Modifier::DIM),
7206        ),
7207    );
7208    status_line.spans.insert(
7209        0,
7210        Span::styled(model_status, Style::default().fg(Color::Cyan)),
7211    );
7212    if let Some(autochat_status) = app.autochat_status_label() {
7213        status_line.spans.insert(
7214            0,
7215            Span::styled(
7216                format!(" {autochat_status} "),
7217                Style::default()
7218                    .fg(Color::Yellow)
7219                    .add_modifier(Modifier::BOLD),
7220            ),
7221        );
7222    }
7223    let status = Paragraph::new(status_line);
7224    f.render_widget(status, main_chunks[3]);
7225
7226    true
7227}
7228
7229fn render_protocol_registry(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7230    let cards = app.protocol_cards();
7231    let selected = app.protocol_selected.min(cards.len().saturating_sub(1));
7232
7233    let chunks = Layout::default()
7234        .direction(Direction::Horizontal)
7235        .constraints([Constraint::Length(34), Constraint::Min(30)])
7236        .split(area);
7237
7238    let list_block = Block::default()
7239        .borders(Borders::ALL)
7240        .title(" Registered Agents ")
7241        .border_style(Style::default().fg(theme.border_color.to_color()));
7242
7243    let mut list_lines: Vec<Line> = Vec::new();
7244    if cards.is_empty() {
7245        list_lines.push(Line::styled(
7246            "No protocol-registered agents.",
7247            Style::default().fg(Color::DarkGray),
7248        ));
7249        list_lines.push(Line::styled(
7250            "Spawn an agent with /spawn.",
7251            Style::default().fg(Color::DarkGray),
7252        ));
7253    } else {
7254        for (idx, card) in cards.iter().enumerate() {
7255            let marker = if idx == selected { "▶" } else { " " };
7256            let style = if idx == selected {
7257                Style::default()
7258                    .fg(Color::Blue)
7259                    .add_modifier(Modifier::BOLD)
7260            } else {
7261                Style::default()
7262            };
7263            let transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
7264            list_lines.push(Line::styled(format!(" {marker} {}", card.name), style));
7265            list_lines.push(Line::styled(
7266                format!(
7267                    "    {transport} • {}",
7268                    truncate_with_ellipsis(&card.url, 22)
7269                ),
7270                Style::default().fg(Color::DarkGray),
7271            ));
7272        }
7273    }
7274
7275    let list = Paragraph::new(list_lines)
7276        .block(list_block)
7277        .wrap(Wrap { trim: false });
7278    f.render_widget(list, chunks[0]);
7279
7280    let detail_block = Block::default()
7281        .borders(Borders::ALL)
7282        .title(" Agent Card Detail ")
7283        .border_style(Style::default().fg(theme.border_color.to_color()));
7284
7285    let mut detail_lines: Vec<Line> = Vec::new();
7286    if let Some(card) = cards.get(selected) {
7287        let label_style = Style::default().fg(Color::DarkGray);
7288        detail_lines.push(Line::from(vec![
7289            Span::styled("Name: ", label_style),
7290            Span::styled(
7291                card.name.clone(),
7292                Style::default().add_modifier(Modifier::BOLD),
7293            ),
7294        ]));
7295        detail_lines.push(Line::from(vec![
7296            Span::styled("Description: ", label_style),
7297            Span::raw(card.description.clone()),
7298        ]));
7299        detail_lines.push(Line::from(vec![
7300            Span::styled("URL: ", label_style),
7301            Span::styled(card.url.clone(), Style::default().fg(Color::Cyan)),
7302        ]));
7303        detail_lines.push(Line::from(vec![
7304            Span::styled("Version: ", label_style),
7305            Span::raw(format!(
7306                "{} (protocol {})",
7307                card.version, card.protocol_version
7308            )),
7309        ]));
7310
7311        let preferred_transport = card.preferred_transport.as_deref().unwrap_or("JSONRPC");
7312        detail_lines.push(Line::from(vec![
7313            Span::styled("Transport: ", label_style),
7314            Span::raw(preferred_transport.to_string()),
7315        ]));
7316        if !card.additional_interfaces.is_empty() {
7317            detail_lines.push(Line::from(vec![
7318                Span::styled("Interfaces: ", label_style),
7319                Span::raw(format!("{} additional", card.additional_interfaces.len())),
7320            ]));
7321            for iface in &card.additional_interfaces {
7322                detail_lines.push(Line::styled(
7323                    format!("  • {} -> {}", iface.transport, iface.url),
7324                    Style::default().fg(Color::DarkGray),
7325                ));
7326            }
7327        }
7328
7329        detail_lines.push(Line::from(""));
7330        detail_lines.push(Line::styled(
7331            "Capabilities",
7332            Style::default().add_modifier(Modifier::BOLD),
7333        ));
7334        detail_lines.push(Line::styled(
7335            format!(
7336                "  streaming={} push_notifications={} state_history={}",
7337                card.capabilities.streaming,
7338                card.capabilities.push_notifications,
7339                card.capabilities.state_transition_history
7340            ),
7341            Style::default().fg(Color::DarkGray),
7342        ));
7343        if !card.capabilities.extensions.is_empty() {
7344            detail_lines.push(Line::styled(
7345                format!(
7346                    "  extensions: {}",
7347                    card.capabilities
7348                        .extensions
7349                        .iter()
7350                        .map(|e| e.uri.as_str())
7351                        .collect::<Vec<_>>()
7352                        .join(", ")
7353                ),
7354                Style::default().fg(Color::DarkGray),
7355            ));
7356        }
7357
7358        detail_lines.push(Line::from(""));
7359        detail_lines.push(Line::styled(
7360            format!("Skills ({})", card.skills.len()),
7361            Style::default().add_modifier(Modifier::BOLD),
7362        ));
7363        if card.skills.is_empty() {
7364            detail_lines.push(Line::styled("  none", Style::default().fg(Color::DarkGray)));
7365        } else {
7366            for skill in &card.skills {
7367                let tags = if skill.tags.is_empty() {
7368                    "".to_string()
7369                } else {
7370                    format!(" [{}]", skill.tags.join(","))
7371                };
7372                detail_lines.push(Line::styled(
7373                    format!("  • {}{}", skill.name, tags),
7374                    Style::default().fg(Color::Green),
7375                ));
7376                if !skill.description.is_empty() {
7377                    detail_lines.push(Line::styled(
7378                        format!("    {}", skill.description),
7379                        Style::default().fg(Color::DarkGray),
7380                    ));
7381                }
7382            }
7383        }
7384
7385        detail_lines.push(Line::from(""));
7386        detail_lines.push(Line::styled(
7387            "Security",
7388            Style::default().add_modifier(Modifier::BOLD),
7389        ));
7390        if card.security_schemes.is_empty() {
7391            detail_lines.push(Line::styled(
7392                "  schemes: none",
7393                Style::default().fg(Color::DarkGray),
7394            ));
7395        } else {
7396            let mut names = card.security_schemes.keys().cloned().collect::<Vec<_>>();
7397            names.sort();
7398            detail_lines.push(Line::styled(
7399                format!("  schemes: {}", names.join(", ")),
7400                Style::default().fg(Color::DarkGray),
7401            ));
7402        }
7403        detail_lines.push(Line::styled(
7404            format!("  requirements: {}", card.security.len()),
7405            Style::default().fg(Color::DarkGray),
7406        ));
7407        detail_lines.push(Line::styled(
7408            format!(
7409                "  authenticated_extended_card: {}",
7410                card.supports_authenticated_extended_card
7411            ),
7412            Style::default().fg(Color::DarkGray),
7413        ));
7414    } else {
7415        detail_lines.push(Line::styled(
7416            "No card selected.",
7417            Style::default().fg(Color::DarkGray),
7418        ));
7419    }
7420
7421    let detail = Paragraph::new(detail_lines)
7422        .block(detail_block)
7423        .wrap(Wrap { trim: false })
7424        .scroll((app.protocol_scroll as u16, 0));
7425    f.render_widget(detail, chunks[1]);
7426}
7427
7428fn render_webview_header(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7429    let session_title = app
7430        .session
7431        .as_ref()
7432        .and_then(|s| s.title.clone())
7433        .unwrap_or_else(|| "Workspace Chat".to_string());
7434    let session_id = app
7435        .session
7436        .as_ref()
7437        .map(|s| s.id.chars().take(8).collect::<String>())
7438        .unwrap_or_else(|| "new".to_string());
7439    let model_label = app
7440        .session
7441        .as_ref()
7442        .and_then(|s| s.metadata.model.clone())
7443        .unwrap_or_else(|| "auto".to_string());
7444    let workspace_label = app.workspace.root_display.clone();
7445    let branch_label = app
7446        .workspace
7447        .git_branch
7448        .clone()
7449        .unwrap_or_else(|| "no-git".to_string());
7450    let dirty_label = if app.workspace.git_dirty_files > 0 {
7451        format!("{} dirty", app.workspace.git_dirty_files)
7452    } else {
7453        "clean".to_string()
7454    };
7455
7456    let header_block = Block::default()
7457        .borders(Borders::ALL)
7458        .title(" CodeTether Webview ")
7459        .border_style(Style::default().fg(theme.border_color.to_color()));
7460
7461    let header_lines = vec![
7462        Line::from(vec![
7463            Span::styled(session_title, Style::default().add_modifier(Modifier::BOLD)),
7464            Span::raw(" "),
7465            Span::styled(
7466                format!("#{}", session_id),
7467                Style::default()
7468                    .fg(theme.timestamp_color.to_color())
7469                    .add_modifier(Modifier::DIM),
7470            ),
7471        ]),
7472        Line::from(vec![
7473            Span::styled(
7474                "Workspace ",
7475                Style::default().fg(theme.timestamp_color.to_color()),
7476            ),
7477            Span::styled(workspace_label, Style::default()),
7478            Span::raw("  "),
7479            Span::styled(
7480                "Branch ",
7481                Style::default().fg(theme.timestamp_color.to_color()),
7482            ),
7483            Span::styled(
7484                branch_label,
7485                Style::default()
7486                    .fg(Color::Cyan)
7487                    .add_modifier(Modifier::BOLD),
7488            ),
7489            Span::raw("  "),
7490            Span::styled(
7491                dirty_label,
7492                Style::default()
7493                    .fg(Color::Yellow)
7494                    .add_modifier(Modifier::BOLD),
7495            ),
7496            Span::raw("  "),
7497            Span::styled(
7498                "Model ",
7499                Style::default().fg(theme.timestamp_color.to_color()),
7500            ),
7501            Span::styled(model_label, Style::default().fg(Color::Green)),
7502        ]),
7503    ];
7504
7505    let header = Paragraph::new(header_lines)
7506        .block(header_block)
7507        .wrap(Wrap { trim: true });
7508    f.render_widget(header, area);
7509}
7510
7511fn render_webview_sidebar(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7512    let sidebar_chunks = Layout::default()
7513        .direction(Direction::Vertical)
7514        .constraints([Constraint::Min(8), Constraint::Min(6)])
7515        .split(area);
7516
7517    let workspace_block = Block::default()
7518        .borders(Borders::ALL)
7519        .title(" Workspace ")
7520        .border_style(Style::default().fg(theme.border_color.to_color()));
7521
7522    let mut workspace_lines = Vec::new();
7523    workspace_lines.push(Line::from(vec![
7524        Span::styled(
7525            "Updated ",
7526            Style::default().fg(theme.timestamp_color.to_color()),
7527        ),
7528        Span::styled(
7529            app.workspace.captured_at.clone(),
7530            Style::default().fg(theme.timestamp_color.to_color()),
7531        ),
7532    ]));
7533    workspace_lines.push(Line::from(""));
7534
7535    if app.workspace.entries.is_empty() {
7536        workspace_lines.push(Line::styled(
7537            "No entries found",
7538            Style::default().fg(Color::DarkGray),
7539        ));
7540    } else {
7541        for entry in app.workspace.entries.iter().take(12) {
7542            let icon = match entry.kind {
7543                WorkspaceEntryKind::Directory => "📁",
7544                WorkspaceEntryKind::File => "📄",
7545            };
7546            workspace_lines.push(Line::from(vec![
7547                Span::styled(icon, Style::default().fg(Color::Cyan)),
7548                Span::raw(" "),
7549                Span::styled(entry.name.clone(), Style::default()),
7550            ]));
7551        }
7552    }
7553
7554    workspace_lines.push(Line::from(""));
7555    workspace_lines.push(Line::styled(
7556        "Use /refresh to rescan",
7557        Style::default()
7558            .fg(Color::DarkGray)
7559            .add_modifier(Modifier::DIM),
7560    ));
7561
7562    let workspace_panel = Paragraph::new(workspace_lines)
7563        .block(workspace_block)
7564        .wrap(Wrap { trim: true });
7565    f.render_widget(workspace_panel, sidebar_chunks[0]);
7566
7567    let sessions_block = Block::default()
7568        .borders(Borders::ALL)
7569        .title(" Recent Sessions ")
7570        .border_style(Style::default().fg(theme.border_color.to_color()));
7571
7572    let mut session_lines = Vec::new();
7573    if app.session_picker_list.is_empty() {
7574        session_lines.push(Line::styled(
7575            "No sessions yet",
7576            Style::default().fg(Color::DarkGray),
7577        ));
7578    } else {
7579        for session in app.session_picker_list.iter().take(6) {
7580            let is_active = app
7581                .session
7582                .as_ref()
7583                .map(|s| s.id == session.id)
7584                .unwrap_or(false);
7585            let title = session.title.as_deref().unwrap_or("(untitled)");
7586            let indicator = if is_active { "●" } else { "○" };
7587            let line_style = if is_active {
7588                Style::default()
7589                    .fg(Color::Cyan)
7590                    .add_modifier(Modifier::BOLD)
7591            } else {
7592                Style::default()
7593            };
7594            session_lines.push(Line::from(vec![
7595                Span::styled(indicator, line_style),
7596                Span::raw(" "),
7597                Span::styled(title, line_style),
7598            ]));
7599            session_lines.push(Line::styled(
7600                format!(
7601                    "  {} msgs • {}",
7602                    session.message_count,
7603                    session.updated_at.format("%m-%d %H:%M")
7604                ),
7605                Style::default().fg(Color::DarkGray),
7606            ));
7607        }
7608    }
7609
7610    let sessions_panel = Paragraph::new(session_lines)
7611        .block(sessions_block)
7612        .wrap(Wrap { trim: true });
7613    f.render_widget(sessions_panel, sidebar_chunks[1]);
7614}
7615
7616fn render_webview_chat_center(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7617    let messages_area = area;
7618    let focused_suffix = app
7619        .active_spawned_agent
7620        .as_ref()
7621        .map(|name| format!(" → @{name}"))
7622        .unwrap_or_default();
7623    let messages_block = Block::default()
7624        .borders(Borders::ALL)
7625        .title(format!(" Chat [{}{}] ", app.current_agent, focused_suffix))
7626        .border_style(Style::default().fg(theme.border_color.to_color()));
7627
7628    let max_width = messages_area.width.saturating_sub(4) as usize;
7629    let message_lines = build_message_lines(app, theme, max_width);
7630
7631    let total_lines = message_lines.len();
7632    let visible_lines = messages_area.height.saturating_sub(2) as usize;
7633    let max_scroll = total_lines.saturating_sub(visible_lines);
7634    let scroll = if app.scroll >= SCROLL_BOTTOM {
7635        max_scroll
7636    } else {
7637        app.scroll.min(max_scroll)
7638    };
7639
7640    let messages_paragraph = Paragraph::new(
7641        message_lines[scroll..(scroll + visible_lines.min(total_lines)).min(total_lines)].to_vec(),
7642    )
7643    .block(messages_block.clone())
7644    .wrap(Wrap { trim: false });
7645
7646    f.render_widget(messages_paragraph, messages_area);
7647
7648    if total_lines > visible_lines {
7649        let scrollbar = Scrollbar::default()
7650            .orientation(ScrollbarOrientation::VerticalRight)
7651            .symbols(ratatui::symbols::scrollbar::VERTICAL)
7652            .begin_symbol(Some("↑"))
7653            .end_symbol(Some("↓"));
7654
7655        let mut scrollbar_state = ScrollbarState::new(total_lines).position(scroll);
7656
7657        let scrollbar_area = Rect::new(
7658            messages_area.right() - 1,
7659            messages_area.top() + 1,
7660            1,
7661            messages_area.height - 2,
7662        );
7663
7664        f.render_stateful_widget(scrollbar, scrollbar_area, &mut scrollbar_state);
7665    }
7666}
7667
7668fn render_webview_inspector(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7669    let block = Block::default()
7670        .borders(Borders::ALL)
7671        .title(" Inspector ")
7672        .border_style(Style::default().fg(theme.border_color.to_color()));
7673
7674    let status_label = if app.is_processing {
7675        "Processing"
7676    } else if app.autochat_running {
7677        "Autochat"
7678    } else {
7679        "Idle"
7680    };
7681    let status_style = if app.is_processing {
7682        Style::default()
7683            .fg(Color::Yellow)
7684            .add_modifier(Modifier::BOLD)
7685    } else if app.autochat_running {
7686        Style::default()
7687            .fg(Color::Cyan)
7688            .add_modifier(Modifier::BOLD)
7689    } else {
7690        Style::default().fg(Color::Green)
7691    };
7692    let tool_label = app
7693        .current_tool
7694        .clone()
7695        .unwrap_or_else(|| "none".to_string());
7696    let message_count = app.messages.len();
7697    let session_id = app
7698        .session
7699        .as_ref()
7700        .map(|s| s.id.chars().take(8).collect::<String>())
7701        .unwrap_or_else(|| "new".to_string());
7702    let model_label = app
7703        .active_model
7704        .as_deref()
7705        .or_else(|| {
7706            app.session
7707                .as_ref()
7708                .and_then(|s| s.metadata.model.as_deref())
7709        })
7710        .unwrap_or("auto");
7711    let conversation_depth = app.session.as_ref().map(|s| s.messages.len()).unwrap_or(0);
7712
7713    let label_style = Style::default().fg(theme.timestamp_color.to_color());
7714
7715    let mut lines = Vec::new();
7716    lines.push(Line::from(vec![
7717        Span::styled("Status: ", label_style),
7718        Span::styled(status_label, status_style),
7719    ]));
7720
7721    // Show elapsed time when processing
7722    if let Some(started) = app.processing_started_at {
7723        let elapsed = started.elapsed();
7724        let elapsed_str = if elapsed.as_secs() >= 60 {
7725            format!("{}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
7726        } else {
7727            format!("{:.1}s", elapsed.as_secs_f64())
7728        };
7729        lines.push(Line::from(vec![
7730            Span::styled("Elapsed: ", label_style),
7731            Span::styled(
7732                elapsed_str,
7733                Style::default()
7734                    .fg(Color::Yellow)
7735                    .add_modifier(Modifier::BOLD),
7736            ),
7737        ]));
7738    }
7739
7740    if app.autochat_running {
7741        if let Some(status) = app.autochat_status_label() {
7742            lines.push(Line::from(vec![
7743                Span::styled("Relay: ", label_style),
7744                Span::styled(status, Style::default().fg(Color::Cyan)),
7745            ]));
7746        }
7747    }
7748
7749    lines.push(Line::from(vec![
7750        Span::styled("Tool: ", label_style),
7751        Span::styled(
7752            tool_label,
7753            if app.current_tool.is_some() {
7754                Style::default()
7755                    .fg(Color::Cyan)
7756                    .add_modifier(Modifier::BOLD)
7757            } else {
7758                Style::default().fg(Color::DarkGray)
7759            },
7760        ),
7761    ]));
7762    lines.push(Line::from(""));
7763    lines.push(Line::styled(
7764        "Session",
7765        Style::default().add_modifier(Modifier::BOLD),
7766    ));
7767    lines.push(Line::from(vec![
7768        Span::styled("ID: ", label_style),
7769        Span::styled(format!("#{}", session_id), Style::default().fg(Color::Cyan)),
7770    ]));
7771    lines.push(Line::from(vec![
7772        Span::styled("Model: ", label_style),
7773        Span::styled(model_label.to_string(), Style::default().fg(Color::Green)),
7774    ]));
7775    let agent_display = if let Some(target) = &app.active_spawned_agent {
7776        format!("{} → @{} (focused)", app.current_agent, target)
7777    } else {
7778        app.current_agent.clone()
7779    };
7780    lines.push(Line::from(vec![
7781        Span::styled("Agent: ", label_style),
7782        Span::styled(agent_display, Style::default()),
7783    ]));
7784    lines.push(Line::from(vec![
7785        Span::styled("Messages: ", label_style),
7786        Span::styled(message_count.to_string(), Style::default()),
7787    ]));
7788    lines.push(Line::from(vec![
7789        Span::styled("Context: ", label_style),
7790        Span::styled(format!("{} turns", conversation_depth), Style::default()),
7791    ]));
7792    lines.push(Line::from(vec![
7793        Span::styled("Tools used: ", label_style),
7794        Span::styled(app.tool_call_count.to_string(), Style::default()),
7795    ]));
7796    lines.push(Line::from(vec![
7797        Span::styled("Protocol: ", label_style),
7798        Span::styled(
7799            format!("{} registered", app.protocol_registered_count()),
7800            Style::default().fg(Color::Cyan),
7801        ),
7802    ]));
7803    lines.push(Line::from(vec![
7804        Span::styled("Archive: ", label_style),
7805        Span::styled(
7806            format!("{} records", app.archived_message_count),
7807            Style::default(),
7808        ),
7809    ]));
7810    let sync_style = if app.chat_sync_last_error.is_some() {
7811        Style::default().fg(Color::Red)
7812    } else if app.chat_sync_rx.is_some() {
7813        Style::default().fg(Color::Green)
7814    } else {
7815        Style::default().fg(Color::DarkGray)
7816    };
7817    lines.push(Line::from(vec![
7818        Span::styled("Remote sync: ", label_style),
7819        Span::styled(
7820            app.chat_sync_status
7821                .as_deref()
7822                .unwrap_or("disabled")
7823                .to_string(),
7824            sync_style,
7825        ),
7826    ]));
7827    lines.push(Line::from(""));
7828    lines.push(Line::styled(
7829        "Sub-agents",
7830        Style::default().add_modifier(Modifier::BOLD),
7831    ));
7832    if app.spawned_agents.is_empty() {
7833        lines.push(Line::styled(
7834            "None (use /spawn <name> <instructions>)",
7835            Style::default().fg(Color::DarkGray),
7836        ));
7837    } else {
7838        for (name, agent) in app.spawned_agents.iter().take(4) {
7839            let status = if agent.is_processing { "⚡" } else { "●" };
7840            let is_registered = app.is_agent_protocol_registered(name);
7841            let protocol = if is_registered { "🔗" } else { "⚠" };
7842            let focused = if app.active_spawned_agent.as_deref() == Some(name.as_str()) {
7843                " [focused]"
7844            } else {
7845                ""
7846            };
7847            lines.push(Line::styled(
7848                format!(
7849                    "{status} {protocol} {} @{name}{focused}",
7850                    agent_avatar(name)
7851                ),
7852                if focused.is_empty() {
7853                    Style::default().fg(Color::Magenta)
7854                } else {
7855                    Style::default()
7856                        .fg(Color::Magenta)
7857                        .add_modifier(Modifier::BOLD)
7858                },
7859            ));
7860            let profile = agent_profile(name);
7861            lines.push(Line::styled(
7862                format!("   {} — {}", profile.codename, profile.profile),
7863                Style::default().fg(Color::Cyan).add_modifier(Modifier::DIM),
7864            ));
7865            lines.push(Line::styled(
7866                format!("   {}", agent.instructions),
7867                Style::default()
7868                    .fg(Color::DarkGray)
7869                    .add_modifier(Modifier::DIM),
7870            ));
7871            if is_registered {
7872                lines.push(Line::styled(
7873                    format!("   bus://local/{name}"),
7874                    Style::default()
7875                        .fg(Color::Green)
7876                        .add_modifier(Modifier::DIM),
7877                ));
7878            }
7879        }
7880        if app.spawned_agents.len() > 4 {
7881            lines.push(Line::styled(
7882                format!("… and {} more", app.spawned_agents.len() - 4),
7883                Style::default()
7884                    .fg(Color::DarkGray)
7885                    .add_modifier(Modifier::DIM),
7886            ));
7887        }
7888    }
7889    lines.push(Line::from(""));
7890    lines.push(Line::styled(
7891        "Shortcuts",
7892        Style::default().add_modifier(Modifier::BOLD),
7893    ));
7894    lines.push(Line::from(vec![
7895        Span::styled("F3      ", Style::default().fg(Color::Yellow)),
7896        Span::styled("Inspector", Style::default().fg(Color::DarkGray)),
7897    ]));
7898    lines.push(Line::from(vec![
7899        Span::styled("Ctrl+B  ", Style::default().fg(Color::Yellow)),
7900        Span::styled("Layout", Style::default().fg(Color::DarkGray)),
7901    ]));
7902    lines.push(Line::from(vec![
7903        Span::styled("Ctrl+Y  ", Style::default().fg(Color::Yellow)),
7904        Span::styled("Copy", Style::default().fg(Color::DarkGray)),
7905    ]));
7906    lines.push(Line::from(vec![
7907        Span::styled("Ctrl+M  ", Style::default().fg(Color::Yellow)),
7908        Span::styled("Model", Style::default().fg(Color::DarkGray)),
7909    ]));
7910    lines.push(Line::from(vec![
7911        Span::styled("Ctrl+S  ", Style::default().fg(Color::Yellow)),
7912        Span::styled("Swarm", Style::default().fg(Color::DarkGray)),
7913    ]));
7914    lines.push(Line::from(vec![
7915        Span::styled("?       ", Style::default().fg(Color::Yellow)),
7916        Span::styled("Help", Style::default().fg(Color::DarkGray)),
7917    ]));
7918
7919    let panel = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
7920    f.render_widget(panel, area);
7921}
7922
7923fn render_webview_input(f: &mut Frame, app: &App, theme: &Theme, area: Rect) {
7924    let title = if app.is_processing {
7925        if let Some(started) = app.processing_started_at {
7926            let elapsed = started.elapsed();
7927            format!(" Processing ({:.0}s)... ", elapsed.as_secs_f64())
7928        } else {
7929            " Message (Processing...) ".to_string()
7930        }
7931    } else if app.autochat_running {
7932        format!(
7933            " {} ",
7934            app.autochat_status_label()
7935                .unwrap_or_else(|| "Autochat running…".to_string())
7936        )
7937    } else if app.input.starts_with('/') {
7938        // Show matching slash commands as hints
7939        let hint = match_slash_command_hint(&app.input);
7940        format!(" {} ", hint)
7941    } else if let Some(target) = &app.active_spawned_agent {
7942        format!(" Message to @{target} (use /agent main to exit) ")
7943    } else {
7944        " Message (Enter to send, / for commands) ".to_string()
7945    };
7946
7947    let input_block = Block::default()
7948        .borders(Borders::ALL)
7949        .title(title)
7950        .border_style(Style::default().fg(if app.is_processing {
7951            Color::Yellow
7952        } else if app.autochat_running {
7953            Color::Cyan
7954        } else if app.input.starts_with('/') {
7955            Color::Magenta
7956        } else {
7957            theme.input_border_color.to_color()
7958        }));
7959
7960    let input = Paragraph::new(app.input.as_str())
7961        .block(input_block)
7962        .wrap(Wrap { trim: false });
7963    f.render_widget(input, area);
7964
7965    f.set_cursor_position((area.x + app.cursor_position as u16 + 1, area.y + 1));
7966}
7967
7968fn build_message_lines(app: &App, theme: &Theme, max_width: usize) -> Vec<Line<'static>> {
7969    let mut message_lines = Vec::new();
7970    let separator_width = max_width.min(60);
7971
7972    for (idx, message) in app.messages.iter().enumerate() {
7973        let role_style = theme.get_role_style(&message.role);
7974
7975        // Add a thin separator between messages (not before the first)
7976        if idx > 0 {
7977            let sep_char = match message.role.as_str() {
7978                "tool" => "·",
7979                _ => "─",
7980            };
7981            message_lines.push(Line::from(Span::styled(
7982                sep_char.repeat(separator_width),
7983                Style::default()
7984                    .fg(theme.timestamp_color.to_color())
7985                    .add_modifier(Modifier::DIM),
7986            )));
7987        }
7988
7989        // Role icons for better visual hierarchy
7990        let role_icon = match message.role.as_str() {
7991            "user" => "▸ ",
7992            "assistant" => "◆ ",
7993            "system" => "⚙ ",
7994            "tool" => "⚡",
7995            _ => "  ",
7996        };
7997
7998        let header_line = {
7999            let mut spans = vec![
8000                Span::styled(
8001                    format!("[{}] ", message.timestamp),
8002                    Style::default()
8003                        .fg(theme.timestamp_color.to_color())
8004                        .add_modifier(Modifier::DIM),
8005                ),
8006                Span::styled(role_icon, role_style),
8007                Span::styled(message.role.clone(), role_style),
8008            ];
8009            if let Some(ref agent) = message.agent_name {
8010                let profile = agent_profile(agent);
8011                spans.push(Span::styled(
8012                    format!(" {} @{agent} ‹{}›", agent_avatar(agent), profile.codename),
8013                    Style::default()
8014                        .fg(Color::Magenta)
8015                        .add_modifier(Modifier::BOLD),
8016                ));
8017            }
8018            Line::from(spans)
8019        };
8020        message_lines.push(header_line);
8021
8022        match &message.message_type {
8023            MessageType::ToolCall {
8024                name,
8025                arguments_preview,
8026                arguments_len,
8027                truncated,
8028            } => {
8029                let tool_header = Line::from(vec![
8030                    Span::styled("  🔧 ", Style::default().fg(Color::Yellow)),
8031                    Span::styled(
8032                        format!("Tool: {}", name),
8033                        Style::default()
8034                            .fg(Color::Yellow)
8035                            .add_modifier(Modifier::BOLD),
8036                    ),
8037                ]);
8038                message_lines.push(tool_header);
8039
8040                if arguments_preview.trim().is_empty() {
8041                    message_lines.push(Line::from(vec![
8042                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8043                        Span::styled(
8044                            "(no arguments)",
8045                            Style::default()
8046                                .fg(Color::DarkGray)
8047                                .add_modifier(Modifier::DIM),
8048                        ),
8049                    ]));
8050                } else {
8051                    for line in arguments_preview.lines() {
8052                        let args_line = Line::from(vec![
8053                            Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8054                            Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
8055                        ]);
8056                        message_lines.push(args_line);
8057                    }
8058                }
8059
8060                if *truncated {
8061                    let args_line = Line::from(vec![
8062                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8063                        Span::styled(
8064                            format!("... (truncated; {} bytes)", arguments_len),
8065                            Style::default()
8066                                .fg(Color::DarkGray)
8067                                .add_modifier(Modifier::DIM),
8068                        ),
8069                    ]);
8070                    message_lines.push(args_line);
8071                }
8072            }
8073            MessageType::ToolResult {
8074                name,
8075                output_preview,
8076                output_len,
8077                truncated,
8078                success,
8079                duration_ms,
8080            } => {
8081                let icon = if *success { "✅" } else { "❌" };
8082                let result_header = Line::from(vec![
8083                    Span::styled(
8084                        format!("  {icon} "),
8085                        Style::default().fg(if *success { Color::Green } else { Color::Red }),
8086                    ),
8087                    Span::styled(
8088                        format!("Result from {}", name),
8089                        Style::default()
8090                            .fg(if *success { Color::Green } else { Color::Red })
8091                            .add_modifier(Modifier::BOLD),
8092                    ),
8093                ]);
8094                message_lines.push(result_header);
8095
8096                let status_line = format!(
8097                    "  │ status: {}{}",
8098                    if *success { "success" } else { "failure" },
8099                    duration_ms
8100                        .map(|ms| format!(" • {}", format_duration_ms(ms)))
8101                        .unwrap_or_default()
8102                );
8103                message_lines.push(Line::from(vec![
8104                    Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8105                    Span::styled(
8106                        status_line.trim_start_matches("  │ ").to_string(),
8107                        Style::default().fg(Color::DarkGray),
8108                    ),
8109                ]));
8110
8111                if output_preview.trim().is_empty() {
8112                    message_lines.push(Line::from(vec![
8113                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8114                        Span::styled(
8115                            "(empty output)",
8116                            Style::default()
8117                                .fg(Color::DarkGray)
8118                                .add_modifier(Modifier::DIM),
8119                        ),
8120                    ]));
8121                } else {
8122                    for line in output_preview.lines() {
8123                        let output_line = Line::from(vec![
8124                            Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8125                            Span::styled(line.to_string(), Style::default().fg(Color::DarkGray)),
8126                        ]);
8127                        message_lines.push(output_line);
8128                    }
8129                }
8130
8131                if *truncated {
8132                    message_lines.push(Line::from(vec![
8133                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8134                        Span::styled(
8135                            format!("... (truncated; {} bytes)", output_len),
8136                            Style::default()
8137                                .fg(Color::DarkGray)
8138                                .add_modifier(Modifier::DIM),
8139                        ),
8140                    ]));
8141                }
8142            }
8143            MessageType::Text(text) => {
8144                let formatter = MessageFormatter::new(max_width);
8145                let formatted_content = formatter.format_content(text, &message.role);
8146                message_lines.extend(formatted_content);
8147            }
8148            MessageType::Thinking(text) => {
8149                let thinking_style = Style::default()
8150                    .fg(Color::DarkGray)
8151                    .add_modifier(Modifier::DIM | Modifier::ITALIC);
8152                message_lines.push(Line::from(Span::styled(
8153                    "  💭 Thinking...",
8154                    Style::default()
8155                        .fg(Color::Magenta)
8156                        .add_modifier(Modifier::DIM),
8157                )));
8158                // Show truncated thinking content
8159                let max_thinking_lines = 8;
8160                let mut iter = text.lines();
8161                let mut shown = 0usize;
8162                while shown < max_thinking_lines {
8163                    let Some(line) = iter.next() else { break };
8164                    message_lines.push(Line::from(vec![
8165                        Span::styled("  │ ", Style::default().fg(Color::DarkGray)),
8166                        Span::styled(line.to_string(), thinking_style),
8167                    ]));
8168                    shown += 1;
8169                }
8170                if iter.next().is_some() {
8171                    message_lines.push(Line::from(Span::styled(
8172                        "  │ ... (truncated)",
8173                        thinking_style,
8174                    )));
8175                }
8176            }
8177            MessageType::Image { url, mime_type } => {
8178                let formatter = MessageFormatter::new(max_width);
8179                let image_line = formatter.format_image(url, mime_type.as_deref());
8180                message_lines.push(image_line);
8181            }
8182            MessageType::File { path, mime_type } => {
8183                let mime_label = mime_type.as_deref().unwrap_or("unknown type");
8184                let file_header = Line::from(vec![
8185                    Span::styled("  📎 ", Style::default().fg(Color::Cyan)),
8186                    Span::styled(
8187                        format!("File: {}", path),
8188                        Style::default()
8189                            .fg(Color::Cyan)
8190                            .add_modifier(Modifier::BOLD),
8191                    ),
8192                    Span::styled(
8193                        format!(" ({})", mime_label),
8194                        Style::default()
8195                            .fg(Color::DarkGray)
8196                            .add_modifier(Modifier::DIM),
8197                    ),
8198                ]);
8199                message_lines.push(file_header);
8200            }
8201        }
8202
8203        // Show usage indicator after assistant messages
8204        if message.role == "assistant" {
8205            if let Some(ref meta) = message.usage_meta {
8206                let duration_str = if meta.duration_ms >= 60_000 {
8207                    format!(
8208                        "{}m{:02}.{}s",
8209                        meta.duration_ms / 60_000,
8210                        (meta.duration_ms % 60_000) / 1000,
8211                        (meta.duration_ms % 1000) / 100
8212                    )
8213                } else {
8214                    format!(
8215                        "{}.{}s",
8216                        meta.duration_ms / 1000,
8217                        (meta.duration_ms % 1000) / 100
8218                    )
8219                };
8220                let tokens_str =
8221                    format!("{}→{} tokens", meta.prompt_tokens, meta.completion_tokens);
8222                let cost_str = match meta.cost_usd {
8223                    Some(c) if c < 0.01 => format!("${:.4}", c),
8224                    Some(c) => format!("${:.2}", c),
8225                    None => String::new(),
8226                };
8227                let dim_style = Style::default()
8228                    .fg(theme.timestamp_color.to_color())
8229                    .add_modifier(Modifier::DIM);
8230                let mut spans = vec![Span::styled(
8231                    format!("  ⏱ {} │ 📊 {}", duration_str, tokens_str),
8232                    dim_style,
8233                )];
8234                if !cost_str.is_empty() {
8235                    spans.push(Span::styled(format!(" │ 💰 {}", cost_str), dim_style));
8236                }
8237                message_lines.push(Line::from(spans));
8238            }
8239        }
8240
8241        message_lines.push(Line::from(""));
8242    }
8243
8244    // Show streaming text preview (text arriving before TextComplete finalizes it)
8245    if let Some(ref streaming) = app.streaming_text {
8246        if !streaming.is_empty() {
8247            message_lines.push(Line::from(Span::styled(
8248                "─".repeat(separator_width),
8249                Style::default()
8250                    .fg(theme.timestamp_color.to_color())
8251                    .add_modifier(Modifier::DIM),
8252            )));
8253            message_lines.push(Line::from(vec![
8254                Span::styled(
8255                    format!("[{}] ", chrono::Local::now().format("%H:%M")),
8256                    Style::default()
8257                        .fg(theme.timestamp_color.to_color())
8258                        .add_modifier(Modifier::DIM),
8259                ),
8260                Span::styled("◆ ", theme.get_role_style("assistant")),
8261                Span::styled("assistant", theme.get_role_style("assistant")),
8262                Span::styled(
8263                    " (streaming...)",
8264                    Style::default()
8265                        .fg(theme.timestamp_color.to_color())
8266                        .add_modifier(Modifier::DIM),
8267                ),
8268            ]));
8269            let formatter = MessageFormatter::new(max_width);
8270            let formatted = formatter.format_content(streaming, "assistant");
8271            message_lines.extend(formatted);
8272            message_lines.push(Line::from(""));
8273        }
8274    }
8275
8276    let mut agent_streams = app.streaming_agent_texts.iter().collect::<Vec<_>>();
8277    agent_streams.sort_by(|(a, _), (b, _)| a.to_lowercase().cmp(&b.to_lowercase()));
8278    for (agent, streaming) in agent_streams {
8279        if streaming.is_empty() {
8280            continue;
8281        }
8282
8283        let profile = agent_profile(agent);
8284
8285        message_lines.push(Line::from(Span::styled(
8286            "─".repeat(separator_width),
8287            Style::default()
8288                .fg(theme.timestamp_color.to_color())
8289                .add_modifier(Modifier::DIM),
8290        )));
8291        message_lines.push(Line::from(vec![
8292            Span::styled(
8293                format!("[{}] ", chrono::Local::now().format("%H:%M")),
8294                Style::default()
8295                    .fg(theme.timestamp_color.to_color())
8296                    .add_modifier(Modifier::DIM),
8297            ),
8298            Span::styled("◆ ", theme.get_role_style("assistant")),
8299            Span::styled("assistant", theme.get_role_style("assistant")),
8300            Span::styled(
8301                format!(" {} @{} ‹{}›", agent_avatar(agent), agent, profile.codename),
8302                Style::default()
8303                    .fg(Color::Magenta)
8304                    .add_modifier(Modifier::BOLD),
8305            ),
8306            Span::styled(
8307                " (streaming...)",
8308                Style::default()
8309                    .fg(theme.timestamp_color.to_color())
8310                    .add_modifier(Modifier::DIM),
8311            ),
8312        ]));
8313
8314        let formatter = MessageFormatter::new(max_width);
8315        let formatted = formatter.format_content(streaming, "assistant");
8316        message_lines.extend(formatted);
8317        message_lines.push(Line::from(""));
8318    }
8319
8320    if app.is_processing {
8321        let spinner = current_spinner_frame();
8322
8323        // Elapsed time display
8324        let elapsed_str = if let Some(started) = app.processing_started_at {
8325            let elapsed = started.elapsed();
8326            if elapsed.as_secs() >= 60 {
8327                format!(" {}m{:02}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
8328            } else {
8329                format!(" {:.1}s", elapsed.as_secs_f64())
8330            }
8331        } else {
8332            String::new()
8333        };
8334
8335        let processing_line = Line::from(vec![
8336            Span::styled(
8337                format!("[{}] ", chrono::Local::now().format("%H:%M")),
8338                Style::default()
8339                    .fg(theme.timestamp_color.to_color())
8340                    .add_modifier(Modifier::DIM),
8341            ),
8342            Span::styled("◆ ", theme.get_role_style("assistant")),
8343            Span::styled("assistant", theme.get_role_style("assistant")),
8344            Span::styled(
8345                elapsed_str,
8346                Style::default()
8347                    .fg(theme.timestamp_color.to_color())
8348                    .add_modifier(Modifier::DIM),
8349            ),
8350        ]);
8351        message_lines.push(processing_line);
8352
8353        let (status_text, status_color) = if let Some(ref tool) = app.current_tool {
8354            (format!("  {spinner} Running: {}", tool), Color::Cyan)
8355        } else {
8356            (
8357                format!(
8358                    "  {} {}",
8359                    spinner,
8360                    app.processing_message.as_deref().unwrap_or("Thinking...")
8361                ),
8362                Color::Yellow,
8363            )
8364        };
8365
8366        let indicator_line = Line::from(vec![Span::styled(
8367            status_text,
8368            Style::default()
8369                .fg(status_color)
8370                .add_modifier(Modifier::BOLD),
8371        )]);
8372        message_lines.push(indicator_line);
8373        message_lines.push(Line::from(""));
8374    }
8375
8376    if app.autochat_running {
8377        let status_text = app
8378            .autochat_status_label()
8379            .unwrap_or_else(|| "Autochat running…".to_string());
8380        message_lines.push(Line::from(Span::styled(
8381            "─".repeat(separator_width),
8382            Style::default()
8383                .fg(theme.timestamp_color.to_color())
8384                .add_modifier(Modifier::DIM),
8385        )));
8386        message_lines.push(Line::from(vec![
8387            Span::styled(
8388                format!("[{}] ", chrono::Local::now().format("%H:%M")),
8389                Style::default()
8390                    .fg(theme.timestamp_color.to_color())
8391                    .add_modifier(Modifier::DIM),
8392            ),
8393            Span::styled("⚙ ", theme.get_role_style("system")),
8394            Span::styled(
8395                status_text,
8396                Style::default()
8397                    .fg(Color::Cyan)
8398                    .add_modifier(Modifier::BOLD),
8399            ),
8400        ]));
8401        message_lines.push(Line::from(""));
8402    }
8403
8404    message_lines
8405}
8406
8407fn match_slash_command_hint(input: &str) -> String {
8408    let commands = [
8409        (
8410            "/go ",
8411            "OKR-gated relay (requires approval, tracks outcomes)",
8412        ),
8413        ("/add ", "Easy mode: create a teammate"),
8414        ("/talk ", "Easy mode: message or focus a teammate"),
8415        ("/list", "Easy mode: list teammates"),
8416        ("/remove ", "Easy mode: remove a teammate"),
8417        ("/home", "Easy mode: return to main chat"),
8418        ("/help", "Open help"),
8419        ("/spawn ", "Create a named sub-agent"),
8420        ("/autochat ", "Tactical relay (fast path, no OKR tracking)"),
8421        ("/agents", "List spawned sub-agents"),
8422        ("/kill ", "Remove a spawned sub-agent"),
8423        ("/agent ", "Focus or message a spawned sub-agent"),
8424        ("/swarm ", "Run task in parallel swarm mode"),
8425        ("/ralph", "Start autonomous PRD loop"),
8426        ("/undo", "Undo last message and response"),
8427        ("/sessions", "Open session picker"),
8428        ("/resume", "Resume session or interrupted relay"),
8429        ("/new", "Start a new session"),
8430        ("/model", "Select or set model"),
8431        ("/webview", "Switch to webview layout"),
8432        ("/classic", "Switch to classic layout"),
8433        ("/inspector", "Toggle inspector pane"),
8434        ("/refresh", "Refresh workspace"),
8435        ("/archive", "Show persistent chat archive path"),
8436        ("/view", "Toggle swarm view"),
8437        ("/buslog", "Show protocol bus log"),
8438        ("/protocol", "Show protocol registry"),
8439    ];
8440
8441    let trimmed = input.trim_start();
8442    let input_lower = trimmed.to_lowercase();
8443
8444    // Exact command (or command + args) should always resolve to one hint.
8445    if let Some((cmd, desc)) = commands.iter().find(|(cmd, _)| {
8446        let key = cmd.trim_end().to_ascii_lowercase();
8447        input_lower == key || input_lower.starts_with(&(key + " "))
8448    }) {
8449        return format!("{} — {}", cmd.trim(), desc);
8450    }
8451
8452    // Fallback to prefix matching while the user is still typing.
8453    let matches: Vec<_> = commands
8454        .iter()
8455        .filter(|(cmd, _)| cmd.starts_with(&input_lower))
8456        .collect();
8457
8458    if matches.len() == 1 {
8459        format!("{} — {}", matches[0].0.trim(), matches[0].1)
8460    } else if matches.is_empty() {
8461        "Unknown command".to_string()
8462    } else {
8463        let cmds: Vec<_> = matches.iter().map(|(cmd, _)| cmd.trim()).collect();
8464        cmds.join(" | ")
8465    }
8466}
8467
8468fn command_with_optional_args<'a>(input: &'a str, command: &str) -> Option<&'a str> {
8469    let trimmed = input.trim();
8470    let rest = trimmed.strip_prefix(command)?;
8471
8472    if rest.is_empty() {
8473        return Some("");
8474    }
8475
8476    let first = rest.chars().next()?;
8477    if first.is_whitespace() {
8478        Some(rest.trim())
8479    } else {
8480        None
8481    }
8482}
8483
8484fn normalize_easy_command(input: &str) -> String {
8485    let trimmed = input.trim();
8486    if trimmed.is_empty() {
8487        return String::new();
8488    }
8489
8490    if !trimmed.starts_with('/') {
8491        return input.to_string();
8492    }
8493
8494    let mut parts = trimmed.splitn(2, char::is_whitespace);
8495    let command = parts.next().unwrap_or("");
8496    let args = parts.next().unwrap_or("").trim();
8497
8498    match command.to_ascii_lowercase().as_str() {
8499        "/go" | "/team" => {
8500            if args.is_empty() {
8501                "/autochat".to_string()
8502            } else {
8503                let mut parts = args.splitn(2, char::is_whitespace);
8504                let first = parts.next().unwrap_or("").trim();
8505                if let Ok(count) = first.parse::<usize>() {
8506                    let rest = parts.next().unwrap_or("").trim();
8507                    if rest.is_empty() {
8508                        format!("/autochat {count} {AUTOCHAT_QUICK_DEMO_TASK}")
8509                    } else {
8510                        format!("/autochat {count} {rest}")
8511                    }
8512                } else {
8513                    format!("/autochat {AUTOCHAT_DEFAULT_AGENTS} {args}")
8514                }
8515            }
8516        }
8517        "/add" => {
8518            if args.is_empty() {
8519                "/spawn".to_string()
8520            } else {
8521                format!("/spawn {args}")
8522            }
8523        }
8524        "/list" | "/ls" => "/agents".to_string(),
8525        "/remove" | "/rm" => {
8526            if args.is_empty() {
8527                "/kill".to_string()
8528            } else {
8529                format!("/kill {args}")
8530            }
8531        }
8532        "/talk" | "/say" => {
8533            if args.is_empty() {
8534                "/agent".to_string()
8535            } else {
8536                format!("/agent {args}")
8537            }
8538        }
8539        "/focus" => {
8540            if args.is_empty() {
8541                "/agent".to_string()
8542            } else {
8543                format!("/agent {}", args.trim_start_matches('@'))
8544            }
8545        }
8546        "/home" | "/main" => "/agent main".to_string(),
8547        "/h" | "/?" => "/help".to_string(),
8548        _ => trimmed.to_string(),
8549    }
8550}
8551
8552fn is_easy_go_command(input: &str) -> bool {
8553    let command = input
8554        .trim_start()
8555        .split_whitespace()
8556        .next()
8557        .unwrap_or("")
8558        .to_ascii_lowercase();
8559
8560    matches!(command.as_str(), "/go" | "/team")
8561}
8562
8563fn is_glm5_model(model: &str) -> bool {
8564    let normalized = model.trim().to_ascii_lowercase();
8565    matches!(
8566        normalized.as_str(),
8567        "zai/glm-5" | "z-ai/glm-5" | "openrouter/z-ai/glm-5"
8568    )
8569}
8570
8571fn is_minimax_m25_model(model: &str) -> bool {
8572    let normalized = model.trim().to_ascii_lowercase();
8573    matches!(normalized.as_str(), "minimax/minimax-m2.5" | "minimax-m2.5")
8574}
8575
8576fn next_go_model(current_model: Option<&str>) -> String {
8577    match current_model {
8578        Some(model) if is_glm5_model(model) => GO_SWAP_MODEL_MINIMAX.to_string(),
8579        Some(model) if is_minimax_m25_model(model) => GO_SWAP_MODEL_GLM.to_string(),
8580        _ => GO_SWAP_MODEL_MINIMAX.to_string(),
8581    }
8582}
8583
8584fn parse_autochat_args(rest: &str) -> Option<(usize, &str)> {
8585    let rest = rest.trim();
8586    if rest.is_empty() {
8587        return None;
8588    }
8589
8590    let mut parts = rest.splitn(2, char::is_whitespace);
8591    let first = parts.next().unwrap_or("").trim();
8592    if first.is_empty() {
8593        return None;
8594    }
8595
8596    if let Ok(count) = first.parse::<usize>() {
8597        let task = parts.next().unwrap_or("").trim();
8598        if task.is_empty() {
8599            Some((count, AUTOCHAT_QUICK_DEMO_TASK))
8600        } else {
8601            Some((count, task))
8602        }
8603    } else {
8604        Some((AUTOCHAT_DEFAULT_AGENTS, rest))
8605    }
8606}
8607
8608fn normalize_for_convergence(text: &str) -> String {
8609    let mut normalized = String::with_capacity(text.len().min(512));
8610    let mut last_was_space = false;
8611
8612    for ch in text.chars() {
8613        if ch.is_ascii_alphanumeric() {
8614            normalized.push(ch.to_ascii_lowercase());
8615            last_was_space = false;
8616        } else if ch.is_whitespace() && !last_was_space {
8617            normalized.push(' ');
8618            last_was_space = true;
8619        }
8620
8621        if normalized.len() >= 280 {
8622            break;
8623        }
8624    }
8625
8626    normalized.trim().to_string()
8627}
8628
8629fn agent_profile(agent_name: &str) -> AgentProfile {
8630    let normalized = agent_name.to_ascii_lowercase();
8631
8632    if normalized.contains("planner") {
8633        return AgentProfile {
8634            codename: "Strategist",
8635            profile: "Goal decomposition specialist",
8636            personality: "calm, methodical, and dependency-aware",
8637            collaboration_style: "opens with numbered plans and explicit priorities",
8638            signature_move: "turns vague goals into concrete execution ladders",
8639        };
8640    }
8641
8642    if normalized.contains("research") {
8643        return AgentProfile {
8644            codename: "Archivist",
8645            profile: "Evidence and assumptions analyst",
8646            personality: "curious, skeptical, and detail-focused",
8647            collaboration_style: "validates claims and cites edge-case evidence",
8648            signature_move: "surfaces blind spots before implementation starts",
8649        };
8650    }
8651
8652    if normalized.contains("coder") || normalized.contains("implement") {
8653        return AgentProfile {
8654            codename: "Forge",
8655            profile: "Implementation architect",
8656            personality: "pragmatic, direct, and execution-heavy",
8657            collaboration_style: "proposes concrete code-level actions quickly",
8658            signature_move: "translates plans into shippable implementation steps",
8659        };
8660    }
8661
8662    if normalized.contains("review") {
8663        return AgentProfile {
8664            codename: "Sentinel",
8665            profile: "Quality and regression guardian",
8666            personality: "disciplined, assertive, and standards-driven",
8667            collaboration_style: "challenges weak reasoning and hardens quality",
8668            signature_move: "detects brittle assumptions and failure modes",
8669        };
8670    }
8671
8672    if normalized.contains("tester") || normalized.contains("test") {
8673        return AgentProfile {
8674            codename: "Probe",
8675            profile: "Verification strategist",
8676            personality: "adversarial in a good way, systematic, and precise",
8677            collaboration_style: "designs checks around failure-first thinking",
8678            signature_move: "builds test matrices that catch hidden breakage",
8679        };
8680    }
8681
8682    if normalized.contains("integrat") {
8683        return AgentProfile {
8684            codename: "Conductor",
8685            profile: "Cross-stream synthesis lead",
8686            personality: "balanced, diplomatic, and outcome-oriented",
8687            collaboration_style: "reconciles competing inputs into one plan",
8688            signature_move: "merges parallel work into coherent delivery",
8689        };
8690    }
8691
8692    if normalized.contains("skeptic") || normalized.contains("risk") {
8693        return AgentProfile {
8694            codename: "Radar",
8695            profile: "Risk and threat analyst",
8696            personality: "blunt, anticipatory, and protective",
8697            collaboration_style: "flags downside scenarios and mitigation paths",
8698            signature_move: "turns uncertainty into explicit risk registers",
8699        };
8700    }
8701
8702    if normalized.contains("summary") || normalized.contains("summarizer") {
8703        return AgentProfile {
8704            codename: "Beacon",
8705            profile: "Decision synthesis specialist",
8706            personality: "concise, clear, and action-first",
8707            collaboration_style: "compresses complexity into executable next steps",
8708            signature_move: "creates crisp briefings that unblock teams quickly",
8709        };
8710    }
8711
8712    let fallback_profiles = [
8713        AgentProfile {
8714            codename: "Navigator",
8715            profile: "Generalist coordinator",
8716            personality: "adaptable and context-aware",
8717            collaboration_style: "balances speed with clarity",
8718            signature_move: "keeps team momentum aligned",
8719        },
8720        AgentProfile {
8721            codename: "Vector",
8722            profile: "Execution operator",
8723            personality: "focused and deadline-driven",
8724            collaboration_style: "prefers direct action and feedback loops",
8725            signature_move: "drives ambiguous tasks toward decisions",
8726        },
8727        AgentProfile {
8728            codename: "Signal",
8729            profile: "Communication specialist",
8730            personality: "clear, friendly, and structured",
8731            collaboration_style: "frames updates for quick handoffs",
8732            signature_move: "turns noisy context into clean status",
8733        },
8734        AgentProfile {
8735            codename: "Kernel",
8736            profile: "Core-systems thinker",
8737            personality: "analytical and stable",
8738            collaboration_style: "organizes work around constraints and invariants",
8739            signature_move: "locks down the critical path early",
8740        },
8741    ];
8742
8743    let mut hash: u64 = 2_166_136_261;
8744    for byte in normalized.bytes() {
8745        hash = (hash ^ u64::from(byte)).wrapping_mul(16_777_619);
8746    }
8747    fallback_profiles[hash as usize % fallback_profiles.len()]
8748}
8749
8750fn format_agent_profile_summary(agent_name: &str) -> String {
8751    let profile = agent_profile(agent_name);
8752    format!(
8753        "{} — {} ({})",
8754        profile.codename, profile.profile, profile.personality
8755    )
8756}
8757
8758fn agent_avatar(agent_name: &str) -> &'static str {
8759    let mut hash: u64 = 2_166_136_261;
8760    for byte in agent_name.bytes() {
8761        hash = (hash ^ u64::from(byte.to_ascii_lowercase())).wrapping_mul(16_777_619);
8762    }
8763    AGENT_AVATARS[hash as usize % AGENT_AVATARS.len()]
8764}
8765
8766fn format_agent_identity(agent_name: &str) -> String {
8767    let profile = agent_profile(agent_name);
8768    format!(
8769        "{} @{} ‹{}›",
8770        agent_avatar(agent_name),
8771        agent_name,
8772        profile.codename
8773    )
8774}
8775
8776fn format_relay_participant(participant: &str) -> String {
8777    if participant.eq_ignore_ascii_case("user") {
8778        "[you]".to_string()
8779    } else {
8780        format_agent_identity(participant)
8781    }
8782}
8783
8784fn format_relay_handoff_line(relay_id: &str, round: usize, from: &str, to: &str) -> String {
8785    format!(
8786        "[relay {relay_id} • round {round}] {} → {}",
8787        format_relay_participant(from),
8788        format_relay_participant(to)
8789    )
8790}
8791
8792fn format_tool_call_arguments(name: &str, arguments: &str) -> String {
8793    // Avoid expensive JSON parsing/pretty-printing for very large payloads.
8794    // Large tool arguments are common (e.g., patches) and reformatting them provides
8795    // little value in a terminal preview.
8796    if arguments.len() > TOOL_ARGS_PRETTY_JSON_MAX_BYTES {
8797        return arguments.to_string();
8798    }
8799
8800    let parsed = match serde_json::from_str::<serde_json::Value>(arguments) {
8801        Ok(value) => value,
8802        Err(_) => return arguments.to_string(),
8803    };
8804
8805    if name == "question"
8806        && let Some(question) = parsed.get("question").and_then(serde_json::Value::as_str)
8807    {
8808        return question.to_string();
8809    }
8810
8811    serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| arguments.to_string())
8812}
8813
8814fn build_tool_arguments_preview(
8815    tool_name: &str,
8816    arguments: &str,
8817    max_lines: usize,
8818    max_bytes: usize,
8819) -> (String, bool) {
8820    // Pretty-print when reasonably sized; otherwise keep raw to avoid a heavy parse.
8821    let formatted = format_tool_call_arguments(tool_name, arguments);
8822    build_text_preview(&formatted, max_lines, max_bytes)
8823}
8824
8825/// Build a stable, size-limited preview used by the renderer.
8826///
8827/// Returns (preview_text, truncated).
8828fn build_text_preview(text: &str, max_lines: usize, max_bytes: usize) -> (String, bool) {
8829    if max_lines == 0 || max_bytes == 0 || text.is_empty() {
8830        return (String::new(), !text.is_empty());
8831    }
8832
8833    let mut out = String::new();
8834    let mut truncated = false;
8835    let mut remaining = max_bytes;
8836
8837    let mut iter = text.lines();
8838    for i in 0..max_lines {
8839        let Some(line) = iter.next() else { break };
8840
8841        // Add newline separator if needed
8842        if i > 0 {
8843            if remaining == 0 {
8844                truncated = true;
8845                break;
8846            }
8847            out.push('\n');
8848            remaining = remaining.saturating_sub(1);
8849        }
8850
8851        if remaining == 0 {
8852            truncated = true;
8853            break;
8854        }
8855
8856        if line.len() <= remaining {
8857            out.push_str(line);
8858            remaining = remaining.saturating_sub(line.len());
8859        } else {
8860            // Truncate this line to remaining bytes, respecting UTF-8 boundaries.
8861            let mut end = remaining;
8862            while end > 0 && !line.is_char_boundary(end) {
8863                end -= 1;
8864            }
8865            out.push_str(&line[..end]);
8866            truncated = true;
8867            break;
8868        }
8869    }
8870
8871    // If there are still lines left, we truncated.
8872    if !truncated && iter.next().is_some() {
8873        truncated = true;
8874    }
8875
8876    (out, truncated)
8877}
8878
8879fn truncate_with_ellipsis(value: &str, max_chars: usize) -> String {
8880    if max_chars == 0 {
8881        return String::new();
8882    }
8883
8884    let mut chars = value.chars();
8885    let mut output = String::new();
8886    for _ in 0..max_chars {
8887        if let Some(ch) = chars.next() {
8888            output.push(ch);
8889        } else {
8890            return value.to_string();
8891        }
8892    }
8893
8894    if chars.next().is_some() {
8895        format!("{output}...")
8896    } else {
8897        output
8898    }
8899}
8900
8901fn message_clipboard_text(message: &ChatMessage) -> String {
8902    let mut prefix = String::new();
8903    if let Some(agent) = &message.agent_name {
8904        prefix = format!("@{agent}\n");
8905    }
8906
8907    match &message.message_type {
8908        MessageType::Text(text) => format!("{prefix}{text}"),
8909        MessageType::Thinking(text) => format!("{prefix}{text}"),
8910        MessageType::Image { url, .. } => format!("{prefix}{url}"),
8911        MessageType::File { path, .. } => format!("{prefix}{path}"),
8912        MessageType::ToolCall {
8913            name,
8914            arguments_preview,
8915            ..
8916        } => format!("{prefix}Tool call: {name}\n{arguments_preview}"),
8917        MessageType::ToolResult {
8918            name,
8919            output_preview,
8920            ..
8921        } => format!("{prefix}Tool result: {name}\n{output_preview}"),
8922    }
8923}
8924
8925fn copy_text_to_clipboard_best_effort(text: &str) -> Result<&'static str, String> {
8926    if text.trim().is_empty() {
8927        return Err("empty text".to_string());
8928    }
8929
8930    // 1) Try system clipboard first (works locally when a clipboard provider is available)
8931    match arboard::Clipboard::new().and_then(|mut clipboard| clipboard.set_text(text.to_string())) {
8932        Ok(()) => return Ok("system clipboard"),
8933        Err(e) => {
8934            tracing::debug!(error = %e, "System clipboard unavailable; falling back to OSC52");
8935        }
8936    }
8937
8938    // 2) Fallback: OSC52 (works in many terminals, including remote SSH sessions)
8939    osc52_copy(text).map_err(|e| format!("osc52 copy failed: {e}"))?;
8940    Ok("OSC52")
8941}
8942
8943fn osc52_copy(text: &str) -> std::io::Result<()> {
8944    // OSC52 format: ESC ] 52 ; c ; <base64> BEL
8945    // Some terminals may disable OSC52 for security; we treat this as best-effort.
8946    let payload = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
8947    let seq = format!("\u{1b}]52;c;{payload}\u{07}");
8948
8949    let mut stdout = std::io::stdout();
8950    crossterm::execute!(stdout, crossterm::style::Print(seq))?;
8951    use std::io::Write;
8952    stdout.flush()?;
8953    Ok(())
8954}
8955
8956fn render_help_overlay_if_needed(f: &mut Frame, app: &App, theme: &Theme) {
8957    if !app.show_help {
8958        return;
8959    }
8960
8961    let area = centered_rect(60, 60, f.area());
8962    f.render_widget(Clear, area);
8963
8964    let token_display = TokenDisplay::new();
8965    let token_info = token_display.create_detailed_display();
8966
8967    // Model / provider info
8968    let model_section: Vec<String> = if let Some(ref active) = app.active_model {
8969        let (provider, model) = crate::provider::parse_model_string(active);
8970        let provider_label = provider.unwrap_or("auto");
8971        vec![
8972            "".to_string(),
8973            "  ACTIVE MODEL".to_string(),
8974            "  ==============".to_string(),
8975            format!("  Provider:  {}", provider_label),
8976            format!("  Model:     {}", model),
8977            format!("  Agent:     {}", app.current_agent),
8978        ]
8979    } else {
8980        vec![
8981            "".to_string(),
8982            "  ACTIVE MODEL".to_string(),
8983            "  ==============".to_string(),
8984            format!("  Provider:  auto"),
8985            format!("  Model:     (default)"),
8986            format!("  Agent:     {}", app.current_agent),
8987        ]
8988    };
8989
8990    let help_text: Vec<String> = vec![
8991        "".to_string(),
8992        "  KEYBOARD SHORTCUTS".to_string(),
8993        "  ==================".to_string(),
8994        "".to_string(),
8995        "  Enter        Send message".to_string(),
8996        "  Tab          Switch between build/plan agents".to_string(),
8997        "  Ctrl+A       Open spawned-agent picker".to_string(),
8998        "  Ctrl+M       Open model picker".to_string(),
8999        "  Ctrl+L       Protocol bus log".to_string(),
9000        "  Ctrl+P       Protocol registry".to_string(),
9001        "  Ctrl+S       Toggle swarm view".to_string(),
9002        "  Ctrl+B       Toggle webview layout".to_string(),
9003        "  Ctrl+Y       Copy latest assistant reply".to_string(),
9004        "  F3           Toggle inspector pane".to_string(),
9005        "  Ctrl+C       Quit".to_string(),
9006        "  ?            Toggle this help".to_string(),
9007        "".to_string(),
9008        "  SLASH COMMANDS (auto-complete hints shown while typing)".to_string(),
9009        "  OKR-GATED MODE (requires approval, tracks measurable outcomes)".to_string(),
9010        "  /go <task>      OKR-gated relay: draft → approve → execute → track KR progress"
9011            .to_string(),
9012        "".to_string(),
9013        "  TACTICAL MODE (fast path, no OKR tracking)".to_string(),
9014        "  /autochat [count] <task>  Immediate relay: no approval needed, no outcome tracking"
9015            .to_string(),
9016        "".to_string(),
9017        "  EASY MODE".to_string(),
9018        "  /add <name>     Create a helper teammate".to_string(),
9019        "  /talk <name> <message>  Message teammate".to_string(),
9020        "  /list           List teammates".to_string(),
9021        "  /remove <name>  Remove teammate".to_string(),
9022        "  /home           Return to main chat".to_string(),
9023        "  /help           Open this help".to_string(),
9024        "".to_string(),
9025        "  ADVANCED MODE".to_string(),
9026        "  /spawn <name> <instructions>  Create a named sub-agent".to_string(),
9027        "  /agents        List spawned sub-agents".to_string(),
9028        "  /kill <name>   Remove a spawned sub-agent".to_string(),
9029        "  /agent <name>  Focus chat on a spawned sub-agent".to_string(),
9030        "  /agent <name> <message>  Send one message to a spawned sub-agent".to_string(),
9031        "  /agent            Open spawned-agent picker".to_string(),
9032        "  /agent main|off  Exit focused sub-agent chat".to_string(),
9033        "  /swarm <task>   Run task in parallel swarm mode".to_string(),
9034        "  /ralph [path]   Start Ralph PRD loop (default: prd.json)".to_string(),
9035        "  /undo           Undo last message and response".to_string(),
9036        "  /sessions       Open session picker (filter, delete, load, n/p paginate)".to_string(),
9037        "  /resume         Resume interrupted relay or most recent session".to_string(),
9038        "  /resume <id>    Resume specific session by ID".to_string(),
9039        "  /new            Start a fresh session".to_string(),
9040        "  /model          Open model picker (or /model <name>)".to_string(),
9041        "  /view           Toggle swarm view".to_string(),
9042        "  /buslog         Show protocol bus log".to_string(),
9043        "  /protocol       Show protocol registry and AgentCards".to_string(),
9044        "  /webview        Web dashboard layout".to_string(),
9045        "  /classic        Single-pane layout".to_string(),
9046        "  /inspector      Toggle inspector pane".to_string(),
9047        "  /refresh        Refresh workspace and sessions".to_string(),
9048        "  /archive        Show persistent chat archive path".to_string(),
9049        "".to_string(),
9050        "  SESSION PICKER".to_string(),
9051        "  ↑/↓/j/k      Navigate sessions".to_string(),
9052        "  Enter         Load selected session".to_string(),
9053        "  d             Delete session (press twice to confirm)".to_string(),
9054        "  Type          Filter sessions by name/agent/ID".to_string(),
9055        "  Backspace     Clear filter character".to_string(),
9056        "  Esc           Close picker".to_string(),
9057        "".to_string(),
9058        "  VIM-STYLE NAVIGATION".to_string(),
9059        "  Alt+j        Scroll down".to_string(),
9060        "  Alt+k        Scroll up".to_string(),
9061        "  Ctrl+g       Go to top".to_string(),
9062        "  Ctrl+G       Go to bottom".to_string(),
9063        "".to_string(),
9064        "  SCROLLING".to_string(),
9065        "  Up/Down      Scroll messages".to_string(),
9066        "  PageUp/Dn    Scroll one page".to_string(),
9067        "  Alt+u/d      Scroll half page".to_string(),
9068        "".to_string(),
9069        "  COMMAND HISTORY".to_string(),
9070        "  Ctrl+R       Search history".to_string(),
9071        "  Ctrl+Up/Dn   Navigate history".to_string(),
9072        "".to_string(),
9073        "  Press ? or Esc to close".to_string(),
9074        "".to_string(),
9075    ];
9076
9077    let mut combined_text = token_info;
9078    combined_text.extend(model_section);
9079    combined_text.extend(help_text);
9080
9081    let help = Paragraph::new(combined_text.join("\n"))
9082        .block(
9083            Block::default()
9084                .borders(Borders::ALL)
9085                .title(" Help ")
9086                .border_style(Style::default().fg(theme.help_border_color.to_color())),
9087        )
9088        .wrap(Wrap { trim: false });
9089
9090    f.render_widget(help, area);
9091}
9092
9093/// Helper to create a centered rect
9094fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
9095    let popup_layout = Layout::default()
9096        .direction(Direction::Vertical)
9097        .constraints([
9098            Constraint::Percentage((100 - percent_y) / 2),
9099            Constraint::Percentage(percent_y),
9100            Constraint::Percentage((100 - percent_y) / 2),
9101        ])
9102        .split(r);
9103
9104    Layout::default()
9105        .direction(Direction::Horizontal)
9106        .constraints([
9107            Constraint::Percentage((100 - percent_x) / 2),
9108            Constraint::Percentage(percent_x),
9109            Constraint::Percentage((100 - percent_x) / 2),
9110        ])
9111        .split(popup_layout[1])[1]
9112}
9113
9114#[cfg(test)]
9115mod tests {
9116    use super::{
9117        AUTOCHAT_QUICK_DEMO_TASK, agent_avatar, agent_profile, command_with_optional_args,
9118        estimate_cost, format_agent_identity, format_relay_handoff_line, is_easy_go_command,
9119        is_secure_environment_from_values, match_slash_command_hint, minio_fallback_endpoint,
9120        next_go_model, normalize_easy_command, normalize_for_convergence, normalize_minio_endpoint,
9121        parse_autochat_args,
9122    };
9123
9124    #[test]
9125    fn command_with_optional_args_handles_bare_command() {
9126        assert_eq!(command_with_optional_args("/spawn", "/spawn"), Some(""));
9127    }
9128
9129    #[test]
9130    fn command_with_optional_args_handles_arguments() {
9131        assert_eq!(
9132            command_with_optional_args("/spawn planner you plan", "/spawn"),
9133            Some("planner you plan")
9134        );
9135    }
9136
9137    #[test]
9138    fn command_with_optional_args_ignores_prefix_collisions() {
9139        assert_eq!(command_with_optional_args("/spawned", "/spawn"), None);
9140    }
9141
9142    #[test]
9143    fn command_with_optional_args_ignores_autochat_prefix_collisions() {
9144        assert_eq!(command_with_optional_args("/autochatty", "/autochat"), None);
9145    }
9146
9147    #[test]
9148    fn command_with_optional_args_trims_leading_whitespace_in_args() {
9149        assert_eq!(
9150            command_with_optional_args("/kill    local-agent-1", "/kill"),
9151            Some("local-agent-1")
9152        );
9153    }
9154
9155    #[test]
9156    fn slash_hint_includes_protocol_command() {
9157        let hint = match_slash_command_hint("/protocol");
9158        assert!(hint.contains("/protocol"));
9159    }
9160
9161    #[test]
9162    fn slash_hint_includes_autochat_command() {
9163        let hint = match_slash_command_hint("/autochat");
9164        assert!(hint.contains("/autochat"));
9165    }
9166
9167    #[test]
9168    fn normalize_easy_command_maps_go_to_autochat() {
9169        assert_eq!(
9170            normalize_easy_command("/go build a calculator"),
9171            "/autochat 3 build a calculator"
9172        );
9173    }
9174
9175    #[test]
9176    fn normalize_easy_command_maps_go_count_and_task() {
9177        assert_eq!(
9178            normalize_easy_command("/go 4 build a calculator"),
9179            "/autochat 4 build a calculator"
9180        );
9181    }
9182
9183    #[test]
9184    fn normalize_easy_command_maps_go_count_only_to_demo_task() {
9185        assert_eq!(
9186            normalize_easy_command("/go 4"),
9187            format!("/autochat 4 {AUTOCHAT_QUICK_DEMO_TASK}")
9188        );
9189    }
9190
9191    #[test]
9192    fn slash_hint_handles_command_with_args() {
9193        let hint = match_slash_command_hint("/go 4");
9194        assert!(hint.contains("/go"));
9195    }
9196
9197    #[test]
9198    fn parse_autochat_args_supports_default_count() {
9199        assert_eq!(
9200            parse_autochat_args("build a calculator"),
9201            Some((3, "build a calculator"))
9202        );
9203    }
9204
9205    #[test]
9206    fn parse_autochat_args_supports_explicit_count() {
9207        assert_eq!(
9208            parse_autochat_args("4 build a calculator"),
9209            Some((4, "build a calculator"))
9210        );
9211    }
9212
9213    #[test]
9214    fn parse_autochat_args_count_only_uses_quick_demo_task() {
9215        assert_eq!(
9216            parse_autochat_args("4"),
9217            Some((4, AUTOCHAT_QUICK_DEMO_TASK))
9218        );
9219    }
9220
9221    #[test]
9222    fn normalize_for_convergence_ignores_case_and_punctuation() {
9223        let a = normalize_for_convergence("Done! Next Step: Add tests.");
9224        let b = normalize_for_convergence("done next step add tests");
9225        assert_eq!(a, b);
9226    }
9227
9228    #[test]
9229    fn agent_avatar_is_stable_and_ascii() {
9230        let avatar = agent_avatar("planner");
9231        assert_eq!(avatar, agent_avatar("planner"));
9232        assert!(avatar.is_ascii());
9233        assert!(avatar.starts_with('[') && avatar.ends_with(']'));
9234    }
9235
9236    #[test]
9237    fn relay_handoff_line_shows_avatar_labels() {
9238        let line = format_relay_handoff_line("relay-1", 2, "planner", "coder");
9239        assert!(line.contains("relay relay-1"));
9240        assert!(line.contains("@planner"));
9241        assert!(line.contains("@coder"));
9242        assert!(line.contains('['));
9243    }
9244
9245    #[test]
9246    fn relay_handoff_line_formats_user_sender() {
9247        let line = format_relay_handoff_line("relay-2", 1, "user", "planner");
9248        assert!(line.contains("[you]"));
9249        assert!(line.contains("@planner"));
9250    }
9251
9252    #[test]
9253    fn planner_profile_has_expected_personality() {
9254        let profile = agent_profile("auto-planner");
9255        assert_eq!(profile.codename, "Strategist");
9256        assert!(profile.profile.contains("decomposition"));
9257    }
9258
9259    #[test]
9260    fn formatted_identity_includes_codename() {
9261        let identity = format_agent_identity("auto-coder");
9262        assert!(identity.contains("@auto-coder"));
9263        assert!(identity.contains("‹Forge›"));
9264    }
9265
9266    #[test]
9267    fn normalize_minio_endpoint_strips_login_path() {
9268        assert_eq!(
9269            normalize_minio_endpoint("http://192.168.50.223:9001/login"),
9270            "http://192.168.50.223:9001"
9271        );
9272    }
9273
9274    #[test]
9275    fn normalize_minio_endpoint_adds_default_scheme() {
9276        assert_eq!(
9277            normalize_minio_endpoint("192.168.50.223:9000"),
9278            "http://192.168.50.223:9000"
9279        );
9280    }
9281
9282    #[test]
9283    fn fallback_endpoint_maps_console_port_to_s3_port() {
9284        assert_eq!(
9285            minio_fallback_endpoint("http://192.168.50.223:9001"),
9286            Some("http://192.168.50.223:9000".to_string())
9287        );
9288        assert_eq!(minio_fallback_endpoint("http://192.168.50.223:9000"), None);
9289    }
9290
9291    #[test]
9292    fn secure_environment_detection_respects_explicit_flags() {
9293        assert!(is_secure_environment_from_values(Some(true), None, None));
9294        assert!(!is_secure_environment_from_values(
9295            Some(false),
9296            Some(true),
9297            Some("secure")
9298        ));
9299    }
9300
9301    #[test]
9302    fn secure_environment_detection_uses_environment_name_fallback() {
9303        assert!(is_secure_environment_from_values(None, None, Some("PROD")));
9304        assert!(is_secure_environment_from_values(
9305            None,
9306            None,
9307            Some("production")
9308        ));
9309        assert!(!is_secure_environment_from_values(None, None, Some("dev")));
9310    }
9311
9312    #[test]
9313    fn minimax_m25_pricing_estimate_matches_announcement_rates() {
9314        let cost = estimate_cost("minimax/MiniMax-M2.5", 1_000_000, 1_000_000)
9315            .expect("MiniMax M2.5 cost should be available");
9316        assert!((cost - 1.35).abs() < 1e-9);
9317    }
9318
9319    #[test]
9320    fn minimax_m25_lightning_pricing_is_case_insensitive() {
9321        let cost = estimate_cost("MiniMax-M2.5-Lightning", 1_000_000, 1_000_000)
9322            .expect("MiniMax M2.5 Lightning cost should be available");
9323        assert!((cost - 2.7).abs() < 1e-9);
9324    }
9325
9326    #[test]
9327    fn easy_go_command_detects_go_and_team_aliases() {
9328        assert!(is_easy_go_command("/go build indexing"));
9329        assert!(is_easy_go_command("/team 4 implement auth"));
9330        assert!(!is_easy_go_command("/autochat build indexing"));
9331    }
9332
9333    #[test]
9334    fn next_go_model_toggles_between_glm_and_minimax() {
9335        assert_eq!(next_go_model(Some("zai/glm-5")), "minimax/MiniMax-M2.5");
9336        assert_eq!(next_go_model(Some("z-ai/glm-5")), "minimax/MiniMax-M2.5");
9337        assert_eq!(next_go_model(Some("minimax/MiniMax-M2.5")), "zai/glm-5");
9338        assert_eq!(next_go_model(Some("unknown/model")), "minimax/MiniMax-M2.5");
9339    }
9340}