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